Skip to content

dsh-neeko

Verified

dsh-neeko Β· v0.1.1 Β· MIT

Bidirectional bridge between DeepSeek Harness and the Neeko desktop shell: WebSocket server, health endpoint, protocol translation, and two-way session transport

Install

dsh plugin add dsh-neeko

Confirm the layer applied with dsh --profile default --dump-config β€” see the install guide.

Source

Tags

Creators

Readme

dsh-neeko

Bidirectional bridge between DeepSeek Harness (DSH) and the Neeko desktop shell.

dsh-neeko is the communication layer described in docs/dsh-neeko-integration.md. It is a neutral protocol bridge with two mutually exclusive runtime surfaces:

  • DSH is the AI backend (agents, sessions, commands, tools, sandbox) and never knows Neeko exists.
  • Neeko is the desktop interaction shell (file editing, Git, terminal, Agent Chat UI) and treats DSH as one optional backend.
  • dsh-neeko translates both directions, owns the WebSocket + health server, and holds session state.
  • dsh-neeko TUI (--tui) renders the same agent to a local terminal instead of a WebSocket client.

What it provides

Feature Status
WebSocket server on 127.0.0.1:3081 (non-3080, dynamic fallback to +1..+9) βœ…
Health probe GET /neeko-health (+ GET / service descriptor) βœ…
Handshake protocol with mode detection (dsh-agent / on-demand) βœ…
DSH event β†’ protocol translation (text.delta, thinking.delta, tool.start, tool.end) βœ…
Neeko message β†’ DSH API (session.prompt, session.steer, session.cancel) βœ…
Approval requests (approval.request / approval.respond) βœ…
User questions (question.request / question.respond) βœ…
session.init with reconstructed history (incl. failure rows) βœ…
CLI mode auto-launch of Neeko βœ…
Daemon mode (pure wait, no spawn) βœ…
Interactive terminal UI (--tui): transcript, tool cards, syntax highlighting, themes βœ…
TUI slash commands, model picker, reasoning-effort selector βœ…
TUI status bar (cwd, branch, tokens, cache hit, context %, timing) βœ…
Fullscreen alt-screen mode with clickable cards βœ…

Architecture

+-----------------------------------------------------------------+
|                      dsh-neeko (bridge)                          |
|                                                                  |
|  WS server: 127.0.0.1:3081  (health at /neeko-health)           |
|                                                                  |
|  +------------------------------------------------------------+  |
|  |  Neeko connection modes                                     |  |
|  |   mode=dsh-agent  -> activate session immediately           |  |
|  |   mode=on-demand, active=true -> activate on click          |  |
|  |   mode=on-demand, active=false -> idle, no event subscription |  |
|  +------------------------------------------------------------+  |
+-----------------------------------------------------------------+
       |                                      |
       |  DSH API (agents/sessions)           |  WS protocol
       v                                      v
    DSH process                           Neeko process

cordis.patch.yml mounts three rows β€” the startup provider (always), the bridge, and the TUI runner. The last two are mutually exclusive via disabled: !!js conditions, so exactly one runs per invocation: --tui selects the TUI, anything else the bridge. Both read their configuration from ctx.neekoStartup.

Launch paths

  • Path A β€” DSH starts Neeko: dsh --profile neeko β†’ the bridge starts, spawns open -a Neeko --args --dsh-ws=ws://127.0.0.1:3081/ws --mode=dsh-agent, and Neeko opens straight into the Agent Chat.
  • Path B β€” Neeko starts DSH: the user clicks DeepSeek in Neeko; Neeko probes GET http://127.0.0.1:3081/neeko-health, and if DSH is down spawns dsh --profile neeko daemon, waits for the health probe, then connects with mode=on-demand, active=true.

Install

npm i -g @deepseek-ai/dsh
dsh plugin --profile neeko add dsh-neeko

# Bridge mode: bridge + auto-launch Neeko
dsh --profile neeko

# Daemon mode: pure wait, never spawns Neeko
dsh --profile neeko daemon

# TUI mode: interactive terminal UI instead of the bridge
dsh --profile neeko --tui

For local development, link this checkout instead:

just install     # = dsh plugin --profile neeko add "."

Usage

dsh --profile neeko [options] [mode]

Options:
  --daemon         pure-wait mode: never spawn Neeko
  --host <host>    bind host (default 127.0.0.1)
  --port <n>       preferred bridge port (default 3081; falls back to +1..+9)
  --session <id>   resume an existing persisted session
  --spawn <cmd>    override the Neeko launch command (e.g. "open -a Neeko")
  --no-spawn       do not spawn Neeko even in CLI mode
  --tui            run the interactive terminal UI instead of the Neeko bridge
  --main-screen    TUI: keep the terminal scrollback (this is the default)

Environment:
  DSH_NEEKO_PORT=3081            port override when --port is absent
  DSH_NEEKO_AUTOSPAWN=0         disable auto-spawn in CLI mode

Wire protocol

The protocol is exported from dsh-neeko/protocol (also usable by the Neeko side). Every frame is a JSON envelope:

interface Envelope {
  rpcId: string
  direction: 'dsh->neeko' | 'neeko->dsh'
  timestamp: number
  message: ProtocolMessage
}

The first frame of a connection must be a handshake (the decoder also accepts a bare unwrapped message, for symmetry with older clients):

{ type: 'handshake', payload: { version: '1.0.0', role: 'neeko',
  mode: 'dsh-agent' | 'on-demand', active: boolean, sessionId?: string } }

Messages (see src/protocol.ts for the full types):

  • DSH β†’ Neeko: session.init, status.change, text.delta, thinking.delta, tool.start, tool.stream, tool.end, approval.request, question.request, error
  • Neeko β†’ DSH: session.prompt, session.steer, session.cancel, approval.respond, question.respond

HistoryItem has four kinds β€” user, assistant, tool, failure. The failure row carries a pre-worded line settled from the turn's turn/end reason, because a request can fail before any assistant message exists (auth, rate limit, transport) and a resumed turn would otherwise show the prompt and then silence.

⚠️ messageId on session.init history rows and messageId on text.delta are disjoint namespaces on purpose and must never be assumed to join. History rows identify a settled message (assistant:{turn}:{step}:{message.id}); delta frames multiplex a live attempt (assistant:{turn}:{step}:{attemptId}), so a failed attempt's text is never spliced into the successful retry.

DSH event β†’ protocol mapping

Two distinct sources feed Neeko β€” this split is the 0.2.x contract:

Live deltas (agent/assistant-stream, agent-scoped):

Frame / chunk Protocol message
start (records turn/step/attempt; emits nothing)
chunk β†’ text-delta text.delta
chunk β†’ reasoning-delta thinking.delta
chunk β†’ tool-call-delta, block-*, usage, finish (nothing)
end (clears the attempt; emits nothing)

Durable log (session.snapshotEvents()):

DSH event Protocol message
tool/call tool.start
tool/result tool.end (with diff when the fs tool attaches one)
assistant/message (nothing β€” text already arrived as live deltas)
user/message (history rows only)
turn/end (history rows only β€” a failure row when the turn failed)
agent/status status.change
approval/request approval.request
user-questions/request question.request

Supported DSH generation: 0.2.x only. 0.1.x carried assistant output as assistant/chunk rows inside the session log and exposed userQuestions.registerProvider(); both are gone in 0.2.x, where live streaming moved to agent/assistant-stream and question answering moved to the agent-scoped user-questions/request waterfall. One codebase cannot serve both, so peerDependencies pins ^0.2.0-rc.2 rather than claiming a wider range.

Terminal UI (--tui)

dsh --profile neeko --tui runs the same agent against a local terminal instead of a WebSocket client. It is published as its own entry point, dsh-neeko/tui.

dsh --profile neeko --tui                          # start a new session
dsh --profile neeko --tui --session <id>           # resume
dsh --profile neeko --tui --fullscreen             # opt in to the alt screen
DSH_NEEKO_THEME=light dsh --profile neeko --tui    # start on a given theme

On exit the TUI prints the conversation back to scrollback and a pasteable resume line:

To resume this session: dsh --profile neeko --tui --session <id>

Screen modes

The terminal-owned main screen is the default, and it matches what pi actually runs. TuiMainScreen appends to the terminal, so the conversation lands in the terminal's scrollback: the scrollbar is the terminal's own, PageUp / Shift+PageUp are the terminal's keys, and history from before the TUI started is still there.

pi's source suggests otherwise β€” settings-defaults.js ships tuiMode: "fullscreen" β€” but getTuiMode() returns "regular" unless the stored value is literally "fullscreen". Measured on a pty: pi starts with no ?1049h.

The cost is the first frame: TuiMainScreen never clears, so the command that launched the TUI stays visible above the banner.

--fullscreen opts into the alt screen. It owns the viewport (application scrolling, click-to-expand cards, PageUp inside the app) and copies through OSC 52 (works over SSH, no pbcopy/xclip), at the cost of the terminal's own scrollback and scrollbar. Its layout is pi's createChatViewport: the transcript scrolls, the composer is a fixed dock below it.

Three sources, most specific first: the flag, then DSH_NEEKO_SCREEN, then the older single-purpose variables. /fullscreen toggles at runtime either way. src/tui/screen.ts holds the single decision; no other layer supplies a default.

Slash commands

Command Effect
/help list commands and key hints
/clear clear the transcript view (a watermark β€” new rows still appear)
/model [id] bare opens a fuzzy-filtered picker; with an id, applies directly
/thinking [level] bare opens a selector; with a level, applies directly
/theme [name] bare opens a picker; with a name, applies directly
/fullscreen toggle fullscreen
/hotkeys print the active key bindings, resolved live
/exit quit the TUI

DSH-plugin-registered commands and skills appear in the same / menu. Host commands dispatch through the commands registry so their semantics stay owned by the defining plugin; skills are namespaced /skill:<name>. A host command can never shadow a built-in. The catalogue is re-read on every menu open so commands registered after boot still appear.

Keys

Key Effect
ctrl+c / ctrl+d exit
ctrl+o expand/collapse the whole transcript (a click flips one row)
y / n / esc allow-once / reject / cancel an approval
1–9 pick a question option
enter send; submit a free-text question answer
esc close any open selector
↑ / ↓ / enter navigate and confirm a selector
ctrl+s (model panel) set the highlighted model as the profile default
tab slash / argument completion

This table is a summary. /hotkeys prints the live bindings, resolved from the installed registry, so it is the authority when the two disagree.

Rebind by writing pi's own <agent-dir>/keybindings.json β€” $PI_AGENT_DIR if set, else ~/.pi/agent (pi itself calls this directory PI_CODING_AGENT_DIR; this build reads the shorter name):

{
  "$schema": "https://pi.dev/schemas/keybindings.schema.json",
  "app.tools.expand": "ctrl+e",
  "tui.altScreen.pageUp": []
}

Actions are pi's ids β€” app.clear (ctrl+c), app.exit (ctrl+d), app.tools.expand (ctrl+o) plus the whole tui.* set, which the editor and the fullscreen viewport read from the same registry. An empty list disables an action. A key id the matcher could never fire on (a typo such as ctr+q) is dropped and the action keeps its default β€” an override replaces the default, so accepting a typo would silently remove the key entirely. A malformed or absent file falls back to the defaults; it never stops the TUI from starting.

Transcript

Four row kinds β€” user, assistant, tool, failure β€” merged from two sources into one ordered list: live agent/assistant-stream frames and the durable session/event log. Restored history renders before any live frame, so the attach timeline cleanly separates "was already in the log" from "will arrive as a delta".

Rows are folded by default. Tool calls render as emoji Title summary [status] cards with a full-width background band by status, across seven variants (read, search, bash, write, edit, code, others); an unknown tool falls into others and still names itself, so a new tool needs no code change. Fenced code blocks are syntax-highlighted for 7 language families (TypeScript/JS, Rust, Python, Shell, JSON, CSS, SQL) β€” deliberately hand-rolled and never-throwing, since a stream can produce arbitrary fragments.

Reasoning stays on one line while streaming (a growing wall would push the answer off screen), with a braille spinner while unfinished. Images are fetched asynchronously through the verified attachment service, cached by id, and negotiated by pi-tui's renderer (Kitty/iTerm2 with a text fallback).

Status bar

Two lines, width-aware (pi's footer shape, with the context line split out so an 80-column terminal never truncates away the token ledger):

~/.neeko/worktrees/dsh-neeko (dsh-neeko)
↑2.4M ↓364k R395M CH99.9% $4.259 78.5%/1.0M      (magpie) group/auto-… β€’ high

Left is cwd + git branch; right is (provider) model β€’ effort; the middle carries the timing segment then the token ledger (↑in ↓out Rread Wwrite CHhit% $cost, zeros omitted). Cache-hit rate is computed from the latest message only β€” a session average would hide a cache that just stopped working. Context occupancy (%/window) is the only segment that carries a severity colour (warning > 70%, error > 90%). Facts are folded once per state change, never once per render, so the streaming hot path only re-layouts the timing segment.

Themes

Two built-ins β€” dark and light, each with pi's full 56-token vocabulary β€” plus unlimited user themes loaded from ~/.dsh-neeko/themes/ and pi's $PI_AGENT_DIR/themes (default ~/.pi/agent/themes). Theme files are plain JSON in pi's format, so a theme authored for pi is usable as-is. A same-named local theme shadows a pi one; a user theme can never shadow a built-in. Loading is fail-open: an invalid file is skipped, and a partial theme is overlaid on the built-in of its appearance so every token resolves. DSH_NEEKO_THEME sets the startup theme.

Models and reasoning effort

The model picker lists every configured provider, not just the active one, with fuzzy filtering (step5 matches group/auto-step-5-preview). ctrl+s writes the highlighted model as the profile default. Switching models drops the previous reasoning effort on purpose β€” carrying it over is what made a switch silently ineffective. /thinking levels come from llm.resolveModelInfo() for the exact route, never hard-coded, because resolveCallConfig rejects an unsupported effort before any provider I/O. Clearing the effort deletes the key entirely, so DSH reads it as "provider default".

TUI environment variables

Variable Purpose
DSH_NEEKO_THEME initial theme name (default dark)
DSH_NEEKO_SCREEN main or fullscreen; an unrecognised value is ignored
DSH_NEEKO_FULLSCREEN 1 β†’ the alt screen (older spelling of DSH_NEEKO_SCREEN=fullscreen)
DSH_NEEKO_MAIN_SCREEN 1 β†’ the main screen (older still; still honoured)
DSH_NEEKO_THEMES_DIR override the TUI's own theme directory
PI_AGENT_DIR pi's agent dir; its themes/ is scanned too (and keybindings.json is read from it)
COLORTERM truecolor/24bit β†’ truecolor, else 256color

Project layout

dsh-neeko/
+-- package.json
+-- cordis.patch.yml         # bundle patch: startup + bridge rows + TUI row
+-- src/
|   +-- index.ts             # main plugin: bridge, session lifecycle, approval/questions
|   +-- ws-server.ts         # HTTP health endpoint + WebSocket server + handshake
|   +-- protocol.ts          # neutral wire protocol types + envelope helpers
|   +-- dsh-adapter.ts       # DSH events -> protocol translation + history
|   +-- neeko-adapter.ts     # protocol -> DSH API calls
|   +-- agent-handlers.ts    # agent-scoped approval/question waterfall wiring
|   +-- startup.ts           # `dsh --profile neeko` CLI parsing
|   +-- tui/                 # interactive terminal UI (37 modules)
|       +-- runner.ts        # Cordis entry: mounts the TUI, owns the live agent
|       +-- app.ts           # screen assembly, lifecycle, key arbitration
|       +-- screen.ts        # which screen mounts, and the ONE default
|       +-- keymap.ts        # action table, priority stack, /hotkeys, rebind file
|       +-- message-view.ts  # row -> pi-tui component (user/assistant/tool/failure)
|       +-- composer.ts      # the editor, with the activity indicator in its border
|       +-- status-indicator.ts # Loader-based indicator + border composition
|       +-- transcript.ts    # merges live frames + durable log into rows
|       +-- fold.ts          # global switch + per-row fold overrides
|       +-- highlight.ts     # hand-rolled lexical syntax highlighter
|       +-- commands.ts      # slash-command table + pure classification
|       +-- command-controller.ts # slash line -> app action + model catalogue
|       +-- model-panel.ts   # /model picker (fuzzy filter, ctrl+s default)
|       +-- theme-selector.ts # /theme picker, with live preview
|       +-- thinking-selector.ts # /thinking picker, with a filter box
|       +-- resume-selector.ts   # /resume picker panel
|       +-- resume-controller.ts # /resume state, paging and title cache
|       +-- resume-list.ts   # session rows (title, age, current marker)
|       +-- thinking.ts      # /thinking decisions (pure)
|       +-- theme.ts         # Theme class, user-theme loader, search paths
|       +-- theme-data.ts    # the two built-in palettes
|       +-- theme-tokens.ts  # pi's 56-token vocabulary + fallback chain
|       +-- picker.ts        # what a key means while a selector is open
|       +-- status-bar.ts    # status-bar formatting (pure)
|       +-- interactions.ts  # approval / question state machine
|       +-- notice-log.ts    # transient notices and the self-clearing status line
|       +-- row-cache.ts     # row signature + container reconciliation
+-- tests/                   # vitest suite (49 spec files)
+-- docs/dsh-neeko-integration.md

Development

pnpm install      # install + build (prepare script)
pnpm run check    # type-check (src + tests)
pnpm test         # vitest
pnpm run build    # tsdown -> lib/

The suite covers protocol envelope round-trips, event translation and history reconstruction, the Neeko→DSH adapter mapping, the WebSocket server (health, handshake, invalid-frame rejection, dynamic port fallback), and spawn error handling — plus the whole TUI surface: transcript merge and retry isolation, row rendering and fold state, tool-card presentation, syntax highlighting, the model and theme pickers, the status bar and timing arithmetic, host-command and skill wiring, fullscreen behaviour, the exit flush barrier, and the repaint cost budget.

tests/support/fake-terminal.ts runs the real pi-tui renderer against a recording terminal so tests never write ANSI into the reporter.

Known limitations

  • tool.stream is part of the protocol but DSH core currently logs no tool stdout/stderr stream event; it will be wired when such an event exists.
  • Reconnect support: the bridge currently disposes the live agent when the Neeko connection closes (persisted sessions can be resumed afterwards).
  • WS authentication token and multi-session switching are not implemented yet.
  • The bridge accepts one active Neeko connection at a time; a new connection supersedes the previous one (close code 4000).
  • The TUI renders its transcript in full (no virtual scrolling); repaint cost is held down by component caching and a coalesced flush window instead.
  • --tui and --daemon coexist without validation: the patch row decides on --tui alone, so --tui --daemon runs the TUI and ignores --daemon.

License

MIT β€” see LICENSE.

The TUI components under src/tui/ are transcribed from pi rather than vendored: the behaviour is ported and the dependency is not. pi is MIT-licensed, and its copyright notice is reproduced in LICENSE as that licence requires.