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
@@ -270,6 +270,12 @@ class CodexReader {
270
270
  lineBuffer = '';
271
271
  _wasTruncated = false;
272
272
  decoder = new StringDecoder('utf8');
273
+ /**
274
+ * Position from `seekTo()` whose prefix still has to be parsed for model,
275
+ * turn, and cumulative-token context; `restoreResumeState()` supplies that
276
+ * context instead and clears it.
277
+ */
278
+ pendingSeek = null;
273
279
  constructor(rolloutPath, onContextWindowLimit, onRateLimits, onTokenUsage) {
274
280
  this.rolloutPath = rolloutPath;
275
281
  this.onContextWindowLimit = onContextWindowLimit;
@@ -279,9 +285,11 @@ class CodexReader {
279
285
  }
280
286
  /** Get the parser's session metadata. */
281
287
  getSessionMeta() {
288
+ this.settleSeek();
282
289
  return this.parser.getSessionMeta();
283
290
  }
284
291
  readNew(endPosition) {
292
+ this.settleSeek();
285
293
  this._wasTruncated = false;
286
294
  const events = [];
287
295
  try {
@@ -335,47 +343,51 @@ class CodexReader {
335
343
  // Skip malformed JSON lines
336
344
  }
337
345
  }
338
- // Propagate model_context_window from token_count events to the provider.
339
- // The model comes along so the value can be persisted per model — the
340
- // window Codex reports is tier-specific, not the published maximum.
341
- const mcw = this.parser.getModelContextWindow();
342
- if (mcw && this.onContextWindowLimit) {
343
- this.onContextWindowLimit(mcw, this.parser.getCurrentModel() ?? undefined);
344
- }
345
- // Propagate rate_limits from token_count events to the provider
346
- const rateLimits = this.parser.getLastRateLimits();
347
- if (rateLimits && this.onRateLimits) {
348
- this.onRateLimits(rateLimits);
349
- }
350
- // Propagate last token usage snapshot to the provider
351
- const lastUsage = this.parser.getLastTokenUsage();
352
- if (lastUsage && this.onTokenUsage) {
353
- const normalized = normalizeProviderUsage({
354
- semantics: 'openai',
355
- provider: 'openai',
356
- source: 'codex-reader-snapshot',
357
- model: this.parser.getCurrentModel() || undefined,
358
- inputTokens: lastUsage.input_tokens,
359
- outputTokens: lastUsage.output_tokens,
360
- cacheReadTokens: lastUsage.cached_input_tokens,
361
- reasoningTokens: lastUsage.reasoning_output_tokens,
362
- });
363
- this.onTokenUsage({
364
- inputTokens: normalized.uncachedInputTokens,
365
- outputTokens: normalized.outputTokens,
366
- cacheWriteTokens: normalized.cacheWriteTokens,
367
- cacheReadTokens: normalized.cacheReadTokens,
368
- reasoningTokens: normalized.reasoningTokens,
369
- model: this.parser.getCurrentModel() || 'unknown',
370
- timestamp: new Date(),
371
- });
372
- }
346
+ this.propagateParserState();
373
347
  }
374
348
  catch {
375
349
  // Skip read errors
376
350
  }
377
351
  return events;
378
352
  }
353
+ /** Hands the parser's context window, rate limits, and last usage to the provider. */
354
+ propagateParserState() {
355
+ // Propagate model_context_window from token_count events to the provider.
356
+ // The model comes along so the value can be persisted per model — the
357
+ // window Codex reports is tier-specific, not the published maximum.
358
+ const mcw = this.parser.getModelContextWindow();
359
+ if (mcw && this.onContextWindowLimit) {
360
+ this.onContextWindowLimit(mcw, this.parser.getCurrentModel() ?? undefined);
361
+ }
362
+ // Propagate rate_limits from token_count events to the provider
363
+ const rateLimits = this.parser.getLastRateLimits();
364
+ if (rateLimits && this.onRateLimits) {
365
+ this.onRateLimits(rateLimits);
366
+ }
367
+ // Propagate last token usage snapshot to the provider
368
+ const lastUsage = this.parser.getLastTokenUsage();
369
+ if (lastUsage && this.onTokenUsage) {
370
+ const normalized = normalizeProviderUsage({
371
+ semantics: 'openai',
372
+ provider: 'openai',
373
+ source: 'codex-reader-snapshot',
374
+ model: this.parser.getCurrentModel() || undefined,
375
+ inputTokens: lastUsage.input_tokens,
376
+ outputTokens: lastUsage.output_tokens,
377
+ cacheReadTokens: lastUsage.cached_input_tokens,
378
+ reasoningTokens: lastUsage.reasoning_output_tokens,
379
+ });
380
+ this.onTokenUsage({
381
+ inputTokens: normalized.uncachedInputTokens,
382
+ outputTokens: normalized.outputTokens,
383
+ cacheWriteTokens: normalized.cacheWriteTokens,
384
+ cacheReadTokens: normalized.cacheReadTokens,
385
+ reasoningTokens: normalized.reasoningTokens,
386
+ model: this.parser.getCurrentModel() || 'unknown',
387
+ timestamp: new Date(),
388
+ });
389
+ }
390
+ }
379
391
  readAll() {
380
392
  this.reset();
381
393
  return this.readNew();
@@ -387,11 +399,13 @@ class CodexReader {
387
399
  this.parser.reset();
388
400
  this.decoder = new StringDecoder('utf8');
389
401
  this._wasTruncated = false;
402
+ this.pendingSeek = null;
390
403
  }
391
404
  exists() {
392
405
  return fs.existsSync(this.rolloutPath);
393
406
  }
394
407
  flush() {
408
+ this.settleSeek();
395
409
  this.lineBuffer += this.decoder.end();
396
410
  // Process any remaining data in the line buffer
397
411
  if (this.lineBuffer.trim()) {
@@ -409,15 +423,45 @@ class CodexReader {
409
423
  return this.committedPosition;
410
424
  }
411
425
  seekTo(position) {
412
- // Rebuild model, turn, and cumulative-token context before resuming.
413
- // The prefix is parsed only; its events are already in the consumer snapshot.
414
426
  this.reset();
427
+ this.filePosition = position;
428
+ this.committedPosition = position;
429
+ // The prefix's context is rebuilt on the next read, unless
430
+ // restoreResumeState() supplies it first.
415
431
  if (position > 0)
416
- this.readNew(position);
432
+ this.pendingSeek = position;
417
433
  }
418
434
  wasTruncated() {
419
435
  return this._wasTruncated;
420
436
  }
437
+ /** The parser's continuation state (see `CodexRolloutParser.exportResumeState`). */
438
+ getResumeState() {
439
+ this.settleSeek();
440
+ return this.parser.exportResumeState();
441
+ }
442
+ /**
443
+ * Restores parser state from `getResumeState()`; after `seekTo()` this
444
+ * replaces re-parsing the rollout up to that position.
445
+ */
446
+ restoreResumeState(state) {
447
+ if (!this.parser.importResumeState(state))
448
+ return false;
449
+ this.pendingSeek = null;
450
+ this.propagateParserState();
451
+ return true;
452
+ }
453
+ /**
454
+ * Rebuilds model, turn, and cumulative-token context for a pending
455
+ * `seekTo()` position. The prefix is parsed only; its events are already
456
+ * in the consumer snapshot.
457
+ */
458
+ settleSeek() {
459
+ const position = this.pendingSeek;
460
+ if (position === null)
461
+ return;
462
+ this.reset();
463
+ this.readNew(position);
464
+ }
421
465
  }
422
466
  // ---------------------------------------------------------------------------
423
467
  // CodexProvider — full SessionProviderBase implementation
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { z } from 'zod';
10
10
  import { normalizedUsageSchema } from './usageNormalization';
11
+ import { taskNotificationPromptFromAttachment } from '../subagentSpawn';
11
12
  // ── MessageUsage ──
12
13
  export const messageUsageSchema = z.object({
13
14
  input_tokens: z.number(),
@@ -52,7 +53,9 @@ const eventBaseShape = {
52
53
  promptSource: z.string().optional(),
53
54
  cwd: z.string().optional(),
54
55
  gitBranch: z.string().optional(),
55
- permissionMode: permissionModeSchema.optional(),
56
+ // A mode this release doesn't know (Claude Code added `auto`) is dropped
57
+ // rather than rejecting the whole line with its prompt or tool results.
58
+ permissionMode: permissionModeSchema.optional().catch(undefined),
56
59
  rateLimits: z
57
60
  .object({
58
61
  primary: z
@@ -169,22 +172,21 @@ export const sessionEventSchema = z.union([
169
172
  // ── Progress unwrapping ──
170
173
  /** Recursion bound for nested progress envelopes; real data nests 1 level. */
171
174
  const MAX_PROGRESS_DEPTH = 8;
172
- /**
173
- * Extracts canonical SessionEvents from a raw JSONL value.
174
- *
175
- * Claude Code wraps subagent/SDK events as
176
- * `{ type: 'progress', data: { message: <SessionEvent> } }`, which
177
- * `sessionEventSchema` alone rejects. This helper tries a direct parse,
178
- * then unwraps progress envelopes (recursively, in case of nesting).
179
- * Returns zero events for unrecognized input — never throws.
180
- */
181
- export function extractSessionEvents(raw, depth = 0) {
182
- return extractSessionEventsWithProvenance(raw, depth, {});
175
+ export function extractSessionEvents(raw, depthOrOptions = 0) {
176
+ if (typeof depthOrOptions === 'number') {
177
+ return extractSessionEventsWithProvenance(raw, depthOrOptions, {}, {});
178
+ }
179
+ return extractSessionEventsWithProvenance(raw, 0, {}, depthOrOptions ?? {});
183
180
  }
184
- function extractSessionEventsWithProvenance(raw, depth, inherited) {
181
+ function extractSessionEventsWithProvenance(raw, depth, inherited, options) {
185
182
  const direct = sessionEventSchema.safeParse(raw);
186
183
  if (direct.success)
187
184
  return [{ ...inherited, ...direct.data }];
185
+ if (options.includeTaskNotifications) {
186
+ const notification = taskNotificationAttachmentEvent(raw);
187
+ if (notification)
188
+ return [{ ...inherited, ...notification }];
189
+ }
188
190
  if (depth >= MAX_PROGRESS_DEPTH)
189
191
  return [];
190
192
  if (typeof raw === 'object' && raw !== null && raw.type === 'progress') {
@@ -193,12 +195,31 @@ function extractSessionEventsWithProvenance(raw, depth, inherited) {
193
195
  if (typeof data === 'object' && data !== null) {
194
196
  const message = data.message;
195
197
  if (typeof message === 'object' && message !== null) {
196
- return extractSessionEventsWithProvenance(message, depth + 1, provenance);
198
+ return extractSessionEventsWithProvenance(message, depth + 1, provenance, options);
197
199
  }
198
200
  }
199
201
  }
200
202
  return [];
201
203
  }
204
+ /** The `system` event for a task-notification attachment line, or `null`. */
205
+ function taskNotificationAttachmentEvent(raw) {
206
+ const prompt = taskNotificationPromptFromAttachment(raw);
207
+ if (prompt === null)
208
+ return null;
209
+ const line = raw;
210
+ const candidate = {
211
+ type: 'system',
212
+ timestamp: line.timestamp,
213
+ origin: { kind: 'task-notification' },
214
+ message: { role: 'user', sourceLabel: 'task notification', content: prompt },
215
+ };
216
+ for (const key of ['entrypoint', 'isSidechain', 'cwd', 'gitBranch']) {
217
+ if (line[key] !== undefined)
218
+ candidate[key] = line[key];
219
+ }
220
+ const parsed = systemSessionEventSchema.safeParse(candidate);
221
+ return parsed.success ? parsed.data : null;
222
+ }
202
223
  function progressProvenance(raw) {
203
224
  const value = raw;
204
225
  const provenance = {};
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Recognizes Claude Code subagent spawns and background-task notifications.
3
+ *
4
+ * Claude Code's subagent tool is `Agent`; `Task` is its earlier name (and the
5
+ * name OpenCode's `task` tool is normalized to). A background agent's spawning
6
+ * tool call gets its result right away ("Async agent launched successfully.")
7
+ * and its completion arrives later as a `<task-notification>` block, delivered
8
+ * either as a user line or as a mid-turn `queued_command` attachment.
9
+ *
10
+ * Pure: no filesystem access, so it is safe for browser bundles.
11
+ *
12
+ * @module subagentSpawn
13
+ */
14
+ /** Tool names that spawn a subagent: `Agent` (current) and `Task` (earlier name). */
15
+ export const SUBAGENT_SPAWN_TOOL_NAMES = Object.freeze(['Agent', 'Task']);
16
+ const SPAWN_TOOLS = new Set(SUBAGENT_SPAWN_TOOL_NAMES);
17
+ /** Whether a tool name spawns a subagent (`Agent` or `Task`). */
18
+ export function isSubagentSpawnTool(name) {
19
+ return typeof name === 'string' && SPAWN_TOOLS.has(name);
20
+ }
21
+ const ASYNC_LAUNCH_PREFIX = /^\s*Async agent launched successfully\b/;
22
+ const AGENT_ID_LINE = /(?:^|\n)[ \t]*agentId:[ \t]*([A-Za-z0-9][\w.@-]*)/;
23
+ /**
24
+ * Plain text of tool-result or message content: a string as-is, or the text
25
+ * blocks of a content array joined by newlines.
26
+ */
27
+ export function contentText(content) {
28
+ if (typeof content === 'string')
29
+ return content;
30
+ if (!Array.isArray(content))
31
+ return '';
32
+ const parts = [];
33
+ for (const block of content) {
34
+ if (!block || typeof block !== 'object')
35
+ continue;
36
+ const typed = block;
37
+ if (typed.type === 'text' && typeof typed.text === 'string')
38
+ parts.push(typed.text);
39
+ }
40
+ return parts.join('\n');
41
+ }
42
+ /**
43
+ * Recognizes the immediate result of a background spawn
44
+ * (`Async agent launched successfully.` … `agentId: <id> …`). Accepts the
45
+ * result text or the raw tool-result content. Returns `null` for anything
46
+ * else, including a finished foreground agent's result.
47
+ */
48
+ export function parseAsyncAgentLaunch(result) {
49
+ const text = contentText(result);
50
+ if (!ASYNC_LAUNCH_PREFIX.test(text))
51
+ return null;
52
+ const match = AGENT_ID_LINE.exec(text);
53
+ return match ? { agentId: match[1] } : {};
54
+ }
55
+ const NOTIFICATION_OPEN = '<task-notification>';
56
+ const NOTIFICATION_CLOSE = '</task-notification>';
57
+ const NOTIFICATION_PREFIX = /^\s*<task-notification>/;
58
+ const TAG_PATTERNS = {
59
+ taskId: /<task-id>\s*([\s\S]*?)\s*<\/task-id>/,
60
+ toolUseId: /<tool-use-id>\s*([\s\S]*?)\s*<\/tool-use-id>/,
61
+ status: /<status>\s*([\s\S]*?)\s*<\/status>/,
62
+ summary: /<summary>\s*([\s\S]*?)\s*<\/summary>/,
63
+ note: /<note>\s*([\s\S]*?)\s*<\/note>/,
64
+ };
65
+ const INTERIM_TEXT = /waiting on its own background work|background work of its own still running/i;
66
+ function tagValue(body, pattern) {
67
+ const value = pattern.exec(body)?.[1]?.trim();
68
+ return value ? value : undefined;
69
+ }
70
+ /**
71
+ * Parses the first `<task-notification>` block in `text`. Whitespace and
72
+ * newlines between tags, missing fields, and unknown extra fields are
73
+ * tolerated; the block needs a `<task-id>`. Fields are read before the
74
+ * agent's own `<result>` output, which may quote anything.
75
+ */
76
+ export function parseTaskNotification(text) {
77
+ if (typeof text !== 'string')
78
+ return null;
79
+ const start = text.indexOf(NOTIFICATION_OPEN);
80
+ if (start < 0)
81
+ return null;
82
+ let body = text.slice(start + NOTIFICATION_OPEN.length);
83
+ const resultAt = body.indexOf('<result>');
84
+ let header = resultAt >= 0 ? body.slice(0, resultAt) : body;
85
+ if (resultAt < 0) {
86
+ const end = body.indexOf(NOTIFICATION_CLOSE);
87
+ if (end >= 0)
88
+ header = body = body.slice(0, end);
89
+ }
90
+ if (!TAG_PATTERNS.taskId.test(header))
91
+ header = body;
92
+ const taskId = tagValue(header, TAG_PATTERNS.taskId);
93
+ if (!taskId)
94
+ return null;
95
+ const toolUseId = tagValue(header, TAG_PATTERNS.toolUseId);
96
+ const status = tagValue(header, TAG_PATTERNS.status);
97
+ const summary = tagValue(header, TAG_PATTERNS.summary);
98
+ const note = tagValue(header, TAG_PATTERNS.note);
99
+ return {
100
+ taskId,
101
+ ...(toolUseId ? { toolUseId } : {}),
102
+ ...(status ? { status } : {}),
103
+ ...(summary ? { summary } : {}),
104
+ interim: INTERIM_TEXT.test(summary ?? '') || INTERIM_TEXT.test(note ?? ''),
105
+ };
106
+ }
107
+ /**
108
+ * The `<task-notification>` prompt of a raw Claude Code attachment line
109
+ * (`{ type: 'attachment', attachment: { type: 'queued_command', commandMode:
110
+ * 'task-notification', prompt } }`), which is how a notification that lands
111
+ * mid-turn is recorded. `null` for any other value.
112
+ */
113
+ export function taskNotificationPromptFromAttachment(raw) {
114
+ if (!raw || typeof raw !== 'object')
115
+ return null;
116
+ const line = raw;
117
+ if (line.type !== 'attachment' || !line.attachment || typeof line.attachment !== 'object') {
118
+ return null;
119
+ }
120
+ const attachment = line.attachment;
121
+ return attachment.type === 'queued_command' &&
122
+ attachment.commandMode === 'task-notification' &&
123
+ typeof attachment.prompt === 'string'
124
+ ? attachment.prompt
125
+ : null;
126
+ }
127
+ /**
128
+ * The synthetic `system` event `extractSessionEvents(raw, {
129
+ * includeTaskNotifications: true })` makes from a mid-turn task-notification
130
+ * attachment. It is bookkeeping, not a message or a prompt.
131
+ */
132
+ export function isTaskNotificationSystemEvent(event) {
133
+ return event.type === 'system' && event.origin?.kind === 'task-notification';
134
+ }
135
+ /**
136
+ * The task notification an event carries: a user line with
137
+ * `origin.kind === 'task-notification'` or whose text starts with
138
+ * `<task-notification>`, or the opt-in synthetic system event. `null` otherwise.
139
+ */
140
+ export function taskNotificationFromEvent(event) {
141
+ if (event.type !== 'user' && event.type !== 'system')
142
+ return null;
143
+ const flagged = event.origin?.kind === 'task-notification';
144
+ if (event.type === 'system' && !flagged)
145
+ return null;
146
+ const text = contentText(event.message?.content);
147
+ if (!flagged && !NOTIFICATION_PREFIX.test(text))
148
+ return null;
149
+ return parseTaskNotification(text);
150
+ }
151
+ /** Maps a notification `<status>` to an outcome; unknown or missing statuses read as `completed`. */
152
+ export function taskNotificationOutcome(status) {
153
+ switch (status?.toLowerCase()) {
154
+ case 'failed':
155
+ return 'failed';
156
+ case 'killed':
157
+ return 'killed';
158
+ case 'stopped':
159
+ return 'stopped';
160
+ default:
161
+ return 'completed';
162
+ }
163
+ }
@@ -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))
@@ -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);
@@ -8,6 +8,7 @@
8
8
  * FollowEvent is a lossy summary for timeline display and CLI output.
9
9
  */
10
10
  import { formatToolSummary } from '../formatters/toolSummary';
11
+ import { isTaskNotificationSystemEvent, taskNotificationFromEvent } from '../subagentSpawn';
11
12
  /**
12
13
  * Converts a single SessionEvent to one or more FollowEvents.
13
14
  *
@@ -148,6 +149,20 @@ export function toFollowEvents(event, providerId) {
148
149
  break;
149
150
  }
150
151
  case 'system': {
152
+ // A background task's notice (opt-in reader event): its summary, not the raw block.
153
+ const notification = isTaskNotificationSystemEvent(event)
154
+ ? taskNotificationFromEvent(event)
155
+ : null;
156
+ if (notification) {
157
+ events.push({
158
+ providerId,
159
+ type: 'system',
160
+ timestamp: ts,
161
+ summary: notification.summary ?? `Background task ${notification.status ?? 'finished'}`,
162
+ raw: event,
163
+ });
164
+ break;
165
+ }
151
166
  const text = extractTextContent(event.message?.content);
152
167
  const label = event.message?.sourceLabel || event.message?.role || 'system';
153
168
  const summary = text ||
@@ -41,13 +41,15 @@ export function resolveSessionPath(provider, workspacePath, sessionId) {
41
41
  export function createWatcher(options) {
42
42
  const { provider, workspacePath, sessionId, callbacks } = options;
43
43
  const sessionPath = resolveSessionPath(provider, workspacePath, sessionId);
44
- const watcher = createWatcherForProvider(provider, sessionPath, callbacks);
44
+ const watcher = createWatcherForProvider(provider, sessionPath, callbacks, {
45
+ includeTaskNotifications: options.includeTaskNotifications === true,
46
+ });
45
47
  return { watcher, sessionPath };
46
48
  }
47
- function createWatcherForProvider(provider, sessionPath, callbacks) {
49
+ function createWatcherForProvider(provider, sessionPath, callbacks, jsonlOptions) {
48
50
  switch (provider.id) {
49
51
  case 'claude-code':
50
- return new JsonlSessionWatcher(provider.id, sessionPath, callbacks);
52
+ return new JsonlSessionWatcher(provider.id, sessionPath, callbacks, jsonlOptions);
51
53
  case 'codex':
52
54
  return new ProviderReaderSessionWatcher(provider, sessionPath, callbacks);
53
55
  case 'opencode': {