@phnx-labs/agents-cli 1.21.3 → 1.22.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 (90) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +32 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/computer-actions.js +1 -0
  5. package/dist/commands/doctor.js +34 -2
  6. package/dist/commands/exec.d.ts +27 -0
  7. package/dist/commands/exec.js +123 -6
  8. package/dist/commands/models.js +36 -1
  9. package/dist/commands/projects.js +120 -62
  10. package/dist/commands/sessions-backfill.d.ts +32 -0
  11. package/dist/commands/sessions-backfill.js +186 -0
  12. package/dist/commands/sessions.d.ts +17 -1
  13. package/dist/commands/sessions.js +317 -18
  14. package/dist/commands/teams.js +1 -1
  15. package/dist/commands/worktree.d.ts +3 -3
  16. package/dist/commands/worktree.js +35 -4
  17. package/dist/lib/app-bundle-install.d.ts +17 -0
  18. package/dist/lib/app-bundle-install.js +94 -0
  19. package/dist/lib/daemon.d.ts +5 -1
  20. package/dist/lib/daemon.js +63 -14
  21. package/dist/lib/devices/doctor-findings.js +12 -4
  22. package/dist/lib/devices/doctor-overview-cache.d.ts +45 -0
  23. package/dist/lib/devices/doctor-overview-cache.js +168 -0
  24. package/dist/lib/devices/fleet.js +7 -2
  25. package/dist/lib/devices/resolve-target.d.ts +6 -0
  26. package/dist/lib/devices/resolve-target.js +9 -3
  27. package/dist/lib/devices/self-host.d.ts +9 -0
  28. package/dist/lib/devices/self-host.js +61 -0
  29. package/dist/lib/exec.js +39 -8
  30. package/dist/lib/hosts/dispatch.d.ts +12 -0
  31. package/dist/lib/hosts/dispatch.js +23 -6
  32. package/dist/lib/hosts/passthrough.js +8 -6
  33. package/dist/lib/hosts/reconnect.d.ts +38 -0
  34. package/dist/lib/hosts/reconnect.js +85 -4
  35. package/dist/lib/hosts/run-target.js +14 -2
  36. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  37. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  38. package/dist/lib/menubar/install-menubar.js +27 -23
  39. package/dist/lib/model-tiers.d.ts +54 -0
  40. package/dist/lib/model-tiers.js +229 -0
  41. package/dist/lib/models.d.ts +3 -0
  42. package/dist/lib/models.js +44 -7
  43. package/dist/lib/pricing/prices.json +16 -1
  44. package/dist/lib/project-focus.d.ts +42 -0
  45. package/dist/lib/project-focus.js +80 -0
  46. package/dist/lib/project-schedule.d.ts +75 -0
  47. package/dist/lib/project-schedule.js +110 -0
  48. package/dist/lib/project-status.d.ts +7 -0
  49. package/dist/lib/project-status.js +9 -0
  50. package/dist/lib/projects.d.ts +11 -2
  51. package/dist/lib/projects.js +57 -9
  52. package/dist/lib/redact.d.ts +2 -0
  53. package/dist/lib/redact.js +22 -0
  54. package/dist/lib/remote-agents-json.d.ts +2 -0
  55. package/dist/lib/remote-agents-json.js +3 -3
  56. package/dist/lib/rotate.d.ts +84 -1
  57. package/dist/lib/rotate.js +155 -5
  58. package/dist/lib/runner.d.ts +4 -2
  59. package/dist/lib/runner.js +13 -4
  60. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  61. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  62. package/dist/lib/secrets/install-helper.js +28 -31
  63. package/dist/lib/session/bash-command.js +60 -9
  64. package/dist/lib/session/db.d.ts +7 -1
  65. package/dist/lib/session/db.js +301 -32
  66. package/dist/lib/session/discover.d.ts +40 -7
  67. package/dist/lib/session/discover.js +144 -83
  68. package/dist/lib/session/parse.d.ts +8 -1
  69. package/dist/lib/session/parse.js +83 -32
  70. package/dist/lib/session/remote-list.d.ts +71 -0
  71. package/dist/lib/session/remote-list.js +410 -2
  72. package/dist/lib/session/shell-programs.d.ts +15 -0
  73. package/dist/lib/session/shell-programs.js +359 -0
  74. package/dist/lib/session/tool-calls.d.ts +88 -0
  75. package/dist/lib/session/tool-calls.js +612 -0
  76. package/dist/lib/session/tool-index.d.ts +100 -0
  77. package/dist/lib/session/tool-index.js +773 -0
  78. package/dist/lib/session/tool-store.d.ts +15 -0
  79. package/dist/lib/session/tool-store.js +198 -0
  80. package/dist/lib/session/types.d.ts +7 -0
  81. package/dist/lib/state.d.ts +10 -1
  82. package/dist/lib/state.js +11 -2
  83. package/dist/lib/teams/remoteWorktree.d.ts +3 -4
  84. package/dist/lib/teams/remoteWorktree.js +3 -4
  85. package/dist/lib/teams/worktree.d.ts +11 -1
  86. package/dist/lib/teams/worktree.js +42 -4
  87. package/dist/lib/types.d.ts +17 -0
  88. package/dist/lib/types.js +17 -0
  89. package/dist/lib/versions.js +69 -22
  90. package/package.json +3 -1
@@ -34,6 +34,8 @@ import { mapBounded } from '../concurrency.js';
34
34
  import { getDB, getScanStampByPath, getScanStampsForPaths, getParserStatesForPaths, getDirLedgerForPaths, recordDirScans, recordScans, syncLabels, seedLabelsFromNames, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, cacheLinearProject, } from './db.js';
35
35
  import { buildRunNameMap } from './run-names.js';
36
36
  import { resolveLinearApiKey } from '../auto-dispatch-linear.js';
37
+ import { ToolCallCollector, collectClaudeToolCalls, collectCodexToolCalls, } from './tool-calls.js';
38
+ import { purgeMissingToolCallsInDirectory } from './tool-store.js';
37
39
  const HOME = os.homedir();
38
40
  // Versions can live under either repo: the user repo (current canonical
39
41
  // location, ~/.agents/.history/versions/) or the system repo (legacy / npm-shipped,
@@ -47,6 +49,73 @@ const HERMES_SESSIONS_DIR = path.join(HOME, '.hermes', 'sessions');
47
49
  /** How long OpenClaw channel/cron snapshots stay valid before we re-shell-out. */
48
50
  const OPENCLAW_TTL_MS = 60_000;
49
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
+ }
50
119
  /**
51
120
  * How recently a file must have been scanned to be treated as "hot" — a
52
121
  * candidate for an in-place append even when its parent dir's mtime hasn't
@@ -102,8 +171,17 @@ export async function discoverSessions(options) {
102
171
  releaseScan(process.pid);
103
172
  }
104
173
  }
105
- const sessions = querySessions(buildQueryOptions(options, agents, { includeLimit: true }));
106
- 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);
107
185
  for (const s of sessions)
108
186
  s.machine = machineForSessionFile(s.filePath, s.agent);
109
187
  return scopeToManaged(sessions, agents, options);
@@ -156,7 +234,7 @@ async function resolveLinearProjects(sessions) {
156
234
  * An agent with no managed versions is left alone entirely — someone who has never
157
235
  * run `agents add` sees exactly what they saw before.
158
236
  */
159
- function scopeToManaged(sessions, agents, options) {
237
+ export function scopeToManaged(sessions, agents, options) {
160
238
  if (options?.includeUnmanaged)
161
239
  return sessions;
162
240
  if (!anyManagedVersions())
@@ -306,11 +384,12 @@ function buildQueryOptions(options, agents, opts) {
306
384
  project: projectQuery,
307
385
  sinceMs,
308
386
  untilMs: Number.isFinite(untilMs) ? untilMs : undefined,
309
- limit: opts.includeLimit ? (options?.limit ?? 50) : undefined,
387
+ limit: opts.includeLimit && !options?.unbounded ? (options?.limit ?? 50) : undefined,
310
388
  excludeTeamOrigin: options?.excludeTeamOrigin,
311
389
  onlyTeamOrigin: options?.onlyTeamOrigin,
312
390
  origin: options?.origin,
313
391
  sortBy: options?.sortBy,
392
+ skipExistenceCheck: options?.skipExistenceCheck,
314
393
  skill: options?.skill,
315
394
  plugin: options?.plugin,
316
395
  };
@@ -582,6 +661,7 @@ export function collectChangedFilesInLeafDirs(leafDirs, ext) {
582
661
  continue;
583
662
  toCompare.push({ filePath, fileMtimeMs: stat.mtimeMs, fileSize: stat.size });
584
663
  }
664
+ purgeMissingToolCallsInDirectory(getDB(), dirPath, files);
585
665
  if (!disabled)
586
666
  dirScansToRecord.push({ dirPath, dirMtimeMs, entryCount });
587
667
  }
@@ -789,7 +869,13 @@ async function scanRoutineArchivesIncremental(agent, onProgress) {
789
869
  try {
790
870
  const result = await readRoutineArchiveMeta(agent, filePath);
791
871
  if (result)
792
- 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
+ });
793
879
  else
794
880
  touched.push({ filePath, scan });
795
881
  }
@@ -964,6 +1050,8 @@ async function scanClaudeIncremental(onProgress) {
964
1050
  scan,
965
1051
  parserState: result.parserState,
966
1052
  contentText: result.contentText,
1053
+ toolCalls: result.toolCalls,
1054
+ toolIndexMode: result.toolIndexMode,
967
1055
  });
968
1056
  }
969
1057
  else {
@@ -993,7 +1081,7 @@ async function scanClaudeIncremental(onProgress) {
993
1081
  */
994
1082
  async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, account, label) {
995
1083
  const prior = parsePriorClaudeState(priorRow);
996
- 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);
997
1085
  if (mode === 'incremental')
998
1086
  claudeIncrementalScanCount++;
999
1087
  else
@@ -1078,6 +1166,8 @@ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, account,
1078
1166
  // doc, cached so the resume can hydrate userTexts without re-reading the file.
1079
1167
  parserState: JSON.stringify(newState),
1080
1168
  contentText: newState.contentText,
1169
+ toolCalls,
1170
+ toolIndexMode: mode === 'full' ? 'replace' : 'append',
1081
1171
  };
1082
1172
  }
1083
1173
  // ---------------------------------------------------------------------------
@@ -1215,6 +1305,8 @@ async function scanCodexIncremental(onProgress) {
1215
1305
  scan,
1216
1306
  parserState: result.parserState,
1217
1307
  contentText: result.contentText,
1308
+ toolCalls: result.toolCalls,
1309
+ toolIndexMode: result.toolIndexMode,
1218
1310
  });
1219
1311
  }
1220
1312
  else {
@@ -1317,6 +1409,8 @@ export async function readCodexMeta(filePath, resolveAccount, currentVersion, sc
1317
1409
  let scan;
1318
1410
  let newState;
1319
1411
  let newOffset = 0;
1412
+ let toolCalls;
1413
+ let toolIndexMode = 'replace';
1320
1414
  if (scanStamp) {
1321
1415
  const prior = parsePriorCodexState(priorRow);
1322
1416
  const result = await scanCodexSessionResumable(filePath, prior, scanStamp.fileMtimeMs, scanStamp.fileSize, priorRow?.fileMtimeMs);
@@ -1327,6 +1421,8 @@ export async function readCodexMeta(filePath, resolveAccount, currentVersion, sc
1327
1421
  scan = result.scan;
1328
1422
  newState = result.newState;
1329
1423
  newOffset = result.newOffset;
1424
+ toolCalls = result.toolCalls;
1425
+ toolIndexMode = result.mode === 'full' ? 'replace' : 'append';
1330
1426
  }
1331
1427
  else {
1332
1428
  scan = await scanCodexSession(filePath);
@@ -1373,6 +1469,8 @@ export async function readCodexMeta(filePath, resolveAccount, currentVersion, sc
1373
1469
  // doc for the resume's hydrate. Absent when no stamp was supplied.
1374
1470
  parserState: newState ? JSON.stringify(newState) : undefined,
1375
1471
  contentText: newState?.contentText,
1472
+ toolCalls,
1473
+ toolIndexMode,
1376
1474
  };
1377
1475
  }
1378
1476
  /**
@@ -2453,6 +2551,7 @@ export function initClaudeParseState() {
2453
2551
  plan: undefined,
2454
2552
  checklistEvents: [],
2455
2553
  recentDirectoriesTouched: [],
2554
+ toolCollector: new ToolCallCollector(),
2456
2555
  skillEvents: [],
2457
2556
  slashCommandEvents: [],
2458
2557
  };
@@ -2491,6 +2590,7 @@ function foldDerivedToolState(state, event) {
2491
2590
  * malformed-line skip happens in the caller, as before).
2492
2591
  */
2493
2592
  export function applyClaudeLine(state, parsed) {
2593
+ collectClaudeToolCalls(state.toolCollector, parsed);
2494
2594
  // entrypoint ships on the first envelope event (attachment/user/assistant)
2495
2595
  // and is the clean structural signal for "was this a team spawn?"
2496
2596
  if (!state.entrypoint && typeof parsed.entrypoint === 'string') {
@@ -2726,15 +2826,16 @@ const SEEN_IDS_RECENT_CAP = 256;
2726
2826
  * `offset` bytes consumed. Round-trips through {@link hydrateClaudeParseState}
2727
2827
  * so incremental replay equals a full parse.
2728
2828
  */
2729
- export function serializeClaudeParserState(state, offset) {
2829
+ export function serializeClaudeParserState(state, offset, jsonlDroppingOversizedLine = false) {
2730
2830
  const allIds = [...state.seenAssistantIds];
2731
2831
  const seenIdsRecent = allIds.length > SEEN_IDS_RECENT_CAP
2732
2832
  ? allIds.slice(allIds.length - SEEN_IDS_RECENT_CAP)
2733
2833
  : allIds;
2734
2834
  const ticket = detectTicket(state.userTexts.join('\n') || undefined, state.gitBranch);
2735
2835
  return {
2736
- v: 1,
2836
+ v: 2,
2737
2837
  offset,
2838
+ jsonlDroppingOversizedLine: jsonlDroppingOversizedLine || undefined,
2738
2839
  timestamp: state.timestamp,
2739
2840
  cwd: state.cwd,
2740
2841
  gitBranch: state.gitBranch,
@@ -2770,6 +2871,7 @@ export function serializeClaudeParserState(state, offset) {
2770
2871
  contentText: state.userTexts.length > 0 ? state.userTexts.join('\n') : undefined,
2771
2872
  checklistEvents: state.checklistEvents,
2772
2873
  recentDirectoriesTouched: state.recentDirectoriesTouched,
2874
+ toolCalls: state.toolCollector.snapshot(),
2773
2875
  skillEvents: state.skillEvents,
2774
2876
  slashCommandEvents: state.slashCommandEvents,
2775
2877
  };
@@ -2828,6 +2930,7 @@ export function hydrateClaudeParseState(prior) {
2828
2930
  plan: prior.plan,
2829
2931
  checklistEvents: prior.checklistEvents ?? [],
2830
2932
  recentDirectoriesTouched: prior.recentDirectoriesTouched ?? [],
2933
+ toolCollector: new ToolCallCollector(prior.toolCalls),
2831
2934
  skillEvents: prior.skillEvents ?? [],
2832
2935
  slashCommandEvents: prior.slashCommandEvents ?? [],
2833
2936
  };
@@ -2853,45 +2956,20 @@ export async function scanClaudeSessionIncremental(filePath, fromOffset, prior)
2853
2956
  // have no dedup, unlike assistant `seenAssistantIds`). A record written
2854
2957
  // non-atomically — bytes first, then '\n' in a second write — is exactly this
2855
2958
  // case. So we slice at the last '\n' ourselves: everything up to and including
2856
- // it is a run of complete lines we apply and commit; any tail after it
2857
- // (syntactically broken OR complete-but-not-yet-terminated) is a still-being-
2858
- // written record we DEFER to the next pass, once its '\n' lands.
2859
- const chunks = [];
2860
- const stream = fs.createReadStream(filePath, { start: fromOffset });
2861
- try {
2862
- for await (const chunk of stream) {
2863
- chunks.push(typeof chunk === 'string' ? Buffer.from(chunk, 'utf-8') : chunk);
2864
- }
2865
- }
2866
- finally {
2867
- stream.destroy();
2868
- }
2869
- const appended = Buffer.concat(chunks);
2870
- // Bytes up to AND INCLUDING the last '\n' are the committed, complete-line run.
2871
- const lastNl = appended.lastIndexOf(0x0a);
2872
- const consumedBytes = lastNl === -1 ? 0 : lastNl + 1;
2873
- if (consumedBytes > 0) {
2874
- // split('\n') on the committed run: the element after the final '\n' is ''
2875
- // (skipped by the trim guard). A stray '\r' from CRLF is tolerated by the
2876
- // JSON.parse below exactly as the full parse tolerates it.
2877
- for (const line of appended.subarray(0, consumedBytes).toString('utf-8').split('\n')) {
2878
- if (!line.trim())
2879
- continue;
2880
- let parsed;
2881
- try {
2882
- parsed = JSON.parse(line);
2883
- }
2884
- catch {
2885
- continue;
2886
- }
2887
- applyClaudeLine(state, parsed);
2888
- }
2889
- }
2890
- 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();
2891
2968
  return {
2892
- scan: finalizeClaudeScan(state),
2893
- newState: serializeClaudeParserState(state, newOffset),
2969
+ scan,
2970
+ newState: serializeClaudeParserState(state, newOffset, append.droppingOversizedLine),
2894
2971
  newOffset,
2972
+ toolCalls,
2895
2973
  };
2896
2974
  }
2897
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. */
@@ -2986,7 +3064,7 @@ function parsePriorClaudeState(row) {
2986
3064
  return null;
2987
3065
  try {
2988
3066
  const parsed = JSON.parse(row.parserState);
2989
- if (parsed?.v !== 1 || typeof parsed.offset !== 'number')
3067
+ if (parsed?.v !== 2 || typeof parsed.offset !== 'number' || parsed.toolCalls?.v !== 1)
2990
3068
  return null;
2991
3069
  return parsed;
2992
3070
  }
@@ -3031,6 +3109,7 @@ export function initCodexParseState() {
3031
3109
  spawnedTeam: undefined,
3032
3110
  checklistEvents: [],
3033
3111
  recentDirectoriesTouched: [],
3112
+ toolCollector: new ToolCallCollector(),
3034
3113
  skillEvents: [],
3035
3114
  slashCommandEvents: [],
3036
3115
  };
@@ -3042,6 +3121,7 @@ export function initCodexParseState() {
3042
3121
  * malformed-line skip happens in the caller, as before).
3043
3122
  */
3044
3123
  export function applyCodexLine(state, parsed) {
3124
+ collectCodexToolCalls(state.toolCollector, parsed);
3045
3125
  // PR signal, structurally: a Codex `function_call` whose command is
3046
3126
  // `gh pr create`, then the pull URL from a `function_call_output`.
3047
3127
  if (parsed.type === 'response_item') {
@@ -3214,11 +3294,12 @@ async function scanCodexSession(filePath) {
3214
3294
  * bytes consumed. Round-trips through {@link hydrateCodexParseState} so
3215
3295
  * incremental replay equals a full parse.
3216
3296
  */
3217
- export function serializeCodexParserState(state, offset) {
3297
+ export function serializeCodexParserState(state, offset, jsonlDroppingOversizedLine = false) {
3218
3298
  const ticket = detectTicket(state.userTexts.join('\n') || undefined, state.gitBranch);
3219
3299
  return {
3220
- v: 1,
3300
+ v: 2,
3221
3301
  offset,
3302
+ jsonlDroppingOversizedLine: jsonlDroppingOversizedLine || undefined,
3222
3303
  sessionId: state.sessionId,
3223
3304
  timestamp: state.timestamp,
3224
3305
  cwd: state.cwd,
@@ -3245,6 +3326,7 @@ export function serializeCodexParserState(state, offset) {
3245
3326
  contentText: state.userTexts.length > 0 ? state.userTexts.join('\n') : undefined,
3246
3327
  checklistEvents: state.checklistEvents,
3247
3328
  recentDirectoriesTouched: state.recentDirectoriesTouched,
3329
+ toolCalls: state.toolCollector.snapshot(),
3248
3330
  };
3249
3331
  }
3250
3332
  /**
@@ -3280,6 +3362,7 @@ export function hydrateCodexParseState(prior) {
3280
3362
  spawnedTeam: prior.spawnedTeam,
3281
3363
  checklistEvents: prior.checklistEvents ?? [],
3282
3364
  recentDirectoriesTouched: prior.recentDirectoriesTouched ?? [],
3365
+ toolCollector: new ToolCallCollector(prior.toolCalls),
3283
3366
  // Not persisted in CodexParserState (always empty for Codex today — see
3284
3367
  // CodexParseState.skillEvents) — a resume starts fresh rather than
3285
3368
  // round-tripping an always-empty array through the continuation blob.
@@ -3294,47 +3377,25 @@ export function hydrateCodexParseState(prior) {
3294
3377
  *
3295
3378
  * Same trailing-line discipline as {@link scanClaudeSessionIncremental}: apply
3296
3379
  * ONLY the run of newline-terminated lines (slice at the last `'\n'`), and set
3297
- * `newOffset = fromOffset + consumedBytes`. Any tail after the last `'\n'` —
3298
- * syntactically broken OR a complete-but-not-yet-terminated record — is DEFERRED
3299
- * 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
3300
3383
  * NO dedup, so re-reading a still-unterminated complete line would double-count
3301
3384
  * it; deferring prevents that (the bug class prix-cloud caught for Claude).
3302
3385
  */
3303
3386
  export async function scanCodexSessionIncremental(filePath, fromOffset, prior) {
3304
3387
  const state = hydrateCodexParseState(prior);
3305
- const chunks = [];
3306
- const stream = fs.createReadStream(filePath, { start: fromOffset });
3307
- try {
3308
- for await (const chunk of stream) {
3309
- chunks.push(typeof chunk === 'string' ? Buffer.from(chunk, 'utf-8') : chunk);
3310
- }
3311
- }
3312
- finally {
3313
- stream.destroy();
3314
- }
3315
- const appended = Buffer.concat(chunks);
3316
- // Bytes up to AND INCLUDING the last '\n' are the committed, complete-line run.
3317
- const lastNl = appended.lastIndexOf(0x0a);
3318
- const consumedBytes = lastNl === -1 ? 0 : lastNl + 1;
3319
- if (consumedBytes > 0) {
3320
- for (const line of appended.subarray(0, consumedBytes).toString('utf-8').split('\n')) {
3321
- if (!line.trim())
3322
- continue;
3323
- let parsed;
3324
- try {
3325
- parsed = JSON.parse(line);
3326
- }
3327
- catch {
3328
- continue;
3329
- }
3330
- applyCodexLine(state, parsed);
3331
- }
3332
- }
3333
- 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();
3334
3394
  return {
3335
- scan: finalizeCodexScan(state),
3336
- newState: serializeCodexParserState(state, newOffset),
3395
+ scan,
3396
+ newState: serializeCodexParserState(state, newOffset, append.droppingOversizedLine),
3337
3397
  newOffset,
3398
+ toolCalls,
3338
3399
  };
3339
3400
  }
3340
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. */
@@ -3423,7 +3484,7 @@ function parsePriorCodexState(row) {
3423
3484
  return null;
3424
3485
  try {
3425
3486
  const parsed = JSON.parse(row.parserState);
3426
- if (parsed?.v !== 1 || typeof parsed.offset !== 'number')
3487
+ if (parsed?.v !== 2 || typeof parsed.offset !== 'number' || parsed.toolCalls?.v !== 1)
3427
3488
  return null;
3428
3489
  return parsed;
3429
3490
  }
@@ -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
  /**