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
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import * as fs from 'fs';
6
6
  import { fileHeadOf, HEAD_BYTES, headMatches } from '../fileHead';
7
+ import { FileContinuity, probeFileContinuity } from '../fileContinuity';
7
8
  import { JsonlParser } from '../parsers/jsonl';
8
9
  const DEFAULT_DEBOUNCE_MS = 100;
9
10
  const DEFAULT_CATCHUP_INTERVAL_MS = 30_000;
@@ -60,6 +61,63 @@ export class JsonlTailSymlinkError extends Error {
60
61
  export function createJsonlTail(options) {
61
62
  return new JsonlTailReader(options);
62
63
  }
64
+ /**
65
+ * Records a {@link JsonlTailCheckpoint} for byte `offset` of `filePath`
66
+ * without a tail, for readers that keep their own offset (a snapshot that
67
+ * stores a reader position, for example). The checkpoint carries the file's
68
+ * current device and inode and a digest of its first bytes, so
69
+ * {@link verifyJsonlCheckpoint} (or {@link JsonlTail.resumeFrom}) can later
70
+ * tell whether `offset` still refers to the same, append-only content.
71
+ *
72
+ * Returns `null` when the file is missing or unreadable, or when `offset` is
73
+ * not a resume-safe position in it: negative, past the end, or not 0 and not
74
+ * just after a newline. Follows symbolic links.
75
+ */
76
+ export function captureJsonlCheckpoint(filePath, offset) {
77
+ if (!Number.isSafeInteger(offset) || offset < 0)
78
+ return null;
79
+ let fd = null;
80
+ try {
81
+ fd = fs.openSync(filePath, 'r');
82
+ const stat = fs.fstatSync(fd);
83
+ if (offset > stat.size)
84
+ return null;
85
+ if (offset > 0 && readAt(fd, offset - 1, 1)[0] !== 0x0a)
86
+ return null;
87
+ return checkpointOf(offset, stat, fileHeadOf(readPrefix(fd, stat)));
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ finally {
93
+ closeQuietly(fd);
94
+ }
95
+ }
96
+ /**
97
+ * Whether `checkpoint` (any value, typically parsed from JSON) is a valid
98
+ * {@link JsonlTailCheckpoint} that still describes `filePath`: same device
99
+ * and inode, the recorded first bytes unchanged, and its offset within the
100
+ * file and just after a newline (or 0). This is the check
101
+ * {@link JsonlTail.resumeFrom} applies. `false` on any error, including a
102
+ * missing file. Follows symbolic links.
103
+ */
104
+ export function verifyJsonlCheckpoint(filePath, checkpoint) {
105
+ const parsed = parseCheckpoint(checkpoint);
106
+ if (parsed === null)
107
+ return false;
108
+ let fd = null;
109
+ try {
110
+ fd = fs.openSync(filePath, 'r');
111
+ const stat = fs.fstatSync(fd);
112
+ return checkpointMatches(parsed, fd, stat, readPrefix(fd, stat));
113
+ }
114
+ catch {
115
+ return false;
116
+ }
117
+ finally {
118
+ closeQuietly(fd);
119
+ }
120
+ }
63
121
  class JsonlTailReader {
64
122
  options;
65
123
  active = false;
@@ -71,10 +129,8 @@ class JsonlTailReader {
71
129
  /** Inside a line over `maxLineBytes`: drop bytes until its newline. */
72
130
  skippingLine = false;
73
131
  currentLine = { offset: 0, length: 0 };
74
- /** Identity and prefix of the file the offsets refer to, once read. */
75
- identity = null;
76
- head = null;
77
- lastMtimeMs = -1;
132
+ /** Identity, mtime, and prefix of the file the offsets refer to, once read. */
133
+ continuity = new FileContinuity();
78
134
  maxReadBytes;
79
135
  maxLineBytes;
80
136
  rewritePolicy;
@@ -282,21 +338,10 @@ class JsonlTailReader {
282
338
  }
283
339
  /** Whether the continuity check needs bytes from the file. */
284
340
  probeNeeded(stat) {
285
- return (this.identity === null ||
286
- stat.dev !== this.identity.dev ||
287
- stat.ino !== this.identity.ino ||
288
- stat.size < this.readOffset ||
289
- stat.mtimeMs !== this.lastMtimeMs ||
290
- this.head === null ||
291
- this.head.bytes < Math.min(HEAD_BYTES, stat.size));
341
+ return this.continuity.needsProbe(stat, this.readOffset);
292
342
  }
293
343
  probeSync(fd, stat) {
294
- return {
295
- prefix: readAt(fd, 0, Math.min(HEAD_BYTES, stat.size)),
296
- before: this.committedOffset > 0 && this.committedOffset <= stat.size
297
- ? readAt(fd, this.committedOffset - 1, 1)
298
- : null,
299
- };
344
+ return probeFileContinuity(fd, stat, this.committedOffset);
300
345
  }
301
346
  async probeAsync(handle, stat) {
302
347
  const committedOffset = this.committedOffset;
@@ -313,31 +358,16 @@ class JsonlTailReader {
313
358
  * newline. `null` when it can continue.
314
359
  */
315
360
  resetReason(stat, probe) {
316
- if (this.identity && (stat.dev !== this.identity.dev || stat.ino !== this.identity.ino)) {
317
- return 'replaced';
318
- }
319
- if (stat.size < this.readOffset)
320
- return 'truncated';
321
- if (this.identity && stat.mtimeMs !== this.lastMtimeMs && probe) {
322
- if (this.head && !headMatches(this.head, probe.prefix))
323
- return 'rewritten';
324
- if (probe.before && probe.before[0] !== 0x0a)
325
- return 'rewritten';
326
- }
327
- return null;
361
+ return this.continuity.reasonFor(stat, this.readOffset, probe);
328
362
  }
329
363
  /** Records the identity and prefix of the file the offsets now refer to. */
330
364
  adoptFile(stat, probe) {
331
- this.identity = { dev: stat.dev, ino: stat.ino };
332
- this.lastMtimeMs = stat.mtimeMs;
333
- if (probe && (!this.head || this.head.bytes < Math.min(HEAD_BYTES, stat.size))) {
334
- this.head = fileHeadOf(probe.prefix);
335
- }
365
+ this.continuity.record(stat, probe);
336
366
  }
337
367
  reset(reason, offset) {
338
368
  const previousOffset = this.committedOffset;
339
369
  this.position(offset);
340
- this.head = null;
370
+ this.continuity.discardHead();
341
371
  if (reason === 'replaced' && this.active) {
342
372
  // fs.watch follows the old inode; watch the file now at the path.
343
373
  this.fsWatcher?.close();
@@ -412,12 +442,7 @@ class JsonlTailReader {
412
442
  this.position(Math.max(0, offset));
413
443
  }
414
444
  checkpoint() {
415
- return {
416
- version: JSONL_TAIL_CHECKPOINT_VERSION,
417
- offset: this.committedOffset,
418
- identity: this.identity ? { dev: this.identity.dev, ino: this.identity.ino } : null,
419
- prefixDigest: this.head ? { bytes: this.head.bytes, hash: this.head.hash } : null,
420
- };
445
+ return checkpointOf(this.committedOffset, this.continuity.knownIdentity, this.continuity.knownHead);
421
446
  }
422
447
  resumeFrom(checkpoint) {
423
448
  this.generation += 1;
@@ -427,7 +452,7 @@ class JsonlTailReader {
427
452
  try {
428
453
  fd = this.openSync();
429
454
  const stat = fs.fstatSync(fd);
430
- const prefix = readAt(fd, 0, Math.min(HEAD_BYTES, stat.size));
455
+ const prefix = readPrefix(fd, stat);
431
456
  const matches = parsed !== null && checkpointMatches(parsed, fd, stat, prefix);
432
457
  const offset = matches
433
458
  ? parsed.offset
@@ -435,16 +460,12 @@ class JsonlTailReader {
435
460
  ? lastCompleteLineOffset(fd, stat.size)
436
461
  : 0;
437
462
  this.position(offset);
438
- this.identity = { dev: stat.dev, ino: stat.ino };
439
- this.lastMtimeMs = stat.mtimeMs;
440
- this.head = fileHeadOf(prefix);
463
+ this.continuity.adoptPrefix(stat, prefix);
441
464
  return matches;
442
465
  }
443
466
  catch (error) {
444
467
  this.position(0);
445
- this.identity = null;
446
- this.head = null;
447
- this.lastMtimeMs = -1;
468
+ this.continuity.forget();
448
469
  this.options.onError?.(toError(error));
449
470
  return false;
450
471
  }
@@ -559,6 +580,15 @@ function parseCheckpoint(value) {
559
580
  prefixDigest: parsedDigest,
560
581
  };
561
582
  }
583
+ /** A fresh checkpoint for `offset` in the file with `identity` and recorded `head`. */
584
+ function checkpointOf(offset, identity, head) {
585
+ return {
586
+ version: JSONL_TAIL_CHECKPOINT_VERSION,
587
+ offset,
588
+ identity: identity ? { dev: identity.dev, ino: identity.ino } : null,
589
+ prefixDigest: head ? { bytes: head.bytes, hash: head.hash } : null,
590
+ };
591
+ }
562
592
  /** Whether `checkpoint` still describes the open file. */
563
593
  function checkpointMatches(checkpoint, fd, stat, prefix) {
564
594
  const { offset, identity, prefixDigest } = checkpoint;
@@ -601,6 +631,10 @@ function readAt(fd, position, length) {
601
631
  const count = length > 0 ? fs.readSync(fd, buffer, 0, length, position) : 0;
602
632
  return buffer.subarray(0, count);
603
633
  }
634
+ /** The file's first `min(HEAD_BYTES, size)` bytes: what a checkpoint digests. */
635
+ function readPrefix(fd, stat) {
636
+ return readAt(fd, 0, Math.min(HEAD_BYTES, stat.size));
637
+ }
604
638
  async function readAtAsync(handle, position, length) {
605
639
  const buffer = Buffer.alloc(length);
606
640
  const count = length > 0 ? (await handle.read(buffer, 0, length, position)).bytesRead : 0;
@@ -3,9 +3,10 @@
3
3
  * Uses byte-offset tracking + fs.watch with debounce + catch-up polling.
4
4
  */
5
5
  import { createJsonlTail } from './jsonlTail';
6
- import { normalizeCodexToolName, resolveCodexCallUsage } from '../parsers/codexParser';
6
+ import { codexTokenUsageFrom, normalizeCodexToolName, resolveCodexCallUsage, } from '../parsers/codexParser';
7
7
  import { formatToolSummary } from '../formatters/toolSummary';
8
8
  import { ClaudeUsageDeduper, normalizeClaudeUsage } from '../usage/claudeUsageDedupe';
9
+ import { describeAgentsStopped, describeTaskStatus, isAgentsKilledSystemEvent, isFinalTaskStatus, parseAgentsStoppedNotice, parseTaskNotification, taskNotificationPromptFromAttachment, taskStatusFromAttachment, } from '../subagentSpawn';
9
10
  // ── Normalizers ──
10
11
  /**
11
12
  * Split lines of one Claude response repeat its usage; run the raw usage
@@ -21,7 +22,7 @@ function dedupedClaudeUsage(raw, deduper) {
21
22
  }));
22
23
  return { usage: event.message?.usage, usageKind: event.message?.usageKind };
23
24
  }
24
- function normalizeClaudeCodeEvent(raw, deduper) {
25
+ function normalizeClaudeCodeEvent(raw, deduper, includeTaskNotifications) {
25
26
  const events = [];
26
27
  const ts = raw.timestamp || new Date().toISOString();
27
28
  const permissionMode = raw.permissionMode;
@@ -133,6 +134,29 @@ function normalizeClaudeCodeEvent(raw, deduper) {
133
134
  raw,
134
135
  });
135
136
  }
137
+ else if (includeTaskNotifications) {
138
+ // A task notification that landed mid-turn is only an attachment line.
139
+ const prompt = taskNotificationPromptFromAttachment(raw);
140
+ const notification = parseTaskNotification(prompt);
141
+ const stopped = notification ? null : parseAgentsStoppedNotice(prompt);
142
+ const taskStatus = taskStatusFromAttachment(raw);
143
+ let summary = null;
144
+ if (notification) {
145
+ summary = notification.summary ?? `Background task ${notification.status ?? 'finished'}`;
146
+ }
147
+ else if (stopped) {
148
+ summary = describeAgentsStopped(stopped);
149
+ }
150
+ else if (taskStatus && isFinalTaskStatus(taskStatus.status)) {
151
+ summary = describeTaskStatus(taskStatus);
152
+ }
153
+ else if (isAgentsKilledSystemEvent(raw)) {
154
+ summary = describeAgentsStopped(null);
155
+ }
156
+ if (summary !== null) {
157
+ events.push({ providerId: 'claude-code', type: 'system', timestamp: ts, summary, raw });
158
+ }
159
+ }
136
160
  }
137
161
  // Propagate permission mode to all generated events
138
162
  if (permissionMode) {
@@ -204,16 +228,18 @@ export class JsonlSessionWatcher {
204
228
  providerId;
205
229
  sessionPath;
206
230
  callbacks;
231
+ options;
207
232
  tail;
208
233
  startOffset;
209
234
  codexInPlanMode = false;
210
235
  claudeUsageDeduper = new ClaudeUsageDeduper();
211
236
  /** Last Codex cumulative usage seen; repeated `token_count` events carry the same value. */
212
237
  codexPrevTotal;
213
- constructor(providerId, sessionPath, callbacks) {
238
+ constructor(providerId, sessionPath, callbacks, options = {}) {
214
239
  this.providerId = providerId;
215
240
  this.sessionPath = sessionPath;
216
241
  this.callbacks = callbacks;
242
+ this.options = options;
217
243
  }
218
244
  get isActive() {
219
245
  return this.tail?.isActive ?? false;
@@ -227,6 +253,37 @@ export class JsonlSessionWatcher {
227
253
  getPosition() {
228
254
  return this.tail?.getOffset() ?? this.startOffset ?? 0;
229
255
  }
256
+ /**
257
+ * Parse state for {@link getPosition}: for Claude Code, the usage deduper's
258
+ * recent message ids (a `ClaudeUsageDedupeState`); for Codex, the previous
259
+ * cumulative token total and plan mode.
260
+ */
261
+ getResumeState() {
262
+ if (this.providerId === 'codex') {
263
+ return {
264
+ version: 1,
265
+ prevTotalUsage: this.codexPrevTotal ? { ...this.codexPrevTotal } : null,
266
+ inPlanMode: this.codexInPlanMode,
267
+ };
268
+ }
269
+ return this.claudeUsageDeduper.exportState();
270
+ }
271
+ /** Restores state from {@link getResumeState} after `seekTo()` and before `start()`. */
272
+ restoreResumeState(state) {
273
+ if (this.providerId !== 'codex')
274
+ return this.claudeUsageDeduper.importState(state);
275
+ if (!state || typeof state !== 'object')
276
+ return false;
277
+ const { version, prevTotalUsage, inPlanMode } = state;
278
+ if (version !== 1 || typeof inPlanMode !== 'boolean')
279
+ return false;
280
+ const total = codexTokenUsageFrom(prevTotalUsage);
281
+ if (total === undefined)
282
+ return false;
283
+ this.codexPrevTotal = total ?? undefined;
284
+ this.codexInPlanMode = inPlanMode;
285
+ return true;
286
+ }
230
287
  start(replay) {
231
288
  if (this.isActive)
232
289
  return;
@@ -258,7 +315,7 @@ export class JsonlSessionWatcher {
258
315
  // JsonlParser parses any JSON object. Cast to Record for codex normalizer.
259
316
  const followEvents = this.providerId === 'codex'
260
317
  ? this.normalizeCodexEvent(event)
261
- : normalizeClaudeCodeEvent(event, this.claudeUsageDeduper);
318
+ : normalizeClaudeCodeEvent(event, this.claudeUsageDeduper, this.options.includeTaskNotifications === true);
262
319
  for (const fe of followEvents) {
263
320
  this.callbacks.onEvent(fe);
264
321
  }
@@ -32,6 +32,19 @@ export class ProviderReaderSessionWatcher {
32
32
  getPosition() {
33
33
  return this.reader.getPosition();
34
34
  }
35
+ /** The reader's resume state, if it keeps any. */
36
+ getResumeState() {
37
+ return this.reader.getResumeState?.();
38
+ }
39
+ /**
40
+ * Restores the reader's resume state after `seekTo()`. A reader that keeps
41
+ * none accepts only `undefined`.
42
+ */
43
+ restoreResumeState(state) {
44
+ if (!this.reader.restoreResumeState)
45
+ return state === undefined;
46
+ return this.reader.restoreResumeState(state);
47
+ }
35
48
  start(replay) {
36
49
  if (this._isActive)
37
50
  return;
@@ -99,11 +112,12 @@ export class ProviderReaderSessionWatcher {
99
112
  if (!this._isActive)
100
113
  return;
101
114
  try {
102
- if (this.reader.wasTruncated()) {
103
- this.reader.reset();
104
- this.callbacks.onReset?.();
105
- }
106
115
  const events = this.reader.readNew();
116
+ // The reader started over (truncated, rewritten, or replaced file):
117
+ // these events are the session from the beginning, so consumers must
118
+ // drop what they built before receiving them.
119
+ if (this.reader.wasTruncated())
120
+ this.callbacks.onReset?.();
107
121
  for (const event of events) {
108
122
  for (const followEvent of toFollowEvents(event, this.provider.id)) {
109
123
  this.callbacks.onEvent(followEvent);
@@ -1,3 +1,4 @@
1
+ import { ownValue } from './ownKey';
1
2
  // ── Tier budgets ──
2
3
  /**
3
4
  * Published z.ai coding-plan prompt budgets.
@@ -215,7 +216,7 @@ export function parseZaiQuotaError(error) {
215
216
  if (!error)
216
217
  return null;
217
218
  const code = String(error.code ?? '');
218
- const kind = ZAI_ERROR_CODES[code];
219
+ const kind = ownValue(ZAI_ERROR_CODES, code);
219
220
  if (!kind)
220
221
  return null;
221
222
  const message = error.message ?? '';
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Continuity of a file read incrementally by byte offset: whether the file
3
+ * now at a path is still the content earlier reads consumed, or was
4
+ * truncated, rewritten, or replaced since.
5
+ *
6
+ * {@link FileContinuity} holds the rules `createJsonlTail` applies, so every
7
+ * incremental reader detects the same cases the same way:
8
+ *
9
+ * - `replaced`: another file (by device and inode, read from the open
10
+ * descriptor, so a rename between a stat and an open cannot mix two files)
11
+ * now has the path.
12
+ * - `truncated`: the file is shorter than the read position.
13
+ * - `rewritten`: the mtime changed and either the first {@link HEAD_BYTES}
14
+ * bytes differ from those recorded, or the byte before the committed
15
+ * (resume-safe) offset is no longer a newline.
16
+ *
17
+ * Replacement and truncation are checked on every read; the content probe
18
+ * only when the mtime changed or the recorded head is still short.
19
+ *
20
+ * Known limits: a rewrite that leaves the mtime unchanged (coarse mtime
21
+ * resolution, or an mtime restored on purpose) or that changes only bytes
22
+ * past the first 4 KiB while the resume point still follows a newline goes
23
+ * unnoticed; on filesystems without stable inode numbers (some SMB mounts,
24
+ * Windows) an unchanged file can read as replaced, which costs a replay.
25
+ *
26
+ * @module fileContinuity
27
+ */
28
+ import * as fs from 'fs';
29
+ import { type FileHead } from './fileHead';
30
+ import type { JsonlTailResetReason } from './watchers/jsonlTail';
31
+ /** The fields of `fs.Stats` the continuity rules read. */
32
+ export type FileContinuityStat = Pick<fs.Stats, 'dev' | 'ino' | 'size' | 'mtimeMs'>;
33
+ /** The bytes the continuity check needs, read before any state changes. */
34
+ export interface FileContinuityProbe {
35
+ /** The file's first `min(HEAD_BYTES, size)` bytes. */
36
+ prefix: Buffer;
37
+ /** The byte before the committed offset, when there is one. */
38
+ before: Buffer | null;
39
+ }
40
+ /**
41
+ * What one reader knows about the file its offsets refer to: identity,
42
+ * mtime, and a digest of the first bytes, recorded by {@link adopt} after
43
+ * each read.
44
+ *
45
+ * Synchronous readers call {@link check} on the open descriptor, restart
46
+ * from offset 0 when it returns a reason, then call {@link adopt}. A reader
47
+ * that repositions without reading (a seek to a stored offset, a reset)
48
+ * calls {@link forget}, so its next read adopts the file instead of
49
+ * comparing it with the old one. The lower-level methods ({@link needsProbe},
50
+ * {@link reasonFor}, {@link record}) serve asynchronous readers that probe
51
+ * on their own.
52
+ */
53
+ export declare class FileContinuity {
54
+ private identity;
55
+ private head;
56
+ private lastMtimeMs;
57
+ /** What the last {@link check} probed and decided, for {@link adopt}. */
58
+ private checked;
59
+ /** Device and inode of the adopted file; `null` before the first read and after {@link forget}. */
60
+ get knownIdentity(): {
61
+ dev: number;
62
+ ino: number;
63
+ } | null;
64
+ /** Length and digest of the adopted file's first bytes, once probed. */
65
+ get knownHead(): FileHead | null;
66
+ /** Whether {@link reasonFor} needs bytes from the file to decide. */
67
+ needsProbe(stat: FileContinuityStat, readOffset: number): boolean;
68
+ /**
69
+ * Why the reader must start over: the path now names another file, the
70
+ * file shrank below `readOffset`, its first bytes changed, or the resume
71
+ * point no longer follows a newline. `null` when it can continue.
72
+ */
73
+ reasonFor(stat: FileContinuityStat, readOffset: number, probe: FileContinuityProbe | null): JsonlTailResetReason | null;
74
+ /** Records the identity, mtime, and (from `probe`) first bytes of the file the offsets now refer to. */
75
+ record(stat: FileContinuityStat, probe: FileContinuityProbe | null): void;
76
+ /** Drops the recorded first bytes (the reader restarted); the next {@link record} with a probe sets them again. */
77
+ discardHead(): void;
78
+ /** Records the file as known from `prefix`, its first `min(HEAD_BYTES, size)` bytes. */
79
+ adoptPrefix(stat: FileContinuityStat, prefix: Buffer): void;
80
+ /** Forgets the file, so the next {@link check} adopts whatever file has the path. */
81
+ forget(): void;
82
+ /**
83
+ * Applies the rules to the file open on `fd` (`stat` from `fstat` on that
84
+ * descriptor), reading the bytes they need. `readOffset` is how far the
85
+ * reader has read; `committedOffset` (default `readOffset`) is its
86
+ * resume-safe position just after a newline. Returns why the reader must
87
+ * restart from offset 0, or `null`. Call {@link adopt} with the same
88
+ * `stat` once the reader has acted on the answer.
89
+ */
90
+ check(fd: number, stat: FileContinuityStat, readOffset: number, committedOffset?: number): JsonlTailResetReason | null;
91
+ /** Records the file just checked as the one the reader's offsets now refer to. */
92
+ adopt(stat: FileContinuityStat): void;
93
+ }
94
+ /** Reads the bytes {@link FileContinuity.reasonFor} compares, from the file open on `fd`. */
95
+ export declare function probeFileContinuity(fd: number, stat: FileContinuityStat, committedOffset: number): FileContinuityProbe;
@@ -0,0 +1,187 @@
1
+ "use strict";
2
+ /**
3
+ * Continuity of a file read incrementally by byte offset: whether the file
4
+ * now at a path is still the content earlier reads consumed, or was
5
+ * truncated, rewritten, or replaced since.
6
+ *
7
+ * {@link FileContinuity} holds the rules `createJsonlTail` applies, so every
8
+ * incremental reader detects the same cases the same way:
9
+ *
10
+ * - `replaced`: another file (by device and inode, read from the open
11
+ * descriptor, so a rename between a stat and an open cannot mix two files)
12
+ * now has the path.
13
+ * - `truncated`: the file is shorter than the read position.
14
+ * - `rewritten`: the mtime changed and either the first {@link HEAD_BYTES}
15
+ * bytes differ from those recorded, or the byte before the committed
16
+ * (resume-safe) offset is no longer a newline.
17
+ *
18
+ * Replacement and truncation are checked on every read; the content probe
19
+ * only when the mtime changed or the recorded head is still short.
20
+ *
21
+ * Known limits: a rewrite that leaves the mtime unchanged (coarse mtime
22
+ * resolution, or an mtime restored on purpose) or that changes only bytes
23
+ * past the first 4 KiB while the resume point still follows a newline goes
24
+ * unnoticed; on filesystems without stable inode numbers (some SMB mounts,
25
+ * Windows) an unchanged file can read as replaced, which costs a replay.
26
+ *
27
+ * @module fileContinuity
28
+ */
29
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
30
+ if (k2 === undefined) k2 = k;
31
+ var desc = Object.getOwnPropertyDescriptor(m, k);
32
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
33
+ desc = { enumerable: true, get: function() { return m[k]; } };
34
+ }
35
+ Object.defineProperty(o, k2, desc);
36
+ }) : (function(o, m, k, k2) {
37
+ if (k2 === undefined) k2 = k;
38
+ o[k2] = m[k];
39
+ }));
40
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
41
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
42
+ }) : function(o, v) {
43
+ o["default"] = v;
44
+ });
45
+ var __importStar = (this && this.__importStar) || (function () {
46
+ var ownKeys = function(o) {
47
+ ownKeys = Object.getOwnPropertyNames || function (o) {
48
+ var ar = [];
49
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
50
+ return ar;
51
+ };
52
+ return ownKeys(o);
53
+ };
54
+ return function (mod) {
55
+ if (mod && mod.__esModule) return mod;
56
+ var result = {};
57
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
58
+ __setModuleDefault(result, mod);
59
+ return result;
60
+ };
61
+ })();
62
+ Object.defineProperty(exports, "__esModule", { value: true });
63
+ exports.FileContinuity = void 0;
64
+ exports.probeFileContinuity = probeFileContinuity;
65
+ const fs = __importStar(require("fs"));
66
+ const fileHead_1 = require("./fileHead");
67
+ /**
68
+ * What one reader knows about the file its offsets refer to: identity,
69
+ * mtime, and a digest of the first bytes, recorded by {@link adopt} after
70
+ * each read.
71
+ *
72
+ * Synchronous readers call {@link check} on the open descriptor, restart
73
+ * from offset 0 when it returns a reason, then call {@link adopt}. A reader
74
+ * that repositions without reading (a seek to a stored offset, a reset)
75
+ * calls {@link forget}, so its next read adopts the file instead of
76
+ * comparing it with the old one. The lower-level methods ({@link needsProbe},
77
+ * {@link reasonFor}, {@link record}) serve asynchronous readers that probe
78
+ * on their own.
79
+ */
80
+ class FileContinuity {
81
+ identity = null;
82
+ head = null;
83
+ lastMtimeMs = -1;
84
+ /** What the last {@link check} probed and decided, for {@link adopt}. */
85
+ checked = null;
86
+ /** Device and inode of the adopted file; `null` before the first read and after {@link forget}. */
87
+ get knownIdentity() {
88
+ return this.identity;
89
+ }
90
+ /** Length and digest of the adopted file's first bytes, once probed. */
91
+ get knownHead() {
92
+ return this.head;
93
+ }
94
+ /** Whether {@link reasonFor} needs bytes from the file to decide. */
95
+ needsProbe(stat, readOffset) {
96
+ return (this.identity === null ||
97
+ stat.dev !== this.identity.dev ||
98
+ stat.ino !== this.identity.ino ||
99
+ stat.size < readOffset ||
100
+ stat.mtimeMs !== this.lastMtimeMs ||
101
+ this.head === null ||
102
+ this.head.bytes < Math.min(fileHead_1.HEAD_BYTES, stat.size));
103
+ }
104
+ /**
105
+ * Why the reader must start over: the path now names another file, the
106
+ * file shrank below `readOffset`, its first bytes changed, or the resume
107
+ * point no longer follows a newline. `null` when it can continue.
108
+ */
109
+ reasonFor(stat, readOffset, probe) {
110
+ if (this.identity && (stat.dev !== this.identity.dev || stat.ino !== this.identity.ino)) {
111
+ return 'replaced';
112
+ }
113
+ if (stat.size < readOffset)
114
+ return 'truncated';
115
+ if (this.identity && stat.mtimeMs !== this.lastMtimeMs && probe) {
116
+ if (this.head && !(0, fileHead_1.headMatches)(this.head, probe.prefix))
117
+ return 'rewritten';
118
+ if (probe.before && probe.before[0] !== 0x0a)
119
+ return 'rewritten';
120
+ }
121
+ return null;
122
+ }
123
+ /** Records the identity, mtime, and (from `probe`) first bytes of the file the offsets now refer to. */
124
+ record(stat, probe) {
125
+ this.identity = { dev: stat.dev, ino: stat.ino };
126
+ this.lastMtimeMs = stat.mtimeMs;
127
+ if (probe && (!this.head || this.head.bytes < Math.min(fileHead_1.HEAD_BYTES, stat.size))) {
128
+ this.head = (0, fileHead_1.fileHeadOf)(probe.prefix);
129
+ }
130
+ }
131
+ /** Drops the recorded first bytes (the reader restarted); the next {@link record} with a probe sets them again. */
132
+ discardHead() {
133
+ this.head = null;
134
+ }
135
+ /** Records the file as known from `prefix`, its first `min(HEAD_BYTES, size)` bytes. */
136
+ adoptPrefix(stat, prefix) {
137
+ this.identity = { dev: stat.dev, ino: stat.ino };
138
+ this.lastMtimeMs = stat.mtimeMs;
139
+ this.head = (0, fileHead_1.fileHeadOf)(prefix);
140
+ }
141
+ /** Forgets the file, so the next {@link check} adopts whatever file has the path. */
142
+ forget() {
143
+ this.identity = null;
144
+ this.head = null;
145
+ this.lastMtimeMs = -1;
146
+ this.checked = null;
147
+ }
148
+ /**
149
+ * Applies the rules to the file open on `fd` (`stat` from `fstat` on that
150
+ * descriptor), reading the bytes they need. `readOffset` is how far the
151
+ * reader has read; `committedOffset` (default `readOffset`) is its
152
+ * resume-safe position just after a newline. Returns why the reader must
153
+ * restart from offset 0, or `null`. Call {@link adopt} with the same
154
+ * `stat` once the reader has acted on the answer.
155
+ */
156
+ check(fd, stat, readOffset, committedOffset = readOffset) {
157
+ const probe = this.needsProbe(stat, readOffset)
158
+ ? probeFileContinuity(fd, stat, committedOffset)
159
+ : null;
160
+ const reason = this.reasonFor(stat, readOffset, probe);
161
+ this.checked = { probe, reset: reason !== null };
162
+ return reason;
163
+ }
164
+ /** Records the file just checked as the one the reader's offsets now refer to. */
165
+ adopt(stat) {
166
+ const checked = this.checked;
167
+ this.checked = null;
168
+ if (checked?.reset)
169
+ this.head = null;
170
+ this.record(stat, checked?.probe ?? null);
171
+ }
172
+ }
173
+ exports.FileContinuity = FileContinuity;
174
+ /** Reads the bytes {@link FileContinuity.reasonFor} compares, from the file open on `fd`. */
175
+ function probeFileContinuity(fd, stat, committedOffset) {
176
+ return {
177
+ prefix: readAt(fd, 0, Math.min(fileHead_1.HEAD_BYTES, stat.size)),
178
+ before: committedOffset > 0 && committedOffset <= stat.size
179
+ ? readAt(fd, committedOffset - 1, 1)
180
+ : null,
181
+ };
182
+ }
183
+ function readAt(fd, position, length) {
184
+ const buffer = Buffer.alloc(length);
185
+ const count = length > 0 ? fs.readSync(fd, buffer, 0, length, position) : 0;
186
+ return buffer.subarray(0, count);
187
+ }