@phnx-labs/agents-cli 1.21.2 → 1.22.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 (102) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +32 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/computer-actions.d.ts +4 -0
  5. package/dist/commands/computer-actions.js +35 -0
  6. package/dist/commands/computer.js +4 -2
  7. package/dist/commands/exec.d.ts +27 -0
  8. package/dist/commands/exec.js +123 -6
  9. package/dist/commands/models.js +36 -1
  10. package/dist/commands/perf.d.ts +16 -0
  11. package/dist/commands/perf.js +11 -1
  12. package/dist/commands/projects.d.ts +11 -1
  13. package/dist/commands/projects.js +38 -4
  14. package/dist/commands/sessions-backfill.d.ts +32 -0
  15. package/dist/commands/sessions-backfill.js +186 -0
  16. package/dist/commands/sessions-picker.js +17 -2
  17. package/dist/commands/sessions.d.ts +22 -1
  18. package/dist/commands/sessions.js +331 -18
  19. package/dist/commands/teams.js +1 -1
  20. package/dist/commands/worktree.d.ts +3 -3
  21. package/dist/commands/worktree.js +35 -4
  22. package/dist/index.js +8 -0
  23. package/dist/lib/browser/service.js +13 -0
  24. package/dist/lib/computer/dispatch.d.ts +3 -1
  25. package/dist/lib/computer/dispatch.js +10 -2
  26. package/dist/lib/daemon.d.ts +5 -1
  27. package/dist/lib/daemon.js +63 -14
  28. package/dist/lib/devices/resolve-target.d.ts +6 -0
  29. package/dist/lib/devices/resolve-target.js +9 -3
  30. package/dist/lib/event-stream.d.ts +2 -0
  31. package/dist/lib/event-stream.js +3 -0
  32. package/dist/lib/events.d.ts +3 -1
  33. package/dist/lib/events.js +4 -2
  34. package/dist/lib/exec.js +39 -8
  35. package/dist/lib/git.d.ts +14 -0
  36. package/dist/lib/git.js +36 -0
  37. package/dist/lib/hooks/profile.js +1 -14
  38. package/dist/lib/hosts/dispatch.d.ts +12 -0
  39. package/dist/lib/hosts/dispatch.js +23 -6
  40. package/dist/lib/hosts/reconnect.d.ts +38 -0
  41. package/dist/lib/hosts/reconnect.js +85 -4
  42. package/dist/lib/hosts/run-target.js +14 -2
  43. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  44. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  45. package/dist/lib/model-tiers.d.ts +54 -0
  46. package/dist/lib/model-tiers.js +229 -0
  47. package/dist/lib/models.d.ts +3 -0
  48. package/dist/lib/models.js +44 -7
  49. package/dist/lib/percentile.d.ts +12 -0
  50. package/dist/lib/percentile.js +24 -0
  51. package/dist/lib/perf/db.d.ts +1 -2
  52. package/dist/lib/perf/db.js +2 -14
  53. package/dist/lib/plugins.js +12 -1
  54. package/dist/lib/pricing/prices.json +16 -1
  55. package/dist/lib/project-focus.d.ts +42 -0
  56. package/dist/lib/project-focus.js +80 -0
  57. package/dist/lib/project-schedule.d.ts +75 -0
  58. package/dist/lib/project-schedule.js +110 -0
  59. package/dist/lib/redact.d.ts +2 -0
  60. package/dist/lib/redact.js +22 -0
  61. package/dist/lib/remote-agents-json.d.ts +2 -0
  62. package/dist/lib/remote-agents-json.js +3 -3
  63. package/dist/lib/resources.d.ts +16 -0
  64. package/dist/lib/resources.js +25 -14
  65. package/dist/lib/rotate.d.ts +84 -1
  66. package/dist/lib/rotate.js +155 -5
  67. package/dist/lib/routines.js +1 -14
  68. package/dist/lib/runner.d.ts +4 -2
  69. package/dist/lib/runner.js +21 -5
  70. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  71. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  72. package/dist/lib/session/bash-command.js +60 -9
  73. package/dist/lib/session/db.d.ts +22 -1
  74. package/dist/lib/session/db.js +516 -24
  75. package/dist/lib/session/discover.d.ts +68 -7
  76. package/dist/lib/session/discover.js +186 -84
  77. package/dist/lib/session/highlights.d.ts +24 -4
  78. package/dist/lib/session/highlights.js +52 -7
  79. package/dist/lib/session/parse.d.ts +8 -1
  80. package/dist/lib/session/parse.js +102 -35
  81. package/dist/lib/session/prompt.d.ts +19 -0
  82. package/dist/lib/session/prompt.js +43 -0
  83. package/dist/lib/session/remote-list.d.ts +71 -0
  84. package/dist/lib/session/remote-list.js +410 -2
  85. package/dist/lib/session/shell-programs.d.ts +15 -0
  86. package/dist/lib/session/shell-programs.js +359 -0
  87. package/dist/lib/session/tool-calls.d.ts +88 -0
  88. package/dist/lib/session/tool-calls.js +612 -0
  89. package/dist/lib/session/tool-index.d.ts +100 -0
  90. package/dist/lib/session/tool-index.js +773 -0
  91. package/dist/lib/session/tool-store.d.ts +15 -0
  92. package/dist/lib/session/tool-store.js +198 -0
  93. package/dist/lib/session/types.d.ts +49 -0
  94. package/dist/lib/state.d.ts +10 -1
  95. package/dist/lib/state.js +11 -2
  96. package/dist/lib/teams/remoteWorktree.d.ts +3 -4
  97. package/dist/lib/teams/remoteWorktree.js +3 -4
  98. package/dist/lib/teams/worktree.d.ts +11 -1
  99. package/dist/lib/teams/worktree.js +42 -4
  100. package/dist/lib/types.d.ts +31 -0
  101. package/dist/lib/types.js +17 -0
  102. package/package.json +3 -1
@@ -23,7 +23,8 @@ import { walkForFilesWithStat } from '../fs-walk.js';
23
23
  import { getConfigSymlinkVersion } from '../shims.js';
24
24
  import { SESSION_AGENTS } from './types.js';
25
25
  import { deriveShortId } from './short-id.js';
26
- import { extractSessionTopic } from './prompt.js';
26
+ import { extractSessionTopic, extractSlashCommandName, extractSlashCommandFromToolInput } from './prompt.js';
27
+ import { isSkillInvocation, extractSkills, extractSlashCommands } from './highlights.js';
27
28
  import { parseAntigravity, parseCursor } from './parse.js';
28
29
  import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand, detectSpawnedTeam, isTicketCreateTool, extractCreatedTicket, extractRecentDirectoriesTouched, extractTodoProgressFromEvents } from './state.js';
29
30
  import { costOfUsage } from '../pricing/index.js';
@@ -33,6 +34,8 @@ import { mapBounded } from '../concurrency.js';
33
34
  import { getDB, getScanStampByPath, getScanStampsForPaths, getParserStatesForPaths, getDirLedgerForPaths, recordDirScans, recordScans, syncLabels, seedLabelsFromNames, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, cacheLinearProject, } from './db.js';
34
35
  import { buildRunNameMap } from './run-names.js';
35
36
  import { resolveLinearApiKey } from '../auto-dispatch-linear.js';
37
+ import { ToolCallCollector, collectClaudeToolCalls, collectCodexToolCalls, } from './tool-calls.js';
38
+ import { purgeMissingToolCallsInDirectory } from './tool-store.js';
36
39
  const HOME = os.homedir();
37
40
  // Versions can live under either repo: the user repo (current canonical
38
41
  // location, ~/.agents/.history/versions/) or the system repo (legacy / npm-shipped,
@@ -46,6 +49,73 @@ const HERMES_SESSIONS_DIR = path.join(HOME, '.hermes', 'sessions');
46
49
  /** How long OpenClaw channel/cron snapshots stay valid before we re-shell-out. */
47
50
  const OPENCLAW_TTL_MS = 60_000;
48
51
  const ACTIVE_APPEND_RESCAN_DEBOUNCE_MS = 5_000;
52
+ /** One JSONL record may not force an unbounded string allocation. */
53
+ export const SESSION_JSONL_LINE_MAX_BYTES = 1024 * 1024;
54
+ /**
55
+ * Stream one appended JSONL range, applying only newline-terminated records.
56
+ * Memory is bounded to one record. An ordinary unterminated tail remains for the
57
+ * next scan. Once a record exceeds the cap, its consumed offset and a one-bit
58
+ * dropping state advance together so later scans never reread the growing tail.
59
+ */
60
+ async function applyJsonlAppend(filePath, fromOffset, wasDroppingOversizedLine, apply) {
61
+ let pending = Buffer.alloc(0);
62
+ let droppingOversizedLine = wasDroppingOversizedLine;
63
+ let bytesBeforeChunk = 0;
64
+ let consumedBytes = 0;
65
+ let skippedOversizedLine = false;
66
+ const stream = fs.createReadStream(filePath, { start: fromOffset });
67
+ try {
68
+ for await (const rawChunk of stream) {
69
+ const chunk = typeof rawChunk === 'string' ? Buffer.from(rawChunk, 'utf-8') : rawChunk;
70
+ let cursor = 0;
71
+ for (;;) {
72
+ const newline = chunk.indexOf(0x0a, cursor);
73
+ if (newline === -1) {
74
+ if (!droppingOversizedLine) {
75
+ const tail = chunk.subarray(cursor);
76
+ if (pending.length + tail.length > SESSION_JSONL_LINE_MAX_BYTES) {
77
+ pending = Buffer.alloc(0);
78
+ droppingOversizedLine = true;
79
+ skippedOversizedLine = true;
80
+ }
81
+ else if (tail.length > 0) {
82
+ pending = pending.length > 0 ? Buffer.concat([pending, tail]) : Buffer.from(tail);
83
+ }
84
+ }
85
+ if (droppingOversizedLine)
86
+ consumedBytes = bytesBeforeChunk + chunk.length;
87
+ break;
88
+ }
89
+ if (!droppingOversizedLine) {
90
+ const segment = chunk.subarray(cursor, newline);
91
+ if (pending.length + segment.length <= SESSION_JSONL_LINE_MAX_BYTES) {
92
+ const line = (pending.length > 0 ? Buffer.concat([pending, segment]) : segment).toString('utf-8');
93
+ if (line.trim()) {
94
+ try {
95
+ apply(JSON.parse(line));
96
+ }
97
+ catch {
98
+ // One malformed line never aborts the session scan.
99
+ }
100
+ }
101
+ }
102
+ else {
103
+ skippedOversizedLine = true;
104
+ }
105
+ }
106
+ pending = Buffer.alloc(0);
107
+ droppingOversizedLine = false;
108
+ consumedBytes = bytesBeforeChunk + newline + 1;
109
+ cursor = newline + 1;
110
+ }
111
+ bytesBeforeChunk += chunk.length;
112
+ }
113
+ }
114
+ finally {
115
+ stream.destroy();
116
+ }
117
+ return { consumedBytes, droppingOversizedLine, skippedOversizedLine };
118
+ }
49
119
  /**
50
120
  * How recently a file must have been scanned to be treated as "hot" — a
51
121
  * candidate for an in-place append even when its parent dir's mtime hasn't
@@ -101,8 +171,17 @@ export async function discoverSessions(options) {
101
171
  releaseScan(process.pid);
102
172
  }
103
173
  }
104
- const sessions = querySessions(buildQueryOptions(options, agents, { includeLimit: true }));
105
- await resolveLinearProjects(sessions);
174
+ return queryIndexedSessions(options, {
175
+ skipExistenceCheck: options?.skipExistenceCheck ?? false,
176
+ });
177
+ }
178
+ /** Read the current SQLite snapshot without scanning or parsing transcript files. */
179
+ export async function queryIndexedSessions(options, indexedOptions = {}) {
180
+ getDB();
181
+ const agents = options?.agent ? [options.agent] : SESSION_AGENTS;
182
+ const sessions = querySessions(buildQueryOptions({ ...options, skipExistenceCheck: indexedOptions.skipExistenceCheck ?? true }, agents, { includeLimit: true }));
183
+ if (indexedOptions.resolveLinear !== false)
184
+ await resolveLinearProjects(sessions);
106
185
  for (const s of sessions)
107
186
  s.machine = machineForSessionFile(s.filePath, s.agent);
108
187
  return scopeToManaged(sessions, agents, options);
@@ -155,7 +234,7 @@ async function resolveLinearProjects(sessions) {
155
234
  * An agent with no managed versions is left alone entirely — someone who has never
156
235
  * run `agents add` sees exactly what they saw before.
157
236
  */
158
- function scopeToManaged(sessions, agents, options) {
237
+ export function scopeToManaged(sessions, agents, options) {
159
238
  if (options?.includeUnmanaged)
160
239
  return sessions;
161
240
  if (!anyManagedVersions())
@@ -305,11 +384,14 @@ function buildQueryOptions(options, agents, opts) {
305
384
  project: projectQuery,
306
385
  sinceMs,
307
386
  untilMs: Number.isFinite(untilMs) ? untilMs : undefined,
308
- limit: opts.includeLimit ? (options?.limit ?? 50) : undefined,
387
+ limit: opts.includeLimit && !options?.unbounded ? (options?.limit ?? 50) : undefined,
309
388
  excludeTeamOrigin: options?.excludeTeamOrigin,
310
389
  onlyTeamOrigin: options?.onlyTeamOrigin,
311
390
  origin: options?.origin,
312
391
  sortBy: options?.sortBy,
392
+ skipExistenceCheck: options?.skipExistenceCheck,
393
+ skill: options?.skill,
394
+ plugin: options?.plugin,
313
395
  };
314
396
  }
315
397
  /**
@@ -579,6 +661,7 @@ export function collectChangedFilesInLeafDirs(leafDirs, ext) {
579
661
  continue;
580
662
  toCompare.push({ filePath, fileMtimeMs: stat.mtimeMs, fileSize: stat.size });
581
663
  }
664
+ purgeMissingToolCallsInDirectory(getDB(), dirPath, files);
582
665
  if (!disabled)
583
666
  dirScansToRecord.push({ dirPath, dirMtimeMs, entryCount });
584
667
  }
@@ -786,7 +869,13 @@ async function scanRoutineArchivesIncremental(agent, onProgress) {
786
869
  try {
787
870
  const result = await readRoutineArchiveMeta(agent, filePath);
788
871
  if (result)
789
- entries.push({ meta: result.meta, content: result.content, scan });
872
+ entries.push({
873
+ meta: result.meta,
874
+ content: result.content,
875
+ scan,
876
+ toolCalls: result.toolCalls,
877
+ toolIndexMode: result.toolIndexMode,
878
+ });
790
879
  else
791
880
  touched.push({ filePath, scan });
792
881
  }
@@ -961,6 +1050,8 @@ async function scanClaudeIncremental(onProgress) {
961
1050
  scan,
962
1051
  parserState: result.parserState,
963
1052
  contentText: result.contentText,
1053
+ toolCalls: result.toolCalls,
1054
+ toolIndexMode: result.toolIndexMode,
964
1055
  });
965
1056
  }
966
1057
  else {
@@ -990,7 +1081,7 @@ async function scanClaudeIncremental(onProgress) {
990
1081
  */
991
1082
  async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, account, label) {
992
1083
  const prior = parsePriorClaudeState(priorRow);
993
- const { scan, newState, mode } = await scanClaudeSessionResumable(filePath, prior, scanStamp.fileMtimeMs, scanStamp.fileSize, priorRow?.fileMtimeMs);
1084
+ const { scan, newState, toolCalls, mode } = await scanClaudeSessionResumable(filePath, prior, scanStamp.fileMtimeMs, scanStamp.fileSize, priorRow?.fileMtimeMs);
994
1085
  if (mode === 'incremental')
995
1086
  claudeIncrementalScanCount++;
996
1087
  else
@@ -1030,6 +1121,8 @@ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, account,
1030
1121
  plan: scan.plan,
1031
1122
  todos: scan.todos,
1032
1123
  recentDirectoriesTouched: scan.recentDirectoriesTouched,
1124
+ skillsUsed: scan.skillsUsed,
1125
+ slashCommandsUsed: scan.slashCommandsUsed,
1033
1126
  };
1034
1127
  }
1035
1128
  else {
@@ -1061,6 +1154,8 @@ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, account,
1061
1154
  plan: scan.plan,
1062
1155
  todos: scan.todos,
1063
1156
  recentDirectoriesTouched: scan.recentDirectoriesTouched,
1157
+ skillsUsed: scan.skillsUsed,
1158
+ slashCommandsUsed: scan.slashCommandsUsed,
1064
1159
  };
1065
1160
  }
1066
1161
  return {
@@ -1071,6 +1166,8 @@ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, account,
1071
1166
  // doc, cached so the resume can hydrate userTexts without re-reading the file.
1072
1167
  parserState: JSON.stringify(newState),
1073
1168
  contentText: newState.contentText,
1169
+ toolCalls,
1170
+ toolIndexMode: mode === 'full' ? 'replace' : 'append',
1074
1171
  };
1075
1172
  }
1076
1173
  // ---------------------------------------------------------------------------
@@ -1208,6 +1305,8 @@ async function scanCodexIncremental(onProgress) {
1208
1305
  scan,
1209
1306
  parserState: result.parserState,
1210
1307
  contentText: result.contentText,
1308
+ toolCalls: result.toolCalls,
1309
+ toolIndexMode: result.toolIndexMode,
1211
1310
  });
1212
1311
  }
1213
1312
  else {
@@ -1310,6 +1409,8 @@ export async function readCodexMeta(filePath, resolveAccount, currentVersion, sc
1310
1409
  let scan;
1311
1410
  let newState;
1312
1411
  let newOffset = 0;
1412
+ let toolCalls;
1413
+ let toolIndexMode = 'replace';
1313
1414
  if (scanStamp) {
1314
1415
  const prior = parsePriorCodexState(priorRow);
1315
1416
  const result = await scanCodexSessionResumable(filePath, prior, scanStamp.fileMtimeMs, scanStamp.fileSize, priorRow?.fileMtimeMs);
@@ -1320,6 +1421,8 @@ export async function readCodexMeta(filePath, resolveAccount, currentVersion, sc
1320
1421
  scan = result.scan;
1321
1422
  newState = result.newState;
1322
1423
  newOffset = result.newOffset;
1424
+ toolCalls = result.toolCalls;
1425
+ toolIndexMode = result.mode === 'full' ? 'replace' : 'append';
1323
1426
  }
1324
1427
  else {
1325
1428
  scan = await scanCodexSession(filePath);
@@ -1366,6 +1469,8 @@ export async function readCodexMeta(filePath, resolveAccount, currentVersion, sc
1366
1469
  // doc for the resume's hydrate. Absent when no stamp was supplied.
1367
1470
  parserState: newState ? JSON.stringify(newState) : undefined,
1368
1471
  contentText: newState?.contentText,
1472
+ toolCalls,
1473
+ toolIndexMode,
1369
1474
  };
1370
1475
  }
1371
1476
  /**
@@ -2446,6 +2551,9 @@ export function initClaudeParseState() {
2446
2551
  plan: undefined,
2447
2552
  checklistEvents: [],
2448
2553
  recentDirectoriesTouched: [],
2554
+ toolCollector: new ToolCallCollector(),
2555
+ skillEvents: [],
2556
+ slashCommandEvents: [],
2449
2557
  };
2450
2558
  }
2451
2559
  const CHECKLIST_TOOLS = new Set(['TodoWrite', 'todo_write', 'update_plan', 'TaskCreate', 'TaskUpdate']);
@@ -2453,6 +2561,16 @@ const DIRECTORY_TOOLS = new Set(['Edit', 'Write', 'edit_file', 'write_file', 'cr
2453
2561
  function foldDerivedToolState(state, event) {
2454
2562
  if (CHECKLIST_TOOLS.has(event.tool ?? ''))
2455
2563
  state.checklistEvents.push(event);
2564
+ // #12: skill/slash-command usage, held here instead of re-parsed later — the
2565
+ // same reason checklistEvents is folded incrementally rather than recomputed
2566
+ // from a full re-parse (see session/db.ts's writeResourceUsage doc comment).
2567
+ if (isSkillInvocation(event))
2568
+ state.skillEvents.push(event);
2569
+ if (event.tool === 'SlashCommand') {
2570
+ const slashCommand = extractSlashCommandFromToolInput(event.args);
2571
+ if (slashCommand)
2572
+ state.slashCommandEvents.push({ ...event, slashCommand });
2573
+ }
2456
2574
  if (!DIRECTORY_TOOLS.has(event.tool ?? ''))
2457
2575
  return;
2458
2576
  const next = extractRecentDirectoriesTouched([event], state.cwd);
@@ -2472,6 +2590,7 @@ function foldDerivedToolState(state, event) {
2472
2590
  * malformed-line skip happens in the caller, as before).
2473
2591
  */
2474
2592
  export function applyClaudeLine(state, parsed) {
2593
+ collectClaudeToolCalls(state.toolCollector, parsed);
2475
2594
  // entrypoint ships on the first envelope event (attachment/user/assistant)
2476
2595
  // and is the clean structural signal for "was this a team spawn?"
2477
2596
  if (!state.entrypoint && typeof parsed.entrypoint === 'string') {
@@ -2582,6 +2701,15 @@ export function applyClaudeLine(state, parsed) {
2582
2701
  state.userTexts.push(text);
2583
2702
  if (!state.topic)
2584
2703
  state.topic = extractSessionTopic(text);
2704
+ // #12: the USER typing a slash command — Claude injects a <command-name>
2705
+ // wrapper as the message content (extractClaudeUserText returns it
2706
+ // un-stripped; isLocalCommandMessage only filters bash-echo wrappers).
2707
+ const slashCommand = extractSlashCommandName(text);
2708
+ if (slashCommand) {
2709
+ state.slashCommandEvents.push({
2710
+ type: 'message', agent: 'claude', timestamp: parsed.timestamp || '', role: 'user', content: text, slashCommand,
2711
+ });
2712
+ }
2585
2713
  }
2586
2714
  return;
2587
2715
  }
@@ -2662,6 +2790,8 @@ export function finalizeClaudeScan(state) {
2662
2790
  plan: state.plan,
2663
2791
  todos: extractTodoProgressFromEvents(state.checklistEvents),
2664
2792
  recentDirectoriesTouched: state.recentDirectoriesTouched.length ? state.recentDirectoriesTouched : undefined,
2793
+ skillsUsed: state.skillEvents.length ? extractSkills(state.skillEvents) : undefined,
2794
+ slashCommandsUsed: state.slashCommandEvents.length ? extractSlashCommands(state.slashCommandEvents) : undefined,
2665
2795
  };
2666
2796
  }
2667
2797
  /** Stream a Claude JSONL file and extract scan-level metadata (timestamp, cwd, topic, tokens). */
@@ -2696,15 +2826,16 @@ const SEEN_IDS_RECENT_CAP = 256;
2696
2826
  * `offset` bytes consumed. Round-trips through {@link hydrateClaudeParseState}
2697
2827
  * so incremental replay equals a full parse.
2698
2828
  */
2699
- export function serializeClaudeParserState(state, offset) {
2829
+ export function serializeClaudeParserState(state, offset, jsonlDroppingOversizedLine = false) {
2700
2830
  const allIds = [...state.seenAssistantIds];
2701
2831
  const seenIdsRecent = allIds.length > SEEN_IDS_RECENT_CAP
2702
2832
  ? allIds.slice(allIds.length - SEEN_IDS_RECENT_CAP)
2703
2833
  : allIds;
2704
2834
  const ticket = detectTicket(state.userTexts.join('\n') || undefined, state.gitBranch);
2705
2835
  return {
2706
- v: 1,
2836
+ v: 2,
2707
2837
  offset,
2838
+ jsonlDroppingOversizedLine: jsonlDroppingOversizedLine || undefined,
2708
2839
  timestamp: state.timestamp,
2709
2840
  cwd: state.cwd,
2710
2841
  gitBranch: state.gitBranch,
@@ -2740,6 +2871,9 @@ export function serializeClaudeParserState(state, offset) {
2740
2871
  contentText: state.userTexts.length > 0 ? state.userTexts.join('\n') : undefined,
2741
2872
  checklistEvents: state.checklistEvents,
2742
2873
  recentDirectoriesTouched: state.recentDirectoriesTouched,
2874
+ toolCalls: state.toolCollector.snapshot(),
2875
+ skillEvents: state.skillEvents,
2876
+ slashCommandEvents: state.slashCommandEvents,
2743
2877
  };
2744
2878
  }
2745
2879
  /**
@@ -2796,6 +2930,9 @@ export function hydrateClaudeParseState(prior) {
2796
2930
  plan: prior.plan,
2797
2931
  checklistEvents: prior.checklistEvents ?? [],
2798
2932
  recentDirectoriesTouched: prior.recentDirectoriesTouched ?? [],
2933
+ toolCollector: new ToolCallCollector(prior.toolCalls),
2934
+ skillEvents: prior.skillEvents ?? [],
2935
+ slashCommandEvents: prior.slashCommandEvents ?? [],
2799
2936
  };
2800
2937
  }
2801
2938
  /**
@@ -2819,45 +2956,20 @@ export async function scanClaudeSessionIncremental(filePath, fromOffset, prior)
2819
2956
  // have no dedup, unlike assistant `seenAssistantIds`). A record written
2820
2957
  // non-atomically — bytes first, then '\n' in a second write — is exactly this
2821
2958
  // case. So we slice at the last '\n' ourselves: everything up to and including
2822
- // it is a run of complete lines we apply and commit; any tail after it
2823
- // (syntactically broken OR complete-but-not-yet-terminated) is a still-being-
2824
- // written record we DEFER to the next pass, once its '\n' lands.
2825
- const chunks = [];
2826
- const stream = fs.createReadStream(filePath, { start: fromOffset });
2827
- try {
2828
- for await (const chunk of stream) {
2829
- chunks.push(typeof chunk === 'string' ? Buffer.from(chunk, 'utf-8') : chunk);
2830
- }
2831
- }
2832
- finally {
2833
- stream.destroy();
2834
- }
2835
- const appended = Buffer.concat(chunks);
2836
- // Bytes up to AND INCLUDING the last '\n' are the committed, complete-line run.
2837
- const lastNl = appended.lastIndexOf(0x0a);
2838
- const consumedBytes = lastNl === -1 ? 0 : lastNl + 1;
2839
- if (consumedBytes > 0) {
2840
- // split('\n') on the committed run: the element after the final '\n' is ''
2841
- // (skipped by the trim guard). A stray '\r' from CRLF is tolerated by the
2842
- // JSON.parse below exactly as the full parse tolerates it.
2843
- for (const line of appended.subarray(0, consumedBytes).toString('utf-8').split('\n')) {
2844
- if (!line.trim())
2845
- continue;
2846
- let parsed;
2847
- try {
2848
- parsed = JSON.parse(line);
2849
- }
2850
- catch {
2851
- continue;
2852
- }
2853
- applyClaudeLine(state, parsed);
2854
- }
2855
- }
2856
- const newOffset = fromOffset + consumedBytes;
2959
+ // it is a run of complete lines we apply and commit. An ordinary tail after
2960
+ // it is deferred until its '\n' lands; after a tail exceeds 1 MiB, its offset
2961
+ // advances with a persisted discard-until-newline bit.
2962
+ const append = await applyJsonlAppend(filePath, fromOffset, prior.jsonlDroppingOversizedLine === true, (parsed) => applyClaudeLine(state, parsed));
2963
+ if (append.skippedOversizedLine)
2964
+ state.toolCollector.recordIndexLimit();
2965
+ const newOffset = fromOffset + append.consumedBytes;
2966
+ const scan = finalizeClaudeScan(state);
2967
+ const toolCalls = state.toolCollector.drainChanged();
2857
2968
  return {
2858
- scan: finalizeClaudeScan(state),
2859
- newState: serializeClaudeParserState(state, newOffset),
2969
+ scan,
2970
+ newState: serializeClaudeParserState(state, newOffset, append.droppingOversizedLine),
2860
2971
  newOffset,
2972
+ toolCalls,
2861
2973
  };
2862
2974
  }
2863
2975
  /** Serialized zero-value continuation: a fresh accumulator at offset 0, used to drive a FULL parse from the start through the same resumable path. */
@@ -2952,7 +3064,7 @@ function parsePriorClaudeState(row) {
2952
3064
  return null;
2953
3065
  try {
2954
3066
  const parsed = JSON.parse(row.parserState);
2955
- if (parsed?.v !== 1 || typeof parsed.offset !== 'number')
3067
+ if (parsed?.v !== 2 || typeof parsed.offset !== 'number' || parsed.toolCalls?.v !== 1)
2956
3068
  return null;
2957
3069
  return parsed;
2958
3070
  }
@@ -2997,6 +3109,9 @@ export function initCodexParseState() {
2997
3109
  spawnedTeam: undefined,
2998
3110
  checklistEvents: [],
2999
3111
  recentDirectoriesTouched: [],
3112
+ toolCollector: new ToolCallCollector(),
3113
+ skillEvents: [],
3114
+ slashCommandEvents: [],
3000
3115
  };
3001
3116
  }
3002
3117
  /**
@@ -3006,6 +3121,7 @@ export function initCodexParseState() {
3006
3121
  * malformed-line skip happens in the caller, as before).
3007
3122
  */
3008
3123
  export function applyCodexLine(state, parsed) {
3124
+ collectCodexToolCalls(state.toolCollector, parsed);
3009
3125
  // PR signal, structurally: a Codex `function_call` whose command is
3010
3126
  // `gh pr create`, then the pull URL from a `function_call_output`.
3011
3127
  if (parsed.type === 'response_item') {
@@ -3178,11 +3294,12 @@ async function scanCodexSession(filePath) {
3178
3294
  * bytes consumed. Round-trips through {@link hydrateCodexParseState} so
3179
3295
  * incremental replay equals a full parse.
3180
3296
  */
3181
- export function serializeCodexParserState(state, offset) {
3297
+ export function serializeCodexParserState(state, offset, jsonlDroppingOversizedLine = false) {
3182
3298
  const ticket = detectTicket(state.userTexts.join('\n') || undefined, state.gitBranch);
3183
3299
  return {
3184
- v: 1,
3300
+ v: 2,
3185
3301
  offset,
3302
+ jsonlDroppingOversizedLine: jsonlDroppingOversizedLine || undefined,
3186
3303
  sessionId: state.sessionId,
3187
3304
  timestamp: state.timestamp,
3188
3305
  cwd: state.cwd,
@@ -3209,6 +3326,7 @@ export function serializeCodexParserState(state, offset) {
3209
3326
  contentText: state.userTexts.length > 0 ? state.userTexts.join('\n') : undefined,
3210
3327
  checklistEvents: state.checklistEvents,
3211
3328
  recentDirectoriesTouched: state.recentDirectoriesTouched,
3329
+ toolCalls: state.toolCollector.snapshot(),
3212
3330
  };
3213
3331
  }
3214
3332
  /**
@@ -3244,6 +3362,12 @@ export function hydrateCodexParseState(prior) {
3244
3362
  spawnedTeam: prior.spawnedTeam,
3245
3363
  checklistEvents: prior.checklistEvents ?? [],
3246
3364
  recentDirectoriesTouched: prior.recentDirectoriesTouched ?? [],
3365
+ toolCollector: new ToolCallCollector(prior.toolCalls),
3366
+ // Not persisted in CodexParserState (always empty for Codex today — see
3367
+ // CodexParseState.skillEvents) — a resume starts fresh rather than
3368
+ // round-tripping an always-empty array through the continuation blob.
3369
+ skillEvents: [],
3370
+ slashCommandEvents: [],
3247
3371
  };
3248
3372
  }
3249
3373
  /**
@@ -3253,47 +3377,25 @@ export function hydrateCodexParseState(prior) {
3253
3377
  *
3254
3378
  * Same trailing-line discipline as {@link scanClaudeSessionIncremental}: apply
3255
3379
  * ONLY the run of newline-terminated lines (slice at the last `'\n'`), and set
3256
- * `newOffset = fromOffset + consumedBytes`. Any tail after the last `'\n'` —
3257
- * syntactically broken OR a complete-but-not-yet-terminated record — is DEFERRED
3258
- * to the next pass once its `'\n'` lands. Codex `messageCount` is additive with
3380
+ * `newOffset = fromOffset + consumedBytes`. An ordinary tail after the last
3381
+ * `'\n'` is deferred; an oversized one advances under a persisted discard bit.
3382
+ * Codex `messageCount` is additive with
3259
3383
  * NO dedup, so re-reading a still-unterminated complete line would double-count
3260
3384
  * it; deferring prevents that (the bug class prix-cloud caught for Claude).
3261
3385
  */
3262
3386
  export async function scanCodexSessionIncremental(filePath, fromOffset, prior) {
3263
3387
  const state = hydrateCodexParseState(prior);
3264
- const chunks = [];
3265
- const stream = fs.createReadStream(filePath, { start: fromOffset });
3266
- try {
3267
- for await (const chunk of stream) {
3268
- chunks.push(typeof chunk === 'string' ? Buffer.from(chunk, 'utf-8') : chunk);
3269
- }
3270
- }
3271
- finally {
3272
- stream.destroy();
3273
- }
3274
- const appended = Buffer.concat(chunks);
3275
- // Bytes up to AND INCLUDING the last '\n' are the committed, complete-line run.
3276
- const lastNl = appended.lastIndexOf(0x0a);
3277
- const consumedBytes = lastNl === -1 ? 0 : lastNl + 1;
3278
- if (consumedBytes > 0) {
3279
- for (const line of appended.subarray(0, consumedBytes).toString('utf-8').split('\n')) {
3280
- if (!line.trim())
3281
- continue;
3282
- let parsed;
3283
- try {
3284
- parsed = JSON.parse(line);
3285
- }
3286
- catch {
3287
- continue;
3288
- }
3289
- applyCodexLine(state, parsed);
3290
- }
3291
- }
3292
- const newOffset = fromOffset + consumedBytes;
3388
+ const append = await applyJsonlAppend(filePath, fromOffset, prior.jsonlDroppingOversizedLine === true, (parsed) => applyCodexLine(state, parsed));
3389
+ if (append.skippedOversizedLine)
3390
+ state.toolCollector.recordIndexLimit();
3391
+ const newOffset = fromOffset + append.consumedBytes;
3392
+ const scan = finalizeCodexScan(state);
3393
+ const toolCalls = state.toolCollector.drainChanged();
3293
3394
  return {
3294
- scan: finalizeCodexScan(state),
3295
- newState: serializeCodexParserState(state, newOffset),
3395
+ scan,
3396
+ newState: serializeCodexParserState(state, newOffset, append.droppingOversizedLine),
3296
3397
  newOffset,
3398
+ toolCalls,
3297
3399
  };
3298
3400
  }
3299
3401
  /** Serialized zero-value continuation: a fresh accumulator at offset 0, used to drive a FULL parse from the start through the same resumable path. */
@@ -3382,7 +3484,7 @@ function parsePriorCodexState(row) {
3382
3484
  return null;
3383
3485
  try {
3384
3486
  const parsed = JSON.parse(row.parserState);
3385
- if (parsed?.v !== 1 || typeof parsed.offset !== 'number')
3487
+ if (parsed?.v !== 2 || typeof parsed.offset !== 'number' || parsed.toolCalls?.v !== 1)
3386
3488
  return null;
3387
3489
  return parsed;
3388
3490
  }
@@ -17,12 +17,32 @@ export interface SkillUse {
17
17
  count: number;
18
18
  }
19
19
  /**
20
- * Skills invoked during the session, from `Skill` tool calls. Claude and Kimi
21
- * both name the tool `Skill` and carry the skill id in `args.skill` (plugin
22
- * skills surface here too — a plugin-provided skill is invoked through the
23
- * same tool). Sorted by count desc, then name.
20
+ * True for a tool_use event that is that harness's skill-invocation call (see
21
+ * {@link SKILL_TOOL_NAME_BY_AGENT}). Exported so an INCREMENTAL parser
22
+ * (discover.ts's ClaudeParseState/foldDerivedToolState) can select matching
23
+ * events into a small held array as it streams, then run {@link extractSkills}
24
+ * over just that subset at finalize — without re-parsing the whole transcript.
25
+ */
26
+ export declare function isSkillInvocation(e: SessionEvent): boolean;
27
+ /**
28
+ * Skills invoked during the session, from that harness's skill-invocation
29
+ * tool calls (see {@link SKILL_TOOL_NAME_BY_AGENT}). Carries the skill id in
30
+ * `args.skill` (or `args.name`). Sorted by count desc, then name.
24
31
  */
25
32
  export declare function extractSkills(events: SessionEvent[]): SkillUse[];
33
+ export interface SlashCommandUse {
34
+ /** WITH the leading slash, e.g. `/recap`, `/code:commit` — matches SessionEvent.slashCommand's shape. */
35
+ name: string;
36
+ count: number;
37
+ }
38
+ /**
39
+ * Slash commands invoked during the session — either the user typing one
40
+ * (Claude's `<command-name>` wrapper) or the model invoking one via the
41
+ * `SlashCommand` tool (`SessionEvent.slashCommand`, populated by
42
+ * `parseClaudeContent` for both sources — see session/prompt.ts). Sorted by
43
+ * count desc, then name.
44
+ */
45
+ export declare function extractSlashCommands(events: SessionEvent[]): SlashCommandUse[];
26
46
  export interface HookUse {
27
47
  /** Hook name as configured, e.g. `SessionStart:startup`. */
28
48
  name: string;
@@ -15,17 +15,44 @@ import os from 'node:os';
15
15
  import path from 'node:path';
16
16
  import { isNoisePath } from './digest.js';
17
17
  /**
18
- * Skills invoked during the session, from `Skill` tool calls. Claude and Kimi
19
- * both name the tool `Skill` and carry the skill id in `args.skill` (plugin
20
- * skills surface here too — a plugin-provided skill is invoked through the
21
- * same tool). Sorted by count desc, then name.
18
+ * The tool name a harness uses to invoke a skill, keyed by `SessionAgentId` —
19
+ * a registry, not a single hardcoded string, so a harness whose transcript
20
+ * names the tool differently is one table row away from support instead of a
21
+ * near-identical `if` arm (see CLAUDE.md's registry-over-if-chain convention).
22
+ *
23
+ * Verified entries only: Claude and Kimi both empirically name the tool
24
+ * `Skill` (a plugin-provided skill rides the same tool). No other harness has
25
+ * a confirmed skill-invocation tool name in this codebase's fixtures — an
26
+ * absent entry means "we don't know yet", not "this harness has no skills";
27
+ * extractSkills yields `[]` for it rather than guessing a name and silently
28
+ * mismatching (or worse, matching a coincidental tool with the same name).
29
+ */
30
+ const SKILL_TOOL_NAME_BY_AGENT = {
31
+ claude: 'Skill',
32
+ kimi: 'Skill',
33
+ };
34
+ /**
35
+ * True for a tool_use event that is that harness's skill-invocation call (see
36
+ * {@link SKILL_TOOL_NAME_BY_AGENT}). Exported so an INCREMENTAL parser
37
+ * (discover.ts's ClaudeParseState/foldDerivedToolState) can select matching
38
+ * events into a small held array as it streams, then run {@link extractSkills}
39
+ * over just that subset at finalize — without re-parsing the whole transcript.
40
+ */
41
+ export function isSkillInvocation(e) {
42
+ if (e.type !== 'tool_use' || e._local)
43
+ return false;
44
+ const skillTool = SKILL_TOOL_NAME_BY_AGENT[e.agent];
45
+ return !!skillTool && e.tool === skillTool;
46
+ }
47
+ /**
48
+ * Skills invoked during the session, from that harness's skill-invocation
49
+ * tool calls (see {@link SKILL_TOOL_NAME_BY_AGENT}). Carries the skill id in
50
+ * `args.skill` (or `args.name`). Sorted by count desc, then name.
22
51
  */
23
52
  export function extractSkills(events) {
24
53
  const counts = new Map();
25
54
  for (const e of events) {
26
- if (e.type !== 'tool_use' || e._local)
27
- continue;
28
- if (e.tool !== 'Skill')
55
+ if (!isSkillInvocation(e))
29
56
  continue;
30
57
  const name = e.args?.skill ?? e.args?.name;
31
58
  if (typeof name !== 'string' || !name.trim())
@@ -37,6 +64,24 @@ export function extractSkills(events) {
37
64
  .map(([name, count]) => ({ name, count }))
38
65
  .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
39
66
  }
67
+ /**
68
+ * Slash commands invoked during the session — either the user typing one
69
+ * (Claude's `<command-name>` wrapper) or the model invoking one via the
70
+ * `SlashCommand` tool (`SessionEvent.slashCommand`, populated by
71
+ * `parseClaudeContent` for both sources — see session/prompt.ts). Sorted by
72
+ * count desc, then name.
73
+ */
74
+ export function extractSlashCommands(events) {
75
+ const counts = new Map();
76
+ for (const e of events) {
77
+ if (!e.slashCommand)
78
+ continue;
79
+ counts.set(e.slashCommand, (counts.get(e.slashCommand) ?? 0) + 1);
80
+ }
81
+ return [...counts.entries()]
82
+ .map(([name, count]) => ({ name, count }))
83
+ .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
84
+ }
40
85
  /**
41
86
  * Hooks that fired during the session, folded from `hook` events (parsed from
42
87
  * Claude's `hook_success`/`hook_error`/… attachment records; other harnesses
@@ -12,7 +12,14 @@ import type { SessionAgentId, SessionEvent } from './types.js';
12
12
  * Node's ~512MB string ceiling with a healthy margin.
13
13
  */
14
14
  export declare const SESSION_FILE_MAX_BYTES = 200000000;
15
- export declare function sanitizeForTerminal(s: string): string;
15
+ /**
16
+ * Strip terminal control sequences that a malicious session file could use to
17
+ * hijack the user's terminal (clipboard via OSC 52, scrollback wipe, alt-screen
18
+ * takeover, cursor moves, etc.). Allowed through: tab (0x09), newline (0x0a),
19
+ * carriage return (0x0d). Everything else in the C0/C1 range and every CSI/OSC
20
+ * escape is dropped.
21
+ */
22
+ export { sanitizeForTerminal } from '../redact.js';
16
23
  /** In-place sanitize every user-visible string field on a list of events. */
17
24
  export declare function sanitizeEvents(events: SessionEvent[]): void;
18
25
  /**