Skip to main content
PathDocs

Interactive Terminal Model Workflow

In one sentence: terminal is six model-facing persistent terminal tools (terminal_open / terminal_send / terminal_read / terminal_signal / terminal_close / terminal_list) that expose an owner-isolated interactive shell/REPL on top of DSH's ctx.terminals seam — foreground sends wait on your prompt, background sends hand you a job id through ctx.jobs, and a real process-group signal interrupts it.

The subprocess page section 7 explains how DSH allocates a PTY and how the ctx.terminals service seam routes to the terminal-bash backend. This page stands on the model side: what the six tools actually look like to a model, and how to choreograph one complete interactive session.

1. Positioning: the model presentation of the mechanism layer

When you mount the terminal row, the packages/terminal/tool-terminal package maps that managed-terminal machinery into six model-visible tools. The layering is clear:

LayerPackage / ctxWhat it ownsCovered here
Terminal machineryterminalctx.terminals seam: opaque session ids, owner gating, routing by backend name, waiting for quiescence on disposeBackground
Backendterminal-basha persistent shell over ctx.subprocess.spawnTerminal (type: shell)Background
Model toolstool-terminal (on top of ctx.terminals)the six tool schemas, owner auth, the result cap, the system prompt, and backgrounding via ctx.jobsThis page

tool-terminal injects only three services — ['terminals', 'tools', 'systemPrompt'] — it does not touch node-pty, the sandbox, or task scheduling; it only exposes machinery as a model workflow.

2. The six tools, unpacked

Below is the full lifecycle from terminal_open to terminal_close. Every parameter and return value comes from the schemas in packages/terminal/tool-terminal/src/index.ts.

1. terminal_open — create a session

terminal_open(type?, name?, cwd?) → { sessionId, name?, type, pid?, status, motd }
  • type (required): a registered terminal backend type, usually "shell".
  • name (optional): an owner-local display name such as "main" or "gdb".
  • cwd (optional): the initial working directory; defaults to the deployment workspace root.
  • Returns a session snapshot with motd — a hint about what the session is for.

Source execute (packages/terminal/tool-terminal/src/index.ts):

const result = await ctx.terminals.spawn(requireAgent(exec.agent), {
type: args.type,
...args.name !== undefined ? { name: args.name } : {},
...args.cwd !== undefined ? { cwd: args.cwd } : {},
}, exec.signal)
return result

2. terminal_send — write input (foreground / background)

terminal_send(sessionId, text, submit?, run_in_background?) →
foreground: { kind:'foreground', viewport, waitReason, sessionStatus, truncated }
background: { kind:'background', jobId }
  • submit defaults to true (sends Enter); set false for control characters or incomplete REPL input.
  • A foreground send by default waits — until one of four things happens, and waitReason tells you exactly which:
waitReasonMeaning
stdin_readthe program read more input again (it wants further input)
inferred_idlean idle inference — output is considered done for now
timeoutthe wait timed out
session_exitthis session has exited

⚠️ Per the system prompt, verbatim: an inferred_idle or timeout result does not prove the foreground command exited — silence ≠ finished, timeout ≠ exited. Check sessionStatus to confirm.

  • Background mode (run_in_background: true) does not block the model: preflight and this session's exclusive in-flight-send reservation both complete before the job id is returned, then it hands you { kind:'background', jobId } into the generic ctx.jobs registry. Collect with job_output, stop with job_kill — and for a pty-send job, job_kill forwards a real SIGINT to the current foreground process group.

Source background branch (packages/terminal/tool-terminal/src/index.ts):

const jobId = jobs.start({
kind: 'pty-send',
label: `${id}: ${args.text || '(input)'}`,
owner,
outputLimitBytes: maxResultBytes,
run: () => {
const operation = ctx.terminals.startSend(owner, id, request)
return {
cancel: () => { cancelRequested = true; operation.cancel() },
done: operation.done.then(
result => ({ status: cancelRequested ? 'killed' : 'completed', detail: sendDetail(result) }),
(error) => ({ status: 'failed', detail: String(error) }),
),
readOutput: () => renderSendRead(operation.readOutput()),
}
},
})
return { kind: 'background', jobId }

kind: 'pty-send' is this package's declaration on @deepseek-ai/dsh-jobs's JobKindMap:

declare module '@deepseek-ai/dsh-jobs' {
interface JobKindMap { 'pty-send': 'pty-send' }
}

Foreground sends render with the terminal call/result card; background sends use the generic execution card.

3. terminal_read — a bounded page, no input

terminal_read(sessionId, offset?, count?) → { text, totalLines, lineBegin, lineEnd, truncated }
  • offset defaults to 0 (newest-relative); count defaults to 500 (backend caps apply).
  • Returns a page of retained output with pagination markers, sending no input: use it to review earlier output without disturbing a program that is waiting on stdin.
  • truncated means this page was cut; combine it with lineBegin/lineEnd/totalLines to decide whether to page further back.

4. terminal_signal — deliver a signal

terminal_signal(sessionId, signal) → { delivered:true, targetPgid }
  • signal is an enum: SIGINT / SIGTERM / SIGKILL / SIGTSTP / SIGHUP, delivered to the current foreground process group (targetPgid).
  • Shell-targeted SIGKILL is rejected — to tear everything down use terminal_close; do not SIGKILL the shell process itself.

5. terminal_close — close and wait for the process tree to vanish

terminal_close(sessionId) → { sessionId, outcome:'closed'|'already-closing' }
  • Closes one persistent session and waits until its captured owned process tree is fully gone before returning.
  • outcome: 'closed' means you closed it this time; 'already-closing' means it was already shutting down — an idempotent acknowledgement.

Source execute:

const closed = await ctx.terminals.kill(requireAgent(exec.agent), id)
return { sessionId: id, outcome: closed ? 'closed' : 'already-closing' }

6. terminal_list — inventory the current agent's sessions

terminal_list() → [ { sessionId, name?, type, pid?, status } ... ]
  • Lists fresh snapshots of the persistent sessions owned by the current initiating agent, one snapshot per session.

Tool quick-reference

ToolPurposeKey returns
terminal_opencreate a persistent sessionsessionId + motd
terminal_sendwrite input; foreground waits, background gives a job idwaitReason/sessionStatus/viewport or {kind:'background', jobId}
terminal_readread a bounded page of retained output, no inputtotalLines/lineBegin/lineEnd/truncated
terminal_signalsignal SIGINT/SIGTERM/SIGKILL/SIGTSTP/SIGHUP to the foreground pgrouptargetPgid
terminal_closeclose a session, wait for full process-tree teardownclosed/already-closing
terminal_listlist all sessions of the current agentarray

3. Core usage conventions

Owner isolation (every step goes through requireAgent)

A hard precondition in the source: all six tools call requireAgent(exec.agent):

function requireAgent(agent: Agent | undefined): Agent {
if (agent === undefined) throw new Error('terminal tools require an initiating agent')
return agent
}

Owner identity comes from the exact Agent that initiated that tool execution. So even if a model learns another agent's session id, it cannot pry into that agent's terminal — every spawn/send/read/signal/kill/list is precisely fenced to the same owner.

The system prompt section (order: 106)

The plugin contributes a fixed guidance section about when to use a terminal:

ctx.systemPrompt.section({
name: 'tool:pty',
order: 106,
text: 'Use a terminal session only when work needs persistent terminal state or interactive stdin; prefer shell/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.',
})

The points (they speak to the model, and are worth keeping in mind when writing a prompt):

  • Use a terminal only when work needs persistent terminal state or interactive stdin; for bounded one-shot operations prefer shell / read / write / edit.
  • Track every session id and close sessions that no longer matter — do not leak terminal_close.
  • inferred_idle or timeout does not prove the foreground command exited.

Configuration

KeyDefaultMinimumMeaning
enableRunInBackgroundtrueexpose and accept run_in_background; when false the schema omits the field and a forced undeclared parameter is rejected
maxResultBytes26214464the UTF-8 cap for one complete terminal/task-output result; computed after wait, session, pagination, truncation, and task-status metadata are all included
  • Both values are validated at load: maxResultBytes must be a safe integer ≥ 64 (MIN_MAX_RESULT_BYTES).
  • The 64-byte minimum is deliberate: it guarantees every registry-issued session id / job id appears fully in its creation acknowledgement.
  • Rendering reserves room for control metadata and the truncation marker ([output truncated], cut at UTF-8 boundaries in the render layer), then trims the body.

4. One complete interactive session

The typical long-running flow — open, send commands, read into silence or when the program asks for more input, signal if needed, then close:

Verification / try-it (in an environment where you can run DSH), with a real REPL:

# 1) start a session that calls the terminal
dsh run --agent-terminal-demo

# 2) watch the order of terminal_* tool events in the session log
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | grep -E '"terminal_' | head

# 3) confirm the tool-terminal row is mounted in the composition tree
dsh web --dump-config | grep -iE "tool-terminal|terminal-bash"

To experience it model-side: ask the agent to terminal_open a shell, send python3, then print(2+2), keep feeding while waitReason is stdin_read, watch waitReason flip to inferred_idle or session_exit, and finally terminal_close to tear down.

5. The division of labor with the subprocess page

Do not confuse this with subprocess and terminal:

  • The subprocess page is the mechanism layer: the ctx.terminals service seam + the terminal-bash backend, and the spawnTerminal primitive beneath them — they own "how a PTY is allocated/routed/cleaned up".
  • This page is the model layer: what the six tools are named, how their parameters are filled, how their returns are read, how to choreograph an interactive session, and how the background path plugs into ctx.jobs.
  • A full tool overview is in built-in tools, "Terminal domain".
mechanism layer (subprocess page)model layer (this page)
Whoterminal / terminal-bashtool-terminal
Deliverablectx.terminals seam, backendsix tool schemas + owner auth + prompt + result cap
For whomother plugins / backendsthe model

Next steps