sidekick-shared 0.28.3 → 0.29.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 (97) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +149 -19
  3. package/dist/authStatus.d.ts +54 -0
  4. package/dist/authStatus.js +91 -0
  5. package/dist/browser.d.ts +12 -6
  6. package/dist/browser.js +17 -1
  7. package/dist/codex.d.ts +23 -0
  8. package/dist/codex.js +44 -0
  9. package/dist/codexProfiles.d.ts +1 -0
  10. package/dist/codexProfiles.js +8 -11
  11. package/dist/esm/authStatus.js +87 -0
  12. package/dist/esm/browser.js +4 -2
  13. package/dist/esm/codex.js +18 -0
  14. package/dist/esm/codexProfiles.js +8 -11
  15. package/dist/esm/formatting.js +10 -1
  16. package/dist/esm/index.js +9 -6
  17. package/dist/esm/modelCatalog.js +2 -1
  18. package/dist/esm/modelInfo.js +422 -45
  19. package/dist/esm/node.js +9 -3
  20. package/dist/esm/parsers/codexParser.js +175 -54
  21. package/dist/esm/parsers/codexRolloutOrigin.js +107 -0
  22. package/dist/esm/parsers/openCodeParser.js +83 -21
  23. package/dist/esm/pricing.js +18 -0
  24. package/dist/esm/pricingCatalog.js +14 -7
  25. package/dist/esm/promptHistory.js +66 -4
  26. package/dist/esm/providerFailure.js +124 -3
  27. package/dist/esm/providers/codex.js +2 -24
  28. package/dist/esm/providers/codexDatabase.js +3 -19
  29. package/dist/esm/providers/codexRollouts.js +24 -1
  30. package/dist/esm/providers/openCode.js +9 -17
  31. package/dist/esm/report/htmlReportGenerator.js +42 -5
  32. package/dist/esm/report/transcriptParser.js +13 -11
  33. package/dist/esm/schemas/providerFailure.js +3 -1
  34. package/dist/esm/schemas/transcript.js +4 -0
  35. package/dist/esm/sessionPromptHistory.js +2 -2
  36. package/dist/esm/statusline/payload.js +9 -0
  37. package/dist/esm/testing/credentialGuard.js +87 -5
  38. package/dist/esm/toolCallOrigins.js +310 -0
  39. package/dist/esm/transcript.js +42 -3
  40. package/dist/esm/usage/usageEvents.js +146 -17
  41. package/dist/esm/usage/usageReports.js +39 -6
  42. package/dist/esm/watchers/jsonlTail.js +378 -59
  43. package/dist/formatting.d.ts +26 -1
  44. package/dist/formatting.js +11 -1
  45. package/dist/index.d.ts +16 -10
  46. package/dist/index.js +35 -12
  47. package/dist/modelCatalog.js +2 -1
  48. package/dist/modelInfo.d.ts +185 -16
  49. package/dist/modelInfo.js +431 -47
  50. package/dist/node.d.ts +16 -5
  51. package/dist/node.js +31 -2
  52. package/dist/parsers/codexParser.d.ts +55 -0
  53. package/dist/parsers/codexParser.js +175 -54
  54. package/dist/parsers/codexRolloutOrigin.d.ts +49 -0
  55. package/dist/parsers/codexRolloutOrigin.js +111 -0
  56. package/dist/parsers/openCodeParser.d.ts +38 -0
  57. package/dist/parsers/openCodeParser.js +86 -21
  58. package/dist/pricing.d.ts +22 -0
  59. package/dist/pricing.js +50 -0
  60. package/dist/pricingCatalog.d.ts +10 -0
  61. package/dist/pricingCatalog.js +13 -6
  62. package/dist/promptHistory.d.ts +65 -4
  63. package/dist/promptHistory.js +68 -5
  64. package/dist/providerFailure.d.ts +44 -0
  65. package/dist/providerFailure.js +126 -4
  66. package/dist/providers/codex.d.ts +3 -22
  67. package/dist/providers/codex.js +3 -26
  68. package/dist/providers/codexDatabase.d.ts +3 -2
  69. package/dist/providers/codexDatabase.js +4 -22
  70. package/dist/providers/codexRollouts.d.ts +22 -0
  71. package/dist/providers/codexRollouts.js +24 -0
  72. package/dist/providers/openCode.js +8 -16
  73. package/dist/report/htmlReportGenerator.js +42 -5
  74. package/dist/report/transcriptParser.d.ts +10 -1
  75. package/dist/report/transcriptParser.js +13 -11
  76. package/dist/schemas/providerFailure.d.ts +39 -0
  77. package/dist/schemas/providerFailure.js +2 -0
  78. package/dist/schemas/transcript.d.ts +2 -0
  79. package/dist/schemas/transcript.js +4 -0
  80. package/dist/sessionPromptHistory.d.ts +7 -1
  81. package/dist/sessionPromptHistory.js +1 -1
  82. package/dist/statusline/payload.d.ts +10 -0
  83. package/dist/statusline/payload.js +14 -0
  84. package/dist/testing/credentialGuard.d.ts +14 -0
  85. package/dist/testing/credentialGuard.js +87 -5
  86. package/dist/toolCallOrigins.d.ts +70 -0
  87. package/dist/toolCallOrigins.js +346 -0
  88. package/dist/transcript.d.ts +12 -0
  89. package/dist/transcript.js +42 -3
  90. package/dist/types/opencode.d.ts +2 -0
  91. package/dist/usage/usageEvents.d.ts +54 -6
  92. package/dist/usage/usageEvents.js +146 -17
  93. package/dist/usage/usageReports.d.ts +35 -4
  94. package/dist/usage/usageReports.js +39 -6
  95. package/dist/watchers/jsonlTail.d.ts +101 -4
  96. package/dist/watchers/jsonlTail.js +380 -60
  97. package/package.json +41 -1
@@ -19,7 +19,7 @@
19
19
  import { promises as fs } from 'node:fs';
20
20
  import * as path from 'node:path';
21
21
  import { _setCatalogContextWindows } from './modelContext';
22
- import { _setPricingOverrides } from './modelInfo';
22
+ import { _setPricingOverrides, getPricingCatalogInfo } from './modelInfo';
23
23
  import { getConfigDir } from './paths';
24
24
  import { atomicWriteJson } from './writers/atomic';
25
25
  // ── Constants ──
@@ -36,6 +36,10 @@ const DEFAULT_TIMEOUT_MS = 3_000;
36
36
  * 2. Otherwise attempt a network fetch; on success, persist + apply.
37
37
  * 3. On any failure, load a stale cache if present; else leave overrides empty.
38
38
  *
39
+ * `getPricingCatalogInfo().source` afterwards reads `litellm` for a fresh fetch
40
+ * or a cache within `ttlMs`, and `stale-cache` for a cache older than `ttlMs`
41
+ * — whether the refresh failed or `offline` skipped it.
42
+ *
39
43
  * Always resolves — never throws. Offline-safe.
40
44
  */
41
45
  export async function hydratePricingCatalog(options = {}) {
@@ -44,8 +48,9 @@ export async function hydratePricingCatalog(options = {}) {
44
48
  const now = Date.now();
45
49
  // 1. Fresh cache? (Offline callers take any cache, however old.)
46
50
  const cached = await readCache(cachePath);
47
- if (cached && (offline || now - Date.parse(cached.fetchedAt) < ttlMs)) {
48
- return applyCacheFile(cached, 'cache');
51
+ const cacheFresh = cached !== null && now - Date.parse(cached.fetchedAt) < ttlMs;
52
+ if (cached && (offline || cacheFresh)) {
53
+ return applyCacheFile(cached, 'cache', cacheFresh ? 'litellm' : 'stale-cache');
49
54
  }
50
55
  // 2. Try network.
51
56
  if (fetchImpl && !offline) {
@@ -58,12 +63,12 @@ export async function hydratePricingCatalog(options = {}) {
58
63
  contextWindows: normalizeLiteLlmContextWindows(fetched),
59
64
  };
60
65
  await writeCache(cachePath, payload, logger);
61
- return applyCacheFile(payload, 'network');
66
+ return applyCacheFile(payload, 'network', 'litellm');
62
67
  }
63
68
  }
64
69
  // 3. Fall back to stale cache if present.
65
70
  if (cached) {
66
- return applyCacheFile(cached, 'cache');
71
+ return applyCacheFile(cached, 'cache', 'stale-cache');
67
72
  }
68
73
  // 4. Nothing we can do — stay on static tables.
69
74
  return {
@@ -71,6 +76,7 @@ export async function hydratePricingCatalog(options = {}) {
71
76
  entries: 0,
72
77
  contextWindowEntries: 0,
73
78
  fetchedAt: new Date(now).toISOString(),
79
+ digest: getPricingCatalogInfo().digest,
74
80
  };
75
81
  }
76
82
  /**
@@ -78,9 +84,9 @@ export async function hydratePricingCatalog(options = {}) {
78
84
  * `contextWindows` is absent on caches written before context hydration; those
79
85
  * contribute no overrides rather than clearing whatever is already loaded.
80
86
  */
81
- function applyCacheFile(cache, source) {
87
+ function applyCacheFile(cache, source, basis) {
82
88
  const contextWindows = cache.contextWindows ?? {};
83
- _setPricingOverrides(cache.overrides);
89
+ _setPricingOverrides(cache.overrides, { source: basis, fetchedAt: cache.fetchedAt });
84
90
  if (Object.keys(contextWindows).length > 0) {
85
91
  _setCatalogContextWindows(contextWindows);
86
92
  }
@@ -89,6 +95,7 @@ function applyCacheFile(cache, source) {
89
95
  entries: Object.keys(cache.overrides).length,
90
96
  contextWindowEntries: Object.keys(contextWindows).length,
91
97
  fetchedAt: cache.fetchedAt,
98
+ digest: getPricingCatalogInfo().digest,
92
99
  };
93
100
  }
94
101
  /**
@@ -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 } : {}),
@@ -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 });
@@ -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 {
@@ -596,14 +596,51 @@ function renderContentBlock(block, includeThinking, includeToolDetail) {
596
596
  return '';
597
597
  }
598
598
  }
599
- function renderToolUse(block, includeDetail) {
599
+ /** Prefix of the bookkeeping keys session parsers stash inside tool inputs. */
600
+ const SIDEKICK_INPUT_KEY_PREFIX = '_sidekick';
601
+ /**
602
+ * The tool's display identity and user-facing input. Entries built outside
603
+ * the canonical projection may still carry the parsers' `_sidekick*` keys in
604
+ * `toolInput`; those are hidden here and read as the raw identity.
605
+ */
606
+ function toolDisplay(block) {
600
607
  const name = block.toolName || 'unknown';
608
+ const raw = block.toolInput;
609
+ let input = raw;
610
+ let rawToolName = block.rawToolName;
611
+ let mcpServerName = block.mcpServerName;
612
+ if (raw && Object.keys(raw).some((key) => key.startsWith(SIDEKICK_INPUT_KEY_PREFIX))) {
613
+ const clean = {};
614
+ for (const [key, value] of Object.entries(raw)) {
615
+ if (!key.startsWith(SIDEKICK_INPUT_KEY_PREFIX))
616
+ clean[key] = value;
617
+ }
618
+ input = clean;
619
+ if (!rawToolName && typeof raw._sidekickRawToolName === 'string') {
620
+ rawToolName = raw._sidekickRawToolName;
621
+ }
622
+ if (!mcpServerName && typeof raw._sidekickMcpServerName === 'string') {
623
+ mcpServerName = raw._sidekickMcpServerName;
624
+ }
625
+ }
626
+ const parts = [];
627
+ if (rawToolName && rawToolName.trim() && rawToolName !== name)
628
+ parts.push(rawToolName);
629
+ if (mcpServerName && mcpServerName.trim())
630
+ parts.push(`MCP: ${mcpServerName}`);
631
+ const hint = parts.length
632
+ ? ` <span class="tool-raw-name" style="color:var(--text-muted);font-size:11px">${escapeHtml(parts.join(' · '))}</span>`
633
+ : '';
634
+ return { name, hint, input };
635
+ }
636
+ function renderToolUse(block, includeDetail) {
637
+ const { name, hint, input } = toolDisplay(block);
601
638
  if (!includeDetail) {
602
- return `<div class="tool-block" data-block-type="tool"><div style="padding:8px 12px;background:rgba(245,166,35,0.08);color:var(--accent-orange);font-family:monospace;font-size:13px;">${escapeHtml(name)}</div></div>`;
639
+ return `<div class="tool-block" data-block-type="tool"><div style="padding:8px 12px;background:rgba(245,166,35,0.08);color:var(--accent-orange);font-family:monospace;font-size:13px;">${escapeHtml(name)}${hint}</div></div>`;
603
640
  }
604
641
  let inputHtml = '';
605
- if (block.toolInput && Object.keys(block.toolInput).length > 0) {
606
- const inputJson = JSON.stringify(block.toolInput, null, 2);
642
+ if (input && Object.keys(input).length > 0) {
643
+ const inputJson = JSON.stringify(input, null, 2);
607
644
  const highlighted = highlightCodeBlock(inputJson, 'json');
608
645
  inputHtml = `<div class="tool-io">
609
646
  <div class="tool-label">Input</div>
@@ -611,7 +648,7 @@ function renderToolUse(block, includeDetail) {
611
648
  </div>`;
612
649
  }
613
650
  return `<details class="tool-block" data-block-type="tool">
614
- <summary>${escapeHtml(name)}${block.toolUseId ? ` <span style="color:var(--text-muted);font-size:11px">${escapeHtml(block.toolUseId)}</span>` : ''}</summary>
651
+ <summary>${escapeHtml(name)}${hint}${block.toolUseId ? ` <span style="color:var(--text-muted);font-size:11px">${escapeHtml(block.toolUseId)}</span>` : ''}</summary>
615
652
  <div class="tool-content">
616
653
  ${inputHtml}
617
654
  </div>
@@ -42,17 +42,19 @@ function toReportBlock(block) {
42
42
  return block.text ? [{ type: 'text', text: block.text }] : [];
43
43
  case 'thinking':
44
44
  return block.text ? [{ type: 'thinking', text: block.text }] : [];
45
- case 'tool_use':
46
- return [
47
- {
48
- type: 'tool_use',
49
- toolName: block.toolName ?? 'unknown',
50
- toolInput: block.input && typeof block.input === 'object'
51
- ? block.input
52
- : {},
53
- toolUseId: block.toolUseId,
54
- },
55
- ];
45
+ case 'tool_use': {
46
+ const toolBlock = {
47
+ type: 'tool_use',
48
+ toolName: block.toolName ?? 'unknown',
49
+ toolInput: block.input && typeof block.input === 'object'
50
+ ? block.input
51
+ : {},
52
+ toolUseId: block.toolUseId,
53
+ ...(block.rawToolName ? { rawToolName: block.rawToolName } : {}),
54
+ ...(block.mcpServerName ? { mcpServerName: block.mcpServerName } : {}),
55
+ };
56
+ return [toolBlock];
57
+ }
56
58
  case 'tool_result':
57
59
  return [
58
60
  {
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { z } from 'zod';
11
11
  import { ACCOUNT_PROVIDER_IDS } from '../providerIds';
12
- import { PROVIDER_AUTHENTICATION_SOURCES, PROVIDER_AUTHENTICATION_STATES, PROVIDER_CREDENTIAL_KINDS, PROVIDER_FAILURE_CODES, PROVIDER_FAILURE_EVIDENCE_SOURCES, PROVIDER_RECOVERY_ACTIONS, } from '../providerFailure';
12
+ import { CODEX_ERROR_INFO_KINDS, PROVIDER_AUTHENTICATION_SOURCES, PROVIDER_AUTHENTICATION_STATES, PROVIDER_CREDENTIAL_KINDS, PROVIDER_FAILURE_CODES, PROVIDER_FAILURE_EVIDENCE_SOURCES, PROVIDER_RECOVERY_ACTIONS, } from '../providerFailure';
13
13
  export const providerAuthenticationEvidenceSchema = z.object({
14
14
  credentialKind: z.enum(PROVIDER_CREDENTIAL_KINDS),
15
15
  state: z.enum(PROVIDER_AUTHENTICATION_STATES),
@@ -20,6 +20,7 @@ export const providerFailureEvidenceSchema = z.object({
20
20
  source: z.enum(PROVIDER_FAILURE_EVIDENCE_SOURCES),
21
21
  rule: z.enum(PROVIDER_FAILURE_CODES),
22
22
  checkedAt: z.string().optional(),
23
+ codexErrorInfo: z.enum(CODEX_ERROR_INFO_KINDS).optional(),
23
24
  });
24
25
  export const providerRetryAfterSchema = z.discriminatedUnion('kind', [
25
26
  z.object({ kind: z.literal('delay'), delayMs: z.number() }),
@@ -30,6 +31,7 @@ export const providerFailureDiagnosisSchema = z.object({
30
31
  credentialKind: z.enum(PROVIDER_CREDENTIAL_KINDS),
31
32
  diagnosis: z.enum(PROVIDER_FAILURE_CODES),
32
33
  recovery: z.enum(PROVIDER_RECOVERY_ACTIONS),
34
+ retryable: z.boolean().optional(),
33
35
  evidence: z.array(providerFailureEvidenceSchema),
34
36
  authentication: providerAuthenticationEvidenceSchema.optional(),
35
37
  httpStatus: z.number().optional(),