pi-kanban 0.3.1 → 2.0.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/CHANGELOG.md +30 -0
- package/README.md +61 -21
- package/cli.js +1586 -0
- package/extensions/kanban.ts +330 -367
- package/extensions/package.json +1 -0
- package/extensions/tools/ask.ts +85 -0
- package/extensions/tools/board-api.ts +142 -0
- package/extensions/tools/cli-path.ts +34 -0
- package/extensions/tools/dispatch.ts +56 -0
- package/extensions/tools/inbox.ts +86 -0
- package/extensions/tools/kanban-command.ts +27 -0
- package/extensions/tools/scratchpad.ts +70 -0
- package/extensions/tsconfig.json +16 -0
- package/lib/agent-dir.js +20 -0
- package/lib/agent-log.js +64 -0
- package/lib/ask.js +252 -0
- package/lib/atomic-write.js +30 -0
- package/lib/board-dir.js +63 -0
- package/lib/claude-settings.js +21 -0
- package/lib/companion-host.js +142 -0
- package/lib/companions.js +136 -0
- package/lib/contain.js +78 -0
- package/lib/dispatch-reports.js +86 -0
- package/lib/file-url.js +38 -0
- package/lib/folder-dialog.js +91 -0
- package/lib/frame-policy.js +79 -0
- package/lib/git-branch.js +37 -102
- package/lib/http-error.js +7 -0
- package/lib/inline-assets.js +238 -0
- package/lib/linked-docs.js +56 -0
- package/lib/live-sessions.js +86 -0
- package/lib/model-windows.js +48 -0
- package/lib/net-guard.js +277 -0
- package/lib/one-line.js +6 -0
- package/lib/open-editor.js +173 -0
- package/lib/panes.js +213 -0
- package/lib/parsers.js +1434 -0
- package/lib/pi-agents.js +178 -0
- package/lib/pi-git-branch.js +112 -0
- package/lib/pi-models.js +34 -0
- package/lib/pi-sessions.js +240 -0
- package/lib/pi-transcript.js +354 -0
- package/lib/preview-action.js +55 -0
- package/lib/priority.js +45 -0
- package/lib/proc-stats.js +82 -0
- package/lib/retention.js +191 -0
- package/lib/scratch-files.js +47 -0
- package/lib/scratchpad-dir.js +95 -0
- package/lib/session-cache.js +32 -0
- package/lib/session-events.js +275 -0
- package/lib/session-pins.js +99 -0
- package/lib/show.js +279 -0
- package/lib/stamped-json-file.js +49 -0
- package/lib/task-counts.js +90 -0
- package/lib/task-dir.js +26 -0
- package/lib/task-store.js +44 -47
- package/lib/terminal-client.js +248 -0
- package/lib/terminal-host.js +113 -0
- package/lib/terminal.js +1116 -0
- package/lib/user-groups.js +384 -0
- package/lib/worktrees.js +132 -0
- package/package.json +39 -15
- package/public/app.js +14349 -3448
- package/public/fonts/OFL-IBM-Plex.txt +93 -0
- package/public/fonts/OFL-Playfair-Display.txt +93 -0
- package/public/fonts/fonts.css +244 -0
- package/public/fonts/ibm-plex-mono-cyrillic-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-600.woff2 +0 -0
- package/public/fonts/playfair-display-cyrillic-400.woff2 +0 -0
- package/public/fonts/playfair-display-latin-400.woff2 +0 -0
- package/public/fonts/playfair-display-latin-ext-400.woff2 +0 -0
- package/public/fonts/playfair-display-vietnamese-400.woff2 +0 -0
- package/public/group-tree.js +21 -0
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/icons/icon-svg.svg +7 -5
- package/public/index.html +493 -328
- package/public/link-url.js +18 -0
- package/public/manifest.json +7 -7
- package/public/project-match.js +34 -0
- package/public/style.css +8301 -2251
- package/public/sw.js +4 -3
- package/public/terminal-frame.js +437 -0
- package/public/terminal.html +26 -0
- package/public/themes.css +876 -0
- package/public/tree-highlight.js +196 -0
- package/server.js +3368 -467
- package/skills/ask/SKILL.md +29 -0
- package/skills/ask/references/playground.md +25 -0
- package/skills/dispatch/SKILL.md +51 -0
- package/skills/dispatch/references/orchestration-patterns.md +50 -0
- package/skills/kanban/SKILL.md +46 -0
- package/skills/show/SKILL.md +41 -0
- package/skills/show/references/NOTICE.md +27 -0
- package/skills/show/references/chart.md +58 -0
- package/skills/show/references/diagram/architecture.md +18 -0
- package/skills/show/references/diagram/er.md +10 -0
- package/skills/show/references/diagram/flow.md +13 -0
- package/skills/show/references/diagram/layers.md +10 -0
- package/skills/show/references/diagram/sequence.md +19 -0
- package/skills/show/references/diagram/state.md +12 -0
- package/skills/show/references/diagram/swimlane.md +10 -0
- package/skills/show/references/diagram/timeline.md +9 -0
- package/skills/show/references/diagram/tree.md +10 -0
- package/skills/show/references/diagram.md +87 -0
- package/skills/show/references/explain.md +100 -0
- package/skills/show/references/html-contract.md +18 -0
- package/skills/show/references/mockup.md +30 -0
- package/skills/visualize/SKILL.md +73 -0
- package/skills/visualize/blocks.html +68 -0
- package/skills/visualize/template.html +179 -0
- package/lib/pi-parsers.js +0 -1000
- package/themes/kanban-dark-default.json +0 -21
- package/themes/kanban-default.json +0 -21
- package/themes/pi-dark.json +0 -21
- package/themes/pi-light.json +0 -21
package/lib/parsers.js
ADDED
|
@@ -0,0 +1,1434 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const { readFileSync, existsSync, readdirSync, statSync } = fs;
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const { readSession, readClaudeLines, readClaudeLineTexts, contentText } = require('./pi-transcript');
|
|
6
|
+
const { agentIdFor } = require('./pi-agents');
|
|
7
|
+
const { oneLine } = require('./one-line');
|
|
8
|
+
const { recordedPadDir } = require('./scratchpad-dir');
|
|
9
|
+
|
|
10
|
+
// The active branch of a pi session as Claude Code transcript lines (JSON text), so the
|
|
11
|
+
// line scanners below read pi sessions unchanged. Rejects for a missing file.
|
|
12
|
+
async function* readLines(file) {
|
|
13
|
+
await fs.promises.access(file);
|
|
14
|
+
yield* readClaudeLineTexts(file);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseTask(raw) {
|
|
18
|
+
const task = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
19
|
+
return {
|
|
20
|
+
id: task.id,
|
|
21
|
+
subject: task.subject,
|
|
22
|
+
description: task.description || null,
|
|
23
|
+
status: task.status,
|
|
24
|
+
blocks: task.blocks || [],
|
|
25
|
+
blockedBy: task.blockedBy || [],
|
|
26
|
+
isInternal: !!task.metadata?._internal,
|
|
27
|
+
raw: task
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseAgent(raw) {
|
|
32
|
+
const agent = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
33
|
+
return {
|
|
34
|
+
agentId: agent.agentId,
|
|
35
|
+
type: agent.type || null,
|
|
36
|
+
status: agent.status,
|
|
37
|
+
startedAt: agent.startedAt,
|
|
38
|
+
stoppedAt: agent.stoppedAt || null,
|
|
39
|
+
updatedAt: agent.updatedAt || null,
|
|
40
|
+
lastMessage: agent.lastMessage || null,
|
|
41
|
+
prompt: agent.prompt || null,
|
|
42
|
+
raw: agent
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseWaiting(raw) {
|
|
47
|
+
const data = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
48
|
+
return {
|
|
49
|
+
status: data.status,
|
|
50
|
+
kind: data.kind || null,
|
|
51
|
+
toolName: data.toolName || null,
|
|
52
|
+
toolInput: data.toolInput || null,
|
|
53
|
+
timestamp: data.timestamp,
|
|
54
|
+
raw: data
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseTeamConfig(raw) {
|
|
59
|
+
const config = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
60
|
+
return {
|
|
61
|
+
name: config.name,
|
|
62
|
+
description: config.description || null,
|
|
63
|
+
leadAgentId: config.leadAgentId,
|
|
64
|
+
leadSessionId: config.leadSessionId || null,
|
|
65
|
+
members: (config.members || []).map(m => ({
|
|
66
|
+
agentId: m.agentId,
|
|
67
|
+
name: m.name,
|
|
68
|
+
agentType: m.agentType || null,
|
|
69
|
+
model: m.model || null,
|
|
70
|
+
cwd: m.cwd || null,
|
|
71
|
+
color: m.color || null
|
|
72
|
+
})),
|
|
73
|
+
raw: config
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseSessionsIndex(raw) {
|
|
78
|
+
const index = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
79
|
+
return {
|
|
80
|
+
version: index.version || null,
|
|
81
|
+
entries: (index.entries || []).map(e => ({
|
|
82
|
+
sessionId: e.sessionId,
|
|
83
|
+
description: e.description || null,
|
|
84
|
+
gitBranch: e.gitBranch || null,
|
|
85
|
+
created: e.created || null,
|
|
86
|
+
projectPath: e.projectPath || null,
|
|
87
|
+
isSidechain: e.isSidechain || false
|
|
88
|
+
})),
|
|
89
|
+
raw: index
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parseJsonlLine(line) {
|
|
94
|
+
const obj = typeof line === 'string' ? JSON.parse(line) : line;
|
|
95
|
+
const base = {
|
|
96
|
+
type: obj.type,
|
|
97
|
+
timestamp: obj.timestamp || null,
|
|
98
|
+
sessionId: obj.sessionId || null,
|
|
99
|
+
uuid: obj.uuid || null
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
if (obj.type === 'assistant' && obj.message?.content && Array.isArray(obj.message.content)) {
|
|
103
|
+
const blocks = obj.message.content.map(block => {
|
|
104
|
+
if (block.type === 'text') return { type: 'text', text: block.text };
|
|
105
|
+
if (block.type === 'tool_use') return { type: 'tool_use', name: block.name, input: block.input || null };
|
|
106
|
+
if (block.type === 'thinking') return { type: 'thinking' };
|
|
107
|
+
return { type: block.type };
|
|
108
|
+
});
|
|
109
|
+
return { ...base, role: 'assistant', model: obj.message.model || null, blocks };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (obj.type === 'user' && obj.message?.role === 'user') {
|
|
113
|
+
return {
|
|
114
|
+
...base,
|
|
115
|
+
role: 'user',
|
|
116
|
+
isMeta: !!obj.isMeta,
|
|
117
|
+
content: typeof obj.message.content === 'string' ? obj.message.content : null
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (obj.type === 'queue-operation' && obj.operation === 'enqueue') {
|
|
122
|
+
return {
|
|
123
|
+
...base,
|
|
124
|
+
role: 'user',
|
|
125
|
+
queued: true,
|
|
126
|
+
content: typeof obj.content === 'string' ? obj.content : null
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (obj.type === 'progress') {
|
|
131
|
+
return { ...base, cwd: obj.cwd || null, version: obj.version || null, slug: obj.slug || null };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return base;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const TOOL_RESULT_MAX = 1500;
|
|
138
|
+
const USER_TEXT_MAX = 500;
|
|
139
|
+
const INTERRUPT_MARKER = '[Request interrupted by user]';
|
|
140
|
+
|
|
141
|
+
// Newer Claude Code no longer inlines pasted images as base64 blocks. It writes
|
|
142
|
+
// them to ~/.claude/image-cache/<sessionId>/<N>.<ext> and leaves only a text marker
|
|
143
|
+
// in the message — an inline "[Image #N]" placeholder and/or a standalone
|
|
144
|
+
// "[Image: source: ...\N.ext]" line. Pull out the N references (so the UI can serve
|
|
145
|
+
// the cached file) and strip the verbose source lines from the displayed text — the
|
|
146
|
+
// rendered preview replaces them; the short "[Image #N]" placeholders stay.
|
|
147
|
+
// Runs on the per-message scan hot path, so it skips the regexes entirely unless a
|
|
148
|
+
// marker is present. Returns { refs: [{ kind: 'cache', n }], text }.
|
|
149
|
+
function parseImageMarkers(text) {
|
|
150
|
+
const raw = text || '';
|
|
151
|
+
if (!raw.includes('[Image')) return { refs: [], text: raw.trim() };
|
|
152
|
+
const ns = new Set();
|
|
153
|
+
for (const m of raw.matchAll(/\[Image #(\d+)\]/g)) ns.add(Number(m[1]));
|
|
154
|
+
for (const m of raw.matchAll(/\[Image: source:[^\]]*?(\d+)\.[a-z0-9]+\]/gi)) ns.add(Number(m[1]));
|
|
155
|
+
const refs = [...ns].sort((a, b) => a - b).map((n) => ({ kind: 'cache', n }));
|
|
156
|
+
const cleaned = raw.replace(/\[Image: source:[^\]]*\]/gi, '').trim();
|
|
157
|
+
return { refs, text: cleaned };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Compact usage suffix for a task-notification chip: " · 22.3k tok · 6 tools · 119s".
|
|
161
|
+
function formatTaskUsage(usage) {
|
|
162
|
+
if (!usage) return '';
|
|
163
|
+
const parts = [];
|
|
164
|
+
if (usage.subagentTokens != null) {
|
|
165
|
+
const t = usage.subagentTokens;
|
|
166
|
+
const tok = t >= 1000 ? `${(t / 1000).toFixed(1).replace(/\.0$/, '')}k` : String(t);
|
|
167
|
+
parts.push(`${tok} tok`);
|
|
168
|
+
}
|
|
169
|
+
if (usage.toolUses != null) parts.push(usage.toolUses + (usage.toolUses === 1 ? ' tool' : ' tools'));
|
|
170
|
+
if (usage.durationMs != null) parts.push(`${Math.round(usage.durationMs / 1000)}s`);
|
|
171
|
+
return parts.length ? ` · ${parts.join(' · ')}` : '';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Raw per-agent cost for the agent modal's chips plus the formatted one-liner for
|
|
175
|
+
// the inline message row. Null when the source carried no numbers.
|
|
176
|
+
function agentUsageEntry(subagentTokens, toolUses, durationMs) {
|
|
177
|
+
const usage = {};
|
|
178
|
+
if (subagentTokens != null) usage.tokens = subagentTokens;
|
|
179
|
+
if (toolUses != null) usage.tools = toolUses;
|
|
180
|
+
if (durationMs != null) usage.durationMs = durationMs;
|
|
181
|
+
if (!Object.keys(usage).length) return null;
|
|
182
|
+
return { usage, usageText: formatTaskUsage({ subagentTokens, toolUses, durationMs }) };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function pushUserMessage(messages, text, timestamp, sysLabel, extras) {
|
|
186
|
+
if (sysLabel === '__skip__') return;
|
|
187
|
+
let safeText = text || '';
|
|
188
|
+
let label = sysLabel;
|
|
189
|
+
// Background-task completion notifications are harness events, not user input.
|
|
190
|
+
// Whichever path delivered them (normal type:'user' or a queued enqueue), show
|
|
191
|
+
// a rich summary+usage chip and surface the agent's actual result as the body —
|
|
192
|
+
// never the raw <task-notification> envelope under a person icon.
|
|
193
|
+
const notif = parseTaskNotification(safeText);
|
|
194
|
+
if (notif) {
|
|
195
|
+
const base = notif.summary || (notif.status ? `Background task ${notif.status}` : 'Background task notification');
|
|
196
|
+
label = base + formatTaskUsage(notif.usage);
|
|
197
|
+
safeText = notif.result || base;
|
|
198
|
+
}
|
|
199
|
+
// A subagent, teammate or peer session handing work back through SendMessage is
|
|
200
|
+
// also model output, not user input. Callers on the `origin` path pass the parts
|
|
201
|
+
// ready-made; the text paths (type:'user', queued enqueue) parse the envelope.
|
|
202
|
+
const agentMsg = extras?.agent || (notif ? null : parseAgentMessage(safeText));
|
|
203
|
+
if (agentMsg) {
|
|
204
|
+
label = agentMsg.label;
|
|
205
|
+
safeText = agentMsg.body;
|
|
206
|
+
}
|
|
207
|
+
const truncated = safeText.length > USER_TEXT_MAX;
|
|
208
|
+
const msg = {
|
|
209
|
+
type: 'user',
|
|
210
|
+
text: truncated ? `${safeText.slice(0, USER_TEXT_MAX)}...` : safeText,
|
|
211
|
+
fullText: truncated ? safeText : null,
|
|
212
|
+
timestamp,
|
|
213
|
+
...(label && { systemLabel: label })
|
|
214
|
+
};
|
|
215
|
+
// Tag task-notifications so the client can group the duplicate enqueue+delivered
|
|
216
|
+
// pair (same taskId) and join the agent type from the agents list.
|
|
217
|
+
if (notif) {
|
|
218
|
+
msg.taskNotification = true;
|
|
219
|
+
if (notif.taskId) msg.taskId = notif.taskId;
|
|
220
|
+
if (notif.status) msg.taskStatus = notif.status;
|
|
221
|
+
}
|
|
222
|
+
if (agentMsg) {
|
|
223
|
+
msg.agentMessage = true;
|
|
224
|
+
if (agentMsg.from) msg.agentFrom = agentMsg.from;
|
|
225
|
+
}
|
|
226
|
+
if (extras) {
|
|
227
|
+
if (extras.uuid) msg.uuid = extras.uuid;
|
|
228
|
+
if (extras.images?.length) msg.images = extras.images;
|
|
229
|
+
if (extras.toolResultRefs?.length) msg.toolResultRefs = extras.toolResultRefs;
|
|
230
|
+
if (extras.queued) msg.queued = true;
|
|
231
|
+
}
|
|
232
|
+
// One agent message reaches the log twice: the queued enqueue record and the
|
|
233
|
+
// delivered one right after it. Keep the first and drop the twin here, so no
|
|
234
|
+
// renderer has to know about the double write.
|
|
235
|
+
if (agentMsg) {
|
|
236
|
+
const prev = messages[messages.length - 1];
|
|
237
|
+
if (prev?.agentMessage && (prev.agentFrom || null) === (msg.agentFrom || null) && prev.text === msg.text) {
|
|
238
|
+
if (!msg.queued) delete prev.queued;
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
messages.push(msg);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
let sessionCacheDirty = false;
|
|
246
|
+
|
|
247
|
+
// Entry keys: jsonlPath. Values carry ino/mtimeMs/scannedUpTo so lib/session-cache.js can
|
|
248
|
+
// persist them; `v` drops entries an older build wrote in another shape.
|
|
249
|
+
const sessionInfoCache = new Map();
|
|
250
|
+
const SESSION_INFO_CACHE_MAX = 2000;
|
|
251
|
+
const SESSION_INFO_VERSION = 'pi-7';
|
|
252
|
+
|
|
253
|
+
const isMainReply = (data) =>
|
|
254
|
+
data.type === 'assistant' && !data.isSidechain && data.message?.usage && data.message.model !== '<synthetic>';
|
|
255
|
+
|
|
256
|
+
// Copy of displayName in the plugin's hooks/context.ts; Node cannot import the mod.
|
|
257
|
+
function modelDisplayName(model) {
|
|
258
|
+
const m = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2})(?!\d))?/.exec(model || '');
|
|
259
|
+
if (!m) return model || null;
|
|
260
|
+
return `${m[1][0].toUpperCase()}${m[1].slice(1)} ${m[3] ? `${m[2]}.${m[3]}` : m[2]}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const replyOf = (data) => {
|
|
264
|
+
const u = data.message.usage;
|
|
265
|
+
return {
|
|
266
|
+
model: data.message.model || null,
|
|
267
|
+
provider: data.message.provider || null,
|
|
268
|
+
usage: {
|
|
269
|
+
input_tokens: u.input_tokens || 0,
|
|
270
|
+
cache_read_input_tokens: u.cache_read_input_tokens || 0,
|
|
271
|
+
cache_creation_input_tokens: u.cache_creation_input_tokens || 0,
|
|
272
|
+
output_tokens: u.output_tokens || 0,
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
};
|
|
276
|
+
// After a compaction the old usage overstates what the next request writes.
|
|
277
|
+
const withoutUsage = (reply) => reply && { ...reply, usage: null };
|
|
278
|
+
|
|
279
|
+
const FIRST_PROMPT_MAX = 500;
|
|
280
|
+
|
|
281
|
+
// The first thing the user typed: a tool result, a labeled line (shell command, extension
|
|
282
|
+
// message) or a compaction summary does not count.
|
|
283
|
+
function firstUserText(lines) {
|
|
284
|
+
for (const l of lines) {
|
|
285
|
+
if (l.type !== 'user' || l.systemLabel || l.isCompactSummary) continue;
|
|
286
|
+
const text = contentText(l.message?.content);
|
|
287
|
+
if (text.trim()) return text;
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const TITLE_MAX = 60;
|
|
293
|
+
|
|
294
|
+
function titleOf(prompt) {
|
|
295
|
+
if (!prompt) return null;
|
|
296
|
+
const line = oneLine(prompt.split('\n', 1)[0]);
|
|
297
|
+
return line.length > TITLE_MAX ? `${line.slice(0, TITLE_MAX - 1).trimEnd()}…` : line;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Every reply was billed, so the totals count all branches, not only the active one.
|
|
301
|
+
function sessionTotals(header, entries) {
|
|
302
|
+
const replies = entries.filter((e) => e.type === 'message' && e.message?.role === 'assistant');
|
|
303
|
+
if (!replies.length) return null;
|
|
304
|
+
const t = { costUsd: 0, input: 0, output: 0, durationMs: null };
|
|
305
|
+
for (const { message } of replies) {
|
|
306
|
+
const u = message.usage || {};
|
|
307
|
+
t.costUsd += u.cost?.total || 0;
|
|
308
|
+
t.input += u.input || 0;
|
|
309
|
+
t.output += u.output || 0;
|
|
310
|
+
}
|
|
311
|
+
const first = Date.parse(header.timestamp);
|
|
312
|
+
const last = Date.parse(entries.findLast((e) => e.timestamp)?.timestamp);
|
|
313
|
+
if (last >= first) t.durationMs = last - first;
|
|
314
|
+
return t;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const INFO_FIELDS = ['projectPath', 'cwd', 'customTitle', 'lastReply', 'totals', 'scratchpadDir', 'sessionId', 'firstPrompt', 'title', 'parentSessionPath', 'createdAt'];
|
|
318
|
+
const infoOf = (entry) => Object.fromEntries(INFO_FIELDS.map((k) => [k, entry?.[k] ?? null]));
|
|
319
|
+
|
|
320
|
+
// scratchpadDir comes from the kanban extension's custom entry. pi has no mid-session cd, so cwd
|
|
321
|
+
// is the header cwd. A fork names its source file in parentSessionPath.
|
|
322
|
+
function sessionInfoOf(session) {
|
|
323
|
+
const { header, entries, lines, name } = session;
|
|
324
|
+
let lastReply = null;
|
|
325
|
+
for (const data of lines) {
|
|
326
|
+
if (isMainReply(data)) lastReply = replyOf(data);
|
|
327
|
+
if (data.subtype === 'compact_boundary') lastReply = withoutUsage(lastReply);
|
|
328
|
+
}
|
|
329
|
+
const firstPrompt = firstUserText(lines)?.trim().slice(0, FIRST_PROMPT_MAX) ?? null;
|
|
330
|
+
return {
|
|
331
|
+
...infoOf(null),
|
|
332
|
+
projectPath: header.cwd || null,
|
|
333
|
+
cwd: header.cwd || null,
|
|
334
|
+
customTitle: name,
|
|
335
|
+
lastReply,
|
|
336
|
+
totals: sessionTotals(header, entries),
|
|
337
|
+
scratchpadDir: recordedPadDir(entries),
|
|
338
|
+
sessionId: header.id,
|
|
339
|
+
firstPrompt,
|
|
340
|
+
title: titleOf(firstPrompt),
|
|
341
|
+
parentSessionPath: header.parentSession || null,
|
|
342
|
+
createdAt: header.timestamp || null,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function readSessionInfoFromJsonl(jsonlPath) {
|
|
347
|
+
let stat;
|
|
348
|
+
try {
|
|
349
|
+
stat = statSync(jsonlPath);
|
|
350
|
+
} catch (_) {
|
|
351
|
+
return infoOf(null);
|
|
352
|
+
}
|
|
353
|
+
const cached = sessionInfoCache.get(jsonlPath);
|
|
354
|
+
if (cached?.v === SESSION_INFO_VERSION && cached.ino === stat.ino && cached.scannedUpTo === stat.size && cached.mtimeMs === stat.mtimeMs) {
|
|
355
|
+
return infoOf(cached);
|
|
356
|
+
}
|
|
357
|
+
const session = readSession(jsonlPath, { retain: false });
|
|
358
|
+
const info = session ? sessionInfoOf(session) : infoOf(null);
|
|
359
|
+
sessionInfoCache.delete(jsonlPath);
|
|
360
|
+
sessionInfoCache.set(jsonlPath, {
|
|
361
|
+
v: SESSION_INFO_VERSION,
|
|
362
|
+
ino: stat.ino,
|
|
363
|
+
mtimeMs: stat.mtimeMs,
|
|
364
|
+
scannedUpTo: stat.size,
|
|
365
|
+
...info,
|
|
366
|
+
});
|
|
367
|
+
if (sessionInfoCache.size > SESSION_INFO_CACHE_MAX) sessionInfoCache.delete(sessionInfoCache.keys().next().value);
|
|
368
|
+
sessionCacheDirty = true;
|
|
369
|
+
return info;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Cache: jsonlPath -> { ino, size, mtimeMs }, the transcript as it last grew.
|
|
373
|
+
// Claude Code, `claude --resume` and backup tools set a transcript's mtime without writing
|
|
374
|
+
// to it (#50), so only a file that grew, shrank or was replaced counts as activity.
|
|
375
|
+
// When a session exits, Claude Code appends bookkeeping lines (cost-state, last-prompt,
|
|
376
|
+
// mode, ai-title ...) with no `timestamp`; a write of only such lines is not activity either.
|
|
377
|
+
const logActivityCache = new Map();
|
|
378
|
+
const BOOKKEEPING_APPEND_MAX = 64 * 1024;
|
|
379
|
+
|
|
380
|
+
function isBookkeepingAppend(jsonlPath, from, to) {
|
|
381
|
+
if (to - from > BOOKKEEPING_APPEND_MAX || from < 1) return false;
|
|
382
|
+
// Reads from one byte early, so the append is known to start on a line boundary.
|
|
383
|
+
const buf = Buffer.allocUnsafe(to - from + 1);
|
|
384
|
+
try {
|
|
385
|
+
const fd = fs.openSync(jsonlPath, 'r');
|
|
386
|
+
try { fs.readSync(fd, buf, 0, buf.length, from - 1); } finally { fs.closeSync(fd); }
|
|
387
|
+
} catch { return false; }
|
|
388
|
+
const text = buf.toString('utf8');
|
|
389
|
+
if (text[0] !== '\n' || !text.endsWith('\n') || text.includes('"timestamp":')) return false;
|
|
390
|
+
return text.slice(1, -1).split('\n').every((line) => {
|
|
391
|
+
if (!line) return true;
|
|
392
|
+
try {
|
|
393
|
+
const obj = JSON.parse(line);
|
|
394
|
+
return obj !== null && typeof obj === 'object';
|
|
395
|
+
} catch { return false; }
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function transcriptActivityMs(jsonlPath, stat) {
|
|
400
|
+
const prev = logActivityCache.get(jsonlPath);
|
|
401
|
+
const sameFile = prev && prev.ino === stat.ino;
|
|
402
|
+
if (sameFile && prev.size === stat.size) return prev.mtimeMs;
|
|
403
|
+
const quiet = sameFile && stat.size > prev.size && isBookkeepingAppend(jsonlPath, prev.size, stat.size);
|
|
404
|
+
const mtimeMs = quiet ? prev.mtimeMs : stat.mtimeMs;
|
|
405
|
+
logActivityCache.delete(jsonlPath);
|
|
406
|
+
logActivityCache.set(jsonlPath, { ino: stat.ino, size: stat.size, mtimeMs });
|
|
407
|
+
if (logActivityCache.size > SESSION_INFO_CACHE_MAX) logActivityCache.delete(logActivityCache.keys().next().value);
|
|
408
|
+
sessionCacheDirty = true;
|
|
409
|
+
return mtimeMs;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const sessionCachesDirty = () => sessionCacheDirty;
|
|
413
|
+
|
|
414
|
+
// Snapshot of the per-transcript caches for lib/session-cache.js. Entries of deleted
|
|
415
|
+
// transcripts are left out, so the snapshot never holds more than the live set.
|
|
416
|
+
function exportSessionCaches() {
|
|
417
|
+
sessionCacheDirty = false;
|
|
418
|
+
const livePaths = new Set([...sessionInfoCache.keys(), ...logActivityCache.keys()]);
|
|
419
|
+
for (const p of livePaths) if (!fs.existsSync(p)) livePaths.delete(p);
|
|
420
|
+
const live = (map) => [...map].filter(([p]) => livePaths.has(p));
|
|
421
|
+
return { info: live(sessionInfoCache), activity: live(logActivityCache) };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function importSessionCaches(snapshot) {
|
|
425
|
+
const num = (v) => typeof v === 'number';
|
|
426
|
+
const scanned = (e) => num(e.ino) && num(e.scannedUpTo) && num(e.mtimeMs);
|
|
427
|
+
const grown = (e) => num(e.ino) && num(e.size) && num(e.mtimeMs);
|
|
428
|
+
const load = (map, entries, valid) => {
|
|
429
|
+
if (!Array.isArray(entries)) return;
|
|
430
|
+
for (const pair of entries) {
|
|
431
|
+
if (!Array.isArray(pair) || typeof pair[0] !== 'string' || !pair[1] || typeof pair[1] !== 'object' || !valid(pair[1])) continue;
|
|
432
|
+
map.set(pair[0], pair[1]);
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
load(sessionInfoCache, snapshot?.info?.slice(-SESSION_INFO_CACHE_MAX), scanned);
|
|
436
|
+
load(logActivityCache, snapshot?.activity?.slice(-SESSION_INFO_CACHE_MAX), grown);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Background-task completion records arrive as a user message whose content is a
|
|
440
|
+
// <task-notification> envelope. Rendering the raw text leaks the tag values as a
|
|
441
|
+
// run-on line (task-id + tool-use-id + output-file path) plus the <usage> numbers
|
|
442
|
+
// mashed together (subagent_tokens|tool_uses|duration_ms with tags stripped).
|
|
443
|
+
// Parse the envelope into structured fields so the UI can show just the agent's
|
|
444
|
+
// result and a clean metadata line instead of the raw wrapper. Returns null when
|
|
445
|
+
// the text is not a task-notification.
|
|
446
|
+
function parseTaskNotification(text) {
|
|
447
|
+
if (typeof text !== 'string' || !text.includes('<task-notification>')) return null;
|
|
448
|
+
// Metadata fields are machine-generated single-line values that appear before
|
|
449
|
+
// the free-form <result>, so first-match non-greedy is safe for them.
|
|
450
|
+
const pick = (tag) => {
|
|
451
|
+
const m = text.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`));
|
|
452
|
+
return m ? m[1].trim() : null;
|
|
453
|
+
};
|
|
454
|
+
// <result> and <usage> carry UNescaped agent text that can itself contain
|
|
455
|
+
// literal "</result>" / "<usage>" (e.g. an agent describing this very format).
|
|
456
|
+
// The real closing tags are always the LAST occurrence, so match greedily and
|
|
457
|
+
// take the last <usage> block to avoid truncating on embedded markers.
|
|
458
|
+
const lastBlock = (tag) => {
|
|
459
|
+
const m = text.match(new RegExp(`<${tag}>([\\s\\S]*)<\\/${tag}>`));
|
|
460
|
+
return m ? m[1] : null;
|
|
461
|
+
};
|
|
462
|
+
let usage = null;
|
|
463
|
+
const usageRaw = lastBlock('usage');
|
|
464
|
+
if (usageRaw != null) {
|
|
465
|
+
const uNum = (tag) => {
|
|
466
|
+
// Take the LAST match: the real <usage> wins over any example block an agent
|
|
467
|
+
// embedded in its result.
|
|
468
|
+
const last = [...usageRaw.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'g'))].at(-1);
|
|
469
|
+
if (!last) return null;
|
|
470
|
+
const n = Number(last[1]);
|
|
471
|
+
return Number.isFinite(n) ? n : null;
|
|
472
|
+
};
|
|
473
|
+
usage = {
|
|
474
|
+
subagentTokens: uNum('subagent_tokens'),
|
|
475
|
+
toolUses: uNum('tool_uses'),
|
|
476
|
+
durationMs: uNum('duration_ms')
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
const resultRaw = lastBlock('result');
|
|
480
|
+
return {
|
|
481
|
+
taskId: pick('task-id'),
|
|
482
|
+
toolUseId: pick('tool-use-id'),
|
|
483
|
+
outputFile: pick('output-file'),
|
|
484
|
+
status: pick('status'),
|
|
485
|
+
summary: pick('summary'),
|
|
486
|
+
result: resultRaw != null ? resultRaw.trim() : null,
|
|
487
|
+
usage
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// A message another agent sent into this session (a subagent hand-back, a teammate
|
|
492
|
+
// or a peer session using SendMessage) is model output, not user input. Turn the
|
|
493
|
+
// sender and the raw body into the chip label plus the body alone, so it never
|
|
494
|
+
// renders under a person icon.
|
|
495
|
+
function agentMessageParts(from, rawBody, handback) {
|
|
496
|
+
let body = (rawBody || '').trimEnd();
|
|
497
|
+
// A hand-back frames the report with the harness note that it carries no user
|
|
498
|
+
// authority, and indents every line of the report by two spaces so a forged
|
|
499
|
+
// frame at column zero is impossible. Strip the frame and that indent.
|
|
500
|
+
const framed = body.match(/^\[Subagent hand-back\][\s\S]*?report follows:\n([\s\S]*)$/);
|
|
501
|
+
if (framed) body = framed[1].replace(/^ {2}/gm, '').trim();
|
|
502
|
+
const handBack = !!framed || !!handback;
|
|
503
|
+
const kind = handBack ? 'Subagent hand-back' : 'Agent message';
|
|
504
|
+
// Harness agent ids are long hex; a named sender ("code-review") is shown whole.
|
|
505
|
+
const short = from && /^[0-9a-f]{12,}$/i.test(from) ? from.slice(0, 8) : from;
|
|
506
|
+
return { from: from || null, handBack, label: short ? `${kind} · ${short}` : kind, body };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// The text delivery paths carry the body inside an <agent-message from="..."> envelope,
|
|
510
|
+
// with the harness notes outside it. The envelope must open the message (after the
|
|
511
|
+
// harness preamble, if any): prose that merely quotes the tag is a user message.
|
|
512
|
+
// The body is unescaped agent text that can quote the envelope it describes, so the
|
|
513
|
+
// real closing tag is the last one.
|
|
514
|
+
function parseAgentMessage(text) {
|
|
515
|
+
if (typeof text !== 'string' || !text.includes('<agent-message')) return null;
|
|
516
|
+
const start = text.indexOf('<agent-message');
|
|
517
|
+
const preamble = text.slice(0, start).trim();
|
|
518
|
+
if (preamble && !/^Another Claude session sent a message[^\n]*:$/.test(preamble)) return null;
|
|
519
|
+
const gt = text.indexOf('>', start);
|
|
520
|
+
const end = text.lastIndexOf('</agent-message>');
|
|
521
|
+
if (gt === -1 || end < gt) return null;
|
|
522
|
+
const from = (text.slice(start, gt).match(/from="([^"]*)"/) || [])[1] || null;
|
|
523
|
+
let bodyStart = gt + 1;
|
|
524
|
+
while (text[bodyStart] === '\n') bodyStart++;
|
|
525
|
+
return agentMessageParts(from, text.slice(bodyStart, end));
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function getSystemMessageLabel(text) {
|
|
529
|
+
const taskMatch = text.match(/<summary>([^<]+)<\/summary>/);
|
|
530
|
+
if (taskMatch) return taskMatch[1].trim();
|
|
531
|
+
if (text.includes('<task-notification>')) {
|
|
532
|
+
const statusMatch = text.match(/<status>([^<]+)<\/status>/);
|
|
533
|
+
return statusMatch ? `Background task ${statusMatch[1]}` : 'Background task notification';
|
|
534
|
+
}
|
|
535
|
+
// The "Compacted (ctrl+o…)" stdout echo is redundant with the isCompactSummary
|
|
536
|
+
// chip (modern Claude Code stores the summary inline). Skipping it lets the one
|
|
537
|
+
// summary-bearing chip stand alone instead of trailing a bare marker that the
|
|
538
|
+
// resume prompt sorts away from, preventing collapse.
|
|
539
|
+
if (text.includes('<local-command-stdout>') && text.includes('Compacted')) return '__skip__';
|
|
540
|
+
if (text.includes('<local-command-stdout>')) return 'Command output';
|
|
541
|
+
if (text.includes('<local-command-caveat>')) return 'System notification';
|
|
542
|
+
if (text.includes('.output completed') && text.includes('Background command')) return 'Background task completed';
|
|
543
|
+
if (text.startsWith('This session is being continued from a previous conversation')) return '__skip__';
|
|
544
|
+
if (text.includes('<command-name>/clear</command-name>')) return '__skip__';
|
|
545
|
+
// The /compact trigger record is redundant: the boundary stdout + isCompactSummary
|
|
546
|
+
// record already render as one expandable "Compacted" chip. Skip it so the chip
|
|
547
|
+
// collapses cleanly instead of leaving a separate marker where the user typed it.
|
|
548
|
+
if (text.includes('<command-name>/compact</command-name>')) return '__skip__';
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Built once per parse of the session (the lines array is replaced when the file changes);
|
|
553
|
+
// callers get shallow copies because some of them add fields to the messages.
|
|
554
|
+
const recentMessagesMemo = new WeakMap();
|
|
555
|
+
|
|
556
|
+
function readRecentMessages(jsonlPath, limit = 10) {
|
|
557
|
+
try {
|
|
558
|
+
const lines = readClaudeLines(jsonlPath);
|
|
559
|
+
let all = recentMessagesMemo.get(lines);
|
|
560
|
+
if (!all) {
|
|
561
|
+
all = buildMessages(lines);
|
|
562
|
+
recentMessagesMemo.set(lines, all);
|
|
563
|
+
}
|
|
564
|
+
return all.slice(-limit).map((m) => ({ ...m }));
|
|
565
|
+
} catch {
|
|
566
|
+
return [];
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function buildMessages(lines) {
|
|
571
|
+
const messages = [];
|
|
572
|
+
const toolResults = new Map();
|
|
573
|
+
const toolResultExtras = new Map();
|
|
574
|
+
const toolResultImageCounts = new Map();
|
|
575
|
+
const toolErrors = new Set();
|
|
576
|
+
for (const obj of lines) {
|
|
577
|
+
try {
|
|
578
|
+
if (obj.type === 'assistant' && obj.message?.content && Array.isArray(obj.message.content)) {
|
|
579
|
+
for (const block of obj.message.content) {
|
|
580
|
+
if (block.type === 'text' && block.text) {
|
|
581
|
+
const truncated = block.text.length > 500;
|
|
582
|
+
messages.push({
|
|
583
|
+
type: 'assistant',
|
|
584
|
+
text: truncated ? `${block.text.slice(0, 500)}...` : block.text,
|
|
585
|
+
fullText: truncated ? block.text : null,
|
|
586
|
+
timestamp: obj.timestamp,
|
|
587
|
+
model: obj.message.model || null
|
|
588
|
+
});
|
|
589
|
+
} else if (block.type === 'tool_use') {
|
|
590
|
+
let detail = null;
|
|
591
|
+
let fullDetail = null;
|
|
592
|
+
let inp = null;
|
|
593
|
+
if (block.input) {
|
|
594
|
+
inp = typeof block.input === 'string' ? (() => { try { return JSON.parse(block.input); } catch(_) { return {}; } })() : block.input;
|
|
595
|
+
if (inp.file_path) { detail = inp.file_path.replace(/^.*[/\\]/, ''); fullDetail = inp.file_path; }
|
|
596
|
+
else if (inp.command) { detail = inp.command.length > 80 ? `${inp.command.slice(0, 80)}...` : inp.command; fullDetail = inp.command; }
|
|
597
|
+
else if (inp.pattern) { detail = inp.pattern; fullDetail = inp.pattern; }
|
|
598
|
+
else if (inp.query) { detail = inp.query; fullDetail = inp.query; }
|
|
599
|
+
else if (inp.url) { detail = inp.url.length > 80 ? `${inp.url.slice(0, 80)}...` : inp.url; fullDetail = inp.url; }
|
|
600
|
+
else if (inp.skill) { const s = inp.skill + (typeof inp.args === 'string' ? ` ${inp.args}` : ''); detail = s.length > 80 ? `${s.slice(0, 80)}...` : s; fullDetail = s; }
|
|
601
|
+
else if (inp.questions && Array.isArray(inp.questions)) {
|
|
602
|
+
const parts = inp.questions.map(q => (q.header ? `${q.header}: ` : '') + q.question);
|
|
603
|
+
const s = parts.join(' | ');
|
|
604
|
+
detail = s.length > 80 ? `${s.slice(0, 80)}...` : s;
|
|
605
|
+
fullDetail = inp.questions.map(q => {
|
|
606
|
+
let text = (q.header ? `[${q.header}] ` : '') + q.question;
|
|
607
|
+
if (q.options) text += `\n\n${q.options.map((o, j) => ` ${j + 1}. ${o.label}${o.description ? ` — ${o.description}` : ''}`).join('\n')}`;
|
|
608
|
+
return text;
|
|
609
|
+
}).join('\n\n');
|
|
610
|
+
}
|
|
611
|
+
else if (inp.to) {
|
|
612
|
+
const proto = inp.message && typeof inp.message === 'object' ? inp.message : null;
|
|
613
|
+
if (proto?.type === 'shutdown_request') {
|
|
614
|
+
detail = `→ ${inp.to}: shutdown request${proto.reason ? ` (${proto.reason})` : ''}`;
|
|
615
|
+
} else if (proto?.type === 'shutdown_response') {
|
|
616
|
+
detail = `→ ${inp.to}: ${proto.approve ? 'shutdown approved' : 'shutdown rejected'}`;
|
|
617
|
+
} else {
|
|
618
|
+
detail = `→ ${inp.to}${inp.summary ? `: ${inp.summary}` : ''}`;
|
|
619
|
+
}
|
|
620
|
+
if (detail.length > 80) detail = `${detail.slice(0, 80)}...`;
|
|
621
|
+
fullDetail = typeof inp.message === 'string' ? inp.message : JSON.stringify(inp.message);
|
|
622
|
+
}
|
|
623
|
+
else if (inp.description) { detail = inp.description; fullDetail = inp.description; }
|
|
624
|
+
}
|
|
625
|
+
const params = {};
|
|
626
|
+
if (inp) {
|
|
627
|
+
if (block.name === 'Edit') {
|
|
628
|
+
if (inp.file_path) params.file_path = inp.file_path;
|
|
629
|
+
if (inp.old_string) params.old_string = inp.old_string;
|
|
630
|
+
if (inp.new_string) params.new_string = inp.new_string;
|
|
631
|
+
if (Array.isArray(inp.edits)) params.edits = inp.edits;
|
|
632
|
+
if (inp.replace_all) params.replace_all = true;
|
|
633
|
+
} else if (block.name === 'Write') {
|
|
634
|
+
if (inp.file_path) params.file_path = inp.file_path;
|
|
635
|
+
if (inp.content) {
|
|
636
|
+
if (inp.content.length > TOOL_RESULT_MAX) {
|
|
637
|
+
params.content = `${inp.content.slice(0, TOOL_RESULT_MAX)}\n... (truncated)`;
|
|
638
|
+
params.contentFull = inp.content;
|
|
639
|
+
} else {
|
|
640
|
+
params.content = inp.content;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
} else if (block.name === 'Grep') {
|
|
644
|
+
if (inp.path) params.path = inp.path;
|
|
645
|
+
if (inp.glob) params.glob = inp.glob;
|
|
646
|
+
if (inp.type) params.type = inp.type;
|
|
647
|
+
if (inp.output_mode) params.output_mode = inp.output_mode;
|
|
648
|
+
if (inp['-i']) params.case_insensitive = true;
|
|
649
|
+
if (inp['-A']) params.after = inp['-A'];
|
|
650
|
+
if (inp['-B']) params.before = inp['-B'];
|
|
651
|
+
if (inp['-C'] || inp.context) params.context = inp['-C'] || inp.context;
|
|
652
|
+
if (inp.multiline) params.multiline = true;
|
|
653
|
+
if (inp.head_limit) params.head_limit = inp.head_limit;
|
|
654
|
+
} else if (block.name === 'Glob') {
|
|
655
|
+
if (inp.path) params.path = inp.path;
|
|
656
|
+
} else if (block.name === 'Bash') {
|
|
657
|
+
if (inp.timeout) params.timeout = inp.timeout;
|
|
658
|
+
if (inp.run_in_background) params.background = true;
|
|
659
|
+
} else if (block.name === 'Read') {
|
|
660
|
+
if (inp.offset) params.offset = inp.offset;
|
|
661
|
+
if (inp.limit) params.limit = inp.limit;
|
|
662
|
+
if (inp.pages) params.pages = inp.pages;
|
|
663
|
+
} else if (block.name === 'WebFetch') {
|
|
664
|
+
if (inp.prompt) params.prompt = inp.prompt;
|
|
665
|
+
} else if (block.name === 'WebSearch') {
|
|
666
|
+
if (inp.max_results) params.max_results = inp.max_results;
|
|
667
|
+
if (inp.allowed_domains) params.allowed_domains = inp.allowed_domains.join(', ');
|
|
668
|
+
if (inp.blocked_domains) params.blocked_domains = inp.blocked_domains.join(', ');
|
|
669
|
+
} else if (block.name === 'LSP') {
|
|
670
|
+
if (inp.operation) params.operation = inp.operation;
|
|
671
|
+
if (inp.filePath) params.filePath = inp.filePath;
|
|
672
|
+
if (inp.line != null) params.line = inp.line;
|
|
673
|
+
if (inp.character != null) params.character = inp.character;
|
|
674
|
+
} else if (block.name === 'ToolSearch') {
|
|
675
|
+
if (inp.max_results) params.max_results = inp.max_results;
|
|
676
|
+
} else if (block.name === 'TaskCreate') {
|
|
677
|
+
if (inp.subject) params.subject = inp.subject;
|
|
678
|
+
} else if (block.name === 'TaskUpdate') {
|
|
679
|
+
if (inp.taskId) params.taskId = `#${inp.taskId}`;
|
|
680
|
+
if (inp.status) params.status = inp.status;
|
|
681
|
+
} else if (block.name === 'NotebookEdit') {
|
|
682
|
+
if (inp.command) params.command = inp.command;
|
|
683
|
+
if (inp.cell_type) params.cell_type = inp.cell_type;
|
|
684
|
+
} else if (block.name === 'Agent') {
|
|
685
|
+
if (inp.mode) params.mode = inp.mode;
|
|
686
|
+
if (inp.model) params.model = inp.model;
|
|
687
|
+
if (inp.run_in_background) params.background = true;
|
|
688
|
+
if (inp.isolation) params.isolation = inp.isolation;
|
|
689
|
+
} else if (block.name === 'SendMessage') {
|
|
690
|
+
if (inp.to) params.to = inp.to;
|
|
691
|
+
if (inp.summary) params.summary = inp.summary;
|
|
692
|
+
if (inp.message && typeof inp.message === 'object') {
|
|
693
|
+
params.protocol = inp.message;
|
|
694
|
+
}
|
|
695
|
+
} else {
|
|
696
|
+
// Passthrough for unknown tools (e.g. MCP `mcp__...`) so the detail panel
|
|
697
|
+
// can render args instead of "No details". Truncate large strings to bound
|
|
698
|
+
// wire/cache size, matching the Write `content` cap above.
|
|
699
|
+
for (const [k, v] of Object.entries(inp)) {
|
|
700
|
+
if (k === 'description' || v == null) continue;
|
|
701
|
+
if (typeof v === 'string' && v.length > TOOL_RESULT_MAX) {
|
|
702
|
+
params[k] = `${v.slice(0, TOOL_RESULT_MAX)}\n... (truncated)`;
|
|
703
|
+
params[`${k}Full`] = v;
|
|
704
|
+
} else {
|
|
705
|
+
params[k] = v;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
const msg = {
|
|
711
|
+
type: 'tool_use',
|
|
712
|
+
tool: block.name,
|
|
713
|
+
detail,
|
|
714
|
+
fullDetail: fullDetail !== detail ? fullDetail : null,
|
|
715
|
+
description: inp?.description || null,
|
|
716
|
+
params: Object.keys(params).length > 0 ? params : null,
|
|
717
|
+
timestamp: obj.timestamp
|
|
718
|
+
};
|
|
719
|
+
if (block.id) msg.toolUseId = block.id;
|
|
720
|
+
if (block.name === 'Agent') {
|
|
721
|
+
if (inp) {
|
|
722
|
+
msg.agentType = inp.subagent_type || null;
|
|
723
|
+
if (inp.prompt) msg.agentPrompt = inp.prompt;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
messages.push(msg);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
} else if (obj.type === 'user' && obj.origin?.kind === 'peer') {
|
|
730
|
+
// Claude Code >= 2.1 files the delivered copy as isMeta (dropped by the
|
|
731
|
+
// branch below) and repeats the envelope's fields on `origin`. That
|
|
732
|
+
// record is always written; the queued enqueue is not, so read it from
|
|
733
|
+
// here and let pushUserMessage drop whichever twin arrives second.
|
|
734
|
+
pushUserMessage(messages, '', obj.timestamp, null, {
|
|
735
|
+
uuid: obj.uuid,
|
|
736
|
+
agent: agentMessageParts(obj.origin.from, obj.origin.body, obj.origin.handback)
|
|
737
|
+
});
|
|
738
|
+
} else if (obj.type === 'user' && obj.message?.role === 'user' && !obj.isMeta) {
|
|
739
|
+
if (obj.isCompactSummary) {
|
|
740
|
+
// Surface the compaction summary as a single expandable "Compacted"
|
|
741
|
+
// chip (compactSummary), never a raw bubble. The boundary marker plus
|
|
742
|
+
// the /compact command/stdout records collapse into this one entry below.
|
|
743
|
+
const raw = typeof obj.message.content === 'string'
|
|
744
|
+
? obj.message.content
|
|
745
|
+
: Array.isArray(obj.message.content)
|
|
746
|
+
? obj.message.content.filter((b) => b.type === 'text').map((b) => b.text).join('\n')
|
|
747
|
+
: '';
|
|
748
|
+
const summary = raw
|
|
749
|
+
.replace(/^This session is being continued[^\n]*\n+(The summary below[^\n]*\n+)?/i, '')
|
|
750
|
+
.trim();
|
|
751
|
+
messages.push({
|
|
752
|
+
type: 'user',
|
|
753
|
+
systemLabel: 'Compacted',
|
|
754
|
+
compactSummary: summary || null,
|
|
755
|
+
timestamp: obj.timestamp
|
|
756
|
+
});
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
if (typeof obj.message.content === 'string') {
|
|
760
|
+
const t = obj.message.content;
|
|
761
|
+
const tmMatch = t.match(/<teammate-message\s+([^>]*)>([\s\S]*?)<\/teammate-message>/);
|
|
762
|
+
if (tmMatch) {
|
|
763
|
+
const attrs = tmMatch[1];
|
|
764
|
+
const body = tmMatch[2].trim();
|
|
765
|
+
const getAttr = (name) => (attrs.match(new RegExp(`${name}="([^"]*)"`)) || [])[1] || null;
|
|
766
|
+
const tid = getAttr('teammate_id');
|
|
767
|
+
const color = getAttr('color');
|
|
768
|
+
const summary = getAttr('summary');
|
|
769
|
+
let protocol = null;
|
|
770
|
+
try {
|
|
771
|
+
const j = JSON.parse(body);
|
|
772
|
+
if (j.type) protocol = j;
|
|
773
|
+
} catch (_) {}
|
|
774
|
+
const isIdle = protocol?.type === 'idle_notification';
|
|
775
|
+
const isProtocol = !!protocol;
|
|
776
|
+
let protocolLabel = null;
|
|
777
|
+
if (protocol) {
|
|
778
|
+
switch (protocol.type) {
|
|
779
|
+
case 'idle_notification': protocolLabel = protocol.idleReason || 'idle'; break;
|
|
780
|
+
case 'task_assignment': protocolLabel = `assigned #${protocol.taskId}: ${protocol.subject || ''}`; break;
|
|
781
|
+
case 'shutdown_request': protocolLabel = `shutdown: ${protocol.reason || 'requested'}`; break;
|
|
782
|
+
case 'shutdown_response': protocolLabel = protocol.approve ? 'shutdown approved' : `shutdown rejected: ${protocol.reason || ''}`; break;
|
|
783
|
+
case 'teammate_terminated': protocolLabel = protocol.message || 'shut down'; break;
|
|
784
|
+
default: protocolLabel = protocol.type.replace(/_/g, ' '); break;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
const truncated = !isProtocol && body.length > 500;
|
|
788
|
+
messages.push({
|
|
789
|
+
type: 'teammate',
|
|
790
|
+
teammateId: tid,
|
|
791
|
+
color,
|
|
792
|
+
summary,
|
|
793
|
+
isIdle,
|
|
794
|
+
isProtocol,
|
|
795
|
+
protocolType: protocol?.type || null,
|
|
796
|
+
protocolLabel,
|
|
797
|
+
protocolData: protocol || null,
|
|
798
|
+
text: isProtocol ? null : (truncated ? `${body.slice(0, 500)}...` : body),
|
|
799
|
+
fullText: isProtocol ? null : (truncated ? body : null),
|
|
800
|
+
timestamp: obj.timestamp
|
|
801
|
+
});
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
pushUserMessage(messages, t, obj.timestamp, obj.systemLabel || getSystemMessageLabel(t));
|
|
805
|
+
} else if (Array.isArray(obj.message.content)) {
|
|
806
|
+
const texts = [];
|
|
807
|
+
const images = [];
|
|
808
|
+
const toolResultRefs = [];
|
|
809
|
+
obj.message.content.forEach((block, idx) => {
|
|
810
|
+
if (block.type === 'text' && typeof block.text === 'string' && block.text) {
|
|
811
|
+
texts.push(block.text);
|
|
812
|
+
} else if (block.type === 'image' && block.source && block.source.type === 'base64') {
|
|
813
|
+
images.push({
|
|
814
|
+
kind: 'base64',
|
|
815
|
+
blockIndex: idx,
|
|
816
|
+
mediaType: block.source.media_type || 'image/png',
|
|
817
|
+
dataLen: typeof block.source.data === 'string' ? block.source.data.length : 0
|
|
818
|
+
});
|
|
819
|
+
} else if (block.type === 'tool_result' && block.tool_use_id) {
|
|
820
|
+
let resultText = '';
|
|
821
|
+
if (typeof block.content === 'string') {
|
|
822
|
+
resultText = block.content;
|
|
823
|
+
} else if (Array.isArray(block.content)) {
|
|
824
|
+
resultText = block.content
|
|
825
|
+
.filter(c => c.type === 'text' && c.text)
|
|
826
|
+
.map(c => c.text)
|
|
827
|
+
.join('\n');
|
|
828
|
+
// A Read on an image file (e.g. .png) returns the image as a
|
|
829
|
+
// base64 block inside the tool_result. Count them so the
|
|
830
|
+
// renderer can request each by index and show what the agent
|
|
831
|
+
// saw; the bytes are fetched lazily per image via the endpoint.
|
|
832
|
+
let imgCount = 0;
|
|
833
|
+
for (const c of block.content) {
|
|
834
|
+
if (c && c.type === 'image' && c.source && c.source.type === 'base64') imgCount++;
|
|
835
|
+
}
|
|
836
|
+
if (imgCount) toolResultImageCounts.set(block.tool_use_id, imgCount);
|
|
837
|
+
}
|
|
838
|
+
if (resultText) {
|
|
839
|
+
toolResults.set(block.tool_use_id, resultText);
|
|
840
|
+
}
|
|
841
|
+
if (block.is_error) toolErrors.add(block.tool_use_id);
|
|
842
|
+
// AskUserQuestion (and similar) stash the structured
|
|
843
|
+
// {questions, answers} payload at the line-level
|
|
844
|
+
// obj.toolUseResult — the block.content string is just a
|
|
845
|
+
// short confirmation. Capture it so the renderer can show
|
|
846
|
+
// the actual answers + option descriptions.
|
|
847
|
+
const tur = obj.toolUseResult;
|
|
848
|
+
let answerPayload = null;
|
|
849
|
+
if (tur && typeof tur === 'object' && !Array.isArray(tur)
|
|
850
|
+
&& tur.answers && typeof tur.answers === 'object') {
|
|
851
|
+
answerPayload = {
|
|
852
|
+
answers: tur.answers,
|
|
853
|
+
questions: Array.isArray(tur.questions) ? tur.questions : null,
|
|
854
|
+
};
|
|
855
|
+
toolResultExtras.set(block.tool_use_id, { answerPayload });
|
|
856
|
+
}
|
|
857
|
+
toolResultRefs.push({
|
|
858
|
+
toolUseId: block.tool_use_id,
|
|
859
|
+
preview: resultText ? resultText.slice(0, 200) : '',
|
|
860
|
+
answerPayload,
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
const joined = texts.join('\n').trim();
|
|
865
|
+
// Prefer inline base64 blocks when present; otherwise fall back to
|
|
866
|
+
// file-cache references parsed from the text markers.
|
|
867
|
+
const { refs, text: displayText } = parseImageMarkers(joined);
|
|
868
|
+
const allImages = images.length ? images : refs;
|
|
869
|
+
const hasText = displayText && displayText !== INTERRUPT_MARKER;
|
|
870
|
+
const hasImages = allImages.length > 0;
|
|
871
|
+
if (hasText || hasImages) {
|
|
872
|
+
pushUserMessage(
|
|
873
|
+
messages,
|
|
874
|
+
displayText,
|
|
875
|
+
obj.timestamp,
|
|
876
|
+
obj.systemLabel || getSystemMessageLabel(displayText),
|
|
877
|
+
{ uuid: obj.uuid, images: allImages, toolResultRefs: hasText ? toolResultRefs : [] }
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
} else if (obj.type === 'queue-operation' && obj.operation === 'enqueue') {
|
|
882
|
+
// Queued messages are stored as top-level `content`, not under
|
|
883
|
+
// message.content, and never re-emitted as a type:'user' line.
|
|
884
|
+
// Surface them so the Session Log reflects what the user sent.
|
|
885
|
+
const qt = typeof obj.content === 'string' ? obj.content : '';
|
|
886
|
+
if (qt) {
|
|
887
|
+
const { refs, text } = parseImageMarkers(qt);
|
|
888
|
+
pushUserMessage(messages, text, obj.timestamp, null, { queued: true, images: refs });
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
} catch { /* malformed entry */ }
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Attach tool results to their corresponding tool_use messages.
|
|
895
|
+
// When truncated, ship the full text inline as toolResultFull so the
|
|
896
|
+
// modal expand toggle is instant. The lazy fetch at
|
|
897
|
+
// /api/sessions/:id/tool-result/:toolUseId remains a fallback for older
|
|
898
|
+
// cached payloads that may lack toolResultFull.
|
|
899
|
+
for (const msg of messages) {
|
|
900
|
+
if (msg.type === 'tool_use' && msg.toolUseId && toolResults.has(msg.toolUseId)) {
|
|
901
|
+
const full = toolResults.get(msg.toolUseId);
|
|
902
|
+
const truncated = full.length > TOOL_RESULT_MAX;
|
|
903
|
+
if (truncated) {
|
|
904
|
+
msg.toolResult = `${full.slice(0, TOOL_RESULT_MAX)}\n... (truncated)`;
|
|
905
|
+
msg.toolResultFull = full;
|
|
906
|
+
} else {
|
|
907
|
+
msg.toolResult = full;
|
|
908
|
+
}
|
|
909
|
+
msg.toolResultTruncated = truncated;
|
|
910
|
+
}
|
|
911
|
+
if (msg.type === 'tool_use' && toolErrors.has(msg.toolUseId)) msg.toolError = true;
|
|
912
|
+
if (msg.type === 'tool_use' && msg.toolUseId && toolResultImageCounts.has(msg.toolUseId)) {
|
|
913
|
+
msg.toolResultImageCount = toolResultImageCounts.get(msg.toolUseId);
|
|
914
|
+
}
|
|
915
|
+
if (msg.type === 'tool_use' && msg.tool === 'AskUserQuestion'
|
|
916
|
+
&& msg.toolUseId && toolResultExtras.has(msg.toolUseId)) {
|
|
917
|
+
const extra = toolResultExtras.get(msg.toolUseId);
|
|
918
|
+
if (extra.answerPayload) msg.answerPayload = extra.answerPayload;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
messages.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
|
|
923
|
+
for (let i = messages.length - 1; i > 0; i--) {
|
|
924
|
+
if (messages[i].systemLabel === 'Compacted' && messages[i - 1].systemLabel === 'Compacted') {
|
|
925
|
+
// Carry the summary body onto the surviving chip so collapsing the
|
|
926
|
+
// boundary + /compact command + stdout + isCompactSummary records into
|
|
927
|
+
// one entry never drops the expandable summary.
|
|
928
|
+
if (messages[i].compactSummary && !messages[i - 1].compactSummary) {
|
|
929
|
+
messages[i - 1].compactSummary = messages[i].compactSummary;
|
|
930
|
+
}
|
|
931
|
+
messages.splice(i, 1);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
return messages;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
async function readFullToolResult(jsonlPath, toolUseId) {
|
|
938
|
+
if (!toolUseId || !jsonlPath || !existsSync(jsonlPath)) return null;
|
|
939
|
+
try {
|
|
940
|
+
for await (const line of readLines(jsonlPath)) {
|
|
941
|
+
if (!line || line.indexOf(toolUseId) === -1) continue;
|
|
942
|
+
try {
|
|
943
|
+
const obj = JSON.parse(line);
|
|
944
|
+
if (obj?.message?.content && Array.isArray(obj.message.content)) {
|
|
945
|
+
for (const block of obj.message.content) {
|
|
946
|
+
if (block.type === 'tool_result' && block.tool_use_id === toolUseId) {
|
|
947
|
+
if (typeof block.content === 'string') return block.content;
|
|
948
|
+
if (Array.isArray(block.content)) {
|
|
949
|
+
return block.content
|
|
950
|
+
.filter((c) => c.type === 'text' && c.text)
|
|
951
|
+
.map((c) => c.text)
|
|
952
|
+
.join('\n');
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
} catch (_) {}
|
|
958
|
+
}
|
|
959
|
+
} catch (_) {}
|
|
960
|
+
return null;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
async function readUserImage(jsonlPath, msgUuid, blockIndex) {
|
|
964
|
+
if (!msgUuid || !jsonlPath) return null;
|
|
965
|
+
const idx = Number(blockIndex);
|
|
966
|
+
if (!Number.isInteger(idx) || idx < 0) return null;
|
|
967
|
+
try {
|
|
968
|
+
for await (const line of readLines(jsonlPath)) {
|
|
969
|
+
if (!line || line.indexOf(msgUuid) === -1) continue;
|
|
970
|
+
try {
|
|
971
|
+
const obj = JSON.parse(line);
|
|
972
|
+
if (obj?.uuid !== msgUuid) continue;
|
|
973
|
+
if (!Array.isArray(obj?.message?.content)) continue;
|
|
974
|
+
const block = obj.message.content[idx];
|
|
975
|
+
if (!block || block.type !== 'image' || !block.source || block.source.type !== 'base64') return null;
|
|
976
|
+
return {
|
|
977
|
+
mediaType: block.source.media_type || 'image/png',
|
|
978
|
+
data: block.source.data
|
|
979
|
+
};
|
|
980
|
+
} catch (_) {}
|
|
981
|
+
}
|
|
982
|
+
} catch (_) {}
|
|
983
|
+
return null;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Read the Nth base64 image embedded in a tool_result block (e.g. the output of
|
|
987
|
+
// a Read on an image file). Scans for the tool_result matching toolUseId.
|
|
988
|
+
async function readToolResultImage(jsonlPath, toolUseId, n) {
|
|
989
|
+
if (!toolUseId || !jsonlPath) return null;
|
|
990
|
+
const idx = Number(n);
|
|
991
|
+
if (!Number.isInteger(idx) || idx < 0) return null;
|
|
992
|
+
try {
|
|
993
|
+
for await (const line of readLines(jsonlPath)) {
|
|
994
|
+
if (!line || line.indexOf(toolUseId) === -1) continue;
|
|
995
|
+
let obj;
|
|
996
|
+
try { obj = JSON.parse(line); } catch (_) { continue; }
|
|
997
|
+
const blocks = obj?.message?.content;
|
|
998
|
+
if (!Array.isArray(blocks)) continue;
|
|
999
|
+
for (const block of blocks) {
|
|
1000
|
+
if (block?.type !== 'tool_result' || block.tool_use_id !== toolUseId) continue;
|
|
1001
|
+
if (!Array.isArray(block.content)) continue;
|
|
1002
|
+
const images = block.content.filter(
|
|
1003
|
+
(c) => c && c.type === 'image' && c.source && c.source.type === 'base64',
|
|
1004
|
+
);
|
|
1005
|
+
const img = images[idx];
|
|
1006
|
+
if (!img) return null;
|
|
1007
|
+
return { mediaType: img.source.media_type || 'image/png', data: img.source.data };
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
} catch (_) {}
|
|
1011
|
+
return null;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
const CACHED_IMAGE_MIME = {
|
|
1015
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp',
|
|
1016
|
+
};
|
|
1017
|
+
|
|
1018
|
+
// Serve pasted image N from <piAgentDir>/image-cache/<sessionId>/. The cached file may
|
|
1019
|
+
// be any image format, so resolve it by index rather than assuming .png. sessionId
|
|
1020
|
+
// is validated to a bare id to prevent path traversal.
|
|
1021
|
+
function readCachedImage(sessionId, n, piAgentDir) {
|
|
1022
|
+
const idx = Number(n);
|
|
1023
|
+
if (!sessionId || !/^[A-Za-z0-9._-]+$/.test(sessionId)) return null;
|
|
1024
|
+
if (!Number.isInteger(idx) || idx < 0) return null;
|
|
1025
|
+
const baseDir = piAgentDir || path.join(os.homedir(), '.claude');
|
|
1026
|
+
const dir = path.join(baseDir, 'image-cache', sessionId);
|
|
1027
|
+
try {
|
|
1028
|
+
const match = readdirSync(dir).find((f) => {
|
|
1029
|
+
const dot = f.lastIndexOf('.');
|
|
1030
|
+
return dot > 0 && f.slice(0, dot) === String(idx) && CACHED_IMAGE_MIME[f.slice(dot + 1).toLowerCase()];
|
|
1031
|
+
});
|
|
1032
|
+
if (!match) return null;
|
|
1033
|
+
const ext = match.slice(match.lastIndexOf('.') + 1).toLowerCase();
|
|
1034
|
+
return { mediaType: CACHED_IMAGE_MIME[ext], buffer: readFileSync(path.join(dir, match)) };
|
|
1035
|
+
} catch (_) {
|
|
1036
|
+
return null;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function readMessagesPage(jsonlPath, limit = 10, beforeTimestamp = null) {
|
|
1041
|
+
const fetchLimit = limit + 1;
|
|
1042
|
+
const applyFilter = beforeTimestamp
|
|
1043
|
+
? (msgs) => msgs.filter((m) => m.timestamp && m.timestamp < beforeTimestamp)
|
|
1044
|
+
: (msgs) => msgs;
|
|
1045
|
+
let readLimit = Math.max(fetchLimit * 5, 200);
|
|
1046
|
+
let allMessages = readRecentMessages(jsonlPath, readLimit);
|
|
1047
|
+
let filtered = applyFilter(allMessages);
|
|
1048
|
+
|
|
1049
|
+
while (filtered.length < fetchLimit && allMessages.length === readLimit && readLimit < 10000) {
|
|
1050
|
+
readLimit *= 4;
|
|
1051
|
+
allMessages = readRecentMessages(jsonlPath, readLimit);
|
|
1052
|
+
filtered = applyFilter(allMessages);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
const page = filtered.slice(-fetchLimit);
|
|
1056
|
+
const hasMore = page.length > limit;
|
|
1057
|
+
return {
|
|
1058
|
+
messages: hasMore ? page.slice(1) : page,
|
|
1059
|
+
hasMore
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// A finished pi subagent call reports its runs in the tool result's details.results
|
|
1064
|
+
// (pi-subagents SingleResult). An async launch has no results; its usage never reaches
|
|
1065
|
+
// the session file. results[].task is redacted, so the prompt comes from the call.
|
|
1066
|
+
function piSubagentUsage(obj) {
|
|
1067
|
+
const results = obj?.toolUseResult?.results;
|
|
1068
|
+
const block = Array.isArray(obj?.message?.content) ? obj.message.content.find((b) => b?.type === 'tool_result') : null;
|
|
1069
|
+
if (!Array.isArray(results) || !results.length || !block?.tool_use_id) return null;
|
|
1070
|
+
const r = results[0] || {};
|
|
1071
|
+
const u = r.usage || {};
|
|
1072
|
+
const tokens = typeof u.input === 'number' || typeof u.output === 'number' ? (u.input || 0) + (u.output || 0) : null;
|
|
1073
|
+
const num = (v) => (typeof v === 'number' ? v : null);
|
|
1074
|
+
const entry = agentUsageEntry(tokens, num(r.progressSummary?.toolCount), num(r.durationMs) ?? num(r.progressSummary?.durationMs));
|
|
1075
|
+
return entry ? { toolUseId: block.tool_use_id, ...entry } : null;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
async function buildSessionDigest(jsonlPath) {
|
|
1079
|
+
const map = {};
|
|
1080
|
+
const terminated = new Map();
|
|
1081
|
+
const rejectedToolUseIds = new Set();
|
|
1082
|
+
const promptByToolUseId = {};
|
|
1083
|
+
const killedAgentIds = new Set();
|
|
1084
|
+
try {
|
|
1085
|
+
const re = /"type":"agent_progress"[^}]*"agentId":"([^"]+)"/;
|
|
1086
|
+
const parentRe = /"parentToolUseID":"([^"]+)"/;
|
|
1087
|
+
const promptRe = /"prompt":"((?:[^"\\]|\\.)*)"/;
|
|
1088
|
+
const bgToolIdRe = /"tool_use_id":"([^"]+)"/;
|
|
1089
|
+
const bgAgentIdRe = /agentId: ([a-zA-Z0-9_@-]+)/;
|
|
1090
|
+
const tmToolIdRe = /"tool_use_id":"([^"]+)"/;
|
|
1091
|
+
const tmAgentIdRe = /agent_id: ([a-zA-Z0-9_@-]+)/;
|
|
1092
|
+
const taskIdRe = /<task-id>([a-zA-Z0-9_-]+)<\/task-id>/;
|
|
1093
|
+
const nameByToolUseId = {};
|
|
1094
|
+
const descByToolUseId = {};
|
|
1095
|
+
const usageByToolUseId = {};
|
|
1096
|
+
for await (const line of readLines(jsonlPath)) {
|
|
1097
|
+
// Terminated-teammate detection: check first since cheap substring guards
|
|
1098
|
+
if (line.includes('teammate-message') &&
|
|
1099
|
+
(line.includes('teammate_terminated') || line.includes('shutdown_response'))) {
|
|
1100
|
+
try {
|
|
1101
|
+
const obj = JSON.parse(line);
|
|
1102
|
+
if (obj.type === 'user') {
|
|
1103
|
+
const text = typeof obj.message?.content === 'string' ? obj.message.content : null;
|
|
1104
|
+
if (text) {
|
|
1105
|
+
const ts = obj.timestamp || null;
|
|
1106
|
+
for (const tmMatch of text.matchAll(/<teammate-message\s+[^>]*teammate_id="([^"]+)"[^>]*>([\s\S]*?)<\/teammate-message>/g)) {
|
|
1107
|
+
try {
|
|
1108
|
+
const tid = tmMatch[1];
|
|
1109
|
+
const body = tmMatch[2].trim();
|
|
1110
|
+
const protocol = JSON.parse(body);
|
|
1111
|
+
if (protocol.type === 'teammate_terminated') {
|
|
1112
|
+
const name = protocol.from || (protocol.message?.match(/^(\S+)\s/)?.[1]) || tid;
|
|
1113
|
+
if (name !== 'system') terminated.set(name, ts);
|
|
1114
|
+
} else if (protocol.type === 'shutdown_response' && protocol.approve) {
|
|
1115
|
+
const name = protocol.from || tid;
|
|
1116
|
+
if (name !== 'system') terminated.set(name, ts);
|
|
1117
|
+
}
|
|
1118
|
+
} catch (_) {}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
} catch (_) {}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
if (line.includes('"agent_progress"')) {
|
|
1126
|
+
const agentMatch = re.exec(line);
|
|
1127
|
+
const parentMatch = parentRe.exec(line);
|
|
1128
|
+
if (agentMatch && parentMatch) {
|
|
1129
|
+
const key = parentMatch[1];
|
|
1130
|
+
if (!map[key]) {
|
|
1131
|
+
let prompt = null;
|
|
1132
|
+
const promptMatch = promptRe.exec(line);
|
|
1133
|
+
if (promptMatch?.[1]) {
|
|
1134
|
+
try { prompt = JSON.parse(`"${promptMatch[1]}"`); } catch (_) { prompt = promptMatch[1]; }
|
|
1135
|
+
}
|
|
1136
|
+
map[key] = { agentId: agentMatch[1], prompt };
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
} else if (line.includes('Async agent launched')) {
|
|
1140
|
+
const toolIdMatch = bgToolIdRe.exec(line);
|
|
1141
|
+
const bgAgentMatch = bgAgentIdRe.exec(line);
|
|
1142
|
+
if (toolIdMatch && bgAgentMatch && !map[toolIdMatch[1]]) {
|
|
1143
|
+
map[toolIdMatch[1]] = { agentId: bgAgentMatch[1], prompt: null };
|
|
1144
|
+
}
|
|
1145
|
+
} else if (line.includes('"teammate_spawned"')) {
|
|
1146
|
+
const toolIdMatch = tmToolIdRe.exec(line);
|
|
1147
|
+
const agentMatch = tmAgentIdRe.exec(line);
|
|
1148
|
+
if (toolIdMatch && agentMatch && !map[toolIdMatch[1]]) {
|
|
1149
|
+
map[toolIdMatch[1]] = { agentId: agentMatch[1], prompt: null };
|
|
1150
|
+
}
|
|
1151
|
+
} else if (line.includes('"toolUseResult"') && line.includes('"results"') && line.includes('"tool_result"')) {
|
|
1152
|
+
try {
|
|
1153
|
+
const found = piSubagentUsage(JSON.parse(line));
|
|
1154
|
+
if (found) usageByToolUseId[found.toolUseId] = { usage: found.usage, usageText: found.usageText };
|
|
1155
|
+
} catch (_) {}
|
|
1156
|
+
} else if (line.includes('"assistant"') && line.includes('"tool_use"') && line.includes('"Agent"')) {
|
|
1157
|
+
try {
|
|
1158
|
+
const obj = JSON.parse(line);
|
|
1159
|
+
const blocks = obj.message?.content;
|
|
1160
|
+
if (Array.isArray(blocks)) {
|
|
1161
|
+
for (const b of blocks) {
|
|
1162
|
+
if (b.type === 'tool_use' && b.name === 'Agent' && b.id) {
|
|
1163
|
+
if (b.input?.name) nameByToolUseId[b.id] = b.input.name;
|
|
1164
|
+
if (b.input?.description) descByToolUseId[b.id] = b.input.description;
|
|
1165
|
+
if (b.input?.prompt) promptByToolUseId[b.id] = b.input.prompt;
|
|
1166
|
+
// A status or list call names no agent; only a launch spawns one. The first run
|
|
1167
|
+
// stands for the call.
|
|
1168
|
+
if (b.input?.subagent_type && !map[b.id]) {
|
|
1169
|
+
map[b.id] = { agentId: agentIdFor(b.id, 0), prompt: b.input.prompt || null, usage: null, usageText: null };
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
} catch (_) {}
|
|
1175
|
+
} else if (line.includes('User rejected tool use') && line.includes('"tool_use_id"')) {
|
|
1176
|
+
const m = tmToolIdRe.exec(line);
|
|
1177
|
+
if (m) rejectedToolUseIds.add(m[1]);
|
|
1178
|
+
} else if (line.includes('<task-notification>')) {
|
|
1179
|
+
// The envelope's tags survive JSON escaping, so read them off the raw line.
|
|
1180
|
+
// This covers every delivery shape (user message, queue-operation,
|
|
1181
|
+
// queued_command attachment) without enumerating them.
|
|
1182
|
+
if (line.includes('<status>killed</status>') || line.includes('<status>error</status>')) {
|
|
1183
|
+
const idMatch = taskIdRe.exec(line);
|
|
1184
|
+
if (idMatch) killedAgentIds.add(idMatch[1]);
|
|
1185
|
+
}
|
|
1186
|
+
if (line.includes('<subagent_tokens>')) {
|
|
1187
|
+
const notif = parseTaskNotification(line);
|
|
1188
|
+
const u = notif?.usage;
|
|
1189
|
+
const entry = u && notif.toolUseId ? agentUsageEntry(u.subagentTokens, u.toolUses, u.durationMs) : null;
|
|
1190
|
+
if (entry) usageByToolUseId[notif.toolUseId] = entry;
|
|
1191
|
+
}
|
|
1192
|
+
} else if (line.includes('"toolUseResult"') && line.includes('"agentId"') && line.includes('"tool_result"')) {
|
|
1193
|
+
try {
|
|
1194
|
+
const obj = JSON.parse(line);
|
|
1195
|
+
const tur = obj.toolUseResult;
|
|
1196
|
+
if (tur?.agentId) {
|
|
1197
|
+
// Foreground agents report their cost on the completion toolUseResult;
|
|
1198
|
+
// background agents report the same numbers in their <task-notification>.
|
|
1199
|
+
const { usage, usageText } = agentUsageEntry(tur.totalTokens, tur.totalToolUseCount, tur.totalDurationMs)
|
|
1200
|
+
|| { usage: null, usageText: null };
|
|
1201
|
+
const blocks = obj.message?.content;
|
|
1202
|
+
if (Array.isArray(blocks)) {
|
|
1203
|
+
for (const b of blocks) {
|
|
1204
|
+
if (b.type === 'tool_result' && b.tool_use_id && !map[b.tool_use_id]) {
|
|
1205
|
+
map[b.tool_use_id] = { agentId: tur.agentId, prompt: tur.prompt || null, usage, usageText };
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
} catch (_) {}
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
for (const [key, entry] of Object.entries(map)) {
|
|
1214
|
+
if (!entry.usage && usageByToolUseId[key]) Object.assign(entry, usageByToolUseId[key]);
|
|
1215
|
+
if (nameByToolUseId[key]) entry.name = nameByToolUseId[key];
|
|
1216
|
+
if (descByToolUseId[key]) entry.description = descByToolUseId[key];
|
|
1217
|
+
// Prefer the full prompt from the assistant's tool_use input — Claude Code's
|
|
1218
|
+
// agent_progress system messages embed a truncated prompt that ends mid-sentence.
|
|
1219
|
+
// Only override when entry already had a prompt (i.e. agent_progress path);
|
|
1220
|
+
// bg/teammate paths intentionally keep prompt null per existing contract.
|
|
1221
|
+
if (entry.prompt && promptByToolUseId[key] && promptByToolUseId[key].length > entry.prompt.length) {
|
|
1222
|
+
entry.prompt = promptByToolUseId[key];
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
} catch (_) {}
|
|
1226
|
+
const rejectedAgentIds = new Set();
|
|
1227
|
+
const rejectedPrompts = new Set();
|
|
1228
|
+
for (const toolUseId of rejectedToolUseIds) {
|
|
1229
|
+
const entry = map[toolUseId];
|
|
1230
|
+
if (entry?.agentId) rejectedAgentIds.add(entry.agentId);
|
|
1231
|
+
const prompt = entry?.prompt || promptByToolUseId[toolUseId];
|
|
1232
|
+
if (prompt) rejectedPrompts.add(prompt);
|
|
1233
|
+
}
|
|
1234
|
+
return { progressMap: map, terminated, rejectedAgentIds, rejectedPrompts, killedAgentIds };
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
async function buildAgentProgressMap(jsonlPath) {
|
|
1238
|
+
return (await buildSessionDigest(jsonlPath)).progressMap;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
const ARTIFACT_URL_RE = /https:\/\/claude\.ai\/\S*artifact\/[A-Za-z0-9_-]+/;
|
|
1242
|
+
|
|
1243
|
+
// Two kinds of line record a published artifact: the `frame-link` entry the
|
|
1244
|
+
// viewer writes, which carries the title, and the result of an Artifact publish,
|
|
1245
|
+
// which is there even when the artifact was never opened in a frame. Keyed by URL,
|
|
1246
|
+
// so the republishes of one page collapse into a single entry. A result counts
|
|
1247
|
+
// only when its tool_use_id belongs to a publish call — an artifact URL quoted
|
|
1248
|
+
// back by a grep or a file read would otherwise register another session's page as
|
|
1249
|
+
// this session's own, and a `read` of someone else's artifact is not a publish.
|
|
1250
|
+
async function readArtifactLinks(jsonlPath) {
|
|
1251
|
+
const byUrl = new Map();
|
|
1252
|
+
const publishedFileByToolUseId = new Map();
|
|
1253
|
+
const upsert = (url, ts) => {
|
|
1254
|
+
let a = byUrl.get(url);
|
|
1255
|
+
if (!a) {
|
|
1256
|
+
a = { url, title: null, path: null, firstSeen: ts || null };
|
|
1257
|
+
byUrl.set(url, a);
|
|
1258
|
+
}
|
|
1259
|
+
if (ts && (!a.firstSeen || ts < a.firstSeen)) a.firstSeen = ts;
|
|
1260
|
+
return a;
|
|
1261
|
+
};
|
|
1262
|
+
try {
|
|
1263
|
+
for await (const line of readLines(jsonlPath)) {
|
|
1264
|
+
if (!line.includes('"frame-link"') && !line.includes('"name":"Artifact"') && !line.includes('claude.ai'))
|
|
1265
|
+
continue;
|
|
1266
|
+
let obj;
|
|
1267
|
+
try { obj = JSON.parse(line); } catch (_) { continue; }
|
|
1268
|
+
if (obj.type === 'frame-link' && obj.frameUrl) {
|
|
1269
|
+
const a = upsert(obj.frameUrl, obj.timestamp);
|
|
1270
|
+
if (obj.title) a.title = obj.title;
|
|
1271
|
+
if (obj.path) a.path = obj.path;
|
|
1272
|
+
continue;
|
|
1273
|
+
}
|
|
1274
|
+
const blocks = obj?.message?.content;
|
|
1275
|
+
if (!Array.isArray(blocks)) continue;
|
|
1276
|
+
for (const b of blocks) {
|
|
1277
|
+
if (b?.type === 'tool_use') {
|
|
1278
|
+
const input = b.name === 'Artifact' ? b.input || {} : null;
|
|
1279
|
+
// The publish action is the default one, so it is spelled by the file it
|
|
1280
|
+
// takes rather than by a name.
|
|
1281
|
+
if (b.id && input?.file_path && (!input.action || input.action === 'publish')) {
|
|
1282
|
+
publishedFileByToolUseId.set(b.id, input.file_path);
|
|
1283
|
+
}
|
|
1284
|
+
continue;
|
|
1285
|
+
}
|
|
1286
|
+
if (b?.type !== 'tool_result' || !publishedFileByToolUseId.has(b.tool_use_id)) continue;
|
|
1287
|
+
const m = contentText(b.content).match(ARTIFACT_URL_RE);
|
|
1288
|
+
if (!m) continue;
|
|
1289
|
+
const a = upsert(m[0], obj.timestamp);
|
|
1290
|
+
if (!a.path) a.path = publishedFileByToolUseId.get(b.tool_use_id);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
} catch (_) {}
|
|
1294
|
+
return [...byUrl.values()].sort((x, y) => (x.firstSeen < y.firstSeen ? -1 : x.firstSeen > y.firstSeen ? 1 : 0));
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
const SCRATCH_NEW_RE = /\bscratch new\b/;
|
|
1298
|
+
// `scratch new` reports where it put the pad; the two spaces are the CLI's own column
|
|
1299
|
+
// padding, which is what makes the prefilter below narrow enough to be worth having.
|
|
1300
|
+
const SCRATCH_MANIFEST_RE = /^\s*manifest\s*:\s*(.+?)\s*$/m;
|
|
1301
|
+
// The name must sit immediately after `new`, and the bare-word branch must refuse a
|
|
1302
|
+
// leading `-`, so that a flag or a flag's value cannot be read as the name.
|
|
1303
|
+
const SCRATCH_NAME_RE = /\bscratch\s+new\s+(?:"([^"]+)"|'([^']+)'|(?!-)(\S+))/;
|
|
1304
|
+
|
|
1305
|
+
// The `scratch new` calls in a transcript, `{ ts, path, name }` per call. `path` is null
|
|
1306
|
+
// whenever the command piped the CLI's output away — the common case, not an edge one:
|
|
1307
|
+
// the manifest is the 4th of ~13 printed lines, so any `| tail -N` cuts it off. `name`
|
|
1308
|
+
// stands in because it is what the manifest stores as its own `name`; the path is not
|
|
1309
|
+
// recovered from the command, which would mean tracking the `cd` and a relative `--dir`.
|
|
1310
|
+
async function readScratchpadCreations(jsonlPath) {
|
|
1311
|
+
const calls = new Map();
|
|
1312
|
+
try {
|
|
1313
|
+
for await (const line of readLines(jsonlPath)) {
|
|
1314
|
+
// Substring reject before JSON.parse: transcripts reach tens of megabytes and
|
|
1315
|
+
// only a handful of lines can ever match.
|
|
1316
|
+
const isCall = line.includes('scratch new');
|
|
1317
|
+
if (!isCall && !line.includes('manifest :')) continue;
|
|
1318
|
+
let obj;
|
|
1319
|
+
try { obj = JSON.parse(line); } catch (_) { continue; }
|
|
1320
|
+
const blocks = obj?.message?.content;
|
|
1321
|
+
if (!Array.isArray(blocks)) continue;
|
|
1322
|
+
const ts = Date.parse(obj.timestamp);
|
|
1323
|
+
for (const b of blocks) {
|
|
1324
|
+
if (b?.type === 'tool_use' && b.name === 'Bash' && SCRATCH_NEW_RE.test(b.input?.command || '')) {
|
|
1325
|
+
if (b.id && Number.isFinite(ts)) {
|
|
1326
|
+
const named = SCRATCH_NAME_RE.exec(b.input.command);
|
|
1327
|
+
calls.set(b.id, { ts, path: null, name: named?.slice(1).find(Boolean) ?? null });
|
|
1328
|
+
}
|
|
1329
|
+
} else if (b?.type === 'tool_result' && calls.has(b.tool_use_id)) {
|
|
1330
|
+
const m = SCRATCH_MANIFEST_RE.exec(contentText(b.content));
|
|
1331
|
+
if (m) calls.get(b.tool_use_id).path = m[1];
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
} catch (_) {}
|
|
1336
|
+
return [...calls.values()];
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
async function findTerminatedTeammates(jsonlPath) {
|
|
1340
|
+
return (await buildSessionDigest(jsonlPath)).terminated;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// The first prompt of the active branch. Throws for a missing path; callers rely on that.
|
|
1344
|
+
function extractPromptFromTranscript(jsonlPath) {
|
|
1345
|
+
statSync(jsonlPath);
|
|
1346
|
+
return firstUserText(readClaudeLines(jsonlPath, { retain: false }));
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// The model of the first reply, or of the first model_change before any reply.
|
|
1350
|
+
// Throws for a missing path.
|
|
1351
|
+
function extractModelFromTranscript(jsonlPath) {
|
|
1352
|
+
statSync(jsonlPath);
|
|
1353
|
+
for (const l of readClaudeLines(jsonlPath, { retain: false })) {
|
|
1354
|
+
const model = l.model || l.message?.model;
|
|
1355
|
+
if (model) return model;
|
|
1356
|
+
}
|
|
1357
|
+
return null;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// Render a StructuredOutput tool input (arbitrary schema object) as markdown:
|
|
1361
|
+
// one section per field, prose kept verbatim, structured values as JSON blocks.
|
|
1362
|
+
function formatStructuredResult(input) {
|
|
1363
|
+
const parts = [];
|
|
1364
|
+
for (const [key, value] of Object.entries(input)) {
|
|
1365
|
+
if (value == null || value === '') continue;
|
|
1366
|
+
parts.push(`### ${key}`);
|
|
1367
|
+
parts.push(typeof value === 'string' ? value : `\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``);
|
|
1368
|
+
}
|
|
1369
|
+
return parts.length ? parts.join('\n\n') : null;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// Some subagents end their run on a tool call and never emit a text response, so
|
|
1373
|
+
// their lastMessage is empty: workflow subagents given a schema call StructuredOutput,
|
|
1374
|
+
// background subagents report through SubagentHandback. Tail-read the transcript and
|
|
1375
|
+
// take the LAST such tool_use as the agent's result. Without one, take the last
|
|
1376
|
+
// assistant text: the plugin's mod records no lastMessage, because Claude Code's
|
|
1377
|
+
// turn.complete carries an empty answer for a subagent. Returns null when neither exists.
|
|
1378
|
+
const RESULT_TOOLS = {
|
|
1379
|
+
StructuredOutput: formatStructuredResult,
|
|
1380
|
+
SubagentHandback: (input) => (typeof input.message === 'string' && input.message) || null,
|
|
1381
|
+
};
|
|
1382
|
+
function resultFromBlock(b) {
|
|
1383
|
+
if (b?.type !== 'tool_use' || !b.input || typeof b.input !== 'object') return null;
|
|
1384
|
+
return RESULT_TOOLS[b.name]?.(b.input) ?? null;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
function extractAgentResultFromTranscript(jsonlPath) {
|
|
1388
|
+
statSync(jsonlPath);
|
|
1389
|
+
const replies = readClaudeLines(jsonlPath, { retain: false })
|
|
1390
|
+
.filter((l) => l.type === 'assistant' && Array.isArray(l.message?.content))
|
|
1391
|
+
.map((l) => l.message.content);
|
|
1392
|
+
const lastFound = (pick) => {
|
|
1393
|
+
for (let i = replies.length - 1; i >= 0; i--) {
|
|
1394
|
+
const found = pick(replies[i]);
|
|
1395
|
+
if (found) return found;
|
|
1396
|
+
}
|
|
1397
|
+
return null;
|
|
1398
|
+
};
|
|
1399
|
+
return lastFound((content) => content.map(resultFromBlock).findLast(Boolean))
|
|
1400
|
+
?? lastFound((content) => contentText(content).trim());
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
module.exports = {
|
|
1404
|
+
modelDisplayName,
|
|
1405
|
+
parseTask,
|
|
1406
|
+
parseAgent,
|
|
1407
|
+
parseWaiting,
|
|
1408
|
+
parseTeamConfig,
|
|
1409
|
+
parseSessionsIndex,
|
|
1410
|
+
parseJsonlLine,
|
|
1411
|
+
parseTaskNotification,
|
|
1412
|
+
parseAgentMessage,
|
|
1413
|
+
getSystemMessageLabel,
|
|
1414
|
+
readSessionInfoFromJsonl,
|
|
1415
|
+
exportSessionCaches,
|
|
1416
|
+
importSessionCaches,
|
|
1417
|
+
sessionCachesDirty,
|
|
1418
|
+
transcriptActivityMs,
|
|
1419
|
+
readRecentMessages,
|
|
1420
|
+
readMessagesPage,
|
|
1421
|
+
readFullToolResult,
|
|
1422
|
+
readUserImage,
|
|
1423
|
+
readToolResultImage,
|
|
1424
|
+
readCachedImage,
|
|
1425
|
+
buildAgentProgressMap,
|
|
1426
|
+
buildSessionDigest,
|
|
1427
|
+
readArtifactLinks,
|
|
1428
|
+
readScratchpadCreations,
|
|
1429
|
+
findTerminatedTeammates,
|
|
1430
|
+
extractPromptFromTranscript,
|
|
1431
|
+
extractModelFromTranscript,
|
|
1432
|
+
extractAgentResultFromTranscript,
|
|
1433
|
+
readLines
|
|
1434
|
+
};
|