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 f7e242b78c..a6877c35ab 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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: 148d4a2f47689e38a3ec83a7a41e4f75c4b73d95 -2026-07-16-persistent-pty-sessions.zh.md: 9a9d9cd4b0f61e8abaf011996ecd8739d13851f8 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +2026-07-16-persistent-pty-sessions.md: 72d903828f8152009a2dd3433d4f7c86b7382ba6 +2026-07-16-persistent-pty-sessions.zh.md: 27dce02618ab7908f38a6b613c109fcbb4b3854c 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 148d4a2f47..72d903828f 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 @@ -80,6 +80,8 @@ 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. +Once a send settles under any tier, `PtySendOperation.append` stops accepting output, so later child output reaches only the scrollback. A test that waits for a marker on the operation must therefore set `idleSilenceMs` and `timeoutMs` above the child's own startup latency; interpreter startup on a loaded macOS runner otherwise ends the send before the marker is printed. + `node-pty` data notifications feed one terminal parser. Parser carry state handles control sequences and a trailing carriage return split across callbacks, so a divided CRLF produces one newline rather than a pagination-changing blank line. The implementation normalizes line-oriented output, but it does not promise correct interaction with a full-screen application. ### Model-visible output and durability 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 9a9d9cd4b0..27dce02618 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 @@ -80,6 +80,8 @@ macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 ` Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 sleep 或网络阻塞的命令可能看似 ready。Tier 3 在 `timeoutMs` 后返回 `timeout`,避免前台工具调用无限占住 agent。结果保留这些区别;调用方可以通过 `ctx.tasks` 等待、向前台组发信号,或从另一个会话排查。 +一次 send 在任一层级 settle 之后,`PtySendOperation.append` 就不再接受输出,此后子进程的输出只会进入 scrollback。因此,在 operation 上等待标记的测试必须把 `idleSilenceMs` 与 `timeoutMs` 设得高于子进程自身的启动耗时;否则在负载较高的 macOS runner 上,解释器启动会在标记打印之前就结束这次 send。 + `node-pty` data 通知进入同一个终端 parser。parser 的 carry state 会处理跨 callback 的控制序列和位于 callback 末尾的回车;因此,即使 CRLF 被拆开,也只会生成一个换行,而不会产生改变分页的空行。实现会规范化行式输出,但不承诺正确操作全屏应用。 ### 模型可见输出与持久性 diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index 6ba3a95757..2f60cf1918 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -39,7 +39,10 @@ function stubAgent(ctx: Context, rawId: string): Agent { } } -async function harness(mode: 'danger-full-access' | 'workspace-write') { +async function harness( + mode: 'danger-full-access' | 'workspace-write', + overrides: { idleSilenceMs?: number; timeoutMs?: number } = {}, +) { const root = mkdtempSync(join(tmpdir(), 'dsh-pty-local-')) roots.push(root) const ctx = new Context() @@ -51,8 +54,8 @@ async function harness(mode: 'danger-full-access' | 'workspace-write') { const fiber = await ctx.plugin(ptyLocal, { pollIntervalMs: 10, exactProbeAfterMs: 20, - idleSilenceMs: 250, - timeoutMs: 2000, + idleSilenceMs: overrides.idleSilenceMs ?? 250, + timeoutMs: overrides.timeoutMs ?? 2_000, disposeGraceMs: 500, scrollbackLines: 100, scrollbackMaxBytes: 32_768, @@ -63,8 +66,10 @@ async function harness(mode: 'danger-full-access' | 'workspace-write') { return { ctx, root, agent, fiber, sandbox: ctx.sandbox as PassthroughSandbox } } +// PtySendOperation.append drops output once the operation settles, so this only +// observes a marker the child prints while the send is still active. async function waitForOutput(operation: PtySendOperation, expected: string): Promise { - const deadline = Date.now() + 2_000 + const deadline = Date.now() + 5_000 let output = '' while (!output.includes(expected) && Date.now() < deadline) { output += operation.readOutput().delta @@ -132,7 +137,13 @@ describe('pty-local real shell', () => { }, 10_000) it('cancels a raw-mode foreground process with a real SIGINT', async () => { - const { ctx, agent } = await harness('danger-full-access') + // A cold `python3` start can stay silent for longer than the 250 ms default + // this harness uses, which settles the send as inferred_idle before the + // interpreter prints its readiness marker; the marker then reaches only the + // scrollback and waitForOutput sees the echoed command line alone. Raise the + // silence bound, and the absolute bound above it, so process startup cannot + // end the send it belongs to. + const { ctx, agent } = await harness('danger-full-access', { idleSilenceMs: 4_000, timeoutMs: 6_000 }) const created = await ctx.pty.spawn(agent, { type: 'shell' }) const controller = new AbortController() const ready = 'RAW_READY' @@ -155,5 +166,5 @@ describe('pty-local real shell', () => { expect(after.viewport).toContain('AFTER_SIGINT') expect(after.waitReason).toBe('stdin_read') await ctx.pty.kill(agent, created.sessionId) - }, 10_000) + }, 20_000) })