sidekick-shared 0.29.0 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +56 -25
  3. package/dist/aggregation/EventAggregator.d.ts +19 -0
  4. package/dist/aggregation/EventAggregator.js +174 -35
  5. package/dist/aggregation/snapshot.d.ts +37 -8
  6. package/dist/aggregation/snapshot.js +31 -9
  7. package/dist/aggregation/types.d.ts +13 -0
  8. package/dist/browser.d.ts +5 -1
  9. package/dist/browser.js +9 -3
  10. package/dist/context/sessionContext.js +4 -0
  11. package/dist/esm/aggregation/EventAggregator.js +174 -35
  12. package/dist/esm/aggregation/snapshot.js +31 -9
  13. package/dist/esm/browser.js +1 -0
  14. package/dist/esm/context/sessionContext.js +4 -0
  15. package/dist/esm/formatters/toolSummary.js +4 -1
  16. package/dist/esm/index.js +3 -1
  17. package/dist/esm/modelCatalog.js +5 -4
  18. package/dist/esm/modelContext.js +4 -3
  19. package/dist/esm/modelInfo.js +4 -3
  20. package/dist/esm/node.js +1 -1
  21. package/dist/esm/observedContextWindows.js +2 -1
  22. package/dist/esm/ownKey.js +26 -0
  23. package/dist/esm/parsers/codexParser.js +180 -4
  24. package/dist/esm/parsers/openCodeParser.js +2 -1
  25. package/dist/esm/parsers/subagentScanner.js +33 -12
  26. package/dist/esm/parsers/subagentTraceParser.js +97 -37
  27. package/dist/esm/pricingCatalog.js +3 -2
  28. package/dist/esm/providers/claudeCode.js +10 -2
  29. package/dist/esm/providers/codex.js +82 -38
  30. package/dist/esm/schemas/sessionEvent.js +35 -14
  31. package/dist/esm/subagentSpawn.js +163 -0
  32. package/dist/esm/transcript.js +4 -0
  33. package/dist/esm/turns/assistantTurn.js +3 -2
  34. package/dist/esm/usage/claudeUsageDedupe.js +68 -0
  35. package/dist/esm/watchers/eventBridge.js +15 -0
  36. package/dist/esm/watchers/factory.js +5 -3
  37. package/dist/esm/watchers/jsonlTail.js +73 -8
  38. package/dist/esm/watchers/jsonlWatcher.js +51 -4
  39. package/dist/esm/watchers/providerReaderWatcher.js +13 -0
  40. package/dist/esm/zaiQuota.js +2 -1
  41. package/dist/formatters/toolSummary.js +4 -1
  42. package/dist/index.d.ts +8 -3
  43. package/dist/index.js +18 -9
  44. package/dist/modelCatalog.js +5 -4
  45. package/dist/modelContext.js +4 -3
  46. package/dist/modelInfo.js +4 -3
  47. package/dist/node.d.ts +1 -1
  48. package/dist/node.js +3 -1
  49. package/dist/observedContextWindows.js +2 -1
  50. package/dist/ownKey.d.ts +16 -0
  51. package/dist/ownKey.js +30 -0
  52. package/dist/parsers/codexParser.d.ts +63 -0
  53. package/dist/parsers/codexParser.js +181 -4
  54. package/dist/parsers/openCodeParser.js +2 -1
  55. package/dist/parsers/subagentScanner.d.ts +19 -6
  56. package/dist/parsers/subagentScanner.js +33 -12
  57. package/dist/parsers/subagentTraceParser.d.ts +25 -5
  58. package/dist/parsers/subagentTraceParser.js +97 -37
  59. package/dist/pricingCatalog.js +3 -2
  60. package/dist/providers/claudeCode.js +9 -1
  61. package/dist/providers/codex.js +82 -38
  62. package/dist/providers/types.d.ts +14 -0
  63. package/dist/schemas/index.d.ts +1 -0
  64. package/dist/schemas/sessionEvent.d.ts +27 -10
  65. package/dist/schemas/sessionEvent.js +35 -14
  66. package/dist/subagentSpawn.d.ts +80 -0
  67. package/dist/subagentSpawn.js +174 -0
  68. package/dist/transcript.js +4 -0
  69. package/dist/turns/assistantTurn.d.ts +1 -0
  70. package/dist/turns/assistantTurn.js +3 -2
  71. package/dist/usage/claudeUsageDedupe.d.ts +31 -0
  72. package/dist/usage/claudeUsageDedupe.js +68 -0
  73. package/dist/watchers/eventBridge.js +15 -0
  74. package/dist/watchers/factory.d.ts +6 -0
  75. package/dist/watchers/factory.js +5 -3
  76. package/dist/watchers/jsonlTail.d.ts +27 -4
  77. package/dist/watchers/jsonlTail.js +75 -8
  78. package/dist/watchers/jsonlWatcher.d.ts +18 -1
  79. package/dist/watchers/jsonlWatcher.js +50 -3
  80. package/dist/watchers/providerReaderWatcher.d.ts +7 -0
  81. package/dist/watchers/providerReaderWatcher.js +13 -0
  82. package/dist/watchers/types.d.ts +12 -0
  83. package/dist/zaiQuota.js +2 -1
  84. package/package.json +1 -1
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Recognizes Claude Code subagent spawns and background-task notifications.
3
+ *
4
+ * Claude Code's subagent tool is `Agent`; `Task` is its earlier name (and the
5
+ * name OpenCode's `task` tool is normalized to). A background agent's spawning
6
+ * tool call gets its result right away ("Async agent launched successfully.")
7
+ * and its completion arrives later as a `<task-notification>` block, delivered
8
+ * either as a user line or as a mid-turn `queued_command` attachment.
9
+ *
10
+ * Pure: no filesystem access, so it is safe for browser bundles.
11
+ *
12
+ * @module subagentSpawn
13
+ */
14
+ import type { SubagentOutcome } from './aggregation/types';
15
+ import type { SessionEvent } from './types/sessionEvent';
16
+ /** Tool names that spawn a subagent: `Agent` (current) and `Task` (earlier name). */
17
+ export declare const SUBAGENT_SPAWN_TOOL_NAMES: readonly string[];
18
+ /** Whether a tool name spawns a subagent (`Agent` or `Task`). */
19
+ export declare function isSubagentSpawnTool(name: string | null | undefined): boolean;
20
+ /** A spawning tool result that launched the agent in the background. */
21
+ export interface AsyncAgentLaunch {
22
+ /** The agent id Claude Code reported (`agentId: <id>`), when present. */
23
+ agentId?: string;
24
+ }
25
+ /**
26
+ * Plain text of tool-result or message content: a string as-is, or the text
27
+ * blocks of a content array joined by newlines.
28
+ */
29
+ export declare function contentText(content: unknown): string;
30
+ /**
31
+ * Recognizes the immediate result of a background spawn
32
+ * (`Async agent launched successfully.` … `agentId: <id> …`). Accepts the
33
+ * result text or the raw tool-result content. Returns `null` for anything
34
+ * else, including a finished foreground agent's result.
35
+ */
36
+ export declare function parseAsyncAgentLaunch(result: unknown): AsyncAgentLaunch | null;
37
+ /** Fields of a `<task-notification>` block. */
38
+ export interface TaskNotification {
39
+ /** `<task-id>`: the background agent's id (or a background shell task's id). */
40
+ taskId: string;
41
+ /** `<tool-use-id>`: the tool call that started the background work. */
42
+ toolUseId?: string;
43
+ /** `<status>` as written, e.g. `completed`, `failed`, `killed`, `stopped`. */
44
+ status?: string;
45
+ /** `<summary>`, e.g. `Agent "Explore auth" finished`. */
46
+ summary?: string;
47
+ /**
48
+ * The agent stopped while background work of its own is still running; it
49
+ * may resume, and the same task id notifies again.
50
+ */
51
+ interim: boolean;
52
+ }
53
+ /**
54
+ * Parses the first `<task-notification>` block in `text`. Whitespace and
55
+ * newlines between tags, missing fields, and unknown extra fields are
56
+ * tolerated; the block needs a `<task-id>`. Fields are read before the
57
+ * agent's own `<result>` output, which may quote anything.
58
+ */
59
+ export declare function parseTaskNotification(text: unknown): TaskNotification | null;
60
+ /**
61
+ * The `<task-notification>` prompt of a raw Claude Code attachment line
62
+ * (`{ type: 'attachment', attachment: { type: 'queued_command', commandMode:
63
+ * 'task-notification', prompt } }`), which is how a notification that lands
64
+ * mid-turn is recorded. `null` for any other value.
65
+ */
66
+ export declare function taskNotificationPromptFromAttachment(raw: unknown): string | null;
67
+ /**
68
+ * The synthetic `system` event `extractSessionEvents(raw, {
69
+ * includeTaskNotifications: true })` makes from a mid-turn task-notification
70
+ * attachment. It is bookkeeping, not a message or a prompt.
71
+ */
72
+ export declare function isTaskNotificationSystemEvent(event: SessionEvent): boolean;
73
+ /**
74
+ * The task notification an event carries: a user line with
75
+ * `origin.kind === 'task-notification'` or whose text starts with
76
+ * `<task-notification>`, or the opt-in synthetic system event. `null` otherwise.
77
+ */
78
+ export declare function taskNotificationFromEvent(event: SessionEvent): TaskNotification | null;
79
+ /** Maps a notification `<status>` to an outcome; unknown or missing statuses read as `completed`. */
80
+ export declare function taskNotificationOutcome(status: string | undefined): SubagentOutcome;
@@ -0,0 +1,174 @@
1
+ "use strict";
2
+ /**
3
+ * Recognizes Claude Code subagent spawns and background-task notifications.
4
+ *
5
+ * Claude Code's subagent tool is `Agent`; `Task` is its earlier name (and the
6
+ * name OpenCode's `task` tool is normalized to). A background agent's spawning
7
+ * tool call gets its result right away ("Async agent launched successfully.")
8
+ * and its completion arrives later as a `<task-notification>` block, delivered
9
+ * either as a user line or as a mid-turn `queued_command` attachment.
10
+ *
11
+ * Pure: no filesystem access, so it is safe for browser bundles.
12
+ *
13
+ * @module subagentSpawn
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.SUBAGENT_SPAWN_TOOL_NAMES = void 0;
17
+ exports.isSubagentSpawnTool = isSubagentSpawnTool;
18
+ exports.contentText = contentText;
19
+ exports.parseAsyncAgentLaunch = parseAsyncAgentLaunch;
20
+ exports.parseTaskNotification = parseTaskNotification;
21
+ exports.taskNotificationPromptFromAttachment = taskNotificationPromptFromAttachment;
22
+ exports.isTaskNotificationSystemEvent = isTaskNotificationSystemEvent;
23
+ exports.taskNotificationFromEvent = taskNotificationFromEvent;
24
+ exports.taskNotificationOutcome = taskNotificationOutcome;
25
+ /** Tool names that spawn a subagent: `Agent` (current) and `Task` (earlier name). */
26
+ exports.SUBAGENT_SPAWN_TOOL_NAMES = Object.freeze(['Agent', 'Task']);
27
+ const SPAWN_TOOLS = new Set(exports.SUBAGENT_SPAWN_TOOL_NAMES);
28
+ /** Whether a tool name spawns a subagent (`Agent` or `Task`). */
29
+ function isSubagentSpawnTool(name) {
30
+ return typeof name === 'string' && SPAWN_TOOLS.has(name);
31
+ }
32
+ const ASYNC_LAUNCH_PREFIX = /^\s*Async agent launched successfully\b/;
33
+ const AGENT_ID_LINE = /(?:^|\n)[ \t]*agentId:[ \t]*([A-Za-z0-9][\w.@-]*)/;
34
+ /**
35
+ * Plain text of tool-result or message content: a string as-is, or the text
36
+ * blocks of a content array joined by newlines.
37
+ */
38
+ function contentText(content) {
39
+ if (typeof content === 'string')
40
+ return content;
41
+ if (!Array.isArray(content))
42
+ return '';
43
+ const parts = [];
44
+ for (const block of content) {
45
+ if (!block || typeof block !== 'object')
46
+ continue;
47
+ const typed = block;
48
+ if (typed.type === 'text' && typeof typed.text === 'string')
49
+ parts.push(typed.text);
50
+ }
51
+ return parts.join('\n');
52
+ }
53
+ /**
54
+ * Recognizes the immediate result of a background spawn
55
+ * (`Async agent launched successfully.` … `agentId: <id> …`). Accepts the
56
+ * result text or the raw tool-result content. Returns `null` for anything
57
+ * else, including a finished foreground agent's result.
58
+ */
59
+ function parseAsyncAgentLaunch(result) {
60
+ const text = contentText(result);
61
+ if (!ASYNC_LAUNCH_PREFIX.test(text))
62
+ return null;
63
+ const match = AGENT_ID_LINE.exec(text);
64
+ return match ? { agentId: match[1] } : {};
65
+ }
66
+ const NOTIFICATION_OPEN = '<task-notification>';
67
+ const NOTIFICATION_CLOSE = '</task-notification>';
68
+ const NOTIFICATION_PREFIX = /^\s*<task-notification>/;
69
+ const TAG_PATTERNS = {
70
+ taskId: /<task-id>\s*([\s\S]*?)\s*<\/task-id>/,
71
+ toolUseId: /<tool-use-id>\s*([\s\S]*?)\s*<\/tool-use-id>/,
72
+ status: /<status>\s*([\s\S]*?)\s*<\/status>/,
73
+ summary: /<summary>\s*([\s\S]*?)\s*<\/summary>/,
74
+ note: /<note>\s*([\s\S]*?)\s*<\/note>/,
75
+ };
76
+ const INTERIM_TEXT = /waiting on its own background work|background work of its own still running/i;
77
+ function tagValue(body, pattern) {
78
+ const value = pattern.exec(body)?.[1]?.trim();
79
+ return value ? value : undefined;
80
+ }
81
+ /**
82
+ * Parses the first `<task-notification>` block in `text`. Whitespace and
83
+ * newlines between tags, missing fields, and unknown extra fields are
84
+ * tolerated; the block needs a `<task-id>`. Fields are read before the
85
+ * agent's own `<result>` output, which may quote anything.
86
+ */
87
+ function parseTaskNotification(text) {
88
+ if (typeof text !== 'string')
89
+ return null;
90
+ const start = text.indexOf(NOTIFICATION_OPEN);
91
+ if (start < 0)
92
+ return null;
93
+ let body = text.slice(start + NOTIFICATION_OPEN.length);
94
+ const resultAt = body.indexOf('<result>');
95
+ let header = resultAt >= 0 ? body.slice(0, resultAt) : body;
96
+ if (resultAt < 0) {
97
+ const end = body.indexOf(NOTIFICATION_CLOSE);
98
+ if (end >= 0)
99
+ header = body = body.slice(0, end);
100
+ }
101
+ if (!TAG_PATTERNS.taskId.test(header))
102
+ header = body;
103
+ const taskId = tagValue(header, TAG_PATTERNS.taskId);
104
+ if (!taskId)
105
+ return null;
106
+ const toolUseId = tagValue(header, TAG_PATTERNS.toolUseId);
107
+ const status = tagValue(header, TAG_PATTERNS.status);
108
+ const summary = tagValue(header, TAG_PATTERNS.summary);
109
+ const note = tagValue(header, TAG_PATTERNS.note);
110
+ return {
111
+ taskId,
112
+ ...(toolUseId ? { toolUseId } : {}),
113
+ ...(status ? { status } : {}),
114
+ ...(summary ? { summary } : {}),
115
+ interim: INTERIM_TEXT.test(summary ?? '') || INTERIM_TEXT.test(note ?? ''),
116
+ };
117
+ }
118
+ /**
119
+ * The `<task-notification>` prompt of a raw Claude Code attachment line
120
+ * (`{ type: 'attachment', attachment: { type: 'queued_command', commandMode:
121
+ * 'task-notification', prompt } }`), which is how a notification that lands
122
+ * mid-turn is recorded. `null` for any other value.
123
+ */
124
+ function taskNotificationPromptFromAttachment(raw) {
125
+ if (!raw || typeof raw !== 'object')
126
+ return null;
127
+ const line = raw;
128
+ if (line.type !== 'attachment' || !line.attachment || typeof line.attachment !== 'object') {
129
+ return null;
130
+ }
131
+ const attachment = line.attachment;
132
+ return attachment.type === 'queued_command' &&
133
+ attachment.commandMode === 'task-notification' &&
134
+ typeof attachment.prompt === 'string'
135
+ ? attachment.prompt
136
+ : null;
137
+ }
138
+ /**
139
+ * The synthetic `system` event `extractSessionEvents(raw, {
140
+ * includeTaskNotifications: true })` makes from a mid-turn task-notification
141
+ * attachment. It is bookkeeping, not a message or a prompt.
142
+ */
143
+ function isTaskNotificationSystemEvent(event) {
144
+ return event.type === 'system' && event.origin?.kind === 'task-notification';
145
+ }
146
+ /**
147
+ * The task notification an event carries: a user line with
148
+ * `origin.kind === 'task-notification'` or whose text starts with
149
+ * `<task-notification>`, or the opt-in synthetic system event. `null` otherwise.
150
+ */
151
+ function taskNotificationFromEvent(event) {
152
+ if (event.type !== 'user' && event.type !== 'system')
153
+ return null;
154
+ const flagged = event.origin?.kind === 'task-notification';
155
+ if (event.type === 'system' && !flagged)
156
+ return null;
157
+ const text = contentText(event.message?.content);
158
+ if (!flagged && !NOTIFICATION_PREFIX.test(text))
159
+ return null;
160
+ return parseTaskNotification(text);
161
+ }
162
+ /** Maps a notification `<status>` to an outcome; unknown or missing statuses read as `completed`. */
163
+ function taskNotificationOutcome(status) {
164
+ switch (status?.toLowerCase()) {
165
+ case 'failed':
166
+ return 'failed';
167
+ case 'killed':
168
+ return 'killed';
169
+ case 'stopped':
170
+ return 'stopped';
171
+ default:
172
+ return 'completed';
173
+ }
174
+ }
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.projectSessionTranscript = projectSessionTranscript;
4
4
  const usageNormalization_1 = require("./usageNormalization");
5
+ const subagentSpawn_1 = require("./subagentSpawn");
5
6
  const EMPTY_TOTALS = {
6
7
  uncachedInputTokens: 0,
7
8
  cacheReadTokens: 0,
@@ -28,6 +29,9 @@ function projectSessionTranscript(events, options = {}) {
28
29
  let cwd;
29
30
  let gitBranch;
30
31
  events.forEach((event, eventIndex) => {
32
+ // A reader's synthetic notice that a background task finished, not a message.
33
+ if ((0, subagentSpawn_1.isTaskNotificationSystemEvent)(event))
34
+ return;
31
35
  if (nonEmpty(event.cwd))
32
36
  cwd = event.cwd;
33
37
  if (nonEmpty(event.gitBranch))
@@ -70,6 +70,7 @@ export interface ReasoningSummary {
70
70
  body: string;
71
71
  }
72
72
  export declare function reasoningSummary(text: string): ReasoningSummary;
73
+ /** Whether a tool call spawns a subagent (`Agent`, or its earlier name `Task`). */
73
74
  export declare function isAssistantTurnSubagentTool(toolName: string | undefined): boolean;
74
75
  export declare function segmentAssistantTurn(events: readonly AssistantTurnEvent[], options?: SegmentAssistantTurnOptions): AssistantTurnProjection;
75
76
  export declare function assistantTurnEventsFromSessionEvents(events: readonly SessionEvent[]): AssistantTurnEvent[];
@@ -6,12 +6,12 @@ exports.segmentAssistantTurn = segmentAssistantTurn;
6
6
  exports.assistantTurnEventsFromSessionEvents = assistantTurnEventsFromSessionEvents;
7
7
  exports.extractTurnSubagents = extractTurnSubagents;
8
8
  const toolSummary_1 = require("../formatters/toolSummary");
9
+ const subagentSpawn_1 = require("../subagentSpawn");
9
10
  const DEFAULT_NARRATION_CHARS = 4000;
10
11
  const DEFAULT_REASONING_CHARS = 8000;
11
12
  const DEFAULT_PROCESS_STEPS = 60;
12
13
  const DEFAULT_REASONING_BLOCKS = 24;
13
14
  const DEFAULT_TOOL_INPUT_CHARS = 200;
14
- const SUBAGENT_TOOL_NAMES = new Set(['Task']);
15
15
  const LABEL_MAX = 80;
16
16
  const TITLE_RE = /^\s*\*\*([^*\n]+)\*\*[ \t]*(?:\n+([\s\S]*))?$/;
17
17
  const TITLE_MAX = 100;
@@ -29,8 +29,9 @@ function reasoningSummary(text) {
29
29
  }
30
30
  return { title: null, body: trimmed };
31
31
  }
32
+ /** Whether a tool call spawns a subagent (`Agent`, or its earlier name `Task`). */
32
33
  function isAssistantTurnSubagentTool(toolName) {
33
- return toolName != null && SUBAGENT_TOOL_NAMES.has(toolName);
34
+ return (0, subagentSpawn_1.isSubagentSpawnTool)(toolName);
34
35
  }
35
36
  function segmentAssistantTurn(events, options = {}) {
36
37
  const maxNarrationChars = options.maxNarrationChars ?? DEFAULT_NARRATION_CHARS;
@@ -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);
@@ -11,6 +11,7 @@
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");
14
15
  /**
15
16
  * Converts a single SessionEvent to one or more FollowEvents.
16
17
  *
@@ -151,6 +152,20 @@ function toFollowEvents(event, providerId) {
151
152
  break;
152
153
  }
153
154
  case 'system': {
155
+ // A background task's notice (opt-in reader event): its summary, not the raw block.
156
+ const notification = (0, subagentSpawn_1.isTaskNotificationSystemEvent)(event)
157
+ ? (0, subagentSpawn_1.taskNotificationFromEvent)(event)
158
+ : null;
159
+ if (notification) {
160
+ events.push({
161
+ providerId,
162
+ type: 'system',
163
+ timestamp: ts,
164
+ summary: notification.summary ?? `Background task ${notification.status ?? 'finished'}`,
165
+ raw: event,
166
+ });
167
+ break;
168
+ }
154
169
  const text = extractTextContent(event.message?.content);
155
170
  const label = event.message?.sourceLabel || event.message?.role || 'system';
156
171
  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,6 +39,8 @@ 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");
44
46
  const jsonl_1 = require("../parsers/jsonl");
@@ -98,6 +100,63 @@ exports.JsonlTailSymlinkError = JsonlTailSymlinkError;
98
100
  function createJsonlTail(options) {
99
101
  return new JsonlTailReader(options);
100
102
  }
103
+ /**
104
+ * Records a {@link JsonlTailCheckpoint} for byte `offset` of `filePath`
105
+ * without a tail, for readers that keep their own offset (a snapshot that
106
+ * stores a reader position, for example). The checkpoint carries the file's
107
+ * current device and inode and a digest of its first bytes, so
108
+ * {@link verifyJsonlCheckpoint} (or {@link JsonlTail.resumeFrom}) can later
109
+ * tell whether `offset` still refers to the same, append-only content.
110
+ *
111
+ * Returns `null` when the file is missing or unreadable, or when `offset` is
112
+ * not a resume-safe position in it: negative, past the end, or not 0 and not
113
+ * just after a newline. Follows symbolic links.
114
+ */
115
+ function captureJsonlCheckpoint(filePath, offset) {
116
+ if (!Number.isSafeInteger(offset) || offset < 0)
117
+ return null;
118
+ let fd = null;
119
+ try {
120
+ fd = fs.openSync(filePath, 'r');
121
+ const stat = fs.fstatSync(fd);
122
+ if (offset > stat.size)
123
+ return null;
124
+ if (offset > 0 && readAt(fd, offset - 1, 1)[0] !== 0x0a)
125
+ return null;
126
+ return checkpointOf(offset, stat, (0, fileHead_1.fileHeadOf)(readPrefix(fd, stat)));
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ finally {
132
+ closeQuietly(fd);
133
+ }
134
+ }
135
+ /**
136
+ * Whether `checkpoint` (any value, typically parsed from JSON) is a valid
137
+ * {@link JsonlTailCheckpoint} that still describes `filePath`: same device
138
+ * and inode, the recorded first bytes unchanged, and its offset within the
139
+ * file and just after a newline (or 0). This is the check
140
+ * {@link JsonlTail.resumeFrom} applies. `false` on any error, including a
141
+ * missing file. Follows symbolic links.
142
+ */
143
+ function verifyJsonlCheckpoint(filePath, checkpoint) {
144
+ const parsed = parseCheckpoint(checkpoint);
145
+ if (parsed === null)
146
+ return false;
147
+ let fd = null;
148
+ try {
149
+ fd = fs.openSync(filePath, 'r');
150
+ const stat = fs.fstatSync(fd);
151
+ return checkpointMatches(parsed, fd, stat, readPrefix(fd, stat));
152
+ }
153
+ catch {
154
+ return false;
155
+ }
156
+ finally {
157
+ closeQuietly(fd);
158
+ }
159
+ }
101
160
  class JsonlTailReader {
102
161
  options;
103
162
  active = false;
@@ -330,7 +389,7 @@ class JsonlTailReader {
330
389
  }
331
390
  probeSync(fd, stat) {
332
391
  return {
333
- prefix: readAt(fd, 0, Math.min(fileHead_1.HEAD_BYTES, stat.size)),
392
+ prefix: readPrefix(fd, stat),
334
393
  before: this.committedOffset > 0 && this.committedOffset <= stat.size
335
394
  ? readAt(fd, this.committedOffset - 1, 1)
336
395
  : null,
@@ -450,12 +509,7 @@ class JsonlTailReader {
450
509
  this.position(Math.max(0, offset));
451
510
  }
452
511
  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
- };
512
+ return checkpointOf(this.committedOffset, this.identity, this.head);
459
513
  }
460
514
  resumeFrom(checkpoint) {
461
515
  this.generation += 1;
@@ -465,7 +519,7 @@ class JsonlTailReader {
465
519
  try {
466
520
  fd = this.openSync();
467
521
  const stat = fs.fstatSync(fd);
468
- const prefix = readAt(fd, 0, Math.min(fileHead_1.HEAD_BYTES, stat.size));
522
+ const prefix = readPrefix(fd, stat);
469
523
  const matches = parsed !== null && checkpointMatches(parsed, fd, stat, prefix);
470
524
  const offset = matches
471
525
  ? parsed.offset
@@ -597,6 +651,15 @@ function parseCheckpoint(value) {
597
651
  prefixDigest: parsedDigest,
598
652
  };
599
653
  }
654
+ /** A fresh checkpoint for `offset` in the file with `identity` and recorded `head`. */
655
+ function checkpointOf(offset, identity, head) {
656
+ return {
657
+ version: exports.JSONL_TAIL_CHECKPOINT_VERSION,
658
+ offset,
659
+ identity: identity ? { dev: identity.dev, ino: identity.ino } : null,
660
+ prefixDigest: head ? { bytes: head.bytes, hash: head.hash } : null,
661
+ };
662
+ }
600
663
  /** Whether `checkpoint` still describes the open file. */
601
664
  function checkpointMatches(checkpoint, fd, stat, prefix) {
602
665
  const { offset, identity, prefixDigest } = checkpoint;
@@ -639,6 +702,10 @@ function readAt(fd, position, length) {
639
702
  const count = length > 0 ? fs.readSync(fd, buffer, 0, length, position) : 0;
640
703
  return buffer.subarray(0, count);
641
704
  }
705
+ /** The file's first `min(HEAD_BYTES, size)` bytes: what a checkpoint digests. */
706
+ function readPrefix(fd, stat) {
707
+ return readAt(fd, 0, Math.min(fileHead_1.HEAD_BYTES, stat.size));
708
+ }
642
709
  async function readAtAsync(handle, position, length) {
643
710
  const buffer = Buffer.alloc(length);
644
711
  const count = length > 0 ? (await handle.read(buffer, 0, length, position)).bytesRead : 0;