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

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.

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: TerminalErrorCodeDUPLICATE_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 callsnonefull PTY scenecwd + exported env
Substratectx.shell (executor seam)ctx.terminals (PTY registry)ctx.terminals
Model toolsbashterminal_open/send/read/signalbash (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.