dsh-neeko
Verifieddsh-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, spawnsopen -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 spawnsdsh --profile neeko daemon, waits for the health probe, then connects withmode=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.
β οΈ
messageIdonsession.inithistory rows andmessageIdontext.deltaare 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.xonly. 0.1.x carried assistant output asassistant/chunkrows inside the session log and exposeduserQuestions.registerProvider(); both are gone in 0.2.x, where live streaming moved toagent/assistant-streamand question answering moved to the agent-scopeduser-questions/requestwaterfall. One codebase cannot serve both, sopeerDependenciespins^0.2.0-rc.2rather 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.jsshipstuiMode: "fullscreen"β butgetTuiMode()returns"regular"unless the stored value is literally"fullscreen". Measured on a pty:pistarts 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.streamis 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.
--tuiand--daemoncoexist without validation: the patch row decides on--tuialone, so--tui --daemonruns 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.