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
@@ -85,7 +85,7 @@ export interface SessionEventBase {
85
85
  /** Provider-reported git branch for this event/session. */
86
86
  gitBranch?: string;
87
87
  /** Permission mode active when this event occurred */
88
- permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan';
88
+ permissionMode?: PermissionMode;
89
89
  /** Subscription rate limits, when provided by the session source. */
90
90
  rateLimits?: {
91
91
  primary?: {
@@ -166,6 +166,11 @@ export interface SystemSessionEvent extends SessionEventBase {
166
166
  type: 'system';
167
167
  /** Bookkeeping-only system rows legitimately have no message. */
168
168
  message?: SessionMessage;
169
+ /**
170
+ * The provider's system-line subtype, when it wrote one (Claude Code:
171
+ * `turn_duration`, `compact_boundary`, `agents_killed`, …).
172
+ */
173
+ subtype?: string;
169
174
  }
170
175
  export interface ToolUseSessionEvent extends SessionEventBase {
171
176
  type: 'tool_use';
@@ -187,9 +192,11 @@ export interface ToolResultSessionEvent extends SessionEventBase {
187
192
  /** Canonical provider-neutral event contract. */
188
193
  export type SessionEvent = UserSessionEvent | AssistantSessionEvent | SummarySessionEvent | SystemSessionEvent | ToolUseSessionEvent | ToolResultSessionEvent;
189
194
  /**
190
- * Permission mode values from Claude Code JSONL.
195
+ * Permission mode values from Claude Code JSONL. `auto` approves or denies
196
+ * permission prompts with a model classifier; `dontAsk` denies anything not
197
+ * pre-approved. Unknown future modes are dropped by the schema, not rejected.
191
198
  */
192
- export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan';
199
+ export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'auto' | 'dontAsk';
193
200
  /**
194
201
  * A permission mode change event in the session timeline.
195
202
  */
@@ -423,6 +430,34 @@ export interface SubagentStats {
423
430
  endTime?: Date;
424
431
  /** Duration in milliseconds */
425
432
  durationMs?: number;
433
+ /**
434
+ * Codex: the subagent's rollout file — the `.jsonl`, or the `.jsonl.zst`
435
+ * when only the compressed one exists (with `includeCompressedRollouts`).
436
+ */
437
+ filePath?: string;
438
+ /**
439
+ * Codex: the parent's spawn call (`spawn_agent` call id) that started this
440
+ * subagent — the tool-use id of that call in the parent's events.
441
+ */
442
+ spawnToolUseId?: string;
443
+ /** Codex: the display nickname Codex gave the subagent (e.g. `Banach`). */
444
+ agentNickname?: string;
445
+ /** Codex: the subagent's agent path (e.g. `/root/review_builder`). */
446
+ agentPath?: string;
447
+ /**
448
+ * Codex: the thread that spawned this subagent — the session's id for its
449
+ * own children, another subagent's `agentId` below that.
450
+ */
451
+ parentAgentId?: string;
452
+ /** Codex: spawn depth — 1 for the session's own children, 2 for theirs, … */
453
+ depth?: number;
454
+ /**
455
+ * The subagent's transcript ended cleanly: Claude Code, its last assistant
456
+ * turn stopped with `end_turn` / `stop_sequence` and left no tool call
457
+ * unanswered; Codex, its last own turn ended with `task_complete`. A later
458
+ * prompt (a resumed agent) clears it. Absent when unknown.
459
+ */
460
+ endedCleanly?: boolean;
426
461
  }
427
462
  /**
428
463
  * Pending user request awaiting assistant response.
@@ -22,6 +22,24 @@ import type { SessionEvent } from '../types/sessionEvent';
22
22
  export declare const SYNTHETIC_MODEL = "<synthetic>";
23
23
  /** Attach Anthropic-semantics normalized usage and Claude Code provenance to an event. */
24
24
  export declare function normalizeClaudeUsage(event: SessionEvent): SessionEvent;
25
+ /** Tokens a {@link ClaudeUsageDeduper} has already counted for one message id. */
26
+ export interface ClaudeUsageDedupeBuckets {
27
+ input: number;
28
+ cacheRead: number;
29
+ cacheWrite: number;
30
+ output: number;
31
+ reasoning: number;
32
+ }
33
+ /**
34
+ * JSON-serializable state of a {@link ClaudeUsageDeduper}, from
35
+ * `exportState()`, for `importState()` in a reader resumed at the same
36
+ * position (for example from a persisted session snapshot).
37
+ */
38
+ export interface ClaudeUsageDedupeState {
39
+ version: 1;
40
+ /** `[message id, counted tokens]` pairs, oldest first. */
41
+ entries: Array<[string, ClaudeUsageDedupeBuckets]>;
42
+ }
25
43
  /**
26
44
  * Per-reader dedupe state. Split lines of one response are adjacent, so a
27
45
  * bounded map (oldest keys evicted first) is enough.
@@ -40,6 +58,19 @@ export declare class ClaudeUsageDeduper {
40
58
  /** Whether a message id has already been counted by this deduper. */
41
59
  has(id: string): boolean;
42
60
  reset(): void;
61
+ /**
62
+ * The most recent `limit` message ids and the tokens counted for each,
63
+ * oldest first. Split lines of one response are adjacent, so a reader
64
+ * resumed at the position this was taken at needs only the last few.
65
+ */
66
+ exportState(limit?: number): ClaudeUsageDedupeState;
67
+ /**
68
+ * Replaces this deduper's state with one from {@link exportState}. Returns
69
+ * false, changing nothing, when `state` is not a valid version-1 state
70
+ * (wrong shape, empty ids, or counts that are not finite non-negative
71
+ * numbers). Entries beyond this deduper's capacity drop oldest first.
72
+ */
73
+ importState(state: unknown): boolean;
43
74
  private remember;
44
75
  }
45
76
  /**
@@ -64,6 +64,25 @@ function normalizeClaudeUsage(event) {
64
64
  },
65
65
  };
66
66
  }
67
+ const BUCKET_KEYS = ['input', 'cacheRead', 'cacheWrite', 'output', 'reasoning'];
68
+ /** Entries {@link ClaudeUsageDeduper.exportState} keeps by default. */
69
+ const DEFAULT_EXPORT_LIMIT = 256;
70
+ function isCount(value) {
71
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0;
72
+ }
73
+ function bucketsFrom(value) {
74
+ if (!value || typeof value !== 'object' || Array.isArray(value))
75
+ return null;
76
+ const record = value;
77
+ const buckets = {};
78
+ for (const key of BUCKET_KEYS) {
79
+ const count = record[key];
80
+ if (!isCount(count))
81
+ return null;
82
+ buckets[key] = count;
83
+ }
84
+ return buckets;
85
+ }
67
86
  function bucketsOf(usage) {
68
87
  return {
69
88
  input: usage.uncachedInputTokens,
@@ -168,6 +187,55 @@ class ClaudeUsageDeduper {
168
187
  reset() {
169
188
  this.seen.clear();
170
189
  }
190
+ /**
191
+ * The most recent `limit` message ids and the tokens counted for each,
192
+ * oldest first. Split lines of one response are adjacent, so a reader
193
+ * resumed at the position this was taken at needs only the last few.
194
+ */
195
+ exportState(limit = DEFAULT_EXPORT_LIMIT) {
196
+ const count = limit >= 0 ? Math.min(this.seen.size, Math.floor(limit)) : 0;
197
+ const entries = [];
198
+ if (count > 0) {
199
+ let skip = this.seen.size - count;
200
+ for (const [id, buckets] of this.seen) {
201
+ if (skip > 0) {
202
+ skip -= 1;
203
+ continue;
204
+ }
205
+ entries.push([id, { ...buckets }]);
206
+ }
207
+ }
208
+ return { version: 1, entries };
209
+ }
210
+ /**
211
+ * Replaces this deduper's state with one from {@link exportState}. Returns
212
+ * false, changing nothing, when `state` is not a valid version-1 state
213
+ * (wrong shape, empty ids, or counts that are not finite non-negative
214
+ * numbers). Entries beyond this deduper's capacity drop oldest first.
215
+ */
216
+ importState(state) {
217
+ if (!state || typeof state !== 'object')
218
+ return false;
219
+ const { version, entries } = state;
220
+ if (version !== 1 || !Array.isArray(entries))
221
+ return false;
222
+ const parsed = [];
223
+ for (const entry of entries) {
224
+ if (!Array.isArray(entry) || entry.length !== 2)
225
+ return false;
226
+ const [id, value] = entry;
227
+ if (typeof id !== 'string' || id.length === 0)
228
+ return false;
229
+ const buckets = bucketsFrom(value);
230
+ if (!buckets)
231
+ return false;
232
+ parsed.push([id, buckets]);
233
+ }
234
+ this.seen.clear();
235
+ for (const [id, buckets] of parsed)
236
+ this.remember(id, buckets);
237
+ return true;
238
+ }
171
239
  remember(id, buckets) {
172
240
  this.seen.delete(id);
173
241
  this.seen.set(id, buckets);
@@ -15,7 +15,10 @@
15
15
  * tagged with the subagent, and their sizes and mtimes join the fingerprint.
16
16
  * (Codex spawned subagents and forks are top-level rollouts, already listed
17
17
  * as sessions of their own; their events are tagged with the parent read
18
- * from the rollout's first line.)
18
+ * from the rollout's first line; the parent history a forked child embeds
19
+ * is not counted (it carries no token usage). Compressed rollouts
20
+ * (`….jsonl.zst`) are read when a Codex provider was created with
21
+ * `includeCompressedRollouts`.)
19
22
  *
20
23
  * Each event also carries the ordinal of the human prompt it answered, from
21
24
  * the same scanner as `collectPromptHistory()`, so usage joins prompt history
@@ -16,7 +16,10 @@
16
16
  * tagged with the subagent, and their sizes and mtimes join the fingerprint.
17
17
  * (Codex spawned subagents and forks are top-level rollouts, already listed
18
18
  * as sessions of their own; their events are tagged with the parent read
19
- * from the rollout's first line.)
19
+ * from the rollout's first line; the parent history a forked child embeds
20
+ * is not counted (it carries no token usage). Compressed rollouts
21
+ * (`….jsonl.zst`) are read when a Codex provider was created with
22
+ * `includeCompressedRollouts`.)
20
23
  *
21
24
  * Each event also carries the ordinal of the human prompt it answered, from
22
25
  * the same scanner as `collectPromptHistory()`, so usage joins prompt history
@@ -11,6 +11,8 @@
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.toFollowEvents = toFollowEvents;
13
13
  const toolSummary_1 = require("../formatters/toolSummary");
14
+ const subagentSpawn_1 = require("../subagentSpawn");
15
+ const subagentActivity_1 = require("../subagentActivity");
14
16
  /**
15
17
  * Converts a single SessionEvent to one or more FollowEvents.
16
18
  *
@@ -151,6 +153,49 @@ function toFollowEvents(event, providerId) {
151
153
  break;
152
154
  }
153
155
  case 'system': {
156
+ // A background task's notice (opt-in reader event): its summary, not the raw block.
157
+ const notification = (0, subagentSpawn_1.isTaskNotificationSystemEvent)(event)
158
+ ? (0, subagentSpawn_1.taskNotificationFromEvent)(event)
159
+ : null;
160
+ if (notification) {
161
+ events.push({
162
+ providerId,
163
+ type: 'system',
164
+ timestamp: ts,
165
+ summary: notification.summary ?? `Background task ${notification.status ?? 'finished'}`,
166
+ raw: event,
167
+ });
168
+ break;
169
+ }
170
+ // The user stopping background agents, or a background agent's final status.
171
+ const stopped = (0, subagentSpawn_1.agentsStoppedNoticeFromEvent)(event);
172
+ const taskStatus = stopped ? null : (0, subagentSpawn_1.taskStatusFromEvent)(event);
173
+ if (stopped || taskStatus || (0, subagentSpawn_1.isAgentsKilledSystemEvent)(event)) {
174
+ events.push({
175
+ providerId,
176
+ type: 'system',
177
+ timestamp: ts,
178
+ summary: stopped
179
+ ? (0, subagentSpawn_1.describeAgentsStopped)(stopped)
180
+ : taskStatus
181
+ ? (0, subagentSpawn_1.describeTaskStatus)(taskStatus)
182
+ : (0, subagentSpawn_1.describeAgentsStopped)(null),
183
+ raw: event,
184
+ });
185
+ break;
186
+ }
187
+ // A subagent lifecycle record (opt-in Codex parser event): a content-free summary.
188
+ const activity = (0, subagentActivity_1.subagentActivityFromEvent)(event);
189
+ if (activity) {
190
+ events.push({
191
+ providerId,
192
+ type: 'system',
193
+ timestamp: ts,
194
+ summary: (0, subagentActivity_1.describeSubagentActivity)(activity),
195
+ raw: event,
196
+ });
197
+ break;
198
+ }
154
199
  const text = extractTextContent(event.message?.content);
155
200
  const label = event.message?.sourceLabel || event.message?.role || 'system';
156
201
  const summary = text ||
@@ -8,6 +8,12 @@ export interface CreateWatcherOptions {
8
8
  workspacePath: string;
9
9
  sessionId?: string;
10
10
  callbacks: SessionWatcherCallbacks;
11
+ /**
12
+ * Claude Code only: also emit a `system` event for each mid-turn
13
+ * task-notification attachment, so an `EventAggregator` fed by this watcher
14
+ * can finish background subagents. Default `false`.
15
+ */
16
+ includeTaskNotifications?: boolean;
11
17
  }
12
18
  /**
13
19
  * Resolve the session a command should act on: an exact or unique-prefix
@@ -78,13 +78,15 @@ function resolveSessionPath(provider, workspacePath, sessionId) {
78
78
  function createWatcher(options) {
79
79
  const { provider, workspacePath, sessionId, callbacks } = options;
80
80
  const sessionPath = resolveSessionPath(provider, workspacePath, sessionId);
81
- const watcher = createWatcherForProvider(provider, sessionPath, callbacks);
81
+ const watcher = createWatcherForProvider(provider, sessionPath, callbacks, {
82
+ includeTaskNotifications: options.includeTaskNotifications === true,
83
+ });
82
84
  return { watcher, sessionPath };
83
85
  }
84
- function createWatcherForProvider(provider, sessionPath, callbacks) {
86
+ function createWatcherForProvider(provider, sessionPath, callbacks, jsonlOptions) {
85
87
  switch (provider.id) {
86
88
  case 'claude-code':
87
- return new jsonlWatcher_1.JsonlSessionWatcher(provider.id, sessionPath, callbacks);
89
+ return new jsonlWatcher_1.JsonlSessionWatcher(provider.id, sessionPath, callbacks, jsonlOptions);
88
90
  case 'codex':
89
91
  return new providerReaderWatcher_1.ProviderReaderSessionWatcher(provider, sessionPath, callbacks);
90
92
  case 'opencode': {
@@ -53,10 +53,11 @@ export interface JsonlTailReadResult<T> extends JsonlTailBatch {
53
53
  reset?: JsonlTailReset;
54
54
  }
55
55
  /**
56
- * A JSON-serializable resume point from {@link JsonlTail.checkpoint}. It
57
- * identifies the file by device and inode plus a digest of its first bytes,
58
- * so a new tail (in this or a later process) can verify it is still reading
59
- * the same, append-only file before continuing from `offset`.
56
+ * A JSON-serializable resume point from {@link JsonlTail.checkpoint} or
57
+ * {@link captureJsonlCheckpoint}. It identifies the file by device and inode
58
+ * plus a digest of its first bytes, so a new tail (in this or a later
59
+ * process) or {@link verifyJsonlCheckpoint} can confirm the file is still the
60
+ * same, append-only one before anything continues from `offset`.
60
61
  */
61
62
  export interface JsonlTailCheckpoint {
62
63
  version: typeof JSONL_TAIL_CHECKPOINT_VERSION;
@@ -151,3 +152,25 @@ export interface JsonlTail<T = unknown> {
151
152
  resumeFrom(checkpoint: unknown): boolean;
152
153
  }
153
154
  export declare function createJsonlTail<T>(options: JsonlTailOptions<T>): JsonlTail<T>;
155
+ /**
156
+ * Records a {@link JsonlTailCheckpoint} for byte `offset` of `filePath`
157
+ * without a tail, for readers that keep their own offset (a snapshot that
158
+ * stores a reader position, for example). The checkpoint carries the file's
159
+ * current device and inode and a digest of its first bytes, so
160
+ * {@link verifyJsonlCheckpoint} (or {@link JsonlTail.resumeFrom}) can later
161
+ * tell whether `offset` still refers to the same, append-only content.
162
+ *
163
+ * Returns `null` when the file is missing or unreadable, or when `offset` is
164
+ * not a resume-safe position in it: negative, past the end, or not 0 and not
165
+ * just after a newline. Follows symbolic links.
166
+ */
167
+ export declare function captureJsonlCheckpoint(filePath: string, offset: number): JsonlTailCheckpoint | null;
168
+ /**
169
+ * Whether `checkpoint` (any value, typically parsed from JSON) is a valid
170
+ * {@link JsonlTailCheckpoint} that still describes `filePath`: same device
171
+ * and inode, the recorded first bytes unchanged, and its offset within the
172
+ * file and just after a newline (or 0). This is the check
173
+ * {@link JsonlTail.resumeFrom} applies. `false` on any error, including a
174
+ * missing file. Follows symbolic links.
175
+ */
176
+ export declare function verifyJsonlCheckpoint(filePath: string, checkpoint: unknown): boolean;
@@ -39,8 +39,11 @@ var __importStar = (this && this.__importStar) || (function () {
39
39
  Object.defineProperty(exports, "__esModule", { value: true });
40
40
  exports.JsonlTailSymlinkError = exports.JSONL_TAIL_CHECKPOINT_VERSION = exports.DEFAULT_JSONL_TAIL_MAX_LINE_BYTES = exports.DEFAULT_JSONL_TAIL_READ_BYTES = void 0;
41
41
  exports.createJsonlTail = createJsonlTail;
42
+ exports.captureJsonlCheckpoint = captureJsonlCheckpoint;
43
+ exports.verifyJsonlCheckpoint = verifyJsonlCheckpoint;
42
44
  const fs = __importStar(require("fs"));
43
45
  const fileHead_1 = require("../fileHead");
46
+ const fileContinuity_1 = require("../fileContinuity");
44
47
  const jsonl_1 = require("../parsers/jsonl");
45
48
  const DEFAULT_DEBOUNCE_MS = 100;
46
49
  const DEFAULT_CATCHUP_INTERVAL_MS = 30_000;
@@ -98,6 +101,63 @@ exports.JsonlTailSymlinkError = JsonlTailSymlinkError;
98
101
  function createJsonlTail(options) {
99
102
  return new JsonlTailReader(options);
100
103
  }
104
+ /**
105
+ * Records a {@link JsonlTailCheckpoint} for byte `offset` of `filePath`
106
+ * without a tail, for readers that keep their own offset (a snapshot that
107
+ * stores a reader position, for example). The checkpoint carries the file's
108
+ * current device and inode and a digest of its first bytes, so
109
+ * {@link verifyJsonlCheckpoint} (or {@link JsonlTail.resumeFrom}) can later
110
+ * tell whether `offset` still refers to the same, append-only content.
111
+ *
112
+ * Returns `null` when the file is missing or unreadable, or when `offset` is
113
+ * not a resume-safe position in it: negative, past the end, or not 0 and not
114
+ * just after a newline. Follows symbolic links.
115
+ */
116
+ function captureJsonlCheckpoint(filePath, offset) {
117
+ if (!Number.isSafeInteger(offset) || offset < 0)
118
+ return null;
119
+ let fd = null;
120
+ try {
121
+ fd = fs.openSync(filePath, 'r');
122
+ const stat = fs.fstatSync(fd);
123
+ if (offset > stat.size)
124
+ return null;
125
+ if (offset > 0 && readAt(fd, offset - 1, 1)[0] !== 0x0a)
126
+ return null;
127
+ return checkpointOf(offset, stat, (0, fileHead_1.fileHeadOf)(readPrefix(fd, stat)));
128
+ }
129
+ catch {
130
+ return null;
131
+ }
132
+ finally {
133
+ closeQuietly(fd);
134
+ }
135
+ }
136
+ /**
137
+ * Whether `checkpoint` (any value, typically parsed from JSON) is a valid
138
+ * {@link JsonlTailCheckpoint} that still describes `filePath`: same device
139
+ * and inode, the recorded first bytes unchanged, and its offset within the
140
+ * file and just after a newline (or 0). This is the check
141
+ * {@link JsonlTail.resumeFrom} applies. `false` on any error, including a
142
+ * missing file. Follows symbolic links.
143
+ */
144
+ function verifyJsonlCheckpoint(filePath, checkpoint) {
145
+ const parsed = parseCheckpoint(checkpoint);
146
+ if (parsed === null)
147
+ return false;
148
+ let fd = null;
149
+ try {
150
+ fd = fs.openSync(filePath, 'r');
151
+ const stat = fs.fstatSync(fd);
152
+ return checkpointMatches(parsed, fd, stat, readPrefix(fd, stat));
153
+ }
154
+ catch {
155
+ return false;
156
+ }
157
+ finally {
158
+ closeQuietly(fd);
159
+ }
160
+ }
101
161
  class JsonlTailReader {
102
162
  options;
103
163
  active = false;
@@ -109,10 +169,8 @@ class JsonlTailReader {
109
169
  /** Inside a line over `maxLineBytes`: drop bytes until its newline. */
110
170
  skippingLine = false;
111
171
  currentLine = { offset: 0, length: 0 };
112
- /** Identity and prefix of the file the offsets refer to, once read. */
113
- identity = null;
114
- head = null;
115
- lastMtimeMs = -1;
172
+ /** Identity, mtime, and prefix of the file the offsets refer to, once read. */
173
+ continuity = new fileContinuity_1.FileContinuity();
116
174
  maxReadBytes;
117
175
  maxLineBytes;
118
176
  rewritePolicy;
@@ -320,21 +378,10 @@ class JsonlTailReader {
320
378
  }
321
379
  /** Whether the continuity check needs bytes from the file. */
322
380
  probeNeeded(stat) {
323
- return (this.identity === null ||
324
- stat.dev !== this.identity.dev ||
325
- stat.ino !== this.identity.ino ||
326
- stat.size < this.readOffset ||
327
- stat.mtimeMs !== this.lastMtimeMs ||
328
- this.head === null ||
329
- this.head.bytes < Math.min(fileHead_1.HEAD_BYTES, stat.size));
381
+ return this.continuity.needsProbe(stat, this.readOffset);
330
382
  }
331
383
  probeSync(fd, stat) {
332
- return {
333
- prefix: readAt(fd, 0, Math.min(fileHead_1.HEAD_BYTES, stat.size)),
334
- before: this.committedOffset > 0 && this.committedOffset <= stat.size
335
- ? readAt(fd, this.committedOffset - 1, 1)
336
- : null,
337
- };
384
+ return (0, fileContinuity_1.probeFileContinuity)(fd, stat, this.committedOffset);
338
385
  }
339
386
  async probeAsync(handle, stat) {
340
387
  const committedOffset = this.committedOffset;
@@ -351,31 +398,16 @@ class JsonlTailReader {
351
398
  * newline. `null` when it can continue.
352
399
  */
353
400
  resetReason(stat, probe) {
354
- if (this.identity && (stat.dev !== this.identity.dev || stat.ino !== this.identity.ino)) {
355
- return 'replaced';
356
- }
357
- if (stat.size < this.readOffset)
358
- return 'truncated';
359
- if (this.identity && stat.mtimeMs !== this.lastMtimeMs && probe) {
360
- if (this.head && !(0, fileHead_1.headMatches)(this.head, probe.prefix))
361
- return 'rewritten';
362
- if (probe.before && probe.before[0] !== 0x0a)
363
- return 'rewritten';
364
- }
365
- return null;
401
+ return this.continuity.reasonFor(stat, this.readOffset, probe);
366
402
  }
367
403
  /** Records the identity and prefix of the file the offsets now refer to. */
368
404
  adoptFile(stat, probe) {
369
- this.identity = { dev: stat.dev, ino: stat.ino };
370
- this.lastMtimeMs = stat.mtimeMs;
371
- if (probe && (!this.head || this.head.bytes < Math.min(fileHead_1.HEAD_BYTES, stat.size))) {
372
- this.head = (0, fileHead_1.fileHeadOf)(probe.prefix);
373
- }
405
+ this.continuity.record(stat, probe);
374
406
  }
375
407
  reset(reason, offset) {
376
408
  const previousOffset = this.committedOffset;
377
409
  this.position(offset);
378
- this.head = null;
410
+ this.continuity.discardHead();
379
411
  if (reason === 'replaced' && this.active) {
380
412
  // fs.watch follows the old inode; watch the file now at the path.
381
413
  this.fsWatcher?.close();
@@ -450,12 +482,7 @@ class JsonlTailReader {
450
482
  this.position(Math.max(0, offset));
451
483
  }
452
484
  checkpoint() {
453
- return {
454
- version: exports.JSONL_TAIL_CHECKPOINT_VERSION,
455
- offset: this.committedOffset,
456
- identity: this.identity ? { dev: this.identity.dev, ino: this.identity.ino } : null,
457
- prefixDigest: this.head ? { bytes: this.head.bytes, hash: this.head.hash } : null,
458
- };
485
+ return checkpointOf(this.committedOffset, this.continuity.knownIdentity, this.continuity.knownHead);
459
486
  }
460
487
  resumeFrom(checkpoint) {
461
488
  this.generation += 1;
@@ -465,7 +492,7 @@ class JsonlTailReader {
465
492
  try {
466
493
  fd = this.openSync();
467
494
  const stat = fs.fstatSync(fd);
468
- const prefix = readAt(fd, 0, Math.min(fileHead_1.HEAD_BYTES, stat.size));
495
+ const prefix = readPrefix(fd, stat);
469
496
  const matches = parsed !== null && checkpointMatches(parsed, fd, stat, prefix);
470
497
  const offset = matches
471
498
  ? parsed.offset
@@ -473,16 +500,12 @@ class JsonlTailReader {
473
500
  ? lastCompleteLineOffset(fd, stat.size)
474
501
  : 0;
475
502
  this.position(offset);
476
- this.identity = { dev: stat.dev, ino: stat.ino };
477
- this.lastMtimeMs = stat.mtimeMs;
478
- this.head = (0, fileHead_1.fileHeadOf)(prefix);
503
+ this.continuity.adoptPrefix(stat, prefix);
479
504
  return matches;
480
505
  }
481
506
  catch (error) {
482
507
  this.position(0);
483
- this.identity = null;
484
- this.head = null;
485
- this.lastMtimeMs = -1;
508
+ this.continuity.forget();
486
509
  this.options.onError?.(toError(error));
487
510
  return false;
488
511
  }
@@ -597,6 +620,15 @@ function parseCheckpoint(value) {
597
620
  prefixDigest: parsedDigest,
598
621
  };
599
622
  }
623
+ /** A fresh checkpoint for `offset` in the file with `identity` and recorded `head`. */
624
+ function checkpointOf(offset, identity, head) {
625
+ return {
626
+ version: exports.JSONL_TAIL_CHECKPOINT_VERSION,
627
+ offset,
628
+ identity: identity ? { dev: identity.dev, ino: identity.ino } : null,
629
+ prefixDigest: head ? { bytes: head.bytes, hash: head.hash } : null,
630
+ };
631
+ }
600
632
  /** Whether `checkpoint` still describes the open file. */
601
633
  function checkpointMatches(checkpoint, fd, stat, prefix) {
602
634
  const { offset, identity, prefixDigest } = checkpoint;
@@ -639,6 +671,10 @@ function readAt(fd, position, length) {
639
671
  const count = length > 0 ? fs.readSync(fd, buffer, 0, length, position) : 0;
640
672
  return buffer.subarray(0, count);
641
673
  }
674
+ /** The file's first `min(HEAD_BYTES, size)` bytes: what a checkpoint digests. */
675
+ function readPrefix(fd, stat) {
676
+ return readAt(fd, 0, Math.min(fileHead_1.HEAD_BYTES, stat.size));
677
+ }
642
678
  async function readAtAsync(handle, position, length) {
643
679
  const buffer = Buffer.alloc(length);
644
680
  const count = length > 0 ? (await handle.read(buffer, 0, length, position)).bytesRead : 0;
@@ -4,22 +4,43 @@
4
4
  */
5
5
  import type { ProviderId } from '../providers/types';
6
6
  import type { SessionWatcher, SessionWatcherCallbacks } from './types';
7
+ /** Options for {@link JsonlSessionWatcher}. */
8
+ export interface JsonlSessionWatcherOptions {
9
+ /**
10
+ * Emit a `system` event (summary from the notification, `raw` the line) for
11
+ * each Claude Code mid-turn task-notification attachment, and for the other
12
+ * lines that settle background agents: the plain-text "background agents
13
+ * were stopped by the user" notice, a background agent's final
14
+ * `task_status` attachment, and an `agents_killed` system line. Default
15
+ * `false`.
16
+ */
17
+ includeTaskNotifications?: boolean;
18
+ }
7
19
  export declare class JsonlSessionWatcher implements SessionWatcher {
8
20
  private readonly providerId;
9
21
  private readonly sessionPath;
10
22
  private readonly callbacks;
23
+ private readonly options;
11
24
  private tail;
12
25
  private startOffset;
13
26
  private codexInPlanMode;
14
27
  private readonly claudeUsageDeduper;
15
28
  /** Last Codex cumulative usage seen; repeated `token_count` events carry the same value. */
16
29
  private codexPrevTotal;
17
- constructor(providerId: ProviderId, sessionPath: string, callbacks: SessionWatcherCallbacks);
30
+ constructor(providerId: ProviderId, sessionPath: string, callbacks: SessionWatcherCallbacks, options?: JsonlSessionWatcherOptions);
18
31
  get isActive(): boolean;
19
32
  /** Seek to a committed byte offset before starting replay. */
20
33
  seekTo(position: number): void;
21
34
  /** Position after the last complete JSONL line. */
22
35
  getPosition(): number;
36
+ /**
37
+ * Parse state for {@link getPosition}: for Claude Code, the usage deduper's
38
+ * recent message ids (a `ClaudeUsageDedupeState`); for Codex, the previous
39
+ * cumulative token total and plan mode.
40
+ */
41
+ getResumeState(): unknown;
42
+ /** Restores state from {@link getResumeState} after `seekTo()` and before `start()`. */
43
+ restoreResumeState(state: unknown): boolean;
23
44
  start(replay: boolean): void;
24
45
  stop(): void;
25
46
  private handleRawEvent;