sidekick-shared 0.29.0 → 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 (84) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +56 -25
  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/browser.d.ts +5 -1
  9. package/dist/browser.js +9 -3
  10. package/dist/context/sessionContext.js +4 -0
  11. package/dist/esm/aggregation/EventAggregator.js +174 -35
  12. package/dist/esm/aggregation/snapshot.js +31 -9
  13. package/dist/esm/browser.js +1 -0
  14. package/dist/esm/context/sessionContext.js +4 -0
  15. package/dist/esm/formatters/toolSummary.js +4 -1
  16. package/dist/esm/index.js +3 -1
  17. package/dist/esm/modelCatalog.js +5 -4
  18. package/dist/esm/modelContext.js +4 -3
  19. package/dist/esm/modelInfo.js +4 -3
  20. package/dist/esm/node.js +1 -1
  21. package/dist/esm/observedContextWindows.js +2 -1
  22. package/dist/esm/ownKey.js +26 -0
  23. package/dist/esm/parsers/codexParser.js +180 -4
  24. package/dist/esm/parsers/openCodeParser.js +2 -1
  25. package/dist/esm/parsers/subagentScanner.js +33 -12
  26. package/dist/esm/parsers/subagentTraceParser.js +97 -37
  27. package/dist/esm/pricingCatalog.js +3 -2
  28. package/dist/esm/providers/claudeCode.js +10 -2
  29. package/dist/esm/providers/codex.js +82 -38
  30. package/dist/esm/schemas/sessionEvent.js +35 -14
  31. package/dist/esm/subagentSpawn.js +163 -0
  32. package/dist/esm/transcript.js +4 -0
  33. package/dist/esm/turns/assistantTurn.js +3 -2
  34. package/dist/esm/usage/claudeUsageDedupe.js +68 -0
  35. package/dist/esm/watchers/eventBridge.js +15 -0
  36. package/dist/esm/watchers/factory.js +5 -3
  37. package/dist/esm/watchers/jsonlTail.js +73 -8
  38. package/dist/esm/watchers/jsonlWatcher.js +51 -4
  39. package/dist/esm/watchers/providerReaderWatcher.js +13 -0
  40. package/dist/esm/zaiQuota.js +2 -1
  41. package/dist/formatters/toolSummary.js +4 -1
  42. package/dist/index.d.ts +8 -3
  43. package/dist/index.js +18 -9
  44. package/dist/modelCatalog.js +5 -4
  45. package/dist/modelContext.js +4 -3
  46. package/dist/modelInfo.js +4 -3
  47. package/dist/node.d.ts +1 -1
  48. package/dist/node.js +3 -1
  49. package/dist/observedContextWindows.js +2 -1
  50. package/dist/ownKey.d.ts +16 -0
  51. package/dist/ownKey.js +30 -0
  52. package/dist/parsers/codexParser.d.ts +63 -0
  53. package/dist/parsers/codexParser.js +181 -4
  54. package/dist/parsers/openCodeParser.js +2 -1
  55. package/dist/parsers/subagentScanner.d.ts +19 -6
  56. package/dist/parsers/subagentScanner.js +33 -12
  57. package/dist/parsers/subagentTraceParser.d.ts +25 -5
  58. package/dist/parsers/subagentTraceParser.js +97 -37
  59. package/dist/pricingCatalog.js +3 -2
  60. package/dist/providers/claudeCode.js +9 -1
  61. package/dist/providers/codex.js +82 -38
  62. package/dist/providers/types.d.ts +14 -0
  63. package/dist/schemas/index.d.ts +1 -0
  64. package/dist/schemas/sessionEvent.d.ts +27 -10
  65. package/dist/schemas/sessionEvent.js +35 -14
  66. package/dist/subagentSpawn.d.ts +80 -0
  67. package/dist/subagentSpawn.js +174 -0
  68. package/dist/transcript.js +4 -0
  69. package/dist/turns/assistantTurn.d.ts +1 -0
  70. package/dist/turns/assistantTurn.js +3 -2
  71. package/dist/usage/claudeUsageDedupe.d.ts +31 -0
  72. package/dist/usage/claudeUsageDedupe.js +68 -0
  73. package/dist/watchers/eventBridge.js +15 -0
  74. package/dist/watchers/factory.d.ts +6 -0
  75. package/dist/watchers/factory.js +5 -3
  76. package/dist/watchers/jsonlTail.d.ts +27 -4
  77. package/dist/watchers/jsonlTail.js +75 -8
  78. package/dist/watchers/jsonlWatcher.d.ts +18 -1
  79. package/dist/watchers/jsonlWatcher.js +50 -3
  80. package/dist/watchers/providerReaderWatcher.d.ts +7 -0
  81. package/dist/watchers/providerReaderWatcher.js +13 -0
  82. package/dist/watchers/types.d.ts +12 -0
  83. package/dist/zaiQuota.js +2 -1
  84. package/package.json +1 -1
@@ -171,6 +171,61 @@ export function extractPatchFilePaths(input) {
171
171
  }
172
172
  /** Default cap on each piece of per-call correlation state the parser keeps. */
173
173
  export const DEFAULT_MAX_TRACKED_TOOL_CALLS = 4096;
174
+ /** Entries per table {@link CodexRolloutParser.exportResumeState} keeps by default. */
175
+ const DEFAULT_RESUME_STATE_LIMIT = 256;
176
+ function isPlainObject(value) {
177
+ return !!value && typeof value === 'object' && !Array.isArray(value);
178
+ }
179
+ function cloneJson(value) {
180
+ return JSON.parse(JSON.stringify(value));
181
+ }
182
+ function isOptionalString(value) {
183
+ return value === undefined || typeof value === 'string';
184
+ }
185
+ function isStringArray(value) {
186
+ return Array.isArray(value) && value.every((item) => typeof item === 'string');
187
+ }
188
+ function isTokenCount(value) {
189
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0;
190
+ }
191
+ /** A valid `CodexTokenUsage` (copied), `null` for `null`, or `undefined` when invalid. Internal. */
192
+ export function codexTokenUsageFrom(value) {
193
+ if (value === null)
194
+ return null;
195
+ if (!isPlainObject(value))
196
+ return undefined;
197
+ if (!isTokenCount(value.input_tokens) || !isTokenCount(value.output_tokens))
198
+ return undefined;
199
+ const usage = {
200
+ input_tokens: value.input_tokens,
201
+ output_tokens: value.output_tokens,
202
+ };
203
+ for (const field of ['cached_input_tokens', 'reasoning_output_tokens', 'total_tokens']) {
204
+ const count = value[field];
205
+ if (count === undefined)
206
+ continue;
207
+ if (!isTokenCount(count))
208
+ return undefined;
209
+ usage[field] = count;
210
+ }
211
+ return usage;
212
+ }
213
+ /** Last `limit` items of an insertion-ordered collection, oldest first. */
214
+ function newest(items, size, limit) {
215
+ const count = limit >= 0 ? Math.min(size, Math.floor(limit)) : 0;
216
+ const kept = [];
217
+ let skip = size - count;
218
+ for (const item of items) {
219
+ if (kept.length >= count)
220
+ break;
221
+ if (skip > 0) {
222
+ skip -= 1;
223
+ continue;
224
+ }
225
+ kept.push(item);
226
+ }
227
+ return kept;
228
+ }
174
229
  /** Fixed per-entry cost added to each pending call's byte estimate. */
175
230
  const PENDING_ENTRY_OVERHEAD_BYTES = 64;
176
231
  /** Prefix of the bookkeeping keys the parser stashes inside tool inputs. */
@@ -282,6 +337,118 @@ export class CodexRolloutParser {
282
337
  getLastRateLimits() {
283
338
  return this.lastRateLimits;
284
339
  }
340
+ /**
341
+ * The state later lines depend on — session metadata, current model,
342
+ * previous cumulative token total, context window, rate limits, plan mode,
343
+ * and the tool-call correlation tables (newest `limit` entries each) — as
344
+ * JSON-serializable data. Eviction counters are not included.
345
+ */
346
+ exportResumeState(limit = DEFAULT_RESUME_STATE_LIMIT) {
347
+ return cloneJson({
348
+ version: 1,
349
+ sessionMeta: this.sessionMeta,
350
+ currentModel: this.currentModel,
351
+ prevTotalUsage: this.prevTotalUsage,
352
+ lastTokenUsage: this.lastTokenUsage,
353
+ modelContextWindow: this.modelContextWindow,
354
+ lastRateLimits: this.lastRateLimits,
355
+ hasAggregateRateLimits: this.hasAggregateRateLimits,
356
+ inPlanMode: this.inPlanMode,
357
+ emittedToolUseIds: newest(this.emittedToolUseIds, this.emittedToolUseIds.size, limit),
358
+ patchExpandedToolUseIds: newest(this.patchExpandedToolUseIds, this.patchExpandedToolUseIds.size, limit),
359
+ pendingExecCommands: newest(this.pendingExecCommands.values(), this.pendingExecCommands.size, limit),
360
+ pendingMcpToolCalls: newest(this.pendingMcpToolCalls.values(), this.pendingMcpToolCalls.size, limit),
361
+ });
362
+ }
363
+ /**
364
+ * Replaces this parser's state with one from {@link exportResumeState}, as
365
+ * if it had parsed the rollout up to where that state was taken. Returns
366
+ * false, changing nothing, when `state` is not a valid version-1 state.
367
+ * This parser's own `maxTrackedToolCalls`, `maxPendingBytes`, and
368
+ * `metadataOnly` options apply to the imported tables.
369
+ */
370
+ importResumeState(state) {
371
+ if (!isPlainObject(state) || state.version !== 1)
372
+ return false;
373
+ const s = state;
374
+ const prevTotalUsage = codexTokenUsageFrom(s.prevTotalUsage);
375
+ const lastTokenUsage = codexTokenUsageFrom(s.lastTokenUsage);
376
+ if (prevTotalUsage === undefined || lastTokenUsage === undefined)
377
+ return false;
378
+ if (s.sessionMeta !== null && !isPlainObject(s.sessionMeta))
379
+ return false;
380
+ if (s.lastRateLimits !== null && !isPlainObject(s.lastRateLimits))
381
+ return false;
382
+ if (s.currentModel !== null && typeof s.currentModel !== 'string')
383
+ return false;
384
+ const contextWindow = s.modelContextWindow;
385
+ if (contextWindow !== null &&
386
+ !(typeof contextWindow === 'number' && Number.isFinite(contextWindow) && contextWindow > 0)) {
387
+ return false;
388
+ }
389
+ if (typeof s.hasAggregateRateLimits !== 'boolean' || typeof s.inPlanMode !== 'boolean') {
390
+ return false;
391
+ }
392
+ if (!isStringArray(s.emittedToolUseIds))
393
+ return false;
394
+ const patches = s.patchExpandedToolUseIds;
395
+ if (!Array.isArray(patches) ||
396
+ !patches.every((entry) => Array.isArray(entry) &&
397
+ entry.length === 2 &&
398
+ typeof entry[0] === 'string' &&
399
+ isStringArray(entry[1]))) {
400
+ return false;
401
+ }
402
+ const execs = s.pendingExecCommands;
403
+ if (!Array.isArray(execs) ||
404
+ !execs.every((entry) => isPlainObject(entry) &&
405
+ typeof entry.call_id === 'string' &&
406
+ isStringArray(entry.command) &&
407
+ isOptionalString(entry.workdir) &&
408
+ typeof entry.timestamp === 'string')) {
409
+ return false;
410
+ }
411
+ const mcps = s.pendingMcpToolCalls;
412
+ if (!Array.isArray(mcps) ||
413
+ !mcps.every((entry) => isPlainObject(entry) &&
414
+ typeof entry.call_id === 'string' &&
415
+ typeof entry.tool_name === 'string' &&
416
+ isOptionalString(entry.server_name) &&
417
+ (entry.arguments === undefined || isPlainObject(entry.arguments)) &&
418
+ typeof entry.timestamp === 'string')) {
419
+ return false;
420
+ }
421
+ const copy = cloneJson({
422
+ sessionMeta: s.sessionMeta,
423
+ lastRateLimits: s.lastRateLimits,
424
+ patches: patches,
425
+ execs: execs,
426
+ mcps: mcps,
427
+ });
428
+ this.reset();
429
+ this.sessionMeta = copy.sessionMeta;
430
+ this.currentModel = s.currentModel;
431
+ this.prevTotalUsage = prevTotalUsage;
432
+ this.lastTokenUsage = lastTokenUsage;
433
+ this.modelContextWindow = contextWindow;
434
+ this.lastRateLimits = copy.lastRateLimits;
435
+ this.hasAggregateRateLimits = s.hasAggregateRateLimits;
436
+ this.inPlanMode = s.inPlanMode;
437
+ for (const id of s.emittedToolUseIds) {
438
+ rememberBounded(this.emittedToolUseIds, id, this.maxTrackedToolCalls);
439
+ }
440
+ for (const [callId, toolUseIds] of copy.patches)
441
+ this.putPendingPatch(callId, toolUseIds);
442
+ for (const pending of copy.execs) {
443
+ this.putPendingExec(this.metadataOnly
444
+ ? { call_id: pending.call_id, command: [], timestamp: pending.timestamp }
445
+ : pending);
446
+ }
447
+ for (const pending of copy.mcps) {
448
+ this.putPendingMcp(this.metadataOnly ? { ...pending, arguments: undefined } : pending);
449
+ }
450
+ return true;
451
+ }
285
452
  /**
286
453
  * Converts a single rollout line to zero or more session events.
287
454
  */
@@ -353,6 +520,16 @@ export class CodexRolloutParser {
353
520
  this.evictedByBytes += 1;
354
521
  }
355
522
  }
523
+ putPendingExec(pending) {
524
+ this.putPending(this.pendingExecCommands, pending.call_id, pending, () => stringBytes(pending.call_id, pending.workdir, pending.timestamp, ...(Array.isArray(pending.command) ? pending.command : [])));
525
+ }
526
+ putPendingMcp(pending) {
527
+ this.putPending(this.pendingMcpToolCalls, pending.call_id, pending, () => stringBytes(pending.call_id, pending.server_name, pending.tool_name, pending.timestamp) +
528
+ approxJsonBytes(pending.arguments));
529
+ }
530
+ putPendingPatch(callId, toolUseIds) {
531
+ this.putPending(this.patchExpandedToolUseIds, callId, toolUseIds, () => stringBytes(callId, ...toolUseIds));
532
+ }
356
533
  /** Removes and returns a pending call. */
357
534
  takePending(table, callId) {
358
535
  const value = table.get(callId);
@@ -582,7 +759,7 @@ export class CodexRolloutParser {
582
759
  if (filePaths.length === 0)
583
760
  return [];
584
761
  const toolUseIds = filePaths.map((fp) => `${item.call_id}-${fp}`);
585
- this.putPending(this.patchExpandedToolUseIds, item.call_id, toolUseIds, () => stringBytes(item.call_id, ...toolUseIds));
762
+ this.putPendingPatch(item.call_id, toolUseIds);
586
763
  for (const id of toolUseIds) {
587
764
  rememberBounded(this.emittedToolUseIds, id, this.maxTrackedToolCalls);
588
765
  }
@@ -732,7 +909,7 @@ export class CodexRolloutParser {
732
909
  const pending = this.metadataOnly
733
910
  ? { call_id: e.call_id, command: [], timestamp }
734
911
  : { call_id: e.call_id, command: e.command, workdir: e.workdir, timestamp };
735
- this.putPending(this.pendingExecCommands, e.call_id, pending, () => stringBytes(pending.call_id, pending.workdir, pending.timestamp, ...(Array.isArray(pending.command) ? pending.command : [])));
912
+ this.putPendingExec(pending);
736
913
  return [];
737
914
  }
738
915
  case 'exec_command_end': {
@@ -795,8 +972,7 @@ export class CodexRolloutParser {
795
972
  arguments: this.metadataOnly ? undefined : e.arguments,
796
973
  timestamp,
797
974
  };
798
- this.putPending(this.pendingMcpToolCalls, e.call_id, pending, () => stringBytes(pending.call_id, pending.server_name, pending.tool_name, timestamp) +
799
- approxJsonBytes(pending.arguments));
975
+ this.putPendingMcp(pending);
800
976
  return [];
801
977
  }
802
978
  case 'mcp_tool_call_end': {
@@ -11,6 +11,7 @@
11
11
  * @module parsers/openCodeParser
12
12
  */
13
13
  import { normalizeProviderUsage } from '../usageNormalization';
14
+ import { ownValue } from '../ownKey';
14
15
  /**
15
16
  * OpenCode → Claude Code tool name mapping.
16
17
  * OpenCode uses lowercase names; downstream code expects PascalCase.
@@ -46,7 +47,7 @@ export function normalizeToolName(name) {
46
47
  if (!name)
47
48
  return name;
48
49
  // Check mapping (case-insensitive lookup via lowercase key)
49
- const mapped = TOOL_NAME_MAP[name.toLowerCase()];
50
+ const mapped = ownValue(TOOL_NAME_MAP, name.toLowerCase());
50
51
  if (mapped)
51
52
  return mapped;
52
53
  // Already PascalCase (starts with uppercase)? Pass through.
@@ -12,28 +12,49 @@ import * as path from 'path';
12
12
  import { ClaudeUsageDeduper, dedupedRawClaudeUsage } from '../usage/claudeUsageDedupe';
13
13
  import { addUsageToSubagent } from '../usage/subagentUsage';
14
14
  import { cachedSubagentStats } from './subagentStatsCache';
15
+ import { isSubagentSpawnTool } from '../subagentSpawn';
15
16
  /**
16
17
  * Pattern for matching subagent JSONL files.
17
18
  * Files are named like: agent-<hash>.jsonl
18
19
  */
19
20
  const AGENT_FILE_PATTERN = /^agent-(.+)\.jsonl$/;
21
+ function metaString(value) {
22
+ return typeof value === 'string' && value ? value : undefined;
23
+ }
20
24
  /**
21
25
  * Reads `agent-<id>.meta.json` (written by Claude Code next to the transcript)
22
- * for the spawn's agent type and description. Missing or malformed files
23
- * yield an empty object.
26
+ * for the spawn's agent type, description, spawning tool_use id, parent agent,
27
+ * and completion flags. Missing or malformed files yield an empty object;
28
+ * wrongly typed fields are left out.
24
29
  */
25
30
  export function readAgentMeta(agentFilePath) {
26
31
  try {
27
32
  const metaPath = agentFilePath.replace(/\.jsonl$/, '.meta.json');
28
33
  const parsed = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
29
- return {
30
- ...(typeof parsed.agentType === 'string' && parsed.agentType
31
- ? { agentType: parsed.agentType }
32
- : {}),
33
- ...(typeof parsed.description === 'string' && parsed.description
34
- ? { description: parsed.description }
35
- : {}),
36
- };
34
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
35
+ return {};
36
+ const record = parsed;
37
+ const meta = {};
38
+ const agentType = metaString(record.agentType);
39
+ if (agentType)
40
+ meta.agentType = agentType;
41
+ const description = metaString(record.description);
42
+ if (description)
43
+ meta.description = description;
44
+ const toolUseId = metaString(record.toolUseId);
45
+ if (toolUseId)
46
+ meta.toolUseId = toolUseId;
47
+ const parentAgentId = metaString(record.parentAgentId);
48
+ if (parentAgentId)
49
+ meta.parentAgentId = parentAgentId;
50
+ const requestShape = metaString(record.requestShape);
51
+ if (requestShape)
52
+ meta.requestShape = requestShape;
53
+ if (typeof record.finished === 'boolean')
54
+ meta.finished = record.finished;
55
+ if (typeof record.stoppedByUser === 'boolean')
56
+ meta.stoppedByUser = record.stoppedByUser;
57
+ return meta;
37
58
  }
38
59
  catch {
39
60
  return {};
@@ -208,8 +229,8 @@ function parseAgentFile(filePath, agentId, log) {
208
229
  }
209
230
  }
210
231
  toolCalls.push(toolCall);
211
- // If this is a Task tool call (spawning another subagent), extract info
212
- if (toolUse.name === 'Task' && toolUse.input) {
232
+ // If this is an Agent/Task call (spawning another subagent), extract info
233
+ if (isSubagentSpawnTool(toolUse.name) && toolUse.input) {
213
234
  if (toolUse.input.subagent_type && !agentType) {
214
235
  agentType = String(toolUse.input.subagent_type);
215
236
  }
@@ -5,10 +5,13 @@
5
5
  * (not just stats) for drill-down trace visualization. Applies noise classification
6
6
  * and tool summary formatting from the formatters module.
7
7
  *
8
- * Three-phase parent-child linking:
9
- * 1. Result-based: match toolUseResult.agentId → parent tool_use_id
10
- * 2. Team summary: parse <teammate-message> XML blocks
11
- * 3. Positional fallback: timestamp ordering + proximity
8
+ * Parent-child linking, exact links first:
9
+ * 1. Meta `parentAgentId` (Claude Code writes it for nested spawns)
10
+ * 2. The child's spawning tool_use id (meta `toolUseId`) found in another
11
+ * trace's `Agent` / `Task` calls
12
+ * Only traces without that spawn metadata (older Claude Code) fall back to:
13
+ * 3. Team summary: parse <teammate-message> XML blocks
14
+ * 4. Positional fallback: timestamp ordering + proximity
12
15
  *
13
16
  * @module parsers/subagentTraceParser
14
17
  */
@@ -19,6 +22,8 @@ import { isHardNoise, classifyMessage, getSoftNoiseReason } from '../formatters/
19
22
  import { ClaudeUsageDeduper, dedupedRawClaudeUsage } from '../usage/claudeUsageDedupe';
20
23
  import { addUsageToSubagent } from '../usage/subagentUsage';
21
24
  import { readAgentMeta } from './subagentScanner';
25
+ import { isSubagentSpawnTool } from '../subagentSpawn';
26
+ import { ownValue } from '../ownKey';
22
27
  // ── Constants ──
23
28
  const AGENT_FILE_PATTERN = /^agent-(.+)\.jsonl$/;
24
29
  // ── Public API ──
@@ -50,10 +55,8 @@ export function scanSubagentTraces(sessionDir, sessionId) {
50
55
  if (trace)
51
56
  traces.push(trace);
52
57
  }
53
- // Link parent-child relationships
54
- linkTraces(traces);
55
- // Return only top-level traces (those without parents)
56
- return traces.filter((t) => !t.parentToolUseId);
58
+ // Link parent-child relationships; returns the traces with no parent agent.
59
+ return linkTraces(traces);
57
60
  }
58
61
  catch {
59
62
  return [];
@@ -133,8 +136,8 @@ function parseAgentTrace(filePath, agentId) {
133
136
  input: block.input || {},
134
137
  timestamp,
135
138
  });
136
- // Extract agent info from Task tool
137
- if (block.name === 'Task' && block.input) {
139
+ // Extract agent info from an Agent/Task spawn
140
+ if (isSubagentSpawnTool(block.name) && block.input) {
138
141
  if (block.input.subagent_type && !agentType) {
139
142
  agentType = String(block.input.subagent_type);
140
143
  }
@@ -171,6 +174,14 @@ function parseAgentTrace(filePath, agentId) {
171
174
  agentId,
172
175
  agentType,
173
176
  description,
177
+ filePath,
178
+ ...(meta.toolUseId
179
+ ? { spawnToolUseId: meta.toolUseId, parentToolUseId: meta.toolUseId }
180
+ : {}),
181
+ ...(meta.parentAgentId ? { parentAgentId: meta.parentAgentId } : {}),
182
+ ...(meta.requestShape ? { background: meta.requestShape === 'background' } : {}),
183
+ ...(meta.finished !== undefined ? { finished: meta.finished } : {}),
184
+ ...(meta.stoppedByUser !== undefined ? { stoppedByUser: meta.stoppedByUser } : {}),
174
185
  events,
175
186
  children: [],
176
187
  stats: {
@@ -195,23 +206,61 @@ function parseAgentTrace(filePath, agentId) {
195
206
  }
196
207
  }
197
208
  // ── Parent-Child Linking ──
209
+ /**
210
+ * Nests child traces under their parents and returns the traces with no
211
+ * parent agent. Exact metadata links come first; the heuristics only place
212
+ * traces Claude Code recorded no spawn metadata for.
213
+ */
198
214
  function linkTraces(traces) {
199
215
  const traceById = new Map();
200
216
  for (const trace of traces) {
201
217
  traceById.set(trace.agentId, trace);
202
218
  }
203
- // Phase 1: Result-based linking
204
- // Look for tool_result events that contain agentId references
219
+ const parentOf = new Map();
220
+ const attach = (parent, child) => {
221
+ if (parent === child || parentOf.has(child))
222
+ return false;
223
+ // Never nest an agent under its own descendant (malformed metadata).
224
+ for (let up = parent; up; up = parentOf.get(up)) {
225
+ if (up === child)
226
+ return false;
227
+ }
228
+ parentOf.set(child, parent);
229
+ parent.children.push(child);
230
+ return true;
231
+ };
232
+ // Phase 1: the meta file names the parent agent
233
+ for (const child of traces) {
234
+ const parent = child.parentAgentId ? traceById.get(child.parentAgentId) : undefined;
235
+ if (parent)
236
+ attach(parent, child);
237
+ }
238
+ // Phase 2: the child's spawning call is in another trace
239
+ const spawnOwner = new Map();
205
240
  for (const trace of traces) {
206
- for (const traceEvent of trace.events) {
207
- const evt = traceEvent.event;
208
- if (evt.type === 'tool_use' && evt.tool?.name === 'Task') {
209
- // The tool_use_id from this call should appear as a result in the parent
210
- // but we can link via timestamp proximity to child traces
211
- }
241
+ for (const id of spawnToolUseIdsOf(trace)) {
242
+ if (!spawnOwner.has(id))
243
+ spawnOwner.set(id, trace);
212
244
  }
213
245
  }
214
- // Phase 2: Team summary linking
246
+ for (const child of traces) {
247
+ if (parentOf.has(child) || !child.spawnToolUseId)
248
+ continue;
249
+ const parent = spawnOwner.get(child.spawnToolUseId);
250
+ if (parent && attach(parent, child))
251
+ child.parentAgentId ??= parent.agentId;
252
+ }
253
+ // Heuristic links set parentAgentId and, as before, parentToolUseId to the
254
+ // parent's agent id.
255
+ const unresolved = (trace) => !parentOf.has(trace) && !trace.spawnToolUseId && !trace.parentAgentId;
256
+ const linkHeuristically = (parent, child) => {
257
+ if (!attach(parent, child))
258
+ return false;
259
+ child.parentAgentId = parent.agentId;
260
+ child.parentToolUseId = parent.agentId;
261
+ return true;
262
+ };
263
+ // Phase 3: Team summary linking
215
264
  for (const trace of traces) {
216
265
  for (const traceEvent of trace.events) {
217
266
  const content = traceEvent.event.message?.content;
@@ -219,45 +268,56 @@ function linkTraces(traces) {
219
268
  // Parse teammate agent IDs from XML blocks
220
269
  const matches = content.matchAll(/agent[_-]id['":\s]+([a-f0-9]+)/gi);
221
270
  for (const match of matches) {
222
- const childId = match[1];
223
- const child = traceById.get(childId);
224
- if (child && child !== trace && !child.parentToolUseId) {
225
- child.parentToolUseId = trace.agentId;
226
- trace.children.push(child);
227
- }
271
+ const child = traceById.get(match[1]);
272
+ if (child && unresolved(child))
273
+ linkHeuristically(trace, child);
228
274
  }
229
275
  }
230
276
  }
231
277
  }
232
- // Phase 3: Positional fallback — timestamp proximity
278
+ // Phase 4: Positional fallback — timestamp proximity
233
279
  // Traces without parents that fall within another trace's time window
234
- const parentless = traces.filter((t) => !t.parentToolUseId);
235
- for (const child of parentless) {
280
+ for (const child of traces.filter(unresolved)) {
236
281
  if (!child.stats.startTime)
237
282
  continue;
238
283
  const childStart = child.stats.startTime.getTime();
239
- // Find a trace that was active (Task tool call) right before this child started
284
+ // Find a top-level trace that spawned an agent right before this child started
240
285
  for (const potential of traces) {
241
- if (potential === child)
286
+ if (potential === child || parentOf.has(potential))
242
287
  continue;
243
- if (potential.parentToolUseId)
244
- continue; // Already linked
245
288
  for (const traceEvent of potential.events) {
246
289
  const evt = traceEvent.event;
247
- if (evt.type === 'assistant' && evt.tool?.name === 'Task') {
290
+ if (evt.type === 'assistant' && isSubagentSpawnTool(evt.tool?.name)) {
248
291
  const taskTime = new Date(evt.timestamp).getTime();
249
292
  // Within 2 seconds before child start
250
293
  if (taskTime <= childStart && childStart - taskTime < 2000) {
251
- child.parentToolUseId = potential.agentId;
252
- potential.children.push(child);
294
+ linkHeuristically(potential, child);
253
295
  break;
254
296
  }
255
297
  }
256
298
  }
257
- if (child.parentToolUseId)
299
+ if (parentOf.has(child))
258
300
  break;
259
301
  }
260
302
  }
303
+ return traces.filter((trace) => !parentOf.has(trace));
304
+ }
305
+ /** Ids of the `Agent` / `Task` calls a trace made. */
306
+ function spawnToolUseIdsOf(trace) {
307
+ const ids = [];
308
+ for (const traceEvent of trace.events) {
309
+ const content = traceEvent.event.message?.content;
310
+ if (!Array.isArray(content))
311
+ continue;
312
+ for (const block of content) {
313
+ if (block?.type === 'tool_use' &&
314
+ typeof block.id === 'string' &&
315
+ isSubagentSpawnTool(block.name)) {
316
+ ids.push(block.id);
317
+ }
318
+ }
319
+ }
320
+ return ids;
261
321
  }
262
322
  // ── Helpers ──
263
323
  function rawToSessionEvent(raw) {
@@ -272,7 +332,7 @@ function rawToSessionEvent(raw) {
272
332
  tool_result: 'tool_result',
273
333
  summary: 'summary',
274
334
  };
275
- const mappedType = typeMap[type];
335
+ const mappedType = ownValue(typeMap, type);
276
336
  if (!mappedType)
277
337
  return null;
278
338
  const message = raw.message;
@@ -20,6 +20,7 @@ import { promises as fs } from 'node:fs';
20
20
  import * as path from 'node:path';
21
21
  import { _setCatalogContextWindows } from './modelContext';
22
22
  import { _setPricingOverrides, getPricingCatalogInfo } from './modelInfo';
23
+ import { setOwnValue } from './ownKey';
23
24
  import { getConfigDir } from './paths';
24
25
  import { atomicWriteJson } from './writers/atomic';
25
26
  // ── Constants ──
@@ -117,7 +118,7 @@ export function normalizeLiteLlmCatalog(raw) {
117
118
  continue;
118
119
  // Catalog keys use both `provider/model` and bare `model` forms. Record both
119
120
  // the raw key and the bare-model form so lookups work regardless of caller.
120
- out[key] = pricing;
121
+ setOwnValue(out, key, pricing);
121
122
  const bare = stripProviderPrefix(key);
122
123
  if (bare && bare !== key && !out[bare]) {
123
124
  out[bare] = pricing;
@@ -154,7 +155,7 @@ export function normalizeLiteLlmContextWindows(raw) {
154
155
  const window = entry.max_input_tokens;
155
156
  if (typeof window !== 'number' || !Number.isFinite(window) || window <= 0)
156
157
  continue;
157
- out[key] = window;
158
+ setOwnValue(out, key, window);
158
159
  const bare = stripProviderPrefix(key);
159
160
  if (bare && bare !== key) {
160
161
  const seen = aliasWindows.get(bare) ?? new Set();
@@ -13,7 +13,7 @@ import { StringDecoder } from 'string_decoder';
13
13
  import { readSessionContextSnapshot } from '../context/sessionContext';
14
14
  import { readSessionFileStats } from '../sessionStats';
15
15
  import { JsonlParser } from '../parsers/jsonl';
16
- import { ClaudeUsageDeduper, normalizeClaudeUsage } from '../usage/claudeUsageDedupe';
16
+ import { ClaudeUsageDeduper, normalizeClaudeUsage, } from '../usage/claudeUsageDedupe';
17
17
  import { ProviderDiagnosticTracker } from './diagnostics';
18
18
  import { DirectoryListingCache, splitWatchedPath } from './directoryListingCache';
19
19
  import { encodeWorkspacePath as encodeWsPath, getSessionDirectory as getSessionDir, discoverSessionDirectory as discoverSessionDir, findActiveSession as findActiveSessionPath, findAllSessions as findAllSessionPaths, findSessionsInDirectory as findSessionsInDir, decodeEncodedPath, getAllProjectFolders as getAllProjectFoldersRaw, findSessionFilesWithStats, } from '../parsers/sessionPathResolver';
@@ -119,7 +119,8 @@ class ClaudeCodeReader {
119
119
  this.usageDeduper = usageDeduper ?? new ClaudeUsageDeduper();
120
120
  this.parser = new JsonlParser({
121
121
  onEvent: (raw) => {
122
- for (const event of extractSessionEvents(raw)) {
122
+ // Mid-turn task notifications finish background subagents.
123
+ for (const event of extractSessionEvents(raw, { includeTaskNotifications: true })) {
123
124
  this.events.push(this.usageDeduper.apply(normalizeClaudeUsage(event)));
124
125
  }
125
126
  },
@@ -208,6 +209,13 @@ class ClaudeCodeReader {
208
209
  wasTruncated() {
209
210
  return this._wasTruncated;
210
211
  }
212
+ /** The usage deduper's recent message ids, so a resumed reader does not recount a split response. */
213
+ getResumeState() {
214
+ return this.usageDeduper.exportState();
215
+ }
216
+ restoreResumeState(state) {
217
+ return this.usageDeduper.importState(state);
218
+ }
211
219
  }
212
220
  /**
213
221
  * Session provider for Claude Code CLI.