sidekick-shared 0.28.3 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +188 -27
  3. package/dist/aggregation/EventAggregator.d.ts +19 -0
  4. package/dist/aggregation/EventAggregator.js +174 -35
  5. package/dist/aggregation/snapshot.d.ts +37 -8
  6. package/dist/aggregation/snapshot.js +31 -9
  7. package/dist/aggregation/types.d.ts +13 -0
  8. package/dist/authStatus.d.ts +54 -0
  9. package/dist/authStatus.js +91 -0
  10. package/dist/browser.d.ts +16 -6
  11. package/dist/browser.js +24 -2
  12. package/dist/codex.d.ts +23 -0
  13. package/dist/codex.js +44 -0
  14. package/dist/codexProfiles.d.ts +1 -0
  15. package/dist/codexProfiles.js +8 -11
  16. package/dist/context/sessionContext.js +4 -0
  17. package/dist/esm/aggregation/EventAggregator.js +174 -35
  18. package/dist/esm/aggregation/snapshot.js +31 -9
  19. package/dist/esm/authStatus.js +87 -0
  20. package/dist/esm/browser.js +5 -2
  21. package/dist/esm/codex.js +18 -0
  22. package/dist/esm/codexProfiles.js +8 -11
  23. package/dist/esm/context/sessionContext.js +4 -0
  24. package/dist/esm/formatters/toolSummary.js +4 -1
  25. package/dist/esm/formatting.js +10 -1
  26. package/dist/esm/index.js +11 -6
  27. package/dist/esm/modelCatalog.js +7 -5
  28. package/dist/esm/modelContext.js +4 -3
  29. package/dist/esm/modelInfo.js +425 -47
  30. package/dist/esm/node.js +9 -3
  31. package/dist/esm/observedContextWindows.js +2 -1
  32. package/dist/esm/ownKey.js +26 -0
  33. package/dist/esm/parsers/codexParser.js +351 -54
  34. package/dist/esm/parsers/codexRolloutOrigin.js +107 -0
  35. package/dist/esm/parsers/openCodeParser.js +85 -22
  36. package/dist/esm/parsers/subagentScanner.js +33 -12
  37. package/dist/esm/parsers/subagentTraceParser.js +97 -37
  38. package/dist/esm/pricing.js +18 -0
  39. package/dist/esm/pricingCatalog.js +17 -9
  40. package/dist/esm/promptHistory.js +66 -4
  41. package/dist/esm/providerFailure.js +124 -3
  42. package/dist/esm/providers/claudeCode.js +10 -2
  43. package/dist/esm/providers/codex.js +84 -62
  44. package/dist/esm/providers/codexDatabase.js +3 -19
  45. package/dist/esm/providers/codexRollouts.js +24 -1
  46. package/dist/esm/providers/openCode.js +9 -17
  47. package/dist/esm/report/htmlReportGenerator.js +42 -5
  48. package/dist/esm/report/transcriptParser.js +13 -11
  49. package/dist/esm/schemas/providerFailure.js +3 -1
  50. package/dist/esm/schemas/sessionEvent.js +35 -14
  51. package/dist/esm/schemas/transcript.js +4 -0
  52. package/dist/esm/sessionPromptHistory.js +2 -2
  53. package/dist/esm/statusline/payload.js +9 -0
  54. package/dist/esm/subagentSpawn.js +163 -0
  55. package/dist/esm/testing/credentialGuard.js +87 -5
  56. package/dist/esm/toolCallOrigins.js +310 -0
  57. package/dist/esm/transcript.js +46 -3
  58. package/dist/esm/turns/assistantTurn.js +3 -2
  59. package/dist/esm/usage/claudeUsageDedupe.js +68 -0
  60. package/dist/esm/usage/usageEvents.js +146 -17
  61. package/dist/esm/usage/usageReports.js +39 -6
  62. package/dist/esm/watchers/eventBridge.js +15 -0
  63. package/dist/esm/watchers/factory.js +5 -3
  64. package/dist/esm/watchers/jsonlTail.js +442 -58
  65. package/dist/esm/watchers/jsonlWatcher.js +51 -4
  66. package/dist/esm/watchers/providerReaderWatcher.js +13 -0
  67. package/dist/esm/zaiQuota.js +2 -1
  68. package/dist/formatters/toolSummary.js +4 -1
  69. package/dist/formatting.d.ts +26 -1
  70. package/dist/formatting.js +11 -1
  71. package/dist/index.d.ts +22 -11
  72. package/dist/index.js +44 -12
  73. package/dist/modelCatalog.js +7 -5
  74. package/dist/modelContext.js +4 -3
  75. package/dist/modelInfo.d.ts +185 -16
  76. package/dist/modelInfo.js +434 -49
  77. package/dist/node.d.ts +16 -5
  78. package/dist/node.js +33 -2
  79. package/dist/observedContextWindows.js +2 -1
  80. package/dist/ownKey.d.ts +16 -0
  81. package/dist/ownKey.js +30 -0
  82. package/dist/parsers/codexParser.d.ts +118 -0
  83. package/dist/parsers/codexParser.js +352 -54
  84. package/dist/parsers/codexRolloutOrigin.d.ts +49 -0
  85. package/dist/parsers/codexRolloutOrigin.js +111 -0
  86. package/dist/parsers/openCodeParser.d.ts +38 -0
  87. package/dist/parsers/openCodeParser.js +88 -22
  88. package/dist/parsers/subagentScanner.d.ts +19 -6
  89. package/dist/parsers/subagentScanner.js +33 -12
  90. package/dist/parsers/subagentTraceParser.d.ts +25 -5
  91. package/dist/parsers/subagentTraceParser.js +97 -37
  92. package/dist/pricing.d.ts +22 -0
  93. package/dist/pricing.js +50 -0
  94. package/dist/pricingCatalog.d.ts +10 -0
  95. package/dist/pricingCatalog.js +16 -8
  96. package/dist/promptHistory.d.ts +65 -4
  97. package/dist/promptHistory.js +68 -5
  98. package/dist/providerFailure.d.ts +44 -0
  99. package/dist/providerFailure.js +126 -4
  100. package/dist/providers/claudeCode.js +9 -1
  101. package/dist/providers/codex.d.ts +3 -22
  102. package/dist/providers/codex.js +85 -64
  103. package/dist/providers/codexDatabase.d.ts +3 -2
  104. package/dist/providers/codexDatabase.js +4 -22
  105. package/dist/providers/codexRollouts.d.ts +22 -0
  106. package/dist/providers/codexRollouts.js +24 -0
  107. package/dist/providers/openCode.js +8 -16
  108. package/dist/providers/types.d.ts +14 -0
  109. package/dist/report/htmlReportGenerator.js +42 -5
  110. package/dist/report/transcriptParser.d.ts +10 -1
  111. package/dist/report/transcriptParser.js +13 -11
  112. package/dist/schemas/index.d.ts +1 -0
  113. package/dist/schemas/providerFailure.d.ts +39 -0
  114. package/dist/schemas/providerFailure.js +2 -0
  115. package/dist/schemas/sessionEvent.d.ts +27 -10
  116. package/dist/schemas/sessionEvent.js +35 -14
  117. package/dist/schemas/transcript.d.ts +2 -0
  118. package/dist/schemas/transcript.js +4 -0
  119. package/dist/sessionPromptHistory.d.ts +7 -1
  120. package/dist/sessionPromptHistory.js +1 -1
  121. package/dist/statusline/payload.d.ts +10 -0
  122. package/dist/statusline/payload.js +14 -0
  123. package/dist/subagentSpawn.d.ts +80 -0
  124. package/dist/subagentSpawn.js +174 -0
  125. package/dist/testing/credentialGuard.d.ts +14 -0
  126. package/dist/testing/credentialGuard.js +87 -5
  127. package/dist/toolCallOrigins.d.ts +70 -0
  128. package/dist/toolCallOrigins.js +346 -0
  129. package/dist/transcript.d.ts +12 -0
  130. package/dist/transcript.js +46 -3
  131. package/dist/turns/assistantTurn.d.ts +1 -0
  132. package/dist/turns/assistantTurn.js +3 -2
  133. package/dist/types/opencode.d.ts +2 -0
  134. package/dist/usage/claudeUsageDedupe.d.ts +31 -0
  135. package/dist/usage/claudeUsageDedupe.js +68 -0
  136. package/dist/usage/usageEvents.d.ts +54 -6
  137. package/dist/usage/usageEvents.js +146 -17
  138. package/dist/usage/usageReports.d.ts +35 -4
  139. package/dist/usage/usageReports.js +39 -6
  140. package/dist/watchers/eventBridge.js +15 -0
  141. package/dist/watchers/factory.d.ts +6 -0
  142. package/dist/watchers/factory.js +5 -3
  143. package/dist/watchers/jsonlTail.d.ts +124 -4
  144. package/dist/watchers/jsonlTail.js +446 -59
  145. package/dist/watchers/jsonlWatcher.d.ts +18 -1
  146. package/dist/watchers/jsonlWatcher.js +50 -3
  147. package/dist/watchers/providerReaderWatcher.d.ts +7 -0
  148. package/dist/watchers/providerReaderWatcher.js +13 -0
  149. package/dist/watchers/types.d.ts +12 -0
  150. package/dist/zaiQuota.js +2 -1
  151. package/package.json +41 -1
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Locates the session record that issued each of a set of tool calls, by id.
3
+ *
4
+ * Given Claude Code `tool_use` ids (`toolu_…`) and/or Codex `call_id`s, finds
5
+ * the session file, session id and time of each call, plus the ordinal of the
6
+ * human prompt the call answered, so a downstream record (an edit, a commit)
7
+ * can be tied back to the prompt that caused it. Scope, listing, session keys
8
+ * and ordinals are the ones `collectPromptHistory()` uses, so results join
9
+ * directly with prompt-history entries.
10
+ *
11
+ * Cost: one listing of the workspace-scoped session files (`since` prunes by
12
+ * modification time), then a streaming pass over each candidate file in which
13
+ * a line is only parsed when it contains one of the wanted ids. Claude Code
14
+ * subagent transcripts of each session are searched too. Stops as soon as
15
+ * every id is found. Prompt ordinals are read only for files that matched.
16
+ *
17
+ * Node-only: reads session files.
18
+ *
19
+ * @module toolCallOrigins
20
+ */
21
+ import * as fs from 'fs';
22
+ import * as path from 'path';
23
+ import * as readline from 'readline';
24
+ import { listCandidates, readPromptOrdinalMarks, resolveRoots, } from './promptHistory';
25
+ import { codexRolloutOrigin } from './parsers/codexRolloutOrigin';
26
+ import { realpathOrNull } from './workspaceRoots';
27
+ /**
28
+ * Finds where each tool call was issued. Ids that are not found are absent
29
+ * from the result. When an id appears more than once (a forked or resumed
30
+ * session can replay history), the earliest record wins, and a session's own
31
+ * record wins over a fork's copy at the same instant.
32
+ */
33
+ export async function findToolCallOrigins(options) {
34
+ const wanted = new Set([...options.ids].filter((id) => typeof id === 'string' && id.length > 0));
35
+ const found = new Map();
36
+ if (wanted.size === 0)
37
+ return found;
38
+ throwIfAborted(options.signal);
39
+ const realpaths = new Map();
40
+ const { roots, listingRoots } = resolveRoots(options.workspacePaths, options.includeWorktrees !== false, realpaths);
41
+ if (roots.length === 0)
42
+ return found;
43
+ const providers = options.providers ?? ['claude-code', 'codex'];
44
+ const sinceMs = options.since ? options.since.getTime() : null;
45
+ const candidates = await listCandidates(providers, listingRoots, sinceMs);
46
+ const byProviderSession = new Map();
47
+ for (const candidate of candidates) {
48
+ byProviderSession.set(`${candidate.provider}:${candidate.sessionId}`, candidate);
49
+ }
50
+ const marksCache = new Map();
51
+ const marksFor = (candidate) => {
52
+ let marks = marksCache.get(candidate.key);
53
+ if (!marks) {
54
+ marks = readPromptOrdinalMarks(candidate.provider, candidate.sessionId, candidate.filePath).catch(() => []);
55
+ marksCache.set(candidate.key, marks);
56
+ }
57
+ return marks;
58
+ };
59
+ const remaining = new Set(wanted);
60
+ // Best timestamp seen per id, to prefer the earliest record across files.
61
+ const bestMs = new Map();
62
+ for (const candidate of candidates) {
63
+ if (remaining.size === 0)
64
+ break;
65
+ throwIfAborted(options.signal);
66
+ const targets = [{ candidate, filePath: candidate.filePath }];
67
+ if (candidate.provider === 'claude-code') {
68
+ for (const sub of claudeSubagentTranscripts(candidate.filePath, candidate.sessionId)) {
69
+ targets.push({ candidate, filePath: sub.filePath, agentId: sub.agentId });
70
+ }
71
+ }
72
+ // A Codex fork or spawned subagent answers its parent's prompts.
73
+ let codexParent;
74
+ if (candidate.provider === 'codex') {
75
+ const origin = codexRolloutOrigin(await readFirstRecord(candidate.filePath));
76
+ if (origin.parentId)
77
+ codexParent = { id: origin.parentId, fork: !origin.isSubagent };
78
+ }
79
+ for (const target of targets) {
80
+ const hits = await scanForIds(target.filePath, candidate.provider, remaining, roots, realpaths, options.signal);
81
+ for (const hit of hits) {
82
+ const isFork = codexParent?.fork === true;
83
+ const previous = bestMs.get(hit.id);
84
+ if (previous &&
85
+ (previous.ms < hit.timestampMs ||
86
+ (previous.ms === hit.timestampMs && (!previous.fork || isFork)))) {
87
+ continue;
88
+ }
89
+ bestMs.set(hit.id, { ms: hit.timestampMs, fork: isFork });
90
+ let subagent;
91
+ if (target.agentId) {
92
+ subagent = { agentId: target.agentId, parentSessionId: candidate.sessionId };
93
+ }
94
+ else if (codexParent && !codexParent.fork) {
95
+ subagent = { agentId: candidate.sessionId, parentSessionId: codexParent.id };
96
+ }
97
+ // Ordinals come from the session whose prompts the call answered: a
98
+ // Codex fork or subagent has none of its own, so its parent's count.
99
+ const parentSession = codexParent
100
+ ? byProviderSession.get(`codex:${codexParent.id}`)
101
+ : undefined;
102
+ const session = parentSession ?? candidate;
103
+ const afterOrdinal = !codexParent || parentSession
104
+ ? ordinalAt(await marksFor(session), hit.timestampMs)
105
+ : null;
106
+ found.set(hit.id, {
107
+ provider: candidate.provider,
108
+ sessionId: session.sessionId,
109
+ sessionKey: session.key,
110
+ filePath: target.filePath,
111
+ timestamp: hit.timestamp,
112
+ afterOrdinal,
113
+ ...(hit.toolName ? { toolName: hit.toolName } : {}),
114
+ ...(subagent ? { subagent } : {}),
115
+ });
116
+ // A fork's copy might be replaced by the original later; keep looking.
117
+ if (!isFork)
118
+ remaining.delete(hit.id);
119
+ }
120
+ if (remaining.size === 0)
121
+ break;
122
+ }
123
+ }
124
+ return found;
125
+ }
126
+ /** Highest ordinal among prompts at or before `ms`; marks are in file order. */
127
+ function ordinalAt(marks, ms) {
128
+ let best = null;
129
+ for (const mark of marks) {
130
+ if (Number.isFinite(mark.timestampMs) && mark.timestampMs <= ms) {
131
+ if (best === null || mark.ordinal > best)
132
+ best = mark.ordinal;
133
+ }
134
+ }
135
+ return best;
136
+ }
137
+ function claudeSubagentTranscripts(sessionFile, sessionId) {
138
+ const dir = path.join(path.dirname(sessionFile), sessionId, 'subagents');
139
+ let names;
140
+ try {
141
+ names = fs.readdirSync(dir);
142
+ }
143
+ catch {
144
+ return [];
145
+ }
146
+ return names
147
+ .filter((name) => name.endsWith('.jsonl'))
148
+ .sort()
149
+ .map((name) => ({
150
+ filePath: path.join(dir, name),
151
+ agentId: path.basename(name, '.jsonl').replace(/^agent-/, ''),
152
+ }));
153
+ }
154
+ async function readFirstRecord(filePath) {
155
+ let handle;
156
+ try {
157
+ handle = await fs.promises.open(filePath, 'r');
158
+ const buffer = Buffer.alloc(256 * 1024);
159
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
160
+ const text = buffer.subarray(0, bytesRead).toString('utf8');
161
+ const newline = text.indexOf('\n');
162
+ return JSON.parse(newline === -1 ? text : text.slice(0, newline));
163
+ }
164
+ catch {
165
+ return undefined;
166
+ }
167
+ finally {
168
+ await handle?.close();
169
+ }
170
+ }
171
+ /** Fail-closed scope check on a recorded working directory. */
172
+ function inScope(cwd, roots, realpaths) {
173
+ if (typeof cwd !== 'string' || cwd.length === 0)
174
+ return false;
175
+ const real = realpathOrNull(cwd, realpaths);
176
+ if (!real)
177
+ return false;
178
+ return roots.some((root) => {
179
+ if (real === root)
180
+ return true;
181
+ const prefix = root.endsWith(path.sep) ? root : root + path.sep;
182
+ return real.startsWith(prefix);
183
+ });
184
+ }
185
+ const ID_PATTERN = /"(?:id|call_id)"\s*:\s*"([^"\\]{1,256})"/g;
186
+ async function scanForIds(filePath, provider, wanted, roots, realpaths, signal) {
187
+ const hits = [];
188
+ let stream;
189
+ try {
190
+ stream = fs.createReadStream(filePath, { encoding: 'utf8' });
191
+ }
192
+ catch {
193
+ return hits;
194
+ }
195
+ const lines = readline.createInterface({ input: stream, crlfDelay: Infinity });
196
+ // Codex records a working directory per session/turn, not per line.
197
+ let codexCwd;
198
+ try {
199
+ for await (const line of lines) {
200
+ throwIfAborted(signal);
201
+ if (provider === 'codex' && line.includes('"cwd"')) {
202
+ const cwd = codexCwdOf(line);
203
+ if (cwd !== undefined)
204
+ codexCwd = cwd;
205
+ }
206
+ let match = false;
207
+ for (const found of line.matchAll(ID_PATTERN)) {
208
+ if (wanted.has(found[1])) {
209
+ match = true;
210
+ break;
211
+ }
212
+ }
213
+ if (!match)
214
+ continue;
215
+ let record;
216
+ try {
217
+ record = JSON.parse(line);
218
+ }
219
+ catch {
220
+ continue;
221
+ }
222
+ const lineHits = provider === 'claude-code' ? claudeToolUses(record, wanted) : codexToolCall(record, wanted);
223
+ if (lineHits.length === 0)
224
+ continue;
225
+ const cwd = provider === 'claude-code' ? record.cwd : codexCwd;
226
+ if (!inScope(cwd, roots, realpaths))
227
+ continue;
228
+ hits.push(...lineHits);
229
+ }
230
+ }
231
+ catch (error) {
232
+ if (signal?.aborted)
233
+ throw error;
234
+ // Unreadable or vanished file: keep what was found.
235
+ }
236
+ finally {
237
+ lines.close();
238
+ stream.destroy();
239
+ }
240
+ return hits;
241
+ }
242
+ function codexCwdOf(line) {
243
+ try {
244
+ const record = JSON.parse(line);
245
+ if (record.type === 'session_meta' || record.type === 'turn_context') {
246
+ return record.payload?.cwd;
247
+ }
248
+ }
249
+ catch {
250
+ // Not a complete record.
251
+ }
252
+ return undefined;
253
+ }
254
+ function timeOf(record) {
255
+ const timestamp = record.timestamp;
256
+ if (typeof timestamp !== 'string')
257
+ return null;
258
+ const timestampMs = Date.parse(timestamp);
259
+ return Number.isFinite(timestampMs) ? { timestamp, timestampMs } : null;
260
+ }
261
+ function claudeToolUses(record, wanted) {
262
+ const row = record;
263
+ if (row?.type !== 'assistant' || !Array.isArray(row.message?.content))
264
+ return [];
265
+ const time = timeOf(record);
266
+ if (!time)
267
+ return [];
268
+ const hits = [];
269
+ for (const block of row.message.content) {
270
+ const item = block;
271
+ if ((item?.type === 'tool_use' || item?.type === 'server_tool_use') &&
272
+ typeof item.id === 'string' &&
273
+ wanted.has(item.id)) {
274
+ hits.push({
275
+ id: item.id,
276
+ ...time,
277
+ ...(typeof item.name === 'string' ? { toolName: item.name } : {}),
278
+ });
279
+ }
280
+ }
281
+ return hits;
282
+ }
283
+ function codexToolCall(record, wanted) {
284
+ const row = record;
285
+ const payloadType = row?.payload?.type;
286
+ if (row?.type !== 'response_item' ||
287
+ typeof payloadType !== 'string' ||
288
+ !payloadType.endsWith('_call')) {
289
+ return [];
290
+ }
291
+ const callId = row.payload?.call_id;
292
+ if (typeof callId !== 'string' || !wanted.has(callId))
293
+ return [];
294
+ const time = timeOf(record);
295
+ if (!time)
296
+ return [];
297
+ const name = row.payload?.name;
298
+ return [
299
+ {
300
+ id: callId,
301
+ ...time,
302
+ toolName: typeof name === 'string' ? name : payloadType.replace(/_call$/, ''),
303
+ },
304
+ ];
305
+ }
306
+ function throwIfAborted(signal) {
307
+ if (signal?.aborted) {
308
+ throw (signal.reason ?? Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }));
309
+ }
310
+ }
@@ -1,4 +1,5 @@
1
1
  import { calculateNormalizedUsageCost, extractNormalizedUsage, } from './usageNormalization';
2
+ import { isTaskNotificationSystemEvent } from './subagentSpawn';
2
3
  const EMPTY_TOTALS = {
3
4
  uncachedInputTokens: 0,
4
5
  cacheReadTokens: 0,
@@ -25,6 +26,9 @@ export function projectSessionTranscript(events, options = {}) {
25
26
  let cwd;
26
27
  let gitBranch;
27
28
  events.forEach((event, eventIndex) => {
29
+ // A reader's synthetic notice that a background task finished, not a message.
30
+ if (isTaskNotificationSystemEvent(event))
31
+ return;
28
32
  if (nonEmpty(event.cwd))
29
33
  cwd = event.cwd;
30
34
  if (nonEmpty(event.gitBranch))
@@ -63,10 +67,12 @@ export function projectSessionTranscript(events, options = {}) {
63
67
  blocks.push({ type: 'text', ...boundedText(event.summary, fidelity, maxContentChars) });
64
68
  }
65
69
  if (event.type === 'tool_use') {
70
+ const projected = projectToolInput(event.tool.input);
66
71
  blocks.push({
67
72
  type: 'tool_use',
68
73
  toolName: event.tool.name,
69
- input: boundedValue(event.tool.input, fidelity, maxContentChars),
74
+ input: boundedValue(projected.input, fidelity, maxContentChars),
75
+ ...toolIdentity(projected),
70
76
  });
71
77
  }
72
78
  if (event.type === 'tool_result') {
@@ -87,6 +93,7 @@ export function projectSessionTranscript(events, options = {}) {
87
93
  timestamp: event.timestamp,
88
94
  input: block.input,
89
95
  command,
96
+ ...toolIdentity(block),
90
97
  source,
91
98
  };
92
99
  tools.push(call);
@@ -183,12 +190,14 @@ function contentBlocks(content, fidelity, maxChars, includeUnknown = false) {
183
190
  });
184
191
  }
185
192
  else if (block.type === 'tool_use' && typeof block.name === 'string') {
193
+ const projected = projectToolInput(block.input);
186
194
  result.push({
187
195
  type: 'tool_use',
188
196
  toolName: block.name,
189
197
  toolUseId: typeof block.id === 'string' ? block.id : undefined,
190
- input: boundedValue(block.input, fidelity, maxChars),
191
- command: commandFromTool(block.name, block.input),
198
+ input: boundedValue(projected.input, fidelity, maxChars),
199
+ command: commandFromTool(block.name, projected.input),
200
+ ...toolIdentity(projected),
192
201
  });
193
202
  }
194
203
  else if (block.type === 'tool_result') {
@@ -212,6 +221,40 @@ function contentBlocks(content, fidelity, maxChars, includeUnknown = false) {
212
221
  }
213
222
  return result;
214
223
  }
224
+ /** Prefix of the bookkeeping keys session parsers stash inside tool inputs. */
225
+ const SIDEKICK_INPUT_KEY_PREFIX = '_sidekick';
226
+ /**
227
+ * Lifts the parsers' `_sidekickRawToolName` / `_sidekickMcpServerName` out of
228
+ * a tool input and drops every other top-level `_sidekick*` key. Returns a
229
+ * copy when anything is removed; the source event is never modified.
230
+ */
231
+ function projectToolInput(input) {
232
+ if (!input || typeof input !== 'object' || Array.isArray(input))
233
+ return { input };
234
+ const source = input;
235
+ const keys = Object.keys(source);
236
+ if (!keys.some((key) => key.startsWith(SIDEKICK_INPUT_KEY_PREFIX)))
237
+ return { input };
238
+ const clean = {};
239
+ for (const key of keys) {
240
+ if (!key.startsWith(SIDEKICK_INPUT_KEY_PREFIX))
241
+ clean[key] = source[key];
242
+ }
243
+ return {
244
+ input: clean,
245
+ rawToolName: nonEmptyString(source._sidekickRawToolName),
246
+ mcpServerName: nonEmptyString(source._sidekickMcpServerName),
247
+ };
248
+ }
249
+ function toolIdentity(value) {
250
+ return {
251
+ ...(value.rawToolName ? { rawToolName: value.rawToolName } : {}),
252
+ ...(value.mcpServerName ? { mcpServerName: value.mcpServerName } : {}),
253
+ };
254
+ }
255
+ function nonEmptyString(value) {
256
+ return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
257
+ }
215
258
  function boundedText(text, fidelity, maxChars) {
216
259
  if (fidelity === 'full' || text.length <= maxChars)
217
260
  return { text, truncated: false };
@@ -1,10 +1,10 @@
1
1
  import { formatToolSummary, truncate } from '../formatters/toolSummary';
2
+ import { isSubagentSpawnTool } from '../subagentSpawn';
2
3
  const DEFAULT_NARRATION_CHARS = 4000;
3
4
  const DEFAULT_REASONING_CHARS = 8000;
4
5
  const DEFAULT_PROCESS_STEPS = 60;
5
6
  const DEFAULT_REASONING_BLOCKS = 24;
6
7
  const DEFAULT_TOOL_INPUT_CHARS = 200;
7
- const SUBAGENT_TOOL_NAMES = new Set(['Task']);
8
8
  const LABEL_MAX = 80;
9
9
  const TITLE_RE = /^\s*\*\*([^*\n]+)\*\*[ \t]*(?:\n+([\s\S]*))?$/;
10
10
  const TITLE_MAX = 100;
@@ -22,8 +22,9 @@ export function reasoningSummary(text) {
22
22
  }
23
23
  return { title: null, body: trimmed };
24
24
  }
25
+ /** Whether a tool call spawns a subagent (`Agent`, or its earlier name `Task`). */
25
26
  export function isAssistantTurnSubagentTool(toolName) {
26
- return toolName != null && SUBAGENT_TOOL_NAMES.has(toolName);
27
+ return isSubagentSpawnTool(toolName);
27
28
  }
28
29
  export function segmentAssistantTurn(events, options = {}) {
29
30
  const maxNarrationChars = options.maxNarrationChars ?? DEFAULT_NARRATION_CHARS;
@@ -59,6 +59,25 @@ export function normalizeClaudeUsage(event) {
59
59
  },
60
60
  };
61
61
  }
62
+ const BUCKET_KEYS = ['input', 'cacheRead', 'cacheWrite', 'output', 'reasoning'];
63
+ /** Entries {@link ClaudeUsageDeduper.exportState} keeps by default. */
64
+ const DEFAULT_EXPORT_LIMIT = 256;
65
+ function isCount(value) {
66
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0;
67
+ }
68
+ function bucketsFrom(value) {
69
+ if (!value || typeof value !== 'object' || Array.isArray(value))
70
+ return null;
71
+ const record = value;
72
+ const buckets = {};
73
+ for (const key of BUCKET_KEYS) {
74
+ const count = record[key];
75
+ if (!isCount(count))
76
+ return null;
77
+ buckets[key] = count;
78
+ }
79
+ return buckets;
80
+ }
62
81
  function bucketsOf(usage) {
63
82
  return {
64
83
  input: usage.uncachedInputTokens,
@@ -163,6 +182,55 @@ export class ClaudeUsageDeduper {
163
182
  reset() {
164
183
  this.seen.clear();
165
184
  }
185
+ /**
186
+ * The most recent `limit` message ids and the tokens counted for each,
187
+ * oldest first. Split lines of one response are adjacent, so a reader
188
+ * resumed at the position this was taken at needs only the last few.
189
+ */
190
+ exportState(limit = DEFAULT_EXPORT_LIMIT) {
191
+ const count = limit >= 0 ? Math.min(this.seen.size, Math.floor(limit)) : 0;
192
+ const entries = [];
193
+ if (count > 0) {
194
+ let skip = this.seen.size - count;
195
+ for (const [id, buckets] of this.seen) {
196
+ if (skip > 0) {
197
+ skip -= 1;
198
+ continue;
199
+ }
200
+ entries.push([id, { ...buckets }]);
201
+ }
202
+ }
203
+ return { version: 1, entries };
204
+ }
205
+ /**
206
+ * Replaces this deduper's state with one from {@link exportState}. Returns
207
+ * false, changing nothing, when `state` is not a valid version-1 state
208
+ * (wrong shape, empty ids, or counts that are not finite non-negative
209
+ * numbers). Entries beyond this deduper's capacity drop oldest first.
210
+ */
211
+ importState(state) {
212
+ if (!state || typeof state !== 'object')
213
+ return false;
214
+ const { version, entries } = state;
215
+ if (version !== 1 || !Array.isArray(entries))
216
+ return false;
217
+ const parsed = [];
218
+ for (const entry of entries) {
219
+ if (!Array.isArray(entry) || entry.length !== 2)
220
+ return false;
221
+ const [id, value] = entry;
222
+ if (typeof id !== 'string' || id.length === 0)
223
+ return false;
224
+ const buckets = bucketsFrom(value);
225
+ if (!buckets)
226
+ return false;
227
+ parsed.push([id, buckets]);
228
+ }
229
+ this.seen.clear();
230
+ for (const [id, buckets] of parsed)
231
+ this.remember(id, buckets);
232
+ return true;
233
+ }
166
234
  remember(id, buckets) {
167
235
  this.seen.delete(id);
168
236
  this.seen.set(id, buckets);