sidekick-shared 0.29.1 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +171 -60
  3. package/dist/aggregation/EventAggregator.d.ts +79 -4
  4. package/dist/aggregation/EventAggregator.js +391 -37
  5. package/dist/aggregation/types.d.ts +27 -3
  6. package/dist/browser.d.ts +7 -3
  7. package/dist/browser.js +23 -3
  8. package/dist/context/sessionContext.js +5 -1
  9. package/dist/esm/aggregation/EventAggregator.js +392 -38
  10. package/dist/esm/browser.js +3 -1
  11. package/dist/esm/context/sessionContext.js +6 -2
  12. package/dist/esm/fileContinuity.js +149 -0
  13. package/dist/esm/index.js +5 -2
  14. package/dist/esm/node.js +2 -0
  15. package/dist/esm/parsers/codexParser.js +192 -3
  16. package/dist/esm/parsers/subagentEndState.js +61 -0
  17. package/dist/esm/parsers/subagentScanner.js +4 -0
  18. package/dist/esm/parsers/subagentTraceParser.js +6 -0
  19. package/dist/esm/promptHistory.js +22 -11
  20. package/dist/esm/providers/claudeCode.js +50 -27
  21. package/dist/esm/providers/codex.js +153 -163
  22. package/dist/esm/providers/codexCompressedReader.js +184 -0
  23. package/dist/esm/providers/codexDatabase.js +47 -2
  24. package/dist/esm/providers/codexRollouts.js +20 -2
  25. package/dist/esm/providers/codexSubagents.js +456 -0
  26. package/dist/esm/providers/compressedRollouts.js +330 -0
  27. package/dist/esm/providers/factory.js +4 -1
  28. package/dist/esm/providers/rolloutWalker.js +32 -4
  29. package/dist/esm/schemas/sessionEvent.js +32 -4
  30. package/dist/esm/sessionMonitor.js +7 -1
  31. package/dist/esm/sessionPreviews.js +9 -0
  32. package/dist/esm/sessionPromptHistory.js +5 -3
  33. package/dist/esm/subagentActivity.js +125 -0
  34. package/dist/esm/subagentDisplayState.js +81 -0
  35. package/dist/esm/subagentSpawn.js +206 -6
  36. package/dist/esm/testing/codexSubagentFixtures.js +336 -0
  37. package/dist/esm/toolCallOrigins.js +17 -10
  38. package/dist/esm/toolCategory.js +6 -2
  39. package/dist/esm/transcript.js +6 -2
  40. package/dist/esm/turns/assistantTurn.js +6 -3
  41. package/dist/esm/usage/usageEvents.js +4 -1
  42. package/dist/esm/watchers/eventBridge.js +31 -1
  43. package/dist/esm/watchers/jsonlTail.js +11 -42
  44. package/dist/esm/watchers/jsonlWatcher.js +19 -9
  45. package/dist/esm/watchers/providerReaderWatcher.js +5 -4
  46. package/dist/fileContinuity.d.ts +95 -0
  47. package/dist/fileContinuity.js +187 -0
  48. package/dist/index.d.ts +14 -7
  49. package/dist/index.js +39 -10
  50. package/dist/node.d.ts +4 -0
  51. package/dist/node.js +16 -2
  52. package/dist/parsers/codexParser.d.ts +44 -0
  53. package/dist/parsers/codexParser.js +192 -3
  54. package/dist/parsers/subagentEndState.d.ts +20 -0
  55. package/dist/parsers/subagentEndState.js +65 -0
  56. package/dist/parsers/subagentScanner.js +4 -0
  57. package/dist/parsers/subagentTraceParser.d.ts +8 -0
  58. package/dist/parsers/subagentTraceParser.js +6 -0
  59. package/dist/promptHistory.d.ts +13 -3
  60. package/dist/promptHistory.js +22 -11
  61. package/dist/providers/claudeCode.js +50 -27
  62. package/dist/providers/codex.d.ts +35 -5
  63. package/dist/providers/codex.js +150 -160
  64. package/dist/providers/codexCompressedReader.d.ts +58 -0
  65. package/dist/providers/codexCompressedReader.js +221 -0
  66. package/dist/providers/codexDatabase.d.ts +25 -0
  67. package/dist/providers/codexDatabase.js +48 -2
  68. package/dist/providers/codexRollouts.d.ts +16 -2
  69. package/dist/providers/codexRollouts.js +20 -2
  70. package/dist/providers/codexSubagents.d.ts +66 -0
  71. package/dist/providers/codexSubagents.js +495 -0
  72. package/dist/providers/compressedRollouts.d.ts +114 -0
  73. package/dist/providers/compressedRollouts.js +380 -0
  74. package/dist/providers/factory.d.ts +5 -0
  75. package/dist/providers/factory.js +4 -1
  76. package/dist/providers/rolloutWalker.d.ts +15 -2
  77. package/dist/providers/rolloutWalker.js +33 -4
  78. package/dist/providers/types.d.ts +22 -1
  79. package/dist/schemas/sessionEvent.d.ts +26 -2
  80. package/dist/schemas/sessionEvent.js +31 -3
  81. package/dist/sessionMonitor.d.ts +6 -0
  82. package/dist/sessionMonitor.js +7 -1
  83. package/dist/sessionPreviews.js +9 -0
  84. package/dist/sessionPromptHistory.d.ts +5 -0
  85. package/dist/sessionPromptHistory.js +5 -36
  86. package/dist/subagentActivity.d.ts +91 -0
  87. package/dist/subagentActivity.js +132 -0
  88. package/dist/subagentDisplayState.d.ts +81 -0
  89. package/dist/subagentDisplayState.js +86 -0
  90. package/dist/subagentSpawn.d.ts +100 -5
  91. package/dist/subagentSpawn.js +218 -7
  92. package/dist/testing/codexSubagentFixtures.d.ts +48 -0
  93. package/dist/testing/codexSubagentFixtures.js +344 -0
  94. package/dist/toolCallOrigins.d.ts +5 -0
  95. package/dist/toolCallOrigins.js +17 -10
  96. package/dist/toolCategory.js +6 -2
  97. package/dist/transcript.js +5 -1
  98. package/dist/turns/assistantTurn.d.ts +4 -1
  99. package/dist/turns/assistantTurn.js +5 -2
  100. package/dist/types/codex.d.ts +71 -2
  101. package/dist/types/sessionEvent.d.ts +38 -3
  102. package/dist/usage/usageEvents.d.ts +4 -1
  103. package/dist/usage/usageEvents.js +4 -1
  104. package/dist/watchers/eventBridge.js +30 -0
  105. package/dist/watchers/jsonlTail.js +11 -42
  106. package/dist/watchers/jsonlWatcher.d.ts +5 -1
  107. package/dist/watchers/jsonlWatcher.js +18 -8
  108. package/dist/watchers/providerReaderWatcher.js +5 -4
  109. package/package.json +1 -1
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Continuity of a file read incrementally by byte offset: whether the file
3
+ * now at a path is still the content earlier reads consumed, or was
4
+ * truncated, rewritten, or replaced since.
5
+ *
6
+ * {@link FileContinuity} holds the rules `createJsonlTail` applies, so every
7
+ * incremental reader detects the same cases the same way:
8
+ *
9
+ * - `replaced`: another file (by device and inode, read from the open
10
+ * descriptor, so a rename between a stat and an open cannot mix two files)
11
+ * now has the path.
12
+ * - `truncated`: the file is shorter than the read position.
13
+ * - `rewritten`: the mtime changed and either the first {@link HEAD_BYTES}
14
+ * bytes differ from those recorded, or the byte before the committed
15
+ * (resume-safe) offset is no longer a newline.
16
+ *
17
+ * Replacement and truncation are checked on every read; the content probe
18
+ * only when the mtime changed or the recorded head is still short.
19
+ *
20
+ * Known limits: a rewrite that leaves the mtime unchanged (coarse mtime
21
+ * resolution, or an mtime restored on purpose) or that changes only bytes
22
+ * past the first 4 KiB while the resume point still follows a newline goes
23
+ * unnoticed; on filesystems without stable inode numbers (some SMB mounts,
24
+ * Windows) an unchanged file can read as replaced, which costs a replay.
25
+ *
26
+ * @module fileContinuity
27
+ */
28
+ import * as fs from 'fs';
29
+ import { fileHeadOf, HEAD_BYTES, headMatches } from './fileHead';
30
+ /**
31
+ * What one reader knows about the file its offsets refer to: identity,
32
+ * mtime, and a digest of the first bytes, recorded by {@link adopt} after
33
+ * each read.
34
+ *
35
+ * Synchronous readers call {@link check} on the open descriptor, restart
36
+ * from offset 0 when it returns a reason, then call {@link adopt}. A reader
37
+ * that repositions without reading (a seek to a stored offset, a reset)
38
+ * calls {@link forget}, so its next read adopts the file instead of
39
+ * comparing it with the old one. The lower-level methods ({@link needsProbe},
40
+ * {@link reasonFor}, {@link record}) serve asynchronous readers that probe
41
+ * on their own.
42
+ */
43
+ export class FileContinuity {
44
+ identity = null;
45
+ head = null;
46
+ lastMtimeMs = -1;
47
+ /** What the last {@link check} probed and decided, for {@link adopt}. */
48
+ checked = null;
49
+ /** Device and inode of the adopted file; `null` before the first read and after {@link forget}. */
50
+ get knownIdentity() {
51
+ return this.identity;
52
+ }
53
+ /** Length and digest of the adopted file's first bytes, once probed. */
54
+ get knownHead() {
55
+ return this.head;
56
+ }
57
+ /** Whether {@link reasonFor} needs bytes from the file to decide. */
58
+ needsProbe(stat, readOffset) {
59
+ return (this.identity === null ||
60
+ stat.dev !== this.identity.dev ||
61
+ stat.ino !== this.identity.ino ||
62
+ stat.size < readOffset ||
63
+ stat.mtimeMs !== this.lastMtimeMs ||
64
+ this.head === null ||
65
+ this.head.bytes < Math.min(HEAD_BYTES, stat.size));
66
+ }
67
+ /**
68
+ * Why the reader must start over: the path now names another file, the
69
+ * file shrank below `readOffset`, its first bytes changed, or the resume
70
+ * point no longer follows a newline. `null` when it can continue.
71
+ */
72
+ reasonFor(stat, readOffset, probe) {
73
+ if (this.identity && (stat.dev !== this.identity.dev || stat.ino !== this.identity.ino)) {
74
+ return 'replaced';
75
+ }
76
+ if (stat.size < readOffset)
77
+ return 'truncated';
78
+ if (this.identity && stat.mtimeMs !== this.lastMtimeMs && probe) {
79
+ if (this.head && !headMatches(this.head, probe.prefix))
80
+ return 'rewritten';
81
+ if (probe.before && probe.before[0] !== 0x0a)
82
+ return 'rewritten';
83
+ }
84
+ return null;
85
+ }
86
+ /** Records the identity, mtime, and (from `probe`) first bytes of the file the offsets now refer to. */
87
+ record(stat, probe) {
88
+ this.identity = { dev: stat.dev, ino: stat.ino };
89
+ this.lastMtimeMs = stat.mtimeMs;
90
+ if (probe && (!this.head || this.head.bytes < Math.min(HEAD_BYTES, stat.size))) {
91
+ this.head = fileHeadOf(probe.prefix);
92
+ }
93
+ }
94
+ /** Drops the recorded first bytes (the reader restarted); the next {@link record} with a probe sets them again. */
95
+ discardHead() {
96
+ this.head = null;
97
+ }
98
+ /** Records the file as known from `prefix`, its first `min(HEAD_BYTES, size)` bytes. */
99
+ adoptPrefix(stat, prefix) {
100
+ this.identity = { dev: stat.dev, ino: stat.ino };
101
+ this.lastMtimeMs = stat.mtimeMs;
102
+ this.head = fileHeadOf(prefix);
103
+ }
104
+ /** Forgets the file, so the next {@link check} adopts whatever file has the path. */
105
+ forget() {
106
+ this.identity = null;
107
+ this.head = null;
108
+ this.lastMtimeMs = -1;
109
+ this.checked = null;
110
+ }
111
+ /**
112
+ * Applies the rules to the file open on `fd` (`stat` from `fstat` on that
113
+ * descriptor), reading the bytes they need. `readOffset` is how far the
114
+ * reader has read; `committedOffset` (default `readOffset`) is its
115
+ * resume-safe position just after a newline. Returns why the reader must
116
+ * restart from offset 0, or `null`. Call {@link adopt} with the same
117
+ * `stat` once the reader has acted on the answer.
118
+ */
119
+ check(fd, stat, readOffset, committedOffset = readOffset) {
120
+ const probe = this.needsProbe(stat, readOffset)
121
+ ? probeFileContinuity(fd, stat, committedOffset)
122
+ : null;
123
+ const reason = this.reasonFor(stat, readOffset, probe);
124
+ this.checked = { probe, reset: reason !== null };
125
+ return reason;
126
+ }
127
+ /** Records the file just checked as the one the reader's offsets now refer to. */
128
+ adopt(stat) {
129
+ const checked = this.checked;
130
+ this.checked = null;
131
+ if (checked?.reset)
132
+ this.head = null;
133
+ this.record(stat, checked?.probe ?? null);
134
+ }
135
+ }
136
+ /** Reads the bytes {@link FileContinuity.reasonFor} compares, from the file open on `fd`. */
137
+ export function probeFileContinuity(fd, stat, committedOffset) {
138
+ return {
139
+ prefix: readAt(fd, 0, Math.min(HEAD_BYTES, stat.size)),
140
+ before: committedOffset > 0 && committedOffset <= stat.size
141
+ ? readAt(fd, committedOffset - 1, 1)
142
+ : null,
143
+ };
144
+ }
145
+ function readAt(fd, position, length) {
146
+ const buffer = Buffer.alloc(length);
147
+ const count = length > 0 ? fs.readSync(fd, buffer, 0, length, position) : 0;
148
+ return buffer.subarray(0, count);
149
+ }
package/dist/esm/index.js CHANGED
@@ -51,6 +51,7 @@ export { ClaudeCodeProvider } from './providers/claudeCode';
51
51
  export { OpenCodeProvider, getOpenCodeDataDir } from './providers/openCode';
52
52
  export { CodexProvider, findCodexRolloutFile } from './providers/codex';
53
53
  export { walkRolloutFiles, walkRolloutFilesAsync } from './providers/rolloutWalker';
54
+ export { COMPRESSED_ROLLOUT_SUFFIX, isCompressedRolloutPath, isZstdDecompressionAvailable, readRolloutBuffer, readRolloutText, resolveRolloutPath, } from './providers/compressedRollouts';
54
55
  // Parsers — JSONL
55
56
  export { JsonlParser, TRUNCATION_PATTERNS } from './parsers/jsonl';
56
57
  // Parsers — OpenCode
@@ -58,6 +59,7 @@ export { normalizeToolName, normalizeToolInput, detectPlanModeFromText, convertO
58
59
  // Parsers — Codex
59
60
  export { CodexRolloutParser, DEFAULT_MAX_TRACKED_TOOL_CALLS, extractPatchFilePaths, normalizeCodexToolName, normalizeCodexToolInput, resolveCodexCallUsage, } from './parsers/codexParser';
60
61
  export { codexRolloutOrigin, codexSpawnParentFromSource } from './parsers/codexRolloutOrigin';
62
+ export { SUBAGENT_ACTIVITY_BLOCK_TYPE, SUBAGENT_ACTIVITY_ORIGIN_KIND, describeSubagentActivity, isSubagentActivitySystemEvent, parseCodexAgentMessageText, subagentActivityFromEvent, } from './subagentActivity';
61
63
  // Parsers — Subagent scanning
62
64
  export { scanSubagentDir, extractTaskInfo, readAgentMeta } from './parsers/subagentScanner';
63
65
  // Usage — split-line dedupe, subagent tokens, session totals
@@ -80,7 +82,7 @@ export { listSessionPreviews, listSessionPreviewsAsync, readSessionPreview, read
80
82
  export { parseDebugLog, filterByLevel, collapseDuplicates, discoverDebugLogs, } from './parsers/debugLogParser';
81
83
  // Database wrappers
82
84
  export { OpenCodeDatabase } from './providers/openCodeDatabase';
83
- export { CodexDatabase } from './providers/codexDatabase';
85
+ export { CodexDatabase, isCodexSubagentThread } from './providers/codexDatabase';
84
86
  // Search
85
87
  export { searchSessions } from './search/sessionSearch';
86
88
  export { FilterEngine } from './search/advancedFilter';
@@ -88,7 +90,8 @@ export { FilterEngine } from './search/advancedFilter';
88
90
  export { composeContext } from './context/composer';
89
91
  export { buildSessionContextSnapshot, calculateSessionContextPressure, createSessionContextProjector, readSessionContextSnapshot, } from './context/sessionContext';
90
92
  // Subagent spawns (`Agent` / `Task`) and background-task notifications
91
- export { SUBAGENT_SPAWN_TOOL_NAMES, isSubagentSpawnTool, isTaskNotificationSystemEvent, parseTaskNotification, taskNotificationFromEvent, } from './subagentSpawn';
93
+ export { SUBAGENT_SPAWN_TOOL_NAMES, agentsStoppedNoticeFromEvent, isAgentsKilledSystemEvent, isCodexSubagentSpawnTool, isSubagentSpawnTool, isTaskNotificationSystemEvent, isTaskStatusSystemEvent, parseAgentsStoppedNotice, parseCodexSpawnLaunch, parseTaskNotification, taskNotificationFromEvent, taskStatusFromAttachment, taskStatusFromEvent, } from './subagentSpawn';
94
+ export { SUBAGENT_PARENT_IDLE_ENDED_MS, SUBAGENT_UNREPORTED_QUIET_MS, resolveSubagentDisplayState, subagentDisplayLabel, } from './subagentDisplayState';
92
95
  // Assistant turn projection
93
96
  export { assistantTurnEventsFromSessionEvents, extractTurnSubagents, isAssistantTurnSubagentTool, reasoningSummary, segmentAssistantTurn, } from './turns/assistantTurn';
94
97
  // Plan Extraction
package/dist/esm/node.js CHANGED
@@ -24,6 +24,7 @@ export { ACTIVE_SESSION_MTIME_THRESHOLD_MS, importSessionHistory, } from './usag
24
24
  export { bucketUsage, summarizeUsageRows, usageBucketKey, weekKey } from './usage/usageReports';
25
25
  export { findCodexRolloutFile } from './providers/codex';
26
26
  export { walkRolloutFiles, walkRolloutFilesAsync } from './providers/rolloutWalker';
27
+ export { COMPRESSED_ROLLOUT_SUFFIX, isCompressedRolloutPath, isZstdDecompressionAvailable, readRolloutBuffer, readRolloutText, resolveRolloutPath, } from './providers/compressedRollouts';
27
28
  export { readCodexHistory } from './parsers/codexHistory';
28
29
  export { listSessionPreviews, listSessionPreviewsAsync, readSessionPreview, readSessionPreviewAsync, } from './sessionPreviews';
29
30
  export { ObservedSessionCollector, observedSessionSourceFromProvider, fileFingerprint, fileFingerprintParts, } from './observedSessionCollector';
@@ -41,6 +42,7 @@ export { parseAuthJson as parseCodexAuthJson, readAuthIdentityFromRaw as readCod
41
42
  export { atomicWriteJson, atomicWriteJsonSync } from './writers/atomic';
42
43
  export { parseClaudeAuthStatus, parseCodexLoginStatus } from './authStatus';
43
44
  export { codexRolloutOrigin, codexSpawnParentFromSource } from './parsers/codexRolloutOrigin';
45
+ export { SUBAGENT_ACTIVITY_BLOCK_TYPE, SUBAGENT_ACTIVITY_ORIGIN_KIND, describeSubagentActivity, isSubagentActivitySystemEvent, parseCodexAgentMessageText, subagentActivityFromEvent, } from './subagentActivity';
44
46
  export { getPricingCatalogInfo, registerModelPricing, clearRegisteredModelPricing, } from './modelInfo';
45
47
  export { fetchProviderServiceStatus } from './providerServiceStatus';
46
48
  // Tool categories across providers
@@ -14,6 +14,7 @@
14
14
  import { isAggregateCodexLimit } from '../types/codex';
15
15
  import { normalizeToolName } from './openCodeParser';
16
16
  import { normalizeProviderUsage } from '../usageNormalization';
17
+ import { SUBAGENT_ACTIVITY_BLOCK_TYPE, SUBAGENT_ACTIVITY_ORIGIN_KIND, parseCodexAgentMessageText, } from '../subagentActivity';
17
18
  function reportedCompaction(tokensBefore, tokensAfter) {
18
19
  if (typeof tokensBefore !== 'number' ||
19
20
  typeof tokensAfter !== 'number' ||
@@ -173,6 +174,29 @@ export function extractPatchFilePaths(input) {
173
174
  export const DEFAULT_MAX_TRACKED_TOOL_CALLS = 4096;
174
175
  /** Entries per table {@link CodexRolloutParser.exportResumeState} keeps by default. */
175
176
  const DEFAULT_RESUME_STATE_LIMIT = 256;
177
+ /** Raw names of Codex's multi-agent tools (namespace `collaboration`). */
178
+ const COLLABORATION_TOOL_NAMES = new Set([
179
+ 'spawn_agent',
180
+ 'followup_task',
181
+ 'send_message',
182
+ 'interrupt_agent',
183
+ 'wait_agent',
184
+ 'list_agents',
185
+ ]);
186
+ const SUBAGENT_ACTIVITY_KINDS = new Set([
187
+ 'started',
188
+ 'completed',
189
+ 'interacted',
190
+ 'interrupted',
191
+ ]);
192
+ function nonEmptyString(value) {
193
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
194
+ }
195
+ /** `subagent_history_start_ordinal` of a session_meta payload, when valid. */
196
+ function historyStartOrdinalOf(meta) {
197
+ const value = meta?.subagent_history_start_ordinal;
198
+ return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null;
199
+ }
176
200
  function isPlainObject(value) {
177
201
  return !!value && typeof value === 'object' && !Array.isArray(value);
178
202
  }
@@ -290,6 +314,15 @@ export class CodexRolloutParser {
290
314
  /** Byte budget for pending calls; `null` = unlimited (no accounting). */
291
315
  maxPendingBytes;
292
316
  metadataOnly;
317
+ includeSubagentActivity;
318
+ /**
319
+ * Set once a forked child's rollout embeds an ancestor's session_meta: its
320
+ * own `subagent_history_start_ordinal`, below which records are the
321
+ * parent's history and are skipped. `null` while nothing is skipped.
322
+ */
323
+ historyStartOrdinal = null;
324
+ /** Multi-agent tool calls (call id → raw name), only with `includeSubagentActivity`. */
325
+ collaborationCalls = new Map();
293
326
  /** Pending calls in insertion order, for the byte budget. */
294
327
  pendingLedger = new Map();
295
328
  pendingBytes = 0;
@@ -305,6 +338,7 @@ export class CodexRolloutParser {
305
338
  ? Math.floor(maxBytes)
306
339
  : null;
307
340
  this.metadataOnly = options.metadataOnly === true;
341
+ this.includeSubagentActivity = options.includeSubagentActivity === true;
308
342
  }
309
343
  /** Counters for the pending tool-call state and what the caps evicted. */
310
344
  getPendingStats() {
@@ -337,6 +371,16 @@ export class CodexRolloutParser {
337
371
  getLastRateLimits() {
338
372
  return this.lastRateLimits;
339
373
  }
374
+ /**
375
+ * In a forked subagent's rollout, the ordinal of its first own record:
376
+ * records with a lower `ordinal` are the parent history the fork embeds
377
+ * and produce no events. Known once the rollout's second line (an embedded
378
+ * ancestor `session_meta`) was parsed; `null` otherwise, and for every
379
+ * rollout that is not such a fork.
380
+ */
381
+ getInheritedHistoryEnd() {
382
+ return this.historyStartOrdinal;
383
+ }
340
384
  /**
341
385
  * The state later lines depend on — session metadata, current model,
342
386
  * previous cumulative token total, context window, rate limits, plan mode,
@@ -358,6 +402,14 @@ export class CodexRolloutParser {
358
402
  patchExpandedToolUseIds: newest(this.patchExpandedToolUseIds, this.patchExpandedToolUseIds.size, limit),
359
403
  pendingExecCommands: newest(this.pendingExecCommands.values(), this.pendingExecCommands.size, limit),
360
404
  pendingMcpToolCalls: newest(this.pendingMcpToolCalls.values(), this.pendingMcpToolCalls.size, limit),
405
+ ...(this.historyStartOrdinal !== null
406
+ ? { inheritedHistoryEnd: this.historyStartOrdinal }
407
+ : {}),
408
+ ...(this.collaborationCalls.size > 0
409
+ ? {
410
+ collaborationCalls: newest(this.collaborationCalls, this.collaborationCalls.size, limit),
411
+ }
412
+ : {}),
361
413
  });
362
414
  }
363
415
  /**
@@ -418,6 +470,21 @@ export class CodexRolloutParser {
418
470
  typeof entry.timestamp === 'string')) {
419
471
  return false;
420
472
  }
473
+ const inheritedEnd = s.inheritedHistoryEnd;
474
+ if (inheritedEnd !== undefined &&
475
+ inheritedEnd !== null &&
476
+ !(typeof inheritedEnd === 'number' && Number.isInteger(inheritedEnd) && inheritedEnd > 0)) {
477
+ return false;
478
+ }
479
+ const collaborationCalls = s.collaborationCalls;
480
+ if (collaborationCalls !== undefined &&
481
+ !(Array.isArray(collaborationCalls) &&
482
+ collaborationCalls.every((entry) => Array.isArray(entry) &&
483
+ entry.length === 2 &&
484
+ typeof entry[0] === 'string' &&
485
+ typeof entry[1] === 'string'))) {
486
+ return false;
487
+ }
421
488
  const copy = cloneJson({
422
489
  sessionMeta: s.sessionMeta,
423
490
  lastRateLimits: s.lastRateLimits,
@@ -427,6 +494,7 @@ export class CodexRolloutParser {
427
494
  });
428
495
  this.reset();
429
496
  this.sessionMeta = copy.sessionMeta;
497
+ this.historyStartOrdinal = typeof inheritedEnd === 'number' ? inheritedEnd : null;
430
498
  this.currentModel = s.currentModel;
431
499
  this.prevTotalUsage = prevTotalUsage;
432
500
  this.lastTokenUsage = lastTokenUsage;
@@ -447,15 +515,28 @@ export class CodexRolloutParser {
447
515
  for (const pending of copy.mcps) {
448
516
  this.putPendingMcp(this.metadataOnly ? { ...pending, arguments: undefined } : pending);
449
517
  }
518
+ if (this.includeSubagentActivity && Array.isArray(collaborationCalls)) {
519
+ for (const [callId, name] of collaborationCalls) {
520
+ this.rememberCollaborationCall(callId, name);
521
+ }
522
+ }
450
523
  return true;
451
524
  }
452
525
  /**
453
526
  * Converts a single rollout line to zero or more session events.
454
527
  */
455
528
  convertLine(line) {
529
+ if (line.type === 'session_meta') {
530
+ return this.handleSessionMeta(line.timestamp, line.payload, line.ordinal);
531
+ }
532
+ // A forked subagent rollout embeds the parent's history before its own
533
+ // records; that history is the parent's, not the child's.
534
+ if (this.historyStartOrdinal !== null &&
535
+ typeof line.ordinal === 'number' &&
536
+ line.ordinal < this.historyStartOrdinal) {
537
+ return [];
538
+ }
456
539
  switch (line.type) {
457
- case 'session_meta':
458
- return this.handleSessionMeta(line.timestamp, line.payload);
459
540
  case 'response_item':
460
541
  return this.handleResponseItem(line.timestamp, line.payload);
461
542
  case 'compacted':
@@ -471,6 +552,8 @@ export class CodexRolloutParser {
471
552
  /** Reset all parser state. */
472
553
  reset() {
473
554
  this.sessionMeta = null;
555
+ this.historyStartOrdinal = null;
556
+ this.collaborationCalls.clear();
474
557
  this.currentModel = null;
475
558
  this.pendingExecCommands.clear();
476
559
  this.pendingMcpToolCalls.clear();
@@ -560,7 +643,25 @@ export class CodexRolloutParser {
560
643
  return this.metadataOnly ? '' : content;
561
644
  }
562
645
  // --- Handlers ---
563
- handleSessionMeta(timestamp, payload) {
646
+ handleSessionMeta(timestamp, payload, ordinal) {
647
+ if (this.sessionMeta) {
648
+ // Only the rollout's own (first) session_meta counts. A forked child
649
+ // embeds its parent's right after its own, followed by the parent's
650
+ // history up to the child's `subagent_history_start_ordinal`; from
651
+ // here on, records below that ordinal are skipped. (Children whose
652
+ // rollout has no embedded meta keep every record: in some, Codex
653
+ // recorded a start ordinal past their own records.)
654
+ const start = historyStartOrdinalOf(this.sessionMeta);
655
+ if (this.historyStartOrdinal === null &&
656
+ start !== null &&
657
+ typeof ordinal === 'number' &&
658
+ ordinal < start) {
659
+ this.historyStartOrdinal = start;
660
+ }
661
+ return [];
662
+ }
663
+ if (!payload || typeof payload !== 'object')
664
+ return [];
564
665
  this.sessionMeta = payload;
565
666
  const text = payload.base_instructions?.text?.trim();
566
667
  return [
@@ -600,10 +701,91 @@ export class CodexRolloutParser {
600
701
  return this.handleCustomToolCall(timestamp, payload);
601
702
  case 'custom_tool_call_output':
602
703
  return this.handleCustomToolCallOutput(timestamp, payload);
704
+ case 'agent_message':
705
+ return this.handleAgentMessage(timestamp, payload);
603
706
  default:
604
707
  return [];
605
708
  }
606
709
  }
710
+ // --- Subagent activity ---
711
+ rememberCollaborationCall(callId, name) {
712
+ this.collaborationCalls.delete(callId);
713
+ this.collaborationCalls.set(callId, name);
714
+ while (this.collaborationCalls.size > this.maxTrackedToolCalls) {
715
+ this.collaborationCalls.delete(this.collaborationCalls.keys().next().value);
716
+ }
717
+ }
718
+ subagentActivityEvent(timestamp, id, activity) {
719
+ return {
720
+ type: 'system',
721
+ timestamp,
722
+ origin: { kind: SUBAGENT_ACTIVITY_ORIGIN_KIND },
723
+ message: {
724
+ role: 'system',
725
+ id: `subagent-activity:${id}`,
726
+ sourceLabel: 'subagent activity',
727
+ content: [{ type: SUBAGENT_ACTIVITY_BLOCK_TYPE, ...activity }],
728
+ },
729
+ providerMetadata: { providerId: 'codex', source: 'codex-rollout' },
730
+ };
731
+ }
732
+ /** A child's `FINAL_ANSWER` message to its parent (opt-in). */
733
+ handleAgentMessage(timestamp, item) {
734
+ if (!this.includeSubagentActivity || !Array.isArray(item.content))
735
+ return [];
736
+ const first = item.content.find((part) => !!part && typeof part === 'object' && typeof part.text === 'string');
737
+ const parsed = parseCodexAgentMessageText(first?.text);
738
+ if (!parsed || parsed.messageType !== 'FINAL_ANSWER')
739
+ return [];
740
+ const agentPath = nonEmptyString(item.author) ?? parsed.sender;
741
+ const recipient = nonEmptyString(item.recipient) ?? parsed.taskName;
742
+ const text = this.metadataOnly ? undefined : nonEmptyString(parsed.payload.trim());
743
+ const activity = {
744
+ kind: 'final-answer',
745
+ ...(agentPath ? { agentPath } : {}),
746
+ ...(recipient ? { recipient } : {}),
747
+ ...(text ? { text } : {}),
748
+ };
749
+ return [
750
+ this.subagentActivityEvent(timestamp, nonEmptyString(item.id) ?? `final-answer-${timestamp}-${agentPath ?? ''}`, activity),
751
+ ];
752
+ }
753
+ /** A `SubAgentActivity` turn item (opt-in). */
754
+ handleItemCompleted(timestamp, event) {
755
+ if (!this.includeSubagentActivity)
756
+ return [];
757
+ const item = event.item;
758
+ if (!item || item.type !== 'SubAgentActivity')
759
+ return [];
760
+ const kind = typeof item.kind === 'string' ? item.kind : '';
761
+ if (!SUBAGENT_ACTIVITY_KINDS.has(kind))
762
+ return [];
763
+ const itemId = nonEmptyString(item.id);
764
+ const completedTurn = kind === 'completed' && itemId?.startsWith('subagent-completed-')
765
+ ? nonEmptyString(itemId.slice('subagent-completed-'.length))
766
+ : undefined;
767
+ const callId = kind === 'completed' ? undefined : itemId;
768
+ const tool = callId ? this.collaborationCalls.get(callId) : undefined;
769
+ const fields = {
770
+ callId,
771
+ tool,
772
+ turnId: completedTurn,
773
+ agentThreadId: nonEmptyString(item.agent_thread_id),
774
+ agentPath: nonEmptyString(item.agent_path),
775
+ model: nonEmptyString(item.model),
776
+ reasoningEffort: nonEmptyString(item.reasoning_effort),
777
+ parentThreadId: nonEmptyString(event.thread_id),
778
+ parentTurnId: nonEmptyString(event.turn_id),
779
+ };
780
+ const activity = { kind: kind };
781
+ for (const [key, value] of Object.entries(fields)) {
782
+ if (value !== undefined)
783
+ activity[key] = value;
784
+ }
785
+ return [
786
+ this.subagentActivityEvent(timestamp, itemId ?? `${kind}-${timestamp}-${activity.agentThreadId ?? ''}`, activity),
787
+ ];
788
+ }
607
789
  handleMessage(timestamp, item) {
608
790
  const text = extractTextFromContent(item.content);
609
791
  if (!text)
@@ -685,6 +867,11 @@ export class CodexRolloutParser {
685
867
  const canonicalName = normalizeCodexToolName(item.name);
686
868
  const normalizedInput = normalizeCodexToolInput(canonicalName, item.name, parsedArgs);
687
869
  rememberBounded(this.emittedToolUseIds, item.call_id, this.maxTrackedToolCalls);
870
+ if (this.includeSubagentActivity &&
871
+ typeof item.call_id === 'string' &&
872
+ (item.namespace === 'collaboration' || COLLABORATION_TOOL_NAMES.has(item.name))) {
873
+ this.rememberCollaborationCall(item.call_id, item.name);
874
+ }
688
875
  return [
689
876
  {
690
877
  type: 'assistant',
@@ -1141,6 +1328,8 @@ export class CodexRolloutParser {
1141
1328
  timestamp,
1142
1329
  },
1143
1330
  ];
1331
+ case 'item_completed':
1332
+ return this.handleItemCompleted(timestamp, event);
1144
1333
  case 'turn_aborted':
1145
1334
  case 'agent_reasoning':
1146
1335
  case 'agent_message':
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Whether a Claude Code subagent transcript ended cleanly, read line by line.
3
+ *
4
+ * Clean means: the last user or assistant line is an assistant line that
5
+ * stopped with `end_turn` or `stop_sequence`, and every `tool_use` in the
6
+ * transcript has its `tool_result`. A user line after that (a resumed agent's
7
+ * new prompt) makes it unfinished again.
8
+ *
9
+ * Pure. Internal to the subagent parsers.
10
+ *
11
+ * @module parsers/subagentEndState
12
+ */
13
+ const ENDED_STOP_REASONS = new Set(['end_turn', 'stop_sequence']);
14
+ /** Bounds the unanswered-call set for pathological transcripts. */
15
+ const MAX_PENDING_TOOL_USES = 4096;
16
+ export class SubagentEndTracker {
17
+ unanswered = new Set();
18
+ lastWasCleanAssistant = false;
19
+ /** Feeds one parsed JSONL line, in file order. */
20
+ observe(raw) {
21
+ if (!raw || typeof raw !== 'object')
22
+ return;
23
+ const line = raw;
24
+ if (line.type !== 'assistant' && line.type !== 'user')
25
+ return;
26
+ const message = line.message && typeof line.message === 'object'
27
+ ? line.message
28
+ : undefined;
29
+ const content = Array.isArray(message?.content) ? message.content : [];
30
+ if (line.type === 'user') {
31
+ for (const block of content) {
32
+ if (!block || typeof block !== 'object')
33
+ continue;
34
+ const typed = block;
35
+ if (typed.type === 'tool_result' && typeof typed.tool_use_id === 'string') {
36
+ this.unanswered.delete(typed.tool_use_id);
37
+ }
38
+ }
39
+ this.lastWasCleanAssistant = false;
40
+ return;
41
+ }
42
+ let callsTool = false;
43
+ for (const block of content) {
44
+ if (!block || typeof block !== 'object')
45
+ continue;
46
+ const typed = block;
47
+ if (typed.type !== 'tool_use')
48
+ continue;
49
+ callsTool = true;
50
+ if (typeof typed.id === 'string' && this.unanswered.size < MAX_PENDING_TOOL_USES) {
51
+ this.unanswered.add(typed.id);
52
+ }
53
+ }
54
+ const stop = typeof message?.stop_reason === 'string' ? message.stop_reason : '';
55
+ this.lastWasCleanAssistant = !callsTool && ENDED_STOP_REASONS.has(stop);
56
+ }
57
+ /** Whether the lines so far end cleanly. */
58
+ endedCleanly() {
59
+ return this.lastWasCleanAssistant && this.unanswered.size === 0;
60
+ }
61
+ }
@@ -13,6 +13,7 @@ import { ClaudeUsageDeduper, dedupedRawClaudeUsage } from '../usage/claudeUsageD
13
13
  import { addUsageToSubagent } from '../usage/subagentUsage';
14
14
  import { cachedSubagentStats } from './subagentStatsCache';
15
15
  import { isSubagentSpawnTool } from '../subagentSpawn';
16
+ import { SubagentEndTracker } from './subagentEndState';
16
17
  /**
17
18
  * Pattern for matching subagent JSONL files.
18
19
  * Files are named like: agent-<hash>.jsonl
@@ -160,6 +161,7 @@ function parseAgentFile(filePath, agentId, log) {
160
161
  let description;
161
162
  const tokens = { agentId, toolCalls: [], inputTokens: 0, outputTokens: 0 };
162
163
  const usageDeduper = new ClaudeUsageDeduper();
164
+ const endTracker = new SubagentEndTracker();
163
165
  let startTime;
164
166
  let endTime;
165
167
  // Task tracking state
@@ -176,6 +178,7 @@ function parseAgentFile(filePath, agentId, log) {
176
178
  if (!startTime)
177
179
  startTime = eventTimestamp;
178
180
  endTime = eventTimestamp;
181
+ endTracker.observe(event);
179
182
  // Extract token usage from assistant messages. Split lines of one
180
183
  // response repeat its usage, so count each response once.
181
184
  const usage = dedupedRawClaudeUsage(event, usageDeduper);
@@ -298,6 +301,7 @@ function parseAgentFile(filePath, agentId, log) {
298
301
  startTime,
299
302
  endTime,
300
303
  durationMs,
304
+ endedCleanly: endTracker.endedCleanly(),
301
305
  };
302
306
  }
303
307
  return null;
@@ -22,6 +22,7 @@ import { isHardNoise, classifyMessage, getSoftNoiseReason } from '../formatters/
22
22
  import { ClaudeUsageDeduper, dedupedRawClaudeUsage } from '../usage/claudeUsageDedupe';
23
23
  import { addUsageToSubagent } from '../usage/subagentUsage';
24
24
  import { readAgentMeta } from './subagentScanner';
25
+ import { SubagentEndTracker } from './subagentEndState';
25
26
  import { isSubagentSpawnTool } from '../subagentSpawn';
26
27
  import { ownValue } from '../ownKey';
27
28
  // ── Constants ──
@@ -73,6 +74,7 @@ function parseAgentTrace(filePath, agentId) {
73
74
  let description;
74
75
  const tokens = { agentId, toolCalls: [], inputTokens: 0, outputTokens: 0 };
75
76
  const usageDeduper = new ClaudeUsageDeduper();
77
+ const endTracker = new SubagentEndTracker();
76
78
  let startTime;
77
79
  let endTime;
78
80
  for (const line of lines) {
@@ -82,6 +84,7 @@ function parseAgentTrace(filePath, agentId) {
82
84
  if (!startTime)
83
85
  startTime = timestamp;
84
86
  endTime = timestamp;
87
+ endTracker.observe(raw);
85
88
  // Build SessionEvent from raw JSONL
86
89
  const sessionEvent = rawToSessionEvent(raw);
87
90
  if (!sessionEvent)
@@ -182,6 +185,8 @@ function parseAgentTrace(filePath, agentId) {
182
185
  ...(meta.requestShape ? { background: meta.requestShape === 'background' } : {}),
183
186
  ...(meta.finished !== undefined ? { finished: meta.finished } : {}),
184
187
  ...(meta.stoppedByUser !== undefined ? { stoppedByUser: meta.stoppedByUser } : {}),
188
+ ...(endTime ? { lastActivityAt: endTime } : {}),
189
+ endedCleanly: endTracker.endedCleanly(),
185
190
  events,
186
191
  children: [],
187
192
  stats: {
@@ -198,6 +203,7 @@ function parseAgentTrace(filePath, agentId) {
198
203
  startTime,
199
204
  endTime,
200
205
  durationMs,
206
+ endedCleanly: endTracker.endedCleanly(),
201
207
  },
202
208
  };
203
209
  }