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 de619790dc..86c05cb07c 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: db161a865f1c30fc3979e1a92dc6d427ecb58f25 -2026-07-16-persistent-pty-sessions.zh.md: 06c902437aee2e5725e1765840dce03ae15c30a4 +2026-07-16-persistent-pty-sessions.md: 689019d56d719884761407f288e1e765dd19c35d +2026-07-16-persistent-pty-sessions.zh.md: 14490137a003e2ca67b594628e506f0b74e3d4b7 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 db161a865f..689019d56d 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 @@ -62,7 +62,7 @@ The ACP render contract is exact and location-free. `terminal_send` uses termina `terminal_send({ sessionId, text, submit?, run_in_background? })` treats `text` as UTF-8 bytes and resolves `submit` to `true` in the tool implementation. When `submit` is true it writes the platform Enter sequence after the text; when false it writes only the text, allowing control characters and REPL fragments without hidden content heuristics. `enableRunInBackground` defaults to true; false removes `run_in_background` from the schema and rejects the same undeclared argument if a caller forces it through execution. -Foreground sends return a bounded rendered delta and two independent facts: `waitReason` (`stdin_read | inferred_idle | timeout | session_exit`) and `sessionStatus` (`running` or `exited` with exit code or signal). `session_exit` refers to the PTY's top-level shell process, not an arbitrary foreground command whose status the shell consumes. A timeout never implies process exit. `dsh-tool-pty.maxResultBytes` defaults to 262144 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. +Foreground sends return a bounded rendered delta and two independent facts: `waitReason` (`stdin_read | inferred_idle | timeout | session_exit`) and `sessionStatus` (`running` or `exited` with exit code or signal). `session_exit` refers to the PTY's top-level shell process, not an arbitrary foreground command whose status the shell consumes. A timeout never implies process exit. `dsh-tool-pty.maxResultBytes` defaults to 262144, rejects values below 64 so creation acknowledgements retain registry-issued ids, and caps the complete UTF-8 result after normalized errors, 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. 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. 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 06c902437a..14490137a0 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 @@ -62,7 +62,7 @@ ACP 渲染契约精确且不携带位置信息。`terminal_send` 只为前台发 `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`,调用方即使强行把这个未声明参数传入执行流程,也会被拒绝。 -前台发送返回有界的渲染增量和两个独立事实:`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 状态包装后,仍受该值限制。渲染器会为后缀预留空间并保持代码点边界,而不会把后端载荷上限当作面向模型结果的最终上限。 +前台发送返回有界的渲染增量和两个独立事实:`waitReason`(`stdin_read | inferred_idle | timeout | session_exit`)与 `sessionStatus`(`running`,或携带退出码或信号的 `exited`)。`session_exit` 指 PTY 顶层 shell 进程退出,不指由 shell 消费状态的任意前台命令。timeout 从不意味着进程已经退出。`dsh-tool-pty.maxResultBytes` 默认为 262144;低于 64 的值会被拒绝,以确保创建确认保留 registry 签发的 id;完整 UTF-8 结果在加入规范化错误、等待与会话状态、分页与截断元数据以及通用 task 状态包装后,仍受该值限制。渲染器会为后缀预留空间并保持代码点边界,而不会把后端载荷上限当作面向模型结果的最终上限。 当 `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。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2bb16b2821..3d46a0faf2 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1383,7 +1383,7 @@ export interface Config { } ``` -Source: [`packages/pty/tool-pty/src/index.ts:33`](../packages/pty/tool-pty/src/index.ts) +Source: [`packages/pty/tool-pty/src/index.ts:44`](../packages/pty/tool-pty/src/index.ts) ## `@deepseek-ai/dsh-tool-ralph` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index feb6fbd64b..24f0f9b252 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -42,7 +42,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:123`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:93`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:93`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy), [`tool-pty`](../packages/pty/tool-pty) | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:105`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`workspace-context`](../packages/context/workspace-context) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:82`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | diff --git a/packages/pty/tool-pty/README.md b/packages/pty/tool-pty/README.md index 0857c5d3ee..8ec8601b6b 100644 --- a/packages/pty/tool-pty/README.md +++ b/packages/pty/tool-pty/README.md @@ -9,9 +9,9 @@ Six model-facing tools over `ctx.pty`: `terminal_open`, `terminal_send`, `termin | 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 | +| `maxResultBytes` | `262144` | UTF-8 cap (minimum `64`) 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. +Both values are validated at load. The minimum result cap keeps every registry-issued session or task id visible in its creation acknowledgement. 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 @@ -53,7 +53,7 @@ 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. 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. +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 normalized error text and generic task status text. Results remain in session history until compaction; incremental task reads do not repeat consumed output. #### Token effect diff --git a/packages/pty/tool-pty/src/index.ts b/packages/pty/tool-pty/src/index.ts index edb8102a79..5e75c97bec 100644 --- a/packages/pty/tool-pty/src/index.ts +++ b/packages/pty/tool-pty/src/index.ts @@ -28,6 +28,17 @@ export const inject = ['pty', 'tools', 'systemPrompt'] /** Default cap for one complete model-facing terminal result. */ export const DEFAULT_MAX_RESULT_BYTES = 256 * 1024 +/** Smallest cap that preserves every counter-backed PTY and task id in its creation acknowledgement. */ +export const MIN_MAX_RESULT_BYTES = 64 + +const TOOL_NAMES = new Set([ + 'terminal_open', + 'terminal_send', + 'terminal_read', + 'terminal_signal', + 'terminal_close', + 'terminal_list', +]) /** Model-facing terminal tool configuration. */ export interface Config { @@ -40,7 +51,7 @@ export interface Config { /** 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), + maxResultBytes: z.number().step(1).min(MIN_MAX_RESULT_BYTES).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_RESULT_BYTES), }) interface SpawnArgs { @@ -100,9 +111,15 @@ function sendDetail(result: PtySendResult): string { 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') + if (!Number.isSafeInteger(maxResultBytes) || maxResultBytes < MIN_MAX_RESULT_BYTES) { + throw new Error(`tool-pty: maxResultBytes must be a safe integer of at least ${MIN_MAX_RESULT_BYTES}`) } + ctx.on('tools/execute', async (exec, next): Promise => { + const result = await next() + if (!TOOL_NAMES.has(exec.name)) return result + const raw = rawResultText(result) + return raw === undefined ? result : { ...result, content: textResult(raw, maxResultBytes) } + }) ctx.systemPrompt.section({ name: 'tool:pty', order: 106, diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index d6023a4d17..0466b02134 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/packages/pty/tool-pty/tests/tools.spec.ts @@ -197,6 +197,32 @@ describe('tool-pty foreground surface', () => { const invalid = await setupBase(false) expect(() => { ToolPty.apply(invalid.ctx, { maxResultBytes: 0 }) }).toThrow('maxResultBytes') + expect(() => { ToolPty.apply(invalid.ctx, { maxResultBytes: 63 }) }).toThrow('at least 64') + }) + + it('bounds normalized errors and preserves allocated ids at the minimum result cap', async () => { + const { ctx, agent } = await setup(true, { maxResultBytes: 64 }) + const failed = await call(ctx, 'terminal_open', { type: 'x'.repeat(1_000) }, agent) + expect(failed.isError).toBe(true) + expect(Buffer.byteLength(text(failed))).toBeLessThanOrEqual(64) + expect(text(failed)).toContain('[output truncated]') + + const opened = await call(ctx, 'terminal_open', { type: 'stub', name: 'n'.repeat(1_000) }, agent) + expect(text(opened)).toContain('pty-1') + expect(Buffer.byteLength(text(opened))).toBeLessThanOrEqual(64) + const background = await call(ctx, 'terminal_send', { + sessionId: 'pty-1', text: 'work', run_in_background: true, + }, agent) + expect(text(background)).toContain('pty-send-1') + expect(Buffer.byteLength(text(background))).toBeLessThanOrEqual(64) + }) + + it('leaves a structured around-dispatch replacement unchanged', async () => { + const { ctx, agent } = await setup(false, { maxResultBytes: 64 }) + ctx.on('tools/execute', async (exec, next) => exec.name === 'terminal_list' + ? { content: [], isError: false } + : next()) + expect((await call(ctx, 'terminal_list', {}, agent)).content).toEqual([]) }) })