Skip to content

feat(core): add signal.reason convention for ShellExecutionService (#3831 PR-1 of 3)#3842

Merged
wenshao merged 9 commits into
mainfrom
feat/shell-promote-signal-reason
May 7, 2026
Merged

feat(core): add signal.reason convention for ShellExecutionService (#3831 PR-1 of 3)#3842
wenshao merged 9 commits into
mainfrom
feat/shell-promote-signal-reason

Conversation

@wenshao

@wenshao wenshao commented May 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

PR-1 of 3 implementing Phase D part (b) of #3831 — Ctrl+B promote of a running foreground shell to background. Pure plumbing, zero behavior change for existing callers (no caller sets the new reason yet).

Defines a discriminated ShellAbortReason union that an AbortController.abort(reason) can carry into ShellExecutionService.execute():

export type ShellAbortReason =
  | { kind: 'cancel' }
  | { kind: 'background'; shellId?: string };
  • Default (no reason set, or { kind: 'cancel' }) → unchanged tree-kill on abort.
  • { kind: 'background' } → takeover signal: execute() skips the kill, drops the child from its active set (so ShellExecutionService.cleanup() won't kill it later either), flushes a snapshot of captured output into the result, and resolves the result Promise immediately with promoted: true so the awaiting caller unblocks. The caller has accepted ownership of the live child.

ShellExecutionResult gains an optional promoted?: boolean field; existing consumers compile against the new shape without source changes.

Review status: 22 review threads across 5 review rounds, all resolved. Round 1 (commit 8e8e18ca7, 6 threads — 4 Critical + 2 Suggestion): introduced dispose-based listener detach (named handler refs for child_process; node-pty IDisposable for PTY's onData/onExit; named ref + off() for ptyProcess.on('error')). Round 2 (9008717b8, 6 threads): defensive hardening — exhaustive-switch on ShellAbortReason.kind, per-dispose try/catch, .catch(() => undefined) on the drain race so a chain rejection doesn't leak unhandled, dedicated PROMOTE_DRAIN_TIMEOUT_MS constant separated from SIGKILL_TIMEOUT_MS, drain timeout sentinel + warn, snapshot serialize warn. Round 3 (094fff09c, 3 threads): real bugptyProcess.off('error', …) throws TypeError on real @lydell/node-pty (only removeListener is exposed), switched + regression-guarded; prototype-pollution-safe own-property kind read. Round 4 (4cc558b3d, 6 threads — 1 Critical + 5): throw-safe abort-reason read (own getters / Proxy traps wrapped in try/catch), child_process post-exit liveness check, encoding-aware PTY snapshot via re-decode + replay, removed unreachable default-warn (kept _exhaustive: never for compile-time safety only), 8 helper boundary tests. Round 5 (289feaa74, 1 Critical): mirrored child_process race guard into PTY via process.kill(pid, 0) liveness probe. Implementation notes below describe the final ownership model.

Why this PR alone

Full sequence per #3831:

  • PR-1 (this) — ~210 LOCsignal.reason foundation + reason-aware abort handling. Zero behavior change.
  • PR-2 (~280 LOC) — shell.ts integration: lazy promote, BackgroundShellRegistry.promoteFromForeground(...), output buffer flush + stream redirect.
  • PR-3 (~120 LOC) — Command.PROMOTE_TO_BACKGROUND keybinding (Ctrl+B), AppContainer wire-up, E2E tests.

#3831's design discussion is open. Pushing PR-1 because the foundation has zero user-visible behavior change and is independently mergeable / revertable — surfacing the contract in code form alongside the issue. Reviewer can sanity-check the abort-handler shape independently from the lazy-promote semantics in PR-2.

Caller contract

Callers wishing to promote MUST attach their own listeners (data / exit / error) to the live child before calling abortController.abort({ kind: 'background', ... }). After the background-abort, execute() no longer routes events through its own handlers' downstream consumers. The caller seeing result.promoted === true knows ownership has been transferred — it must NOT treat result.exitCode / result.signal as terminal (the underlying process has not exited).

Design rationale (why signal.reason, not a new parameter)

Three alternatives were considered (full discussion in #3831):

  1. New execute() parameter like onPromoteRequest callback or promotionMode flag — adds an always-present API surface for a rare path; couples the takeover lifecycle into the call signature; harder to extend later (would need a 2nd parameter for shellId, etc.).
  2. Pre-allocate hidden BackgroundShellEntry for every foreground spawn, then flip visible: true on Ctrl+B — touches ShellExecutionService heavily (output stream conditionally file-vs-memory throughout) for a feature that fires <1% of the time. Wasteful in the common case.
  3. signal.reason as takeover channel (this PR) — reuses existing AbortController plumbing every caller already has; the discrimination is a 5-line addition to the existing abort handler; zero overhead in the common case (no Ctrl+B → no promote logic runs); leverages Node's native AbortSignal.reason (Node 17.2+, our engines: >=20 covers it).

Option 3 was preferred in the #3831 design issue. This PR implements that choice.

Why resolve the Promise immediately on background-abort (vs. wait for natural child exit): blocking the caller's await until the (potentially long-running) child exits would defeat the entire purpose — the caller needs to unblock so the agent can move on. Once the caller has accepted ownership (per the contract above), the result Promise's job is done.

Implementation notes

  • getShellAbortReasonKind(reason) helper (exported) reads signal.reason.kind defensively: rejects non-object reasons; reads kind only as an OWN property via hasOwnProperty.call (prototype-pollution defense — Object.prototype.kind = 'background' cannot force the promote branch); wraps the read in try/catch so an own getter / Proxy trap that throws can't leak past the async abort handler (which is dispatched via addEventListener and not awaited — a leaked throw would leave the shell process alive instead of killed); whitelists the value against the known union members and defaults to 'cancel' for everything else (safe historical kill behavior).
  • Both abort handlers branch via exhaustive switchexecuteWithPty and childProcessFallback extract performBackgroundPromote / performCancelKill named helpers and switch (kind) between them. The default: { const _exhaustive: never = kind; ... } arm forces a compile-time error if ShellAbortReason ever grows a new variant — directing the developer to (1) extend the helper's whitelist and (2) add a case arm. (The runtime default branch is unreachable today; the assertion is the static-only safety net.)
  • activeChildProcesses / activePtys set deletion — critical so ShellExecutionService.cleanup() (the process-exit handler that wipes active sets) won't kill the promoted child on Ctrl+C or process shutdown later.
  • Race guards before detach — both paths check terminal state right before disposing:
    • childProcess: if (child.exitCode !== null || child.signalCode !== null) — fall through to the normal exit path. The child may have already exited but the 'exit' event hasn't reached our handler yet (Node delivers child_process events on the next microtask); promoting in that window would detach our exit listener and report promoted: true for a process that's already dead.
    • PTY: if (!ShellExecutionService.isPtyActive(ptyProcess.pid)) — node-pty's IPty doesn't expose exitCode, so we use process.kill(pid, 0) (the existing helper) as a best-effort liveness probe. If the pid is gone, fall through and let the pending onExit callback resolve normally with the real exit status.
  • Listener detach is dispose-based, not flag-based:
    • childProcess: extracted named handler refs (stdoutHandler / stderrHandler / errorHandler / exitHandler); the abort handler calls detachServiceListeners() which off()s all four. Without this, post-promote bytes re-enter handleOutput, which calls decoder.decode() on the now-finalized text decoder (cleanup() called .decode() without stream:true) → TypeError crash. Even without the crash, the old onOutputEvent would fire for new data → ownership contract violation + duplication.
    • PTY: node-pty's onData / onExit return IDisposable handles; the abort handler captures dataDisposable / exitDisposable and calls .dispose() — each in its own try/catch (the IDisposable contract doesn't guarantee no-throw, and a single throwing dispose must not skip subsequent teardown). ptyProcess.on('error') is EventEmitter-style — extracted named ptyErrorHandler ref and removeListener'd (NOT off()@lydell/node-pty's IPty interface only exposes the legacy removeListener; .off() throws TypeError on a real PTY). Without these, post-promote PTY error throws → Node.js process crash; post-promote data continues writing to headlessTerminal and calling old onOutputEvent → ownership violation.
    • Net result: post-promote, our service-side listeners simply never fire. There's no "wasted no-op work" — there's no work at all.
  • In-flight processingChain ownershipprocessingChain may have already-enqueued callbacks past the dispose point. listenersDetached flag guards each onOutputEvent emit individually inside the chain callbacks. This way in-flight writes still LAND in headlessTerminal (so the snapshot reflects them), but no events leak to the foreground onOutputEvent. Also clear renderTimeout in the abort handler so a pending throttled render doesn't fire post-promote.
  • Drain race + dedicated PROMOTE_DRAIN_TIMEOUT_MS constant — abort handler awaits Promise.race([processingChain.then(drain).then(drain).catch(() => undefined), timeout]) before serialization (mirrors the onExit finalize pattern at line ~970) so in-flight headlessTerminal.write callbacks land before we read the buffer. The chain side has .catch(() => undefined) so a chain rejection becomes the resolved branch instead of an unhandled rejection (abort handlers run via addEventListener which doesn't await our return, so a leaked rejection would mean resolve() is never called and the caller hangs). Race result is observed via a Symbol sentinel; if the timeout side wins, debugLogger.warn fires pointing the caller at rawOutput as the un-truncated fallback. The drain timeout has its own constant separate from SIGKILL_TIMEOUT_MS so tuning kill escalation doesn't silently change drain behavior.
  • Encoding-aware PTY snapshotresult.output for the promoted PTY is the same shape as the normal exit path: re-decode finalBuffer with getCachedEncodingForBuffer(finalBuffer) + replayTerminalOutput(...). Direct serializeTerminalToText(headlessTerminal) would risk mojibake when early output is ASCII-only but later output is in a different encoding (GBK / Shift-JIS). Direct serialize stays as a last-ditch fallback if replay throws. Deliberately does NOT call render(true)renderFn emits via onOutputEvent, which would violate the "no emit post-promote" invariant.
  • Snapshot semanticsresult.output for the promoted result is the output captured up to the moment of abort (text decoders flushed for child_process via cleanup(); encoding-aware re-decode + replay for PTY after drain). Caller (PR-2) uses this to seed the BackgroundShellEntry's on-disk output file before installing its own data listener for subsequent output.
  • streamStdout: true + { kind: 'background' } is a latent empty-snapshot combo (snapshot reads from string accumulators that streamStdout mode bypasses). Today PR-1's only caller uses streamStdout: false so the combination is unreachable, but debugLogger.warn fires if a future caller pairs them, pointing at rawOutput as the fallback.
  • Defensive ?? '' on PTY snapshotserializeTerminalToText may return undefined under unusual conditions (mid-render race, or in test mocks); the fallback keeps result.output typed as string.

Test coverage

shellExecutionService.test.ts: 69 / 69 pass (50 baseline + 19 new across five review rounds).

First-pass — discrimination (round 1, 4 tests):

  • PTY: { kind: 'cancel' } still tree-kills via process.kill(-pid, 'SIGTERM'); { kind: 'background' } skips kill — mockProcessKill NOT called with SIGTERM/SIGKILL on group pid; mockPtyProcess.kill NOT called; result.promoted === true; result.exitCode === null; result.signal === null.
  • child_process fallback: { kind: 'cancel' } still tree-kills (mirror of PTY); { kind: 'background' } skips kill — output captured pre-abort preserved as snapshot in result.output (test emits 'line1\nline2\n' via stdout, then aborts; verifies snapshot contains both lines).

Handoff-boundary (round 1, 2 tests):

  • PTY: post-promotion, mockPtyProcess.onData.mock.results[0].value.dispose and mockPtyProcess.onExit.mock.results[0].value.dispose ARE called — verifying the disposable handles returned by node-pty are correctly disposed; result shape stays promoted: true even when subsequent events would otherwise fire.
  • child_process: post-promotion, emitting more 'data' on the live cp.stdout / cp.stderr does NOT increase onOutputEvent.mock.calls.length; emitting 'exit', 42, null does NOT change result.exitCode (stays null). Pre-promote bytes ARE in the snapshot; post-promote bytes are NOT.

Post-exit race guards (round 4, 2 tests):

  • child_process: pretend child.exitCode was set to 0 BEFORE 'exit' reaches our handler, abort with { kind: 'background' } — production race guard detects the terminal state and falls through; result has no promoted: true, exitCode: 0 flows through.
  • PTY: mock process.kill(pid, 0) to throw ESRCH on the liveness probe, abort with { kind: 'background' } — production guard refuses to promote a dead PTY; result has no promoted: true.

removeListener regression guard (round 3, 1 test):

  • Spies both mockPtyProcess.removeListener and mockPtyProcess.off, asserts production teardown calls removeListener('error', …) and NOT off('error', …)@lydell/node-pty's IPty interface only exposes removeListener, not the modern off alias. The EventEmitter mock tolerates both, so without this guard a future swap to .off() would silently regress under tests but throw TypeError on a real PTY.

getShellAbortReasonKind boundary tests (round 4, 8 tests, helper exported for direct testing):

  • null / undefined reason → 'cancel'
  • non-object reasons (string / number / boolean / Error-instance) → 'cancel'
  • empty object (no own kind) → 'cancel'
  • prototype-only kind (Object.create({ kind: 'background' })) → 'cancel' (pollution defense)
  • unknown kind value (typo / future-untyped variant) → 'cancel'
  • throwing accessor (Object.defineProperty('kind', { get() { throw } })) → 'cancel'
  • Proxy with throwing get trap → 'cancel'
  • canonical { kind: 'background' } / { kind: 'cancel' } happy paths

Test fixture hygiene (round 4, 2 mock-setup adjustments):

  • Both child_process fallback and execution method selection beforeEach hooks set mockChildProcess.exitCode / signalCode to null (matches real Node ChildProcess shape — alive process has these as null, not undefined). Without this, the new race guard child.exitCode !== null would silently misfire on undefined.
  • mockPtyProcess.onData / .onExit return { dispose: vi.fn() } so the production dataDisposable.dispose() / exitDisposable.dispose() calls don't crash on undefined.dispose(). The stub's .mock.results[0].value is then inspected by the handoff-boundary tests.

Test plan

  • `vitest run packages/core/src/services/shellExecutionService.test.ts`: 69 / 69 pass (50 baseline + 19 new across five review rounds)
  • `tsc --build packages/cli` (CI-equivalent full mode): clean
  • `npm run build --workspace=@qwen-code/qwen-code-core`: clean
  • ESLint: clean
  • Manual (deferred — needs PR-2 caller to exercise): foreground bash + Ctrl+B → child process kept alive, registry entry created. PR-1 alone has no user-visible surface to manually test.

Related

中文版

PR-1 of 3 — 实现 #3831 的 Phase D 第 (b) 部分:Ctrl+B 把运行中的前台 shell 甩到后台。纯 plumbing,对现有调用方零行为变化(没人传新 reason)。

定义一个 discriminated union ShellAbortReason,让 AbortController.abort(reason) 携带进 ShellExecutionService.execute()

export type ShellAbortReason =
  | { kind: 'cancel' }
  | { kind: 'background'; shellId?: string };
  • 默认(不传 reason 或 { kind: 'cancel' })→ 维持现状的 tree-kill。
  • { kind: 'background' } → takeover 信号:execute() 跳过 kill,把 child 从 active 集合移除(防 ShellExecutionService.cleanup() 后续杀它),flush 一份已捕获 output 的 snapshot 进 result,立即 resolve result Promise 带 promoted: true 让 await 中的调用方解锁。调用方已经接管了运行中的 child。

ShellExecutionResult 加可选字段 promoted?: boolean,现有消费方不用改源码就能编译通过。

为什么 PR-1 单独推(不等 #3831 alignment):foundation 零用户可见行为变化,独立可合可回退;用 PR 把契约具象化呈现给 reviewer,跟 issue 的设计讨论对照。Reviewer 可以在不看 PR-2 lazy-promote 语义的情况下独立 sanity-check abort handler 的形状。

调用方契约:想 promote 的调用方必须在调 abortController.abort({ kind: 'background', ... }) 之前给 live child attach 自己的 listener(data / exit / error)。background-abort 之后,execute() 不再把事件路由给自己 handler 的下游消费方。调用方看到 result.promoted === true 就知道所有权已交接 — 不能result.exitCode / result.signal 当作终态(底层进程没真退出)。

设计 rationale(为啥用 signal.reason 不加新参数):考虑过 3 个选项(详细在 #3831):(1) 给 execute() 加新参数 — 给罕见路径加常驻 API 表面,耦合 takeover 生命周期到调用签名;(2) 给每个前台 spawn 预分配 hidden BackgroundShellEntry 然后 Ctrl+B 翻 visible: true — 改 ShellExecutionService 太多(输出流条件 file-vs-memory),common case 浪费;(3) signal.reason 作 takeover 通道(本 PR)— 复用每个调用方都有的 AbortController plumbing,区分逻辑是 5 行加在现有 abort handler 里,common case 零开销,用 Node 原生 AbortSignal.reason(Node 17.2+,我们 engines: >=20 覆盖)。#3831 design 倾向 (3),本 PR 实现这个选择。

为什么 background-abort 立刻 resolve Promise(不是等 child 自然 exit):让调用方 await 阻塞到(可能长跑的)child exit 就完全违背初衷 — 调用方需要解锁让 agent 继续。一旦调用方接管了所有权(按上面契约),result Promise 的使命完成。

Review 状态5 轮 review 累计 22 个 thread 全部 resolved。第 1 轮(8e8e18ca7,6 个 — 4 Critical + 2 Suggestion):引入 dispose-based listener detach。第 2 轮(9008717b8,6 个):defensive hardening — 穷尽 switch、per-dispose try/catch、drain race .catch 防 unhandled rejection、独立 PROMOTE_DRAIN_TIMEOUT_MS、drain timeout sentinel/warn、snapshot serialize warn。第 3 轮(094fff09c,3 个):真 bugptyProcess.off('error')@lydell/node-pty 抛 TypeError(只暴露 removeListener),改 + 加 regression guard;prototype-pollution-safe own-property kind 读。第 4 轮(4cc558b3d,6 个 — 1 Critical + 5):throw-safe abort-reason 读(own getter / Proxy trap try/catch 包)、child_process post-exit liveness check、encoding-aware PTY snapshot(re-decode + replay)、删 unreachable default-warn(保 _exhaustive: never 静态检查)、8 helper 边界测试。第 5 轮(289feaa74,1 Critical):PTY 同款 race guard via process.kill(pid, 0) liveness probe。下面"实现要点"描述最终 ownership 模型。

实现要点

  • getShellAbortReasonKind(reason) helper(已 export)防御性读 signal.reason.kind:拒绝非对象 reason;用 hasOwnProperty.call 只读 OWN property(防原型污染 — Object.prototype.kind = 'background' 不能进 promote);try/catch 包读取(防 own getter / Proxy trap 抛错穿透 async abort handler — addEventListener 不 await,泄漏 throw 会让 shell 进程留活而非被 kill);返值白名单到 union 成员,未知值默认 'cancel'(安全历史 kill 行为)。
  • 两个 abort handler 用穷尽 switchexecuteWithPty + childProcessFallback 提取 performBackgroundPromote / performCancelKill 命名 helper,switch (kind) 分支。default: { const _exhaustive: never = kind; ... }ShellAbortReason union 扩展时强制 TS 报错 — 提示开发者扩 helper 白名单 + 加 case。(runtime default 不可达,仅静态保护。)
  • activeChildProcesses / activePtys 集合 delete 关键:防 ShellExecutionService.cleanup()(process exit handler 清 active 集合)在 Ctrl+C 或 process shutdown 时杀掉 promoted child。
  • Detach 之前 race guards:两路径都在 dispose 前检 terminal 状态:
    • child_processif (child.exitCode !== null || child.signalCode !== null) → fall through 走正常 exit 路径。child 可能已 exit 但 'exit' 事件还没 reach handler(Node 异步派发);该窗口 promote 会 detach exit listener 上报 promoted: true 给已死进程。
    • PTYif (!ShellExecutionService.isPtyActive(ptyProcess.pid))。node-pty 的 IPty 不暴露 exitCode,用 process.kill(pid, 0)(现有 helper)作 best-effort liveness probe。pid 不存在 → fall through,让 pending onExit 走真实 exit。
  • Listener detach 是 dispose-based 不是 flag-based
    • child_process:提取 named handler refs (stdoutHandler / stderrHandler / errorHandler / exitHandler);abort handler 调 detachServiceListeners() 把 4 个全 off()。否则 post-promote 字节会 re-enter handleOutput → 它会调 decoder.decode() 在已 finalize 的 decoder(cleanup() 已调 .decode() no stream:true)→ TypeError crash。即使没 crash,老 onOutputEvent 也会 fire → 违反 ownership + duplicate。
    • PTY:node-pty 的 onData / onExitIDisposable;abort handler 拿 dataDisposable / exitDisposable.dispose() — 每个独立 try/catch(IDisposable contract 不保证 no-throw,单一 dispose 抛错不能跳过后续 teardown)。ptyProcess.on('error') 是 EventEmitter 风格 — 提取 named ptyErrorHandler ref + removeListener不是 off()@lydell/node-pty 的 IPty 接口只暴露 legacy removeListener.off() 在真 PTY 抛 TypeError)。否则 post-promote PTY error throw → Node.js 进程 crash;post-promote data 继续写 headlessTerminal 调老 onOutputEvent → 违反 ownership。
    • 净效果:post-promote service-side listener 完全不 fire — 不是"wasted no-op work",根本没 work
  • In-flight processingChain ownership — chain 可能有 dispose 之前已 enqueue 的 callback。listenersDetached flag 守每个 chain callback 内的 onOutputEvent emit。这样 in-flight write 仍 LAND 进 headlessTerminal(snapshot 反映它们),但事件不 leak 到老 onOutputEvent。abort handler 内也清 renderTimeout 防 pending 节流 render fire。
  • Drain race + 独立 PROMOTE_DRAIN_TIMEOUT_MS 常量 — abort handler await Promise.race([processingChain.then(drain).then(drain).catch(() => undefined), timeout]) 后再 serialize(镜像 onExit 的 finalize pattern at line ~970)让 in-flight headlessTerminal.write callback 在读 buffer 之前 land。chain 边带 .catch(() => undefined),让 chain rejection 变 resolved 而非 unhandled rejection(abort handler 经 addEventListener 不被 await,泄漏 rejection → resolve 永不调 → caller 死锁)。Race 结果用 Symbol sentinel 检测;timeout 边赢 → debugLogger.warn 指向 rawOutput 作未截断 fallback。drain timeout 用独立常量与 SIGKILL_TIMEOUT_MS 解耦,调一不动另一。
  • Encoding-aware PTY snapshot — promoted PTY 的 result.output 跟正常 exit 同形:用 getCachedEncodingForBuffer(finalBuffer) + replayTerminalOutput(...) 重新解码 finalBuffer。直接 serializeTerminalToText(headlessTerminal) 在早期 ASCII 但后段 GBK / Shift-JIS 时会 mojibake。replay 抛错时直接 serialize 作 last-ditch fallback。故意不render(true)renderFn 通过 onOutputEvent emit,会违反 "no emit post-promote" 不变量。
  • Snapshot 语义:promoted result 的 output 是 abort 那一刻已捕获的 output(child_process 走 text decoder flush via cleanup();PTY drain 后 encoding-aware re-decode + replay)。caller (PR-2) 用这个 seed BackgroundShellEntry 的 on-disk output file,再 attach 自己的 data listener 接后续输出。
  • streamStdout: true + { kind: 'background' } 是潜在空 snapshot 组合(snapshot 读 string accumulator 但 streamStdout 模式跳过累积)。今 PR-1 的唯一 caller 用 streamStdout: false 不踩,但 future caller 配对会触发 debugLogger.warn 指向 rawOutput 兜底。
  • PTY snapshot 加 ?? '' 防御serializeTerminalToText 在异常情况下(mid-render race / 测试 mock)可能返 undefined,fallback 保 result.output 类型仍是 string。

测试覆盖shellExecutionService.test.ts 69 / 69 pass(50 baseline + 19 个新加,跨 5 轮 review)。

第 1 轮 — discrimination(4 测试):

  • PTY{ kind: 'cancel' } 仍走 process.kill(-pid, 'SIGTERM') tree-kill;{ kind: 'background' } 跳 kill — mockProcessKill 不被调,mockPtyProcess.kill 不被调,result.promoted === true
  • child_process fallback:同上 + 验证 result.output snapshot 包含 abort 前 emit 的 'line1\nline2\n'

第 1 轮 — Handoff-boundary(2 测试):

  • PTY:post-promotion,mockPtyProcess.onData.mock.results[0].value.dispose.onExit.mock.results[0].value.dispose 被调用 — 验证 node-pty 返回的 disposable handle 被正确 dispose;result shape 仍 promoted: true
  • child_process:post-promotion,往 live cp.stdout / cp.stderr 再 emit 'data' 增加 onOutputEvent.mock.calls.length;emit 'exit', 42, null result.exitCode(仍 null)。

第 4 轮 — Post-exit race guards(2 测试):

  • child_process:模拟 child.exitCode 已设 0 但 'exit' 还没 reach handler 的 race 窗口,abort with { kind: 'background' } — production race guard 检测到 terminal 状态 → fall through,result 没 promoted: true,正常 exitCode: 0
  • PTY:mock process.kill(pid, 0) 在 liveness probe 时抛 ESRCH,abort with { kind: 'background' } — production guard 拒绝 promote 已死 PTY;result 没 promoted: true

第 3 轮 — removeListener regression guard(1 测试):

  • spy mockPtyProcess.removeListener + mockPtyProcess.off,断言 production teardown 调 removeListener('error', …)off('error', …)@lydell/node-pty 的 IPty 接口只暴露 removeListener(无 off alias)— EventEmitter mock 容忍两者,没这测试 future 改回 .off() 会在 mock 下静默 regress 但 real PTY 抛 TypeError。

第 4 轮 — getShellAbortReasonKind 边界(8 测试,helper 已 export 给直接测试):

  • null / undefined reason → 'cancel'
  • 非对象 reason(string / number / boolean / Error 实例)→ 'cancel'
  • 空对象(无 own kind)→ 'cancel'
  • prototype-only kind(Object.create({ kind: 'background' }))→ 'cancel'(污染防御)
  • 未知 kind 值(typo / 未来 untyped variant)→ 'cancel'
  • throwing accessor(Object.defineProperty('kind', { get() { throw } }))→ 'cancel'
  • Proxy with throwing get trap → 'cancel'
  • 标准 { kind: 'background' } / { kind: 'cancel' } happy path

第 4 轮 — Test fixture 一致性(2 mock setup 调整):

  • 两个 beforeEachchild_process fallback + execution method selection)都设 mockChildProcess.exitCode / signalCode = null — 镜像真 Node ChildProcess 形状(alive 时 null 不 undefined)。否则 race guard child.exitCode !== null 会因 undefined 误触发。
  • mockPtyProcess.onData / .onExit{ dispose: vi.fn() } 让 production dispose path 不会 crash on undefined.dispose() — stub 的 .mock.results[0].value 然后被 handoff 测试 inspect。

测试计划

  • vitest run packages/core/src/services/shellExecutionService.test.ts69 / 69 pass(50 baseline + 19 个新加,跨 5 轮 review)
  • tsc --build packages/cli(CI 等价 full mode):clean
  • npm run build --workspace=@qwen-code/qwen-code-core:clean
  • ESLint:clean
  • Manual(推迟 — 需 PR-2 caller 才能 exercise):前台 bash + Ctrl+B → child 仍 alive,registry entry 创建。PR-1 单独无用户可见 surface 可手测。

Related#3831(Phase D b 设计 + 3-PR sequencing)/ #3634(Background task 管理 roadmap)/ #3809(Phase D a,已合)

…ecute()

Foundation for #3831 Phase D (b) — Ctrl+B promote of a running foreground
shell to background. Defines a discriminated `ShellAbortReason` union that
the AbortSignal carries; default behavior (no reason / `{ kind: 'cancel' }`)
keeps the existing tree-kill on abort. `{ kind: 'background' }` is a takeover
signal — execute() skips the kill, drops the child from its active set (so
cleanup() won't kill it later), flushes a snapshot of captured output, and
resolves the result Promise immediately with `promoted: true` so the
awaiting caller unblocks.

Pure plumbing: no caller sets the reason yet, so this is a zero-behavior
change for existing call sites. The `promoted?: boolean` field is optional
on ShellExecutionResult so existing consumers compile against the new shape
without source changes.

Tests pin both branches in both childProcessFallback and executeWithPty:
default abort still SIGTERM-tree-kills; `{ kind: 'cancel' }` is identical to
default (pin against accidental routing through the background branch);
`{ kind: 'background' }` skips the kill, snapshot output is preserved,
mockProcessKill / mockPtyProcess.kill are NOT called.

Part of #3831 (Phase D part b — Ctrl+B promote running shell to background).
PR-1 of 3.
@github-actions

github-actions Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

Code Coverage Summary

Package Lines Statements Functions Branches
CLI 55.8% 55.8% 71.67% 79.12%
Core 76.55% 76.55% 79.06% 82.11%
CLI Package - Full Text Report
-------------------|---------|----------|---------|---------|-------------------
File               | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
-------------------|---------|----------|---------|---------|-------------------
All files          |    55.8 |    79.12 |   71.67 |    55.8 |                   
 src               |   67.99 |    62.34 |   74.19 |   67.99 |                   
  gemini.tsx       |   59.52 |    58.88 |   66.66 |   59.52 | ...62,770-773,781 
  ...ractiveCli.ts |   69.53 |    57.42 |   72.72 |   69.53 | ...21-768,776-783 
  ...liCommands.ts |   73.92 |     72.5 |     100 |   73.92 | ...40-264,289,389 
  ...ActiveAuth.ts |     100 |     87.5 |     100 |     100 | 66-80             
 ...cp-integration |    46.3 |    63.01 |   55.88 |    46.3 |                   
  acpAgent.ts      |   48.12 |    63.38 |   62.06 |   48.12 | ...91-793,807-815 
  authMethods.ts   |   12.19 |      100 |       0 |   12.19 | 11-31,34-38,41-50 
  errorCodes.ts    |       0 |        0 |       0 |       0 | 1-22              
  ...DirContext.ts |     100 |      100 |     100 |     100 |                   
 ...ration/service |   68.65 |    83.33 |   66.66 |   68.65 |                   
  filesystem.ts    |   68.65 |    83.33 |   66.66 |   68.65 | ...32,77-94,97-98 
 ...ration/session |   74.86 |    68.57 |    82.6 |   74.86 |                   
  ...ryReplayer.ts |   65.93 |    75.67 |   81.81 |   65.93 | ...40-255,268-269 
  Session.ts       |   73.52 |    66.12 |   82.92 |   73.52 | ...2322,2328-2331 
  ...entTracker.ts |   90.85 |    84.84 |      90 |   90.85 | ...35,199,251-260 
  index.ts         |       0 |        0 |       0 |       0 | 1-40              
  ...ssionUtils.ts |   84.21 |    77.77 |     100 |   84.21 | ...37-153,209-211 
  types.ts         |       0 |        0 |       0 |       0 | 1                 
 ...ssion/emitters |   96.01 |    90.75 |    92.3 |   96.01 |                   
  BaseEmitter.ts   |   76.92 |    66.66 |      80 |   76.92 | 23-24,39-40,55-56 
  ...ageEmitter.ts |     100 |    89.47 |     100 |     100 | 109,111           
  PlanEmitter.ts   |     100 |      100 |     100 |     100 |                   
  ...allEmitter.ts |   98.06 |     92.3 |     100 |   98.06 | 227-228,327,335   
  index.ts         |       0 |        0 |       0 |       0 | 1-10              
 ...ession/rewrite |   89.69 |    85.89 |   94.11 |   89.69 |                   
  LlmRewriter.ts   |   80.53 |    79.31 |     100 |   80.53 | ...17-119,170-174 
  ...Middleware.ts |   95.83 |    85.71 |     100 |   95.83 | 119,127-129       
  TurnBuffer.ts    |     100 |      100 |     100 |     100 |                   
  config.ts        |     100 |      100 |     100 |     100 |                   
  index.ts         |     100 |      100 |     100 |     100 |                   
  types.ts         |       0 |        0 |       0 |       0 | 1                 
 src/commands      |   62.18 |      100 |    9.52 |   62.18 |                   
  auth.ts          |   46.91 |      100 |       0 |   46.91 | ...,91-98,101-102 
  channel.ts       |   56.66 |      100 |       0 |   56.66 | 15-19,27-34       
  extensions.tsx   |   96.55 |      100 |      50 |   96.55 | 37                
  hooks.tsx        |   66.66 |      100 |       0 |   66.66 | 20-24             
  mcp.ts           |   94.73 |      100 |      50 |   94.73 | 28                
  review.ts        |   51.85 |      100 |       0 |   51.85 | 24-35,38          
 src/commands/auth |   66.16 |    79.82 |   78.94 |   66.16 |                   
  handler.ts       |   47.07 |    74.68 |   35.29 |   47.07 | ...-968,1058-1068 
  ...veSelector.ts |     100 |    96.66 |     100 |     100 | 58                
  ...outerOAuth.ts |   89.02 |    78.99 |   96.87 |   89.02 | ...18-622,716-718 
 ...mmands/channel |    39.2 |    79.45 |      50 |    39.2 |                   
  ...l-registry.ts |    8.57 |      100 |       0 |    8.57 | 6-21,24-42        
  config-utils.ts  |   91.89 |      100 |   66.66 |   91.89 | 20-25             
  configure.ts     |    14.7 |      100 |       0 |    14.7 | 18-21,23-84       
  pairing.ts       |   26.31 |      100 |       0 |   26.31 | ...30,40-50,52-65 
  pidfile.ts       |   96.34 |    86.95 |     100 |   96.34 | 49,59,91          
  start.ts         |   31.15 |       52 |   69.23 |   31.15 | ...73-476,485-487 
  status.ts        |   17.54 |      100 |       0 |   17.54 | 15-26,32-77       
  stop.ts          |      20 |      100 |       0 |      20 | 14-48             
 ...nds/extensions |   84.53 |    88.95 |   81.81 |   84.53 |                   
  consent.ts       |   71.65 |    89.28 |   42.85 |   71.65 | ...85-141,156-162 
  disable.ts       |     100 |      100 |     100 |     100 |                   
  enable.ts        |     100 |      100 |     100 |     100 |                   
  install.ts       |    75.6 |    66.66 |   66.66 |    75.6 | ...39-142,145-153 
  link.ts          |     100 |      100 |     100 |     100 |                   
  list.ts          |     100 |      100 |     100 |     100 |                   
  new.ts           |     100 |      100 |     100 |     100 |                   
  settings.ts      |   99.15 |      100 |   83.33 |   99.15 | 151               
  uninstall.ts     |    37.5 |      100 |   33.33 |    37.5 | 23-45,57-64,67-70 
  update.ts        |   96.32 |      100 |     100 |   96.32 | 101-105           
  utils.ts         |   60.24 |    28.57 |     100 |   60.24 | ...81,83-87,89-93 
 ...les/mcp-server |       0 |        0 |       0 |       0 |                   
  example.ts       |       0 |        0 |       0 |       0 | 1-60              
 src/commands/mcp  |   92.29 |    86.08 |   88.88 |   92.29 |                   
  add.ts           |     100 |    98.03 |     100 |     100 | 293               
  list.ts          |   91.22 |    80.76 |      80 |   91.22 | ...19-121,146-147 
  reconnect.ts     |   76.72 |    71.42 |   85.71 |   76.72 | 35-48,153-175     
  remove.ts        |     100 |       80 |     100 |     100 | 21-25             
 ...ommands/review |   11.57 |      100 |       0 |   11.57 |                   
  cleanup.ts       |   17.94 |      100 |       0 |   17.94 | ...01-106,108-109 
  deterministic.ts |   13.75 |      100 |       0 |   13.75 | ...22-738,740-741 
  fetch-pr.ts      |   11.36 |      100 |       0 |   11.36 | ...80-201,203-204 
  load-rules.ts    |   11.32 |      100 |       0 |   11.32 | ...41-153,155-156 
  pr-context.ts    |    6.22 |      100 |       0 |    6.22 | ...97-312,314-315 
  presubmit.ts     |    9.35 |      100 |       0 |    9.35 | ...62-287,289-290 
 ...nds/review/lib |      30 |      100 |       0 |      30 |                   
  gh.ts            |   22.58 |      100 |       0 |   22.58 | ...49,53-54,62-69 
  git.ts           |   22.72 |      100 |       0 |   22.72 | 15-18,29-39,43-44 
  paths.ts         |   52.94 |      100 |       0 |   52.94 | ...26,37-38,42-43 
 src/config        |   92.04 |    82.54 |   84.72 |   92.04 |                   
  auth.ts          |   87.87 |    81.35 |     100 |   87.87 | ...20-221,237-238 
  config.ts        |   86.36 |    82.53 |   72.72 |   86.36 | ...1339,1361-1362 
  keyBindings.ts   |   95.95 |       50 |     100 |   95.95 | 160-163           
  ...idersScope.ts |      92 |       90 |     100 |      92 | 11-12             
  sandboxConfig.ts |    58.9 |    61.53 |   66.66 |    58.9 | ...54-68,73,77-89 
  settings.ts      |   83.13 |    82.55 |   85.71 |   83.13 | ...35-936,941-944 
  ...ingsSchema.ts |     100 |      100 |     100 |     100 |                   
  ...tedFolders.ts |   96.29 |       94 |     100 |   96.29 | ...88-190,205-206 
 ...nfig/migration |   94.56 |    78.94 |   83.33 |   94.56 |                   
  index.ts         |   93.93 |    88.88 |     100 |   93.93 | 85-86             
  scheduler.ts     |   96.55 |    77.77 |     100 |   96.55 | 19-20             
  types.ts         |       0 |        0 |       0 |       0 | 1                 
 ...ation/versions |   93.63 |     94.5 |     100 |   93.63 |                   
  ...-v2-shared.ts |     100 |      100 |     100 |     100 |                   
  v1-to-v2.ts      |   81.75 |    90.19 |     100 |   81.75 | ...28-229,231-247 
  v2-to-v3.ts      |     100 |      100 |     100 |     100 |                   
 src/constants     |   11.97 |     87.5 |   16.66 |   11.97 |                   
  ...dardApiKey.ts |     100 |      100 |     100 |     100 |                   
  codingPlan.ts    |    8.75 |     87.5 |   16.66 |    8.75 | ...22-327,335-347 
 src/core          |     100 |      100 |     100 |     100 |                   
  auth.ts          |     100 |      100 |     100 |     100 |                   
  initializer.ts   |     100 |      100 |     100 |     100 |                   
  theme.ts         |     100 |      100 |     100 |     100 |                   
 src/dualOutput    |   63.09 |    64.51 |   55.55 |   63.09 |                   
  ...tputBridge.ts |   62.94 |    65.51 |   56.25 |   62.94 | ...22-323,331-334 
  ...utContext.tsx |     100 |      100 |     100 |     100 |                   
  index.ts         |       0 |        0 |       0 |       0 | 1-8               
 src/export        |       0 |        0 |       0 |       0 |                   
  index.ts         |       0 |        0 |       0 |       0 | 1-7               
 src/generated     |     100 |      100 |     100 |     100 |                   
  git-commit.ts    |     100 |      100 |     100 |     100 |                   
 src/i18n          |   48.26 |    76.19 |   38.88 |   48.26 |                   
  index.ts         |   26.92 |    76.92 |   26.66 |   26.92 | ...38-239,249-260 
  languages.ts     |    98.7 |       75 |     100 |    98.7 | 110               
 src/i18n/locales  |       0 |        0 |       0 |       0 |                   
  ca.js            |       0 |        0 |       0 |       0 | 1-2146            
  de.js            |       0 |        0 |       0 |       0 | 1-2069            
  en.js            |       0 |        0 |       0 |       0 | 1-2119            
  fr.js            |       0 |        0 |       0 |       0 | 1-2102            
  ja.js            |       0 |        0 |       0 |       0 | 1-1560            
  pt.js            |       0 |        0 |       0 |       0 | 1-2060            
  ru.js            |       0 |        0 |       0 |       0 | 1-2065            
  zh-TW.js         |       0 |        0 |       0 |       0 | 1-1681            
  zh.js            |       0 |        0 |       0 |       0 | 1-1920            
 ...nonInteractive |   72.67 |    72.14 |   74.07 |   72.67 |                   
  session.ts       |   76.86 |    70.45 |   85.71 |   76.86 | ...78-779,787-797 
  types.ts         |    42.5 |      100 |   33.33 |    42.5 | ...80-581,584-585 
 ...active/control |   77.55 |    88.23 |      80 |   77.55 |                   
  ...rolContext.ts |    7.69 |        0 |       0 |    7.69 | 47-79             
  ...Dispatcher.ts |   91.66 |    91.83 |   88.88 |   91.66 | ...54-372,388,391 
  ...rolService.ts |       8 |        0 |       0 |       8 | 46-179            
 ...ol/controllers |    7.04 |       80 |   13.33 |    7.04 |                   
  ...Controller.ts |   19.32 |      100 |      60 |   19.32 | 81-118,127-210    
  ...Controller.ts |       0 |        0 |       0 |       0 | 1-56              
  ...Controller.ts |    3.96 |      100 |   11.11 |    3.96 | ...61-379,389-494 
  ...Controller.ts |   14.06 |      100 |       0 |   14.06 | ...82-117,130-133 
  ...Controller.ts |    5.21 |      100 |       0 |    5.21 | ...21-433,442-471 
 .../control/types |       0 |        0 |       0 |       0 |                   
  serviceAPIs.ts   |       0 |        0 |       0 |       0 | 1                 
 ...Interactive/io |   97.59 |    93.06 |   95.18 |   97.59 |                   
  ...putAdapter.ts |   97.33 |    91.89 |   98.07 |   97.33 | ...1343,1368-1369 
  ...putAdapter.ts |      96 |    91.66 |   85.71 |      96 | 51-52             
  ...nputReader.ts |     100 |    94.73 |     100 |     100 | 67                
  ...putAdapter.ts |   98.28 |      100 |      90 |   98.28 | 81-82,122-123     
  index.ts         |     100 |      100 |     100 |     100 |                   
 src/patches       |       0 |        0 |       0 |       0 |                   
  is-in-ci.ts      |       0 |        0 |       0 |       0 | 1-17              
 src/remoteInput   |   86.98 |       75 |   85.71 |   86.98 |                   
  ...utContext.tsx |     100 |      100 |     100 |     100 |                   
  ...putWatcher.ts |   88.12 |    76.08 |   91.66 |   88.12 | ...21-222,233-236 
  index.ts         |       0 |        0 |       0 |       0 | 1-8               
 src/services      |   90.37 |    89.75 |   94.28 |   90.37 |                   
  ...mandLoader.ts |     100 |     92.3 |     100 |     100 | 89                
  ...killLoader.ts |     100 |    96.29 |     100 |     100 | 44                
  ...andService.ts |    93.5 |      100 |      80 |    93.5 | 107,150-153       
  ...mandLoader.ts |   86.83 |    83.87 |     100 |   86.83 | ...30-335,340-345 
  ...omptLoader.ts |   75.32 |    80.64 |   83.33 |   75.32 | ...05-206,272-273 
  ...mandLoader.ts |     100 |      100 |     100 |     100 |                   
  ...nd-factory.ts |      91 |     90.9 |     100 |      91 | 123,132-139       
  ...ation-tool.ts |     100 |    95.45 |     100 |     100 | 125               
  commandUtils.ts  |      96 |       90 |     100 |      96 | 48                
  ...and-parser.ts |   90.69 |    85.71 |     100 |   90.69 | 63-66             
  ...ionService.ts |     100 |      100 |     100 |     100 |                   
  types.ts         |     100 |      100 |     100 |     100 |                   
 ...ght/generators |   85.95 |    86.42 |   90.47 |   85.95 |                   
  DataProcessor.ts |   85.68 |    86.46 |   92.85 |   85.68 | ...1110,1114-1121 
  ...tGenerator.ts |   98.21 |    85.71 |     100 |   98.21 | 46                
  ...teRenderer.ts |   45.45 |      100 |       0 |   45.45 | 13-51             
 .../insight/types |       0 |       50 |      50 |       0 |                   
  ...sightTypes.ts |       0 |        0 |       0 |       0 |                   
  ...sightTypes.ts |       0 |        0 |       0 |       0 | 1                 
 ...mpt-processors |   97.27 |    94.04 |     100 |   97.27 |                   
  ...tProcessor.ts |     100 |      100 |     100 |     100 |                   
  ...eProcessor.ts |   94.52 |    84.21 |     100 |   94.52 | 46-47,93-94       
  ...tionParser.ts |     100 |      100 |     100 |     100 |                   
  ...lProcessor.ts |   97.41 |    95.65 |     100 |   97.41 | 95-98             
  types.ts         |     100 |      100 |     100 |     100 |                   
 src/services/tips |   92.38 |    84.12 |     100 |   92.38 |                   
  index.ts         |     100 |      100 |     100 |     100 |                   
  tipHistory.ts    |    78.3 |    71.42 |     100 |    78.3 | ...33-148,151,160 
  tipRegistry.ts   |     100 |    95.23 |     100 |     100 | 33                
  tipScheduler.ts  |     100 |    91.66 |     100 |     100 | 55                
 src/test-utils    |   93.75 |    83.33 |      80 |   93.75 |                   
  ...omMatchers.ts |   69.69 |       50 |      50 |   69.69 | 32-35,37-39,45-47 
  ...andContext.ts |     100 |      100 |     100 |     100 |                   
  render.tsx       |     100 |      100 |     100 |     100 |                   
 src/ui            |   63.21 |    68.42 |   51.28 |   63.21 |                   
  App.tsx          |     100 |      100 |     100 |     100 |                   
  AppContainer.tsx |   65.87 |    62.67 |   66.66 |   65.87 | ...2279,2283-2287 
  ...tionNudge.tsx |    9.58 |      100 |       0 |    9.58 | 24-94             
  ...ackDialog.tsx |   29.23 |      100 |       0 |   29.23 | 25-75             
  ...tionNudge.tsx |    7.69 |      100 |       0 |    7.69 | 25-103            
  colors.ts        |   52.72 |      100 |   23.52 |   52.72 | ...52,54-55,60-61 
  constants.ts     |     100 |      100 |     100 |     100 |                   
  keyMatchers.ts   |   91.83 |       90 |     100 |   91.83 | 25-26,54-55       
  ...tic-colors.ts |     100 |      100 |     100 |     100 |                   
  textConstants.ts |     100 |      100 |     100 |     100 |                   
  types.ts         |     100 |      100 |     100 |     100 |                   
 src/ui/auth       |   53.26 |    65.51 |      68 |   53.26 |                   
  AuthDialog.tsx   |   67.75 |    64.95 |    65.9 |   67.75 | ...1271,1273,1275 
  ...nProgress.tsx |       0 |        0 |       0 |       0 | 1-64              
  useAuth.ts       |    34.3 |    70.37 |     100 |    34.3 | ...14-920,922-937 
 src/ui/commands   |   59.85 |    77.91 |   60.78 |   59.85 |                   
  aboutCommand.ts  |     100 |    85.71 |     100 |     100 | 36                
  agentsCommand.ts |   72.97 |      100 |      20 |   72.97 | ...32,37-38,42-44 
  ...odeCommand.ts |     100 |      100 |     100 |     100 |                   
  arenaCommand.ts  |   33.13 |    67.64 |    37.5 |   33.13 | ...60-565,644-649 
  authCommand.ts   |     100 |      100 |     100 |     100 |                   
  btwCommand.ts    |   95.59 |    71.42 |     100 |   95.59 | 72,154-159        
  bugCommand.ts    |   77.35 |    66.66 |      50 |   77.35 | 21-22,60-69       
  clearCommand.ts  |   90.58 |    73.68 |      50 |   90.58 | ...46,74-75,93-94 
  ...essCommand.ts |   63.39 |       48 |      50 |   63.39 | ...48-149,163-166 
  ...extCommand.ts |    6.17 |      100 |      10 |    6.17 | ...21-522,527-528 
  copyCommand.ts   |     100 |      100 |     100 |     100 |                   
  deleteCommand.ts |     100 |      100 |     100 |     100 |                   
  ...ryCommand.tsx |   66.11 |    76.74 |   55.55 |   66.11 | ...05-306,315-323 
  docsCommand.ts   |   96.07 |     87.5 |      50 |   96.07 | 20-21             
  doctorCommand.ts |     100 |    93.33 |     100 |     100 | 21                
  dreamCommand.ts  |      75 |    66.66 |   66.66 |      75 | 22-27,44-47       
  editorCommand.ts |     100 |      100 |     100 |     100 |                   
  exportCommand.ts |   56.93 |    91.66 |   33.33 |   56.93 | ...52-353,361-362 
  ...onsCommand.ts |   45.08 |    85.71 |   27.27 |   45.08 | ...37-238,247-248 
  forgetCommand.ts |   26.82 |      100 |      50 |   26.82 | 18-51             
  helpCommand.ts   |     100 |      100 |     100 |     100 |                   
  hooksCommand.ts  |   19.04 |       25 |      20 |   19.04 | ...86-187,204-205 
  ideCommand.ts    |   57.33 |    57.69 |   35.29 |   57.33 | ...05-306,310-324 
  initCommand.ts   |   84.33 |    72.72 |     100 |   84.33 | 68,82-87,89-94    
  ...ghtCommand.ts |    72.8 |    66.66 |   83.33 |    72.8 | ...31-245,250-273 
  ...ageCommand.ts |   89.39 |    82.35 |   76.92 |   89.39 | ...22-325,348-349 
  ...elsCommand.ts |     100 |      100 |     100 |     100 |                   
  mcpCommand.ts    |   86.66 |      100 |      50 |   86.66 | 14-15             
  memoryCommand.ts |   86.66 |      100 |      50 |   86.66 | 14-15             
  modelCommand.ts  |   42.19 |       65 |      50 |   42.19 | ...35-168,175-193 
  ...onsCommand.ts |     100 |      100 |     100 |     100 |                   
  planCommand.ts   |   78.82 |    76.92 |     100 |   78.82 | 30-35,51-56,68-73 
  quitCommand.ts   |   93.93 |      100 |      50 |   93.93 | 15-16             
  recapCommand.ts  |   21.81 |      100 |      50 |   21.81 | 24-73             
  ...berCommand.ts |   32.43 |      100 |      50 |   32.43 | 23-57             
  renameCommand.ts |   85.61 |    78.18 |     100 |   85.61 | ...15-322,329-334 
  ...oreCommand.ts |    92.3 |     87.5 |     100 |    92.3 | ...,83-88,129-130 
  resumeCommand.ts |     100 |      100 |     100 |     100 |                   
  rewindCommand.ts |      80 |      100 |      50 |      80 | 19-21             
  ...ngsCommand.ts |     100 |      100 |     100 |     100 |                   
  ...hubCommand.ts |   81.43 |    65.21 |      80 |   81.43 | ...70-173,176-179 
  skillsCommand.ts |   15.04 |      100 |      25 |   15.04 | ...90-106,109-136 
  statsCommand.ts  |   83.91 |    81.25 |      50 |   83.91 | ...31-132,142-145 
  ...ineCommand.ts |     100 |      100 |     100 |     100 |                   
  ...aryCommand.ts |    6.51 |      100 |      50 |    6.51 | 28-323            
  tasksCommand.ts  |   77.45 |    73.43 |     100 |   77.45 | ...55-159,181-186 
  ...tupCommand.ts |     100 |      100 |     100 |     100 |                   
  themeCommand.ts  |     100 |      100 |     100 |     100 |                   
  toolsCommand.ts  |   95.23 |      100 |      50 |   95.23 | 18-19             
  trustCommand.ts  |     100 |      100 |     100 |     100 |                   
  types.ts         |     100 |      100 |     100 |     100 |                   
  vimCommand.ts    |   54.54 |      100 |      50 |   54.54 | 19-29             
 src/ui/components |    59.4 |    73.36 |   61.53 |    59.4 |                   
  AboutBox.tsx     |     100 |      100 |     100 |     100 |                   
  AnsiOutput.tsx   |   65.57 |      100 |      50 |   65.57 | 69-90             
  ApiKeyInput.tsx  |   18.91 |      100 |       0 |   18.91 | 30-95             
  AppHeader.tsx    |   86.79 |    42.85 |     100 |   86.79 | 32-38,40          
  ...odeDialog.tsx |     9.7 |      100 |       0 |     9.7 | 35-47,50-182      
  AsciiArt.ts      |     100 |      100 |     100 |     100 |                   
  ...Indicator.tsx |   14.63 |      100 |       0 |   14.63 | 18-56             
  ...TextInput.tsx |   67.83 |    72.09 |      50 |   67.83 | ...30-232,250,259 
  Composer.tsx     |   79.31 |    57.14 |     100 |   79.31 | ...-77,95,133,146 
  ...entPrompt.tsx |     100 |      100 |     100 |     100 |                   
  ...ryDisplay.tsx |   75.89 |    62.06 |     100 |   75.89 | ...,88,93-108,113 
  ...geDisplay.tsx |   68.42 |    57.14 |     100 |   68.42 | 16-17,31-32,42-50 
  ...ification.tsx |   28.57 |      100 |       0 |   28.57 | 16-36             
  ...gProfiler.tsx |       0 |        0 |       0 |       0 | 1-36              
  ...ogManager.tsx |    12.4 |      100 |       0 |    12.4 | 61-457            
  ...ngsDialog.tsx |    8.44 |      100 |       0 |    8.44 | 37-195            
  ExitWarning.tsx  |     100 |      100 |     100 |     100 |                   
  ...hProgress.tsx |    87.8 |    33.33 |     100 |    87.8 | 28-31,56          
  ...ustDialog.tsx |     100 |      100 |     100 |     100 |                   
  Footer.tsx       |   79.67 |    58.06 |     100 |   79.67 | ...98-102,104-108 
  ...ngSpinner.tsx |   54.28 |       50 |      50 |   54.28 | 31-48,61          
  Header.tsx       |   98.14 |    85.71 |     100 |   98.14 | 97,99             
  Help.tsx         |   98.74 |    68.75 |     100 |   98.74 | 74,129            
  ...emDisplay.tsx |   62.72 |     37.5 |     100 |   62.72 | ...18-327,330,333 
  ...ngeDialog.tsx |     100 |      100 |     100 |     100 |                   
  InputPrompt.tsx  |   81.63 |    76.68 |   83.33 |   81.63 | ...1339,1404,1454 
  ...Shortcuts.tsx |   20.87 |      100 |       0 |   20.87 | ...6,49-51,67-125 
  ...Indicator.tsx |     100 |    91.42 |     100 |     100 | 65,74             
  ...firmation.tsx |   91.42 |      100 |      50 |   91.42 | 26-31             
  MainContent.tsx  |   57.66 |    54.54 |     100 |   57.66 | ...89-200,209-223 
  ...elsDialog.tsx |   16.07 |    89.18 |      50 |   16.07 | ...58-159,162-648 
  MemoryDialog.tsx |   53.35 |    51.21 |   57.14 |   53.35 | ...55,367,380-382 
  ...geDisplay.tsx |       0 |        0 |       0 |       0 | 1-41              
  ModelDialog.tsx  |   76.59 |    54.54 |     100 |   76.59 | ...60-476,533-537 
  ...tsDisplay.tsx |     100 |    96.96 |     100 |     100 | 234               
  ...fications.tsx |   18.18 |      100 |       0 |   18.18 | 15-58             
  ...onsDialog.tsx |    2.13 |      100 |       0 |    2.13 | 62-133,148-1004   
  ...ryDisplay.tsx |     100 |      100 |     100 |     100 |                   
  ...icePrompt.tsx |   88.14 |    83.87 |     100 |   88.14 | ...01-105,133-138 
  PrepareLabel.tsx |   91.66 |    76.19 |     100 |   91.66 | 73-75,77-79,110   
  ...geDisplay.tsx |     100 |      100 |     100 |     100 |                   
  ...ngDisplay.tsx |   21.42 |      100 |       0 |   21.42 | 13-39             
  ...hProgress.tsx |   85.25 |    88.46 |     100 |   85.25 | 121-147           
  ...dSelector.tsx |    4.45 |      100 |       0 |    4.45 | 28-92,100-328     
  ...ionPicker.tsx |   94.76 |    87.17 |     100 |   94.76 | 99,132,253-261    
  ...onPreview.tsx |   91.73 |    78.26 |     100 |   91.73 | ...,70-71,126-128 
  ...ryDisplay.tsx |     100 |      100 |     100 |     100 |                   
  ...putPrompt.tsx |   72.56 |       80 |      40 |   72.56 | ...06-109,114-117 
  ...ngsDialog.tsx |   66.88 |    73.52 |     100 |   66.88 | ...11-819,825-826 
  ...ionDialog.tsx |    87.8 |      100 |   33.33 |    87.8 | 36-39,44-51       
  ...putPrompt.tsx |    15.9 |      100 |       0 |    15.9 | 20-63             
  ...Indicator.tsx |   57.14 |      100 |       0 |   57.14 | 12-15             
  ...MoreLines.tsx |      28 |      100 |       0 |      28 | 18-40             
  ...ionPicker.tsx |   17.59 |      100 |       0 |   17.59 | 55-172            
  StatsDisplay.tsx |     100 |      100 |     100 |     100 |                   
  ...yTodoList.tsx |   94.17 |       80 |     100 |   94.17 | 56-57,131-134     
  ...nsDisplay.tsx |   84.09 |    57.14 |     100 |   84.09 | ...16-118,125-127 
  ThemeDialog.tsx  |   89.95 |    46.15 |      75 |   89.95 | ...71-173,243-245 
  Tips.tsx         |   21.87 |      100 |       0 |   21.87 | 22-40,43-53       
  TodoDisplay.tsx  |     100 |      100 |     100 |     100 |                   
  ...tsDisplay.tsx |     100 |     87.5 |     100 |     100 | 31-32             
  TrustDialog.tsx  |     100 |    81.81 |     100 |     100 | 71-86             
  ...ification.tsx |   36.36 |      100 |       0 |   36.36 | 15-22             
  ...ackDialog.tsx |    7.84 |      100 |       0 |    7.84 | 24-134            
 ...nts/agent-view |    25.2 |       90 |      10 |    25.2 |                   
  ...atContent.tsx |    8.79 |      100 |       0 |    8.79 | 53-265,271-273    
  ...tChatView.tsx |   21.05 |      100 |       0 |   21.05 | 21-39             
  ...tComposer.tsx |    9.95 |      100 |       0 |    9.95 | 57-308            
  AgentFooter.tsx  |   17.07 |      100 |       0 |   17.07 | 28-66             
  AgentHeader.tsx  |   15.38 |      100 |       0 |   15.38 | 27-64             
  AgentTabBar.tsx  |    8.13 |      100 |       0 |    8.13 | 39-59,64-187      
  ...oryAdapter.ts |     100 |    91.83 |     100 |     100 | 103,109-110,138   
  index.ts         |       0 |        0 |       0 |       0 | 1-12              
 ...mponents/arena |   45.72 |    70.53 |   60.86 |   45.72 |                   
  ArenaCards.tsx   |   73.06 |    71.79 |   85.71 |   73.06 | ...83-185,321-326 
  ...ectDialog.tsx |   83.48 |    69.86 |   88.88 |   83.48 | ...88-392,409-410 
  ...artDialog.tsx |   10.15 |      100 |       0 |   10.15 | 27-161            
  ...tusDialog.tsx |    5.63 |      100 |       0 |    5.63 | 33-75,80-288      
  ...topDialog.tsx |    6.17 |      100 |       0 |    6.17 | 33-213            
 ...ackground-view |    71.2 |    79.71 |   78.94 |    71.2 |                   
  ...sksDialog.tsx |   71.23 |    78.83 |   73.33 |   71.23 | ...1063,1137-1139 
  ...TasksPill.tsx |   70.83 |    86.95 |     100 |   70.83 | 44,77-89,97-105   
 ...nts/extensions |   45.28 |    33.33 |      60 |   45.28 |                   
  ...gerDialog.tsx |   44.31 |    34.14 |      75 |   44.31 | ...71-480,483-488 
  index.ts         |       0 |        0 |       0 |       0 | 1-9               
  types.ts         |     100 |      100 |     100 |     100 |                   
 ...tensions/steps |   54.77 |    94.23 |   66.66 |   54.77 |                   
  ...ctionStep.tsx |   95.12 |    92.85 |   85.71 |   95.12 | 84-86,89          
  ...etailStep.tsx |    6.18 |      100 |       0 |    6.18 | 17-128            
  ...nListStep.tsx |   88.35 |    94.73 |      80 |   88.35 | 51-52,58-71,105   
  ...electStep.tsx |   13.46 |      100 |       0 |   13.46 | 20-70             
  ...nfirmStep.tsx |   19.56 |      100 |       0 |   19.56 | 23-65             
  index.ts         |     100 |      100 |     100 |     100 |                   
 ...mponents/hooks |   72.24 |    70.52 |      80 |   72.24 |                   
  ...etailStep.tsx |   96.52 |       75 |     100 |   96.52 | 33,37,50,59       
  ...etailStep.tsx |   93.27 |    73.68 |     100 |   93.27 | 41-42,99-104,110  
  ...abledStep.tsx |     100 |      100 |     100 |     100 |                   
  ...sListStep.tsx |     100 |      100 |     100 |     100 |                   
  ...entDialog.tsx |   36.09 |    47.05 |      50 |   36.09 | ...49,453-466,470 
  constants.ts     |     100 |      100 |     100 |     100 |                   
  index.ts         |       0 |        0 |       0 |       0 | 1-13              
  types.ts         |     100 |      100 |     100 |     100 |                   
 ...components/mcp |    20.2 |    84.61 |   81.81 |    20.2 |                   
  ...ealthPill.tsx |   68.42 |    85.71 |     100 |   68.42 | 40-46             
  ...entDialog.tsx |    3.64 |      100 |       0 |    3.64 | 41-717            
  constants.ts     |     100 |      100 |     100 |     100 |                   
  index.ts         |       0 |        0 |       0 |       0 | 1-30              
  types.ts         |     100 |      100 |     100 |     100 |                   
  utils.ts         |   96.42 |    87.09 |     100 |   96.42 | 21,96-97          
 ...ents/mcp/steps |    6.65 |      100 |       0 |    6.65 |                   
  ...icateStep.tsx |     5.1 |      100 |       0 |     5.1 | 34-95,98-334      
  ...electStep.tsx |   10.95 |      100 |       0 |   10.95 | 16-88             
  ...etailStep.tsx |    5.26 |      100 |       0 |    5.26 | 31-247            
  ...rListStep.tsx |    5.88 |      100 |       0 |    5.88 | 20-176            
  ...etailStep.tsx |   10.41 |      100 |       0 |   10.41 | ...1,67-79,82-139 
  ToolListStep.tsx |    7.14 |      100 |       0 |    7.14 | 16-146            
 ...nents/messages |   79.88 |    78.93 |   69.84 |   79.88 |                   
  ...ionDialog.tsx |   77.35 |    74.54 |    62.5 |   77.35 | ...90,508,526-528 
  BtwMessage.tsx   |     100 |      100 |     100 |     100 |                   
  ...upDisplay.tsx |   97.67 |    83.33 |     100 |   97.67 | 119,142,150       
  ...onMessage.tsx |   91.93 |    82.35 |     100 |   91.93 | 57-59,61,63       
  ...nMessages.tsx |   77.35 |      100 |      70 |   77.35 | ...31-244,248-260 
  DiffRenderer.tsx |   93.19 |    86.17 |     100 |   93.19 | ...09,237-238,304 
  ...ssMessage.tsx |    12.5 |      100 |       0 |    12.5 | 18-59             
  ...edMessage.tsx |   16.66 |      100 |       0 |   16.66 | 22-38             
  ...sMessages.tsx |   55.67 |       40 |   28.57 |   55.67 | ...20-125,133-145 
  ...ryMessage.tsx |   12.82 |      100 |       0 |   12.82 | 22-59             
  ...onMessage.tsx |   73.55 |    55.81 |   33.33 |   73.55 | ...41-443,450-452 
  ...upMessage.tsx |   76.95 |    82.08 |     100 |   76.95 | ...24-251,273-288 
  ToolMessage.tsx  |   90.68 |    81.35 |   91.66 |   90.68 | ...58-663,690-692 
 ...ponents/shared |    82.5 |    77.29 |   92.64 |    82.5 |                   
  ...ctionList.tsx |   99.03 |    95.65 |     100 |   99.03 | 85                
  ...tonSelect.tsx |     100 |      100 |     100 |     100 |                   
  EnumSelector.tsx |     100 |    96.42 |     100 |     100 | 58                
  MaxSizedBox.tsx  |   83.01 |    86.25 |   88.88 |   83.01 | ...12-513,618-619 
  MultiSelect.tsx  |    6.29 |      100 |       0 |    6.29 | 35-42,45-176      
  ...tonSelect.tsx |     100 |      100 |     100 |     100 |                   
  ...eSelector.tsx |     100 |       60 |     100 |     100 | 40-45             
  TextInput.tsx    |   74.84 |    57.14 |      75 |   74.84 | ...90-194,206-212 
  ...apsedTime.tsx |     100 |      100 |     100 |     100 |                   
  ...Indicator.tsx |     100 |      100 |     100 |     100 |                   
  text-buffer.ts   |   83.62 |    75.62 |   97.61 |   83.62 | ...2272,2300,2368 
  ...er-actions.ts |   86.71 |    67.79 |     100 |   86.71 | ...07-608,809-811 
 ...ents/subagents |   32.77 |    33.33 |    12.5 |   32.77 |                   
  constants.ts     |     100 |      100 |     100 |     100 |                   
  index.ts         |     100 |      100 |     100 |     100 |                   
  reducers.tsx     |    12.1 |      100 |       0 |    12.1 | 33-190            
  types.ts         |     100 |      100 |     100 |     100 |                   
  utils.ts         |   13.69 |    33.33 |   16.66 |   13.69 | ...1,56-57,60-102 
 ...bagents/create |    9.13 |      100 |       0 |    9.13 |                   
  ...ionWizard.tsx |    7.28 |      100 |       0 |    7.28 | 34-299            
  ...rSelector.tsx |   14.75 |      100 |       0 |   14.75 | 26-85             
  ...onSummary.tsx |    4.26 |      100 |       0 |    4.26 | 27-331            
  ...tionInput.tsx |    8.63 |      100 |       0 |    8.63 | 23-177            
  ...dSelector.tsx |   33.33 |      100 |       0 |   33.33 | 20-21,26-27,36-63 
  ...nSelector.tsx |    37.5 |      100 |       0 |    37.5 | 20-21,26-27,36-58 
  ...EntryStep.tsx |   12.76 |      100 |       0 |   12.76 | 34-78             
  ToolSelector.tsx |    4.16 |      100 |       0 |    4.16 | 31-253            
 ...bagents/manage |    8.39 |      100 |       0 |    8.39 |                   
  ...ctionStep.tsx |   10.25 |      100 |       0 |   10.25 | 21-103            
  ...eleteStep.tsx |   20.93 |      100 |       0 |   20.93 | 23-62             
  ...tEditStep.tsx |   25.53 |      100 |       0 |   25.53 | ...2,37-38,51-124 
  ...ctionStep.tsx |    2.29 |      100 |       0 |    2.29 | 28-449            
  ...iewerStep.tsx |   13.72 |      100 |       0 |   13.72 | 18-73             
  ...gerDialog.tsx |    6.74 |      100 |       0 |    6.74 | 35-341            
 ...agents/runtime |   81.76 |    58.24 |   92.85 |   81.76 |                   
  ...onDisplay.tsx |   81.76 |    58.24 |   92.85 |   81.76 | ...14-716,719-722 
 ...mponents/views |   42.16 |    69.23 |   21.42 |   42.16 |                   
  ContextUsage.tsx |     4.7 |      100 |       0 |     4.7 | ...52-167,170-456 
  DoctorReport.tsx |     9.8 |      100 |       0 |     9.8 | 25-54,57-131      
  ...sionsList.tsx |   87.69 |    73.68 |     100 |   87.69 | 65-72             
  McpStatus.tsx    |   89.53 |    60.52 |     100 |   89.53 | ...72,175-177,262 
  SkillsList.tsx   |   27.27 |      100 |       0 |   27.27 | 18-35             
  ToolsList.tsx    |     100 |      100 |     100 |     100 |                   
 src/ui/contexts   |   76.84 |    78.03 |   84.31 |   76.84 |                   
  ...ewContext.tsx |   65.77 |      100 |      75 |   65.77 | ...22-225,231-241 
  AppContext.tsx   |      80 |       50 |     100 |      80 | 19-20             
  ...ewContext.tsx |   93.37 |    68.57 |      50 |   93.37 | ...94-195,222-226 
  ...deContext.tsx |     100 |      100 |     100 |     100 |                   
  ...igContext.tsx |   81.81 |       50 |     100 |   81.81 | 15-16             
  ...ssContext.tsx |   81.88 |    82.26 |     100 |   81.88 | ...1153,1159-1161 
  ...owContext.tsx |   89.28 |       80 |   66.66 |   89.28 | 34,47-48,60-62    
  ...onContext.tsx |   43.28 |     62.5 |    62.5 |   43.28 | ...56-259,263-266 
  ...gsContext.tsx |   83.33 |       50 |     100 |   83.33 | 17-18             
  ...usContext.tsx |     100 |      100 |     100 |     100 |                   
  ...ngContext.tsx |   71.42 |       50 |     100 |   71.42 | 17-20             
  ...nsContext.tsx |   88.88 |       50 |     100 |   88.88 | 145-146           
  ...teContext.tsx |   85.71 |       50 |     100 |   85.71 | 175-176           
  ...deContext.tsx |   76.08 |    72.72 |     100 |   76.08 | 47-48,52-59,77-78 
 src/ui/editors    |   93.33 |    85.71 |   66.66 |   93.33 |                   
  ...ngsManager.ts |   93.33 |    85.71 |   66.66 |   93.33 | 49,63-64          
 src/ui/hooks      |    80.8 |    81.38 |   85.56 |    80.8 |                   
  ...dProcessor.ts |   83.12 |    82.56 |     100 |   83.12 | ...88-389,408-435 
  keyToAnsi.ts     |    3.92 |      100 |       0 |    3.92 | 19-77             
  ...dProcessor.ts |    94.8 |    70.58 |     100 |    94.8 | ...76-277,282-283 
  ...dProcessor.ts |   72.85 |    57.85 |   61.53 |   72.85 | ...84,808,827-831 
  ...amingState.ts |   12.22 |      100 |       0 |   12.22 | 54-158            
  ...agerDialog.ts |   88.23 |      100 |     100 |   88.23 | 20,24             
  ...ationFrame.ts |      32 |       60 |     100 |      32 | 42-44,51-90       
  ...odeCommand.ts |   58.82 |      100 |     100 |   58.82 | 28,33-48          
  ...enaCommand.ts |      85 |      100 |     100 |      85 | 23-24,29          
  ...aInProcess.ts |   19.81 |    66.66 |      25 |   19.81 | 57-175            
  ...Completion.ts |   92.77 |    89.09 |     100 |   92.77 | ...86-187,220-223 
  ...ifications.ts |   92.07 |    96.29 |     100 |   92.07 | 116-124           
  ...tIndicator.ts |     100 |    93.75 |     100 |     100 | 63                
  ...waySummary.ts |   96.22 |    69.69 |     100 |   96.22 | 125-127,169       
  ...ndTaskView.ts |   94.11 |    76.92 |     100 |   94.11 | 119-123,216,222   
  ...ketedPaste.ts |    23.8 |      100 |       0 |    23.8 | 19-37             
  ...lanUpdates.ts |     100 |       92 |     100 |     100 | 59,158            
  ...ompletion.tsx |   91.28 |    79.59 |     100 |   91.28 | ...20-221,259-269 
  ...dMigration.ts |   90.62 |       75 |     100 |   90.62 | 38-40             
  useCompletion.ts |    92.4 |     87.5 |     100 |    92.4 | 68-69,93-94,98-99 
  ...nitMessage.ts |     100 |      100 |     100 |     100 |                   
  ...extualTips.ts |   76.92 |       50 |     100 |   76.92 | 55,68,71-75,88-96 
  ...eteCommand.ts |   33.33 |       50 |     100 |   33.33 | 30,34,41-90       
  ...ialogClose.ts |   18.18 |      100 |     100 |   18.18 | 75-130            
  ...oublePress.ts |   53.12 |       75 |     100 |   53.12 | 33-35,41-54       
  ...orSettings.ts |     100 |      100 |     100 |     100 |                   
  ...Completion.ts |   99.12 |     97.7 |     100 |   99.12 | 182-183           
  ...ionUpdates.ts |   93.45 |     92.3 |     100 |   93.45 | ...83-287,300-306 
  ...agerDialog.ts |   88.88 |      100 |     100 |   88.88 | 21,25             
  ...backDialog.ts |   54.88 |       50 |   33.33 |   54.88 | ...71-173,195-196 
  useFocus.ts      |     100 |      100 |     100 |     100 |                   
  ...olderTrust.ts |     100 |      100 |     100 |     100 |                   
  ...ggestions.tsx |   67.46 |       90 |      50 |   67.46 | ...09-130,149-150 
  ...miniStream.ts |   75.84 |    72.89 |   91.66 |   75.84 | ...2293,2306-2314 
  ...BranchName.ts |    90.9 |     92.3 |     100 |    90.9 | 19-20,55-58       
  ...oryManager.ts |   93.15 |    93.75 |     100 |   93.15 | 44,107-110        
  ...ooksDialog.ts |    87.5 |      100 |     100 |    87.5 | 19,23             
  ...stListener.ts |     100 |      100 |     100 |     100 |                   
  ...nAuthError.ts |   76.19 |       50 |     100 |   76.19 | 39-40,43-45       
  ...putHistory.ts |   92.59 |    85.71 |     100 |   92.59 | 63-64,72,94-96    
  ...storyStore.ts |     100 |    94.11 |     100 |     100 | 69                
  useKeypress.ts   |     100 |      100 |     100 |     100 |                   
  ...rdProtocol.ts |   36.36 |      100 |       0 |   36.36 | 24-31             
  ...unchEditor.ts |    9.67 |      100 |       0 |    9.67 | 11-32,39-90       
  ...gIndicator.ts |     100 |      100 |     100 |     100 |                   
  useLogger.ts     |   21.05 |      100 |       0 |   21.05 | 15-37             
  useMCPHealth.ts  |   70.58 |       75 |      50 |   70.58 | 42-47,59-62       
  ...elsCommand.ts |     100 |      100 |     100 |     100 |                   
  useMcpDialog.ts  |    87.5 |      100 |     100 |    87.5 | 19,23             
  ...moryDialog.ts |    87.5 |      100 |     100 |    87.5 | 19,23             
  ...oryMonitor.ts |     100 |      100 |     100 |     100 |                   
  ...ssageQueue.ts |     100 |      100 |     100 |     100 |                   
  ...delCommand.ts |     100 |       75 |     100 |     100 | 22                
  ...raseCycler.ts |   84.74 |    76.47 |     100 |   84.74 | ...49,52-53,69-71 
  useQwenAuth.ts   |     100 |      100 |     100 |     100 |                   
  ...lScheduler.ts |   84.52 |    93.33 |     100 |   84.52 | ...27-232,328-338 
  ...oryCommand.ts |       0 |        0 |       0 |       0 | 1-7               
  ...umeCommand.ts |   97.24 |    76.92 |     100 |   97.24 | 104-105,145       
  ...ompletion.tsx |   90.59 |    83.33 |     100 |   90.59 | ...01,104,137-140 
  ...ectionList.ts |   96.96 |    95.69 |     100 |   96.96 | ...82-183,237-240 
  ...sionPicker.ts |   90.23 |    71.69 |     100 |   90.23 | ...78-279,283-284 
  ...ngsCommand.ts |   18.75 |      100 |       0 |   18.75 | 10-25             
  ...ellHistory.ts |   91.74 |    79.41 |     100 |   91.74 | ...74,122-123,133 
  ...oryCommand.ts |       0 |        0 |       0 |       0 | 1-73              
  ...Completion.ts |   78.99 |    81.48 |   94.11 |   78.99 | ...77-579,587-624 
  ...tateAndRef.ts |     100 |      100 |     100 |     100 |                   
  useStatusLine.ts |     100 |    98.79 |     100 |     100 | 257               
  ...eateDialog.ts |   88.23 |      100 |     100 |   88.23 | 14,18             
  ...tification.ts |     100 |    85.71 |     100 |     100 | 47                
  ...alProgress.ts |   53.06 |       50 |   66.66 |   53.06 | ...53,61-68,79-85 
  ...rminalSize.ts |   76.19 |      100 |      50 |   76.19 | 21-25             
  ...emeCommand.ts |   67.01 |    29.41 |     100 |   67.01 | ...10-111,115-116 
  useTimer.ts      |   88.09 |    85.71 |     100 |   88.09 | 44-45,51-53       
  ...lMigration.ts |       0 |        0 |       0 |       0 |                   
  ...rustModify.ts |     100 |      100 |     100 |     100 |                   
  ...elcomeBack.ts |   87.36 |     90.9 |     100 |   87.36 | ...,94-96,114-115 
  vim.ts           |   83.77 |    80.31 |     100 |   83.77 | ...55,759-767,776 
 src/ui/layouts    |   89.51 |    86.95 |     100 |   89.51 |                   
  ...AppLayout.tsx |   89.53 |    86.66 |     100 |   89.53 | 50-52,92-97       
  ...AppLayout.tsx |   89.47 |     87.5 |     100 |   89.47 | 58-63             
 ...i/manageModels |   93.61 |       48 |     100 |   93.61 |                   
  manageModels.ts  |   93.61 |       48 |     100 |   93.61 | ...63-166,179,209 
 src/ui/models     |   80.24 |    79.16 |   71.42 |   80.24 |                   
  ...ableModels.ts |   80.24 |    79.16 |   71.42 |   80.24 | ...,61-71,123-125 
 ...noninteractive |     100 |      100 |    7.14 |     100 |                   
  ...eractiveUi.ts |     100 |      100 |    7.14 |     100 |                   
 src/ui/state      |   94.91 |    81.81 |     100 |   94.91 |                   
  extensions.ts    |   94.91 |    81.81 |     100 |   94.91 | 68-69,88          
 src/ui/themes     |   98.53 |    70.31 |     100 |   98.53 |                   
  ansi-light.ts    |     100 |      100 |     100 |     100 |                   
  ansi.ts          |     100 |      100 |     100 |     100 |                   
  atom-one-dark.ts |     100 |      100 |     100 |     100 |                   
  ayu-light.ts     |     100 |      100 |     100 |     100 |                   
  ayu.ts           |     100 |      100 |     100 |     100 |                   
  color-utils.ts   |     100 |      100 |     100 |     100 |                   
  default-light.ts |     100 |      100 |     100 |     100 |                   
  default.ts       |     100 |      100 |     100 |     100 |                   
  ...inal-theme.ts |   88.59 |    85.45 |     100 |   88.59 | ...57-261,266-270 
  dracula.ts       |     100 |      100 |     100 |     100 |                   
  github-dark.ts   |     100 |      100 |     100 |     100 |                   
  github-light.ts  |     100 |      100 |     100 |     100 |                   
  googlecode.ts    |     100 |      100 |     100 |     100 |                   
  no-color.ts      |     100 |      100 |     100 |     100 |                   
  qwen-dark.ts     |     100 |      100 |     100 |     100 |                   
  qwen-light.ts    |     100 |      100 |     100 |     100 |                   
  ...tic-tokens.ts |     100 |      100 |     100 |     100 |                   
  ...-of-purple.ts |     100 |      100 |     100 |     100 |                   
  theme-manager.ts |   87.98 |    82.89 |     100 |   87.98 | ...48-357,362-363 
  theme.ts         |     100 |    38.02 |     100 |     100 | ...34-449,457-461 
  xcode.ts         |     100 |      100 |     100 |     100 |                   
 src/ui/utils      |   77.04 |    86.24 |   85.71 |   77.04 |                   
  ...Colorizer.tsx |   82.78 |    88.23 |     100 |   82.78 | ...10-111,197-223 
  ...nRenderer.tsx |   52.41 |    36.36 |      50 |   52.41 | ...49-151,171-180 
  ...wnDisplay.tsx |   86.79 |    88.88 |     100 |   86.79 | ...06-315,348-373 
  ...eRenderer.tsx |   94.45 |    81.25 |   94.11 |   94.45 | ...65,477,480-483 
  ...dWorkUtils.ts |     100 |      100 |     100 |     100 |                   
  ...boardUtils.ts |   59.61 |    58.82 |     100 |   59.61 | ...,86-88,107-149 
  commandUtils.ts  |    84.7 |    88.13 |      90 |    84.7 | ...63-164,260-279 
  computeStats.ts  |     100 |      100 |     100 |     100 |                   
  displayUtils.ts  |   88.37 |    72.22 |     100 |   88.37 | 23,25,29,31,33    
  formatters.ts    |   95.23 |    98.27 |     100 |   95.23 | 117-120           
  gradientUtils.ts |     100 |      100 |     100 |     100 |                   
  highlight.ts     |   98.63 |       95 |     100 |   98.63 | 93                
  ...oryMapping.ts |     100 |    94.28 |     100 |     100 | 34,56             
  isNarrowWidth.ts |     100 |      100 |     100 |     100 |                   
  ...olDetector.ts |    8.23 |      100 |       0 |    8.23 | ...31-132,135-136 
  layoutUtils.ts   |     100 |      100 |     100 |     100 |                   
  ...nUtilities.ts |   69.84 |    85.71 |     100 |   69.84 | 75-91,100-101     
  ...ToolGroups.ts |    98.3 |    95.65 |     100 |    98.3 | 48-49             
  ...lsBySource.ts |     100 |    95.23 |     100 |     100 | 84                
  ...mConstants.ts |     100 |      100 |     100 |     100 |                   
  ...storyUtils.ts |   57.81 |    67.14 |      90 |   57.81 | ...64,412,417-439 
  ...ickerUtils.ts |     100 |      100 |     100 |     100 |                   
  ...izedOutput.ts |   94.94 |      100 |   88.88 |   94.94 | 112-117           
  ...wOptimizer.ts |     100 |    96.77 |     100 |     100 | 69                
  terminalSetup.ts |    4.37 |      100 |       0 |    4.37 | 44-393            
  textUtils.ts     |   96.47 |    93.18 |   91.66 |   96.47 | ...46-247,382-383 
  todoSnapshot.ts  |   89.11 |    93.18 |     100 |   89.11 | ...,66-78,180-181 
  updateCheck.ts   |     100 |    80.95 |     100 |     100 | 30-42             
 ...i/utils/export |   56.77 |     40.8 |   79.41 |   56.77 |                   
  collect.ts       |   55.92 |    50.58 |   86.36 |   55.92 | ...25-640,642-647 
  index.ts         |     100 |      100 |     100 |     100 |                   
  normalize.ts     |   57.47 |    20.51 |      80 |   57.47 | ...09-310,324-359 
  types.ts         |       0 |        0 |       0 |       0 | 1                 
  utils.ts         |      40 |      100 |       0 |      40 | 11-13             
 ...ort/formatters |    3.38 |      100 |       0 |    3.38 |                   
  html.ts          |    9.61 |      100 |       0 |    9.61 | ...28,34-76,82-84 
  json.ts          |      50 |      100 |       0 |      50 | 14-15             
  jsonl.ts         |     3.5 |      100 |       0 |     3.5 | 14-76             
  markdown.ts      |    0.94 |      100 |       0 |    0.94 | 13-295            
 src/utils         |   73.76 |    89.53 |   94.55 |   73.76 |                   
  acpModelUtils.ts |     100 |      100 |     100 |     100 |                   
  apiPreconnect.ts |   96.52 |    97.05 |     100 |   96.52 | 166-169           
  checks.ts        |   33.33 |      100 |       0 |   33.33 | 23-28             
  cleanup.ts       |   84.12 |    93.33 |      80 |   84.12 | 75,106-115        
  commands.ts      |     100 |      100 |     100 |     100 |                   
  commentJson.ts   |   85.29 |    89.47 |     100 |   85.29 | 48-57             
  ...Calculator.ts |     100 |      100 |     100 |     100 |                   
  deepMerge.ts     |     100 |       90 |     100 |     100 | 41-43,49          
  ...ScopeUtils.ts |   97.56 |    88.88 |     100 |   97.56 | 67                
  doctorChecks.ts  |   68.59 |    64.28 |     100 |   68.59 | ...63-269,293-309 
  ...putCapture.ts |   90.65 |    86.17 |     100 |   90.65 | ...72,370,372-373 
  ...arResolver.ts |   94.28 |    88.46 |     100 |   94.28 | 28-29,125-126     
  errors.ts        |   98.63 |    96.15 |     100 |   98.63 | 67-68             
  events.ts        |     100 |      100 |     100 |     100 |                   
  gitUtils.ts      |   91.91 |    84.61 |     100 |   91.91 | 78-81,124-127     
  ...AutoUpdate.ts |   90.76 |    93.33 |   88.88 |   90.76 | 103-114           
  ...lationInfo.ts |     100 |      100 |     100 |     100 |                   
  languageUtils.ts |   97.89 |    96.42 |     100 |   97.89 | 132-133           
  math.ts          |       0 |        0 |       0 |       0 | 1-15              
  ...onfigUtils.ts |     100 |      100 |     100 |     100 |                   
  ...iveHelpers.ts |   96.79 |    93.28 |     100 |   96.79 | ...76-477,575,588 
  osc.ts           |    97.5 |      100 |   88.88 |    97.5 | 195-196           
  package.ts       |   88.88 |       80 |     100 |   88.88 | 33-34             
  processUtils.ts  |     100 |      100 |     100 |     100 |                   
  readStdin.ts     |   79.62 |       90 |      80 |   79.62 | 33-40,52-54       
  relaunch.ts      |   98.07 |    76.92 |     100 |   98.07 | 70                
  resolvePath.ts   |   66.66 |       25 |     100 |   66.66 | 12-13,16,18-19    
  sandbox.ts       |       0 |        0 |       0 |       0 | 1-980             
  settingsUtils.ts |   86.32 |    90.59 |   94.44 |   86.32 | ...38,569,632-644 
  spawnWrapper.ts  |     100 |      100 |     100 |     100 |                   
  ...upProfiler.ts |     100 |    95.83 |     100 |     100 | 110               
  ...upWarnings.ts |     100 |      100 |     100 |     100 |                   
  stdioHelpers.ts  |     100 |       60 |     100 |     100 | 23,32             
  systemInfo.ts    |   92.52 |     90.9 |   83.33 |   92.52 | 63-69,184         
  ...InfoFields.ts |   86.91 |    65.78 |     100 |   86.91 | ...16-117,138-139 
  ...iffPreview.ts |   94.11 |    83.33 |     100 |   94.11 | 13                
  ...entEmitter.ts |     100 |      100 |     100 |     100 |                   
  ...upWarnings.ts |   91.17 |    82.35 |     100 |   91.17 | 67-68,73-74,77-78 
  version.ts       |     100 |       50 |     100 |     100 | 11                
  windowTitle.ts   |     100 |      100 |     100 |     100 |                   
  ...WithBackup.ts |    62.1 |    77.77 |     100 |    62.1 | 93,107,118-157    
-------------------|---------|----------|---------|---------|-------------------
Core Package - Full Text Report
-------------------|---------|----------|---------|---------|-------------------
File               | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
-------------------|---------|----------|---------|---------|-------------------
All files          |   76.55 |    82.11 |   79.06 |   76.55 |                   
 src               |     100 |      100 |     100 |     100 |                   
  index.ts         |     100 |      100 |     100 |     100 |                   
 src/__mocks__/fs  |       0 |        0 |       0 |       0 |                   
  promises.ts      |       0 |        0 |       0 |       0 | 1-48              
 src/agents        |   84.11 |    76.21 |   89.61 |   84.11 |                   
  ...transcript.ts |   88.76 |    75.43 |     100 |   88.76 | ...82,306-307,434 
  ...ent-resume.ts |   78.75 |       70 |      75 |   78.75 | ...78-982,985-987 
  ...ound-tasks.ts |   94.19 |    86.17 |     100 |   94.19 | ...15-616,633-634 
  index.ts         |     100 |      100 |     100 |     100 |                   
 src/agents/arena  |    76.9 |    66.66 |   78.94 |    76.9 |                   
  ...gentClient.ts |   79.47 |    88.88 |   81.81 |   79.47 | ...68-183,189-204 
  ArenaManager.ts  |   75.84 |     62.9 |   78.57 |   75.84 | ...1889,1895-1896 
  arena-events.ts  |   64.44 |      100 |      50 |   64.44 | ...71-175,178-183 
  diff-summary.ts  |    87.5 |    73.46 |     100 |    87.5 | ...32-133,137-138 
  index.ts         |     100 |      100 |     100 |     100 |                   
  types.ts         |     100 |      100 |     100 |     100 |                   
 ...gents/backends |    76.3 |    86.23 |   72.41 |    76.3 |                   
  ITermBackend.ts  |   97.97 |    93.93 |     100 |   97.97 | ...78-180,255,307 
  ...essBackend.ts |   91.06 |     90.9 |   82.35 |   91.06 | ...51-271,330,430 
  TmuxBackend.ts   |    90.7 |    76.55 |   97.36 |    90.7 | ...87,697,743-747 
  detect.ts        |   31.25 |      100 |       0 |   31.25 | 34-88             
  index.ts         |     100 |      100 |     100 |     100 |                   
  iterm-it2.ts     |     100 |     92.1 |     100 |     100 | 37-38,106         
  tmux-commands.ts |    6.64 |      100 |    3.03 |    6.64 | ...93-363,386-503 
  types.ts         |     100 |      100 |     100 |     100 |                   
 ...agents/runtime |   79.81 |    75.21 |      66 |   79.81 |                   
  agent-core.ts    |   73.97 |    69.03 |   48.48 |   73.97 | ...1299,1326-1372 
  agent-events.ts  |     100 |      100 |     100 |     100 |                   
  ...t-headless.ts |   79.09 |    69.76 |   52.38 |   79.09 | ...78-379,382-383 
  ...nteractive.ts |   79.71 |    79.62 |      75 |   79.71 | ...54,456,458,461 
  ...statistics.ts |   98.19 |    82.35 |     100 |   98.19 | 127,151,192,225   
  agent-types.ts   |     100 |      100 |     100 |     100 |                   
  index.ts         |     100 |      100 |     100 |     100 |                   
 src/config        |   74.35 |    75.86 |    61.2 |   74.35 |                   
  config.ts        |   71.97 |    72.98 |   55.61 |   71.97 | ...2888,2892-2904 
  constants.ts     |     100 |      100 |     100 |     100 |                   
  models.ts        |     100 |      100 |     100 |     100 |                   
  storage.ts       |   95.72 |     93.1 |   91.66 |   95.72 | ...06-207,241-242 
 ...nfirmation-bus |   98.29 |    97.14 |     100 |   98.29 |                   
  message-bus.ts   |   98.14 |    97.05 |     100 |   98.14 | 42-43             
  types.ts         |     100 |      100 |     100 |     100 |                   
 src/constants     |    4.95 |      100 |       0 |    4.95 |                   
  codingPlan.ts    |    4.95 |      100 |       0 |    4.95 | ...79-291,299-309 
 src/core          |   82.72 |    82.31 |   87.91 |   82.72 |                   
  baseLlmClient.ts |   96.77 |    96.42 |      80 |   96.77 | 123-126           
  client.ts        |   77.17 |    77.88 |    86.2 |   77.17 | ...1264,1268-1284 
  ...tGenerator.ts |    72.1 |    61.11 |     100 |    72.1 | ...63,365,372-375 
  ...lScheduler.ts |   77.12 |    81.01 |   92.68 |   77.12 | ...2200,2252-2256 
  geminiChat.ts    |   88.73 |    84.23 |   83.33 |   88.73 | ...1113,1180-1181 
  geminiRequest.ts |     100 |      100 |     100 |     100 |                   
  ...htProtocol.ts |    9.09 |      100 |       0 |    9.09 | 34-42,45-49,52-87 
  logger.ts        |   82.25 |    81.81 |     100 |   82.25 | ...57-361,407-421 
  ...tyDefaults.ts |     100 |      100 |     100 |     100 |                   
  ...olExecutor.ts |   92.59 |       75 |      50 |   92.59 | 41-42             
  ...on-helpers.ts |   85.71 |    70.58 |     100 |   85.71 | ...90-191,205-214 
  ...issionFlow.ts |   98.59 |    94.73 |     100 |   98.59 | 93                
  prompts.ts       |    88.8 |    88.05 |      75 |    88.8 | ...-898,1101-1102 
  tokenLimits.ts   |     100 |    89.47 |     100 |     100 | 51-52             
  ...okTriggers.ts |   99.31 |    91.02 |     100 |   99.31 | 124,135           
  turn.ts          |   96.42 |    88.88 |     100 |   96.42 | ...00,413-414,462 
 ...ntentGenerator |    94.6 |    79.35 |   92.68 |    94.6 |                   
  ...tGenerator.ts |   96.49 |    79.35 |      90 |   96.49 | ...24,481,637,693 
  converter.ts     |   94.38 |    79.78 |     100 |   94.38 | ...40-541,551,734 
  index.ts         |       0 |        0 |       0 |       0 | 1-21              
 ...ntentGenerator |   91.53 |    71.64 |   93.33 |   91.53 |                   
  ...tGenerator.ts |      90 |    70.96 |   92.85 |      90 | ...80-286,304-305 
  index.ts         |     100 |       80 |     100 |     100 | 50                
 ...ntentGenerator |   91.08 |    76.14 |   85.71 |   91.08 |                   
  index.ts         |     100 |      100 |     100 |     100 |                   
  ...tGenerator.ts |   91.04 |    76.14 |   85.71 |   91.04 | ...23,533-534,562 
 ...ntentGenerator |   79.48 |    83.73 |   89.47 |   79.48 |                   
  constants.ts     |     100 |      100 |     100 |     100 |                   
  converter.ts     |   74.92 |    80.85 |   86.95 |   74.92 | ...1410,1431-1437 
  errorHandler.ts  |     100 |      100 |     100 |     100 |                   
  index.ts         |   43.85 |    14.28 |      50 |   43.85 | ...,87-91,102-103 
  ...tGenerator.ts |   48.78 |    91.66 |   77.77 |   48.78 | ...10-163,166-167 
  pipeline.ts      |   93.62 |    84.76 |     100 |   93.62 | ...78-479,487,547 
  ...ingOptions.ts |       0 |        0 |       0 |       0 | 1                 
  ...CallParser.ts |   90.66 |     88.4 |     100 |   90.66 | ...15-319,349-350 
  ...kingParser.ts |     100 |    96.87 |     100 |     100 | 42                
  types.ts         |       0 |        0 |       0 |       0 | 1                 
 ...rator/provider |   96.67 |    89.74 |   94.87 |   96.67 |                   
  dashscope.ts     |   97.22 |    87.69 |   93.33 |   97.22 | ...10-211,287-288 
  deepseek.ts      |   95.55 |    90.56 |     100 |   95.55 | ...31-132,145-146 
  default.ts       |   94.62 |    86.36 |   85.71 |   94.62 | 85-86,156-158     
  index.ts         |     100 |      100 |     100 |     100 |                   
  minimax.ts       |     100 |      100 |     100 |     100 |                   
  modelscope.ts    |     100 |      100 |     100 |     100 |                   
  openrouter.ts    |     100 |      100 |     100 |     100 |                   
  types.ts         |       0 |        0 |       0 |       0 |                   
 src/extension     |   60.62 |    79.43 |   79.03 |   60.62 |                   
  ...-converter.ts |   62.35 |    47.82 |      90 |   62.35 | ...90-791,800-832 
  ...ionManager.ts |   46.92 |    82.19 |   67.44 |   46.92 | ...1386,1396-1415 
  ...onSettings.ts |   93.46 |    93.05 |     100 |   93.46 | ...17-221,228-232 
  ...-converter.ts |   54.88 |    94.44 |      60 |   54.88 | ...35-146,158-192 
  github.ts        |   44.94 |    88.52 |      60 |   44.94 | ...53-359,398-451 
  index.ts         |     100 |      100 |     100 |     100 |                   
  marketplace.ts   |   97.29 |    93.75 |     100 |   97.29 | ...64,184-185,274 
  npm.ts           |   48.66 |    76.08 |      75 |   48.66 | ...18-420,427-431 
  override.ts      |   94.11 |    88.88 |     100 |   94.11 | 63-64,81-82       
  settings.ts      |   66.26 |      100 |      50 |   66.26 | 81-108,143-149    
  storage.ts       |   94.73 |       90 |     100 |   94.73 | 41-42             
  ...ableSchema.ts |     100 |      100 |     100 |     100 |                   
  variables.ts     |   88.75 |    83.33 |     100 |   88.75 | ...28-231,234-237 
 src/followup      |   46.35 |     92.3 |   71.87 |   46.35 |                   
  followupState.ts |      96 |    89.74 |     100 |      96 | 159-161,218-219   
  index.ts         |     100 |      100 |     100 |     100 |                   
  overlayFs.ts     |   95.06 |       84 |     100 |   95.06 | 78,108,122,133    
  speculation.ts   |   13.22 |      100 |   16.66 |   13.22 | 88-458,518-568    
  ...onToolGate.ts |     100 |    96.29 |     100 |     100 | 93                
  ...nGenerator.ts |   36.67 |    95.12 |   33.33 |   36.67 | ...24-326,361-391 
 src/generated     |       0 |        0 |       0 |       0 |                   
  git-commit.ts    |       0 |        0 |       0 |       0 | 1-10              
 src/hooks         |    80.6 |    84.37 |   84.16 |    80.6 |                   
  ...okRegistry.ts |   86.48 |    77.08 |     100 |   86.48 | ...41-344,362-369 
  ...bortSignal.ts |     100 |      100 |     100 |     100 |                   
  ...terpolator.ts |   96.66 |    93.33 |     100 |   96.66 | 66-67             
  ...HookRunner.ts |   96.68 |    87.23 |     100 |   96.68 | 110-112,231-233   
  ...Aggregator.ts |   96.37 |    90.54 |     100 |   96.37 | ...89,291-292,365 
  ...entHandler.ts |   95.58 |    84.37 |   92.59 |   95.58 | ...29,682-683,693 
  hookPlanner.ts   |   84.13 |    76.59 |      90 |   84.13 | ...38,144,162-173 
  hookRegistry.ts  |   88.83 |    86.36 |     100 |   88.83 | ...21,326,330,334 
  hookRunner.ts    |   53.63 |    72.22 |   61.11 |   53.63 | ...23-724,733-734 
  hookSystem.ts    |   75.47 |      100 |   56.41 |   75.47 | ...75-576,582-583 
  ...HookRunner.ts |   75.51 |     61.9 |      80 |   75.51 | ...05-406,424-425 
  index.ts         |     100 |      100 |     100 |     100 |                   
  ...SkillHooks.ts |   78.75 |       75 |   66.66 |   78.75 | 62-66,137-152     
  ...oksManager.ts |    96.5 |     91.8 |     100 |    96.5 | ...90,209-210,223 
  ssrfGuard.ts     |   77.22 |    85.36 |     100 |   77.22 | ...57,261-267,273 
  trustedHooks.ts  |       0 |        0 |       0 |       0 | 1-124             
  types.ts         |   90.15 |    91.02 |   85.18 |   90.15 | ...91-392,452-456 
  urlValidator.ts  |     100 |      100 |     100 |     100 |                   
 src/ide           |   74.28 |    83.39 |   78.33 |   74.28 |                   
  constants.ts     |     100 |      100 |     100 |     100 |                   
  detect-ide.ts    |     100 |      100 |     100 |     100 |                   
  ide-client.ts    |    64.2 |    81.48 |   66.66 |    64.2 | ...9-970,999-1007 
  ide-installer.ts |   89.06 |    79.31 |     100 |   89.06 | ...36,143-147,160 
  ideContext.ts    |     100 |      100 |     100 |     100 |                   
  process-utils.ts |   84.84 |    71.79 |     100 |   84.84 | ...37,151,193-194 
  types.ts         |     100 |      100 |     100 |     100 |                   
 src/lsp           |   33.92 |    45.16 |   45.76 |   33.92 |                   
  ...nfigLoader.ts |   70.27 |    35.89 |   94.73 |   70.27 | ...20-422,426-432 
  ...ionFactory.ts |    4.29 |      100 |       0 |    4.29 | ...20-371,377-394 
  ...Normalizer.ts |   23.09 |    13.72 |   30.43 |   23.09 | ...04-905,909-924 
  ...verManager.ts |   13.52 |    81.25 |   29.16 |   13.52 | ...75-694,700-730 
  ...eLspClient.ts |   17.89 |      100 |       0 |   17.89 | ...37-244,254-258 
  ...LspService.ts |   45.87 |    62.13 |   66.66 |   45.87 | ...1282,1299-1309 
  constants.ts     |     100 |      100 |     100 |     100 |                   
  types.ts         |     100 |      100 |     100 |     100 |                   
 src/mcp           |   78.69 |    75.34 |   75.92 |   78.69 |                   
  constants.ts     |     100 |      100 |     100 |     100 |                   
  ...h-provider.ts |   86.95 |      100 |   33.33 |   86.95 | ...,93,97,101-102 
  ...h-provider.ts |   73.82 |    53.92 |     100 |   73.82 | ...88-895,902-904 
  ...en-storage.ts |   98.62 |    97.72 |     100 |   98.62 | 87-88             
  oauth-utils.ts   |   70.58 |    85.29 |    90.9 |   70.58 | ...70-290,315-344 
  ...n-provider.ts |   89.83 |    95.83 |   45.45 |   89.83 | ...43,147,151-152 
 .../token-storage |   79.48 |    86.66 |   86.36 |   79.48 |                   
  ...en-storage.ts |     100 |      100 |     100 |     100 |                   
  ...en-storage.ts |   82.75 |    82.35 |   92.85 |   82.75 | ...62-172,180-181 
  ...en-storage.ts |     100 |      100 |     100 |     100 |                   
  index.ts         |     100 |      100 |     100 |     100 |                   
  ...en-storage.ts |   68.14 |    82.35 |   64.28 |   68.14 | ...81-295,298-314 
  types.ts         |     100 |      100 |     100 |     100 |                   
 src/memory        |   61.59 |    74.87 |   66.44 |   61.59 |                   
  const.ts         |     100 |      100 |     100 |     100 |                   
  dream.ts         |   65.65 |    73.33 |      50 |   65.65 | 50,107-148        
  ...entPlanner.ts |   57.84 |    72.72 |   33.33 |   57.84 | ...35,140-147,152 
  entries.ts       |   59.84 |       70 |      50 |   59.84 | ...72-180,183-189 
  extract.ts       |    95.2 |    79.16 |     100 |    95.2 | 81-86,125         
  ...entPlanner.ts |   63.08 |    65.71 |   41.17 |   63.08 | ...17,222-223,332 
  ...ionPlanner.ts |       0 |        0 |       0 |       0 | 1                 
  forget.ts        |    8.04 |      100 |       0 |    8.04 | 67-342            
  governance.ts    |       0 |        0 |       0 |       0 | 1-352             
  indexer.ts       |   83.87 |    45.45 |     100 |   83.87 | ...50,56-57,69-70 
  manager.ts       |   73.32 |    78.94 |   74.35 |   73.32 | ...1163,1176-1178 
  memoryAge.ts     |   90.47 |    77.77 |     100 |   90.47 | 50-51             
  paths.ts         |   55.47 |    89.47 |   85.71 |   55.47 | ...,88-89,105-113 
  prompt.ts        |   93.36 |    71.42 |     100 |   93.36 | ...58,161,228-229 
  recall.ts        |   79.56 |    69.38 |   88.88 |   79.56 | ...40-245,269-280 
  ...ceSelector.ts |   91.86 |    77.27 |     100 |   91.86 | ...04,106-107,115 
  scan.ts          |   87.91 |    68.42 |     100 |   87.91 | ...47-48,58,82-87 
  status.ts        |   10.52 |      100 |       0 |   10.52 | 41-98             
  store.ts         |   94.44 |    83.33 |     100 |   94.44 | 56-57,92-93       
  types.ts         |     100 |      100 |     100 |     100 |                   
 src/mocks         |       0 |        0 |       0 |       0 |                   
  msw.ts           |       0 |        0 |       0 |       0 | 1-9               
 src/models        |   89.49 |    85.58 |   87.14 |   89.49 |                   
  constants.ts     |     100 |      100 |     100 |     100 |                   
  ...tor-config.ts |   88.67 |     90.9 |     100 |   88.67 | 112,118,121-130   
  index.ts         |     100 |      100 |     100 |     100 |                   
  ...nfigErrors.ts |   74.22 |       44 |   84.61 |   74.22 | ...,67-74,106-117 
  ...igResolver.ts |   98.63 |    92.53 |     100 |   98.63 | 161,323,329       
  modelRegistry.ts |     100 |    98.21 |     100 |     100 | 182               
  modelsConfig.ts  |   85.37 |    83.54 |   81.57 |   85.37 | ...1210,1239-1240 
  types.ts         |     100 |      100 |     100 |     100 |                   
 src/output        |     100 |      100 |     100 |     100 |                   
  ...-formatter.ts |     100 |      100 |     100 |     100 |                   
  types.ts         |     100 |      100 |     100 |     100 |                   
 src/permissions   |   71.18 |    88.73 |   48.57 |   71.18 |                   
  index.ts         |     100 |      100 |     100 |     100 |                   
  ...on-manager.ts |   81.42 |    86.66 |      80 |   81.42 | ...19-820,827-836 
  rule-parser.ts   |   95.99 |    93.18 |     100 |   95.99 | ...-864,1013-1015 
  ...-semantics.ts |   58.28 |    85.27 |    30.2 |   58.28 | ...1604-1614,1643 
  types.ts         |     100 |      100 |     100 |     100 |                   
 src/prompts       |   83.63 |      100 |    87.5 |   83.63 |                   
  mcp-prompts.ts   |   18.18 |      100 |       0 |   18.18 | 11-19             
  ...t-registry.ts |     100 |      100 |     100 |     100 |                   
 src/qwen          |   86.03 |    79.48 |   97.18 |   86.03 |                   
  ...tGenerator.ts |   98.64 |    98.18 |     100 |   98.64 | 105-106           
  qwenOAuth2.ts    |   85.01 |    74.81 |   93.33 |   85.01 | ...,986-1002,1032 
  ...kenManager.ts |   83.79 |    76.22 |     100 |   83.79 | ...63-768,789-794 
 src/services      |   84.83 |    83.68 |   89.14 |   84.83 |                   
  ...llRegistry.ts |   97.82 |    94.73 |     100 |   97.82 | 172-173           
  ...ionService.ts |   95.42 |       94 |     100 |   95.42 | ...79,336,338-342 
  ...ingService.ts |   79.37 |     82.5 |   78.12 |   79.37 | ...1066,1083-1084 
  cronScheduler.ts |   97.56 |    92.98 |     100 |   97.56 | 62-63,77,155      
  ...eryService.ts |   80.43 |    95.45 |      75 |   80.43 | ...19-134,140-141 
  fileReadCache.ts |     100 |      100 |     100 |     100 |                   
  ...temService.ts |   89.76 |     85.1 |   88.88 |   89.76 | ...89,191,266-273 
  gitInit.ts       |     100 |      100 |     100 |     100 |                   
  gitService.ts    |   68.75 |     92.3 |   55.55 |   68.75 | ...12-122,125-129 
  ...reeService.ts |   71.83 |    68.47 |    91.3 |   71.83 | ...89-790,806,822 
  ...ionService.ts |   98.13 |     97.8 |   95.45 |   98.13 | ...32-333,380-381 
  ...orRegistry.ts |   96.76 |    92.15 |     100 |   96.76 | ...95-396,449-450 
  sessionRecap.ts  |   10.71 |      100 |       0 |   10.71 | 48-161            
  ...ionService.ts |   85.48 |    71.62 |      96 |   85.48 | ...73-983,987-988 
  sessionTitle.ts  |   93.95 |    70.37 |     100 |   93.95 | ...36-239,270-271 
  ...ionService.ts |   83.04 |    78.75 |   87.75 |   83.04 | ...1453,1459-1464 
  ...UseSummary.ts |    94.7 |    88.67 |     100 |    94.7 | ...69-171,221-222 
 ...icrocompaction |   98.62 |    86.44 |     100 |   98.62 |                   
  microcompact.ts  |   98.62 |    86.44 |     100 |   98.62 | 138,142           
 src/skills        |    86.7 |    83.88 |   93.61 |    86.7 |                   
  index.ts         |     100 |      100 |     100 |     100 |                   
  ...activation.ts |     100 |     93.1 |     100 |     100 | 93,112            
  skill-load.ts    |   89.72 |    80.76 |     100 |   89.72 | ...28,248,260-262 
  skill-manager.ts |   82.68 |    79.32 |   90.32 |   82.68 | ...1135,1142-1146 
  symlinkScope.ts  |     100 |      100 |     100 |     100 |                   
  types.ts         |     100 |      100 |     100 |     100 |                   
 src/subagents     |   82.66 |    79.74 |   91.11 |   82.66 |                   
  ...tin-agents.ts |     100 |      100 |     100 |     100 |                   
  index.ts         |     100 |      100 |     100 |     100 |                   
  ...-selection.ts |     100 |      100 |     100 |     100 |                   
  ...nt-manager.ts |   76.51 |    71.42 |   87.09 |   76.51 | ...1148,1170-1171 
  types.ts         |     100 |      100 |     100 |     100 |                   
  validation.ts    |   92.46 |    95.18 |     100 |   92.46 | 51-56,69-74,78-83 
 src/telemetry     |   70.05 |       83 |   75.11 |   70.05 |                   
  config.ts        |     100 |      100 |     100 |     100 |                   
  constants.ts     |     100 |      100 |     100 |     100 |                   
  ...-exporters.ts |   46.37 |      100 |   44.44 |   46.37 | ...85,88-89,92-93 
  index.ts         |     100 |      100 |     100 |     100 |                   
  ...t.circular.ts |       0 |        0 |       0 |       0 | 1-111             
  ...-processor.ts |   91.28 |    83.67 |   92.85 |   91.28 | ...66-171,186-187 
  ...t.circular.ts |       0 |        0 |       0 |       0 | 1-128             
  loggers.ts       |    51.9 |       64 |   57.77 |    51.9 | ...1214,1231-1251 
  metrics.ts       |    74.9 |    82.95 |   74.54 |    74.9 | ...58-978,981-992 
  sanitize.ts      |      80 |    83.33 |     100 |      80 | 35-36,41-42       
  sdk.ts           |   88.97 |    77.77 |     100 |   88.97 | ...80-281,300-304 
  ...etry-utils.ts |     100 |      100 |     100 |     100 |                   
  ...l-decision.ts |     100 |      100 |     100 |     100 |                   
  types.ts         |   79.09 |    85.59 |   83.33 |   79.09 | ...1134,1137-1166 
  uiTelemetry.ts   |   92.97 |    96.96 |   81.25 |   92.97 | ...93-194,200-207 
 ...ry/qwen-logger |   68.01 |    80.21 |   64.91 |   68.01 |                   
  event-types.ts   |       0 |        0 |       0 |       0 |                   
  qwen-logger.ts   |   68.01 |       80 |   64.28 |   68.01 | ...1042,1080-1081 
 src/test-utils    |   93.07 |    95.55 |   73.52 |   93.07 |                   
  config.ts        |     100 |      100 |     100 |     100 |                   
  ...st-helpers.ts |   94.11 |       90 |     100 |   94.11 | 69-70             
  index.ts         |     100 |      100 |     100 |     100 |                   
  mock-tool.ts     |   91.02 |    96.77 |   68.96 |   91.02 | ...32,196-197,210 
  ...aceContext.ts |     100 |      100 |     100 |     100 |                   
 src/tools         |   76.37 |    80.81 |   81.41 |   76.37 |                   
  ...erQuestion.ts |    88.8 |    76.74 |    90.9 |    88.8 | ...36-337,344-345 
  cron-create.ts   |   97.61 |    88.88 |   83.33 |   97.61 | 30-31             
  cron-delete.ts   |   96.55 |      100 |   83.33 |   96.55 | 26-27             
  cron-list.ts     |   96.36 |      100 |   83.33 |   96.36 | 25-26             
  diffOptions.ts   |     100 |      100 |     100 |     100 |                   
  edit.ts          |   77.69 |    84.46 |   73.33 |   77.69 | ...76-677,764-814 
  exitPlanMode.ts  |   84.61 |    85.71 |     100 |   84.61 | ...60-163,177-189 
  glob.ts          |   90.63 |    88.33 |   84.61 |   90.63 | ...28,171,302,305 
  grep.ts          |   79.19 |    85.71 |   78.94 |   79.19 | ...20,560,569-576 
  ls.ts            |   96.74 |    90.27 |     100 |   96.74 | 176-181,212,216   
  lsp.ts           |   72.69 |    60.09 |   90.32 |   72.69 | ...1208,1210-1211 
  ...nt-manager.ts |   52.09 |     65.9 |   47.36 |   52.09 | ...02-520,523-560 
  mcp-client.ts    |   29.65 |    71.05 |   46.87 |   29.65 | ...1434,1438-1441 
  mcp-tool.ts      |   90.92 |    88.88 |   96.42 |   90.92 | ...89-590,640-641 
  memory-config.ts |       0 |        0 |       0 |       0 | 1-48              
  ...iable-tool.ts |     100 |    84.61 |     100 |     100 | 102,109           
  monitor.ts       |   92.16 |    83.45 |      92 |   92.16 | ...15,544-547,560 
  ...nforcement.ts |   82.03 |    89.47 |     100 |   82.03 | 137-148,197-210   
  read-file.ts     |   95.09 |     88.6 |      90 |   95.09 | ...99,271-274,277 
  ripGrep.ts       |   94.59 |    85.71 |   93.33 |   94.59 | ...60,463,541-542 
  ...-transport.ts |    6.34 |        0 |       0 |    6.34 | 47-145            
  send-message.ts  |   88.77 |    91.66 |   83.33 |   88.77 | 44-45,68-76       
  shell.ts         |   81.42 |    80.74 |    90.9 |   81.42 | ...1243,1292-1298 
  skill-utils.ts   |     100 |      100 |     100 |     100 |                   
  skill.ts         |   88.11 |    91.17 |   84.61 |   88.11 | ...95,399,422-444 
  task-stop.ts     |   92.94 |    96.15 |   85.71 |   92.94 | 39-40,54-64       
  todoWrite.ts     |   85.42 |    84.09 |   84.61 |   85.42 | ...05-410,432-433 
  tool-error.ts    |     100 |      100 |     100 |     100 |                   
  tool-names.ts    |     100 |      100 |     100 |     100 |                   
  tool-registry.ts |   67.49 |    68.91 |   65.71 |   67.49 | ...59-660,668-669 
  tools.ts         |    87.6 |    89.79 |   88.23 |    87.6 | ...31-432,448-454 
  web-fetch.ts     |   88.44 |    76.92 |    92.3 |   88.44 | ...05-306,308-309 
  write-file.ts    |   78.82 |     78.2 |   83.33 |   78.82 | ...13-616,628-663 
 src/tools/agent   |   82.63 |    83.83 |      80 |   82.63 |                   
  agent-context.ts |     100 |      100 |     100 |     100 |                   
  agent.ts         |   82.72 |    83.95 |   78.94 |   82.72 | ...1448,1457-1461 
  fork-subagent.ts |   78.26 |    71.42 |      80 |   78.26 | 54-72,104-105     
 src/utils         |    87.3 |    87.32 |    91.7 |    87.3 |                   
  LruCache.ts      |       0 |        0 |       0 |       0 | 1-41              
  ...ssageQueue.ts |     100 |      100 |     100 |     100 |                   
  ...cFileWrite.ts |   76.08 |    44.44 |     100 |   76.08 | 61-70,72          
  bareMode.ts      |   27.27 |      100 |       0 |   27.27 | 9-15,18-19        
  browser.ts       |    7.69 |      100 |       0 |    7.69 | 17-56             
  ...igResolver.ts |     100 |      100 |     100 |     100 |                   
  cronDisplay.ts   |   42.85 |    23.07 |     100 |   42.85 | 26-31,33-45,47-54 
  cronParser.ts    |   89.74 |    85.71 |     100 |   89.74 | ...,63-64,183-186 
  debugLogger.ts   |   96.12 |    93.75 |   93.75 |   96.12 | 164-168           
  editHelper.ts    |   93.63 |     83.9 |     100 |   93.63 | ...28-429,463-464 
  editor.ts        |   97.61 |    95.71 |     100 |   97.61 | ...70-271,273-274 
  ...arResolver.ts |   94.28 |    88.88 |     100 |   94.28 | 28-29,125-126     
  ...entContext.ts |     100 |    95.45 |     100 |     100 | 83                
  errorParsing.ts  |    97.7 |    96.87 |     100 |    97.7 | 72-73             
  ...rReporting.ts |   88.46 |       90 |     100 |   88.46 | 69-74             
  errors.ts        |   70.92 |    80.39 |   53.33 |   70.92 | ...03-219,223-229 
  fetch.ts         |   70.18 |    71.42 |   71.42 |   70.18 | ...42,148,161,186 
  fileUtils.ts     |   89.18 |    85.13 |   94.73 |   89.18 | ...91-898,902-908 
  forkedAgent.ts   |   62.98 |    54.54 |      75 |   62.98 | ...23-432,434-447 
  formatters.ts    |   54.54 |       50 |     100 |   54.54 | 12-16             
  ...eUtilities.ts |   89.21 |    86.66 |     100 |   89.21 | 16-17,49-55,65-66 
  ...rStructure.ts |   94.36 |    94.28 |     100 |   94.36 | ...17-120,330-335 
  getPty.ts        |    12.5 |      100 |       0 |    12.5 | 21-34             
  ...noreParser.ts |    92.3 |    89.36 |     100 |    92.3 | ...15-116,186-187 
  gitUtils.ts      |   38.88 |    84.61 |      50 |   38.88 | ...2,51-74,97-148 
  iconvHelper.ts   |     100 |      100 |     100 |     100 |                   
  ...rePatterns.ts |     100 |      100 |     100 |     100 |                   
  ...ionManager.ts |     100 |     90.9 |     100 |     100 | 26                
  ...lPromptIds.ts |     100 |      100 |     100 |     100 |                   
  jsonl-utils.ts   |   59.57 |    89.74 |   45.45 |   59.57 | ...53-286,292-298 
  ...-detection.ts |     100 |      100 |     100 |     100 |                   
  ...yDiscovery.ts |   83.85 |    79.36 |     100 |   83.85 | ...15,318,410-413 
  ...tProcessor.ts |   93.63 |       90 |     100 |   93.63 | ...96-302,384-385 
  ...Inspectors.ts |   61.53 |      100 |      50 |   61.53 | 18-23             
  ...kerChecker.ts |   82.55 |    78.57 |     100 |   82.55 | 68-69,79-84,92-98 
  notebook.ts      |   94.35 |    84.78 |     100 |   94.35 | ...10,122,174-176 
  openaiLogger.ts  |   86.27 |    82.14 |     100 |   86.27 | ...05-107,130-135 
  partUtils.ts     |     100 |      100 |     100 |     100 |                   
  pathReader.ts    |     100 |      100 |     100 |     100 |                   
  paths.ts         |   92.82 |    91.02 |     100 |   92.82 | ...71-372,374-376 
  pdf.ts           |   93.68 |    87.05 |     100 |   93.68 | ...96-297,321-325 
  projectPath.ts   |     100 |      100 |     100 |     100 |                   
  ...ectSummary.ts |   89.39 |    72.41 |     100 |   89.39 | ...37-142,193-196 
  ...tIdContext.ts |     100 |      100 |     100 |     100 |                   
  proxyUtils.ts    |     100 |      100 |     100 |     100 |                   
  ...rDetection.ts |   58.57 |       76 |     100 |   58.57 | ...4,88-89,95-100 
  ...noreParser.ts |   85.45 |    85.18 |     100 |   85.45 | ...59,65-66,72-73 
  rateLimit.ts     |   91.48 |    94.11 |     100 |   91.48 | 80,93-95          
  readManyFiles.ts |   87.96 |    86.95 |     100 |   87.96 | ...05-207,223-234 
  retry.ts         |   89.81 |    88.05 |     100 |   89.81 | ...29,350,357-358 
  ripgrepUtils.ts  |   46.53 |    83.33 |   66.66 |   46.53 | ...32-233,245-322 
  ...sDiscovery.ts |   97.42 |    92.85 |     100 |   97.42 | ...04,182-183,202 
  ...tchOptions.ts |   63.85 |    64.28 |   83.33 |   63.85 | ...29-130,187-188 
  safeJsonParse.ts |   74.07 |    83.33 |     100 |   74.07 | 40-46             
  ...nStringify.ts |     100 |      100 |     100 |     100 |                   
  ...aConverter.ts |   90.78 |    87.87 |     100 |   90.78 | ...41-42,93,95-96 
  ...aValidator.ts |   93.43 |    77.04 |     100 |   93.43 | ...46,155-158,212 
  ...r-launcher.ts |   76.92 |     91.3 |   66.66 |   76.92 | ...34,136,157-195 
  ...orageUtils.ts |   92.41 |    82.82 |     100 |   92.41 | ...39,423-430,441 
  shell-utils.ts   |   82.93 |     89.5 |     100 |   82.93 | ...1522,1529-1533 
  ...lAstParser.ts |   95.58 |    85.79 |     100 |   95.58 | ...1059-1061,1071 
  ...nlyChecker.ts |   95.75 |    92.47 |     100 |   95.75 | ...00-301,313-314 
  sideQuery.ts     |     100 |    92.85 |     100 |     100 | 43                
  ...tGenerator.ts |     100 |      100 |     100 |     100 |                   
  ...ameContext.ts |     100 |      100 |     100 |     100 |                   
  symlink.ts       |   77.77 |       50 |     100 |   77.77 | 44,54-59          
  ...emEncoding.ts |   96.36 |    91.17 |     100 |   96.36 | 59-60,124-125     
  terminalSafe.ts  |     100 |      100 |     100 |     100 |                   
  ...Serializer.ts |   98.72 |       90 |     100 |   98.72 | 42-43,134,201-203 
  testUtils.ts     |   53.33 |      100 |   33.33 |   53.33 | ...53,59-64,70-72 
  textUtils.ts     |      60 |      100 |   66.66 |      60 | 36-55             
  thoughtUtils.ts  |     100 |    92.85 |     100 |     100 | 71                
  ...-converter.ts |   94.59 |    85.71 |     100 |   94.59 | 35-36             
  tool-utils.ts    |    93.6 |     91.3 |     100 |    93.6 | ...58-159,162-163 
  truncation.ts    |     100 |       92 |     100 |     100 | 52,71             
  windowsPath.ts   |   89.47 |    79.31 |     100 |   89.47 | ...57-58,62,90-91 
  ...aceContext.ts |   93.71 |    88.88 |   93.33 |   93.71 | ...24-225,249-251 
  xml.ts           |     100 |      100 |     100 |     100 |                   
  yaml-parser.ts   |      92 |    84.31 |     100 |      92 | 49-53,65-69       
 ...ils/filesearch |   96.34 |    91.66 |     100 |   96.34 |                   
  crawlCache.ts    |     100 |      100 |     100 |     100 |                   
  crawler.ts       |   96.87 |    94.44 |     100 |   96.87 | 83-84             
  fileSearch.ts    |   93.29 |    86.76 |     100 |   93.29 | ...40-241,243-244 
  ignore.ts        |     100 |      100 |     100 |     100 |                   
  result-cache.ts  |     100 |     92.3 |     100 |     100 | 46                
 ...uest-tokenizer |   56.63 |    74.52 |   74.19 |   56.63 |                   
  ...eTokenizer.ts |   41.86 |    76.47 |   69.23 |   41.86 | ...70-443,453-507 
  index.ts         |     100 |      100 |     100 |     100 |                   
  ...tTokenizer.ts |   68.39 |    69.49 |    90.9 |   68.39 | ...24-325,327-328 
  ...ageFormats.ts |      76 |      100 |   33.33 |      76 | 45-48,55-56       
  textTokenizer.ts |     100 |      100 |     100 |     100 |                   
  types.ts         |       0 |        0 |       0 |       0 | 1                 
-------------------|---------|----------|---------|---------|-------------------

For detailed HTML reports, please see the 'coverage-reports-22.x-ubuntu-latest' artifact from the main CI run.

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Downgraded from Request Changes to Comment: self-PR review (GitHub rejects REQUEST_CHANGES on own PR); CI has 1 failure (Test windows-latest 20.x). The Critical findings below should still be addressed before merging.

Comment thread packages/core/src/services/shellExecutionService.ts Outdated
Comment thread packages/core/src/services/shellExecutionService.ts Outdated
Comment thread packages/core/src/services/shellExecutionService.ts

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Downgraded from Request changes to Comment: self-PR; GitHub does not allow requesting changes on your own PR. CI also has a failing check: Test (windows-latest, 20.x).

Comment thread packages/core/src/services/shellExecutionService.ts Outdated
Comment thread packages/core/src/services/shellExecutionService.ts
Comment thread packages/core/src/services/shellExecutionService.test.ts
…view)

Addresses 4 Critical + 2 Suggestion findings on PR-1 of #3831:

- **childProcess listener detach** (review line 555 + 573): Anonymous arrow
  listeners on stdout/stderr/error/exit could not be off()'d. After
  background-promote, post-promote bytes would re-enter handleOutput, which
  then calls decoder.decode() on a now-finalized text decoder (cleanup()
  already called .decode() without stream:true) → TypeError crash. Even
  without the crash, old onOutputEvent would fire for new data → ownership
  contract violation + duplication. Fix: extract named handler refs
  (stdoutHandler / stderrHandler / errorHandler / exitHandler) and call
  off() on all four in the background-promote branch via a
  detachServiceListeners() helper.

- **PTY listener detach** (review line 967 + 990): node-pty's onData / onExit
  return IDisposable handles; the abort handler now captures
  dataDisposable / exitDisposable and calls .dispose() in the
  background-promote branch. ptyProcess.on('error') is EventEmitter-style
  (not IDisposable) — extract a named ptyErrorHandler ref and off() it.
  Without these, post-promote PTY error throws → Node.js crash; post-promote
  data continues writing to headlessTerminal and calling old onOutputEvent
  → ownership violation.

- **PTY in-flight chain item ownership** (related to review line 990):
  processingChain may have already-enqueued callbacks past the early
  listenersDetached check. Refactored from "early-return short-circuit" to
  "guard each onOutputEvent emit individually" so in-flight writes still
  LAND in headlessTerminal (snapshot reflects them) but no events leak to
  the foreground onOutputEvent. Also clear renderTimeout in the abort
  handler so a pending throttled render doesn't fire post-promote.

- **PTY snapshot freshness** (review line 972, suggestion): The original
  abort handler called serializeTerminalToText immediately. Now we
  await Promise.race([processingChain drain, SIGKILL_TIMEOUT_MS]) first
  (mirrors the onExit finalize pattern at ~line 970) so in-flight
  headlessTerminal.write callbacks land before serialization. Skipped
  render(true) intentionally because it would emit final onOutputEvent
  data (renderFn calls onOutputEvent), violating the "no emit post-promote"
  invariant — added a comment explaining why direct serialize is correct.

- **Handoff-boundary tests** (review line 1257, suggestion): Added 4 new
  tests pinning the ownership contract — 2 for child_process (post-promote
  stdout/stderr does NOT route to onOutputEvent; child exit does NOT
  re-resolve result), 2 for PTY (data/exit disposables ARE called; result
  shape stays promoted: true even if post-promote events fire).

Also: test setup now stubs mockPtyProcess.onData / .onExit to return
{ dispose: vi.fn() } so the background-promote path's dispose() calls
don't crash on undefined (the stub's mock.results[0].value is then
inspected by the new handoff tests).

58 / 58 tests pass (50 baseline + 4 first-pass + 4 handoff). Total +235 / -35
on top of the prior commit.

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No review findings. Downgraded from Approve to Comment: self-PR reviews cannot be approved by the author; CI has failing check: Test (macos-latest, 24.x). — gpt-5.5 via Qwen Code /review

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Downgraded from Approve to Comment: self-PR (your own PR can only receive Comment events); CI has 1 failure (Test macos-latest 24.x — likely flaky, not related to these changes).


[Suggestion] Both promote paths (child_process and PTY) have zero logging. If promote fails silently — drain timeout, snapshot empty, serializeTerminalToText throws — there is no indicator in logs/stdout to show promote happened, which path was taken, or why the snapshot is missing. This makes debugging at 3am nearly impossible. Consider adding at minimum a console.warn with pid/shellId and the method path on promote entry.

— deepseek-v4-pro via Qwen Code /review

Comment thread packages/core/src/services/shellExecutionService.ts

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Downgraded from Approve to Comment: self-PR; CI failing: Test (macos-latest, 24.x).

Review found 11 Suggestions and 2 Nice-to-haves — no Critical issues. Core logic correct, previous listener-retention feedback addressed.

Additional finding (could not map to diff line): In childProcessFallback, abortSignal.addEventListener('abort', abortHandler) (~L622) runs before child.on('exit', exitHandler) (~L628). If the signal is pre-aborted, detachServiceListeners() tries child.off('exit', exitHandler) before the handler is attached — then L628 attaches it anyway, leaking through the promote boundary. Reorder: register the exit handler before the abort listener.

Comment thread packages/core/src/services/shellExecutionService.ts
Comment thread packages/core/src/services/shellExecutionService.ts Outdated
Comment thread packages/core/src/services/shellExecutionService.ts Outdated
Comment thread packages/core/src/services/shellExecutionService.ts
Comment thread packages/core/src/services/shellExecutionService.ts Outdated
…romote (resolve 2nd review pass)

Addresses 6 follow-up [Suggestion] threads on PR-1 of #3831 — all
substantive code-quality issues raised by the second-pass review of
the dispose-based detach commit (8e8e18c):

- **Exhaustive switch on `ShellAbortReason.kind`** (both abort handlers).
  Earlier `if (reason?.kind === 'background')` form silently fell
  through to kill for any unrecognized variant — a future
  `{ kind: 'suspend' }` would have killed the process with zero
  compile-time signal. Switched to `switch (kind)` with a `never`-typed
  default that runs `debugLogger.warn` and falls back to the safest
  behavior (cancel/kill). Each branch is now extracted into a named
  helper (`performBackgroundPromote` / `performCancelKill`) so the
  switch body stays a single screenful.

- **Each `dispose()` wrapped in its own try/catch** (PTY). node-pty's
  `IDisposable` contract doesn't guarantee no-throw. Without per-dispose
  try/catch a single throwing dispose() would skip subsequent cleanup
  (the other dispose, off('error'), activePtys.delete, drain, resolve)
  and the caller would hang forever on `await result`. Each call now
  logs via debugLogger.warn on failure but continues.

- **`.catch(() => undefined)` on the processingChain side of the drain
  race** (PTY). `Promise.race([processingChain.then(drain).then(drain),
  timeout])` would propagate a chain rejection out of the race; since
  `addEventListener` doesn't await our handler, the rejection became
  unhandled and `resolve()` was never called → caller hung. Now the
  rejection is swallowed; the timeout side still terminates the race
  on time.

- **Drain-timeout truncation now emits a diagnostic warning** (PTY).
  Previously the 200ms drain timeout could fire, the snapshot would be
  taken with the buffer in mid-write state, and the result.output
  would be silently truncated. Race result is now observed via a
  symbol sentinel; when the timeout side wins, debugLogger.warn fires
  pointing the user at rawOutput as the un-truncated fallback.

- **Snapshot serialize failure logs instead of swallowing silently**
  (PTY). Empty `catch {}` made result.output indistinguishable from
  "command produced no output" if serializeTerminalToText threw. Now
  `debugLogger.warn` with the error message leaves a trail for support
  bundles.

- **Dedicated `PROMOTE_DRAIN_TIMEOUT_MS` constant** separated from
  `SIGKILL_TIMEOUT_MS`. Both are 200ms today, but they have unrelated
  reasons-to-change (kill escalation timing vs. promote drain
  ceiling) — sharing the constant means tuning one would silently
  change the other.

Also adds a module-level `debugLogger = createDebugLogger('SHELL_EXECUTION')`
since the service had no logging surface before this commit.

58 / 58 tests pass; tsc clean; ESLint clean. No new tests added: the new
behaviors (timeout sentinel firing, dispose throw, exhaustive switch
default) are defensive log-only paths; existing handoff tests already
cover the happy path. Adding mock-throw tests is reasonable
follow-up but not blocking.
@wenshao

wenshao commented May 6, 2026

Copy link
Copy Markdown
Collaborator Author

Code Review

Overview

PR-1 of 3 implementing Phase D(b) of #3831. Pure plumbing for a future Ctrl+B "promote foreground shell to background" feature.

It introduces a ShellAbortReason discriminated union ({ kind: 'cancel' } | { kind: 'background', shellId? }) carried via AbortController.abort(reason) into ShellExecutionService.execute(). When the abort reason kind is 'background', execute() skips the kill, drops the child from its active set, snapshots captured output, and resolves with promoted: true — letting the caller take ownership of the still-alive child. Default behavior is unchanged.

Both executeWithPty and childProcessFallback are updated symmetrically. ~250 LOC source, ~250 LOC tests, 8 new tests added.

Code Quality & Style

Strengths

  • The discriminated union with an exhaustive switch gives a never compile-time check on the default branch — correctly addresses the "silent fall-through to kill" review concern. Future variants (e.g., 'suspend') would fail to type-check.
  • Named handler refs (stdoutHandler, stderrHandler, errorHandler, exitHandler, ptyErrorHandler) for clean off() detachment is the right shape — anonymous arrows here would have leaked.
  • dataDisposable / exitDisposable correctly captured from node-pty's onData/onExit IDisposable returns.
  • Defensive try/catch around each dispose() — good, since IDisposable doesn't guarantee no-throw and a single throw shouldn't abandon the rest of the teardown.
  • PROMOTE_DRAIN_TIMEOUT_MS deliberately separated from SIGKILL_TIMEOUT_MS with a clear "different reasons-to-change" comment — nice forward-thinking.
  • The listenersDetached guard inside processingChain callbacks lets in-flight writes still LAND in headlessTerminal (so the snapshot reflects them) but suppresses onOutputEvent emits — a subtle but correct ownership boundary.
  • Drain race uses a TIMEOUT_SENTINEL so the timeout case can be observed and warned about — better than discarding the race result.
  • Tests pin the contract crisply: kill path NOT taken, listeners disposed, post-promote bytes don't reach onOutputEvent, post-promote exit doesn't re-resolve.

Specific Suggestions

Minor: phantom renderTimeout after promote

After performBackgroundPromote() clears renderTimeout, an in-flight headlessTerminal.write callback still calls render(). render() sees renderTimeout === null, calls renderFn() (no-op due to listenersDetached), then schedules a new setTimeout. That phantom timer fires ~100ms later as a no-op and clears itself.

Not a leak — self-cleaning, bounded — but unnecessary. Adding a guard at the top of render() would eliminate it:

const render = (finalRender = false) => {
  if (listenersDetached) return;
  // ...
};

Minor: snapshot normalization differs between PTY and child_process

  • child_process: output: stripAnsi(combined).trim()
  • PTY: output: serializeTerminalToText(headlessTerminal) ?? '' (no stripAnsi, no trim)

This mirrors how each path normally produces output on natural exit, so it's defensible — but the caller in PR-2 will need to be aware that snapshot shape depends on which execution method was used. A one-line note in the promoted field's JSDoc on ShellExecutionResult would make this contract explicit.

Minor: error field on PTY promote is always null

In the PTY performBackgroundPromote, error, references the const error: Error | null = null declared near the top of the Promise body — it never mutates. Reads as if it could be non-null. error: null, would be clearer; or remove the const if PR-2 is expected to introduce mutation.

Minor: code-comment overstates "TypeError crash" claim

The comment near the named-handler refs (and the parallel test comment) says post-promote bytes routed into the finalized text decoder would cause a TypeError. In practice TextDecoder.decode() after a non-streaming flush is reusable in Node (state resets) — it would not throw. The actual harm is the duplicate onOutputEvent emit / ownership leak, which the comment also mentions. Suggest softening the wording so future maintainers don't go hunting for a non-existent crash.

Question: is the PTY promote snapshot's serializeTerminalToText deterministic enough across the drain bound?

The 200ms drain bound is reasonable for tests, but on a slow box under load, hitting the timeout and ending up with a partial snapshot could surprise the user. The warning-log-only mitigation is OK for PR-1, but PR-2 should probably surface this to the registry entry (e.g., a snapshot_truncated: true marker the UI can display) so the user understands a "missing tail" is real, not a render bug.

Correctness

  • Race window: abort fires between addEventListener('abort', ...) and child.on('exit', exitHandler) in childProcessFallback — this race pre-exists the PR and isn't worsened. Worth flagging for a future cleanup but not blocking.
  • { once: true } on the abort listener: the explicit removeEventListener calls inside cleanup() and performBackgroundPromote are technically redundant since once: true auto-removes on first fire. Harmless and consistent with the existing pattern.
  • Idempotency of cleanup() in child_process: performBackgroundPromote calls cleanup(), which sets exited = true. The exit listener has been off()'d before this, so handleExit won't re-enter cleanup(). Safe.

Test Coverage

Excellent. Both PTY and child_process paths have parallel coverage of:

  1. { kind: 'cancel' } still tree-kills (regression guard against accidental routing).
  2. { kind: 'background' } skips kill, resolves promoted: true, no kill calls observed.
  3. Post-promotion handoff: subsequent events on the still-alive child do NOT reach onOutputEvent and do NOT re-resolve the result.

The mockReturnValue({ dispose: vi.fn() }) stubs for onData/onExit are necessary so the production dispose-path doesn't crash on undefined.dispose() — and the mock results are inspected by the handoff tests. Clean setup.

Security & Performance

  • Security: no new attack surface. signal.reason is opt-in and only flows through trusted internal callers (no parsing of external input).
  • Performance: zero overhead in the common case — the new branch is reached only when signal.reason?.kind === 'background'. performBackgroundPromote adds a one-time bounded drain, which is comparable to the existing onExit finalize drain.

Risks

  • Manual coverage deferred to PR-2: documented and reasonable since PR-1 has no caller setting the new reason. The promoted: true contract is entirely test-pinned for now; first real exercise will be in PR-2.
  • Future variant safety: the exhaustive switch will require visiting both executeWithPty and childProcessFallback when adding (e.g.) 'suspend'. Symmetric structure makes this easy but a comment near the type definition pointing at the two switch sites would help.

Verdict

Approve with the minor suggestions above. The plumbing is well-shaped, the ownership contract is clearly documented in code, and the test pinning is rigorous. Splitting Phase D(b) into 3 reviewable PRs is the right call given the subtle ownership-handoff invariants here.

Comment thread packages/core/src/services/shellExecutionService.ts Outdated
Comment thread packages/core/src/services/shellExecutionService.ts Outdated
Comment thread packages/core/src/services/shellExecutionService.ts
…t-reason read

Resolves the third review pass on PR-1 of #3831 — 1 real bug + 2
defensive hardenings:

- **Real bug: `ptyProcess.off('error', ...)` throws TypeError at
  runtime** (line ~1074). `@lydell/node-pty`'s `IPty` interface
  exposes the legacy Node EventEmitter `removeListener`, not the
  modern `off` alias. Previous form threw, the surrounding try/catch
  swallowed it (post-prior-pass dispose hardening), but the old
  `ptyErrorHandler` stayed registered — so a post-promote PTY error
  would still hit our foreground handler and `throw err`, breaking
  the handoff contract that PR-1's whole listener-detach work is
  supposed to enforce. Switched to `removeListener`. The catch +
  warn stays as defense-in-depth; the message wording is updated.

- **Prototype-pollution-safe `kind` read** (extracted to module-level
  helper `getShellAbortReasonKind`). The previous `reason?.kind`
  walked the prototype chain — a polluted
  `Object.prototype.kind = 'background'` would silently route
  `abortController.abort({})` (any plain object reason) into the
  promote branch and skip the kill. Lifecycle/safety branch deserves
  the extra check. Helper now: rejects non-object reasons; reads
  `kind` only as an OWN property (`hasOwnProperty`); whitelists
  against `'background' | 'cancel'`; defaults to `'cancel'` (the
  safe historical behavior) for everything else. Both abort handlers
  (childProcess + PTY) now share this helper.

- **`streamStdout: true` + background-promote = silent empty
  snapshot** (childProcess `performBackgroundPromote`). The promote
  snapshot reads from the `stdout` / `stderr` string accumulators;
  but in `streamStdout` mode `handleOutput` forwards bytes through
  `onOutputEvent` and skips the accumulators entirely. Today PR-1's
  only call site (foreground shell.ts) uses `streamStdout: false`,
  so the combination is unreachable — but if a future caller pairs
  the two, `result.output` would be empty with no diagnostic. Added a
  `debugLogger.warn` when the combination occurs, pointing the caller
  at `rawOutput` as the fallback. Cheaper than building a parallel
  accumulator just for this latent case.

58 / 58 tests pass; tsc clean; ESLint clean.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR (PR-1 of 3 for #3831) introduces a signal.reason convention to let ShellExecutionService.execute() distinguish between a normal cancel abort vs a “promote to background” takeover abort. It adds a new discriminated ShellAbortReason union, updates both PTY and child_process fallback abort handlers to skip killing the child on { kind: 'background' }, detach service listeners, drop the child from active tracking, snapshot output, and resolve early with promoted: true. Unit tests are updated to pin both branches and verify post-promotion listener handoff boundaries.

Changes:

  • Add ShellAbortReason (via AbortSignal.reason) and ShellExecutionResult.promoted?: boolean to support background promotion signaling.
  • Implement reason-aware abort handling for both PTY and child_process execution paths (detach listeners, skip kill, snapshot output, resolve early).
  • Extend shellExecutionService unit tests to cover cancel-vs-background discrimination and post-promotion handoff behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
packages/core/src/services/shellExecutionService.ts Adds ShellAbortReason, promoted result field, and reason-aware abort handling with listener detachment + snapshot behavior for PTY and child_process.
packages/core/src/services/shellExecutionService.test.ts Adds/updates tests for abort-reason branching and validates listener disposal/off behavior across the promotion boundary.
Comments suppressed due to low confidence (1)

packages/core/src/services/shellExecutionService.ts:783

  • This comment says in-flight callbacks should avoid emitting or writing to the "(now caller-owned) headlessTerminal", but the terminal instance is still owned/managed by ShellExecutionService; only the PTY process ownership is transferred. Consider rewording to avoid implying the terminal object is handed off to the caller.
          ? [...argsPrefix, commandToExecute].join(' ')
          : [...argsPrefix, commandToExecute];

      const ptyProcess = ptyInfo.module.spawn(executable, args, {
        cwd,

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/core/src/services/shellExecutionService.ts
Comment thread packages/core/src/services/shellExecutionService.ts

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Downgraded from Request changes to Comment: self-PR reviews cannot use REQUEST_CHANGES on GitHub.

Comment thread packages/core/src/services/shellExecutionService.ts Outdated
Comment thread packages/core/src/services/shellExecutionService.ts
Comment thread packages/core/src/services/shellExecutionService.ts Outdated
Comment thread packages/core/src/services/shellExecutionService.ts
…ware PTY snapshot (resolve 4th review pass)

Resolves 6 threads on PR-1 of #3831 — 1 Critical + 1 real bug + 2
quality + 2 test-coverage:

- **[Critical] `getShellAbortReasonKind` throw-safe property read.**
  Previous form read `reason.kind` after only checking that `kind` is
  an own property. An own accessor that throws (or a Proxy with a
  trapping getter) would throw before the helper reached either the
  cancel kill path or the background promote path. Abort handlers are
  dispatched async and not awaited by AbortSignal, so a leaked throw
  here would have left the shell process alive instead of being killed
  on cancel — quietly. Wrapped the property read in try/catch with a
  fall-back to the safe 'cancel' kill behavior.

- **Real bug: child_process post-exit race in background-promote**
  (`performBackgroundPromote`). The child may have already exited but
  the 'exit' event hasn't reached our handler yet (Node delivers
  events on the next microtask). Promoting in that window would
  detach our exit listener and report `promoted: true` for a process
  that's already dead — the caller would hold an inert pid expecting
  to take over. Now we read `child.exitCode` / `child.signalCode`
  before detaching: if either is non-null, fall through and let the
  pending exit handler resolve normally with the real exit info.
  Mirrored mock setup so `exitCode` / `signalCode` default to `null`
  (matching real ChildProcess) instead of `undefined`.

- **PTY snapshot: re-decode + replay (mirror exit-path encoding).**
  The promoted snapshot was serializing `headlessTerminal` directly,
  which was fed by a streaming decoder initialized from the
  first-chunk encoding heuristic. When early output is ASCII-only but
  later output is in a different encoding (GBK / Shift-JIS / etc.),
  this produces mojibake — and the normal exit path doesn't, because
  it re-decodes `finalBuffer` with `getCachedEncodingForBuffer` and
  replays through a fresh terminal. Now mirrors that logic so
  `result.output` shape matches across the two paths. Direct-serialize
  remains as a last-ditch fallback if replay throws.

- **Switch `default` no longer emits a runtime warn.** Reviewer noted
  the helper's whitelist made the `default: { _exhaustive: never }`
  branch unreachable at runtime — the `debugLogger.warn` in it could
  never fire. Kept the `_: never = kind` type assertion (so a future
  ShellAbortReason variant forces a TS error here, directing the
  developer to extend BOTH the helper's whitelist AND add a `case`),
  removed the unreachable warn. Added a comment that the assertion is
  the static-only safety net the union expansion would trigger.

- **Direct unit tests for `getShellAbortReasonKind`** (8 cases). The
  helper's prototype-pollution defense is the main reason it exists;
  if `hasOwnProperty` is accidentally removed the regression would
  silently send `abortController.abort({})` (any plain reason) into
  the promote path. Exported the helper and added direct tests for:
  null / undefined, non-object, empty object (no own kind), prototype-
  only kind (pollution), unknown kind value, throwing accessor, Proxy
  trap, and the two happy paths.

- **`removeListener` regression guard.** The fix to call
  `ptyProcess.removeListener('error', ...)` instead of `.off(...)`
  matters because `@lydell/node-pty`'s IPty interface only exposes
  `removeListener` — `.off()` throws TypeError on a real PTY but the
  EventEmitter mock tolerates both. Added a test that spies on both
  methods and asserts the production code uses `removeListener` for
  the 'error' event, so a future swap back to `.off()` regresses
  loudly under the mock instead of silently.

68 / 68 tests pass (58 baseline + 9 helper boundary + 1 removeListener
guard + 1 post-exit race); tsc clean; ESLint clean.
Comment thread packages/core/src/services/shellExecutionService.ts
@tanzhenxin tanzhenxin added the type/feature-request New feature or enhancement request label May 6, 2026
wenshao added 4 commits May 6, 2026 22:59
…eview pass)

Mirrors the child_process post-exit race fix from 4cc558b into the
PTY path — addresses 1 [Critical] thread on PR-1 of #3831:

The PTY may have already exited but our `exitDisposable` (onExit
callback) hasn't run yet — node-pty delivers the exit event
asynchronously after the PTY's native SIGCHLD, so there's a window
between "PTY actually dead" and "service onExit fires". Promoting in
that window detaches our exit listener and reports `promoted: true`
for a dead PTY, losing the real exit status; the caller would hold an
inert pid expecting to take over.

The IPty interface doesn't expose an `exitCode` field we can read
directly (unlike `child.exitCode` / `child.signalCode` for
child_process), so use `process.kill(pid, 0)` as a best-effort
liveness check via the existing `ShellExecutionService.isPtyActive`
helper. If kill(pid, 0) throws ESRCH, the pid is gone — log at debug
level and fall through, letting the pending onExit callback resolve
normally with the real exit info.

Also adds a unit test mirroring the child_process race test: mocks
`process.kill(pid, 0)` to throw ESRCH on the liveness probe, asserts
the result has no `promoted: true` and reports the real exitCode.

69 / 69 tests pass; tsc clean; ESLint clean.
Doc said 'all six edge cases' but the test suite has 8 cases (added
Proxy-trap and undefined later). Off-by-2 cosmetic only — no behavior
change. Caught during a multi-round self-audit of PR-1 of #3831.

Audit summary: 7 rounds (correctness / reverse / consistency / coverage
/ build / exception paths / style) found one false-positive (a sync-
abort registration-order race I initially thought existed). Verified
that Node's WHATWG AbortSignal does NOT auto-fire 'abort' listeners
on already-aborted signals, so the race window cannot open. No code
change needed for that scenario; this commit is just the JSDoc fix.

69 / 69 tests still pass; tsc + ESLint clean.
…citly

Multi-round self-audit found that `getShellAbortReasonKind`'s value
whitelist has no compile-time tie to the `ShellAbortReason` union: when
the union grows, TypeScript's `_exhaustive: never` in each switch
forces #3 (the case arm) to be added, but the helper's whitelist
(#2) silently keeps degrading the new variant to 'cancel', and the
new case arm is never reached at runtime.

Reviewer #4 raised this on the second pass; the original commit chose
to accept it (option B in that thread) but didn't leave a strong
in-code signal for future contributors. Added an INVARIANT block
inside the helper enumerating the three sites that must be kept in
sync, so the next person extending `ShellAbortReason` sees the
coupling at the place where they're most likely to forget it.

No behavior change — comment-only. 69 / 69 tests still pass; tsc +
ESLint clean.

Audit summary (this round + prior round): 18 angles total over two
sweeps and one reverse-attack pass. Found:
  - 0 real bugs
  - 1 false-positive race (sync-abort registration order — Node WHATWG
    AbortSignal does NOT auto-fire on already-aborted signals;
    investigated, reverted)
  - 1 cosmetic doc fix (boundary-test count off-by-2)
  - 1 cosmetic INVARIANT block (this commit)

Areas reviewed without finding new issues: caller-side
ShellExecutionResult shape compatibility (optional `promoted?` field,
existing callers spread-untouched); `exited` flag lifecycle
(monotonic, cleanup() idempotent); processingChain in-flight
ownership (listenersDetached guards every onOutputEvent emit
including the renderFn-rendered case via the same flag); race
between exit event and abort handler (both microtasks, FIFO ordering
gives correct outcome either way); Node version dependence
(`AbortSignal.reason` is Node 17.2+, engines: >=20 covers it);
test isolation (mockImplementationOnce + module-level mockProcessKill
clears each beforeEach); `process.kill(pid, 0)` Windows liveness
reliability (best-effort, acceptable for PR-1 plumbing); PID reuse
race on the PTY liveness check (theoretically possible, microsecond
window, unavoidable at the OS level — rejected in spec discussion);
PR-2/PR-3 contract surface (caller MUST attach listeners before
abort — documented; any future caller violating this is its own bug).
…reEach

The 'execution method selection' describe block has its own
beforeEach (separate from 'child_process fallback') that builds
mockChildProcess but does not set `exitCode` / `signalCode = null`.
Real Node `ChildProcess.exitCode` / `signalCode` are `null` while the
process is alive — and production now reads these in the
background-promote race guard. The current tests in this block don't
exercise the promote path, so they pass regardless, but any future
promote-related test landing here would silently trip the guard
(`undefined !== null` is true) and fall through to the normal-exit
branch instead of promoting.

Mirror the `child_process fallback` block's mock setup so the two
beforeEach hooks produce equivalent ChildProcess shapes, eliminating
a quiet foot-gun for future contributors.

Comment-only / test-fixture change. 69 / 69 tests still pass; tsc clean.
Found during a deeper third-round self-audit of PR-1 of #3831.

@tanzhenxin tanzhenxin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Approving — clean plumbing after the iteration rounds, no behavior change for existing callers. A few notes to consider, none blocking.

Notes

1. aborted + promoted shape. Both promote branches resolve with aborted: true, promoted: true. The existing tools/shell.ts consumer checks aborted first and emits "cancelled" copy. PR-2 callers will need to branch on promoted before aborted. Might be cleaner to set aborted: false when promoted: true so existing branches just work.

2. getShellAbortReasonKind defensive read. The hasOwnProperty.call on the reason runs outside the try. A Proxy with a throwing getOwnPropertyDescriptor trap would propagate past the helper. Probably worth moving the check inside the try.

3. PTY handoff-boundary test. Asserts dispose was called, but doesn't emit data after the abort to verify the foreground handler actually stops firing — the child_process equivalent does. Worth mirroring when convenient.

APPROVE.

@wenshao wenshao merged commit b9f56e4 into main May 7, 2026
13 checks passed
TaimoorSiddiquiOfficial pushed a commit to TaimoorSiddiquiOfficial/HopCode that referenced this pull request May 7, 2026
…wenLM#3831 PR-1 of 3) (QwenLM#3842)

* feat(core): add signal.reason convention for ShellExecutionService.execute()

Foundation for QwenLM#3831 Phase D (b) — Ctrl+B promote of a running foreground
shell to background. Defines a discriminated `ShellAbortReason` union that
the AbortSignal carries; default behavior (no reason / `{ kind: 'cancel' }`)
keeps the existing tree-kill on abort. `{ kind: 'background' }` is a takeover
signal — execute() skips the kill, drops the child from its active set (so
cleanup() won't kill it later), flushes a snapshot of captured output, and
resolves the result Promise immediately with `promoted: true` so the
awaiting caller unblocks.

Pure plumbing: no caller sets the reason yet, so this is a zero-behavior
change for existing call sites. The `promoted?: boolean` field is optional
on ShellExecutionResult so existing consumers compile against the new shape
without source changes.

Tests pin both branches in both childProcessFallback and executeWithPty:
default abort still SIGTERM-tree-kills; `{ kind: 'cancel' }` is identical to
default (pin against accidental routing through the background branch);
`{ kind: 'background' }` skips the kill, snapshot output is preserved,
mockProcessKill / mockPtyProcess.kill are NOT called.

Part of QwenLM#3831 (Phase D part b — Ctrl+B promote running shell to background).
PR-1 of 3.

* fix(core): detach service listeners on background-promote (resolve review)

Addresses 4 Critical + 2 Suggestion findings on PR-1 of QwenLM#3831:

- **childProcess listener detach** (review line 555 + 573): Anonymous arrow
  listeners on stdout/stderr/error/exit could not be off()'d. After
  background-promote, post-promote bytes would re-enter handleOutput, which
  then calls decoder.decode() on a now-finalized text decoder (cleanup()
  already called .decode() without stream:true) → TypeError crash. Even
  without the crash, old onOutputEvent would fire for new data → ownership
  contract violation + duplication. Fix: extract named handler refs
  (stdoutHandler / stderrHandler / errorHandler / exitHandler) and call
  off() on all four in the background-promote branch via a
  detachServiceListeners() helper.

- **PTY listener detach** (review line 967 + 990): node-pty's onData / onExit
  return IDisposable handles; the abort handler now captures
  dataDisposable / exitDisposable and calls .dispose() in the
  background-promote branch. ptyProcess.on('error') is EventEmitter-style
  (not IDisposable) — extract a named ptyErrorHandler ref and off() it.
  Without these, post-promote PTY error throws → Node.js crash; post-promote
  data continues writing to headlessTerminal and calling old onOutputEvent
  → ownership violation.

- **PTY in-flight chain item ownership** (related to review line 990):
  processingChain may have already-enqueued callbacks past the early
  listenersDetached check. Refactored from "early-return short-circuit" to
  "guard each onOutputEvent emit individually" so in-flight writes still
  LAND in headlessTerminal (snapshot reflects them) but no events leak to
  the foreground onOutputEvent. Also clear renderTimeout in the abort
  handler so a pending throttled render doesn't fire post-promote.

- **PTY snapshot freshness** (review line 972, suggestion): The original
  abort handler called serializeTerminalToText immediately. Now we
  await Promise.race([processingChain drain, SIGKILL_TIMEOUT_MS]) first
  (mirrors the onExit finalize pattern at ~line 970) so in-flight
  headlessTerminal.write callbacks land before serialization. Skipped
  render(true) intentionally because it would emit final onOutputEvent
  data (renderFn calls onOutputEvent), violating the "no emit post-promote"
  invariant — added a comment explaining why direct serialize is correct.

- **Handoff-boundary tests** (review line 1257, suggestion): Added 4 new
  tests pinning the ownership contract — 2 for child_process (post-promote
  stdout/stderr does NOT route to onOutputEvent; child exit does NOT
  re-resolve result), 2 for PTY (data/exit disposables ARE called; result
  shape stays promoted: true even if post-promote events fire).

Also: test setup now stubs mockPtyProcess.onData / .onExit to return
{ dispose: vi.fn() } so the background-promote path's dispose() calls
don't crash on undefined (the stub's mock.results[0].value is then
inspected by the new handoff tests).

58 / 58 tests pass (50 baseline + 4 first-pass + 4 handoff). Total +235 / -35
on top of the prior commit.

* fix(core): defensive hardening for ShellExecutionService background-promote (resolve 2nd review pass)

Addresses 6 follow-up [Suggestion] threads on PR-1 of QwenLM#3831 — all
substantive code-quality issues raised by the second-pass review of
the dispose-based detach commit (8e8e18c):

- **Exhaustive switch on `ShellAbortReason.kind`** (both abort handlers).
  Earlier `if (reason?.kind === 'background')` form silently fell
  through to kill for any unrecognized variant — a future
  `{ kind: 'suspend' }` would have killed the process with zero
  compile-time signal. Switched to `switch (kind)` with a `never`-typed
  default that runs `debugLogger.warn` and falls back to the safest
  behavior (cancel/kill). Each branch is now extracted into a named
  helper (`performBackgroundPromote` / `performCancelKill`) so the
  switch body stays a single screenful.

- **Each `dispose()` wrapped in its own try/catch** (PTY). node-pty's
  `IDisposable` contract doesn't guarantee no-throw. Without per-dispose
  try/catch a single throwing dispose() would skip subsequent cleanup
  (the other dispose, off('error'), activePtys.delete, drain, resolve)
  and the caller would hang forever on `await result`. Each call now
  logs via debugLogger.warn on failure but continues.

- **`.catch(() => undefined)` on the processingChain side of the drain
  race** (PTY). `Promise.race([processingChain.then(drain).then(drain),
  timeout])` would propagate a chain rejection out of the race; since
  `addEventListener` doesn't await our handler, the rejection became
  unhandled and `resolve()` was never called → caller hung. Now the
  rejection is swallowed; the timeout side still terminates the race
  on time.

- **Drain-timeout truncation now emits a diagnostic warning** (PTY).
  Previously the 200ms drain timeout could fire, the snapshot would be
  taken with the buffer in mid-write state, and the result.output
  would be silently truncated. Race result is now observed via a
  symbol sentinel; when the timeout side wins, debugLogger.warn fires
  pointing the user at rawOutput as the un-truncated fallback.

- **Snapshot serialize failure logs instead of swallowing silently**
  (PTY). Empty `catch {}` made result.output indistinguishable from
  "command produced no output" if serializeTerminalToText threw. Now
  `debugLogger.warn` with the error message leaves a trail for support
  bundles.

- **Dedicated `PROMOTE_DRAIN_TIMEOUT_MS` constant** separated from
  `SIGKILL_TIMEOUT_MS`. Both are 200ms today, but they have unrelated
  reasons-to-change (kill escalation timing vs. promote drain
  ceiling) — sharing the constant means tuning one would silently
  change the other.

Also adds a module-level `debugLogger = createDebugLogger('SHELL_EXECUTION')`
since the service had no logging surface before this commit.

58 / 58 tests pass; tsc clean; ESLint clean. No new tests added: the new
behaviors (timeout sentinel firing, dispose throw, exhaustive switch
default) are defensive log-only paths; existing handoff tests already
cover the happy path. Adding mock-throw tests is reasonable
follow-up but not blocking.

* fix(core): real bug — ptyProcess.off → removeListener; defensive abort-reason read

Resolves the third review pass on PR-1 of QwenLM#3831 — 1 real bug + 2
defensive hardenings:

- **Real bug: `ptyProcess.off('error', ...)` throws TypeError at
  runtime** (line ~1074). `@lydell/node-pty`'s `IPty` interface
  exposes the legacy Node EventEmitter `removeListener`, not the
  modern `off` alias. Previous form threw, the surrounding try/catch
  swallowed it (post-prior-pass dispose hardening), but the old
  `ptyErrorHandler` stayed registered — so a post-promote PTY error
  would still hit our foreground handler and `throw err`, breaking
  the handoff contract that PR-1's whole listener-detach work is
  supposed to enforce. Switched to `removeListener`. The catch +
  warn stays as defense-in-depth; the message wording is updated.

- **Prototype-pollution-safe `kind` read** (extracted to module-level
  helper `getShellAbortReasonKind`). The previous `reason?.kind`
  walked the prototype chain — a polluted
  `Object.prototype.kind = 'background'` would silently route
  `abortController.abort({})` (any plain object reason) into the
  promote branch and skip the kill. Lifecycle/safety branch deserves
  the extra check. Helper now: rejects non-object reasons; reads
  `kind` only as an OWN property (`hasOwnProperty`); whitelists
  against `'background' | 'cancel'`; defaults to `'cancel'` (the
  safe historical behavior) for everything else. Both abort handlers
  (childProcess + PTY) now share this helper.

- **`streamStdout: true` + background-promote = silent empty
  snapshot** (childProcess `performBackgroundPromote`). The promote
  snapshot reads from the `stdout` / `stderr` string accumulators;
  but in `streamStdout` mode `handleOutput` forwards bytes through
  `onOutputEvent` and skips the accumulators entirely. Today PR-1's
  only call site (foreground shell.ts) uses `streamStdout: false`,
  so the combination is unreachable — but if a future caller pairs
  the two, `result.output` would be empty with no diagnostic. Added a
  `debugLogger.warn` when the combination occurs, pointing the caller
  at `rawOutput` as the fallback. Cheaper than building a parallel
  accumulator just for this latent case.

58 / 58 tests pass; tsc clean; ESLint clean.

* fix(core): liveness check + throw-safe abort-reason read + encoding-aware PTY snapshot (resolve 4th review pass)

Resolves 6 threads on PR-1 of QwenLM#3831 — 1 Critical + 1 real bug + 2
quality + 2 test-coverage:

- **[Critical] `getShellAbortReasonKind` throw-safe property read.**
  Previous form read `reason.kind` after only checking that `kind` is
  an own property. An own accessor that throws (or a Proxy with a
  trapping getter) would throw before the helper reached either the
  cancel kill path or the background promote path. Abort handlers are
  dispatched async and not awaited by AbortSignal, so a leaked throw
  here would have left the shell process alive instead of being killed
  on cancel — quietly. Wrapped the property read in try/catch with a
  fall-back to the safe 'cancel' kill behavior.

- **Real bug: child_process post-exit race in background-promote**
  (`performBackgroundPromote`). The child may have already exited but
  the 'exit' event hasn't reached our handler yet (Node delivers
  events on the next microtask). Promoting in that window would
  detach our exit listener and report `promoted: true` for a process
  that's already dead — the caller would hold an inert pid expecting
  to take over. Now we read `child.exitCode` / `child.signalCode`
  before detaching: if either is non-null, fall through and let the
  pending exit handler resolve normally with the real exit info.
  Mirrored mock setup so `exitCode` / `signalCode` default to `null`
  (matching real ChildProcess) instead of `undefined`.

- **PTY snapshot: re-decode + replay (mirror exit-path encoding).**
  The promoted snapshot was serializing `headlessTerminal` directly,
  which was fed by a streaming decoder initialized from the
  first-chunk encoding heuristic. When early output is ASCII-only but
  later output is in a different encoding (GBK / Shift-JIS / etc.),
  this produces mojibake — and the normal exit path doesn't, because
  it re-decodes `finalBuffer` with `getCachedEncodingForBuffer` and
  replays through a fresh terminal. Now mirrors that logic so
  `result.output` shape matches across the two paths. Direct-serialize
  remains as a last-ditch fallback if replay throws.

- **Switch `default` no longer emits a runtime warn.** Reviewer noted
  the helper's whitelist made the `default: { _exhaustive: never }`
  branch unreachable at runtime — the `debugLogger.warn` in it could
  never fire. Kept the `_: never = kind` type assertion (so a future
  ShellAbortReason variant forces a TS error here, directing the
  developer to extend BOTH the helper's whitelist AND add a `case`),
  removed the unreachable warn. Added a comment that the assertion is
  the static-only safety net the union expansion would trigger.

- **Direct unit tests for `getShellAbortReasonKind`** (8 cases). The
  helper's prototype-pollution defense is the main reason it exists;
  if `hasOwnProperty` is accidentally removed the regression would
  silently send `abortController.abort({})` (any plain reason) into
  the promote path. Exported the helper and added direct tests for:
  null / undefined, non-object, empty object (no own kind), prototype-
  only kind (pollution), unknown kind value, throwing accessor, Proxy
  trap, and the two happy paths.

- **`removeListener` regression guard.** The fix to call
  `ptyProcess.removeListener('error', ...)` instead of `.off(...)`
  matters because `@lydell/node-pty`'s IPty interface only exposes
  `removeListener` — `.off()` throws TypeError on a real PTY but the
  EventEmitter mock tolerates both. Added a test that spies on both
  methods and asserts the production code uses `removeListener` for
  the 'error' event, so a future swap back to `.off()` regresses
  loudly under the mock instead of silently.

68 / 68 tests pass (58 baseline + 9 helper boundary + 1 removeListener
guard + 1 post-exit race); tsc clean; ESLint clean.

* fix(core): PTY background-promote post-exit race guard (resolve 5th review pass)

Mirrors the child_process post-exit race fix from 4cc558b into the
PTY path — addresses 1 [Critical] thread on PR-1 of QwenLM#3831:

The PTY may have already exited but our `exitDisposable` (onExit
callback) hasn't run yet — node-pty delivers the exit event
asynchronously after the PTY's native SIGCHLD, so there's a window
between "PTY actually dead" and "service onExit fires". Promoting in
that window detaches our exit listener and reports `promoted: true`
for a dead PTY, losing the real exit status; the caller would hold an
inert pid expecting to take over.

The IPty interface doesn't expose an `exitCode` field we can read
directly (unlike `child.exitCode` / `child.signalCode` for
child_process), so use `process.kill(pid, 0)` as a best-effort
liveness check via the existing `ShellExecutionService.isPtyActive`
helper. If kill(pid, 0) throws ESRCH, the pid is gone — log at debug
level and fall through, letting the pending onExit callback resolve
normally with the real exit info.

Also adds a unit test mirroring the child_process race test: mocks
`process.kill(pid, 0)` to throw ESRCH on the liveness probe, asserts
the result has no `promoted: true` and reports the real exitCode.

69 / 69 tests pass; tsc clean; ESLint clean.

* docs(core): correct getShellAbortReasonKind boundary-test count in JSDoc

Doc said 'all six edge cases' but the test suite has 8 cases (added
Proxy-trap and undefined later). Off-by-2 cosmetic only — no behavior
change. Caught during a multi-round self-audit of PR-1 of QwenLM#3831.

Audit summary: 7 rounds (correctness / reverse / consistency / coverage
/ build / exception paths / style) found one false-positive (a sync-
abort registration-order race I initially thought existed). Verified
that Node's WHATWG AbortSignal does NOT auto-fire 'abort' listeners
on already-aborted signals, so the race window cannot open. No code
change needed for that scenario; this commit is just the JSDoc fix.

69 / 69 tests still pass; tsc + ESLint clean.

* docs(core): document the helper / union / switch sync invariant explicitly

Multi-round self-audit found that `getShellAbortReasonKind`'s value
whitelist has no compile-time tie to the `ShellAbortReason` union: when
the union grows, TypeScript's `_exhaustive: never` in each switch
forces #3 (the case arm) to be added, but the helper's whitelist
(#2) silently keeps degrading the new variant to 'cancel', and the
new case arm is never reached at runtime.

Reviewer #4 raised this on the second pass; the original commit chose
to accept it (option B in that thread) but didn't leave a strong
in-code signal for future contributors. Added an INVARIANT block
inside the helper enumerating the three sites that must be kept in
sync, so the next person extending `ShellAbortReason` sees the
coupling at the place where they're most likely to forget it.

No behavior change — comment-only. 69 / 69 tests still pass; tsc +
ESLint clean.

Audit summary (this round + prior round): 18 angles total over two
sweeps and one reverse-attack pass. Found:
  - 0 real bugs
  - 1 false-positive race (sync-abort registration order — Node WHATWG
    AbortSignal does NOT auto-fire on already-aborted signals;
    investigated, reverted)
  - 1 cosmetic doc fix (boundary-test count off-by-2)
  - 1 cosmetic INVARIANT block (this commit)

Areas reviewed without finding new issues: caller-side
ShellExecutionResult shape compatibility (optional `promoted?` field,
existing callers spread-untouched); `exited` flag lifecycle
(monotonic, cleanup() idempotent); processingChain in-flight
ownership (listenersDetached guards every onOutputEvent emit
including the renderFn-rendered case via the same flag); race
between exit event and abort handler (both microtasks, FIFO ordering
gives correct outcome either way); Node version dependence
(`AbortSignal.reason` is Node 17.2+, engines: >=20 covers it);
test isolation (mockImplementationOnce + module-level mockProcessKill
clears each beforeEach); `process.kill(pid, 0)` Windows liveness
reliability (best-effort, acceptable for PR-1 plumbing); PID reuse
race on the PTY liveness check (theoretically possible, microsecond
window, unavoidable at the OS level — rejected in spec discussion);
PR-2/PR-3 contract surface (caller MUST attach listeners before
abort — documented; any future caller violating this is its own bug).

* test(core): align mockChildProcess.exitCode/signalCode in second beforeEach

The 'execution method selection' describe block has its own
beforeEach (separate from 'child_process fallback') that builds
mockChildProcess but does not set `exitCode` / `signalCode = null`.
Real Node `ChildProcess.exitCode` / `signalCode` are `null` while the
process is alive — and production now reads these in the
background-promote race guard. The current tests in this block don't
exercise the promote path, so they pass regardless, but any future
promote-related test landing here would silently trip the guard
(`undefined !== null` is true) and fall through to the normal-exit
branch instead of promoting.

Mirror the `child_process fallback` block's mock setup so the two
beforeEach hooks produce equivalent ChildProcess shapes, eliminating
a quiet foot-gun for future contributors.

Comment-only / test-fixture change. 69 / 69 tests still pass; tsc clean.
Found during a deeper third-round self-audit of PR-1 of QwenLM#3831.
tanzhenxin pushed a commit that referenced this pull request May 7, 2026
…-up to #3842) (#3886)

* fix(core): wrap hasOwnProperty.call inside try + add post-abort PTY data assertion

Two non-blocking review notes from @tanzhenxin's PR-1 approval
(#3842, post-merge follow-up):

- **Note 2 (real bug)**: `getShellAbortReasonKind` had
  `Object.prototype.hasOwnProperty.call(reason, 'kind')` outside the
  try/catch. `hasOwnProperty.call` triggers the `[[GetOwnProperty]]`
  Proxy trap (`getOwnPropertyDescriptor` handler). A Proxy whose
  `getOwnPropertyDescriptor` throws — separate from a throwing `get`
  trap, which the prior commit already covered — would propagate past
  the helper, leaving the abort handler's switch on `kind` to throw
  through `addEventListener` (which doesn't await async listener
  return values), so the shell process would stay alive instead of
  being killed on cancel. Moved the descriptor probe inside the same
  try block as the value read.

  My own multi-round audit covered six attack vectors but missed this
  one — only `get` trap throws were considered. The reviewer caught
  it on first pass.

- **Note 3 (test parity)**: the PTY post-promotion handoff test
  asserted `dispose` was called but never re-invoked the data
  callback to verify the foreground `onOutputEvent` actually stops
  firing — the child_process equivalent has that assertion. Mirrored
  it: emit data AFTER abort by re-invoking the captured `dataCallback`
  reference, and assert `onOutputEventMock.mock.calls.length` does NOT
  increase past the moment of promote. Exercises the production
  `listenersDetached` guard inside the chain callback, which the
  bare dispose-was-called check didn't.

Note 1 from the same review (the `aborted: true + promoted: true`
shape forcing PR-2 callers to check `promoted` before `aborted`) is
deliberately NOT addressed here — it's a contract simplification that
affects PR-2's branching, so it belongs in PR-2 along with the
caller-side decision on whether to flip `aborted` for promoted
results. Added a TODO upstream in #3831 (PR-2 design) to track.

70 / 70 tests pass (69 baseline + 1 new helper boundary for the
throwing-getOwnPropertyDescriptor case). tsc + ESLint clean.

* test(core): fix tautological PTY post-promote assertion (audit follow-up)

The PTY post-promotion handoff test added in the previous commit
copied the child_process equivalent's pattern verbatim — sync
\`expect(count).toBe(countAtPromote)\` immediately after dataCallback
returns. That works for child_process because its \`handleOutput\` is
fully synchronous (sniff → decoder → emit, all on the same call
stack), so the count change happens BEFORE the assertion.

PTY's \`handleOutput\` is async — \`processingChain.then(...)\` queues a
microtask that does the sniff + write + render-then-emit work. The
sync assertion captures both \`countAtPromote\` and the post-emit count
BEFORE the chain microtask ever runs, so both reads return whatever
happened before the assert (typically 0). The test would
tautologically pass even if the production \`listenersDetached\` guard
were removed — i.e., it didn't actually verify the guard.

Restructured to:
1. Drive the PTY through \`simulateExecution\` so \`await handle.result\`
   forces all queued microtasks (including pre-promote chain items
   AND the abort handler's drain) to settle.
2. Capture \`eventCountAfterSettle\` once everything has stabilized.
3. Re-invoke the captured \`dataCallback\` with post-promote data,
   await two more macrotask boundaries to let the new chain item
   fully run.
4. Assert the count hasn't moved.

If the production \`listenersDetached\` guard is removed, the
post-promote chain item emits, count increases past
\`eventCountAfterSettle\`, and this assertion fails. So the test
actually exercises the guard now.

Found in self-audit while reviewing my own follow-up commit. Caught
because audit was paranoid about *whether the test verifies what it
claims to verify*, not just whether it passes.

70 / 70 tests pass; tsc + ESLint clean.

* test(core): pin eventCountAfterSettle === 0 in PTY post-promote test

The previous fix asserted post-promote count equals the
\`eventCountAfterSettle\` baseline, but didn't pin the baseline
itself. With the production \`listenersDetached\` guard intact, both
halves (pre-promote chain and post-promote chain) suppress emit, so
\`eventCountAfterSettle === 0 === post-count\` and the relative
comparison is vacuously true.

If a future refactor changed the production guard semantics so the
pre-promote chain item DID emit (count becomes 1+ after settle), the
relative-comparison test would still pass as long as post-promote
also emitted the same number — that's a regression the test should
catch. Adding \`expect(eventCountAfterSettle).toBe(0)\` makes the
contract explicit: once \`listenersDetached\` is set during the abort
handler's sync part, BOTH the in-flight chain item (pre-promote) and
the future chain item (post-promote) skip emit.

Found in another self-audit pass — even after fixing the tautological
assertion, the test could still mask certain future regressions.
70 / 70 tests pass; tsc + ESLint clean.

* test(core): drop dataCallbackHolder pattern, read mock.calls directly

The dataCallbackHolder pattern (capturing the onData callback inside
simulateExecution then invoking it after via a closure-shared object)
was unnecessary indirection — \`mockPtyProcess.onData.mock.calls[0][0]\`
reads the same callback reference whether you read it inside or outside
the simulation closure. Vi's mock.calls array is per-mock-instance and
beforeEach re-creates mockPtyProcess + .onData freshly, so there's no
stale-reference risk in the simpler form.

No behavior change in what's tested. 70 / 70 pass.

* chore(core): drop in-source @-mention attribution + dead Proxy.get handler

Two cosmetic cleanups found in another self-audit pass:

- **Helper comment**: removed the parenthetical attribution ("Caught
  by @tanzhenxin in the PR-1 review; my own audit only covered \`get\`
  trap throws."). The technical content of the comment — explaining
  why both the descriptor probe and the value read live inside the
  try — stands on its own. The reviewer credit lives in commit
  history / PR description, where it belongs; an in-source @-mention
  ages poorly (handles change, the relevant person may move on) and
  doesn't help future readers reason about the code.

- **Test Proxy**: \`throwingDescriptorProxy\` declared a \`get()\` handler
  that always returned \`undefined\`. The descriptor probe throws
  before the helper ever reaches the value read, so the \`get\`
  handler is unreachable — dropped it and added a one-line comment
  explaining why no \`get\` handler is needed for this test. Mirror
  test (\`throwingReason\` with throwing accessor + \`proxyReason\` with
  throwing \`get\`) keeps the symmetric "throwing-`get`" coverage.

70 / 70 tests pass; tsc + ESLint clean.
DragonnZhang pushed a commit that referenced this pull request May 8, 2026
…3831 PR-1 of 3) (#3842)

* feat(core): add signal.reason convention for ShellExecutionService.execute()

Foundation for #3831 Phase D (b) — Ctrl+B promote of a running foreground
shell to background. Defines a discriminated `ShellAbortReason` union that
the AbortSignal carries; default behavior (no reason / `{ kind: 'cancel' }`)
keeps the existing tree-kill on abort. `{ kind: 'background' }` is a takeover
signal — execute() skips the kill, drops the child from its active set (so
cleanup() won't kill it later), flushes a snapshot of captured output, and
resolves the result Promise immediately with `promoted: true` so the
awaiting caller unblocks.

Pure plumbing: no caller sets the reason yet, so this is a zero-behavior
change for existing call sites. The `promoted?: boolean` field is optional
on ShellExecutionResult so existing consumers compile against the new shape
without source changes.

Tests pin both branches in both childProcessFallback and executeWithPty:
default abort still SIGTERM-tree-kills; `{ kind: 'cancel' }` is identical to
default (pin against accidental routing through the background branch);
`{ kind: 'background' }` skips the kill, snapshot output is preserved,
mockProcessKill / mockPtyProcess.kill are NOT called.

Part of #3831 (Phase D part b — Ctrl+B promote running shell to background).
PR-1 of 3.

* fix(core): detach service listeners on background-promote (resolve review)

Addresses 4 Critical + 2 Suggestion findings on PR-1 of #3831:

- **childProcess listener detach** (review line 555 + 573): Anonymous arrow
  listeners on stdout/stderr/error/exit could not be off()'d. After
  background-promote, post-promote bytes would re-enter handleOutput, which
  then calls decoder.decode() on a now-finalized text decoder (cleanup()
  already called .decode() without stream:true) → TypeError crash. Even
  without the crash, old onOutputEvent would fire for new data → ownership
  contract violation + duplication. Fix: extract named handler refs
  (stdoutHandler / stderrHandler / errorHandler / exitHandler) and call
  off() on all four in the background-promote branch via a
  detachServiceListeners() helper.

- **PTY listener detach** (review line 967 + 990): node-pty's onData / onExit
  return IDisposable handles; the abort handler now captures
  dataDisposable / exitDisposable and calls .dispose() in the
  background-promote branch. ptyProcess.on('error') is EventEmitter-style
  (not IDisposable) — extract a named ptyErrorHandler ref and off() it.
  Without these, post-promote PTY error throws → Node.js crash; post-promote
  data continues writing to headlessTerminal and calling old onOutputEvent
  → ownership violation.

- **PTY in-flight chain item ownership** (related to review line 990):
  processingChain may have already-enqueued callbacks past the early
  listenersDetached check. Refactored from "early-return short-circuit" to
  "guard each onOutputEvent emit individually" so in-flight writes still
  LAND in headlessTerminal (snapshot reflects them) but no events leak to
  the foreground onOutputEvent. Also clear renderTimeout in the abort
  handler so a pending throttled render doesn't fire post-promote.

- **PTY snapshot freshness** (review line 972, suggestion): The original
  abort handler called serializeTerminalToText immediately. Now we
  await Promise.race([processingChain drain, SIGKILL_TIMEOUT_MS]) first
  (mirrors the onExit finalize pattern at ~line 970) so in-flight
  headlessTerminal.write callbacks land before serialization. Skipped
  render(true) intentionally because it would emit final onOutputEvent
  data (renderFn calls onOutputEvent), violating the "no emit post-promote"
  invariant — added a comment explaining why direct serialize is correct.

- **Handoff-boundary tests** (review line 1257, suggestion): Added 4 new
  tests pinning the ownership contract — 2 for child_process (post-promote
  stdout/stderr does NOT route to onOutputEvent; child exit does NOT
  re-resolve result), 2 for PTY (data/exit disposables ARE called; result
  shape stays promoted: true even if post-promote events fire).

Also: test setup now stubs mockPtyProcess.onData / .onExit to return
{ dispose: vi.fn() } so the background-promote path's dispose() calls
don't crash on undefined (the stub's mock.results[0].value is then
inspected by the new handoff tests).

58 / 58 tests pass (50 baseline + 4 first-pass + 4 handoff). Total +235 / -35
on top of the prior commit.

* fix(core): defensive hardening for ShellExecutionService background-promote (resolve 2nd review pass)

Addresses 6 follow-up [Suggestion] threads on PR-1 of #3831 — all
substantive code-quality issues raised by the second-pass review of
the dispose-based detach commit (8e8e18c):

- **Exhaustive switch on `ShellAbortReason.kind`** (both abort handlers).
  Earlier `if (reason?.kind === 'background')` form silently fell
  through to kill for any unrecognized variant — a future
  `{ kind: 'suspend' }` would have killed the process with zero
  compile-time signal. Switched to `switch (kind)` with a `never`-typed
  default that runs `debugLogger.warn` and falls back to the safest
  behavior (cancel/kill). Each branch is now extracted into a named
  helper (`performBackgroundPromote` / `performCancelKill`) so the
  switch body stays a single screenful.

- **Each `dispose()` wrapped in its own try/catch** (PTY). node-pty's
  `IDisposable` contract doesn't guarantee no-throw. Without per-dispose
  try/catch a single throwing dispose() would skip subsequent cleanup
  (the other dispose, off('error'), activePtys.delete, drain, resolve)
  and the caller would hang forever on `await result`. Each call now
  logs via debugLogger.warn on failure but continues.

- **`.catch(() => undefined)` on the processingChain side of the drain
  race** (PTY). `Promise.race([processingChain.then(drain).then(drain),
  timeout])` would propagate a chain rejection out of the race; since
  `addEventListener` doesn't await our handler, the rejection became
  unhandled and `resolve()` was never called → caller hung. Now the
  rejection is swallowed; the timeout side still terminates the race
  on time.

- **Drain-timeout truncation now emits a diagnostic warning** (PTY).
  Previously the 200ms drain timeout could fire, the snapshot would be
  taken with the buffer in mid-write state, and the result.output
  would be silently truncated. Race result is now observed via a
  symbol sentinel; when the timeout side wins, debugLogger.warn fires
  pointing the user at rawOutput as the un-truncated fallback.

- **Snapshot serialize failure logs instead of swallowing silently**
  (PTY). Empty `catch {}` made result.output indistinguishable from
  "command produced no output" if serializeTerminalToText threw. Now
  `debugLogger.warn` with the error message leaves a trail for support
  bundles.

- **Dedicated `PROMOTE_DRAIN_TIMEOUT_MS` constant** separated from
  `SIGKILL_TIMEOUT_MS`. Both are 200ms today, but they have unrelated
  reasons-to-change (kill escalation timing vs. promote drain
  ceiling) — sharing the constant means tuning one would silently
  change the other.

Also adds a module-level `debugLogger = createDebugLogger('SHELL_EXECUTION')`
since the service had no logging surface before this commit.

58 / 58 tests pass; tsc clean; ESLint clean. No new tests added: the new
behaviors (timeout sentinel firing, dispose throw, exhaustive switch
default) are defensive log-only paths; existing handoff tests already
cover the happy path. Adding mock-throw tests is reasonable
follow-up but not blocking.

* fix(core): real bug — ptyProcess.off → removeListener; defensive abort-reason read

Resolves the third review pass on PR-1 of #3831 — 1 real bug + 2
defensive hardenings:

- **Real bug: `ptyProcess.off('error', ...)` throws TypeError at
  runtime** (line ~1074). `@lydell/node-pty`'s `IPty` interface
  exposes the legacy Node EventEmitter `removeListener`, not the
  modern `off` alias. Previous form threw, the surrounding try/catch
  swallowed it (post-prior-pass dispose hardening), but the old
  `ptyErrorHandler` stayed registered — so a post-promote PTY error
  would still hit our foreground handler and `throw err`, breaking
  the handoff contract that PR-1's whole listener-detach work is
  supposed to enforce. Switched to `removeListener`. The catch +
  warn stays as defense-in-depth; the message wording is updated.

- **Prototype-pollution-safe `kind` read** (extracted to module-level
  helper `getShellAbortReasonKind`). The previous `reason?.kind`
  walked the prototype chain — a polluted
  `Object.prototype.kind = 'background'` would silently route
  `abortController.abort({})` (any plain object reason) into the
  promote branch and skip the kill. Lifecycle/safety branch deserves
  the extra check. Helper now: rejects non-object reasons; reads
  `kind` only as an OWN property (`hasOwnProperty`); whitelists
  against `'background' | 'cancel'`; defaults to `'cancel'` (the
  safe historical behavior) for everything else. Both abort handlers
  (childProcess + PTY) now share this helper.

- **`streamStdout: true` + background-promote = silent empty
  snapshot** (childProcess `performBackgroundPromote`). The promote
  snapshot reads from the `stdout` / `stderr` string accumulators;
  but in `streamStdout` mode `handleOutput` forwards bytes through
  `onOutputEvent` and skips the accumulators entirely. Today PR-1's
  only call site (foreground shell.ts) uses `streamStdout: false`,
  so the combination is unreachable — but if a future caller pairs
  the two, `result.output` would be empty with no diagnostic. Added a
  `debugLogger.warn` when the combination occurs, pointing the caller
  at `rawOutput` as the fallback. Cheaper than building a parallel
  accumulator just for this latent case.

58 / 58 tests pass; tsc clean; ESLint clean.

* fix(core): liveness check + throw-safe abort-reason read + encoding-aware PTY snapshot (resolve 4th review pass)

Resolves 6 threads on PR-1 of #3831 — 1 Critical + 1 real bug + 2
quality + 2 test-coverage:

- **[Critical] `getShellAbortReasonKind` throw-safe property read.**
  Previous form read `reason.kind` after only checking that `kind` is
  an own property. An own accessor that throws (or a Proxy with a
  trapping getter) would throw before the helper reached either the
  cancel kill path or the background promote path. Abort handlers are
  dispatched async and not awaited by AbortSignal, so a leaked throw
  here would have left the shell process alive instead of being killed
  on cancel — quietly. Wrapped the property read in try/catch with a
  fall-back to the safe 'cancel' kill behavior.

- **Real bug: child_process post-exit race in background-promote**
  (`performBackgroundPromote`). The child may have already exited but
  the 'exit' event hasn't reached our handler yet (Node delivers
  events on the next microtask). Promoting in that window would
  detach our exit listener and report `promoted: true` for a process
  that's already dead — the caller would hold an inert pid expecting
  to take over. Now we read `child.exitCode` / `child.signalCode`
  before detaching: if either is non-null, fall through and let the
  pending exit handler resolve normally with the real exit info.
  Mirrored mock setup so `exitCode` / `signalCode` default to `null`
  (matching real ChildProcess) instead of `undefined`.

- **PTY snapshot: re-decode + replay (mirror exit-path encoding).**
  The promoted snapshot was serializing `headlessTerminal` directly,
  which was fed by a streaming decoder initialized from the
  first-chunk encoding heuristic. When early output is ASCII-only but
  later output is in a different encoding (GBK / Shift-JIS / etc.),
  this produces mojibake — and the normal exit path doesn't, because
  it re-decodes `finalBuffer` with `getCachedEncodingForBuffer` and
  replays through a fresh terminal. Now mirrors that logic so
  `result.output` shape matches across the two paths. Direct-serialize
  remains as a last-ditch fallback if replay throws.

- **Switch `default` no longer emits a runtime warn.** Reviewer noted
  the helper's whitelist made the `default: { _exhaustive: never }`
  branch unreachable at runtime — the `debugLogger.warn` in it could
  never fire. Kept the `_: never = kind` type assertion (so a future
  ShellAbortReason variant forces a TS error here, directing the
  developer to extend BOTH the helper's whitelist AND add a `case`),
  removed the unreachable warn. Added a comment that the assertion is
  the static-only safety net the union expansion would trigger.

- **Direct unit tests for `getShellAbortReasonKind`** (8 cases). The
  helper's prototype-pollution defense is the main reason it exists;
  if `hasOwnProperty` is accidentally removed the regression would
  silently send `abortController.abort({})` (any plain reason) into
  the promote path. Exported the helper and added direct tests for:
  null / undefined, non-object, empty object (no own kind), prototype-
  only kind (pollution), unknown kind value, throwing accessor, Proxy
  trap, and the two happy paths.

- **`removeListener` regression guard.** The fix to call
  `ptyProcess.removeListener('error', ...)` instead of `.off(...)`
  matters because `@lydell/node-pty`'s IPty interface only exposes
  `removeListener` — `.off()` throws TypeError on a real PTY but the
  EventEmitter mock tolerates both. Added a test that spies on both
  methods and asserts the production code uses `removeListener` for
  the 'error' event, so a future swap back to `.off()` regresses
  loudly under the mock instead of silently.

68 / 68 tests pass (58 baseline + 9 helper boundary + 1 removeListener
guard + 1 post-exit race); tsc clean; ESLint clean.

* fix(core): PTY background-promote post-exit race guard (resolve 5th review pass)

Mirrors the child_process post-exit race fix from 4cc558b into the
PTY path — addresses 1 [Critical] thread on PR-1 of #3831:

The PTY may have already exited but our `exitDisposable` (onExit
callback) hasn't run yet — node-pty delivers the exit event
asynchronously after the PTY's native SIGCHLD, so there's a window
between "PTY actually dead" and "service onExit fires". Promoting in
that window detaches our exit listener and reports `promoted: true`
for a dead PTY, losing the real exit status; the caller would hold an
inert pid expecting to take over.

The IPty interface doesn't expose an `exitCode` field we can read
directly (unlike `child.exitCode` / `child.signalCode` for
child_process), so use `process.kill(pid, 0)` as a best-effort
liveness check via the existing `ShellExecutionService.isPtyActive`
helper. If kill(pid, 0) throws ESRCH, the pid is gone — log at debug
level and fall through, letting the pending onExit callback resolve
normally with the real exit info.

Also adds a unit test mirroring the child_process race test: mocks
`process.kill(pid, 0)` to throw ESRCH on the liveness probe, asserts
the result has no `promoted: true` and reports the real exitCode.

69 / 69 tests pass; tsc clean; ESLint clean.

* docs(core): correct getShellAbortReasonKind boundary-test count in JSDoc

Doc said 'all six edge cases' but the test suite has 8 cases (added
Proxy-trap and undefined later). Off-by-2 cosmetic only — no behavior
change. Caught during a multi-round self-audit of PR-1 of #3831.

Audit summary: 7 rounds (correctness / reverse / consistency / coverage
/ build / exception paths / style) found one false-positive (a sync-
abort registration-order race I initially thought existed). Verified
that Node's WHATWG AbortSignal does NOT auto-fire 'abort' listeners
on already-aborted signals, so the race window cannot open. No code
change needed for that scenario; this commit is just the JSDoc fix.

69 / 69 tests still pass; tsc + ESLint clean.

* docs(core): document the helper / union / switch sync invariant explicitly

Multi-round self-audit found that `getShellAbortReasonKind`'s value
whitelist has no compile-time tie to the `ShellAbortReason` union: when
the union grows, TypeScript's `_exhaustive: never` in each switch
forces #3 (the case arm) to be added, but the helper's whitelist
(#2) silently keeps degrading the new variant to 'cancel', and the
new case arm is never reached at runtime.

Reviewer #4 raised this on the second pass; the original commit chose
to accept it (option B in that thread) but didn't leave a strong
in-code signal for future contributors. Added an INVARIANT block
inside the helper enumerating the three sites that must be kept in
sync, so the next person extending `ShellAbortReason` sees the
coupling at the place where they're most likely to forget it.

No behavior change — comment-only. 69 / 69 tests still pass; tsc +
ESLint clean.

Audit summary (this round + prior round): 18 angles total over two
sweeps and one reverse-attack pass. Found:
  - 0 real bugs
  - 1 false-positive race (sync-abort registration order — Node WHATWG
    AbortSignal does NOT auto-fire on already-aborted signals;
    investigated, reverted)
  - 1 cosmetic doc fix (boundary-test count off-by-2)
  - 1 cosmetic INVARIANT block (this commit)

Areas reviewed without finding new issues: caller-side
ShellExecutionResult shape compatibility (optional `promoted?` field,
existing callers spread-untouched); `exited` flag lifecycle
(monotonic, cleanup() idempotent); processingChain in-flight
ownership (listenersDetached guards every onOutputEvent emit
including the renderFn-rendered case via the same flag); race
between exit event and abort handler (both microtasks, FIFO ordering
gives correct outcome either way); Node version dependence
(`AbortSignal.reason` is Node 17.2+, engines: >=20 covers it);
test isolation (mockImplementationOnce + module-level mockProcessKill
clears each beforeEach); `process.kill(pid, 0)` Windows liveness
reliability (best-effort, acceptable for PR-1 plumbing); PID reuse
race on the PTY liveness check (theoretically possible, microsecond
window, unavoidable at the OS level — rejected in spec discussion);
PR-2/PR-3 contract surface (caller MUST attach listeners before
abort — documented; any future caller violating this is its own bug).

* test(core): align mockChildProcess.exitCode/signalCode in second beforeEach

The 'execution method selection' describe block has its own
beforeEach (separate from 'child_process fallback') that builds
mockChildProcess but does not set `exitCode` / `signalCode = null`.
Real Node `ChildProcess.exitCode` / `signalCode` are `null` while the
process is alive — and production now reads these in the
background-promote race guard. The current tests in this block don't
exercise the promote path, so they pass regardless, but any future
promote-related test landing here would silently trip the guard
(`undefined !== null` is true) and fall through to the normal-exit
branch instead of promoting.

Mirror the `child_process fallback` block's mock setup so the two
beforeEach hooks produce equivalent ChildProcess shapes, eliminating
a quiet foot-gun for future contributors.

Comment-only / test-fixture change. 69 / 69 tests still pass; tsc clean.
Found during a deeper third-round self-audit of PR-1 of #3831.
DragonnZhang pushed a commit that referenced this pull request May 8, 2026
…-up to #3842) (#3886)

* fix(core): wrap hasOwnProperty.call inside try + add post-abort PTY data assertion

Two non-blocking review notes from @tanzhenxin's PR-1 approval
(#3842, post-merge follow-up):

- **Note 2 (real bug)**: `getShellAbortReasonKind` had
  `Object.prototype.hasOwnProperty.call(reason, 'kind')` outside the
  try/catch. `hasOwnProperty.call` triggers the `[[GetOwnProperty]]`
  Proxy trap (`getOwnPropertyDescriptor` handler). A Proxy whose
  `getOwnPropertyDescriptor` throws — separate from a throwing `get`
  trap, which the prior commit already covered — would propagate past
  the helper, leaving the abort handler's switch on `kind` to throw
  through `addEventListener` (which doesn't await async listener
  return values), so the shell process would stay alive instead of
  being killed on cancel. Moved the descriptor probe inside the same
  try block as the value read.

  My own multi-round audit covered six attack vectors but missed this
  one — only `get` trap throws were considered. The reviewer caught
  it on first pass.

- **Note 3 (test parity)**: the PTY post-promotion handoff test
  asserted `dispose` was called but never re-invoked the data
  callback to verify the foreground `onOutputEvent` actually stops
  firing — the child_process equivalent has that assertion. Mirrored
  it: emit data AFTER abort by re-invoking the captured `dataCallback`
  reference, and assert `onOutputEventMock.mock.calls.length` does NOT
  increase past the moment of promote. Exercises the production
  `listenersDetached` guard inside the chain callback, which the
  bare dispose-was-called check didn't.

Note 1 from the same review (the `aborted: true + promoted: true`
shape forcing PR-2 callers to check `promoted` before `aborted`) is
deliberately NOT addressed here — it's a contract simplification that
affects PR-2's branching, so it belongs in PR-2 along with the
caller-side decision on whether to flip `aborted` for promoted
results. Added a TODO upstream in #3831 (PR-2 design) to track.

70 / 70 tests pass (69 baseline + 1 new helper boundary for the
throwing-getOwnPropertyDescriptor case). tsc + ESLint clean.

* test(core): fix tautological PTY post-promote assertion (audit follow-up)

The PTY post-promotion handoff test added in the previous commit
copied the child_process equivalent's pattern verbatim — sync
\`expect(count).toBe(countAtPromote)\` immediately after dataCallback
returns. That works for child_process because its \`handleOutput\` is
fully synchronous (sniff → decoder → emit, all on the same call
stack), so the count change happens BEFORE the assertion.

PTY's \`handleOutput\` is async — \`processingChain.then(...)\` queues a
microtask that does the sniff + write + render-then-emit work. The
sync assertion captures both \`countAtPromote\` and the post-emit count
BEFORE the chain microtask ever runs, so both reads return whatever
happened before the assert (typically 0). The test would
tautologically pass even if the production \`listenersDetached\` guard
were removed — i.e., it didn't actually verify the guard.

Restructured to:
1. Drive the PTY through \`simulateExecution\` so \`await handle.result\`
   forces all queued microtasks (including pre-promote chain items
   AND the abort handler's drain) to settle.
2. Capture \`eventCountAfterSettle\` once everything has stabilized.
3. Re-invoke the captured \`dataCallback\` with post-promote data,
   await two more macrotask boundaries to let the new chain item
   fully run.
4. Assert the count hasn't moved.

If the production \`listenersDetached\` guard is removed, the
post-promote chain item emits, count increases past
\`eventCountAfterSettle\`, and this assertion fails. So the test
actually exercises the guard now.

Found in self-audit while reviewing my own follow-up commit. Caught
because audit was paranoid about *whether the test verifies what it
claims to verify*, not just whether it passes.

70 / 70 tests pass; tsc + ESLint clean.

* test(core): pin eventCountAfterSettle === 0 in PTY post-promote test

The previous fix asserted post-promote count equals the
\`eventCountAfterSettle\` baseline, but didn't pin the baseline
itself. With the production \`listenersDetached\` guard intact, both
halves (pre-promote chain and post-promote chain) suppress emit, so
\`eventCountAfterSettle === 0 === post-count\` and the relative
comparison is vacuously true.

If a future refactor changed the production guard semantics so the
pre-promote chain item DID emit (count becomes 1+ after settle), the
relative-comparison test would still pass as long as post-promote
also emitted the same number — that's a regression the test should
catch. Adding \`expect(eventCountAfterSettle).toBe(0)\` makes the
contract explicit: once \`listenersDetached\` is set during the abort
handler's sync part, BOTH the in-flight chain item (pre-promote) and
the future chain item (post-promote) skip emit.

Found in another self-audit pass — even after fixing the tautological
assertion, the test could still mask certain future regressions.
70 / 70 tests pass; tsc + ESLint clean.

* test(core): drop dataCallbackHolder pattern, read mock.calls directly

The dataCallbackHolder pattern (capturing the onData callback inside
simulateExecution then invoking it after via a closure-shared object)
was unnecessary indirection — \`mockPtyProcess.onData.mock.calls[0][0]\`
reads the same callback reference whether you read it inside or outside
the simulation closure. Vi's mock.calls array is per-mock-instance and
beforeEach re-creates mockPtyProcess + .onData freshly, so there's no
stale-reference risk in the simpler form.

No behavior change in what's tested. 70 / 70 pass.

* chore(core): drop in-source @-mention attribution + dead Proxy.get handler

Two cosmetic cleanups found in another self-audit pass:

- **Helper comment**: removed the parenthetical attribution ("Caught
  by @tanzhenxin in the PR-1 review; my own audit only covered \`get\`
  trap throws."). The technical content of the comment — explaining
  why both the descriptor probe and the value read live inside the
  try — stands on its own. The reviewer credit lives in commit
  history / PR description, where it belongs; an in-source @-mention
  ages poorly (handles change, the relevant person may move on) and
  doesn't help future readers reason about the code.

- **Test Proxy**: \`throwingDescriptorProxy\` declared a \`get()\` handler
  that always returned \`undefined\`. The descriptor probe throws
  before the helper ever reaches the value read, so the \`get\`
  handler is unreachable — dropped it and added a one-line comment
  explaining why no \`get\` handler is needed for this test. Mirror
  test (\`throwingReason\` with throwing accessor + \`proxyReason\` with
  throwing \`get\`) keeps the symmetric "throwing-`get`" coverage.

70 / 70 tests pass; tsc + ESLint clean.
wenshao added a commit that referenced this pull request May 8, 2026
…f 3) (#3894)

* feat(core): foreground → background promote integration (#3831 PR-2 of 3)

Builds on the \`signal.reason\` foundation merged in #3842 / #3886. Wires
the foreground \`shell\` tool to detect a background-promote abort, snapshot
the captured output to a \`bg_xxx.output\` file, register a
\`BackgroundShellEntry\` in the existing \`BackgroundShellRegistry\`, and
return a model-facing \`ToolResult\` pointing at \`/tasks\` / the dialog /
\`task_stop\`. Also resolves design question 7 from #3831 (raised by
@tanzhenxin in the PR-1 review): set \`result.aborted: false\` when
\`result.promoted: true\` so existing \`if (result.aborted)\` consumer
branches fall through naturally.

## Changes

**\`shellExecutionService.ts\`** — both \`executeWithPty\` and
\`childProcessFallback\` background-promote branches now resolve with
\`aborted: false, promoted: true\` (was \`aborted: true\`). The flag now
answers "should the caller emit a cancel/timeout message?" rather than
"did the abort signal fire?" — and a promoted shell is neither
cancelled nor timed out (the child is still running, ownership simply
transferred). \`ShellExecutionResult.promoted?\` JSDoc updated to
document this contract.

**\`shell.ts\`** — \`ShellToolInvocation.execute()\` gains a 5th optional
parameter \`setPromoteAbortControllerCallback?: (ac: AbortController)
=> void\`. The foreground path now creates an internal
\`promoteAbortController\` and combines its signal into the existing
\`signal + timeoutSignal\` AbortSignal.any() chain. Right after
\`setPidCallback\` fires, \`setPromoteAbortControllerCallback\` exposes
the controller to the scheduler so a UI surface (PR-3 Ctrl+B keybind)
can find it by callId and trigger \`abort({ kind: 'background',
shellId })\`.

When \`result.promoted\` is observed after \`await resultPromise\`, a new
\`handlePromotedForeground\` private method:
  1. Generates \`bg_xxx\` shellId + on-disk \`outputPath\` under the same
     project temp dir \`executeBackground\` uses.
  2. Writes \`result.output\` (the snapshot the service flushed at
     promote time) as the file's initial content (best-effort —
     ENOSPC / EACCES logged + swallowed; the registry entry is
     valuable on its own).
  3. Constructs a \`BackgroundShellEntry\` with the running pid + the
     same \`promoteAbortController\` already wired into the live child
     — \`task_stop bg_xxx\` and the dialog's \`x\` key both abort via
     \`entry.abortController\` and will land on the still-running
     process.
  4. Returns a model-facing \`ToolResult\` pointing at \`/tasks\` / the
     Background tasks dialog / \`task_stop\` for follow-up.

**\`coreToolScheduler.ts\`** — \`TrackedExecutingToolCall\` gains an
optional \`promoteAbortController?: AbortController\` field, populated
when the shell tool's \`setPromoteAbortControllerCallback\` fires. The
scheduler routes only the shell-tool branch to pass this callback,
matching the existing \`setPidCallback\` pattern.

## Limitations (deferred to PR-2.5)

Two follow-up items intentionally NOT in scope here. Scope discipline
keeps PR-2 reviewable while still delivering the user-facing promote
flow end-to-end (PR-3's Ctrl+B keybind can wire to this PR's
\`promoteAbortController\` to ship a working feature).

- **Post-promote stream redirect**: today the \`outputPath\` content is
  FROZEN at the promote moment. The service detached its data
  listener as part of PR-1's ownership-transfer contract, so
  post-promote bytes from the still-running child don't reach the
  file. \`Read\`-ing the output via \`/tasks\` shows what was captured
  before promote, not live updates. PR-2.5 will add caller-side
  \`onPostPromoteData\` callback (or equivalent) so post-promote bytes
  stream to the file like a normal background shell.

- **Natural-exit registry settle**: the registry entry stays
  \`'running'\` until \`task_stop bg_xxx\` or session-end \`abortAll\`
  clears it. The service's exit listener was disposed at promote, so
  there's no observation point for natural child exit. PR-2.5 will
  keep the exit listener attached post-promote (with a separate
  \`onPostPromoteSettle\` callback) so the entry transitions to
  \`completed\` / \`failed\` like a normal background shell.

These limitations are visible to users (output frozen, entry stays
running until task_stop/session end) but don't break the core promote
contract: the agent unblocks, the registry entry is observable, the
process stays alive, cancel via \`task_stop\` works.

## Tests

**\`shellExecutionService.test.ts\`** — two existing promote tests now
assert \`aborted: false\` (per design question 7) instead of \`true\`.
\`70 / 70 pass\`.

**\`shell.test.ts\`** — three new tests in a \`foreground → background
promote (#3831 PR-2)\` describe block:
  1. \`setPromoteAbortControllerCallback\` exposes a real
     \`AbortController\` after spawn.
  2. On \`result.promoted: true\`, the registry receives a \`bg_xxx\`
     entry with pid + abortController + outputPath, the snapshot is
     written via \`fs.writeFileSync\`, and the model-facing copy
     references \`/tasks\` + \`task_stop\` + the dialog.
  3. A snapshot-write failure (mocked ENOSPC) doesn't break promote —
     the registry entry still gets registered with the running pid.
\`96 / 96 pass\`.

**\`coreToolScheduler.test.ts\`** — \`98 / 98 pass\` (no new tests; the
new \`promoteAbortController\` field is exercised end-to-end via
shell.test.ts).

Total: \`264 / 264 affected tests pass\`; tsc + ESLint clean.

## Related

- #3831 (Phase D part b — design + 3-PR sequencing; question 7
  resolved here)
- #3842 (PR-1 — \`signal.reason\` foundation)
- #3886 (PR-1 follow-up — Proxy-trap fix + handoff test parity)
- #3634 (Background task management roadmap)

cc @tanzhenxin

* fix(core): give promoted shell entry a FRESH AbortController so task_stop kills the child

Real bug found in self-audit of #3894 PR-2: \`entry.abortController\`
was being set to the same \`promoteAbortController\` that triggered the
promote — which is **already aborted** by the time we reach
\`handlePromotedForeground\`. Two consequences:

1. \`task_stop bg_xxx\` calls \`entry.abortController.abort()\`. On an
   already-aborted controller this is a no-op (the abort event was
   dispatched once when the controller fired; the second \`abort()\`
   doesn't re-fire listeners per WHATWG spec).
2. \`ShellExecutionService\` has already detached its own abort
   listener as part of the PR-1 ownership-transfer contract, so even
   if the abort COULD re-fire, there's nobody left listening to
   translate the signal into an actual SIGTERM/SIGKILL on the still-
   running child.

Net effect: a promoted shell would survive \`task_stop\` forever — the
agent would think it cancelled, the registry entry would stay
\`'running'\`, and the OS process would keep running until the user
killed the CLI session.

Fix: \`handlePromotedForeground\` now creates a fresh \`AbortController\`
for the registry entry and wires its abort listener to:
1. Send SIGTERM → SIGKILL to the still-running child via
   \`process.kill(-pid, …)\` (Linux/Mac process group, mirroring the
   \`detached: !isWindows\` spawn the foreground path uses) or
   \`taskkill /pid /f /t\` (Windows). Reuses the same SIGTERM-then-
   timeout-then-SIGKILL pattern \`ShellExecutionService.execute()\`
   uses on the non-promote cancel path; new constant
   \`PROMOTE_CANCEL_SIGKILL_TIMEOUT_MS = 200ms\` (intentionally
   separate from the service's \`SIGKILL_TIMEOUT_MS\` so tuning one
   doesn't silently change the other).
2. Sync-mark the registry entry \`cancelled\` via \`registry.cancel()\`
   so \`/tasks\` and the dialog reflect the user intent immediately.

Added a regression test pinning \`entry.abortController.signal.aborted
=== false\` at registration time. Without the fix, this asserts
\`true\` and the test fails — which is the visible canary for the
silent-task_stop-failure mode.

97 / 97 shell.test.ts pass; tsc + ESLint clean.

* fix(core): add 'error' listener on Windows taskkill spawn (audit follow-up)

Reverse-audit found a Windows-specific crash mode: \`cpSpawn('taskkill',
…)\` returns a \`ChildProcess\` whose 'error' event (emitted when the
spawn fails — taskkill binary missing, EACCES, etc.) crashes Node by
default if no 'error' listener is attached. Same pattern as PR-1's
\`@lydell/node-pty\` IPty incident — Web/Node spec quirk easy to miss
without specifically thinking about Windows + spawn-failure.

Also wrapped the \`cpSpawn\` call itself in try/catch for the rarer
sync-throw mode (EMFILE / ENOMEM at spawn-time). Recovery in both
cases: log via debugLogger.warn + continue; \`registry.cancel\` below
still transitions the entry, and the still-running child becomes an
orphan that Windows reaps when the CLI session ends.

97 / 97 shell.test.ts pass; tsc + ESLint clean.

* test(core): close 3 test gaps from #3894 review

Three [Suggestion] threads from the @tanzhenxin-style review on PR-2,
all real test gaps that would have let silent regressions through:

1. **\`setPromoteAbortControllerCallback\` test was too weak.** The old
   test only asserted that the callback received an \`AbortController\`
   instance, not that the controller's signal was actually wired into
   the \`AbortSignal.any(...)\` chain handed to ShellExecutionService.
   If \`shell.ts\` exposed the controller but forgot to combine its
   signal, Ctrl+B promotion would never reach the service while the
   bare-instance test still passed. Strengthened: capture the
   AbortSignal handed to ShellExecutionService.execute (4th arg),
   abort the promote controller, and assert the captured signal goes
   from \`aborted: false\` → \`true\`.

2. **The post-promote cancellation kill path was unverified.** The
   prior commit added a real-bug fix (fresh \`entryAc\` + abort
   listener that sends SIGTERM/SIGKILL + sync-marks the registry
   entry cancelled) but the only test it had was "the controller is
   fresh, signal not aborted". Reviewer rightly noted that this is
   the **core operational guarantee** for promoted shells — \`task_stop
   bg_xxx\` must actually stop the child. Added a test that uses
   fake timers + a \`process.kill\` spy: register a promoted entry,
   abort \`entry.abortController\`, flush microtasks (SIGTERM dispatch),
   advance fake time past \`PROMOTE_CANCEL_SIGKILL_TIMEOUT_MS\`
   (SIGKILL dispatch + \`registry.cancel\` mark). Pins the entire
   kill chain.

3. **Scheduler-side wiring of \`promoteAbortController\` was untested.**
   PR-3's Ctrl+B keybind looks up the executing tool call by callId
   and aborts \`tc.promoteAbortController\` — if \`CoreToolScheduler\`
   stops populating that field, the keybind silently breaks. Added
   a test in \`coreToolScheduler.test.ts\` that uses a
   \`TestShellInvocation extends ShellToolInvocation\` (so the
   scheduler's \`instanceof ShellToolInvocation\` check still routes
   the call through the shell-specific branch that wires the
   callback) and asserts that an \`onToolCallsUpdate\` batch emitted
   during the executing window contains a tool call where
   \`tc.promoteAbortController\` matches the controller the test
   exposed.

98 / 98 shell.test.ts pass; 99 / 99 coreToolScheduler.test.ts pass; tsc + ESLint clean.

* fix(core): use commandToExecute in promoted entry + try/catch register

Resolves 3 #3894 review threads:

- **Critical**: `entry.command` and `llmContent` for the promoted
  foreground shell now use `commandToExecute` (post-co-author-rewrite
  form) instead of raw `this.params.command`. For `git commit -m`
  invocations that `addCoAuthorToGitCommit()` rewrote, the registry
  entry now mirrors what actually ran — matching `executeBackground`'s
  long-standing convention (line 1234).

- Defensive try/catch around `registry.register(entry)`: today the
  call is internally safe (Map.set + emit), but a future
  implementation that throws would leave a zombie child detached from
  service listeners with no kill path. Catch path logs, fires
  `entryAc.abort()` for best-effort kill via the wired listener, and
  re-throws so the scheduler surfaces the failure.

- Updates the misleading comment (line 748) that claimed the registry
  entry uses "the same `promoteAbortController`" — actual impl uses a
  fresh `entryAc` (the audit-fix from the previous push).

Tests:
- `entry.command` git-commit case pinning post-rewrite form
- register-throw rejection + SIGTERM/SIGKILL kill via fake timers
- 100/100 shell.test.ts pass; tsc + ESLint clean

* fix(core): close 2 #3894 review findings — promote refused-race + mkdir orphan

Resolves @tanzhenxin's CHANGES_REQUESTED review on #3894.

1. **Refused-promote race no longer reported as "Command timed out"**

The combined-abort signal folds in `signal | timeoutSignal |
promoteAbortController.signal`, but the timeout discriminator only
excluded the user-cancel signal — not the promote signal. When the
user fires Ctrl+B (PR-3's keybind) but the service's race guard
refuses promotion (the child terminated a beat earlier), the result
lands `aborted: true, promoted: false` and the foreground path
falsely reported `Command timed out after 120000ms`. Both the agent
and the user would see a timeout that didn't happen.

Fix: extend the discriminator to ALSO exclude
`promoteAbortController.signal.aborted`. Add a `wasPromoteRefused`
branch that surfaces the actual cause: "Command finished before the
background-promote request could be honoured (the child had already
exited)." Same fix applied to both the llmContent path and the
returnDisplay path so the model and the visible UI agree.

Latent in PR-2 itself (no in-tree caller fires the promote yet), but
PR-3's keybind would expose it on first ship.

2. **Unguarded mkdirSync orphans the promoted child**

After `result.promoted: true`, ownership of the still-running child
has transferred and the service's kill path is detached. The promote
handler creates the snapshot output directory next, but the original
`fs.mkdirSync(outputDir, { recursive: true })` had no guard — read-
only temp mounts, sandboxed perms, full disk on inode/metadata
exhaustion would reject the handler BEFORE the registry's kill
listener was wired. The still-running child became an orphan zombie
with no kill path until the OS reaped it on session end.

Fix: wrap mkdirSync in try/catch (matches the safety pattern around
`registry.register`). On failure, log + best-effort kill the child
(SIGTERM via process.kill(-pid) on POSIX, taskkill /f /t on Windows
with an `error` listener so a spawn failure doesn't crash Node) +
re-throw so the scheduler surfaces the failure to the agent.

Tests: 2 new regressions in `shell.test.ts`:
- `mkdirSync(outputDir) throws → child gets SIGTERM, error re-raised`
- `promote-refused race (aborted: true, promoted: false after promote
  signal) is NOT reported as "Command timed out"`

171/171 shell.test.ts pass; tsc + ESLint clean.
TaimoorSiddiquiOfficial pushed a commit to TaimoorSiddiquiOfficial/HopCode that referenced this pull request May 8, 2026
…PR-2 of 3) (QwenLM#3894)

* feat(core): foreground → background promote integration (QwenLM#3831 PR-2 of 3)

Builds on the \`signal.reason\` foundation merged in QwenLM#3842 / QwenLM#3886. Wires
the foreground \`shell\` tool to detect a background-promote abort, snapshot
the captured output to a \`bg_xxx.output\` file, register a
\`BackgroundShellEntry\` in the existing \`BackgroundShellRegistry\`, and
return a model-facing \`ToolResult\` pointing at \`/tasks\` / the dialog /
\`task_stop\`. Also resolves design question 7 from QwenLM#3831 (raised by
@tanzhenxin in the PR-1 review): set \`result.aborted: false\` when
\`result.promoted: true\` so existing \`if (result.aborted)\` consumer
branches fall through naturally.

**\`shellExecutionService.ts\`** — both \`executeWithPty\` and
\`childProcessFallback\` background-promote branches now resolve with
\`aborted: false, promoted: true\` (was \`aborted: true\`). The flag now
answers "should the caller emit a cancel/timeout message?" rather than
"did the abort signal fire?" — and a promoted shell is neither
cancelled nor timed out (the child is still running, ownership simply
transferred). \`ShellExecutionResult.promoted?\` JSDoc updated to
document this contract.

**\`shell.ts\`** — \`ShellToolInvocation.execute()\` gains a 5th optional
parameter \`setPromoteAbortControllerCallback?: (ac: AbortController)
=> void\`. The foreground path now creates an internal
\`promoteAbortController\` and combines its signal into the existing
\`signal + timeoutSignal\` AbortSignal.any() chain. Right after
\`setPidCallback\` fires, \`setPromoteAbortControllerCallback\` exposes
the controller to the scheduler so a UI surface (PR-3 Ctrl+B keybind)
can find it by callId and trigger \`abort({ kind: 'background',
shellId })\`.

When \`result.promoted\` is observed after \`await resultPromise\`, a new
\`handlePromotedForeground\` private method:
  1. Generates \`bg_xxx\` shellId + on-disk \`outputPath\` under the same
     project temp dir \`executeBackground\` uses.
  2. Writes \`result.output\` (the snapshot the service flushed at
     promote time) as the file's initial content (best-effort —
     ENOSPC / EACCES logged + swallowed; the registry entry is
     valuable on its own).
  3. Constructs a \`BackgroundShellEntry\` with the running pid + the
     same \`promoteAbortController\` already wired into the live child
     — \`task_stop bg_xxx\` and the dialog's \`x\` key both abort via
     \`entry.abortController\` and will land on the still-running
     process.
  4. Returns a model-facing \`ToolResult\` pointing at \`/tasks\` / the
     Background tasks dialog / \`task_stop\` for follow-up.

**\`coreToolScheduler.ts\`** — \`TrackedExecutingToolCall\` gains an
optional \`promoteAbortController?: AbortController\` field, populated
when the shell tool's \`setPromoteAbortControllerCallback\` fires. The
scheduler routes only the shell-tool branch to pass this callback,
matching the existing \`setPidCallback\` pattern.

Two follow-up items intentionally NOT in scope here. Scope discipline
keeps PR-2 reviewable while still delivering the user-facing promote
flow end-to-end (PR-3's Ctrl+B keybind can wire to this PR's
\`promoteAbortController\` to ship a working feature).

- **Post-promote stream redirect**: today the \`outputPath\` content is
  FROZEN at the promote moment. The service detached its data
  listener as part of PR-1's ownership-transfer contract, so
  post-promote bytes from the still-running child don't reach the
  file. \`Read\`-ing the output via \`/tasks\` shows what was captured
  before promote, not live updates. PR-2.5 will add caller-side
  \`onPostPromoteData\` callback (or equivalent) so post-promote bytes
  stream to the file like a normal background shell.

- **Natural-exit registry settle**: the registry entry stays
  \`'running'\` until \`task_stop bg_xxx\` or session-end \`abortAll\`
  clears it. The service's exit listener was disposed at promote, so
  there's no observation point for natural child exit. PR-2.5 will
  keep the exit listener attached post-promote (with a separate
  \`onPostPromoteSettle\` callback) so the entry transitions to
  \`completed\` / \`failed\` like a normal background shell.

These limitations are visible to users (output frozen, entry stays
running until task_stop/session end) but don't break the core promote
contract: the agent unblocks, the registry entry is observable, the
process stays alive, cancel via \`task_stop\` works.

**\`shellExecutionService.test.ts\`** — two existing promote tests now
assert \`aborted: false\` (per design question 7) instead of \`true\`.
\`70 / 70 pass\`.

**\`shell.test.ts\`** — three new tests in a \`foreground → background
promote (QwenLM#3831 PR-2)\` describe block:
  1. \`setPromoteAbortControllerCallback\` exposes a real
     \`AbortController\` after spawn.
  2. On \`result.promoted: true\`, the registry receives a \`bg_xxx\`
     entry with pid + abortController + outputPath, the snapshot is
     written via \`fs.writeFileSync\`, and the model-facing copy
     references \`/tasks\` + \`task_stop\` + the dialog.
  3. A snapshot-write failure (mocked ENOSPC) doesn't break promote —
     the registry entry still gets registered with the running pid.
\`96 / 96 pass\`.

**\`coreToolScheduler.test.ts\`** — \`98 / 98 pass\` (no new tests; the
new \`promoteAbortController\` field is exercised end-to-end via
shell.test.ts).

Total: \`264 / 264 affected tests pass\`; tsc + ESLint clean.

- QwenLM#3831 (Phase D part b — design + 3-PR sequencing; question 7
  resolved here)
- QwenLM#3842 (PR-1 — \`signal.reason\` foundation)
- QwenLM#3886 (PR-1 follow-up — Proxy-trap fix + handoff test parity)
- QwenLM#3634 (Background task management roadmap)

cc @tanzhenxin

* fix(core): give promoted shell entry a FRESH AbortController so task_stop kills the child

Real bug found in self-audit of QwenLM#3894 PR-2: \`entry.abortController\`
was being set to the same \`promoteAbortController\` that triggered the
promote — which is **already aborted** by the time we reach
\`handlePromotedForeground\`. Two consequences:

1. \`task_stop bg_xxx\` calls \`entry.abortController.abort()\`. On an
   already-aborted controller this is a no-op (the abort event was
   dispatched once when the controller fired; the second \`abort()\`
   doesn't re-fire listeners per WHATWG spec).
2. \`ShellExecutionService\` has already detached its own abort
   listener as part of the PR-1 ownership-transfer contract, so even
   if the abort COULD re-fire, there's nobody left listening to
   translate the signal into an actual SIGTERM/SIGKILL on the still-
   running child.

Net effect: a promoted shell would survive \`task_stop\` forever — the
agent would think it cancelled, the registry entry would stay
\`'running'\`, and the OS process would keep running until the user
killed the CLI session.

Fix: \`handlePromotedForeground\` now creates a fresh \`AbortController\`
for the registry entry and wires its abort listener to:
1. Send SIGTERM → SIGKILL to the still-running child via
   \`process.kill(-pid, …)\` (Linux/Mac process group, mirroring the
   \`detached: !isWindows\` spawn the foreground path uses) or
   \`taskkill /pid /f /t\` (Windows). Reuses the same SIGTERM-then-
   timeout-then-SIGKILL pattern \`ShellExecutionService.execute()\`
   uses on the non-promote cancel path; new constant
   \`PROMOTE_CANCEL_SIGKILL_TIMEOUT_MS = 200ms\` (intentionally
   separate from the service's \`SIGKILL_TIMEOUT_MS\` so tuning one
   doesn't silently change the other).
2. Sync-mark the registry entry \`cancelled\` via \`registry.cancel()\`
   so \`/tasks\` and the dialog reflect the user intent immediately.

Added a regression test pinning \`entry.abortController.signal.aborted
=== false\` at registration time. Without the fix, this asserts
\`true\` and the test fails — which is the visible canary for the
silent-task_stop-failure mode.

97 / 97 shell.test.ts pass; tsc + ESLint clean.

* fix(core): add 'error' listener on Windows taskkill spawn (audit follow-up)

Reverse-audit found a Windows-specific crash mode: \`cpSpawn('taskkill',
…)\` returns a \`ChildProcess\` whose 'error' event (emitted when the
spawn fails — taskkill binary missing, EACCES, etc.) crashes Node by
default if no 'error' listener is attached. Same pattern as PR-1's
\`@lydell/node-pty\` IPty incident — Web/Node spec quirk easy to miss
without specifically thinking about Windows + spawn-failure.

Also wrapped the \`cpSpawn\` call itself in try/catch for the rarer
sync-throw mode (EMFILE / ENOMEM at spawn-time). Recovery in both
cases: log via debugLogger.warn + continue; \`registry.cancel\` below
still transitions the entry, and the still-running child becomes an
orphan that Windows reaps when the CLI session ends.

97 / 97 shell.test.ts pass; tsc + ESLint clean.

* test(core): close 3 test gaps from QwenLM#3894 review

Three [Suggestion] threads from the @tanzhenxin-style review on PR-2,
all real test gaps that would have let silent regressions through:

1. **\`setPromoteAbortControllerCallback\` test was too weak.** The old
   test only asserted that the callback received an \`AbortController\`
   instance, not that the controller's signal was actually wired into
   the \`AbortSignal.any(...)\` chain handed to ShellExecutionService.
   If \`shell.ts\` exposed the controller but forgot to combine its
   signal, Ctrl+B promotion would never reach the service while the
   bare-instance test still passed. Strengthened: capture the
   AbortSignal handed to ShellExecutionService.execute (4th arg),
   abort the promote controller, and assert the captured signal goes
   from \`aborted: false\` → \`true\`.

2. **The post-promote cancellation kill path was unverified.** The
   prior commit added a real-bug fix (fresh \`entryAc\` + abort
   listener that sends SIGTERM/SIGKILL + sync-marks the registry
   entry cancelled) but the only test it had was "the controller is
   fresh, signal not aborted". Reviewer rightly noted that this is
   the **core operational guarantee** for promoted shells — \`task_stop
   bg_xxx\` must actually stop the child. Added a test that uses
   fake timers + a \`process.kill\` spy: register a promoted entry,
   abort \`entry.abortController\`, flush microtasks (SIGTERM dispatch),
   advance fake time past \`PROMOTE_CANCEL_SIGKILL_TIMEOUT_MS\`
   (SIGKILL dispatch + \`registry.cancel\` mark). Pins the entire
   kill chain.

3. **Scheduler-side wiring of \`promoteAbortController\` was untested.**
   PR-3's Ctrl+B keybind looks up the executing tool call by callId
   and aborts \`tc.promoteAbortController\` — if \`CoreToolScheduler\`
   stops populating that field, the keybind silently breaks. Added
   a test in \`coreToolScheduler.test.ts\` that uses a
   \`TestShellInvocation extends ShellToolInvocation\` (so the
   scheduler's \`instanceof ShellToolInvocation\` check still routes
   the call through the shell-specific branch that wires the
   callback) and asserts that an \`onToolCallsUpdate\` batch emitted
   during the executing window contains a tool call where
   \`tc.promoteAbortController\` matches the controller the test
   exposed.

98 / 98 shell.test.ts pass; 99 / 99 coreToolScheduler.test.ts pass; tsc + ESLint clean.

* fix(core): use commandToExecute in promoted entry + try/catch register

Resolves 3 QwenLM#3894 review threads:

- **Critical**: `entry.command` and `llmContent` for the promoted
  foreground shell now use `commandToExecute` (post-co-author-rewrite
  form) instead of raw `this.params.command`. For `git commit -m`
  invocations that `addCoAuthorToGitCommit()` rewrote, the registry
  entry now mirrors what actually ran — matching `executeBackground`'s
  long-standing convention (line 1234).

- Defensive try/catch around `registry.register(entry)`: today the
  call is internally safe (Map.set + emit), but a future
  implementation that throws would leave a zombie child detached from
  service listeners with no kill path. Catch path logs, fires
  `entryAc.abort()` for best-effort kill via the wired listener, and
  re-throws so the scheduler surfaces the failure.

- Updates the misleading comment (line 748) that claimed the registry
  entry uses "the same `promoteAbortController`" — actual impl uses a
  fresh `entryAc` (the audit-fix from the previous push).

Tests:
- `entry.command` git-commit case pinning post-rewrite form
- register-throw rejection + SIGTERM/SIGKILL kill via fake timers
- 100/100 shell.test.ts pass; tsc + ESLint clean

* fix(core): close 2 QwenLM#3894 review findings — promote refused-race + mkdir orphan

Resolves @tanzhenxin's CHANGES_REQUESTED review on QwenLM#3894.

1. **Refused-promote race no longer reported as "Command timed out"**

The combined-abort signal folds in `signal | timeoutSignal |
promoteAbortController.signal`, but the timeout discriminator only
excluded the user-cancel signal — not the promote signal. When the
user fires Ctrl+B (PR-3's keybind) but the service's race guard
refuses promotion (the child terminated a beat earlier), the result
lands `aborted: true, promoted: false` and the foreground path
falsely reported `Command timed out after 120000ms`. Both the agent
and the user would see a timeout that didn't happen.

Fix: extend the discriminator to ALSO exclude
`promoteAbortController.signal.aborted`. Add a `wasPromoteRefused`
branch that surfaces the actual cause: "Command finished before the
background-promote request could be honoured (the child had already
exited)." Same fix applied to both the llmContent path and the
returnDisplay path so the model and the visible UI agree.

Latent in PR-2 itself (no in-tree caller fires the promote yet), but
PR-3's keybind would expose it on first ship.

2. **Unguarded mkdirSync orphans the promoted child**

After `result.promoted: true`, ownership of the still-running child
has transferred and the service's kill path is detached. The promote
handler creates the snapshot output directory next, but the original
`fs.mkdirSync(outputDir, { recursive: true })` had no guard — read-
only temp mounts, sandboxed perms, full disk on inode/metadata
exhaustion would reject the handler BEFORE the registry's kill
listener was wired. The still-running child became an orphan zombie
with no kill path until the OS reaped it on session end.

Fix: wrap mkdirSync in try/catch (matches the safety pattern around
`registry.register`). On failure, log + best-effort kill the child
(SIGTERM via process.kill(-pid) on POSIX, taskkill /f /t on Windows
with an `error` listener so a spawn failure doesn't crash Node) +
re-throw so the scheduler surfaces the failure to the agent.

Tests: 2 new regressions in `shell.test.ts`:
- `mkdirSync(outputDir) throws → child gets SIGTERM, error re-raised`
- `promote-refused race (aborted: true, promoted: false after promote
  signal) is NOT reported as "Command timed out"`

171/171 shell.test.ts pass; tsc + ESLint clean.
B-A-M-N pushed a commit to B-A-M-N/qwen-code that referenced this pull request May 8, 2026
…PR-2 of 3) (QwenLM#3894)

* feat(core): foreground → background promote integration (QwenLM#3831 PR-2 of 3)

Builds on the \`signal.reason\` foundation merged in QwenLM#3842 / QwenLM#3886. Wires
the foreground \`shell\` tool to detect a background-promote abort, snapshot
the captured output to a \`bg_xxx.output\` file, register a
\`BackgroundShellEntry\` in the existing \`BackgroundShellRegistry\`, and
return a model-facing \`ToolResult\` pointing at \`/tasks\` / the dialog /
\`task_stop\`. Also resolves design question 7 from QwenLM#3831 (raised by
@tanzhenxin in the PR-1 review): set \`result.aborted: false\` when
\`result.promoted: true\` so existing \`if (result.aborted)\` consumer
branches fall through naturally.

## Changes

**\`shellExecutionService.ts\`** — both \`executeWithPty\` and
\`childProcessFallback\` background-promote branches now resolve with
\`aborted: false, promoted: true\` (was \`aborted: true\`). The flag now
answers "should the caller emit a cancel/timeout message?" rather than
"did the abort signal fire?" — and a promoted shell is neither
cancelled nor timed out (the child is still running, ownership simply
transferred). \`ShellExecutionResult.promoted?\` JSDoc updated to
document this contract.

**\`shell.ts\`** — \`ShellToolInvocation.execute()\` gains a 5th optional
parameter \`setPromoteAbortControllerCallback?: (ac: AbortController)
=> void\`. The foreground path now creates an internal
\`promoteAbortController\` and combines its signal into the existing
\`signal + timeoutSignal\` AbortSignal.any() chain. Right after
\`setPidCallback\` fires, \`setPromoteAbortControllerCallback\` exposes
the controller to the scheduler so a UI surface (PR-3 Ctrl+B keybind)
can find it by callId and trigger \`abort({ kind: 'background',
shellId })\`.

When \`result.promoted\` is observed after \`await resultPromise\`, a new
\`handlePromotedForeground\` private method:
  1. Generates \`bg_xxx\` shellId + on-disk \`outputPath\` under the same
     project temp dir \`executeBackground\` uses.
  2. Writes \`result.output\` (the snapshot the service flushed at
     promote time) as the file's initial content (best-effort —
     ENOSPC / EACCES logged + swallowed; the registry entry is
     valuable on its own).
  3. Constructs a \`BackgroundShellEntry\` with the running pid + the
     same \`promoteAbortController\` already wired into the live child
     — \`task_stop bg_xxx\` and the dialog's \`x\` key both abort via
     \`entry.abortController\` and will land on the still-running
     process.
  4. Returns a model-facing \`ToolResult\` pointing at \`/tasks\` / the
     Background tasks dialog / \`task_stop\` for follow-up.

**\`coreToolScheduler.ts\`** — \`TrackedExecutingToolCall\` gains an
optional \`promoteAbortController?: AbortController\` field, populated
when the shell tool's \`setPromoteAbortControllerCallback\` fires. The
scheduler routes only the shell-tool branch to pass this callback,
matching the existing \`setPidCallback\` pattern.

## Limitations (deferred to PR-2.5)

Two follow-up items intentionally NOT in scope here. Scope discipline
keeps PR-2 reviewable while still delivering the user-facing promote
flow end-to-end (PR-3's Ctrl+B keybind can wire to this PR's
\`promoteAbortController\` to ship a working feature).

- **Post-promote stream redirect**: today the \`outputPath\` content is
  FROZEN at the promote moment. The service detached its data
  listener as part of PR-1's ownership-transfer contract, so
  post-promote bytes from the still-running child don't reach the
  file. \`Read\`-ing the output via \`/tasks\` shows what was captured
  before promote, not live updates. PR-2.5 will add caller-side
  \`onPostPromoteData\` callback (or equivalent) so post-promote bytes
  stream to the file like a normal background shell.

- **Natural-exit registry settle**: the registry entry stays
  \`'running'\` until \`task_stop bg_xxx\` or session-end \`abortAll\`
  clears it. The service's exit listener was disposed at promote, so
  there's no observation point for natural child exit. PR-2.5 will
  keep the exit listener attached post-promote (with a separate
  \`onPostPromoteSettle\` callback) so the entry transitions to
  \`completed\` / \`failed\` like a normal background shell.

These limitations are visible to users (output frozen, entry stays
running until task_stop/session end) but don't break the core promote
contract: the agent unblocks, the registry entry is observable, the
process stays alive, cancel via \`task_stop\` works.

## Tests

**\`shellExecutionService.test.ts\`** — two existing promote tests now
assert \`aborted: false\` (per design question 7) instead of \`true\`.
\`70 / 70 pass\`.

**\`shell.test.ts\`** — three new tests in a \`foreground → background
promote (QwenLM#3831 PR-2)\` describe block:
  1. \`setPromoteAbortControllerCallback\` exposes a real
     \`AbortController\` after spawn.
  2. On \`result.promoted: true\`, the registry receives a \`bg_xxx\`
     entry with pid + abortController + outputPath, the snapshot is
     written via \`fs.writeFileSync\`, and the model-facing copy
     references \`/tasks\` + \`task_stop\` + the dialog.
  3. A snapshot-write failure (mocked ENOSPC) doesn't break promote —
     the registry entry still gets registered with the running pid.
\`96 / 96 pass\`.

**\`coreToolScheduler.test.ts\`** — \`98 / 98 pass\` (no new tests; the
new \`promoteAbortController\` field is exercised end-to-end via
shell.test.ts).

Total: \`264 / 264 affected tests pass\`; tsc + ESLint clean.

## Related

- QwenLM#3831 (Phase D part b — design + 3-PR sequencing; question 7
  resolved here)
- QwenLM#3842 (PR-1 — \`signal.reason\` foundation)
- QwenLM#3886 (PR-1 follow-up — Proxy-trap fix + handoff test parity)
- QwenLM#3634 (Background task management roadmap)

cc @tanzhenxin

* fix(core): give promoted shell entry a FRESH AbortController so task_stop kills the child

Real bug found in self-audit of QwenLM#3894 PR-2: \`entry.abortController\`
was being set to the same \`promoteAbortController\` that triggered the
promote — which is **already aborted** by the time we reach
\`handlePromotedForeground\`. Two consequences:

1. \`task_stop bg_xxx\` calls \`entry.abortController.abort()\`. On an
   already-aborted controller this is a no-op (the abort event was
   dispatched once when the controller fired; the second \`abort()\`
   doesn't re-fire listeners per WHATWG spec).
2. \`ShellExecutionService\` has already detached its own abort
   listener as part of the PR-1 ownership-transfer contract, so even
   if the abort COULD re-fire, there's nobody left listening to
   translate the signal into an actual SIGTERM/SIGKILL on the still-
   running child.

Net effect: a promoted shell would survive \`task_stop\` forever — the
agent would think it cancelled, the registry entry would stay
\`'running'\`, and the OS process would keep running until the user
killed the CLI session.

Fix: \`handlePromotedForeground\` now creates a fresh \`AbortController\`
for the registry entry and wires its abort listener to:
1. Send SIGTERM → SIGKILL to the still-running child via
   \`process.kill(-pid, …)\` (Linux/Mac process group, mirroring the
   \`detached: !isWindows\` spawn the foreground path uses) or
   \`taskkill /pid /f /t\` (Windows). Reuses the same SIGTERM-then-
   timeout-then-SIGKILL pattern \`ShellExecutionService.execute()\`
   uses on the non-promote cancel path; new constant
   \`PROMOTE_CANCEL_SIGKILL_TIMEOUT_MS = 200ms\` (intentionally
   separate from the service's \`SIGKILL_TIMEOUT_MS\` so tuning one
   doesn't silently change the other).
2. Sync-mark the registry entry \`cancelled\` via \`registry.cancel()\`
   so \`/tasks\` and the dialog reflect the user intent immediately.

Added a regression test pinning \`entry.abortController.signal.aborted
=== false\` at registration time. Without the fix, this asserts
\`true\` and the test fails — which is the visible canary for the
silent-task_stop-failure mode.

97 / 97 shell.test.ts pass; tsc + ESLint clean.

* fix(core): add 'error' listener on Windows taskkill spawn (audit follow-up)

Reverse-audit found a Windows-specific crash mode: \`cpSpawn('taskkill',
…)\` returns a \`ChildProcess\` whose 'error' event (emitted when the
spawn fails — taskkill binary missing, EACCES, etc.) crashes Node by
default if no 'error' listener is attached. Same pattern as PR-1's
\`@lydell/node-pty\` IPty incident — Web/Node spec quirk easy to miss
without specifically thinking about Windows + spawn-failure.

Also wrapped the \`cpSpawn\` call itself in try/catch for the rarer
sync-throw mode (EMFILE / ENOMEM at spawn-time). Recovery in both
cases: log via debugLogger.warn + continue; \`registry.cancel\` below
still transitions the entry, and the still-running child becomes an
orphan that Windows reaps when the CLI session ends.

97 / 97 shell.test.ts pass; tsc + ESLint clean.

* test(core): close 3 test gaps from QwenLM#3894 review

Three [Suggestion] threads from the @tanzhenxin-style review on PR-2,
all real test gaps that would have let silent regressions through:

1. **\`setPromoteAbortControllerCallback\` test was too weak.** The old
   test only asserted that the callback received an \`AbortController\`
   instance, not that the controller's signal was actually wired into
   the \`AbortSignal.any(...)\` chain handed to ShellExecutionService.
   If \`shell.ts\` exposed the controller but forgot to combine its
   signal, Ctrl+B promotion would never reach the service while the
   bare-instance test still passed. Strengthened: capture the
   AbortSignal handed to ShellExecutionService.execute (4th arg),
   abort the promote controller, and assert the captured signal goes
   from \`aborted: false\` → \`true\`.

2. **The post-promote cancellation kill path was unverified.** The
   prior commit added a real-bug fix (fresh \`entryAc\` + abort
   listener that sends SIGTERM/SIGKILL + sync-marks the registry
   entry cancelled) but the only test it had was "the controller is
   fresh, signal not aborted". Reviewer rightly noted that this is
   the **core operational guarantee** for promoted shells — \`task_stop
   bg_xxx\` must actually stop the child. Added a test that uses
   fake timers + a \`process.kill\` spy: register a promoted entry,
   abort \`entry.abortController\`, flush microtasks (SIGTERM dispatch),
   advance fake time past \`PROMOTE_CANCEL_SIGKILL_TIMEOUT_MS\`
   (SIGKILL dispatch + \`registry.cancel\` mark). Pins the entire
   kill chain.

3. **Scheduler-side wiring of \`promoteAbortController\` was untested.**
   PR-3's Ctrl+B keybind looks up the executing tool call by callId
   and aborts \`tc.promoteAbortController\` — if \`CoreToolScheduler\`
   stops populating that field, the keybind silently breaks. Added
   a test in \`coreToolScheduler.test.ts\` that uses a
   \`TestShellInvocation extends ShellToolInvocation\` (so the
   scheduler's \`instanceof ShellToolInvocation\` check still routes
   the call through the shell-specific branch that wires the
   callback) and asserts that an \`onToolCallsUpdate\` batch emitted
   during the executing window contains a tool call where
   \`tc.promoteAbortController\` matches the controller the test
   exposed.

98 / 98 shell.test.ts pass; 99 / 99 coreToolScheduler.test.ts pass; tsc + ESLint clean.

* fix(core): use commandToExecute in promoted entry + try/catch register

Resolves 3 QwenLM#3894 review threads:

- **Critical**: `entry.command` and `llmContent` for the promoted
  foreground shell now use `commandToExecute` (post-co-author-rewrite
  form) instead of raw `this.params.command`. For `git commit -m`
  invocations that `addCoAuthorToGitCommit()` rewrote, the registry
  entry now mirrors what actually ran — matching `executeBackground`'s
  long-standing convention (line 1234).

- Defensive try/catch around `registry.register(entry)`: today the
  call is internally safe (Map.set + emit), but a future
  implementation that throws would leave a zombie child detached from
  service listeners with no kill path. Catch path logs, fires
  `entryAc.abort()` for best-effort kill via the wired listener, and
  re-throws so the scheduler surfaces the failure.

- Updates the misleading comment (line 748) that claimed the registry
  entry uses "the same `promoteAbortController`" — actual impl uses a
  fresh `entryAc` (the audit-fix from the previous push).

Tests:
- `entry.command` git-commit case pinning post-rewrite form
- register-throw rejection + SIGTERM/SIGKILL kill via fake timers
- 100/100 shell.test.ts pass; tsc + ESLint clean

* fix(core): close 2 QwenLM#3894 review findings — promote refused-race + mkdir orphan

Resolves @tanzhenxin's CHANGES_REQUESTED review on QwenLM#3894.

1. **Refused-promote race no longer reported as "Command timed out"**

The combined-abort signal folds in `signal | timeoutSignal |
promoteAbortController.signal`, but the timeout discriminator only
excluded the user-cancel signal — not the promote signal. When the
user fires Ctrl+B (PR-3's keybind) but the service's race guard
refuses promotion (the child terminated a beat earlier), the result
lands `aborted: true, promoted: false` and the foreground path
falsely reported `Command timed out after 120000ms`. Both the agent
and the user would see a timeout that didn't happen.

Fix: extend the discriminator to ALSO exclude
`promoteAbortController.signal.aborted`. Add a `wasPromoteRefused`
branch that surfaces the actual cause: "Command finished before the
background-promote request could be honoured (the child had already
exited)." Same fix applied to both the llmContent path and the
returnDisplay path so the model and the visible UI agree.

Latent in PR-2 itself (no in-tree caller fires the promote yet), but
PR-3's keybind would expose it on first ship.

2. **Unguarded mkdirSync orphans the promoted child**

After `result.promoted: true`, ownership of the still-running child
has transferred and the service's kill path is detached. The promote
handler creates the snapshot output directory next, but the original
`fs.mkdirSync(outputDir, { recursive: true })` had no guard — read-
only temp mounts, sandboxed perms, full disk on inode/metadata
exhaustion would reject the handler BEFORE the registry's kill
listener was wired. The still-running child became an orphan zombie
with no kill path until the OS reaped it on session end.

Fix: wrap mkdirSync in try/catch (matches the safety pattern around
`registry.register`). On failure, log + best-effort kill the child
(SIGTERM via process.kill(-pid) on POSIX, taskkill /f /t on Windows
with an `error` listener so a spawn failure doesn't crash Node) +
re-throw so the scheduler surfaces the failure to the agent.

Tests: 2 new regressions in `shell.test.ts`:
- `mkdirSync(outputDir) throws → child gets SIGTERM, error re-raised`
- `promote-refused race (aborted: true, promoted: false after promote
  signal) is NOT reported as "Command timed out"`

171/171 shell.test.ts pass; tsc + ESLint clean.
wenshao added a commit that referenced this pull request May 11, 2026
* feat(cli): Ctrl+B promote keybind — wire UI to PR-2's promoteAbortController (#3831 PR-3 of 3)

Final piece of the foreground → background promote feature. PR-1
(#3842) landed the `signal.reason` foundation; PR-2 (#3894) wired
`shell.ts` to detect a `{ kind: 'background' }` abort, snapshot
output, register a `BackgroundShellEntry`, and stash the promote
`AbortController` on `TrackedExecutingToolCall`. This PR exposes
the user-visible surface: pressing Ctrl+B during an in-flight
foreground shell command transfers ownership to a background task
the user can inspect via `/tasks` or stop via `task_stop`.

## Changes

- `keyBindings.ts`: new `Command.PROMOTE_SHELL_TO_BACKGROUND` bound to
  `Ctrl+B`. JSDoc explains the no-shell-running no-op semantics.

- `useReactToolScheduler.ts`: project `promoteAbortController` from
  the core's `ExecutingToolCall` through `TrackedExecutingToolCall`
  so the React layer (AppContainer keypress handler) can find it
  by callId without re-plumbing through the scheduler.

- `AppContainer.tsx`: `handleGlobalKeypress` gains a
  `PROMOTE_SHELL_TO_BACKGROUND` branch that walks
  `pendingToolCallsRef.current` (the ref, not the destructured
  array — keeps the deps list stable so the handler isn't re-bound
  on every tool-call status update), finds the executing tool call
  with a defined `promoteAbortController`, calls
  `.abort({ kind: 'background' })`, and returns early.
  No-op when no foreground shell is executing — Ctrl+B then falls
  through to the input layer's existing cursor-left binding.

- `keyboard-shortcuts.md`: documents Ctrl+B with explicit
  fall-through behavior so the conflict with the prompt-area
  cursor-left binding is intentional + understandable.

## Tests

- `keyMatchers.test.ts` (+1): Ctrl+B positive / bare-b + meta+b +
  Ctrl+other negatives.
- `AppContainer.test.tsx` (+2):
  - **Ctrl+B promotes** — pendingToolCalls includes an executing
    shell with a stubbed `AbortController` + spy; firing Ctrl+B
    asserts `abort({ kind: 'background' })` is called once.
  - **Ctrl+B no-op** — empty `pendingToolCalls` + Ctrl+B must NOT
    throw (pins the safety contract for the typing-mid-prompt
    case where the input layer's own Ctrl+B should still fire).
- 37/37 keyMatchers + 58/58 AppContainer pass; tsc + ESLint clean.

## E2E (manual, PR description guidance)

The unit / integration tests cover the keybind → abort wiring and
the promote handler's downstream behavior (PR-2's tests). Real-PTY
E2E is intentionally manual since headless test infrastructure
doesn't drive a real shell child + Ctrl+B keystroke; documented in
the PR description checklist.

Closes the 3-PR sequence for #3831 (Phase D part b of #3634).

* fix(cli): #3969 review wave — broadcast comment + debug log + redundancy

5 #3969 review threads addressed:

- **AppContainer.tsx Ctrl+B handler**: documented the
  KeypressContext.broadcast caveat (after `return`, the same Ctrl+B
  is still dispatched to text-buffer cursor-left + DebugProfiler;
  visible cursor-left side effect is cosmetic) so future readers
  understand why the prompt cursor moves on a successful promote.
  Added `debugLogger.debug` calls on both branches (matched callId
  on success; streamingState + pendingToolCalls.length on no-op
  fall-through) so "Ctrl+B doesn't work" reports are debuggable.

- **useReactToolScheduler.ts TrackedExecutingToolCall**: dropped
  the redundant `pid?` and `promoteAbortController?` declarations
  — both come through the `& ExecutingToolCall` intersection
  unchanged. Fixed the JSDoc that wrote `{ kind: 'background',
  shellId }`: callers don't generate `shellId` (it's optional on
  the abort-reason union and `handlePromotedForeground` produces
  it downstream). The corresponding executing branch in
  `toolCallsUpdateHandler` no longer projects pid /
  promoteAbortController explicitly — `...coreTc` already spreads
  them; the explicit-undefined clearing in the non-executing
  branch is also dropped (those fields aren't on coreTc when
  status !== 'executing', so `...coreTc` doesn't carry them).

- **AppContainer.test.tsx**: replaced two `as unknown as Key`
  double-casts with direct `: Key` annotations on the literal —
  the object already conforms to the Key interface, double-cast
  was bypassing type safety needlessly.

Tests: 37/37 keyMatchers + 58/58 AppContainer pass; tsc + ESLint
clean. No behavior change beyond the new debug log lines.

* fix(cli): #3969 wave — tool-name guard + non-shell test + defensive clear

3 #3969 review threads addressed; 1 deferred:

- AppContainer.tsx: Ctrl+B `find()` predicate now also checks
  `tc.request.name === ToolNames.SHELL` before matching the executing
  tool call. Defense-in-depth — today only the shell tool wires
  `promoteAbortController`, but a future copy-paste / type confusion
  that adds the property to a non-shell tool would otherwise let
  Ctrl+B mistakenly fire `abort({kind:'background'})` on a tool
  whose service has no promote-handoff handler.

- useReactToolScheduler.ts: re-added explicit `pid: undefined` and
  `promoteAbortController: undefined` to the non-executing return.
  Previously dropped on the assumption that `...coreTc` doesn't
  carry these fields when the status isn't `executing` — true today,
  but the explicit clearing is defense-in-depth against a future
  core change that adds either field to a non-executing status type
  (would surface as a stuck PID display or a Ctrl+B handler that
  matches a no-longer-executing tool call).

- AppContainer.test.tsx: replaced the placeholder "no-op when no
  pending tool calls" framing on the empty-array case (it does
  exercise the `executing-status` predicate but NOT the tool-name
  guard) with TWO tests:
    1. existing empty-array no-throw test (renamed for clarity)
    2. NEW: executing non-shell tool with a hostile-shape
       `promoteAbortController` — asserts `abortSpy` is NOT called.
       This is the regression test for the new tool-name guard above.

Tests: 61/61 AppContainer.test.tsx pass; tsc + ESLint clean.

Deferred to follow-up (replied + tracked):
- `debugLogger.debug` is file-only; success-path "agent unblocks +
  next message says 'promoted to bg_xxx'" is the user-visible signal.
  Adding a synthetic history item or stderr line for the gap between
  keypress and agent message conflicts with Ink rendering and is
  better as a focused UX PR.

* test(cli): pin inheritance of pid + promoteAbortController via type assertions

#3969 review: the earlier "redundant declaration" review removed the
explicit `pid?: number` and `promoteAbortController?: AbortController`
from `TrackedExecutingToolCall`, relying on the `& ExecutingToolCall`
intersection to inherit them. Current review flags the type-safety
regression: if core renames or removes either field, the React-side
build won't catch it locally — Ctrl+B handler silently breaks at
runtime.

Compromise: keep the type minimal (no re-declaration noise the prior
review flagged) but add compile-time `extends keyof ExecutingToolCall`
assertions that fail loudly + locally if either field disappears.
The assertions are evaluated at compile time and zero-cost at
runtime; the dummy `const` pins them so they aren't dead code.

61/61 AppContainer tests pass; tsc clean.
xaelistic pushed a commit to xaelistic/qwen-code that referenced this pull request Jun 7, 2026
…wenLM#3831 PR-1 of 3) (QwenLM#3842)

* feat(core): add signal.reason convention for ShellExecutionService.execute()

Foundation for QwenLM#3831 Phase D (b) — Ctrl+B promote of a running foreground
shell to background. Defines a discriminated `ShellAbortReason` union that
the AbortSignal carries; default behavior (no reason / `{ kind: 'cancel' }`)
keeps the existing tree-kill on abort. `{ kind: 'background' }` is a takeover
signal — execute() skips the kill, drops the child from its active set (so
cleanup() won't kill it later), flushes a snapshot of captured output, and
resolves the result Promise immediately with `promoted: true` so the
awaiting caller unblocks.

Pure plumbing: no caller sets the reason yet, so this is a zero-behavior
change for existing call sites. The `promoted?: boolean` field is optional
on ShellExecutionResult so existing consumers compile against the new shape
without source changes.

Tests pin both branches in both childProcessFallback and executeWithPty:
default abort still SIGTERM-tree-kills; `{ kind: 'cancel' }` is identical to
default (pin against accidental routing through the background branch);
`{ kind: 'background' }` skips the kill, snapshot output is preserved,
mockProcessKill / mockPtyProcess.kill are NOT called.

Part of QwenLM#3831 (Phase D part b — Ctrl+B promote running shell to background).
PR-1 of 3.

* fix(core): detach service listeners on background-promote (resolve review)

Addresses 4 Critical + 2 Suggestion findings on PR-1 of QwenLM#3831:

- **childProcess listener detach** (review line 555 + 573): Anonymous arrow
  listeners on stdout/stderr/error/exit could not be off()'d. After
  background-promote, post-promote bytes would re-enter handleOutput, which
  then calls decoder.decode() on a now-finalized text decoder (cleanup()
  already called .decode() without stream:true) → TypeError crash. Even
  without the crash, old onOutputEvent would fire for new data → ownership
  contract violation + duplication. Fix: extract named handler refs
  (stdoutHandler / stderrHandler / errorHandler / exitHandler) and call
  off() on all four in the background-promote branch via a
  detachServiceListeners() helper.

- **PTY listener detach** (review line 967 + 990): node-pty's onData / onExit
  return IDisposable handles; the abort handler now captures
  dataDisposable / exitDisposable and calls .dispose() in the
  background-promote branch. ptyProcess.on('error') is EventEmitter-style
  (not IDisposable) — extract a named ptyErrorHandler ref and off() it.
  Without these, post-promote PTY error throws → Node.js crash; post-promote
  data continues writing to headlessTerminal and calling old onOutputEvent
  → ownership violation.

- **PTY in-flight chain item ownership** (related to review line 990):
  processingChain may have already-enqueued callbacks past the early
  listenersDetached check. Refactored from "early-return short-circuit" to
  "guard each onOutputEvent emit individually" so in-flight writes still
  LAND in headlessTerminal (snapshot reflects them) but no events leak to
  the foreground onOutputEvent. Also clear renderTimeout in the abort
  handler so a pending throttled render doesn't fire post-promote.

- **PTY snapshot freshness** (review line 972, suggestion): The original
  abort handler called serializeTerminalToText immediately. Now we
  await Promise.race([processingChain drain, SIGKILL_TIMEOUT_MS]) first
  (mirrors the onExit finalize pattern at ~line 970) so in-flight
  headlessTerminal.write callbacks land before serialization. Skipped
  render(true) intentionally because it would emit final onOutputEvent
  data (renderFn calls onOutputEvent), violating the "no emit post-promote"
  invariant — added a comment explaining why direct serialize is correct.

- **Handoff-boundary tests** (review line 1257, suggestion): Added 4 new
  tests pinning the ownership contract — 2 for child_process (post-promote
  stdout/stderr does NOT route to onOutputEvent; child exit does NOT
  re-resolve result), 2 for PTY (data/exit disposables ARE called; result
  shape stays promoted: true even if post-promote events fire).

Also: test setup now stubs mockPtyProcess.onData / .onExit to return
{ dispose: vi.fn() } so the background-promote path's dispose() calls
don't crash on undefined (the stub's mock.results[0].value is then
inspected by the new handoff tests).

58 / 58 tests pass (50 baseline + 4 first-pass + 4 handoff). Total +235 / -35
on top of the prior commit.

* fix(core): defensive hardening for ShellExecutionService background-promote (resolve 2nd review pass)

Addresses 6 follow-up [Suggestion] threads on PR-1 of QwenLM#3831 — all
substantive code-quality issues raised by the second-pass review of
the dispose-based detach commit (2bca15d5c):

- **Exhaustive switch on `ShellAbortReason.kind`** (both abort handlers).
  Earlier `if (reason?.kind === 'background')` form silently fell
  through to kill for any unrecognized variant — a future
  `{ kind: 'suspend' }` would have killed the process with zero
  compile-time signal. Switched to `switch (kind)` with a `never`-typed
  default that runs `debugLogger.warn` and falls back to the safest
  behavior (cancel/kill). Each branch is now extracted into a named
  helper (`performBackgroundPromote` / `performCancelKill`) so the
  switch body stays a single screenful.

- **Each `dispose()` wrapped in its own try/catch** (PTY). node-pty's
  `IDisposable` contract doesn't guarantee no-throw. Without per-dispose
  try/catch a single throwing dispose() would skip subsequent cleanup
  (the other dispose, off('error'), activePtys.delete, drain, resolve)
  and the caller would hang forever on `await result`. Each call now
  logs via debugLogger.warn on failure but continues.

- **`.catch(() => undefined)` on the processingChain side of the drain
  race** (PTY). `Promise.race([processingChain.then(drain).then(drain),
  timeout])` would propagate a chain rejection out of the race; since
  `addEventListener` doesn't await our handler, the rejection became
  unhandled and `resolve()` was never called → caller hung. Now the
  rejection is swallowed; the timeout side still terminates the race
  on time.

- **Drain-timeout truncation now emits a diagnostic warning** (PTY).
  Previously the 200ms drain timeout could fire, the snapshot would be
  taken with the buffer in mid-write state, and the result.output
  would be silently truncated. Race result is now observed via a
  symbol sentinel; when the timeout side wins, debugLogger.warn fires
  pointing the user at rawOutput as the un-truncated fallback.

- **Snapshot serialize failure logs instead of swallowing silently**
  (PTY). Empty `catch {}` made result.output indistinguishable from
  "command produced no output" if serializeTerminalToText threw. Now
  `debugLogger.warn` with the error message leaves a trail for support
  bundles.

- **Dedicated `PROMOTE_DRAIN_TIMEOUT_MS` constant** separated from
  `SIGKILL_TIMEOUT_MS`. Both are 200ms today, but they have unrelated
  reasons-to-change (kill escalation timing vs. promote drain
  ceiling) — sharing the constant means tuning one would silently
  change the other.

Also adds a module-level `debugLogger = createDebugLogger('SHELL_EXECUTION')`
since the service had no logging surface before this commit.

58 / 58 tests pass; tsc clean; ESLint clean. No new tests added: the new
behaviors (timeout sentinel firing, dispose throw, exhaustive switch
default) are defensive log-only paths; existing handoff tests already
cover the happy path. Adding mock-throw tests is reasonable
follow-up but not blocking.

* fix(core): real bug — ptyProcess.off → removeListener; defensive abort-reason read

Resolves the third review pass on PR-1 of QwenLM#3831 — 1 real bug + 2
defensive hardenings:

- **Real bug: `ptyProcess.off('error', ...)` throws TypeError at
  runtime** (line ~1074). `@lydell/node-pty`'s `IPty` interface
  exposes the legacy Node EventEmitter `removeListener`, not the
  modern `off` alias. Previous form threw, the surrounding try/catch
  swallowed it (post-prior-pass dispose hardening), but the old
  `ptyErrorHandler` stayed registered — so a post-promote PTY error
  would still hit our foreground handler and `throw err`, breaking
  the handoff contract that PR-1's whole listener-detach work is
  supposed to enforce. Switched to `removeListener`. The catch +
  warn stays as defense-in-depth; the message wording is updated.

- **Prototype-pollution-safe `kind` read** (extracted to module-level
  helper `getShellAbortReasonKind`). The previous `reason?.kind`
  walked the prototype chain — a polluted
  `Object.prototype.kind = 'background'` would silently route
  `abortController.abort({})` (any plain object reason) into the
  promote branch and skip the kill. Lifecycle/safety branch deserves
  the extra check. Helper now: rejects non-object reasons; reads
  `kind` only as an OWN property (`hasOwnProperty`); whitelists
  against `'background' | 'cancel'`; defaults to `'cancel'` (the
  safe historical behavior) for everything else. Both abort handlers
  (childProcess + PTY) now share this helper.

- **`streamStdout: true` + background-promote = silent empty
  snapshot** (childProcess `performBackgroundPromote`). The promote
  snapshot reads from the `stdout` / `stderr` string accumulators;
  but in `streamStdout` mode `handleOutput` forwards bytes through
  `onOutputEvent` and skips the accumulators entirely. Today PR-1's
  only call site (foreground shell.ts) uses `streamStdout: false`,
  so the combination is unreachable — but if a future caller pairs
  the two, `result.output` would be empty with no diagnostic. Added a
  `debugLogger.warn` when the combination occurs, pointing the caller
  at `rawOutput` as the fallback. Cheaper than building a parallel
  accumulator just for this latent case.

58 / 58 tests pass; tsc clean; ESLint clean.

* fix(core): liveness check + throw-safe abort-reason read + encoding-aware PTY snapshot (resolve 4th review pass)

Resolves 6 threads on PR-1 of QwenLM#3831 — 1 Critical + 1 real bug + 2
quality + 2 test-coverage:

- **[Critical] `getShellAbortReasonKind` throw-safe property read.**
  Previous form read `reason.kind` after only checking that `kind` is
  an own property. An own accessor that throws (or a Proxy with a
  trapping getter) would throw before the helper reached either the
  cancel kill path or the background promote path. Abort handlers are
  dispatched async and not awaited by AbortSignal, so a leaked throw
  here would have left the shell process alive instead of being killed
  on cancel — quietly. Wrapped the property read in try/catch with a
  fall-back to the safe 'cancel' kill behavior.

- **Real bug: child_process post-exit race in background-promote**
  (`performBackgroundPromote`). The child may have already exited but
  the 'exit' event hasn't reached our handler yet (Node delivers
  events on the next microtask). Promoting in that window would
  detach our exit listener and report `promoted: true` for a process
  that's already dead — the caller would hold an inert pid expecting
  to take over. Now we read `child.exitCode` / `child.signalCode`
  before detaching: if either is non-null, fall through and let the
  pending exit handler resolve normally with the real exit info.
  Mirrored mock setup so `exitCode` / `signalCode` default to `null`
  (matching real ChildProcess) instead of `undefined`.

- **PTY snapshot: re-decode + replay (mirror exit-path encoding).**
  The promoted snapshot was serializing `headlessTerminal` directly,
  which was fed by a streaming decoder initialized from the
  first-chunk encoding heuristic. When early output is ASCII-only but
  later output is in a different encoding (GBK / Shift-JIS / etc.),
  this produces mojibake — and the normal exit path doesn't, because
  it re-decodes `finalBuffer` with `getCachedEncodingForBuffer` and
  replays through a fresh terminal. Now mirrors that logic so
  `result.output` shape matches across the two paths. Direct-serialize
  remains as a last-ditch fallback if replay throws.

- **Switch `default` no longer emits a runtime warn.** Reviewer noted
  the helper's whitelist made the `default: { _exhaustive: never }`
  branch unreachable at runtime — the `debugLogger.warn` in it could
  never fire. Kept the `_: never = kind` type assertion (so a future
  ShellAbortReason variant forces a TS error here, directing the
  developer to extend BOTH the helper's whitelist AND add a `case`),
  removed the unreachable warn. Added a comment that the assertion is
  the static-only safety net the union expansion would trigger.

- **Direct unit tests for `getShellAbortReasonKind`** (8 cases). The
  helper's prototype-pollution defense is the main reason it exists;
  if `hasOwnProperty` is accidentally removed the regression would
  silently send `abortController.abort({})` (any plain reason) into
  the promote path. Exported the helper and added direct tests for:
  null / undefined, non-object, empty object (no own kind), prototype-
  only kind (pollution), unknown kind value, throwing accessor, Proxy
  trap, and the two happy paths.

- **`removeListener` regression guard.** The fix to call
  `ptyProcess.removeListener('error', ...)` instead of `.off(...)`
  matters because `@lydell/node-pty`'s IPty interface only exposes
  `removeListener` — `.off()` throws TypeError on a real PTY but the
  EventEmitter mock tolerates both. Added a test that spies on both
  methods and asserts the production code uses `removeListener` for
  the 'error' event, so a future swap back to `.off()` regresses
  loudly under the mock instead of silently.

68 / 68 tests pass (58 baseline + 9 helper boundary + 1 removeListener
guard + 1 post-exit race); tsc clean; ESLint clean.

* fix(core): PTY background-promote post-exit race guard (resolve 5th review pass)

Mirrors the child_process post-exit race fix from 0df1be23f into the
PTY path — addresses 1 [Critical] thread on PR-1 of QwenLM#3831:

The PTY may have already exited but our `exitDisposable` (onExit
callback) hasn't run yet — node-pty delivers the exit event
asynchronously after the PTY's native SIGCHLD, so there's a window
between "PTY actually dead" and "service onExit fires". Promoting in
that window detaches our exit listener and reports `promoted: true`
for a dead PTY, losing the real exit status; the caller would hold an
inert pid expecting to take over.

The IPty interface doesn't expose an `exitCode` field we can read
directly (unlike `child.exitCode` / `child.signalCode` for
child_process), so use `process.kill(pid, 0)` as a best-effort
liveness check via the existing `ShellExecutionService.isPtyActive`
helper. If kill(pid, 0) throws ESRCH, the pid is gone — log at debug
level and fall through, letting the pending onExit callback resolve
normally with the real exit info.

Also adds a unit test mirroring the child_process race test: mocks
`process.kill(pid, 0)` to throw ESRCH on the liveness probe, asserts
the result has no `promoted: true` and reports the real exitCode.

69 / 69 tests pass; tsc clean; ESLint clean.

* docs(core): correct getShellAbortReasonKind boundary-test count in JSDoc

Doc said 'all six edge cases' but the test suite has 8 cases (added
Proxy-trap and undefined later). Off-by-2 cosmetic only — no behavior
change. Caught during a multi-round self-audit of PR-1 of QwenLM#3831.

Audit summary: 7 rounds (correctness / reverse / consistency / coverage
/ build / exception paths / style) found one false-positive (a sync-
abort registration-order race I initially thought existed). Verified
that Node's WHATWG AbortSignal does NOT auto-fire 'abort' listeners
on already-aborted signals, so the race window cannot open. No code
change needed for that scenario; this commit is just the JSDoc fix.

69 / 69 tests still pass; tsc + ESLint clean.

* docs(core): document the helper / union / switch sync invariant explicitly

Multi-round self-audit found that `getShellAbortReasonKind`'s value
whitelist has no compile-time tie to the `ShellAbortReason` union: when
the union grows, TypeScript's `_exhaustive: never` in each switch
forces QwenLM#3 (the case arm) to be added, but the helper's whitelist
(QwenLM#2) silently keeps degrading the new variant to 'cancel', and the
new case arm is never reached at runtime.

Reviewer QwenLM#4 raised this on the second pass; the original commit chose
to accept it (option B in that thread) but didn't leave a strong
in-code signal for future contributors. Added an INVARIANT block
inside the helper enumerating the three sites that must be kept in
sync, so the next person extending `ShellAbortReason` sees the
coupling at the place where they're most likely to forget it.

No behavior change — comment-only. 69 / 69 tests still pass; tsc +
ESLint clean.

Audit summary (this round + prior round): 18 angles total over two
sweeps and one reverse-attack pass. Found:
  - 0 real bugs
  - 1 false-positive race (sync-abort registration order — Node WHATWG
    AbortSignal does NOT auto-fire on already-aborted signals;
    investigated, reverted)
  - 1 cosmetic doc fix (boundary-test count off-by-2)
  - 1 cosmetic INVARIANT block (this commit)

Areas reviewed without finding new issues: caller-side
ShellExecutionResult shape compatibility (optional `promoted?` field,
existing callers spread-untouched); `exited` flag lifecycle
(monotonic, cleanup() idempotent); processingChain in-flight
ownership (listenersDetached guards every onOutputEvent emit
including the renderFn-rendered case via the same flag); race
between exit event and abort handler (both microtasks, FIFO ordering
gives correct outcome either way); Node version dependence
(`AbortSignal.reason` is Node 17.2+, engines: >=20 covers it);
test isolation (mockImplementationOnce + module-level mockProcessKill
clears each beforeEach); `process.kill(pid, 0)` Windows liveness
reliability (best-effort, acceptable for PR-1 plumbing); PID reuse
race on the PTY liveness check (theoretically possible, microsecond
window, unavoidable at the OS level — rejected in spec discussion);
PR-2/PR-3 contract surface (caller MUST attach listeners before
abort — documented; any future caller violating this is its own bug).

* test(core): align mockChildProcess.exitCode/signalCode in second beforeEach

The 'execution method selection' describe block has its own
beforeEach (separate from 'child_process fallback') that builds
mockChildProcess but does not set `exitCode` / `signalCode = null`.
Real Node `ChildProcess.exitCode` / `signalCode` are `null` while the
process is alive — and production now reads these in the
background-promote race guard. The current tests in this block don't
exercise the promote path, so they pass regardless, but any future
promote-related test landing here would silently trip the guard
(`undefined !== null` is true) and fall through to the normal-exit
branch instead of promoting.

Mirror the `child_process fallback` block's mock setup so the two
beforeEach hooks produce equivalent ChildProcess shapes, eliminating
a quiet foot-gun for future contributors.

Comment-only / test-fixture change. 69 / 69 tests still pass; tsc clean.
Found during a deeper third-round self-audit of PR-1 of QwenLM#3831.
xaelistic pushed a commit to xaelistic/qwen-code that referenced this pull request Jun 7, 2026
…-up to QwenLM#3842) (QwenLM#3886)

* fix(core): wrap hasOwnProperty.call inside try + add post-abort PTY data assertion

Two non-blocking review notes from @tanzhenxin's PR-1 approval
(QwenLM#3842, post-merge follow-up):

- **Note 2 (real bug)**: `getShellAbortReasonKind` had
  `Object.prototype.hasOwnProperty.call(reason, 'kind')` outside the
  try/catch. `hasOwnProperty.call` triggers the `[[GetOwnProperty]]`
  Proxy trap (`getOwnPropertyDescriptor` handler). A Proxy whose
  `getOwnPropertyDescriptor` throws — separate from a throwing `get`
  trap, which the prior commit already covered — would propagate past
  the helper, leaving the abort handler's switch on `kind` to throw
  through `addEventListener` (which doesn't await async listener
  return values), so the shell process would stay alive instead of
  being killed on cancel. Moved the descriptor probe inside the same
  try block as the value read.

  My own multi-round audit covered six attack vectors but missed this
  one — only `get` trap throws were considered. The reviewer caught
  it on first pass.

- **Note 3 (test parity)**: the PTY post-promotion handoff test
  asserted `dispose` was called but never re-invoked the data
  callback to verify the foreground `onOutputEvent` actually stops
  firing — the child_process equivalent has that assertion. Mirrored
  it: emit data AFTER abort by re-invoking the captured `dataCallback`
  reference, and assert `onOutputEventMock.mock.calls.length` does NOT
  increase past the moment of promote. Exercises the production
  `listenersDetached` guard inside the chain callback, which the
  bare dispose-was-called check didn't.

Note 1 from the same review (the `aborted: true + promoted: true`
shape forcing PR-2 callers to check `promoted` before `aborted`) is
deliberately NOT addressed here — it's a contract simplification that
affects PR-2's branching, so it belongs in PR-2 along with the
caller-side decision on whether to flip `aborted` for promoted
results. Added a TODO upstream in QwenLM#3831 (PR-2 design) to track.

70 / 70 tests pass (69 baseline + 1 new helper boundary for the
throwing-getOwnPropertyDescriptor case). tsc + ESLint clean.

* test(core): fix tautological PTY post-promote assertion (audit follow-up)

The PTY post-promotion handoff test added in the previous commit
copied the child_process equivalent's pattern verbatim — sync
\`expect(count).toBe(countAtPromote)\` immediately after dataCallback
returns. That works for child_process because its \`handleOutput\` is
fully synchronous (sniff → decoder → emit, all on the same call
stack), so the count change happens BEFORE the assertion.

PTY's \`handleOutput\` is async — \`processingChain.then(...)\` queues a
microtask that does the sniff + write + render-then-emit work. The
sync assertion captures both \`countAtPromote\` and the post-emit count
BEFORE the chain microtask ever runs, so both reads return whatever
happened before the assert (typically 0). The test would
tautologically pass even if the production \`listenersDetached\` guard
were removed — i.e., it didn't actually verify the guard.

Restructured to:
1. Drive the PTY through \`simulateExecution\` so \`await handle.result\`
   forces all queued microtasks (including pre-promote chain items
   AND the abort handler's drain) to settle.
2. Capture \`eventCountAfterSettle\` once everything has stabilized.
3. Re-invoke the captured \`dataCallback\` with post-promote data,
   await two more macrotask boundaries to let the new chain item
   fully run.
4. Assert the count hasn't moved.

If the production \`listenersDetached\` guard is removed, the
post-promote chain item emits, count increases past
\`eventCountAfterSettle\`, and this assertion fails. So the test
actually exercises the guard now.

Found in self-audit while reviewing my own follow-up commit. Caught
because audit was paranoid about *whether the test verifies what it
claims to verify*, not just whether it passes.

70 / 70 tests pass; tsc + ESLint clean.

* test(core): pin eventCountAfterSettle === 0 in PTY post-promote test

The previous fix asserted post-promote count equals the
\`eventCountAfterSettle\` baseline, but didn't pin the baseline
itself. With the production \`listenersDetached\` guard intact, both
halves (pre-promote chain and post-promote chain) suppress emit, so
\`eventCountAfterSettle === 0 === post-count\` and the relative
comparison is vacuously true.

If a future refactor changed the production guard semantics so the
pre-promote chain item DID emit (count becomes 1+ after settle), the
relative-comparison test would still pass as long as post-promote
also emitted the same number — that's a regression the test should
catch. Adding \`expect(eventCountAfterSettle).toBe(0)\` makes the
contract explicit: once \`listenersDetached\` is set during the abort
handler's sync part, BOTH the in-flight chain item (pre-promote) and
the future chain item (post-promote) skip emit.

Found in another self-audit pass — even after fixing the tautological
assertion, the test could still mask certain future regressions.
70 / 70 tests pass; tsc + ESLint clean.

* test(core): drop dataCallbackHolder pattern, read mock.calls directly

The dataCallbackHolder pattern (capturing the onData callback inside
simulateExecution then invoking it after via a closure-shared object)
was unnecessary indirection — \`mockPtyProcess.onData.mock.calls[0][0]\`
reads the same callback reference whether you read it inside or outside
the simulation closure. Vi's mock.calls array is per-mock-instance and
beforeEach re-creates mockPtyProcess + .onData freshly, so there's no
stale-reference risk in the simpler form.

No behavior change in what's tested. 70 / 70 pass.

* chore(core): drop in-source @-mention attribution + dead Proxy.get handler

Two cosmetic cleanups found in another self-audit pass:

- **Helper comment**: removed the parenthetical attribution ("Caught
  by @tanzhenxin in the PR-1 review; my own audit only covered \`get\`
  trap throws."). The technical content of the comment — explaining
  why both the descriptor probe and the value read live inside the
  try — stands on its own. The reviewer credit lives in commit
  history / PR description, where it belongs; an in-source @-mention
  ages poorly (handles change, the relevant person may move on) and
  doesn't help future readers reason about the code.

- **Test Proxy**: \`throwingDescriptorProxy\` declared a \`get()\` handler
  that always returned \`undefined\`. The descriptor probe throws
  before the helper ever reaches the value read, so the \`get\`
  handler is unreachable — dropped it and added a one-line comment
  explaining why no \`get\` handler is needed for this test. Mirror
  test (\`throwingReason\` with throwing accessor + \`proxyReason\` with
  throwing \`get\`) keeps the symmetric "throwing-`get`" coverage.

70 / 70 tests pass; tsc + ESLint clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type/feature-request New feature or enhancement request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants