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 |
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.
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 four owner-scoped tools: terminal_open, terminal_send, terminal_read, terminal_signal (plus a background-send form). 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 echo markers (__DSH_PERSISTENT_BASH_START_<nonce>__ … __DSH_PERSISTENT_BASH_END_<nonce>:<status>) and a prompt (__DSH_PERSISTENT_BASH_PROMPT__), so state (cwd, exported vars) persists across calls for one agent while output is delimited and truncated to maxOutputChars.
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 | bash (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.