Shell, PTY, Terminal, and Background Jobs
Short-lived processes, persistent sessions, retained output, and ownership
Conclusion: This Is Not One Shell but Four Orthogonal Lifecycle Systems
DeepSeek Harness splits “run a command” into four layers: ctx.subprocess owns the execution world, process trees, and byte streams; ctx.shell normalizes a one-shot command into a foreground result or background process; ctx.jobs gives heterogeneous producers one background identity, ownership, and completion plane; and ctx.terminals manages PTY sessions that survive across calls. Tools are model-facing adapters over those seams; they do not own the underlying resources.
The split solves lifecycle rather than command syntax: who may see a resource, when cancellation counts as complete, how output loss remains explicit, and who reaps work when an Agent disappears. The cost is that deployment composition, owner scope, and provider capability all matter. A tool named bash alone does not reveal whether it starts a fresh process or reuses a persistent shell.
1. Four-Layer Topology: Execution, Commands, Background Control, and Interactive Sessions
model tools
bash / pwsh ───────────────→ ctx.shell ──────→ ctx.subprocess.spawn
└─ run_in_background ─→ ctx.jobs ───────┘
terminal_* ────────────────→ ctx.terminals ─→ PTY backend
└─ background send ───→ ctx.jobs └→ ctx.subprocess.spawnTerminal
persistent bash wrapper ───→ ctx.terminals (one reused PTY per Agent)
SubprocessRuntime is the shared substrate for ordinary processes and the shipped Bash terminal backend. A provider must resolve executables in the same execution world, return live handles immediately, define tree-scoped termination contracts, and reap live resources when the service is disposed. TerminalBackend remains replaceable, so another backend may bring a different substrate. SHELL-SUBPROCESS-SEAM
ctx.shell intentionally knows nothing about Sessions or Job IDs; background registration, polling, and notices belong to the Job layer. Conversely, the Job Registry knows nothing about commands, PTYs, or subagent implementations. It accepts producer-supplied cancel, done, and readOutput hooks.
2. Shipped Compositions Select Two Completely Different bash Contracts
| Preset | Model-facing Bash | State | Background control |
|---|---|---|---|
| Standard | dsh-tool-bash; pwsh on Windows | Fresh process per call | run_in_background plus three job_* tools |
| Minimal | dsh-tool-bash-persistent | One reused PTY per exact Agent | Commands serialize; long work backgrounds inside the shell |
| Terminal tool package | Six terminal_* tools | Explicit create, send, read, signal, list, and close | terminal_send can join the Job Registry |
The Standard preset registers one-shot Bash off Windows and PowerShell on Windows, plus Job controls. Minimal isolates its own Terminal service/backend and exposes only persistent Bash and an editor. Standard does not expose the six Terminal tools, and Minimal does not hand raw PTY lifecycle control to the model. SHELL-STANDARD-COMPOSITIONSHELL-MINIMAL-COMPOSITION
3. The Subprocess Seam Rejects Hidden Defaults: the Caller Specifies Every Execution Condition
SubprocessSpawnSpec explicitly carries argv, cwd, all three stdio dispositions, cleanup grace, an AbortSignal, and an environment overlay. The provider does not guess a shell, directory, buffer limit, or deadline. argv[0] is the executable; the substrate performs no shell interpretation.
done rejects only for a spawn-level failure; exit code and signal after a successful spawn are ordinary outcomes. Collected streams use caller-owned byte offsets, so independent readers do not consume one another. A raw pipe belongs completely to the upper layer. SHELL-SPAWN-CONTRACT
This allows local and remote execution to be swapped without changing the tool contract. A provider can change executable lookup, process/PTY transport, and filesystem world while callers continue to own policy and presentation.
4. Child Environments Are Scrubbed First, Then the Current Harness Identity Is Rebuilt Explicitly
process.env
├─ remove names matching KEY|PASSWORD|SECRET|TOKEN
├─ remove every DSH_* name, case-insensitively
└─ retain PATH / HOME / locale / proxy and ordinary values
↓
caller env overlay (string restores; undefined deletes)
↓
shell tool injects the current ctx.shellEnv DSH_* snapshot per call
Ordinary-process and PTY backends share one scrub function. The explicit overlay merges afterward, so a deployment can still deliberately forward a credential-shaped or DSH_* value. SHELL-ENV-SCRUB
ctx.shellEnv rebuilds, sorts, and freezes DSH_HOME, DSH_SHELL=1, an Agent's DSH_SESSION_ID, and DSH_SESSION_JSONL when the backend exposes a location. Contributors must predeclare keys; duplicate ownership or an undeclared runtime key fails loudly. SHELL-MANAGED-ENV
5. Long Output Prefers the Tail; a Complete Spill Is Published Only While It Remains Trustworthy
Bounded memory
The collector counts bytes and drops from the head to retain exactly the last maxBytes.
Lazy spill
First overflow creates a random, exclusive, mode-0600 file in a private temporary directory and backfills prior chunks.
Completeness has a cap
Once the total stream exceeds the spill cap, the spill is removed because it can no longer represent the complete output.
Readable while running, final after settlement
The current spill path may be returned while complete; it represents final complete output only after process settlement and a successful seal.
An offset read returns lossy, the next offset, and the spill path while complete. During execution that path represents the complete stream so far; only successful settlement sealing makes it the final file. If the requested offset has fallen out of the memory window, the reader returns the entire retained tail instead of pretending it is a contiguous delta. SHELL-OUTPUT-SPILL
Tail retention is optimized for diagnostics: errors and summaries often appear at the end, while the spill preserves complete retrieval. Tool rendering applies another result cap, so recoverable substrate output does not mean every byte enters that model request.
6. One-Shot Shell Treats Nonzero Exit, Timeout, and Cancellation as Facts, Not Infrastructure Failures
| Case | ctx.shell.run | Model tool |
|---|---|---|
| Exit 0 / nonzero | Resolves a ShellRunResult | Returns output; nonzero adds an exit marker |
| Executor timeout | Resolves with timedOut:true | Shows timeout and partial output |
| Tool AbortSignal wins first | Resolves with aborted:true | Converts it to a stable tool-aborted error |
| Spawn/provider failure | Rejects in foreground | Normalizes an execution error |
The executor's resolve owns defaults and caps; foreground run rejects only for infrastructure failure. Background start applies no timeout, and even a spawn failure becomes a killed handle with stderr so Job cleanup cannot hang on a rejected done. SHELL-EXECUTOR-CONTRACT
7. Switching to Background Is an Atomic Ownership Transfer, Not Merely “Do Not Await”
bash(args)
→ resolve arguments / escalation / workdir / shellEnv
→ verify the tool-call signal is not already aborted
→ jobs.start({ owner, kind, label, run })
├─ controller / owner / capacity preflight
├─ run() actually spawns
├─ register record and JobId
└─ Job runtime owns cancel / done / output
→ return job id
The Bash tool retains tool-call cancellation until jobs.start. After Job ID publication it no longer passes exec.signal to the process; only job_kill or owner/service teardown stops it. A nonzero background command is completed with an exit-code detail, not registry-level failed. SHELL-BASH-TOOLSHELL-BACKGROUND-OUTCOME
8. The Job Registry Is a Producer-Neutral, Process-Local Control Plane
A Job carries a kind, label, optional exact-Agent owner, output cap, and three hooks. The producer returns cancel, non-rejecting done, and optional consuming readOutput from run(); the registry owns IDs, status, access control, waiters, notices, and teardown.
The local registry validates a reachable scoped controller, kind, label, output limit, live registered owner, and the per-owner active cap—10 by default—before invoking the producer. There is no queue. Only after all preflight succeeds does it call run() and mint <kind>-N. SHELL-JOBS-START
An owned Job can be read or cancelled only by a caller with the same Session ID; listeners are routed through the exact owner's scope chain. Settlement is first-wins: commit terminal state, release waiters, notify UI observers, then invoke completion listeners, so a reporter cannot open a turn before other observers see the committed record. SHELL-JOBS-SETTLEMENT
9. Job Tools Distinguish “Cancellation Requested” from “Actually Stopped”
| Tool | Read/mutation | Key semantics |
|---|---|---|
job_output | Consumes the next stream delta, or retrieves final output after settlement | A timed-out wait:true returns running and leaves the Job alive |
job_list | Lists visible live and settled records | The registry does not delete records on settlement |
job_kill | Calls the cancel hook immediately | Returns cancellation-requested; terminal state waits for producer settlement |
All three tools honor the producer's output cap. job_output owns its wait deadline so ordinary “still running” is not mapped to a tool timeout. Kill then reads a non-consuming snapshot, avoiding accidental consumption of remaining output. SHELL-JOB-TOOLS
10. Completion Notices Choose the Current Turn's Inbox or a New Follow-up Turn
Job settles and is not reported
├─ owner busy → owner.inject(notice)
├─ owner idle + wake budget remains → owner.followup(notice)
└─ quiet mode / exhausted budget → owner.inject(notice)
The default wake budget permits three consecutive Job-completion turns per exact Agent. Only claiming a genuinely user-authored message resets it. This prevents “completion starts a turn, that turn starts another job, its completion starts another turn” from buying requests forever. SHELL-JOB-NOTICES
11. Job Lifetime Is Bound to the Exact Agent Object, Not Merely a Reusable Session String
Owner cleanup attaches to the exact Agent scope. On disposal the registry requests cancellation for owned Jobs, awaits every settlement, then deletes records and updates mirrors. Whole-service disposal performs the same quiescence protocol.
If a cancel hook throws, the registry force-fails the record and explicitly records that work may be orphaned. If cancel returns but the producer never settles, teardown cannot distinguish “slow stop” from “ineffective cancellation” and continues waiting. SHELL-JOBS-CLEANUP
The Web Job list is only a read-only mirror pushed by the Host. It empties on process restart while historical start cards remain in the transcript. “A start was recorded” and “a live control handle still exists” are distinct facts. SHELL-JOBS-UI
12. The Terminal Service Publishes Exact-Owner Sessions, Not Guessable Global PTYs
Reserve
Validate the live owner, backend, and owner-local name; reserve the name and pending spawn.
Initialize
The backend allocates and initializes a PTY; owner/service cancellation can abort unpublished setup.
Publish
Recheck the service and exact owner before inserting pty-N into the registry.
Roll back
Close an unpublished session on failure; retain or aggregate cleanup errors instead of silently leaking.
Read, signal, kill, and list all require the exact owner; at most one send is active per PTY. Close removes the record only after backend quiescence. A close failure clears the closing fence so a caller can retry. SHELL-TERMINAL-SERVICE
13. A terminal_send Return Means “Reasonable Time to Think Again,” Not Command Exit
| waitReason | Evidence | It does not prove |
|---|---|---|
stdin_read | Controlled prompt plus shell foreground group, or foreground syscall probe waiting for input | The entire Session exited |
inferred_idle | A configured silence window after output | The foreground process completed |
timeout | This send's wait deadline elapsed | The process was killed or the Session exited |
session_exit | Top-level PTY process outcome arrived | An earlier idle inference was equivalent to exit |
The local PTY backend combines a controlled prompt, foreground process group, Linux exact stdin-wait probing, output silence, and an absolute timeout. A prompt is accepted only after the shell regains the foreground group. Cancelling an active send delivers a real SIGINT to the foreground group and retains the exclusive slot until the signal path settles. SHELL-PTY-READINESS
14. Six Terminal Tools Expose the Resource; Persistent Bash Hides It
| Interface | Model responsibility | Runtime responsibility |
|---|---|---|
terminal_open/send/read/signal/list/close | Retain Session IDs, interpret readiness, and eventually close | Owner fence, scrollback, signals, quiescent cleanup |
Persistent bash(command) | Submit only commands | Cache a PTY per owner, serialize calls, extract marker-delimited exits, reset after timeout/abort |
One-shot bash/pwsh | Supply a complete command/workdir each time | Fresh process, timeout, bounded output, optional Job detach |
All six tools require a calling Agent. Background terminal_send reserves the PTY's exclusive send and exposes it as a pty-send Job. Job cancellation becomes operation cancellation/SIGINT, and output plus results share one byte cap. SHELL-TERMINAL-TOOLS
The wrapper surrounds each command with random start/end markers and serially reads scrollback per owner. Timeout, abort, send failure, or shell exit closes the uncertain session and recreates it from the workspace next time rather than reusing potentially contaminated state. SHELL-PERSISTENT-BASH
15. Kill Targets a Process Tree, but the Quiescence Proof Is Platform-Specific
An ordinary local process uses a detached process group on POSIX, TERM followed by grace and KILL, with group-liveness observation. Windows requests tree termination through taskkill /T /F but treats direct-child exit as the observable completion proxy; it has no independent descendant/group liveness probe.
On POSIX, the implementation continues probing group liveness after direct-child close so a TERM-trapping helper is not missed, and stops escalation only after group disappearance. The Windows guarantee is weaker: termination includes /T, but the wait boundary remains root-process exit and therefore does not independently prove whole-tree quiescence. SHELL-PROCESS-TREE
The PTY backend tracks descendants by PID plus start identity, clears descendants, stops the shell, then scans descendants again. SIGKILL to the current shell itself is rejected; callers should close the Terminal session. Close fails explicitly if survivors remain. SHELL-PTY-CLEANUP
16. Cross-Platform and Remote Replacement Belongs in the Substrate/Provider, Not the Tool Contract
Bash and PowerShell tools share ctx.shell, Job, approval, and managed-environment semantics; executable choice, launch flags, encoding, and sandbox runner stay provider-specific. The E2B provider replaces executable lookup, ordinary process execution, and Terminal transport behind ctx.subprocess, leaving upper Shell/PTY consumers on the same seam. SHELL-E2B-SEAM
This is a stronger boundary than letting every tool call child-process APIs directly: security environment, process trees, output, and PTY transport can be replaced centrally. Cross-platform parity still depends on each concrete tool provider actually reusing the shared helpers. A public Service Definition is a contract, not automatic proof of parity.
17. Three Static Audit Findings at the Pinned Baseline
| Finding | Impact | Assessment |
|---|---|---|
shellEnv.list() omits the registry's three built-ins | Diagnostics/UI treating it as exhaustive will under-report actual injection | Explicitly recorded in both source TODO and README |
The PowerShell README still declares old inject names: tools,bash,systemPrompt,bashEnv | Extension authors copying it reference obsolete or absent seams | Current source uses tools,shell,systemPrompt,shellEnv |
| PowerShell's default cwd still uses the raw Session header while Bash canonicalizes and prefers the policy workspace root | The same Session can have non-isomorphic path identity across platforms | The README records this as a known parity gap |
These are direct comparisons between current production source and adjacent official READMEs, not runtime inference. SHELL-ENV-LIST-GAPSHELL-PWSH-DOC-DRIFTSHELL-PWSH-INJECTSHELL-PWSH-CWD-GAPSHELL-BASH-CWD
18. Guarantee Matrix: Durable Facts Versus Current-Process Control State
| Object | Across calls | Across Agents | Across process restart | Output read |
|---|---|---|---|---|
| Foreground one-shot Shell | No | N/A | No | Replayable after its result enters the Session |
| Background Job | Yes | No for owned Jobs | No | Streams are single-consumer; result/notice is durable once logged |
| Explicit Terminal | Yes | No, exact owner | No | Bounded paginated scrollback |
| Persistent Bash | Yes, same Agent | No | No | One marker-delimited command result |
| Spill file | While process/file survives | Host path permissions decide | Not Session durability | Published only while complete and successfully sealed |
The strongest design choice is keeping “cancellation requested” separate from “resource quiescent,” “wait returned” separate from “command exited,” and “transcript has a start card” separate from “a live control handle still exists.” That precision is also the largest cognitive cost: Process, Job, Terminal, and Turn each have their own state machine. Operations and UI must expose those states rather than flattening all of them into running/done.
In addition to tracing source, configuration, READMEs, and test control flow at the pinned commit, this delivery ran 15 targeted Vitest files spanning approval, presets, escalation, filesystem, process, Job, and Terminal behavior: 508 tests passed and one was skipped. Platform differences and the chapter's static findings remain separately bounded by their evidence.
My Learning Notes
Autosaved only in this browser. Nothing is uploaded or committed. Export Markdown whenever you want to keep a copy.