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.
- package/LICENSE +21 -0
- package/README.md +353 -0
- package/README.v2.md +287 -0
- package/README.zh-CN.md +352 -0
- package/dashboard/dist/assets/AgentTab-UZPIhlkr.js +1 -0
- package/dashboard/dist/assets/DirBrowser-Ckcmi-Pi.js +1 -0
- package/dashboard/dist/assets/ExtensionsTab-KZhEDrdu.js +1 -0
- package/dashboard/dist/assets/IMAccessTab-Bd_IY1GQ.js +1 -0
- package/dashboard/dist/assets/Modal-CTeL0y7P.js +1 -0
- package/dashboard/dist/assets/Modals-axftHasy.js +1 -0
- package/dashboard/dist/assets/Select-C8tOdPhe.js +1 -0
- package/dashboard/dist/assets/SessionPanel-C1geSRxw.js +1 -0
- package/dashboard/dist/assets/SystemTab-DBDkaPiO.js +1 -0
- package/dashboard/dist/assets/anthropic-BAdojD7P.ico +0 -0
- package/dashboard/dist/assets/codex-DYadqqp0.png +0 -0
- package/dashboard/dist/assets/deepseek-BeYNZEk0.ico +0 -0
- package/dashboard/dist/assets/doubao-DloFDuFR.png +0 -0
- package/dashboard/dist/assets/feishu-C4OMrjCW.ico +0 -0
- package/dashboard/dist/assets/gemini-BYkEpiWr.svg +1 -0
- package/dashboard/dist/assets/hermes-BAarh-tH.png +0 -0
- package/dashboard/dist/assets/index-CpM4CqZJ.js +23 -0
- package/dashboard/dist/assets/index-DXSohzrE.js +3 -0
- package/dashboard/dist/assets/index-reSbuley.css +1 -0
- package/dashboard/dist/assets/markdown-DxQYQFeH.js +29 -0
- package/dashboard/dist/assets/minimax-PuEGTfrF.ico +0 -0
- package/dashboard/dist/assets/mlx-DhWwjtMw.png +0 -0
- package/dashboard/dist/assets/ollama-Bt9O-2K_.png +0 -0
- package/dashboard/dist/assets/openrouter-CsJ_bD5Q.ico +0 -0
- package/dashboard/dist/assets/playwright-BldPFZgC.ico +0 -0
- package/dashboard/dist/assets/qwen-xykkX0_y.png +0 -0
- package/dashboard/dist/assets/react-vendor-C7Sl8SE7.js +9 -0
- package/dashboard/dist/assets/router-DHISdpPk.js +3 -0
- package/dashboard/dist/assets/shared-BIP_4k4I.js +1 -0
- package/dashboard/dist/favicon.svg +28 -0
- package/dashboard/dist/index.html +17 -0
- package/dist/agent/acp-client.js +261 -0
- package/dist/agent/auto-update.js +432 -0
- package/dist/agent/await-resume.js +50 -0
- package/dist/agent/cli/auth.js +325 -0
- package/dist/agent/cli/catalog.js +40 -0
- package/dist/agent/cli/detector.js +136 -0
- package/dist/agent/cli/index.js +7 -0
- package/dist/agent/cli/registry.js +33 -0
- package/dist/agent/driver.js +39 -0
- package/dist/agent/drivers/claude-tui.js +2297 -0
- package/dist/agent/drivers/claude.js +2689 -0
- package/dist/agent/drivers/codex.js +2210 -0
- package/dist/agent/drivers/gemini.js +1059 -0
- package/dist/agent/drivers/hermes.js +795 -0
- package/dist/agent/goal.js +274 -0
- package/dist/agent/handover.js +130 -0
- package/dist/agent/images.js +355 -0
- package/dist/agent/index.js +50 -0
- package/dist/agent/mcp/bridge.js +791 -0
- package/dist/agent/mcp/extensions.js +637 -0
- package/dist/agent/mcp/oauth.js +353 -0
- package/dist/agent/mcp/registry.js +119 -0
- package/dist/agent/mcp/session-server.js +229 -0
- package/dist/agent/mcp/tools/ask-user.js +113 -0
- package/dist/agent/mcp/tools/await-resume.js +77 -0
- package/dist/agent/mcp/tools/goal.js +144 -0
- package/dist/agent/mcp/tools/types.js +12 -0
- package/dist/agent/mcp/tools/workspace.js +212 -0
- package/dist/agent/npm.js +31 -0
- package/dist/agent/session.js +1206 -0
- package/dist/agent/skill-installer.js +160 -0
- package/dist/agent/skills.js +257 -0
- package/dist/agent/stream.js +743 -0
- package/dist/agent/types.js +13 -0
- package/dist/agent/utils.js +687 -0
- package/dist/bot/bot.js +2499 -0
- package/dist/bot/command-ui.js +633 -0
- package/dist/bot/commands.js +513 -0
- package/dist/bot/headless-bot.js +36 -0
- package/dist/bot/host.js +192 -0
- package/dist/bot/human-loop.js +168 -0
- package/dist/bot/menu.js +48 -0
- package/dist/bot/orchestration.js +79 -0
- package/dist/bot/render-shared.js +309 -0
- package/dist/bot/session-hub.js +361 -0
- package/dist/bot/session-status.js +55 -0
- package/dist/bot/streaming.js +309 -0
- package/dist/browser-profile.js +579 -0
- package/dist/browser-supervisor.js +249 -0
- package/dist/catalog/cli-tools.js +421 -0
- package/dist/catalog/index.js +21 -0
- package/dist/catalog/local-models.js +94 -0
- package/dist/catalog/mcp-servers.js +315 -0
- package/dist/catalog/skill-repos.js +173 -0
- package/dist/channels/base.js +55 -0
- package/dist/channels/dingtalk/bot.js +549 -0
- package/dist/channels/dingtalk/channel.js +268 -0
- package/dist/channels/discord/bot.js +552 -0
- package/dist/channels/discord/channel.js +245 -0
- package/dist/channels/feishu/bot.js +1275 -0
- package/dist/channels/feishu/channel.js +911 -0
- package/dist/channels/feishu/markdown.js +91 -0
- package/dist/channels/feishu/render.js +619 -0
- package/dist/channels/health.js +109 -0
- package/dist/channels/slack/bot.js +554 -0
- package/dist/channels/slack/channel.js +283 -0
- package/dist/channels/states.js +6 -0
- package/dist/channels/telegram/bot.js +1310 -0
- package/dist/channels/telegram/channel.js +820 -0
- package/dist/channels/telegram/directory.js +111 -0
- package/dist/channels/telegram/live-preview.js +220 -0
- package/dist/channels/telegram/render.js +384 -0
- package/dist/channels/wecom/bot.js +558 -0
- package/dist/channels/wecom/channel.js +479 -0
- package/dist/channels/weixin/api.js +520 -0
- package/dist/channels/weixin/bot.js +1000 -0
- package/dist/channels/weixin/channel.js +222 -0
- package/dist/cli/autostart.js +262 -0
- package/dist/cli/channel-supervisor.js +313 -0
- package/dist/cli/channels.js +54 -0
- package/dist/cli/main.js +726 -0
- package/dist/cli/onboarding.js +227 -0
- package/dist/cli/run.js +308 -0
- package/dist/cli/setup-wizard.js +235 -0
- package/dist/core/config/runtime-config.js +201 -0
- package/dist/core/config/user-config.js +510 -0
- package/dist/core/config/validation.js +521 -0
- package/dist/core/constants.js +400 -0
- package/dist/core/git.js +145 -0
- package/dist/core/legacy-compat.js +60 -0
- package/dist/core/logging.js +101 -0
- package/dist/core/platform.js +59 -0
- package/dist/core/process-control.js +315 -0
- package/dist/core/secrets/index.js +42 -0
- package/dist/core/secrets/inline-seal.js +60 -0
- package/dist/core/secrets/ref.js +33 -0
- package/dist/core/secrets/resolver.js +65 -0
- package/dist/core/secrets/store.js +63 -0
- package/dist/core/utils.js +233 -0
- package/dist/core/version.js +15 -0
- package/dist/dashboard/platform.js +219 -0
- package/dist/dashboard/routes/agents.js +450 -0
- package/dist/dashboard/routes/cli.js +174 -0
- package/dist/dashboard/routes/config.js +523 -0
- package/dist/dashboard/routes/extensions.js +745 -0
- package/dist/dashboard/routes/local-models.js +290 -0
- package/dist/dashboard/routes/models.js +324 -0
- package/dist/dashboard/routes/sessions.js +838 -0
- package/dist/dashboard/runtime.js +410 -0
- package/dist/dashboard/server.js +237 -0
- package/dist/dashboard/session-control.js +347 -0
- package/dist/model/catalog.js +104 -0
- package/dist/model/index.js +20 -0
- package/dist/model/injector.js +272 -0
- package/dist/model/provider-models.js +112 -0
- package/dist/model/store.js +212 -0
- package/dist/model/types.js +13 -0
- package/dist/model/validation.js +203 -0
- 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());
|