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,2689 @@
1
+ /**
2
+ * Claude Code CLI driver: stream parsing, session reads, model listing, usage.
3
+ */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { execSync, spawn } from 'node:child_process';
7
+ import { createInterface } from 'node:readline';
8
+ import { registerDriver } from '../driver.js';
9
+ import {
10
+ // shared helpers
11
+ Q, run, agentError, agentLog, agentWarn, buildStreamPreviewMeta, computeContext, pushRecentActivity, summarizeClaudeToolUse, summarizeClaudeToolResult, joinErrorMessages, parseTodoWriteAsPlan, previewToolCallInput, previewToolCallResult, detectClaudeApiError, isRetryableClaudeApiError, detectClaudeModelError, claudeModelErrorMessage, emitSessionIdUpdate, IMAGE_EXTS, mimeForExt, listPikiloopSessions, mergeManagedAndNativeSessions, readTailLines, stripInjectedPrompts, sanitizeSessionUserPreviewText, SESSION_PREVIEW_IMAGE_PLACEHOLDER_RE, CLAUDE_AT_MENTION_IMAGE_RE, extractClaudeAtMentionImagePaths, attachAgentImage, applyTurnWindow, shortValue, roundPercent, modelFamily, normalizeClaudeModelId, emptyUsage, normalizeUsageStatus, collapseSkillPrompt, } from '../index.js';
12
+ import { AGENT_STREAM_HARD_KILL_GRACE_MS, AGENT_GRACEFUL_ABORT_GRACE_MS, SESSION_RUNNING_THRESHOLD_MS } from '../../core/constants.js';
13
+ import { terminateProcessTree } from '../../core/process-control.js';
14
+ import { getHome, IS_MAC, encodePathAsDirName } from '../../core/platform.js';
15
+ // ---------------------------------------------------------------------------
16
+ // Multimodal stdin
17
+ // ---------------------------------------------------------------------------
18
+ function buildClaudeUserMessage(prompt, attachments) {
19
+ const content = [];
20
+ for (const filePath of attachments) {
21
+ const ext = path.extname(filePath).toLowerCase();
22
+ if (IMAGE_EXTS.has(ext)) {
23
+ try {
24
+ const data = fs.readFileSync(filePath);
25
+ content.push({
26
+ type: 'image',
27
+ source: { type: 'base64', media_type: mimeForExt(ext), data: data.toString('base64') },
28
+ });
29
+ }
30
+ catch (e) {
31
+ agentWarn(`[attach] failed to read image ${filePath}: ${e.message}`);
32
+ }
33
+ }
34
+ else {
35
+ content.push({ type: 'text', text: `[Attached file: ${filePath}]` });
36
+ }
37
+ }
38
+ content.push({ type: 'text', text: prompt });
39
+ return JSON.stringify({ type: 'user', message: { role: 'user', content } }) + '\n';
40
+ }
41
+ function claudeUsesStreamJsonInput(o) {
42
+ return !!o.attachments?.length || !!o.onSteerReady;
43
+ }
44
+ const CLAUDE_STEER_IDLE_CLOSE_MS = 1200;
45
+ /**
46
+ * Effort + multi-agent-Workflow gate args, shared by BOTH Claude spawn paths
47
+ * (`claude -p` in claudeCmd below and the PTY/TUI driver in claude-tui.ts).
48
+ * Kept in one place so the gate can never drift between them — the omission
49
+ * that once left the Workflow tool always-on under the TUI driver.
50
+ *
51
+ * "ultra" is a synthetic picker rung (max depth + Workflow orchestration), never
52
+ * a real --effort value — translate it to `max` so a stray "ultra" can't reach
53
+ * and break the CLI, and treat it as an implicit workflow opt-in. The Workflow
54
+ * tool ships in the default toolset and triggers on a bare "workflow" keyword;
55
+ * under the bypassPermissions mode pikiloop runs by default that could auto-spawn
56
+ * a fleet of sub-agents, so drop it entirely unless orchestration was explicitly
57
+ * enabled (the workflow flag or the "ultra" rung).
58
+ */
59
+ export function claudeEffortAndWorkflowArgs(o) {
60
+ const args = [];
61
+ const ultraEffort = o.thinkingEffort === 'ultra';
62
+ if (o.thinkingEffort)
63
+ args.push('--effort', ultraEffort ? 'max' : o.thinkingEffort);
64
+ if (!o.claudeWorkflowEnabled && !ultraEffort)
65
+ args.push('--disallowed-tools', 'Workflow');
66
+ return args;
67
+ }
68
+ /**
69
+ * Env keys the claude CLI exports to its own subprocesses (Bash tool, hooks)
70
+ * to mark them as children of a running session. If pikiloop itself was
71
+ * launched from inside a Claude Code session — agent-driven `npm run dev`
72
+ * restarts, `! npx pikiloop` typed into the TUI, the self-bootstrap path —
73
+ * these leak into the daemon's environment and every claude it spawns
74
+ * inherits them. A claude started with `CLAUDE_CODE_CHILD_SESSION` set runs
75
+ * in child-session mode: it mirrors transcript persistence to its (absent)
76
+ * SDK parent instead of writing `~/.claude/projects/<dir>/<id>.jsonl`.
77
+ * The TUI driver tails that JSONL as its only text source, so a contaminated
78
+ * spawn streams nothing, returns "(no textual response)", and loses the whole
79
+ * turn on SIGTERM. Verified on 2.1.173: with these vars set the transcript
80
+ * never grows past the ai-title line; with them scrubbed every event lands
81
+ * 0.2–1.2s after it happens.
82
+ *
83
+ * Deliberately a closed list: config-style vars users set on purpose
84
+ * (CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_MAX_OUTPUT_TOKENS, …) must survive.
85
+ * Shared by both spawn paths (`claude -p` here and the PTY/TUI driver).
86
+ */
87
+ const CLAUDE_SESSION_CONTEXT_ENV_KEYS = [
88
+ 'CLAUDECODE',
89
+ 'CLAUDE_CODE_CHILD_SESSION',
90
+ 'CLAUDE_CODE_ENTRYPOINT',
91
+ 'CLAUDE_CODE_EXECPATH',
92
+ 'CLAUDE_CODE_SESSION_ID',
93
+ 'CLAUDE_CODE_SSE_PORT',
94
+ 'CLAUDE_EFFORT',
95
+ 'CLAUDE_PERMISSION_MODE',
96
+ ];
97
+ export function scrubClaudeSessionContextEnv(env) {
98
+ for (const key of CLAUDE_SESSION_CONTEXT_ENV_KEYS)
99
+ delete env[key];
100
+ }
101
+ // ---------------------------------------------------------------------------
102
+ // Command & parser
103
+ // ---------------------------------------------------------------------------
104
+ function claudeCmd(o) {
105
+ const args = ['claude', '-p', '--verbose', '--output-format', 'stream-json', '--include-partial-messages'];
106
+ const model = normalizeClaudeModelId(o.claudeModel);
107
+ if (model)
108
+ args.push('--model', model);
109
+ if (o.claudePermissionMode)
110
+ args.push('--permission-mode', o.claudePermissionMode);
111
+ // Fork: branch off the parent's full history into a fresh sessionId. The
112
+ // claude CLI exposes this via `--resume <parent> --fork-session`; the new
113
+ // session inherits the parent's transcript and gets its own JSONL file.
114
+ // We record `forkedAtTurn` as lineage metadata only — the agent's actual
115
+ // context is the full parent history.
116
+ if (o.forkOf) {
117
+ args.push('--resume', o.forkOf.parentSessionId, '--fork-session');
118
+ }
119
+ else if (o.sessionId) {
120
+ args.push('--resume', o.sessionId);
121
+ }
122
+ if (claudeUsesStreamJsonInput(o)) {
123
+ args.push('--input-format', 'stream-json');
124
+ if (o.onSteerReady)
125
+ args.push('--replay-user-messages');
126
+ if (o.attachments?.length)
127
+ o._stdinOverride = buildClaudeUserMessage(o.prompt, o.attachments);
128
+ }
129
+ // Effort + Workflow gate — shared with the TUI driver (claude-tui.ts) so the
130
+ // two spawn paths can never drift. See claudeEffortAndWorkflowArgs.
131
+ args.push(...claudeEffortAndWorkflowArgs(o));
132
+ if (o.claudeAppendSystemPrompt)
133
+ args.push('--append-system-prompt', o.claudeAppendSystemPrompt);
134
+ if (o.mcpConfigPath)
135
+ args.push('--mcp-config', o.mcpConfigPath);
136
+ if (o.claudeExtraArgs?.length)
137
+ args.push(...o.claudeExtraArgs);
138
+ return args;
139
+ }
140
+ /**
141
+ * Route a JSONL event that belongs to a sub-agent (parent_tool_use_id is set).
142
+ * The event is owned by the Task tool_use whose id matches `parentToolUseId`;
143
+ * we accumulate the sub-agent's model and tool calls without ever touching
144
+ * `s.recentActivity` or `s.text` (those stay scoped to the parent agent).
145
+ */
146
+ function routeClaudeSubAgentEvent(ev, t, parentToolUseId, s) {
147
+ const sub = s.subAgents.get(parentToolUseId);
148
+ if (!sub)
149
+ return; // Task tool_use should always precede sub-agent events; ignore stragglers.
150
+ if (t === 'system' || t === 'assistant') {
151
+ const model = ev.model ?? ev.message?.model;
152
+ if (typeof model === 'string' && model.trim())
153
+ sub.model = model;
154
+ }
155
+ if (t === 'assistant') {
156
+ const contents = ev.message?.content || [];
157
+ for (const block of contents) {
158
+ if (block?.type !== 'tool_use')
159
+ continue;
160
+ const toolId = String(block?.id || '').trim();
161
+ if (!toolId || s.seenClaudeToolIds.has(toolId))
162
+ continue;
163
+ const toolName = String(block?.name || 'Tool').trim() || 'Tool';
164
+ const summary = toolName === 'TodoWrite' ? 'Update plan' : summarizeClaudeToolUse(block?.name, block?.input || {});
165
+ s.seenClaudeToolIds.add(toolId);
166
+ s.claudeToolsById.set(toolId, { name: toolName, summary });
167
+ sub.tools.push({ id: toolId, name: toolName, summary });
168
+ }
169
+ }
170
+ }
171
+ function buildClaudeTurnUsage(u, turnOutput) {
172
+ if (u.input == null && u.output == null && u.cacheRead == null && u.cacheCreation == null)
173
+ return null;
174
+ const ctxWindow = claudeEffectiveContextWindow(claudeContextWindowFromModel(u.model));
175
+ const used = claudeContextUsedFromUsage({
176
+ input: u.input, cached: u.cacheRead, cacheCreation: u.cacheCreation, output: u.output,
177
+ });
178
+ const contextPercent = ctxWindow && used > 0
179
+ ? Math.min(99.9, Math.round(used / ctxWindow * 1000) / 10)
180
+ : null;
181
+ const meta = {
182
+ inputTokens: u.input,
183
+ outputTokens: u.output,
184
+ cachedInputTokens: u.cacheRead,
185
+ contextUsedTokens: used > 0 ? used : null,
186
+ contextPercent,
187
+ };
188
+ if (turnOutput && turnOutput > 0)
189
+ meta.turnOutputTokens = turnOutput;
190
+ return meta;
191
+ }
192
+ /** Hard cap beyond which a native Claude session is treated as idle regardless
193
+ * of the last JSONL event — guards against sessions abandoned mid-turn (Ctrl-C
194
+ * during a tool call, terminal crash) so they don't stick on "running". */
195
+ const CLAUDE_NATIVE_RUNNING_HARD_CAP_MS = 5 * 60 * 1000;
196
+ const CLAUDE_TURN_TERMINAL_STOP_REASONS = new Set(['end_turn', 'stop_sequence', 'max_tokens', 'refusal']);
197
+ /** Inspect a native Claude JSONL to decide whether a turn is currently in
198
+ * progress. Pure-mtime detection misses cases where Claude is mid-tool-use and
199
+ * hasn't appended for >10s; this checks the trailing event for a non-terminal
200
+ * state (user message awaiting reply, or assistant message with a non-end stop
201
+ * reason like `tool_use`). */
202
+ function isClaudeNativeSessionRunning(filePath, mtimeMs) {
203
+ const age = Date.now() - mtimeMs;
204
+ if (age < SESSION_RUNNING_THRESHOLD_MS)
205
+ return true;
206
+ if (age > CLAUDE_NATIVE_RUNNING_HARD_CAP_MS)
207
+ return false;
208
+ const tailLines = readTailLines(filePath, 64 * 1024);
209
+ for (let i = tailLines.length - 1; i >= 0; i--) {
210
+ const line = tailLines[i];
211
+ if (!line || line[0] !== '{')
212
+ continue;
213
+ let ev;
214
+ try {
215
+ ev = JSON.parse(line);
216
+ }
217
+ catch {
218
+ continue;
219
+ }
220
+ const t = ev?.type;
221
+ // Skip auto-title events — they fire after a turn completes and are not a liveness signal.
222
+ if (t === 'ai-title' || t === 'system')
223
+ continue;
224
+ if (t === 'user')
225
+ return true;
226
+ if (t === 'assistant') {
227
+ const stop = ev?.message?.stop_reason;
228
+ return stop != null ? !CLAUDE_TURN_TERMINAL_STOP_REASONS.has(stop) : true;
229
+ }
230
+ // Unknown event type — be conservative.
231
+ return false;
232
+ }
233
+ return false;
234
+ }
235
+ export function claudeContextWindowFromModel(model) {
236
+ const id = normalizeClaudeModelId(model).toLowerCase();
237
+ if (!id)
238
+ return null;
239
+ if (id === 'haiku' || /^claude-haiku-/.test(id))
240
+ return 200_000;
241
+ if (id === 'opus' || id === 'sonnet' || id === 'fable')
242
+ return 1_000_000;
243
+ if (/^claude-(opus|sonnet)-/.test(id) || /^claude-fable-/.test(id))
244
+ return 1_000_000;
245
+ return null;
246
+ }
247
+ // Mirrors Claude Code 2.1.112's `Yn()` + `v38()` denominator (and `pj()` display
248
+ // formula) — verified by extracting cli.js from the last JS-source release.
249
+ // uDY = 20_000 // max output reservation cap
250
+ // t_7 = 13_000 // auto-compact buffer
251
+ // Effective denominator with autoCompact enabled (cc's default) is
252
+ // `window - 20K - 13K`. Without these subtractions the percent we display
253
+ // drifts from the number cc itself reports (e.g. Opus 1M shows the user
254
+ // `Context left until auto-compact: X%` against a 967_000 denominator, not
255
+ // against a flat 1_000_000).
256
+ const CLAUDE_MAX_OUTPUT_RESERVE = 20_000;
257
+ const CLAUDE_AUTOCOMPACT_BUFFER = 13_000;
258
+ const CLAUDE_USABLE_WINDOW_RESERVE = CLAUDE_MAX_OUTPUT_RESERVE + CLAUDE_AUTOCOMPACT_BUFFER;
259
+ export function claudeEffectiveContextWindow(advertised) {
260
+ if (advertised == null)
261
+ return null;
262
+ if (advertised <= CLAUDE_USABLE_WINDOW_RESERVE)
263
+ return advertised;
264
+ return advertised - CLAUDE_USABLE_WINDOW_RESERVE;
265
+ }
266
+ // cc's `ey6` (was `hYB` pre-2.1.112) — context size of one assistant call. The
267
+ // `output_tokens` slice matters: cc walks back to the latest assistant message
268
+ // and adds its output to the input/cached/creation counters because that
269
+ // generated content already exists in conversation history and would be
270
+ // re-fed to the next call.
271
+ function claudeContextUsedFromUsage(u) {
272
+ return (u.input ?? 0) + (u.cached ?? 0) + (u.cacheCreation ?? 0) + (u.output ?? 0);
273
+ }
274
+ function recomputeClaudeContextUsed(s) {
275
+ const total = claudeContextUsedFromUsage({
276
+ input: s.inputTokens,
277
+ cached: s.cachedInputTokens,
278
+ cacheCreation: s.cacheCreationInputTokens,
279
+ output: s.outputTokens,
280
+ });
281
+ s.contextUsedTokens = total > 0 ? total : null;
282
+ }
283
+ /**
284
+ * Tool names whose `tool_result` image content does NOT count as
285
+ * assistant-generated output and must NOT be re-rendered in the assistant
286
+ * card. These tools merely read existing files (user attachments, project
287
+ * assets) — the bytes already lived somewhere the user can see them, so
288
+ * surfacing them again in the assistant block creates a duplicate of the
289
+ * user's own upload below Claude's text reply.
290
+ *
291
+ * Genuine image producers (MCP image-gen tools, mermaid-mcp, chart, dalle-mcp,
292
+ * Codex built-in image_gen, …) are NOT in this set and continue to render
293
+ * normally.
294
+ */
295
+ const CLAUDE_FILE_READING_TOOLS = new Set(['Read']);
296
+ function isClaudeFileReadingTool(toolName) {
297
+ return !!toolName && CLAUDE_FILE_READING_TOOLS.has(toolName);
298
+ }
299
+ /**
300
+ * Walk a content array (assistant message body OR a tool_result.content array)
301
+ * and push any image entries into the stream state's `imageBlocks`, deduped by
302
+ * the first 64 chars of base64 data. Used during live parsing so the final
303
+ * StreamResult carries every image the turn produced.
304
+ */
305
+ function accumulateClaudeImagesFromContent(content, s) {
306
+ if (!Array.isArray(content))
307
+ return;
308
+ for (const entry of content) {
309
+ if (!entry || typeof entry !== 'object')
310
+ continue;
311
+ if (entry.type === 'image') {
312
+ const block = claudeImageBlockFromEntry(entry);
313
+ if (!block)
314
+ continue;
315
+ const key = (entry.source?.data || '').slice(0, 64);
316
+ if (key && s.seenImageKeys?.has(key))
317
+ continue;
318
+ if (key)
319
+ s.seenImageKeys?.add(key);
320
+ s.imageBlocks?.push(block);
321
+ }
322
+ else if (entry.type === 'tool_result' && Array.isArray(entry.content)) {
323
+ // MCP / Skill tool_result with multimodal content — recurse for images,
324
+ // but skip tools that just read existing files (the bytes are already
325
+ // visible in the user's own upload bubble).
326
+ const toolName = entry.tool_use_id ? s.claudeToolsById?.get(entry.tool_use_id)?.name : null;
327
+ if (isClaudeFileReadingTool(toolName))
328
+ continue;
329
+ accumulateClaudeImagesFromContent(entry.content, s);
330
+ }
331
+ }
332
+ }
333
+ /**
334
+ * Read the server-assigned task id from a TaskCreate tool_result. Claude
335
+ * surfaces it via the structured `ev.toolUseResult.task.id` companion field,
336
+ * with a textual fallback ("Task #N created successfully: …") that we parse
337
+ * if the structured form is missing.
338
+ */
339
+ function readClaudeTaskCreateId(ev, block) {
340
+ const structured = ev?.toolUseResult?.task?.id;
341
+ if (structured != null && String(structured).trim())
342
+ return String(structured).trim();
343
+ const content = block?.content;
344
+ if (typeof content === 'string') {
345
+ const match = content.match(/Task #(\d+)/);
346
+ if (match)
347
+ return match[1];
348
+ }
349
+ return null;
350
+ }
351
+ /**
352
+ * Rebuild s.plan from the accumulated TaskCreate / TaskUpdate state so the
353
+ * dashboard + IM plan card show the canonical Claude Code 2.x task progress.
354
+ * Order follows insertion order (matches the on-screen Claude task list).
355
+ */
356
+ function rebuildClaudePlanFromTasks(s) {
357
+ if (!s.claudeTaskOrder?.length) {
358
+ // Nothing to render — leave s.plan alone so TodoWrite-era data (if any)
359
+ // doesn't get clobbered by an empty rebuild.
360
+ return;
361
+ }
362
+ const steps = [];
363
+ for (const id of s.claudeTaskOrder) {
364
+ const task = s.claudeTaskList.get(id);
365
+ if (!task)
366
+ continue;
367
+ const lowered = String(task.status || '').toLowerCase();
368
+ const status = lowered === 'completed' ? 'completed'
369
+ : lowered === 'in_progress' || lowered === 'inprogress' ? 'inProgress'
370
+ : 'pending';
371
+ steps.push({ step: task.subject, status });
372
+ }
373
+ s.plan = { explanation: null, steps };
374
+ }
375
+ function ensureClaudeBgAgentState(s) {
376
+ if (!s.bgAgentLaunchedToolUseIds)
377
+ s.bgAgentLaunchedToolUseIds = new Set();
378
+ if (!s.bgAgentCompletedToolUseIds)
379
+ s.bgAgentCompletedToolUseIds = new Set();
380
+ if (!s.bgBashToolUseIds)
381
+ s.bgBashToolUseIds = new Set();
382
+ if (!s.bgTaskIdToToolUse)
383
+ s.bgTaskIdToToolUse = new Map();
384
+ if (typeof s.lastTaskNotificationAt !== 'number')
385
+ s.lastTaskNotificationAt = 0;
386
+ }
387
+ /** Record a Task/Agent tool_use launched with `run_in_background: true`. */
388
+ export function registerClaudeBackgroundAgentLaunch(s, toolUseId) {
389
+ const id = String(toolUseId || '').trim();
390
+ if (!id)
391
+ return;
392
+ ensureClaudeBgAgentState(s);
393
+ s.bgAgentLaunchedToolUseIds.add(id);
394
+ }
395
+ /**
396
+ * Record a `Bash` tool_use launched with `run_in_background: true`.
397
+ *
398
+ * Background Bash lives *inside the claude process* exactly like a
399
+ * backgrounded sub-agent: its tool_result is a launch ack, the real
400
+ * completion arrives later as a `<task-notification>` which re-invokes the
401
+ * model in the same process. Before this registration existed only Task/Agent
402
+ * launches counted as "pending background work" — a turn that backgrounded a
403
+ * Bash command would hit Stop, decideClaudeTuiStop saw pending=0 and
404
+ * terminated the PTY, killing the command and its future report-back turn
405
+ * (the「claude 后台任务一停止就被掐死」failure).
406
+ */
407
+ export function registerClaudeBackgroundBashLaunch(s, toolUseId) {
408
+ const id = String(toolUseId || '').trim();
409
+ if (!id)
410
+ return;
411
+ ensureClaudeBgAgentState(s);
412
+ s.bgAgentLaunchedToolUseIds.add(id);
413
+ s.bgBashToolUseIds.add(id);
414
+ }
415
+ /** Launched background tasks (agents + bash) whose <task-notification> hasn't arrived yet. */
416
+ export function pendingClaudeBackgroundAgentCount(s) {
417
+ const launched = s?.bgAgentLaunchedToolUseIds;
418
+ if (!launched?.size)
419
+ return 0;
420
+ const completed = s?.bgAgentCompletedToolUseIds;
421
+ let pending = 0;
422
+ for (const id of launched) {
423
+ if (!completed?.has(id))
424
+ pending++;
425
+ }
426
+ return pending;
427
+ }
428
+ /** Pending background *Bash* tasks specifically. Unlike agents (whose sidecar
429
+ * JSONL keeps emitting events while alive), a background command is silent by
430
+ * nature — callers use this to pick a longer hold/stall budget. */
431
+ export function pendingClaudeBackgroundBashCount(s) {
432
+ const bash = s?.bgBashToolUseIds;
433
+ if (!bash?.size)
434
+ return 0;
435
+ const completed = s?.bgAgentCompletedToolUseIds;
436
+ let pending = 0;
437
+ for (const id of bash) {
438
+ if (!completed?.has(id))
439
+ pending++;
440
+ }
441
+ return pending;
442
+ }
443
+ /**
444
+ * Pull the background task id out of a launch ack. Claude Code's backgrounded
445
+ * Bash tool_result reads like "Command running in background with ID: bash_3
446
+ * (output: …)" — the id is what the later <task-notification> carries (its
447
+ * <tool-use-id> is often omitted for bash), so mapping id → tool_use here is
448
+ * what lets applyClaudeTaskNotification resolve the completion.
449
+ */
450
+ export function extractClaudeBackgroundTaskId(content) {
451
+ let text = '';
452
+ if (typeof content === 'string')
453
+ text = content;
454
+ else if (Array.isArray(content)) {
455
+ text = content
456
+ .filter((b) => b?.type === 'text' && typeof b.text === 'string')
457
+ .map((b) => b.text)
458
+ .join('\n');
459
+ }
460
+ else if (content && typeof content === 'object') {
461
+ try {
462
+ text = JSON.stringify(content);
463
+ }
464
+ catch {
465
+ return null;
466
+ }
467
+ }
468
+ if (!text || !/background/i.test(text))
469
+ return null;
470
+ const m = text.match(/\b(?:ID|id)\s*[::]?\s*[`"']?([A-Za-z0-9][A-Za-z0-9_-]{1,63})/);
471
+ return m ? m[1] : null;
472
+ }
473
+ /**
474
+ * Pull the workflow runId (`wf_…`) out of a Workflow launch ack. The Workflow
475
+ * tool returns immediately with `{ runId }` and the orchestration runs in the
476
+ * background; its later `<task-notification>` may carry only that id (no
477
+ * `<tool-use-id>`), so mapping runId → tool_use here is what lets
478
+ * applyClaudeTaskNotification resolve the completion and release the PTY hold.
479
+ */
480
+ export function extractClaudeWorkflowRunId(content) {
481
+ let text = '';
482
+ if (typeof content === 'string')
483
+ text = content;
484
+ else if (Array.isArray(content)) {
485
+ text = content
486
+ .filter((b) => b?.type === 'text' && typeof b.text === 'string')
487
+ .map((b) => b.text)
488
+ .join('\n');
489
+ }
490
+ else if (content && typeof content === 'object') {
491
+ try {
492
+ text = JSON.stringify(content);
493
+ }
494
+ catch {
495
+ return null;
496
+ }
497
+ }
498
+ if (!text)
499
+ return null;
500
+ const m = text.match(/\bwf_[a-z0-9][a-z0-9-]{4,}\b/i);
501
+ return m ? m[0] : null;
502
+ }
503
+ /**
504
+ * Parse a `<task-notification>` wrapper out of a user event's content.
505
+ * Shape (observed, Claude Code 2.x):
506
+ * <task-notification>
507
+ * <task-id>a7a61bd5e0e76e0f3</task-id>
508
+ * <tool-use-id>toolu_01MsPk…</tool-use-id> ← omitted for orphaned tasks
509
+ * <output-file>…</output-file>
510
+ * <status>completed | failed | killed</status>
511
+ * <summary>…</summary>
512
+ * </task-notification>
513
+ */
514
+ export function extractClaudeTaskNotification(content) {
515
+ let text = '';
516
+ if (typeof content === 'string')
517
+ text = content;
518
+ else if (Array.isArray(content)) {
519
+ text = content
520
+ .filter((b) => b?.type === 'text' && typeof b.text === 'string')
521
+ .map((b) => b.text)
522
+ .join('\n');
523
+ }
524
+ if (!text || !text.includes('<task-notification>'))
525
+ return null;
526
+ const tag = (name) => {
527
+ const m = text.match(new RegExp(`<${name}>\\s*([^<]*?)\\s*</${name}>`));
528
+ return m ? (m[1].trim() || null) : null;
529
+ };
530
+ return { taskId: tag('task-id'), toolUseId: tag('tool-use-id'), status: tag('status') };
531
+ }
532
+ /**
533
+ * Fold a task-notification into the stream state: mark the matching background
534
+ * agent completed and flip its preview card status. Notifications for unknown
535
+ * tasks (orphans from a previous process, background Bash, …) still bump
536
+ * `lastTaskNotificationAt` — the TUI driver uses that timestamp to tell a
537
+ * pre-notification Stop hook apart from the model's post-resume Stop.
538
+ */
539
+ export function applyClaudeTaskNotification(s, notification, eventAtMs) {
540
+ ensureClaudeBgAgentState(s);
541
+ // Prefer the event's own timestamp: when a JSONL flush delivers the
542
+ // notification and the wrap-up segment's Stop in one burst, parse-time would
543
+ // postdate the Stop and make a genuinely-fresh Stop look stale.
544
+ s.lastTaskNotificationAt = eventAtMs && Number.isFinite(eventAtMs) ? eventAtMs : Date.now();
545
+ const toolUseId = notification.toolUseId
546
+ || (notification.taskId ? s.bgTaskIdToToolUse.get(notification.taskId) : undefined)
547
+ || null;
548
+ if (!toolUseId)
549
+ return;
550
+ if (!s.bgAgentLaunchedToolUseIds.has(toolUseId) || s.bgAgentCompletedToolUseIds.has(toolUseId))
551
+ return;
552
+ s.bgAgentCompletedToolUseIds.add(toolUseId);
553
+ const sub = s.subAgents?.get(toolUseId);
554
+ if (sub && sub.status === 'running') {
555
+ const failed = /^(fail|kill|cancel|stop|abort|error)/i.test(notification.status || '');
556
+ sub.status = failed ? 'failed' : 'done';
557
+ }
558
+ const left = pendingClaudeBackgroundAgentCount(s);
559
+ pushRecentActivity(s.recentActivity, left > 0
560
+ ? `Background agent finished (${left} still running)`
561
+ : 'All background agents finished');
562
+ s.activity = s.recentActivity.join('\n');
563
+ }
564
+ export function claudeParse(ev, s) {
565
+ const t = ev.type || '';
566
+ // Sub-agent events (Task tool spawns a child agent) carry parent_tool_use_id
567
+ // pointing back to the parent's Task tool_use_id. They share the JSONL stream
568
+ // with parent events but must be isolated so their tool calls don't pollute
569
+ // the parent's activity list and their model/effort don't override the
570
+ // parent's runtime context.
571
+ const parentToolUseId = (typeof ev.parent_tool_use_id === 'string' && ev.parent_tool_use_id)
572
+ ? ev.parent_tool_use_id : null;
573
+ if (parentToolUseId) {
574
+ routeClaudeSubAgentEvent(ev, t, parentToolUseId, s);
575
+ return;
576
+ }
577
+ if (t === 'system') {
578
+ emitSessionIdUpdate(s, ev.session_id);
579
+ s.model = ev.model ?? s.model;
580
+ s.thinkingEffort = ev.thinking_level ?? s.thinkingEffort;
581
+ if (!s.byokContextWindow) {
582
+ const advertised = claudeContextWindowFromModel(s.model);
583
+ s.contextWindow = claudeEffectiveContextWindow(advertised) ?? s.contextWindow;
584
+ }
585
+ }
586
+ if (t === 'stream_event') {
587
+ const inner = ev.event || {};
588
+ if (inner.type === 'message_start') {
589
+ const u = inner.message?.usage;
590
+ // Per-call semantics: each LLM call inside a turn resets the counters,
591
+ // so the displayed In/Cached/Out and contextPercent describe the same
592
+ // call (matches cc's `LX(messages)` which returns the latest assistant
593
+ // usage, not a cumulative across calls).
594
+ // The finished call's output is folded into the turn-cumulative base
595
+ // first so `turnOutputTokens` keeps climbing across tool roundtrips.
596
+ s.turnOutputTokensBase = (s.turnOutputTokensBase ?? 0) + (s.outputTokens ?? 0);
597
+ s.inputTokens = u?.input_tokens ?? 0;
598
+ s.cachedInputTokens = u?.cache_read_input_tokens ?? 0;
599
+ s.cacheCreationInputTokens = u?.cache_creation_input_tokens ?? 0;
600
+ s.outputTokens = 0;
601
+ recomputeClaudeContextUsed(s);
602
+ }
603
+ // When a new text/thinking block starts after an earlier one (e.g. between
604
+ // a text block and a tool_use and back to text), insert a paragraph break
605
+ // so deltas from distinct blocks don't collapse into a single markdown
606
+ // paragraph.
607
+ if (inner.type === 'content_block_start') {
608
+ const blockType = inner.content_block?.type;
609
+ if (blockType === 'text' && s.text && !s.text.endsWith('\n\n')) {
610
+ s.text += s.text.endsWith('\n') ? '\n' : '\n\n';
611
+ }
612
+ else if (blockType === 'thinking' && s.thinking && !s.thinking.endsWith('\n\n')) {
613
+ s.thinking += s.thinking.endsWith('\n') ? '\n' : '\n\n';
614
+ }
615
+ }
616
+ if (inner.type === 'content_block_delta') {
617
+ const d = inner.delta || {};
618
+ if (d.type === 'thinking_delta')
619
+ s.thinking += d.thinking || '';
620
+ else if (d.type === 'text_delta')
621
+ s.text += d.text || '';
622
+ }
623
+ if (inner.type === 'message_delta') {
624
+ const d = inner.delta || {};
625
+ s.stopReason = d.stop_reason ?? s.stopReason;
626
+ const u = inner.usage;
627
+ if (u) {
628
+ // message_delta reports running totals for the active call. Per-call
629
+ // semantics: just overwrite — last value wins.
630
+ if (u.input_tokens != null)
631
+ s.inputTokens = u.input_tokens;
632
+ if (u.cache_read_input_tokens != null)
633
+ s.cachedInputTokens = u.cache_read_input_tokens;
634
+ if (u.cache_creation_input_tokens != null)
635
+ s.cacheCreationInputTokens = u.cache_creation_input_tokens;
636
+ if (u.output_tokens != null)
637
+ s.outputTokens = u.output_tokens;
638
+ recomputeClaudeContextUsed(s);
639
+ }
640
+ }
641
+ emitSessionIdUpdate(s, ev.session_id);
642
+ s.model = ev.model ?? s.model;
643
+ if (!s.byokContextWindow) {
644
+ const advertised = claudeContextWindowFromModel(s.model);
645
+ s.contextWindow = claudeEffectiveContextWindow(advertised) ?? s.contextWindow;
646
+ }
647
+ }
648
+ if (t === 'assistant') {
649
+ const msg = ev.message || {};
650
+ // Skip Claude CLI's synthetic feedback events on the live channel — they
651
+ // arrive as `assistant` events but represent runtime notices (no response,
652
+ // model error, …), not real Claude output. The historical jsonl reader
653
+ // converts them into `system_notice` blocks; on the live stream we just
654
+ // drop them so they don't pollute s.text / s.thinking.
655
+ if (msg.model === '<synthetic>') {
656
+ // …except the "selected model is unavailable" notice (404 model_not_found):
657
+ // a hard, non-retryable failure. The result event's text fallback below
658
+ // also catches it, but recording s.errors here upgrades the turn from a
659
+ // bare "(no textual response)" reply to a clear error + non-retryable
660
+ // stopReason (so doClaudeWithRetry won't loop on the same dead model).
661
+ if (!s.errors) {
662
+ const synthText = (msg.content || [])
663
+ .filter((b) => b?.type === 'text').map((b) => b.text || '').join(' ');
664
+ if (ev.error === 'model_not_found' || detectClaudeModelError(synthText)) {
665
+ s.stopReason = 'model_error';
666
+ s.errors = [claudeModelErrorMessage(s.model)];
667
+ }
668
+ }
669
+ return;
670
+ }
671
+ const contents = msg.content || [];
672
+ const th = contents.filter((b) => b?.type === 'thinking').map((b) => b.thinking || '').join('\n\n');
673
+ const tx = contents.filter((b) => b?.type === 'text').map((b) => b.text || '').join('\n\n');
674
+ const toolUses = contents.filter((b) => b?.type === 'tool_use');
675
+ accumulateClaudeImagesFromContent(contents, s);
676
+ if (th && !s.thinking.trim())
677
+ s.thinking = th;
678
+ if (tx && !s.text.trim())
679
+ s.text = tx;
680
+ for (const block of toolUses) {
681
+ const toolId = String(block?.id || '').trim();
682
+ if (!toolId || s.seenClaudeToolIds.has(toolId))
683
+ continue;
684
+ const toolName = String(block?.name || 'Tool').trim() || 'Tool';
685
+ // TodoWrite → update plan instead of adding activity noise (Claude Code 1.x)
686
+ if (toolName === 'TodoWrite') {
687
+ const plan = parseTodoWriteAsPlan(block?.input);
688
+ if (plan)
689
+ s.plan = plan;
690
+ s.seenClaudeToolIds.add(toolId);
691
+ s.claudeToolsById.set(toolId, { name: toolName, summary: 'Update plan' });
692
+ continue;
693
+ }
694
+ // TaskCreate / TaskUpdate → 2.x plan tools. Same intent as TodoWrite, but
695
+ // emitted one task at a time. Buffer TaskCreate inputs until the matching
696
+ // tool_result arrives with the server-assigned id; apply TaskUpdate status
697
+ // changes against the running map. Both rebuild s.plan so the dashboard /
698
+ // IM plan card keeps surfacing total + current progress.
699
+ if (toolName === 'TaskCreate') {
700
+ const subject = typeof block?.input?.subject === 'string' ? block.input.subject.trim() : '';
701
+ if (subject)
702
+ s.pendingClaudeTaskCreates.set(toolId, { subject });
703
+ s.seenClaudeToolIds.add(toolId);
704
+ s.claudeToolsById.set(toolId, { name: toolName, summary: subject ? `Create task: ${subject}` : 'Create task' });
705
+ continue;
706
+ }
707
+ if (toolName === 'TaskUpdate') {
708
+ const taskId = String(block?.input?.taskId ?? '').trim();
709
+ const rawStatus = String(block?.input?.status ?? '').trim().toLowerCase();
710
+ if (taskId) {
711
+ if (rawStatus === 'deleted') {
712
+ s.claudeTaskList.delete(taskId);
713
+ s.claudeTaskOrder = s.claudeTaskOrder.filter((id) => id !== taskId);
714
+ }
715
+ else if (rawStatus) {
716
+ const existing = s.claudeTaskList.get(taskId);
717
+ if (existing)
718
+ existing.status = rawStatus;
719
+ }
720
+ rebuildClaudePlanFromTasks(s);
721
+ }
722
+ s.seenClaudeToolIds.add(toolId);
723
+ s.claudeToolsById.set(toolId, { name: toolName, summary: `Update task ${taskId || '?'} → ${rawStatus || 'unknown'}` });
724
+ continue;
725
+ }
726
+ // Task → represents a sub-agent invocation. Carve it out as its own
727
+ // streamed unit so the child's tool stream and model don't bleed into
728
+ // the parent's activity card.
729
+ if (toolName === 'Task' || toolName === 'Agent') {
730
+ const input = block?.input || {};
731
+ const subAgent = {
732
+ id: toolId,
733
+ kind: typeof input.subagent_type === 'string' ? input.subagent_type : null,
734
+ description: typeof input.description === 'string' ? input.description : null,
735
+ model: null,
736
+ tools: [],
737
+ status: 'running',
738
+ };
739
+ s.subAgents.set(toolId, subAgent);
740
+ if (input.run_in_background === true)
741
+ registerClaudeBackgroundAgentLaunch(s, toolId);
742
+ s.seenClaudeToolIds.add(toolId);
743
+ s.claudeToolsById.set(toolId, { name: toolName, summary: subAgent.description || 'Run task' });
744
+ continue;
745
+ }
746
+ // Background Bash — same in-process lifecycle as a backgrounded agent:
747
+ // launch ack now, <task-notification> later. Register so the TUI driver
748
+ // holds the PTY open instead of SIGTERMing the command mid-flight.
749
+ if (toolName === 'Bash' && block?.input?.run_in_background === true) {
750
+ registerClaudeBackgroundBashLaunch(s, toolId);
751
+ }
752
+ // Workflow → multi-agent orchestration. ALWAYS backgrounded: the tool
753
+ // returns immediately with a runId and the orchestration keeps running
754
+ // *inside the claude process*, reporting completion via a later
755
+ // `<task-notification>` — the same in-process lifecycle as a
756
+ // run_in_background Task. Register it so decideClaudeTuiStop holds the PTY
757
+ // open instead of SIGTERMing the in-flight workflow when the launch
758
+ // segment's Stop fires (the「ultra 下 workflow 离线跑、TUI 误判结束退出把
759
+ // workflow 打断」failure — the workflow analogue of the bg-Bash fix above).
760
+ if (toolName === 'Workflow') {
761
+ registerClaudeBackgroundAgentLaunch(s, toolId);
762
+ }
763
+ const tool = {
764
+ name: toolName,
765
+ summary: summarizeClaudeToolUse(block?.name, block?.input || {}),
766
+ input: previewToolCallInput(toolName, block?.input),
767
+ status: 'running',
768
+ };
769
+ s.seenClaudeToolIds.add(toolId);
770
+ s.claudeToolsById.set(toolId, tool);
771
+ if (!s.claudeToolCallOrder)
772
+ s.claudeToolCallOrder = [];
773
+ s.claudeToolCallOrder.push(toolId);
774
+ pushRecentActivity(s.recentActivity, tool.summary);
775
+ }
776
+ s.activity = s.recentActivity.join('\n');
777
+ s.stopReason = msg.stop_reason ?? s.stopReason;
778
+ }
779
+ if (t === 'user') {
780
+ const msg = ev.message || {};
781
+ const contents = Array.isArray(msg.content) ? msg.content : [];
782
+ // Background-task completion notice. Claude Code injects these as user
783
+ // events when a `run_in_background` task finishes (or dies); they are the
784
+ // only completion signal backgrounded agents ever get — the Task tool's
785
+ // own tool_result fired back at launch time as an ack.
786
+ const notification = extractClaudeTaskNotification(msg.content);
787
+ if (notification) {
788
+ const eventAtMs = typeof ev.timestamp === 'string' ? Date.parse(ev.timestamp) : NaN;
789
+ applyClaudeTaskNotification(s, notification, Number.isFinite(eventAtMs) ? eventAtMs : null);
790
+ }
791
+ const toolResults = contents.filter((b) => b?.type === 'tool_result');
792
+ for (const block of toolResults) {
793
+ const toolId = String(block?.tool_use_id || '').trim();
794
+ // Dedup against tool_results already pushed by the TUI hook stream —
795
+ // PreToolUse / PostToolUse arrive in real time, JSONL eventually
796
+ // delivers the same events at end-of-turn and would otherwise re-push
797
+ // each summary into activity / re-process TaskCreate's plan entry.
798
+ if (toolId && s.seenClaudeToolResultIds?.has(toolId))
799
+ continue;
800
+ if (toolId) {
801
+ if (!s.seenClaudeToolResultIds)
802
+ s.seenClaudeToolResultIds = new Set();
803
+ s.seenClaudeToolResultIds.add(toolId);
804
+ }
805
+ const tool = toolId ? s.claudeToolsById.get(toolId) : undefined;
806
+ // Skip TodoWrite / TaskCreate / TaskUpdate results from activity — plan
807
+ // card handles them. TaskCreate's tool_result carries the assigned task
808
+ // id, which we splice into the running task list before skipping.
809
+ if (tool?.name === 'TodoWrite')
810
+ continue;
811
+ if (tool?.name === 'TaskCreate') {
812
+ const pending = toolId ? s.pendingClaudeTaskCreates.get(toolId) : undefined;
813
+ const assignedId = readClaudeTaskCreateId(ev, block);
814
+ if (pending && assignedId) {
815
+ s.pendingClaudeTaskCreates.delete(toolId);
816
+ if (!s.claudeTaskList.has(assignedId))
817
+ s.claudeTaskOrder.push(assignedId);
818
+ s.claudeTaskList.set(assignedId, { subject: pending.subject, status: 'pending' });
819
+ rebuildClaudePlanFromTasks(s);
820
+ }
821
+ continue;
822
+ }
823
+ if (tool?.name === 'TaskUpdate')
824
+ continue;
825
+ // Sub-agent tool_result closes out the sub-agent's lifecycle — flip its
826
+ // status and skip the regular activity append (the sub-agent card carries
827
+ // it). The result content text is the sub-agent's full response which
828
+ // would otherwise leak into the parent activity feed.
829
+ // Exception: a `run_in_background` launch returns its tool_result
830
+ // immediately as a mere ack — the agent is still running. Its real
831
+ // completion is the later <task-notification> (see the user branch).
832
+ if (tool?.name === 'Task' || tool?.name === 'Agent') {
833
+ const sub = s.subAgents.get(toolId);
834
+ if (sub) {
835
+ const isBgLaunchAck = !block?.is_error
836
+ && s.bgAgentLaunchedToolUseIds?.has(toolId)
837
+ && !s.bgAgentCompletedToolUseIds?.has(toolId);
838
+ if (!isBgLaunchAck)
839
+ sub.status = block?.is_error ? 'failed' : 'done';
840
+ }
841
+ continue;
842
+ }
843
+ if (tool) {
844
+ tool.result = previewToolCallResult(block?.content);
845
+ tool.status = block?.is_error ? 'failed' : 'done';
846
+ }
847
+ // Background Bash launch ack → map its task id to the tool_use so the
848
+ // later <task-notification> (which usually omits <tool-use-id> for bash)
849
+ // can resolve and decrement the pending count.
850
+ if (tool?.name === 'Bash' && s.bgBashToolUseIds?.has(toolId)
851
+ && !s.bgAgentCompletedToolUseIds?.has(toolId)) {
852
+ const taskId = extractClaudeBackgroundTaskId(block?.content);
853
+ if (taskId && !s.bgTaskIdToToolUse.has(taskId))
854
+ s.bgTaskIdToToolUse.set(taskId, toolId);
855
+ }
856
+ // Workflow launch ack carries the runId (wf_…). Map it → tool_use so a
857
+ // later <task-notification> that identifies the workflow only by task id
858
+ // (no <tool-use-id>) still resolves and decrements the pending count.
859
+ if (tool?.name === 'Workflow' && s.bgAgentLaunchedToolUseIds?.has(toolId)
860
+ && !s.bgAgentCompletedToolUseIds?.has(toolId)) {
861
+ const runId = extractClaudeWorkflowRunId(block?.content);
862
+ if (runId && !s.bgTaskIdToToolUse.has(runId))
863
+ s.bgTaskIdToToolUse.set(runId, toolId);
864
+ }
865
+ pushRecentActivity(s.recentActivity, summarizeClaudeToolResult(tool, block, ev.tool_use_result));
866
+ // MCP / Skill tool_result with multimodal content — recurse for image
867
+ // entries so the final StreamResult carries them. Filesystem-reading
868
+ // tools (Read) are skipped: their image content is a copy of an
869
+ // existing file (often the user's own upload) and would otherwise be
870
+ // re-rendered below the assistant text.
871
+ if (Array.isArray(block.content) && !isClaudeFileReadingTool(tool?.name)) {
872
+ accumulateClaudeImagesFromContent(block.content, s);
873
+ }
874
+ }
875
+ s.activity = s.recentActivity.join('\n');
876
+ }
877
+ if (t === 'result') {
878
+ emitSessionIdUpdate(s, ev.session_id);
879
+ s.model = ev.model ?? s.model;
880
+ if (ev.is_error && ev.errors?.length)
881
+ s.errors = ev.errors;
882
+ if (ev.result && !s.text.trim())
883
+ s.text = ev.result;
884
+ // A model-unavailable turn carries a normal stop_reason on its result event
885
+ // (e.g. 'stop_sequence') that would otherwise clobber the 'model_error' the
886
+ // synthetic handler set — preserve it so the failure stays diagnosable.
887
+ if (s.stopReason !== 'model_error')
888
+ s.stopReason = ev.stop_reason ?? s.stopReason;
889
+ const u = ev.usage;
890
+ if (u) {
891
+ // Per-call semantics: the last message_start/message_delta snapshot is
892
+ // authoritative. Only fall back to result.usage when nothing arrived via
893
+ // stream_event (e.g. an early-exit error before any message_start).
894
+ const cached = u.cache_read_input_tokens ?? u.cached_input_tokens;
895
+ if (s.inputTokens == null && u.input_tokens != null)
896
+ s.inputTokens = u.input_tokens;
897
+ if (s.cachedInputTokens == null && cached != null)
898
+ s.cachedInputTokens = cached;
899
+ if (s.cacheCreationInputTokens == null && u.cache_creation_input_tokens != null) {
900
+ s.cacheCreationInputTokens = u.cache_creation_input_tokens;
901
+ }
902
+ if (s.outputTokens == null && u.output_tokens != null)
903
+ s.outputTokens = u.output_tokens;
904
+ recomputeClaudeContextUsed(s);
905
+ }
906
+ const mu = ev.modelUsage;
907
+ if (mu && typeof mu === 'object' && !s.byokContextWindow) {
908
+ for (const info of Object.values(mu)) {
909
+ // cc reports the *advertised* contextWindow on result.modelUsage; we
910
+ // store the *effective* (post-reservation) window so the percent
911
+ // matches cc's UM6 display formula.
912
+ if (info?.contextWindow > 0) {
913
+ s.contextWindow = claudeEffectiveContextWindow(info.contextWindow) ?? info.contextWindow;
914
+ break;
915
+ }
916
+ }
917
+ }
918
+ }
919
+ }
920
+ export function createClaudeStreamState(opts) {
921
+ // When BYOK is bound, the real context window (e.g. 1M for DeepSeek v4 Pro
922
+ // via OpenRouter) comes from the provider's cached /models listing — cc
923
+ // reports its own Claude-shaped fallback (200k) for unknown model ids, so
924
+ // we lock in the real value upfront and refuse to overwrite it from cc's
925
+ // event stream below.
926
+ const byokWindow = opts.byokContextWindow && opts.byokContextWindow > 0
927
+ ? opts.byokContextWindow
928
+ : null;
929
+ const byokProvider = opts.byokProviderName || null;
930
+ return {
931
+ sessionId: opts.sessionId,
932
+ text: '',
933
+ thinking: '',
934
+ msgs: [],
935
+ thinkParts: [],
936
+ model: opts.model,
937
+ thinkingEffort: opts.thinkingEffort,
938
+ errors: null,
939
+ inputTokens: null,
940
+ outputTokens: null,
941
+ cachedInputTokens: null,
942
+ cacheCreationInputTokens: null,
943
+ /** Output tokens from this turn's finished LLM calls — folded in when a
944
+ * new call resets the per-call counter, so the turn total only climbs. */
945
+ turnOutputTokensBase: 0,
946
+ /** message.id of the LLM call whose usage `outputTokens` currently
947
+ * reflects (TUI/JSONL mode, where there is no message_start marker). */
948
+ turnUsageMsgId: null,
949
+ contextWindow: byokWindow,
950
+ /** When set, ignore cc-advertised contextWindow updates from the stream. */
951
+ byokContextWindow: byokWindow,
952
+ /** BYOK provider display name surfaced in preview meta + IM footers. */
953
+ byokProviderName: byokProvider,
954
+ contextUsedTokens: null,
955
+ codexCumulative: null,
956
+ stopReason: null,
957
+ activity: '',
958
+ recentActivity: [],
959
+ plan: null,
960
+ // Claude Code 2.x replaced the single `TodoWrite` plan tool with two
961
+ // separate tools — `TaskCreate` (one task per call, server-assigned id)
962
+ // and `TaskUpdate` (taskId + status). We maintain an ordered map and
963
+ // rebuild s.plan whenever either fires so the dashboard / IM plan card
964
+ // keeps showing total / current progress just like the TodoWrite era.
965
+ claudeTaskList: new Map(),
966
+ claudeTaskOrder: [],
967
+ /** Pending TaskCreate tool_uses indexed by tool_use id — the input
968
+ * carries the subject but Claude assigns the numeric task id only in
969
+ * the matching tool_result, so we have to bridge the two halves. */
970
+ pendingClaudeTaskCreates: new Map(),
971
+ claudeToolsById: new Map(),
972
+ /** Insertion order of expandable tool-call rows (parent activity tools
973
+ * only — plan tools and sub-agent launches have their own cards). Feeds
974
+ * `StreamPreviewMeta.toolCalls` via buildStreamPreviewMeta. */
975
+ claudeToolCallOrder: [],
976
+ seenClaudeToolIds: new Set(),
977
+ subAgents: new Map(),
978
+ /** Tool_use ids of Task/Agent launches with `run_in_background: true`.
979
+ * Their immediate tool_result is only a launch ack — real completion
980
+ * arrives later as a `<task-notification>` user event. The TUI driver
981
+ * reads the launched/completed delta to keep the PTY alive until every
982
+ * background agent has actually finished (they live inside the claude
983
+ * process; killing it would destroy them mid-flight). */
984
+ bgAgentLaunchedToolUseIds: new Set(),
985
+ /** Subset of bgAgentLaunchedToolUseIds whose <task-notification> arrived. */
986
+ bgAgentCompletedToolUseIds: new Set(),
987
+ /** Background task id (the `agent-<id>` sidecar stem / `<task-id>` tag) →
988
+ * parent tool_use id. Fallback matcher for notifications that omit the
989
+ * `<tool-use-id>` tag. */
990
+ bgTaskIdToToolUse: new Map(),
991
+ /** Wall-clock ms of the most recent <task-notification> parsed this turn. */
992
+ lastTaskNotificationAt: 0,
993
+ // Image blocks accumulated during the turn (user-attached on the request
994
+ // side, MCP / Skill tool_result on the response side). Surfaced in the
995
+ // final StreamResult so IM channels can dispatch images at end-of-turn.
996
+ imageBlocks: [],
997
+ /** Stable dedupe keys (sha-ish data prefixes) for image blocks already
998
+ * added to `imageBlocks`. Lets repeated stream_event / assistant deltas
999
+ * for the same image not pile up duplicates. */
1000
+ seenImageKeys: new Set(),
1001
+ // Wired to opts.onSessionId so claudeParse can broadcast id changes the
1002
+ // instant cc surfaces them (see emitSessionIdUpdate in agent/utils.ts).
1003
+ _emitSessionId: opts.onSessionId ?? null,
1004
+ };
1005
+ }
1006
+ function resetClaudeTurnState(s, note) {
1007
+ s.text = '';
1008
+ s.thinking = '';
1009
+ s.msgs = [];
1010
+ s.thinkParts = [];
1011
+ s.errors = null;
1012
+ s.inputTokens = null;
1013
+ s.outputTokens = null;
1014
+ s.turnOutputTokensBase = 0;
1015
+ s.turnUsageMsgId = null;
1016
+ s.cachedInputTokens = null;
1017
+ s.cacheCreationInputTokens = null;
1018
+ s.contextUsedTokens = null;
1019
+ s.stopReason = null;
1020
+ s.activity = '';
1021
+ s.recentActivity = [];
1022
+ s.claudeToolsById = new Map();
1023
+ s.claudeToolCallOrder = [];
1024
+ s.subAgents = new Map();
1025
+ s.seenClaudeToolIds = new Set();
1026
+ s.bgAgentLaunchedToolUseIds = new Set();
1027
+ s.bgAgentCompletedToolUseIds = new Set();
1028
+ s.bgTaskIdToToolUse = new Map();
1029
+ s.lastTaskNotificationAt = 0;
1030
+ s.imageBlocks = [];
1031
+ s.seenImageKeys = new Set();
1032
+ if (note) {
1033
+ pushRecentActivity(s.recentActivity, note);
1034
+ s.activity = s.recentActivity.join('\n');
1035
+ }
1036
+ }
1037
+ async function doClaudeInteractiveStream(opts) {
1038
+ const start = Date.now();
1039
+ const deadline = start + opts.timeout * 1000;
1040
+ let stderr = '';
1041
+ let lineCount = 0;
1042
+ let timedOut = false;
1043
+ let interrupted = false;
1044
+ let stdinClosed = false;
1045
+ let steerQueued = false;
1046
+ let awaitingSteeredResponseStart = false;
1047
+ let idleCloseTimer = null;
1048
+ const s = createClaudeStreamState(opts);
1049
+ const cmd = claudeCmd(opts);
1050
+ const shellCmd = cmd.map(Q).join(' ');
1051
+ agentLog(`[spawn] full command: cd ${Q(opts.workdir)} && ${shellCmd}`);
1052
+ agentLog(`[spawn] timeout: ${opts.timeout}s session: ${opts.sessionId || '(new)'}`);
1053
+ agentLog(`[spawn] prompt (stdin): "${opts.prompt.slice(0, 300)}${opts.prompt.length > 300 ? '…' : ''}"`);
1054
+ const spawnEnv = { ...process.env, ...(opts.extraEnv || {}) };
1055
+ scrubClaudeSessionContextEnv(spawnEnv);
1056
+ const proc = spawn(shellCmd, {
1057
+ cwd: opts.workdir,
1058
+ env: spawnEnv,
1059
+ stdio: ['pipe', 'pipe', 'pipe'],
1060
+ shell: true,
1061
+ detached: process.platform !== 'win32',
1062
+ });
1063
+ agentLog(`[spawn] pid=${proc.pid}`);
1064
+ const closeInput = () => {
1065
+ if (idleCloseTimer) {
1066
+ clearTimeout(idleCloseTimer);
1067
+ idleCloseTimer = null;
1068
+ }
1069
+ if (stdinClosed)
1070
+ return;
1071
+ stdinClosed = true;
1072
+ try {
1073
+ proc.stdin?.end();
1074
+ }
1075
+ catch { }
1076
+ };
1077
+ const emit = () => {
1078
+ opts.onText(s.text, s.thinking, s.activity, buildStreamPreviewMeta(s), s.plan);
1079
+ };
1080
+ const abortStream = () => {
1081
+ if (interrupted || proc.killed)
1082
+ return;
1083
+ interrupted = true;
1084
+ s.stopReason = 'interrupted';
1085
+ closeInput();
1086
+ agentWarn(`[abort] user interrupt, closing stdin for graceful shutdown pid=${proc.pid}`);
1087
+ // Wait until the session JSONL has stopped being written before SIGTERM.
1088
+ // Claude CLI streams events on stdout (which we observe in real time) BEFORE
1089
+ // flushing the matching JSONL line — and its signal handler is just
1090
+ // `process.exit()`, which doesn't drain the async write queue. SIGTERMing
1091
+ // on a fixed window races that flush: the dashboard's live snapshot would
1092
+ // show N tool calls, then once it reloads from disk the persisted turn
1093
+ // regresses below N (e.g. 30 → 27). Poll the JSONL size and SIGTERM only
1094
+ // after it's been stable for FILE_STABLE_MS, or after MAX_WAIT_MS as a
1095
+ // hard cap (a still-active LLM stream would never go stable on its own).
1096
+ const sessionFile = s.sessionId
1097
+ ? path.join(getHome(), '.claude', 'projects', claudeProjectDirName(opts.workdir), `${s.sessionId}.jsonl`)
1098
+ : null;
1099
+ const FILE_STABLE_MS = 600;
1100
+ const POLL_MS = 100;
1101
+ const MAX_WAIT_MS = 6000;
1102
+ const startedAt = Date.now();
1103
+ let lastSize = -1;
1104
+ let lastChangedAt = startedAt;
1105
+ const tick = () => {
1106
+ if (proc.exitCode != null || proc.killed)
1107
+ return;
1108
+ let curSize = lastSize;
1109
+ if (sessionFile) {
1110
+ try {
1111
+ curSize = fs.statSync(sessionFile).size;
1112
+ }
1113
+ catch { /* file not yet created */ }
1114
+ }
1115
+ if (curSize !== lastSize) {
1116
+ lastSize = curSize;
1117
+ lastChangedAt = Date.now();
1118
+ }
1119
+ const stableFor = Date.now() - lastChangedAt;
1120
+ const totalElapsed = Date.now() - startedAt;
1121
+ // Without a JSONL path (very early abort, before sessionId is known) fall
1122
+ // back to the legacy fixed grace window.
1123
+ const shouldKill = !sessionFile
1124
+ ? totalElapsed >= AGENT_GRACEFUL_ABORT_GRACE_MS
1125
+ : (stableFor >= FILE_STABLE_MS || totalElapsed >= MAX_WAIT_MS);
1126
+ if (shouldKill) {
1127
+ const reason = !sessionFile
1128
+ ? `no JSONL, fixed grace ${totalElapsed}ms`
1129
+ : (stableFor >= FILE_STABLE_MS ? `JSONL stable ${stableFor}ms` : `max wait ${totalElapsed}ms`);
1130
+ agentWarn(`[abort] ${reason}, killing process tree pid=${proc.pid}`);
1131
+ terminateProcessTree(proc, { signal: 'SIGTERM', forceSignal: 'SIGKILL', forceAfterMs: 5000 });
1132
+ return;
1133
+ }
1134
+ setTimeout(tick, POLL_MS);
1135
+ };
1136
+ setTimeout(tick, POLL_MS);
1137
+ };
1138
+ if (opts.abortSignal?.aborted)
1139
+ abortStream();
1140
+ opts.abortSignal?.addEventListener('abort', abortStream, { once: true });
1141
+ const scheduleIdleClose = () => {
1142
+ if (idleCloseTimer)
1143
+ clearTimeout(idleCloseTimer);
1144
+ idleCloseTimer = setTimeout(() => {
1145
+ idleCloseTimer = null;
1146
+ if (stdinClosed || interrupted || timedOut || proc.killed || proc.exitCode != null)
1147
+ return;
1148
+ agentLog(`[stdin] closing Claude input after ${CLAUDE_STEER_IDLE_CLOSE_MS}ms idle result window`);
1149
+ closeInput();
1150
+ }, CLAUDE_STEER_IDLE_CLOSE_MS);
1151
+ };
1152
+ const startsClaudeFollowup = (ev) => {
1153
+ const evType = ev?.type || '';
1154
+ if (evType === 'assistant')
1155
+ return true;
1156
+ if (evType !== 'stream_event')
1157
+ return false;
1158
+ const innerType = ev?.event?.type || '';
1159
+ return innerType === 'message_start' || innerType === 'content_block_delta';
1160
+ };
1161
+ const sendInput = (prompt, attachments = [], note, kind = 'steer') => {
1162
+ if (stdinClosed || interrupted || timedOut || proc.killed || proc.exitCode != null)
1163
+ return false;
1164
+ try {
1165
+ proc.stdin?.write(buildClaudeUserMessage(prompt, attachments));
1166
+ if (kind === 'steer') {
1167
+ steerQueued = true;
1168
+ if (idleCloseTimer) {
1169
+ clearTimeout(idleCloseTimer);
1170
+ idleCloseTimer = null;
1171
+ }
1172
+ }
1173
+ if (note) {
1174
+ pushRecentActivity(s.recentActivity, note);
1175
+ s.activity = s.recentActivity.join('\n');
1176
+ emit();
1177
+ }
1178
+ return true;
1179
+ }
1180
+ catch (error) {
1181
+ agentWarn(`[stdin] failed to write Claude input: ${error?.message || error}`);
1182
+ return false;
1183
+ }
1184
+ };
1185
+ if (!sendInput(opts.prompt, opts.attachments || [], undefined, 'initial')) {
1186
+ closeInput();
1187
+ }
1188
+ try {
1189
+ opts.onSteerReady?.(async (prompt, attachments = []) => {
1190
+ if (!sendInput(prompt, attachments, 'Queued steer input', 'steer'))
1191
+ return false;
1192
+ return true;
1193
+ });
1194
+ }
1195
+ catch (error) {
1196
+ agentWarn(`[stdin] onSteerReady error: ${error?.message || error}`);
1197
+ }
1198
+ proc.stderr?.on('data', (c) => {
1199
+ const chunk = c.toString();
1200
+ stderr += chunk;
1201
+ agentLog(`[stderr] ${chunk.trim().slice(0, 200)}`);
1202
+ });
1203
+ const rl = createInterface({ input: proc.stdout, crlfDelay: Infinity });
1204
+ rl.on('line', raw => {
1205
+ if (Date.now() > deadline) {
1206
+ timedOut = true;
1207
+ s.stopReason = 'timeout';
1208
+ closeInput();
1209
+ agentWarn('[timeout] deadline exceeded, killing process tree');
1210
+ terminateProcessTree(proc, { signal: 'SIGKILL' });
1211
+ return;
1212
+ }
1213
+ const line = raw.trim();
1214
+ if (!line || line[0] !== '{')
1215
+ return;
1216
+ lineCount++;
1217
+ try {
1218
+ const ev = JSON.parse(line);
1219
+ const evType = ev.type || '?';
1220
+ if (evType !== 'result' && idleCloseTimer) {
1221
+ clearTimeout(idleCloseTimer);
1222
+ idleCloseTimer = null;
1223
+ }
1224
+ if (awaitingSteeredResponseStart && startsClaudeFollowup(ev)) {
1225
+ awaitingSteeredResponseStart = false;
1226
+ steerQueued = false;
1227
+ resetClaudeTurnState(s);
1228
+ }
1229
+ if (evType === 'system' || evType === 'result' || evType === 'assistant') {
1230
+ agentLog(`[event] type=${evType} session=${ev.session_id || s.sessionId || '?'} model=${ev.model || s.model || '?'}`);
1231
+ }
1232
+ if (evType === 'stream_event') {
1233
+ const inner = ev.event || {};
1234
+ if (inner.type === 'message_start' || inner.type === 'message_delta') {
1235
+ agentLog(`[event] stream_event/${inner.type} session=${ev.session_id || s.sessionId || '?'}`);
1236
+ }
1237
+ }
1238
+ claudeParse(ev, s);
1239
+ if (evType === 'result') {
1240
+ const hasError = !!ev.is_error || (Array.isArray(ev.errors) && ev.errors.length > 0);
1241
+ if (hasError) {
1242
+ awaitingSteeredResponseStart = false;
1243
+ steerQueued = false;
1244
+ closeInput();
1245
+ }
1246
+ else if (steerQueued) {
1247
+ awaitingSteeredResponseStart = true;
1248
+ scheduleIdleClose();
1249
+ }
1250
+ else {
1251
+ closeInput();
1252
+ }
1253
+ }
1254
+ emit();
1255
+ }
1256
+ catch { }
1257
+ });
1258
+ const hardTimer = setTimeout(() => {
1259
+ timedOut = true;
1260
+ s.stopReason = 'timeout';
1261
+ closeInput();
1262
+ agentWarn(`[timeout] hard deadline reached (${opts.timeout}s), killing process tree pid=${proc.pid}`);
1263
+ terminateProcessTree(proc, { signal: 'SIGTERM', forceSignal: 'SIGKILL', forceAfterMs: 5000 });
1264
+ }, opts.timeout * 1000 + AGENT_STREAM_HARD_KILL_GRACE_MS);
1265
+ const [procOk, code] = await new Promise(resolve => {
1266
+ proc.on('close', code => {
1267
+ clearTimeout(hardTimer);
1268
+ if (idleCloseTimer) {
1269
+ clearTimeout(idleCloseTimer);
1270
+ idleCloseTimer = null;
1271
+ }
1272
+ agentLog(`[exit] code=${code} lines_parsed=${lineCount}`);
1273
+ resolve([code === 0, code]);
1274
+ });
1275
+ proc.on('error', e => {
1276
+ clearTimeout(hardTimer);
1277
+ if (idleCloseTimer) {
1278
+ clearTimeout(idleCloseTimer);
1279
+ idleCloseTimer = null;
1280
+ }
1281
+ agentError(`[error] ${e.message}`);
1282
+ stderr += e.message;
1283
+ resolve([false, -1]);
1284
+ });
1285
+ });
1286
+ opts.abortSignal?.removeEventListener('abort', abortStream);
1287
+ if (!s.text.trim() && s.msgs.length)
1288
+ s.text = s.msgs.join('\n\n');
1289
+ if (!s.thinking.trim() && s.thinkParts.length)
1290
+ s.thinking = s.thinkParts.join('\n\n');
1291
+ // Catch the Claude CLI's synthetic "API Error: …" assistant body (transient
1292
+ // Anthropic 5xx / 529 Overloaded). Without this rewrite the raw error string
1293
+ // gets surfaced into the IM card as if it were Claude's reply, and the
1294
+ // retry wrapper in `doClaudeStream` can't tell a transient failure apart
1295
+ // from a real short reply.
1296
+ const apiErrorReason = detectClaudeApiError(s.text);
1297
+ if (apiErrorReason) {
1298
+ agentWarn(`[claude] upstream API error detected: ${apiErrorReason}`);
1299
+ s.stopReason = 'api_error';
1300
+ s.text = '';
1301
+ if (!s.errors)
1302
+ s.errors = [`Anthropic API error: ${apiErrorReason}`];
1303
+ }
1304
+ const errorText = joinErrorMessages(s.errors);
1305
+ const ok = procOk && !s.errors && !timedOut && !interrupted;
1306
+ const error = errorText
1307
+ || (interrupted ? 'Interrupted by user.' : null)
1308
+ || (timedOut ? `Timed out after ${opts.timeout}s before the agent reported completion.` : null)
1309
+ || (!procOk ? (stderr.trim() || `Failed (exit=${code}).`) : null);
1310
+ const incomplete = !ok || s.stopReason === 'max_tokens' || s.stopReason === 'timeout';
1311
+ const elapsed = ((Date.now() - start) / 1000).toFixed(1);
1312
+ agentLog(`[result] ok=${ok && !s.errors} elapsed=${elapsed}s text=${s.text.length}chars thinking=${s.thinking.length}chars session=${s.sessionId || '?'}`);
1313
+ if (errorText)
1314
+ agentWarn(`[result] errors: ${errorText}`);
1315
+ if (s.stopReason)
1316
+ agentLog(`[result] stop_reason=${s.stopReason}`);
1317
+ if (stderr.trim() && !procOk)
1318
+ agentWarn(`[result] stderr: ${stderr.trim().slice(0, 300)}`);
1319
+ return {
1320
+ ok,
1321
+ sessionId: s.sessionId,
1322
+ workspacePath: null,
1323
+ model: s.model,
1324
+ thinkingEffort: s.thinkingEffort,
1325
+ message: s.text.trim() || errorText || (procOk ? '(no textual response)' : `Failed (exit=${code}).\n\n${stderr.trim() || '(no output)'}`),
1326
+ thinking: s.thinking.trim() || null,
1327
+ elapsedS: (Date.now() - start) / 1000,
1328
+ inputTokens: s.inputTokens,
1329
+ outputTokens: s.outputTokens,
1330
+ cachedInputTokens: s.cachedInputTokens,
1331
+ cacheCreationInputTokens: s.cacheCreationInputTokens,
1332
+ contextWindow: s.contextWindow,
1333
+ contextUsedTokens: s.contextUsedTokens,
1334
+ // Reuse the same calc as the live preview (computeContext) so the final
1335
+ // footer % matches the running %. Previously this passed a fraction
1336
+ // (used/window) into roundPercent, which expects a percent — divide-by-100
1337
+ // bug that made the final read ~12% as ~0.1%.
1338
+ contextPercent: computeContext(s).contextPercent,
1339
+ codexCumulative: null,
1340
+ error,
1341
+ plan: s.plan,
1342
+ stopReason: s.stopReason,
1343
+ incomplete,
1344
+ activity: s.activity.trim() || null,
1345
+ assistantBlocks: s.imageBlocks.length ? [...s.imageBlocks] : undefined,
1346
+ };
1347
+ }
1348
+ // ---------------------------------------------------------------------------
1349
+ // Stream
1350
+ // ---------------------------------------------------------------------------
1351
+ export async function doClaudeStream(opts) {
1352
+ const result = opts.onSteerReady
1353
+ ? await doClaudeInteractiveStream(opts)
1354
+ : await run(claudeCmd(opts), opts, claudeParse);
1355
+ const retryText = `${result.error || ''}\n${result.message}`;
1356
+ if (!result.ok && opts.sessionId && /no conversation found/i.test(retryText)) {
1357
+ return doClaudeStream({ ...opts, sessionId: null });
1358
+ }
1359
+ return result;
1360
+ }
1361
+ // ---------------------------------------------------------------------------
1362
+ // Sessions
1363
+ // ---------------------------------------------------------------------------
1364
+ export const claudeProjectDirName = encodePathAsDirName;
1365
+ /** Read native Claude Code sessions from ~/.claude/projects/{dirName}/*.jsonl */
1366
+ function extractClaudeTailQA(filePath) {
1367
+ // Use a larger tail (1 MB) so we can reach past tool-result / assistant
1368
+ // exchanges that follow the last real user question (which may be multi-MB
1369
+ // due to embedded images).
1370
+ const lines = readTailLines(filePath, 1024 * 1024);
1371
+ let lastQuestion = null;
1372
+ let lastAnswer = null;
1373
+ let lastMessageText = null;
1374
+ for (const raw of lines) {
1375
+ if (!raw || raw[0] !== '{')
1376
+ continue;
1377
+ try {
1378
+ const ev = JSON.parse(raw);
1379
+ if (ev.type === 'user' && ev.isMeta !== true) {
1380
+ const text = sanitizeSessionUserPreviewText(extractClaudeText(ev.message?.content, true));
1381
+ if (text) {
1382
+ lastQuestion = shortValue(text, 500);
1383
+ lastMessageText = shortValue(text, 500);
1384
+ }
1385
+ }
1386
+ else if (ev.type === 'assistant') {
1387
+ const text = extractClaudeText(ev.message?.content).trim();
1388
+ if (text) {
1389
+ lastAnswer = shortValue(text, 500);
1390
+ lastMessageText = shortValue(text, 500);
1391
+ }
1392
+ }
1393
+ }
1394
+ catch { /* skip */ }
1395
+ }
1396
+ return { lastQuestion, lastAnswer, lastMessageText };
1397
+ }
1398
+ // Per-file cache of the expensive content-derived fields. getNativeClaudeSessions
1399
+ // runs per dashboard session-list request AND per workspace×agent in the overview
1400
+ // fan-out; without this each call re-read + JSON-parsed every transcript in the
1401
+ // project dir. Keyed by (mtime,size) so any append/edit re-reads just that file.
1402
+ // `running`/`runState` are NOT cached — they depend on Date.now() - mtime, so they
1403
+ // are recomputed per call below.
1404
+ const nativeClaudeContentCache = new Map();
1405
+ function readNativeClaudeContent(filePath, stat) {
1406
+ try {
1407
+ // Read enough bytes to get past the system_prompt line (can be 20KB+) and
1408
+ // reach the first user/assistant events for title and model extraction.
1409
+ const fd = fs.openSync(filePath, 'r');
1410
+ const buf = Buffer.alloc(65536);
1411
+ const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
1412
+ fs.closeSync(fd);
1413
+ const head = buf.toString('utf8', 0, bytesRead);
1414
+ const lines = head.split('\n');
1415
+ let title = null;
1416
+ let model = null;
1417
+ for (const line of lines) {
1418
+ if (!line || line[0] !== '{')
1419
+ continue;
1420
+ try {
1421
+ const ev = JSON.parse(line);
1422
+ if (!title && ev.type === 'user' && ev.isMeta !== true) {
1423
+ const text = sanitizeSessionUserPreviewText(extractClaudeText(ev.message?.content, true));
1424
+ if (text) {
1425
+ const display = collapseSkillPrompt(text) ?? text;
1426
+ title = display.length <= 120 ? display : `${display.slice(0, 117).trimEnd()}...`;
1427
+ }
1428
+ }
1429
+ if (!model && ev.type === 'assistant' && ev.message?.model && ev.message.model !== '<synthetic>') {
1430
+ model = ev.message.model;
1431
+ }
1432
+ if (title && model)
1433
+ break;
1434
+ }
1435
+ catch { /* skip */ }
1436
+ }
1437
+ // Fallback: if the first user message line is too large (e.g. contains
1438
+ // base64 images) JSON.parse above will fail. Read a bigger chunk and
1439
+ // regex-extract text blocks to find the actual user question.
1440
+ if (!title) {
1441
+ let scanStr = head;
1442
+ if (stat.size > 65536) {
1443
+ try {
1444
+ const fd2 = fs.openSync(filePath, 'r');
1445
+ const bigBuf = Buffer.alloc(Math.min(10 * 1024 * 1024, stat.size));
1446
+ const bigRead = fs.readSync(fd2, bigBuf, 0, bigBuf.length, 0);
1447
+ fs.closeSync(fd2);
1448
+ scanStr = bigBuf.toString('utf8', 0, bigRead);
1449
+ }
1450
+ catch { /* keep using head */ }
1451
+ }
1452
+ const re = /"type":"text","text":"((?:[^"\\]|\\.)*)"/g;
1453
+ let m;
1454
+ while ((m = re.exec(scanStr)) !== null) {
1455
+ let raw = m[1]
1456
+ .replace(/\\n/g, ' ').replace(/\\t/g, ' ')
1457
+ .replace(/\\"/g, '"').replace(/\\\\/g, '\\')
1458
+ .replace(/\s+/g, ' ').trim();
1459
+ if (!raw || raw.startsWith('<') || raw.startsWith('[Image:'))
1460
+ continue;
1461
+ raw = stripInjectedPrompts(raw);
1462
+ if (!raw)
1463
+ continue;
1464
+ const display = collapseSkillPrompt(raw) ?? raw;
1465
+ title = display.length <= 120 ? display : `${display.slice(0, 117).trimEnd()}...`;
1466
+ break;
1467
+ }
1468
+ }
1469
+ // Quick turn count: count real user messages (exclude tool_result and
1470
+ // system-injected isMeta events — Skill outputs, resume prompts, etc.).
1471
+ let numTurns = 0;
1472
+ try {
1473
+ const raw = fs.readFileSync(filePath, 'utf8');
1474
+ const rawLines = raw.split('\n');
1475
+ for (const rl of rawLines) {
1476
+ if (rl.length <= 2 || !rl.includes('"type":"user"'))
1477
+ continue;
1478
+ if (rl.includes('"tool_result"') || rl.includes('"isMeta":true'))
1479
+ continue;
1480
+ numTurns++;
1481
+ }
1482
+ }
1483
+ catch { /* ignore count errors */ }
1484
+ const tailQA = extractClaudeTailQA(filePath);
1485
+ return {
1486
+ title,
1487
+ model,
1488
+ numTurns: numTurns || null,
1489
+ createdAt: stat.birthtime.toISOString(),
1490
+ lastQuestion: tailQA.lastQuestion,
1491
+ lastAnswer: tailQA.lastAnswer,
1492
+ lastMessageText: tailQA.lastMessageText,
1493
+ };
1494
+ }
1495
+ catch {
1496
+ return null;
1497
+ }
1498
+ }
1499
+ function getNativeClaudeSessions(workdir, limit) {
1500
+ const home = getHome();
1501
+ if (!home)
1502
+ return [];
1503
+ const projectDir = path.join(home, '.claude', 'projects', claudeProjectDirName(workdir));
1504
+ let entries;
1505
+ try {
1506
+ entries = fs.readdirSync(projectDir, { withFileTypes: true });
1507
+ }
1508
+ catch {
1509
+ return [];
1510
+ }
1511
+ // Stat first (cheap), sort newest-first, then read bodies only for as many as
1512
+ // can surface: `limit` is applied to a recency-sorted merge downstream, so a
1513
+ // transcript older than the top-`limit` can never appear in that view. Unchanged
1514
+ // files come straight from the per-file content cache, so repeated list calls
1515
+ // (dashboard polls, workspace×agent overview fan-out) don't re-parse the dir.
1516
+ const files = [];
1517
+ for (const entry of entries) {
1518
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
1519
+ continue;
1520
+ const filePath = path.join(projectDir, entry.name);
1521
+ try {
1522
+ files.push({ sessionId: entry.name.slice(0, -6), filePath, stat: fs.statSync(filePath) });
1523
+ }
1524
+ catch { /* skip */ }
1525
+ }
1526
+ files.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
1527
+ const selected = typeof limit === 'number' ? files.slice(0, Math.max(0, limit)) : files;
1528
+ const sessions = [];
1529
+ for (const { sessionId, filePath, stat } of selected) {
1530
+ const cached = nativeClaudeContentCache.get(filePath);
1531
+ let content;
1532
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
1533
+ content = cached.content;
1534
+ }
1535
+ else {
1536
+ const parsed = readNativeClaudeContent(filePath, stat);
1537
+ if (!parsed)
1538
+ continue;
1539
+ content = parsed;
1540
+ nativeClaudeContentCache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, content });
1541
+ }
1542
+ const isRunning = isClaudeNativeSessionRunning(filePath, stat.mtimeMs);
1543
+ sessions.push({
1544
+ sessionId,
1545
+ agent: 'claude',
1546
+ workdir,
1547
+ workspacePath: null,
1548
+ model: content.model,
1549
+ createdAt: content.createdAt,
1550
+ title: content.title,
1551
+ running: isRunning,
1552
+ runState: isRunning ? 'running' : 'completed',
1553
+ runDetail: null,
1554
+ runUpdatedAt: stat.mtime.toISOString(),
1555
+ classification: null,
1556
+ userStatus: null,
1557
+ userNote: null,
1558
+ lastQuestion: content.lastQuestion,
1559
+ lastAnswer: content.lastAnswer,
1560
+ lastMessageText: content.lastMessageText,
1561
+ migratedFrom: null,
1562
+ migratedTo: null,
1563
+ linkedSessions: [],
1564
+ numTurns: content.numTurns,
1565
+ });
1566
+ }
1567
+ return sessions;
1568
+ }
1569
+ function getClaudeSessions(workdir, limit) {
1570
+ const resolvedWorkdir = path.resolve(workdir);
1571
+ // Merge pikiloop-tracked sessions with native Claude sessions
1572
+ const pikiloopSessions = listPikiloopSessions(resolvedWorkdir, 'claude').map(record => ({
1573
+ sessionId: record.sessionId,
1574
+ agent: 'claude',
1575
+ workdir: record.workdir,
1576
+ workspacePath: record.workspacePath,
1577
+ threadId: record.threadId,
1578
+ model: record.model,
1579
+ createdAt: record.createdAt,
1580
+ title: record.title,
1581
+ running: record.runState === 'running',
1582
+ runState: record.runState,
1583
+ runDetail: record.runDetail,
1584
+ runUpdatedAt: record.runUpdatedAt,
1585
+ runPid: record.runPid,
1586
+ classification: record.classification,
1587
+ userStatus: record.userStatus,
1588
+ userNote: record.userNote,
1589
+ lastQuestion: record.lastQuestion,
1590
+ lastAnswer: record.lastAnswer,
1591
+ lastMessageText: record.lastMessageText,
1592
+ migratedFrom: record.migratedFrom,
1593
+ migratedTo: record.migratedTo,
1594
+ linkedSessions: record.linkedSessions,
1595
+ numTurns: record.numTurns ?? null,
1596
+ }));
1597
+ const nativeSessions = getNativeClaudeSessions(resolvedWorkdir, limit);
1598
+ const merged = mergeManagedAndNativeSessions(pikiloopSessions, nativeSessions);
1599
+ const sessions = typeof limit === 'number' ? merged.slice(0, limit) : merged;
1600
+ const projectDir = path.join(getHome(), '.claude', 'projects', claudeProjectDirName(resolvedWorkdir));
1601
+ agentLog(`[sessions:claude] workdir=${resolvedWorkdir} projectDir=${projectDir} projectDirExists=${fs.existsSync(projectDir)} ` +
1602
+ `pikiloop=${pikiloopSessions.length} native=${nativeSessions.length} merged=${sessions.length}`);
1603
+ return { ok: true, sessions, error: null };
1604
+ }
1605
+ // ---------------------------------------------------------------------------
1606
+ // Session tail
1607
+ // ---------------------------------------------------------------------------
1608
+ function extractClaudeText(content, skipSystemBlocks = false) {
1609
+ if (typeof content === 'string')
1610
+ return content;
1611
+ if (!Array.isArray(content))
1612
+ return '';
1613
+ const parts = [];
1614
+ for (const block of content) {
1615
+ if (block?.type === 'text' && typeof block.text === 'string') {
1616
+ if (skipSystemBlocks && block.text.startsWith('<'))
1617
+ continue;
1618
+ parts.push(block.text);
1619
+ }
1620
+ }
1621
+ // Join with blank line so consecutive text blocks render as separate markdown paragraphs.
1622
+ return parts.join('\n\n');
1623
+ }
1624
+ function getClaudeSessionTail(opts) {
1625
+ const limit = opts.limit ?? 4;
1626
+ const projectDir = path.join(getHome(), '.claude', 'projects', claudeProjectDirName(opts.workdir));
1627
+ const filePath = path.join(projectDir, `${opts.sessionId}.jsonl`);
1628
+ if (!fs.existsSync(filePath)) {
1629
+ return { ok: false, messages: [], error: 'Session file not found' };
1630
+ }
1631
+ try {
1632
+ const lines = readTailLines(filePath);
1633
+ const allMsgs = [];
1634
+ for (const raw of lines) {
1635
+ if (!raw || raw[0] !== '{')
1636
+ continue;
1637
+ try {
1638
+ const ev = JSON.parse(raw);
1639
+ if (ev.type === 'user') {
1640
+ const text = stripInjectedPrompts(extractClaudeText(ev.message?.content, true));
1641
+ if (text)
1642
+ allMsgs.push({ role: 'user', text });
1643
+ }
1644
+ else if (ev.type === 'assistant') {
1645
+ const text = extractClaudeText(ev.message?.content, true);
1646
+ if (text)
1647
+ allMsgs.push({ role: 'assistant', text });
1648
+ }
1649
+ }
1650
+ catch { /* skip */ }
1651
+ }
1652
+ return { ok: true, messages: allMsgs.slice(-limit), error: null };
1653
+ }
1654
+ catch (e) {
1655
+ return { ok: false, messages: [], error: e.message };
1656
+ }
1657
+ }
1658
+ // ---------------------------------------------------------------------------
1659
+ // Session messages (full content)
1660
+ // ---------------------------------------------------------------------------
1661
+ /** Build an image MessageBlock from a Claude content entry of the shape
1662
+ * `{ type: 'image', source: { type: 'base64', media_type, data } }`. Returns
1663
+ * null when the source is missing or the encoded size exceeds the cap. */
1664
+ function claudeImageBlockFromEntry(entry) {
1665
+ if (!entry || entry.type !== 'image' || !entry.source)
1666
+ return null;
1667
+ const source = entry.source;
1668
+ if (source.type !== 'base64' || typeof source.data !== 'string')
1669
+ return null;
1670
+ // 12MB base64 ≈ 9MB binary — keep API payloads sane.
1671
+ if (source.data.length > 12 * 1024 * 1024)
1672
+ return null;
1673
+ const mime = (source.media_type || 'image/png').toLowerCase();
1674
+ return { type: 'image', content: `data:${mime};base64,${source.data}`, imageMime: mime };
1675
+ }
1676
+ /** Extract structured content blocks from Claude message content.
1677
+ * When `todoWriteToolIds` is provided, TodoWrite tool_use blocks are emitted
1678
+ * as `plan` blocks and their IDs are tracked so tool_results can be skipped.
1679
+ *
1680
+ * When a `tool_result` block carries multimodal content (`content: [{type:'image',...}, ...]`),
1681
+ * the inner image entries are emitted as siblings of the textual tool_result —
1682
+ * this is the path MCP image-returning tools (mermaid-mcp, chart, dalle-mcp, …)
1683
+ * travel through. Filesystem-reading tools (Claude's `Read`) are excluded
1684
+ * via `toolNamesByUseId`: their image content is just an echo of an existing
1685
+ * file (often the user's own attachment) and re-rendering it under the
1686
+ * assistant card produces a confusing duplicate. */
1687
+ function extractClaudeBlocks(content, skipSystemBlocks = false, todoWriteToolIds, toolNamesByUseId) {
1688
+ if (typeof content === 'string')
1689
+ return [{ type: 'text', content }];
1690
+ if (!Array.isArray(content))
1691
+ return [];
1692
+ const blocks = [];
1693
+ for (const block of content) {
1694
+ if (!block || typeof block !== 'object')
1695
+ continue;
1696
+ if (block.type === 'text' && typeof block.text === 'string') {
1697
+ if (skipSystemBlocks && block.text.startsWith('<'))
1698
+ continue;
1699
+ blocks.push({ type: 'text', content: block.text });
1700
+ }
1701
+ else if (block.type === 'thinking' && typeof block.thinking === 'string') {
1702
+ blocks.push({ type: 'thinking', content: block.thinking });
1703
+ }
1704
+ else if (block.type === 'tool_use') {
1705
+ // TodoWrite → emit as plan block instead of generic tool_use
1706
+ if (block.name === 'TodoWrite' && todoWriteToolIds) {
1707
+ const plan = parseTodoWriteAsPlan(block.input);
1708
+ if (plan) {
1709
+ todoWriteToolIds.add(block.id);
1710
+ blocks.push({ type: 'plan', content: '', plan, toolId: block.id });
1711
+ continue;
1712
+ }
1713
+ }
1714
+ const inputStr = block.input ? JSON.stringify(block.input, null, 2) : '';
1715
+ blocks.push({ type: 'tool_use', content: inputStr, toolName: block.name || 'unknown', toolId: block.id });
1716
+ }
1717
+ else if (block.type === 'tool_result') {
1718
+ const resultText = typeof block.content === 'string'
1719
+ ? block.content
1720
+ : Array.isArray(block.content)
1721
+ ? block.content.filter((c) => c?.type === 'text').map((c) => c.text).join('\n')
1722
+ : '';
1723
+ blocks.push({ type: 'tool_result', content: resultText, toolId: block.tool_use_id });
1724
+ // Recurse into multimodal tool_result content so MCP-returned images
1725
+ // (and any future non-text inner types) surface alongside the textual
1726
+ // tool_result rather than being silently dropped. Skip file-reading
1727
+ // tools — their image content is an echo, not a new asset.
1728
+ const toolName = block.tool_use_id ? toolNamesByUseId?.get(block.tool_use_id) : undefined;
1729
+ if (Array.isArray(block.content) && !isClaudeFileReadingTool(toolName)) {
1730
+ for (const inner of block.content) {
1731
+ const img = claudeImageBlockFromEntry(inner);
1732
+ if (img)
1733
+ blocks.push(img);
1734
+ }
1735
+ }
1736
+ }
1737
+ else if (block.type === 'image') {
1738
+ const img = claudeImageBlockFromEntry(block);
1739
+ if (img)
1740
+ blocks.push(img);
1741
+ }
1742
+ }
1743
+ return blocks;
1744
+ }
1745
+ /**
1746
+ * Top-level XML wrapper tags that Claude Code injects into "user" events for
1747
+ * non-user-authored content: background-task results, system reminders, IDE
1748
+ * state, persisted output truncations, slash-command stdout, etc. The dashboard
1749
+ * should never render these as a user bubble — they're conversation infra.
1750
+ */
1751
+ const SYSTEM_INJECTED_USER_TAGS = new Set([
1752
+ 'task-notification',
1753
+ 'system-reminder',
1754
+ 'persisted-output',
1755
+ 'local-command-stdout',
1756
+ 'local-command-caveat',
1757
+ 'local-command-stderr',
1758
+ 'ide_opened_file',
1759
+ 'ide_diagnostics',
1760
+ 'ide_selection',
1761
+ 'event',
1762
+ 'analysis',
1763
+ 'case_id',
1764
+ 'tool-use-id',
1765
+ 'output-file',
1766
+ ]);
1767
+ /**
1768
+ * Detect Claude CLI's boilerplate `<synthetic>` responses that surface only
1769
+ * because of TUI-resume bookkeeping — they carry no information for the user.
1770
+ *
1771
+ * The reproducible case: every `claude --resume <id>` in interactive mode
1772
+ * writes a sentinel turn into the JSONL on startup, consisting of an
1773
+ * `isMeta:true` user "Continue from where you left off." plus a `<synthetic>`
1774
+ * "No response requested." acknowledgment. The print-mode driver doesn't hit
1775
+ * this because `-p` skips the interactive resume nudge. Filtering it out here
1776
+ * keeps the dashboard timeline clean across all driver paths.
1777
+ */
1778
+ function isClaudeSyntheticResumeNoise(text) {
1779
+ const t = (text || '').trim().toLowerCase();
1780
+ if (!t)
1781
+ return true;
1782
+ return t === 'no response requested.' || t === 'no response requested';
1783
+ }
1784
+ /** Detect system-injected user events (compression summaries, interruption
1785
+ * markers, task-notifications, IDE state, etc.) that should not render as a
1786
+ * user message when parsing session JSONL. */
1787
+ function isSystemInjectedUserEvent(text) {
1788
+ const trimmed = (text || '').trim();
1789
+ if (!trimmed)
1790
+ return true;
1791
+ // Interruption markers injected by Claude Code
1792
+ if (/^\[Request interrupted by user(?: for tool use)?\]$/i.test(trimmed))
1793
+ return true;
1794
+ // Leading XML wrapper from a known infra tag — these are never user-authored.
1795
+ const leading = trimmed.match(/^<([a-z][a-z0-9_-]*)\b/i);
1796
+ if (leading && SYSTEM_INJECTED_USER_TAGS.has(leading[1].toLowerCase()))
1797
+ return true;
1798
+ // Context compression summaries carry a recognizable opening marker. Detect
1799
+ // by that marker, not length — long, legitimate user prompts (multi-paragraph
1800
+ // briefs, pasted documents) routinely exceed any size threshold and must
1801
+ // render as real user turns.
1802
+ const lower = trimmed.toLowerCase();
1803
+ const markers = ['continued from a previous', 'summary below covers', 'earlier portion of the conversation', 'here is a summary of', 'conversation summary'];
1804
+ return markers.some(m => lower.includes(m));
1805
+ }
1806
+ function getClaudeSessionMessages(opts) {
1807
+ const projectDir = path.join(getHome(), '.claude', 'projects', claudeProjectDirName(opts.workdir));
1808
+ const filePath = path.join(projectDir, `${opts.sessionId}.jsonl`);
1809
+ if (!fs.existsSync(filePath)) {
1810
+ return { ok: false, messages: [], totalTurns: 0, error: 'Session file not found' };
1811
+ }
1812
+ try {
1813
+ const content = fs.readFileSync(filePath, 'utf-8');
1814
+ const lines = content.split('\n').filter(l => l.trim());
1815
+ // Parse raw events, merging consecutive same-role events into one message.
1816
+ // Claude JSONL writes one event per content block (thinking, text, tool_use, tool_result).
1817
+ // Consecutive assistant events form a single assistant message.
1818
+ // User events with tool_result are system-injected and should be hidden;
1819
+ // only user events with actual user text start a new user message.
1820
+ const allMsgs = [];
1821
+ const richMsgs = [];
1822
+ let pendingRole = null;
1823
+ let pendingTextParts = [];
1824
+ let pendingBlocks = [];
1825
+ /** Latest assistant-event usage snapshot — overwritten on each LLM call within a
1826
+ * turn so the flushed RichMessage carries the final call's context state, matching
1827
+ * the live `StreamPreviewMeta` semantics. */
1828
+ let pendingUsage = null;
1829
+ /** Per-call output tokens keyed by message.id — events of one call share an
1830
+ * id and carry running totals, so last-write-wins per id and the turn's
1831
+ * cumulative output is the sum across ids (→ `turnOutputTokens`). */
1832
+ let pendingCallOutputs = new Map();
1833
+ const todoWriteToolIds = new Set();
1834
+ /**
1835
+ * Sub-agent blocks live in `pendingBlocks` like any other block but we keep
1836
+ * a side-table of references keyed by the Task tool_use_id so subsequent
1837
+ * sub-agent assistant events (which carry parent_tool_use_id) can mutate
1838
+ * the captured `subAgent` payload in place — that way the rendered turn
1839
+ * shows the sub-agent's full tool stream without polluting the parent's
1840
+ * tool list.
1841
+ */
1842
+ const subAgentBlocksById = new Map();
1843
+ /** Tool ids belonging to sub-agents — their tool_results in user events are skipped from the parent activity. */
1844
+ const subAgentToolIds = new Set();
1845
+ /** Tool name keyed by tool_use_id — populated from assistant tool_use
1846
+ * events so the user-event tool_result loop can filter image content for
1847
+ * filesystem-reading tools (Read). */
1848
+ const toolNamesByUseId = new Map();
1849
+ const flush = () => {
1850
+ if (!pendingRole)
1851
+ return;
1852
+ const text = pendingTextParts.join('\n\n');
1853
+ if (text || pendingBlocks.length) {
1854
+ allMsgs.push({ role: pendingRole, text });
1855
+ let turnOutput = 0;
1856
+ for (const v of pendingCallOutputs.values())
1857
+ turnOutput += v;
1858
+ const usage = pendingRole === 'assistant' && pendingUsage
1859
+ ? buildClaudeTurnUsage(pendingUsage, turnOutput)
1860
+ : null;
1861
+ richMsgs.push({ role: pendingRole, text, blocks: [...pendingBlocks], usage });
1862
+ }
1863
+ pendingRole = null;
1864
+ pendingTextParts = [];
1865
+ pendingBlocks = [];
1866
+ pendingUsage = null;
1867
+ pendingCallOutputs = new Map();
1868
+ subAgentBlocksById.clear();
1869
+ subAgentToolIds.clear();
1870
+ toolNamesByUseId.clear();
1871
+ };
1872
+ for (const raw of lines) {
1873
+ if (!raw || raw[0] !== '{')
1874
+ continue;
1875
+ try {
1876
+ const ev = JSON.parse(raw);
1877
+ const parentToolUseId = (typeof ev.parent_tool_use_id === 'string' && ev.parent_tool_use_id) ? ev.parent_tool_use_id : null;
1878
+ if (parentToolUseId) {
1879
+ // Sub-agent emission — fold tool calls into the matching sub_agent block
1880
+ // and never let them surface as siblings of the parent's blocks.
1881
+ const block = subAgentBlocksById.get(parentToolUseId);
1882
+ if (!block?.subAgent)
1883
+ continue;
1884
+ const sub = block.subAgent;
1885
+ if (ev.type === 'assistant') {
1886
+ const model = ev.model ?? ev.message?.model;
1887
+ if (typeof model === 'string' && model.trim())
1888
+ sub.model = model;
1889
+ const contents = Array.isArray(ev.message?.content) ? ev.message.content : [];
1890
+ for (const inner of contents) {
1891
+ if (inner?.type !== 'tool_use')
1892
+ continue;
1893
+ const toolId = String(inner?.id || '').trim();
1894
+ if (!toolId)
1895
+ continue;
1896
+ const toolName = String(inner?.name || 'Tool').trim() || 'Tool';
1897
+ subAgentToolIds.add(toolId);
1898
+ const summary = toolName === 'TodoWrite' ? 'Update plan' : summarizeClaudeToolUse(inner?.name, inner?.input || {});
1899
+ if (!sub.tools.some(t => t.id === toolId)) {
1900
+ sub.tools.push({ id: toolId, name: toolName, summary });
1901
+ }
1902
+ }
1903
+ }
1904
+ continue;
1905
+ }
1906
+ if (ev.type === 'user') {
1907
+ // System-injected meta events (Skill tool results, /command stdout,
1908
+ // resume prompts) come through with `type:user` + `isMeta:true`. The
1909
+ // accompanying `message.content` is plain text (NOT a tool_result
1910
+ // block), so the regular `isToolResult` check below misses them and
1911
+ // they would otherwise render as a fake user bubble — splitting the
1912
+ // conversation into a phantom new turn (visible as a fresh
1913
+ // "Claude Code" divider mid-session). Re-attach the text to the
1914
+ // originating tool's activity feed when sourceToolUseID is known,
1915
+ // otherwise drop silently.
1916
+ if (ev.isMeta === true) {
1917
+ if (pendingRole === 'assistant') {
1918
+ const toolUseId = typeof ev.sourceToolUseID === 'string' ? ev.sourceToolUseID : '';
1919
+ if (toolUseId && !todoWriteToolIds.has(toolUseId) && !subAgentToolIds.has(toolUseId)) {
1920
+ const text = extractClaudeText(ev.message?.content, false);
1921
+ if (text) {
1922
+ pendingBlocks.push({ type: 'tool_result', content: text, toolId: toolUseId });
1923
+ }
1924
+ }
1925
+ }
1926
+ continue;
1927
+ }
1928
+ const contentArr = ev.message?.content;
1929
+ const isToolResult = Array.isArray(contentArr)
1930
+ && contentArr.length > 0
1931
+ && contentArr.every((b) => b?.type === 'tool_result');
1932
+ if (isToolResult) {
1933
+ if (pendingRole === 'assistant') {
1934
+ for (const block of contentArr) {
1935
+ const toolUseId = block.tool_use_id;
1936
+ if (todoWriteToolIds.has(toolUseId))
1937
+ continue;
1938
+ // Sub-agent inner tool results — already accounted for by the sub_agent block.
1939
+ if (subAgentToolIds.has(toolUseId))
1940
+ continue;
1941
+ // Top-level tool_result for a sub-agent (Task / Agent) — close
1942
+ // out its lifecycle. The result content text is the sub-agent's
1943
+ // full final answer; surface it on the sub_agent block so the
1944
+ // dedicated card can render it instead of leaking into the
1945
+ // parent's tool_result feed.
1946
+ const subBlock = subAgentBlocksById.get(toolUseId);
1947
+ if (subBlock?.subAgent) {
1948
+ subBlock.subAgent.status = block?.is_error ? 'failed' : 'done';
1949
+ const resultText = typeof block.content === 'string'
1950
+ ? block.content
1951
+ : Array.isArray(block.content)
1952
+ ? block.content.filter((c) => c?.type === 'text').map((c) => c.text).join('\n')
1953
+ : '';
1954
+ if (resultText)
1955
+ subBlock.content = resultText;
1956
+ continue;
1957
+ }
1958
+ const resultText = typeof block.content === 'string'
1959
+ ? block.content
1960
+ : Array.isArray(block.content)
1961
+ ? block.content.filter((c) => c?.type === 'text').map((c) => c.text).join('\n')
1962
+ : '';
1963
+ if (resultText) {
1964
+ pendingBlocks.push({ type: 'tool_result', content: resultText, toolId: toolUseId });
1965
+ }
1966
+ // Multimodal tool_result content from MCP servers can include
1967
+ // image entries — surface them as siblings of the textual
1968
+ // tool_result so the rendered turn carries the image. Skip
1969
+ // filesystem-reading tools (Read): their image content is an
1970
+ // echo of an existing file (e.g. the user's own attachment)
1971
+ // and would otherwise be duplicated below the assistant text.
1972
+ const toolName = toolNamesByUseId.get(toolUseId);
1973
+ if (Array.isArray(block.content) && !isClaudeFileReadingTool(toolName)) {
1974
+ for (const inner of block.content) {
1975
+ const img = claudeImageBlockFromEntry(inner);
1976
+ if (img)
1977
+ pendingBlocks.push(img);
1978
+ }
1979
+ }
1980
+ }
1981
+ }
1982
+ continue;
1983
+ }
1984
+ if (pendingRole === 'assistant') {
1985
+ const probe = extractClaudeText(ev.message?.content, true);
1986
+ if (isSystemInjectedUserEvent(probe))
1987
+ continue;
1988
+ }
1989
+ flush();
1990
+ const rawText = stripInjectedPrompts(extractClaudeText(ev.message?.content, true));
1991
+ const userBlocks = extractClaudeBlocks(ev.message?.content, true);
1992
+ const imageBlocks = userBlocks.filter(b => b.type === 'image');
1993
+ // TUI mode (claude-tui.ts) persists user `content` as a plain string
1994
+ // with leading `@/abs/path/image.png` mentions — that's how the TUI
1995
+ // ingests local images (it can't accept stream-json image blocks like
1996
+ // `-p` mode). The `extractClaudeBlocks` call above yields no image
1997
+ // blocks for that shape; lift the mentions into structured image
1998
+ // blocks via the shared pipeline so the dashboard renders thumbnails
1999
+ // instead of raw paths. Also resolves the "first message drops the
2000
+ // image" + "queued message drops the image" symptoms because the
2001
+ // optimisticBridgesImages bridge (SessionPanel) was previously
2002
+ // falling open: the server-side user text contained the @-path while
2003
+ // the optimistic pendingPrompt did not, so the bridge's text-equality
2004
+ // check failed and the no-image server bubble replaced the
2005
+ // optimistic one.
2006
+ let displayText = rawText;
2007
+ if (typeof ev.message?.content === 'string' && imageBlocks.length === 0) {
2008
+ const recoveredPaths = new Set();
2009
+ for (const absPath of extractClaudeAtMentionImagePaths(rawText)) {
2010
+ const block = attachAgentImage({ imagePath: absPath });
2011
+ if (!block)
2012
+ continue;
2013
+ imageBlocks.push(block);
2014
+ recoveredPaths.add(absPath);
2015
+ }
2016
+ // Only strip the mentions we successfully turned into image blocks;
2017
+ // leave unresolved ones (file deleted/moved) in the text so the
2018
+ // user sees what was attached even when we can't render it.
2019
+ if (recoveredPaths.size) {
2020
+ displayText = rawText.replace(CLAUDE_AT_MENTION_IMAGE_RE, (full, leading, p) => recoveredPaths.has(p) ? (leading || '') : full);
2021
+ }
2022
+ }
2023
+ const text = displayText.replace(SESSION_PREVIEW_IMAGE_PLACEHOLDER_RE, '').replace(/\s+/g, ' ').trim();
2024
+ if (text || imageBlocks.length) {
2025
+ pendingRole = 'user';
2026
+ pendingTextParts = text ? [text] : [];
2027
+ pendingBlocks = text ? [{ type: 'text', content: text }, ...imageBlocks] : [...imageBlocks];
2028
+ }
2029
+ }
2030
+ else if (ev.type === 'assistant') {
2031
+ // `model:"<synthetic>"` is Claude CLI's out-of-band channel — emitted
2032
+ // when the runtime needs to tell the user something *as if* the
2033
+ // assistant spoke ("No response requested.", "There's an issue with
2034
+ // the selected model…", etc.). Surface it as a `system_notice` block
2035
+ // rather than a real text reply: the content stays visible (so model
2036
+ // errors and other meaningful feedback aren't lost), but it renders
2037
+ // as a notice tile instead of impersonating a Claude turn.
2038
+ if (ev.message?.model === '<synthetic>') {
2039
+ const noticeText = extractClaudeText(ev.message?.content, true).trim();
2040
+ // Suppress TUI-resume startup noise. When `claude --resume <id>`
2041
+ // boots in interactive mode it injects a sentinel turn — an
2042
+ // `isMeta:true` user "Continue from where you left off." followed
2043
+ // by a `<synthetic>` "No response requested." acknowledgment.
2044
+ // This is harmless internal book-keeping; rendering it as a
2045
+ // yellow notice on every TUI-mode turn just pollutes the UI.
2046
+ if (isClaudeSyntheticResumeNoise(noticeText))
2047
+ continue;
2048
+ if (pendingRole === 'user')
2049
+ flush();
2050
+ pendingRole = 'assistant';
2051
+ if (noticeText)
2052
+ pendingBlocks.push({ type: 'system_notice', content: noticeText });
2053
+ continue;
2054
+ }
2055
+ if (pendingRole === 'user')
2056
+ flush();
2057
+ pendingRole = 'assistant';
2058
+ const u = ev.message?.usage;
2059
+ if (u && typeof u === 'object') {
2060
+ const numOrNull = (v) => typeof v === 'number' && Number.isFinite(v) ? v : null;
2061
+ const prevModel = pendingUsage ? pendingUsage.model : null;
2062
+ pendingUsage = {
2063
+ input: numOrNull(u.input_tokens),
2064
+ output: numOrNull(u.output_tokens),
2065
+ cacheRead: numOrNull(u.cache_read_input_tokens),
2066
+ cacheCreation: numOrNull(u.cache_creation_input_tokens),
2067
+ model: typeof ev.message?.model === 'string' ? ev.message.model : prevModel,
2068
+ };
2069
+ // Per-call output for the turn-cumulative counter. Same-id events
2070
+ // carry running totals → keep the last value per call.
2071
+ const output = numOrNull(u.output_tokens);
2072
+ if (output != null) {
2073
+ const msgId = typeof ev.message?.id === 'string' && ev.message.id ? ev.message.id : '(no-id)';
2074
+ pendingCallOutputs.set(msgId, output);
2075
+ }
2076
+ }
2077
+ const text = extractClaudeText(ev.message?.content, true);
2078
+ if (text)
2079
+ pendingTextParts.push(text);
2080
+ // Record tool names from this assistant turn before extracting blocks
2081
+ // so any tool_result that follows in a later user event can be
2082
+ // attributed to the right tool (Read → skip image recursion, etc.).
2083
+ const assistantContents = Array.isArray(ev.message?.content) ? ev.message.content : [];
2084
+ for (const inner of assistantContents) {
2085
+ if (inner?.type === 'tool_use' && typeof inner.id === 'string' && typeof inner.name === 'string') {
2086
+ toolNamesByUseId.set(inner.id, inner.name);
2087
+ }
2088
+ }
2089
+ const blocks = extractClaudeBlocks(ev.message?.content, true, todoWriteToolIds, toolNamesByUseId);
2090
+ // Convert sub-agent tool_use blocks into sub_agent placeholders we
2091
+ // can later mutate. Claude Code surfaces the Task tool as `Agent` in
2092
+ // its v2 stream format; accept both names so older sessions still
2093
+ // parse correctly.
2094
+ const contents = Array.isArray(ev.message?.content) ? ev.message.content : [];
2095
+ for (let i = 0; i < blocks.length; i++) {
2096
+ const block = blocks[i];
2097
+ if (block.type !== 'tool_use')
2098
+ continue;
2099
+ if (block.toolName !== 'Task' && block.toolName !== 'Agent')
2100
+ continue;
2101
+ const raw = contents.find((c) => c?.type === 'tool_use' && c?.id === block.toolId);
2102
+ const input = raw?.input || {};
2103
+ const subAgent = {
2104
+ id: block.toolId || '',
2105
+ kind: typeof input.subagent_type === 'string' ? input.subagent_type : null,
2106
+ description: typeof input.description === 'string' ? input.description : null,
2107
+ model: null,
2108
+ tools: [],
2109
+ status: 'running',
2110
+ };
2111
+ const subBlock = { type: 'sub_agent', content: '', toolId: block.toolId, subAgent };
2112
+ blocks[i] = subBlock;
2113
+ if (subAgent.id)
2114
+ subAgentBlocksById.set(subAgent.id, subBlock);
2115
+ }
2116
+ pendingBlocks.push(...blocks);
2117
+ }
2118
+ }
2119
+ catch { /* skip malformed lines */ }
2120
+ }
2121
+ flush();
2122
+ // Hydrate sub_agent blocks from sidecar files. Claude Code stores each
2123
+ // sub-agent's full transcript in
2124
+ // ~/.claude/projects/<dir>/<session-id>/subagents/agent-<id>.jsonl
2125
+ // alongside an `agent-<id>.meta.json` carrying the agentType. The parent
2126
+ // session only records the Agent tool_use + tool_result; without this step
2127
+ // the sub-agent card has no tool list.
2128
+ const subAgentsDir = path.join(projectDir, opts.sessionId, 'subagents');
2129
+ if (fs.existsSync(subAgentsDir))
2130
+ hydrateSubAgentBlocksFromSidecar(richMsgs, subAgentsDir);
2131
+ return applyTurnWindow(allMsgs, opts, opts.rich ? richMsgs : undefined);
2132
+ }
2133
+ catch (e) {
2134
+ return { ok: false, messages: [], totalTurns: 0, error: e.message };
2135
+ }
2136
+ }
2137
+ /**
2138
+ * Walk a session's `subagents/` directory and merge each sidecar's tool stream
2139
+ * onto the matching `sub_agent` block (matched by description, the only stable
2140
+ * shared field between parent and child sessions). Best-effort — silent on any
2141
+ * I/O or parse failure.
2142
+ */
2143
+ function hydrateSubAgentBlocksFromSidecar(richMsgs, subAgentsDir) {
2144
+ let entries;
2145
+ try {
2146
+ entries = fs.readdirSync(subAgentsDir);
2147
+ }
2148
+ catch {
2149
+ return;
2150
+ }
2151
+ const sidecars = new Map();
2152
+ for (const name of entries) {
2153
+ if (!name.endsWith('.jsonl'))
2154
+ continue;
2155
+ const id = name.replace(/\.jsonl$/, '');
2156
+ const metaPath = path.join(subAgentsDir, `${id}.meta.json`);
2157
+ let metaKind = null;
2158
+ let metaDescription = null;
2159
+ try {
2160
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
2161
+ metaKind = typeof meta?.agentType === 'string' ? meta.agentType : null;
2162
+ metaDescription = typeof meta?.description === 'string' ? meta.description : null;
2163
+ }
2164
+ catch { /* meta optional */ }
2165
+ const tools = [];
2166
+ let model = null;
2167
+ try {
2168
+ const content = fs.readFileSync(path.join(subAgentsDir, name), 'utf-8');
2169
+ for (const raw of content.split('\n')) {
2170
+ if (!raw || raw[0] !== '{')
2171
+ continue;
2172
+ let ev;
2173
+ try {
2174
+ ev = JSON.parse(raw);
2175
+ }
2176
+ catch {
2177
+ continue;
2178
+ }
2179
+ if (ev.type !== 'assistant')
2180
+ continue;
2181
+ const msg = ev.message || {};
2182
+ if (typeof msg.model === 'string' && msg.model.trim())
2183
+ model = msg.model;
2184
+ const contents = Array.isArray(msg.content) ? msg.content : [];
2185
+ for (const block of contents) {
2186
+ if (block?.type !== 'tool_use')
2187
+ continue;
2188
+ const toolId = String(block?.id || '').trim();
2189
+ if (!toolId || tools.some(t => t.id === toolId))
2190
+ continue;
2191
+ const toolName = String(block?.name || 'Tool').trim() || 'Tool';
2192
+ const summary = toolName === 'TodoWrite' ? 'Update plan' : summarizeClaudeToolUse(block?.name, block?.input || {});
2193
+ tools.push({ id: toolId, name: toolName, summary });
2194
+ }
2195
+ }
2196
+ }
2197
+ catch {
2198
+ continue;
2199
+ }
2200
+ if (metaDescription) {
2201
+ sidecars.set(metaDescription, { kind: metaKind, tools, model });
2202
+ }
2203
+ }
2204
+ if (sidecars.size === 0)
2205
+ return;
2206
+ for (const msg of richMsgs) {
2207
+ for (const block of msg.blocks) {
2208
+ if (block.type !== 'sub_agent' || !block.subAgent || !block.subAgent.description)
2209
+ continue;
2210
+ const sidecar = sidecars.get(block.subAgent.description);
2211
+ if (!sidecar)
2212
+ continue;
2213
+ if (!block.subAgent.kind && sidecar.kind)
2214
+ block.subAgent.kind = sidecar.kind;
2215
+ if (!block.subAgent.model && sidecar.model)
2216
+ block.subAgent.model = sidecar.model;
2217
+ if (block.subAgent.tools.length === 0 && sidecar.tools.length > 0)
2218
+ block.subAgent.tools = sidecar.tools;
2219
+ }
2220
+ }
2221
+ }
2222
+ // ---------------------------------------------------------------------------
2223
+ // Models
2224
+ // ---------------------------------------------------------------------------
2225
+ const CLAUDE_MODELS = [
2226
+ { id: 'claude-fable-5', alias: 'fable' },
2227
+ { id: 'claude-opus-4-8', alias: 'opus' },
2228
+ { id: 'claude-sonnet-4-6', alias: 'sonnet' },
2229
+ { id: 'claude-haiku-4-5-20251001', alias: 'haiku' },
2230
+ ];
2231
+ // ---------------------------------------------------------------------------
2232
+ // Usage
2233
+ // ---------------------------------------------------------------------------
2234
+ // The account-usage query below hits api.anthropic.com/api/oauth/usage, which is
2235
+ // itself rate-limited. The dashboard rebuilds agent status ~every 30s (plus a
2236
+ // forced refresh on usage-ring hover), and querying that often trips the
2237
+ // endpoint's 429 — which (since we treat a query error as "unknown") blanks the
2238
+ // header usage ring entirely. Quota windows (5h/7d) move slowly, so we query at
2239
+ // most once per this interval and serve the last good result in between
2240
+ // (including across transient 429s), decoupling usage cadence from how often
2241
+ // agent status is rebuilt.
2242
+ const CLAUDE_USAGE_QUERY_TTL_MS = 5 * 60_000;
2243
+ const claudeUsageCache = { lastGood: null, lastAttemptAt: 0 };
2244
+ function getClaudeOAuthToken() {
2245
+ // `security` is macOS-only; other platforms store Claude creds differently
2246
+ // (DPAPI on Windows, libsecret on Linux) and Claude Code manages those itself.
2247
+ if (!IS_MAC)
2248
+ return null;
2249
+ try {
2250
+ const raw = execSync('security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null', {
2251
+ encoding: 'utf-8', timeout: 3000,
2252
+ }).trim();
2253
+ if (!raw)
2254
+ return null;
2255
+ const parsed = JSON.parse(raw);
2256
+ return parsed?.claudeAiOauth?.accessToken || null;
2257
+ }
2258
+ catch {
2259
+ return null;
2260
+ }
2261
+ }
2262
+ function getClaudeUsageFromOAuth() {
2263
+ const token = getClaudeOAuthToken();
2264
+ if (!token)
2265
+ return null;
2266
+ try {
2267
+ const raw = execSync(`curl -s --max-time 5 -H "Authorization: Bearer ${token}" -H "anthropic-beta: oauth-2025-04-20" -H "Content-Type: application/json" "https://api.anthropic.com/api/oauth/usage"`, { encoding: 'utf-8', timeout: 8000 }).trim();
2268
+ if (!raw || raw[0] !== '{')
2269
+ return null;
2270
+ const data = JSON.parse(raw);
2271
+ const capturedAt = new Date().toISOString();
2272
+ const apiError = data?.error;
2273
+ if (apiError && typeof apiError === 'object') {
2274
+ // The usage query endpoint itself returned an error (e.g. 429 rate
2275
+ // limit on the query API). This does NOT reflect the user's actual
2276
+ // Claude usage status, so fall through to telemetry instead of
2277
+ // reporting a misleading "limit_reached".
2278
+ return null;
2279
+ }
2280
+ const makeWindow = (label, entry) => {
2281
+ if (!entry || typeof entry !== 'object')
2282
+ return null;
2283
+ const usedPercent = roundPercent(entry.utilization);
2284
+ if (usedPercent == null)
2285
+ return null;
2286
+ const remainingPercent = Math.max(0, Math.round((100 - usedPercent) * 10) / 10);
2287
+ const resetAt = typeof entry.resets_at === 'string' ? entry.resets_at : null;
2288
+ let resetAfterSeconds = null;
2289
+ if (resetAt) {
2290
+ const resetAtMs = Date.parse(resetAt);
2291
+ if (Number.isFinite(resetAtMs))
2292
+ resetAfterSeconds = Math.max(0, Math.round((resetAtMs - Date.now()) / 1000));
2293
+ }
2294
+ return {
2295
+ label, usedPercent, remainingPercent, resetAt, resetAfterSeconds,
2296
+ status: usedPercent >= 100 ? 'limit_reached' : usedPercent >= 80 ? 'warning' : 'allowed',
2297
+ };
2298
+ };
2299
+ const windows = [];
2300
+ for (const [label, key] of [['5h', 'five_hour'], ['7d', 'seven_day'], ['7d Opus', 'seven_day_opus'], ['7d Sonnet', 'seven_day_sonnet'], ['Extra', 'extra_usage']]) {
2301
+ const w = makeWindow(label, data[key]);
2302
+ if (w)
2303
+ windows.push(w);
2304
+ }
2305
+ if (!windows.length)
2306
+ return null;
2307
+ const overallStatus = windows.some(w => w.status === 'limit_reached') ? 'limit_reached'
2308
+ : windows.some(w => w.status === 'warning') ? 'warning' : 'allowed';
2309
+ return { ok: true, agent: 'claude', source: 'oauth-api', capturedAt, status: overallStatus, windows, error: null };
2310
+ }
2311
+ catch {
2312
+ return null;
2313
+ }
2314
+ }
2315
+ function getClaudeUsageFromTelemetry(home, model) {
2316
+ const telemetryRoot = path.join(home, '.claude', 'telemetry');
2317
+ if (!fs.existsSync(telemetryRoot))
2318
+ return null;
2319
+ const preferredFamily = modelFamily(model);
2320
+ let bestAny = null;
2321
+ let bestMatch = null;
2322
+ try {
2323
+ const files = fs.readdirSync(telemetryRoot)
2324
+ .filter(name => name.endsWith('.json'))
2325
+ .map(name => ({ full: path.join(telemetryRoot, name), mtime: fs.statSync(path.join(telemetryRoot, name)).mtimeMs }))
2326
+ .sort((a, b) => b.mtime - a.mtime)
2327
+ .slice(0, 50);
2328
+ for (const file of files) {
2329
+ const lines = fs.readFileSync(file.full, 'utf-8').trim().split('\n');
2330
+ for (let i = lines.length - 1; i >= 0; i--) {
2331
+ const raw = lines[i];
2332
+ if (!raw || raw[0] !== '{' || !raw.includes('tengu_claudeai_limits_status_changed'))
2333
+ continue;
2334
+ let parsed;
2335
+ try {
2336
+ parsed = JSON.parse(raw);
2337
+ }
2338
+ catch {
2339
+ continue;
2340
+ }
2341
+ const data = parsed?.event_data;
2342
+ if (data?.event_name !== 'tengu_claudeai_limits_status_changed')
2343
+ continue;
2344
+ const capturedAtMs = Date.parse(data.client_timestamp || '');
2345
+ if (!Number.isFinite(capturedAtMs))
2346
+ continue;
2347
+ let meta = data.additional_metadata;
2348
+ if (typeof meta === 'string') {
2349
+ try {
2350
+ meta = JSON.parse(meta);
2351
+ }
2352
+ catch {
2353
+ meta = null;
2354
+ }
2355
+ }
2356
+ const hoursTillReset = Number(meta?.hoursTillReset);
2357
+ const candidate = {
2358
+ capturedAtMs, capturedAt: new Date(capturedAtMs).toISOString(),
2359
+ status: typeof meta?.status === 'string' ? meta.status : null,
2360
+ hoursTillReset: Number.isFinite(hoursTillReset) ? hoursTillReset : null,
2361
+ model: typeof data.model === 'string' ? data.model : null,
2362
+ };
2363
+ if (!bestAny || candidate.capturedAtMs > bestAny.capturedAtMs)
2364
+ bestAny = candidate;
2365
+ if (preferredFamily && candidate.model?.toLowerCase().includes(preferredFamily)) {
2366
+ if (!bestMatch || candidate.capturedAtMs > bestMatch.capturedAtMs)
2367
+ bestMatch = candidate;
2368
+ }
2369
+ }
2370
+ }
2371
+ }
2372
+ catch {
2373
+ return null;
2374
+ }
2375
+ const chosen = bestMatch || bestAny;
2376
+ if (!chosen)
2377
+ return null;
2378
+ const status = normalizeUsageStatus(chosen.status);
2379
+ const resetAfterSeconds = chosen.hoursTillReset == null ? null : Math.max(0, Math.round(chosen.hoursTillReset * 3600));
2380
+ const resetAt = resetAfterSeconds == null ? null : new Date(chosen.capturedAtMs + resetAfterSeconds * 1000).toISOString();
2381
+ // Build a locale-neutral label from capture age (e.g. "3h ago", "2d ago")
2382
+ const ageMs = Date.now() - chosen.capturedAtMs;
2383
+ const ageMins = Math.round(ageMs / 60_000);
2384
+ const ageLabel = ageMins < 1 ? '<1m ago' : ageMins < 60 ? `${ageMins}m ago` : ageMins < 1440 ? `${Math.round(ageMins / 60)}h ago` : `${Math.round(ageMins / 1440)}d ago`;
2385
+ const windows = [{ label: ageLabel, usedPercent: null, remainingPercent: null, resetAt, resetAfterSeconds, status }];
2386
+ return { ok: true, agent: 'claude', source: 'telemetry', capturedAt: chosen.capturedAt, status, windows, error: null };
2387
+ }
2388
+ function claudeSessionTranscriptPath(workdir, sessionId) {
2389
+ const home = getHome();
2390
+ if (!home || !workdir || !sessionId)
2391
+ return '';
2392
+ return path.join(home, '.claude', 'projects', encodePathAsDirName(workdir), `${sessionId}.jsonl`);
2393
+ }
2394
+ /**
2395
+ * Scan a claude session transcript for the latest native /goal state. Returns
2396
+ * null when no `goal_status` attachment is present.
2397
+ */
2398
+ export function getClaudeNativeGoal(workdir, sessionId) {
2399
+ const file = claudeSessionTranscriptPath(workdir, sessionId);
2400
+ if (!file || !fs.existsSync(file))
2401
+ return null;
2402
+ // Goal status lines are tiny attachments. Walk the tail (1 MB) to find the
2403
+ // last one — tail covers all realistic session sizes without parsing every
2404
+ // line of a long transcript.
2405
+ const lines = readTailLines(file, 1024 * 1024);
2406
+ let latest = null;
2407
+ for (const raw of lines) {
2408
+ if (!raw || raw[0] !== '{')
2409
+ continue;
2410
+ // Cheap pre-filter so we only JSON.parse the relevant subset.
2411
+ if (!raw.includes('"goal_status"'))
2412
+ continue;
2413
+ try {
2414
+ const ev = JSON.parse(raw);
2415
+ const att = ev?.attachment;
2416
+ if (!att || att.type !== 'goal_status')
2417
+ continue;
2418
+ const condition = typeof att.condition === 'string' ? att.condition : '';
2419
+ const met = !!att.met;
2420
+ const ts = typeof ev.timestamp === 'string' ? Date.parse(ev.timestamp) : NaN;
2421
+ latest = {
2422
+ condition,
2423
+ met,
2424
+ status: met || !condition ? 'complete' : 'active',
2425
+ updatedAtMs: Number.isFinite(ts) ? ts : Date.now(),
2426
+ };
2427
+ }
2428
+ catch { /* skip */ }
2429
+ }
2430
+ // After auto-clear (met:true) claude still leaves the goal_status line in the
2431
+ // transcript; pikiloop treats "no active goal" as null so the bridge mirrors
2432
+ // the codex semantics where `goal_get` returns null after a clear.
2433
+ if (latest && latest.met)
2434
+ return null;
2435
+ return latest;
2436
+ }
2437
+ /** Build the user-prompt that triggers claude's native `/goal <condition>` slash command. */
2438
+ export function buildClaudeSetGoalPrompt(objective) {
2439
+ return `/goal ${objective.trim()}`;
2440
+ }
2441
+ /** Build the user-prompt that triggers claude's native `/goal clear` slash command. */
2442
+ export function buildClaudeClearGoalPrompt() {
2443
+ return '/goal clear';
2444
+ }
2445
+ // ---------------------------------------------------------------------------
2446
+ // Driver
2447
+ // ---------------------------------------------------------------------------
2448
+ /**
2449
+ * Claude turns default to the real interactive TUI under PTY — usage stays
2450
+ * inside the Pro/Max subscription quota. `claude -p` calls (headless / print
2451
+ * mode) bill against the separate Agent SDK credit pool that Anthropic split
2452
+ * out on 2026-06-15, so we keep that off the hot path.
2453
+ *
2454
+ * Opt out to the legacy print path with `PIKILOOP_CLAUDE_PRINT=1` (also
2455
+ * accepts `=true` / `=yes` / `=on`). For backwards compat the older
2456
+ * `PIKILOOP_CLAUDE_TUI=0` / `=false` / `=no` / `=off` is honoured too.
2457
+ *
2458
+ * When TUI startup fails (node-pty missing, prebuilt helper unusable, PTY
2459
+ * allocation refused in a sandbox, …) the dispatcher automatically falls
2460
+ * through to the print-mode driver so pikiloop still works — at the cost of
2461
+ * the calls landing on the Agent SDK credit pool. The fallback is logged so
2462
+ * users can investigate.
2463
+ */
2464
+ export function isClaudePrintModeForced() {
2465
+ const print = (process.env.PIKILOOP_CLAUDE_PRINT ?? '').trim().toLowerCase();
2466
+ if (print === '1' || print === 'true' || print === 'yes' || print === 'on')
2467
+ return true;
2468
+ // Legacy env var: PIKILOOP_CLAUDE_TUI=0 (or false/no/off) explicitly opts
2469
+ // back to print mode. Truthy values are now the default behaviour and a
2470
+ // no-op.
2471
+ const tui = (process.env.PIKILOOP_CLAUDE_TUI ?? '').trim().toLowerCase();
2472
+ if (tui === '0' || tui === 'false' || tui === 'no' || tui === 'off')
2473
+ return true;
2474
+ return false;
2475
+ }
2476
+ /**
2477
+ * Single-attempt dispatch: print mode when the resolved access mode is 'api'
2478
+ * (or, when no explicit mode was threaded, when forced via env), otherwise TUI
2479
+ * mode with print-mode fallback if TUI prerequisites are missing (node-pty
2480
+ * absent, PTY allocation refused, …).
2481
+ *
2482
+ * `opts.claudeAccessMode` is authoritative when set — the bot resolves it from
2483
+ * the per-agent config so a live dashboard toggle takes effect on the next
2484
+ * spawned turn. Env (isClaudePrintModeForced) is only the fallback for callers
2485
+ * that don't thread a mode (the `pikiloop run` one-shot path).
2486
+ */
2487
+ async function doClaudeStreamOnce(opts) {
2488
+ const printMode = opts.claudeAccessMode
2489
+ ? opts.claudeAccessMode === 'api'
2490
+ : isClaudePrintModeForced();
2491
+ if (printMode) {
2492
+ agentLog(`[claude] print mode (-p) — ${opts.claudeAccessMode === 'api' ? 'access mode: api (Agent SDK credits)' : 'forced via env'}`);
2493
+ return doClaudeStream(opts);
2494
+ }
2495
+ try {
2496
+ const mod = await import('./claude-tui.js');
2497
+ return await mod.doClaudeTuiStream(opts);
2498
+ }
2499
+ catch (err) {
2500
+ // TUI prerequisite failed (node-pty missing, PTY allocation refused, etc.).
2501
+ // Fall back to print mode so pikiloop stays functional — with the caveat
2502
+ // that this turn lands on the Agent SDK credit pool.
2503
+ agentWarn(`[claude] TUI unavailable (${err?.message || err}); falling back to -p — this turn bills the Agent SDK credit pool`);
2504
+ return doClaudeStream(opts);
2505
+ }
2506
+ }
2507
+ /**
2508
+ * Backoff schedule (in ms) for retrying transient Anthropic upstream failures
2509
+ * — 529 Overloaded, 5xx, gateway timeouts. Total wait budget ~30s before we
2510
+ * surface the failure to the user. Non-retryable errors (auth, quota,
2511
+ * context-length) skip the loop and fail fast.
2512
+ */
2513
+ const CLAUDE_API_RETRY_BACKOFFS_MS = [4000, 12000];
2514
+ function makeOverloadFriendlyResult(result, reason, attempts) {
2515
+ const wait = CLAUDE_API_RETRY_BACKOFFS_MS.slice(0, attempts).reduce((sum, ms) => sum + ms, 0);
2516
+ const elapsedNote = wait > 0 ? ` (retried ${attempts}× over ${Math.round(wait / 1000)}s)` : '';
2517
+ const message = [
2518
+ `Anthropic API temporarily overloaded${elapsedNote}.`,
2519
+ `Reason from upstream: ${reason}.`,
2520
+ 'Please re-send your last message in a moment — your session is intact and will resume from where it stopped.',
2521
+ ].join(' ');
2522
+ return {
2523
+ ...result,
2524
+ ok: false,
2525
+ incomplete: true,
2526
+ stopReason: 'api_error',
2527
+ message,
2528
+ error: `Anthropic API error: ${reason}`,
2529
+ };
2530
+ }
2531
+ /**
2532
+ * Driver-entry wrapper. Detects the Claude CLI's synthetic "API Error: …"
2533
+ * assistant turn and re-issues the request with backoff for retryable upstream
2534
+ * conditions (Overloaded, 5xx, timeouts). Non-retryable failures surface
2535
+ * immediately. After the budget is exhausted, the final result carries a
2536
+ * friendly human-readable explanation in `message` so the IM card doesn't
2537
+ * dump raw "API Error: Overloaded" text on the user.
2538
+ */
2539
+ /**
2540
+ * Continuation prompt for stall recovery. The frozen process already accepted
2541
+ * and partially executed the user's prompt (it sits in the transcript), so the
2542
+ * resumed process must NOT receive the original prompt again — it gets an
2543
+ * explicit "pick up where you left off" instead.
2544
+ */
2545
+ const CLAUDE_STALL_RESUME_PROMPT = '[pikiloop] The previous agent process stalled mid-turn and was restarted. '
2546
+ + 'Continue the task from where it left off — do not start over or repeat work that already completed.';
2547
+ /** At most one automatic resume per turn; a second stall surfaces to the user. */
2548
+ const CLAUDE_STALL_RESUME_LIMIT = 1;
2549
+ async function doClaudeWithRetry(opts) {
2550
+ let lastResult = await doClaudeStreamOnce(opts);
2551
+ // Mid-turn stall recovery. The TUI driver SIGTERMs a frozen claude process
2552
+ // (stopReason 'stalled' — see decideClaudeTuiStall in claude-tui.ts) instead
2553
+ // of letting the IM card spin forever. Resume the same session once with a
2554
+ // continuation prompt so the turn picks up where the frozen process died.
2555
+ let stallResumes = 0;
2556
+ while (lastResult.stopReason === 'stalled'
2557
+ && stallResumes < CLAUDE_STALL_RESUME_LIMIT
2558
+ && !opts.abortSignal?.aborted) {
2559
+ const stalledSessionId = lastResult.sessionId || opts.sessionId;
2560
+ if (!stalledSessionId)
2561
+ break;
2562
+ stallResumes++;
2563
+ agentWarn(`[claude] turn stalled mid-flight; auto-resuming session ${stalledSessionId.slice(0, 8)} (${stallResumes}/${CLAUDE_STALL_RESUME_LIMIT})`);
2564
+ lastResult = await doClaudeStreamOnce({
2565
+ ...opts,
2566
+ sessionId: stalledSessionId,
2567
+ forkOf: undefined,
2568
+ prompt: CLAUDE_STALL_RESUME_PROMPT,
2569
+ attachments: undefined,
2570
+ });
2571
+ }
2572
+ if (lastResult.stopReason === 'stalled') {
2573
+ // Still stalled after the resume budget (or no session id to resume).
2574
+ // Surface a self-explanatory failure instead of the raw error text.
2575
+ return {
2576
+ ...lastResult,
2577
+ ok: false,
2578
+ incomplete: true,
2579
+ message: [
2580
+ 'The agent process stalled mid-turn and could not be auto-recovered (a known claude CLI mid-turn freeze).',
2581
+ 'Your session is intact — re-send your message (or say "continue") to pick up where it stopped.',
2582
+ ].join(' '),
2583
+ };
2584
+ }
2585
+ let attempts = 0;
2586
+ // Use the error text recorded by detectClaudeApiError-driven branches to
2587
+ // decide retry: lastResult.error is "Anthropic API error: <reason>" on
2588
+ // detection, undefined otherwise.
2589
+ const reasonOf = (r) => {
2590
+ if (r.stopReason !== 'api_error')
2591
+ return null;
2592
+ const m = (r.error || '').match(/^Anthropic API error:\s*(.+)$/i);
2593
+ return m ? m[1].trim() : null;
2594
+ };
2595
+ while (attempts < CLAUDE_API_RETRY_BACKOFFS_MS.length) {
2596
+ const reason = reasonOf(lastResult);
2597
+ if (!reason || !isRetryableClaudeApiError(reason))
2598
+ break;
2599
+ const wait = CLAUDE_API_RETRY_BACKOFFS_MS[attempts];
2600
+ attempts++;
2601
+ agentWarn(`[claude] API error "${reason}", retry ${attempts}/${CLAUDE_API_RETRY_BACKOFFS_MS.length} after ${wait}ms`);
2602
+ if (opts.abortSignal?.aborted) {
2603
+ agentWarn('[claude] retry skipped — abort signal already fired');
2604
+ break;
2605
+ }
2606
+ await new Promise(r => setTimeout(r, wait));
2607
+ if (opts.abortSignal?.aborted) {
2608
+ agentWarn('[claude] retry skipped after backoff — abort signal fired');
2609
+ break;
2610
+ }
2611
+ // Resume the same session so we don't restart from scratch. The previous
2612
+ // attempt may have written a synthetic "API Error" assistant block into
2613
+ // the JSONL; Claude resumes past it and re-answers the user's prompt.
2614
+ const nextOpts = {
2615
+ ...opts,
2616
+ sessionId: lastResult.sessionId || opts.sessionId,
2617
+ };
2618
+ lastResult = await doClaudeStreamOnce(nextOpts);
2619
+ }
2620
+ const finalReason = reasonOf(lastResult);
2621
+ if (finalReason) {
2622
+ return makeOverloadFriendlyResult(lastResult, finalReason, attempts);
2623
+ }
2624
+ return lastResult;
2625
+ }
2626
+ class ClaudeDriver {
2627
+ id = 'claude';
2628
+ cmd = 'claude';
2629
+ thinkLabel = 'Thinking';
2630
+ capabilities = { fork: true, modelSwitch: true, workflow: true };
2631
+ // Claude Code BYOK routes through ANTHROPIC_BASE_URL — accepts both
2632
+ // first-party Anthropic and any openai-compatible provider that exposes an
2633
+ // Anthropic-protocol-shaped endpoint (OpenRouter `/api/v1`, DeepSeek
2634
+ // `/anthropic/v1`, …). cf. src/model/injector.ts:claudeInjector.
2635
+ acceptedProviderKinds = ['anthropic', 'openai-compatible'];
2636
+ async doStream(opts) {
2637
+ return doClaudeWithRetry(opts);
2638
+ }
2639
+ async getSessions(workdir, limit) {
2640
+ return getClaudeSessions(workdir, limit);
2641
+ }
2642
+ async getSessionTail(opts) {
2643
+ return getClaudeSessionTail(opts);
2644
+ }
2645
+ async getSessionMessages(opts) {
2646
+ return getClaudeSessionMessages(opts);
2647
+ }
2648
+ async listModels(_opts) {
2649
+ return { agent: 'claude', models: [...CLAUDE_MODELS], sources: [], note: null };
2650
+ }
2651
+ getUsage(opts) {
2652
+ const home = getHome();
2653
+ if (!home)
2654
+ return emptyUsage('claude', 'HOME is not set.');
2655
+ const telemetry = () => getClaudeUsageFromTelemetry(home, opts.model)
2656
+ || emptyUsage('claude', 'No recent Claude usage data found.');
2657
+ // Throttle the rate-limited OAuth usage query (see CLAUDE_USAGE_QUERY_TTL_MS).
2658
+ // Within the window we reuse the last good result rather than re-querying on
2659
+ // every agent-status rebuild, so a transient query-API 429 can't blank the
2660
+ // ring between successful polls.
2661
+ const now = Date.now();
2662
+ if (now - claudeUsageCache.lastAttemptAt < CLAUDE_USAGE_QUERY_TTL_MS) {
2663
+ return claudeUsageCache.lastGood ?? telemetry();
2664
+ }
2665
+ claudeUsageCache.lastAttemptAt = now;
2666
+ const oauth = getClaudeUsageFromOAuth();
2667
+ if (oauth) {
2668
+ claudeUsageCache.lastGood = oauth;
2669
+ return oauth;
2670
+ }
2671
+ // OAuth unavailable (non-mac, no token, or transient 429): keep showing the
2672
+ // last good windows if we have any; otherwise fall back to telemetry.
2673
+ return claudeUsageCache.lastGood ?? telemetry();
2674
+ }
2675
+ async deleteNativeSession(workdir, sessionId) {
2676
+ const file = claudeSessionTranscriptPath(workdir, sessionId);
2677
+ if (!file || !fs.existsSync(file))
2678
+ return [];
2679
+ try {
2680
+ fs.rmSync(file, { force: true });
2681
+ return [file];
2682
+ }
2683
+ catch {
2684
+ return [];
2685
+ }
2686
+ }
2687
+ shutdown() { }
2688
+ }
2689
+ registerDriver(new ClaudeDriver());