Files
deepseek-harness/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md
Chinesezjc 153157195c docs(pty): scope the settle boundary to the settled operation
The paragraph claimed later child output reaches only the scrollback. Output
after a settle still reaches the scrollback and whichever send is active when
it arrives — appendOutput writes both (session.ts:302-307), and settleActive
clears `active` rather than suppressing delivery. Only the settled operation
stops receiving it, which is what the timing bound protects.
2026-07-27 18:47:23 +08:00

23 KiB

Agent Note: persistent PTY sessions

Status: implemented

English | 中文

Problem

The harness can run foreground and background commands, edit files, and delegate work, but it cannot continue an interactive terminal conversation across tool calls. Each bash foreground run starts a fresh shell, so shell-local cwd, exported variables, virtual-environment activation, functions, job-control state, and interactive child processes end with that call.

That gap excludes workflows whose state lives in a terminal rather than a file: stepping through gdb, exploring in a Python or Node REPL, driving a line-oriented editor such as ed, or returning to a shell after interrupting its foreground command. The generic ctx.tasks runtime retains background-operation handles and output, but it does not provide interactive stdin or terminal semantics.

The existing bash, read, write, and edit tools remain the reliable default for bounded, auditable operations. A PTY is an additional capability for work that genuinely requires terminal state, not evidence that those tools are defective or candidates for removal.

Decision

The optional packages/pty/ capability family exposes agent-owned, persistent, line-oriented PTY sessions. It follows the repository's capability pattern, coexists with the existing command and filesystem tools, and does not change agent-loop.

The implementation supports interactive shells and line-oriented REPLs on Linux and macOS. Full-screen terminal applications, keystroke sequences, BEL-triggered control flow, session restoration after process loss, and cross-agent session sharing are explicitly deferred.

Package topology

Package Role ctx key
dsh-pty PtyService, branded PtySessionId, backend registry, owner-scoped session contract, and result types ctx.pty
dsh-pty-local node-pty-based local backend, platform process inspection, bounded terminal buffer, sandbox resolution, and process-tree supervision registers a backend on ctx.pty
dsh-tool-pty Six model-facing tools, task-runtime integration for background sends, guidance, and UI render intents registers on ctx.tools

Idle detection is backend behavior, not a second public seam. A remote or container backend may have authoritative readiness signals that do not resemble local /proc inspection; every PtyBackend therefore returns the common send result while owning its detection mechanism internally.

Agent ownership and identity

PtyService stores live sessions process-locally, but every session is owned by the exact Agent passed through the tool execution context. The service mints an opaque PtySessionId; an optional model-chosen name is display metadata and is unique only within that owner. Every operation targets sessionId, and list/read/signal/kill reject callers other than the owner.

There are no plugin-load auto-start sessions. terminal_open creates a session only during an agent tool call, when ownership and the owning event-sourced session are known. A future declarative startup feature must compose through unpublished agent setup rather than create shared global terminals.

Agent-scope disposal closes registrations first, then awaits quiescent teardown of every owned PTY. Unpublished backend setup is a tracked lifecycle operation: owner or service disposal aborts its service-owned signal, waits for backend settlement and rollback, and only then returns. Caller cancellation retains its exact AbortSignal.reason even when the backend rejects or returns a session whose rollback close fails; that cleanup failure remains tracked for later owner or service disposal instead of replacing the caller reason. A lifecycle-triggered rollback close failure rejects both the spawn and the disposing lifecycle, while PtyBackendCleanupError lets a backend preserve its own failed startup cleanup for the disposing lifecycle without replacing a caller cancellation. When caller cancellation settles before disposal, the cleanup failure remains tracked owner activity until later owner or service disposal consumes and reports it, so sandbox-mode policy cannot mistake failed cleanup for quiescence. Backend or tool-plugin reload does not orphan sessions: ownership lives in PtyService until the agent ends, following the same service-owned-record pattern as ctx.tasks. The service reserves the session synchronously for one active send before returning its operation, including before a background task id becomes visible; a second send fails with SEND_ACTIVE, so output and cancellation cannot cross operation ownership.

Security and process boundary

A registered shell backend constrains how a terminal starts; it does not constrain commands typed after startup. dsh-pty-local therefore applies two protections before spawning:

  • It builds a scrubbed child environment using the same credential-shaped-name policy as bash-local, removing ambient *KEY*, *SECRET*, *TOKEN*, and harness-managed variables unless an explicit trusted mapping supplies them.
  • It requires ctx.sandbox and the shared ctx.sandboxPolicy. At spawn, the backend resolves the owner's effective session mode over the deployment default and wraps the shell argv once; that mode and workspace root remain the process boundary for the PTY lifetime. A write that would change the effective sandbox/mode is rejected before commit while the owner has any open PTY or unpublished spawn, with an instruction to wait for creation to settle and close those sessions first; same-effective-mode writes remain valid. The pending reservation spans backend setup through publication, so there is no race in which a wider terminal appears after a downgrade. danger-full-access is the existing explicit unconfined choice rather than a PTY-specific bypass.

Sandboxing confines local process effects but does not make arbitrary shell input safe: network calls and other external side effects remain governed by deployment policy. Tool descriptions state that PTY sessions are less auditable than one-shot tools and should be used only when persistence or interactive stdin is necessary.

The implementation uses only public node-pty capabilities: child PID, data and exit notifications, write, resize, and kill. It does not assume access to the native master fd or call waitpid from TypeScript. Platform process inspectors derive foreground process groups and parent/child identity from /proc on Linux and ps on macOS.

Six model-facing tools

Tool Purpose Result
terminal_open Create an owner-scoped session from a registered backend type { sessionId, name, type, motd }
terminal_send Send text, optionally submit Enter, and wait for readiness or register a background task bounded viewport plus wait and session status; background also returns taskId
terminal_read Read a bounded page from retained scrollback { text, totalLines, lineBegin, lineEnd, truncated }
terminal_signal Send one allowed signal to the current foreground process group { delivered, targetPgid }
terminal_close Close one session and await process-tree quiescence { killed }
terminal_list List the caller's live sessions owner-scoped session summaries

The UI render contract is exact and location-free. terminal_send uses terminal call/result cards only for foreground sends; its background form is generic execute. terminal_open, terminal_read, terminal_signal, terminal_close, and terminal_list use generic execute, read, execute, delete, and read cards respectively. No PTY tool emits locations.

terminal_send({ sessionId, text, submit?, run_in_background? }) treats text as UTF-8 bytes and resolves submit to true in the tool implementation. When submit is true it writes the platform Enter sequence after the text; when false it writes only the text, allowing control characters and REPL fragments without hidden content heuristics. enableRunInBackground defaults to true; false removes run_in_background from the schema and rejects the same undeclared argument if a caller forces it through execution.

Foreground sends return a bounded rendered delta and two independent facts: waitReason (stdin_read | inferred_idle | timeout | session_exit) and sessionStatus (running or exited with exit code or signal). session_exit refers to the PTY's top-level shell process, not an arbitrary foreground command whose status the shell consumes. A timeout never implies process exit. dsh-tool-pty.maxResultBytes defaults to 262144, rejects values below 64 so creation acknowledgements retain registry-issued ids, and caps each single-text UTF-8 result after normalized tool or pipeline errors, wait, session, pagination, truncation, generic task-status wrappers, policy denials or short-circuits, and post-execute replacements or blocks; the terminal definitions' last-mile finalizeContent callback leaves deliberately structured multi-block policy content unchanged. The renderer reserves suffix space and preserves code-point boundaries instead of treating the backend payload cap as the final model bound.

With run_in_background: true, dsh-tool-pty registers the in-flight send on ctx.tasks and returns immediately with taskId. The producer places maxResultBytes on the task snapshot so task_output, terminal kill status, and completion notices enforce the same complete-result cap after generic metadata. task_output(wait: true) waits, reads incremental output, and records the final result; task_kill resolves the current foreground PGID and delivers a real SIGINT, including when the application has disabled terminal ISIG, and escalates only through the PTY backend's owned teardown path. If the task surface is absent, background mode fails before writing input. No PTY-specific sleep tool or general wake-up seam is added.

terminal_read pages backward from the newest retained line. The backend enforces both line and UTF-8 byte caps on retained scrollback and the returned page payload, so one oversized line cannot bypass the backend bound; the tool then caps the fully rendered page including pagination and truncation metadata. truncated distinguishes retention loss from an ordinary viewport delta.

terminal_signal accepts the closed set SIGINT | SIGTERM | SIGKILL | SIGTSTP | SIGHUP. The backend resolves the terminal foreground process group at execution time. SIGKILL is rejected when that group is the top-level shell, directing the caller to terminal_close; a failed group lookup fails the operation instead of signaling a guessed PID.

Local readiness detection

The local backend first recognizes a private OSC prompt marker emitted by its controlled bash startup, then requires printable prompt text after that marker before declaring prompt readiness and runs three bounded fallback tiers. Carrying that state across data callbacks covers macOS delivery where the OSC marker and PS1 arrive separately; the marker alone can no longer publish an empty MOTD. The marker is removed before output reaches the model and avoids a fixed silence delay for ordinary shell commands on both platforms. Unpublished startup does not accept zero-output silence as readiness; timeout rejects the spawn. If caller cancellation wins during startup, the backend closes the private session and propagates the exact AbortSignal.reason; a foreground PGID that is not observable yet cannot replace cancellation with a lookup error. All timings are validated config fields: pollIntervalMs, exactProbeAfterMs, idleSilenceMs, and timeoutMs.

On Linux, the inspector reads the shell's terminal foreground PGID from /proc/<shellPid>/stat, enumerates every process and thread in that process group, and probes their current syscalls. A positive Tier 1 result requires an observed stdin wait: direct read(0), a permitted read of a select/pselect6 or poll/ppoll argument containing fd 0, or an epoll interest list containing fd 0. Unreadable process memory and unrecognized syscalls are misses, never positive guesses. Architecture tables contain only syscall numbers defined by the corresponding Linux UAPI; unsupported architectures skip Tier 1.

On macOS there is no exact syscall tier. Output silence returns inferred_idle for any foreground process group, including Python and gdb; ps-derived terminal PGID is used for signaling, not as proof that only the shell can be idle. Pure process-inspector logic is injectable and unit-tested on Linux, while a macOS CI job exercises the real PTY and process-table path.

Tier 2 returns inferred_idle after idleSilenceMs without output. A sleeping or network-blocked command can therefore look ready. Tier 3 returns timeout after timeoutMs so a foreground tool call cannot hold the agent indefinitely. The result preserves the distinction; callers may wait through ctx.tasks, signal the foreground group, or inspect from another session.

Once a send settles under any tier, PtySendOperation.append stops accepting output, so later child output no longer reaches that settled operation; it still reaches the scrollback, and any send that is active when it arrives. A test that waits for a marker on the operation it started must therefore set idleSilenceMs and timeoutMs above the child's own startup latency; interpreter startup on a loaded macOS runner otherwise ends the send before the marker is printed.

node-pty data notifications feed one terminal parser. Parser carry state handles control sequences and a trailing carriage return split across callbacks, so a divided CRLF produces one newline rather than a pagination-changing blank line. The implementation normalizes line-oriented output, but it does not promise correct interaction with a full-screen application.

Model-visible output and durability

The existing durable tool/call and tool/result events are the source of truth for text sent by the model and rendered output returned to it. terminal_open returns its MOTD through the logged tool result; foreground send/read/list/signal/close results are logged the same way. The PTY packages do not duplicate raw byte streams into custom session events.

Background sends use the existing task completion notice and task_output result path, so any output that reaches a later model request is likewise durable. Raw terminal bytes remain bounded process-local state and are neither persisted nor restorable. A future opt-in transcript sink would need its own retention, credential, and privacy contract.

Process-tree teardown

The top-level node-pty child is the ownership anchor. On close, the backend stops callbacks, snapshots its transitive descendants by parent PID in children-first order, sends SIGTERM, waits, rescans for children forked during shutdown, sends SIGKILL to the remaining descendant tree, and verifies that every non-zombie descendant left the process table while the shell is still alive. A matching Linux zombie has no executable work and therefore counts as quiescent, allowing shell shutdown to reap or reparent it. Only then does the backend stop the shell with its own TERM/grace/KILL sequence. Every captured PID includes process-start identity so reuse cannot redirect escalation.

Teardown reports root exit and survivor cleanup independently. It does not claim success merely because the shell exited; disposal resolves only after no captured non-quiescent tree member remains or returns a cleanup failure naming the survivors. A failed close is not cached forever: the registry and local session each clear the fence only when it still names that failed attempt, so a later explicit or lifecycle close retries after the external survivor condition changes without disturbing a newer concurrent attempt. Service disposal still clears its backend, reservation, and owner-detacher registries when a close fails. It never broadens ownership to every member of the root PID's POSIX session.

Composition and rollout

The example composition remains opt-in and safe by default:

plugins:
  '@deepseek-ai/dsh-sandbox-local':
  '@deepseek-ai/dsh-sandbox-policy':
    config:
      mode: workspace-write
      workspaceRoot: .
  '@deepseek-ai/dsh-pty':
  '@deepseek-ai/dsh-pty-local':
    config:
      scrollbackLines: 10000
      scrollbackMaxBytes: 4194304
      maxReadBytes: 262144
      pollIntervalMs: 50
      exactProbeAfterMs: 150
      idleSilenceMs: 3000
      timeoutMs: 30000
      disposeGraceMs: 3000
  '@deepseek-ai/dsh-tool-pty':
    config:
      enableRunInBackground: true
      maxResultBytes: 262144

The package ships concise tool guidance explaining persistent state, owner isolation, uncertain idle results, cleanup, and the preference for existing one-shot tools when interaction is unnecessary. It does not mount PTY in the base shipped examples: PTY is opt-in through the dedicated composition, while ACP and headless snapshot overlays exercise it. Within an enabled dsh-tool-pty instance, the six tools and run_in_background are enabled by default; deployments may disable only the background argument with config.

Deferred work

  • Full-screen TUI support, named key sequences, BEL interruption, terminal resize tools, and alternate-screen snapshots require a separately proven model-facing contract.
  • Declarative per-agent startup requires an agent-setup composition point; plugin-load global sessions remain prohibited.
  • Session restoration across harness-process loss requires an out-of-process owner and a versioned protocol.
  • Network-egress policy and rollback of external side effects are broader than PTY and remain separate security work.
  • Windows/ConPTY support requires a backend with Windows-native process ownership and signaling semantics.

Alternatives considered

Replace bash, filesystem tools, or task tools with PTY. Rejected. One-shot tools retain stronger validation, approval, sandbox, output-bound, and replay contracts. PTY is reserved for interactive state.

Add persistent mode to bash. Rejected. Returning on readiness rather than process exit, retaining a process tree across calls, and exposing interactive stdin create a different ownership and failure contract.

Require native master-fd access from node-pty. Rejected. Its public API exposes no master fd. The local backend instead derives foreground groups and descendants from supported OS process metadata and treats unreadable metadata as a detector miss.

Signal every member of the root PID's POSIX session. Rejected. node-pty may expose a helper PID whose session belongs to the launcher, so SID-wide teardown can signal unrelated harness or desktop processes. A PID-identity-fenced descendant tree is narrower and safe by construction.

Publish PtyIdleDetector as a replaceable registry. Rejected. Only the local backend needs these platform probes, while remote backends may receive readiness over their own protocol. Backend replacement already provides the necessary extension point.

Add a PTY-specific sleep tool. Rejected. ctx.tasks already owns bounded waiting, cancellation, completion notices, and model-facing collection. A second general wake mechanism would cross the agent-loop boundary and duplicate that contract.

Include TUI sequences and BEL handling. Rejected. The source prototype treats those paths as timing-sensitive and still records unresolved alternate-screen and interaction failures. Line-oriented PTY use proves the core value without making those unverified behaviors foundational.

Use an out-of-process daemon immediately. Rejected for the initial in-process capability because current persistent front doors already keep a Cordis context alive. A daemon becomes justified by cross-process restoration or multi-client attachment, both deferred here.

Verification

  • Per-file coverage pins owner fencing, concurrent reservations, unpublished-spawn cancellation and awaited teardown, sandbox-mode change rejection, retriable lifecycle cleanup, readiness tiers, sanitizer carry state, complete UTF-8 bounds, task integration, schemas, and exact render intents.
  • Linux process fixtures cover non-leader and non-main-thread stdin waits, zombie quiescence, unreadable process state, supported syscall tables, unsupported architectures, and false-positive rejection; macOS inspector logic is injected into the same unit suite.
  • Real node-pty tests exercise shell state, shared sandbox policy, environment scrubbing, raw-mode foreground SIGINT after deliberately delayed child readiness under scenario-owned timing bounds, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts.
  • A Loader-driven cordis.yml test mounts the real three-package composition. ACP and headless snapshots pin the six schemas, bounded results, and errors through opt-in overlays; TUI snapshots pin terminal and generic card presentation.
  • Package contracts, the architecture map, core data structures, generated catalogs, and the website API describe the same shipped surface.
  • The repository CI-equivalent sequence owns type, lint, coverage, snapshot, documentation, build, hygiene, demo, and built-entry verification.

Consequences

Persistent terminal state is available without weakening one-shot tools. Shell and REPL state can survive tool calls, while bash, read, write, and edit retain their narrower validation, approval, and replay contracts.

Idle below Linux Tier 1 is heuristic. Output silence cannot distinguish a prompt from sleep or network I/O. The typed result preserves uncertainty, and bounded timeout plus task waiting and signaling keep control with the model.

Persistent state can drift from the model's belief. The model may forget its cwd or active REPL. Session summaries and retained output help recovery, but no prompt can make state persistence deterministic.

A daemonized descendant can leave the captured tree. A process that reparents before teardown is no longer discoverable from the node-pty root. The implementation accepts that cleanup gap instead of risking SID-wide signals to unrelated processes.

A shell can cause external side effects. Session sandboxing and environment scrubbing reduce local exposure but do not undo pushes, API calls, or messages. Deployments that cannot tolerate those effects must omit PTY or add network policy.

Process loss destroys terminal state. In-process sessions do not survive a harness crash or restart, and raw scrollback is not durable. Important work must be committed to files or another durable system.

node-pty is a native dependency. Installation, supported Node versions, prebuild availability, and platform behavior require built-artifact smokes on every supported OS.