Skip to content

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 Cordis declare module augmentation.
  • 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.

text
            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:

PackageRole
@deepseek-ai/dsh-shellService Definition (ShellExecutor, ctx.shell)
@deepseek-ai/dsh-bash-localProvider: local bash -c over ctx.subprocess
@deepseek-ai/dsh-bash-sandboxProvider: bash wrapped through ctx.sandbox
@deepseek-ai/dsh-pwsh-localProvider: local PowerShell over ctx.subprocess
@deepseek-ai/dsh-pwsh-sandboxProvider: PowerShell wrapped through ctx.sandbox
@deepseek-ai/dsh-shell-envCore: ctx.shellEnv managed DSH_* facts
@deepseek-ai/dsh-tool-bashConsumer: model-facing bash tool
@deepseek-ai/dsh-tool-bash-persistentConsumer: persistent-bash tool over the PTY seam
@deepseek-ai/dsh-tool-pwshConsumer: model-facing PowerShell tool
@deepseek-ai/dsh-tool-pwsh-persistentConsumer: 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:

ts
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:

ts
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 only head retention (maxItems).
  • TextRetainer — bounds a byte-oriented text stream with head / tail / headTail strategies, 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:

ts
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 (default 120_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 managed DSH_* snapshot beats both the caller's env and the terminal overrides. The subprocess service applies its own ambient credential scrub first.
  • LocalBashExecutor.runArgv/startArgv are protected so subclasses can replace the public bash -c argv 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:

ts
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; pass workdir instead of cd.
  • Non-zero exits render as [exit code: N]; a normal exit has no marker.
  • run_in_background: true returns a job id immediately; job_output/job_kill manage it via ctx.jobs.
  • A sandbox denial renders [sandbox: file access denied under <mode> mode]; escalation re-runs the same command once with sandbox_permissions + a justification, routed through ctx.approval via approveEscalation.
  • 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.terminals seam owner terminal, provider terminal-bash, consumer tool-terminal (per docs/capability-seams.md).
  • Backend interface: TerminalBackend + TerminalBackendSession. Backends spawn via ctx.subprocess.spawnTerminal and 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 callsnonefull PTY scenecwd + exported env
Substratectx.shell (executor seam)ctx.terminals (PTY registry)ctx.terminals
Model toolsbashterminal_open/send/read/signal/close/listbash (persistent) / pwsh (persistent)

Further reading ​

  • Filesystem Tools & Policies — the sibling ctx.fs seam, with the same local/sandboxed provider pattern.
  • Code Runtime — how the same seam idea runs untrusted programs.
  • Sandbox Architecture — the enforcement machines behind bash-sandbox and the three modes.
  • Permissions & Approval — the escalation flow tool-bash drives through ctx.approval.
  • docs/capability-seams.md — the seam taxonomy and the full ctx.shell/ctx.terminals rows.
  • packages/shell/shell/src/index.ts — the canonical ShellExecutor definition.