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,107 @@
1
+ /**
2
+ * Where a Codex rollout came from, read from its `session_meta` record:
3
+ * a session a person started, a fork of another session, or a subagent.
4
+ *
5
+ * Codex writes `session_meta.source` as a plain string for sessions started
6
+ * from a surface (`cli`, `vscode`, `exec`, …) and as an object for subagents:
7
+ * `{ subagent: { thread_spawn: { parent_thread_id, … } } }` for a spawned
8
+ * agent, `{ subagent: { other: 'guardian' } }` for internal reviewers, or
9
+ * `{ subagent: 'review' }`. The same JSON appears as a string in the state
10
+ * database's `threads.source`. A forked rollout records `forked_from_id`, and
11
+ * embeds its parent's `session_meta` after its own, so only the first counts.
12
+ *
13
+ * Pure: no filesystem access. Browser-safe.
14
+ *
15
+ * @module parsers/codexRolloutOrigin
16
+ */
17
+ function record(value) {
18
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
19
+ ? value
20
+ : undefined;
21
+ }
22
+ function nonEmpty(value) {
23
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
24
+ }
25
+ /** A `source` value as an object: rollouts store it parsed, the database as JSON text. */
26
+ function sourceObject(source) {
27
+ if (typeof source !== 'string')
28
+ return record(source);
29
+ if (!source.startsWith('{'))
30
+ return undefined;
31
+ try {
32
+ return record(JSON.parse(source));
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ }
38
+ /**
39
+ * Parent thread id of a spawned subagent's `source` (object, or JSON text as
40
+ * stored in `threads.source`): `subagent.thread_spawn.parent_thread_id`.
41
+ */
42
+ export function codexSpawnParentFromSource(source) {
43
+ const spawn = record(record(sourceObject(source)?.subagent)?.thread_spawn);
44
+ const parent = spawn?.parent_thread_id;
45
+ return typeof parent === 'string' ? parent : undefined;
46
+ }
47
+ function subagentKindOf(subagent) {
48
+ if (typeof subagent === 'string')
49
+ return nonEmpty(subagent);
50
+ const variant = record(subagent);
51
+ if (!variant)
52
+ return undefined;
53
+ const other = nonEmpty(variant.other);
54
+ if (other)
55
+ return other;
56
+ return Object.keys(variant)[0];
57
+ }
58
+ /** The `session_meta` payload of a record, of a bare payload, or of the first in a list. */
59
+ function metaPayload(input) {
60
+ if (Array.isArray(input)) {
61
+ for (const item of input) {
62
+ const row = record(item);
63
+ if (row?.type === 'session_meta')
64
+ return record(row.payload);
65
+ }
66
+ return undefined;
67
+ }
68
+ const row = record(input);
69
+ if (!row)
70
+ return undefined;
71
+ if (row.type === 'session_meta')
72
+ return record(row.payload);
73
+ // A payload on its own, as `readSessionMeta()` returns it.
74
+ if (row.type === undefined && row.payload === undefined)
75
+ return row;
76
+ return undefined;
77
+ }
78
+ /**
79
+ * Origin of a Codex rollout from its `session_meta`. Accepts the parsed
80
+ * `session_meta` record, its payload, or the rollout's records in order (the
81
+ * first `session_meta` wins: a fork embeds its parent's after its own).
82
+ * Anything else yields `{ isSubagent: false }`.
83
+ */
84
+ export function codexRolloutOrigin(input) {
85
+ const payload = metaPayload(input);
86
+ if (!payload)
87
+ return { isSubagent: false };
88
+ const forkedFromId = nonEmpty(payload.forked_from_id);
89
+ const object = sourceObject(payload.source);
90
+ const isSubagent = object !== undefined && 'subagent' in object;
91
+ const source = typeof payload.source === 'string' && object === undefined
92
+ ? nonEmpty(payload.source)
93
+ : isSubagent
94
+ ? 'subagent'
95
+ : undefined;
96
+ const parentId = forkedFromId ??
97
+ nonEmpty(codexSpawnParentFromSource(object)) ??
98
+ (isSubagent ? nonEmpty(payload.parent_thread_id) : undefined);
99
+ const subagentKind = isSubagent ? subagentKindOf(object?.subagent) : undefined;
100
+ return {
101
+ ...(forkedFromId ? { forkedFromId } : {}),
102
+ ...(source ? { source } : {}),
103
+ isSubagent,
104
+ ...(parentId ? { parentId } : {}),
105
+ ...(subagentKind ? { subagentKind } : {}),
106
+ };
107
+ }
@@ -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.
@@ -115,6 +116,78 @@ function toISOString(time) {
115
116
  return time;
116
117
  return new Date(time).toISOString();
117
118
  }
119
+ /**
120
+ * Token counts from a message in either storage shape: database rows are
121
+ * mapped to flat `cacheRead` / `cacheWrite`, while file-storage messages keep
122
+ * OpenCode's nested `cache: { read, write }`.
123
+ * @internal
124
+ */
125
+ export function openCodeTokenCounts(tokens) {
126
+ const raw = (tokens ?? {});
127
+ const cache = (raw.cache ?? {});
128
+ const num = (value) => (typeof value === 'number' ? value : undefined);
129
+ return {
130
+ input: num(raw.input),
131
+ output: num(raw.output),
132
+ reasoning: num(raw.reasoning),
133
+ cacheRead: num(raw.cacheRead) ?? num(cache.read),
134
+ cacheWrite: num(raw.cacheWrite) ?? num(cache.write),
135
+ total: num(raw.total),
136
+ };
137
+ }
138
+ /**
139
+ * How a stored OpenCode message splits its tokens. Current OpenCode stores
140
+ * every bucket disjoint for every provider: `input` excludes cache reads and
141
+ * writes (since 2025-11) and `output` excludes `reasoning` (since 2026-04),
142
+ * with `total` equal to their sum. Older messages either counted cached input
143
+ * in `input` (OpenAI-compatible providers) or reasoning in `output`. The
144
+ * stored `total` tells the layouts apart; without it the current layout is
145
+ * assumed.
146
+ * @internal
147
+ */
148
+ export function openCodeTokenLayout(tokens) {
149
+ const n = (value) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
150
+ const input = n(tokens.input);
151
+ const output = n(tokens.output);
152
+ const reasoning = n(tokens.reasoning);
153
+ const cache = n(tokens.cacheRead) + n(tokens.cacheWrite);
154
+ const total = n(tokens.total);
155
+ if (total > 0 && total !== input + cache + output + reasoning) {
156
+ if (reasoning > 0 && total === input + cache + output) {
157
+ return { inputIncludesCache: false, reasoningInOutput: true };
158
+ }
159
+ if (cache > 0 && total === input + output + reasoning) {
160
+ return { inputIncludesCache: true, reasoningInOutput: false };
161
+ }
162
+ if (cache > 0 && reasoning > 0 && total === input + output) {
163
+ return { inputIncludesCache: true, reasoningInOutput: true };
164
+ }
165
+ }
166
+ return { inputIncludesCache: false, reasoningInOutput: false };
167
+ }
168
+ /** Normalizes one OpenCode message's or step's stored tokens. @internal */
169
+ export function normalizeOpenCodeUsage(tokens, providerID, extra = {}) {
170
+ const layout = openCodeTokenLayout(tokens);
171
+ return normalizeProviderUsage({
172
+ // `openai` semantics subtract cache reads from input; only an old
173
+ // cache-inclusive message needs that.
174
+ semantics: layout.inputIncludesCache
175
+ ? 'openai'
176
+ : providerID === 'anthropic'
177
+ ? 'anthropic'
178
+ : 'sidekick',
179
+ provider: providerID ?? 'opencode',
180
+ ...(extra.source ? { source: extra.source } : {}),
181
+ ...(extra.model ? { model: extra.model } : {}),
182
+ inputTokens: tokens.input,
183
+ outputTokens: tokens.output,
184
+ cacheReadTokens: tokens.cacheRead,
185
+ cacheWriteTokens: tokens.cacheWrite,
186
+ reasoningTokens: tokens.reasoning,
187
+ reasoningIncludedInOutput: layout.reasoningInOutput,
188
+ ...(extra.reportedCostUsd !== undefined ? { reportedCostUsd: extra.reportedCostUsd } : {}),
189
+ });
190
+ }
118
191
  /**
119
192
  * Converts an OpenCode message and its parts into SessionEvent array.
120
193
  *
@@ -320,31 +393,20 @@ function convertAssistantMessage(message, parts) {
320
393
  : stepFinishCost > 0
321
394
  ? stepFinishCost
322
395
  : undefined;
323
- const usage = {
324
- input_tokens: message.tokens.input || 0,
325
- output_tokens: message.tokens.output || 0,
326
- cache_creation_input_tokens: message.tokens.cacheWrite || 0,
327
- cache_read_input_tokens: message.tokens.cacheRead || 0,
328
- reported_cost: reportedCost,
329
- reasoning_tokens: message.tokens.reasoning || 0,
330
- };
331
- const normalizedUsage = normalizeProviderUsage({
332
- semantics: message.providerID === 'openai'
333
- ? 'openai'
334
- : message.providerID === 'anthropic'
335
- ? 'anthropic'
336
- : 'sidekick',
337
- provider: message.providerID ?? 'opencode',
396
+ const counts = openCodeTokenCounts(message.tokens);
397
+ const normalizedUsage = normalizeOpenCodeUsage(counts, message.providerID, {
338
398
  source: 'opencode-message',
339
399
  model: message.modelID,
340
- inputTokens: message.tokens.input,
341
- outputTokens: message.tokens.output,
342
- cacheReadTokens: message.tokens.cacheRead,
343
- cacheWriteTokens: message.tokens.cacheWrite,
344
- reasoningTokens: message.tokens.reasoning,
345
- reasoningIncludedInOutput: message.providerID === 'openai',
346
400
  reportedCostUsd: reportedCost,
347
401
  });
402
+ const usage = {
403
+ input_tokens: normalizedUsage.uncachedInputTokens,
404
+ output_tokens: counts.output || 0,
405
+ cache_creation_input_tokens: counts.cacheWrite || 0,
406
+ cache_read_input_tokens: counts.cacheRead || 0,
407
+ reported_cost: reportedCost,
408
+ reasoning_tokens: counts.reasoning || 0,
409
+ };
348
410
  const timestamp = toISOString(message.time.completed || message.time.created);
349
411
  const retryAttempts = parts
350
412
  .filter((part) => part.type === 'retry')
@@ -483,6 +545,7 @@ export function parseDbMessageData(row) {
483
545
  cacheRead: cache?.read || 0,
484
546
  cacheWrite: cache?.write || 0,
485
547
  reasoning: tokens?.reasoning || 0,
548
+ ...(typeof tokens?.total === 'number' ? { total: tokens.total } : {}),
486
549
  },
487
550
  time: {
488
551
  created: time?.created || row.time_created,
@@ -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;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `sidekick-shared/pricing` — model pricing, model-id identity, usage
3
+ * normalization and cost, cost provenance, cost/token formatting, and text
4
+ * token estimates, as one small pure entry (no filesystem, network, or Zod).
5
+ *
6
+ * For hosts that price usage or format costs without session reading: it
7
+ * loads a handful of modules instead of the full browser surface. LiteLLM
8
+ * catalog hydration stays Node-only in `sidekick-shared/node`
9
+ * (`hydratePricingCatalog`); once hydrated, lookups here see the catalog.
10
+ * Everything here is also exported from the package root and
11
+ * `sidekick-shared/browser`.
12
+ *
13
+ * @module pricing
14
+ */
15
+ export { MODEL_PROVIDER_IDS, canonicalModelId, isMovingModelAlias, parseModelId, compareModelIds, sortModelIds, shortModelName, getModelDisplayInfo, getModelInfo, getModelPricing, resolveModelPricing, registerModelPricing, clearRegisteredModelPricing, getPricingCatalogInfo, calculateCost, calculateCostWithPricing, calculateCostWithProvenance, mergeCostSources, costSourceFromPricingProvenance, mergeCostComponents, formatCost, } from './modelInfo';
16
+ export { normalizeProviderUsage, extractNormalizedUsage, calculateNormalizedUsageCost, } from './usageNormalization';
17
+ export { formatTokenCount, formatDurationMs } from './formatting';
18
+ export { ASCII_CHARS_PER_TOKEN, estimateTextTokens, estimateSerializedTokens, } from './tokenEstimation';
@@ -19,7 +19,8 @@
19
19
  import { promises as fs } from 'node:fs';
20
20
  import * as path from 'node:path';
21
21
  import { _setCatalogContextWindows } from './modelContext';
22
- import { _setPricingOverrides } from './modelInfo';
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 ──
@@ -36,6 +37,10 @@ const DEFAULT_TIMEOUT_MS = 3_000;
36
37
  * 2. Otherwise attempt a network fetch; on success, persist + apply.
37
38
  * 3. On any failure, load a stale cache if present; else leave overrides empty.
38
39
  *
40
+ * `getPricingCatalogInfo().source` afterwards reads `litellm` for a fresh fetch
41
+ * or a cache within `ttlMs`, and `stale-cache` for a cache older than `ttlMs`
42
+ * — whether the refresh failed or `offline` skipped it.
43
+ *
39
44
  * Always resolves — never throws. Offline-safe.
40
45
  */
41
46
  export async function hydratePricingCatalog(options = {}) {
@@ -44,8 +49,9 @@ export async function hydratePricingCatalog(options = {}) {
44
49
  const now = Date.now();
45
50
  // 1. Fresh cache? (Offline callers take any cache, however old.)
46
51
  const cached = await readCache(cachePath);
47
- if (cached && (offline || now - Date.parse(cached.fetchedAt) < ttlMs)) {
48
- return applyCacheFile(cached, 'cache');
52
+ const cacheFresh = cached !== null && now - Date.parse(cached.fetchedAt) < ttlMs;
53
+ if (cached && (offline || cacheFresh)) {
54
+ return applyCacheFile(cached, 'cache', cacheFresh ? 'litellm' : 'stale-cache');
49
55
  }
50
56
  // 2. Try network.
51
57
  if (fetchImpl && !offline) {
@@ -58,12 +64,12 @@ export async function hydratePricingCatalog(options = {}) {
58
64
  contextWindows: normalizeLiteLlmContextWindows(fetched),
59
65
  };
60
66
  await writeCache(cachePath, payload, logger);
61
- return applyCacheFile(payload, 'network');
67
+ return applyCacheFile(payload, 'network', 'litellm');
62
68
  }
63
69
  }
64
70
  // 3. Fall back to stale cache if present.
65
71
  if (cached) {
66
- return applyCacheFile(cached, 'cache');
72
+ return applyCacheFile(cached, 'cache', 'stale-cache');
67
73
  }
68
74
  // 4. Nothing we can do — stay on static tables.
69
75
  return {
@@ -71,6 +77,7 @@ export async function hydratePricingCatalog(options = {}) {
71
77
  entries: 0,
72
78
  contextWindowEntries: 0,
73
79
  fetchedAt: new Date(now).toISOString(),
80
+ digest: getPricingCatalogInfo().digest,
74
81
  };
75
82
  }
76
83
  /**
@@ -78,9 +85,9 @@ export async function hydratePricingCatalog(options = {}) {
78
85
  * `contextWindows` is absent on caches written before context hydration; those
79
86
  * contribute no overrides rather than clearing whatever is already loaded.
80
87
  */
81
- function applyCacheFile(cache, source) {
88
+ function applyCacheFile(cache, source, basis) {
82
89
  const contextWindows = cache.contextWindows ?? {};
83
- _setPricingOverrides(cache.overrides);
90
+ _setPricingOverrides(cache.overrides, { source: basis, fetchedAt: cache.fetchedAt });
84
91
  if (Object.keys(contextWindows).length > 0) {
85
92
  _setCatalogContextWindows(contextWindows);
86
93
  }
@@ -89,6 +96,7 @@ function applyCacheFile(cache, source) {
89
96
  entries: Object.keys(cache.overrides).length,
90
97
  contextWindowEntries: Object.keys(contextWindows).length,
91
98
  fetchedAt: cache.fetchedAt,
99
+ digest: getPricingCatalogInfo().digest,
92
100
  };
93
101
  }
94
102
  /**
@@ -110,7 +118,7 @@ export function normalizeLiteLlmCatalog(raw) {
110
118
  continue;
111
119
  // Catalog keys use both `provider/model` and bare `model` forms. Record both
112
120
  // the raw key and the bare-model form so lookups work regardless of caller.
113
- out[key] = pricing;
121
+ setOwnValue(out, key, pricing);
114
122
  const bare = stripProviderPrefix(key);
115
123
  if (bare && bare !== key && !out[bare]) {
116
124
  out[bare] = pricing;
@@ -147,7 +155,7 @@ export function normalizeLiteLlmContextWindows(raw) {
147
155
  const window = entry.max_input_tokens;
148
156
  if (typeof window !== 'number' || !Number.isFinite(window) || window <= 0)
149
157
  continue;
150
- out[key] = window;
158
+ setOwnValue(out, key, window);
151
159
  const bare = stripProviderPrefix(key);
152
160
  if (bare && bare !== key) {
153
161
  const seen = aliasWindows.get(bare) ?? new Set();