DSH-Only Tool Surface: terminal / lsp / jobs
What the tools others lack each solve. Core source: packages/terminal/, packages/lsp/, and packages/jobs/; full tool descriptions in docs/tool-catalog.zh.md.
Same task: start a Python REPL and debug a snippet in three steps. Scenario A gives only bash on the left and the terminal six-pack on the right — watch the gap in rounds and repeated work. Scenario B demos the jobs panel: three totally different background tasks managed by one list.
bash and terminal_* in docs/tool-catalog.zh.md; Scenario B task shapes map to the three tools in @deepseek-ai/dsh-tool-jobs. SEND_ACTIVE error behavior maps to line 246 of packages/terminal/terminal/src/index.ts.- Six tools:
terminal_open / send / read / signal / close / list, backed by a persistent PTY session. - Stateful programs like REPL, gdb, ssh stay alive across calls.
- Each session belongs to an exact Agent instance — another agent with the id still can’t operate it.
- Exactly four query types: go-to-def, find-refs, go-to-impl, hover — a closed union; adding a fifth is a compile-time breaking change.
- Deliberately no generic JSON-RPC escape hatch — the model can’t invent protocol tricks.
- With no provider, schema stays unchanged and returns structured
LSP_UNAVAILABLE.
- Background bash, background PTY sends, background subagents — all register as
<kind>-Ntasks. - One trio —
job_list / job_output / job_kill— manages everything. - Auth checks the owner session — predictable ids are fine; each owner defaults to at most 10 concurrent.
Start with the most underrated: terminal. The one-shot bash problem already showed up in the demo: REPL variables don’t survive a call, so the model must resend all old code — burning rounds and tokens twice. DSH’s fix makes the PTY session a first-class resource: terminal_open creates a session and returns an id; later send/read/signal/close all key off that id; the session stays alive across backend and tool-plugin hot reloads (see “Ownership & persistence” in docs/subsystems/terminal.zh.md).
The tool description itself is a prompt-engineering model. In the 1878-line tool catalog, the terminal_send entry clears wait semantics in one sentence:
“Send text to a persistent terminal. By default submits Enter and waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a job id for job_output / job_kill.”
Then concurrency discipline: one PTY session accepts only one active send at a time. Two calls racing the same terminal — the second gets a structured error; nobody sneaks characters into someone else’s command.
Watch startSend()’s gate order — all three checks are mandatory. First through the door: expectOwned(owner, id) — auth compares the exact Agent instance that owns the session; the id isn’t a secret, the boundary is ownership; another agent that guesses the id still fails. Second: is the session closing? A half-closed session rejects any new input. Third: record.active — if the previous send hasn’t settled, the new one throws TerminalError with SEND_ACTIVE; the model sees a structured failure and should wait for settlement. After all checks pass, this op is registered as the session’s only active send; only when its done promise settles (success or failure) does the registry clear and the next send qualify. Ownership-is-boundary shows up again in jobs.
Source: packages/terminal/terminal/src/index.ts lines 243–254 (startSend()), verified on 2026-08-13.
The lsp tool narrows language-server power to four semantic queries. Why not expose the whole LSP protocol? Because the model would face a bottomless schema, and swapping providers would drift behavior. DSH closes it: seam, provider, and tool share the same four-op union — adding a fifth fails compile until all three layers change. Routing is blunt: find the provider by file extension; one extension belongs to one house:
async query(request: LspQueryRequest, signal?: AbortSignal): Promise<LspQueryResult> {
const route = this.routes.get(finalExtension(request.filePath))
if (route === undefined) {
throw new LspError(`no LSP provider handles "${request.filePath}"`, 'LSP_UNAVAILABLE')
}
return route.provider.query({ ...request, languageId: route.languageId }, signal)
}
packages/lsp/lsp/src/index.ts, verified on 2026-08-13. Code blocks keep the original source text.The key when there’s no provider: the lsp tool stays in the catalog, schema unchanged, and the call returns a structured failure with LSP_UNAVAILABLE. The model learns this project has no language server — no guessing why a tool vanished. Degradation stays structured, vocabulary stays stable — same spirit as bash reporting [sandbox: …].
Finally jobs. Background bash, terminal_send’s background mode, background subagents — three producer shapes, totally different. DSH registers them all into one ctx.jobs registry, issues ids like bash-1 / subagent-2 (kind + sequence), and the model uses one job_list / job_output / job_kill set for all. Producers own execution resources; the registry owns identity, access, and lifecycle state (docs/subsystems/jobs.zh.md).
Auth doesn’t rely on secret idsIds issue as <kind>-N in order — fully predictable. The defense is owner auth: read, kill, wait all verify the caller’s session matches the task owner; you can’t even see another’s label.
done waits for resource releaseThe producer’s done promise resolves only after resources are released — finishing work isn’t enough. When an owner is destroyed, the registry cancels and awaits tasks; no orphan processes.
Completion notices don’t double-pingOnce an interface has delivered a terminal state, the reported flag suppresses duplicate completion notices — so one finished task doesn’t give the model two messages and two wasted turns.
On PTY, Grok Build and DSH landed in the same place: the repo has a standalone ptyctl crate (crates/codegen/ptyctl/ with pty, session, server, term, wait modules, plus ptyctl-cli), making terminal control reusable infrastructure. Both treat stateful terminals as first-class; the difference is composition: Grok links a Rust crate at compile time; DSH mounts a runtime plugin plus six model-facing tools.
Claude Code’s reconstructed tool list (manuscript ch. 2) has no first-class PTY session tool and no LSP tool — based on public reconstructed-source evidence. It does have background power: bash with run_in_background, and agent tasks can auto-background by duration (tengu_auto_background_agents, ch. 2 lines 448–458) — but those are built around bash and subagent separately, with no cross-kind unified job registry. The gap isn’t laziness: CC is a product — interactive debug has the IDE, semantic nav has the editor; DSH is a runtime that must feed these capabilities to the model alone in headless environments. What a product can skip, a runtime should do. One more beat: none of these three sit on the agent-loop trunk — all optional plugins; the minimal preset mounts none and is still a complete coding agent.
Walk through two boundary scenarios
Scenario 1: on the same terminal session the agent first sends with run_in_background: true, then immediately a foreground send. What happens to the second, which error code, and what does the job panel show? Scenario 2: the project has no language server; the model calls lsp once for the definition of a.py. Write the result shape the model gets, and why that’s friendlier than yanking lsp from the catalog.