The shell seam
packages/shell/shell is the repository's canonical capability seam. A seam splits a capability into three roles (docs/capability-seams.md):
- Service Definition — the abstract class + vocabulary, owned by the seam package. Registers a
ctx.<key>service via a Cordisdeclare moduleaugmentation. - Providers — concrete subclasses loaded as plugins that implement the abstract methods. Exactly one mounts per context; loading a second throws the standard Cordis duplicate-service error.
- Consumers — tool plugins (and hook bridges) that depend on the service and stay provider-independent.
The shell seam is the reference example: ShellExecutor defines ctx.shell, four providers implement it, and tool-bash/tool-pwsh consume it. Direct consumers per docs/capability-seams.md: tool-bash, tool-pwsh, hooks-claude-code, hooks-codex.
tool-bash ──┐ tool-pwsh ──┐
hooks-claude-code ─┤ hooks-codex ────┤ (CONSUMERS)
▼ ▼
┌──────────────────────────────┐
│ Service Definition: ctx.shell │
│ ShellExecutor (abstract) │ @deepseek-ai/dsh-shell
└──────────────────────────────┘
▲
┌──────┬──────────┴──────┬─────────────┐
▼ ▼ ▼ ▼
bash-local │ bash-sandbox pwsh-local ── pwsh-sandbox (PROVIDERS)
(LocalBashExecutor) │ (SandboxBashExecutor)
└──┬───┘ │
│ spawn via ctx.subprocess (the subprocess seam)ctx.shell owns only process handles. Background job ids, sessions, ownership, polling, and notices belong to ctx.jobs (@deepseek-ai/dsh-jobs), so executors stay independent of sessions.
Package versions
All workspace packages in this group share the pinned root version:
| Package | Role |
|---|---|
@deepseek-ai/dsh-shell | Service Definition (ShellExecutor, ctx.shell) |
@deepseek-ai/dsh-bash-local | Provider: local bash -c over ctx.subprocess |
@deepseek-ai/dsh-bash-sandbox | Provider: bash wrapped through ctx.sandbox |
@deepseek-ai/dsh-pwsh-local | Provider: local PowerShell over ctx.subprocess |
@deepseek-ai/dsh-pwsh-sandbox | Provider: PowerShell wrapped through ctx.sandbox |
@deepseek-ai/dsh-shell-env | Core: ctx.shellEnv managed DSH_* facts |
@deepseek-ai/dsh-tool-bash | Consumer: model-facing bash tool |
@deepseek-ai/dsh-tool-bash-persistent | Consumer: persistent-bash tool over the PTY seam |
@deepseek-ai/dsh-tool-pwsh | Consumer: model-facing PowerShell tool |
@deepseek-ai/dsh-tool-pwsh-persistent | Consumer: owner-isolated persistent PowerShell tool over the PTY seam |
The execution contract
ShellExecutor (packages/shell/shell/src/index.ts) exposes three abstract methods plus a sandboxMode getter:
export abstract class ShellExecutor extends Service {
constructor(ctx: Context) { super(ctx, 'shell') }
get sandboxMode(): SandboxMode | undefined { return undefined }
abstract resolve(request: ShellExecRequest): ShellExecSpec
abstract run(spec: ShellExecSpec): Promise<ShellRunResult>
abstract start(spec: ShellExecSpec): ShellProcess
}The flow is request → resolve → run/start. Callers pass a raw ShellExecRequest (command + optional workdir/timeoutMs/stdoutMaxBytes/signal/stdin/env/dshEnv/sandboxPolicy); resolve() fills defaults and caps timeout. The tool layer always calls resolve before run/start, so those read explicit values.
Foreground: what a shell execution returns
ShellRunResult (packages/shell/shell/src/types.ts) is the shape a foreground run resolves to:
export interface ShellRunResult {
exitCode: number | null // null when killed by a signal
signal: NodeJS.Signals | null
timedOut: boolean // executor timeout was the FIRST cause
aborted: boolean // caller's AbortSignal was the FIRST cause
timeoutMs: number // effective timeout after default/cap
stdout: CollectedOutput
stderr: CollectedOutput
sandbox?: ShellSandboxInfo // absent for unsandboxed executors
}Note the error discipline (see docs/defensive-patterns.md): run rejects only for infrastructure failures. A non-zero exit, a timeout kill, or an abort kill resolves with a descriptive ShellRunResult. timedOut and aborted are mutually exclusive — one fused deadline (@deepseek-ai/dsh-timeout) drives both, and the first cause wins.
Output capture and retention
CollectedOutput is re-exported from the subprocess seam, but the retention strategy is the shared library @deepseek-ai/dsh-output-retention (packages/util/output-retention). It is explicitly not a Cordis service or plugin — it takes no ctx, registers nothing, and emits no events. Two stateful retainers:
ItemRetainer<T>— bounds an ordered stream of logical units (paths, grep matches, search sources). v1 supports onlyheadretention (maxItems).TextRetainer— bounds a byte-oriented text stream withhead/tail/headTailstrategies, rolling a bounded suffix in memory and trimming partial UTF-8 codepoints at each cut so the returned text never introduces a replacement character.
Both report an exact Omitted count; formatRetentionNotice turns that into a standard footer. Tool-specific "recovery words" (narrow the pattern, read the spill file) stay in the tool — the library owns only the mechanics.
Separately, bash-local config has a per-stream spill cap (maxSpillBytes, default 64 MiB): in-memory output held up to maxOutputBytes (64_000 by default), and overflow beyond the spill cap retains only its in-memory tail, reporting the truncation.
Background processes
start(spec) returns a ShellProcess handle synchronously:
export interface ShellProcess {
status: ShellProcessStatus // 'running' | 'completed' | 'killed'
exitCode: number | null
signal: NodeJS.Signals | null
readonly done: Promise<void> // never rejects; spawn failure settles as 'killed'
sandbox?: ShellSandboxInfo
readOutput(): ShellProcessRead // incremental, consuming; lossy reads flag spill paths
kill(): boolean
}Background runs ignore timeoutMs (no executor timeout); callers stop them via kill() or the spec's signal. readOutput is incremental and consuming — consecutive reads never re-deliver output; a read that dropped unread bytes sets lossy and points at full-stream spill files. A still-running background process is killed and awaited when its owning composition tears down.
Providers: local vs sandboxed
LocalBashExecutor (packages/shell/bash-local/src/index.ts) spawns bash -c <command> through ctx.subprocess and owns command defaulting, deadlines, the model-friendly terminal environment, and the stdout/stderr merge. Key facts:
- Env overrides (
ENV_OVERRIDES):NO_COLOR=1,TERM=dumb,PAGER=cat,GIT_PAGER=cat— disables paging/color that would garble tool output. - Config (
static Config):cwd,timeoutMs(default120_000),maxTimeoutMs(600_000),maxOutputBytes(64_000),maxSpillBytes(64 MiB),graceMs(3_000, the SIGTERM→SIGKILL grace). - Env layering: the spawn merges
{...ENV_OVERRIDES, ...spec.env, ...spec.dshEnv}, so a trusted managedDSH_*snapshot beats both the caller's env and the terminal overrides. The subprocess service applies its own ambient credential scrub first. LocalBashExecutor.runArgv/startArgvareprotectedso subclasses can replace the publicbash -cargv at an execution boundary.
SandboxBashExecutor (@deepseek-ai/dsh-bash-sandbox) extends LocalBashExecutor and wraps the exact argv through ctx.sandbox. It inherits the local config verbatim; the sandbox policy (default mode + workspace root) lives on ctx.sandboxPolicy, and the runner choice is the ctx.sandbox provider's config. resolve() stamps a complete per-call policy; run()/start() report result.sandbox = { mode, denied, enforcement?, runnerFailed? }. A runner-launch failure makes foreground calls throw SANDBOX_UNAVAILABLE, while background processes carry runnerFailed. The pwsh-local/pwsh-sandbox pair mirror this for PowerShell on the Windows layer, swapping the POSIX rows.
The three sandbox modes (packages/sandbox/sandbox/src/index.ts): read-only, workspace-write, danger-full-access. See Sandbox Architecture for the enforcement story (local Landlock/Seatbelt/bwrap runners vs the E2B remote substrate).
The model-facing tools
@deepseek-ai/dsh-tool-bash registers the bash tool via defineTool. Its resolved BashToolArgs:
interface BashToolArgs {
command: string
description: string
timeoutMs?: number
workdir?: string
run_in_background?: boolean
sandbox_permissions?: string // advertised only under a confining executor
justification?: string
}Execution semantics (encoded in the tool description the model sees):
- Each call runs in a fresh shell — no
cwd/variable/function state persists; passworkdirinstead ofcd. - Non-zero exits render as
[exit code: N]; a normal exit has no marker. run_in_background: truereturns a job id immediately;job_output/job_killmanage it viactx.jobs.- A sandbox denial renders
[sandbox: file access denied under <mode> mode]; escalation re-runs the same command once withsandbox_permissions+ ajustification, routed throughctx.approvalviaapproveEscalation. - Long output is truncated to its tail, and the full output is saved to a file whose path is reported (
[output truncated; full output: <spill>]).
The renderer marks a time-out or signal: [timed out after <ms>ms] then [killed by signal: X], else [exit code: N] for non-zero. renderResult/renderProcessRead live in packages/shell/tool-bash/src/render.ts. The exit marker contract is shared: parseExitStatus (in @deepseek-ai/dsh-shell/render) recovers { exitCode } or { signal } from the rendered string, so terminal presentation can render a real exit pill from a replayed result.
@deepseek-ai/dsh-tool-pwsh mirrors the bash tool for PowerShell.
@deepseek-ai/dsh-tool-pwsh-persistent (packages/shell/tool-pwsh-persistent) is the PowerShell counterpart of tool-bash-persistent: it registers a persistent pwsh tool whose PowerShell state (cwd, $env: variables, functions, background jobs) survives across calls for the owning agent. Each agent gets its own shell backed by an owner-scoped PTY session with a pwsh-dialect backend (the default shell backend starts PowerShell through dsh-terminal-bash configured with shellDialect: pwsh; deployments may register another pwsh-dialect PTY backend and select it by name), and commands for one agent run one at a time. A timeout or an explicit exit closes the shell and the next call starts fresh; an interactive foreground child that reads stdin blocks until the command timeout, so interactive work belongs to the terminal tools.
shell-env: managed DSH_* facts
@deepseek-ai/dsh-shell-env owns ctx.shellEnv, a registry of trusted, per-execution DSH_* variables consumed by the shell tools. Built-in facts (e.g. DSH_HOME, resolved via DSH_HOME or ~/.dsh) are owned by the registry; plugins can register additional enumerable facts with effect-scoped disposal. Each shell tool collects one trusted snapshot per execution, and the executor rebuilds the namespace (DSH_ENV_PREFIX lives in the subprocess seam). The subprocess service scrubs credential-shaped env names (SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i) and ambient DSH_* names before merging the explicit layer.
The terminal seam (persistent PTYs)
packages/terminal/terminal defines ctx.terminals — an owner-scoped persistent PTY registry. Unlike the one-shot bash tool, a PTY session persists between calls. Backends own terminal mechanics; the registry owns ids, publication, authorization (every session is bound to its owner: Agent), and awaited cleanup.
ctx.terminalsseamownerterminal, providerterminal-bash, consumertool-terminal(perdocs/capability-seams.md).- Backend interface:
TerminalBackend+TerminalBackendSession. Backends spawn viactx.subprocess.spawnTerminaland enforce the shared sandbox fence. - Signals permitted on the model surface:
SIGINT | SIGTERM | SIGKILL | SIGTSTP | SIGHUP. - Error taxonomy:
TerminalErrorCode—DUPLICATE_BACKEND,DUPLICATE_NAME,FOREIGN_SESSION,NO_BACKEND,NO_SESSION,OWNER_NOT_LIVE,SEND_ACTIVE,SERVICE_DISPOSING.
tool-terminal (@deepseek-ai/dsh-tool-terminal) exposes six owner-scoped tools: terminal_open, terminal_send, terminal_read, terminal_signal, terminal_close (waits until the captured owned process tree is gone; shell-targeted SIGKILL is rejected in favor of it), and terminal_list (sessions owned by the current agent, per packages/terminal/tool-terminal/src/index.ts). Sends are line-oriented: TerminalSendRequest { text, submit, signal? }; a settle returns a viewport plus a waitReason (stdin_read | inferred_idle | timeout | session_exit).
@deepseek-ai/dsh-tool-bash-persistent is a second model-facing consumer of the PTY seam: it registers a persistent bash tool that wraps each command with start/end markers (__DSH_PERSISTENT_BASH_START_<nonce>__ … __DSH_PERSISTENT_BASH_END_<nonce>:<status>), so state (cwd, exported vars) persists across calls for one agent while output between the markers is captured, stripped of the markers, and truncated to maxOutputChars. The prompt stays the backend's own — terminal-bash sets a controlled PS1 and a PROMPT_COMMAND that re-asserts it before every prompt, keeping readiness detection working — and when the shell reads stdin again without the end marker (exec, an interrupt, an interactive foreground child), the call returns the captured partial output, which can end with the backend's own prompt text.
terminal vs persistent-bash
tool-bash (one-shot) | tool-terminal (PTY) | tool-bash-persistent | |
|---|---|---|---|
| State across calls | none | full PTY scene | cwd + exported env |
| Substrate | ctx.shell (executor seam) | ctx.terminals (PTY registry) | ctx.terminals |
| Model tools | bash | terminal_open/send/read/signal/close/list | bash (persistent) / pwsh (persistent) |
Further reading
- Filesystem Tools & Policies — the sibling
ctx.fsseam, with the same local/sandboxed provider pattern. - Code Runtime — how the same seam idea runs untrusted programs.
- Sandbox Architecture — the enforcement machines behind
bash-sandboxand the three modes. - Permissions & Approval — the escalation flow
tool-bashdrives throughctx.approval. docs/capability-seams.md— the seam taxonomy and the fullctx.shell/ctx.terminalsrows.packages/shell/shell/src/index.ts— the canonicalShellExecutordefinition.