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
@@ -18,6 +18,7 @@ import { HeatmapTracker } from './HeatmapTracker';
18
18
  import { PatternExtractor } from './PatternExtractor';
19
19
  import { categorizeError } from '../extractors/errorTaxonomy';
20
20
  import { estimateTextTokens } from '../tokenEstimation';
21
+ import { isSubagentSpawnTool, isTaskNotificationSystemEvent, parseAsyncAgentLaunch, parseTaskNotification, taskNotificationFromEvent, taskNotificationOutcome, taskNotificationPromptFromAttachment, } from '../subagentSpawn';
21
22
  import { calculateNormalizedUsageCost, extractNormalizedUsage, normalizeProviderUsage, } from '../usageNormalization';
22
23
  // ── Defaults ──
23
24
  const DEFAULT_TIMELINE_CAP = 200;
@@ -30,6 +31,22 @@ const COMPACTION_DROP_THRESHOLD = 0.8; // >20% drop
30
31
  export const SNAPSHOT_SCHEMA_VERSION = 6;
31
32
  // ── Goal gate detection regex ──
32
33
  const GOAL_GATE_REGEX = /\b(CRITICAL|MUST|blocker|required|must.?complete|goal.?gate|essential|do.?not.?skip|blocking)\b/i;
34
+ // ── Task notifications delivered as attachments ──
35
+ /**
36
+ * The notification a `system` FollowEvent carries when it stands for a
37
+ * mid-turn task-notification attachment (its `raw` is the attachment line or
38
+ * the SessionEvent `extractSessionEvents` made from it).
39
+ */
40
+ function taskNotificationFromAttachmentFollowEvent(event) {
41
+ const raw = event.raw;
42
+ if (!raw || typeof raw !== 'object')
43
+ return null;
44
+ const prompt = taskNotificationPromptFromAttachment(raw);
45
+ if (prompt !== null)
46
+ return parseTaskNotification(prompt);
47
+ const asEvent = raw;
48
+ return isTaskNotificationSystemEvent(asEvent) ? taskNotificationFromEvent(asEvent) : null;
49
+ }
33
50
  // ── Todo dependency parsing ──
34
51
  /**
35
52
  * Parses dependency references from a todo item's content text.
@@ -168,6 +185,17 @@ export class EventAggregator {
168
185
  // processEvent — main entry for SessionEvent
169
186
  // ═══════════════════════════════════════════════════════════════════════
170
187
  processEvent(event) {
188
+ // Opt-in mid-turn task notifications (`extractSessionEvents` with
189
+ // `includeTaskNotifications`) only finish background subagents and mark
190
+ // the timeline: they are not messages, prompts, context, or activity.
191
+ if (isTaskNotificationSystemEvent(event)) {
192
+ const notification = taskNotificationFromEvent(event);
193
+ if (notification) {
194
+ this.applyTaskNotification(notification, event.timestamp);
195
+ this.addTaskNotificationTimeline(event.timestamp, notification);
196
+ }
197
+ return;
198
+ }
171
199
  // 1. Increment counters, capture timestamps
172
200
  this.eventCount++;
173
201
  if (!this.sessionStartTime) {
@@ -233,6 +261,15 @@ export class EventAggregator {
233
261
  // processFollowEvent — adapter for CLI FollowEvent format
234
262
  // ═══════════════════════════════════════════════════════════════════════
235
263
  processFollowEvent(event) {
264
+ // A mid-turn task-notification attachment, like its SessionEvent form above.
265
+ if (event.type === 'system') {
266
+ const notification = taskNotificationFromAttachmentFollowEvent(event);
267
+ if (notification) {
268
+ this.applyTaskNotification(notification, event.timestamp);
269
+ this.addTaskNotificationTimeline(event.timestamp, notification);
270
+ return;
271
+ }
272
+ }
236
273
  // 1. Increment counters, capture timestamps
237
274
  this.eventCount++;
238
275
  if (!this.sessionStartTime) {
@@ -626,6 +663,14 @@ export class EventAggregator {
626
663
  this.pendingToolCalls.clear();
627
664
  this.pendingTaskCreates.clear();
628
665
  this.pendingSubagents.clear();
666
+ // A spawn still waiting for its tool_result is resolved by that result,
667
+ // which can land after the snapshot boundary. Background agents need no
668
+ // entry: their notifications are matched against every subagent.
669
+ this.subagents.forEach((agent, idx) => {
670
+ if (agent.status === 'running' && !agent.background && agent.id) {
671
+ this.pendingSubagents.set(agent.id, idx);
672
+ }
673
+ });
629
674
  this.pendingUserRequest = null;
630
675
  this.planExtractor.reset();
631
676
  return true;
@@ -1104,7 +1149,7 @@ export class EventAggregator {
1104
1149
  else if (name === 'UpdatePlan') {
1105
1150
  this.handleUpdatePlan(input, new Date(event.timestamp));
1106
1151
  }
1107
- else if ((name === 'Agent' || name === 'Task') && toolUseId) {
1152
+ else if (isSubagentSpawnTool(name) && toolUseId) {
1108
1153
  this.handleAgentSpawn(toolUseId, input, new Date(event.timestamp));
1109
1154
  }
1110
1155
  }
@@ -1114,7 +1159,7 @@ export class EventAggregator {
1114
1159
  continue;
1115
1160
  const isError = block.is_error ?? false;
1116
1161
  this.resolveTaskCreate(toolUseId, block.content ?? block.output, event.timestamp, isError);
1117
- this.resolveAgentResult(toolUseId, isError, event.timestamp);
1162
+ this.resolveAgentResult(toolUseId, isError, event.timestamp, block.content ?? block.output);
1118
1163
  }
1119
1164
  }
1120
1165
  }
@@ -1148,7 +1193,7 @@ export class EventAggregator {
1148
1193
  else if (event.toolName === 'UpdatePlan') {
1149
1194
  this.handleUpdatePlan(input, new Date(event.timestamp));
1150
1195
  }
1151
- else if ((event.toolName === 'Agent' || event.toolName === 'Task') && toolUseId) {
1196
+ else if (isSubagentSpawnTool(event.toolName) && toolUseId) {
1152
1197
  this.handleAgentSpawn(toolUseId, input, new Date(event.timestamp));
1153
1198
  }
1154
1199
  }
@@ -1160,7 +1205,7 @@ export class EventAggregator {
1160
1205
  return;
1161
1206
  const isError = raw.is_error ?? false;
1162
1207
  this.resolveTaskCreate(toolUseId, raw.content, event.timestamp, isError);
1163
- this.resolveAgentResult(toolUseId, isError, event.timestamp);
1208
+ this.resolveAgentResult(toolUseId, isError, event.timestamp, raw.content);
1164
1209
  }
1165
1210
  }
1166
1211
  applyTaskUpdate(input) {
@@ -1465,11 +1510,14 @@ export class EventAggregator {
1465
1510
  };
1466
1511
  this.tasks.set(agentTaskId, task);
1467
1512
  }
1468
- resolveAgentResult(toolUseId, isError, timestamp) {
1513
+ resolveAgentResult(toolUseId, isError, timestamp, content) {
1469
1514
  const agentTaskId = `agent-${toolUseId}`;
1470
1515
  const agentTask = this.tasks.get(agentTaskId);
1471
1516
  if (!agentTask)
1472
1517
  return;
1518
+ // A background launch returns at once; its task notification finishes it.
1519
+ if (!isError && parseAsyncAgentLaunch(content))
1520
+ return;
1473
1521
  agentTask.status = isError ? 'deleted' : 'completed';
1474
1522
  agentTask.updatedAt = new Date(timestamp);
1475
1523
  }
@@ -1489,31 +1537,25 @@ export class EventAggregator {
1489
1537
  // Private: Subagent Tracking from SessionEvent
1490
1538
  // ═══════════════════════════════════════════════════════════════════════
1491
1539
  extractSubagentFromEvent(event) {
1540
+ const notification = taskNotificationFromEvent(event);
1541
+ if (notification)
1542
+ this.applyTaskNotification(notification, event.timestamp);
1492
1543
  const content = event.message?.content;
1493
1544
  if (!Array.isArray(content))
1494
1545
  return;
1495
1546
  for (const block of content) {
1496
- if (block.type === 'tool_use' && block.name === 'Task') {
1547
+ if (block.type === 'tool_use' && isSubagentSpawnTool(block.name)) {
1497
1548
  const input = block.input;
1498
1549
  const toolUseId = block.id;
1499
1550
  if (!input || !toolUseId)
1500
1551
  continue;
1501
- const info = {
1502
- id: toolUseId,
1503
- description: input.description || 'Unknown task',
1504
- subagentType: input.subagent_type || input.subagentType || 'general',
1505
- spawnTime: event.timestamp,
1506
- status: 'running',
1507
- };
1508
- const idx = this.subagents.length;
1509
- this.subagents.push(info);
1510
- this.pendingSubagents.set(toolUseId, idx);
1552
+ this.trackSubagentSpawn(toolUseId, input, event.timestamp);
1511
1553
  }
1512
1554
  else if (block.type === 'tool_result') {
1513
1555
  const toolUseId = block.tool_use_id;
1514
1556
  if (!toolUseId)
1515
1557
  continue;
1516
- this.completeSubagent(toolUseId, event.timestamp);
1558
+ this.resolveSubagentResult(toolUseId, block.content ?? block.output, block.is_error === true, event.timestamp);
1517
1559
  }
1518
1560
  }
1519
1561
  }
@@ -1522,23 +1564,10 @@ export class EventAggregator {
1522
1564
  // ═══════════════════════════════════════════════════════════════════════
1523
1565
  extractSubagentFromFollowEvent(event) {
1524
1566
  const raw = event.raw;
1525
- if (event.type === 'tool_use' && event.toolName === 'Task') {
1567
+ if (event.type === 'tool_use' && isSubagentSpawnTool(event.toolName)) {
1526
1568
  if (!raw?.input)
1527
1569
  return;
1528
- const input = raw.input;
1529
- const toolUseId = raw.id || '';
1530
- const info = {
1531
- id: toolUseId,
1532
- description: input.description || 'Unknown task',
1533
- subagentType: input.subagent_type || input.subagentType || 'general',
1534
- spawnTime: event.timestamp,
1535
- status: 'running',
1536
- };
1537
- const idx = this.subagents.length;
1538
- this.subagents.push(info);
1539
- if (toolUseId) {
1540
- this.pendingSubagents.set(toolUseId, idx);
1541
- }
1570
+ this.trackSubagentSpawn(raw.id || '', raw.input, event.timestamp);
1542
1571
  }
1543
1572
  else if (event.type === 'tool_result') {
1544
1573
  if (!raw)
@@ -1546,23 +1575,133 @@ export class EventAggregator {
1546
1575
  const toolUseId = raw.tool_use_id;
1547
1576
  if (!toolUseId)
1548
1577
  return;
1549
- this.completeSubagent(toolUseId, event.timestamp);
1578
+ this.resolveSubagentResult(toolUseId, raw.content, raw.is_error === true, event.timestamp);
1579
+ }
1580
+ else if (event.type === 'user' && raw?.type === 'user') {
1581
+ // Claude Code delivers most task notifications as their own user line.
1582
+ const notification = taskNotificationFromEvent(raw);
1583
+ if (notification)
1584
+ this.applyTaskNotification(notification, event.timestamp);
1585
+ }
1586
+ }
1587
+ trackSubagentSpawn(toolUseId, input, timestamp) {
1588
+ const info = {
1589
+ id: toolUseId,
1590
+ description: input.description || 'Unknown task',
1591
+ subagentType: input.subagent_type || input.subagentType || 'general',
1592
+ spawnTime: timestamp,
1593
+ status: 'running',
1594
+ };
1595
+ const idx = this.subagents.length;
1596
+ this.subagents.push(info);
1597
+ if (toolUseId) {
1598
+ this.pendingSubagents.set(toolUseId, idx);
1550
1599
  }
1551
1600
  }
1552
- completeSubagent(toolUseId, timestamp) {
1601
+ /**
1602
+ * The spawning tool call's result. A foreground agent ends here; a
1603
+ * background launch only reports the agent id, and the agent keeps running
1604
+ * until its task notification arrives.
1605
+ */
1606
+ resolveSubagentResult(toolUseId, content, isError, timestamp) {
1553
1607
  const idx = this.pendingSubagents.get(toolUseId);
1554
1608
  if (idx === undefined)
1555
1609
  return;
1556
- this.pendingSubagents.delete(toolUseId);
1610
+ const launch = isError ? null : parseAsyncAgentLaunch(content);
1611
+ if (launch) {
1612
+ this.markBackgroundLaunch(idx, launch);
1613
+ return;
1614
+ }
1615
+ this.completeSubagent(toolUseId, timestamp, isError ? 'failed' : 'completed');
1616
+ }
1617
+ markBackgroundLaunch(idx, launch) {
1557
1618
  const agent = this.subagents[idx];
1619
+ this.pendingSubagents.delete(agent.id);
1620
+ agent.background = true;
1621
+ if (launch.agentId)
1622
+ agent.agentId = launch.agentId;
1623
+ }
1624
+ completeSubagent(toolUseId, timestamp, outcome = 'completed') {
1625
+ const idx = this.pendingSubagents.get(toolUseId);
1626
+ if (idx === undefined)
1627
+ return;
1628
+ this.finishSubagent(this.subagents[idx], timestamp, outcome);
1629
+ }
1630
+ finishSubagent(agent, timestamp, outcome) {
1631
+ this.pendingSubagents.delete(agent.id);
1558
1632
  agent.status = 'completed';
1559
1633
  agent.completionTime = timestamp;
1634
+ agent.outcome = outcome;
1560
1635
  const start = new Date(agent.spawnTime).getTime();
1561
1636
  const end = new Date(timestamp).getTime();
1562
1637
  if (!isNaN(start) && !isNaN(end) && end >= start) {
1563
1638
  agent.durationMs = end - start;
1564
1639
  }
1565
1640
  }
1641
+ /**
1642
+ * Applies a background task's notification to the subagent it reports on,
1643
+ * matched by the spawning tool_use id (or by agent id when the notification
1644
+ * has none). The latest notification wins: an interim one ("waiting on its
1645
+ * own background work") keeps or puts the agent back to running, a final one
1646
+ * completes it. Notifications for background shell tasks match nothing.
1647
+ */
1648
+ applyTaskNotification(notification, timestamp) {
1649
+ const agent = this.findNotifiedSubagent(notification);
1650
+ const toolUseId = agent?.id || notification.toolUseId;
1651
+ const outcome = taskNotificationOutcome(notification.status);
1652
+ if (agent) {
1653
+ agent.background = true;
1654
+ if (!agent.agentId)
1655
+ agent.agentId = notification.taskId;
1656
+ if (notification.interim) {
1657
+ this.pendingSubagents.delete(agent.id);
1658
+ agent.status = 'running';
1659
+ delete agent.completionTime;
1660
+ delete agent.durationMs;
1661
+ delete agent.outcome;
1662
+ }
1663
+ else {
1664
+ this.finishSubagent(agent, timestamp, outcome);
1665
+ }
1666
+ }
1667
+ const agentTask = toolUseId ? this.tasks.get(`agent-${toolUseId}`) : undefined;
1668
+ if (agentTask?.isSubagent) {
1669
+ agentTask.status = notification.interim
1670
+ ? 'in_progress'
1671
+ : outcome === 'failed'
1672
+ ? 'deleted'
1673
+ : 'completed';
1674
+ agentTask.updatedAt = new Date(timestamp);
1675
+ }
1676
+ }
1677
+ findNotifiedSubagent(notification) {
1678
+ const { toolUseId, taskId } = notification;
1679
+ for (let i = this.subagents.length - 1; i >= 0; i--) {
1680
+ if (toolUseId && this.subagents[i].id === toolUseId)
1681
+ return this.subagents[i];
1682
+ }
1683
+ for (let i = this.subagents.length - 1; i >= 0; i--) {
1684
+ if (this.subagents[i].agentId === taskId)
1685
+ return this.subagents[i];
1686
+ }
1687
+ return undefined;
1688
+ }
1689
+ addTaskNotificationTimeline(timestamp, notification) {
1690
+ let description = notification.summary ?? `Background task ${notification.status ?? 'finished'}`;
1691
+ if (description.length > 200)
1692
+ description = description.substring(0, 197) + '...';
1693
+ this.timeline.push({
1694
+ type: 'tool_result',
1695
+ timestamp,
1696
+ description,
1697
+ noiseLevel: 'system',
1698
+ messageClassification: 'system',
1699
+ ...(notification.toolUseId ? { metadata: { toolUseId: notification.toolUseId } } : {}),
1700
+ });
1701
+ if (this.timeline.length > this.timelineCap) {
1702
+ this.timeline.shift();
1703
+ }
1704
+ }
1566
1705
  // ═══════════════════════════════════════════════════════════════════════
1567
1706
  // Private: Plan Extraction from SessionEvent
1568
1707
  // ═══════════════════════════════════════════════════════════════════════
@@ -2,14 +2,18 @@
2
2
  * Session snapshot sidecar — persists aggregator state + reader position
3
3
  * so that re-attaching to a session skips full replay.
4
4
  *
5
- * Snapshots are stored in ~/.config/sidekick/snapshots/{sessionId}.json.
6
- * They are invalidated when the source file changes beyond the snapshot position.
5
+ * Snapshots are stored in `<config dir>/snapshots/{sessionId}.json` (see
6
+ * `getConfigDir()`). They are invalidated when the source file shrank below
7
+ * the snapshot position or, for file-backed sessions that recorded a
8
+ * {@link SessionSnapshot.sourceCheckpoint}, when the file is no longer the
9
+ * same append-only file.
7
10
  *
8
11
  * @module aggregation/snapshot
9
12
  */
10
13
  import * as fs from 'fs';
11
14
  import * as path from 'path';
12
15
  import { getConfigDir } from '../paths';
16
+ import { verifyJsonlCheckpoint } from '../watchers/jsonlTail';
13
17
  import { atomicWriteJsonSync } from '../writers/atomic';
14
18
  import { SNAPSHOT_SCHEMA_VERSION } from './EventAggregator';
15
19
  /** Current schema version — bump when format changes. */
@@ -101,8 +105,8 @@ export function pruneSnapshots(keep = MAX_SNAPSHOT_FILES) {
101
105
  * - Schema version doesn't match
102
106
  * - JSON parse fails
103
107
  *
104
- * The caller is responsible for staleness checks (comparing sourceSize
105
- * and readerPosition against the actual file).
108
+ * The caller is responsible for staleness checks against the actual file:
109
+ * `isSnapshotValid(snapshot, currentSize, sourcePath)`.
106
110
  */
107
111
  export function loadSnapshot(sessionId) {
108
112
  try {
@@ -142,13 +146,21 @@ export function deleteSnapshot(sessionId) {
142
146
  /**
143
147
  * Validates whether a snapshot is still usable for a given source file.
144
148
  *
145
- * For JSONL providers: checks that the file hasn't been truncated
146
- * (current size >= snapshot position) and that the size matches or has grown.
147
- *
148
149
  * For DB-backed providers: sourceSize is 0, so we always consider valid
149
150
  * (the DB reader's timestamp-based cursor handles staleness naturally).
151
+ *
152
+ * For JSONL providers: the file must not have shrunk below the snapshot
153
+ * position. With `sourcePath` the check is also content-aware: the snapshot
154
+ * must carry a {@link SessionSnapshot.sourceCheckpoint} for exactly
155
+ * `readerPosition`, and that checkpoint must still match the file at
156
+ * `sourcePath` (same device and inode, same first bytes, position just after
157
+ * a newline), so a file replaced by another one or rewritten to the same or
158
+ * a larger size is not resumed mid-content. A snapshot without a checkpoint
159
+ * (written before 0.29.1) is invalid when `sourcePath` is given, which costs
160
+ * one full replay. Without `sourcePath` only the size check applies, as it
161
+ * did before.
150
162
  */
151
- export function isSnapshotValid(snapshot, currentSourceSize) {
163
+ export function isSnapshotValid(snapshot, currentSourceSize, sourcePath) {
152
164
  // DB-backed providers store sourceSize=0 — always valid (cursor is a timestamp)
153
165
  if (snapshot.sourceSize === 0) {
154
166
  return true;
@@ -157,5 +169,15 @@ export function isSnapshotValid(snapshot, currentSourceSize) {
157
169
  if (currentSourceSize < snapshot.readerPosition) {
158
170
  return false;
159
171
  }
160
- return true;
172
+ if (typeof sourcePath !== 'string') {
173
+ return true;
174
+ }
175
+ // Snapshot content comes from disk: guard the shape before trusting it.
176
+ const checkpoint = snapshot.sourceCheckpoint;
177
+ if (!checkpoint ||
178
+ typeof checkpoint !== 'object' ||
179
+ checkpoint.offset !== snapshot.readerPosition) {
180
+ return false;
181
+ }
182
+ return verifyJsonlCheckpoint(sourcePath, checkpoint);
161
183
  }
@@ -17,6 +17,7 @@ export { addLocalDays, formatDurationMs, formatLocalDateKey, formatTokenCount, p
17
17
  export { parseMcpToolName } from './parsers/mcpToolName';
18
18
  export { buildSessionContextSnapshot, calculateSessionContextPressure, createSessionContextProjector, } from './context/sessionContext';
19
19
  export { ACCOUNT_PROVIDER_IDS, MODEL_PROVIDER_IDS, QUOTA_PROVIDER_IDS, RUNTIME_QUOTA_PROVIDER_IDS, SESSION_PROVIDER_IDS, } from './providerIds';
20
+ export { SUBAGENT_SPAWN_TOOL_NAMES, isSubagentSpawnTool, isTaskNotificationSystemEvent, parseTaskNotification, taskNotificationFromEvent, } from './subagentSpawn';
20
21
  export { assistantTurnEventsFromSessionEvents, extractTurnSubagents, isAssistantTurnSubagentTool, reasoningSummary, segmentAssistantTurn, } from './turns/assistantTurn';
21
22
  export { parseModelId, getModelPricing, resolveModelPricing, getModelInfo, calculateCost, calculateCostWithPricing, calculateCostWithProvenance, mergeCostSources, shortModelName, getModelDisplayInfo, compareModelIds, sortModelIds, formatCost, canonicalModelId, isMovingModelAlias, registerModelPricing, clearRegisteredModelPricing, getPricingCatalogInfo, costSourceFromPricingProvenance, mergeCostComponents, } from './modelInfo';
22
23
  export { exportResolvedModelCatalog, importResolvedModelCatalog, registerModelAlias, resolveModelAlias, RESOLVED_MODEL_CATALOG_SCHEMA_VERSION, } from './modelCatalog';
@@ -8,6 +8,7 @@ import { EventAggregator } from '../aggregation/EventAggregator';
8
8
  import { getModelContextWindowSize } from '../modelContext';
9
9
  import { parseMcpToolName } from '../parsers/mcpToolName';
10
10
  import { estimateTextTokens } from '../tokenEstimation';
11
+ import { isTaskNotificationSystemEvent } from '../subagentSpawn';
11
12
  const DEFAULT_CONTEXT_SOURCE_LIMIT = 200;
12
13
  const DEFAULT_SNIPPET_MAX_CHARS = 240;
13
14
  const DEFAULT_BODY_MAX_CHARS = 5000;
@@ -205,6 +206,9 @@ function extractSourcesFromEvent(event, state) {
205
206
  }
206
207
  }
207
208
  function extractSystemSource(event, state) {
209
+ // Background-task notices only update subagent lifecycles (like the aggregator).
210
+ if (isTaskNotificationSystemEvent(event))
211
+ return;
208
212
  const message = event.message;
209
213
  const text = extractText(message?.content);
210
214
  const label = message?.sourceLabel ?? message?.role ?? 'system';
@@ -7,6 +7,7 @@
7
7
  *
8
8
  * @module formatters/toolSummary
9
9
  */
10
+ import { ownValue } from '../ownKey';
10
11
  // ── Helpers ──
11
12
  function truncate(text, maxLen) {
12
13
  const clean = text.replace(/\s+/g, ' ').trim();
@@ -171,6 +172,8 @@ const TOOL_FORMATTERS = {
171
172
  Bash: formatBash,
172
173
  Grep: formatGrep,
173
174
  Glob: formatGlob,
175
+ // Claude Code's subagent tool; `Task` is its earlier name.
176
+ Agent: formatTask,
174
177
  Task: formatTask,
175
178
  WebFetch: formatWebFetch,
176
179
  WebSearch: formatWebSearch,
@@ -214,7 +217,7 @@ function genericFallback(input) {
214
217
  export function formatToolSummary(toolName, input) {
215
218
  // Strip MCP prefixes: "mcp__server__tool" → use "tool" for matching
216
219
  const baseName = toolName.includes('__') ? (toolName.split('__').pop() ?? toolName) : toolName;
217
- const formatter = TOOL_FORMATTERS[toolName] ?? TOOL_FORMATTERS[baseName];
220
+ const formatter = ownValue(TOOL_FORMATTERS, toolName) ?? ownValue(TOOL_FORMATTERS, baseName);
218
221
  if (formatter) {
219
222
  const result = formatter(input);
220
223
  if (result)
package/dist/esm/index.js CHANGED
@@ -87,6 +87,8 @@ export { FilterEngine } from './search/advancedFilter';
87
87
  // Context
88
88
  export { composeContext } from './context/composer';
89
89
  export { buildSessionContextSnapshot, calculateSessionContextPressure, createSessionContextProjector, readSessionContextSnapshot, } from './context/sessionContext';
90
+ // Subagent spawns (`Agent` / `Task`) and background-task notifications
91
+ export { SUBAGENT_SPAWN_TOOL_NAMES, isSubagentSpawnTool, isTaskNotificationSystemEvent, parseTaskNotification, taskNotificationFromEvent, } from './subagentSpawn';
90
92
  // Assistant turn projection
91
93
  export { assistantTurnEventsFromSessionEvents, extractTurnSubagents, isAssistantTurnSubagentTool, reasoningSummary, segmentAssistantTurn, } from './turns/assistantTurn';
92
94
  // Plan Extraction
@@ -95,7 +97,7 @@ export { PlanExtractor, parsePlanMarkdown as parsePlanMarkdownShared, extractPro
95
97
  export { parseChangelog } from './parsers/changelogParser';
96
98
  export { createWatcher, resolveSessionPath } from './watchers/factory';
97
99
  export { toFollowEvents } from './watchers/eventBridge';
98
- export { createJsonlTail, DEFAULT_JSONL_TAIL_MAX_LINE_BYTES, DEFAULT_JSONL_TAIL_READ_BYTES, JSONL_TAIL_CHECKPOINT_VERSION, JsonlTailSymlinkError, } from './watchers/jsonlTail';
100
+ export { captureJsonlCheckpoint, createJsonlTail, DEFAULT_JSONL_TAIL_MAX_LINE_BYTES, DEFAULT_JSONL_TAIL_READ_BYTES, JSONL_TAIL_CHECKPOINT_VERSION, JsonlTailSymlinkError, verifyJsonlCheckpoint, } from './watchers/jsonlTail';
99
101
  // Formatters
100
102
  export { addLocalDays, formatDurationMs, formatLocalDateKey, formatTokenCount, parseLocalDateKey, } from './formatting';
101
103
  export { formatToolSummary } from './formatters/toolSummary';
@@ -1,3 +1,4 @@
1
+ import { setOwnValue } from './ownKey';
1
2
  import { _getModelContextIds, _setImportedResolvedContextWindows, resolveModelContextWindow, } from './modelContext';
2
3
  import { _getModelPricingIds, _setImportedResolvedPricing, resolveModelPricing, } from './modelInfo';
3
4
  import { _getModelAliases, _importModelAliases, registerModelAlias, resolveModelAlias, } from './modelAliases';
@@ -24,13 +25,13 @@ export function exportResolvedModelCatalog(ids) {
24
25
  const selectedIds = normalizedIds(ids);
25
26
  for (const id of selectedIds) {
26
27
  const canonicalId = resolveModelAlias(id);
27
- models[id] = {
28
+ setOwnValue(models, id, {
28
29
  id,
29
30
  canonicalId,
30
31
  contextWindow: resolveModelContextWindow(id),
31
32
  // Registered prices are this realm's local configuration, never exported.
32
33
  pricing: resolveModelPricing(id, { includeRegistered: false }),
33
- };
34
+ });
34
35
  }
35
36
  const selectedCanonicalIds = new Set(Object.values(models).flatMap(({ id, canonicalId }) => [id, canonicalId]));
36
37
  const aliases = Object.fromEntries(Object.entries(_getModelAliases()).filter(([alias, canonicalId]) => selectedCanonicalIds.has(alias) || selectedCanonicalIds.has(canonicalId)));
@@ -75,9 +76,9 @@ export function importResolvedModelCatalog(snapshot) {
75
76
  if (!id)
76
77
  continue;
77
78
  if (contextResolved)
78
- contexts[id] = context;
79
+ setOwnValue(contexts, id, context);
79
80
  if (pricingResolved)
80
- prices[id] = pricing;
81
+ setOwnValue(prices, id, pricing);
81
82
  }
82
83
  imported++;
83
84
  }
@@ -28,6 +28,7 @@
28
28
  * webviews. Same pattern as `_setPricingOverrides` in `modelInfo.ts`.
29
29
  */
30
30
  import { resolveModelAlias } from './modelAliases';
31
+ import { ownValue, setOwnValue } from './ownKey';
31
32
  /** Known model context window sizes (in tokens); current models verified 2026-09-05. */
32
33
  const MODEL_CONTEXT_SIZES = {
33
34
  // Published API limits verified 2026-10-30; GLM's API documents 1M even
@@ -144,7 +145,7 @@ function normalizeOverrides(overrides) {
144
145
  for (const [key, value] of Object.entries(overrides)) {
145
146
  if (!Number.isFinite(value) || value <= 0)
146
147
  continue;
147
- out[key.trim().toLowerCase()] = value;
148
+ setOwnValue(out, key.trim().toLowerCase(), value);
148
149
  }
149
150
  return out;
150
151
  }
@@ -208,7 +209,7 @@ export function _getModelContextIds() {
208
209
  // ── Lookup ──
209
210
  /** Exact match against one table. */
210
211
  function exactWindow(table, modelId) {
211
- const hit = table[modelId];
212
+ const hit = ownValue(table, modelId);
212
213
  return hit === undefined ? null : hit;
213
214
  }
214
215
  function exactWindowMatch(table, modelId) {
@@ -256,7 +257,7 @@ export function resolveModelContextWindow(modelId) {
256
257
  // Imported resolutions rank below local observed data: a window this realm
257
258
  // saw first-hand is fresher than a snapshot transferred from another realm.
258
259
  if (!observedExact) {
259
- const imported = importedTable[stripped] ?? importedTable[canonicalModelId];
260
+ const imported = ownValue(importedTable, stripped) ?? ownValue(importedTable, canonicalModelId);
260
261
  if (imported) {
261
262
  return { ...imported, modelId: rawModelId, canonicalModelId };
262
263
  }
@@ -23,6 +23,7 @@
23
23
  */
24
24
  import { getModelContextWindowSize } from './modelContext';
25
25
  import { resolveModelAlias } from './modelAliases';
26
+ import { ownValue } from './ownKey';
26
27
  export { MODEL_PROVIDER_IDS } from './providerIds';
27
28
  export { formatCost } from './formatting';
28
29
  // ── Static Pricing Table ──
@@ -1078,11 +1079,11 @@ export function resolveModelPricing(modelId, options) {
1078
1079
  });
1079
1080
  if (registered && !registered.entry.onlyIfMissing)
1080
1081
  return registeredResult(registered);
1081
- const imported = importedTable[rawId] ?? importedTable[aliasId];
1082
+ const imported = ownValue(importedTable, rawId) ?? ownValue(importedTable, aliasId);
1082
1083
  if (imported)
1083
1084
  return { ...imported, modelId, canonicalModelId: aliasId };
1084
1085
  for (const id of candidateIds) {
1085
- const exactOverride = overrideTable[id];
1086
+ const exactOverride = ownValue(overrideTable, id);
1086
1087
  if (exactOverride) {
1087
1088
  return {
1088
1089
  modelId,
@@ -1098,7 +1099,7 @@ export function resolveModelPricing(modelId, options) {
1098
1099
  }
1099
1100
  }
1100
1101
  for (const id of candidateIds) {
1101
- const exactStatic = PRICING_TABLE[id];
1102
+ const exactStatic = ownValue(PRICING_TABLE, id);
1102
1103
  if (exactStatic) {
1103
1104
  return {
1104
1105
  modelId,
package/dist/esm/node.js CHANGED
@@ -49,4 +49,4 @@ export { classifyTool, TOOL_CATEGORIES } from './toolCategory';
49
49
  export { resolveWorkspaceRoots } from './workspaceRoots';
50
50
  export { discoverWorktreeSiblings, resolveWorktreeMainRepo } from './parsers/sessionPathResolver';
51
51
  // Bounded JSONL tail reader
52
- export { createJsonlTail, DEFAULT_JSONL_TAIL_MAX_LINE_BYTES, DEFAULT_JSONL_TAIL_READ_BYTES, JSONL_TAIL_CHECKPOINT_VERSION, JsonlTailSymlinkError, } from './watchers/jsonlTail';
52
+ export { captureJsonlCheckpoint, createJsonlTail, DEFAULT_JSONL_TAIL_MAX_LINE_BYTES, DEFAULT_JSONL_TAIL_READ_BYTES, JSONL_TAIL_CHECKPOINT_VERSION, JsonlTailSymlinkError, verifyJsonlCheckpoint, } from './watchers/jsonlTail';
@@ -32,6 +32,7 @@
32
32
  import { promises as fs } from 'node:fs';
33
33
  import * as path from 'node:path';
34
34
  import { _setObservedContextWindows, _getObservedContextWindows } from './modelContext';
35
+ import { setOwnValue } from './ownKey';
35
36
  import { getConfigDir } from './paths';
36
37
  import { updateJsonStoreAtomic } from './writers/atomic';
37
38
  // ── Constants ──
@@ -80,7 +81,7 @@ function toOverrideMap(store, now = Date.now()) {
80
81
  const observedAt = typeof entry.observedAt === 'string' ? Date.parse(entry.observedAt) : NaN;
81
82
  if (Number.isFinite(observedAt) && now - observedAt > OBSERVED_CONTEXT_WINDOW_TTL_MS)
82
83
  continue;
83
- out[modelId] = window;
84
+ setOwnValue(out, modelId, window);
84
85
  }
85
86
  return out;
86
87
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Own-property lookups for plain-object tables indexed by external strings.
3
+ *
4
+ * `table[key]` also finds inherited members, so a model id, tool name, or
5
+ * error code such as `constructor`, `toString`, or `__proto__` would return an
6
+ * `Object.prototype` member instead of `undefined`.
7
+ *
8
+ * @module ownKey
9
+ */
10
+ const hasOwn = Object.prototype.hasOwnProperty;
11
+ /** The value stored under `key` in `table` itself, or `undefined`. */
12
+ export function ownValue(table, key) {
13
+ return hasOwn.call(table, key) ? table[key] : undefined;
14
+ }
15
+ /**
16
+ * Store `value` as an own, enumerable property, even for `__proto__`, which a
17
+ * plain assignment would treat as a prototype change.
18
+ */
19
+ export function setOwnValue(table, key, value) {
20
+ Object.defineProperty(table, key, {
21
+ value,
22
+ enumerable: true,
23
+ writable: true,
24
+ configurable: true,
25
+ });
26
+ }