pikiloop 0.4.0

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 (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +353 -0
  3. package/README.v2.md +287 -0
  4. package/README.zh-CN.md +352 -0
  5. package/dashboard/dist/assets/AgentTab-UZPIhlkr.js +1 -0
  6. package/dashboard/dist/assets/DirBrowser-Ckcmi-Pi.js +1 -0
  7. package/dashboard/dist/assets/ExtensionsTab-KZhEDrdu.js +1 -0
  8. package/dashboard/dist/assets/IMAccessTab-Bd_IY1GQ.js +1 -0
  9. package/dashboard/dist/assets/Modal-CTeL0y7P.js +1 -0
  10. package/dashboard/dist/assets/Modals-axftHasy.js +1 -0
  11. package/dashboard/dist/assets/Select-C8tOdPhe.js +1 -0
  12. package/dashboard/dist/assets/SessionPanel-C1geSRxw.js +1 -0
  13. package/dashboard/dist/assets/SystemTab-DBDkaPiO.js +1 -0
  14. package/dashboard/dist/assets/anthropic-BAdojD7P.ico +0 -0
  15. package/dashboard/dist/assets/codex-DYadqqp0.png +0 -0
  16. package/dashboard/dist/assets/deepseek-BeYNZEk0.ico +0 -0
  17. package/dashboard/dist/assets/doubao-DloFDuFR.png +0 -0
  18. package/dashboard/dist/assets/feishu-C4OMrjCW.ico +0 -0
  19. package/dashboard/dist/assets/gemini-BYkEpiWr.svg +1 -0
  20. package/dashboard/dist/assets/hermes-BAarh-tH.png +0 -0
  21. package/dashboard/dist/assets/index-CpM4CqZJ.js +23 -0
  22. package/dashboard/dist/assets/index-DXSohzrE.js +3 -0
  23. package/dashboard/dist/assets/index-reSbuley.css +1 -0
  24. package/dashboard/dist/assets/markdown-DxQYQFeH.js +29 -0
  25. package/dashboard/dist/assets/minimax-PuEGTfrF.ico +0 -0
  26. package/dashboard/dist/assets/mlx-DhWwjtMw.png +0 -0
  27. package/dashboard/dist/assets/ollama-Bt9O-2K_.png +0 -0
  28. package/dashboard/dist/assets/openrouter-CsJ_bD5Q.ico +0 -0
  29. package/dashboard/dist/assets/playwright-BldPFZgC.ico +0 -0
  30. package/dashboard/dist/assets/qwen-xykkX0_y.png +0 -0
  31. package/dashboard/dist/assets/react-vendor-C7Sl8SE7.js +9 -0
  32. package/dashboard/dist/assets/router-DHISdpPk.js +3 -0
  33. package/dashboard/dist/assets/shared-BIP_4k4I.js +1 -0
  34. package/dashboard/dist/favicon.svg +28 -0
  35. package/dashboard/dist/index.html +17 -0
  36. package/dist/agent/acp-client.js +261 -0
  37. package/dist/agent/auto-update.js +432 -0
  38. package/dist/agent/await-resume.js +50 -0
  39. package/dist/agent/cli/auth.js +325 -0
  40. package/dist/agent/cli/catalog.js +40 -0
  41. package/dist/agent/cli/detector.js +136 -0
  42. package/dist/agent/cli/index.js +7 -0
  43. package/dist/agent/cli/registry.js +33 -0
  44. package/dist/agent/driver.js +39 -0
  45. package/dist/agent/drivers/claude-tui.js +2297 -0
  46. package/dist/agent/drivers/claude.js +2689 -0
  47. package/dist/agent/drivers/codex.js +2210 -0
  48. package/dist/agent/drivers/gemini.js +1059 -0
  49. package/dist/agent/drivers/hermes.js +795 -0
  50. package/dist/agent/goal.js +274 -0
  51. package/dist/agent/handover.js +130 -0
  52. package/dist/agent/images.js +355 -0
  53. package/dist/agent/index.js +50 -0
  54. package/dist/agent/mcp/bridge.js +791 -0
  55. package/dist/agent/mcp/extensions.js +637 -0
  56. package/dist/agent/mcp/oauth.js +353 -0
  57. package/dist/agent/mcp/registry.js +119 -0
  58. package/dist/agent/mcp/session-server.js +229 -0
  59. package/dist/agent/mcp/tools/ask-user.js +113 -0
  60. package/dist/agent/mcp/tools/await-resume.js +77 -0
  61. package/dist/agent/mcp/tools/goal.js +144 -0
  62. package/dist/agent/mcp/tools/types.js +12 -0
  63. package/dist/agent/mcp/tools/workspace.js +212 -0
  64. package/dist/agent/npm.js +31 -0
  65. package/dist/agent/session.js +1206 -0
  66. package/dist/agent/skill-installer.js +160 -0
  67. package/dist/agent/skills.js +257 -0
  68. package/dist/agent/stream.js +743 -0
  69. package/dist/agent/types.js +13 -0
  70. package/dist/agent/utils.js +687 -0
  71. package/dist/bot/bot.js +2499 -0
  72. package/dist/bot/command-ui.js +633 -0
  73. package/dist/bot/commands.js +513 -0
  74. package/dist/bot/headless-bot.js +36 -0
  75. package/dist/bot/host.js +192 -0
  76. package/dist/bot/human-loop.js +168 -0
  77. package/dist/bot/menu.js +48 -0
  78. package/dist/bot/orchestration.js +79 -0
  79. package/dist/bot/render-shared.js +309 -0
  80. package/dist/bot/session-hub.js +361 -0
  81. package/dist/bot/session-status.js +55 -0
  82. package/dist/bot/streaming.js +309 -0
  83. package/dist/browser-profile.js +579 -0
  84. package/dist/browser-supervisor.js +249 -0
  85. package/dist/catalog/cli-tools.js +421 -0
  86. package/dist/catalog/index.js +21 -0
  87. package/dist/catalog/local-models.js +94 -0
  88. package/dist/catalog/mcp-servers.js +315 -0
  89. package/dist/catalog/skill-repos.js +173 -0
  90. package/dist/channels/base.js +55 -0
  91. package/dist/channels/dingtalk/bot.js +549 -0
  92. package/dist/channels/dingtalk/channel.js +268 -0
  93. package/dist/channels/discord/bot.js +552 -0
  94. package/dist/channels/discord/channel.js +245 -0
  95. package/dist/channels/feishu/bot.js +1275 -0
  96. package/dist/channels/feishu/channel.js +911 -0
  97. package/dist/channels/feishu/markdown.js +91 -0
  98. package/dist/channels/feishu/render.js +619 -0
  99. package/dist/channels/health.js +109 -0
  100. package/dist/channels/slack/bot.js +554 -0
  101. package/dist/channels/slack/channel.js +283 -0
  102. package/dist/channels/states.js +6 -0
  103. package/dist/channels/telegram/bot.js +1310 -0
  104. package/dist/channels/telegram/channel.js +820 -0
  105. package/dist/channels/telegram/directory.js +111 -0
  106. package/dist/channels/telegram/live-preview.js +220 -0
  107. package/dist/channels/telegram/render.js +384 -0
  108. package/dist/channels/wecom/bot.js +558 -0
  109. package/dist/channels/wecom/channel.js +479 -0
  110. package/dist/channels/weixin/api.js +520 -0
  111. package/dist/channels/weixin/bot.js +1000 -0
  112. package/dist/channels/weixin/channel.js +222 -0
  113. package/dist/cli/autostart.js +262 -0
  114. package/dist/cli/channel-supervisor.js +313 -0
  115. package/dist/cli/channels.js +54 -0
  116. package/dist/cli/main.js +726 -0
  117. package/dist/cli/onboarding.js +227 -0
  118. package/dist/cli/run.js +308 -0
  119. package/dist/cli/setup-wizard.js +235 -0
  120. package/dist/core/config/runtime-config.js +201 -0
  121. package/dist/core/config/user-config.js +510 -0
  122. package/dist/core/config/validation.js +521 -0
  123. package/dist/core/constants.js +400 -0
  124. package/dist/core/git.js +145 -0
  125. package/dist/core/legacy-compat.js +60 -0
  126. package/dist/core/logging.js +101 -0
  127. package/dist/core/platform.js +59 -0
  128. package/dist/core/process-control.js +315 -0
  129. package/dist/core/secrets/index.js +42 -0
  130. package/dist/core/secrets/inline-seal.js +60 -0
  131. package/dist/core/secrets/ref.js +33 -0
  132. package/dist/core/secrets/resolver.js +65 -0
  133. package/dist/core/secrets/store.js +63 -0
  134. package/dist/core/utils.js +233 -0
  135. package/dist/core/version.js +15 -0
  136. package/dist/dashboard/platform.js +219 -0
  137. package/dist/dashboard/routes/agents.js +450 -0
  138. package/dist/dashboard/routes/cli.js +174 -0
  139. package/dist/dashboard/routes/config.js +523 -0
  140. package/dist/dashboard/routes/extensions.js +745 -0
  141. package/dist/dashboard/routes/local-models.js +290 -0
  142. package/dist/dashboard/routes/models.js +324 -0
  143. package/dist/dashboard/routes/sessions.js +838 -0
  144. package/dist/dashboard/runtime.js +410 -0
  145. package/dist/dashboard/server.js +237 -0
  146. package/dist/dashboard/session-control.js +347 -0
  147. package/dist/model/catalog.js +104 -0
  148. package/dist/model/index.js +20 -0
  149. package/dist/model/injector.js +272 -0
  150. package/dist/model/provider-models.js +112 -0
  151. package/dist/model/store.js +212 -0
  152. package/dist/model/types.js +13 -0
  153. package/dist/model/validation.js +203 -0
  154. package/package.json +82 -0
@@ -0,0 +1,2297 @@
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 `PIKILOOP_CLAUDE_PRINT=1` (or the
10
+ * legacy `PIKILOOP_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 pikiloop 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
+ import fs from 'node:fs';
40
+ import path from 'node:path';
41
+ import { randomUUID } from 'node:crypto';
42
+ import { tmpdir } from 'node:os';
43
+ import { Q, agentLog, agentWarn, buildStreamPreviewMeta, computeContext, joinErrorMessages, emitSessionIdUpdate, normalizeClaudeModelId, pushRecentActivity, summarizeClaudeToolUse, summarizeClaudeToolResult, previewToolCallInput, previewToolCallResult, detectClaudeApiError, detectClaudeModelError, claudeModelErrorMessage, } from '../utils.js';
44
+ import { encodePathAsDirName, getHome, whichSync } from '../../core/platform.js';
45
+ import { createRetainedLogSink } from '../../core/logging.js';
46
+ import { stripAnsiEscapes } from '../../core/utils.js';
47
+ 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
+ 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
+ // ~/.pikiloop/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
+ 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
+ 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
+ let stallDiagSink;
75
+ function writeStallDiag(record) {
76
+ if (stallDiagSink === null)
77
+ return;
78
+ try {
79
+ if (stallDiagSink === undefined) {
80
+ const file = path.join(getHome(), '.pikiloop', 'diagnostics', 'claude-tui-stall.jsonl');
81
+ stallDiagSink = createRetainedLogSink(file, {
82
+ maxLines: 50_000,
83
+ maxAgeMs: 14 * 24 * 60 * 60_000,
84
+ trimEveryWrites: 500,
85
+ });
86
+ agentLog(`[claude-tui] stall diagnostics → ${file}`);
87
+ }
88
+ stallDiagSink(JSON.stringify({ ts: Date.now(), ...record }) + '\n');
89
+ }
90
+ catch {
91
+ stallDiagSink = null;
92
+ }
93
+ }
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
+ export function classifyClaudeJsonlEvent(ev) {
98
+ const type = typeof ev?.type === 'string' ? ev.type : 'unknown';
99
+ const content = ev?.message?.content;
100
+ if (Array.isArray(content)) {
101
+ if (content.some((b) => b?.type === 'tool_use'))
102
+ return `${type}:tool_use`;
103
+ if (content.some((b) => b?.type === 'tool_result'))
104
+ return `${type}:tool_result`;
105
+ if (content.some((b) => b?.type === 'thinking'))
106
+ return `${type}:thinking`;
107
+ if (content.some((b) => b?.type === 'text'))
108
+ return `${type}:text`;
109
+ }
110
+ return type;
111
+ }
112
+ 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
+ const specifier = 'node-pty';
119
+ const mod = await import(/* @vite-ignore */ specifier);
120
+ const api = mod?.default ?? mod;
121
+ if (!api?.spawn)
122
+ throw new Error('node-pty loaded but spawn() is missing');
123
+ await preflightSpawnHelper();
124
+ return api;
125
+ }
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
+ let spawnHelperPreflightDone = false;
135
+ async function preflightSpawnHelper() {
136
+ if (spawnHelperPreflightDone || process.platform === 'win32') {
137
+ spawnHelperPreflightDone = true;
138
+ return;
139
+ }
140
+ spawnHelperPreflightDone = true;
141
+ 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
+ const ptyRoot = await locatePtyPackageRoot();
145
+ if (!ptyRoot)
146
+ return;
147
+ const arch = process.arch === 'arm64' ? 'arm64' : 'x64';
148
+ const platform = process.platform === 'darwin' ? 'darwin' : 'linux';
149
+ const helper = path.join(ptyRoot, 'prebuilds', `${platform}-${arch}`, 'spawn-helper');
150
+ if (!fs.existsSync(helper))
151
+ return;
152
+ const stat = fs.statSync(helper);
153
+ if ((stat.mode & 0o111) === 0) {
154
+ fs.chmodSync(helper, stat.mode | 0o755);
155
+ agentLog(`[claude-tui] restored executable bit on ${helper}`);
156
+ }
157
+ }
158
+ catch (e) {
159
+ agentWarn(`[claude-tui] spawn-helper preflight skipped: ${e?.message || e}`);
160
+ }
161
+ }
162
+ 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
+ let dir = path.dirname(new URL(import.meta.url).pathname);
167
+ for (let i = 0; i < 8; i++) {
168
+ const candidate = path.join(dir, 'node_modules', 'node-pty');
169
+ if (fs.existsSync(path.join(candidate, 'package.json')))
170
+ return candidate;
171
+ const parent = path.dirname(dir);
172
+ if (parent === dir)
173
+ break;
174
+ dir = parent;
175
+ }
176
+ return null;
177
+ }
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
+ const HOOK_SCRIPT = `#!/usr/bin/env node
184
+ "use strict";
185
+ const fs = require("node:fs");
186
+ const event = process.argv[2] || "";
187
+ const stateFile = process.argv[3] || "";
188
+ const toolEventsFile = process.argv[4] || "";
189
+ let stdin = "";
190
+ process.stdin.setEncoding("utf8");
191
+ process.stdin.on("data", (d) => { stdin += d; });
192
+ process.stdin.on("end", () => {
193
+ let payload = {};
194
+ try { payload = stdin ? JSON.parse(stdin) : {}; } catch (_) {}
195
+ // Tool events go to an append-only JSONL. Sequential lifecycle events
196
+ // (SessionStart / UserPromptSubmit / Stop) still use the state file —
197
+ // they fire once each so the read-modify-write race is benign there.
198
+ if ((event === "PreToolUse" || event === "PostToolUse") && toolEventsFile) {
199
+ const line = JSON.stringify({
200
+ event,
201
+ at: Date.now(),
202
+ tool_use_id: typeof payload.tool_use_id === "string" ? payload.tool_use_id : null,
203
+ tool_name: typeof payload.tool_name === "string" ? payload.tool_name : null,
204
+ tool_input: payload.tool_input || null,
205
+ tool_response: payload.tool_response || null,
206
+ // Claude Code tags sub-agent tool calls with agent_id so the parent can
207
+ // tell them apart from main-thread calls. Forwarding it lets the driver
208
+ // route the hook to the right sub-agent card instead of the parent's
209
+ // 执行 list.
210
+ agent_id: typeof payload.agent_id === "string" ? payload.agent_id : null,
211
+ }) + "\\n";
212
+ try { fs.appendFileSync(toolEventsFile, line); } catch (_) {}
213
+ process.stdout.write(JSON.stringify({ continue: true }) + "\\n");
214
+ return;
215
+ }
216
+ let state = {};
217
+ try { state = JSON.parse(fs.readFileSync(stateFile, "utf8")); } catch (_) {}
218
+ state.events = Array.isArray(state.events) ? state.events : [];
219
+ state.events.push({ event, at: Date.now() });
220
+ const sid = typeof payload.session_id === "string" ? payload.session_id : null;
221
+ const tpath = typeof payload.transcript_path === "string" ? payload.transcript_path : null;
222
+ if (sid) state.sessionId = sid;
223
+ if (tpath) state.transcriptPath = tpath;
224
+ if (event === "SessionStart") state.sessionStartedAt = Date.now();
225
+ else if (event === "UserPromptSubmit") state.promptSubmittedAt = Date.now();
226
+ else if (event === "Stop") state.stoppedAt = Date.now();
227
+ try { fs.writeFileSync(stateFile, JSON.stringify(state)); } catch (_) {}
228
+ process.stdout.write(JSON.stringify({ continue: true }) + "\\n");
229
+ });
230
+ process.stdin.on("error", () => {
231
+ try { process.stdout.write(JSON.stringify({ continue: true }) + "\\n"); } catch (_) {}
232
+ });
233
+ `;
234
+ function readHookState(statePath) {
235
+ try {
236
+ return JSON.parse(fs.readFileSync(statePath, 'utf8'));
237
+ }
238
+ catch {
239
+ return {};
240
+ }
241
+ }
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
+ function readJsonlIncrement(filePath, fromOffset) {
249
+ try {
250
+ const stat = fs.statSync(filePath);
251
+ if (stat.size <= fromOffset)
252
+ return { offset: fromOffset, lines: [] };
253
+ const len = stat.size - fromOffset;
254
+ const fd = fs.openSync(filePath, 'r');
255
+ const buf = Buffer.alloc(len);
256
+ fs.readSync(fd, buf, 0, len, fromOffset);
257
+ fs.closeSync(fd);
258
+ const chunk = buf.toString('utf8');
259
+ if (!chunk)
260
+ return { offset: fromOffset, lines: [] };
261
+ const endsWithNewline = chunk[chunk.length - 1] === '\n';
262
+ const segments = chunk.split('\n');
263
+ if (endsWithNewline) {
264
+ // Last segment after split is empty — drop it.
265
+ segments.pop();
266
+ return { offset: stat.size, lines: segments };
267
+ }
268
+ // Partial last line — keep its bytes unread for the next tick.
269
+ const lastLine = segments.pop() || '';
270
+ const consumed = stat.size - Buffer.byteLength(lastLine, 'utf8');
271
+ return { offset: consumed, lines: segments };
272
+ }
273
+ catch {
274
+ return { offset: fromOffset, lines: [] };
275
+ }
276
+ }
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
+ const TUI_STREAM_CHUNK_CHARS = 20;
282
+ const TUI_STREAM_CHUNK_INTERVAL_MS = 20;
283
+ function makeTuiStreamBuffer() {
284
+ return { trueText: '', displayedLen: 0, timer: null };
285
+ }
286
+ function extractTextBlocks(content) {
287
+ if (!Array.isArray(content))
288
+ return '';
289
+ return content
290
+ .filter((block) => block?.type === 'text' && typeof block.text === 'string')
291
+ .map((block) => block.text)
292
+ .join('\n')
293
+ .trim();
294
+ }
295
+ function normalizedNoticeLines(text) {
296
+ return stripAnsiEscapes(text)
297
+ .split(/\r?\n/)
298
+ .map(line => line.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, ''))
299
+ .map(line => line.replace(/\s+/g, ' ').trim())
300
+ .filter(Boolean);
301
+ }
302
+ function limitNoticeFromText(text) {
303
+ if (!text)
304
+ return null;
305
+ const patterns = [
306
+ /you(?:'|’)ve hit your (?:session|usage) limit/i,
307
+ /you have hit your (?:session|usage) limit/i,
308
+ /(?:session|usage) limit (?:reached|exceeded)/i,
309
+ /(?:session|usage) limit.{0,100}resets?/i,
310
+ /(?:rate limit|rate limited).{0,100}(?:try again|resets?|later)/i,
311
+ /(?:try again|resets?|later).{0,100}(?:rate limit|rate limited)/i,
312
+ ];
313
+ for (const line of normalizedNoticeLines(text)) {
314
+ if (patterns.some(pattern => pattern.test(line)))
315
+ return line.slice(0, 240);
316
+ }
317
+ return null;
318
+ }
319
+ export function detectClaudeTuiTerminalLimitNotice(msgOrText) {
320
+ if (typeof msgOrText === 'string')
321
+ return limitNoticeFromText(msgOrText);
322
+ if (!msgOrText || msgOrText.model !== '<synthetic>')
323
+ return null;
324
+ return limitNoticeFromText(extractTextBlocks(msgOrText.content));
325
+ }
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
+ export function resolveClaudeTuiLimitOutcome(input) {
346
+ if (!input.noticeText)
347
+ return 'none';
348
+ if (input.hasOutputText || input.lastSubstantiveEventAt > input.noticeAt)
349
+ return 'info';
350
+ return 'fatal';
351
+ }
352
+ /**
353
+ * Detect Claude Code's startup "Bypass Permissions mode" confirmation dialog in
354
+ * a slice of (ANSI-stripped) PTY screen output. When pikiloop 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
+ export function detectClaudeBypassPrompt(screen) {
372
+ if (typeof screen !== 'string' || !screen)
373
+ 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
+ const t = stripAnsiEscapes(screen).replace(/\s+/g, '').toLowerCase();
381
+ return t.includes('bypasspermissionsmode')
382
+ && t.includes('yes,iaccept')
383
+ && t.includes('no,exit');
384
+ }
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
+ export function detectClaudeProceedPrompt(screen) {
410
+ return classifyClaudeScreen(screen).state === 'confirm-prompt';
411
+ }
412
+ /**
413
+ * Read what determinate state Claude's TUI is in from a slice of (ANSI-stripped) PTY screen
414
+ * output. This is the single source of truth consumed by BOTH the in-flight auto-answer (onData)
415
+ * and the stall watchdog: when a turn goes quiet we cannot tell from timing alone whether the TUI
416
+ * is (a) frozen mid-turn (the known CLI freeze — PTY dead), (b) thinking for a long time (PTY
417
+ * repaints a spinner), (c) blocked on an interactive confirm bypass mode does NOT suppress
418
+ * (ask-rule "Do you want to proceed?", trust-a-new-folder), (d) sitting back at the idle REPL
419
+ * (turn finished but the Stop hook was missed/held), or (e) showing a model-unavailable banner.
420
+ *
421
+ * Keys on STRUCTURAL invariants, not exact footers — Claude lays words out with cursor-move
422
+ * escapes so the despaced screen runs together ("doyouwanttoproceed"), and footers TRUNCATE at the
423
+ * 200-col edge ("Esc to cancel" → "sctocancel"). So the footer is corroborating, never required;
424
+ * the load-bearing signals are the cursor'd numbered select (`❯`+`1.`) plus the proceed/confirm
425
+ * question, and the persistent idle mode-line. Robust to claude version churn for the same reason.
426
+ *
427
+ * Default-deny: anything not high-confidence returns 'unknown', because mislabelling a real freeze
428
+ * as a clearable/idle state would convert a self-healing stall (auto-resume) into a silently
429
+ * dropped turn — ambiguity must bias to the freeze path.
430
+ */
431
+ export function classifyClaudeScreen(screen) {
432
+ if (typeof screen !== 'string' || !screen)
433
+ return { state: 'unknown', affirmativeKey: null, sample: '' };
434
+ const stripped = stripAnsiEscapes(screen);
435
+ 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
+ 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
+ if (ds.includes('bypasspermissionsmode') && ds.includes('yes,iaccept') && ds.includes('no,exit')) {
443
+ return { state: 'bypass-startup', affirmativeKey: '2', sample };
444
+ }
445
+ // 2. Selected-model-unavailable banner. Distinctive phrasing; reuse the shared detector so the
446
+ // -p and TUI paths stay in lockstep.
447
+ if (detectClaudeModelError(ds))
448
+ return { state: 'model-error', affirmativeKey: null, sample };
449
+ 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).
454
+ if ((asksProceed || ds.includes('readytoexecute'))
455
+ && (ds.includes('manuallyapproveedits') || ds.includes('yes,andbypasspermissions'))) {
456
+ return { state: 'plan-approval', affirmativeKey: null, sample };
457
+ }
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
+ if ((asksProceed || ds.includes('requiresconfirmation')) && hasCursorSelect) {
461
+ return { state: 'confirm-prompt', affirmativeKey: '1', sample };
462
+ }
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
+ if (ds.includes('trustthisfolder'))
466
+ return { state: 'confirm-prompt', affirmativeKey: '1', sample };
467
+ if ((asksProceed || ds.includes('doyouwant')) && ds.includes('(y/n)')) {
468
+ return { state: 'confirm-prompt', affirmativeKey: 'y', sample };
469
+ }
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
+ if (ds.includes('bypasspermissionson')
475
+ && (ds.includes('shift+tabtocycle') || ds.includes('foragents') || ds.includes('tomanage'))
476
+ && !ds.includes('esctointerrupt')) {
477
+ return { state: 'idle-repl', affirmativeKey: null, sample };
478
+ }
479
+ return { state: 'unknown', affirmativeKey: null, sample };
480
+ }
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
+ export function classifyStallScreen(screen) {
487
+ const info = classifyClaudeScreen(screen);
488
+ const looksLikePrompt = info.state === 'confirm-prompt'
489
+ || info.state === 'plan-approval' || info.state === 'bypass-startup';
490
+ return { looksLikePrompt, sample: info.sample };
491
+ }
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
+ function readAssignedTaskIdFromHookResponse(toolResponse) {
507
+ const structured = toolResponse?.task?.id;
508
+ if (structured != null && String(structured).trim())
509
+ return String(structured).trim();
510
+ if (typeof toolResponse === 'string') {
511
+ const m = toolResponse.match(/Task #(\d+)/);
512
+ if (m)
513
+ return m[1];
514
+ }
515
+ if (toolResponse && typeof toolResponse.result === 'string') {
516
+ const m = toolResponse.result.match(/Task #(\d+)/);
517
+ if (m)
518
+ return m[1];
519
+ }
520
+ return null;
521
+ }
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
+ export function applyHookToolEvent(ev, s) {
532
+ const toolUseId = String(ev?.tool_use_id || '').trim();
533
+ const toolName = String(ev?.tool_name || '').trim();
534
+ if (!toolName || !toolUseId)
535
+ 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
+ const subAgentId = typeof ev?.agent_id === 'string' && ev.agent_id ? ev.agent_id : '';
543
+ if (subAgentId) {
544
+ if (ev.event === 'PreToolUse') {
545
+ const parentToolUseId = s.subAgentIdToParent?.get(subAgentId);
546
+ const sub = parentToolUseId ? s.subAgents?.get(parentToolUseId) : undefined;
547
+ if (sub && !sub.tools.some((t) => t.id === toolUseId)) {
548
+ const summary = toolName === 'TodoWrite'
549
+ ? 'Update plan'
550
+ : summarizeClaudeToolUse(toolName, ev.tool_input || {});
551
+ sub.tools.push({ id: toolUseId, name: toolName, summary });
552
+ }
553
+ }
554
+ return true;
555
+ }
556
+ if (ev.event === 'PreToolUse') {
557
+ if (s.seenClaudeToolIds.has(toolUseId))
558
+ return false;
559
+ if (toolName === 'TaskCreate') {
560
+ const subject = typeof ev.tool_input?.subject === 'string' ? ev.tool_input.subject.trim() : '';
561
+ if (subject)
562
+ s.pendingClaudeTaskCreates.set(toolUseId, { subject });
563
+ s.seenClaudeToolIds.add(toolUseId);
564
+ s.claudeToolsById.set(toolUseId, { name: toolName, summary: subject ? `Create task: ${subject}` : 'Create task' });
565
+ return true;
566
+ }
567
+ if (toolName === 'TaskUpdate') {
568
+ const taskId = String(ev.tool_input?.taskId ?? '').trim();
569
+ const rawStatus = String(ev.tool_input?.status ?? '').trim().toLowerCase();
570
+ if (taskId) {
571
+ if (rawStatus === 'deleted') {
572
+ s.claudeTaskList.delete(taskId);
573
+ s.claudeTaskOrder = s.claudeTaskOrder.filter((id) => id !== taskId);
574
+ }
575
+ else if (rawStatus) {
576
+ const existing = s.claudeTaskList.get(taskId);
577
+ if (existing)
578
+ existing.status = rawStatus;
579
+ }
580
+ rebuildClaudePlanFromTasksFromState(s);
581
+ }
582
+ s.seenClaudeToolIds.add(toolUseId);
583
+ s.claudeToolsById.set(toolUseId, { name: toolName, summary: `Update task ${taskId || '?'} → ${rawStatus || 'unknown'}` });
584
+ return true;
585
+ }
586
+ if (toolName === 'TodoWrite') {
587
+ const plan = parseTodoWriteAsPlanLite(ev.tool_input);
588
+ if (plan)
589
+ s.plan = plan;
590
+ s.seenClaudeToolIds.add(toolUseId);
591
+ s.claudeToolsById.set(toolUseId, { name: toolName, summary: 'Update plan' });
592
+ return true;
593
+ }
594
+ 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
+ const input = ev.tool_input || {};
602
+ const desc = typeof input.description === 'string' ? input.description.trim() : '';
603
+ const kind = typeof input.subagent_type === 'string' ? input.subagent_type.trim() : '';
604
+ if (!s.subAgents.has(toolUseId)) {
605
+ s.subAgents.set(toolUseId, {
606
+ id: toolUseId,
607
+ kind: kind || null,
608
+ description: desc || null,
609
+ model: null,
610
+ tools: [],
611
+ status: 'running',
612
+ });
613
+ }
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
+ if (input.run_in_background === true)
619
+ registerClaudeBackgroundAgentLaunch(s, toolUseId);
620
+ s.seenClaudeToolIds.add(toolUseId);
621
+ s.claudeToolsById.set(toolUseId, { name: toolName, summary: desc || kind || 'Sub-agent' });
622
+ return true;
623
+ }
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
+ if (toolName === 'Bash' && ev.tool_input?.run_in_background === true) {
628
+ registerClaudeBackgroundBashLaunch(s, toolUseId);
629
+ }
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
+ if (toolName === 'Workflow') {
636
+ registerClaudeBackgroundAgentLaunch(s, toolUseId);
637
+ }
638
+ const summary = summarizeClaudeToolUse(toolName, ev.tool_input || {});
639
+ pushRecentActivity(s.recentActivity, summary);
640
+ s.seenClaudeToolIds.add(toolUseId);
641
+ s.claudeToolsById.set(toolUseId, {
642
+ name: toolName,
643
+ summary,
644
+ input: previewToolCallInput(toolName, ev.tool_input),
645
+ status: 'running',
646
+ });
647
+ if (!s.claudeToolCallOrder)
648
+ s.claudeToolCallOrder = [];
649
+ s.claudeToolCallOrder.push(toolUseId);
650
+ s.activity = s.recentActivity.join('\n');
651
+ return true;
652
+ }
653
+ if (ev.event === 'PostToolUse') {
654
+ if (!s.seenClaudeToolResultIds)
655
+ s.seenClaudeToolResultIds = new Set();
656
+ if (s.seenClaudeToolResultIds.has(toolUseId))
657
+ return false;
658
+ if (toolName === 'TaskCreate') {
659
+ const pending = s.pendingClaudeTaskCreates.get(toolUseId);
660
+ const assignedId = readAssignedTaskIdFromHookResponse(ev.tool_response);
661
+ if (pending && assignedId) {
662
+ s.pendingClaudeTaskCreates.delete(toolUseId);
663
+ if (!s.claudeTaskList.has(assignedId))
664
+ s.claudeTaskOrder.push(assignedId);
665
+ s.claudeTaskList.set(assignedId, { subject: pending.subject, status: 'pending' });
666
+ rebuildClaudePlanFromTasksFromState(s);
667
+ }
668
+ s.seenClaudeToolResultIds.add(toolUseId);
669
+ return true;
670
+ }
671
+ if (toolName === 'TaskUpdate' || toolName === 'TodoWrite') {
672
+ s.seenClaudeToolResultIds.add(toolUseId);
673
+ return true;
674
+ }
675
+ 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
+ const sub = s.subAgents.get(toolUseId);
684
+ if (sub) {
685
+ const isBgLaunchAck = !ev.tool_response?.is_error
686
+ && (ev.tool_input?.run_in_background === true
687
+ || (s.bgAgentLaunchedToolUseIds?.has(toolUseId) && !s.bgAgentCompletedToolUseIds?.has(toolUseId)));
688
+ if (!isBgLaunchAck)
689
+ sub.status = ev.tool_response?.is_error ? 'failed' : 'done';
690
+ }
691
+ s.seenClaudeToolResultIds.add(toolUseId);
692
+ return true;
693
+ }
694
+ const tool = s.claudeToolsById.get(toolUseId);
695
+ if (tool) {
696
+ tool.result = previewToolCallResult(ev.tool_response);
697
+ tool.status = ev.tool_response?.is_error ? 'failed' : 'done';
698
+ const summary = summarizeClaudeToolResult(tool, { content: ev.tool_response }, ev.tool_response);
699
+ if (summary) {
700
+ pushRecentActivity(s.recentActivity, summary);
701
+ s.activity = s.recentActivity.join('\n');
702
+ }
703
+ }
704
+ // Background Bash launch ack → map task id → tool_use for notification
705
+ // resolution (bash notifications usually omit <tool-use-id>).
706
+ if (toolName === 'Bash' && s.bgBashToolUseIds?.has(toolUseId)
707
+ && !s.bgAgentCompletedToolUseIds?.has(toolUseId)) {
708
+ const taskId = extractClaudeBackgroundTaskId(ev.tool_response);
709
+ if (taskId && !s.bgTaskIdToToolUse.has(taskId))
710
+ s.bgTaskIdToToolUse.set(taskId, toolUseId);
711
+ }
712
+ // Workflow launch ack → map runId → tool_use for notification resolution
713
+ // (the workflow's <task-notification> may carry only the task id).
714
+ if (toolName === 'Workflow' && s.bgAgentLaunchedToolUseIds?.has(toolUseId)
715
+ && !s.bgAgentCompletedToolUseIds?.has(toolUseId)) {
716
+ const runId = extractClaudeWorkflowRunId(ev.tool_response);
717
+ if (runId && !s.bgTaskIdToToolUse.has(runId))
718
+ s.bgTaskIdToToolUse.set(runId, toolUseId);
719
+ }
720
+ s.seenClaudeToolResultIds.add(toolUseId);
721
+ return true;
722
+ }
723
+ return false;
724
+ }
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
+ function parseTodoWriteAsPlanLite(input) {
731
+ if (!input || typeof input !== 'object')
732
+ return null;
733
+ const rawTodos = Array.isArray(input.todos) ? input.todos : [];
734
+ if (!rawTodos.length)
735
+ return null;
736
+ const steps = [];
737
+ for (const todo of rawTodos) {
738
+ if (!todo || typeof todo !== 'object')
739
+ continue;
740
+ const content = typeof todo.content === 'string' ? todo.content.trim() : '';
741
+ if (!content)
742
+ continue;
743
+ const rawStatus = typeof todo.status === 'string' ? todo.status : 'pending';
744
+ const status = rawStatus === 'completed' ? 'completed'
745
+ : rawStatus === 'in_progress' ? 'inProgress'
746
+ : 'pending';
747
+ steps.push({ step: content, status });
748
+ }
749
+ if (!steps.length)
750
+ return null;
751
+ return { explanation: null, steps };
752
+ }
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
+ function rebuildClaudePlanFromTasksFromState(s) {
759
+ if (!s.claudeTaskOrder?.length)
760
+ return;
761
+ const steps = [];
762
+ for (const id of s.claudeTaskOrder) {
763
+ const task = s.claudeTaskList.get(id);
764
+ if (!task)
765
+ continue;
766
+ const lowered = String(task.status || '').toLowerCase();
767
+ const status = lowered === 'completed' ? 'completed'
768
+ : lowered === 'in_progress' || lowered === 'inprogress' ? 'inProgress'
769
+ : 'pending';
770
+ steps.push({ step: task.subject, status });
771
+ }
772
+ s.plan = { explanation: null, steps };
773
+ }
774
+ function applyAssistantStreaming(s, msg, buf) {
775
+ if (!msg || msg.model === '<synthetic>')
776
+ return;
777
+ const contents = Array.isArray(msg.content) ? msg.content : [];
778
+ let appendText = '';
779
+ let appendThinking = '';
780
+ for (const block of contents) {
781
+ if (!block)
782
+ continue;
783
+ if (block.type === 'text' && typeof block.text === 'string') {
784
+ appendText += (appendText ? '\n\n' : '') + block.text;
785
+ }
786
+ else if (block.type === 'thinking' && typeof block.thinking === 'string') {
787
+ appendThinking += (appendThinking ? '\n\n' : '') + block.thinking;
788
+ }
789
+ }
790
+ if (appendText) {
791
+ buf.trueText = buf.trueText ? `${buf.trueText}\n\n${appendText}` : appendText;
792
+ }
793
+ if (appendThinking) {
794
+ s.thinking = s.thinking ? `${s.thinking}\n\n${appendThinking}` : appendThinking;
795
+ }
796
+ }
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
+ function callClaudeParseForTui(ev, s) {
806
+ if (ev.type !== 'assistant' || !ev.message) {
807
+ claudeParse(ev, s);
808
+ return;
809
+ }
810
+ const filtered = {
811
+ ...ev,
812
+ message: {
813
+ ...ev.message,
814
+ content: Array.isArray(ev.message.content)
815
+ ? ev.message.content.filter((b) => b?.type !== 'text' && b?.type !== 'thinking')
816
+ : ev.message.content,
817
+ },
818
+ };
819
+ claudeParse(filtered, s);
820
+ }
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
+ function applyModelContextWindow(s) {
831
+ if (s.byokContextWindow)
832
+ return;
833
+ const advertised = claudeContextWindowFromModel(s.model);
834
+ const effective = claudeEffectiveContextWindow(advertised);
835
+ if (effective != null)
836
+ s.contextWindow = effective;
837
+ }
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
+ function applyAssistantUsage(s, msg) {
843
+ const u = msg?.usage;
844
+ if (!u || typeof u !== 'object')
845
+ 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
+ const msgId = typeof msg?.id === 'string' && msg.id ? msg.id : null;
851
+ if (msgId && msgId !== s.turnUsageMsgId) {
852
+ if (s.turnUsageMsgId != null)
853
+ s.turnOutputTokensBase = (s.turnOutputTokensBase ?? 0) + (s.outputTokens ?? 0);
854
+ s.turnUsageMsgId = msgId;
855
+ }
856
+ if (typeof u.input_tokens === 'number')
857
+ s.inputTokens = u.input_tokens;
858
+ if (typeof u.output_tokens === 'number')
859
+ s.outputTokens = u.output_tokens;
860
+ if (typeof u.cache_read_input_tokens === 'number')
861
+ s.cachedInputTokens = u.cache_read_input_tokens;
862
+ if (typeof u.cache_creation_input_tokens === 'number')
863
+ s.cacheCreationInputTokens = u.cache_creation_input_tokens;
864
+ const total = (s.inputTokens ?? 0) + (s.cachedInputTokens ?? 0) + (s.cacheCreationInputTokens ?? 0) + (s.outputTokens ?? 0);
865
+ s.contextUsedTokens = total > 0 ? total : null;
866
+ }
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
+ 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
+ export function decideClaudeTuiStop(input) {
903
+ if (input.pendingBackgroundAgents > 0) {
904
+ const ttl = input.holdQuietTtlMs ?? CLAUDE_TUI_STOP_HOLD_QUIET_TTL_MS;
905
+ const lastActivityAt = Math.max(input.stoppedAt, input.lastJsonlEventAt, input.lastTaskNotificationAt, input.lastHookOrSidecarEventAt ?? 0);
906
+ if (input.now - lastActivityAt > ttl)
907
+ return 'terminate'; // 幽灵 hold:全通道静默超 TTL
908
+ return 'hold-background';
909
+ }
910
+ const stopIsStale = input.lastTaskNotificationAt > 0 && input.lastTaskNotificationAt >= input.stoppedAt;
911
+ if (stopIsStale) {
912
+ const quietMs = input.resettleQuietMs ?? BG_RESETTLE_QUIET_MS;
913
+ const lastActivityAt = Math.max(input.lastJsonlEventAt, input.lastTaskNotificationAt);
914
+ if (input.now - lastActivityAt < quietMs)
915
+ return 'hold-resettle';
916
+ }
917
+ return 'terminate';
918
+ }
919
+ /**
920
+ * Decide whether the turn has gone dead. claude CLI is known to freeze
921
+ * mid-turn (observed 2026-06-02 on 2.1.160): after a tool_result lands the
922
+ * next assistant segment never starts — the process stays alive, the JSONL
923
+ * goes permanently quiet, no Stop hook ever fires, no error surfaces. Without
924
+ * a watchdog the IM card spins forever.
925
+ *
926
+ * `lastProgressAt` is the freshest of every live signal the driver tracks
927
+ * (main JSONL, hook tool events, sub-agent sidecars, hook lifecycle state).
928
+ * A pending tool (PreToolUse seen, no PostToolUse) extends the threshold:
929
+ * the freeze can also hit mid-execution, but a legitimately long foreground
930
+ * command must not get shot — claude's own Bash timeout fires PostToolUse
931
+ * well inside CLAUDE_TUI_STALL_PENDING_TOOL_MS.
932
+ *
933
+ * Fast path: `lastPtyDataAt` is raw PTY output (any repaint frame counts). A
934
+ * healthy TUI animates continuously mid-turn — spinner, stream ticks, status
935
+ * line — so PTY byte-silence is the cheapest possible "event loop is dead"
936
+ * detector. When BOTH the PTY and all structured signals have been silent
937
+ * past `ptyDeadMs`, declare the stall immediately instead of waiting out the
938
+ * 10/30-minute quiet thresholds. Long thinking and long foreground commands
939
+ * keep painting frames, which routes them to the slow thresholds as before.
940
+ */
941
+ export function decideClaudeTuiStall(input) {
942
+ const ptyAt = input.lastPtyDataAt ?? 0;
943
+ if (ptyAt > 0) {
944
+ const ptyDeadMs = input.ptyDeadMs ?? CLAUDE_TUI_STALL_PTY_DEAD_MS;
945
+ if (input.now - Math.max(ptyAt, input.lastProgressAt) > ptyDeadMs)
946
+ return 'stall';
947
+ }
948
+ const threshold = input.pendingToolCount > 0
949
+ ? (input.pendingToolMs ?? CLAUDE_TUI_STALL_PENDING_TOOL_MS)
950
+ : (input.quietMs ?? CLAUDE_TUI_STALL_QUIET_MS);
951
+ return input.now - input.lastProgressAt > threshold ? 'stall' : 'wait';
952
+ }
953
+ /**
954
+ * Map the screen state at the moment the stall watchdog would fire to what we should actually do.
955
+ * This is the chokepoint the diagnostics proved was missing: today the screen is classified at kill
956
+ * time but the verdict only changes the error STRING, never the action — so confirm-dialog /
957
+ * idle-REPL / model-error turns get SIGTERMed as 'stalled' and auto-resumed into the same wall.
958
+ *
959
+ * Safety (default-deny): a non-'unknown' state may only DOWNGRADE to a still-terminating, non-
960
+ * resuming outcome — it never cancels termination and never waits beyond one bounded retry. Anything
961
+ * ambiguous falls through to 'terminate-stalled' (today's self-healing path), because mislabelling a
962
+ * real freeze as clearable/idle would convert an auto-resumable stall into a silently dropped turn.
963
+ */
964
+ export function decideStallAction(input) {
965
+ if (input.state === 'model-error')
966
+ return 'model-error';
967
+ // Answerable dialogs (ask-rule confirm / startup bypass). Plan-approval reaches here too but
968
+ // carries affirmativeKey=null under the current policy → falls straight to unanswered.
969
+ if (input.state === 'confirm-prompt' || input.state === 'plan-approval' || input.state === 'bypass-startup') {
970
+ if (input.affirmativeKey && !input.alreadyTriedAnswer)
971
+ return 'answer-retry';
972
+ return 'terminate-prompt-unanswered';
973
+ }
974
+ if (input.state === 'idle-repl') {
975
+ // Back at the prompt = the turn ended (Stop hook missed/held). Terminate cleanly ONLY when no
976
+ // background work is outstanding: a bg agent/bash lives inside the claude process and bg-Bash
977
+ // is silent by nature, so killing an idle-looking screen with pending bg would abort live work
978
+ // ("进程退出把子代理打断"). Leave those to the existing Stop-hold / TTL machinery.
979
+ return input.pendingBgAgents > 0 ? 'terminate-stalled' : 'terminate-clean';
980
+ }
981
+ return 'terminate-stalled';
982
+ }
983
+ // ---------------------------------------------------------------------------
984
+ // Main entry
985
+ // ---------------------------------------------------------------------------
986
+ export async function doClaudeTuiStream(opts) {
987
+ const start = Date.now();
988
+ const deadline = start + opts.timeout * 1000;
989
+ // 0. Probe node-pty FIRST — before any temp-dir creation or session work.
990
+ // If it's not installed (or its prebuilt helper can't be made executable),
991
+ // throw so the dispatcher in claude.ts catches the error and falls back to
992
+ // print mode. No cleanup needed because no resources have been allocated.
993
+ const pty = await loadPty();
994
+ // 1. Resolve session lifecycle.
995
+ const isFork = !!opts.forkOf;
996
+ const isResume = !isFork && !!opts.sessionId;
997
+ const newSessionId = (isFork || !isResume) ? randomUUID() : opts.sessionId;
998
+ const home = getHome();
999
+ const projectDir = path.join(home, '.claude', 'projects', encodePathAsDirName(opts.workdir));
1000
+ // For resume we know the exact file; for new/fork we either know upfront
1001
+ // (--session-id) or learn it from the SessionStart hook (--fork-session
1002
+ // rotates to a fresh uuid Claude generates on its own).
1003
+ let activeSessionId = isResume ? opts.sessionId : newSessionId;
1004
+ let activeJsonlPath = path.join(projectDir, `${activeSessionId}.jsonl`);
1005
+ // Resume: skip everything that was already in the transcript before our turn.
1006
+ let jsonlReadOffset = 0;
1007
+ if (isResume) {
1008
+ try {
1009
+ jsonlReadOffset = fs.statSync(activeJsonlPath).size;
1010
+ }
1011
+ catch { }
1012
+ }
1013
+ // 2. Temp workspace for hook script + state + settings.
1014
+ let workDir;
1015
+ try {
1016
+ workDir = fs.mkdtempSync(path.join(tmpdir(), 'pikiloop-claude-tui-'));
1017
+ }
1018
+ catch (e) {
1019
+ return makeErrorResult(opts, start, `Failed to create temp dir: ${e?.message || e}`);
1020
+ }
1021
+ const hookPath = path.join(workDir, 'hook.cjs');
1022
+ const statePath = path.join(workDir, 'state.json');
1023
+ const toolEventsPath = path.join(workDir, 'tool-events.jsonl');
1024
+ const settingsPath = path.join(workDir, 'settings.json');
1025
+ const ptyLogPath = path.join(workDir, 'pty.log');
1026
+ try {
1027
+ fs.writeFileSync(hookPath, HOOK_SCRIPT, { mode: 0o755 });
1028
+ fs.writeFileSync(statePath, JSON.stringify({ events: [] }));
1029
+ fs.writeFileSync(toolEventsPath, '');
1030
+ // Use the same Node binary that's running pikiloop — `node` may not be on
1031
+ // PATH inside the claude TUI's hook subprocess on every distro.
1032
+ const nodeBin = Q(process.execPath);
1033
+ const hookCmd = (event) => `${nodeBin} ${Q(hookPath)} ${event} ${Q(statePath)} ${Q(toolEventsPath)}`;
1034
+ // Pre/PostToolUse hooks give us a live tool-event stream. The transcript
1035
+ // JSONL is itself written incrementally (events land ~0.2–1.2s after they
1036
+ // happen — measured on 2.1.173), but the hooks still earn their keep:
1037
+ // PreToolUse fires the instant a tool *starts* (the JSONL tool_use only
1038
+ // proves it was requested; a long-running Bash would otherwise sit
1039
+ // invisible), they carry agent_id for sub-agent attribution, and they are
1040
+ // the registration point for run_in_background launches that the Stop
1041
+ // gating in decideClaudeTuiStop depends on. The hook script writes to
1042
+ // tool-events.jsonl via atomic appends, sidestepping the
1043
+ // read-modify-write race that affects the shared state.json file.
1044
+ // Pre/PostToolUse require an explicit `matcher` field — without it Claude
1045
+ // Code's hook dispatcher silently never fires the hook (the lifecycle
1046
+ // hooks below don't need a matcher because they aren't tool-scoped).
1047
+ // `*` matches every tool. Without this, the entire live-streaming wire-up
1048
+ // is dead code.
1049
+ const settings = {
1050
+ hooks: {
1051
+ SessionStart: [{ hooks: [{ type: 'command', command: hookCmd('SessionStart'), timeout: 5 }] }],
1052
+ UserPromptSubmit: [{ hooks: [{ type: 'command', command: hookCmd('UserPromptSubmit'), timeout: 5 }] }],
1053
+ PreToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: hookCmd('PreToolUse'), timeout: 5 }] }],
1054
+ PostToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: hookCmd('PostToolUse'), timeout: 5 }] }],
1055
+ Stop: [{ hooks: [{ type: 'command', command: hookCmd('Stop'), timeout: 5 }] }],
1056
+ },
1057
+ };
1058
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
1059
+ }
1060
+ catch (e) {
1061
+ try {
1062
+ fs.rmSync(workDir, { recursive: true, force: true });
1063
+ }
1064
+ catch { }
1065
+ return makeErrorResult(opts, start, `Failed to seed hook scaffold: ${e?.message || e}`);
1066
+ }
1067
+ // 3. Build the claude argv. Crucially: NO `-p` — that's the whole point.
1068
+ const claudeArgs = [];
1069
+ if (isFork) {
1070
+ claudeArgs.push('--resume', opts.forkOf.parentSessionId, '--fork-session');
1071
+ }
1072
+ else if (isResume) {
1073
+ claudeArgs.push('--resume', opts.sessionId);
1074
+ }
1075
+ else {
1076
+ claudeArgs.push('--session-id', newSessionId);
1077
+ }
1078
+ claudeArgs.push('--settings', settingsPath);
1079
+ const model = normalizeClaudeModelId(opts.claudeModel);
1080
+ if (model)
1081
+ claudeArgs.push('--model', model);
1082
+ if (opts.claudePermissionMode)
1083
+ claudeArgs.push('--permission-mode', opts.claudePermissionMode);
1084
+ // Effort + Workflow gate — same source of truth as the `claude -p` driver, so
1085
+ // the TUI path drops the Workflow tool unless orchestration was opted in.
1086
+ claudeArgs.push(...claudeEffortAndWorkflowArgs(opts));
1087
+ if (opts.claudeAppendSystemPrompt)
1088
+ claudeArgs.push('--append-system-prompt', opts.claudeAppendSystemPrompt);
1089
+ if (opts.mcpConfigPath)
1090
+ claudeArgs.push('--mcp-config', opts.mcpConfigPath);
1091
+ if (opts.claudeExtraArgs?.length)
1092
+ claudeArgs.push(...opts.claudeExtraArgs);
1093
+ // Attachments: TUI doesn't accept base64-image stream-json input. Reference
1094
+ // local paths via the @-mention syntax — Claude's TUI reads images from
1095
+ // disk and inlines them into the message.
1096
+ let fullPrompt = opts.prompt;
1097
+ if (opts.attachments?.length) {
1098
+ const refs = opts.attachments.map(p => `@${p}`).join(' ');
1099
+ fullPrompt = `${refs}\n\n${opts.prompt}`;
1100
+ }
1101
+ // `--mcp-config <configs...>` (and a few other Claude flags) are *variadic*
1102
+ // — without a `--` terminator the positional prompt would be consumed as
1103
+ // another MCP config path. Always end with `--` then the prompt.
1104
+ claudeArgs.push('--', fullPrompt);
1105
+ // 4. Honour the existing steer-callback contract — TUI mode can't accept
1106
+ // mid-turn additional input, but callers (bot.ts) always pass onSteerReady
1107
+ // and expect it to be invoked. Give them a no-op so the orchestration doesn't
1108
+ // hang waiting for the callback that never fires.
1109
+ try {
1110
+ opts.onSteerReady?.(async () => {
1111
+ agentWarn('[claude-tui] steer requested but TUI mode does not support mid-turn input — ignored');
1112
+ return false;
1113
+ });
1114
+ }
1115
+ catch (e) {
1116
+ agentWarn(`[claude-tui] onSteerReady callback raised: ${e?.message || e}`);
1117
+ }
1118
+ // 5. Set up parser state and ensure the bot side has the upfront session id.
1119
+ const s = createClaudeStreamState(opts);
1120
+ // Resume: lock in the native id we are resuming. Fork: keep a placeholder until
1121
+ // Claude reports its rotated id via the SessionStart hook. New session: leave
1122
+ // s.sessionId at its initial value (null for a pending session) so the emit
1123
+ // below detects a change and fires the pending→native promotion callback.
1124
+ if (isResume || isFork)
1125
+ s.sessionId = activeSessionId;
1126
+ // Seed the context window from whatever model is configured up front (e.g.
1127
+ // "haiku" / "opus" / "sonnet" via opts.claudeModel) so the dashboard's
1128
+ // context-percent chip + green-dot indicator can render starting from the
1129
+ // very first emit, before any assistant event has arrived to confirm the
1130
+ // model. Subsequent assistant events with concrete model ids will refresh
1131
+ // s.model + recompute the window via applyModelContextWindow.
1132
+ if (!s.model && (opts.claudeModel || opts.model)) {
1133
+ s.model = opts.claudeModel || opts.model;
1134
+ }
1135
+ applyModelContextWindow(s);
1136
+ // A brand-new session uses the id we generated and passed via --session-id, so
1137
+ // it is final the instant we spawn. Emit it now: s.sessionId is still unset
1138
+ // here, so emitSessionIdUpdate fires the onSessionId callback that promotes the
1139
+ // pending pikiloop record (and its in-memory runtime) to the native id. The
1140
+ // prior `s.sessionId = activeSessionId` made this a silent no-op (emit dedups on
1141
+ // `id === s.sessionId`), so the record stayed `pending_*` for the whole run —
1142
+ // and since mergeManagedAndNativeSessions drops pending records, the dashboard
1143
+ // never saw the in-flight session as running on (re)load. Fork waits for the
1144
+ // SessionStart hook to report Claude's rotated id; resume is already native.
1145
+ if (!isResume && !isFork)
1146
+ emitSessionIdUpdate(s, activeSessionId);
1147
+ let stderrCapture = '';
1148
+ let lineCount = 0;
1149
+ let timedOut = false;
1150
+ let interrupted = false;
1151
+ let stopHookFired = false;
1152
+ let stopHookSeenAt = 0;
1153
+ let processExited = false;
1154
+ let exitCode = null;
1155
+ let exitSignal = null;
1156
+ let terminalLimitNotice = null;
1157
+ let terminalLimitNoticeAt = 0;
1158
+ let terminalModelError = null;
1159
+ let proc;
1160
+ const emit = () => {
1161
+ try {
1162
+ opts.onText(s.text, s.thinking, s.activity, buildStreamPreviewMeta(s), s.plan);
1163
+ }
1164
+ catch { }
1165
+ };
1166
+ const killProc = (signal, after = 5000) => {
1167
+ try {
1168
+ proc.kill(signal);
1169
+ }
1170
+ catch { }
1171
+ setTimeout(() => {
1172
+ if (!processExited) {
1173
+ try {
1174
+ proc.kill('SIGKILL');
1175
+ }
1176
+ catch { }
1177
+ }
1178
+ }, after);
1179
+ };
1180
+ // Answer a confirm/select dialog: settle (Ink drops input on the dialog's first frames) → the
1181
+ // affirmative key → a split-out Enter (a combined "1\r" gets swallowed — only the digit lands)
1182
+ // → drop the answered frame from screenTail so re-detection only fires on a genuine repaint, not
1183
+ // the stale text we just answered. Shared by the in-flight onData path and the stall-chokepoint
1184
+ // retry so the keystroke discipline lives in one place.
1185
+ const sendConfirmAnswer = (key, settleMs, confirmDelayMs, onConfirmed) => {
1186
+ setTimeout(() => {
1187
+ if (processExited)
1188
+ return;
1189
+ try {
1190
+ proc.write(key);
1191
+ }
1192
+ catch { }
1193
+ setTimeout(() => {
1194
+ if (processExited)
1195
+ return;
1196
+ try {
1197
+ proc.write('\r');
1198
+ }
1199
+ catch { }
1200
+ screenTail = '';
1201
+ onConfirmed?.();
1202
+ }, confirmDelayMs);
1203
+ }, settleMs);
1204
+ };
1205
+ // Record-only: a limit banner is EVIDENCE, not a verdict. Some banners are
1206
+ // informational (extra-usage credits kick in and the turn continues), so
1207
+ // killing here would shoot healthy turns. resolveClaudeTuiLimitOutcome
1208
+ // arbitrates later — at the stall watchdog and at result assembly — based
1209
+ // on whether the turn produced anything substantive after the banner.
1210
+ const noteTerminalLimitNotice = (notice) => {
1211
+ if (terminalLimitNotice)
1212
+ return;
1213
+ terminalLimitNotice = notice;
1214
+ terminalLimitNoticeAt = Date.now();
1215
+ agentWarn(`[claude-tui] limit notice observed (watching turn liveness): ${notice}`);
1216
+ pushRecentActivity(s.recentActivity, `Claude usage notice: ${notice}`);
1217
+ s.activity = s.recentActivity.join('\n');
1218
+ emit();
1219
+ };
1220
+ // Selected-model-unavailable notice (404 model_not_found). Unlike the limit
1221
+ // banner this is terminal AND invisible to every structured signal: the TUI
1222
+ // paints it to the PTY screen, writes nothing to the JSONL, and fires no Stop
1223
+ // hook — so the turn would otherwise idle at the REPL until the 3–10 min stall
1224
+ // watchdog kills it with a misleading "CLI freeze" message. We surface the
1225
+ // real reason and end the turn now. The banner is still EVIDENCE, not a bare
1226
+ // verdict: a short settle confirms nothing substantive followed (cross-
1227
+ // validating the screen scrape) before we kill — never granting a lone text
1228
+ // match the authority to shoot a healthy turn.
1229
+ const noteTerminalModelError = (notice) => {
1230
+ if (terminalModelError)
1231
+ return;
1232
+ terminalModelError = notice;
1233
+ agentWarn(`[claude-tui] model unavailable observed (settling before terminate): ${notice}`);
1234
+ pushRecentActivity(s.recentActivity, notice);
1235
+ s.activity = s.recentActivity.join('\n');
1236
+ emit();
1237
+ setTimeout(() => {
1238
+ if (processExited || interrupted)
1239
+ return;
1240
+ const hadOutput = !!s.text.trim()
1241
+ || lastAssistantEventAt > 0 || lastSidecarEventAt > 0 || lastToolEventAt > start;
1242
+ if (hadOutput) {
1243
+ agentWarn('[claude-tui] model-unavailable banner was followed by real output — not terminating');
1244
+ return;
1245
+ }
1246
+ agentWarn('[claude-tui] model unavailable confirmed (no JSONL/tool/Stop activity) — terminating turn');
1247
+ killProc('SIGTERM');
1248
+ }, CLAUDE_TUI_MODEL_ERROR_SETTLE_MS);
1249
+ };
1250
+ // Simulated streaming. See TuiStreamBuffer / applyAssistantStreaming above.
1251
+ const streamBuf = makeTuiStreamBuffer();
1252
+ const scheduleStreamTick = () => {
1253
+ if (streamBuf.timer)
1254
+ return;
1255
+ if (processExited)
1256
+ return;
1257
+ if (streamBuf.displayedLen >= streamBuf.trueText.length)
1258
+ return;
1259
+ streamBuf.timer = setTimeout(() => {
1260
+ streamBuf.timer = null;
1261
+ if (streamBuf.displayedLen >= streamBuf.trueText.length)
1262
+ return;
1263
+ const next = Math.min(streamBuf.trueText.length, streamBuf.displayedLen + TUI_STREAM_CHUNK_CHARS);
1264
+ streamBuf.displayedLen = next;
1265
+ s.text = streamBuf.trueText.slice(0, next);
1266
+ emit();
1267
+ // Keep ticking until we catch up — or until flushStream cancels us.
1268
+ if (streamBuf.displayedLen < streamBuf.trueText.length)
1269
+ scheduleStreamTick();
1270
+ }, TUI_STREAM_CHUNK_INTERVAL_MS);
1271
+ };
1272
+ const flushStream = () => {
1273
+ if (streamBuf.timer) {
1274
+ clearTimeout(streamBuf.timer);
1275
+ streamBuf.timer = null;
1276
+ }
1277
+ if (streamBuf.displayedLen < streamBuf.trueText.length) {
1278
+ s.text = streamBuf.trueText;
1279
+ streamBuf.displayedLen = streamBuf.trueText.length;
1280
+ emit();
1281
+ }
1282
+ };
1283
+ // 6. Spawn the TUI under PTY. (node-pty itself was already loaded at step
1284
+ // 0 — see the top of this function. By the time we reach this point the
1285
+ // module is guaranteed to be importable and `spawn-helper` is executable.)
1286
+ const spawnEnv = { TERM: 'xterm-256color' };
1287
+ for (const [k, v] of Object.entries(process.env)) {
1288
+ if (typeof v === 'string')
1289
+ spawnEnv[k] = v;
1290
+ }
1291
+ for (const [k, v] of Object.entries(opts.extraEnv || {})) {
1292
+ if (typeof v === 'string')
1293
+ spawnEnv[k] = v;
1294
+ }
1295
+ // Strip the session-context markers a parent claude process exports to its
1296
+ // subprocesses (CLAUDECODE, CLAUDE_CODE_CHILD_SESSION, …). Inherited e.g.
1297
+ // when an agent restarted the pikiloop daemon from inside a Claude Code
1298
+ // session, they flip this spawn into child-session mode — the transcript
1299
+ // JSONL (our only text source) is then never written locally and the turn
1300
+ // streams nothing. See CLAUDE_SESSION_CONTEXT_ENV_KEYS in claude.ts.
1301
+ scrubClaudeSessionContextEnv(spawnEnv);
1302
+ // Critical: leaving ANTHROPIC_API_KEY set would route TUI through API
1303
+ // billing too, defeating the whole point. Strip it unless the user
1304
+ // explicitly opts back in.
1305
+ if (process.env.PIKILOOP_CLAUDE_TUI_KEEP_API_KEY !== '1') {
1306
+ delete spawnEnv.ANTHROPIC_API_KEY;
1307
+ delete spawnEnv.ANTHROPIC_AUTH_TOKEN;
1308
+ }
1309
+ // Resolve `claude` to an absolute path. node-pty's `posix_spawnp` does not
1310
+ // reliably honour PATH on macOS when the lookup happens inside an embedded
1311
+ // libuv worker — passing the absolute path sidesteps cryptic
1312
+ // "posix_spawnp failed" errors. Falls back to the bare name (let
1313
+ // posix_spawnp try) when `which` can't resolve it.
1314
+ const claudeBin = whichSync('claude') || 'claude';
1315
+ agentLog(`[claude-tui] spawning ${claudeBin} TUI session=${activeSessionId} model=${model || '(default)'} prompt=${fullPrompt.length}ch resume=${isResume} fork=${isFork}`);
1316
+ try {
1317
+ proc = pty.spawn(claudeBin, claudeArgs, {
1318
+ cwd: opts.workdir,
1319
+ env: spawnEnv,
1320
+ cols: 200,
1321
+ rows: 50,
1322
+ name: 'xterm-256color',
1323
+ });
1324
+ }
1325
+ catch (e) {
1326
+ // Throw rather than return an error result — pty.spawn failures (PTY
1327
+ // allocation refused in sandboxed CI / Docker without /dev/ptmx, etc.)
1328
+ // mean TUI can't run at all, so the dispatcher should fall back to
1329
+ // print mode. Clean up the temp scaffolding before bailing.
1330
+ try {
1331
+ fs.rmSync(workDir, { recursive: true, force: true });
1332
+ }
1333
+ catch { }
1334
+ throw new Error(`pty.spawn failed (bin=${claudeBin}): ${e?.message || e}`);
1335
+ }
1336
+ agentLog(`[claude-tui] pid=${proc.pid}`);
1337
+ const dbg = process.env.PIKILOOP_CLAUDE_TUI_DEBUG === '1';
1338
+ /** Wall-clock of the last raw PTY byte — stall watchdog fast-path signal. */
1339
+ let lastPtyDataAt = Date.now();
1340
+ // Startup-dialog auto-answer. Claude's TUI can paint a blocking "Bypass
1341
+ // Permissions mode" confirmation before it accepts our positional prompt
1342
+ // (default highlight = "No, exit"). We keep a bounded ANSI-stripped tail of
1343
+ // the screen, detect that dialog (see detectClaudeBypassPrompt), and select
1344
+ // "Yes, I accept" so the turn never stalls on a pre-prompt.
1345
+ const SCREEN_TAIL_MAX = 8192;
1346
+ const BYPASS_ACCEPT_MAX_ATTEMPTS = 3;
1347
+ // Settle delay after the dialog first paints before we send any key. Claude's
1348
+ // Ink select drops input aimed at it during the first frames — sending the
1349
+ // digit too early is a no-op. ~500ms is comfortably past readiness in repro.
1350
+ const BYPASS_SETTLE_MS = 500;
1351
+ // Gap between the selection key and the confirm Enter. Claude's Ink select
1352
+ // swallows a combined "2\r" (only the digit lands; the Enter is dropped before
1353
+ // the highlight repaints), so the two keystrokes must be split in time —
1354
+ // 600ms is what reproduces reliably against the live 2.1.168 dialog.
1355
+ const BYPASS_CONFIRM_DELAY_MS = 600;
1356
+ // How long after the last bypass-dialog repaint we still treat it as on
1357
+ // screen — suppresses the blind prompt-submit Enter nudge across the whole
1358
+ // select→confirm sequence so a stray CR can't land on "No, exit".
1359
+ const BYPASS_DIALOG_ACTIVE_WINDOW_MS = 2000;
1360
+ // Mid-turn permission-prompt auto-answer (see detectClaudeProceedPrompt).
1361
+ // Same Ink-select timing as the bypass dialog: settle past the frames where
1362
+ // input is dropped, send the digit, then a split-out Enter to confirm. Unlike
1363
+ // the bypass dialog (fires once at startup), `ask`-rule prompts recur — one
1364
+ // per gated command — so we re-arm after each answer instead of capping at a
1365
+ // few total attempts. PROCEED_ANSWER_MAX is only a runaway backstop.
1366
+ const PROCEED_SETTLE_MS = 500;
1367
+ const PROCEED_CONFIRM_DELAY_MS = 600;
1368
+ // After confirming, wait before re-arming so a stale dialog frame can't drive
1369
+ // a double "1\r"; a genuine next prompt always trails its command by longer.
1370
+ const PROCEED_REARM_MS = 1000;
1371
+ const PROCEED_ANSWER_MAX = 40;
1372
+ let screenTail = '';
1373
+ let bypassPromptLastSeenAt = 0;
1374
+ let bypassAcceptAttempts = 0;
1375
+ let bypassPhase = 'idle';
1376
+ let proceedAnswerCount = 0;
1377
+ let proceedPhase = 'idle';
1378
+ proc.onData((data) => {
1379
+ // We deliberately do not parse the TUI screen output. The JSONL is the
1380
+ // canonical source of structured events. Stash bytes only when debugging.
1381
+ // Raw byte arrival doubles as the cheapest liveness signal: a healthy TUI
1382
+ // repaints continuously mid-turn, so PTY silence = event loop dead — feeds
1383
+ // the stall watchdog's fast path (decideClaudeTuiStall.lastPtyDataAt).
1384
+ lastPtyDataAt = Date.now();
1385
+ if (dbg) {
1386
+ try {
1387
+ fs.appendFileSync(ptyLogPath, data);
1388
+ }
1389
+ catch { }
1390
+ }
1391
+ // Auto-answer the bypass-permissions confirmation. Detect it the moment it
1392
+ // paints (off the raw PTY, not the 200ms poll tick) and arm a short timed
1393
+ // keystroke sequence. Keep a bounded stripped tail across chunks so a dialog
1394
+ // split across reads still matches.
1395
+ screenTail = (screenTail + stripAnsiEscapes(data)).slice(-SCREEN_TAIL_MAX);
1396
+ if (detectClaudeBypassPrompt(screenTail)) {
1397
+ bypassPromptLastSeenAt = Date.now();
1398
+ if (bypassPhase === 'idle' && bypassAcceptAttempts < BYPASS_ACCEPT_MAX_ATTEMPTS) {
1399
+ bypassAcceptAttempts++;
1400
+ bypassPhase = 'armed';
1401
+ // Three timed steps — verified 3/3 against the live 2.1.168 dialog:
1402
+ // settle (dialog ignores input on its first frames)
1403
+ // → "2" (jumps to the second option "Yes, I accept"; idempotent —
1404
+ // re-sending can't overshoot a 2-option menu onto "No, exit")
1405
+ // → Enter (confirms; must arrive *after* the highlight repaints — a
1406
+ // combined "2\r" gets swallowed, only the digit lands).
1407
+ agentLog(`[claude-tui] bypass-permissions prompt — auto-accepting "Yes, I accept" (attempt ${bypassAcceptAttempts}/${BYPASS_ACCEPT_MAX_ATTEMPTS})`);
1408
+ setTimeout(() => {
1409
+ if (processExited)
1410
+ return;
1411
+ try {
1412
+ proc.write('2');
1413
+ }
1414
+ catch { }
1415
+ setTimeout(() => {
1416
+ if (processExited)
1417
+ return;
1418
+ try {
1419
+ proc.write('\r');
1420
+ }
1421
+ catch { }
1422
+ bypassPhase = 'confirmed';
1423
+ agentLog('[claude-tui] bypass-permissions — confirm Enter sent');
1424
+ // Drop the buffered dialog frame: the post-accept REPL output can be
1425
+ // tiny (e.g. a "Not logged in" line), so the old dialog text would
1426
+ // otherwise linger in the 8192-char tail and make the re-arm below
1427
+ // re-fire on a stale screen — typing "2"/Enter into the live prompt.
1428
+ // Clearing means the re-arm only sees output that arrives *after*
1429
+ // the confirm, so it re-fires only on a genuine repaint of the
1430
+ // dialog (accept didn't take), never on stale bytes.
1431
+ screenTail = '';
1432
+ setTimeout(() => {
1433
+ if (!processExited && detectClaudeBypassPrompt(screenTail))
1434
+ bypassPhase = 'idle';
1435
+ }, 1200);
1436
+ }, BYPASS_CONFIRM_DELAY_MS);
1437
+ }, BYPASS_SETTLE_MS);
1438
+ }
1439
+ }
1440
+ // Auto-answer a *mid-turn* permission confirmation. `else if` so the startup bypass dialog
1441
+ // (handled above) never falls through here. The affirmative key comes from the classifier, not
1442
+ // a hard-coded "1": confirm-prompt → "1. Yes", (y/n) → "y". (plan-approval carries no key under
1443
+ // the current policy, so it is not auto-answered here.) These recur (one per ask-gated command),
1444
+ // so we re-arm after each answer rather than cap total attempts.
1445
+ else {
1446
+ const screenInfo = classifyClaudeScreen(screenTail);
1447
+ if (screenInfo.state === 'confirm-prompt' && screenInfo.affirmativeKey
1448
+ && proceedPhase === 'idle' && proceedAnswerCount < PROCEED_ANSWER_MAX) {
1449
+ proceedAnswerCount++;
1450
+ proceedPhase = 'armed';
1451
+ const key = screenInfo.affirmativeKey;
1452
+ agentLog(`[claude-tui] mid-turn permission prompt — auto-selecting "${key}" (answer ${proceedAnswerCount}/${PROCEED_ANSWER_MAX})`);
1453
+ sendConfirmAnswer(key, PROCEED_SETTLE_MS, PROCEED_CONFIRM_DELAY_MS, () => {
1454
+ agentLog('[claude-tui] permission prompt — confirm Enter sent');
1455
+ setTimeout(() => { if (!processExited)
1456
+ proceedPhase = 'idle'; }, PROCEED_REARM_MS);
1457
+ });
1458
+ }
1459
+ }
1460
+ // Capture stderr-ish bytes (TUI startup errors, "claude: command not
1461
+ // found"-style messages) for the final error payload when the run aborts
1462
+ // before any JSONL is written. Strip ANSI on the way in — otherwise the
1463
+ // raw PTY screen (cursor positions, SGR colours, column-aligned reply
1464
+ // rendering) leaks into IM as gibberish like "[3G你把 [8Gsnipe …" when a
1465
+ // user hits Stop before the JSONL has flushed any assistant text. Keep
1466
+ // the buffer bounded after stripping.
1467
+ if (stderrCapture.length < 4096) {
1468
+ stderrCapture += stripAnsiEscapes(data);
1469
+ if (stderrCapture.length > 4096)
1470
+ stderrCapture = stderrCapture.slice(0, 4096);
1471
+ const notice = detectClaudeTuiTerminalLimitNotice(stderrCapture);
1472
+ if (notice)
1473
+ noteTerminalLimitNotice(notice);
1474
+ }
1475
+ // Selected-model-unavailable notice — see noteTerminalModelError. The TUI
1476
+ // only paints this to the screen (no JSONL, no Stop hook), so the live
1477
+ // screen tail is the sole signal. detectClaudeModelError is whitespace-
1478
+ // insensitive so it survives the TUI's char-by-char paint.
1479
+ if (!terminalModelError && detectClaudeModelError(screenTail)) {
1480
+ noteTerminalModelError(claudeModelErrorMessage(s.model || opts.claudeModel || null));
1481
+ }
1482
+ });
1483
+ // 7. Abort handling.
1484
+ const abortStream = () => {
1485
+ if (interrupted || processExited)
1486
+ return;
1487
+ interrupted = true;
1488
+ s.stopReason = 'interrupted';
1489
+ agentWarn(`[claude-tui] abort requested pid=${proc.pid}`);
1490
+ killProc('SIGTERM');
1491
+ };
1492
+ if (opts.abortSignal?.aborted)
1493
+ abortStream();
1494
+ opts.abortSignal?.addEventListener('abort', abortStream, { once: true });
1495
+ // 8. Hard deadline timer.
1496
+ const hardTimer = setTimeout(() => {
1497
+ if (processExited)
1498
+ return;
1499
+ timedOut = true;
1500
+ s.stopReason = 'timeout';
1501
+ agentWarn(`[claude-tui] hard deadline reached (${opts.timeout}s) pid=${proc.pid}`);
1502
+ killProc('SIGTERM');
1503
+ }, opts.timeout * 1000 + AGENT_STREAM_HARD_KILL_GRACE_MS);
1504
+ // 9. Poll loop — hook state + JSONL tail.
1505
+ const POLL_INTERVAL_MS = 200;
1506
+ // After Stop hook fires we give the JSONL ~600ms to settle (matches the
1507
+ // print-mode driver's graceful-abort observation window) so the assistant's
1508
+ // final event lands before we SIGTERM.
1509
+ const POST_STOP_DRAIN_MS = 600;
1510
+ // Fallback Enter — most Claude versions auto-submit a positional prompt in
1511
+ // TUI mode, but if UserPromptSubmit hasn't fired by this deadline we type a
1512
+ // carriage return into the PTY in case the prompt is sitting on the input
1513
+ // line waiting for it.
1514
+ const PROMPT_SUBMIT_NUDGE_MS = 1500;
1515
+ // After the stall chokepoint fires its affirmative keystroke at a stuck dialog, give the dialog
1516
+ // this long to clear before deciding it's unanswerable. Generous enough to also cover the
1517
+ // in-flight onData answer cycle (settle + confirm + re-arm) so a chained next-prompt isn't
1518
+ // mistaken for a failed answer; short enough that we never re-wait the full 3-min quiet window.
1519
+ const CHOKEPOINT_ANSWER_GRACE_MS = 5000;
1520
+ let promptNudged = false;
1521
+ let pollHandle = null;
1522
+ let drainScheduled = false;
1523
+ // Wall-clock of the last parsed main-JSONL line. Feeds the stale-Stop
1524
+ // quiet-window check in decideClaudeTuiStop — sidecar / hook traffic is
1525
+ // deliberately excluded (only main-JSONL activity signals a model segment).
1526
+ let lastMainJsonlEventAt = start;
1527
+ // Last pending-background count we logged, so the waiting state logs on
1528
+ // transitions instead of every 200ms poll tick.
1529
+ let lastLoggedPendingBg = -1;
1530
+ // Stall-watchdog liveness signals. Together with lastMainJsonlEventAt they
1531
+ // answer "is the claude process still doing anything at all?" — see
1532
+ // decideClaudeTuiStall for why this exists (claude CLI mid-turn freeze).
1533
+ let lastToolEventAt = start;
1534
+ let lastSidecarEventAt = 0;
1535
+ // Last non-synthetic assistant JSONL event — substantive-progress signal
1536
+ // for the limit-notice arbitration (resolveClaudeTuiLimitOutcome). Distinct
1537
+ // from lastMainJsonlEventAt, which also counts bookkeeping lines (mode,
1538
+ // last-prompt, …) that land right after submit and prove nothing.
1539
+ let lastAssistantEventAt = 0;
1540
+ let stallKilled = false;
1541
+ // Chokepoint answer-retry: when the watchdog is about to fire on a confirm dialog, send the
1542
+ // affirmative key ONCE against the now-stable (3-min-quiet, fully-painted) screen — the in-flight
1543
+ // onData answer only fires while bytes arrive, so a dialog that paints once then goes silent never
1544
+ // gets a second attempt (the diagnostics showed full-match prompts killed with answer-count 1).
1545
+ // `stallAnswerSentAt` arms a bounded grace check: if the dialog hasn't cleared by then, terminate
1546
+ // without auto-resuming (re-running would just re-paint the same dialog).
1547
+ let stallAnswerTried = false;
1548
+ let stallAnswerSentAt = 0;
1549
+ // Stall diagnostics (capture-only) — see writeStallDiag.
1550
+ let observedClaudeVersion = '';
1551
+ let lastMainJsonlType = '';
1552
+ let lastStallDiagHeartbeatAt = 0;
1553
+ let stallDiagWentQuiet = false;
1554
+ let stallDiagMaxQuietMs = 0;
1555
+ let stallDiagPtyAliveWhileQuiet = false;
1556
+ /** Last state.stoppedAt for which pendingHookToolIds was reconciled. */
1557
+ let lastClearedStopAt = 0;
1558
+ /** Hook-reported tools still executing: PreToolUse seen, no PostToolUse. */
1559
+ const pendingHookToolIds = new Set();
1560
+ // Incremental main-JSONL drain — the canonical text/thinking/usage feed.
1561
+ // Used by both the 200ms poll tick and the post-exit final drain. Returns
1562
+ // true when any line was consumed so callers can emit().
1563
+ const drainMainJsonl = () => {
1564
+ // No existsSync guard: readJsonlIncrement returns no lines (offset unchanged)
1565
+ // for a missing file, so the guard was a redundant extra syscall every tick.
1566
+ const inc = readJsonlIncrement(activeJsonlPath, jsonlReadOffset);
1567
+ jsonlReadOffset = inc.offset;
1568
+ let touched = false;
1569
+ for (const line of inc.lines) {
1570
+ const trimmed = line.trim();
1571
+ if (!trimmed || trimmed[0] !== '{')
1572
+ continue;
1573
+ lineCount++;
1574
+ let ev;
1575
+ try {
1576
+ ev = JSON.parse(trimmed);
1577
+ }
1578
+ catch {
1579
+ continue;
1580
+ }
1581
+ // Ignore sub-agent sidecar events — they belong to a child agent's
1582
+ // stream and would re-enter the parent's accumulator. claudeParse's
1583
+ // own sub-agent routing handles them.
1584
+ const isSubAgentEvent = typeof ev.parent_tool_use_id === 'string' && ev.parent_tool_use_id;
1585
+ if (!isSubAgentEvent && ev.type === 'assistant') {
1586
+ const notice = detectClaudeTuiTerminalLimitNotice(ev.message);
1587
+ if (notice) {
1588
+ // A synthetic limit banner is not substantive progress — skip the
1589
+ // liveness/type bookkeeping below so the limit arbitration and the
1590
+ // stall watchdog don't mistake it for a live model segment.
1591
+ noteTerminalLimitNotice(notice);
1592
+ touched = true;
1593
+ continue;
1594
+ }
1595
+ applyAssistantStreaming(s, ev.message, streamBuf);
1596
+ applyAssistantUsage(s, ev.message);
1597
+ if (ev.message?.model && ev.message.model !== '<synthetic>' && typeof ev.message.model === 'string') {
1598
+ lastAssistantEventAt = Date.now();
1599
+ s.model = ev.message.model;
1600
+ applyModelContextWindow(s);
1601
+ }
1602
+ }
1603
+ try {
1604
+ callClaudeParseForTui(ev, s);
1605
+ }
1606
+ catch (e) {
1607
+ agentWarn(`[claude-tui] claudeParse threw on line: ${e?.message || e}`);
1608
+ }
1609
+ touched = true;
1610
+ lastMainJsonlEventAt = Date.now();
1611
+ if (typeof ev.version === 'string' && ev.version)
1612
+ observedClaudeVersion = ev.version;
1613
+ if (!isSubAgentEvent)
1614
+ lastMainJsonlType = classifyClaudeJsonlEvent(ev);
1615
+ }
1616
+ return touched;
1617
+ };
1618
+ // Append-only tool-events log fed by PreToolUse / PostToolUse hooks. We
1619
+ // tail it with the same incremental reader the JSONL transcript uses. Hook
1620
+ // events usually beat their JSONL counterpart by a second or so (and
1621
+ // PreToolUse fires before the tool even runs); whichever feed arrives first
1622
+ // wins, the other dedups via seenClaudeToolIds / seenClaudeToolResultIds.
1623
+ let toolEventsReadOffset = 0;
1624
+ const drainToolEvents = () => {
1625
+ const inc = readJsonlIncrement(toolEventsPath, toolEventsReadOffset);
1626
+ toolEventsReadOffset = inc.offset;
1627
+ let any = false;
1628
+ for (const line of inc.lines) {
1629
+ const trimmed = line.trim();
1630
+ if (!trimmed || trimmed[0] !== '{')
1631
+ continue;
1632
+ let ev;
1633
+ try {
1634
+ ev = JSON.parse(trimmed);
1635
+ }
1636
+ catch {
1637
+ continue;
1638
+ }
1639
+ // Stall-watchdog bookkeeping: any hook event is proof of life, and the
1640
+ // Pre/Post pairing tells the watchdog whether a tool is mid-execution
1641
+ // (which extends the stall threshold — long foreground commands are
1642
+ // legitimately silent).
1643
+ lastToolEventAt = Date.now();
1644
+ const hookToolId = typeof ev?.tool_use_id === 'string' ? ev.tool_use_id : '';
1645
+ if (hookToolId) {
1646
+ if (ev?.event === 'PreToolUse')
1647
+ pendingHookToolIds.add(hookToolId);
1648
+ else if (ev?.event === 'PostToolUse')
1649
+ pendingHookToolIds.delete(hookToolId);
1650
+ }
1651
+ // A Task PreToolUse and the first sub-agent tool PreToolUse can land in
1652
+ // the same tick batch. If the sub-agent's hook arrives before we've
1653
+ // discovered its sidecar (and thus before s.subAgentIdToParent knows
1654
+ // its agent_id), refresh discovery so the hook resolves its parent on
1655
+ // this pass instead of leaking through unattributed.
1656
+ const subAgentId = typeof ev?.agent_id === 'string' ? ev.agent_id : '';
1657
+ if (subAgentId && !s.subAgentIdToParent?.has(subAgentId))
1658
+ tryDiscoverSubAgents();
1659
+ try {
1660
+ if (applyHookToolEvent(ev, s))
1661
+ any = true;
1662
+ }
1663
+ catch (e) {
1664
+ agentWarn(`[claude-tui] hook tool event apply threw: ${e?.message || e}`);
1665
+ }
1666
+ }
1667
+ return any;
1668
+ };
1669
+ const trackedSubAgents = new Map();
1670
+ const tryDiscoverSubAgents = () => {
1671
+ const sidecarDir = path.join(projectDir, activeSessionId, 'subagents');
1672
+ if (!fs.existsSync(sidecarDir))
1673
+ return;
1674
+ let entries;
1675
+ try {
1676
+ entries = fs.readdirSync(sidecarDir);
1677
+ }
1678
+ catch {
1679
+ return;
1680
+ }
1681
+ for (const name of entries) {
1682
+ if (!name.endsWith('.meta.json'))
1683
+ continue;
1684
+ const stem = name.slice(0, -'.meta.json'.length);
1685
+ if (trackedSubAgents.has(stem))
1686
+ continue;
1687
+ let meta;
1688
+ try {
1689
+ meta = JSON.parse(fs.readFileSync(path.join(sidecarDir, name), 'utf8'));
1690
+ }
1691
+ catch {
1692
+ continue;
1693
+ }
1694
+ const parentToolUseId = typeof meta?.toolUseId === 'string' ? meta.toolUseId : '';
1695
+ if (!parentToolUseId)
1696
+ continue;
1697
+ // Only start tailing once the parent Task tool_use has been registered
1698
+ // in s.subAgents — otherwise routeClaudeSubAgentEvent silently drops
1699
+ // every event because it can't find the parent.
1700
+ if (!s.subAgents.has(parentToolUseId))
1701
+ continue;
1702
+ const sidecarPath = path.join(sidecarDir, `${stem}.jsonl`);
1703
+ trackedSubAgents.set(stem, { sidecarPath, offset: 0, parentToolUseId });
1704
+ // `stem` is "agent-<id>"; Claude Code's hook payload `agent_id` carries
1705
+ // just the raw id. Keep both keys so applyHookToolEvent can attribute
1706
+ // sub-agent tool hooks to the parent's Task tool_use no matter which
1707
+ // form arrives.
1708
+ const rawAgentId = stem.startsWith('agent-') ? stem.slice('agent-'.length) : stem;
1709
+ if (!s.subAgentIdToParent)
1710
+ s.subAgentIdToParent = new Map();
1711
+ s.subAgentIdToParent.set(rawAgentId, parentToolUseId);
1712
+ s.subAgentIdToParent.set(stem, parentToolUseId);
1713
+ // <task-notification> events identify background tasks by this raw id
1714
+ // (and only sometimes carry <tool-use-id>) — keep the mapping so
1715
+ // applyClaudeTaskNotification can resolve them either way.
1716
+ if (!s.bgTaskIdToToolUse)
1717
+ s.bgTaskIdToToolUse = new Map();
1718
+ s.bgTaskIdToToolUse.set(rawAgentId, parentToolUseId);
1719
+ agentLog(`[claude-tui] subagent sidecar discovered ${stem} parent=${parentToolUseId.slice(0, 14)}`);
1720
+ }
1721
+ };
1722
+ const pumpSubAgentSidecars = () => {
1723
+ let any = false;
1724
+ for (const tail of trackedSubAgents.values()) {
1725
+ const inc = readJsonlIncrement(tail.sidecarPath, tail.offset);
1726
+ tail.offset = inc.offset;
1727
+ for (const line of inc.lines) {
1728
+ const trimmed = line.trim();
1729
+ if (!trimmed || trimmed[0] !== '{')
1730
+ continue;
1731
+ let ev;
1732
+ try {
1733
+ ev = JSON.parse(trimmed);
1734
+ }
1735
+ catch {
1736
+ continue;
1737
+ }
1738
+ // Inject parent_tool_use_id so claudeParse routes via routeClaudeSubAgentEvent
1739
+ // → updates sub.model + sub.tools on the existing s.subAgents entry.
1740
+ const injected = { ...ev, parent_tool_use_id: tail.parentToolUseId };
1741
+ try {
1742
+ callClaudeParseForTui(injected, s);
1743
+ }
1744
+ catch (e) {
1745
+ agentWarn(`[claude-tui] subagent parse threw: ${e?.message || e}`);
1746
+ }
1747
+ any = true;
1748
+ }
1749
+ }
1750
+ // Stall-watchdog: live sub-agents count as turn progress even while the
1751
+ // parent thread is quietly waiting on them.
1752
+ if (any)
1753
+ lastSidecarEventAt = Date.now();
1754
+ return any;
1755
+ };
1756
+ // End a turn that is blocked on a confirm/select dialog the auto-answer could not clear. Unlike a
1757
+ // 'stalled' kill this does NOT auto-resume — re-running the prompt just re-paints the same dialog
1758
+ // (the loop the user kept seeing). The session is intact; the user re-sends to continue.
1759
+ const terminatePromptUnanswered = (screenState, sample) => {
1760
+ stallKilled = true;
1761
+ const nowMs = Date.now();
1762
+ const progressAt = Math.max(start, lastMainJsonlEventAt, lastToolEventAt, lastSidecarEventAt);
1763
+ writeStallDiag({
1764
+ kind: 'stall', sessionId: activeSessionId, version: observedClaudeVersion, model: s.model || null,
1765
+ elapsedTurnMs: nowMs - start, quietMs: nowMs - progressAt, ptyQuietMs: nowMs - lastPtyDataAt,
1766
+ ptyAliveWhileQuiet: stallDiagPtyAliveWhileQuiet, lastJsonlType: lastMainJsonlType,
1767
+ pendingHookTools: pendingHookToolIds.size, pendingBgAgents: pendingClaudeBackgroundAgentCount(s),
1768
+ looksLikePrompt: true, screenState, action: 'terminate-prompt-unanswered', screenSample: sample,
1769
+ });
1770
+ s.stopReason = 'prompt_unanswered';
1771
+ if (!s.errors)
1772
+ s.errors = ['Claude paused for a confirmation pikiloop could not auto-approve. Your session is intact — re-send your message (or reply "continue") to proceed.'];
1773
+ agentWarn(`[claude-tui] confirm dialog (${screenState}) did not clear after auto-answer — ending turn without auto-resume pid=${proc.pid}`);
1774
+ pushRecentActivity(s.recentActivity, 'Waiting on a confirmation pikiloop could not auto-approve — re-send to continue');
1775
+ s.activity = s.recentActivity.join('\n');
1776
+ emit();
1777
+ killProc('SIGTERM');
1778
+ };
1779
+ const tick = () => {
1780
+ pollHandle = null;
1781
+ if (processExited)
1782
+ return;
1783
+ if (Date.now() > deadline) {
1784
+ if (!timedOut) {
1785
+ timedOut = true;
1786
+ s.stopReason = 'timeout';
1787
+ agentWarn(`[claude-tui] deadline exceeded mid-poll`);
1788
+ killProc('SIGTERM');
1789
+ }
1790
+ return;
1791
+ }
1792
+ // Hook state — pick up real session id / transcript path.
1793
+ const state = readHookState(statePath);
1794
+ if (state.sessionId && state.sessionId !== activeSessionId) {
1795
+ const prevId = activeSessionId;
1796
+ activeSessionId = state.sessionId;
1797
+ activeJsonlPath = state.transcriptPath || path.join(projectDir, `${activeSessionId}.jsonl`);
1798
+ // For forks Claude rotates to a fresh UUID — start reading the new file
1799
+ // from offset 0 since we haven't read any of it yet.
1800
+ if (!isResume)
1801
+ jsonlReadOffset = 0;
1802
+ emitSessionIdUpdate(s, activeSessionId);
1803
+ agentLog(`[claude-tui] session id resolved ${prevId} -> ${activeSessionId} transcript=${activeJsonlPath}`);
1804
+ }
1805
+ else if (state.transcriptPath && state.transcriptPath !== activeJsonlPath) {
1806
+ activeJsonlPath = state.transcriptPath;
1807
+ }
1808
+ // Submit nudge — only if UserPromptSubmit hook hasn't fired yet. Suppress
1809
+ // it while the bypass-permissions dialog is (or was just) on screen: a blind
1810
+ // CR there lands on the default "No, exit" and kills the session. The dialog
1811
+ // auto-answer in onData drives that screen instead; once it clears the
1812
+ // prompt submits on its own (or this nudge fires on a later tick).
1813
+ const bypassDialogActive = bypassPromptLastSeenAt > 0
1814
+ && Date.now() - bypassPromptLastSeenAt < BYPASS_DIALOG_ACTIVE_WINDOW_MS;
1815
+ if (!promptNudged && !state.promptSubmittedAt && !bypassDialogActive
1816
+ && Date.now() - start > PROMPT_SUBMIT_NUDGE_MS) {
1817
+ promptNudged = true;
1818
+ try {
1819
+ proc.write('\r');
1820
+ }
1821
+ catch { }
1822
+ agentLog(`[claude-tui] prompt-submit nudge sent (no UserPromptSubmit after ${PROMPT_SUBMIT_NUDGE_MS}ms)`);
1823
+ }
1824
+ // JSONL tail.
1825
+ if (drainMainJsonl()) {
1826
+ // Emit immediately so non-text changes (tool_use, plan, activity,
1827
+ // thinking, usage) reach the dashboard without waiting for the
1828
+ // chunked stream tick. The streaming timer separately advances
1829
+ // s.text from the buffer over the next few ticks.
1830
+ emit();
1831
+ scheduleStreamTick();
1832
+ }
1833
+ // Live tool-events stream — fed by Pre/PostToolUse hooks. Hook and JSONL
1834
+ // feeds race per tool call; both record into seenClaudeToolIds /
1835
+ // seenClaudeToolResultIds so whichever lands first wins and the other
1836
+ // pass dedups naturally.
1837
+ if (drainToolEvents())
1838
+ emit();
1839
+ // Sub-agent sidecar discovery + pump. Order matters: discovery first so a
1840
+ // newly-spawned sub-agent gets registered for tailing this same tick if
1841
+ // its events have already been written. Skip the readdir + per-meta reads
1842
+ // until a Task tool_use is actually registered — discovery can't succeed
1843
+ // before then anyway (it requires the parent in s.subAgents), so the common
1844
+ // no-subagent turn would otherwise readdir the sidecar dir every 200ms for
1845
+ // nothing.
1846
+ if (s.subAgents.size > 0)
1847
+ tryDiscoverSubAgents();
1848
+ if (pumpSubAgentSidecars())
1849
+ emit();
1850
+ // Stop hook handling. A Stop is NOT automatically the end of the turn:
1851
+ // Claude fires it per response segment, including the segment that merely
1852
+ // *launched* run_in_background agents. Those agents run inside the claude
1853
+ // process — terminating here would destroy them (the "进程退出把子代理
1854
+ // 打断" incident). Hold the PTY open until every launched background agent
1855
+ // has reported its <task-notification> AND the latest Stop is fresher than
1856
+ // the latest notification (i.e. the model's wrap-up segment finished).
1857
+ if (state.stoppedAt && !stopHookFired) {
1858
+ // A fired Stop means no foreground tool is genuinely mid-flight any
1859
+ // more. Surviving entries in pendingHookToolIds are lost PostToolUse
1860
+ // hook events (MCP flap / hook timeout ate them) — clearing here stops
1861
+ // them from silently pushing the stall watchdog onto the 30-minute
1862
+ // pending-tool threshold for the rest of the turn.
1863
+ if (state.stoppedAt !== lastClearedStopAt) {
1864
+ lastClearedStopAt = state.stoppedAt;
1865
+ if (pendingHookToolIds.size) {
1866
+ agentWarn(`[claude-tui] Stop fired with ${pendingHookToolIds.size} unmatched PreToolUse event(s) — clearing (lost PostToolUse hooks)`);
1867
+ pendingHookToolIds.clear();
1868
+ }
1869
+ }
1870
+ const pendingBg = pendingClaudeBackgroundAgentCount(s);
1871
+ const decision = decideClaudeTuiStop({
1872
+ stoppedAt: state.stoppedAt,
1873
+ pendingBackgroundAgents: pendingBg,
1874
+ lastTaskNotificationAt: s.lastTaskNotificationAt || 0,
1875
+ lastJsonlEventAt: lastMainJsonlEventAt,
1876
+ lastHookOrSidecarEventAt: Math.max(lastToolEventAt, lastSidecarEventAt),
1877
+ // Background *Bash* is silent by nature (no sidecar/hook traffic while
1878
+ // it runs) — give it the long pending-tool budget; agent-only holds
1879
+ // keep the default TTL (live agents emit sidecar events constantly).
1880
+ holdQuietTtlMs: pendingClaudeBackgroundBashCount(s) > 0
1881
+ ? CLAUDE_TUI_STALL_PENDING_TOOL_MS
1882
+ : undefined,
1883
+ now: Date.now(),
1884
+ });
1885
+ if (decision === 'terminate') {
1886
+ stopHookFired = true;
1887
+ stopHookSeenAt = Date.now();
1888
+ if (pendingBg > 0) {
1889
+ // 幽灵 hold 释放:计数说还有后台 agent,但所有通道静默已超 TTL。
1890
+ agentWarn(`[claude-tui] releasing phantom hold — ${pendingBg} background agent(s) still counted pending but every channel quiet past TTL; treating Stop as final`);
1891
+ }
1892
+ agentLog(`[claude-tui] Stop hook fired — draining JSONL for ${POST_STOP_DRAIN_MS}ms before SIGTERM`);
1893
+ }
1894
+ else if (decision === 'hold-background' && pendingBg !== lastLoggedPendingBg) {
1895
+ lastLoggedPendingBg = pendingBg;
1896
+ agentLog(`[claude-tui] Stop hook fired with ${pendingBg} background agent(s) still running — holding TUI alive until they finish`);
1897
+ pushRecentActivity(s.recentActivity, `Waiting for ${pendingBg} background agent(s) to finish`);
1898
+ s.activity = s.recentActivity.join('\n');
1899
+ emit();
1900
+ }
1901
+ }
1902
+ if (stopHookFired && !drainScheduled && Date.now() - stopHookSeenAt >= POST_STOP_DRAIN_MS) {
1903
+ drainScheduled = true;
1904
+ agentLog(`[claude-tui] drain complete, terminating TUI pid=${proc.pid}`);
1905
+ killProc('SIGTERM');
1906
+ // Continue polling so any post-Stop JSONL writes still get parsed; the
1907
+ // process will exit shortly and onExit will resolve the wait.
1908
+ }
1909
+ // Stall watchdog. claude CLI can freeze mid-turn (observed on 2.1.160):
1910
+ // a tool_result lands, then the next assistant segment never starts — the
1911
+ // process stays alive, every signal goes quiet, no Stop hook ever fires.
1912
+ // When ALL liveness signals have been silent past the threshold, declare
1913
+ // the turn stalled and SIGTERM; doClaudeWithRetry auto-resumes the session
1914
+ // once so the turn continues instead of spinning forever in the IM card.
1915
+ if (!stopHookFired && !timedOut && !interrupted && !stallKilled) {
1916
+ const lastProgressAt = Math.max(start, lastMainJsonlEventAt, lastToolEventAt, lastSidecarEventAt, state.stoppedAt || 0, state.promptSubmittedAt || 0);
1917
+ // Pending background work (agents + bash) extends the stall budget the
1918
+ // same way a pending foreground tool does: a silent 15-minute background
1919
+ // build must not get shot by the 10-minute quiet threshold. The PTY
1920
+ // fast path still catches true process freezes within minutes.
1921
+ const pendingBgForStall = pendingClaudeBackgroundAgentCount(s);
1922
+ // PTY fast path is for *mid-turn* freezes only. While the TUI idles in a
1923
+ // post-Stop background hold it legitimately paints nothing — a static
1924
+ // screen there is healthy, not frozen. Stop being the freshest signal is
1925
+ // exactly that hold state → disarm the fast path (0 = unavailable).
1926
+ const nonStopProgressAt = Math.max(start, lastMainJsonlEventAt, lastToolEventAt, lastSidecarEventAt, state.promptSubmittedAt || 0);
1927
+ const inPostStopHold = !!state.stoppedAt && state.stoppedAt >= nonStopProgressAt;
1928
+ // Chokepoint answer-retry grace. We sent the affirmative key at a stuck dialog; give it time
1929
+ // to clear. If the screen is no longer a blocking dialog, the answer took — disarm and re-arm
1930
+ // so a later prompt in the same turn can also get a chokepoint retry. If it is STILL a dialog
1931
+ // (and the in-flight onData answer didn't clear it either), it is genuinely unanswerable —
1932
+ // end without auto-resume.
1933
+ if (stallAnswerSentAt > 0 && Date.now() - stallAnswerSentAt > CHOKEPOINT_ANSWER_GRACE_MS) {
1934
+ const after = classifyClaudeScreen(screenTail);
1935
+ const stillBlocking = after.state === 'confirm-prompt'
1936
+ || after.state === 'plan-approval' || after.state === 'bypass-startup';
1937
+ if (stillBlocking) {
1938
+ terminatePromptUnanswered(after.state, after.sample);
1939
+ }
1940
+ else {
1941
+ agentLog(`[claude-tui] chokepoint answer cleared the dialog (now ${after.state}) — turn continues`);
1942
+ stallAnswerSentAt = 0;
1943
+ stallAnswerTried = false;
1944
+ }
1945
+ }
1946
+ // Stall diagnostics: sample the quiet lead-up so the watchdog can later be
1947
+ // tuned from data. Capture-only — changes no control flow.
1948
+ if (!stallKilled) {
1949
+ const nowMs = Date.now();
1950
+ const quietMs = nowMs - lastProgressAt;
1951
+ if (quietMs >= STALL_DIAG_QUIET_THRESHOLD_MS && !inPostStopHold) {
1952
+ const ptyQuietMs = nowMs - lastPtyDataAt;
1953
+ stallDiagWentQuiet = true;
1954
+ if (quietMs > stallDiagMaxQuietMs)
1955
+ stallDiagMaxQuietMs = quietMs;
1956
+ // PTY still painting while every structured signal is silent = the
1957
+ // frozen-stream-behind-a-live-spinner case that defeats the fast path.
1958
+ if (ptyQuietMs < CLAUDE_TUI_STALL_PTY_DEAD_MS)
1959
+ stallDiagPtyAliveWhileQuiet = true;
1960
+ if (nowMs - lastStallDiagHeartbeatAt >= STALL_DIAG_HEARTBEAT_INTERVAL_MS) {
1961
+ lastStallDiagHeartbeatAt = nowMs;
1962
+ // Snapshot the screen so a quiet stretch can later be classified as a frozen stream vs
1963
+ // a long think vs a blocking dialog vs an idle hold. Record the full screenState (not
1964
+ // just looksLikePrompt) so the lead-up to a kill is measurable as claude versions churn.
1965
+ const screenInfo = classifyClaudeScreen(screenTail);
1966
+ const looksLikePrompt = screenInfo.state === 'confirm-prompt'
1967
+ || screenInfo.state === 'plan-approval' || screenInfo.state === 'bypass-startup';
1968
+ writeStallDiag({
1969
+ kind: 'quiet',
1970
+ sessionId: activeSessionId,
1971
+ version: observedClaudeVersion,
1972
+ model: s.model || null,
1973
+ elapsedTurnMs: nowMs - start,
1974
+ quietMs,
1975
+ ptyQuietMs,
1976
+ lastJsonlType: lastMainJsonlType,
1977
+ mainJsonlAgoMs: nowMs - lastMainJsonlEventAt,
1978
+ toolEventAgoMs: nowMs - lastToolEventAt,
1979
+ sidecarAgoMs: lastSidecarEventAt ? nowMs - lastSidecarEventAt : null,
1980
+ pendingHookTools: pendingHookToolIds.size,
1981
+ pendingBgAgents: pendingBgForStall,
1982
+ pendingBgBash: pendingClaudeBackgroundBashCount(s),
1983
+ looksLikePrompt,
1984
+ screenState: screenInfo.state,
1985
+ screenSample: screenInfo.sample,
1986
+ });
1987
+ }
1988
+ }
1989
+ }
1990
+ if (!stallKilled) {
1991
+ const stallDecision = decideClaudeTuiStall({
1992
+ now: Date.now(),
1993
+ lastProgressAt,
1994
+ pendingToolCount: pendingHookToolIds.size + pendingBgForStall,
1995
+ lastPtyDataAt: inPostStopHold ? 0 : lastPtyDataAt,
1996
+ });
1997
+ if (stallDecision === 'stall') {
1998
+ const quietMin = Math.round((Date.now() - lastProgressAt) / 60_000);
1999
+ const ptyQuietS = Math.round((Date.now() - lastPtyDataAt) / 1000);
2000
+ // The screen is ground truth. Classify it, then map to an action — the chokepoint the
2001
+ // diagnostics proved was missing (the verdict used to change only the error string).
2002
+ const screen = classifyClaudeScreen(screenTail);
2003
+ const action = decideStallAction({
2004
+ state: screen.state,
2005
+ affirmativeKey: screen.affirmativeKey,
2006
+ pendingBgAgents: pendingBgForStall,
2007
+ alreadyTriedAnswer: stallAnswerTried,
2008
+ });
2009
+ const looksLikePrompt = screen.state === 'confirm-prompt'
2010
+ || screen.state === 'plan-approval' || screen.state === 'bypass-startup';
2011
+ // Diagnostics oracle: record the screen state + chosen action on every kill-point so the
2012
+ // false-positive rate stays measurable as claude versions churn.
2013
+ const writeStallRecord = () => writeStallDiag({
2014
+ kind: 'stall', sessionId: activeSessionId, version: observedClaudeVersion, model: s.model || null,
2015
+ elapsedTurnMs: Date.now() - start, quietMs: Date.now() - lastProgressAt, ptyQuietMs: Date.now() - lastPtyDataAt,
2016
+ ptyAliveWhileQuiet: stallDiagPtyAliveWhileQuiet, lastJsonlType: lastMainJsonlType,
2017
+ pendingHookTools: pendingHookToolIds.size, pendingBgAgents: pendingBgForStall,
2018
+ looksLikePrompt, screenState: screen.state, action, screenSample: screen.sample,
2019
+ });
2020
+ if (action === 'answer-retry' && screen.affirmativeKey) {
2021
+ // #1 fix: the in-flight onData answer only fires while bytes arrive, so a dialog that
2022
+ // painted once then went byte-silent never got a retry (full-match prompts were killed
2023
+ // with answer-count 1). Send the affirmative key now against the stable, fully-painted
2024
+ // screen (no settle — quiet 3 min) and let the grace check decide the outcome. No kill.
2025
+ stallAnswerTried = true;
2026
+ stallAnswerSentAt = Date.now();
2027
+ 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}`);
2028
+ sendConfirmAnswer(screen.affirmativeKey, 0, PROCEED_CONFIRM_DELAY_MS);
2029
+ }
2030
+ else if (action === 'terminate-clean') {
2031
+ // Idle REPL, nothing pending — the turn finished and we merely missed/held its Stop hook.
2032
+ // Hand off to the normal post-Stop drain: clean end, no 'stalled', no auto-resume.
2033
+ writeStallRecord();
2034
+ stopHookFired = true;
2035
+ stopHookSeenAt = Date.now();
2036
+ agentLog(`[claude-tui] watchdog saw an idle REPL with no pending work after ${quietMin}m — treating as a finished turn (clean end, no resume) pid=${proc.pid}`);
2037
+ }
2038
+ else if (action === 'terminate-prompt-unanswered') {
2039
+ writeStallRecord();
2040
+ terminatePromptUnanswered(screen.state, screen.sample);
2041
+ }
2042
+ else if (action === 'model-error') {
2043
+ // Selected model unavailable (banner painted to screen only — no JSONL, no Stop hook).
2044
+ // Surface the real reason; 'model_error' is non-retryable so we never resume into it.
2045
+ stallKilled = true;
2046
+ if (!terminalModelError)
2047
+ terminalModelError = claudeModelErrorMessage(s.model || opts.claudeModel || null);
2048
+ writeStallRecord();
2049
+ s.stopReason = 'model_error';
2050
+ if (!s.errors)
2051
+ s.errors = [terminalModelError];
2052
+ agentWarn(`[claude-tui] watchdog hit a model-unavailable banner after ${quietMin}m — ending turn (model_error, no resume) pid=${proc.pid}`);
2053
+ pushRecentActivity(s.recentActivity, 'Selected model unavailable — stopping');
2054
+ s.activity = s.recentActivity.join('\n');
2055
+ emit();
2056
+ killProc('SIGTERM');
2057
+ }
2058
+ else {
2059
+ // terminate-stalled: a genuine freeze candidate (state 'unknown') OR an idle hold with
2060
+ // pending background work we must not interrupt. Keep the self-healing SIGTERM-as-
2061
+ // 'stalled' path (auto-resumes once) with the existing model / limit arbitration.
2062
+ stallKilled = true;
2063
+ s.stopReason = 'stalled';
2064
+ writeStallRecord();
2065
+ if (!s.errors) {
2066
+ if (terminalModelError && !s.text.trim()) {
2067
+ s.stopReason = 'model_error';
2068
+ s.errors = [terminalModelError];
2069
+ }
2070
+ else {
2071
+ const limitOutcome = resolveClaudeTuiLimitOutcome({
2072
+ noticeText: terminalLimitNotice,
2073
+ noticeAt: terminalLimitNoticeAt,
2074
+ lastSubstantiveEventAt: Math.max(lastAssistantEventAt, lastToolEventAt, lastSidecarEventAt),
2075
+ hasOutputText: !!s.text.trim(),
2076
+ });
2077
+ if (limitOutcome === 'fatal') {
2078
+ s.stopReason = 'rate_limit';
2079
+ s.errors = [terminalLimitNotice];
2080
+ }
2081
+ else {
2082
+ s.errors = [`Claude process went silent mid-turn for ${quietMin}m (no JSONL, hook, or sub-agent events; PTY quiet ${ptyQuietS}s) — known claude CLI freeze. Terminated for auto-resume.`];
2083
+ }
2084
+ }
2085
+ }
2086
+ agentWarn(`[claude-tui] stall detected: no progress for ${quietMin}m (state=${screen.state}, pendingTools=${pendingHookToolIds.size}, pendingBg=${pendingBgForStall}, ptyQuiet=${ptyQuietS}s) — terminating TUI pid=${proc.pid}${s.stopReason === 'rate_limit' ? ' (usage limit)' : s.stopReason === 'model_error' ? ' (model unavailable)' : ' for auto-resume'}`);
2087
+ pushRecentActivity(s.recentActivity, s.stopReason === 'rate_limit'
2088
+ ? 'Usage limit blocked the turn — stopping'
2089
+ : s.stopReason === 'model_error'
2090
+ ? 'Selected model unavailable — stopping'
2091
+ : `Agent stalled (${quietMin}m silent) — restarting turn`);
2092
+ s.activity = s.recentActivity.join('\n');
2093
+ emit();
2094
+ killProc('SIGTERM');
2095
+ }
2096
+ // Keep polling: onExit resolves the wait and the final drains pick up
2097
+ // whatever the dying process flushes.
2098
+ }
2099
+ }
2100
+ }
2101
+ pollHandle = setTimeout(tick, POLL_INTERVAL_MS);
2102
+ };
2103
+ pollHandle = setTimeout(tick, POLL_INTERVAL_MS);
2104
+ // 10. Wait for process exit.
2105
+ await new Promise(resolve => {
2106
+ proc.onExit(({ exitCode: code, signal }) => {
2107
+ processExited = true;
2108
+ exitCode = code;
2109
+ exitSignal = typeof signal === 'number' ? signal : null;
2110
+ if (pollHandle) {
2111
+ clearTimeout(pollHandle);
2112
+ pollHandle = null;
2113
+ }
2114
+ clearTimeout(hardTimer);
2115
+ agentLog(`[claude-tui] exit code=${code} signal=${signal ?? '-'} lines=${lineCount}`);
2116
+ resolve();
2117
+ });
2118
+ });
2119
+ opts.abortSignal?.removeEventListener('abort', abortStream);
2120
+ // 11. Final drain — pick up anything written between the last poll and
2121
+ // process exit. Claude flushes its remaining JSONL events on shutdown.
2122
+ if (drainMainJsonl())
2123
+ emit();
2124
+ // Final tool-events drain — any PreToolUse / PostToolUse hooks that fired
2125
+ // between the last poll tick and process exit.
2126
+ if (drainToolEvents())
2127
+ emit();
2128
+ // Final sub-agent drain. The sub-agent's last events (closing tool_results)
2129
+ // may have landed after our last poll tick; mirror the main JSONL drain to
2130
+ // make sure sub.tools / sub.status carry the complete picture into the
2131
+ // final result.
2132
+ if (s.subAgents.size > 0)
2133
+ tryDiscoverSubAgents();
2134
+ if (pumpSubAgentSidecars())
2135
+ emit();
2136
+ // Process has exited and final drain is done — promote whatever is left in
2137
+ // the stream buffer into `s.text` so the final result message carries the
2138
+ // complete reply (not a truncated mid-stream prefix).
2139
+ flushStream();
2140
+ // 12. Cleanup temp dir. Keep it around when debugging so users can inspect
2141
+ // the captured PTY bytes + state file.
2142
+ if (!dbg) {
2143
+ try {
2144
+ fs.rmSync(workDir, { recursive: true, force: true });
2145
+ }
2146
+ catch { }
2147
+ }
2148
+ else {
2149
+ agentLog(`[claude-tui] debug artifacts retained in ${workDir}`);
2150
+ }
2151
+ // 13. Build the StreamResult — mirror the shape and semantics of
2152
+ // doClaudeInteractiveStream so downstream consumers (finalizeStreamResult,
2153
+ // dashboard rendering) cannot tell the two paths apart.
2154
+ const cleanStderr = stderrCapture.trim();
2155
+ // Detect Claude Code's synthetic "API Error: …" assistant reply (e.g.
2156
+ // 529 Overloaded). The text gets rewritten so the IM card doesn't surface
2157
+ // the raw "API Error: Overloaded" string to the user, and stopReason is
2158
+ // upgraded so the ClaudeDriver retry wrapper can decide to re-issue the
2159
+ // turn rather than letting the synthetic failure stick.
2160
+ const apiErrorReason = detectClaudeApiError(s.text);
2161
+ if (apiErrorReason) {
2162
+ agentWarn(`[claude-tui] upstream API error detected: ${apiErrorReason}`);
2163
+ s.stopReason = 'api_error';
2164
+ s.text = '';
2165
+ if (!s.errors)
2166
+ s.errors = [`Anthropic API error: ${apiErrorReason}`];
2167
+ }
2168
+ // Model-unavailable arbitration: the TUI painted the "selected model is
2169
+ // unavailable" banner (noteTerminalModelError) and the turn produced nothing
2170
+ // — no JSONL, no Stop hook. The early settle-timer's SIGTERM (or any exit)
2171
+ // brought us here; surface the real reason instead of a bare "(no textual
2172
+ // response)". stopReason 'model_error' is non-retryable (doClaudeWithRetry
2173
+ // only auto-resumes 'stalled'), so we never loop on the same dead model.
2174
+ if (!interrupted && !s.errors && terminalModelError && !s.text.trim()) {
2175
+ s.stopReason = 'model_error';
2176
+ s.errors = [terminalModelError];
2177
+ }
2178
+ // Limit-notice arbitration (see resolveClaudeTuiLimitOutcome). Covers the
2179
+ // paths the stall watchdog never reaches: the TUI painted a limit banner,
2180
+ // then Stop fired on an empty turn or the process exited — nothing
2181
+ // substantive ever followed the banner, so the limit ate the turn. A banner
2182
+ // followed by real output stays informational (already in the activity log).
2183
+ if (!interrupted && !timedOut && !s.errors) {
2184
+ const limitOutcome = resolveClaudeTuiLimitOutcome({
2185
+ noticeText: terminalLimitNotice,
2186
+ noticeAt: terminalLimitNoticeAt,
2187
+ lastSubstantiveEventAt: Math.max(lastAssistantEventAt, lastToolEventAt, lastSidecarEventAt),
2188
+ hasOutputText: !!s.text.trim(),
2189
+ });
2190
+ if (limitOutcome === 'fatal') {
2191
+ s.stopReason = 'rate_limit';
2192
+ s.errors = [terminalLimitNotice];
2193
+ }
2194
+ }
2195
+ const errorText = joinErrorMessages(s.errors);
2196
+ // "ok" requires: process exited cleanly (or via our own SIGTERM after Stop
2197
+ // hook fired, which yields a non-zero exit), no errors from the parser, no
2198
+ // user abort, no timeout. SIGTERM-after-Stop is the normal happy path.
2199
+ const exitedViaStopHook = stopHookFired && !timedOut && !interrupted;
2200
+ const procOk = (exitCode === 0) || exitedViaStopHook;
2201
+ const ok = procOk && !s.errors && !timedOut && !interrupted && stopHookFired;
2202
+ const error = errorText
2203
+ || (interrupted ? 'Interrupted by user.' : null)
2204
+ || (timedOut ? `Timed out after ${opts.timeout}s before the agent reported completion.` : null)
2205
+ || (!stopHookFired
2206
+ ? (cleanStderr
2207
+ || `Claude TUI exited (code=${exitCode}, signal=${exitSignal ?? '-'}) without completing the turn.`)
2208
+ : null);
2209
+ const incomplete = !ok || s.stopReason === 'max_tokens' || s.stopReason === 'timeout';
2210
+ const elapsedS = (Date.now() - start) / 1000;
2211
+ 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}`);
2212
+ // Stall diagnostics: a turn that went quiet has now ended. Recording the
2213
+ // outcome separates benign long-thinking/long-tool (completed) from true
2214
+ // freezes the watchdog had to kill (stalled-killed) — the calibration the
2215
+ // threshold tuning needs.
2216
+ if (stallDiagWentQuiet) {
2217
+ writeStallDiag({
2218
+ kind: 'resolved',
2219
+ sessionId: activeSessionId,
2220
+ version: observedClaudeVersion,
2221
+ model: s.model || null,
2222
+ elapsedTurnMs: Date.now() - start,
2223
+ maxQuietMs: stallDiagMaxQuietMs,
2224
+ ptyAliveWhileQuiet: stallDiagPtyAliveWhileQuiet,
2225
+ lastJsonlType: lastMainJsonlType,
2226
+ outcome: stallKilled ? 'stalled-killed'
2227
+ : interrupted ? 'interrupted'
2228
+ : timedOut ? 'timeout'
2229
+ : stopHookFired ? 'completed'
2230
+ : 'exited-no-stop',
2231
+ stopReason: s.stopReason || null,
2232
+ ok,
2233
+ });
2234
+ }
2235
+ // Build the message body. Order:
2236
+ // 1. Any assistant text captured from JSONL (the canonical reply).
2237
+ // 2. Parser-surfaced errors.
2238
+ // 3. For interrupted runs with no text yet, a clear status — never the
2239
+ // raw PTY scrape (it would be a half-rendered TUI screen with no value
2240
+ // to the user, and pre-ANSI-strip used to render as garbled gibberish
2241
+ // in IM).
2242
+ // 4. Fall back to ANSI-stripped stderrCapture for genuine startup
2243
+ // failures like "claude: command not found".
2244
+ const messageBody = s.text.trim()
2245
+ || errorText
2246
+ || (interrupted ? '(Interrupted before any reply landed.)'
2247
+ : procOk ? '(no textual response)'
2248
+ : `Failed (exit=${exitCode}).\n\n${cleanStderr || '(no output)'}`);
2249
+ return {
2250
+ ok,
2251
+ sessionId: s.sessionId,
2252
+ workspacePath: null,
2253
+ model: s.model,
2254
+ thinkingEffort: s.thinkingEffort,
2255
+ message: messageBody,
2256
+ thinking: s.thinking.trim() || null,
2257
+ elapsedS,
2258
+ inputTokens: s.inputTokens,
2259
+ outputTokens: s.outputTokens,
2260
+ cachedInputTokens: s.cachedInputTokens,
2261
+ cacheCreationInputTokens: s.cacheCreationInputTokens,
2262
+ contextWindow: s.contextWindow,
2263
+ contextUsedTokens: s.contextUsedTokens,
2264
+ contextPercent: computeContext(s).contextPercent,
2265
+ codexCumulative: null,
2266
+ error,
2267
+ plan: s.plan,
2268
+ stopReason: s.stopReason,
2269
+ incomplete,
2270
+ activity: s.activity.trim() || null,
2271
+ };
2272
+ }
2273
+ function makeErrorResult(opts, start, message) {
2274
+ return {
2275
+ ok: false,
2276
+ sessionId: opts.sessionId,
2277
+ workspacePath: null,
2278
+ model: opts.model,
2279
+ thinkingEffort: opts.thinkingEffort,
2280
+ message,
2281
+ thinking: null,
2282
+ elapsedS: (Date.now() - start) / 1000,
2283
+ inputTokens: null,
2284
+ outputTokens: null,
2285
+ cachedInputTokens: null,
2286
+ cacheCreationInputTokens: null,
2287
+ contextWindow: null,
2288
+ contextUsedTokens: null,
2289
+ contextPercent: null,
2290
+ codexCumulative: null,
2291
+ error: message,
2292
+ plan: null,
2293
+ stopReason: null,
2294
+ incomplete: true,
2295
+ activity: null,
2296
+ };
2297
+ }