pikiloom 0.4.26 → 0.4.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/dashboard/dist/assets/{AgentTab-BNJ73P0m.js → AgentTab-DtjuI8NU.js} +1 -1
  2. package/dashboard/dist/assets/ConnectionModal-BIx6a5o1.js +1 -0
  3. package/dashboard/dist/assets/{DirBrowser-D-GT-G-w.js → DirBrowser-BINh_7NB.js} +1 -1
  4. package/dashboard/dist/assets/{ExtensionsTab-BrJMulJR.js → ExtensionsTab-CHpd147b.js} +1 -1
  5. package/dashboard/dist/assets/{IMAccessTab-auPnx468.js → IMAccessTab-C4DbGxbG.js} +1 -1
  6. package/dashboard/dist/assets/{Modal-lYfO9m-I.js → Modal-D_PUi-5O.js} +1 -1
  7. package/dashboard/dist/assets/{Modals-PRdWOIY9.js → Modals-BahDEMqf.js} +1 -1
  8. package/dashboard/dist/assets/{Select-BhbU-rll.js → Select-Cp5VXb_2.js} +1 -1
  9. package/dashboard/dist/assets/SessionPanel-DZVNXptx.js +1 -0
  10. package/dashboard/dist/assets/SystemTab-CLznS31f.js +1 -0
  11. package/dashboard/dist/assets/index-CLGovkkw.js +3 -0
  12. package/dashboard/dist/assets/index-CXIN3nTr.css +1 -0
  13. package/dashboard/dist/assets/index-DcYOI6rL.js +23 -0
  14. package/dashboard/dist/assets/{shared-C5uXvhZJ.js → shared-C8DCTJ44.js} +1 -1
  15. package/dashboard/dist/index.html +2 -2
  16. package/dist/agent/acp-client.js +0 -59
  17. package/dist/agent/artifacts.js +1 -38
  18. package/dist/agent/auto-update.js +2 -78
  19. package/dist/agent/await-resume.js +0 -20
  20. package/dist/agent/cli/auth.js +3 -51
  21. package/dist/agent/cli/catalog.js +0 -3
  22. package/dist/agent/cli/detector.js +1 -19
  23. package/dist/agent/cli/index.js +0 -3
  24. package/dist/agent/cli/registry.js +0 -25
  25. package/dist/agent/driver.js +0 -14
  26. package/dist/agent/drivers/claude-tui.js +2 -769
  27. package/dist/agent/drivers/claude.js +12 -631
  28. package/dist/agent/drivers/codex.js +11 -162
  29. package/dist/agent/drivers/gemini.js +6 -114
  30. package/dist/agent/drivers/hermes.js +0 -128
  31. package/dist/agent/goal.js +0 -45
  32. package/dist/agent/handover.js +1 -46
  33. package/dist/agent/images.js +7 -133
  34. package/dist/agent/index.js +1 -29
  35. package/dist/agent/mcp/bridge.js +1 -197
  36. package/dist/agent/mcp/extensions.js +5 -84
  37. package/dist/agent/mcp/oauth.js +2 -69
  38. package/dist/agent/mcp/registry.js +2 -33
  39. package/dist/agent/mcp/session-server.js +2 -45
  40. package/dist/agent/mcp/tools/ask-user.js +0 -6
  41. package/dist/agent/mcp/tools/await-resume.js +0 -15
  42. package/dist/agent/mcp/tools/goal.js +0 -25
  43. package/dist/agent/mcp/tools/types.js +0 -5
  44. package/dist/agent/mcp/tools/workspace.js +0 -23
  45. package/dist/agent/npm.js +0 -14
  46. package/dist/agent/session.js +86 -196
  47. package/dist/agent/skill-installer.js +3 -70
  48. package/dist/agent/skills.js +0 -43
  49. package/dist/agent/stream.js +5 -148
  50. package/dist/agent/types.js +0 -12
  51. package/dist/agent/utils.js +2 -120
  52. package/dist/bot/bot.js +3 -468
  53. package/dist/bot/command-ui.js +0 -45
  54. package/dist/bot/commands.js +0 -51
  55. package/dist/bot/headless-bot.js +0 -21
  56. package/dist/bot/host.js +1 -5
  57. package/dist/bot/human-loop.js +0 -8
  58. package/dist/bot/menu.js +0 -3
  59. package/dist/bot/orchestration.js +0 -3
  60. package/dist/bot/render-shared.js +0 -63
  61. package/dist/bot/session-hub.js +14 -92
  62. package/dist/bot/session-status.js +1 -5
  63. package/dist/bot/streaming.js +0 -53
  64. package/dist/browser-profile.js +0 -51
  65. package/dist/browser-supervisor.js +0 -75
  66. package/dist/catalog/cli-tools.js +0 -53
  67. package/dist/catalog/index.js +0 -18
  68. package/dist/catalog/local-models.js +0 -18
  69. package/dist/catalog/mcp-servers.js +0 -35
  70. package/dist/catalog/skill-repos.js +0 -32
  71. package/dist/channels/base.js +0 -19
  72. package/dist/channels/dingtalk/bot.js +0 -3
  73. package/dist/channels/dingtalk/channel.js +0 -33
  74. package/dist/channels/discord/bot.js +0 -7
  75. package/dist/channels/discord/channel.js +0 -22
  76. package/dist/channels/feishu/bot.js +0 -71
  77. package/dist/channels/feishu/channel.js +0 -78
  78. package/dist/channels/feishu/markdown.js +0 -5
  79. package/dist/channels/feishu/render.js +0 -45
  80. package/dist/channels/health.js +0 -46
  81. package/dist/channels/slack/bot.js +0 -7
  82. package/dist/channels/slack/channel.js +1 -27
  83. package/dist/channels/states.js +0 -3
  84. package/dist/channels/telegram/bot.js +0 -60
  85. package/dist/channels/telegram/channel.js +2 -99
  86. package/dist/channels/telegram/directory.js +0 -3
  87. package/dist/channels/telegram/live-preview.js +0 -8
  88. package/dist/channels/telegram/render.js +0 -30
  89. package/dist/channels/wecom/bot.js +0 -3
  90. package/dist/channels/wecom/channel.js +0 -58
  91. package/dist/channels/weixin/api.js +0 -9
  92. package/dist/channels/weixin/bot.js +0 -88
  93. package/dist/channels/weixin/channel.js +0 -6
  94. package/dist/cli/autostart.js +1 -44
  95. package/dist/cli/channel-supervisor.js +1 -59
  96. package/dist/cli/channels.js +0 -6
  97. package/dist/cli/main.js +1 -145
  98. package/dist/cli/onboarding.js +0 -6
  99. package/dist/cli/run.js +0 -11
  100. package/dist/cli/setup-wizard.js +0 -3
  101. package/dist/core/config/runtime-config.js +0 -38
  102. package/dist/core/config/user-config.js +0 -63
  103. package/dist/core/config/validation.js +0 -9
  104. package/dist/core/constants.js +0 -268
  105. package/dist/core/git.js +0 -31
  106. package/dist/core/legacy-compat.js +1 -28
  107. package/dist/core/logging.js +0 -3
  108. package/dist/core/platform.js +0 -28
  109. package/dist/core/process-control.js +0 -8
  110. package/dist/core/secrets/index.js +0 -17
  111. package/dist/core/secrets/inline-seal.js +0 -7
  112. package/dist/core/secrets/ref.js +0 -19
  113. package/dist/core/secrets/resolver.js +0 -8
  114. package/dist/core/secrets/store.js +1 -12
  115. package/dist/core/utils.js +1 -37
  116. package/dist/core/version.js +0 -3
  117. package/dist/dashboard/platform.js +1 -35
  118. package/dist/dashboard/routes/agents.js +2 -71
  119. package/dist/dashboard/routes/cli.js +0 -27
  120. package/dist/dashboard/routes/config.js +0 -54
  121. package/dist/dashboard/routes/extensions.js +0 -132
  122. package/dist/dashboard/routes/local-models.js +0 -68
  123. package/dist/dashboard/routes/models.js +0 -46
  124. package/dist/dashboard/routes/sessions.js +0 -100
  125. package/dist/dashboard/runtime.js +1 -58
  126. package/dist/dashboard/server.js +0 -49
  127. package/dist/dashboard/session-control.js +2 -69
  128. package/dist/model/catalog.js +1 -17
  129. package/dist/model/index.js +0 -15
  130. package/dist/model/injector.js +1 -181
  131. package/dist/model/provider-models.js +1 -48
  132. package/dist/model/responses-bridge.js +3 -45
  133. package/dist/model/store.js +0 -17
  134. package/dist/model/types.js +0 -12
  135. package/dist/model/validation.js +1 -18
  136. package/dist/pikichannel/adapter-pikiloom.js +0 -31
  137. package/dist/pikichannel/code.js +0 -12
  138. package/dist/pikichannel/codec.js +0 -14
  139. package/dist/pikichannel/host.js +4 -33
  140. package/dist/pikichannel/protocol.js +0 -52
  141. package/dist/pikichannel/rendezvous-broker.js +5 -26
  142. package/dist/pikichannel/rendezvous-host.js +3 -16
  143. package/dist/pikichannel/server.js +6 -55
  144. package/dist/pikichannel/transport.js +0 -25
  145. package/dist/pikichannel/transports/webrtc-host.js +0 -18
  146. package/dist/pikichannel/transports/webrtc-shared.js +4 -34
  147. package/dist/pikichannel/transports/websocket-host.js +1 -14
  148. package/dist/pikichannel/turn.js +2 -69
  149. package/dist/pikichannel/web/sdk.js +14 -71
  150. package/package.json +1 -1
  151. package/dashboard/dist/assets/ConnectionModal-Dt05H2f9.js +0 -1
  152. package/dashboard/dist/assets/SessionPanel-_0dLWvEb.js +0 -1
  153. package/dashboard/dist/assets/SystemTab-CN_QrRKt.js +0 -1
  154. package/dashboard/dist/assets/index-CP3LYJR2.js +0 -23
  155. package/dashboard/dist/assets/index-C_neLnaO.js +0 -3
  156. package/dashboard/dist/assets/index-CyCspgtc.css +0 -1
@@ -1,41 +1,3 @@
1
- /**
2
- * Claude TUI driver — runs the interactive `claude` CLI under a PTY so usage
3
- * counts against the user's Pro/Max subscription instead of the API-priced
4
- * Agent SDK credit pool. Functionally near-equivalent to the headless -p
5
- * stream: we tail the JSONL transcript that Claude Code writes incrementally
6
- * to `~/.claude/projects/<encoded>/<id>.jsonl` and surface tool/text/usage
7
- * events through the same `claudeParse` parser used by the print-mode driver.
8
- *
9
- * Default driver for Claude turns. Set `PIKILOOM_CLAUDE_PRINT=1` (or the
10
- * legacy `PIKILOOM_CLAUDE_TUI=0`) to force the print-mode driver instead.
11
- * When any startup prerequisite fails (node-pty missing, prebuilt helper
12
- * unusable, PTY allocation refused) this function THROWS — the dispatcher in
13
- * `claude.ts` catches that and falls back to print mode so pikiloom stays
14
- * working out of the box.
15
- *
16
- * How it works:
17
- * 1. Reserve a session id upfront (random UUID, or the resume target).
18
- * 2. Drop a temp settings file with `SessionStart` / `Stop` /
19
- * `UserPromptSubmit` hooks pointing at a tiny helper script — the script
20
- * mutates a shared state JSON file so the parent process learns the real
21
- * session id / transcript path / turn-end signal.
22
- * 3. Spawn `claude` under a real PTY (via `node-pty`) with the prompt as
23
- * positional argv. Claude TUI auto-submits the prompt on startup.
24
- * 4. Poll the transcript JSONL incrementally; feed each line through
25
- * `claudeParse`. JSONL records lack `stream_event` / `result` events, so
26
- * we patch up the missing `s.text` / `s.thinking` accumulation and
27
- * `assistant.message.usage` extraction in the loop.
28
- * 5. When the `Stop` hook fires (Claude has finished the assistant turn),
29
- * SIGTERM the PTY process. The JSONL is fully flushed by then.
30
- * Exception — background sub-agents: Claude fires `Stop` whenever the
31
- * main loop finishes a response segment, *including* the segment that
32
- * launched `run_in_background` agents. Those agents live inside the
33
- * claude process, so killing on that first Stop would destroy them
34
- * mid-flight. The driver therefore refuses to terminate while launched
35
- * background agents haven't reported their `<task-notification>`, and
36
- * only accepts a Stop that is "fresh" (fired after the last
37
- * notification) — see `decideClaudeTuiStop`.
38
- */
39
1
  import fs from 'node:fs';
40
2
  import path from 'node:path';
41
3
  import { randomUUID } from 'node:crypto';
@@ -46,31 +8,8 @@ import { createRetainedLogSink } from '../../core/logging.js';
46
8
  import { stripAnsiEscapes } from '../../core/utils.js';
47
9
  import { AGENT_STREAM_HARD_KILL_GRACE_MS, CLAUDE_TUI_STALL_QUIET_MS, CLAUDE_TUI_STALL_PENDING_TOOL_MS, CLAUDE_TUI_STALL_PTY_DEAD_MS, CLAUDE_TUI_STOP_HOLD_QUIET_TTL_MS, CLAUDE_TUI_MODEL_ERROR_SETTLE_MS, } from '../../core/constants.js';
48
10
  import { claudeParse, createClaudeStreamState, claudeContextWindowFromModel, claudeEffectiveContextWindow, registerClaudeBackgroundAgentLaunch, pendingClaudeBackgroundAgentCount, registerClaudeBackgroundBashLaunch, pendingClaudeBackgroundBashCount, extractClaudeBackgroundTaskId, extractClaudeWorkflowRunId, claudeEffortAndWorkflowArgs, scrubClaudeSessionContextEnv, } from './claude.js';
49
- // ---------------------------------------------------------------------------
50
- // Stall diagnostics (capture-only)
51
- // ---------------------------------------------------------------------------
52
- //
53
- // We instrument the mid-turn freeze before tuning the watchdog: the next pause
54
- // should be classified from data, not guesswork. Records land append-only in
55
- // ~/.pikiloom/diagnostics/claude-tui-stall.jsonl across three moments:
56
- // - 'quiet' — heartbeat while a turn has gone silent past the threshold
57
- // (captures the lead-up to a stall, throttled)
58
- // - 'stall' — the watchdog declared the turn dead and SIGTERMed it
59
- // - 'resolved' — a turn that went quiet ended (completed / killed / aborted),
60
- // so benign long-thinking is separable from true freezes
61
- //
62
- // The decisive field is `ptyQuietMs` vs `quietMs`: a large quietMs (JSONL/hook
63
- // signals silent) paired with a small ptyQuietMs (PTY still painting frames)
64
- // means the model stream froze behind a live spinner — which defeats the
65
- // PTY-dead fast path and forces the slow 10-min quiet threshold. Confirming
66
- // that pattern (or refuting it) is the whole point of this pass.
67
- /** Begin recording heartbeats once no live signal has advanced for this long. */
68
11
  const STALL_DIAG_QUIET_THRESHOLD_MS = 45_000;
69
- /** Throttle heartbeats while a turn stays quiet, so a long freeze is sampled
70
- * (not logged every 200ms poll tick). */
71
12
  const STALL_DIAG_HEARTBEAT_INTERVAL_MS = 30_000;
72
- // undefined = not yet initialised; null = init failed (give up, never retry);
73
- // function = ready. Shared across all turns so every session appends to one file.
74
13
  let stallDiagSink;
75
14
  function writeStallDiag(record) {
76
15
  if (stallDiagSink === null)
@@ -91,9 +30,6 @@ function writeStallDiag(record) {
91
30
  stallDiagSink = null;
92
31
  }
93
32
  }
94
- /** Cheap capture-only label for the last transcript event before a quiet
95
- * stretch — the freeze signature is "next assistant never starts after a
96
- * tool_result", so the kind of the last event matters. */
97
33
  export function classifyClaudeJsonlEvent(ev) {
98
34
  const type = typeof ev?.type === 'string' ? ev.type : 'unknown';
99
35
  const content = ev?.message?.content;
@@ -110,11 +46,6 @@ export function classifyClaudeJsonlEvent(ev) {
110
46
  return type;
111
47
  }
112
48
  async function loadPty() {
113
- // Dynamic import keeps node-pty an optional dependency — if it's not
114
- // installed the print-mode dispatcher in claude.ts will catch the throw
115
- // and fall back to `-p`. The variable-specifier indirection is required so
116
- // TypeScript does not try to resolve `node-pty` at compile time when the
117
- // dep is absent.
118
49
  const specifier = 'node-pty';
119
50
  const mod = await import(/* @vite-ignore */ specifier);
120
51
  const api = mod?.default ?? mod;
@@ -123,14 +54,6 @@ async function loadPty() {
123
54
  await preflightSpawnHelper();
124
55
  return api;
125
56
  }
126
- /**
127
- * On macOS / Linux, node-pty's prebuilt `spawn-helper` ships without the
128
- * executable bit set on some npm installs (the npm tarball drops mode bits
129
- * when extracted under certain umask settings). Without the bit, every
130
- * `pty.spawn` returns the cryptic `posix_spawnp failed.` because the helper
131
- * itself can't run. Restore the bit eagerly the first time the driver loads
132
- * so users don't have to debug this on their own.
133
- */
134
57
  let spawnHelperPreflightDone = false;
135
58
  async function preflightSpawnHelper() {
136
59
  if (spawnHelperPreflightDone || process.platform === 'win32') {
@@ -139,8 +62,6 @@ async function preflightSpawnHelper() {
139
62
  }
140
63
  spawnHelperPreflightDone = true;
141
64
  try {
142
- // Resolve relative to the loaded node-pty package. require.resolve isn't
143
- // available in ESM; walk node_modules from this file's URL instead.
144
65
  const ptyRoot = await locatePtyPackageRoot();
145
66
  if (!ptyRoot)
146
67
  return;
@@ -160,9 +81,6 @@ async function preflightSpawnHelper() {
160
81
  }
161
82
  }
162
83
  async function locatePtyPackageRoot() {
163
- // Walk up from this file looking for a node_modules/node-pty/package.json.
164
- // This is the dist-time layout (compiled to dist/) AND the tsx runtime
165
- // layout (running from src/) — both have node_modules at the project root.
166
84
  let dir = path.dirname(new URL(import.meta.url).pathname);
167
85
  for (let i = 0; i < 8; i++) {
168
86
  const candidate = path.join(dir, 'node_modules', 'node-pty');
@@ -175,11 +93,6 @@ async function locatePtyPackageRoot() {
175
93
  }
176
94
  return null;
177
95
  }
178
- // ---------------------------------------------------------------------------
179
- // Hook helper script — written to a temp dir per turn. Receives Claude Code
180
- // hook JSON payloads on stdin and mutates a shared state file so the parent
181
- // can react to lifecycle events without needing socket / IPC plumbing.
182
- // ---------------------------------------------------------------------------
183
96
  const HOOK_SCRIPT = `#!/usr/bin/env node
184
97
  "use strict";
185
98
  const fs = require("node:fs");
@@ -239,12 +152,6 @@ function readHookState(statePath) {
239
152
  return {};
240
153
  }
241
154
  }
242
- /**
243
- * Incremental JSONL tail. Reads from `fromOffset` to the file's current size,
244
- * splits on newlines, and stops one line short if the last segment doesn't end
245
- * with `\n` (so a partially-written final line gets re-read next tick rather
246
- * than corrupting JSON.parse).
247
- */
248
155
  function readJsonlIncrement(filePath, fromOffset) {
249
156
  try {
250
157
  const stat = fs.statSync(filePath);
@@ -261,11 +168,9 @@ function readJsonlIncrement(filePath, fromOffset) {
261
168
  const endsWithNewline = chunk[chunk.length - 1] === '\n';
262
169
  const segments = chunk.split('\n');
263
170
  if (endsWithNewline) {
264
- // Last segment after split is empty — drop it.
265
171
  segments.pop();
266
172
  return { offset: stat.size, lines: segments };
267
173
  }
268
- // Partial last line — keep its bytes unread for the next tick.
269
174
  const lastLine = segments.pop() || '';
270
175
  const consumed = stat.size - Buffer.byteLength(lastLine, 'utf8');
271
176
  return { offset: consumed, lines: segments };
@@ -274,10 +179,6 @@ function readJsonlIncrement(filePath, fromOffset) {
274
179
  return { offset: fromOffset, lines: [] };
275
180
  }
276
181
  }
277
- // 20 chars / 20 ms = 1000 chars/s. Haiku generates ~150 tok/s (~600 chars/s),
278
- // Sonnet/Opus are slower. Running ahead of the model keeps the buffer drained
279
- // during continuous generation. CJK characters render at ~2x ASCII visual
280
- // width but this rate still feels natural in both scripts.
281
182
  const TUI_STREAM_CHUNK_CHARS = 20;
282
183
  const TUI_STREAM_CHUNK_INTERVAL_MS = 20;
283
184
  function makeTuiStreamBuffer() {
@@ -323,25 +224,6 @@ export function detectClaudeTuiTerminalLimitNotice(msgOrText) {
323
224
  return null;
324
225
  return limitNoticeFromText(extractTextBlocks(msgOrText.content));
325
226
  }
326
- /**
327
- * Evidence-based arbitration for a detected limit notice. The banner text is
328
- * deliberately matched broadly (wording shifts across CLI versions and some
329
- * notices are informational — "You're now using usage credits · Your session
330
- * limit resets 3pm" means the turn CONTINUES on extra-usage credits), so a
331
- * match alone must never fail the turn. What decides the outcome is whether
332
- * the turn produced anything substantive after the banner:
333
- *
334
- * - 'info' — assistant text exists, or a substantive signal (non-synthetic
335
- * assistant JSONL, hook tool event, sub-agent sidecar) postdates
336
- * the notice. The turn is alive; the notice is informational.
337
- * - 'fatal' — nothing substantive after the banner. The limit genuinely ate
338
- * the turn; surface the banner text as a rate_limit failure.
339
- * - 'none' — no notice was seen.
340
- *
341
- * Worst case of the broad matching is therefore an activity line, not a
342
- * killed turn (the bug this replaced: the credits banner used to SIGTERM the
343
- * process mid-answer).
344
- */
345
227
  export function resolveClaudeTuiLimitOutcome(input) {
346
228
  if (!input.noticeText)
347
229
  return 'none';
@@ -349,63 +231,14 @@ export function resolveClaudeTuiLimitOutcome(input) {
349
231
  return 'info';
350
232
  return 'fatal';
351
233
  }
352
- /**
353
- * Detect Claude Code's startup "Bypass Permissions mode" confirmation dialog in
354
- * a slice of (ANSI-stripped) PTY screen output. When pikiloom spawns the TUI
355
- * with `--permission-mode bypassPermissions` (the default) on a machine that
356
- * has not yet accepted bypass mode, Claude paints a blocking prompt:
357
- *
358
- * WARNING: Claude Code running in Bypass Permissions mode
359
- * ...
360
- * ❯ 1. No, exit
361
- * 2. Yes, I accept
362
- *
363
- * The default highlight sits on "No, exit", so the driver's blind prompt-submit
364
- * Enter nudge would pick *exit* — the message never gets processed and the turn
365
- * hangs on a pre-prompt. Seeding `bypassPermissionsModeAccepted` in config is
366
- * not a reliable fix: it is version-fragile (observed no-op on 2.1.169) and
367
- * gated by org policy (`isBypassPermissionsModeAvailable`). So we detect the
368
- * dialog on the wire and auto-select "Yes, I accept". Require all three
369
- * distinctive fragments so ordinary text mentioning "bypass" can't trigger it.
370
- */
371
234
  export function detectClaudeBypassPrompt(screen) {
372
235
  if (typeof screen !== 'string' || !screen)
373
236
  return false;
374
- // Claude's TUI lays words out with cursor-move escapes (`\x1b[<col>G`) rather
375
- // than literal spaces, so once ANSI is stripped the on-screen text runs
376
- // together — the real dialog reads "BypassPermissionsmode" / "Yes,Iaccept" /
377
- // "No,exit", not the spaced form. Collapse all whitespace before matching so
378
- // the detector fires on the live PTY screen *and* on space-preserving
379
- // renderings. (Verified against claude 2.1.168's actual bypass screen.)
380
237
  const t = stripAnsiEscapes(screen).replace(/\s+/g, '').toLowerCase();
381
238
  return t.includes('bypasspermissionsmode')
382
239
  && t.includes('yes,iaccept')
383
240
  && t.includes('no,exit');
384
241
  }
385
- /**
386
- * Detect Claude Code's *mid-turn* per-command permission confirmation in a slice
387
- * of (ANSI-stripped) PTY screen output. Even under `--permission-mode
388
- * bypassPermissions`, an explicit `ask` rule in settings (e.g. `Bash(git tag:*)`,
389
- * `git commit`, `git push`) is still honoured, so the TUI paints:
390
- *
391
- * Permission rule Bash(git tag:*) requires confirmation for this command.
392
- * Do you want to proceed?
393
- * ❯ 1. Yes
394
- * 2. Yes, and don't ask again for: …
395
- * 3. No
396
- * Esc to cancel · Tab to amend · ctrl+e to explain
397
- *
398
- * Nothing answers it (detectClaudeBypassPrompt only handles the *startup* bypass
399
- * dialog), so the turn hangs until the stall watchdog SIGTERMs it and mislabels
400
- * the block as a "CLI freeze". We detect it on the wire and select "1. Yes" —
401
- * restoring the bypass intent turn-by-turn without mutating the user's settings
402
- * (option 2 "don't ask again" would).
403
- *
404
- * Thin wrapper over {@link classifyClaudeScreen} (state === 'confirm-prompt') so the in-flight
405
- * auto-answer and the stall watchdog share ONE verdict. The earlier standalone implementation
406
- * required the literal footer "esctocancel", which truncates at the 200-col screen edge
407
- * ("sctocancel") and silently missed real prompts — the structural classifier does not.
408
- */
409
242
  export function detectClaudeProceedPrompt(screen) {
410
243
  return classifyClaudeScreen(screen).state === 'confirm-prompt';
411
244
  }
@@ -433,44 +266,26 @@ export function classifyClaudeScreen(screen) {
433
266
  return { state: 'unknown', affirmativeKey: null, sample: '' };
434
267
  const stripped = stripAnsiEscapes(screen);
435
268
  const sample = stripped.replace(/\s+/g, ' ').trim().slice(-400);
436
- // Claude positions words with cursor moves, so the live screen is spaceless; match against the
437
- // despaced form (see detectClaudeBypassPrompt).
438
269
  const ds = stripped.replace(/\s+/g, '').toLowerCase();
439
- // 1. Startup bypass-permissions dialog (option 1 is "No, exit" — affirmative is option 2).
440
- // Require all three distinctive fragments so ordinary "bypass" prose can't trigger it
441
- // (mirrors detectClaudeBypassPrompt). Checked first: it overlaps the numbered-select shape.
442
270
  if (ds.includes('bypasspermissionsmode') && ds.includes('yes,iaccept') && ds.includes('no,exit')) {
443
271
  return { state: 'bypass-startup', affirmativeKey: '2', sample };
444
272
  }
445
- // 2. Selected-model-unavailable banner. Distinctive phrasing; reuse the shared detector so the
446
- // -p and TUI paths stay in lockstep.
447
273
  if (detectClaudeModelError(ds))
448
274
  return { state: 'model-error', affirmativeKey: null, sample };
449
275
  const asksProceed = ds.includes('doyouwanttoproceed') || ds.includes('wouldyouliketoproceed');
450
- const hasCursorSelect = ds.includes('❯') && ds.includes('1.'); // a real Ink select, not prose
451
- // 3. Plan-approval dialog (ExitPlanMode / Ultraplan). Distinctive option text. NOT auto-answered:
452
- // affirmativeKey stays null so the chokepoint terminates cleanly and asks the user to re-send
453
- // rather than pressing "Yes, and bypass permissions" (standing session bypass).
276
+ const hasCursorSelect = ds.includes('❯') && ds.includes('1.');
454
277
  if ((asksProceed || ds.includes('readytoexecute'))
455
278
  && (ds.includes('manuallyapproveedits') || ds.includes('yes,andbypasspermissions'))) {
456
279
  return { state: 'plan-approval', affirmativeKey: null, sample };
457
280
  }
458
- // 4. Mid-turn confirm/select. A proceed/confirm question + a cursor'd numbered select. Bypass and
459
- // plan dialogs are already handled above, so by here option 1 is the "Yes" affirmative.
460
281
  if ((asksProceed || ds.includes('requiresconfirmation')) && hasCursorSelect) {
461
282
  return { state: 'confirm-prompt', affirmativeKey: '1', sample };
462
283
  }
463
- // 4b. Standalone interactive prompts distinctive enough to need no numbered select: the
464
- // trust-a-new-folder dialog, and explicit (y/n) confirmations.
465
284
  if (ds.includes('trustthisfolder'))
466
285
  return { state: 'confirm-prompt', affirmativeKey: '1', sample };
467
286
  if ((asksProceed || ds.includes('doyouwant')) && ds.includes('(y/n)')) {
468
287
  return { state: 'confirm-prompt', affirmativeKey: 'y', sample };
469
288
  }
470
- // 5. Idle REPL — claude finished and is back at the input line. Key on the PERSISTENT mode-line
471
- // (real idle screens carry typed-ahead like "/install", so an empty `❯` is unreliable) and
472
- // require the absence of the active "esc to interrupt" hint so a frozen spinner frame that
473
- // happens to still show the mode-line is NOT mistaken for idle.
474
289
  if (ds.includes('bypasspermissionson')
475
290
  && (ds.includes('shift+tabtocycle') || ds.includes('foragents') || ds.includes('tomanage'))
476
291
  && !ds.includes('esctointerrupt')) {
@@ -478,31 +293,12 @@ export function classifyClaudeScreen(screen) {
478
293
  }
479
294
  return { state: 'unknown', affirmativeKey: null, sample };
480
295
  }
481
- /**
482
- * Backward-compatible capture-only wrapper retained for the stall-diagnostics heartbeat. A "prompt"
483
- * is any blocking dialog state (confirm / plan / startup-bypass). Derives from
484
- * {@link classifyClaudeScreen} so there is one classifier, not two.
485
- */
486
296
  export function classifyStallScreen(screen) {
487
297
  const info = classifyClaudeScreen(screen);
488
298
  const looksLikePrompt = info.state === 'confirm-prompt'
489
299
  || info.state === 'plan-approval' || info.state === 'bypass-startup';
490
300
  return { looksLikePrompt, sample: info.sample };
491
301
  }
492
- /**
493
- * Extract text / thinking blocks from an assistant JSONL event and route them:
494
- * text → the chunked stream buffer (slow drain), thinking → `s.thinking`
495
- * directly. Tool uses, stop reasons, sub-agents, etc. are still handled by
496
- * `claudeParse` once we've stripped the text/thinking blocks out of the event
497
- * (see `callClaudeParseForTui`) — otherwise `claudeParse`'s "fill if empty"
498
- * fallback would clobber the buffered streaming.
499
- */
500
- /**
501
- * Pull the server-assigned task id out of a PostToolUse hook's tool_response.
502
- * Claude Code's hook payload mirrors the JSONL tool_result shape — usually
503
- * `{ task: { id, subject }, ...}` for TaskCreate. Falls back to scanning the
504
- * textual response for "Task #N created" when the structured form is missing.
505
- */
506
302
  function readAssignedTaskIdFromHookResponse(toolResponse) {
507
303
  const structured = toolResponse?.task?.id;
508
304
  if (structured != null && String(structured).trim())
@@ -519,26 +315,11 @@ function readAssignedTaskIdFromHookResponse(toolResponse) {
519
315
  }
520
316
  return null;
521
317
  }
522
- /**
523
- * Apply a single PreToolUse / PostToolUse hook event to the parser state.
524
- * Mirrors what `claudeParse` would do for the matching JSONL tool_use /
525
- * tool_result, but fires the instant Claude calls the tool — so the IM
526
- * placeholder card actually updates during the turn instead of staying empty
527
- * until Stop. Dedup with the eventual JSONL flush is via `tool_use_id`:
528
- * claudeParse skips tools already in `s.seenClaudeToolIds`, and the new
529
- * `s.seenClaudeToolResultIds` guards tool_result re-pushes.
530
- */
531
318
  export function applyHookToolEvent(ev, s) {
532
319
  const toolUseId = String(ev?.tool_use_id || '').trim();
533
320
  const toolName = String(ev?.tool_name || '').trim();
534
321
  if (!toolName || !toolUseId)
535
322
  return false;
536
- // Sub-agent tool calls fire the parent's Pre/PostToolUse hooks too (one
537
- // hook pipeline per CLI process). Claude Code tags those payloads with
538
- // `agent_id`; route them to the matching sub-agent's tool list instead of
539
- // appending to the parent's recentActivity. Without this every Task spawn
540
- // floods the parent's 执行 card with the children's tool stream while the
541
- // sub-agent cards sit empty until the sidecar JSONL flushes at Stop.
542
323
  const subAgentId = typeof ev?.agent_id === 'string' && ev.agent_id ? ev.agent_id : '';
543
324
  if (subAgentId) {
544
325
  if (ev.event === 'PreToolUse') {
@@ -592,12 +373,6 @@ export function applyHookToolEvent(ev, s) {
592
373
  return true;
593
374
  }
594
375
  if (toolName === 'Task' || toolName === 'Agent') {
595
- // Register the sub-agent so `meta.subAgents` lights up the new
596
- // Sub-agent preview block. Sub-agents are isolated from parent activity
597
- // by design (the dedicated section shows their own tool stream); pushing
598
- // into parent recentActivity would re-introduce the noise the isolation
599
- // is meant to prevent. Granular sub-agent tool calls land later via the
600
- // sidecar pump → `routeClaudeSubAgentEvent`.
601
376
  const input = ev.tool_input || {};
602
377
  const desc = typeof input.description === 'string' ? input.description.trim() : '';
603
378
  const kind = typeof input.subagent_type === 'string' ? input.subagent_type.trim() : '';
@@ -611,27 +386,15 @@ export function applyHookToolEvent(ev, s) {
611
386
  status: 'running',
612
387
  });
613
388
  }
614
- // Backgrounded launch — track it so the turn doesn't end (and the PTY
615
- // doesn't get SIGTERMed) until its <task-notification> arrives. The hook
616
- // fires live; the JSONL replay of the same tool_use dedupes via
617
- // seenClaudeToolIds, so this is the only registration point in TUI mode.
618
389
  if (input.run_in_background === true)
619
390
  registerClaudeBackgroundAgentLaunch(s, toolUseId);
620
391
  s.seenClaudeToolIds.add(toolUseId);
621
392
  s.claudeToolsById.set(toolUseId, { name: toolName, summary: desc || kind || 'Sub-agent' });
622
393
  return true;
623
394
  }
624
- // Background Bash — register like a backgrounded agent so the turn's Stop
625
- // holds the PTY open until its <task-notification> lands, instead of
626
- // SIGTERMing the still-running command (and its future report-back turn).
627
395
  if (toolName === 'Bash' && ev.tool_input?.run_in_background === true) {
628
396
  registerClaudeBackgroundBashLaunch(s, toolUseId);
629
397
  }
630
- // Workflow → always-backgrounded multi-agent orchestration. Same in-process
631
- // lifecycle as a run_in_background Task; register so the turn's Stop holds
632
- // the PTY instead of SIGTERMing the in-flight workflow. The hook fires live;
633
- // the JSONL replay of the same tool_use dedupes via seenClaudeToolIds, so
634
- // this is the only registration point in TUI mode.
635
398
  if (toolName === 'Workflow') {
636
399
  registerClaudeBackgroundAgentLaunch(s, toolUseId);
637
400
  }
@@ -673,13 +436,6 @@ export function applyHookToolEvent(ev, s) {
673
436
  return true;
674
437
  }
675
438
  if (toolName === 'Task' || toolName === 'Agent') {
676
- // Sub-agent finished — flip its status so it drops out of the live
677
- // Sub-agent preview block. The completion fact itself is implicit: the
678
- // block stops listing this entry.
679
- // Backgrounded launches are the exception: their PostToolUse fires
680
- // immediately with a launch ack while the agent keeps running. Leave the
681
- // status alone — applyClaudeTaskNotification flips it when the real
682
- // completion lands.
683
439
  const sub = s.subAgents.get(toolUseId);
684
440
  if (sub) {
685
441
  const isBgLaunchAck = !ev.tool_response?.is_error
@@ -701,16 +457,12 @@ export function applyHookToolEvent(ev, s) {
701
457
  s.activity = s.recentActivity.join('\n');
702
458
  }
703
459
  }
704
- // Background Bash launch ack → map task id → tool_use for notification
705
- // resolution (bash notifications usually omit <tool-use-id>).
706
460
  if (toolName === 'Bash' && s.bgBashToolUseIds?.has(toolUseId)
707
461
  && !s.bgAgentCompletedToolUseIds?.has(toolUseId)) {
708
462
  const taskId = extractClaudeBackgroundTaskId(ev.tool_response);
709
463
  if (taskId && !s.bgTaskIdToToolUse.has(taskId))
710
464
  s.bgTaskIdToToolUse.set(taskId, toolUseId);
711
465
  }
712
- // Workflow launch ack → map runId → tool_use for notification resolution
713
- // (the workflow's <task-notification> may carry only the task id).
714
466
  if (toolName === 'Workflow' && s.bgAgentLaunchedToolUseIds?.has(toolUseId)
715
467
  && !s.bgAgentCompletedToolUseIds?.has(toolUseId)) {
716
468
  const runId = extractClaudeWorkflowRunId(ev.tool_response);
@@ -722,11 +474,6 @@ export function applyHookToolEvent(ev, s) {
722
474
  }
723
475
  return false;
724
476
  }
725
- /**
726
- * Lite TodoWrite parser used by the hook path — avoids pulling parseTodoWriteAsPlan
727
- * from agent/utils into this file's already-large import surface. Identical
728
- * semantics for the legacy 1.x plan tool.
729
- */
730
477
  function parseTodoWriteAsPlanLite(input) {
731
478
  if (!input || typeof input !== 'object')
732
479
  return null;
@@ -750,11 +497,6 @@ function parseTodoWriteAsPlanLite(input) {
750
497
  return null;
751
498
  return { explanation: null, steps };
752
499
  }
753
- /**
754
- * Reimplementation of claude.ts's rebuildClaudePlanFromTasks (it's private to
755
- * that module). Kept tiny and dependency-free so the hook code path stays
756
- * independent of the JSONL parser's internals.
757
- */
758
500
  function rebuildClaudePlanFromTasksFromState(s) {
759
501
  if (!s.claudeTaskOrder?.length)
760
502
  return;
@@ -794,14 +536,6 @@ function applyAssistantStreaming(s, msg, buf) {
794
536
  s.thinking = s.thinking ? `${s.thinking}\n\n${appendThinking}` : appendThinking;
795
537
  }
796
538
  }
797
- /**
798
- * Hand a JSONL event to the shared `claudeParse`, but for `assistant` events
799
- * first strip out the text/thinking blocks. Reason: `claudeParse`'s assistant
800
- * branch contains a `if (tx && !s.text.trim()) s.text = tx` fallback — useful
801
- * for print mode where deltas may have missed, harmful here because it would
802
- * dump the entire response into `s.text` in one go, bypassing the simulated
803
- * stream we just routed into the buffer.
804
- */
805
539
  function callClaudeParseForTui(ev, s) {
806
540
  if (ev.type !== 'assistant' || !ev.message) {
807
541
  claudeParse(ev, s);
@@ -818,15 +552,6 @@ function callClaudeParseForTui(ev, s) {
818
552
  };
819
553
  claudeParse(filtered, s);
820
554
  }
821
- /**
822
- * Set `s.contextWindow` from a model id, the same way the `-p` parser does on
823
- * each `system` / `stream_event` / `result` event. TUI mode never sees those
824
- * events (JSONL is the source of truth and only carries `user`/`assistant`/
825
- * `attachment`/`summary`), so without this call `s.contextWindow` stays null
826
- * and `computeContext()` returns `contextPercent: null` → the dashboard's
827
- * `ContextDot` and percent chip both disappear. Guarded by `byokContextWindow`
828
- * so BYOK Profiles' externally-cached window wins (matches print-mode).
829
- */
830
555
  function applyModelContextWindow(s) {
831
556
  if (s.byokContextWindow)
832
557
  return;
@@ -835,18 +560,10 @@ function applyModelContextWindow(s) {
835
560
  if (effective != null)
836
561
  s.contextWindow = effective;
837
562
  }
838
- /** Per-call token usage from an assistant event's `message.usage`. -p mode
839
- * derives these from `stream_event/message_delta`; JSONL only carries them
840
- * here. Per-call semantics: each assistant event represents one LLM call and
841
- * its usage replaces the prior snapshot. */
842
563
  function applyAssistantUsage(s, msg) {
843
564
  const u = msg?.usage;
844
565
  if (!u || typeof u !== 'object')
845
566
  return;
846
- // JSONL has no message_start marker — a fresh message.id is the per-call
847
- // boundary. Fold the finished call's output into the turn-cumulative base
848
- // before this call's counters take over (events of the same call share an
849
- // id and carry running totals, so only the id transition folds).
850
567
  const msgId = typeof msg?.id === 'string' && msg.id ? msg.id : null;
851
568
  if (msgId && msgId !== s.turnUsageMsgId) {
852
569
  if (s.turnUsageMsgId != null)
@@ -864,47 +581,13 @@ function applyAssistantUsage(s, msg) {
864
581
  const total = (s.inputTokens ?? 0) + (s.cachedInputTokens ?? 0) + (s.cacheCreationInputTokens ?? 0) + (s.outputTokens ?? 0);
865
582
  s.contextUsedTokens = total > 0 ? total : null;
866
583
  }
867
- // ---------------------------------------------------------------------------
868
- // Stop-hook gating
869
- // ---------------------------------------------------------------------------
870
- /**
871
- * After the last pending background agent reports its <task-notification>,
872
- * the harness re-invokes the model (wrap-up segment) and a fresh Stop hook
873
- * follows. A Stop timestamp that *predates* the latest notification belongs to
874
- * an earlier segment and must not terminate the turn. If no re-invocation
875
- * materialises, we accept the stale Stop once the main JSONL has been quiet
876
- * for this long — the safety valve against waiting forever on a harness that
877
- * chose not to resume.
878
- */
879
584
  const BG_RESETTLE_QUIET_MS = 30_000;
880
- /**
881
- * Decide what a fired Stop hook means for the PTY lifecycle.
882
- *
883
- * - `hold-background`: launched `run_in_background` agents haven't reported
884
- * completion. They live inside the claude process — SIGTERM now would
885
- * destroy them mid-flight (the "进程退出把子代理打断" failure). Keep the
886
- * PTY alive; the harness will deliver <task-notification> events and
887
- * re-invoke the model, producing further segments and a later Stop.
888
- * - `hold-resettle`: nothing pending, but the Stop predates the latest
889
- * notification — the model's post-notification segment (and its own Stop)
890
- * is still expected. Hold until a fresh Stop or BG_RESETTLE_QUIET_MS of
891
- * JSONL silence.
892
- * - `terminate`: the Stop is the genuine end of the turn.
893
- *
894
- * The `hold-background` path carries a quiet-TTL: a genuinely-running
895
- * background agent keeps emitting hook/sidecar/JSONL traffic, so a hold whose
896
- * every channel has been silent past CLAUDE_TUI_STOP_HOLD_QUIET_TTL_MS is a
897
- * phantom (lost <task-notification> / completion never observed). Releasing
898
- * it as a normal Stop keeps the turn's clean semantics — letting the stall
899
- * watchdog reap it instead would mislabel a finished turn 'stalled' and
900
- * inject a confusing auto-resume prompt into the next turn.
901
- */
902
585
  export function decideClaudeTuiStop(input) {
903
586
  if (input.pendingBackgroundAgents > 0) {
904
587
  const ttl = input.holdQuietTtlMs ?? CLAUDE_TUI_STOP_HOLD_QUIET_TTL_MS;
905
588
  const lastActivityAt = Math.max(input.stoppedAt, input.lastJsonlEventAt, input.lastTaskNotificationAt, input.lastHookOrSidecarEventAt ?? 0);
906
589
  if (input.now - lastActivityAt > ttl)
907
- return 'terminate'; // 幽灵 hold:全通道静默超 TTL
590
+ return 'terminate';
908
591
  return 'hold-background';
909
592
  }
910
593
  const stopIsStale = input.lastTaskNotificationAt > 0 && input.lastTaskNotificationAt >= input.stoppedAt;
@@ -916,52 +599,11 @@ export function decideClaudeTuiStop(input) {
916
599
  }
917
600
  return 'terminate';
918
601
  }
919
- /**
920
- * True for the `im_ask_user` MCP tool in any form — Claude Code names MCP tools
921
- * `mcp__<server>__<tool>` (here `mcp__pikiloom__im_ask_user`), so match the
922
- * namespaced suffix as well as the bare name (robust to the server name
923
- * changing). The driver tails Pre/PostToolUse hooks by id but must also know
924
- * *which* pending tool is the one that blocks on the user, so the stall
925
- * watchdog can stay armed for every other tool and disarm only for a live
926
- * question. See decideClaudeTuiStall(`awaitingUserReply`).
927
- */
928
602
  export function isAskUserToolName(name) {
929
603
  if (typeof name !== 'string' || !name)
930
604
  return false;
931
605
  return name === 'im_ask_user' || name.endsWith('__im_ask_user');
932
606
  }
933
- /**
934
- * Decide whether the turn has gone dead. claude CLI is known to freeze
935
- * mid-turn (observed 2026-06-02 on 2.1.160): after a tool_result lands the
936
- * next assistant segment never starts — the process stays alive, the JSONL
937
- * goes permanently quiet, no Stop hook ever fires, no error surfaces. Without
938
- * a watchdog the IM card spins forever.
939
- *
940
- * `lastProgressAt` is the freshest of every live signal the driver tracks
941
- * (main JSONL, hook tool events, sub-agent sidecars, hook lifecycle state).
942
- * A pending tool (PreToolUse seen, no PostToolUse) extends the threshold:
943
- * the freeze can also hit mid-execution, but a legitimately long foreground
944
- * command must not get shot — claude's own Bash timeout fires PostToolUse
945
- * well inside CLAUDE_TUI_STALL_PENDING_TOOL_MS.
946
- *
947
- * Fast path: `lastPtyDataAt` is raw PTY output (any repaint frame counts). A
948
- * healthy TUI animates continuously mid-turn — spinner, stream ticks, status
949
- * line — so PTY byte-silence is the cheapest possible "event loop is dead"
950
- * detector. When BOTH the PTY and all structured signals have been silent
951
- * past `ptyDeadMs`, declare the stall immediately instead of waiting out the
952
- * 10/30-minute quiet thresholds. Long thinking and long foreground commands
953
- * keep painting frames, which routes them to the slow thresholds as before.
954
- *
955
- * `awaitingUserReply` is the one state that is quiet BY DESIGN, not because the
956
- * process froze: an `im_ask_user` MCP call is in flight (PreToolUse seen, no
957
- * PostToolUse) and the turn is blocked on the user's reply — the `/ask-user`
958
- * callback never times out. Every signal (JSONL, hooks, PTY) goes silent while
959
- * the user thinks, which is indistinguishable by timing alone from a freeze, so
960
- * the watchdog must never stall here. The hard turn deadline remains the
961
- * backstop if the user never answers; PostToolUse re-arms the watchdog the
962
- * instant the answer lands. Without this, the last question regularly gets
963
- * SIGTERMed mid-wait and mislabelled a "CLI freeze".
964
- */
965
607
  export function decideClaudeTuiStall(input) {
966
608
  if (input.awaitingUserReply)
967
609
  return 'wait';
@@ -976,59 +618,30 @@ export function decideClaudeTuiStall(input) {
976
618
  : (input.quietMs ?? CLAUDE_TUI_STALL_QUIET_MS);
977
619
  return input.now - input.lastProgressAt > threshold ? 'stall' : 'wait';
978
620
  }
979
- /**
980
- * Map the screen state at the moment the stall watchdog would fire to what we should actually do.
981
- * This is the chokepoint the diagnostics proved was missing: today the screen is classified at kill
982
- * time but the verdict only changes the error STRING, never the action — so confirm-dialog /
983
- * idle-REPL / model-error turns get SIGTERMed as 'stalled' and auto-resumed into the same wall.
984
- *
985
- * Safety (default-deny): a non-'unknown' state may only DOWNGRADE to a still-terminating, non-
986
- * resuming outcome — it never cancels termination and never waits beyond one bounded retry. Anything
987
- * ambiguous falls through to 'terminate-stalled' (today's self-healing path), because mislabelling a
988
- * real freeze as clearable/idle would convert an auto-resumable stall into a silently dropped turn.
989
- */
990
621
  export function decideStallAction(input) {
991
622
  if (input.state === 'model-error')
992
623
  return 'model-error';
993
- // Answerable dialogs (ask-rule confirm / startup bypass). Plan-approval reaches here too but
994
- // carries affirmativeKey=null under the current policy → falls straight to unanswered.
995
624
  if (input.state === 'confirm-prompt' || input.state === 'plan-approval' || input.state === 'bypass-startup') {
996
625
  if (input.affirmativeKey && !input.alreadyTriedAnswer)
997
626
  return 'answer-retry';
998
627
  return 'terminate-prompt-unanswered';
999
628
  }
1000
629
  if (input.state === 'idle-repl') {
1001
- // Back at the prompt = the turn ended (Stop hook missed/held). Terminate cleanly ONLY when no
1002
- // background work is outstanding: a bg agent/bash lives inside the claude process and bg-Bash
1003
- // is silent by nature, so killing an idle-looking screen with pending bg would abort live work
1004
- // ("进程退出把子代理打断"). Leave those to the existing Stop-hold / TTL machinery.
1005
630
  return input.pendingBgAgents > 0 ? 'terminate-stalled' : 'terminate-clean';
1006
631
  }
1007
632
  return 'terminate-stalled';
1008
633
  }
1009
- // ---------------------------------------------------------------------------
1010
- // Main entry
1011
- // ---------------------------------------------------------------------------
1012
634
  export async function doClaudeTuiStream(opts) {
1013
635
  const start = Date.now();
1014
636
  const deadline = start + opts.timeout * 1000;
1015
- // 0. Probe node-pty FIRST — before any temp-dir creation or session work.
1016
- // If it's not installed (or its prebuilt helper can't be made executable),
1017
- // throw so the dispatcher in claude.ts catches the error and falls back to
1018
- // print mode. No cleanup needed because no resources have been allocated.
1019
637
  const pty = await loadPty();
1020
- // 1. Resolve session lifecycle.
1021
638
  const isFork = !!opts.forkOf;
1022
639
  const isResume = !isFork && !!opts.sessionId;
1023
640
  const newSessionId = (isFork || !isResume) ? randomUUID() : opts.sessionId;
1024
641
  const home = getHome();
1025
642
  const projectDir = path.join(home, '.claude', 'projects', encodePathAsDirName(opts.workdir));
1026
- // For resume we know the exact file; for new/fork we either know upfront
1027
- // (--session-id) or learn it from the SessionStart hook (--fork-session
1028
- // rotates to a fresh uuid Claude generates on its own).
1029
643
  let activeSessionId = isResume ? opts.sessionId : newSessionId;
1030
644
  let activeJsonlPath = path.join(projectDir, `${activeSessionId}.jsonl`);
1031
- // Resume: skip everything that was already in the transcript before our turn.
1032
645
  let jsonlReadOffset = 0;
1033
646
  if (isResume) {
1034
647
  try {
@@ -1036,7 +649,6 @@ export async function doClaudeTuiStream(opts) {
1036
649
  }
1037
650
  catch { }
1038
651
  }
1039
- // 2. Temp workspace for hook script + state + settings.
1040
652
  let workDir;
1041
653
  try {
1042
654
  workDir = fs.mkdtempSync(path.join(tmpdir(), 'pikiloom-claude-tui-'));
@@ -1053,25 +665,8 @@ export async function doClaudeTuiStream(opts) {
1053
665
  fs.writeFileSync(hookPath, HOOK_SCRIPT, { mode: 0o755 });
1054
666
  fs.writeFileSync(statePath, JSON.stringify({ events: [] }));
1055
667
  fs.writeFileSync(toolEventsPath, '');
1056
- // Use the same Node binary that's running pikiloom — `node` may not be on
1057
- // PATH inside the claude TUI's hook subprocess on every distro.
1058
668
  const nodeBin = Q(process.execPath);
1059
669
  const hookCmd = (event) => `${nodeBin} ${Q(hookPath)} ${event} ${Q(statePath)} ${Q(toolEventsPath)}`;
1060
- // Pre/PostToolUse hooks give us a live tool-event stream. The transcript
1061
- // JSONL is itself written incrementally (events land ~0.2–1.2s after they
1062
- // happen — measured on 2.1.173), but the hooks still earn their keep:
1063
- // PreToolUse fires the instant a tool *starts* (the JSONL tool_use only
1064
- // proves it was requested; a long-running Bash would otherwise sit
1065
- // invisible), they carry agent_id for sub-agent attribution, and they are
1066
- // the registration point for run_in_background launches that the Stop
1067
- // gating in decideClaudeTuiStop depends on. The hook script writes to
1068
- // tool-events.jsonl via atomic appends, sidestepping the
1069
- // read-modify-write race that affects the shared state.json file.
1070
- // Pre/PostToolUse require an explicit `matcher` field — without it Claude
1071
- // Code's hook dispatcher silently never fires the hook (the lifecycle
1072
- // hooks below don't need a matcher because they aren't tool-scoped).
1073
- // `*` matches every tool. Without this, the entire live-streaming wire-up
1074
- // is dead code.
1075
670
  const settings = {
1076
671
  hooks: {
1077
672
  SessionStart: [{ hooks: [{ type: 'command', command: hookCmd('SessionStart'), timeout: 5 }] }],
@@ -1090,7 +685,6 @@ export async function doClaudeTuiStream(opts) {
1090
685
  catch { }
1091
686
  return makeErrorResult(opts, start, `Failed to seed hook scaffold: ${e?.message || e}`);
1092
687
  }
1093
- // 3. Build the claude argv. Crucially: NO `-p` — that's the whole point.
1094
688
  const claudeArgs = [];
1095
689
  if (isFork) {
1096
690
  claudeArgs.push('--resume', opts.forkOf.parentSessionId, '--fork-session');
@@ -1107,8 +701,6 @@ export async function doClaudeTuiStream(opts) {
1107
701
  claudeArgs.push('--model', model);
1108
702
  if (opts.claudePermissionMode)
1109
703
  claudeArgs.push('--permission-mode', opts.claudePermissionMode);
1110
- // Effort + Workflow gate — same source of truth as the `claude -p` driver, so
1111
- // the TUI path drops the Workflow tool unless orchestration was opted in.
1112
704
  claudeArgs.push(...claudeEffortAndWorkflowArgs(opts));
1113
705
  if (opts.claudeAppendSystemPrompt)
1114
706
  claudeArgs.push('--append-system-prompt', opts.claudeAppendSystemPrompt);
@@ -1116,22 +708,12 @@ export async function doClaudeTuiStream(opts) {
1116
708
  claudeArgs.push('--mcp-config', opts.mcpConfigPath);
1117
709
  if (opts.claudeExtraArgs?.length)
1118
710
  claudeArgs.push(...opts.claudeExtraArgs);
1119
- // Attachments: TUI doesn't accept base64-image stream-json input. Reference
1120
- // local paths via the @-mention syntax — Claude's TUI reads images from
1121
- // disk and inlines them into the message.
1122
711
  let fullPrompt = opts.prompt;
1123
712
  if (opts.attachments?.length) {
1124
713
  const refs = opts.attachments.map(p => `@${p}`).join(' ');
1125
714
  fullPrompt = `${refs}\n\n${opts.prompt}`;
1126
715
  }
1127
- // `--mcp-config <configs...>` (and a few other Claude flags) are *variadic*
1128
- // — without a `--` terminator the positional prompt would be consumed as
1129
- // another MCP config path. Always end with `--` then the prompt.
1130
716
  claudeArgs.push('--', fullPrompt);
1131
- // 4. Honour the existing steer-callback contract — TUI mode can't accept
1132
- // mid-turn additional input, but callers (bot.ts) always pass onSteerReady
1133
- // and expect it to be invoked. Give them a no-op so the orchestration doesn't
1134
- // hang waiting for the callback that never fires.
1135
717
  try {
1136
718
  opts.onSteerReady?.(async () => {
1137
719
  agentWarn('[claude-tui] steer requested but TUI mode does not support mid-turn input — ignored');
@@ -1141,33 +723,13 @@ export async function doClaudeTuiStream(opts) {
1141
723
  catch (e) {
1142
724
  agentWarn(`[claude-tui] onSteerReady callback raised: ${e?.message || e}`);
1143
725
  }
1144
- // 5. Set up parser state and ensure the bot side has the upfront session id.
1145
726
  const s = createClaudeStreamState(opts);
1146
- // Resume: lock in the native id we are resuming. Fork: keep a placeholder until
1147
- // Claude reports its rotated id via the SessionStart hook. New session: leave
1148
- // s.sessionId at its initial value (null for a pending session) so the emit
1149
- // below detects a change and fires the pending→native promotion callback.
1150
727
  if (isResume || isFork)
1151
728
  s.sessionId = activeSessionId;
1152
- // Seed the context window from whatever model is configured up front (e.g.
1153
- // "haiku" / "opus" / "sonnet" via opts.claudeModel) so the dashboard's
1154
- // context-percent chip + green-dot indicator can render starting from the
1155
- // very first emit, before any assistant event has arrived to confirm the
1156
- // model. Subsequent assistant events with concrete model ids will refresh
1157
- // s.model + recompute the window via applyModelContextWindow.
1158
729
  if (!s.model && (opts.claudeModel || opts.model)) {
1159
730
  s.model = opts.claudeModel || opts.model;
1160
731
  }
1161
732
  applyModelContextWindow(s);
1162
- // A brand-new session uses the id we generated and passed via --session-id, so
1163
- // it is final the instant we spawn. Emit it now: s.sessionId is still unset
1164
- // here, so emitSessionIdUpdate fires the onSessionId callback that promotes the
1165
- // pending pikiloom record (and its in-memory runtime) to the native id. The
1166
- // prior `s.sessionId = activeSessionId` made this a silent no-op (emit dedups on
1167
- // `id === s.sessionId`), so the record stayed `pending_*` for the whole run —
1168
- // and since mergeManagedAndNativeSessions drops pending records, the dashboard
1169
- // never saw the in-flight session as running on (re)load. Fork waits for the
1170
- // SessionStart hook to report Claude's rotated id; resume is already native.
1171
733
  if (!isResume && !isFork)
1172
734
  emitSessionIdUpdate(s, activeSessionId);
1173
735
  let stderrCapture = '';
@@ -1203,11 +765,6 @@ export async function doClaudeTuiStream(opts) {
1203
765
  }
1204
766
  }, after);
1205
767
  };
1206
- // Answer a confirm/select dialog: settle (Ink drops input on the dialog's first frames) → the
1207
- // affirmative key → a split-out Enter (a combined "1\r" gets swallowed — only the digit lands)
1208
- // → drop the answered frame from screenTail so re-detection only fires on a genuine repaint, not
1209
- // the stale text we just answered. Shared by the in-flight onData path and the stall-chokepoint
1210
- // retry so the keystroke discipline lives in one place.
1211
768
  const sendConfirmAnswer = (key, settleMs, confirmDelayMs, onConfirmed) => {
1212
769
  setTimeout(() => {
1213
770
  if (processExited)
@@ -1228,11 +785,6 @@ export async function doClaudeTuiStream(opts) {
1228
785
  }, confirmDelayMs);
1229
786
  }, settleMs);
1230
787
  };
1231
- // Record-only: a limit banner is EVIDENCE, not a verdict. Some banners are
1232
- // informational (extra-usage credits kick in and the turn continues), so
1233
- // killing here would shoot healthy turns. resolveClaudeTuiLimitOutcome
1234
- // arbitrates later — at the stall watchdog and at result assembly — based
1235
- // on whether the turn produced anything substantive after the banner.
1236
788
  const noteTerminalLimitNotice = (notice) => {
1237
789
  if (terminalLimitNotice)
1238
790
  return;
@@ -1243,15 +795,6 @@ export async function doClaudeTuiStream(opts) {
1243
795
  s.activity = s.recentActivity.join('\n');
1244
796
  emit();
1245
797
  };
1246
- // Selected-model-unavailable notice (404 model_not_found). Unlike the limit
1247
- // banner this is terminal AND invisible to every structured signal: the TUI
1248
- // paints it to the PTY screen, writes nothing to the JSONL, and fires no Stop
1249
- // hook — so the turn would otherwise idle at the REPL until the 3–10 min stall
1250
- // watchdog kills it with a misleading "CLI freeze" message. We surface the
1251
- // real reason and end the turn now. The banner is still EVIDENCE, not a bare
1252
- // verdict: a short settle confirms nothing substantive followed (cross-
1253
- // validating the screen scrape) before we kill — never granting a lone text
1254
- // match the authority to shoot a healthy turn.
1255
798
  const noteTerminalModelError = (notice) => {
1256
799
  if (terminalModelError)
1257
800
  return;
@@ -1273,7 +816,6 @@ export async function doClaudeTuiStream(opts) {
1273
816
  killProc('SIGTERM');
1274
817
  }, CLAUDE_TUI_MODEL_ERROR_SETTLE_MS);
1275
818
  };
1276
- // Simulated streaming. See TuiStreamBuffer / applyAssistantStreaming above.
1277
819
  const streamBuf = makeTuiStreamBuffer();
1278
820
  const scheduleStreamTick = () => {
1279
821
  if (streamBuf.timer)
@@ -1290,7 +832,6 @@ export async function doClaudeTuiStream(opts) {
1290
832
  streamBuf.displayedLen = next;
1291
833
  s.text = streamBuf.trueText.slice(0, next);
1292
834
  emit();
1293
- // Keep ticking until we catch up — or until flushStream cancels us.
1294
835
  if (streamBuf.displayedLen < streamBuf.trueText.length)
1295
836
  scheduleStreamTick();
1296
837
  }, TUI_STREAM_CHUNK_INTERVAL_MS);
@@ -1306,9 +847,6 @@ export async function doClaudeTuiStream(opts) {
1306
847
  emit();
1307
848
  }
1308
849
  };
1309
- // 6. Spawn the TUI under PTY. (node-pty itself was already loaded at step
1310
- // 0 — see the top of this function. By the time we reach this point the
1311
- // module is guaranteed to be importable and `spawn-helper` is executable.)
1312
850
  const spawnEnv = { TERM: 'xterm-256color' };
1313
851
  for (const [k, v] of Object.entries(process.env)) {
1314
852
  if (typeof v === 'string')
@@ -1318,25 +856,11 @@ export async function doClaudeTuiStream(opts) {
1318
856
  if (typeof v === 'string')
1319
857
  spawnEnv[k] = v;
1320
858
  }
1321
- // Strip the session-context markers a parent claude process exports to its
1322
- // subprocesses (CLAUDECODE, CLAUDE_CODE_CHILD_SESSION, …). Inherited e.g.
1323
- // when an agent restarted the pikiloom daemon from inside a Claude Code
1324
- // session, they flip this spawn into child-session mode — the transcript
1325
- // JSONL (our only text source) is then never written locally and the turn
1326
- // streams nothing. See CLAUDE_SESSION_CONTEXT_ENV_KEYS in claude.ts.
1327
859
  scrubClaudeSessionContextEnv(spawnEnv);
1328
- // Critical: leaving ANTHROPIC_API_KEY set would route TUI through API
1329
- // billing too, defeating the whole point. Strip it unless the user
1330
- // explicitly opts back in.
1331
860
  if (process.env.PIKILOOM_CLAUDE_TUI_KEEP_API_KEY !== '1') {
1332
861
  delete spawnEnv.ANTHROPIC_API_KEY;
1333
862
  delete spawnEnv.ANTHROPIC_AUTH_TOKEN;
1334
863
  }
1335
- // Resolve `claude` to an absolute path. node-pty's `posix_spawnp` does not
1336
- // reliably honour PATH on macOS when the lookup happens inside an embedded
1337
- // libuv worker — passing the absolute path sidesteps cryptic
1338
- // "posix_spawnp failed" errors. Falls back to the bare name (let
1339
- // posix_spawnp try) when `which` can't resolve it.
1340
864
  const claudeBin = whichSync('claude') || 'claude';
1341
865
  agentLog(`[claude-tui] spawning ${claudeBin} TUI session=${activeSessionId} model=${model || '(default)'} prompt=${fullPrompt.length}ch resume=${isResume} fork=${isFork}`);
1342
866
  try {
@@ -1349,10 +873,6 @@ export async function doClaudeTuiStream(opts) {
1349
873
  });
1350
874
  }
1351
875
  catch (e) {
1352
- // Throw rather than return an error result — pty.spawn failures (PTY
1353
- // allocation refused in sandboxed CI / Docker without /dev/ptmx, etc.)
1354
- // mean TUI can't run at all, so the dispatcher should fall back to
1355
- // print mode. Clean up the temp scaffolding before bailing.
1356
876
  try {
1357
877
  fs.rmSync(workDir, { recursive: true, force: true });
1358
878
  }
@@ -1361,38 +881,14 @@ export async function doClaudeTuiStream(opts) {
1361
881
  }
1362
882
  agentLog(`[claude-tui] pid=${proc.pid}`);
1363
883
  const dbg = process.env.PIKILOOM_CLAUDE_TUI_DEBUG === '1';
1364
- /** Wall-clock of the last raw PTY byte — stall watchdog fast-path signal. */
1365
884
  let lastPtyDataAt = Date.now();
1366
- // Startup-dialog auto-answer. Claude's TUI can paint a blocking "Bypass
1367
- // Permissions mode" confirmation before it accepts our positional prompt
1368
- // (default highlight = "No, exit"). We keep a bounded ANSI-stripped tail of
1369
- // the screen, detect that dialog (see detectClaudeBypassPrompt), and select
1370
- // "Yes, I accept" so the turn never stalls on a pre-prompt.
1371
885
  const SCREEN_TAIL_MAX = 8192;
1372
886
  const BYPASS_ACCEPT_MAX_ATTEMPTS = 3;
1373
- // Settle delay after the dialog first paints before we send any key. Claude's
1374
- // Ink select drops input aimed at it during the first frames — sending the
1375
- // digit too early is a no-op. ~500ms is comfortably past readiness in repro.
1376
887
  const BYPASS_SETTLE_MS = 500;
1377
- // Gap between the selection key and the confirm Enter. Claude's Ink select
1378
- // swallows a combined "2\r" (only the digit lands; the Enter is dropped before
1379
- // the highlight repaints), so the two keystrokes must be split in time —
1380
- // 600ms is what reproduces reliably against the live 2.1.168 dialog.
1381
888
  const BYPASS_CONFIRM_DELAY_MS = 600;
1382
- // How long after the last bypass-dialog repaint we still treat it as on
1383
- // screen — suppresses the blind prompt-submit Enter nudge across the whole
1384
- // select→confirm sequence so a stray CR can't land on "No, exit".
1385
889
  const BYPASS_DIALOG_ACTIVE_WINDOW_MS = 2000;
1386
- // Mid-turn permission-prompt auto-answer (see detectClaudeProceedPrompt).
1387
- // Same Ink-select timing as the bypass dialog: settle past the frames where
1388
- // input is dropped, send the digit, then a split-out Enter to confirm. Unlike
1389
- // the bypass dialog (fires once at startup), `ask`-rule prompts recur — one
1390
- // per gated command — so we re-arm after each answer instead of capping at a
1391
- // few total attempts. PROCEED_ANSWER_MAX is only a runaway backstop.
1392
890
  const PROCEED_SETTLE_MS = 500;
1393
891
  const PROCEED_CONFIRM_DELAY_MS = 600;
1394
- // After confirming, wait before re-arming so a stale dialog frame can't drive
1395
- // a double "1\r"; a genuine next prompt always trails its command by longer.
1396
892
  const PROCEED_REARM_MS = 1000;
1397
893
  const PROCEED_ANSWER_MAX = 40;
1398
894
  let screenTail = '';
@@ -1402,11 +898,6 @@ export async function doClaudeTuiStream(opts) {
1402
898
  let proceedAnswerCount = 0;
1403
899
  let proceedPhase = 'idle';
1404
900
  proc.onData((data) => {
1405
- // We deliberately do not parse the TUI screen output. The JSONL is the
1406
- // canonical source of structured events. Stash bytes only when debugging.
1407
- // Raw byte arrival doubles as the cheapest liveness signal: a healthy TUI
1408
- // repaints continuously mid-turn, so PTY silence = event loop dead — feeds
1409
- // the stall watchdog's fast path (decideClaudeTuiStall.lastPtyDataAt).
1410
901
  lastPtyDataAt = Date.now();
1411
902
  if (dbg) {
1412
903
  try {
@@ -1414,22 +905,12 @@ export async function doClaudeTuiStream(opts) {
1414
905
  }
1415
906
  catch { }
1416
907
  }
1417
- // Auto-answer the bypass-permissions confirmation. Detect it the moment it
1418
- // paints (off the raw PTY, not the 200ms poll tick) and arm a short timed
1419
- // keystroke sequence. Keep a bounded stripped tail across chunks so a dialog
1420
- // split across reads still matches.
1421
908
  screenTail = (screenTail + stripAnsiEscapes(data)).slice(-SCREEN_TAIL_MAX);
1422
909
  if (detectClaudeBypassPrompt(screenTail)) {
1423
910
  bypassPromptLastSeenAt = Date.now();
1424
911
  if (bypassPhase === 'idle' && bypassAcceptAttempts < BYPASS_ACCEPT_MAX_ATTEMPTS) {
1425
912
  bypassAcceptAttempts++;
1426
913
  bypassPhase = 'armed';
1427
- // Three timed steps — verified 3/3 against the live 2.1.168 dialog:
1428
- // settle (dialog ignores input on its first frames)
1429
- // → "2" (jumps to the second option "Yes, I accept"; idempotent —
1430
- // re-sending can't overshoot a 2-option menu onto "No, exit")
1431
- // → Enter (confirms; must arrive *after* the highlight repaints — a
1432
- // combined "2\r" gets swallowed, only the digit lands).
1433
914
  agentLog(`[claude-tui] bypass-permissions prompt — auto-accepting "Yes, I accept" (attempt ${bypassAcceptAttempts}/${BYPASS_ACCEPT_MAX_ATTEMPTS})`);
1434
915
  setTimeout(() => {
1435
916
  if (processExited)
@@ -1447,13 +928,6 @@ export async function doClaudeTuiStream(opts) {
1447
928
  catch { }
1448
929
  bypassPhase = 'confirmed';
1449
930
  agentLog('[claude-tui] bypass-permissions — confirm Enter sent');
1450
- // Drop the buffered dialog frame: the post-accept REPL output can be
1451
- // tiny (e.g. a "Not logged in" line), so the old dialog text would
1452
- // otherwise linger in the 8192-char tail and make the re-arm below
1453
- // re-fire on a stale screen — typing "2"/Enter into the live prompt.
1454
- // Clearing means the re-arm only sees output that arrives *after*
1455
- // the confirm, so it re-fires only on a genuine repaint of the
1456
- // dialog (accept didn't take), never on stale bytes.
1457
931
  screenTail = '';
1458
932
  setTimeout(() => {
1459
933
  if (!processExited && detectClaudeBypassPrompt(screenTail))
@@ -1463,11 +937,6 @@ export async function doClaudeTuiStream(opts) {
1463
937
  }, BYPASS_SETTLE_MS);
1464
938
  }
1465
939
  }
1466
- // Auto-answer a *mid-turn* permission confirmation. `else if` so the startup bypass dialog
1467
- // (handled above) never falls through here. The affirmative key comes from the classifier, not
1468
- // a hard-coded "1": confirm-prompt → "1. Yes", (y/n) → "y". (plan-approval carries no key under
1469
- // the current policy, so it is not auto-answered here.) These recur (one per ask-gated command),
1470
- // so we re-arm after each answer rather than cap total attempts.
1471
940
  else {
1472
941
  const screenInfo = classifyClaudeScreen(screenTail);
1473
942
  if (screenInfo.state === 'confirm-prompt' && screenInfo.affirmativeKey
@@ -1483,13 +952,6 @@ export async function doClaudeTuiStream(opts) {
1483
952
  });
1484
953
  }
1485
954
  }
1486
- // Capture stderr-ish bytes (TUI startup errors, "claude: command not
1487
- // found"-style messages) for the final error payload when the run aborts
1488
- // before any JSONL is written. Strip ANSI on the way in — otherwise the
1489
- // raw PTY screen (cursor positions, SGR colours, column-aligned reply
1490
- // rendering) leaks into IM as gibberish like "[3G你把 [8Gsnipe …" when a
1491
- // user hits Stop before the JSONL has flushed any assistant text. Keep
1492
- // the buffer bounded after stripping.
1493
955
  if (stderrCapture.length < 4096) {
1494
956
  stderrCapture += stripAnsiEscapes(data);
1495
957
  if (stderrCapture.length > 4096)
@@ -1498,15 +960,10 @@ export async function doClaudeTuiStream(opts) {
1498
960
  if (notice)
1499
961
  noteTerminalLimitNotice(notice);
1500
962
  }
1501
- // Selected-model-unavailable notice — see noteTerminalModelError. The TUI
1502
- // only paints this to the screen (no JSONL, no Stop hook), so the live
1503
- // screen tail is the sole signal. detectClaudeModelError is whitespace-
1504
- // insensitive so it survives the TUI's char-by-char paint.
1505
963
  if (!terminalModelError && detectClaudeModelError(screenTail)) {
1506
964
  noteTerminalModelError(claudeModelErrorMessage(s.model || opts.claudeModel || null));
1507
965
  }
1508
966
  });
1509
- // 7. Abort handling.
1510
967
  const abortStream = () => {
1511
968
  if (interrupted || processExited)
1512
969
  return;
@@ -1518,7 +975,6 @@ export async function doClaudeTuiStream(opts) {
1518
975
  if (opts.abortSignal?.aborted)
1519
976
  abortStream();
1520
977
  opts.abortSignal?.addEventListener('abort', abortStream, { once: true });
1521
- // 8. Hard deadline timer.
1522
978
  const hardTimer = setTimeout(() => {
1523
979
  if (processExited)
1524
980
  return;
@@ -1527,75 +983,32 @@ export async function doClaudeTuiStream(opts) {
1527
983
  agentWarn(`[claude-tui] hard deadline reached (${opts.timeout}s) pid=${proc.pid}`);
1528
984
  killProc('SIGTERM');
1529
985
  }, opts.timeout * 1000 + AGENT_STREAM_HARD_KILL_GRACE_MS);
1530
- // 9. Poll loop — hook state + JSONL tail.
1531
986
  const POLL_INTERVAL_MS = 200;
1532
- // After Stop hook fires we give the JSONL ~600ms to settle (matches the
1533
- // print-mode driver's graceful-abort observation window) so the assistant's
1534
- // final event lands before we SIGTERM.
1535
987
  const POST_STOP_DRAIN_MS = 600;
1536
- // Fallback Enter — most Claude versions auto-submit a positional prompt in
1537
- // TUI mode, but if UserPromptSubmit hasn't fired by this deadline we type a
1538
- // carriage return into the PTY in case the prompt is sitting on the input
1539
- // line waiting for it.
1540
988
  const PROMPT_SUBMIT_NUDGE_MS = 1500;
1541
- // After the stall chokepoint fires its affirmative keystroke at a stuck dialog, give the dialog
1542
- // this long to clear before deciding it's unanswerable. Generous enough to also cover the
1543
- // in-flight onData answer cycle (settle + confirm + re-arm) so a chained next-prompt isn't
1544
- // mistaken for a failed answer; short enough that we never re-wait the full 3-min quiet window.
1545
989
  const CHOKEPOINT_ANSWER_GRACE_MS = 5000;
1546
990
  let promptNudged = false;
1547
991
  let pollHandle = null;
1548
992
  let drainScheduled = false;
1549
- // Wall-clock of the last parsed main-JSONL line. Feeds the stale-Stop
1550
- // quiet-window check in decideClaudeTuiStop — sidecar / hook traffic is
1551
- // deliberately excluded (only main-JSONL activity signals a model segment).
1552
993
  let lastMainJsonlEventAt = start;
1553
- // Last pending-background count we logged, so the waiting state logs on
1554
- // transitions instead of every 200ms poll tick.
1555
994
  let lastLoggedPendingBg = -1;
1556
- // Stall-watchdog liveness signals. Together with lastMainJsonlEventAt they
1557
- // answer "is the claude process still doing anything at all?" — see
1558
- // decideClaudeTuiStall for why this exists (claude CLI mid-turn freeze).
1559
995
  let lastToolEventAt = start;
1560
996
  let lastSidecarEventAt = 0;
1561
- // Last non-synthetic assistant JSONL event — substantive-progress signal
1562
- // for the limit-notice arbitration (resolveClaudeTuiLimitOutcome). Distinct
1563
- // from lastMainJsonlEventAt, which also counts bookkeeping lines (mode,
1564
- // last-prompt, …) that land right after submit and prove nothing.
1565
997
  let lastAssistantEventAt = 0;
1566
998
  let stallKilled = false;
1567
- // Chokepoint answer-retry: when the watchdog is about to fire on a confirm dialog, send the
1568
- // affirmative key ONCE against the now-stable (3-min-quiet, fully-painted) screen — the in-flight
1569
- // onData answer only fires while bytes arrive, so a dialog that paints once then goes silent never
1570
- // gets a second attempt (the diagnostics showed full-match prompts killed with answer-count 1).
1571
- // `stallAnswerSentAt` arms a bounded grace check: if the dialog hasn't cleared by then, terminate
1572
- // without auto-resuming (re-running would just re-paint the same dialog).
1573
999
  let stallAnswerTried = false;
1574
1000
  let stallAnswerSentAt = 0;
1575
- // Stall diagnostics (capture-only) — see writeStallDiag.
1576
1001
  let observedClaudeVersion = '';
1577
1002
  let lastMainJsonlType = '';
1578
1003
  let lastStallDiagHeartbeatAt = 0;
1579
1004
  let stallDiagWentQuiet = false;
1580
1005
  let stallDiagMaxQuietMs = 0;
1581
1006
  let stallDiagPtyAliveWhileQuiet = false;
1582
- /** Last state.stoppedAt for which pendingHookToolIds was reconciled. */
1583
1007
  let lastClearedStopAt = 0;
1584
- /** Hook-reported tools still executing: PreToolUse seen, no PostToolUse. */
1585
1008
  const pendingHookToolIds = new Set();
1586
- /** Subset of pendingHookToolIds that are `im_ask_user` calls — the turn is
1587
- * intentionally blocked on the user's reply (the /ask-user callback never
1588
- * times out), so the stall watchdog must stay disarmed while any are in
1589
- * flight. Cleared by the answer (PostToolUse) or the turn ending (Stop). */
1590
1009
  const pendingAskUserToolIds = new Set();
1591
- /** Edge-logged so the "watchdog disarmed" line fires on transitions only. */
1592
1010
  let loggedAwaitingUser = false;
1593
- // Incremental main-JSONL drain — the canonical text/thinking/usage feed.
1594
- // Used by both the 200ms poll tick and the post-exit final drain. Returns
1595
- // true when any line was consumed so callers can emit().
1596
1011
  const drainMainJsonl = () => {
1597
- // No existsSync guard: readJsonlIncrement returns no lines (offset unchanged)
1598
- // for a missing file, so the guard was a redundant extra syscall every tick.
1599
1012
  const inc = readJsonlIncrement(activeJsonlPath, jsonlReadOffset);
1600
1013
  jsonlReadOffset = inc.offset;
1601
1014
  let touched = false;
@@ -1611,16 +1024,10 @@ export async function doClaudeTuiStream(opts) {
1611
1024
  catch {
1612
1025
  continue;
1613
1026
  }
1614
- // Ignore sub-agent sidecar events — they belong to a child agent's
1615
- // stream and would re-enter the parent's accumulator. claudeParse's
1616
- // own sub-agent routing handles them.
1617
1027
  const isSubAgentEvent = typeof ev.parent_tool_use_id === 'string' && ev.parent_tool_use_id;
1618
1028
  if (!isSubAgentEvent && ev.type === 'assistant') {
1619
1029
  const notice = detectClaudeTuiTerminalLimitNotice(ev.message);
1620
1030
  if (notice) {
1621
- // A synthetic limit banner is not substantive progress — skip the
1622
- // liveness/type bookkeeping below so the limit arbitration and the
1623
- // stall watchdog don't mistake it for a live model segment.
1624
1031
  noteTerminalLimitNotice(notice);
1625
1032
  touched = true;
1626
1033
  continue;
@@ -1648,11 +1055,6 @@ export async function doClaudeTuiStream(opts) {
1648
1055
  }
1649
1056
  return touched;
1650
1057
  };
1651
- // Append-only tool-events log fed by PreToolUse / PostToolUse hooks. We
1652
- // tail it with the same incremental reader the JSONL transcript uses. Hook
1653
- // events usually beat their JSONL counterpart by a second or so (and
1654
- // PreToolUse fires before the tool even runs); whichever feed arrives first
1655
- // wins, the other dedups via seenClaudeToolIds / seenClaudeToolResultIds.
1656
1058
  let toolEventsReadOffset = 0;
1657
1059
  const drainToolEvents = () => {
1658
1060
  const inc = readJsonlIncrement(toolEventsPath, toolEventsReadOffset);
@@ -1669,17 +1071,11 @@ export async function doClaudeTuiStream(opts) {
1669
1071
  catch {
1670
1072
  continue;
1671
1073
  }
1672
- // Stall-watchdog bookkeeping: any hook event is proof of life, and the
1673
- // Pre/Post pairing tells the watchdog whether a tool is mid-execution
1674
- // (which extends the stall threshold — long foreground commands are
1675
- // legitimately silent).
1676
1074
  lastToolEventAt = Date.now();
1677
1075
  const hookToolId = typeof ev?.tool_use_id === 'string' ? ev.tool_use_id : '';
1678
1076
  if (hookToolId) {
1679
1077
  if (ev?.event === 'PreToolUse') {
1680
1078
  pendingHookToolIds.add(hookToolId);
1681
- // im_ask_user blocks the turn on the user — track it separately so the
1682
- // stall watchdog can tell an intentional question-wait from a freeze.
1683
1079
  if (isAskUserToolName(ev?.tool_name))
1684
1080
  pendingAskUserToolIds.add(hookToolId);
1685
1081
  }
@@ -1688,11 +1084,6 @@ export async function doClaudeTuiStream(opts) {
1688
1084
  pendingAskUserToolIds.delete(hookToolId);
1689
1085
  }
1690
1086
  }
1691
- // A Task PreToolUse and the first sub-agent tool PreToolUse can land in
1692
- // the same tick batch. If the sub-agent's hook arrives before we've
1693
- // discovered its sidecar (and thus before s.subAgentIdToParent knows
1694
- // its agent_id), refresh discovery so the hook resolves its parent on
1695
- // this pass instead of leaking through unattributed.
1696
1087
  const subAgentId = typeof ev?.agent_id === 'string' ? ev.agent_id : '';
1697
1088
  if (subAgentId && !s.subAgentIdToParent?.has(subAgentId))
1698
1089
  tryDiscoverSubAgents();
@@ -1734,25 +1125,15 @@ export async function doClaudeTuiStream(opts) {
1734
1125
  const parentToolUseId = typeof meta?.toolUseId === 'string' ? meta.toolUseId : '';
1735
1126
  if (!parentToolUseId)
1736
1127
  continue;
1737
- // Only start tailing once the parent Task tool_use has been registered
1738
- // in s.subAgents — otherwise routeClaudeSubAgentEvent silently drops
1739
- // every event because it can't find the parent.
1740
1128
  if (!s.subAgents.has(parentToolUseId))
1741
1129
  continue;
1742
1130
  const sidecarPath = path.join(sidecarDir, `${stem}.jsonl`);
1743
1131
  trackedSubAgents.set(stem, { sidecarPath, offset: 0, parentToolUseId });
1744
- // `stem` is "agent-<id>"; Claude Code's hook payload `agent_id` carries
1745
- // just the raw id. Keep both keys so applyHookToolEvent can attribute
1746
- // sub-agent tool hooks to the parent's Task tool_use no matter which
1747
- // form arrives.
1748
1132
  const rawAgentId = stem.startsWith('agent-') ? stem.slice('agent-'.length) : stem;
1749
1133
  if (!s.subAgentIdToParent)
1750
1134
  s.subAgentIdToParent = new Map();
1751
1135
  s.subAgentIdToParent.set(rawAgentId, parentToolUseId);
1752
1136
  s.subAgentIdToParent.set(stem, parentToolUseId);
1753
- // <task-notification> events identify background tasks by this raw id
1754
- // (and only sometimes carry <tool-use-id>) — keep the mapping so
1755
- // applyClaudeTaskNotification can resolve them either way.
1756
1137
  if (!s.bgTaskIdToToolUse)
1757
1138
  s.bgTaskIdToToolUse = new Map();
1758
1139
  s.bgTaskIdToToolUse.set(rawAgentId, parentToolUseId);
@@ -1775,8 +1156,6 @@ export async function doClaudeTuiStream(opts) {
1775
1156
  catch {
1776
1157
  continue;
1777
1158
  }
1778
- // Inject parent_tool_use_id so claudeParse routes via routeClaudeSubAgentEvent
1779
- // → updates sub.model + sub.tools on the existing s.subAgents entry.
1780
1159
  const injected = { ...ev, parent_tool_use_id: tail.parentToolUseId };
1781
1160
  try {
1782
1161
  callClaudeParseForTui(injected, s);
@@ -1787,15 +1166,10 @@ export async function doClaudeTuiStream(opts) {
1787
1166
  any = true;
1788
1167
  }
1789
1168
  }
1790
- // Stall-watchdog: live sub-agents count as turn progress even while the
1791
- // parent thread is quietly waiting on them.
1792
1169
  if (any)
1793
1170
  lastSidecarEventAt = Date.now();
1794
1171
  return any;
1795
1172
  };
1796
- // End a turn that is blocked on a confirm/select dialog the auto-answer could not clear. Unlike a
1797
- // 'stalled' kill this does NOT auto-resume — re-running the prompt just re-paints the same dialog
1798
- // (the loop the user kept seeing). The session is intact; the user re-sends to continue.
1799
1173
  const terminatePromptUnanswered = (screenState, sample) => {
1800
1174
  stallKilled = true;
1801
1175
  const nowMs = Date.now();
@@ -1829,14 +1203,11 @@ export async function doClaudeTuiStream(opts) {
1829
1203
  }
1830
1204
  return;
1831
1205
  }
1832
- // Hook state — pick up real session id / transcript path.
1833
1206
  const state = readHookState(statePath);
1834
1207
  if (state.sessionId && state.sessionId !== activeSessionId) {
1835
1208
  const prevId = activeSessionId;
1836
1209
  activeSessionId = state.sessionId;
1837
1210
  activeJsonlPath = state.transcriptPath || path.join(projectDir, `${activeSessionId}.jsonl`);
1838
- // For forks Claude rotates to a fresh UUID — start reading the new file
1839
- // from offset 0 since we haven't read any of it yet.
1840
1211
  if (!isResume)
1841
1212
  jsonlReadOffset = 0;
1842
1213
  emitSessionIdUpdate(s, activeSessionId);
@@ -1845,11 +1216,6 @@ export async function doClaudeTuiStream(opts) {
1845
1216
  else if (state.transcriptPath && state.transcriptPath !== activeJsonlPath) {
1846
1217
  activeJsonlPath = state.transcriptPath;
1847
1218
  }
1848
- // Submit nudge — only if UserPromptSubmit hook hasn't fired yet. Suppress
1849
- // it while the bypass-permissions dialog is (or was just) on screen: a blind
1850
- // CR there lands on the default "No, exit" and kills the session. The dialog
1851
- // auto-answer in onData drives that screen instead; once it clears the
1852
- // prompt submits on its own (or this nudge fires on a later tick).
1853
1219
  const bypassDialogActive = bypassPromptLastSeenAt > 0
1854
1220
  && Date.now() - bypassPromptLastSeenAt < BYPASS_DIALOG_ACTIVE_WINDOW_MS;
1855
1221
  if (!promptNudged && !state.promptSubmittedAt && !bypassDialogActive
@@ -1861,52 +1227,23 @@ export async function doClaudeTuiStream(opts) {
1861
1227
  catch { }
1862
1228
  agentLog(`[claude-tui] prompt-submit nudge sent (no UserPromptSubmit after ${PROMPT_SUBMIT_NUDGE_MS}ms)`);
1863
1229
  }
1864
- // JSONL tail.
1865
1230
  if (drainMainJsonl()) {
1866
- // Emit immediately so non-text changes (tool_use, plan, activity,
1867
- // thinking, usage) reach the dashboard without waiting for the
1868
- // chunked stream tick. The streaming timer separately advances
1869
- // s.text from the buffer over the next few ticks.
1870
1231
  emit();
1871
1232
  scheduleStreamTick();
1872
1233
  }
1873
- // Live tool-events stream — fed by Pre/PostToolUse hooks. Hook and JSONL
1874
- // feeds race per tool call; both record into seenClaudeToolIds /
1875
- // seenClaudeToolResultIds so whichever lands first wins and the other
1876
- // pass dedups naturally.
1877
1234
  if (drainToolEvents())
1878
1235
  emit();
1879
- // Sub-agent sidecar discovery + pump. Order matters: discovery first so a
1880
- // newly-spawned sub-agent gets registered for tailing this same tick if
1881
- // its events have already been written. Skip the readdir + per-meta reads
1882
- // until a Task tool_use is actually registered — discovery can't succeed
1883
- // before then anyway (it requires the parent in s.subAgents), so the common
1884
- // no-subagent turn would otherwise readdir the sidecar dir every 200ms for
1885
- // nothing.
1886
1236
  if (s.subAgents.size > 0)
1887
1237
  tryDiscoverSubAgents();
1888
1238
  if (pumpSubAgentSidecars())
1889
1239
  emit();
1890
- // Stop hook handling. A Stop is NOT automatically the end of the turn:
1891
- // Claude fires it per response segment, including the segment that merely
1892
- // *launched* run_in_background agents. Those agents run inside the claude
1893
- // process — terminating here would destroy them (the "进程退出把子代理
1894
- // 打断" incident). Hold the PTY open until every launched background agent
1895
- // has reported its <task-notification> AND the latest Stop is fresher than
1896
- // the latest notification (i.e. the model's wrap-up segment finished).
1897
1240
  if (state.stoppedAt && !stopHookFired) {
1898
- // A fired Stop means no foreground tool is genuinely mid-flight any
1899
- // more. Surviving entries in pendingHookToolIds are lost PostToolUse
1900
- // hook events (MCP flap / hook timeout ate them) — clearing here stops
1901
- // them from silently pushing the stall watchdog onto the 30-minute
1902
- // pending-tool threshold for the rest of the turn.
1903
1241
  if (state.stoppedAt !== lastClearedStopAt) {
1904
1242
  lastClearedStopAt = state.stoppedAt;
1905
1243
  if (pendingHookToolIds.size) {
1906
1244
  agentWarn(`[claude-tui] Stop fired with ${pendingHookToolIds.size} unmatched PreToolUse event(s) — clearing (lost PostToolUse hooks)`);
1907
1245
  pendingHookToolIds.clear();
1908
1246
  }
1909
- // A fired Stop ends the turn — any tracked ask-user is moot.
1910
1247
  pendingAskUserToolIds.clear();
1911
1248
  }
1912
1249
  const pendingBg = pendingClaudeBackgroundAgentCount(s);
@@ -1916,9 +1253,6 @@ export async function doClaudeTuiStream(opts) {
1916
1253
  lastTaskNotificationAt: s.lastTaskNotificationAt || 0,
1917
1254
  lastJsonlEventAt: lastMainJsonlEventAt,
1918
1255
  lastHookOrSidecarEventAt: Math.max(lastToolEventAt, lastSidecarEventAt),
1919
- // Background *Bash* is silent by nature (no sidecar/hook traffic while
1920
- // it runs) — give it the long pending-tool budget; agent-only holds
1921
- // keep the default TTL (live agents emit sidecar events constantly).
1922
1256
  holdQuietTtlMs: pendingClaudeBackgroundBashCount(s) > 0
1923
1257
  ? CLAUDE_TUI_STALL_PENDING_TOOL_MS
1924
1258
  : undefined,
@@ -1928,7 +1262,6 @@ export async function doClaudeTuiStream(opts) {
1928
1262
  stopHookFired = true;
1929
1263
  stopHookSeenAt = Date.now();
1930
1264
  if (pendingBg > 0) {
1931
- // 幽灵 hold 释放:计数说还有后台 agent,但所有通道静默已超 TTL。
1932
1265
  agentWarn(`[claude-tui] releasing phantom hold — ${pendingBg} background agent(s) still counted pending but every channel quiet past TTL; treating Stop as final`);
1933
1266
  }
1934
1267
  agentLog(`[claude-tui] Stop hook fired — draining JSONL for ${POST_STOP_DRAIN_MS}ms before SIGTERM`);
@@ -1945,34 +1278,12 @@ export async function doClaudeTuiStream(opts) {
1945
1278
  drainScheduled = true;
1946
1279
  agentLog(`[claude-tui] drain complete, terminating TUI pid=${proc.pid}`);
1947
1280
  killProc('SIGTERM');
1948
- // Continue polling so any post-Stop JSONL writes still get parsed; the
1949
- // process will exit shortly and onExit will resolve the wait.
1950
1281
  }
1951
- // Stall watchdog. claude CLI can freeze mid-turn (observed on 2.1.160):
1952
- // a tool_result lands, then the next assistant segment never starts — the
1953
- // process stays alive, every signal goes quiet, no Stop hook ever fires.
1954
- // When ALL liveness signals have been silent past the threshold, declare
1955
- // the turn stalled and SIGTERM; doClaudeWithRetry auto-resumes the session
1956
- // once so the turn continues instead of spinning forever in the IM card.
1957
1282
  if (!stopHookFired && !timedOut && !interrupted && !stallKilled) {
1958
1283
  const lastProgressAt = Math.max(start, lastMainJsonlEventAt, lastToolEventAt, lastSidecarEventAt, state.stoppedAt || 0, state.promptSubmittedAt || 0);
1959
- // Pending background work (agents + bash) extends the stall budget the
1960
- // same way a pending foreground tool does: a silent 15-minute background
1961
- // build must not get shot by the 10-minute quiet threshold. The PTY
1962
- // fast path still catches true process freezes within minutes.
1963
1284
  const pendingBgForStall = pendingClaudeBackgroundAgentCount(s);
1964
- // PTY fast path is for *mid-turn* freezes only. While the TUI idles in a
1965
- // post-Stop background hold it legitimately paints nothing — a static
1966
- // screen there is healthy, not frozen. Stop being the freshest signal is
1967
- // exactly that hold state → disarm the fast path (0 = unavailable).
1968
1285
  const nonStopProgressAt = Math.max(start, lastMainJsonlEventAt, lastToolEventAt, lastSidecarEventAt, state.promptSubmittedAt || 0);
1969
1286
  const inPostStopHold = !!state.stoppedAt && state.stoppedAt >= nonStopProgressAt;
1970
- // An im_ask_user call is in flight → the turn is blocked on the user's
1971
- // reply, not frozen (the /ask-user callback never times out). Disarm the
1972
- // stall watchdog AND the freeze diagnostics until the user answers
1973
- // (PostToolUse clears the id). Otherwise the quiet wait trips
1974
- // decideClaudeTuiStall, classifies 'unknown', and gets SIGTERMed +
1975
- // mislabelled a "CLI freeze" — the last-question hang the user reported.
1976
1287
  const awaitingUserReply = pendingAskUserToolIds.size > 0;
1977
1288
  if (awaitingUserReply !== loggedAwaitingUser) {
1978
1289
  loggedAwaitingUser = awaitingUserReply;
@@ -1980,11 +1291,6 @@ export async function doClaudeTuiStream(opts) {
1980
1291
  agentLog(`[claude-tui] im_ask_user in flight — stall watchdog disarmed until the user replies pid=${proc.pid}`);
1981
1292
  }
1982
1293
  }
1983
- // Chokepoint answer-retry grace. We sent the affirmative key at a stuck dialog; give it time
1984
- // to clear. If the screen is no longer a blocking dialog, the answer took — disarm and re-arm
1985
- // so a later prompt in the same turn can also get a chokepoint retry. If it is STILL a dialog
1986
- // (and the in-flight onData answer didn't clear it either), it is genuinely unanswerable —
1987
- // end without auto-resume.
1988
1294
  if (stallAnswerSentAt > 0 && Date.now() - stallAnswerSentAt > CHOKEPOINT_ANSWER_GRACE_MS) {
1989
1295
  const after = classifyClaudeScreen(screenTail);
1990
1296
  const stillBlocking = after.state === 'confirm-prompt'
@@ -1998,8 +1304,6 @@ export async function doClaudeTuiStream(opts) {
1998
1304
  stallAnswerTried = false;
1999
1305
  }
2000
1306
  }
2001
- // Stall diagnostics: sample the quiet lead-up so the watchdog can later be
2002
- // tuned from data. Capture-only — changes no control flow.
2003
1307
  if (!stallKilled) {
2004
1308
  const nowMs = Date.now();
2005
1309
  const quietMs = nowMs - lastProgressAt;
@@ -2008,15 +1312,10 @@ export async function doClaudeTuiStream(opts) {
2008
1312
  stallDiagWentQuiet = true;
2009
1313
  if (quietMs > stallDiagMaxQuietMs)
2010
1314
  stallDiagMaxQuietMs = quietMs;
2011
- // PTY still painting while every structured signal is silent = the
2012
- // frozen-stream-behind-a-live-spinner case that defeats the fast path.
2013
1315
  if (ptyQuietMs < CLAUDE_TUI_STALL_PTY_DEAD_MS)
2014
1316
  stallDiagPtyAliveWhileQuiet = true;
2015
1317
  if (nowMs - lastStallDiagHeartbeatAt >= STALL_DIAG_HEARTBEAT_INTERVAL_MS) {
2016
1318
  lastStallDiagHeartbeatAt = nowMs;
2017
- // Snapshot the screen so a quiet stretch can later be classified as a frozen stream vs
2018
- // a long think vs a blocking dialog vs an idle hold. Record the full screenState (not
2019
- // just looksLikePrompt) so the lead-up to a kill is measurable as claude versions churn.
2020
1319
  const screenInfo = classifyClaudeScreen(screenTail);
2021
1320
  const looksLikePrompt = screenInfo.state === 'confirm-prompt'
2022
1321
  || screenInfo.state === 'plan-approval' || screenInfo.state === 'bypass-startup';
@@ -2053,8 +1352,6 @@ export async function doClaudeTuiStream(opts) {
2053
1352
  if (stallDecision === 'stall') {
2054
1353
  const quietMin = Math.round((Date.now() - lastProgressAt) / 60_000);
2055
1354
  const ptyQuietS = Math.round((Date.now() - lastPtyDataAt) / 1000);
2056
- // The screen is ground truth. Classify it, then map to an action — the chokepoint the
2057
- // diagnostics proved was missing (the verdict used to change only the error string).
2058
1355
  const screen = classifyClaudeScreen(screenTail);
2059
1356
  const action = decideStallAction({
2060
1357
  state: screen.state,
@@ -2064,8 +1361,6 @@ export async function doClaudeTuiStream(opts) {
2064
1361
  });
2065
1362
  const looksLikePrompt = screen.state === 'confirm-prompt'
2066
1363
  || screen.state === 'plan-approval' || screen.state === 'bypass-startup';
2067
- // Diagnostics oracle: record the screen state + chosen action on every kill-point so the
2068
- // false-positive rate stays measurable as claude versions churn.
2069
1364
  const writeStallRecord = () => writeStallDiag({
2070
1365
  kind: 'stall', sessionId: activeSessionId, version: observedClaudeVersion, model: s.model || null,
2071
1366
  elapsedTurnMs: Date.now() - start, quietMs: Date.now() - lastProgressAt, ptyQuietMs: Date.now() - lastPtyDataAt,
@@ -2074,18 +1369,12 @@ export async function doClaudeTuiStream(opts) {
2074
1369
  looksLikePrompt, screenState: screen.state, action, screenSample: screen.sample,
2075
1370
  });
2076
1371
  if (action === 'answer-retry' && screen.affirmativeKey) {
2077
- // #1 fix: the in-flight onData answer only fires while bytes arrive, so a dialog that
2078
- // painted once then went byte-silent never got a retry (full-match prompts were killed
2079
- // with answer-count 1). Send the affirmative key now against the stable, fully-painted
2080
- // screen (no settle — quiet 3 min) and let the grace check decide the outcome. No kill.
2081
1372
  stallAnswerTried = true;
2082
1373
  stallAnswerSentAt = Date.now();
2083
1374
  agentWarn(`[claude-tui] watchdog hit a ${screen.state} after ${quietMin}m quiet — auto-answering "${screen.affirmativeKey}" against the stable dialog (no kill yet) pid=${proc.pid}`);
2084
1375
  sendConfirmAnswer(screen.affirmativeKey, 0, PROCEED_CONFIRM_DELAY_MS);
2085
1376
  }
2086
1377
  else if (action === 'terminate-clean') {
2087
- // Idle REPL, nothing pending — the turn finished and we merely missed/held its Stop hook.
2088
- // Hand off to the normal post-Stop drain: clean end, no 'stalled', no auto-resume.
2089
1378
  writeStallRecord();
2090
1379
  stopHookFired = true;
2091
1380
  stopHookSeenAt = Date.now();
@@ -2096,8 +1385,6 @@ export async function doClaudeTuiStream(opts) {
2096
1385
  terminatePromptUnanswered(screen.state, screen.sample);
2097
1386
  }
2098
1387
  else if (action === 'model-error') {
2099
- // Selected model unavailable (banner painted to screen only — no JSONL, no Stop hook).
2100
- // Surface the real reason; 'model_error' is non-retryable so we never resume into it.
2101
1388
  stallKilled = true;
2102
1389
  if (!terminalModelError)
2103
1390
  terminalModelError = claudeModelErrorMessage(s.model || opts.claudeModel || null);
@@ -2112,9 +1399,6 @@ export async function doClaudeTuiStream(opts) {
2112
1399
  killProc('SIGTERM');
2113
1400
  }
2114
1401
  else {
2115
- // terminate-stalled: a genuine freeze candidate (state 'unknown') OR an idle hold with
2116
- // pending background work we must not interrupt. Keep the self-healing SIGTERM-as-
2117
- // 'stalled' path (auto-resumes once) with the existing model / limit arbitration.
2118
1402
  stallKilled = true;
2119
1403
  s.stopReason = 'stalled';
2120
1404
  writeStallRecord();
@@ -2149,15 +1433,12 @@ export async function doClaudeTuiStream(opts) {
2149
1433
  emit();
2150
1434
  killProc('SIGTERM');
2151
1435
  }
2152
- // Keep polling: onExit resolves the wait and the final drains pick up
2153
- // whatever the dying process flushes.
2154
1436
  }
2155
1437
  }
2156
1438
  }
2157
1439
  pollHandle = setTimeout(tick, POLL_INTERVAL_MS);
2158
1440
  };
2159
1441
  pollHandle = setTimeout(tick, POLL_INTERVAL_MS);
2160
- // 10. Wait for process exit.
2161
1442
  await new Promise(resolve => {
2162
1443
  proc.onExit(({ exitCode: code, signal }) => {
2163
1444
  processExited = true;
@@ -2173,28 +1454,15 @@ export async function doClaudeTuiStream(opts) {
2173
1454
  });
2174
1455
  });
2175
1456
  opts.abortSignal?.removeEventListener('abort', abortStream);
2176
- // 11. Final drain — pick up anything written between the last poll and
2177
- // process exit. Claude flushes its remaining JSONL events on shutdown.
2178
1457
  if (drainMainJsonl())
2179
1458
  emit();
2180
- // Final tool-events drain — any PreToolUse / PostToolUse hooks that fired
2181
- // between the last poll tick and process exit.
2182
1459
  if (drainToolEvents())
2183
1460
  emit();
2184
- // Final sub-agent drain. The sub-agent's last events (closing tool_results)
2185
- // may have landed after our last poll tick; mirror the main JSONL drain to
2186
- // make sure sub.tools / sub.status carry the complete picture into the
2187
- // final result.
2188
1461
  if (s.subAgents.size > 0)
2189
1462
  tryDiscoverSubAgents();
2190
1463
  if (pumpSubAgentSidecars())
2191
1464
  emit();
2192
- // Process has exited and final drain is done — promote whatever is left in
2193
- // the stream buffer into `s.text` so the final result message carries the
2194
- // complete reply (not a truncated mid-stream prefix).
2195
1465
  flushStream();
2196
- // 12. Cleanup temp dir. Keep it around when debugging so users can inspect
2197
- // the captured PTY bytes + state file.
2198
1466
  if (!dbg) {
2199
1467
  try {
2200
1468
  fs.rmSync(workDir, { recursive: true, force: true });
@@ -2204,15 +1472,7 @@ export async function doClaudeTuiStream(opts) {
2204
1472
  else {
2205
1473
  agentLog(`[claude-tui] debug artifacts retained in ${workDir}`);
2206
1474
  }
2207
- // 13. Build the StreamResult — mirror the shape and semantics of
2208
- // doClaudeInteractiveStream so downstream consumers (finalizeStreamResult,
2209
- // dashboard rendering) cannot tell the two paths apart.
2210
1475
  const cleanStderr = stderrCapture.trim();
2211
- // Detect Claude Code's synthetic "API Error: …" assistant reply (e.g.
2212
- // 529 Overloaded). The text gets rewritten so the IM card doesn't surface
2213
- // the raw "API Error: Overloaded" string to the user, and stopReason is
2214
- // upgraded so the ClaudeDriver retry wrapper can decide to re-issue the
2215
- // turn rather than letting the synthetic failure stick.
2216
1476
  const apiErrorReason = detectClaudeApiError(s.text);
2217
1477
  if (apiErrorReason) {
2218
1478
  agentWarn(`[claude-tui] upstream API error detected: ${apiErrorReason}`);
@@ -2221,21 +1481,10 @@ export async function doClaudeTuiStream(opts) {
2221
1481
  if (!s.errors)
2222
1482
  s.errors = [`Anthropic API error: ${apiErrorReason}`];
2223
1483
  }
2224
- // Model-unavailable arbitration: the TUI painted the "selected model is
2225
- // unavailable" banner (noteTerminalModelError) and the turn produced nothing
2226
- // — no JSONL, no Stop hook. The early settle-timer's SIGTERM (or any exit)
2227
- // brought us here; surface the real reason instead of a bare "(no textual
2228
- // response)". stopReason 'model_error' is non-retryable (doClaudeWithRetry
2229
- // only auto-resumes 'stalled'), so we never loop on the same dead model.
2230
1484
  if (!interrupted && !s.errors && terminalModelError && !s.text.trim()) {
2231
1485
  s.stopReason = 'model_error';
2232
1486
  s.errors = [terminalModelError];
2233
1487
  }
2234
- // Limit-notice arbitration (see resolveClaudeTuiLimitOutcome). Covers the
2235
- // paths the stall watchdog never reaches: the TUI painted a limit banner,
2236
- // then Stop fired on an empty turn or the process exited — nothing
2237
- // substantive ever followed the banner, so the limit ate the turn. A banner
2238
- // followed by real output stays informational (already in the activity log).
2239
1488
  if (!interrupted && !timedOut && !s.errors) {
2240
1489
  const limitOutcome = resolveClaudeTuiLimitOutcome({
2241
1490
  noticeText: terminalLimitNotice,
@@ -2249,9 +1498,6 @@ export async function doClaudeTuiStream(opts) {
2249
1498
  }
2250
1499
  }
2251
1500
  const errorText = joinErrorMessages(s.errors);
2252
- // "ok" requires: process exited cleanly (or via our own SIGTERM after Stop
2253
- // hook fired, which yields a non-zero exit), no errors from the parser, no
2254
- // user abort, no timeout. SIGTERM-after-Stop is the normal happy path.
2255
1501
  const exitedViaStopHook = stopHookFired && !timedOut && !interrupted;
2256
1502
  const procOk = (exitCode === 0) || exitedViaStopHook;
2257
1503
  const ok = procOk && !s.errors && !timedOut && !interrupted && stopHookFired;
@@ -2265,10 +1511,6 @@ export async function doClaudeTuiStream(opts) {
2265
1511
  const incomplete = !ok || s.stopReason === 'max_tokens' || s.stopReason === 'timeout';
2266
1512
  const elapsedS = (Date.now() - start) / 1000;
2267
1513
  agentLog(`[claude-tui] result ok=${ok} elapsed=${elapsedS.toFixed(1)}s text=${s.text.length}ch thinking=${s.thinking.length}ch session=${s.sessionId || '?'} stop=${stopHookFired}`);
2268
- // Stall diagnostics: a turn that went quiet has now ended. Recording the
2269
- // outcome separates benign long-thinking/long-tool (completed) from true
2270
- // freezes the watchdog had to kill (stalled-killed) — the calibration the
2271
- // threshold tuning needs.
2272
1514
  if (stallDiagWentQuiet) {
2273
1515
  writeStallDiag({
2274
1516
  kind: 'resolved',
@@ -2288,15 +1530,6 @@ export async function doClaudeTuiStream(opts) {
2288
1530
  ok,
2289
1531
  });
2290
1532
  }
2291
- // Build the message body. Order:
2292
- // 1. Any assistant text captured from JSONL (the canonical reply).
2293
- // 2. Parser-surfaced errors.
2294
- // 3. For interrupted runs with no text yet, a clear status — never the
2295
- // raw PTY scrape (it would be a half-rendered TUI screen with no value
2296
- // to the user, and pre-ANSI-strip used to render as garbled gibberish
2297
- // in IM).
2298
- // 4. Fall back to ANSI-stripped stderrCapture for genuine startup
2299
- // failures like "claude: command not found".
2300
1533
  const messageBody = s.text.trim()
2301
1534
  || errorText
2302
1535
  || (interrupted ? '(Interrupted before any reply landed.)'