sidekick-shared 0.29.0 → 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 (142) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +198 -56
  3. package/dist/aggregation/EventAggregator.d.ts +96 -2
  4. package/dist/aggregation/EventAggregator.js +535 -42
  5. package/dist/aggregation/snapshot.d.ts +37 -8
  6. package/dist/aggregation/snapshot.js +31 -9
  7. package/dist/aggregation/types.d.ts +38 -1
  8. package/dist/browser.d.ts +9 -1
  9. package/dist/browser.js +29 -3
  10. package/dist/context/sessionContext.js +8 -0
  11. package/dist/esm/aggregation/EventAggregator.js +535 -42
  12. package/dist/esm/aggregation/snapshot.js +31 -9
  13. package/dist/esm/browser.js +3 -0
  14. package/dist/esm/context/sessionContext.js +8 -0
  15. package/dist/esm/fileContinuity.js +149 -0
  16. package/dist/esm/formatters/toolSummary.js +4 -1
  17. package/dist/esm/index.js +7 -2
  18. package/dist/esm/modelCatalog.js +5 -4
  19. package/dist/esm/modelContext.js +4 -3
  20. package/dist/esm/modelInfo.js +4 -3
  21. package/dist/esm/node.js +3 -1
  22. package/dist/esm/observedContextWindows.js +2 -1
  23. package/dist/esm/ownKey.js +26 -0
  24. package/dist/esm/parsers/codexParser.js +372 -7
  25. package/dist/esm/parsers/openCodeParser.js +2 -1
  26. package/dist/esm/parsers/subagentEndState.js +61 -0
  27. package/dist/esm/parsers/subagentScanner.js +37 -12
  28. package/dist/esm/parsers/subagentTraceParser.js +103 -37
  29. package/dist/esm/pricingCatalog.js +3 -2
  30. package/dist/esm/promptHistory.js +22 -11
  31. package/dist/esm/providers/claudeCode.js +60 -29
  32. package/dist/esm/providers/codex.js +235 -201
  33. package/dist/esm/providers/codexCompressedReader.js +184 -0
  34. package/dist/esm/providers/codexDatabase.js +47 -2
  35. package/dist/esm/providers/codexRollouts.js +20 -2
  36. package/dist/esm/providers/codexSubagents.js +456 -0
  37. package/dist/esm/providers/compressedRollouts.js +330 -0
  38. package/dist/esm/providers/factory.js +4 -1
  39. package/dist/esm/providers/rolloutWalker.js +32 -4
  40. package/dist/esm/schemas/sessionEvent.js +63 -14
  41. package/dist/esm/sessionMonitor.js +7 -1
  42. package/dist/esm/sessionPreviews.js +9 -0
  43. package/dist/esm/sessionPromptHistory.js +5 -3
  44. package/dist/esm/subagentActivity.js +125 -0
  45. package/dist/esm/subagentDisplayState.js +81 -0
  46. package/dist/esm/subagentSpawn.js +363 -0
  47. package/dist/esm/testing/codexSubagentFixtures.js +336 -0
  48. package/dist/esm/toolCallOrigins.js +17 -10
  49. package/dist/esm/toolCategory.js +6 -2
  50. package/dist/esm/transcript.js +8 -0
  51. package/dist/esm/turns/assistantTurn.js +6 -2
  52. package/dist/esm/usage/claudeUsageDedupe.js +68 -0
  53. package/dist/esm/usage/usageEvents.js +4 -1
  54. package/dist/esm/watchers/eventBridge.js +45 -0
  55. package/dist/esm/watchers/factory.js +5 -3
  56. package/dist/esm/watchers/jsonlTail.js +82 -48
  57. package/dist/esm/watchers/jsonlWatcher.js +61 -4
  58. package/dist/esm/watchers/providerReaderWatcher.js +18 -4
  59. package/dist/esm/zaiQuota.js +2 -1
  60. package/dist/fileContinuity.d.ts +95 -0
  61. package/dist/fileContinuity.js +187 -0
  62. package/dist/formatters/toolSummary.js +4 -1
  63. package/dist/index.d.ts +19 -7
  64. package/dist/index.js +48 -10
  65. package/dist/modelCatalog.js +5 -4
  66. package/dist/modelContext.js +4 -3
  67. package/dist/modelInfo.js +4 -3
  68. package/dist/node.d.ts +5 -1
  69. package/dist/node.js +18 -2
  70. package/dist/observedContextWindows.js +2 -1
  71. package/dist/ownKey.d.ts +16 -0
  72. package/dist/ownKey.js +30 -0
  73. package/dist/parsers/codexParser.d.ts +107 -0
  74. package/dist/parsers/codexParser.js +373 -7
  75. package/dist/parsers/openCodeParser.js +2 -1
  76. package/dist/parsers/subagentEndState.d.ts +20 -0
  77. package/dist/parsers/subagentEndState.js +65 -0
  78. package/dist/parsers/subagentScanner.d.ts +19 -6
  79. package/dist/parsers/subagentScanner.js +37 -12
  80. package/dist/parsers/subagentTraceParser.d.ts +33 -5
  81. package/dist/parsers/subagentTraceParser.js +103 -37
  82. package/dist/pricingCatalog.js +3 -2
  83. package/dist/promptHistory.d.ts +13 -3
  84. package/dist/promptHistory.js +22 -11
  85. package/dist/providers/claudeCode.js +59 -28
  86. package/dist/providers/codex.d.ts +35 -5
  87. package/dist/providers/codex.js +232 -198
  88. package/dist/providers/codexCompressedReader.d.ts +58 -0
  89. package/dist/providers/codexCompressedReader.js +221 -0
  90. package/dist/providers/codexDatabase.d.ts +25 -0
  91. package/dist/providers/codexDatabase.js +48 -2
  92. package/dist/providers/codexRollouts.d.ts +16 -2
  93. package/dist/providers/codexRollouts.js +20 -2
  94. package/dist/providers/codexSubagents.d.ts +66 -0
  95. package/dist/providers/codexSubagents.js +495 -0
  96. package/dist/providers/compressedRollouts.d.ts +114 -0
  97. package/dist/providers/compressedRollouts.js +380 -0
  98. package/dist/providers/factory.d.ts +5 -0
  99. package/dist/providers/factory.js +4 -1
  100. package/dist/providers/rolloutWalker.d.ts +15 -2
  101. package/dist/providers/rolloutWalker.js +33 -4
  102. package/dist/providers/types.d.ts +36 -1
  103. package/dist/schemas/index.d.ts +1 -0
  104. package/dist/schemas/sessionEvent.d.ts +51 -10
  105. package/dist/schemas/sessionEvent.js +63 -14
  106. package/dist/sessionMonitor.d.ts +6 -0
  107. package/dist/sessionMonitor.js +7 -1
  108. package/dist/sessionPreviews.js +9 -0
  109. package/dist/sessionPromptHistory.d.ts +5 -0
  110. package/dist/sessionPromptHistory.js +5 -36
  111. package/dist/subagentActivity.d.ts +91 -0
  112. package/dist/subagentActivity.js +132 -0
  113. package/dist/subagentDisplayState.d.ts +81 -0
  114. package/dist/subagentDisplayState.js +86 -0
  115. package/dist/subagentSpawn.d.ts +175 -0
  116. package/dist/subagentSpawn.js +385 -0
  117. package/dist/testing/codexSubagentFixtures.d.ts +48 -0
  118. package/dist/testing/codexSubagentFixtures.js +344 -0
  119. package/dist/toolCallOrigins.d.ts +5 -0
  120. package/dist/toolCallOrigins.js +17 -10
  121. package/dist/toolCategory.js +6 -2
  122. package/dist/transcript.js +8 -0
  123. package/dist/turns/assistantTurn.d.ts +4 -0
  124. package/dist/turns/assistantTurn.js +6 -2
  125. package/dist/types/codex.d.ts +71 -2
  126. package/dist/types/sessionEvent.d.ts +38 -3
  127. package/dist/usage/claudeUsageDedupe.d.ts +31 -0
  128. package/dist/usage/claudeUsageDedupe.js +68 -0
  129. package/dist/usage/usageEvents.d.ts +4 -1
  130. package/dist/usage/usageEvents.js +4 -1
  131. package/dist/watchers/eventBridge.js +45 -0
  132. package/dist/watchers/factory.d.ts +6 -0
  133. package/dist/watchers/factory.js +5 -3
  134. package/dist/watchers/jsonlTail.d.ts +27 -4
  135. package/dist/watchers/jsonlTail.js +84 -48
  136. package/dist/watchers/jsonlWatcher.d.ts +22 -1
  137. package/dist/watchers/jsonlWatcher.js +60 -3
  138. package/dist/watchers/providerReaderWatcher.d.ts +7 -0
  139. package/dist/watchers/providerReaderWatcher.js +18 -4
  140. package/dist/watchers/types.d.ts +12 -0
  141. package/dist/zaiQuota.js +2 -1
  142. package/package.json +1 -1
@@ -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,9 @@ 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 { SubagentEndTracker } from './subagentEndState';
26
+ import { isSubagentSpawnTool } from '../subagentSpawn';
27
+ import { ownValue } from '../ownKey';
22
28
  // ── Constants ──
23
29
  const AGENT_FILE_PATTERN = /^agent-(.+)\.jsonl$/;
24
30
  // ── Public API ──
@@ -50,10 +56,8 @@ export function scanSubagentTraces(sessionDir, sessionId) {
50
56
  if (trace)
51
57
  traces.push(trace);
52
58
  }
53
- // Link parent-child relationships
54
- linkTraces(traces);
55
- // Return only top-level traces (those without parents)
56
- return traces.filter((t) => !t.parentToolUseId);
59
+ // Link parent-child relationships; returns the traces with no parent agent.
60
+ return linkTraces(traces);
57
61
  }
58
62
  catch {
59
63
  return [];
@@ -70,6 +74,7 @@ function parseAgentTrace(filePath, agentId) {
70
74
  let description;
71
75
  const tokens = { agentId, toolCalls: [], inputTokens: 0, outputTokens: 0 };
72
76
  const usageDeduper = new ClaudeUsageDeduper();
77
+ const endTracker = new SubagentEndTracker();
73
78
  let startTime;
74
79
  let endTime;
75
80
  for (const line of lines) {
@@ -79,6 +84,7 @@ function parseAgentTrace(filePath, agentId) {
79
84
  if (!startTime)
80
85
  startTime = timestamp;
81
86
  endTime = timestamp;
87
+ endTracker.observe(raw);
82
88
  // Build SessionEvent from raw JSONL
83
89
  const sessionEvent = rawToSessionEvent(raw);
84
90
  if (!sessionEvent)
@@ -133,8 +139,8 @@ function parseAgentTrace(filePath, agentId) {
133
139
  input: block.input || {},
134
140
  timestamp,
135
141
  });
136
- // Extract agent info from Task tool
137
- if (block.name === 'Task' && block.input) {
142
+ // Extract agent info from an Agent/Task spawn
143
+ if (isSubagentSpawnTool(block.name) && block.input) {
138
144
  if (block.input.subagent_type && !agentType) {
139
145
  agentType = String(block.input.subagent_type);
140
146
  }
@@ -171,6 +177,16 @@ function parseAgentTrace(filePath, agentId) {
171
177
  agentId,
172
178
  agentType,
173
179
  description,
180
+ filePath,
181
+ ...(meta.toolUseId
182
+ ? { spawnToolUseId: meta.toolUseId, parentToolUseId: meta.toolUseId }
183
+ : {}),
184
+ ...(meta.parentAgentId ? { parentAgentId: meta.parentAgentId } : {}),
185
+ ...(meta.requestShape ? { background: meta.requestShape === 'background' } : {}),
186
+ ...(meta.finished !== undefined ? { finished: meta.finished } : {}),
187
+ ...(meta.stoppedByUser !== undefined ? { stoppedByUser: meta.stoppedByUser } : {}),
188
+ ...(endTime ? { lastActivityAt: endTime } : {}),
189
+ endedCleanly: endTracker.endedCleanly(),
174
190
  events,
175
191
  children: [],
176
192
  stats: {
@@ -187,6 +203,7 @@ function parseAgentTrace(filePath, agentId) {
187
203
  startTime,
188
204
  endTime,
189
205
  durationMs,
206
+ endedCleanly: endTracker.endedCleanly(),
190
207
  },
191
208
  };
192
209
  }
@@ -195,23 +212,61 @@ function parseAgentTrace(filePath, agentId) {
195
212
  }
196
213
  }
197
214
  // ── Parent-Child Linking ──
215
+ /**
216
+ * Nests child traces under their parents and returns the traces with no
217
+ * parent agent. Exact metadata links come first; the heuristics only place
218
+ * traces Claude Code recorded no spawn metadata for.
219
+ */
198
220
  function linkTraces(traces) {
199
221
  const traceById = new Map();
200
222
  for (const trace of traces) {
201
223
  traceById.set(trace.agentId, trace);
202
224
  }
203
- // Phase 1: Result-based linking
204
- // Look for tool_result events that contain agentId references
225
+ const parentOf = new Map();
226
+ const attach = (parent, child) => {
227
+ if (parent === child || parentOf.has(child))
228
+ return false;
229
+ // Never nest an agent under its own descendant (malformed metadata).
230
+ for (let up = parent; up; up = parentOf.get(up)) {
231
+ if (up === child)
232
+ return false;
233
+ }
234
+ parentOf.set(child, parent);
235
+ parent.children.push(child);
236
+ return true;
237
+ };
238
+ // Phase 1: the meta file names the parent agent
239
+ for (const child of traces) {
240
+ const parent = child.parentAgentId ? traceById.get(child.parentAgentId) : undefined;
241
+ if (parent)
242
+ attach(parent, child);
243
+ }
244
+ // Phase 2: the child's spawning call is in another trace
245
+ const spawnOwner = new Map();
205
246
  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
- }
247
+ for (const id of spawnToolUseIdsOf(trace)) {
248
+ if (!spawnOwner.has(id))
249
+ spawnOwner.set(id, trace);
212
250
  }
213
251
  }
214
- // Phase 2: Team summary linking
252
+ for (const child of traces) {
253
+ if (parentOf.has(child) || !child.spawnToolUseId)
254
+ continue;
255
+ const parent = spawnOwner.get(child.spawnToolUseId);
256
+ if (parent && attach(parent, child))
257
+ child.parentAgentId ??= parent.agentId;
258
+ }
259
+ // Heuristic links set parentAgentId and, as before, parentToolUseId to the
260
+ // parent's agent id.
261
+ const unresolved = (trace) => !parentOf.has(trace) && !trace.spawnToolUseId && !trace.parentAgentId;
262
+ const linkHeuristically = (parent, child) => {
263
+ if (!attach(parent, child))
264
+ return false;
265
+ child.parentAgentId = parent.agentId;
266
+ child.parentToolUseId = parent.agentId;
267
+ return true;
268
+ };
269
+ // Phase 3: Team summary linking
215
270
  for (const trace of traces) {
216
271
  for (const traceEvent of trace.events) {
217
272
  const content = traceEvent.event.message?.content;
@@ -219,45 +274,56 @@ function linkTraces(traces) {
219
274
  // Parse teammate agent IDs from XML blocks
220
275
  const matches = content.matchAll(/agent[_-]id['":\s]+([a-f0-9]+)/gi);
221
276
  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
- }
277
+ const child = traceById.get(match[1]);
278
+ if (child && unresolved(child))
279
+ linkHeuristically(trace, child);
228
280
  }
229
281
  }
230
282
  }
231
283
  }
232
- // Phase 3: Positional fallback — timestamp proximity
284
+ // Phase 4: Positional fallback — timestamp proximity
233
285
  // 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) {
286
+ for (const child of traces.filter(unresolved)) {
236
287
  if (!child.stats.startTime)
237
288
  continue;
238
289
  const childStart = child.stats.startTime.getTime();
239
- // Find a trace that was active (Task tool call) right before this child started
290
+ // Find a top-level trace that spawned an agent right before this child started
240
291
  for (const potential of traces) {
241
- if (potential === child)
292
+ if (potential === child || parentOf.has(potential))
242
293
  continue;
243
- if (potential.parentToolUseId)
244
- continue; // Already linked
245
294
  for (const traceEvent of potential.events) {
246
295
  const evt = traceEvent.event;
247
- if (evt.type === 'assistant' && evt.tool?.name === 'Task') {
296
+ if (evt.type === 'assistant' && isSubagentSpawnTool(evt.tool?.name)) {
248
297
  const taskTime = new Date(evt.timestamp).getTime();
249
298
  // Within 2 seconds before child start
250
299
  if (taskTime <= childStart && childStart - taskTime < 2000) {
251
- child.parentToolUseId = potential.agentId;
252
- potential.children.push(child);
300
+ linkHeuristically(potential, child);
253
301
  break;
254
302
  }
255
303
  }
256
304
  }
257
- if (child.parentToolUseId)
305
+ if (parentOf.has(child))
258
306
  break;
259
307
  }
260
308
  }
309
+ return traces.filter((trace) => !parentOf.has(trace));
310
+ }
311
+ /** Ids of the `Agent` / `Task` calls a trace made. */
312
+ function spawnToolUseIdsOf(trace) {
313
+ const ids = [];
314
+ for (const traceEvent of trace.events) {
315
+ const content = traceEvent.event.message?.content;
316
+ if (!Array.isArray(content))
317
+ continue;
318
+ for (const block of content) {
319
+ if (block?.type === 'tool_use' &&
320
+ typeof block.id === 'string' &&
321
+ isSubagentSpawnTool(block.name)) {
322
+ ids.push(block.id);
323
+ }
324
+ }
325
+ }
326
+ return ids;
261
327
  }
262
328
  // ── Helpers ──
263
329
  function rawToSessionEvent(raw) {
@@ -272,7 +338,7 @@ function rawToSessionEvent(raw) {
272
338
  tool_result: 'tool_result',
273
339
  summary: 'summary',
274
340
  };
275
- const mappedType = typeMap[type];
341
+ const mappedType = ownValue(typeMap, type);
276
342
  if (!mappedType)
277
343
  return null;
278
344
  const message = raw.message;
@@ -20,6 +20,7 @@ import { promises as fs } from 'node:fs';
20
20
  import * as path from 'node:path';
21
21
  import { _setCatalogContextWindows } from './modelContext';
22
22
  import { _setPricingOverrides, getPricingCatalogInfo } from './modelInfo';
23
+ import { setOwnValue } from './ownKey';
23
24
  import { getConfigDir } from './paths';
24
25
  import { atomicWriteJson } from './writers/atomic';
25
26
  // ── Constants ──
@@ -117,7 +118,7 @@ export function normalizeLiteLlmCatalog(raw) {
117
118
  continue;
118
119
  // Catalog keys use both `provider/model` and bare `model` forms. Record both
119
120
  // the raw key and the bare-model form so lookups work regardless of caller.
120
- out[key] = pricing;
121
+ setOwnValue(out, key, pricing);
121
122
  const bare = stripProviderPrefix(key);
122
123
  if (bare && bare !== key && !out[bare]) {
123
124
  out[bare] = pricing;
@@ -154,7 +155,7 @@ export function normalizeLiteLlmContextWindows(raw) {
154
155
  const window = entry.max_input_tokens;
155
156
  if (typeof window !== 'number' || !Number.isFinite(window) || window <= 0)
156
157
  continue;
157
- out[key] = window;
158
+ setOwnValue(out, key, window);
158
159
  const bare = stripProviderPrefix(key);
159
160
  if (bare && bare !== key) {
160
161
  const seen = aliasWindows.get(bare) ?? new Set();
@@ -24,11 +24,11 @@
24
24
  * answering call brings model or usage (`metadataStatus` says how settled).
25
25
  */
26
26
  import { createHash } from 'crypto';
27
- import * as fs from 'fs';
28
27
  import * as path from 'path';
29
28
  import { HUMAN_CODEX_SOURCES, humanPromptText } from './humanPrompt';
30
29
  import { listClaudeSessionFilesAsync } from './providers/claudeListing';
31
30
  import { listCodexSessionFilesAsync } from './providers/codexRollouts';
31
+ import { canReadCompressedRollouts, openRolloutReadHandle, statRolloutContent, } from './providers/compressedRollouts';
32
32
  import { DirectoryListingCache } from './providers/directoryListingCache';
33
33
  import { realpathOrNull, resolveWorkspaceRoots } from './workspaceRoots';
34
34
  import { fileHeadOf, HEAD_BYTES, headMatches } from './fileHead';
@@ -105,7 +105,9 @@ export async function collectPromptHistory(options) {
105
105
  return finish();
106
106
  const sinceMs = options.since ? options.since.getTime() : null;
107
107
  const providers = options.providers ?? ALL_PROVIDERS;
108
- const candidates = rotate(await listCandidates(providers, listingRoots, sinceMs), previousNext);
108
+ const candidates = rotate(await listCandidates(providers, listingRoots, sinceMs, {
109
+ includeCompressedRollouts: options.includeCompressedRollouts,
110
+ }), previousNext);
109
111
  const moved = movedSessionKeys(previous, candidates);
110
112
  const context = {
111
113
  roots,
@@ -141,7 +143,8 @@ export async function collectPromptHistory(options) {
141
143
  }
142
144
  let stat;
143
145
  try {
144
- stat = await fs.promises.stat(candidate.filePath);
146
+ // A compressed rollout's size is its decompressed content's.
147
+ stat = await statRolloutContent(candidate.filePath);
145
148
  }
146
149
  catch {
147
150
  continue;
@@ -246,7 +249,7 @@ export async function collectPromptHistory(options) {
246
249
  */
247
250
  export async function readPromptOrdinalMarks(provider, sessionId, filePath) {
248
251
  const marks = [];
249
- const stat = await fs.promises.stat(filePath);
252
+ const stat = await statRolloutContent(filePath);
250
253
  const unbounded = Number.MAX_SAFE_INTEGER;
251
254
  const context = {
252
255
  roots: [],
@@ -435,19 +438,24 @@ function scopedCwd(cwd, roots, realpaths) {
435
438
  return null;
436
439
  }
437
440
  /** @internal */
438
- export async function listCandidates(providers, listingRoots, sinceMs) {
441
+ export async function listCandidates(providers, listingRoots, sinceMs, options = {}) {
439
442
  const byPath = new Map();
440
443
  const listOptions = sinceMs !== null ? { since: sinceMs } : {};
441
444
  for (const provider of [...new Set(providers)]) {
442
445
  if (!ALL_PROVIDERS.includes(provider))
443
446
  continue;
444
447
  // Listings are only candidate supersets; scope is re-checked per prompt.
445
- const list = provider === 'claude-code' ? listClaudeSessionFilesAsync : listCodexSessionFilesAsync;
446
448
  const cache = new DirectoryListingCache();
447
449
  for (const root of listingRoots) {
448
450
  let files;
449
451
  try {
450
- files = await list(root, listOptions, cache);
452
+ files =
453
+ provider === 'claude-code'
454
+ ? await listClaudeSessionFilesAsync(root, listOptions, cache)
455
+ : await listCodexSessionFilesAsync(root, {
456
+ ...listOptions,
457
+ includeCompressed: options.includeCompressedRollouts === true && canReadCompressedRollouts(),
458
+ }, cache);
451
459
  }
452
460
  catch {
453
461
  files = [];
@@ -469,11 +477,11 @@ export async function listCandidates(providers, listingRoots, sinceMs) {
469
477
  compareStrings(left.sessionId, right.sessionId) ||
470
478
  compareStrings(left.key, right.key));
471
479
  }
472
- /** True when the file's first bytes still match a recorded head. */
480
+ /** True when the file's first bytes (a compressed rollout's content) still match a recorded head. */
473
481
  async function fileStartsWith(filePath, head) {
474
482
  let handle;
475
483
  try {
476
- handle = await fs.promises.open(filePath, 'r');
484
+ ({ handle } = await openRolloutReadHandle(filePath));
477
485
  return headMatches(head, await readAt(handle, 0, Math.min(HEAD_BYTES, head.bytes)));
478
486
  }
479
487
  catch {
@@ -546,8 +554,11 @@ async function verifyFile(handle, size, session) {
546
554
  return { head: fileHeadOf(head), bytesRead };
547
555
  }
548
556
  /** @internal */
549
- export async function scanFile(candidate, stat, prior, budget, context) {
550
- const handle = await fs.promises.open(candidate.filePath, 'r');
557
+ export async function scanFile(candidate, fileStat, prior, budget, context) {
558
+ // A compressed rollout is scanned over its decompressed content.
559
+ const opened = await openRolloutReadHandle(candidate.filePath);
560
+ const handle = opened.handle;
561
+ const stat = opened.size === null ? fileStat : { size: opened.size, mtimeMs: fileStat.mtimeMs };
551
562
  try {
552
563
  let bytesRead = 0;
553
564
  const verified = await verifyFile(handle, stat.size, prior);
@@ -13,7 +13,8 @@ import { StringDecoder } from 'string_decoder';
13
13
  import { readSessionContextSnapshot } from '../context/sessionContext';
14
14
  import { readSessionFileStats } from '../sessionStats';
15
15
  import { JsonlParser } from '../parsers/jsonl';
16
- import { ClaudeUsageDeduper, normalizeClaudeUsage } from '../usage/claudeUsageDedupe';
16
+ import { FileContinuity } from '../fileContinuity';
17
+ import { ClaudeUsageDeduper, normalizeClaudeUsage, } from '../usage/claudeUsageDedupe';
17
18
  import { ProviderDiagnosticTracker } from './diagnostics';
18
19
  import { DirectoryListingCache, splitWatchedPath } from './directoryListingCache';
19
20
  import { encodeWorkspacePath as encodeWsPath, getSessionDirectory as getSessionDir, discoverSessionDirectory as discoverSessionDir, findActiveSession as findActiveSessionPath, findAllSessions as findAllSessionPaths, findSessionsInDirectory as findSessionsInDir, decodeEncodedPath, getAllProjectFolders as getAllProjectFoldersRaw, findSessionFilesWithStats, } from '../parsers/sessionPathResolver';
@@ -110,7 +111,10 @@ class ClaudeCodeReader {
110
111
  /** Cursor after the last complete line, safe to persist across restarts. */
111
112
  committedPosition = 0;
112
113
  events = [];
113
- _wasTruncated = false;
114
+ /** Why the last `readNew()` started over from offset 0, if it did. */
115
+ _lastReset = null;
116
+ /** Whether the file is still the one the positions refer to. */
117
+ continuity = new FileContinuity();
114
118
  decoder = new StringDecoder('utf8');
115
119
  /** Split lines of one response repeat its usage; count each response once. */
116
120
  usageDeduper;
@@ -119,7 +123,8 @@ class ClaudeCodeReader {
119
123
  this.usageDeduper = usageDeduper ?? new ClaudeUsageDeduper();
120
124
  this.parser = new JsonlParser({
121
125
  onEvent: (raw) => {
122
- for (const event of extractSessionEvents(raw)) {
126
+ // Mid-turn task notifications finish background subagents.
127
+ for (const event of extractSessionEvents(raw, { includeTaskNotifications: true })) {
123
128
  this.events.push(this.usageDeduper.apply(normalizeClaudeUsage(event)));
124
129
  }
125
130
  },
@@ -130,37 +135,28 @@ class ClaudeCodeReader {
130
135
  }
131
136
  readNew() {
132
137
  this.events = [];
133
- this._wasTruncated = false;
138
+ this._lastReset = null;
139
+ let fd = null;
134
140
  try {
135
- if (!fs.existsSync(this.sessionPath)) {
136
- return [];
137
- }
138
- const stats = fs.statSync(this.sessionPath);
141
+ // Open first and stat the descriptor: the size and identity checked
142
+ // are those of the file actually read, even if the path is replaced.
143
+ fd = fs.openSync(this.sessionPath, 'r');
144
+ const stats = fs.fstatSync(fd);
145
+ const reason = this.continuity.check(fd, stats, this.filePosition, this.committedPosition);
146
+ // Truncated, rewritten, or replaced: what was read no longer describes
147
+ // the file, so start over from its beginning.
148
+ if (reason !== null)
149
+ this.restart(reason);
150
+ this.continuity.adopt(stats);
139
151
  const currentSize = stats.size;
140
- // Handle truncation
141
- if (currentSize < this.filePosition) {
142
- this._wasTruncated = true;
143
- this.filePosition = 0;
144
- this.committedPosition = 0;
145
- this.parser.reset();
146
- this.decoder = new StringDecoder('utf8');
147
- this.usageDeduper.reset();
148
- }
149
152
  // No new content
150
153
  if (currentSize <= this.filePosition) {
151
- return [];
154
+ return this.events;
152
155
  }
153
156
  // Read new bytes from last position
154
- const fd = fs.openSync(this.sessionPath, 'r');
155
157
  const bufferSize = currentSize - this.filePosition;
156
158
  const buffer = Buffer.alloc(bufferSize);
157
- let bytesRead = 0;
158
- try {
159
- bytesRead = fs.readSync(fd, buffer, 0, bufferSize, this.filePosition);
160
- }
161
- finally {
162
- fs.closeSync(fd);
163
- }
159
+ const bytesRead = fs.readSync(fd, buffer, 0, bufferSize, this.filePosition);
164
160
  const bytes = buffer.subarray(0, bytesRead);
165
161
  const newline = bytes.lastIndexOf(0x0a);
166
162
  if (newline >= 0)
@@ -170,10 +166,31 @@ class ClaudeCodeReader {
170
166
  this.filePosition += bytesRead;
171
167
  }
172
168
  catch (error) {
173
- console.error(`ClaudeCodeReader: error reading ${this.sessionPath}: ${error}`);
169
+ if (error?.code !== 'ENOENT') {
170
+ console.error(`ClaudeCodeReader: error reading ${this.sessionPath}: ${error}`);
171
+ }
172
+ }
173
+ finally {
174
+ if (fd !== null) {
175
+ try {
176
+ fs.closeSync(fd);
177
+ }
178
+ catch {
179
+ // Ignore close errors.
180
+ }
181
+ }
174
182
  }
175
183
  return this.events;
176
184
  }
185
+ /** Starts over from offset 0 of a file that was truncated, rewritten, or replaced. */
186
+ restart(reason) {
187
+ this._lastReset = { reason, previousPosition: this.committedPosition };
188
+ this.filePosition = 0;
189
+ this.committedPosition = 0;
190
+ this.parser.reset();
191
+ this.decoder = new StringDecoder('utf8');
192
+ this.usageDeduper.reset();
193
+ }
177
194
  readAll() {
178
195
  this.reset();
179
196
  return this.readNew();
@@ -184,7 +201,8 @@ class ClaudeCodeReader {
184
201
  this.parser.reset();
185
202
  this.decoder = new StringDecoder('utf8');
186
203
  this.usageDeduper.reset();
187
- this._wasTruncated = false;
204
+ this._lastReset = null;
205
+ this.continuity.forget();
188
206
  }
189
207
  exists() {
190
208
  return fs.existsSync(this.sessionPath);
@@ -204,9 +222,22 @@ class ClaudeCodeReader {
204
222
  this.committedPosition = position;
205
223
  this.parser.reset();
206
224
  this.decoder = new StringDecoder('utf8');
225
+ // The position comes from elsewhere (a snapshot): adopt the file the next
226
+ // read finds rather than compare it with one read before.
227
+ this.continuity.forget();
207
228
  }
208
229
  wasTruncated() {
209
- return this._wasTruncated;
230
+ return this._lastReset !== null;
231
+ }
232
+ lastReset() {
233
+ return this._lastReset ? { ...this._lastReset } : null;
234
+ }
235
+ /** The usage deduper's recent message ids, so a resumed reader does not recount a split response. */
236
+ getResumeState() {
237
+ return this.usageDeduper.exportState();
238
+ }
239
+ restoreResumeState(state) {
240
+ return this.usageDeduper.importState(state);
210
241
  }
211
242
  }
212
243
  /**