sidekick-shared 0.28.3 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +188 -27
  3. package/dist/aggregation/EventAggregator.d.ts +19 -0
  4. package/dist/aggregation/EventAggregator.js +174 -35
  5. package/dist/aggregation/snapshot.d.ts +37 -8
  6. package/dist/aggregation/snapshot.js +31 -9
  7. package/dist/aggregation/types.d.ts +13 -0
  8. package/dist/authStatus.d.ts +54 -0
  9. package/dist/authStatus.js +91 -0
  10. package/dist/browser.d.ts +16 -6
  11. package/dist/browser.js +24 -2
  12. package/dist/codex.d.ts +23 -0
  13. package/dist/codex.js +44 -0
  14. package/dist/codexProfiles.d.ts +1 -0
  15. package/dist/codexProfiles.js +8 -11
  16. package/dist/context/sessionContext.js +4 -0
  17. package/dist/esm/aggregation/EventAggregator.js +174 -35
  18. package/dist/esm/aggregation/snapshot.js +31 -9
  19. package/dist/esm/authStatus.js +87 -0
  20. package/dist/esm/browser.js +5 -2
  21. package/dist/esm/codex.js +18 -0
  22. package/dist/esm/codexProfiles.js +8 -11
  23. package/dist/esm/context/sessionContext.js +4 -0
  24. package/dist/esm/formatters/toolSummary.js +4 -1
  25. package/dist/esm/formatting.js +10 -1
  26. package/dist/esm/index.js +11 -6
  27. package/dist/esm/modelCatalog.js +7 -5
  28. package/dist/esm/modelContext.js +4 -3
  29. package/dist/esm/modelInfo.js +425 -47
  30. package/dist/esm/node.js +9 -3
  31. package/dist/esm/observedContextWindows.js +2 -1
  32. package/dist/esm/ownKey.js +26 -0
  33. package/dist/esm/parsers/codexParser.js +351 -54
  34. package/dist/esm/parsers/codexRolloutOrigin.js +107 -0
  35. package/dist/esm/parsers/openCodeParser.js +85 -22
  36. package/dist/esm/parsers/subagentScanner.js +33 -12
  37. package/dist/esm/parsers/subagentTraceParser.js +97 -37
  38. package/dist/esm/pricing.js +18 -0
  39. package/dist/esm/pricingCatalog.js +17 -9
  40. package/dist/esm/promptHistory.js +66 -4
  41. package/dist/esm/providerFailure.js +124 -3
  42. package/dist/esm/providers/claudeCode.js +10 -2
  43. package/dist/esm/providers/codex.js +84 -62
  44. package/dist/esm/providers/codexDatabase.js +3 -19
  45. package/dist/esm/providers/codexRollouts.js +24 -1
  46. package/dist/esm/providers/openCode.js +9 -17
  47. package/dist/esm/report/htmlReportGenerator.js +42 -5
  48. package/dist/esm/report/transcriptParser.js +13 -11
  49. package/dist/esm/schemas/providerFailure.js +3 -1
  50. package/dist/esm/schemas/sessionEvent.js +35 -14
  51. package/dist/esm/schemas/transcript.js +4 -0
  52. package/dist/esm/sessionPromptHistory.js +2 -2
  53. package/dist/esm/statusline/payload.js +9 -0
  54. package/dist/esm/subagentSpawn.js +163 -0
  55. package/dist/esm/testing/credentialGuard.js +87 -5
  56. package/dist/esm/toolCallOrigins.js +310 -0
  57. package/dist/esm/transcript.js +46 -3
  58. package/dist/esm/turns/assistantTurn.js +3 -2
  59. package/dist/esm/usage/claudeUsageDedupe.js +68 -0
  60. package/dist/esm/usage/usageEvents.js +146 -17
  61. package/dist/esm/usage/usageReports.js +39 -6
  62. package/dist/esm/watchers/eventBridge.js +15 -0
  63. package/dist/esm/watchers/factory.js +5 -3
  64. package/dist/esm/watchers/jsonlTail.js +442 -58
  65. package/dist/esm/watchers/jsonlWatcher.js +51 -4
  66. package/dist/esm/watchers/providerReaderWatcher.js +13 -0
  67. package/dist/esm/zaiQuota.js +2 -1
  68. package/dist/formatters/toolSummary.js +4 -1
  69. package/dist/formatting.d.ts +26 -1
  70. package/dist/formatting.js +11 -1
  71. package/dist/index.d.ts +22 -11
  72. package/dist/index.js +44 -12
  73. package/dist/modelCatalog.js +7 -5
  74. package/dist/modelContext.js +4 -3
  75. package/dist/modelInfo.d.ts +185 -16
  76. package/dist/modelInfo.js +434 -49
  77. package/dist/node.d.ts +16 -5
  78. package/dist/node.js +33 -2
  79. package/dist/observedContextWindows.js +2 -1
  80. package/dist/ownKey.d.ts +16 -0
  81. package/dist/ownKey.js +30 -0
  82. package/dist/parsers/codexParser.d.ts +118 -0
  83. package/dist/parsers/codexParser.js +352 -54
  84. package/dist/parsers/codexRolloutOrigin.d.ts +49 -0
  85. package/dist/parsers/codexRolloutOrigin.js +111 -0
  86. package/dist/parsers/openCodeParser.d.ts +38 -0
  87. package/dist/parsers/openCodeParser.js +88 -22
  88. package/dist/parsers/subagentScanner.d.ts +19 -6
  89. package/dist/parsers/subagentScanner.js +33 -12
  90. package/dist/parsers/subagentTraceParser.d.ts +25 -5
  91. package/dist/parsers/subagentTraceParser.js +97 -37
  92. package/dist/pricing.d.ts +22 -0
  93. package/dist/pricing.js +50 -0
  94. package/dist/pricingCatalog.d.ts +10 -0
  95. package/dist/pricingCatalog.js +16 -8
  96. package/dist/promptHistory.d.ts +65 -4
  97. package/dist/promptHistory.js +68 -5
  98. package/dist/providerFailure.d.ts +44 -0
  99. package/dist/providerFailure.js +126 -4
  100. package/dist/providers/claudeCode.js +9 -1
  101. package/dist/providers/codex.d.ts +3 -22
  102. package/dist/providers/codex.js +85 -64
  103. package/dist/providers/codexDatabase.d.ts +3 -2
  104. package/dist/providers/codexDatabase.js +4 -22
  105. package/dist/providers/codexRollouts.d.ts +22 -0
  106. package/dist/providers/codexRollouts.js +24 -0
  107. package/dist/providers/openCode.js +8 -16
  108. package/dist/providers/types.d.ts +14 -0
  109. package/dist/report/htmlReportGenerator.js +42 -5
  110. package/dist/report/transcriptParser.d.ts +10 -1
  111. package/dist/report/transcriptParser.js +13 -11
  112. package/dist/schemas/index.d.ts +1 -0
  113. package/dist/schemas/providerFailure.d.ts +39 -0
  114. package/dist/schemas/providerFailure.js +2 -0
  115. package/dist/schemas/sessionEvent.d.ts +27 -10
  116. package/dist/schemas/sessionEvent.js +35 -14
  117. package/dist/schemas/transcript.d.ts +2 -0
  118. package/dist/schemas/transcript.js +4 -0
  119. package/dist/sessionPromptHistory.d.ts +7 -1
  120. package/dist/sessionPromptHistory.js +1 -1
  121. package/dist/statusline/payload.d.ts +10 -0
  122. package/dist/statusline/payload.js +14 -0
  123. package/dist/subagentSpawn.d.ts +80 -0
  124. package/dist/subagentSpawn.js +174 -0
  125. package/dist/testing/credentialGuard.d.ts +14 -0
  126. package/dist/testing/credentialGuard.js +87 -5
  127. package/dist/toolCallOrigins.d.ts +70 -0
  128. package/dist/toolCallOrigins.js +346 -0
  129. package/dist/transcript.d.ts +12 -0
  130. package/dist/transcript.js +46 -3
  131. package/dist/turns/assistantTurn.d.ts +1 -0
  132. package/dist/turns/assistantTurn.js +3 -2
  133. package/dist/types/opencode.d.ts +2 -0
  134. package/dist/usage/claudeUsageDedupe.d.ts +31 -0
  135. package/dist/usage/claudeUsageDedupe.js +68 -0
  136. package/dist/usage/usageEvents.d.ts +54 -6
  137. package/dist/usage/usageEvents.js +146 -17
  138. package/dist/usage/usageReports.d.ts +35 -4
  139. package/dist/usage/usageReports.js +39 -6
  140. package/dist/watchers/eventBridge.js +15 -0
  141. package/dist/watchers/factory.d.ts +6 -0
  142. package/dist/watchers/factory.js +5 -3
  143. package/dist/watchers/jsonlTail.d.ts +124 -4
  144. package/dist/watchers/jsonlTail.js +446 -59
  145. package/dist/watchers/jsonlWatcher.d.ts +18 -1
  146. package/dist/watchers/jsonlWatcher.js +50 -3
  147. package/dist/watchers/providerReaderWatcher.d.ts +7 -0
  148. package/dist/watchers/providerReaderWatcher.js +13 -0
  149. package/dist/watchers/types.d.ts +12 -0
  150. package/dist/zaiQuota.js +2 -1
  151. package/package.json +41 -1
@@ -14,6 +14,10 @@
14
14
  * record over `maxRecordBytes` is skipped without being buffered and reported
15
15
  * in `exclusions`, the only way a record is permanently left out.
16
16
  *
17
+ * `includeText: false` returns the same entries, ordinals, timestamps,
18
+ * metadata, exclusions, and cursor without prompt text, for consumers that
19
+ * only count or attribute prompts. Cursors are interchangeable between modes.
20
+ *
17
21
  * A prompt can be written before its answer. The cursor then keeps the byte
18
22
  * offset of that prompt's line and its answer-tracking state (never text), and
19
23
  * the prompt is returned again under the same identity once its first
@@ -28,12 +32,19 @@ import { listCodexSessionFilesAsync } from './providers/codexRollouts';
28
32
  import { DirectoryListingCache } from './providers/directoryListingCache';
29
33
  import { realpathOrNull, resolveWorkspaceRoots } from './workspaceRoots';
30
34
  import { fileHeadOf, HEAD_BYTES, headMatches } from './fileHead';
35
+ /**
36
+ * The default `maxRecordBytes` (16 MiB). Prompt ordinals are only comparable
37
+ * between readers that skip the same records, so every reader that publishes
38
+ * or joins on ordinals (prompt history, session prompt history, usage events'
39
+ * `promptOrdinal`) uses this bound.
40
+ */
41
+ export const PROMPT_HISTORY_ORDINAL_MAX_RECORD_BYTES = 16 * 1024 * 1024;
31
42
  /** @internal */
32
43
  export const DEFAULT_BOUNDS = {
33
44
  maxSessions: 1000,
34
45
  maxFileBytes: 16 * 1024 * 1024,
35
46
  maxTotalBytes: 256 * 1024 * 1024,
36
- maxRecordBytes: 16 * 1024 * 1024,
47
+ maxRecordBytes: PROMPT_HISTORY_ORDINAL_MAX_RECORD_BYTES,
37
48
  maxEntries: 10_000,
38
49
  deadlineMs: 30_000,
39
50
  };
@@ -108,6 +119,7 @@ export async function collectPromptHistory(options) {
108
119
  if (exclusions.length < MAX_EXCLUSIONS)
109
120
  exclusions.push(exclusion);
110
121
  },
122
+ includeText: options.includeText !== false,
111
123
  };
112
124
  let totalBytes = 0;
113
125
  let scannedAny = false;
@@ -221,6 +233,46 @@ export async function collectPromptHistory(options) {
221
233
  }
222
234
  return finish();
223
235
  }
236
+ /**
237
+ * Every human prompt of one session file with its ordinal and timestamp, read
238
+ * whole in one pass with the default record bound, so the ordinals are the
239
+ * ones `collectPromptHistory()` returns with default bounds. No scope check
240
+ * and no text: prompts are counted wherever their cwd points. Prompts whose
241
+ * line has no usable timestamp still take an ordinal but are left out. A
242
+ * non-interactive Codex session (subagent, fork, unknown source) has none.
243
+ * In file order. Rejects when the file cannot be opened or changes while its
244
+ * head is read.
245
+ * @internal
246
+ */
247
+ export async function readPromptOrdinalMarks(provider, sessionId, filePath) {
248
+ const marks = [];
249
+ const stat = await fs.promises.stat(filePath);
250
+ const unbounded = Number.MAX_SAFE_INTEGER;
251
+ const context = {
252
+ roots: [],
253
+ sinceMs: null,
254
+ stats: emptyStats(),
255
+ realpaths: new Map(),
256
+ bounds: {
257
+ maxSessions: unbounded,
258
+ maxFileBytes: unbounded,
259
+ maxTotalBytes: unbounded,
260
+ maxRecordBytes: PROMPT_HISTORY_ORDINAL_MAX_RECORD_BYTES,
261
+ maxEntries: unbounded,
262
+ deadlineMs: unbounded,
263
+ },
264
+ shouldStop: () => null,
265
+ entriesSoFar: () => 0,
266
+ exclude: () => undefined,
267
+ includeText: false,
268
+ onPrompt: ({ ordinal, timestamp }) => {
269
+ if (timestamp !== undefined)
270
+ marks.push({ ordinal, timestampMs: Date.parse(timestamp) });
271
+ },
272
+ };
273
+ await scanFile({ provider, sessionId, filePath, key: `${provider}:${sessionId}:${hashPath(filePath)}` }, stat, undefined, { bytes: unbounded, bound: 'maxFileBytes', forceProgress: true }, context);
274
+ return marks;
275
+ }
224
276
  /** @internal */
225
277
  export function emptyStats() {
226
278
  return {
@@ -899,7 +951,7 @@ class LineScanner {
899
951
  return {
900
952
  entry: {
901
953
  ...entry,
902
- text,
954
+ ...this.textFields(text),
903
955
  cwd: scoped,
904
956
  ...(place.gitBranch ? { gitBranch: place.gitBranch } : {}),
905
957
  ...(entry.usage ? { usage: { ...entry.usage } } : {}),
@@ -918,11 +970,21 @@ class LineScanner {
918
970
  this.closedCarried = open;
919
971
  this.open = null;
920
972
  }
973
+ /** Entry text fields: the text, or an empty string flagged as omitted. */
974
+ textFields(text) {
975
+ return this.context.includeText === false ? { text: '', textOmitted: true } : { text };
976
+ }
921
977
  /** Assign an ordinal to a human prompt and emit it when in scope. */
922
978
  recordPrompt(text, timestamp, cwd, gitBranch, lineOffset, lineBytes, stateBefore) {
923
979
  const ordinal = this.nextOrdinal++;
924
980
  this.closeOpen();
925
- if (typeof timestamp !== 'string' || Number.isNaN(Date.parse(timestamp))) {
981
+ const usable = typeof timestamp === 'string' && !Number.isNaN(Date.parse(timestamp));
982
+ this.context.onPrompt?.({
983
+ ordinal,
984
+ timestamp: usable ? timestamp : undefined,
985
+ lineOffset,
986
+ });
987
+ if (!usable) {
926
988
  this.context.stats.promptsMissingTimestamp++;
927
989
  return;
928
990
  }
@@ -940,7 +1002,7 @@ class LineScanner {
940
1002
  sessionId: this.candidate.sessionId,
941
1003
  ordinal,
942
1004
  timestamp,
943
- text,
1005
+ ...this.textFields(text),
944
1006
  cwd: scoped,
945
1007
  ...(gitBranch ? { gitBranch } : {}),
946
1008
  metadataStatus: 'pending',
@@ -42,6 +42,31 @@ export const PROVIDER_FAILURE_EVIDENCE_SOURCES = [
42
42
  'message',
43
43
  'authentication-check',
44
44
  ];
45
+ /**
46
+ * The variants Codex reports in `codexErrorInfo` on `error` and failed-turn
47
+ * payloads (app-server camelCase spelling; the snake_case spelling Codex
48
+ * writes to rollout files is accepted too). `rateLimitExceeded` is an older
49
+ * spelling some Codex builds emitted.
50
+ */
51
+ export const CODEX_ERROR_INFO_KINDS = [
52
+ 'contextWindowExceeded',
53
+ 'sessionBudgetExceeded',
54
+ 'usageLimitExceeded',
55
+ 'rateLimitExceeded',
56
+ 'serverOverloaded',
57
+ 'cyberPolicy',
58
+ 'httpConnectionFailed',
59
+ 'responseStreamConnectionFailed',
60
+ 'internalServerError',
61
+ 'unauthorized',
62
+ 'badRequest',
63
+ 'threadRollbackFailed',
64
+ 'sandboxError',
65
+ 'responseStreamDisconnected',
66
+ 'responseTooManyFailedAttempts',
67
+ 'activeTurnNotSteerable',
68
+ 'other',
69
+ ];
45
70
  function read(value, key) {
46
71
  try {
47
72
  return value && typeof value === 'object' ? value[key] : undefined;
@@ -77,6 +102,40 @@ function retryAfter(value) {
77
102
  const at = timestamp(value);
78
103
  return at ? { kind: 'date', at } : undefined;
79
104
  }
105
+ function compactKey(value) {
106
+ return value.toLowerCase().replace(/[^a-z0-9]/g, '');
107
+ }
108
+ const CODEX_ERROR_INFO_BY_KEY = new Map(CODEX_ERROR_INFO_KINDS.map((kind) => [compactKey(kind), kind]));
109
+ /**
110
+ * Decode one Codex `codexErrorInfo` value: a string variant
111
+ * (`"serverOverloaded"`, `"usage_limit_exceeded"`) or an object variant
112
+ * (`{ "httpConnectionFailed": { "httpStatusCode": 503 } }`, snake_case keys
113
+ * accepted). Returns `undefined` for anything else, including `null`.
114
+ */
115
+ export function decodeCodexErrorInfo(value) {
116
+ if (typeof value === 'string') {
117
+ const kind = value.length <= 64 ? CODEX_ERROR_INFO_BY_KEY.get(compactKey(value)) : undefined;
118
+ return kind ? { kind } : undefined;
119
+ }
120
+ if (!value || typeof value !== 'object' || Array.isArray(value))
121
+ return undefined;
122
+ let keys;
123
+ try {
124
+ keys = Object.keys(value);
125
+ }
126
+ catch {
127
+ return undefined;
128
+ }
129
+ for (const key of keys.slice(0, 8)) {
130
+ const kind = key.length <= 64 ? CODEX_ERROR_INFO_BY_KEY.get(compactKey(key)) : undefined;
131
+ if (!kind)
132
+ continue;
133
+ const payload = read(value, key);
134
+ const status = statusNumber(read(payload, 'httpStatusCode') ?? read(payload, 'http_status_code'));
135
+ return status === undefined ? { kind } : { kind, httpStatusCode: status };
136
+ }
137
+ return undefined;
138
+ }
80
139
  function rejected(kind) {
81
140
  return kind === 'oauth'
82
141
  ? 'oauth_reauthentication_required'
@@ -167,6 +226,44 @@ function classifyStatus(status, kind) {
167
226
  return 'service_unavailable';
168
227
  return undefined;
169
228
  }
229
+ /** Variants with no matching failure code (bad request, policy, budget, other) stay unclassified. */
230
+ function classifyCodexErrorInfo(info, kind) {
231
+ switch (info.kind) {
232
+ case 'usageLimitExceeded':
233
+ case 'rateLimitExceeded':
234
+ return 'rate_limited';
235
+ case 'serverOverloaded':
236
+ return 'overloaded';
237
+ case 'internalServerError':
238
+ return 'service_unavailable';
239
+ case 'unauthorized':
240
+ return rejected(kind);
241
+ case 'contextWindowExceeded':
242
+ return 'context_overflow';
243
+ case 'httpConnectionFailed':
244
+ case 'responseStreamConnectionFailed':
245
+ case 'responseStreamDisconnected':
246
+ case 'responseTooManyFailedAttempts':
247
+ return info.httpStatusCode === undefined
248
+ ? 'connection_failed'
249
+ : classifyStatus(info.httpStatusCode, kind);
250
+ default:
251
+ return undefined;
252
+ }
253
+ }
254
+ function isRetryable(code, quotaExhausted, retry) {
255
+ switch (code) {
256
+ case 'rate_limited':
257
+ return !quotaExhausted || retry !== undefined;
258
+ case 'overloaded':
259
+ case 'service_unavailable':
260
+ case 'timeout':
261
+ case 'connection_failed':
262
+ return true;
263
+ default:
264
+ return false;
265
+ }
266
+ }
170
267
  function classifyMessage(message, kind) {
171
268
  if (/\b(execution policy|sandbox policy|approval)\b.{0,60}\b(denied|blocked|rejected|not allowed)\b|\bdenied by policy\b/i.test(message))
172
269
  return 'execution_policy_denied';
@@ -232,6 +329,13 @@ function recovery(code, kind) {
232
329
  /**
233
330
  * Classify a caller-designated terminal failure without I/O or recovery actions.
234
331
  * Public status and runtime readiness do not establish request authentication.
332
+ *
333
+ * A Codex `codexErrorInfo` (or `codex_error_info`) on the error or on any
334
+ * nested `error` / `cause` / `response` / `data` / `body` / `errors` entry is
335
+ * structured evidence. It ranks ahead of the same object's `code` / `type` /
336
+ * `name`, objects are read outer first, and every structured code ranks ahead
337
+ * of HTTP status and message text. Its transport variants also supply
338
+ * `httpStatus`.
235
339
  */
236
340
  export function diagnoseProviderFailure(input) {
237
341
  const codes = [];
@@ -249,11 +353,15 @@ export function diagnoseProviderFailure(input) {
249
353
  if (!value || typeof value !== 'object' || visited.has(value))
250
354
  continue;
251
355
  visited.add(value);
356
+ const codex = decodeCodexErrorInfo(read(value, 'codexErrorInfo') ?? read(value, 'codex_error_info'));
357
+ if (codex)
358
+ codes.push(codex);
252
359
  for (const key of ['code', 'type', 'name'])
253
360
  codes.push(text(read(value, key)));
254
361
  messages.push(text(read(value, 'message')));
255
362
  for (const key of ['status', 'statusCode', 'httpStatus', 'status_code'])
256
363
  httpStatus ??= statusNumber(read(value, key));
364
+ httpStatus ??= codex?.httpStatusCode;
257
365
  const headers = read(value, 'headers');
258
366
  const header = typeof Headers !== 'undefined' && headers instanceof Headers
259
367
  ? headers.get('retry-after')
@@ -306,9 +414,20 @@ export function diagnoseProviderFailure(input) {
306
414
  source: auth.source,
307
415
  };
308
416
  }
309
- let diagnosis = codes
310
- .map((code) => classifyCode(code, input.credentialKind))
311
- .find((code) => code !== undefined);
417
+ let diagnosis;
418
+ let codexErrorInfo;
419
+ for (const code of codes) {
420
+ if (typeof code === 'string') {
421
+ diagnosis = classifyCode(code, input.credentialKind);
422
+ }
423
+ else {
424
+ diagnosis = classifyCodexErrorInfo(code, input.credentialKind);
425
+ if (diagnosis)
426
+ codexErrorInfo = code.kind;
427
+ }
428
+ if (diagnosis)
429
+ break;
430
+ }
312
431
  let source = 'structured-error';
313
432
  if (!diagnosis && httpStatus !== undefined) {
314
433
  diagnosis = classifyStatus(httpStatus, input.credentialKind);
@@ -344,6 +463,7 @@ export function diagnoseProviderFailure(input) {
344
463
  credentialKind: input.credentialKind,
345
464
  diagnosis,
346
465
  recovery: recovery(diagnosis, input.credentialKind),
466
+ retryable: isRetryable(diagnosis, codexErrorInfo === 'usageLimitExceeded', retry),
347
467
  evidence: diagnosis === 'unknown'
348
468
  ? []
349
469
  : [
@@ -353,6 +473,7 @@ export function diagnoseProviderFailure(input) {
353
473
  ...(source === 'authentication-check'
354
474
  ? { checkedAt: authentication.checkedAt }
355
475
  : {}),
476
+ ...(codexErrorInfo ? { codexErrorInfo } : {}),
356
477
  },
357
478
  ],
358
479
  ...(authentication ? { authentication } : {}),
@@ -13,7 +13,7 @@ import { StringDecoder } from 'string_decoder';
13
13
  import { readSessionContextSnapshot } from '../context/sessionContext';
14
14
  import { readSessionFileStats } from '../sessionStats';
15
15
  import { JsonlParser } from '../parsers/jsonl';
16
- import { ClaudeUsageDeduper, normalizeClaudeUsage } from '../usage/claudeUsageDedupe';
16
+ import { ClaudeUsageDeduper, normalizeClaudeUsage, } from '../usage/claudeUsageDedupe';
17
17
  import { ProviderDiagnosticTracker } from './diagnostics';
18
18
  import { DirectoryListingCache, splitWatchedPath } from './directoryListingCache';
19
19
  import { encodeWorkspacePath as encodeWsPath, getSessionDirectory as getSessionDir, discoverSessionDirectory as discoverSessionDir, findActiveSession as findActiveSessionPath, findAllSessions as findAllSessionPaths, findSessionsInDirectory as findSessionsInDir, decodeEncodedPath, getAllProjectFolders as getAllProjectFoldersRaw, findSessionFilesWithStats, } from '../parsers/sessionPathResolver';
@@ -119,7 +119,8 @@ class ClaudeCodeReader {
119
119
  this.usageDeduper = usageDeduper ?? new ClaudeUsageDeduper();
120
120
  this.parser = new JsonlParser({
121
121
  onEvent: (raw) => {
122
- for (const event of extractSessionEvents(raw)) {
122
+ // Mid-turn task notifications finish background subagents.
123
+ for (const event of extractSessionEvents(raw, { includeTaskNotifications: true })) {
123
124
  this.events.push(this.usageDeduper.apply(normalizeClaudeUsage(event)));
124
125
  }
125
126
  },
@@ -208,6 +209,13 @@ class ClaudeCodeReader {
208
209
  wasTruncated() {
209
210
  return this._wasTruncated;
210
211
  }
212
+ /** The usage deduper's recent message ids, so a resumed reader does not recount a split response. */
213
+ getResumeState() {
214
+ return this.usageDeduper.exportState();
215
+ }
216
+ restoreResumeState(state) {
217
+ return this.usageDeduper.importState(state);
218
+ }
211
219
  }
212
220
  /**
213
221
  * Session provider for Claude Code CLI.
@@ -18,7 +18,7 @@ import { CodexDatabase, spawnParentFromSource } from './codexDatabase';
18
18
  import { cachedSubagentStats } from '../parsers/subagentStatsCache';
19
19
  import { DEFAULT_ROLLOUT_WALK_MAX_DEPTH, extractRolloutSessionId as extractSessionId, isRolloutFile, walkRolloutFiles, } from './rolloutWalker';
20
20
  import { getCodexSessionsDirs } from '../codexPaths';
21
- import { cwdMatches, getSessionsDirs, listCodexSessionFilesAsync, normalizePath, readFirstLines, readSessionCwd, readSessionCwdAsync, readSessionMeta, } from './codexRollouts';
21
+ import { cwdMatches, findCodexRolloutFile, getSessionsDirs, listCodexSessionFilesAsync, normalizePath, readFirstLines, readSessionCwd, readSessionCwdAsync, readSessionMeta, } from './codexRollouts';
22
22
  import { DirectoryListingCache, splitWatchedPath } from './directoryListingCache';
23
23
  import { ProviderDiagnosticTracker, diagnosticFromRuntimeStatus } from './diagnostics';
24
24
  import { getModelContextWindowSize } from '../modelContext';
@@ -47,29 +47,7 @@ function hasRolloutFile(dir) {
47
47
  function findRolloutFilesInConfiguredHomes(options = {}) {
48
48
  return walkRolloutFiles(getSessionsDirs(), options);
49
49
  }
50
- /**
51
- * Locates the rollout file for a Codex session id by walking
52
- * `<home>/sessions/YYYY/MM/DD/rollout-<timestamp>-<id>.jsonl`, then
53
- * `<home>/archived_sessions/`, where Codex moves the rollouts of archived
54
- * threads.
55
- *
56
- * Filename-only match, case-insensitive on the id — no file contents are read
57
- * and files are only stat'ed once their name matches, so the walk stays cheap
58
- * even over a large history. When the same id exists in more than one home,
59
- * the most recently modified copy wins. Returns `null` for an unknown, empty,
60
- * or whitespace id. Consumers who want the database's answer instead can query
61
- * the already-public `CodexDatabase` (requires the external `sqlite3` binary).
62
- */
63
- export function findCodexRolloutFile(sessionId, options = {}) {
64
- const normalizedId = sessionId.trim().toLowerCase();
65
- if (!normalizedId)
66
- return null;
67
- const sessionsDirs = options.codexHome
68
- ? getCodexSessionsDirs([options.codexHome])
69
- : getSessionsDirs();
70
- // Name-only filter, one walk across the homes, newest modification first.
71
- return walkRolloutFiles(sessionsDirs, { sessionId: normalizedId })[0]?.path ?? null;
72
- }
50
+ export { findCodexRolloutFile };
73
51
  /** Pick the best sessions directory for UI/search entrypoints. */
74
52
  function getSessionsDir() {
75
53
  const dirs = getCodexSessionsDirs(undefined, { includeArchived: false });
@@ -292,6 +270,12 @@ class CodexReader {
292
270
  lineBuffer = '';
293
271
  _wasTruncated = false;
294
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;
295
279
  constructor(rolloutPath, onContextWindowLimit, onRateLimits, onTokenUsage) {
296
280
  this.rolloutPath = rolloutPath;
297
281
  this.onContextWindowLimit = onContextWindowLimit;
@@ -301,9 +285,11 @@ class CodexReader {
301
285
  }
302
286
  /** Get the parser's session metadata. */
303
287
  getSessionMeta() {
288
+ this.settleSeek();
304
289
  return this.parser.getSessionMeta();
305
290
  }
306
291
  readNew(endPosition) {
292
+ this.settleSeek();
307
293
  this._wasTruncated = false;
308
294
  const events = [];
309
295
  try {
@@ -357,47 +343,51 @@ class CodexReader {
357
343
  // Skip malformed JSON lines
358
344
  }
359
345
  }
360
- // Propagate model_context_window from token_count events to the provider.
361
- // The model comes along so the value can be persisted per model — the
362
- // window Codex reports is tier-specific, not the published maximum.
363
- const mcw = this.parser.getModelContextWindow();
364
- if (mcw && this.onContextWindowLimit) {
365
- this.onContextWindowLimit(mcw, this.parser.getCurrentModel() ?? undefined);
366
- }
367
- // Propagate rate_limits from token_count events to the provider
368
- const rateLimits = this.parser.getLastRateLimits();
369
- if (rateLimits && this.onRateLimits) {
370
- this.onRateLimits(rateLimits);
371
- }
372
- // Propagate last token usage snapshot to the provider
373
- const lastUsage = this.parser.getLastTokenUsage();
374
- if (lastUsage && this.onTokenUsage) {
375
- const normalized = normalizeProviderUsage({
376
- semantics: 'openai',
377
- provider: 'openai',
378
- source: 'codex-reader-snapshot',
379
- model: this.parser.getCurrentModel() || undefined,
380
- inputTokens: lastUsage.input_tokens,
381
- outputTokens: lastUsage.output_tokens,
382
- cacheReadTokens: lastUsage.cached_input_tokens,
383
- reasoningTokens: lastUsage.reasoning_output_tokens,
384
- });
385
- this.onTokenUsage({
386
- inputTokens: normalized.uncachedInputTokens,
387
- outputTokens: normalized.outputTokens,
388
- cacheWriteTokens: normalized.cacheWriteTokens,
389
- cacheReadTokens: normalized.cacheReadTokens,
390
- reasoningTokens: normalized.reasoningTokens,
391
- model: this.parser.getCurrentModel() || 'unknown',
392
- timestamp: new Date(),
393
- });
394
- }
346
+ this.propagateParserState();
395
347
  }
396
348
  catch {
397
349
  // Skip read errors
398
350
  }
399
351
  return events;
400
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
+ }
401
391
  readAll() {
402
392
  this.reset();
403
393
  return this.readNew();
@@ -409,11 +399,13 @@ class CodexReader {
409
399
  this.parser.reset();
410
400
  this.decoder = new StringDecoder('utf8');
411
401
  this._wasTruncated = false;
402
+ this.pendingSeek = null;
412
403
  }
413
404
  exists() {
414
405
  return fs.existsSync(this.rolloutPath);
415
406
  }
416
407
  flush() {
408
+ this.settleSeek();
417
409
  this.lineBuffer += this.decoder.end();
418
410
  // Process any remaining data in the line buffer
419
411
  if (this.lineBuffer.trim()) {
@@ -431,15 +423,45 @@ class CodexReader {
431
423
  return this.committedPosition;
432
424
  }
433
425
  seekTo(position) {
434
- // Rebuild model, turn, and cumulative-token context before resuming.
435
- // The prefix is parsed only; its events are already in the consumer snapshot.
436
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.
437
431
  if (position > 0)
438
- this.readNew(position);
432
+ this.pendingSeek = position;
439
433
  }
440
434
  wasTruncated() {
441
435
  return this._wasTruncated;
442
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
+ }
443
465
  }
444
466
  // ---------------------------------------------------------------------------
445
467
  // CodexProvider — full SessionProviderBase implementation
@@ -5,30 +5,14 @@
5
5
  import * as fs from 'fs';
6
6
  import * as path from 'path';
7
7
  import { execFile, execFileSync } from 'child_process';
8
+ import { codexSpawnParentFromSource as spawnParentFromSource } from '../parsers/codexRolloutOrigin';
8
9
  /**
9
10
  * Ids per inlined `IN (…)` query. Values are passed inside a single argv
10
11
  * element to sqlite3, so an unbounded list would hit E2BIG on large stores.
11
12
  */
12
13
  const SQL_ID_CHUNK_SIZE = 400;
13
- /** Parent thread id recorded in a Codex `threads.source` JSON value, if any. */
14
- export function spawnParentFromSource(source) {
15
- let parsed = source;
16
- if (typeof source === 'string') {
17
- if (!source.startsWith('{'))
18
- return undefined;
19
- try {
20
- parsed = JSON.parse(source);
21
- }
22
- catch {
23
- return undefined;
24
- }
25
- }
26
- if (!parsed || typeof parsed !== 'object')
27
- return undefined;
28
- const parent = parsed
29
- .subagent?.thread_spawn?.parent_thread_id;
30
- return typeof parent === 'string' ? parent : undefined;
31
- }
14
+ /** Parent thread id of a spawned subagent's `source`; one rule shared with rollout parsing. */
15
+ export { spawnParentFromSource };
32
16
  export class CodexDatabase {
33
17
  dbPath;
34
18
  sqlite3Available = null;
@@ -8,7 +8,7 @@ import * as fs from 'fs';
8
8
  import * as path from 'path';
9
9
  import { getCodexSessionsDirs } from '../codexPaths';
10
10
  import { DirectoryListingCache } from './directoryListingCache';
11
- import { walkRolloutFilesAsync } from './rolloutWalker';
11
+ import { walkRolloutFiles, walkRolloutFilesAsync } from './rolloutWalker';
12
12
  /** Every directory across the monitored homes that may hold rollouts, archived ones included. */
13
13
  export function getSessionsDirs() {
14
14
  return getCodexSessionsDirs();
@@ -213,3 +213,26 @@ export async function listCodexSessionFilesAsync(workspacePath, options = {}, ca
213
213
  }
214
214
  return results;
215
215
  }
216
+ /**
217
+ * Locates the rollout file for a Codex session id by walking
218
+ * `<home>/sessions/YYYY/MM/DD/rollout-<timestamp>-<id>.jsonl`, then
219
+ * `<home>/archived_sessions/`, where Codex moves the rollouts of archived
220
+ * threads.
221
+ *
222
+ * Filename-only match, case-insensitive on the id — no file contents are read
223
+ * and files are only stat'ed once their name matches, so the walk stays cheap
224
+ * even over a large history. When the same id exists in more than one home,
225
+ * the most recently modified copy wins. Returns `null` for an unknown, empty,
226
+ * or whitespace id. Consumers who want the database's answer instead can query
227
+ * the already-public `CodexDatabase` (requires the external `sqlite3` binary).
228
+ */
229
+ export function findCodexRolloutFile(sessionId, options = {}) {
230
+ const normalizedId = sessionId.trim().toLowerCase();
231
+ if (!normalizedId)
232
+ return null;
233
+ const sessionsDirs = options.codexHome
234
+ ? getCodexSessionsDirs([options.codexHome])
235
+ : getSessionsDirs();
236
+ // Name-only filter, one walk across the homes, newest modification first.
237
+ return walkRolloutFiles(sessionsDirs, { sessionId: normalizedId })[0]?.path ?? null;
238
+ }
@@ -21,9 +21,8 @@ import * as path from 'path';
21
21
  import { execSync } from 'child_process';
22
22
  import { readSessionContextSnapshot } from '../context/sessionContext';
23
23
  import { readSessionFileStats } from '../sessionStats';
24
- import { convertOpenCodeMessage, parseDbMessageData, parseDbPartData, normalizeToolName, normalizeToolInput, } from '../parsers/openCodeParser';
24
+ import { convertOpenCodeMessage, parseDbMessageData, parseDbPartData, normalizeOpenCodeUsage, normalizeToolName, normalizeToolInput, } from '../parsers/openCodeParser';
25
25
  import { OpenCodeDatabase } from './openCodeDatabase';
26
- import { normalizeProviderUsage } from '../usageNormalization';
27
26
  import { addUsageToSubagent } from '../usage/subagentUsage';
28
27
  import { accumulateZaiUsage, inferZaiQuotaState, parseZaiQuotaError, ZAI_PROVIDER_IDS, } from '../zaiQuota';
29
28
  import { ProviderDiagnosticTracker, diagnosticFromRuntimeStatus } from './diagnostics';
@@ -1456,21 +1455,14 @@ export class OpenCodeProvider {
1456
1455
  if (tokens) {
1457
1456
  // Same semantics as the main-session parser (openCodeParser).
1458
1457
  const cache = tokens.cache;
1459
- const providerID = msgData.providerID;
1460
- addUsageToSubagent(tokenTotals, normalizeProviderUsage({
1461
- semantics: providerID === 'openai'
1462
- ? 'openai'
1463
- : providerID === 'anthropic'
1464
- ? 'anthropic'
1465
- : 'sidekick',
1466
- provider: providerID ?? 'opencode',
1467
- inputTokens: tokens.input,
1468
- outputTokens: tokens.output,
1469
- cacheReadTokens: cache?.read,
1470
- cacheWriteTokens: cache?.write,
1471
- reasoningTokens: tokens.reasoning,
1472
- reasoningIncludedInOutput: providerID === 'openai',
1473
- }));
1458
+ addUsageToSubagent(tokenTotals, normalizeOpenCodeUsage({
1459
+ input: tokens.input,
1460
+ output: tokens.output,
1461
+ cacheRead: cache?.read,
1462
+ cacheWrite: cache?.write,
1463
+ reasoning: tokens.reasoning,
1464
+ total: tokens.total,
1465
+ }, msgData.providerID));
1474
1466
  }
1475
1467
  }
1476
1468
  catch {