From 57a47b1fb3812488c6cbec4c5a6242fc543baf1f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:37:20 +0800 Subject: [PATCH 01/28] fix(pty): close review lifecycle gaps --- ...06-20-generic-long-running-tool-runtime.md | 10 +- ...26-07-16-persistent-pty-sessions.i18n.yaml | 4 +- .../2026-07-16-persistent-pty-sessions.md | 31 ++-- .../2026-07-16-persistent-pty-sessions.zh.md | 31 ++-- docs/config-catalog.md | 21 ++- docs/cordis-catalog/services.md | 11 +- docs/core-data-structures/tasks.md | 7 + docs/event-producer-consumer.md | 2 +- docs/module-graph.md | 10 +- examples/acp-agent/pty.cordis.snapshot.yml | 2 + .../tests/snapshots/pty-tools/session.jsonl | 2 +- .../snapshots/pty-tools/stdout.expected.jsonl | 2 +- .../headless-agent/pty.cordis.snapshot.yml | 2 + .../tests/snapshots/pty-tools/session.jsonl | 2 +- .../pty-tools/stream-json.expected.jsonl | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 8 +- packages/pty/pty-local/README.md | 6 +- packages/pty/pty-local/package.json | 2 + packages/pty/pty-local/src/index.ts | 20 ++- packages/pty/pty-local/src/sanitize.ts | 41 ++++- packages/pty/pty-local/src/session.ts | 163 ++++++++++++------ packages/pty/pty-local/tests/index.spec.ts | 101 ++++++++++- packages/pty/pty-local/tests/local.spec.ts | 33 ++++ packages/pty/pty-local/tests/sanitize.spec.ts | 16 +- packages/pty/pty-local/tests/session.spec.ts | 99 ++++++++++- packages/pty/pty-local/tsconfig.json | 6 + packages/pty/pty/README.md | 4 +- packages/pty/pty/src/index.ts | 36 +++- packages/pty/pty/tests/service.spec.ts | 40 ++++- packages/pty/tool-pty/README.md | 15 +- packages/pty/tool-pty/package.json | 5 + packages/pty/tool-pty/src/index.ts | 54 ++++-- packages/pty/tool-pty/src/render.ts | 92 ++++++++-- packages/pty/tool-pty/tests/render.spec.ts | 54 ++++-- packages/pty/tool-pty/tests/tools.spec.ts | 51 +++++- packages/pty/tool-pty/tsconfig.json | 3 + packages/tasks/tasks/README.md | 4 +- packages/tasks/tasks/src/index.ts | 7 + packages/tasks/tasks/src/types.ts | 7 + packages/tasks/tasks/tests/tasks.spec.ts | 23 ++- packages/tasks/tool-tasks/README.md | 4 +- packages/tasks/tool-tasks/package.json | 8 +- packages/tasks/tool-tasks/src/index.ts | 52 +++++- .../tasks/tool-tasks/tests/tool-tasks.spec.ts | 22 ++- packages/tasks/tool-tasks/tsconfig.json | 3 + pnpm-lock.yaml | 13 ++ python/sdk-runtime/package.json | 1 + 47 files changed, 940 insertions(+), 192 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md index 4db0d78910..11425939c3 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md @@ -21,7 +21,9 @@ Long-running tools are producers. `dsh-tool-bash` adapts a `BashProcess` into in ## Runtime contract -The literal types live in the [task data-structure catalog](../../../../docs/core-data-structures/tasks.md). A producer calls `ctx.tasks.start()` with a kind, label, optional owning `Agent`, and a `run()` function. The runtime completes all failable preflight work before calling `run()` and invokes it once. After `run()` returns hooks, registration commits without another failable step; a producer cannot start work that lacks a collectable task id. +The literal types live in the [task data-structure catalog](../../../../docs/core-data-structures/tasks.md). A producer calls `ctx.tasks.start()` with a kind, label, optional owning `Agent`, optional positive `outputLimitBytes`, and a `run()` function. The runtime completes all failable preflight work before calling `run()` and invokes it once. After `run()` returns hooks, registration commits without another failable step; a producer cannot start work that lacks a collectable task id. + +`outputLimitBytes` is producer-owned presentation policy, not a registry buffer. The registry validates and projects it unchanged into `TaskSnapshot`; generic control surfaces apply the cap to complete model-facing output after adding their own status or notice metadata. Omitting it preserves the existing surface behavior, so the runtime does not impose a hidden default on unrelated producer families. The producer hooks define three responsibilities: @@ -73,11 +75,11 @@ Stream reads share one task-scoped consuming cursor because the owning model is The system prompt tells the model to retain task ids, continue independent work instead of busy-polling or duplicating a running task, collect relevant tasks before its final answer, and kill work that no longer matters. Completion injects a logged `context/message` into the exact owner's session; it becomes durable context for the next request but does not wake an idle agent. -The runtime marks a terminal task `reported` when a read or wait delivers it, when a live waiter has claimed delivery at settlement, or when the model explicitly kills it. Reported tasks do not inject redundant completion notices. Listener failures are logged independently, do not stop later listeners, and are not awaited by waiters or teardown. +The runtime marks a terminal task `reported` when a read or wait delivers it, when a live waiter has claimed delivery at settlement, or when the model explicitly kills it. Reported tasks do not inject redundant completion notices. Listener failures are logged independently, do not stop later listeners, and are not awaited by waiters or teardown. When a snapshot carries `outputLimitBytes`, `dsh-tool-tasks` reserves space for status or notice suffixes, preserves UTF-8 boundaries, and reuses an existing producer truncation marker rather than duplicating it. ## Producer opt-in -Each producer owns whether its schema exposes `run_in_background` through defaulted config. `dsh-tool-bash` and each `dsh-tool-subagent` instance use `enableRunInBackground`, defaulting to true. A disabled instance omits the parameter and also rejects a forced background argument at execution because the generic argument validator permits undeclared keys. Schema omission advertises the capability; the execution check enforces it. +Each producer owns whether its schema exposes `run_in_background` through defaulted config. `dsh-tool-bash`, `dsh-tool-pty`, and each `dsh-tool-subagent` instance use `enableRunInBackground`, defaulting to true. A disabled instance omits the parameter and also rejects a forced background argument at execution because the generic argument validator permits undeclared keys. Schema omission advertises the capability; the execution check enforces it. `ctx.tasks` does not rewrite producer schemas. A bundle forwards configuration only for producers it owns. If a background call reaches `start()` without an attached surface, the runtime fence fails before execution. @@ -119,7 +121,7 @@ Authorization, not unguessability, is the access boundary, and ids do not derive ## Testing -Unit coverage pins preflight atomicity, per-kind ids, stream and final reads, wait timeout and abort races, cancellation, first-wins settlement, listener containment, notice suppression, owner isolation, stale owner instances, owner cleanup, service teardown, and the no-surface fence. Producer tests cover bash process mapping, subagent startup cancellation, terminal mapping, and disposal. Snapshot coverage pins the control-tool schemas and prompt guidance. +Unit coverage pins preflight atomicity, per-kind ids, output-limit validation and projection, complete UTF-8 result bounds, stream and final reads, wait timeout and abort races, cancellation, first-wins settlement, listener containment, notice suppression, owner isolation, stale owner instances, owner cleanup, service teardown, and the no-surface fence. Producer tests cover bash process mapping, subagent startup cancellation, terminal mapping, and disposal. Snapshot coverage pins the control-tool schemas and prompt guidance. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index f58ed600f0..627e46ace5 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-16-persistent-pty-sessions.md: 76354891f557974b953a1b0b805d94330c9f6e69 -2026-07-16-persistent-pty-sessions.zh.md: 86200d70c6eda815a440c44e8b55457b0424edb6 +2026-07-16-persistent-pty-sessions.md: 8d279fea2e606894e4e8856a706113c0ea173e98 +2026-07-16-persistent-pty-sessions.zh.md: 9e81cad7357bc37856dc74ed5654744d70981a06 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index 76354891f5..8d279fea2e 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -34,14 +34,14 @@ Idle detection is backend behavior, not a second public seam. A remote or contai 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. 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`](../../../../packages/tasks/tasks/README.md). +Agent-scope disposal closes registrations first, then awaits quiescent teardown of every owned PTY. 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`](../../../../packages/tasks/tasks/README.md). 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. `danger-full-access` is the existing explicit unconfined choice rather than a PTY-specific bypass. +- 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. @@ -58,19 +58,21 @@ The implementation uses only public `node-pty` capabilities: child PID, `data` a | `terminal_close` | Close one session and await process-tree quiescence | `{ killed }` | | `terminal_list` | List the caller's live sessions | owner-scoped session summaries | -`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. +The ACP 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`. -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. +`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. -With `run_in_background: true`, `dsh-tool-pty` registers the in-flight send on `ctx.tasks` and returns immediately with `taskId`. `task_output(wait: true)` waits, reads incremental output, and records the final result; `task_kill` forwards cancellation as `SIGINT` 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. +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 and caps the complete UTF-8 result after wait, session, pagination, truncation, and generic task-status wrappers; the renderer reserves suffix space and preserves code-point boundaries instead of treating the backend payload cap as the final model bound. -`terminal_read` pages backward from the newest retained line. The backend enforces both line and UTF-8 byte caps on retained scrollback and the complete returned value, so one oversized line cannot bypass the bound. `truncated` distinguishes retention loss from an ordinary viewport delta. +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 runs three bounded fallback tiers. 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. All timings are validated config fields: `pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, and `timeoutMs`. +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. All timings are validated config fields: `pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, and `timeoutMs`. On Linux, the inspector reads the shell's terminal foreground PGID from `/proc//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. @@ -78,7 +80,7 @@ On macOS there is no exact syscall tier. Output silence returns `inferred_idle` 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. -`node-pty` data notifications feed one streaming decoder and terminal parser. Parser carry state handles UTF-8 and terminal query sequences split across chunks. The implementation normalizes line-oriented output and detects alternate-screen entry, but it does not promise correct interaction with a full-screen application. +`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 @@ -88,9 +90,9 @@ Background sends use the existing task completion notice and `task_output` resul ### Process-tree teardown -The top-level `node-pty` child is the ownership anchor. On close, the backend stops callbacks, snapshots that PID and its transitive descendants by parent PID in children-first order, sends `SIGTERM`, closes the PTY, waits for quiescence, then sends `SIGKILL` to verified survivors after configurable `disposeGraceMs` and waits for them to leave the process table. Every captured PID includes process-start identity so reuse cannot redirect escalation. +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 descendant left the process table while the shell is still alive to reap it. Only then does it 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 tree member remains or returns a structured cleanup failure naming the survivors. 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. +Teardown reports root exit and survivor cleanup independently. It does not claim success merely because the shell exited; disposal resolves only after no captured tree member remains or returns a cleanup failure naming the survivors. A failed close is not cached forever: the registry and local session retain their closing fence but allow a later close to retry after the external survivor condition changes. 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 @@ -115,9 +117,12 @@ plugins: 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 add a global system-prompt recommendation or mount PTY in shipped defaults; dedicated ACP and headless snapshot overlays exercise the opt-in composition. +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 @@ -147,9 +152,9 @@ The package ships concise tool guidance explaining persistent state, owner isola ## Verification -- Per-file coverage pins owner fencing, concurrent reservations, lifecycle cleanup, readiness tiers, sanitizer carry state, UTF-8 bounds, task integration, schemas, and render intents. +- Per-file coverage pins owner fencing, concurrent reservations, 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, 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, signals, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts. +- Real `node-pty` tests exercise shell state, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT`, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts. - A Loader-driven `cordis.yml` test mounts the real three-package composition, while ACP and headless snapshots pin the six schemas, bounded results, error rendering, and terminal/generic cards through opt-in overlays. - 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. diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index 86200d70c6..9e81cad735 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -34,14 +34,14 @@ idle 检测属于后端行为,不是第二条公共 seam。远程或容器后 实现不提供插件加载期 auto-start 会话。`terminal_open` 只在 agent 工具调用期间创建会话,此时所有权和所属的事件溯源会话都已确定。未来的声明式启动功能必须通过尚未发布的 agent setup 组合,而不能创建全局共享终端。 -agent scope dispose 时先关闭注册,再等待全部所属 PTY 静默退出。后端或工具插件 reload 不会遗留会话:所有权持续存放在 `PtyService` 中,直到 agent 结束,与 [`ctx.tasks`](../../../../packages/tasks/tasks/README.md) 的服务持有记录模式一致。 +agent scope dispose 时先关闭注册,再等待全部所属 PTY 静默退出。后端或工具插件 reload 不会遗留会话:所有权持续存放在 `PtyService` 中,直到 agent 结束,与 [`ctx.tasks`](../../../../packages/tasks/tasks/README.md) 的服务持有记录模式一致。服务会先同步把会话预留给一次活跃发送,再返回该操作;后台发送同样会在 task id 对外可见前完成预留。第二次发送会以 `SEND_ACTIVE` 失败,因此输出与取消无法跨越操作所有权。 ### 安全与进程边界 注册的 `shell` 后端只约束终端如何启动,不约束启动后输入的命令。因此 `dsh-pty-local` 在 spawn 前应用两层保护: - 它使用与 `bash-local` 相同的凭证形态名称策略构建清洗后的子进程环境,移除环境中的 `*KEY*`、`*SECRET*`、`*TOKEN*` 和 harness 管理的变量,除非显式的可信映射提供这些值。 -- 它要求 `ctx.sandbox` 和共享的 `ctx.sandboxPolicy`。后端在 spawn 时,以部署默认值为底折叠 owner 的有效 session mode,并只包装一次 shell argv;该 mode 与 workspace root 在 PTY 的整个生命周期中充当进程边界。`danger-full-access` 是现有的显式无约束选择,不另设 PTY 私有 bypass。 +- 它要求 `ctx.sandbox` 和共享的 `ctx.sandboxPolicy`。后端在 spawn 时,以部署默认值为底折叠 owner 的有效 session mode,并只包装一次 shell argv;该 mode 与 workspace root 在 PTY 的整个生命周期中充当进程边界。只要 owner 有任何已打开的 PTY 或尚未发布的 spawn,任何会改变生效 `sandbox/mode` 的写入都会在提交前被拒绝,并提示先等待创建操作结算,再关闭这些会话;不会改变生效模式的写入仍然有效。这项进行中的预留从后端 setup 持续到发布完成,因此不存在降级后又出现权限更宽的终端这一竞态。`danger-full-access` 是现有的显式无约束选择,不另设 PTY 私有 bypass。 沙箱限制本地进程副作用,但不会让任意 shell 输入自动安全:网络调用和其他外部副作用仍由部署策略治理。工具描述会说明 PTY 会话比一次性工具更难审计,只应在确实需要持久状态或交互式 stdin 时使用。 @@ -58,19 +58,21 @@ agent scope dispose 时先关闭注册,再等待全部所属 PTY 静默退出 | `terminal_close` | 关闭一个会话并等待进程树静默退出 | `{ killed }` | | `terminal_list` | 列出调用方的活会话 | 按 owner 隔离的会话摘要 | -`terminal_send({ sessionId, text, submit?, run_in_background? })` 将 `text` 视为 UTF-8 字节,并由工具实现在解析阶段把 `submit` 默认成 `true`。`submit` 为 true 时先写入文本,再写入平台 Enter 序列;为 false 时只写文本,使控制字符和 REPL 片段无需隐藏的内容启发式即可发送。 +ACP 渲染契约精确且不携带位置信息。`terminal_send` 只为前台发送使用 terminal 调用卡片和结果卡片;后台形式使用通用 `execute` 卡片。`terminal_open`、`terminal_read`、`terminal_signal`、`terminal_close` 和 `terminal_list` 分别使用通用 `execute`、`read`、`execute`、`delete` 和 `read` 卡片。所有 PTY 工具都不发出 `locations`。 -前台发送返回有界的渲染增量和两个独立事实:`waitReason`(`stdin_read | inferred_idle | timeout | session_exit`)与 `sessionStatus`(`running`,或携带退出码或信号的 `exited`)。`session_exit` 指 PTY 顶层 shell 进程退出,不指由 shell 消费状态的任意前台命令。timeout 从不意味着进程已经退出。 +`terminal_send({ sessionId, text, submit?, run_in_background? })` 将 `text` 视为 UTF-8 字节,并由工具实现在解析阶段把 `submit` 默认成 `true`。`submit` 为 true 时先写入文本,再写入平台 Enter 序列;为 false 时只写文本,使控制字符和 REPL 片段无需隐藏的内容启发式即可发送。`enableRunInBackground` 默认为 true;设为 false 时,schema 中会移除 `run_in_background`,调用方即使强行把这个未声明参数传入执行流程,也会被拒绝。 -当 `run_in_background: true` 时,`dsh-tool-pty` 在 `ctx.tasks` 上注册进行中的发送,并立即返回 `taskId`。`task_output(wait: true)` 负责等待、读取增量输出并记录最终结果;`task_kill` 将取消转发为 `SIGINT`,只有 PTY 后端拥有的 teardown 路径可以升级信号。若 task 对外接口不存在,后台模式必须在写入输入前失败。设计不新增 PTY 专用的 `sleep` 工具或通用唤醒 seam。 +前台发送返回有界的渲染增量和两个独立事实:`waitReason`(`stdin_read | inferred_idle | timeout | session_exit`)与 `sessionStatus`(`running`,或携带退出码或信号的 `exited`)。`session_exit` 指 PTY 顶层 shell 进程退出,不指由 shell 消费状态的任意前台命令。timeout 从不意味着进程已经退出。`dsh-tool-pty.maxResultBytes` 默认为 262144;完整 UTF-8 结果在加入等待与会话状态、分页与截断元数据以及通用 task 状态包装后,仍受该值限制。渲染器会为后缀预留空间并保持代码点边界,而不会把后端载荷上限当作面向模型结果的最终上限。 -`terminal_read` 从最新保留行向后分页。后端同时对保留的 scrollback 和完整返回值执行行数与 UTF-8 字节上限,因此单个超长行无法绕过限制。`truncated` 用于区分保留数据丢失与普通 viewport 增量。 +当 `run_in_background: true` 时,`dsh-tool-pty` 在 `ctx.tasks` 上注册进行中的发送,并立即返回 `taskId`。生产方把 `maxResultBytes` 写入 task 快照,使 `task_output`、kill 返回的终态状态和完成通知在加上通用元数据后,仍对完整结果执行同一上限。`task_output(wait: true)` 负责等待、读取增量输出并记录最终结果;`task_kill` 会解析当前前台 PGID 并发送真正的 `SIGINT`,即使应用已禁用终端 `ISIG` 也同样如此,且后续升级仍只通过 PTY 后端拥有的 teardown 路径进行。若 task 对外接口不存在,后台模式必须在写入输入前失败。设计不新增 PTY 专用的 `sleep` 工具或通用唤醒 seam。 + +`terminal_read` 从最新保留行向后分页。后端同时对保留的 scrollback 和返回页载荷执行行数与 UTF-8 字节上限,因此单个超长行无法绕过后端上限;工具随后再限制包含分页与截断元数据的完整渲染页。`truncated` 用于区分保留数据丢失与普通 viewport 增量。 `terminal_signal` 接受闭合集 `SIGINT | SIGTERM | SIGKILL | SIGTSTP | SIGHUP`。后端在执行时解析终端前台进程组。当目标组是顶层 shell 时拒绝 `SIGKILL`,并指引调用方使用 `terminal_close`;进程组解析失败时操作直接失败,而不是向猜测的 PID 发送信号。 ### 本地就绪检测 -本地后端先识别受控 bash 启动时发出的私有 OSC prompt marker,再执行 3 个有界 fallback 层级。marker 在输出到达模型前被移除,使两个平台上的普通 shell 命令都无需固定等待静默阈值。尚未发布的 startup 不会把零输出静默视为就绪;timeout 会拒绝 spawn。所有时间参数都是经校验的配置字段:`pollIntervalMs`、`exactProbeAfterMs`、`idleSilenceMs` 和 `timeoutMs`。 +本地后端先识别受控 bash 启动时发出的私有 OSC prompt marker,并且只有在该 marker 后出现可打印的 prompt 文本时才据此声明 prompt 就绪;除此之外,它还运行 3 个有界 fallback 层级。在 data callback 之间保留这项状态,可以适配 macOS 分开交付 OSC marker 与 `PS1` 的情况;单独的 marker 不会发布空 MOTD。marker 在输出到达模型前被移除,使两个平台上的普通 shell 命令都无需固定等待静默阈值。尚未发布的 startup 不会把零输出静默视为就绪;timeout 会拒绝 spawn。所有时间参数都是经校验的配置字段:`pollIntervalMs`、`exactProbeAfterMs`、`idleSilenceMs` 和 `timeoutMs`。 在 Linux 上,检查器从 `/proc//stat` 读取 shell 的终端前台 PGID,枚举该进程组中的每个进程与线程,并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6` 或 `poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。无法读取的进程内存和未识别的 syscall 都是 miss,绝不作为正向猜测。架构表只包含对应 Linux UAPI 定义的 syscall number;不支持的架构跳过 Tier 1。 @@ -78,7 +80,7 @@ macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 ` Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 sleep 或网络阻塞的命令可能看似 ready。Tier 3 在 `timeoutMs` 后返回 `timeout`,避免前台工具调用无限占住 agent。结果保留这些区别;调用方可以通过 `ctx.tasks` 等待、向前台组发信号,或从另一个会话排查。 -`node-pty` data 通知进入同一个流式 decoder 和终端 parser。parser 的 carry 状态处理跨 chunk 的 UTF-8 与终端查询序列。当前实现只规范化行式输出并检测 alternate-screen 进入,不承诺正确操作全屏应用。 +`node-pty` data 通知进入同一个终端 parser。parser 的 carry state 会处理跨 callback 的控制序列和位于 callback 末尾的回车;因此,即使 CRLF 被拆开,也只会生成一个换行,而不会产生改变分页的空行。实现会规范化行式输出,但不承诺正确操作全屏应用。 ### 模型可见输出与持久性 @@ -88,9 +90,9 @@ Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 ### 进程树 teardown -顶层 `node-pty` 子进程是所有权锚点。关闭时,后端先停止 callback,再按父 PID 以子进程优先顺序捕获该 PID 及其传递子进程、发送 `SIGTERM`、关闭 PTY 并等待静默,然后在可配置的 `disposeGraceMs` 后向已验证的存活者发送 `SIGKILL`,并等待它们离开进程表。每个捕获的 PID 都包含进程启动身份,避免 PID 复用把升级信号发给无关进程。 +顶层 `node-pty` 子进程是所有权锚点。关闭时,后端先停止 callback,再按父 PID 以子进程优先顺序捕获其传递子进程、发送 `SIGTERM` 并等待,然后重新扫描关停期间 fork 出的子进程,向剩余子孙进程树发送 `SIGKILL`,并在 shell 仍存活、可以回收这些进程时,验证每个子孙进程都已离开进程表。完成这些步骤后,后端才用 shell 自身的 TERM、宽限等待、KILL 序列停止 shell。每个捕获的 PID 都包含进程启动身份,避免 PID 复用把升级信号发给无关进程。 -teardown 独立报告根进程退出与存活进程清理。它不会只因 shell 退出就声称成功;dispose 只有在已捕获的进程树成员全部消失后才完成,否则返回结构化清理失败并列出存活者。即使某个 close 失败,服务 dispose 仍会清空其后端、预留与 owner detacher 注册表。所有权绝不会扩大到根 PID 所属 POSIX 会话的全部成员。 +teardown 独立报告根进程退出与存活进程清理。它不会只因 shell 退出就声称成功;dispose 只有在已捕获的进程树成员全部消失后才完成,否则返回清理失败并列出存活者。失败的 close 不会永久缓存:注册表与本地会话会保留关闭围栏,但在外部存活进程状态改变后允许后续 close 重试。即使某个 close 失败,服务 dispose 仍会清空其后端、预留与 owner detacher 注册表。所有权绝不会扩大到根 PID 所属 POSIX 会话的全部成员。 ### 组合与推行 @@ -115,9 +117,12 @@ plugins: timeoutMs: 30000 disposeGraceMs: 3000 '@deepseek-ai/dsh-tool-pty': + config: + enableRunInBackground: true + maxResultBytes: 262144 ``` -包提供简洁的工具指引,说明持久状态、owner 隔离、不确定的 idle 结果、清理,以及无需交互时优先使用现有一次性工具。它不增加全局 system prompt 推荐,也不在已发布的默认配置中挂载 PTY;专用 ACP 与 headless 快照 overlay 覆盖 opt-in 组合。 +包提供简洁的工具指引,说明持久状态、owner 隔离、不确定的 idle 结果、清理,以及无需交互时优先使用现有一次性工具。已发布的基础示例不挂载 PTY:PTY 仅通过专用组合 opt-in,ACP 与 headless 快照 overlay 覆盖该组合。`dsh-tool-pty` 实例一旦启用,6 个工具和 `run_in_background` 就会默认启用;部署可通过配置仅禁用后台参数。 ### 推迟的工作 @@ -147,9 +152,9 @@ plugins: ## 验证 -- 每文件覆盖率固定 owner 隔离、并发预留、生命周期清理、就绪层级、sanitizer carry state、UTF-8 上限、task 集成、schema 和 render intent。 +- 每文件覆盖率固定 owner 隔离、并发预留、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。 - Linux 进程 fixture 覆盖非 leader 与非主线程的 stdin 等待、不可读进程状态、受支持的 syscall 表、不支持的架构和误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。 -- 真实 `node-pty` 测试在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、信号、忽略 `SIGTERM` 的子进程,以及 dispose 返回后立即静默。 +- 真实 `node-pty` 测试在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、raw mode 下的前台 `SIGINT`、忽略 `SIGTERM` 的子进程,以及 dispose 返回后立即静默。 - Loader 驱动的 `cordis.yml` 测试挂载真实三包组合;ACP 与 headless 快照通过 opt-in overlay 固定 6 个 schema、有界结果、错误渲染和 terminal/generic card。 - 包契约、架构图、核心数据结构、生成目录和 website API 描述同一个已发布接口。 - 仓库 CI 等价序列负责类型、lint、覆盖率、快照、文档、构建、hygiene、demo 和 built-entry 验证。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 76afd7d68a..34d69c5227 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -802,7 +802,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:57`](../packages/plan/plan-mode/s ## `@deepseek-ai/dsh-pty-local` -Requires: `pty` · `sandbox` · `sandboxPolicy` +Requires: `agents` · `pty` · `sandbox` · `sandboxPolicy` ```ts config-catalog /** Public plugin configuration. */ @@ -1339,6 +1339,22 @@ export interface Config { Source: [`packages/lsp/tool-lsp/src/index.ts:58`](../packages/lsp/tool-lsp/src/index.ts) +## `@deepseek-ai/dsh-tool-pty` + +Requires: `pty` · `tools` · `systemPrompt` + +```ts config-catalog +/** Model-facing terminal tool configuration. */ +export interface Config { + /** Expose `run_in_background` and accept background sends (default true). */ + enableRunInBackground?: boolean + /** Maximum UTF-8 bytes in one complete terminal or task-output result. */ + maxResultBytes?: number +} +``` + +Source: [`packages/pty/tool-pty/src/index.ts:33`](../packages/pty/tool-pty/src/index.ts) + ## `@deepseek-ai/dsh-tool-ralph` Requires: `tools` · `workflows` · `subagents` · `systemPrompt` @@ -1443,7 +1459,7 @@ export interface Config { } ``` -Source: [`packages/tasks/tool-tasks/src/index.ts:21`](../packages/tasks/tool-tasks/src/index.ts) +Source: [`packages/tasks/tool-tasks/src/index.ts:22`](../packages/tasks/tool-tasks/src/index.ts) ## `@deepseek-ai/dsh-tool-web` @@ -1840,7 +1856,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-tasks` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts)) - `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts)) -- `@deepseek-ai/dsh-tool-pty` — requires `pty` · `tools` · `systemPrompt` ([`packages/pty/tool-pty/src/index.ts`](../packages/pty/tool-pty/src/index.ts)) - `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts)) - `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index d7ca6cb602..15d32e9089 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -787,6 +787,13 @@ listBackends(): string[] */ async spawn(owner: Agent, request: PtySpawnRequest, signal?: AbortSignal): Promise +/** + * Test whether an exact owner has a published session or unpublished spawn. + * @param owner - exact live owner to inspect. + * @returns true across the entire spawn-to-close interval, with no publication gap. + */ +hasOwnerActivity(owner: Agent): boolean + /** * Start one exclusive interactive send. * @param owner - exact session owner. @@ -833,7 +840,7 @@ list(owner: Agent): PtySessionSnapshot[] Types: [Agent](../core-data-structures/core.md) · [PtyBackend](../core-data-structures/pty.md) · [PtyReadRequest](../core-data-structures/pty.md) · [PtyReadResult](../core-data-structures/pty.md) · [PtySendOperation](../core-data-structures/pty.md) · [PtySendRequest](../core-data-structures/pty.md) · [PtySessionId](../core-data-structures/pty.md) · [PtySessionSnapshot](../core-data-structures/pty.md) · [PtySignal](../core-data-structures/pty.md) · [PtySignalResult](../core-data-structures/pty.md) · [PtySpawnRequest](../core-data-structures/pty.md) · [PtySpawnResult](../core-data-structures/pty.md) -Source: [`packages/pty/pty/src/index.ts:95`](../../packages/pty/pty/src/index.ts) +Source: [`packages/pty/pty/src/index.ts:91`](../../packages/pty/pty/src/index.ts) ## `ctx.sandbox` — `SandboxProvider` (abstract seam) @@ -1404,7 +1411,7 @@ attachSurface(name: string): () => void Types: [Agent](../core-data-structures/core.md) · [TaskDoneListener](../core-data-structures/tasks.md) · [TaskId](../core-data-structures/tasks.md) · [TaskRead](../core-data-structures/tasks.md) · [TaskSnapshot](../core-data-structures/tasks.md) · [TaskStart](../core-data-structures/tasks.md) -Source: [`packages/tasks/tasks/src/index.ts:76`](../../packages/tasks/tasks/src/index.ts) +Source: [`packages/tasks/tasks/src/index.ts:77`](../../packages/tasks/tasks/src/index.ts) ## `ctx.tokenMeter` — `TokenMeterService` diff --git a/docs/core-data-structures/tasks.md b/docs/core-data-structures/tasks.md index 491f380166..2c7555b84d 100644 --- a/docs/core-data-structures/tasks.md +++ b/docs/core-data-structures/tasks.md @@ -34,6 +34,11 @@ interface TaskStart { kind: TaskKind /** One-line model-facing label (the command; the delegation description). */ label: string + /** + * Optional UTF-8 byte cap for each complete model-facing completion notice or + * output read, including control-surface status metadata. + */ + outputLimitBytes?: number /** * Owning live agent. Access is fenced by its session id, and agent disposal * cancels and awaits the task. The instance must be the one currently @@ -104,6 +109,8 @@ interface TaskSnapshot { kind: TaskKind /** The producer-supplied one-line label. */ label: string + /** Producer-owned cap for complete model-facing notices and output reads. */ + outputLimitBytes?: number /** * Owner session id used for authorization and correlation; absent for * unowned tasks. Completion listeners receive the exact {@link Agent} diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d7f4123b91..100362892f 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -57,7 +57,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | -| `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | +| `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `webserver` | | `internal/status` | - | [`agent`](../packages/core/agent) | | `slots/changed` | `runtime` (`emit`) | - | diff --git a/docs/module-graph.md b/docs/module-graph.md index ca4848477f..86b2ed9fbe 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -434,10 +434,12 @@ flowchart TD pkg_permission --> pkg_sandbox_policy pkg_permission --> pkg_session pkg_permission --> pkg_user_approval + pkg_pty_local --> pkg_agent pkg_pty_local --> pkg_invariants pkg_pty_local --> pkg_pty pkg_pty_local --> pkg_sandbox pkg_pty_local --> pkg_sandbox_policy + pkg_pty_local --> pkg_session pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_invariants pkg_agent_loop --> pkg_llm @@ -563,11 +565,13 @@ flowchart TD pkg_tool_pty --> pkg_invariants pkg_tool_pty --> pkg_llm pkg_tool_pty --> pkg_pty + pkg_tool_pty --> pkg_retention pkg_tool_pty --> pkg_system_prompt pkg_tool_pty --> pkg_tasks pkg_tool_pty --> pkg_tools pkg_tool_tasks --> pkg_agent pkg_tool_tasks --> pkg_invariants + pkg_tool_tasks --> pkg_retention pkg_tool_tasks --> pkg_system_prompt pkg_tool_tasks --> pkg_tasks pkg_tool_tasks --> pkg_tools @@ -808,7 +812,7 @@ flowchart TD | [`session-title-all-messages-llm`](../packages/session-title/session-title-all-messages-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) | | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | -| [`pty-local`](../packages/pty/pty-local) | `pty` | [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | @@ -829,8 +833,8 @@ flowchart TD | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | -| [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | -| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | diff --git a/examples/acp-agent/pty.cordis.snapshot.yml b/examples/acp-agent/pty.cordis.snapshot.yml index 9ef3ff6418..07e4605375 100644 --- a/examples/acp-agent/pty.cordis.snapshot.yml +++ b/examples/acp-agent/pty.cordis.snapshot.yml @@ -14,6 +14,8 @@ name: './pty-snapshot-backend.mjs' - id: tool-pty name: '@deepseek-ai/dsh-tool-pty' + config: + maxResultBytes: 64 - id: llm-replay name: '@deepseek-ai/dsh-llm-replay' config: diff --git a/examples/acp-agent/tests/snapshots/pty-tools/session.jsonl b/examples/acp-agent/tests/snapshots/pty-tools/session.jsonl index f3157d811b..5694ae4343 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/session.jsonl +++ b/examples/acp-agent/tests/snapshots/pty-tools/session.jsonl @@ -21,7 +21,7 @@ {"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-pro"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} {"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}} -{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"pty-send","content":[{"type":"text","text":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[wait: stdin_read]\n[session: running]"}],"isError":false,"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[21],"surfaceOp":"append"} +{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"pty-send","content":[{"type":"text","text":"K\ndsh> \n[wait: stdin_read]\n[session: running]\n[output truncated]"}],"isError":false,"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[21],"surfaceOp":"append"} {"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}} {"type":"step/start","seq":24,"time":0,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/pty-tools/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/pty-tools/stdout.expected.jsonl index 94cb1f180e..6ecaac24c6 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/pty-tools/stdout.expected.jsonl @@ -5,7 +5,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"pty-spawn","title":"Open terminal main","kind":"execute","status":"in_progress"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"pty-spawn","status":"completed","content":[{"type":"content","content":{"type":"text","text":"started terminal session pty-1 (main) [type: shell]\ndsh> "}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"pty-send","title":"printf 'PTY_OK\\n'","kind":"execute","status":"in_progress","rawInput":"printf 'PTY_OK\\n'","content":[{"type":"content","content":{"type":"text","text":"Terminal pty-1"}},{"type":"terminal","terminalId":"pty-send"}],"_meta":{"terminal_info":{"terminal_id":"pty-send","cwd":"{{cwd}}"}}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"pty-send","status":"completed","_meta":{"terminal_output":{"terminal_id":"pty-send","data":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[wait: stdin_read]\n[session: running]"}}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"pty-send","status":"completed","_meta":{"terminal_output":{"terminal_id":"pty-send","data":"K\ndsh> \n[wait: stdin_read]\n[session: running]\n[output truncated]"}}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"pty-read","title":"Read terminal pty-1","kind":"read","status":"in_progress","rawInput":{"sessionId":"pty-1","offset":0,"count":20}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"pty-read","status":"completed","content":[{"type":"content","content":{"type":"text","text":"dsh> printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[lines: 0-3 of 3]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"pty-signal","title":"Signal terminal pty-missing","kind":"execute","status":"in_progress","rawInput":{"sessionId":"pty-missing","signal":"SIGINT"}}}} diff --git a/examples/headless-agent/pty.cordis.snapshot.yml b/examples/headless-agent/pty.cordis.snapshot.yml index f7fcea389a..0de292a8f9 100644 --- a/examples/headless-agent/pty.cordis.snapshot.yml +++ b/examples/headless-agent/pty.cordis.snapshot.yml @@ -14,5 +14,7 @@ name: '../acp-agent/pty-snapshot-backend.mjs' - id: tool-pty name: '@deepseek-ai/dsh-tool-pty' + config: + maxResultBytes: 64 - id: llm-replay name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl index bad2f0353d..d91782e1e2 100644 --- a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl +++ b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl @@ -21,7 +21,7 @@ {"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} {"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}} -{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"pty-send","content":[{"type":"text","text":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[wait: stdin_read]\n[session: running]"}],"isError":false,"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[21],"surfaceOp":"append"} +{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"pty-send","content":[{"type":"text","text":"K\ndsh> \n[wait: stdin_read]\n[session: running]\n[output truncated]"}],"isError":false,"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[21],"surfaceOp":"append"} {"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}} {"type":"step/start","seq":24,"time":0,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl index b4db490cb2..ab37662dde 100644 --- a/examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl @@ -20,7 +20,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"pty-send","content":[{"type":"text","text":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[wait: stdin_read]\n[session: running]"}],"isError":false,"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[21],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"pty-send","content":[{"type":"text","text":"K\ndsh> \n[wait: stdin_read]\n[session: running]\n[output truncated]"}],"isError":false,"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[21],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":24,"time":0,"data":{"turn":1,"step":3}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 7a8e8abdf3..a454623486 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -402,6 +402,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async spawn(owner: Agent, request: PtySpawnRequest, signal?: AbortSignal): Promise', jsDoc: '/**\n * Create and publish one owner-scoped session after backend setup succeeds.\n * @param owner - exact registered Agent that owns access and cleanup.\n * @param request - backend type plus optional owner-local name and cwd.\n * @param signal - cancellation of unpublished setup.\n * @returns published identity, metadata, status, and MOTD.\n */', }, + { + signature: 'hasOwnerActivity(owner: Agent): boolean', + jsDoc: '/**\n * Test whether an exact owner has a published session or unpublished spawn.\n * @param owner - exact live owner to inspect.\n * @returns true across the entire spawn-to-close interval, with no publication gap.\n */', + }, { signature: 'startSend(owner: Agent, id: PtySessionId, request: PtySendRequest): PtySendOperation', jsDoc: '/**\n * Start one exclusive interactive send.\n * @param owner - exact session owner.\n * @param id - target PTY identity.\n * @param request - explicit text, submit behavior, and cancellation.\n * @returns live operation handle for foreground await or task registration.\n */', @@ -1854,11 +1858,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TaskSnapshot', - declaration: 'export interface TaskSnapshot {\n id: TaskId;\n kind: TaskKind;\n label: string;\n ownerSession?: SessionId;\n status: TaskStatus;\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n reported: boolean;\n}', + declaration: 'export interface TaskSnapshot {\n id: TaskId;\n kind: TaskKind;\n label: string;\n outputLimitBytes?: number;\n ownerSession?: SessionId;\n status: TaskStatus;\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n reported: boolean;\n}', }, { name: 'TaskStart', - declaration: 'export interface TaskStart {\n kind: TaskKind;\n label: string;\n owner?: Agent;\n run(): TaskHooks;\n}', + declaration: 'export interface TaskStart {\n kind: TaskKind;\n label: string;\n outputLimitBytes?: number;\n owner?: Agent;\n run(): TaskHooks;\n}', }, { name: 'TaskStatus', diff --git a/packages/pty/pty-local/README.md b/packages/pty/pty-local/README.md index 00cd1b8b00..32b2721074 100644 --- a/packages/pty/pty-local/README.md +++ b/packages/pty/pty-local/README.md @@ -4,9 +4,11 @@ Local `node-pty` backend for `ctx.pty`. It starts an interactive shell under the ## Plugin (`pty-local`) -The plugin injects `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The current session-level sandbox override is resolved at spawn and remains fixed for the PTY lifetime. +The plugin injects `agents`, `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The effective session mode is resolved at spawn. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade. -Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit. +Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline. + +Send cancellation resolves the current foreground process group and delivers a real `SIGINT`; it never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close sends `SIGTERM` to descendants, waits, rescans and sends `SIGKILL` to the remaining tree, verifies that descendants left the process table while the shell can still reap them, and only then stops the shell. A survivor failure does not cache a permanently rejected close; a later close retries the teardown. ## Model Experience diff --git a/packages/pty/pty-local/package.json b/packages/pty/pty-local/package.json index 339c3916ad..fb26d845e5 100644 --- a/packages/pty/pty-local/package.json +++ b/packages/pty/pty-local/package.json @@ -30,10 +30,12 @@ }, "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-pty": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { diff --git a/packages/pty/pty-local/src/index.ts b/packages/pty/pty-local/src/index.ts index 0b99d1b058..706a1bba7a 100644 --- a/packages/pty/pty-local/src/index.ts +++ b/packages/pty/pty-local/src/index.ts @@ -7,6 +7,7 @@ import { Context } from 'cordis' import * as nodePty from 'node-pty' import type { IPtyForkOptions } from 'node-pty' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' @@ -20,8 +21,8 @@ export type { Config as PtyLocalConfig } from './config.ts' /** Cordis plugin name. */ export const name = 'pty-local' -/** Required services: registry plus the one shared confinement policy. */ -export const inject = ['pty', 'sandbox', 'sandboxPolicy'] +/** Required services: owner/PTY registries plus the one shared confinement policy. */ +export const inject = ['agents', 'pty', 'sandbox', 'sandboxPolicy'] const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i @@ -73,7 +74,7 @@ export class LocalPtyBackend implements PtyBackend { } async spawn(spec: PtyBackendSpawnSpec): Promise { - if (spec.signal?.aborted === true) throw new Error('PTY spawn aborted') + spec.signal?.throwIfAborted() const argv = spawnArgv(this.ctx, this.config, spec) const file = argv[0] if (file === undefined) throw new Error('pty-local: sandbox returned empty argv') @@ -105,4 +106,17 @@ export function apply(ctx: Context, config: Config): void { validateConfig(config) const inspector = createProcessInspector() ctx.pty.registerBackend(new LocalPtyBackend(ctx, config, inspector)) + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const [session, event] = args as [Session, SessionEvent] + if (event.type !== 'sandbox/mode') return + const currentMode = effectiveSandboxMode(session.events) ?? ctx.sandboxPolicy.defaultMode + if (event.data.mode === currentMode) return + const owner = ctx.agents.get(session.id) + if (owner === undefined) return + if (!ctx.pty.hasOwnerActivity(owner)) return + throw new Error( + `cannot change sandbox mode from "${currentMode}" to "${event.data.mode}" while persistent terminal sessions are open or being created; wait for creation to settle and close them first`, + ) + }, { global: true }) } diff --git a/packages/pty/pty-local/src/sanitize.ts b/packages/pty/pty-local/src/sanitize.ts index 6e109f6115..cc22c29c01 100644 --- a/packages/pty/pty-local/src/sanitize.ts +++ b/packages/pty/pty-local/src/sanitize.ts @@ -9,6 +9,8 @@ export const PROMPT_MARKER_PREFIX = '133;D;' export interface SanitizedChunk { text: string prompt: boolean + /** Present when printable text followed the latest owned prompt marker. */ + promptText?: true } /** @@ -20,6 +22,8 @@ export class TerminalSanitizer { private pending = '' private discardMode: 'osc' | 'csi' | undefined private discardOscEscape = false + private trailingCarriageReturn = false + private awaitingPromptText = false constructor(private readonly maxPendingBytes: number) {} @@ -32,15 +36,24 @@ export class TerminalSanitizer { this.pending += this.discardPrefix(chunk) let text = '' let prompt = false + let promptText = false let index = 0 + const appendText = (value: string): boolean => { + text += value + if (this.awaitingPromptText && value.replace(/[\r\n\x07]/g, '').length > 0) { + this.awaitingPromptText = false + return true + } + return false + } while (index < this.pending.length) { const escape = this.pending.indexOf('\x1b', index) if (escape < 0) { - text += this.pending.slice(index) + promptText = appendText(this.pending.slice(index)) || promptText index = this.pending.length break } - text += this.pending.slice(index, escape) + promptText = appendText(this.pending.slice(index, escape)) || promptText if (escape + 1 >= this.pending.length) { index = escape break @@ -59,7 +72,11 @@ export class TerminalSanitizer { } const terminatorBytes = this.pending[end - 1] === '\x07' ? 1 : 2 const content = this.pending.slice(escape + 2, end - terminatorBytes) - if (content.startsWith(PROMPT_MARKER_PREFIX)) prompt = true + if (content.startsWith(PROMPT_MARKER_PREFIX)) { + prompt = true + promptText = false + this.awaitingPromptText = true + } index = end continue } @@ -82,7 +99,7 @@ export class TerminalSanitizer { } this.pending = this.pending.slice(index) this.enforcePendingBound() - return { text: normalizeTerminalText(text), prompt } + return { text: this.normalizeText(text), prompt, ...promptText ? { promptText: true } : {} } } /** @@ -94,7 +111,21 @@ export class TerminalSanitizer { this.pending = '' this.discardMode = undefined this.discardOscEscape = false - return normalizeTerminalText(text) + this.awaitingPromptText = false + const normalized = this.normalizeText(text) + if (!this.trailingCarriageReturn) return normalized + this.trailingCarriageReturn = false + return `${normalized}\n` + } + + private normalizeText(text: string): string { + let complete = this.trailingCarriageReturn ? `\r${text}` : text + this.trailingCarriageReturn = false + if (complete.endsWith('\r')) { + complete = complete.slice(0, -1) + this.trailingCarriageReturn = true + } + return normalizeTerminalText(complete) } private enforcePendingBound(): void { diff --git a/packages/pty/pty-local/src/session.ts b/packages/pty/pty-local/src/session.ts index 52863db674..a1e638e0d2 100644 --- a/packages/pty/pty-local/src/session.ts +++ b/packages/pty/pty-local/src/session.ts @@ -17,7 +17,7 @@ import type { PtyWaitReason, } from '@deepseek-ai/dsh-pty' import type { ResolvedConfig } from './config.ts' -import type { ProcessInspector } from './process-inspector.ts' +import type { ProcessIdentity, ProcessInspector } from './process-inspector.ts' import { TerminalSanitizer } from './sanitize.ts' function delay(ms: number): Promise { @@ -148,9 +148,11 @@ export class LocalPtySession implements PtyBackendSession { private activeTimer: NodeJS.Timeout | undefined private activeAbort: (() => void) | undefined private promptSeen = false + private promptTextSeen = false private shellPgid: number | undefined private initializing = false private lastOutputAt = Date.now() + private closing = false private closePromise: Promise | undefined constructor( @@ -190,21 +192,20 @@ export class LocalPtySession implements PtyBackendSession { } startSend(request: PtySendRequest): PtySendOperation { - if (this.closePromise !== undefined) throw new Error('PTY session is closing') + if (this.closing) throw new Error('PTY session is closing') if (this.statusValue.kind === 'exited') throw new Error('PTY session has exited') if (this.active !== undefined) throw new Error('PTY session already has an active send') if (request.signal?.aborted === true) throw new Error('PTY send aborted before write') - const operation = new LocalSendOperation(this.config.maxReadBytes, Date.now(), () => { - try { - this.terminal.write('\x03') - } catch (error: unknown) { - operation.fail(error) - } - }) + const operation = new LocalSendOperation( + this.config.maxReadBytes, + Date.now(), + () => { this.interrupt(operation) }, + ) this.active = operation this.lastOutputAt = Date.now() this.promptSeen = false + this.promptTextSeen = false if (request.signal !== undefined) { const onAbort = (): void => { operation.cancel() } @@ -267,8 +268,15 @@ export class LocalPtySession implements PtyBackendSession { } close(reason: string): Promise { - this.closePromise ??= this.closeOnce(reason) - return this.closePromise + this.closing = true + if (this.closePromise !== undefined) return this.closePromise + const closing = this.closeOnce(reason).catch((error: unknown) => { + this.closePromise = undefined + this.failActive(error) + throw error + }) + this.closePromise = closing + return closing } private onData(data: string): void { @@ -279,8 +287,11 @@ export class LocalPtySession implements PtyBackendSession { if (this.shellPgid === undefined) this.shellPgid = foregroundPgid if (foregroundPgid !== undefined && foregroundPgid === this.shellPgid) { this.promptSeen = true + this.promptTextSeen = sanitized.promptText === true this.lastOutputAt = Date.now() } + } else if (this.promptSeen && sanitized.promptText === true) { + this.promptTextSeen = true } } @@ -297,7 +308,7 @@ export class LocalPtySession implements PtyBackendSession { this.settleActive('session_exit') return } - if (this.promptSeen && Date.now() - this.lastOutputAt >= this.config.pollIntervalMs) { + if (this.promptSeen && this.promptTextSeen && Date.now() - this.lastOutputAt >= this.config.pollIntervalMs) { this.settleActive('stdin_read') return } @@ -337,58 +348,98 @@ export class LocalPtySession implements PtyBackendSession { this.active = undefined } + private failActive(error: unknown): void { + const operation = this.active + if (operation === undefined) return + this.clearActive() + operation.fail(error) + } + + private interrupt(operation: LocalSendOperation): void { + if (this.active !== operation) return + try { + const pgid = this.inspector.foregroundPgid(this.pid) + if (pgid === undefined) throw new Error(`cannot resolve foreground process group for PTY ${this.pid}`) + this.inspector.signalGroup(pgid, 'SIGINT') + } catch (error: unknown) { + this.failActive(error) + } + } + + private survivors(members: ProcessIdentity[]): ProcessIdentity[] { + return members.filter(member => this.inspector.isAlive(member)) + } + + private descendants(): ProcessIdentity[] { + return this.inspector.processTree(this.pid).filter(member => member.pid !== this.pid) + } + + private async waitForExit(members: ProcessIdentity[]): Promise { + const deadline = Date.now() + this.config.disposeGraceMs + let survivors = this.survivors(members) + while (survivors.length > 0 && Date.now() < deadline) { + await delay(Math.min(25, Math.max(1, deadline - Date.now()))) + survivors = this.survivors(members) + } + return survivors + } + + private signalMembers(members: ProcessIdentity[], signal: 'SIGTERM' | 'SIGKILL'): void { + for (const member of members) { + try { + this.inspector.signalProcess(member, signal) + } catch (_alreadyExitedDuringSignal) { + // Identity is rechecked by the inspector; a same-tick exit is success. + } + } + } + + private async stopDescendants(): Promise { + let members = this.descendants() + this.signalMembers(members, 'SIGTERM') + await this.waitForExit(members) + // A TERM-handling descendant may have forked while winding down. Rescan + // while the shell can still reap every member, then kill the fresh tree. + members = this.descendants() + this.signalMembers(members, 'SIGKILL') + await this.waitForExit(members) + return this.descendants().filter(member => this.inspector.isAlive(member)) + } + + private async stopShell(): Promise { + try { + this.terminal.kill('SIGTERM') + } catch (_topLevelAlreadyExitedDuringTerm) { + // The exit notification remains authoritative. + } + if (this.statusValue.kind === 'running') { + await Promise.race([this.exitPromise.promise, delay(this.config.disposeGraceMs)]) + } + if (this.statusValue.kind === 'running') { + try { + this.terminal.kill('SIGKILL') + } catch (_topLevelAlreadyExitedDuringKill) { + // The exit notification remains authoritative. + } + await Promise.race([this.exitPromise.promise, delay(this.config.disposeGraceMs)]) + } + if (this.statusValue.kind === 'running') { + throw new Error(`PTY cleanup failed; surviving pids: ${this.pid}`) + } + } + private async closeOnce(reason: string): Promise { this.dataDisposable.dispose() // Stop readiness polling but retain the active operation: teardown settles // it as session_exit below, so an in-flight send is never mis-settled as // stdin_read/inferred_idle/timeout during the grace period. this.stopPolling() - const members = this.inspector.processTree(this.pid) - for (const member of members) { - try { - this.inspector.signalProcess(member, 'SIGTERM') - } catch (_alreadyExitedDuringTerm) { - // Identity is rechecked by the inspector; a same-tick exit is success. - } - } - try { - this.terminal.kill('SIGTERM') - } catch (_topLevelAlreadyExited) { - // onExit or identity checks below remain authoritative. - } - - const deadline = Date.now() + this.config.disposeGraceMs - let survivors = members.filter(member => this.inspector.isAlive(member)) - while (survivors.length > 0 && Date.now() < deadline) { - await delay(Math.min(25, this.config.disposeGraceMs)) - survivors = members.filter(member => this.inspector.isAlive(member)) - } - for (const survivor of survivors) { - try { - this.inspector.signalProcess(survivor, 'SIGKILL') - } catch (_alreadyExitedDuringKill) { - // Final identity check below decides success. - } - } - try { - this.terminal.kill('SIGKILL') - } catch (_topLevelAlreadyKilled) { - // The root may already have delivered onExit. - } - - const killDeadline = Date.now() + this.config.disposeGraceMs - survivors = members.filter(member => this.inspector.isAlive(member)) - while (survivors.length > 0 && Date.now() < killDeadline) { - await delay(Math.min(25, this.config.disposeGraceMs)) - survivors = members.filter(member => this.inspector.isAlive(member)) - } - const exitWaitMs = Math.max(0, killDeadline - Date.now()) - await Promise.race([this.exitPromise.promise, delay(exitWaitMs)]) - survivors = members.filter(member => this.inspector.isAlive(member)) - this.settleActive('session_exit') - this.exitDisposable.dispose() + const survivors = await this.stopDescendants() if (survivors.length > 0) { throw new Error(`PTY cleanup failed (${reason}); surviving pids: ${survivors.map(member => member.pid).join(', ')}`) } + await this.stopShell() + this.settleActive('session_exit') + this.exitDisposable.dispose() } } diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index 1273824033..55cc134787 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -2,12 +2,14 @@ import { describe, expect, it, vi } from 'vitest' import type { IPty, IPtyForkOptions } from 'node-pty' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SandboxProvider from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' -import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' +import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' import PtyService, { PtySessionId } from '@deepseek-ai/dsh-pty' +import type { PtyBackendSession } from '@deepseek-ai/dsh-pty' import { LocalPtyBackend } from '@deepseek-ai/dsh-pty-local' import * as ptyLocal from '@deepseek-ai/dsh-pty-local' import type { ResolvedConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts' @@ -69,8 +71,9 @@ describe('LocalPtyBackend startup rollback', () => { await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: '/tmp' }) const backend = new LocalPtyBackend(ctx, config(), inspector) const controller = new AbortController() - controller.abort() - await expect(backend.spawn(spec(agent(ctx), controller.signal))).rejects.toThrow('spawn aborted') + const abortReason = new Error('spawn aborted') + controller.abort(abortReason) + await expect(backend.spawn(spec(agent(ctx), controller.signal))).rejects.toBe(abortReason) await expect(backend.spawn(spec(agent(ctx)))).rejects.toThrow('empty argv') }) @@ -169,12 +172,13 @@ describe('pty-local plugin shape', () => { const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(ptyLocal) as Record expect(unwrapped.name).toBe('pty-local') - expect(unwrapped.inject).toEqual(['pty', 'sandbox', 'sandboxPolicy']) + expect(unwrapped.inject).toEqual(['agents', 'pty', 'sandbox', 'sandboxPolicy']) expect(unwrapped.Config).toBeDefined() }) it('validates config and registers the configured backend', async () => { const ctx = new Context() + await ctx.plugin(AgentRegistry) await ctx.plugin(PtyService) await ctx.plugin(EmptySandbox) await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' }) @@ -183,4 +187,91 @@ describe('pty-local plugin shape', () => { await fiber.dispose() expect(ctx.pty.listBackends()).toEqual([]) }) + + it('ignores unrelated session events and mode changes without a live owner', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(PtyService) + await ctx.plugin(EmptySandbox) + await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' }) + await ctx.plugin(ptyLocal, config()) + + const session = ctx.sessions.create(SessionId('unowned-mode')) + expect(() => { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + }).not.toThrow() + expect(() => { setSandboxMode(session, 'read-only') }).not.toThrow() + }) + + it('rejects an effective sandbox-mode change until the owner closes live terminals', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(PtyService) + await ctx.plugin(EmptySandbox) + await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' }) + await ctx.plugin(ptyLocal, config()) + + const session = ctx.sessions.create(SessionId('mode-owner')) + const owner: Agent = { + id: session.id, options: {}, session, status: 'idle', ctx, + send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + } + ctx.agents.register(owner) + const backendSession = { + motd: '', + startSend: () => { throw new Error('unused') }, + read: () => { throw new Error('unused') }, + signal: () => Promise.resolve({ delivered: true, targetPgid: 1 }), + status: () => ({ kind: 'running' as const }), + close: () => Promise.resolve(), + } satisfies PtyBackendSession + ctx.pty.registerBackend({ type: 'stub', spawn: () => Promise.resolve(backendSession) }) + const created = await ctx.pty.spawn(owner, { type: 'stub' }) + + expect(() => { setSandboxMode(session, 'danger-full-access') }).not.toThrow() + expect(() => { setSandboxMode(session, 'read-only') }).toThrow( + 'cannot change sandbox mode from "danger-full-access" to "read-only" while persistent terminal sessions are open or being created; wait for creation to settle and close them first', + ) + expect(session.events.filter(event => event.type === 'sandbox/mode')).toHaveLength(1) + + await ctx.pty.kill(owner, created.sessionId) + expect(() => { setSandboxMode(session, 'read-only') }).not.toThrow() + expect(session.events.filter(event => event.type === 'sandbox/mode')).toHaveLength(2) + }) + + it('also fences sandbox-mode changes across unpublished PTY creation', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(PtyService) + await ctx.plugin(EmptySandbox) + await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' }) + await ctx.plugin(ptyLocal, config()) + + const session = ctx.sessions.create(SessionId('pending-mode-owner')) + const owner: Agent = { + id: session.id, options: {}, session, status: 'idle', ctx, + send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + } + ctx.agents.register(owner) + const gate = Promise.withResolvers() + ctx.pty.registerBackend({ type: 'slow', spawn: () => gate.promise }) + const spawning = ctx.pty.spawn(owner, { type: 'slow' }) + + expect(ctx.pty.hasOwnerActivity(owner)).toBe(true) + expect(() => { setSandboxMode(session, 'read-only') }).toThrow('open or being created') + gate.resolve({ + motd: '', + startSend: () => { throw new Error('unused') }, + read: () => { throw new Error('unused') }, + signal: () => Promise.resolve({ delivered: true, targetPgid: 1 }), + status: () => ({ kind: 'running' as const }), + close: () => Promise.resolve(), + }) + const created = await spawning + await ctx.pty.kill(owner, created.sessionId) + expect(ctx.pty.hasOwnerActivity(owner)).toBe(false) + }) }) diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index 0ff5aebf35..182e2aae19 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -7,6 +7,7 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import PtyService from '@deepseek-ai/dsh-pty' +import type { PtySendOperation } from '@deepseek-ai/dsh-pty' import SandboxProvider from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' @@ -62,6 +63,16 @@ async function harness(mode: 'danger-full-access' | 'workspace-write') { return { ctx, root, agent, fiber, sandbox: ctx.sandbox as PassthroughSandbox } } +async function waitForOutput(operation: PtySendOperation, expected: string): Promise { + const deadline = Date.now() + 2_000 + let output = '' + while (!output.includes(expected) && Date.now() < deadline) { + output += operation.readOutput().delta + if (!output.includes(expected)) await new Promise(resolve => setTimeout(resolve, 10)) + } + expect(output).toContain(expected) +} + describe('pty-local real shell', () => { it('persists cwd and environment across sends, scrubs secrets, and closes', async () => { const previous = process.env.DSH_TEST_SECRET @@ -119,4 +130,26 @@ describe('pty-local real shell', () => { await ctx.pty.kill(agent, created.sessionId) expect(() => process.kill(pid, 0)).toThrow() }, 10_000) + + it('cancels a raw-mode foreground process with a real SIGINT', async () => { + const { ctx, agent } = await harness('danger-full-access') + const created = await ctx.pty.spawn(agent, { type: 'shell' }) + const controller = new AbortController() + const foreground = ctx.pty.startSend(agent, created.sessionId, { + text: 'python3 -c \'import signal,sys,termios,time; signal.signal(signal.SIGINT, lambda *_: (print("SIGINT_SEEN", flush=True), sys.exit(0))); attrs=termios.tcgetattr(0); attrs[3] &= ~termios.ISIG; termios.tcsetattr(0, termios.TCSANOW, attrs); print("RAW_READY", flush=True); time.sleep(60)\'', + submit: true, + signal: controller.signal, + }) + await waitForOutput(foreground, 'RAW_READY') + controller.abort() + const result = await foreground.done + expect(result.waitReason).toBe('stdin_read') + const after = await ctx.pty.startSend(agent, created.sessionId, { + text: 'echo AFTER_SIGINT', + submit: true, + }).done + expect(after.viewport).toContain('AFTER_SIGINT') + expect(after.waitReason).toBe('stdin_read') + await ctx.pty.kill(agent, created.sessionId) + }, 10_000) }) diff --git a/packages/pty/pty-local/tests/sanitize.spec.ts b/packages/pty/pty-local/tests/sanitize.spec.ts index eee994e1e5..4da4ab0d73 100644 --- a/packages/pty/pty-local/tests/sanitize.spec.ts +++ b/packages/pty/pty-local/tests/sanitize.spec.ts @@ -7,7 +7,7 @@ describe('TerminalSanitizer', () => { expect(sanitizer.push('red\x1b[3')).toEqual({ text: 'red', prompt: false }) expect(sanitizer.push('1m text\x1b[0m\r\n')).toEqual({ text: ' text\n', prompt: false }) expect(sanitizer.push('\x1b]133;')).toEqual({ text: '', prompt: false }) - expect(sanitizer.push('D;0\x07dsh> ')).toEqual({ text: 'dsh> ', prompt: true }) + expect(sanitizer.push('D;0\x07dsh> ')).toEqual({ text: 'dsh> ', prompt: true, promptText: true }) }) it('drops unrelated OSC, short escapes, BEL, and incomplete trailing escape', () => { @@ -25,6 +25,20 @@ describe('TerminalSanitizer', () => { expect(normalizeTerminalText('a\r\nb\rc\x07')).toBe('a\nb\nc') }) + it('carries a trailing carriage return across data chunks and flushes standalone CR', () => { + const sanitizer = new TerminalSanitizer(64) + expect(sanitizer.push('a\r')).toEqual({ text: 'a', prompt: false }) + expect(sanitizer.push('\nb')).toEqual({ text: '\nb', prompt: false }) + expect(sanitizer.push('\r')).toEqual({ text: '', prompt: false }) + expect(sanitizer.flush()).toBe('\n') + }) + + it('reports printable prompt text that follows a marker in a later chunk', () => { + const sanitizer = new TerminalSanitizer(64) + expect(sanitizer.push('\x1b]133;D;0\x07')).toEqual({ text: '', prompt: true }) + expect(sanitizer.push('dsh> ')).toEqual({ text: 'dsh> ', prompt: false, promptText: true }) + }) + it('bounds and discards unterminated control sequences through their terminators', () => { const oscBel = new TerminalSanitizer(8) expect(oscBel.push(`\x1b]0;${'x'.repeat(16)}`)).toEqual({ text: '', prompt: false }) diff --git a/packages/pty/pty-local/tests/session.spec.ts b/packages/pty/pty-local/tests/session.spec.ts index 562aaf4eb3..42d634e368 100644 --- a/packages/pty/pty-local/tests/session.spec.ts +++ b/packages/pty/pty-local/tests/session.spec.ts @@ -15,6 +15,7 @@ class FakeTerminal { kills: string[] = [] throwWrite = false throwKill = false + autoExitOnKill = true private dataListeners = new Set<(data: string) => void>() private exitListeners = new Set<(event: { exitCode: number; signal?: number }) => void>() @@ -44,7 +45,7 @@ class FakeTerminal { kill(signal?: string): void { if (this.throwKill) throw new Error('kill failed') this.kills.push(signal ?? 'SIGHUP') - this.emitExit(0, signal === 'SIGKILL' ? 9 : 15) + if (this.autoExitOnKill) this.emitExit(0, signal === 'SIGKILL' ? 9 : 15) } resize() {} @@ -148,7 +149,7 @@ describe('LocalPtySession readiness and output', () => { expect(() => session.startSend({ text: '', submit: false })).toThrow('has exited') }) - it('cancels with Ctrl-C, observes AbortSignal, and contains write failures', async () => { + it('cancels with foreground-group SIGINT, observes AbortSignal, and contains write failures', async () => { vi.useFakeTimers() const terminal = new FakeTerminal() const inspector = new FakeInspector() @@ -159,7 +160,8 @@ describe('LocalPtySession readiness and output', () => { const operation = session.startSend({ text: 'sleep', submit: true, signal: controller.signal }) expect(() => session.startSend({ text: 'again', submit: true })).toThrow('active send') controller.abort() - expect(terminal.writes.at(-1)).toBe('\x03') + expect(inspector.groups).toContainEqual([456, 'SIGINT']) + expect(terminal.writes).not.toContain('\x03') terminal.emitData('\x1b]133;D;130\x07dsh> ') await vi.advanceTimersByTimeAsync(10) await operation.done @@ -196,11 +198,13 @@ describe('LocalPtySession readiness and output', () => { operationInternal.append('') const sessionInternal = session as unknown as { pollReadiness(operation: PtySendOperation): void + interrupt(operation: PtySendOperation): void statusValue: PtySessionStatus appendOutput(text: string): void } sessionInternal.appendOutput('') sessionInternal.pollReadiness({} as PtySendOperation) + sessionInternal.interrupt({} as PtySendOperation) sessionInternal.statusValue = { kind: 'exited', exitCode: 2, signal: null } sessionInternal.pollReadiness(operation) await operation.done @@ -212,12 +216,23 @@ describe('LocalPtySession readiness and output', () => { expect(unknown.status()).toEqual({ kind: 'exited', exitCode: 1, signal: null }) const cancelTerminal = new FakeTerminal() - const cancel = new LocalPtySession(cancelTerminal.asPty(), new FakeInspector(), config()) + const cancelInspector = new FakeInspector() + const cancel = new LocalPtySession(cancelTerminal.asPty(), cancelInspector, config()) await initialize(cancel, cancelTerminal) const cancellable = cancel.startSend({ text: '', submit: false }) - cancelTerminal.throwWrite = true + cancelInspector.throwGroup = true expect(cancellable.cancel()).toBe(true) - await expect(cancellable.done).rejects.toThrow('write failed') + await expect(cancellable.done).rejects.toThrow('group failed') + expect(cancellable.cancel()).toBe(false) + + const missingGroupTerminal = new FakeTerminal() + const missingGroupInspector = new FakeInspector() + const missingGroup = new LocalPtySession(missingGroupTerminal.asPty(), missingGroupInspector, config()) + await initialize(missingGroup, missingGroupTerminal) + missingGroupInspector.pgid = undefined + const unresolved = missingGroup.startSend({ text: '', submit: false }) + expect(unresolved.cancel()).toBe(true) + await expect(unresolved.done).rejects.toThrow('cannot resolve foreground process group') }) it('does not treat zero-output startup silence as readiness and fails on startup timeout', async () => { @@ -239,6 +254,23 @@ describe('LocalPtySession readiness and output', () => { await timedOut }) + it('waits for printable prompt text when the startup marker is split from PS1', async () => { + vi.useFakeTimers() + const terminal = new FakeTerminal() + const session = new LocalPtySession(terminal.asPty(), new FakeInspector(), config()) + let settled = false + const initializing = session.initialize().then(() => { settled = true }) + + terminal.emitData('\x1b]133;D;0\x07') + await vi.advanceTimersByTimeAsync(20) + expect(settled).toBe(false) + + terminal.emitData('dsh> ') + await vi.advanceTimersByTimeAsync(10) + await initializing + expect(session.motd).toBe('dsh> ') + }) + it('trusts prompt markers only while the startup shell owns the foreground group', async () => { vi.useFakeTimers() const terminal = new FakeTerminal() @@ -328,14 +360,15 @@ describe('LocalPtySession bounds, signals, and teardown', () => { // readiness poll would otherwise mis-settle this as stdin_read once close // begins, so teardown must stop polling before its grace period. terminal.emitData('\x1b]133;D;0\x07dsh> ') - terminal.throwKill = true + terminal.autoExitOnKill = false const closing = session.close('mid-send') - await vi.advanceTimersByTimeAsync(60) + await vi.advanceTimersByTimeAsync(20) + terminal.emitExit(0, 15) expect((await operation.done).waitReason).toBe('session_exit') await closing }) - it('waits for SIGKILL recipients to leave the process table after the shell exits', async () => { + it('keeps the shell alive until SIGKILL recipients leave the process table', async () => { vi.useFakeTimers() const terminal = new FakeTerminal() const inspector = new FakeInspector() @@ -348,11 +381,59 @@ describe('LocalPtySession bounds, signals, and teardown', () => { const closing = session.close('test').then(() => { settled = true }) await vi.advanceTimersByTimeAsync(20) expect(inspector.processes).toContainEqual([124, 'SIGKILL']) + expect(terminal.kills).toEqual([]) expect(settled).toBe(false) inspector.alive.delete(124) await vi.advanceTimersByTimeAsync(20) await closing + expect(terminal.kills).toEqual(['SIGTERM']) expect(settled).toBe(true) }) + + it('rescans for descendants forked during TERM before stopping the shell', async () => { + const terminal = new FakeTerminal() + const inspector = new FakeInspector() + let reads = 0 + inspector.processTree = () => { + reads += 1 + if (reads === 1) { + inspector.alive.add(124) + return [{ pid: 124, started: 'first' }] + } + if (reads === 2) { + inspector.alive.add(125) + return [{ pid: 125, started: 'late' }] + } + return [] + } + const session = new LocalPtySession(terminal.asPty(), inspector, config()) + + await session.close('test') + + expect(inspector.processes).toEqual([[124, 'SIGTERM'], [125, 'SIGKILL']]) + expect(terminal.kills).toEqual(['SIGTERM']) + }) + + it('allows teardown to retry after a descendant-survivor failure', async () => { + vi.useFakeTimers() + const terminal = new FakeTerminal() + const inspector = new FakeInspector() + inspector.members = [{ pid: 124, started: 'child' }] + inspector.alive.add(124) + inspector.removeOnSignal = false + const session = new LocalPtySession(terminal.asPty(), inspector, config({ disposeGraceMs: 10 })) + + const first = session.close('first') + const rejected = expect(first).rejects.toThrow('surviving pids: 124') + await vi.advanceTimersByTimeAsync(25) + await rejected + expect(terminal.kills).toEqual([]) + + inspector.alive.delete(124) + const second = session.close('retry') + expect(second).not.toBe(first) + await second + expect(terminal.kills).toEqual(['SIGTERM']) + }) }) diff --git a/packages/pty/pty-local/tsconfig.json b/packages/pty/pty-local/tsconfig.json index 06b5dcd4e7..45a03248db 100644 --- a/packages/pty/pty-local/tsconfig.json +++ b/packages/pty/pty-local/tsconfig.json @@ -17,6 +17,12 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, { "path": "../pty" }, diff --git a/packages/pty/pty/README.md b/packages/pty/pty/README.md index 3620d8eaab..6916a4ef82 100644 --- a/packages/pty/pty/README.md +++ b/packages/pty/pty/README.md @@ -5,10 +5,12 @@ Owner-scoped persistent PTY seam. `PtyService` registers as `ctx.pty`, mints opa ## Contract - Backends register one stable `type` and return an unpublished `PtyBackendSession`; failed or cancelled setup must clean partial resources. +- Spawn cancellation preserves the caller's exact abort reason. Service disposal and owner loss remain distinct machine-routable failures after backend setup. +- `hasOwnerActivity(owner)` spans unpublished setup through final close, so lifecycle policy can fence the exact owner without a publication race. - A successful spawn publishes one `PtySessionId`. The optional `name` is owner-local display metadata, never authority. - One session accepts at most one live send operation. Reads and signals may observe it; another send fails until the operation settles. - `PtySendResult.waitReason` and `sessionStatus` are independent. `session_exit` describes the top-level PTY process, not an arbitrary foreground command. -- `kill()` and disposal resolve only after the backend's captured process tree is quiescent. A cleanup failure rejects instead of claiming success. +- `kill()` and disposal resolve only after the backend's captured process tree is quiescent. A cleanup failure rejects instead of claiming success and leaves the close retriable. The seam contains no `node-pty`, sandbox, tool-schema, prompt, task, or terminal-rendering policy. Implementations own terminal mechanics; consumers own model presentation and optional background-task registration. diff --git a/packages/pty/pty/src/index.ts b/packages/pty/pty/src/index.ts index 7bef5f709f..e809f50e3e 100644 --- a/packages/pty/pty/src/index.ts +++ b/packages/pty/pty/src/index.ts @@ -77,10 +77,6 @@ export function PtySessionId(value: string): PtySessionId { return value as PtySessionId } -function isAborted(signal: AbortSignal | undefined): boolean { - return signal?.aborted === true -} - interface SessionRecord { readonly id: PtySessionId readonly owner: Agent @@ -96,6 +92,7 @@ export class PtyService extends Service { private readonly backends = new Map() private readonly sessions = new Map() private readonly reservedNames = new Map>() + private readonly pendingSpawns = new Map() private readonly ownerCleanups = new Map Promise | void>() private readonly disposedOwners = new WeakSet() private nextId = 0 @@ -142,13 +139,13 @@ export class PtyService extends Service { */ async spawn(owner: Agent, request: PtySpawnRequest, signal?: AbortSignal): Promise { this.assertActive() + signal?.throwIfAborted() this.ensureOwnerCleanup(owner) const backend = this.backends.get(request.type) if (backend === undefined) throw new PtyError(`no PTY backend registered for "${request.type}"`, 'NO_BACKEND') if (request.name !== undefined && request.name.length === 0) throw new Error('PTY session name must be non-empty') - if (isAborted(signal)) throw new Error('PTY spawn aborted') - const releaseName = this.reserveName(owner, request.name) + const releaseSpawn = this.reserveSpawn(owner) const sessionId = PtySessionId(`pty-${++this.nextId}`) let session: PtyBackendSession | undefined try { @@ -160,7 +157,11 @@ export class PtyService extends Service { ...request.cwd !== undefined ? { cwd: request.cwd } : {}, ...signal !== undefined ? { signal } : {}, }) - if (this.disposing || isAborted(signal) || !this.isLiveOwner(owner)) { + signal?.throwIfAborted() + if (this.disposing) { + throw new PtyError('PTY service is disposing', 'SERVICE_DISPOSING') + } + if (!this.isLiveOwner(owner)) { throw new PtyError('PTY owner is no longer live', 'OWNER_NOT_LIVE') } const record: SessionRecord = { @@ -184,10 +185,21 @@ export class PtyService extends Service { } throw error } finally { + releaseSpawn() releaseName() } } + /** + * Test whether an exact owner has a published session or unpublished spawn. + * @param owner - exact live owner to inspect. + * @returns true across the entire spawn-to-close interval, with no publication gap. + */ + hasOwnerActivity(owner: Agent): boolean { + return (this.pendingSpawns.get(owner) ?? 0) > 0 + || [...this.sessions.values()].some(record => record.owner === owner) + } + /** * Start one exclusive interactive send. * @param owner - exact session owner. @@ -302,6 +314,15 @@ export class PtyService extends Service { } } + private reserveSpawn(owner: Agent): () => void { + this.pendingSpawns.set(owner, (this.pendingSpawns.get(owner) ?? 0) + 1) + return () => { + const remaining = (this.pendingSpawns.get(owner) ?? 1) - 1 + if (remaining === 0) this.pendingSpawns.delete(owner) + else this.pendingSpawns.set(owner, remaining) + } + } + private expectOwned(owner: Agent, id: PtySessionId): SessionRecord { const record = this.sessions.get(id) if (record === undefined) throw new PtyError(`unknown PTY session ${id}`, 'NO_SESSION') @@ -339,6 +360,7 @@ export class PtyService extends Service { } finally { this.backends.clear() this.reservedNames.clear() + this.pendingSpawns.clear() const cleanups = [...this.ownerCleanups.values()] this.ownerCleanups.clear() await Promise.all(cleanups.map(cleanup => Promise.resolve(cleanup()))) diff --git a/packages/pty/pty/tests/service.spec.ts b/packages/pty/pty/tests/service.spec.ts index 17b0302ea6..21587163f6 100644 --- a/packages/pty/pty/tests/service.spec.ts +++ b/packages/pty/pty/tests/service.spec.ts @@ -178,8 +178,9 @@ describe('PtyService ownership and lifecycle', () => { const created = await ctx.pty.spawn(owner, { type: 'stub', name: 'main' }) await expect(ctx.pty.spawn(owner, { type: 'stub', name: '' })).rejects.toThrow('must be non-empty') const aborted = new AbortController() - aborted.abort() - await expect(ctx.pty.spawn(owner, { type: 'stub' }, aborted.signal)).rejects.toThrow('spawn aborted') + const abortReason = new Error('spawn aborted') + aborted.abort(abortReason) + await expect(ctx.pty.spawn(owner, { type: 'stub' }, aborted.signal)).rejects.toBe(abortReason) await expect(ctx.pty.spawn(owner, { type: 'stub', name: 'main' })).rejects.toMatchObject({ code: 'DUPLICATE_NAME' }) const operation = ctx.pty.startSend(owner, created.sessionId, { text: 'echo hi', submit: true }) @@ -211,6 +212,41 @@ describe('PtyService ownership and lifecycle', () => { expect(session.closed).toEqual(['PTY spawn rolled back']) }) + it('preserves caller cancellation when a pending backend spawn completes', async () => { + const ctx = await harness() + const gate = Promise.withResolvers() + const session = new StubSession() + ctx.pty.registerBackend({ type: 'slow', spawn: () => gate.promise }) + const owner = stubAgent(ctx, 'owner') + ctx.agents.register(owner) + const controller = new AbortController() + const reason = new Error('cancelled by caller') + + const pending = ctx.pty.spawn(owner, { type: 'slow' }, controller.signal) + controller.abort(reason) + gate.resolve(session) + + await expect(pending).rejects.toBe(reason) + expect(session.closed).toEqual(['PTY spawn rolled back']) + expect(ctx.agents.get(owner.id)).toBe(owner) + }) + + it('rolls back an unpublished backend session when service disposal wins', async () => { + const ctx = await harness() + const gate = Promise.withResolvers() + const session = new StubSession() + ctx.pty.registerBackend({ type: 'slow', spawn: () => gate.promise }) + const owner = stubAgent(ctx, 'owner') + ctx.agents.register(owner) + + const pending = ctx.pty.spawn(owner, { type: 'slow' }) + await disposePtyService(ctx) + gate.resolve(session) + + await expect(pending).rejects.toMatchObject({ code: 'SERVICE_DISPOSING' }) + expect(session.closed).toEqual(['PTY spawn rolled back']) + }) + it('keeps independent reservations and handles provider failure before publication', async () => { const ctx = await harness() const firstGate = Promise.withResolvers() diff --git a/packages/pty/tool-pty/README.md b/packages/pty/tool-pty/README.md index 5a0edca33a..0857c5d3ee 100644 --- a/packages/pty/tool-pty/README.md +++ b/packages/pty/tool-pty/README.md @@ -2,7 +2,16 @@ Six model-facing tools over `ctx.pty`: `terminal_open`, `terminal_send`, `terminal_read`, `terminal_signal`, `terminal_close`, and `terminal_list`. Every operation requires the exact initiating `Agent`, so a model cannot address another agent's terminal even if it learns the id. -`terminal_send(run_in_background: true)` reuses `ctx.tasks`; task preflight occurs before any terminal write, completion is collected with `task_output`, and `task_kill` requests `Ctrl-C`. Foreground sends use terminal ACP cards; lifecycle, history, signal, and list calls use generic cards. +`terminal_send(run_in_background: true)` reuses `ctx.tasks`; task preflight and the PTY service's exclusive per-session send reservation occur before the task id is returned, completion is collected with `task_output`, and `task_kill` delivers `SIGINT` to the foreground process group. Foreground sends use terminal ACP call/result cards. Background sends use a generic execute card; open, read, signal, close, and list use generic `execute`, `read`, `execute`, `delete`, and `read` cards respectively. None declares source locations. + +## Config + +| key | default | meaning | +|---|---:|---| +| `enableRunInBackground` | `true` | expose and accept `run_in_background`; false omits the schema field and rejects a forced undeclared argument | +| `maxResultBytes` | `262144` | UTF-8 cap for each complete terminal result or PTY task output after wait, session, pagination, truncation, and task-status metadata | + +Both values are validated at load. When a result exceeds `maxResultBytes`, rendering reserves space for control metadata and a truncation marker when they fit; cuts preserve UTF-8 boundaries. ## Model Experience @@ -44,11 +53,11 @@ Prefix-stable while tool visibility and definitions are unchanged. #### What the model sees -Spawn returns the id and bounded MOTD. Send/read return bounded terminal text plus readiness/history markers. Background mode returns a generic task id. Results remain in session history until compaction; incremental task reads do not repeat consumed output. +Spawn returns the id and bounded MOTD. Send/read return bounded terminal text plus readiness/history markers. Background mode returns a generic task id. Every complete result is capped by `maxResultBytes`, including generic task status text. Results remain in session history until compaction; incremental task reads do not repeat consumed output. #### Token effect -Data-dependent and bounded by the backend; each returned result remains in history until compaction. +Data-dependent and bounded by `maxResultBytes`; each returned result remains in history until compaction. #### KV Cache effect diff --git a/packages/pty/tool-pty/package.json b/packages/pty/tool-pty/package.json index 6bc602699c..2d36fb5c9b 100644 --- a/packages/pty/tool-pty/package.json +++ b/packages/pty/tool-pty/package.json @@ -26,11 +26,15 @@ "src" ], "license": "BSD-3-Clause", + "dependencies": { + "schemastery": "^3.18.0" + }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-pty": "^0.0.1", + "@deepseek-ai/dsh-retention": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", @@ -44,6 +48,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-pty": "workspace:^", "@deepseek-ai/dsh-pty-local": "workspace:^", + "@deepseek-ai/dsh-retention": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/pty/tool-pty/src/index.ts b/packages/pty/tool-pty/src/index.ts index f145102dea..edb8102a79 100644 --- a/packages/pty/tool-pty/src/index.ts +++ b/packages/pty/tool-pty/src/index.ts @@ -5,6 +5,7 @@ */ import { Context } from 'cordis' +import z from 'schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { PtySessionId } from '@deepseek-ai/dsh-pty' @@ -12,7 +13,7 @@ import type { PtySendResult, PtySessionId as PtySessionIdType, PtySignal } from import type {} from '@deepseek-ai/dsh-tasks' import { defineTool } from '@deepseek-ai/dsh-tools' import type { ToolExecutionResult, ToolResult } from '@deepseek-ai/dsh-tools' -import { renderList, renderRead, renderSend, renderSendRead, renderSpawn } from './render.ts' +import { boundTerminalText, renderList, renderRead, renderSend, renderSendRead, renderSpawn } from './render.ts' declare module '@deepseek-ai/dsh-tasks' { interface TaskKindMap { @@ -25,6 +26,23 @@ export const name = 'tool-pty' /** Required capability, registry, and prompt services. */ export const inject = ['pty', 'tools', 'systemPrompt'] +/** Default cap for one complete model-facing terminal result. */ +export const DEFAULT_MAX_RESULT_BYTES = 256 * 1024 + +/** Model-facing terminal tool configuration. */ +export interface Config { + /** Expose `run_in_background` and accept background sends (default true). */ + enableRunInBackground?: boolean + /** Maximum UTF-8 bytes in one complete terminal or task-output result. */ + maxResultBytes?: number +} + +/** Schemastery configuration for the terminal tool consumer. */ +export const Config: z = z.object({ + enableRunInBackground: z.boolean().default(true), + maxResultBytes: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_RESULT_BYTES), +}) + interface SpawnArgs { type: string name?: string @@ -62,8 +80,8 @@ function sessionId(args: SessionArgs): PtySessionIdType { return PtySessionId(args.sessionId) } -function textResult(text: string): ContentBlock[] { - return [{ type: 'text', text }] +function textResult(text: string, maxBytes: number): ContentBlock[] { + return [{ type: 'text', text: boundTerminalText(text, maxBytes) }] } function rawResultText(result: ToolResult): string | undefined { @@ -79,7 +97,12 @@ function sendDetail(result: PtySendResult): string { } /** Register all terminal tools and the minimal usage guidance. */ -export function apply(ctx: Context): void { +export function apply(ctx: Context, config: Config = {}): void { + const enableRunInBackground = config.enableRunInBackground ?? true + const maxResultBytes = config.maxResultBytes ?? DEFAULT_MAX_RESULT_BYTES + if (!Number.isSafeInteger(maxResultBytes) || maxResultBytes <= 0) { + throw new Error('tool-pty: maxResultBytes must be a positive safe integer') + } ctx.systemPrompt.section({ name: 'tool:pty', order: 106, @@ -101,7 +124,7 @@ export function apply(ctx: Context): void { ...args.name !== undefined ? { name: args.name } : {}, ...args.cwd !== undefined ? { cwd: args.cwd } : {}, }, exec.signal) - return textResult(renderSpawn(result)) + return textResult(renderSpawn(result, maxResultBytes), maxResultBytes) }, presentCall: (args) => { const parsed = args @@ -111,18 +134,22 @@ export function apply(ctx: Context): void { ctx.tools.register(defineTool({ name: 'terminal_send', - description: 'Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.', + description: 'Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit.' + + (enableRunInBackground ? ' Background mode returns a task id for task_output/task_kill.' : ''), parameters: { sessionId: { type: 'string', required: true, description: 'Terminal session id returned by terminal_open or terminal_list.' }, text: { type: 'string', required: true, description: 'UTF-8 text to write to the terminal.' }, submit: { type: 'boolean', description: 'Submit Enter after text (default true). Set false for control characters or incomplete REPL input.' }, - run_in_background: { type: 'boolean', description: 'Return a task id immediately; collect with task_output or stop with task_kill.' }, + ...enableRunInBackground + ? { run_in_background: { type: 'boolean' as const, description: 'Return a task id immediately; collect with task_output or stop with task_kill.' } } + : {}, }, async execute(args: SendArgs, exec): Promise { const owner = requireAgent(exec.agent) const id = sessionId(args) const request = { text: args.text, submit: args.submit ?? true } if (args.run_in_background === true) { + if (!enableRunInBackground) throw new Error('background terminal sends are disabled by tool-pty configuration') const tasks = ctx.get('tasks') if (tasks === undefined) throw new Error('background terminal sends require @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') let cancelRequested = false @@ -130,6 +157,7 @@ export function apply(ctx: Context): void { kind: 'pty-send', label: `${id}: ${args.text || '(input)'}`, owner, + outputLimitBytes: maxResultBytes, run: () => { const operation = ctx.pty.startSend(owner, id, request) return { @@ -145,12 +173,12 @@ export function apply(ctx: Context): void { } }, }) - return { content: textResult(`started background task ${taskId}`), isError: false } + return { content: textResult(`started background task ${taskId}`, maxResultBytes), isError: false } } const operation = ctx.pty.startSend(owner, id, { ...request, signal: exec.signal }) const result = await operation.done if (exec.signal.aborted) throw new Error('terminal send aborted') - return { content: textResult(renderSend(result)), isError: false, meta: result } + return { content: textResult(renderSend(result, maxResultBytes), maxResultBytes), isError: false, meta: result } }, presentCall(args) { const parsed = args as Partial @@ -179,7 +207,7 @@ export function apply(ctx: Context): void { ...args.offset !== undefined ? { offset: args.offset } : {}, ...args.count !== undefined ? { count: args.count } : {}, }) - return Promise.resolve(textResult(renderRead(result))) + return Promise.resolve(textResult(renderRead(result, maxResultBytes), maxResultBytes)) }, presentCall: args => ({ card: 'generic', title: `Read terminal ${(args).sessionId}`, kind: 'read', rawInput: args }), })) @@ -193,7 +221,7 @@ export function apply(ctx: Context): void { }, async execute(args: SignalArgs, exec) { const result = await ctx.pty.signal(requireAgent(exec.agent), sessionId(args), args.signal) - return textResult(`delivered ${args.signal} to foreground process group ${result.targetPgid}`) + return textResult(`delivered ${args.signal} to foreground process group ${result.targetPgid}`, maxResultBytes) }, presentCall: args => ({ card: 'generic', title: `Signal terminal ${(args as SignalArgs).sessionId}`, kind: 'execute', rawInput: args }), })) @@ -207,7 +235,7 @@ export function apply(ctx: Context): void { async execute(args: SessionArgs, exec) { const id = sessionId(args) const closed = await ctx.pty.kill(requireAgent(exec.agent), id) - return textResult(closed ? `closed terminal session ${id}` : `terminal session ${id} was already closing`) + return textResult(closed ? `closed terminal session ${id}` : `terminal session ${id} was already closing`, maxResultBytes) }, presentCall: args => ({ card: 'generic', title: `Close terminal ${(args).sessionId}`, kind: 'delete' }), })) @@ -217,7 +245,7 @@ export function apply(ctx: Context): void { description: 'List persistent terminal sessions owned by the current agent.', parameters: {}, execute(_args: Record, exec) { - return Promise.resolve(textResult(renderList(ctx.pty.list(requireAgent(exec.agent))))) + return Promise.resolve(textResult(renderList(ctx.pty.list(requireAgent(exec.agent)), maxResultBytes), maxResultBytes)) }, presentCall: () => ({ card: 'generic', title: 'List terminal sessions', kind: 'read' }), })) diff --git a/packages/pty/tool-pty/src/render.ts b/packages/pty/tool-pty/src/render.ts index bed176e890..0930d205ad 100644 --- a/packages/pty/tool-pty/src/render.ts +++ b/packages/pty/tool-pty/src/render.ts @@ -1,57 +1,128 @@ /** Model and ACP rendering for persistent terminal tool results. */ +import { TextRetainer } from '@deepseek-ai/dsh-retention' import type { PtyReadResult, PtySendRead, PtySendResult, PtySessionSnapshot, PtySpawnResult } from '@deepseek-ai/dsh-pty' +const encoder = new TextEncoder() +const TRUNCATED = '\n[output truncated]' + +function byteLength(text: string): number { + return encoder.encode(text).byteLength +} + +function retain(text: string, maxBytes: number, kind: 'head' | 'tail'): string { + const retainer = new TextRetainer({ kind, maxBytes }) + retainer.push(text) + return retainer.finish().text +} + +function fitWithSuffix(content: string, suffix: string, maxBytes: number): string { + const fixedBytes = byteLength(suffix) + if (fixedBytes >= maxBytes) return retain(suffix, maxBytes, 'tail') + return `${retain(content, maxBytes - fixedBytes, 'tail')}${suffix}` +} + +function fitWithPrefix(prefix: string, content: string, maxBytes: number): string { + const fixed = `${prefix}${TRUNCATED}` + const fixedBytes = byteLength(fixed) + if (fixedBytes >= maxBytes) return retain(fixed, maxBytes, 'head') + return `${prefix}${retain(content, maxBytes - fixedBytes, 'tail')}${TRUNCATED}` +} + +function boundBodyWithSuffix( + content: string, + metadata: string, + upstreamTruncated: boolean, + maxBytes: number, +): string { + const suffix = `${metadata}${upstreamTruncated ? TRUNCATED : ''}` + const complete = `${content}${suffix}` + if (byteLength(complete) <= maxBytes) return complete + return fitWithSuffix(content, `${metadata}${TRUNCATED}`, maxBytes) +} + +/** + * Bound one complete terminal acknowledgement while preserving UTF-8 cuts. + * @param text - complete acknowledgement text. + * @param maxBytes - positive final result cap. + * @returns bounded text with a truncation marker when it fits. + */ +export function boundTerminalText(text: string, maxBytes: number): string { + if (byteLength(text) <= maxBytes) return text + const markerBytes = byteLength(TRUNCATED) + if (markerBytes >= maxBytes) return retain(TRUNCATED, maxBytes, 'tail') + return `${retain(text, maxBytes - markerBytes, 'head')}${TRUNCATED}` +} + /** * Render one created session and its bounded MOTD. * @param result - published spawn result. + * @param maxBytes - complete UTF-8 result cap. * @returns Model-facing session acknowledgement. */ -export function renderSpawn(result: PtySpawnResult): string { +export function renderSpawn(result: PtySpawnResult, maxBytes: number): string { const label = result.name === undefined ? result.sessionId : `${result.sessionId} (${result.name})` - return `started terminal session ${label} [type: ${result.type}]\n${result.motd || '(no startup output)'}` + const prefix = `started terminal session ${label} [type: ${result.type}]\n` + const motd = result.motd || '(no startup output)' + const complete = `${prefix}${motd}` + return byteLength(complete) <= maxBytes ? complete : fitWithPrefix(prefix, motd, maxBytes) } /** * Render one settled interactive send. * @param result - settled send outcome. + * @param maxBytes - complete UTF-8 result cap. * @returns Terminal output plus wait/session markers. */ -export function renderSend(result: PtySendResult): string { +export function renderSend(result: PtySendResult, maxBytes: number): string { const output = result.viewport || '(no new output)' const status = result.sessionStatus.kind === 'running' ? 'running' : `exited code=${result.sessionStatus.exitCode ?? 'null'} signal=${result.sessionStatus.signal ?? 'null'}` - return `${output}\n[wait: ${result.waitReason}]\n[session: ${status}]${result.truncated ? '\n[output truncated]' : ''}` + return boundBodyWithSuffix( + output, + `\n[wait: ${result.waitReason}]\n[session: ${status}]`, + result.truncated, + maxBytes, + ) } /** * Render one incremental background operation read. * @param read - consuming operation delta. - * @returns Delta plus truncation marker when needed. + * @returns Delta plus its upstream truncation marker. The generic task control + * applies the producer's complete-result cap after adding task status. */ export function renderSendRead(read: PtySendRead): string { - return `${read.delta}${read.truncated ? `${read.delta.endsWith('\n') || read.delta.length === 0 ? '' : '\n'}[output truncated]` : ''}` + const separator = read.delta.endsWith('\n') || read.delta.length === 0 ? '' : '\n' + return `${read.delta}${read.truncated ? `${separator}[output truncated]` : ''}` } /** * Render one bounded historical page. * @param result - retained scrollback page. + * @param maxBytes - complete UTF-8 result cap. * @returns Page text plus pagination and truncation markers. */ -export function renderRead(result: PtyReadResult): string { +export function renderRead(result: PtyReadResult, maxBytes: number): string { const output = result.text || '(no retained output)' - return `${output}\n[lines: ${result.lineBegin}-${result.lineEnd} of ${result.totalLines}]${result.truncated ? '\n[output truncated]' : ''}` + return boundBodyWithSuffix( + output, + `\n[lines: ${result.lineBegin}-${result.lineEnd} of ${result.totalLines}]`, + result.truncated, + maxBytes, + ) } /** * Render owner-visible live sessions. * @param sessions - fresh owner-scoped snapshots. + * @param maxBytes - complete UTF-8 result cap. * @returns One line per session or the empty marker. */ -export function renderList(sessions: PtySessionSnapshot[]): string { +export function renderList(sessions: PtySessionSnapshot[], maxBytes: number): string { if (sessions.length === 0) return '(no terminal sessions)' - return sessions.map((session) => { + const text = sessions.map((session) => { const name = session.name === undefined ? '' : ` (${session.name})` const pid = session.pid === undefined ? '' : ` pid=${session.pid}` const status = session.status.kind === 'running' @@ -59,4 +130,5 @@ export function renderList(sessions: PtySessionSnapshot[]): string { : `exited code=${session.status.exitCode ?? 'null'} signal=${session.status.signal ?? 'null'}` return `${session.sessionId}${name} [${session.type}] ${status}${pid}` }).join('\n') + return boundBodyWithSuffix(text, '', false, maxBytes) } diff --git a/packages/pty/tool-pty/tests/render.spec.ts b/packages/pty/tool-pty/tests/render.spec.ts index 33b288ab5f..b02ba3b8ef 100644 --- a/packages/pty/tool-pty/tests/render.spec.ts +++ b/packages/pty/tool-pty/tests/render.spec.ts @@ -1,23 +1,23 @@ import { describe, expect, it } from 'vitest' import { PtySessionId } from '@deepseek-ai/dsh-pty' -import { renderList, renderRead, renderSend, renderSendRead, renderSpawn } from '@deepseek-ai/dsh-tool-pty/src/render.ts' +import { boundTerminalText, renderList, renderRead, renderSend, renderSendRead, renderSpawn } from '@deepseek-ai/dsh-tool-pty/src/render.ts' describe('tool-pty rendering', () => { it('renders spawn with and without names or MOTD', () => { - expect(renderSpawn({ sessionId: PtySessionId('pty-1'), type: 'shell', status: { kind: 'running' }, motd: '' })) + expect(renderSpawn({ sessionId: PtySessionId('pty-1'), type: 'shell', status: { kind: 'running' }, motd: '' }, 1024)) .toBe('started terminal session pty-1 [type: shell]\n(no startup output)') - expect(renderSpawn({ sessionId: PtySessionId('pty-2'), name: 'main', type: 'shell', pid: 2, status: { kind: 'running' }, motd: 'ready' })) + expect(renderSpawn({ sessionId: PtySessionId('pty-2'), name: 'main', type: 'shell', pid: 2, status: { kind: 'running' }, motd: 'ready' }, 1024)) .toContain('pty-2 (main)') }) it('renders running, exited, empty, and truncated sends', () => { - expect(renderSend({ viewport: '', waitReason: 'timeout', sessionStatus: { kind: 'running' }, truncated: true })) + expect(renderSend({ viewport: '', waitReason: 'timeout', sessionStatus: { kind: 'running' }, truncated: true }, 1024)) .toBe('(no new output)\n[wait: timeout]\n[session: running]\n[output truncated]') - expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: 'SIGTERM' }, truncated: false })) + expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: 'SIGTERM' }, truncated: false }, 1024)) .toContain('exited code=null signal=SIGTERM') - expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: 2, signal: null }, truncated: false })) + expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: 2, signal: null }, truncated: false }, 1024)) .toContain('exited code=2 signal=null') - expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: null }, truncated: false })) + expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: null }, truncated: false }, 1024)) .toContain('exited code=null signal=null') expect(renderSendRead({ delta: '', truncated: true })).toBe('[output truncated]') expect(renderSendRead({ delta: 'x', truncated: true })).toBe('x\n[output truncated]') @@ -26,14 +26,48 @@ describe('tool-pty rendering', () => { }) it('renders history and every list status shape', () => { - expect(renderRead({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: true })) + expect(renderRead({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: true }, 1024)) .toBe('(no retained output)\n[lines: 0-0 of 0]\n[output truncated]') - expect(renderList([])).toBe('(no terminal sessions)') + expect(renderList([], 1024)).toBe('(no terminal sessions)') expect(renderList([ { sessionId: PtySessionId('pty-1'), type: 'shell', status: { kind: 'running' } }, { sessionId: PtySessionId('pty-2'), name: 'done', type: 'shell', pid: 9, status: { kind: 'exited', exitCode: 2, signal: null } }, { sessionId: PtySessionId('pty-3'), type: 'shell', status: { kind: 'exited', exitCode: null, signal: 'SIGTERM' } }, { sessionId: PtySessionId('pty-4'), type: 'shell', status: { kind: 'exited', exitCode: null, signal: null } }, - ])).toBe('pty-1 [shell] running\npty-2 (done) [shell] exited code=2 signal=null pid=9\npty-3 [shell] exited code=null signal=SIGTERM\npty-4 [shell] exited code=null signal=null') + ], 1024)).toBe('pty-1 [shell] running\npty-2 (done) [shell] exited code=2 signal=null pid=9\npty-3 [shell] exited code=null signal=SIGTERM\npty-4 [shell] exited code=null signal=null') + }) + + it('bounds complete UTF-8 results while retaining terminal metadata when it fits', () => { + const send = renderSend({ + viewport: `prefix-${'界'.repeat(40)}`, + waitReason: 'stdin_read', + sessionStatus: { kind: 'running' }, + truncated: false, + }, 64) + expect(Buffer.byteLength(send)).toBeLessThanOrEqual(64) + expect(send).toContain('[wait: stdin_read]') + expect(send).toContain('[output truncated]') + + const read = renderRead({ + text: 'x'.repeat(200), totalLines: 20, lineBegin: 0, lineEnd: 10, truncated: false, + }, 48) + expect(Buffer.byteLength(read)).toBeLessThanOrEqual(48) + expect(read).toContain('[lines: 0-10 of 20]') + + expect(Buffer.byteLength(renderSpawn({ + sessionId: PtySessionId('pty-1'), type: 'shell', status: { kind: 'running' }, motd: 'x'.repeat(200), + }, 32))).toBeLessThanOrEqual(32) + + const boundedSpawn = renderSpawn({ + sessionId: PtySessionId('pty-1'), type: 'shell', status: { kind: 'running' }, motd: 'x'.repeat(200), + }, 96) + expect(boundedSpawn).toContain('started terminal session pty-1') + expect(boundedSpawn).toContain('[output truncated]') + + expect(Buffer.byteLength(renderSend({ + viewport: 'x'.repeat(200), waitReason: 'stdin_read', sessionStatus: { kind: 'running' }, truncated: false, + }, 8))).toBeLessThanOrEqual(8) + expect(boundTerminalText('x'.repeat(200), 8)).toHaveLength(8) + expect(boundTerminalText('x'.repeat(200), 32).endsWith('[output truncated]')).toBe(true) }) }) diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index 5adcaea441..d6023a4d17 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/packages/pty/tool-pty/tests/tools.spec.ts @@ -31,20 +31,23 @@ class StubSession implements PtyBackendSession { autoSettle = true rejectOperation = false closeGate: PromiseWithResolvers | undefined + viewport = 'command output' + delta = 'live output' + deltaTruncated = false startSend(_request: PtySendRequest): PtySendOperation { let settle!: () => void let reject!: (error: unknown) => void let cancelled = false const done = new Promise((resolve, rejectPromise) => { settle = resolve; reject = rejectPromise }).then(() => ({ - viewport: cancelled ? '^C' : 'command output', + viewport: cancelled ? '^C' : this.viewport, waitReason: 'stdin_read' as const, sessionStatus: this.statusValue, truncated: false, })) const operation: PtySendOperation = { done, - readOutput: () => ({ delta: 'live output', truncated: false }), + readOutput: () => ({ delta: this.delta, truncated: this.deltaTruncated }), cancel: () => { if (cancelled) return false cancelled = true @@ -87,7 +90,13 @@ function stubBackend() { return { backend, sessions } } -async function setup(tasks: boolean) { +async function setup(tasks: boolean, config: ToolPty.Config = {}) { + const base = await setupBase(tasks) + await base.ctx.plugin(ToolPty, config) + return base +} + +async function setupBase(tasks: boolean) { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) @@ -99,7 +108,6 @@ async function setup(tasks: boolean) { await ctx.plugin(TaskService) await ctx.plugin(ToolTasks) } - await ctx.plugin(ToolPty) return { ctx, stub, agent: fakeAgent(ctx, tasks ? 'with-tasks' : 'foreground') } } @@ -172,6 +180,24 @@ describe('tool-pty foreground surface', () => { expect(ctx.tools.get('terminal_close')?.presentCall?.({ sessionId: 'pty-1' })).toMatchObject({ card: 'generic', title: 'Close terminal pty-1' }) expect(ctx.tools.get('terminal_list')?.presentCall?.({})).toMatchObject({ card: 'generic', title: 'List terminal sessions' }) }) + + it('configuration-gates background sends and validates the final result bound', async () => { + const disabled = await setup(true, { enableRunInBackground: false }) + const definition = disabled.ctx.tools.get('terminal_send') + expect(definition?.parameters).not.toHaveProperty('properties.run_in_background') + expect(definition?.description).not.toContain('Background mode') + await call(disabled.ctx, 'terminal_open', { type: 'stub' }, disabled.agent) + expect((await call(disabled.ctx, 'terminal_send', { + sessionId: 'pty-1', text: 'work', run_in_background: true, + }, disabled.agent)).isError).toBe(true) + + const defaults = await setupBase(false) + ToolPty.apply(defaults.ctx) + expect(defaults.ctx.tools.get('terminal_send')?.parameters).toHaveProperty('properties.run_in_background') + + const invalid = await setupBase(false) + expect(() => { ToolPty.apply(invalid.ctx, { maxResultBytes: 0 }) }).toThrow('maxResultBytes') + }) }) describe('tool-pty task integration', () => { @@ -184,6 +210,23 @@ describe('tool-pty task integration', () => { expect(text(output)).toContain('[status: completed, wait: stdin_read]') }) + it('bounds foreground and background results after terminal and task metadata', async () => { + const { ctx, agent, stub } = await setup(true, { maxResultBytes: 64 }) + await call(ctx, 'terminal_open', { type: 'stub' }, agent) + stub.sessions[0]!.viewport = '界'.repeat(100) + const foreground = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'foreground' }, agent) + expect(Buffer.byteLength(text(foreground))).toBeLessThanOrEqual(64) + + stub.sessions[0]!.delta = '界'.repeat(100) + stub.sessions[0]!.deltaTruncated = true + await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'background', run_in_background: true }, agent) + const background = await call(ctx, 'task_output', { task_id: 'pty-send-1', wait: true }, agent) + expect(Buffer.byteLength(text(background))).toBeLessThanOrEqual(64) + expect(text(background)).toContain('[status: completed') + expect(text(background).match(/\[output truncated\]/g)).toHaveLength(1) + expect(text(background)).toContain('[output truncated]\n[status: completed') + }) + it('rejects pre-aborted background calls, maps task cancellation, and contains operation failure', async () => { const { ctx, agent, stub } = await setup(true) await call(ctx, 'terminal_open', { type: 'stub' }, agent) diff --git a/packages/pty/tool-pty/tsconfig.json b/packages/pty/tool-pty/tsconfig.json index 7ba9633c2c..9674519034 100644 --- a/packages/pty/tool-pty/tsconfig.json +++ b/packages/pty/tool-pty/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../util/retention" + }, { "path": "../pty" }, diff --git a/packages/tasks/tasks/README.md b/packages/tasks/tasks/README.md index 37d342e0fe..1d9ce2b249 100644 --- a/packages/tasks/tasks/README.md +++ b/packages/tasks/tasks/README.md @@ -4,7 +4,7 @@ The process-local background task registry (`ctx.tasks`). It gives long-running ## Service API -- `start(spec): TaskId` validates the control surface, spec, and exact live owner before calling the producer's `run()` once. A starter throw leaves nothing registered; successful return commits without another failable step. +- `start(spec): TaskId` validates the control surface, spec, exact live owner, and optional positive `outputLimitBytes` before calling the producer's `run()` once. A starter throw leaves nothing registered; successful return commits without another failable step. - `get(id, caller?)` and `list(caller?)` return non-consuming snapshots. Listing includes only caller-owned and unowned tasks. - `read(id, caller?)` consumes the single cursor for stream tasks and reads terminal output idempotently for final-output tasks. - `kill(id, caller?, reason?)` invokes producer cancellation before changing status. A cancellation throw leaves the task running; success changes it to `stopping` and marks terminal delivery reported. @@ -14,6 +14,8 @@ The process-local background task registry (`ctx.tasks`). It gives long-running Owned access compares the task's `SessionId` with the caller's. Ids such as `bash-1` are predictable, so this fence is the boundary. Unowned tasks are open to callers and last until service disposal. +`outputLimitBytes` is producer-owned model-presentation policy carried unchanged into snapshots. A control surface applies it after adding status or notice metadata; the registry does not rewrite producer output or invent a default for producers that omit it. + ## Lifecycle Tasks belong to their owner and backend, not the producer tool fiber, so producer and surface reloads do not stop them. The first task for an owner attaches one awaited effect to the exact `Agent` scope. Owner disposal cancels that object's tasks, awaits producer quiescence, and removes their snapshots; reused agent or session ids cannot redirect an old cleanup. diff --git a/packages/tasks/tasks/src/index.ts b/packages/tasks/tasks/src/index.ts index 457f5473a3..16f0807656 100644 --- a/packages/tasks/tasks/src/index.ts +++ b/packages/tasks/tasks/src/index.ts @@ -42,6 +42,7 @@ interface TrackedTask { id: TaskId kind: TaskKind label: string + outputLimitBytes: number | undefined /** Exact lifecycle owner; session-id authorization is derived from it. */ owner: Agent | undefined cancel: (reason?: string) => void @@ -104,6 +105,10 @@ export class TaskService extends Service { } if (spec.kind.length === 0) throw new Error('invalid task kind: expected a non-empty string') if (spec.label.length === 0) throw new Error('invalid task label: expected a non-empty string') + if (spec.outputLimitBytes !== undefined + && (!Number.isSafeInteger(spec.outputLimitBytes) || spec.outputLimitBytes <= 0)) { + throw new Error(`invalid outputLimitBytes: expected a positive safe integer, got ${JSON.stringify(spec.outputLimitBytes)}`) + } if (spec.owner !== undefined) this.ensureOwnerCleanup(spec.owner) const hooks = spec.run() @@ -117,6 +122,7 @@ export class TaskService extends Service { id, kind: spec.kind, label: spec.label, + outputLimitBytes: spec.outputLimitBytes, owner: spec.owner, cancel: hooks.cancel.bind(hooks), readOutput: hooks.readOutput?.bind(hooks), @@ -329,6 +335,7 @@ export class TaskService extends Service { id: task.id, kind: task.kind, label: task.label, + ...task.outputLimitBytes !== undefined ? { outputLimitBytes: task.outputLimitBytes } : {}, ...ownerSession !== undefined ? { ownerSession } : {}, status: task.status, ...task.detail !== undefined ? { detail: task.detail } : {}, diff --git a/packages/tasks/tasks/src/types.ts b/packages/tasks/tasks/src/types.ts index 96316260ec..d722f3fce6 100644 --- a/packages/tasks/tasks/src/types.ts +++ b/packages/tasks/tasks/src/types.ts @@ -61,6 +61,11 @@ export interface TaskStart { kind: TaskKind /** One-line model-facing label (the command; the delegation description). */ label: string + /** + * Optional UTF-8 byte cap for each complete model-facing completion notice or + * output read, including control-surface status metadata. + */ + outputLimitBytes?: number /** * Owning live agent. Access is fenced by its session id, and agent disposal * cancels and awaits the task. The instance must be the one currently @@ -109,6 +114,8 @@ export interface TaskSnapshot { kind: TaskKind /** The producer-supplied one-line label. */ label: string + /** Producer-owned cap for complete model-facing notices and output reads. */ + outputLimitBytes?: number /** * Owner session id used for authorization and correlation; absent for * unowned tasks. Completion listeners receive the exact {@link Agent} diff --git a/packages/tasks/tasks/tests/tasks.spec.ts b/packages/tasks/tasks/tests/tasks.spec.ts index 0d3eae8338..34506b3a47 100644 --- a/packages/tasks/tasks/tests/tasks.spec.ts +++ b/packages/tasks/tasks/tests/tasks.spec.ts @@ -44,13 +44,19 @@ function producer(overrides: Partial & TaskHooks> = {}) { let settle!: (outcome: TaskOutcome) => void let reject!: (error: unknown) => void const cancels: (string | undefined)[] = [] - const { kind = 'bash', label = 'sleep 60', owner, ...hookOverrides } = overrides + const { kind = 'bash', label = 'sleep 60', owner, outputLimitBytes, ...hookOverrides } = overrides const hooks: TaskHooks = { cancel(reason) { cancels.push(reason) }, done: new Promise((res, rej) => { settle = res; reject = rej }), ...hookOverrides, } - const spec: TaskStart = { kind, label, ...owner !== undefined ? { owner } : {}, run: () => hooks } + const spec: TaskStart = { + kind, + label, + ...owner !== undefined ? { owner } : {}, + ...outputLimitBytes !== undefined ? { outputLimitBytes } : {}, + run: () => hooks, + } return { spec, settle, reject, cancels } } @@ -85,10 +91,11 @@ describe('TaskService.start', () => { .toThrow('background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)') }) - it('rejects an empty kind and an empty label', async () => { + it('rejects an empty kind, empty label, and invalid output limit', async () => { const ctx = await harness() expect(() => ctx.tasks.start(producer({ kind: '' as TaskKind }).spec)).toThrow('invalid task kind') expect(() => ctx.tasks.start(producer({ label: '' }).spec)).toThrow('invalid task label') + expect(() => ctx.tasks.start(producer({ outputLimitBytes: 0 }).spec)).toThrow('outputLimitBytes') }) it('issues kind-prefixed ids from per-kind counters', async () => { @@ -118,6 +125,16 @@ describe('TaskService reads and settlement', () => { expect(read.snapshot.finishedAt).toBeTypeOf('number') }) + it('projects a producer-owned model output limit into reads and snapshots', async () => { + const ctx = await harness() + const p = producer({ outputLimitBytes: 64, readOutput: () => 'delta' }) + const id = ctx.tasks.start(p.spec) + expect(ctx.tasks.read(id)).toMatchObject({ + text: 'delta', snapshot: { outputLimitBytes: 64 }, + }) + expect(ctx.tasks.get(id)).toMatchObject({ outputLimitBytes: 64 }) + }) + it('final-output kinds read empty while live, the outcome output idempotently once settled', async () => { const ctx = await harness() const p = producer({ kind: 'subagent', label: 'research task' }) diff --git a/packages/tasks/tool-tasks/README.md b/packages/tasks/tool-tasks/README.md index 7e1ab82971..f4a3475786 100644 --- a/packages/tasks/tool-tasks/README.md +++ b/packages/tasks/tool-tasks/README.md @@ -10,6 +10,8 @@ The model-facing control surface for `ctx.tasks`: three kind-independent tools, All three use generic ACP cards: `read` for output and list, `execute` for kill. +When a producer supplies `outputLimitBytes`, `task_output`, terminal `task_kill`, and completion notices cap the complete UTF-8 result after adding status or notice text. The output tail and control suffix are retained when they fit; an existing producer truncation marker is reused rather than duplicated. Producers that omit the field retain the existing unbounded control-surface behavior. + ## Completion notices An unreported completion injects `background task (: