@phnx-labs/agents-cli 1.20.74 → 1.20.77

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 (231) hide show
  1. package/CHANGELOG.md +1055 -0
  2. package/README.md +36 -10
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.js +21 -3
  5. package/dist/commands/attach.d.ts +10 -0
  6. package/dist/commands/attach.js +41 -0
  7. package/dist/commands/computer.js +3 -3
  8. package/dist/commands/detach-core.d.ts +46 -0
  9. package/dist/commands/detach-core.js +61 -0
  10. package/dist/commands/detach.d.ts +5 -0
  11. package/dist/commands/detach.js +171 -0
  12. package/dist/commands/doctor.js +18 -0
  13. package/dist/commands/exec.js +83 -13
  14. package/dist/commands/feed.d.ts +7 -1
  15. package/dist/commands/feed.js +47 -4
  16. package/dist/commands/go.d.ts +9 -2
  17. package/dist/commands/go.js +16 -6
  18. package/dist/commands/harness.d.ts +14 -0
  19. package/dist/commands/harness.js +145 -0
  20. package/dist/commands/import.js +38 -8
  21. package/dist/commands/inspect.js +5 -2
  22. package/dist/commands/profiles.d.ts +20 -0
  23. package/dist/commands/profiles.js +68 -21
  24. package/dist/commands/repo.js +14 -0
  25. package/dist/commands/routines.js +286 -15
  26. package/dist/commands/secrets.js +102 -37
  27. package/dist/commands/sessions-browser.js +12 -2
  28. package/dist/commands/sessions-export.js +19 -5
  29. package/dist/commands/sessions-migrate.d.ts +29 -0
  30. package/dist/commands/sessions-migrate.js +596 -0
  31. package/dist/commands/sessions-picker.d.ts +14 -1
  32. package/dist/commands/sessions-picker.js +184 -16
  33. package/dist/commands/sessions.d.ts +56 -14
  34. package/dist/commands/sessions.js +322 -62
  35. package/dist/commands/setup-computer.js +2 -2
  36. package/dist/commands/ssh.d.ts +13 -0
  37. package/dist/commands/ssh.js +110 -37
  38. package/dist/commands/status.js +10 -2
  39. package/dist/commands/versions.js +15 -6
  40. package/dist/commands/view.d.ts +5 -0
  41. package/dist/commands/view.js +24 -4
  42. package/dist/commands/watchdog.d.ts +7 -2
  43. package/dist/commands/watchdog.js +73 -57
  44. package/dist/index.js +59 -7
  45. package/dist/lib/activity.d.ts +17 -6
  46. package/dist/lib/activity.js +466 -40
  47. package/dist/lib/actor.d.ts +50 -0
  48. package/dist/lib/actor.js +166 -0
  49. package/dist/lib/agent-spec/provider.js +2 -1
  50. package/dist/lib/agent-spec/resolve.js +19 -5
  51. package/dist/lib/agent-spec/types.d.ts +9 -1
  52. package/dist/lib/agents.d.ts +21 -0
  53. package/dist/lib/agents.js +70 -6
  54. package/dist/lib/cloud/codex.d.ts +2 -0
  55. package/dist/lib/cloud/codex.js +14 -3
  56. package/dist/lib/cloud/session-index.d.ts +32 -0
  57. package/dist/lib/cloud/session-index.js +58 -0
  58. package/dist/lib/cloud/store.d.ts +7 -0
  59. package/dist/lib/cloud/store.js +25 -0
  60. package/dist/lib/config-transfer.js +4 -0
  61. package/dist/lib/daemon.d.ts +10 -1
  62. package/dist/lib/daemon.js +114 -11
  63. package/dist/lib/devices/connect.d.ts +15 -1
  64. package/dist/lib/devices/connect.js +15 -1
  65. package/dist/lib/devices/fleet.d.ts +37 -1
  66. package/dist/lib/devices/fleet.js +36 -2
  67. package/dist/lib/devices/health-report.d.ts +38 -0
  68. package/dist/lib/devices/health-report.js +214 -0
  69. package/dist/lib/devices/health.js +4 -1
  70. package/dist/lib/devices/reachability.d.ts +31 -0
  71. package/dist/lib/devices/reachability.js +40 -0
  72. package/dist/lib/devices/registry.d.ts +33 -0
  73. package/dist/lib/devices/registry.js +37 -0
  74. package/dist/lib/devices/resolve-target.d.ts +15 -27
  75. package/dist/lib/devices/resolve-target.js +63 -102
  76. package/dist/lib/devices/sync.d.ts +18 -0
  77. package/dist/lib/devices/sync.js +23 -1
  78. package/dist/lib/devices/tailscale.d.ts +3 -0
  79. package/dist/lib/devices/tailscale.js +1 -0
  80. package/dist/lib/events.d.ts +1 -1
  81. package/dist/lib/exec.d.ts +82 -0
  82. package/dist/lib/exec.js +218 -6
  83. package/dist/lib/feed-post.d.ts +63 -0
  84. package/dist/lib/feed-post.js +204 -0
  85. package/dist/lib/feed.d.ts +7 -1
  86. package/dist/lib/feed.js +96 -6
  87. package/dist/lib/fleet/apply.d.ts +32 -2
  88. package/dist/lib/fleet/apply.js +97 -10
  89. package/dist/lib/fleet/types.d.ts +11 -0
  90. package/dist/lib/fs-walk.d.ts +13 -0
  91. package/dist/lib/fs-walk.js +16 -7
  92. package/dist/lib/heal.d.ts +7 -4
  93. package/dist/lib/heal.js +10 -22
  94. package/dist/lib/hooks.d.ts +1 -0
  95. package/dist/lib/hooks.js +156 -0
  96. package/dist/lib/hosts/dispatch.d.ts +16 -0
  97. package/dist/lib/hosts/dispatch.js +26 -4
  98. package/dist/lib/hosts/passthrough.js +9 -2
  99. package/dist/lib/hosts/reconnect.d.ts +78 -0
  100. package/dist/lib/hosts/reconnect.js +127 -0
  101. package/dist/lib/hosts/registry.d.ts +75 -14
  102. package/dist/lib/hosts/registry.js +205 -30
  103. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  104. package/dist/lib/hosts/remote-cmd.js +32 -8
  105. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  106. package/dist/lib/hosts/remote-session-id.js +84 -0
  107. package/dist/lib/hosts/run-target.d.ts +17 -5
  108. package/dist/lib/hosts/run-target.js +30 -10
  109. package/dist/lib/hosts/session-index.d.ts +18 -1
  110. package/dist/lib/hosts/session-index.js +40 -5
  111. package/dist/lib/hosts/session-marker.d.ts +33 -0
  112. package/dist/lib/hosts/session-marker.js +51 -0
  113. package/dist/lib/hosts/tasks.d.ts +8 -4
  114. package/dist/lib/import.d.ts +2 -0
  115. package/dist/lib/import.js +35 -2
  116. package/dist/lib/menubar/install-menubar.d.ts +29 -0
  117. package/dist/lib/menubar/install-menubar.js +59 -1
  118. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  119. package/dist/lib/menubar/notify-desktop.js +78 -0
  120. package/dist/lib/migrate.d.ts +16 -0
  121. package/dist/lib/migrate.js +36 -0
  122. package/dist/lib/models.d.ts +27 -0
  123. package/dist/lib/models.js +54 -1
  124. package/dist/lib/overdue.d.ts +6 -2
  125. package/dist/lib/overdue.js +10 -36
  126. package/dist/lib/picker.js +4 -1
  127. package/dist/lib/platform/process.d.ts +17 -0
  128. package/dist/lib/platform/process.js +70 -0
  129. package/dist/lib/profiles-presets.js +9 -7
  130. package/dist/lib/profiles.d.ts +31 -0
  131. package/dist/lib/profiles.js +70 -0
  132. package/dist/lib/pty-server.d.ts +2 -10
  133. package/dist/lib/pty-server.js +4 -38
  134. package/dist/lib/registry.d.ts +1 -1
  135. package/dist/lib/registry.js +48 -8
  136. package/dist/lib/rotate.d.ts +18 -0
  137. package/dist/lib/rotate.js +28 -0
  138. package/dist/lib/routine-notify.d.ts +76 -0
  139. package/dist/lib/routine-notify.js +190 -0
  140. package/dist/lib/routines-placement.d.ts +38 -0
  141. package/dist/lib/routines-placement.js +79 -0
  142. package/dist/lib/routines-project.d.ts +97 -0
  143. package/dist/lib/routines-project.js +349 -0
  144. package/dist/lib/routines.d.ts +94 -0
  145. package/dist/lib/routines.js +93 -9
  146. package/dist/lib/runner.d.ts +12 -2
  147. package/dist/lib/runner.js +242 -20
  148. package/dist/lib/sandbox.js +10 -0
  149. package/dist/lib/secrets/account-token.d.ts +20 -0
  150. package/dist/lib/secrets/account-token.js +64 -0
  151. package/dist/lib/secrets/agent.d.ts +74 -4
  152. package/dist/lib/secrets/agent.js +181 -107
  153. package/dist/lib/secrets/bundles.d.ts +54 -6
  154. package/dist/lib/secrets/bundles.js +230 -34
  155. package/dist/lib/secrets/index.d.ts +26 -3
  156. package/dist/lib/secrets/index.js +42 -12
  157. package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
  158. package/dist/lib/secrets/rc-hygiene.js +154 -0
  159. package/dist/lib/secrets/remote.d.ts +7 -4
  160. package/dist/lib/secrets/remote.js +7 -4
  161. package/dist/lib/secrets/session-store.d.ts +6 -2
  162. package/dist/lib/secrets/session-store.js +65 -15
  163. package/dist/lib/secrets/sync-commands.d.ts +21 -0
  164. package/dist/lib/secrets/sync-commands.js +21 -0
  165. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  166. package/dist/lib/secrets/unlock-hints.js +36 -0
  167. package/dist/lib/self-update.d.ts +23 -2
  168. package/dist/lib/self-update.js +86 -5
  169. package/dist/lib/session/active.d.ts +124 -7
  170. package/dist/lib/session/active.js +390 -56
  171. package/dist/lib/session/cloud.js +2 -1
  172. package/dist/lib/session/db.d.ts +54 -0
  173. package/dist/lib/session/db.js +320 -19
  174. package/dist/lib/session/detached.d.ts +30 -0
  175. package/dist/lib/session/detached.js +92 -0
  176. package/dist/lib/session/discover.d.ts +478 -3
  177. package/dist/lib/session/discover.js +1740 -472
  178. package/dist/lib/session/fork.js +2 -1
  179. package/dist/lib/session/hook-sessions.d.ts +43 -0
  180. package/dist/lib/session/hook-sessions.js +135 -0
  181. package/dist/lib/session/linear.d.ts +6 -0
  182. package/dist/lib/session/linear.js +55 -0
  183. package/dist/lib/session/migrate-targets.d.ts +65 -0
  184. package/dist/lib/session/migrate-targets.js +94 -0
  185. package/dist/lib/session/migrations.d.ts +37 -0
  186. package/dist/lib/session/migrations.js +60 -0
  187. package/dist/lib/session/parse.d.ts +18 -0
  188. package/dist/lib/session/parse.js +141 -32
  189. package/dist/lib/session/pid-registry.d.ts +34 -2
  190. package/dist/lib/session/pid-registry.js +49 -2
  191. package/dist/lib/session/prompt.d.ts +7 -0
  192. package/dist/lib/session/prompt.js +37 -0
  193. package/dist/lib/session/provenance.d.ts +7 -0
  194. package/dist/lib/session/render.d.ts +7 -2
  195. package/dist/lib/session/render.js +40 -26
  196. package/dist/lib/session/short-id.d.ts +17 -0
  197. package/dist/lib/session/short-id.js +20 -0
  198. package/dist/lib/session/state.d.ts +4 -0
  199. package/dist/lib/session/state.js +99 -13
  200. package/dist/lib/session/types.d.ts +25 -1
  201. package/dist/lib/session/types.js +8 -0
  202. package/dist/lib/shims.d.ts +1 -1
  203. package/dist/lib/shims.js +20 -6
  204. package/dist/lib/sqlite.d.ts +3 -2
  205. package/dist/lib/sqlite.js +27 -4
  206. package/dist/lib/staleness/detectors/commands.js +1 -1
  207. package/dist/lib/staleness/detectors/workflows.js +13 -2
  208. package/dist/lib/staleness/writers/commands.js +7 -7
  209. package/dist/lib/staleness/writers/workflows.d.ts +4 -2
  210. package/dist/lib/startup/command-registry.d.ts +1 -0
  211. package/dist/lib/startup/command-registry.js +3 -0
  212. package/dist/lib/state.d.ts +8 -4
  213. package/dist/lib/state.js +21 -37
  214. package/dist/lib/teams/agents.d.ts +19 -10
  215. package/dist/lib/teams/agents.js +40 -39
  216. package/dist/lib/types.d.ts +47 -2
  217. package/dist/lib/usage.d.ts +33 -1
  218. package/dist/lib/usage.js +172 -12
  219. package/dist/lib/versions.d.ts +9 -2
  220. package/dist/lib/versions.js +37 -8
  221. package/dist/lib/watchdog/log.d.ts +43 -0
  222. package/dist/lib/watchdog/log.js +69 -0
  223. package/dist/lib/watchdog/routine.d.ts +44 -0
  224. package/dist/lib/watchdog/routine.js +69 -0
  225. package/dist/lib/watchdog/runner.d.ts +51 -7
  226. package/dist/lib/watchdog/runner.js +239 -64
  227. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  228. package/dist/lib/watchdog/watchdog.js +31 -16
  229. package/dist/lib/workflows.d.ts +16 -0
  230. package/dist/lib/workflows.js +110 -1
  231. package/package.json +1 -1
@@ -14,20 +14,23 @@ import * as readline from 'readline';
14
14
  import { execFile } from 'child_process';
15
15
  import { promisify } from 'util';
16
16
  import Database from '../sqlite.js';
17
- import { getAgentsDir, getHistoryDir, getRunsDir } from '../state.js';
17
+ import { getAgentsDir, getUserAgentsDir, getHistoryDir, getRunsDir } from '../state.js';
18
+ import { shortCodexHome } from '../codex-home.js';
18
19
  const execFileAsync = promisify(execFile);
19
20
  import { AGENTS, agentConfigDirName, getCliVersion } from '../agents.js';
20
- import { walkForFiles } from '../fs-walk.js';
21
+ import { walkForFilesWithStat } from '../fs-walk.js';
21
22
  import { getConfigSymlinkVersion } from '../shims.js';
22
23
  import { SESSION_AGENTS } from './types.js';
24
+ import { deriveShortId } from './short-id.js';
23
25
  import { extractSessionTopic } from './prompt.js';
24
26
  import { parseAntigravity } from './parse.js';
25
- import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand, detectSpawnedTeam, isTicketCreateTool, extractCreatedTicket } from './state.js';
27
+ import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand, detectSpawnedTeam, isTicketCreateTool, extractCreatedTicket, extractRecentDirectoriesTouched, extractTodoProgressFromEvents } from './state.js';
26
28
  import { costOfUsage } from '../pricing/index.js';
27
29
  import { machineId } from './sync/config.js';
28
30
  import { mapBounded } from '../concurrency.js';
29
- import { getDB, getScanStampByPath, getScanStampsForPaths, recordScans, syncLabels, seedLabelsFromNames, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, } from './db.js';
31
+ import { getDB, getScanStampByPath, getScanStampsForPaths, getParserStatesForPaths, getDirLedgerForPaths, recordDirScans, recordScans, syncLabels, seedLabelsFromNames, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, cacheLinearProject, } from './db.js';
30
32
  import { buildRunNameMap } from './run-names.js';
33
+ import { resolveLinearApiKey } from '../auto-dispatch-linear.js';
31
34
  const HOME = os.homedir();
32
35
  // Versions can live under either repo: the user repo (current canonical
33
36
  // location, ~/.agents/.history/versions/) or the system repo (legacy / npm-shipped,
@@ -41,6 +44,26 @@ const HERMES_SESSIONS_DIR = path.join(HOME, '.hermes', 'sessions');
41
44
  /** How long OpenClaw channel/cron snapshots stay valid before we re-shell-out. */
42
45
  const OPENCLAW_TTL_MS = 60_000;
43
46
  const ACTIVE_APPEND_RESCAN_DEBOUNCE_MS = 5_000;
47
+ /**
48
+ * How recently a file must have been scanned to be treated as "hot" — a
49
+ * candidate for an in-place append even when its parent dir's mtime hasn't
50
+ * moved. A dir-ledger match lets us skip the per-file stat of everything in a
51
+ * leaf dir EXCEPT its hot set; a file is hot if it lives under the agent's live
52
+ * `~/.<agent>` root (the only tree an agent appends to live) or was scanned
53
+ * within this window. 10 minutes comfortably covers a session that paused
54
+ * between `agents sessions` calls but is still being written to.
55
+ */
56
+ const HOT_FILE_WINDOW_MS = 600_000;
57
+ /**
58
+ * Kill-switch: set `AGENTS_SESSIONS_NO_DIR_LEDGER=1` to force the old full-walk
59
+ * path (readdir + per-file stat every dir, every run — the pre-A-2 behavior),
60
+ * skipping the dir_ledger short-circuit entirely. One env var reverts a field
61
+ * regression to today's behavior.
62
+ */
63
+ function dirLedgerDisabled() {
64
+ const v = process.env.AGENTS_SESSIONS_NO_DIR_LEDGER;
65
+ return v === '1' || v === 'true';
66
+ }
44
67
  let cachedOpenClawWorkspaces = null;
45
68
  const cachedAgentVersions = new Map();
46
69
  /**
@@ -77,9 +100,97 @@ export async function discoverSessions(options) {
77
100
  }
78
101
  }
79
102
  const sessions = querySessions(buildQueryOptions(options, agents, { includeLimit: true }));
103
+ await resolveLinearProjects(sessions);
80
104
  for (const s of sessions)
81
105
  s.machine = machineForSessionFile(s.filePath, s.agent);
82
- return sessions;
106
+ return scopeToManaged(sessions, agents, options);
107
+ }
108
+ const linearProjectCache = new Map();
109
+ async function resolveLinearProjects(sessions) {
110
+ const apiKey = resolveLinearApiKey();
111
+ if (!apiKey)
112
+ return;
113
+ await Promise.all(sessions.map(async (session) => {
114
+ if (!session.ticketId || session.linearProject)
115
+ return;
116
+ let project = linearProjectCache.get(session.ticketId);
117
+ if (project === undefined) {
118
+ try {
119
+ const response = await fetch('https://api.linear.app/graphql', {
120
+ method: 'POST',
121
+ signal: AbortSignal.timeout(3_000),
122
+ headers: { Authorization: apiKey, 'Content-Type': 'application/json' },
123
+ body: JSON.stringify({
124
+ query: `query($id:String!){ issue(id:$id){ project{ name url } } }`,
125
+ variables: { id: session.ticketId },
126
+ }),
127
+ });
128
+ if (!response.ok)
129
+ throw new Error(String(response.status));
130
+ const body = await response.json();
131
+ const node = body.data?.issue?.project;
132
+ project = node?.name && node?.url ? { name: node.name, url: node.url } : null;
133
+ }
134
+ catch {
135
+ project = null;
136
+ }
137
+ linearProjectCache.set(session.ticketId, project);
138
+ }
139
+ if (!project)
140
+ return;
141
+ session.linearProject = project.name;
142
+ session.linearProjectUrl = project.url;
143
+ cacheLinearProject(session.id, project.name, project.url);
144
+ }));
145
+ }
146
+ /**
147
+ * Drop unmanaged rows for agents that HAVE managed versions.
148
+ *
149
+ * Scoping happens here, at query time, rather than by narrowing the scan: the index
150
+ * stays complete, so `--unmanaged` needs no re-scan and every other consumer of the
151
+ * DB (watchdog, the Factory watcher, `--roots`) is unaffected.
152
+ *
153
+ * An agent with no managed versions is left alone entirely — someone who has never
154
+ * run `agents add` sees exactly what they saw before.
155
+ */
156
+ function scopeToManaged(sessions, agents, options) {
157
+ if (options?.includeUnmanaged)
158
+ return sessions;
159
+ if (!anyManagedVersions())
160
+ return sessions;
161
+ const kept = sessions.filter((s) => isManagedSessionFile(s.filePath));
162
+ const hidden = sessions.length - kept.length;
163
+ if (hidden > 0)
164
+ options?.onHiddenUnmanaged?.(hidden);
165
+ return kept;
166
+ }
167
+ /**
168
+ * True once agents-cli manages ANY agent version. Until then it manages nothing, so
169
+ * scoping to "managed only" would leave the listing empty for a user who has never
170
+ * run `agents add` — the browser is most of the tool's value before you install
171
+ * anything through it.
172
+ */
173
+ function anyManagedVersions() {
174
+ for (const root of VERSIONS_ROOTS) {
175
+ const base = path.join(root, 'versions');
176
+ let agentDirs;
177
+ try {
178
+ agentDirs = fs.readdirSync(base, { withFileTypes: true });
179
+ }
180
+ catch {
181
+ continue;
182
+ }
183
+ for (const a of agentDirs) {
184
+ if (!a.isDirectory())
185
+ continue;
186
+ try {
187
+ if (fs.readdirSync(path.join(base, a.name), { withFileTypes: true }).some((e) => e.isDirectory()))
188
+ return true;
189
+ }
190
+ catch { /* unreadable */ }
191
+ }
192
+ }
193
+ return false;
83
194
  }
84
195
  /**
85
196
  * How many agents' dotfile dirs we scan at once, and the minimum spacing between
@@ -108,6 +219,7 @@ function dispatchAgentScan(agent, onProgress) {
108
219
  case 'hermes': return scanHermesIncremental(onProgress);
109
220
  case 'kimi': return scanKimiIncremental(onProgress);
110
221
  case 'droid': return scanDroidIncremental(onProgress);
222
+ case 'grok': return scanGrokIncremental(onProgress);
111
223
  default: return Promise.resolve();
112
224
  }
113
225
  }
@@ -119,6 +231,45 @@ let _localMachineId;
119
231
  * when the path sits under the agent's backups root, the first segment below it
120
232
  * is the origin machine id; otherwise it's the local machine.
121
233
  */
234
+ /**
235
+ * True when this transcript belongs to a version agents-cli manages — i.e. it lives
236
+ * under a version home (or a backup mirror of one) rather than in the user's own
237
+ * `~/.<agent>`.
238
+ *
239
+ * `agents sessions` scans both, which is right for indexing: the DB stays a complete
240
+ * picture and `--unmanaged` can surface everything without a re-scan. But listing
241
+ * *by default* is a different question. Once you have managed versions, an unmanaged
242
+ * install's history is not really agents-cli's to show — most visibly after
243
+ * `agents add --isolated`, where the whole point was to keep the two apart.
244
+ */
245
+ export function isManagedSessionFile(filePath) {
246
+ // Synthetic rows (OpenClaw workspace sessions, cloud/remote entries) have no local
247
+ // transcript to classify. They are produced BY agents-cli rather than read out of
248
+ // someone's dotfile dir, so scoping must not silently swallow them.
249
+ if (!filePath || !path.isAbsolute(filePath))
250
+ return true;
251
+ const roots = [
252
+ ...VERSIONS_ROOTS.map((root) => path.join(root, 'versions')),
253
+ path.join(getHistoryDir(), 'backups'),
254
+ // Codex's managed home is not always under versions/. On macOS the versioned path
255
+ // overflows SUN_LEN for codex's control socket, so the shim relocates it to
256
+ // `<agentsUserDir>/.codex-homes/<version>/` (lib/codex-home.ts).
257
+ path.join(getUserAgentsDir(), '.codex-homes'),
258
+ // Routine archives are agents-cli's OWN run output — managed by definition.
259
+ getRunsDir(),
260
+ ];
261
+ // Compare realpaths as well as the literal roots. A transcript's stored path is
262
+ // resolved, so on macOS (`/var` -> `/private/var`) a temp-dir HOME yields
263
+ // `/private/var/...` for the file and `/var/...` for the root, and a plain prefix
264
+ // test silently classifies every managed session as the user's own.
265
+ const real = safeRealpathSync(filePath) || filePath;
266
+ return roots.some((root) => {
267
+ if (filePath.startsWith(root + path.sep))
268
+ return true;
269
+ const realRoot = safeRealpathSync(root);
270
+ return !!realRoot && real.startsWith(realRoot + path.sep);
271
+ });
272
+ }
122
273
  export function machineForSessionFile(filePath, agent) {
123
274
  const base = path.join(getHistoryDir(), 'backups', agent) + path.sep;
124
275
  if (filePath.startsWith(base)) {
@@ -168,12 +319,74 @@ function buildQueryOptions(options, agents, opts) {
168
319
  sortBy: options?.sortBy,
169
320
  };
170
321
  }
171
- /** Resolve and canonicalize a working directory path (follows symlinks). */
322
+ /**
323
+ * Canonicalize a working directory path (follows symlinks when it is local).
324
+ *
325
+ * Most callers pass a cwd RECORDED in a transcript, which may name a directory
326
+ * on another machine — a POSIX path read on a Windows host, say. `path.resolve()`
327
+ * rebases such a path onto the current drive (`/Users/me` -> `D:\Users\me`),
328
+ * inventing a location that never existed. So an absolute path is normalized but
329
+ * never rebased; only a genuinely relative one resolves against the process cwd.
330
+ *
331
+ * `path.normalize()` still runs on every branch: it collapses `.`, `..`, and
332
+ * duplicate separators, and folds separators on Windows. Both sides of the cwd
333
+ * filter in `db.ts` (`cwd = ?` and `cwd LIKE ? || path.sep || '%'`) come through
334
+ * here, so dropping that would leave a trailing slash or a `..` segment in one
335
+ * side and match nothing.
336
+ *
337
+ * Realpath is attempted only for a path that is absolute in THIS platform's
338
+ * terms. A POSIX-rooted path on Windows is drive-relative to `fs.realpathSync`,
339
+ * which would resolve `/Users/me` against the current drive and reintroduce the
340
+ * graft for any path that happens to exist locally.
341
+ */
342
+ export function _normalizeCwdForTest(cwd) {
343
+ return normalizeCwd(cwd);
344
+ }
172
345
  function normalizeCwd(cwd) {
173
346
  if (!cwd)
174
347
  return '';
175
- const resolved = path.resolve(cwd);
176
- return safeRealpathSync(resolved) || resolved;
348
+ // A POSIX-rooted path on Windows belongs to another machine. Normalize it with
349
+ // POSIX rules so its separators survive — path.win32.normalize would fold them
350
+ // to backslashes, mangling the very path we are trying to preserve — and never
351
+ // realpath it, since fs.realpathSync would resolve it against the current drive.
352
+ if (process.platform === 'win32' && /^\//.test(cwd) && !/^[a-zA-Z]:/.test(cwd)) {
353
+ return stripTrailingSep(path.posix.normalize(cwd));
354
+ }
355
+ const normalized = path.isAbsolute(cwd) ? stripTrailingSep(path.normalize(cwd)) : path.resolve(cwd);
356
+ return safeRealpathSync(normalized) || normalized;
357
+ }
358
+ /** Drop a trailing separator so `cwd = ?` and the `cwd LIKE ? + sep` subdir
359
+ * wildcard agree; a root path (`/`, `C:\`) keeps its separator. */
360
+ function stripTrailingSep(p) {
361
+ const stripped = p.replace(/[\\/]+$/, '');
362
+ return stripped.length > 0 && !/^[a-zA-Z]:$/.test(stripped) ? stripped : p;
363
+ }
364
+ /** Canonical 8-4-4-4-12 hex UUID (covers both v4 and the v7 ids newer harnesses mint). */
365
+ const UUID_36 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
366
+ /** kimi and rush mint `session_` + a UUID. */
367
+ const SESSION_UUID_PREFIX = /^session_/;
368
+ /** opencode mints `ses_` + a 26-char ULID — NOT a UUID, so it needs its own shape. */
369
+ const SES_ULID = /^ses_[0-9a-z]{26}$/;
370
+ /**
371
+ * Whether a query is a session id in full, rather than an id prefix or a search
372
+ * phrase.
373
+ *
374
+ * Callers use this to decide that an id lookup is the ONLY admissible
375
+ * interpretation: a complete id is unique, so when it misses there is nothing
376
+ * left to widen to. Without the check, `sessions <uuid>` fell through to the
377
+ * FTS content search, which tokenizes the UUID and matches every transcript that
378
+ * merely mentions it — surfacing unrelated sessions as if they were id matches.
379
+ *
380
+ * The accepted shapes are the ones the index actually holds, measured over a
381
+ * 12,507-row index: a bare UUID (11,116 rows), `session_` + UUID (1,360 — kimi
382
+ * and rush), and `ses_` + ULID (15 — opencode). Deliberately NOT covered, so a
383
+ * miss keeps today's search behavior rather than gaining a wrong error: routine
384
+ * run ids (ISO timestamps, matched via `routineRunId` below) and cloud execution
385
+ * ids, whose charset is too permissive to distinguish from a search phrase.
386
+ */
387
+ export function isCompleteSessionId(query) {
388
+ const q = query.trim().toLowerCase();
389
+ return UUID_36.test(q.replace(SESSION_UUID_PREFIX, '')) || SES_ULID.test(q);
177
390
  }
178
391
  /**
179
392
  * Resolve a session by full or short ID. Accepts a pre-loaded session list
@@ -230,26 +443,39 @@ export function searchContentIndex(sessions, query) {
230
443
  * growing transcript over and over. The cached row is good enough for a few
231
444
  * seconds; once writes settle or the debounce expires, the file is parsed once.
232
445
  */
233
- function filterChangedFiles(filePaths) {
234
- const ledger = getScanStampsForPaths(filePaths);
235
- const out = [];
236
- const now = Date.now();
446
+ export function filterChangedFiles(filePaths) {
447
+ const entries = [];
237
448
  for (const filePath of filePaths) {
238
449
  const stat = safeStatSync(filePath);
239
450
  if (!stat)
240
451
  continue;
452
+ entries.push({ filePath, fileMtimeMs: stat.mtimeMs, fileSize: stat.size });
453
+ }
454
+ return filterChangedEntries(entries);
455
+ }
456
+ /**
457
+ * Ledger-compare pre-stat'd entries (from the walk's own stat) without a second
458
+ * stat. Same debounce and change-detection as filterChangedFiles; the raw
459
+ * mtime is floored here so warm files match the ledger exactly as the stat path
460
+ * does (Math.floor(stat.mtimeMs)).
461
+ */
462
+ export function filterChangedEntries(entries) {
463
+ const ledger = getScanStampsForPaths(entries.map(e => e.filePath));
464
+ const out = [];
465
+ const now = Date.now();
466
+ for (const entry of entries) {
241
467
  const scan = {
242
- fileMtimeMs: Math.floor(stat.mtimeMs),
243
- fileSize: stat.size,
468
+ fileMtimeMs: Math.floor(entry.fileMtimeMs),
469
+ fileSize: entry.fileSize,
244
470
  };
245
- const prev = ledger.get(filePath);
471
+ const prev = ledger.get(entry.filePath);
246
472
  if (prev && prev.fileMtimeMs === scan.fileMtimeMs && prev.fileSize === scan.fileSize) {
247
473
  continue;
248
474
  }
249
475
  if (prev && shouldDeferRecentAppend(prev, scan, now)) {
250
476
  continue;
251
477
  }
252
- out.push({ filePath, scan });
478
+ out.push({ filePath: entry.filePath, scan });
253
479
  }
254
480
  return out;
255
481
  }
@@ -262,6 +488,96 @@ export function shouldDeferRecentAppend(prev, current, nowMs, debounceMs = ACTIV
262
488
  return false;
263
489
  return nowMs - prev.scannedAt < debounceMs;
264
490
  }
491
+ /**
492
+ * Walk a set of leaf transcript directories and return the files that changed,
493
+ * skipping the per-file `stat` of directories whose (mtime, entry_count) matches
494
+ * the dir_ledger.
495
+ *
496
+ * Per leaf dir:
497
+ * - `stat` the dir once and `readdir` it (one cheap syscall) to get the entry
498
+ * count and the file list.
499
+ * - If the dir matches the dir_ledger (floored mtime AND entry_count), no file
500
+ * was created / deleted / renamed since we last walked it. We then stat ONLY
501
+ * the hot files (live-root files, or files scanned within HOT_FILE_WINDOW_MS)
502
+ * and run just those through the ledger compare — so an in-place append to a
503
+ * still-live session is still caught, while immutable backup/version dirs
504
+ * collapse to a single dir stat and zero per-file stats.
505
+ * - Else (changed dir, or no ledger row) we stat every file (today's full
506
+ * walk) and record the fresh dir stamp so the next run can short-circuit.
507
+ *
508
+ * The kill-switch (`AGENTS_SESSIONS_NO_DIR_LEDGER=1`) forces the full-walk branch
509
+ * for every dir and never consults or records the dir_ledger.
510
+ */
511
+ export function collectChangedFilesInLeafDirs(leafDirs, ext) {
512
+ const disabled = dirLedgerDisabled();
513
+ const dirStamps = disabled ? new Map() : getDirLedgerForPaths(leafDirs.map(d => d.dirPath));
514
+ const now = Date.now();
515
+ // Files whose per-file stat we still need to ledger-compare this run.
516
+ const toCompare = [];
517
+ const allFiles = [];
518
+ const dirScansToRecord = [];
519
+ for (const { dirPath, isLiveRoot } of leafDirs) {
520
+ const dirStat = safeStatSync(dirPath);
521
+ if (!dirStat?.isDirectory())
522
+ continue;
523
+ let names;
524
+ try {
525
+ names = fs.readdirSync(dirPath).filter(f => f.endsWith(ext));
526
+ }
527
+ catch {
528
+ continue;
529
+ }
530
+ const files = names.map(f => path.join(dirPath, f));
531
+ for (const filePath of files)
532
+ allFiles.push({ filePath, isLiveRoot });
533
+ const dirMtimeMs = Math.floor(dirStat.mtimeMs);
534
+ const entryCount = names.length;
535
+ const prevDir = dirStamps.get(dirPath);
536
+ const dirUnchanged = !disabled && prevDir !== undefined && prevDir.dirMtimeMs === dirMtimeMs && prevDir.entryCount === entryCount;
537
+ if (dirUnchanged) {
538
+ // Contents did not change (no create/delete/rename). Stat only the hot
539
+ // files; the rest are served from the DB with no stat. An immutable backup
540
+ // dir (not a live root, nothing recently scanned) does zero per-file stats.
541
+ //
542
+ // A live-root dir treats every file as hot — that is the tree an agent
543
+ // appends to live, and an append does NOT bump the parent-dir mtime, so
544
+ // without this a growing session would be silently skipped. A non-live
545
+ // file is hot only if it was scanned within HOT_FILE_WINDOW_MS (bulk ledger
546
+ // lookup), covering a session under a version/backup path that is somehow
547
+ // still being written.
548
+ const stamps = isLiveRoot ? null : getScanStampsForPaths(files);
549
+ for (const filePath of files) {
550
+ let hot = isLiveRoot;
551
+ if (!hot && stamps) {
552
+ const s = stamps.get(filePath);
553
+ hot = s?.scannedAt !== undefined && now - s.scannedAt <= HOT_FILE_WINDOW_MS;
554
+ }
555
+ if (!hot)
556
+ continue;
557
+ const stat = safeStatSync(filePath);
558
+ if (!stat)
559
+ continue;
560
+ toCompare.push({ filePath, fileMtimeMs: stat.mtimeMs, fileSize: stat.size });
561
+ }
562
+ // No dir stamp to record — nothing about the dir changed.
563
+ }
564
+ else {
565
+ // Changed dir (or cold ledger): full per-file stat, exactly as today.
566
+ for (const filePath of files) {
567
+ const stat = safeStatSync(filePath);
568
+ if (!stat)
569
+ continue;
570
+ toCompare.push({ filePath, fileMtimeMs: stat.mtimeMs, fileSize: stat.size });
571
+ }
572
+ if (!disabled)
573
+ dirScansToRecord.push({ dirPath, dirMtimeMs, entryCount });
574
+ }
575
+ }
576
+ const changed = filterChangedEntries(toCompare);
577
+ if (dirScansToRecord.length > 0)
578
+ recordDirScans(dirScansToRecord);
579
+ return { changed, allFiles };
580
+ }
265
581
  // ---------------------------------------------------------------------------
266
582
  // Multi-version directory scanning
267
583
  // ---------------------------------------------------------------------------
@@ -294,6 +610,17 @@ export function getAgentSessionDirs(agent, subdir) {
294
610
  try {
295
611
  for (const version of fs.readdirSync(versionsBase)) {
296
612
  addDir(path.join(versionsBase, version, 'home', configDirName, subdir));
613
+ // Codex's managed home is not always where the version layout says. On macOS
614
+ // the versioned path overflows SUN_LEN (104 bytes) for codex's control
615
+ // socket, so the shim relocates the home to
616
+ // `<agentsUserDir>/.codex-homes/<version>/.codex` (lib/codex-home.ts). Every
617
+ // transcript an isolated codex writes lands there, and nothing scanned it —
618
+ // `agents sessions --roots` listed only the user's own ~/.codex, so a managed
619
+ // copy's own history was invisible. addDir skips what does not exist, so this
620
+ // is inert on Linux and for versions that never needed relocating.
621
+ if (agent === 'codex') {
622
+ addDir(path.join(shortCodexHome(getUserAgentsDir(), version), subdir));
623
+ }
297
624
  }
298
625
  }
299
626
  catch { /* dir unreadable */ }
@@ -324,6 +651,7 @@ const SESSION_ROOT_SPECS = [
324
651
  { agent: 'antigravity', subdir: 'conversations' },
325
652
  { agent: 'droid', subdir: 'sessions' },
326
653
  { agent: 'kimi', subdir: 'sessions' },
654
+ { agent: 'grok', subdir: 'sessions' },
327
655
  ];
328
656
  function sessionRootSubdir(agent) {
329
657
  return SESSION_ROOT_SPECS.find((spec) => spec.agent === agent)?.subdir ?? null;
@@ -402,7 +730,14 @@ async function readRoutineArchiveMeta(agent, filePath) {
402
730
  return null;
403
731
  if (agent === 'claude') {
404
732
  const sessionId = path.basename(filePath).replace(/\.jsonl$/, '');
405
- const result = await readClaudeMeta(filePath, sessionId);
733
+ // Routine archives are finalized, immutable transcripts — no live append, so
734
+ // no continuation to resume. A FULL parse (undefined prior) is correct here;
735
+ // the returned continuation is unused by this archive path.
736
+ const stat = safeStatSync(filePath);
737
+ if (!stat)
738
+ return null;
739
+ const scanStamp = { fileMtimeMs: Math.floor(stat.mtimeMs), fileSize: stat.size };
740
+ const result = await readClaudeMeta(filePath, sessionId, scanStamp, undefined);
406
741
  return result ? { ...result, meta: decorateRoutineSession(result.meta, info) } : null;
407
742
  }
408
743
  if (agent === 'codex') {
@@ -416,12 +751,13 @@ async function scanRoutineArchivesIncremental(agent, onProgress) {
416
751
  if (!subdir)
417
752
  return;
418
753
  const ext = agent === 'gemini' ? '.json' : '.jsonl';
419
- const filePaths = [];
754
+ const prestat = [];
420
755
  for (const sessionsDir of getRoutineArchiveSessionDirs(agent, subdir)) {
421
- for (const fp of walkForFiles(sessionsDir, ext, 100_000))
422
- filePaths.push(fp);
756
+ for (const f of walkForFilesWithStat(sessionsDir, ext, 100_000)) {
757
+ prestat.push({ filePath: f.path, fileMtimeMs: f.mtimeMs, fileSize: f.size });
758
+ }
423
759
  }
424
- const changed = filterChangedFiles(filePaths);
760
+ const changed = filterChangedEntries(prestat);
425
761
  if (changed.length === 0)
426
762
  return;
427
763
  onProgress?.({ agent, parsed: 0, total: changed.length });
@@ -533,40 +869,64 @@ export function buildClaudeLabelMap() {
533
869
  async function scanClaudeIncremental(onProgress) {
534
870
  const account = getClaudeAccount();
535
871
  const labelMap = buildClaudeLabelMap();
536
- const filePaths = [];
537
- const seen = new Set();
538
- for (const projectsDir of getAgentSessionDirs('claude', 'projects')) {
872
+ // Enumerate every leaf project dir across all Claude roots. The FIRST root
873
+ // returned by getAgentSessionDirs is the agent's live `~/.claude/projects` —
874
+ // the only tree Claude appends to in place, so its project dirs are live roots
875
+ // (every file hot). Version-home + backup roots are immutable: their dirs
876
+ // short-circuit to a single dir stat when unchanged.
877
+ const roots = getAgentSessionDirs('claude', 'projects');
878
+ const leafDirs = [];
879
+ const seenLeaf = new Set();
880
+ roots.forEach((projectsDir, rootIdx) => {
881
+ const isLiveRoot = rootIdx === 0;
539
882
  let projectDirs;
540
883
  try {
541
884
  projectDirs = fs.readdirSync(projectsDir);
542
885
  }
543
886
  catch {
544
- continue;
887
+ return;
545
888
  }
546
889
  for (const dirName of projectDirs) {
547
890
  const dirPath = path.join(projectsDir, dirName);
548
- const stat = safeStatSync(dirPath);
549
- if (!stat?.isDirectory())
550
- continue;
551
- let files;
552
- try {
553
- files = fs.readdirSync(dirPath).filter(f => f.endsWith('.jsonl'));
554
- }
555
- catch {
891
+ const key = safeRealpathSync(dirPath) || dirPath;
892
+ if (seenLeaf.has(key))
556
893
  continue;
557
- }
558
- for (const file of files) {
559
- const sessionId = file.replace('.jsonl', '');
560
- if (seen.has(sessionId))
561
- continue;
562
- seen.add(sessionId);
563
- filePaths.push(path.join(dirPath, file));
564
- }
894
+ seenLeaf.add(key);
895
+ leafDirs.push({ dirPath, isLiveRoot });
565
896
  }
566
- }
567
- const changed = filterChangedFiles(filePaths);
897
+ });
898
+ const { changed: changedAll, allFiles } = collectChangedFilesInLeafDirs(leafDirs, '.jsonl');
899
+ // Restore the pre-A-2 cross-root precedence: a session id present in multiple
900
+ // roots is ALWAYS served from its live path, never a frozen backup/version
901
+ // copy. Pre-A-2, dedup happened at enumeration time via a live-first `seen`
902
+ // set, so a non-live copy was never even stat'd when a live copy existed. This
903
+ // PR must not regress that to "whichever copy changed this run wins" — a cold
904
+ // (unchanged) live copy paired with a freshly-written backup snapshot would
905
+ // otherwise flip the row's file_path to the backup path.
906
+ //
907
+ // allFiles is every transcript file across all roots in live-first order, so
908
+ // the FIRST occurrence of each session id is its live (or highest-precedence)
909
+ // path — the durable winner, independent of which copy was flagged changed.
910
+ const sessionIdOf = (fp) => path.basename(fp).replace('.jsonl', '');
911
+ const winnerBySession = new Map();
912
+ for (const { filePath } of allFiles) {
913
+ const id = sessionIdOf(filePath);
914
+ if (!winnerBySession.has(id))
915
+ winnerBySession.set(id, filePath);
916
+ }
917
+ // Keep a changed entry only if it is its session's winner. A changed non-live
918
+ // copy is dropped whenever a live copy exists anywhere; the winning path is
919
+ // parsed only when it itself changed (a cold winner needs no re-parse — its DB
920
+ // row already points at the live path).
921
+ const changed = changedAll.filter(e => winnerBySession.get(sessionIdOf(e.filePath)) === e.filePath);
568
922
  if (changed.length > 0) {
569
923
  onProgress?.({ agent: 'claude', parsed: 0, total: changed.length });
924
+ // Bulk-fetch each changed file's prior resumable continuation. A file with a
925
+ // usable prior state + growth goes incremental (re-parse only the appended
926
+ // bytes); everything else does a FULL from-offset-0 parse. The decision + the
927
+ // parse both live in scanClaudeSessionResumable so full and incremental share
928
+ // one reducer and produce identical rows.
929
+ const priorStates = getParserStatesForPaths(changed.map(c => c.filePath));
570
930
  const entries = [];
571
931
  const touched = [];
572
932
  let parsed = 0;
@@ -574,9 +934,16 @@ async function scanClaudeIncremental(onProgress) {
574
934
  try {
575
935
  const sessionId = path.basename(filePath).replace('.jsonl', '');
576
936
  const label = labelMap.get(sessionId) ?? undefined;
577
- const result = await readClaudeMeta(filePath, sessionId, account, label);
937
+ const priorRow = priorStates.get(filePath);
938
+ const result = await readClaudeMeta(filePath, sessionId, scan, priorRow, account, label);
578
939
  if (result) {
579
- entries.push({ meta: result.meta, content: result.content, scan });
940
+ entries.push({
941
+ meta: result.meta,
942
+ content: result.content,
943
+ scan,
944
+ parserState: result.parserState,
945
+ contentText: result.contentText,
946
+ });
580
947
  }
581
948
  else {
582
949
  touched.push({ filePath, scan });
@@ -596,16 +963,27 @@ async function scanClaudeIncremental(onProgress) {
596
963
  if (labelMap.size > 0)
597
964
  syncLabels(labelMap);
598
965
  }
599
- /** Stream-parse a single Claude JSONL file to extract session metadata. */
600
- async function readClaudeMeta(filePath, sessionId, account, label) {
601
- const scan = await scanClaudeSession(filePath);
966
+ /**
967
+ * Stream-parse a single Claude JSONL file to extract session metadata, resuming
968
+ * from the persisted continuation when the file merely grew (see
969
+ * {@link scanClaudeSessionResumable}). Returns the row's meta + FTS content plus
970
+ * the serialized continuation (parser_state + content_text) to persist for the
971
+ * next scan.
972
+ */
973
+ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, account, label) {
974
+ const prior = parsePriorClaudeState(priorRow);
975
+ const { scan, newState, mode } = await scanClaudeSessionResumable(filePath, prior, scanStamp.fileMtimeMs, scanStamp.fileSize, priorRow?.fileMtimeMs);
976
+ if (mode === 'incremental')
977
+ claudeIncrementalScanCount++;
978
+ else
979
+ claudeFullScanCount++;
602
980
  const isTeamOrigin = scan.entrypoint === 'sdk-cli';
603
981
  let meta;
604
982
  if (scan.timestamp) {
605
983
  const cwd = normalizeCwd(scan.cwd || '');
606
984
  meta = {
607
985
  id: sessionId,
608
- shortId: sessionId.slice(0, 8),
986
+ shortId: deriveShortId(sessionId),
609
987
  agent: 'claude',
610
988
  timestamp: scan.timestamp,
611
989
  lastActivity: scan.lastActivity,
@@ -630,13 +1008,15 @@ async function readClaudeMeta(filePath, sessionId, account, label) {
630
1008
  createdTickets: scan.createdTickets,
631
1009
  spawnedTeam: scan.spawnedTeam,
632
1010
  plan: scan.plan,
1011
+ todos: scan.todos,
1012
+ recentDirectoriesTouched: scan.recentDirectoriesTouched,
633
1013
  };
634
1014
  }
635
1015
  else {
636
1016
  const stat = safeStatSync(filePath);
637
1017
  meta = {
638
1018
  id: sessionId,
639
- shortId: sessionId.slice(0, 8),
1019
+ shortId: deriveShortId(sessionId),
640
1020
  agent: 'claude',
641
1021
  timestamp: stat ? stat.mtime.toISOString() : new Date().toISOString(),
642
1022
  lastActivity: scan.lastActivity,
@@ -657,9 +1037,19 @@ async function readClaudeMeta(filePath, sessionId, account, label) {
657
1037
  createdTickets: scan.createdTickets,
658
1038
  spawnedTeam: scan.spawnedTeam,
659
1039
  plan: scan.plan,
1040
+ todos: scan.todos,
1041
+ recentDirectoriesTouched: scan.recentDirectoriesTouched,
660
1042
  };
661
1043
  }
662
- return { meta, content: scan.contentText || '' };
1044
+ return {
1045
+ meta,
1046
+ content: scan.contentText || '',
1047
+ // Persist the continuation so the next scan of this file can resume from the
1048
+ // offset instead of a full reparse. content_text is the same accumulated user
1049
+ // doc, cached so the resume can hydrate userTexts without re-reading the file.
1050
+ parserState: JSON.stringify(newState),
1051
+ contentText: newState.contentText,
1052
+ };
663
1053
  }
664
1054
  // ---------------------------------------------------------------------------
665
1055
  // Codex account info
@@ -745,37 +1135,58 @@ async function scanCodexIncremental(onProgress) {
745
1135
  // readCodexMeta when a changed session actually needs it — never eagerly here,
746
1136
  // so a no-op scan (changed.length === 0) never touches the credential file.
747
1137
  const currentVersion = await getCurrentAgentVersion('codex');
748
- const filePaths = [];
1138
+ const prestat = [];
749
1139
  for (const sessionsDir of getAgentSessionDirs('codex', 'sessions')) {
750
- // High limit: we only stat files here, parsing is gated by ledger match.
751
- for (const fp of walkForFiles(sessionsDir, '.jsonl', 100_000)) {
752
- filePaths.push(fp);
1140
+ // High limit: the walk stats each file once here; parsing is gated by the
1141
+ // ledger match below, which reuses that stat instead of re-stat'ing.
1142
+ for (const f of walkForFilesWithStat(sessionsDir, '.jsonl', 100_000)) {
1143
+ prestat.push({ filePath: f.path, fileMtimeMs: f.mtimeMs, fileSize: f.size });
753
1144
  }
754
1145
  }
755
- const changed = filterChangedFiles(filePaths);
1146
+ const changed = filterChangedEntries(prestat);
756
1147
  // Codex keeps human-readable titles (`thread_name`) in `session_index.jsonl`,
757
- // which updates independently of the rollout files apply them by id on every
758
- // scan so a title that lands after a session was first indexed still surfaces.
1148
+ // which updates independently of the rollout files. Stat each index against the
1149
+ // ledger *without reading it*; only read + re-apply titles when the index (or a
1150
+ // rollout) actually changed. On a fully unchanged scan this collapses to a
1151
+ // couple of stat() calls instead of a full read + a `syncTopics` DB pass.
1152
+ const titleIndex = diffCodexTitleIndexes();
1153
+ if (changed.length === 0 && !titleIndex.changed)
1154
+ return;
759
1155
  const titles = readCodexThreadNames();
760
1156
  if (changed.length === 0) {
1157
+ // No rollouts changed, but the title index did — apply the new titles.
761
1158
  syncTopics(titles);
1159
+ recordScans(titleIndex.stamps);
762
1160
  return;
763
1161
  }
764
1162
  onProgress?.({ agent: 'codex', parsed: 0, total: changed.length });
1163
+ // Bulk-fetch each changed rollout's prior resumable continuation. A file with
1164
+ // a usable prior state + growth goes incremental (re-parse only the appended
1165
+ // bytes); everything else does a FULL from-offset-0 parse. The decision + the
1166
+ // parse both live in scanCodexSessionResumable so full and incremental share
1167
+ // one reducer and produce identical rows.
1168
+ const priorStates = getParserStatesForPaths(changed.map(c => c.filePath));
765
1169
  const entries = [];
766
1170
  const touched = [];
767
1171
  const seen = new Set();
768
1172
  let parsed = 0;
769
1173
  for (const { filePath, scan } of changed) {
770
1174
  try {
771
- const result = await readCodexMeta(filePath, getCodexAccount, currentVersion);
1175
+ const priorRow = priorStates.get(filePath);
1176
+ const result = await readCodexMeta(filePath, getCodexAccount, currentVersion, scan, priorRow);
772
1177
  if (result && !seen.has(result.meta.id)) {
773
1178
  seen.add(result.meta.id);
774
1179
  // Prefer the Codex-generated title over the first-prompt fallback.
775
1180
  const title = titles.get(result.meta.id);
776
1181
  if (title)
777
1182
  result.meta.topic = title;
778
- entries.push({ meta: result.meta, content: result.content, scan });
1183
+ entries.push({
1184
+ meta: result.meta,
1185
+ content: result.content,
1186
+ scan,
1187
+ parserState: result.parserState,
1188
+ contentText: result.contentText,
1189
+ });
779
1190
  }
780
1191
  else {
781
1192
  touched.push({ filePath, scan });
@@ -789,9 +1200,12 @@ async function scanCodexIncremental(onProgress) {
789
1200
  }
790
1201
  upsertSessionsBatch(entries);
791
1202
  recordScans(touched);
792
- // Catch sessions whose rollout file was unchanged but gained a title since the
793
- // last scan (the index changed, the transcript did not).
794
- syncTopics(titles);
1203
+ // Only when the title index changed can an *unchanged* rollout have gained a
1204
+ // title since the last scan; the inline titles applied above already cover
1205
+ // every changed session, so skip the extra sync when the index is untouched.
1206
+ if (titleIndex.changed)
1207
+ syncTopics(titles);
1208
+ recordScans(titleIndex.stamps);
795
1209
  }
796
1210
  /** Parse the lines of a Codex `session_index.jsonl` into a session id -> title map. */
797
1211
  export function parseCodexThreadNameIndex(raw) {
@@ -812,6 +1226,32 @@ export function parseCodexThreadNameIndex(raw) {
812
1226
  }
813
1227
  return titles;
814
1228
  }
1229
+ /**
1230
+ * Stat every Codex `session_index.jsonl` and diff it against the scan ledger
1231
+ * *without reading it*. Returns the fresh stamps (persisted only after a
1232
+ * successful title sync) and whether any index changed since the last scan — the
1233
+ * signal that lets a no-op scan skip the file read + `syncTopics` entirely.
1234
+ *
1235
+ * The index path is a sibling of `sessions/` (never inside it), so it is never
1236
+ * walked as a rollout and its ledger row can't collide with a transcript's.
1237
+ */
1238
+ function diffCodexTitleIndexes() {
1239
+ const stamps = [];
1240
+ let changed = false;
1241
+ for (const sessionsDir of getAgentSessionDirs('codex', 'sessions')) {
1242
+ const indexPath = path.join(path.dirname(sessionsDir), 'session_index.jsonl');
1243
+ const stat = safeStatSync(indexPath);
1244
+ if (!stat)
1245
+ continue; // no index in this home
1246
+ const scan = { fileMtimeMs: Math.floor(stat.mtimeMs), fileSize: stat.size };
1247
+ const prev = getScanStampByPath(indexPath);
1248
+ if (!prev || prev.fileMtimeMs !== scan.fileMtimeMs || prev.fileSize !== scan.fileSize) {
1249
+ changed = true;
1250
+ }
1251
+ stamps.push({ filePath: indexPath, scan });
1252
+ }
1253
+ return { stamps, changed };
1254
+ }
815
1255
  /**
816
1256
  * Read Codex session titles across every Codex home (live + versioned). The
817
1257
  * `session_index.jsonl` file sits beside each `sessions/` rollout tree.
@@ -839,15 +1279,36 @@ function readCodexThreadNames() {
839
1279
  * performs is deferred until we know this file is a real session worth building
840
1280
  * metadata for, and only then — never during the file walk / stat phase.
841
1281
  */
842
- export async function readCodexMeta(filePath, resolveAccount, currentVersion) {
843
- const scan = await scanCodexSession(filePath);
1282
+ export async function readCodexMeta(filePath, resolveAccount, currentVersion, scanStamp, priorRow) {
1283
+ // Resume from the persisted continuation when the file merely grew; otherwise
1284
+ // full-parse from byte 0. Both branches share one reducer, so an append yields
1285
+ // a row identical to a from-scratch reparse. When no stamp is supplied (a
1286
+ // caller outside the live scan path), fall back to a plain full parse with no
1287
+ // continuation to persist.
1288
+ let scan;
1289
+ let newState;
1290
+ let newOffset = 0;
1291
+ if (scanStamp) {
1292
+ const prior = parsePriorCodexState(priorRow);
1293
+ const result = await scanCodexSessionResumable(filePath, prior, scanStamp.fileMtimeMs, scanStamp.fileSize, priorRow?.fileMtimeMs);
1294
+ if (result.mode === 'incremental')
1295
+ codexIncrementalScanCount++;
1296
+ else
1297
+ codexFullScanCount++;
1298
+ scan = result.scan;
1299
+ newState = result.newState;
1300
+ newOffset = result.newOffset;
1301
+ }
1302
+ else {
1303
+ scan = await scanCodexSession(filePath);
1304
+ }
844
1305
  const sessionId = scan.sessionId || '';
845
1306
  if (!sessionId)
846
1307
  return null;
847
1308
  const cwd = normalizeCwd(scan.cwd || '');
848
1309
  const meta = {
849
1310
  id: sessionId,
850
- shortId: sessionId.slice(0, 8),
1311
+ shortId: deriveShortId(sessionId),
851
1312
  agent: 'codex',
852
1313
  // Codex `session_meta` only carries the start time; use file mtime when
853
1314
  // it's newer so long-running sessions register as recently active.
@@ -871,8 +1332,18 @@ export async function readCodexMeta(filePath, resolveAccount, currentVersion) {
871
1332
  ticketId: scan.ticketId,
872
1333
  createdTickets: scan.createdTickets,
873
1334
  spawnedTeam: scan.spawnedTeam,
1335
+ todos: scan.todos,
1336
+ recentDirectoriesTouched: scan.recentDirectoriesTouched,
1337
+ };
1338
+ return {
1339
+ meta,
1340
+ content: scan.contentText || '',
1341
+ // Persist the continuation so the next scan of this rollout resumes from the
1342
+ // offset instead of a full reparse; content_text caches the accumulated user
1343
+ // doc for the resume's hydrate. Absent when no stamp was supplied.
1344
+ parserState: newState ? JSON.stringify(newState) : undefined,
1345
+ contentText: newState?.contentText,
874
1346
  };
875
- return { meta, content: scan.contentText || '' };
876
1347
  }
877
1348
  /**
878
1349
  * Codex writes `session_meta` (with the start timestamp) on the first line of a
@@ -901,33 +1372,34 @@ function pickLatestCodexTimestamp(metaTimestamp, filePath) {
901
1372
  async function scanGeminiIncremental(onProgress) {
902
1373
  const currentVersion = await getCurrentAgentVersion('gemini');
903
1374
  const projectMap = buildGeminiProjectMap();
904
- const filePaths = [];
905
- for (const tmpDir of getAgentSessionDirs('gemini', 'tmp')) {
1375
+ // Each `<tmpDir>/<hashDir>/chats` is a leaf dir of Gemini transcripts. The
1376
+ // FIRST tmp root is the live `~/.gemini/tmp` — its chats dirs are live roots;
1377
+ // version-home + backup roots are immutable and short-circuit when unchanged.
1378
+ const tmpRoots = getAgentSessionDirs('gemini', 'tmp');
1379
+ const leafDirs = [];
1380
+ const seenLeaf = new Set();
1381
+ tmpRoots.forEach((tmpDir, rootIdx) => {
1382
+ const isLiveRoot = rootIdx === 0;
906
1383
  let hashDirs;
907
1384
  try {
908
1385
  hashDirs = fs.readdirSync(tmpDir);
909
1386
  }
910
1387
  catch {
911
- continue;
1388
+ return;
912
1389
  }
913
1390
  for (const hashDir of hashDirs) {
914
1391
  const chatsDir = path.join(tmpDir, hashDir, 'chats');
915
1392
  if (!fs.existsSync(chatsDir))
916
1393
  continue;
917
- let chatFiles;
918
- try {
919
- chatFiles = fs.readdirSync(chatsDir).filter(f => f.endsWith('.json'));
920
- }
921
- catch {
1394
+ const key = safeRealpathSync(chatsDir) || chatsDir;
1395
+ if (seenLeaf.has(key))
922
1396
  continue;
923
- }
924
- for (const file of chatFiles) {
925
- filePaths.push({ filePath: path.join(chatsDir, file), hashDir });
926
- }
1397
+ seenLeaf.add(key);
1398
+ leafDirs.push({ dirPath: chatsDir, isLiveRoot });
927
1399
  }
928
- }
929
- const changedPaths = filterChangedFiles(filePaths.map(f => f.filePath));
930
- const changedByPath = new Map(changedPaths.map(c => [c.filePath, c.scan]));
1400
+ });
1401
+ const { changed } = collectChangedFilesInLeafDirs(leafDirs, '.json');
1402
+ const changedByPath = new Map(changed.map(c => [c.filePath, c.scan]));
931
1403
  if (changedByPath.size === 0)
932
1404
  return;
933
1405
  onProgress?.({ agent: 'gemini', parsed: 0, total: changedByPath.size });
@@ -935,10 +1407,9 @@ async function scanGeminiIncremental(onProgress) {
935
1407
  const touched = [];
936
1408
  const seen = new Set();
937
1409
  let parsed = 0;
938
- for (const { filePath, hashDir } of filePaths) {
939
- const scan = changedByPath.get(filePath);
940
- if (!scan)
941
- continue;
1410
+ for (const { filePath, scan } of changed) {
1411
+ // The hashDir is the directory two levels up: <hashDir>/chats/<file>.json.
1412
+ const hashDir = path.basename(path.dirname(path.dirname(filePath)));
942
1413
  try {
943
1414
  const result = readGeminiMeta(filePath, hashDir, projectMap, currentVersion);
944
1415
  if (result && !seen.has(result.meta.id)) {
@@ -1058,7 +1529,7 @@ function readGeminiMeta(filePath, hashDir, projectMap, currentVersion) {
1058
1529
  : undefined;
1059
1530
  const meta = {
1060
1531
  id: sessionId,
1061
- shortId: sessionId.slice(0, 8),
1532
+ shortId: deriveShortId(sessionId),
1062
1533
  agent: 'gemini',
1063
1534
  timestamp: startTime || (stat ? stat.mtime.toISOString() : new Date().toISOString()),
1064
1535
  lastActivity: lastTsMs !== undefined ? new Date(lastTsMs).toISOString() : undefined,
@@ -1204,7 +1675,7 @@ function readAntigravityMeta(filePath, currentVersion) {
1204
1675
  const stat = safeStatSync(filePath);
1205
1676
  const meta = {
1206
1677
  id: sessionId,
1207
- shortId: sessionId.slice(0, 8),
1678
+ shortId: deriveShortId(sessionId),
1208
1679
  agent: 'antigravity',
1209
1680
  timestamp: stat ? stat.mtime.toISOString() : new Date().toISOString(),
1210
1681
  project: normalizedCwd ? path.basename(normalizedCwd) : undefined,
@@ -1332,7 +1803,7 @@ async function scanOpenCodeIncremental() {
1332
1803
  const topic = title || undefined;
1333
1804
  const meta = {
1334
1805
  id,
1335
- shortId: id.replace(/^ses_/, '').slice(0, 8),
1806
+ shortId: deriveShortId(id, /^ses_/),
1336
1807
  agent: 'opencode',
1337
1808
  timestamp,
1338
1809
  lastActivity,
@@ -1402,7 +1873,7 @@ async function scanOpenClawIncremental() {
1402
1873
  entries.push({
1403
1874
  meta: {
1404
1875
  id: `openclaw-${agentId}`,
1405
- shortId: agentId.slice(0, 8),
1876
+ shortId: deriveShortId(agentId),
1406
1877
  agent: 'openclaw',
1407
1878
  timestamp: new Date().toISOString(),
1408
1879
  project: name,
@@ -1438,7 +1909,7 @@ async function scanOpenClawIncremental() {
1438
1909
  entries.push({
1439
1910
  meta: {
1440
1911
  id: `openclaw-cron-${jobId}`,
1441
- shortId: jobId.slice(0, 8),
1912
+ shortId: deriveShortId(jobId),
1442
1913
  agent: 'openclaw',
1443
1914
  timestamp: new Date().toISOString(),
1444
1915
  project: `${jobName} (${agentId || 'unknown'})`,
@@ -1512,7 +1983,7 @@ async function readRushMeta(filePath, sessionId) {
1512
1983
  const stat = safeStatSync(filePath);
1513
1984
  const timestamp = scan.timestamp
1514
1985
  || (stat ? stat.mtime.toISOString() : new Date().toISOString());
1515
- const shortId = sessionId.replace(/^session_/, '').slice(0, 8);
1986
+ const shortId = deriveShortId(sessionId, /^session_/);
1516
1987
  const meta = {
1517
1988
  id: sessionId,
1518
1989
  shortId,
@@ -1672,7 +2143,7 @@ function readHermesMeta(filePath) {
1672
2143
  : typeof session.session_start === 'string'
1673
2144
  ? session.session_start
1674
2145
  : stat ? stat.mtime.toISOString() : new Date().toISOString();
1675
- const shortId = sessionId.replace(/^api-/, '').slice(0, 8);
2146
+ const shortId = deriveShortId(sessionId, /^api-/);
1676
2147
  const model = typeof session.model === 'string' ? session.model : undefined;
1677
2148
  const platform = typeof session.platform === 'string' ? session.platform : undefined;
1678
2149
  const meta = {
@@ -1713,14 +2184,15 @@ function extractHermesMessageText(content) {
1713
2184
  */
1714
2185
  async function scanDroidIncremental(onProgress) {
1715
2186
  const currentVersion = await getCurrentAgentVersion('droid');
1716
- const filePaths = [];
2187
+ const prestat = [];
1717
2188
  for (const sessionsDir of getAgentSessionDirs('droid', 'sessions')) {
1718
- // High limit: we only stat files here, parsing is gated by ledger match.
1719
- for (const fp of walkForFiles(sessionsDir, '.jsonl', 100_000)) {
1720
- filePaths.push(fp);
2189
+ // High limit: the walk stats each file once here; parsing is gated by the
2190
+ // ledger match below, which reuses that stat instead of re-stat'ing.
2191
+ for (const f of walkForFilesWithStat(sessionsDir, '.jsonl', 100_000)) {
2192
+ prestat.push({ filePath: f.path, fileMtimeMs: f.mtimeMs, fileSize: f.size });
1721
2193
  }
1722
2194
  }
1723
- const changed = filterChangedFiles(filePaths);
2195
+ const changed = filterChangedEntries(prestat);
1724
2196
  if (changed.length === 0)
1725
2197
  return;
1726
2198
  onProgress?.({ agent: 'droid', parsed: 0, total: changed.length });
@@ -1772,7 +2244,7 @@ async function readDroidMeta(filePath, currentVersion) {
1772
2244
  const cwd = normalizeCwd(scan.cwd || '');
1773
2245
  const meta = {
1774
2246
  id: sessionId,
1775
- shortId: sessionId.slice(0, 8),
2247
+ shortId: deriveShortId(sessionId),
1776
2248
  agent: 'droid',
1777
2249
  timestamp: scan.timestamp || (stat ? stat.mtime.toISOString() : new Date().toISOString()),
1778
2250
  lastActivity: scan.lastActivity,
@@ -1916,46 +2388,254 @@ function extractDroidMessageText(content) {
1916
2388
  .join('\n')
1917
2389
  .trim();
1918
2390
  }
2391
+ /** Zero-value accumulator for a fresh (from-byte-0) Claude parse. */
2392
+ export function initClaudeParseState() {
2393
+ return {
2394
+ timestamp: undefined,
2395
+ cwd: undefined,
2396
+ gitBranch: undefined,
2397
+ version: undefined,
2398
+ topic: undefined,
2399
+ customTitle: undefined,
2400
+ aiTitle: undefined,
2401
+ entrypoint: undefined,
2402
+ messageCount: 0,
2403
+ tokenCount: 0,
2404
+ outputTokens: 0,
2405
+ sawTokenCount: false,
2406
+ costUsd: 0,
2407
+ sawCost: false,
2408
+ firstTsMs: undefined,
2409
+ lastTsMs: undefined,
2410
+ seenAssistantIds: new Set(),
2411
+ userTexts: [],
2412
+ sawPrCreate: false,
2413
+ prUrl: undefined,
2414
+ prNumber: undefined,
2415
+ createdTickets: new Set(),
2416
+ pendingTicketTools: new Set(),
2417
+ spawnedTeam: undefined,
2418
+ plan: undefined,
2419
+ checklistEvents: [],
2420
+ recentDirectoriesTouched: [],
2421
+ };
2422
+ }
2423
+ const CHECKLIST_TOOLS = new Set(['TodoWrite', 'todo_write', 'update_plan', 'TaskCreate', 'TaskUpdate']);
2424
+ const DIRECTORY_TOOLS = new Set(['Edit', 'Write', 'edit_file', 'write_file', 'create_file', 'edit', 'write', 'Bash', 'exec_command', 'run_shell_command', 'shell', 'Execute']);
2425
+ function foldDerivedToolState(state, event) {
2426
+ if (CHECKLIST_TOOLS.has(event.tool ?? ''))
2427
+ state.checklistEvents.push(event);
2428
+ if (!DIRECTORY_TOOLS.has(event.tool ?? ''))
2429
+ return;
2430
+ const next = extractRecentDirectoriesTouched([event], state.cwd);
2431
+ for (const dir of next ?? []) {
2432
+ const old = state.recentDirectoriesTouched.indexOf(dir);
2433
+ if (old >= 0)
2434
+ state.recentDirectoriesTouched.splice(old, 1);
2435
+ state.recentDirectoriesTouched.push(dir);
2436
+ }
2437
+ if (state.recentDirectoriesTouched.length > 10)
2438
+ state.recentDirectoriesTouched.splice(0, state.recentDirectoriesTouched.length - 10);
2439
+ }
2440
+ /**
2441
+ * Fold one parsed transcript line into the accumulator. This is the exact loop
2442
+ * body {@link scanClaudeSession} used to run inline — extracted verbatim,
2443
+ * mutating `state.*` in place. `parsed` is the already-`JSON.parse`d line (the
2444
+ * malformed-line skip happens in the caller, as before).
2445
+ */
2446
+ export function applyClaudeLine(state, parsed) {
2447
+ // entrypoint ships on the first envelope event (attachment/user/assistant)
2448
+ // and is the clean structural signal for "was this a team spawn?"
2449
+ if (!state.entrypoint && typeof parsed.entrypoint === 'string') {
2450
+ state.entrypoint = parsed.entrypoint;
2451
+ }
2452
+ // Produced-artifact signals, structurally (independent of the PR gate below):
2453
+ // - a Bash `agents teams create/add` command → the team it spawned
2454
+ // - a Linear create_issue / `gh issue create` tool_use → its result carries
2455
+ // the new ticket ref, read from the matching tool_result.
2456
+ if (parsed.type === 'assistant' && Array.isArray(parsed.message?.content)) {
2457
+ for (const b of parsed.message.content) {
2458
+ if (b?.type !== 'tool_use')
2459
+ continue;
2460
+ if (!state.spawnedTeam && typeof b?.input?.command === 'string') {
2461
+ const team = detectSpawnedTeam(b.input.command);
2462
+ if (team)
2463
+ state.spawnedTeam = team;
2464
+ }
2465
+ if (typeof b?.id === 'string' && isTicketCreateTool(b?.name, b?.input?.command)) {
2466
+ state.pendingTicketTools.add(b.id);
2467
+ }
2468
+ // ExitPlanMode plan markdown — last one wins so a re-planned session
2469
+ // reports its most recent plan.
2470
+ if (b?.name === 'ExitPlanMode' && typeof b?.input?.plan === 'string') {
2471
+ const p = b.input.plan.trim();
2472
+ if (p)
2473
+ state.plan = b.input.plan;
2474
+ }
2475
+ foldDerivedToolState(state, {
2476
+ type: 'tool_use', agent: 'claude', timestamp: parsed.timestamp || '', tool: b?.name, args: b?.input || {},
2477
+ path: b?.input?.file_path || b?.input?.path, command: b?.input?.command,
2478
+ });
2479
+ }
2480
+ }
2481
+ if (state.pendingTicketTools.size > 0 && parsed.type === 'user' && Array.isArray(parsed.message?.content)) {
2482
+ for (const b of parsed.message.content) {
2483
+ if (b?.type !== 'tool_result' || typeof b?.tool_use_id !== 'string')
2484
+ continue;
2485
+ if (!state.pendingTicketTools.has(b.tool_use_id))
2486
+ continue;
2487
+ state.pendingTicketTools.delete(b.tool_use_id);
2488
+ const text = typeof b.content === 'string'
2489
+ ? b.content
2490
+ : Array.isArray(b.content) ? b.content.map((c) => c?.text || '').join('\n') : '';
2491
+ const t = extractCreatedTicket(text);
2492
+ if (t)
2493
+ state.createdTickets.add(t);
2494
+ }
2495
+ }
2496
+ // PR signal, structurally: a Bash tool_use whose command is `gh pr create`
2497
+ // marks intent; the pull URL is then read from a tool_result's output.
2498
+ if (!state.prUrl) {
2499
+ if (!state.sawPrCreate && parsed.type === 'assistant' && Array.isArray(parsed.message?.content)) {
2500
+ for (const b of parsed.message.content) {
2501
+ if (b?.type === 'tool_use' && typeof b?.input?.command === 'string' && isPrCreateCommand(b.input.command)) {
2502
+ state.sawPrCreate = true;
2503
+ }
2504
+ }
2505
+ }
2506
+ if (state.sawPrCreate && parsed.type === 'user' && Array.isArray(parsed.message?.content)) {
2507
+ for (const b of parsed.message.content) {
2508
+ if (b?.type !== 'tool_result')
2509
+ continue;
2510
+ const text = typeof b.content === 'string'
2511
+ ? b.content
2512
+ : Array.isArray(b.content) ? b.content.map((c) => c?.text || '').join('\n') : '';
2513
+ const pr = extractPrUrl(text);
2514
+ if (pr) {
2515
+ state.prUrl = pr.url;
2516
+ state.prNumber = pr.number;
2517
+ }
2518
+ }
2519
+ }
2520
+ }
2521
+ // Track duration across every timestamped event, not just the first.
2522
+ if (typeof parsed.timestamp === 'string') {
2523
+ const ms = new Date(parsed.timestamp).getTime();
2524
+ if (!Number.isNaN(ms)) {
2525
+ if (state.firstTsMs === undefined || ms < state.firstTsMs)
2526
+ state.firstTsMs = ms;
2527
+ if (state.lastTsMs === undefined || ms > state.lastTsMs)
2528
+ state.lastTsMs = ms;
2529
+ }
2530
+ }
2531
+ if (!state.timestamp && (parsed.type === 'user' || parsed.type === 'assistant') && parsed.timestamp) {
2532
+ state.timestamp = parsed.timestamp;
2533
+ state.cwd = parsed.cwd || '';
2534
+ state.gitBranch = parsed.gitBranch || undefined;
2535
+ state.version = parsed.version || undefined;
2536
+ }
2537
+ if (parsed.type === 'custom-title') {
2538
+ const t = typeof parsed.customTitle === 'string' ? parsed.customTitle.trim() : '';
2539
+ if (t)
2540
+ state.customTitle = t;
2541
+ return;
2542
+ }
2543
+ if (parsed.type === 'ai-title') {
2544
+ const t = typeof parsed.aiTitle === 'string' ? parsed.aiTitle.trim() : '';
2545
+ if (t)
2546
+ state.aiTitle = t;
2547
+ return;
2548
+ }
2549
+ if (parsed.type === 'user') {
2550
+ const text = extractClaudeUserText(parsed);
2551
+ if (text) {
2552
+ state.messageCount++;
2553
+ state.userTexts.push(text);
2554
+ if (!state.topic)
2555
+ state.topic = extractSessionTopic(text);
2556
+ }
2557
+ return;
2558
+ }
2559
+ if (parsed.type !== 'assistant')
2560
+ return;
2561
+ const assistantId = typeof parsed.message?.id === 'string'
2562
+ ? parsed.message.id
2563
+ : typeof parsed.uuid === 'string'
2564
+ ? parsed.uuid
2565
+ : undefined;
2566
+ const logicalId = assistantId || `${parsed.timestamp || ''}:${state.seenAssistantIds.size}`;
2567
+ if (state.seenAssistantIds.has(logicalId))
2568
+ return;
2569
+ state.seenAssistantIds.add(logicalId);
2570
+ state.messageCount++;
2571
+ const usageObj = parsed.message?.usage || parsed.usage;
2572
+ const usage = getClaudeUsageTotal(usageObj);
2573
+ if (usage !== null) {
2574
+ state.tokenCount += usage;
2575
+ state.sawTokenCount = true;
2576
+ }
2577
+ if (typeof usageObj?.output_tokens === 'number')
2578
+ state.outputTokens += usageObj.output_tokens;
2579
+ // Per-assistant-message cost: each event carries its own model, so we
2580
+ // multiply that event's raw token directions by that model's price.
2581
+ const model = parsed.message?.model;
2582
+ if (model && usageObj && typeof usageObj === 'object') {
2583
+ const eventCost = costOfUsage({
2584
+ model,
2585
+ inputTokens: usageObj.input_tokens,
2586
+ outputTokens: usageObj.output_tokens,
2587
+ cacheReadTokens: usageObj.cache_read_input_tokens,
2588
+ cacheCreationTokens: usageObj.cache_creation_input_tokens,
2589
+ });
2590
+ if (eventCost > 0) {
2591
+ state.costUsd += eventCost;
2592
+ state.sawCost = true;
2593
+ }
2594
+ }
2595
+ }
2596
+ /**
2597
+ * Build the {@link ClaudeSessionScan} return object from an accumulator. This is
2598
+ * the exact return-building {@link scanClaudeSession} used to run inline.
2599
+ */
2600
+ export function finalizeClaudeScan(state) {
2601
+ const durationMs = state.firstTsMs !== undefined && state.lastTsMs !== undefined && state.lastTsMs > state.firstTsMs
2602
+ ? state.lastTsMs - state.firstTsMs
2603
+ : undefined;
2604
+ // Prefer an explicit session title (user `/rename` > Claude auto-title) over
2605
+ // the first-prompt topic.
2606
+ const resolvedTopic = state.customTitle || state.aiTitle || state.topic;
2607
+ const worktree = detectWorktree(state.cwd, state.gitBranch);
2608
+ const ticket = detectTicket(state.userTexts.join('\n') || undefined, state.gitBranch);
2609
+ return {
2610
+ timestamp: state.timestamp,
2611
+ cwd: state.cwd,
2612
+ gitBranch: state.gitBranch,
2613
+ version: state.version,
2614
+ topic: resolvedTopic,
2615
+ entrypoint: state.entrypoint,
2616
+ messageCount: state.messageCount,
2617
+ tokenCount: state.sawTokenCount ? state.tokenCount : undefined,
2618
+ outputTokens: state.sawTokenCount ? state.outputTokens : undefined,
2619
+ costUsd: state.sawCost ? state.costUsd : undefined,
2620
+ durationMs,
2621
+ lastActivity: state.lastTsMs !== undefined ? new Date(state.lastTsMs).toISOString() : undefined,
2622
+ contentText: state.userTexts.length > 0 ? state.userTexts.join('\n') : undefined,
2623
+ prUrl: state.prUrl,
2624
+ prNumber: state.prNumber,
2625
+ worktreeSlug: worktree?.slug,
2626
+ ticketId: ticket?.id,
2627
+ createdTickets: state.createdTickets.size > 0 ? [...state.createdTickets] : undefined,
2628
+ spawnedTeam: state.spawnedTeam,
2629
+ plan: state.plan,
2630
+ todos: extractTodoProgressFromEvents(state.checklistEvents),
2631
+ recentDirectoriesTouched: state.recentDirectoriesTouched.length ? state.recentDirectoriesTouched : undefined,
2632
+ };
2633
+ }
1919
2634
  /** Stream a Claude JSONL file and extract scan-level metadata (timestamp, cwd, topic, tokens). */
1920
2635
  export async function scanClaudeSession(filePath) {
1921
2636
  const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
1922
2637
  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
1923
- let timestamp;
1924
- let cwd;
1925
- let gitBranch;
1926
- let version;
1927
- let topic;
1928
- // Explicit session titles: `/rename` writes a `custom-title` event; Claude
1929
- // auto-generates an `ai-title`. Both can repeat across the file — last wins.
1930
- let customTitle;
1931
- let aiTitle;
1932
- let entrypoint;
1933
- let messageCount = 0;
1934
- let tokenCount = 0;
1935
- let outputTokens = 0;
1936
- let sawTokenCount = false;
1937
- let costUsd = 0;
1938
- let sawCost = false;
1939
- // Track the first and last timestamped event to derive wall-clock duration.
1940
- let firstTsMs;
1941
- let lastTsMs;
1942
- const seenAssistantIds = new Set();
1943
- const userTexts = [];
1944
- // Durable PR signal: set only when an actual `gh pr create` Bash *command*
1945
- // runs (structural — the command field, not any prose mentioning it), then
1946
- // capture the pull URL from a later tool_result's output.
1947
- let sawPrCreate = false;
1948
- let prUrl;
1949
- let prNumber;
1950
- // Artifacts the session PRODUCED: tracker refs it created and any team it spawned.
1951
- // Ticket creation spans two events — a create_issue tool_use, then the tool_result
1952
- // carrying the new id — so we hold the pending tool_use ids until their result lands.
1953
- const createdTickets = new Set();
1954
- const pendingTicketTools = new Set();
1955
- let spawnedTeam;
1956
- // The LAST ExitPlanMode plan wins so a re-planned session surfaces its most
1957
- // recent plan, matching the semantic the extension's re-parser relied on.
1958
- let plan;
2638
+ const state = initClaudeParseState();
1959
2639
  try {
1960
2640
  for await (const line of rl) {
1961
2641
  if (!line.trim())
@@ -1967,213 +2647,163 @@ export async function scanClaudeSession(filePath) {
1967
2647
  catch {
1968
2648
  continue;
1969
2649
  }
1970
- // entrypoint ships on the first envelope event (attachment/user/assistant)
1971
- // and is the clean structural signal for "was this a team spawn?"
1972
- if (!entrypoint && typeof parsed.entrypoint === 'string') {
1973
- entrypoint = parsed.entrypoint;
1974
- }
1975
- // Produced-artifact signals, structurally (independent of the PR gate below):
1976
- // - a Bash `agents teams create/add` command → the team it spawned
1977
- // - a Linear create_issue / `gh issue create` tool_use → its result carries
1978
- // the new ticket ref, read from the matching tool_result.
1979
- if (parsed.type === 'assistant' && Array.isArray(parsed.message?.content)) {
1980
- for (const b of parsed.message.content) {
1981
- if (b?.type !== 'tool_use')
1982
- continue;
1983
- if (!spawnedTeam && typeof b?.input?.command === 'string') {
1984
- const team = detectSpawnedTeam(b.input.command);
1985
- if (team)
1986
- spawnedTeam = team;
1987
- }
1988
- if (typeof b?.id === 'string' && isTicketCreateTool(b?.name, b?.input?.command)) {
1989
- pendingTicketTools.add(b.id);
1990
- }
1991
- // ExitPlanMode plan markdown — last one wins so a re-planned session
1992
- // reports its most recent plan.
1993
- if (b?.name === 'ExitPlanMode' && typeof b?.input?.plan === 'string') {
1994
- const p = b.input.plan.trim();
1995
- if (p)
1996
- plan = b.input.plan;
1997
- }
1998
- }
1999
- }
2000
- if (pendingTicketTools.size > 0 && parsed.type === 'user' && Array.isArray(parsed.message?.content)) {
2001
- for (const b of parsed.message.content) {
2002
- if (b?.type !== 'tool_result' || typeof b?.tool_use_id !== 'string')
2003
- continue;
2004
- if (!pendingTicketTools.has(b.tool_use_id))
2005
- continue;
2006
- pendingTicketTools.delete(b.tool_use_id);
2007
- const text = typeof b.content === 'string'
2008
- ? b.content
2009
- : Array.isArray(b.content) ? b.content.map((c) => c?.text || '').join('\n') : '';
2010
- const t = extractCreatedTicket(text);
2011
- if (t)
2012
- createdTickets.add(t);
2013
- }
2014
- }
2015
- // PR signal, structurally: a Bash tool_use whose command is `gh pr create`
2016
- // marks intent; the pull URL is then read from a tool_result's output.
2017
- if (!prUrl) {
2018
- if (!sawPrCreate && parsed.type === 'assistant' && Array.isArray(parsed.message?.content)) {
2019
- for (const b of parsed.message.content) {
2020
- if (b?.type === 'tool_use' && typeof b?.input?.command === 'string' && isPrCreateCommand(b.input.command)) {
2021
- sawPrCreate = true;
2022
- }
2023
- }
2024
- }
2025
- if (sawPrCreate && parsed.type === 'user' && Array.isArray(parsed.message?.content)) {
2026
- for (const b of parsed.message.content) {
2027
- if (b?.type !== 'tool_result')
2028
- continue;
2029
- const text = typeof b.content === 'string'
2030
- ? b.content
2031
- : Array.isArray(b.content) ? b.content.map((c) => c?.text || '').join('\n') : '';
2032
- const pr = extractPrUrl(text);
2033
- if (pr) {
2034
- prUrl = pr.url;
2035
- prNumber = pr.number;
2036
- }
2037
- }
2038
- }
2039
- }
2040
- // Track duration across every timestamped event, not just the first.
2041
- if (typeof parsed.timestamp === 'string') {
2042
- const ms = new Date(parsed.timestamp).getTime();
2043
- if (!Number.isNaN(ms)) {
2044
- if (firstTsMs === undefined || ms < firstTsMs)
2045
- firstTsMs = ms;
2046
- if (lastTsMs === undefined || ms > lastTsMs)
2047
- lastTsMs = ms;
2048
- }
2049
- }
2050
- if (!timestamp && (parsed.type === 'user' || parsed.type === 'assistant') && parsed.timestamp) {
2051
- timestamp = parsed.timestamp;
2052
- cwd = parsed.cwd || '';
2053
- gitBranch = parsed.gitBranch || undefined;
2054
- version = parsed.version || undefined;
2055
- }
2056
- if (parsed.type === 'custom-title') {
2057
- const t = typeof parsed.customTitle === 'string' ? parsed.customTitle.trim() : '';
2058
- if (t)
2059
- customTitle = t;
2060
- continue;
2061
- }
2062
- if (parsed.type === 'ai-title') {
2063
- const t = typeof parsed.aiTitle === 'string' ? parsed.aiTitle.trim() : '';
2064
- if (t)
2065
- aiTitle = t;
2066
- continue;
2067
- }
2068
- if (parsed.type === 'user') {
2069
- const text = extractClaudeUserText(parsed);
2070
- if (text) {
2071
- messageCount++;
2072
- userTexts.push(text);
2073
- if (!topic)
2074
- topic = extractSessionTopic(text);
2075
- }
2076
- continue;
2077
- }
2078
- if (parsed.type !== 'assistant')
2079
- continue;
2080
- const assistantId = typeof parsed.message?.id === 'string'
2081
- ? parsed.message.id
2082
- : typeof parsed.uuid === 'string'
2083
- ? parsed.uuid
2084
- : undefined;
2085
- const logicalId = assistantId || `${parsed.timestamp || ''}:${seenAssistantIds.size}`;
2086
- if (seenAssistantIds.has(logicalId))
2087
- continue;
2088
- seenAssistantIds.add(logicalId);
2089
- messageCount++;
2090
- const usageObj = parsed.message?.usage || parsed.usage;
2091
- const usage = getClaudeUsageTotal(usageObj);
2092
- if (usage !== null) {
2093
- tokenCount += usage;
2094
- sawTokenCount = true;
2095
- }
2096
- if (typeof usageObj?.output_tokens === 'number')
2097
- outputTokens += usageObj.output_tokens;
2098
- // Per-assistant-message cost: each event carries its own model, so we
2099
- // multiply that event's raw token directions by that model's price.
2100
- const model = parsed.message?.model;
2101
- if (model && usageObj && typeof usageObj === 'object') {
2102
- const eventCost = costOfUsage({
2103
- model,
2104
- inputTokens: usageObj.input_tokens,
2105
- outputTokens: usageObj.output_tokens,
2106
- cacheReadTokens: usageObj.cache_read_input_tokens,
2107
- cacheCreationTokens: usageObj.cache_creation_input_tokens,
2108
- });
2109
- if (eventCost > 0) {
2110
- costUsd += eventCost;
2111
- sawCost = true;
2112
- }
2113
- }
2650
+ applyClaudeLine(state, parsed);
2114
2651
  }
2115
2652
  }
2116
2653
  finally {
2117
2654
  rl.close();
2118
2655
  stream.destroy();
2119
2656
  }
2120
- const durationMs = firstTsMs !== undefined && lastTsMs !== undefined && lastTsMs > firstTsMs
2121
- ? lastTsMs - firstTsMs
2122
- : undefined;
2123
- // Prefer an explicit session title (user `/rename` > Claude auto-title) over
2124
- // the first-prompt topic.
2125
- const resolvedTopic = customTitle || aiTitle || topic;
2126
- const worktree = detectWorktree(cwd, gitBranch);
2127
- const ticket = detectTicket(userTexts.join('\n') || undefined, gitBranch);
2657
+ return finalizeClaudeScan(state);
2658
+ }
2659
+ /** Cap on the FIFO window of recent assistant ids persisted in the continuation. */
2660
+ const SEEN_IDS_RECENT_CAP = 256;
2661
+ /**
2662
+ * Snapshot a live {@link ClaudeParseState} into its serializable form at
2663
+ * `offset` bytes consumed. Round-trips through {@link hydrateClaudeParseState}
2664
+ * so incremental replay equals a full parse.
2665
+ */
2666
+ export function serializeClaudeParserState(state, offset) {
2667
+ const allIds = [...state.seenAssistantIds];
2668
+ const seenIdsRecent = allIds.length > SEEN_IDS_RECENT_CAP
2669
+ ? allIds.slice(allIds.length - SEEN_IDS_RECENT_CAP)
2670
+ : allIds;
2671
+ const ticket = detectTicket(state.userTexts.join('\n') || undefined, state.gitBranch);
2128
2672
  return {
2129
- timestamp,
2130
- cwd,
2131
- gitBranch,
2132
- version,
2133
- topic: resolvedTopic,
2134
- entrypoint,
2135
- messageCount,
2136
- tokenCount: sawTokenCount ? tokenCount : undefined,
2137
- outputTokens: sawTokenCount ? outputTokens : undefined,
2138
- costUsd: sawCost ? costUsd : undefined,
2139
- durationMs,
2140
- lastActivity: lastTsMs !== undefined ? new Date(lastTsMs).toISOString() : undefined,
2141
- contentText: userTexts.length > 0 ? userTexts.join('\n') : undefined,
2142
- prUrl,
2143
- prNumber,
2144
- worktreeSlug: worktree?.slug,
2673
+ v: 1,
2674
+ offset,
2675
+ timestamp: state.timestamp,
2676
+ cwd: state.cwd,
2677
+ gitBranch: state.gitBranch,
2678
+ version: state.version,
2679
+ entrypoint: state.entrypoint,
2680
+ firstTsMs: state.firstTsMs,
2681
+ topic: state.topic,
2682
+ customTitle: state.customTitle,
2683
+ aiTitle: state.aiTitle,
2684
+ plan: state.plan,
2685
+ lastTsMs: state.lastTsMs,
2686
+ messageCount: state.messageCount,
2687
+ tokenCount: state.tokenCount,
2688
+ outputTokens: state.outputTokens,
2689
+ sawTokenCount: state.sawTokenCount,
2690
+ sawCost: state.sawCost,
2691
+ costUsd: state.costUsd,
2692
+ seenIdsSize: state.seenAssistantIds.size,
2693
+ seenIdsRecent,
2694
+ sawPrCreate: state.sawPrCreate,
2695
+ prUrl: state.prUrl,
2696
+ prNumber: state.prNumber,
2697
+ pendingTicketTools: [...state.pendingTicketTools],
2698
+ createdTickets: [...state.createdTickets],
2699
+ spawnedTeam: state.spawnedTeam,
2700
+ // ticketId is derived at finalize time; persist it (and content_text) so a
2701
+ // consumer (B-2) can rebuild the row + FTS doc on append without re-reading
2702
+ // the whole file. worktreeSlug is re-derived from cwd/gitBranch, so it need
2703
+ // not be persisted.
2145
2704
  ticketId: ticket?.id,
2146
- createdTickets: createdTickets.size > 0 ? [...createdTickets] : undefined,
2147
- spawnedTeam,
2148
- plan,
2705
+ contentText: state.userTexts.length > 0 ? state.userTexts.join('\n') : undefined,
2706
+ checklistEvents: state.checklistEvents,
2707
+ recentDirectoriesTouched: state.recentDirectoriesTouched,
2149
2708
  };
2150
2709
  }
2151
- /** Stream a Codex JSONL file and extract scan-level metadata (session ID, cwd, topic, tokens). */
2152
- async function scanCodexSession(filePath) {
2153
- const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
2154
- const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
2155
- let sessionId;
2156
- let timestamp;
2157
- let cwd;
2158
- let gitBranch;
2159
- let version;
2160
- let topic;
2161
- let messageCount = 0;
2162
- let tokenCount;
2163
- let model;
2164
- let lastTotalTokenUsage;
2165
- let firstTsMs;
2166
- let lastTsMs;
2167
- const userTexts = [];
2168
- let sawPrCreate = false;
2169
- let prUrl;
2170
- let prNumber;
2171
- // Produced artifacts (mirror of the Claude scan): created tracker refs + spawned team.
2172
- const createdTickets = new Set();
2173
- const pendingTicketTools = new Set();
2174
- let spawnedTeam;
2710
+ /**
2711
+ * Rebuild a live {@link ClaudeParseState} from a persisted continuation so that
2712
+ * applying the appended lines yields the same accumulator a full parse would.
2713
+ *
2714
+ * `seenAssistantIds` is rehydrated from the recent-id FIFO window, then padded
2715
+ * with unique sentinel entries so its `.size` matches the true prior count
2716
+ * (`seenIdsSize`) — the fallback id `${ts}:${size}` must line up with the full
2717
+ * parse even when the window dropped older ids. Padding sentinels can never
2718
+ * collide with a real logical id (real ids are message ids/uuids or
2719
+ * `${ts}:${n}`; the sentinel prefix is not JSON-line-derived).
2720
+ *
2721
+ * `userTexts` is rehydrated as a single joined blob from `contentText`: only
2722
+ * `userTexts.join('\n')` (detectTicket + contentText) and `userTexts.length > 0`
2723
+ * are ever read downstream, and both are preserved by a one-element array
2724
+ * holding the joined content.
2725
+ */
2726
+ export function hydrateClaudeParseState(prior) {
2727
+ const seen = new Set(prior.seenIdsRecent);
2728
+ // Pad to the true prior size so `seenAssistantIds.size` (which feeds the
2729
+ // fallback logical id) is exact even when older ids fell out of the window.
2730
+ let pad = 0;
2731
+ while (seen.size < prior.seenIdsSize) {
2732
+ seen.add(`pad:${pad++}`);
2733
+ }
2734
+ return {
2735
+ timestamp: prior.timestamp,
2736
+ cwd: prior.cwd,
2737
+ gitBranch: prior.gitBranch,
2738
+ version: prior.version,
2739
+ topic: prior.topic,
2740
+ customTitle: prior.customTitle,
2741
+ aiTitle: prior.aiTitle,
2742
+ entrypoint: prior.entrypoint,
2743
+ messageCount: prior.messageCount,
2744
+ tokenCount: prior.tokenCount,
2745
+ outputTokens: prior.outputTokens,
2746
+ sawTokenCount: prior.sawTokenCount,
2747
+ costUsd: prior.costUsd,
2748
+ sawCost: prior.sawCost,
2749
+ firstTsMs: prior.firstTsMs,
2750
+ lastTsMs: prior.lastTsMs,
2751
+ seenAssistantIds: seen,
2752
+ userTexts: prior.contentText !== undefined && prior.contentText.length > 0 ? [prior.contentText] : [],
2753
+ sawPrCreate: prior.sawPrCreate,
2754
+ prUrl: prior.prUrl,
2755
+ prNumber: prior.prNumber,
2756
+ createdTickets: new Set(prior.createdTickets),
2757
+ pendingTicketTools: new Set(prior.pendingTicketTools),
2758
+ spawnedTeam: prior.spawnedTeam,
2759
+ plan: prior.plan,
2760
+ checklistEvents: prior.checklistEvents ?? [],
2761
+ recentDirectoriesTouched: prior.recentDirectoriesTouched ?? [],
2762
+ };
2763
+ }
2764
+ /**
2765
+ * Resume a Claude parse from `fromOffset` bytes into the file, folding only the
2766
+ * newly-appended lines into `prior`. Returns the finalized scan, the next
2767
+ * serialized continuation, and the byte offset to resume from next time —
2768
+ * `newOffset` stops at the last `'\n'` seen so a half-written trailing record is
2769
+ * re-read (not lost) on the next append.
2770
+ *
2771
+ * NOT wired into the live scan path yet (that is B-2); {@link scanClaudeSession}
2772
+ * remains the only caller-facing entry point. This exists so the parity harness
2773
+ * can prove full === hydrate(state@k) + apply(k+1..n).
2774
+ */
2775
+ export async function scanClaudeSessionIncremental(filePath, fromOffset, prior) {
2776
+ const state = hydrateClaudeParseState(prior);
2777
+ // Read the appended byte range and apply ONLY newline-terminated lines. The
2778
+ // applied lines and `newOffset` MUST stay consistent: readline (like the full
2779
+ // parse) would emit a trailing UNTERMINATED final line at EOF, but `newOffset`
2780
+ // stops before it — so the next pass, after that record's '\n' is flushed,
2781
+ // would re-read and re-apply the same line and double-count it (user events
2782
+ // have no dedup, unlike assistant `seenAssistantIds`). A record written
2783
+ // non-atomically — bytes first, then '\n' in a second write — is exactly this
2784
+ // case. So we slice at the last '\n' ourselves: everything up to and including
2785
+ // it is a run of complete lines we apply and commit; any tail after it
2786
+ // (syntactically broken OR complete-but-not-yet-terminated) is a still-being-
2787
+ // written record we DEFER to the next pass, once its '\n' lands.
2788
+ const chunks = [];
2789
+ const stream = fs.createReadStream(filePath, { start: fromOffset });
2175
2790
  try {
2176
- for await (const line of rl) {
2791
+ for await (const chunk of stream) {
2792
+ chunks.push(typeof chunk === 'string' ? Buffer.from(chunk, 'utf-8') : chunk);
2793
+ }
2794
+ }
2795
+ finally {
2796
+ stream.destroy();
2797
+ }
2798
+ const appended = Buffer.concat(chunks);
2799
+ // Bytes up to AND INCLUDING the last '\n' are the committed, complete-line run.
2800
+ const lastNl = appended.lastIndexOf(0x0a);
2801
+ const consumedBytes = lastNl === -1 ? 0 : lastNl + 1;
2802
+ if (consumedBytes > 0) {
2803
+ // split('\n') on the committed run: the element after the final '\n' is ''
2804
+ // (skipped by the trim guard). A stray '\r' from CRLF is tolerated by the
2805
+ // JSON.parse below exactly as the full parse tolerates it.
2806
+ for (const line of appended.subarray(0, consumedBytes).toString('utf-8').split('\n')) {
2177
2807
  if (!line.trim())
2178
2808
  continue;
2179
2809
  let parsed;
@@ -2183,140 +2813,558 @@ async function scanCodexSession(filePath) {
2183
2813
  catch {
2184
2814
  continue;
2185
2815
  }
2186
- // PR signal, structurally: a Codex `function_call` whose command is
2187
- // `gh pr create`, then the pull URL from a `function_call_output`.
2188
- if (parsed.type === 'response_item') {
2189
- const p = parsed.payload || {};
2190
- if (p.type === 'function_call') {
2191
- let cmd = '';
2192
- try {
2193
- const args = typeof p.arguments === 'string' ? JSON.parse(p.arguments) : (p.arguments || {});
2194
- cmd = String(args.command || args.cmd || '');
2195
- }
2196
- catch { /* non-JSON args */ }
2197
- if (!prUrl && !sawPrCreate && isPrCreateCommand(cmd))
2198
- sawPrCreate = true;
2199
- if (!spawnedTeam) {
2200
- const team = detectSpawnedTeam(cmd);
2201
- if (team)
2202
- spawnedTeam = team;
2203
- }
2204
- if (typeof p.call_id === 'string' && isTicketCreateTool(p.name, cmd)) {
2205
- pendingTicketTools.add(p.call_id);
2206
- }
2207
- }
2208
- if (p.type === 'function_call_output') {
2209
- if (!prUrl && sawPrCreate) {
2210
- const pr = extractPrUrl(String(p.output || ''));
2211
- if (pr) {
2212
- prUrl = pr.url;
2213
- prNumber = pr.number;
2214
- }
2215
- }
2216
- if (typeof p.call_id === 'string' && pendingTicketTools.has(p.call_id)) {
2217
- pendingTicketTools.delete(p.call_id);
2218
- const t = extractCreatedTicket(String(p.output || ''));
2219
- if (t)
2220
- createdTickets.add(t);
2221
- }
2222
- }
2223
- }
2224
- // Track duration across every timestamped event.
2225
- if (typeof parsed.timestamp === 'string') {
2226
- const ms = new Date(parsed.timestamp).getTime();
2227
- if (!Number.isNaN(ms)) {
2228
- if (firstTsMs === undefined || ms < firstTsMs)
2229
- firstTsMs = ms;
2230
- if (lastTsMs === undefined || ms > lastTsMs)
2231
- lastTsMs = ms;
2232
- }
2233
- }
2234
- if (parsed.type === 'session_meta') {
2235
- const payload = parsed.payload || {};
2236
- sessionId = payload.id || sessionId;
2237
- timestamp = payload.timestamp || parsed.timestamp || timestamp;
2238
- cwd = payload.cwd || cwd;
2239
- gitBranch = payload.git?.branch || gitBranch;
2240
- version = payload.cli_version || payload.version || version;
2241
- model = payload.model || model;
2816
+ applyClaudeLine(state, parsed);
2817
+ }
2818
+ }
2819
+ const newOffset = fromOffset + consumedBytes;
2820
+ return {
2821
+ scan: finalizeClaudeScan(state),
2822
+ newState: serializeClaudeParserState(state, newOffset),
2823
+ newOffset,
2824
+ };
2825
+ }
2826
+ /** Serialized zero-value continuation: a fresh accumulator at offset 0, used to drive a FULL parse from the start through the same resumable path. */
2827
+ function freshClaudeParserState() {
2828
+ return serializeClaudeParserState(initClaudeParseState(), 0);
2829
+ }
2830
+ /**
2831
+ * Decide full-vs-incremental for one Claude file and parse it uniformly, always
2832
+ * returning a finalized scan plus the continuation to persist. Both branches run
2833
+ * through the SAME reducer (via {@link scanClaudeSessionIncremental}), so the row
2834
+ * an append produces is identical to a from-scratch full reparse by construction
2835
+ * (the B-1 parity harness proves this at the function level).
2836
+ *
2837
+ * INCREMENTAL when a prior continuation exists AND the file grew past the
2838
+ * persisted offset AND its mtime did not go backwards — an in-place append.
2839
+ * FULL (from byte 0, fresh state) otherwise: cold start (no prior), truncation /
2840
+ * rewrite (size shrank to at or below the offset), or a clock rewind / restore
2841
+ * (mtime older than the last parse). A FULL parse still produces a continuation,
2842
+ * so the file's very next append can go incremental.
2843
+ *
2844
+ * `mode` is returned so the caller (and tests) can confirm which branch ran.
2845
+ */
2846
+ async function scanClaudeSessionResumable(filePath, prior, currentFileMtimeMs, currentFileSize, priorFileMtimeMs) {
2847
+ // File size + mtime cannot distinguish an APPEND from an in-place rewrite or a
2848
+ // restore that dropped DIFFERENT, larger content at the same path: both grow
2849
+ // the file and move mtime forward. Resuming from the stored offset across that
2850
+ // boundary would fold the new file's bytes into an accumulator hydrated from
2851
+ // the OLD session, so the persisted row silently diverges from a full reparse.
2852
+ // So the metadata gate below only makes a file ELIGIBLE; before trusting the
2853
+ // offset we re-read the transcript's first user/assistant timestamp and require
2854
+ // it to still match the prior continuation's. An append keeps that identity
2855
+ // byte-for-byte; a rewrite/restore of a different session changes it. A
2856
+ // mismatch or an identity we cannot derive — falls back to a FULL parse,
2857
+ // which is always correct. (A shrink is already handled: currentFileSize is not
2858
+ // > prior.offset, so it takes the FULL branch.)
2859
+ let canIncrement = false;
2860
+ if (prior !== null &&
2861
+ currentFileSize > prior.offset &&
2862
+ (priorFileMtimeMs === undefined || currentFileMtimeMs >= priorFileMtimeMs) &&
2863
+ prior.timestamp !== undefined) {
2864
+ canIncrement = (await claudeSessionIdentityAt(filePath)) === prior.timestamp;
2865
+ }
2866
+ if (canIncrement && prior !== null) {
2867
+ const result = await scanClaudeSessionIncremental(filePath, prior.offset, prior);
2868
+ return { ...result, mode: 'incremental' };
2869
+ }
2870
+ const result = await scanClaudeSessionIncremental(filePath, 0, freshClaudeParserState());
2871
+ return { ...result, mode: 'full' };
2872
+ }
2873
+ /**
2874
+ * Cheaply derive a Claude transcript's session identity — the first
2875
+ * user/assistant event `timestamp` — by streaming only the START of the file
2876
+ * (at most `maxBytes`) and stopping at the first such event. Used by
2877
+ * {@link scanClaudeSessionResumable} to confirm a grown file is still the SAME
2878
+ * session before resuming from a stored parse offset. Returns undefined when no
2879
+ * user/assistant event appears within the budget, which forces a FULL parse.
2880
+ */
2881
+ async function claudeSessionIdentityAt(filePath, maxBytes = 1_048_576) {
2882
+ const state = initClaudeParseState();
2883
+ const stream = fs.createReadStream(filePath, { start: 0, end: maxBytes - 1, encoding: 'utf-8' });
2884
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
2885
+ try {
2886
+ for await (const line of rl) {
2887
+ if (!line.trim())
2242
2888
  continue;
2889
+ let parsed;
2890
+ try {
2891
+ parsed = JSON.parse(line);
2243
2892
  }
2244
- if (parsed.type === 'response_item' && parsed.payload?.type === 'message') {
2245
- const role = parsed.payload.role === 'user' || parsed.payload.role === 'developer'
2246
- ? 'user'
2247
- : 'assistant';
2248
- const text = extractCodexMessageText(parsed.payload.content, role);
2249
- if (!text)
2250
- continue;
2251
- messageCount++;
2252
- if (role === 'user') {
2253
- userTexts.push(text);
2254
- if (!topic)
2255
- topic = extractSessionTopic(text);
2256
- }
2893
+ catch {
2257
2894
  continue;
2258
2895
  }
2259
- if (parsed.type === 'event_msg' && parsed.payload?.type === 'token_count') {
2260
- const totalUsage = parsed.payload.info?.total_token_usage;
2261
- const total = getCodexTokenCount(totalUsage);
2262
- if (total !== null)
2263
- tokenCount = total;
2264
- // token_count is cumulative — keep the latest snapshot and price it once
2265
- // after the stream, so we don't double-count across intermediate events.
2266
- if (totalUsage && typeof totalUsage === 'object')
2267
- lastTotalTokenUsage = totalUsage;
2268
- // Codex also stamps the model on the rate_limits/token_count payload on
2269
- // some versions; prefer session_meta but fall back to it.
2270
- if (!model && typeof parsed.payload.info?.model === 'string')
2271
- model = parsed.payload.info.model;
2272
- }
2896
+ applyClaudeLine(state, parsed);
2897
+ if (state.timestamp !== undefined)
2898
+ break;
2273
2899
  }
2274
2900
  }
2275
2901
  finally {
2276
2902
  rl.close();
2277
2903
  stream.destroy();
2278
2904
  }
2905
+ return state.timestamp;
2906
+ }
2907
+ /**
2908
+ * Parse the prior continuation blob for a changed file into a usable
2909
+ * {@link ClaudeParserState}, or null when there is none / it is unusable. A blob
2910
+ * from a different serialization version is treated as absent so the file falls
2911
+ * back to a clean FULL parse rather than resuming against a stale shape.
2912
+ */
2913
+ function parsePriorClaudeState(row) {
2914
+ if (!row?.parserState)
2915
+ return null;
2916
+ try {
2917
+ const parsed = JSON.parse(row.parserState);
2918
+ if (parsed?.v !== 1 || typeof parsed.offset !== 'number')
2919
+ return null;
2920
+ return parsed;
2921
+ }
2922
+ catch {
2923
+ return null;
2924
+ }
2925
+ }
2926
+ /** Test seam: how many times the incremental (append-resume) branch was taken since the last reset. */
2927
+ let claudeIncrementalScanCount = 0;
2928
+ /** Test seam: how many times a full (from-offset-0) Claude parse ran since the last reset. */
2929
+ let claudeFullScanCount = 0;
2930
+ /** Test seam: read the (incremental, full) Claude parse counters. */
2931
+ export function __claudeScanBranchCountsForTest() {
2932
+ return { incremental: claudeIncrementalScanCount, full: claudeFullScanCount };
2933
+ }
2934
+ /** Test seam: reset the Claude parse-branch counters to observe a scan from a clean slate. */
2935
+ export function __resetClaudeScanBranchCountsForTest() {
2936
+ claudeIncrementalScanCount = 0;
2937
+ claudeFullScanCount = 0;
2938
+ }
2939
+ /** Zero-value accumulator for a fresh (from-byte-0) Codex parse. */
2940
+ export function initCodexParseState() {
2941
+ return {
2942
+ sessionId: undefined,
2943
+ timestamp: undefined,
2944
+ cwd: undefined,
2945
+ gitBranch: undefined,
2946
+ version: undefined,
2947
+ model: undefined,
2948
+ topic: undefined,
2949
+ messageCount: 0,
2950
+ tokenCount: undefined,
2951
+ lastTotalTokenUsage: undefined,
2952
+ firstTsMs: undefined,
2953
+ lastTsMs: undefined,
2954
+ userTexts: [],
2955
+ sawPrCreate: false,
2956
+ prUrl: undefined,
2957
+ prNumber: undefined,
2958
+ createdTickets: new Set(),
2959
+ pendingTicketTools: new Set(),
2960
+ spawnedTeam: undefined,
2961
+ checklistEvents: [],
2962
+ recentDirectoriesTouched: [],
2963
+ };
2964
+ }
2965
+ /**
2966
+ * Fold one parsed Codex line into the accumulator — the exact loop body
2967
+ * {@link scanCodexSession} used to run inline, extracted verbatim and mutating
2968
+ * `state.*` in place. `parsed` is the already-`JSON.parse`d line (the
2969
+ * malformed-line skip happens in the caller, as before).
2970
+ */
2971
+ export function applyCodexLine(state, parsed) {
2972
+ // PR signal, structurally: a Codex `function_call` whose command is
2973
+ // `gh pr create`, then the pull URL from a `function_call_output`.
2974
+ if (parsed.type === 'response_item') {
2975
+ const p = parsed.payload || {};
2976
+ if (p.type === 'function_call') {
2977
+ let cmd = '';
2978
+ let args = {};
2979
+ try {
2980
+ args = typeof p.arguments === 'string' ? JSON.parse(p.arguments) : (p.arguments || {});
2981
+ cmd = String(args.command || args.cmd || '');
2982
+ }
2983
+ catch { /* non-JSON args */ }
2984
+ foldDerivedToolState(state, {
2985
+ type: 'tool_use', agent: 'codex', timestamp: parsed.timestamp || '', tool: p.name, args,
2986
+ path: args.file_path || args.path, command: cmd || undefined,
2987
+ });
2988
+ if (!state.prUrl && !state.sawPrCreate && isPrCreateCommand(cmd))
2989
+ state.sawPrCreate = true;
2990
+ if (!state.spawnedTeam) {
2991
+ const team = detectSpawnedTeam(cmd);
2992
+ if (team)
2993
+ state.spawnedTeam = team;
2994
+ }
2995
+ if (typeof p.call_id === 'string' && isTicketCreateTool(p.name, cmd)) {
2996
+ state.pendingTicketTools.add(p.call_id);
2997
+ }
2998
+ }
2999
+ if (p.type === 'function_call_output') {
3000
+ if (!state.prUrl && state.sawPrCreate) {
3001
+ const pr = extractPrUrl(String(p.output || ''));
3002
+ if (pr) {
3003
+ state.prUrl = pr.url;
3004
+ state.prNumber = pr.number;
3005
+ }
3006
+ }
3007
+ if (typeof p.call_id === 'string' && state.pendingTicketTools.has(p.call_id)) {
3008
+ state.pendingTicketTools.delete(p.call_id);
3009
+ const t = extractCreatedTicket(String(p.output || ''));
3010
+ if (t)
3011
+ state.createdTickets.add(t);
3012
+ }
3013
+ }
3014
+ }
3015
+ // Track duration across every timestamped event.
3016
+ if (typeof parsed.timestamp === 'string') {
3017
+ const ms = new Date(parsed.timestamp).getTime();
3018
+ if (!Number.isNaN(ms)) {
3019
+ if (state.firstTsMs === undefined || ms < state.firstTsMs)
3020
+ state.firstTsMs = ms;
3021
+ if (state.lastTsMs === undefined || ms > state.lastTsMs)
3022
+ state.lastTsMs = ms;
3023
+ }
3024
+ }
3025
+ if (parsed.type === 'session_meta') {
3026
+ const payload = parsed.payload || {};
3027
+ state.sessionId = payload.id || state.sessionId;
3028
+ state.timestamp = payload.timestamp || parsed.timestamp || state.timestamp;
3029
+ state.cwd = payload.cwd || state.cwd;
3030
+ state.gitBranch = payload.git?.branch || state.gitBranch;
3031
+ state.version = payload.cli_version || payload.version || state.version;
3032
+ state.model = payload.model || state.model;
3033
+ return;
3034
+ }
3035
+ if (parsed.type === 'response_item' && parsed.payload?.type === 'message') {
3036
+ const role = parsed.payload.role === 'user' || parsed.payload.role === 'developer'
3037
+ ? 'user'
3038
+ : 'assistant';
3039
+ const text = extractCodexMessageText(parsed.payload.content, role);
3040
+ if (!text)
3041
+ return;
3042
+ state.messageCount++;
3043
+ if (role === 'user') {
3044
+ state.userTexts.push(text);
3045
+ if (!state.topic)
3046
+ state.topic = extractSessionTopic(text);
3047
+ }
3048
+ return;
3049
+ }
3050
+ if (parsed.type === 'event_msg' && parsed.payload?.type === 'token_count') {
3051
+ const totalUsage = parsed.payload.info?.total_token_usage;
3052
+ const total = getCodexTokenCount(totalUsage);
3053
+ if (total !== null)
3054
+ state.tokenCount = total;
3055
+ // token_count is cumulative — keep the latest snapshot and price it once
3056
+ // after the stream, so we don't double-count across intermediate events.
3057
+ if (totalUsage && typeof totalUsage === 'object')
3058
+ state.lastTotalTokenUsage = totalUsage;
3059
+ // Codex also stamps the model on the rate_limits/token_count payload on
3060
+ // some versions; prefer session_meta but fall back to it.
3061
+ if (!state.model && typeof parsed.payload.info?.model === 'string')
3062
+ state.model = parsed.payload.info.model;
3063
+ }
3064
+ }
3065
+ /**
3066
+ * Build the {@link CodexSessionScan} return object from an accumulator — the
3067
+ * exact return-building {@link scanCodexSession} used to run inline.
3068
+ */
3069
+ export function finalizeCodexScan(state) {
2279
3070
  // Price the final cumulative token snapshot once, against the session model.
2280
3071
  let costUsd;
2281
- if (model && lastTotalTokenUsage) {
3072
+ if (state.model && state.lastTotalTokenUsage) {
2282
3073
  const c = costOfUsage({
2283
- model,
2284
- inputTokens: lastTotalTokenUsage.input_tokens,
2285
- outputTokens: (lastTotalTokenUsage.output_tokens ?? 0) + (lastTotalTokenUsage.reasoning_output_tokens ?? 0),
2286
- cacheReadTokens: lastTotalTokenUsage.cached_input_tokens,
3074
+ model: state.model,
3075
+ inputTokens: state.lastTotalTokenUsage.input_tokens,
3076
+ outputTokens: (state.lastTotalTokenUsage.output_tokens ?? 0) + (state.lastTotalTokenUsage.reasoning_output_tokens ?? 0),
3077
+ cacheReadTokens: state.lastTotalTokenUsage.cached_input_tokens,
2287
3078
  });
2288
3079
  if (c > 0)
2289
3080
  costUsd = c;
2290
3081
  }
2291
- const durationMs = firstTsMs !== undefined && lastTsMs !== undefined && lastTsMs > firstTsMs
2292
- ? lastTsMs - firstTsMs
3082
+ const durationMs = state.firstTsMs !== undefined && state.lastTsMs !== undefined && state.lastTsMs > state.firstTsMs
3083
+ ? state.lastTsMs - state.firstTsMs
2293
3084
  : undefined;
2294
- const worktree = detectWorktree(cwd, gitBranch);
2295
- const ticket = detectTicket(userTexts.join('\n') || undefined, gitBranch);
3085
+ const worktree = detectWorktree(state.cwd, state.gitBranch);
3086
+ const ticket = detectTicket(state.userTexts.join('\n') || undefined, state.gitBranch);
2296
3087
  return {
2297
- sessionId,
2298
- timestamp,
2299
- cwd,
2300
- gitBranch,
2301
- version,
2302
- topic,
2303
- messageCount,
2304
- tokenCount,
2305
- outputTokens: lastTotalTokenUsage
2306
- ? (lastTotalTokenUsage.output_tokens ?? 0) + (lastTotalTokenUsage.reasoning_output_tokens ?? 0)
3088
+ sessionId: state.sessionId,
3089
+ timestamp: state.timestamp,
3090
+ cwd: state.cwd,
3091
+ gitBranch: state.gitBranch,
3092
+ version: state.version,
3093
+ topic: state.topic,
3094
+ messageCount: state.messageCount,
3095
+ tokenCount: state.tokenCount,
3096
+ outputTokens: state.lastTotalTokenUsage
3097
+ ? (state.lastTotalTokenUsage.output_tokens ?? 0) + (state.lastTotalTokenUsage.reasoning_output_tokens ?? 0)
2307
3098
  : undefined,
2308
3099
  costUsd,
2309
3100
  durationMs,
2310
- lastActivity: lastTsMs !== undefined ? new Date(lastTsMs).toISOString() : undefined,
2311
- contentText: userTexts.length > 0 ? userTexts.join('\n') : undefined,
2312
- prUrl,
2313
- prNumber,
3101
+ lastActivity: state.lastTsMs !== undefined ? new Date(state.lastTsMs).toISOString() : undefined,
3102
+ contentText: state.userTexts.length > 0 ? state.userTexts.join('\n') : undefined,
3103
+ prUrl: state.prUrl,
3104
+ prNumber: state.prNumber,
2314
3105
  worktreeSlug: worktree?.slug,
2315
3106
  ticketId: ticket?.id,
2316
- createdTickets: createdTickets.size > 0 ? [...createdTickets] : undefined,
2317
- spawnedTeam,
3107
+ createdTickets: state.createdTickets.size > 0 ? [...state.createdTickets] : undefined,
3108
+ spawnedTeam: state.spawnedTeam,
3109
+ todos: extractTodoProgressFromEvents(state.checklistEvents),
3110
+ recentDirectoriesTouched: state.recentDirectoriesTouched.length ? state.recentDirectoriesTouched : undefined,
3111
+ };
3112
+ }
3113
+ /** Stream a Codex JSONL file and extract scan-level metadata (session ID, cwd, topic, tokens). */
3114
+ async function scanCodexSession(filePath) {
3115
+ const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
3116
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
3117
+ const state = initCodexParseState();
3118
+ try {
3119
+ for await (const line of rl) {
3120
+ if (!line.trim())
3121
+ continue;
3122
+ let parsed;
3123
+ try {
3124
+ parsed = JSON.parse(line);
3125
+ }
3126
+ catch {
3127
+ continue;
3128
+ }
3129
+ applyCodexLine(state, parsed);
3130
+ }
3131
+ }
3132
+ finally {
3133
+ rl.close();
3134
+ stream.destroy();
3135
+ }
3136
+ return finalizeCodexScan(state);
3137
+ }
3138
+ /**
3139
+ * Snapshot a live {@link CodexParseState} into its serializable form at `offset`
3140
+ * bytes consumed. Round-trips through {@link hydrateCodexParseState} so
3141
+ * incremental replay equals a full parse.
3142
+ */
3143
+ export function serializeCodexParserState(state, offset) {
3144
+ const ticket = detectTicket(state.userTexts.join('\n') || undefined, state.gitBranch);
3145
+ return {
3146
+ v: 1,
3147
+ offset,
3148
+ sessionId: state.sessionId,
3149
+ timestamp: state.timestamp,
3150
+ cwd: state.cwd,
3151
+ gitBranch: state.gitBranch,
3152
+ version: state.version,
3153
+ model: state.model,
3154
+ topic: state.topic,
3155
+ messageCount: state.messageCount,
3156
+ tokenCount: state.tokenCount,
3157
+ lastTotalTokenUsage: state.lastTotalTokenUsage,
3158
+ firstTsMs: state.firstTsMs,
3159
+ lastTsMs: state.lastTsMs,
3160
+ sawPrCreate: state.sawPrCreate,
3161
+ prUrl: state.prUrl,
3162
+ prNumber: state.prNumber,
3163
+ pendingTicketTools: [...state.pendingTicketTools],
3164
+ createdTickets: [...state.createdTickets],
3165
+ spawnedTeam: state.spawnedTeam,
3166
+ // ticketId is derived at finalize time; persist it (and content_text) so a
3167
+ // consumer can rebuild the row + FTS doc on append without re-reading the
3168
+ // whole file. worktreeSlug is re-derived from cwd/gitBranch, so it need not
3169
+ // be persisted.
3170
+ ticketId: ticket?.id,
3171
+ contentText: state.userTexts.length > 0 ? state.userTexts.join('\n') : undefined,
3172
+ checklistEvents: state.checklistEvents,
3173
+ recentDirectoriesTouched: state.recentDirectoriesTouched,
3174
+ };
3175
+ }
3176
+ /**
3177
+ * Rebuild a live {@link CodexParseState} from a persisted continuation so that
3178
+ * applying the appended lines yields the same accumulator a full parse would.
3179
+ *
3180
+ * `userTexts` is rehydrated as a single joined blob from `contentText`: only
3181
+ * `userTexts.join('\n')` (detectTicket + contentText) and `userTexts.length > 0`
3182
+ * are ever read downstream, and both are preserved by a one-element array
3183
+ * holding the joined content. Topic is first-wins and already persisted, so a
3184
+ * collapsed userTexts never changes it.
3185
+ */
3186
+ export function hydrateCodexParseState(prior) {
3187
+ return {
3188
+ sessionId: prior.sessionId,
3189
+ timestamp: prior.timestamp,
3190
+ cwd: prior.cwd,
3191
+ gitBranch: prior.gitBranch,
3192
+ version: prior.version,
3193
+ model: prior.model,
3194
+ topic: prior.topic,
3195
+ messageCount: prior.messageCount,
3196
+ tokenCount: prior.tokenCount,
3197
+ lastTotalTokenUsage: prior.lastTotalTokenUsage,
3198
+ firstTsMs: prior.firstTsMs,
3199
+ lastTsMs: prior.lastTsMs,
3200
+ userTexts: prior.contentText !== undefined && prior.contentText.length > 0 ? [prior.contentText] : [],
3201
+ sawPrCreate: prior.sawPrCreate,
3202
+ prUrl: prior.prUrl,
3203
+ prNumber: prior.prNumber,
3204
+ createdTickets: new Set(prior.createdTickets),
3205
+ pendingTicketTools: new Set(prior.pendingTicketTools),
3206
+ spawnedTeam: prior.spawnedTeam,
3207
+ checklistEvents: prior.checklistEvents ?? [],
3208
+ recentDirectoriesTouched: prior.recentDirectoriesTouched ?? [],
3209
+ };
3210
+ }
3211
+ /**
3212
+ * Resume a Codex parse from `fromOffset` bytes into the file, folding only the
3213
+ * newly-appended lines into `prior`. Returns the finalized scan, the next
3214
+ * serialized continuation, and the byte offset to resume from next time.
3215
+ *
3216
+ * Same trailing-line discipline as {@link scanClaudeSessionIncremental}: apply
3217
+ * ONLY the run of newline-terminated lines (slice at the last `'\n'`), and set
3218
+ * `newOffset = fromOffset + consumedBytes`. Any tail after the last `'\n'` —
3219
+ * syntactically broken OR a complete-but-not-yet-terminated record — is DEFERRED
3220
+ * to the next pass once its `'\n'` lands. Codex `messageCount` is additive with
3221
+ * NO dedup, so re-reading a still-unterminated complete line would double-count
3222
+ * it; deferring prevents that (the bug class prix-cloud caught for Claude).
3223
+ */
3224
+ export async function scanCodexSessionIncremental(filePath, fromOffset, prior) {
3225
+ const state = hydrateCodexParseState(prior);
3226
+ const chunks = [];
3227
+ const stream = fs.createReadStream(filePath, { start: fromOffset });
3228
+ try {
3229
+ for await (const chunk of stream) {
3230
+ chunks.push(typeof chunk === 'string' ? Buffer.from(chunk, 'utf-8') : chunk);
3231
+ }
3232
+ }
3233
+ finally {
3234
+ stream.destroy();
3235
+ }
3236
+ const appended = Buffer.concat(chunks);
3237
+ // Bytes up to AND INCLUDING the last '\n' are the committed, complete-line run.
3238
+ const lastNl = appended.lastIndexOf(0x0a);
3239
+ const consumedBytes = lastNl === -1 ? 0 : lastNl + 1;
3240
+ if (consumedBytes > 0) {
3241
+ for (const line of appended.subarray(0, consumedBytes).toString('utf-8').split('\n')) {
3242
+ if (!line.trim())
3243
+ continue;
3244
+ let parsed;
3245
+ try {
3246
+ parsed = JSON.parse(line);
3247
+ }
3248
+ catch {
3249
+ continue;
3250
+ }
3251
+ applyCodexLine(state, parsed);
3252
+ }
3253
+ }
3254
+ const newOffset = fromOffset + consumedBytes;
3255
+ return {
3256
+ scan: finalizeCodexScan(state),
3257
+ newState: serializeCodexParserState(state, newOffset),
3258
+ newOffset,
2318
3259
  };
2319
3260
  }
3261
+ /** Serialized zero-value continuation: a fresh accumulator at offset 0, used to drive a FULL parse from the start through the same resumable path. */
3262
+ function freshCodexParserState() {
3263
+ return serializeCodexParserState(initCodexParseState(), 0);
3264
+ }
3265
+ /**
3266
+ * Cheaply derive a Codex rollout's session identity — the `session_meta` id — by
3267
+ * streaming only the START of the file (at most `maxBytes`) and stopping once the
3268
+ * id is known. Mirrors {@link claudeSessionIdentityAt}: used by
3269
+ * {@link scanCodexSessionResumable} to confirm a grown file is still the SAME
3270
+ * session before resuming from a stored parse offset. Codex writes `session_meta`
3271
+ * (carrying the durable session UUID) on the first line of every rollout, so the
3272
+ * id is reached almost immediately. Returns undefined when no id appears within
3273
+ * the budget, which forces a FULL parse.
3274
+ */
3275
+ async function codexSessionIdentityAt(filePath, maxBytes = 1_048_576) {
3276
+ const state = initCodexParseState();
3277
+ const stream = fs.createReadStream(filePath, { start: 0, end: maxBytes - 1, encoding: 'utf-8' });
3278
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
3279
+ try {
3280
+ for await (const line of rl) {
3281
+ if (!line.trim())
3282
+ continue;
3283
+ let parsed;
3284
+ try {
3285
+ parsed = JSON.parse(line);
3286
+ }
3287
+ catch {
3288
+ continue;
3289
+ }
3290
+ applyCodexLine(state, parsed);
3291
+ if (state.sessionId !== undefined)
3292
+ break;
3293
+ }
3294
+ }
3295
+ finally {
3296
+ rl.close();
3297
+ stream.destroy();
3298
+ }
3299
+ return state.sessionId;
3300
+ }
3301
+ /**
3302
+ * Decide full-vs-incremental for one Codex rollout and parse it uniformly,
3303
+ * always returning a finalized scan plus the continuation to persist. Both
3304
+ * branches run through the SAME reducer (via {@link scanCodexSessionIncremental}),
3305
+ * so an append produces a row identical to a from-scratch full reparse by
3306
+ * construction. INCREMENTAL when a prior continuation exists AND the file grew
3307
+ * past the persisted offset AND its mtime did not go backwards; FULL (from byte
3308
+ * 0, fresh state) otherwise (cold start, truncation/rewrite, clock rewind).
3309
+ */
3310
+ async function scanCodexSessionResumable(filePath, prior, currentFileMtimeMs, currentFileSize, priorFileMtimeMs) {
3311
+ // File size + mtime cannot distinguish an APPEND from an in-place rewrite or a
3312
+ // restore that dropped a DIFFERENT, larger rollout at the same path: both grow
3313
+ // the file and move mtime forward. Resuming from the stored offset across that
3314
+ // boundary would fold the new session's bytes into an accumulator hydrated from
3315
+ // the OLD session, so the persisted row silently diverges from a full reparse.
3316
+ // So the metadata gate below only makes a file ELIGIBLE; before trusting the
3317
+ // offset we re-read the rollout's `session_meta` id and require it to still
3318
+ // match the prior continuation's. An append keeps that id; a rewrite/restore of
3319
+ // a different session changes it. A mismatch — or an id we cannot derive —
3320
+ // falls back to a FULL parse, which is always correct. (A shrink is already
3321
+ // handled: currentFileSize is not > prior.offset, so it takes the FULL branch.)
3322
+ let canIncrement = false;
3323
+ if (prior !== null &&
3324
+ currentFileSize > prior.offset &&
3325
+ (priorFileMtimeMs === undefined || currentFileMtimeMs >= priorFileMtimeMs) &&
3326
+ prior.sessionId !== undefined) {
3327
+ canIncrement = (await codexSessionIdentityAt(filePath)) === prior.sessionId;
3328
+ }
3329
+ if (canIncrement && prior !== null) {
3330
+ const result = await scanCodexSessionIncremental(filePath, prior.offset, prior);
3331
+ return { ...result, mode: 'incremental' };
3332
+ }
3333
+ const result = await scanCodexSessionIncremental(filePath, 0, freshCodexParserState());
3334
+ return { ...result, mode: 'full' };
3335
+ }
3336
+ /**
3337
+ * Parse the prior continuation blob for a changed Codex file into a usable
3338
+ * {@link CodexParserState}, or null when there is none / it is unusable. A blob
3339
+ * from a different serialization version is treated as absent so the file falls
3340
+ * back to a clean FULL parse rather than resuming against a stale shape.
3341
+ */
3342
+ function parsePriorCodexState(row) {
3343
+ if (!row?.parserState)
3344
+ return null;
3345
+ try {
3346
+ const parsed = JSON.parse(row.parserState);
3347
+ if (parsed?.v !== 1 || typeof parsed.offset !== 'number')
3348
+ return null;
3349
+ return parsed;
3350
+ }
3351
+ catch {
3352
+ return null;
3353
+ }
3354
+ }
3355
+ /** Test seam: how many times the incremental (append-resume) branch was taken since the last reset. */
3356
+ let codexIncrementalScanCount = 0;
3357
+ /** Test seam: how many times a full (from-offset-0) Codex parse ran since the last reset. */
3358
+ let codexFullScanCount = 0;
3359
+ /** Test seam: read the (incremental, full) Codex parse counters. */
3360
+ export function __codexScanBranchCountsForTest() {
3361
+ return { incremental: codexIncrementalScanCount, full: codexFullScanCount };
3362
+ }
3363
+ /** Test seam: reset the Codex parse-branch counters to observe a scan from a clean slate. */
3364
+ export function __resetCodexScanBranchCountsForTest() {
3365
+ codexIncrementalScanCount = 0;
3366
+ codexFullScanCount = 0;
3367
+ }
2320
3368
  /** Resolve the working directory for an OpenClaw agent from its workspace config. */
2321
3369
  function getOpenClawSessionCwd(agentId) {
2322
3370
  const workspace = agentId ? getOpenClawWorkspaceMap().get(agentId) : undefined;
@@ -2576,16 +3624,20 @@ async function scanKimiIncremental(onProgress) {
2576
3624
  if (changed.length === 0)
2577
3625
  return;
2578
3626
  onProgress?.({ agent: 'kimi', parsed: 0, total: changed.length });
3627
+ // Bulk-fetch each changed session's prior wire-parse continuation (offset +
3628
+ // counter bases). A session whose wire.jsonl grew resumes from the offset;
3629
+ // everything else (cold start, truncation) full-parses from byte 0.
3630
+ const priorStates = getParserStatesForPaths(changed.map(c => c.filePath));
2579
3631
  const scanEntries = [];
2580
3632
  const touched = [];
2581
3633
  const seen = new Set();
2582
3634
  let parsed = 0;
2583
3635
  for (const { filePath, scan } of changed) {
2584
3636
  try {
2585
- const result = readKimiMeta(filePath);
3637
+ const result = readKimiMeta(filePath, priorStates.get(filePath));
2586
3638
  if (result && !seen.has(result.meta.id)) {
2587
3639
  seen.add(result.meta.id);
2588
- scanEntries.push({ meta: result.meta, content: result.content, scan });
3640
+ scanEntries.push({ meta: result.meta, content: result.content, scan, parserState: result.parserState });
2589
3641
  }
2590
3642
  else {
2591
3643
  touched.push({ filePath, scan });
@@ -2601,7 +3653,7 @@ async function scanKimiIncremental(onProgress) {
2601
3653
  recordScans(touched);
2602
3654
  }
2603
3655
  /** Parse a single Kimi session state.json file to extract session metadata. */
2604
- export function readKimiMeta(filePath) {
3656
+ export function readKimiMeta(filePath, priorRow) {
2605
3657
  let state;
2606
3658
  try {
2607
3659
  state = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
@@ -2626,7 +3678,7 @@ export function readKimiMeta(filePath) {
2626
3678
  const stat = safeStatSync(filePath);
2627
3679
  const timestamp = updatedAt || createdAt
2628
3680
  || (stat ? stat.mtime.toISOString() : new Date().toISOString());
2629
- const shortId = sessionId.replace(/^session_/, '').slice(0, 8);
3681
+ const shortId = deriveShortId(sessionId, /^session_/);
2630
3682
  // Try to infer project from session directory path
2631
3683
  // ~/.kimi-code/sessions/<workdir_hash>/session_<uuid>/
2632
3684
  const workDirName = path.basename(path.dirname(sessionDir));
@@ -2637,8 +3689,11 @@ export function readKimiMeta(filePath) {
2637
3689
  project = parts.slice(0, -1).join('/');
2638
3690
  }
2639
3691
  }
2640
- // Parse wire.jsonl to extract message count and token usage
2641
- const { messageCount, tokenCount, outputTokens } = parseKimiWireMetrics(sessionDir);
3692
+ // Parse wire.jsonl incrementally: resume from the persisted offset + counter
3693
+ // bases when the wire grew, else full-parse from byte 0. The continuation is
3694
+ // persisted on this session's state.json ledger row.
3695
+ const prior = parsePriorKimiState(priorRow);
3696
+ const { messageCount, tokenCount, outputTokens, newState } = parseKimiWireMetricsIncremental(sessionDir, prior);
2642
3697
  const meta = {
2643
3698
  id: sessionId,
2644
3699
  shortId,
@@ -2651,46 +3706,259 @@ export function readKimiMeta(filePath) {
2651
3706
  tokenCount: tokenCount > 0 ? tokenCount : undefined,
2652
3707
  outputTokens: outputTokens > 0 ? outputTokens : undefined,
2653
3708
  };
2654
- return { meta, content: lastPrompt || '' };
3709
+ return { meta, content: lastPrompt || '', parserState: JSON.stringify(newState) };
2655
3710
  }
2656
- /** Parse Kimi's wire.jsonl to extract message count and token usage.
2657
- * TODO: optimize to stream (like scanClaudeSession) to avoid loading large files into memory.
2658
- * For now, synchronous readFileSync matches the pattern of reading state.json and is acceptable
2659
- * since session dirs are usually fresh in FS cache during incremental scans. */
2660
- function parseKimiWireMetrics(sessionDir) {
3711
+ /** Fold one parsed Kimi wire event into the additive counters, in place. */
3712
+ function applyKimiWireEvent(acc, event) {
3713
+ if (event.type === 'context.append_message') {
3714
+ acc.messageCount++;
3715
+ }
3716
+ else if (event.type === 'usage.record' && event.usage) {
3717
+ // Kimi usage structure: inputOther + output + inputCacheRead + inputCacheCreation
3718
+ const u = event.usage;
3719
+ acc.tokenCount += (u.inputOther || 0) + (u.output || 0) + (u.inputCacheRead || 0) + (u.inputCacheCreation || 0);
3720
+ acc.outputTokens += (u.output || 0);
3721
+ }
3722
+ }
3723
+ /**
3724
+ * Incrementally parse Kimi's wire.jsonl for message-count and token counters,
3725
+ * resuming from a persisted continuation instead of re-reading from byte 0 every
3726
+ * scan. Returns the finalized counters and the next {@link KimiParserState} to
3727
+ * persist (offset + the three counter bases).
3728
+ *
3729
+ * Same trailing-line discipline as {@link scanClaudeSessionIncremental}: read
3730
+ * only the appended byte range from `prior.offset`, apply ONLY the run of
3731
+ * newline-terminated lines (slice at the last `'\n'`), and advance the offset to
3732
+ * `prior.offset + consumedBytes`. A complete-but-not-yet-terminated last record
3733
+ * is DEFERRED to the next pass; because these counters are additive with no
3734
+ * dedup, re-reading such a line would double-count it. FULL parse from byte 0
3735
+ * (fresh counters) when there is no prior OR the file shrank below the stored
3736
+ * offset (truncation/rewrite).
3737
+ */
3738
+ export function parseKimiWireMetricsIncremental(sessionDir, prior) {
2661
3739
  const wirePath = path.join(sessionDir, 'agents', 'main', 'wire.jsonl');
2662
- let messageCount = 0;
2663
- let tokenCount = 0;
2664
- let outputTokens = 0;
2665
- if (!fs.existsSync(wirePath)) {
2666
- return { messageCount: 0, tokenCount: 0, outputTokens: 0 };
3740
+ const stat = safeStatSync(wirePath);
3741
+ if (!stat) {
3742
+ // No wire.jsonl (yet): zero counters, offset 0 so a later append is a clean
3743
+ // full parse.
3744
+ return { messageCount: 0, tokenCount: 0, outputTokens: 0, newState: { v: 1, offset: 0, messageCount: 0, tokenCount: 0, outputTokens: 0 } };
3745
+ }
3746
+ // INCREMENTAL only when a usable prior exists AND the file grew past its
3747
+ // offset; otherwise FULL from byte 0 with fresh counters (cold start OR the
3748
+ // file shrank to/below the offset — a truncation/rewrite).
3749
+ //
3750
+ // No session-identity re-check is needed here (unlike Claude's
3751
+ // claudeSessionIdentityAt / Codex's codexSessionIdentityAt, which guard against
3752
+ // an in-place rewrite dropping a DIFFERENT session at the same path). A Kimi
3753
+ // wire.jsonl is uniquely keyed by its session dir — `.../session_<uuid>/agents/
3754
+ // main/wire.jsonl` (see readKimiMeta: sessionId is `session_<uuid>` and must
3755
+ // start with `session_`) — and Kimi only ever APPENDS to that per-session log.
3756
+ // The path therefore cannot host a different session's transcript, so a
3757
+ // size-grew wire.jsonl is always the same session's append. (A truncation/
3758
+ // rewrite — the only way its bytes could diverge — already shrinks it to/below
3759
+ // the offset and takes the FULL branch above.)
3760
+ const canIncrement = prior !== null && stat.size > prior.offset;
3761
+ const fromOffset = canIncrement ? prior.offset : 0;
3762
+ const acc = canIncrement
3763
+ ? { messageCount: prior.messageCount, tokenCount: prior.tokenCount, outputTokens: prior.outputTokens }
3764
+ : { messageCount: 0, tokenCount: 0, outputTokens: 0 };
3765
+ let consumedBytes = 0;
3766
+ let fd;
3767
+ try {
3768
+ // Read ONLY the appended byte range [fromOffset, stat.size) — not the whole
3769
+ // file. readSync from an explicit position keeps this function synchronous
3770
+ // (its callers are sync) while making the disk read + allocation scale with
3771
+ // the appended delta, not total file size, matching scanCodexSessionIncremental
3772
+ // / scanClaudeSessionIncremental. Bytes past the stat'd size are a concurrent
3773
+ // append and are deferred to the next scan.
3774
+ const bytesToRead = Math.max(0, stat.size - fromOffset);
3775
+ const appended = Buffer.allocUnsafe(bytesToRead);
3776
+ if (bytesToRead > 0) {
3777
+ fd = fs.openSync(wirePath, 'r');
3778
+ let read = 0;
3779
+ while (read < bytesToRead) {
3780
+ const n = fs.readSync(fd, appended, read, bytesToRead - read, fromOffset + read);
3781
+ if (n <= 0)
3782
+ break;
3783
+ read += n;
3784
+ }
3785
+ const chunk = read === bytesToRead ? appended : appended.subarray(0, read);
3786
+ // Bytes up to AND INCLUDING the last '\n' are the committed, complete-line run.
3787
+ const lastNl = chunk.lastIndexOf(0x0a);
3788
+ consumedBytes = lastNl === -1 ? 0 : lastNl + 1;
3789
+ if (consumedBytes > 0) {
3790
+ for (const line of chunk.subarray(0, consumedBytes).toString('utf-8').split('\n')) {
3791
+ if (!line.trim())
3792
+ continue;
3793
+ try {
3794
+ applyKimiWireEvent(acc, JSON.parse(line));
3795
+ }
3796
+ catch {
3797
+ // Malformed line, skip
3798
+ }
3799
+ }
3800
+ }
3801
+ }
2667
3802
  }
3803
+ catch {
3804
+ // If wire.jsonl can't be read, keep the accumulated counters (0s on a cold
3805
+ // parse) — graceful degradation, matching the pre-incremental behavior.
3806
+ }
3807
+ finally {
3808
+ if (fd !== undefined) {
3809
+ try {
3810
+ fs.closeSync(fd);
3811
+ }
3812
+ catch { /* already closed / gone */ }
3813
+ }
3814
+ }
3815
+ return {
3816
+ messageCount: acc.messageCount,
3817
+ tokenCount: acc.tokenCount,
3818
+ outputTokens: acc.outputTokens,
3819
+ newState: { v: 1, offset: fromOffset + consumedBytes, messageCount: acc.messageCount, tokenCount: acc.tokenCount, outputTokens: acc.outputTokens },
3820
+ };
3821
+ }
3822
+ /**
3823
+ * Parse the prior continuation blob for a changed Kimi session into a usable
3824
+ * {@link KimiParserState}, or null when there is none / it is unusable. A blob
3825
+ * from a different serialization version is treated as absent so the wire parse
3826
+ * falls back to a clean FULL parse rather than resuming against a stale shape.
3827
+ */
3828
+ function parsePriorKimiState(row) {
3829
+ if (!row?.parserState)
3830
+ return null;
2668
3831
  try {
2669
- const lines = fs.readFileSync(wirePath, 'utf-8').split('\n');
2670
- for (const line of lines) {
2671
- if (!line.trim())
3832
+ const parsed = JSON.parse(row.parserState);
3833
+ if (parsed?.v !== 1 || typeof parsed.offset !== 'number')
3834
+ return null;
3835
+ return parsed;
3836
+ }
3837
+ catch {
3838
+ return null;
3839
+ }
3840
+ }
3841
+ /**
3842
+ * Scan Grok sessions. Grok stores one directory per session under
3843
+ * ~/.grok/sessions/<url-encoded-cwd>/<uuid>/, each holding a summary.json with
3844
+ * structured metadata (id, cwd, title, timestamps, message count). Same
3845
+ * dir-per-session (L3) shape as Kimi, so it walks two levels and gates the
3846
+ * summary.json read through the scan ledger. Before this, Grok had a type slot
3847
+ * and a placeholder parser but no scanner, so `agents sessions` never indexed it.
3848
+ */
3849
+ async function scanGrokIncremental(onProgress) {
3850
+ const currentVersion = await getCurrentAgentVersion('grok');
3851
+ const filePaths = [];
3852
+ for (const sessionsDir of getAgentSessionDirs('grok', 'sessions')) {
3853
+ if (!fs.existsSync(sessionsDir))
3854
+ continue;
3855
+ let cwdDirNames;
3856
+ try {
3857
+ cwdDirNames = fs.readdirSync(sessionsDir);
3858
+ }
3859
+ catch {
3860
+ continue;
3861
+ }
3862
+ for (const cwdDirName of cwdDirNames) {
3863
+ const cwdDir = path.join(sessionsDir, cwdDirName);
3864
+ const stat = safeStatSync(cwdDir);
3865
+ if (!stat?.isDirectory())
2672
3866
  continue;
3867
+ let sessionNames;
2673
3868
  try {
2674
- const event = JSON.parse(line);
2675
- if (event.type === 'context.append_message') {
2676
- messageCount++;
2677
- }
2678
- else if (event.type === 'usage.record' && event.usage) {
2679
- // Kimi usage structure: inputOther + output + inputCacheRead + inputCacheCreation
2680
- const u = event.usage;
2681
- tokenCount += (u.inputOther || 0) + (u.output || 0) + (u.inputCacheRead || 0) + (u.inputCacheCreation || 0);
2682
- outputTokens += (u.output || 0);
2683
- }
3869
+ sessionNames = fs.readdirSync(cwdDir);
2684
3870
  }
2685
3871
  catch {
2686
- // Malformed line, skip
3872
+ continue;
3873
+ }
3874
+ for (const sessionName of sessionNames) {
3875
+ const summaryPath = path.join(cwdDir, sessionName, 'summary.json');
3876
+ if (!fs.existsSync(summaryPath))
3877
+ continue;
3878
+ filePaths.push(summaryPath);
3879
+ }
3880
+ }
3881
+ }
3882
+ const changed = filterChangedFiles(filePaths);
3883
+ if (changed.length === 0)
3884
+ return;
3885
+ onProgress?.({ agent: 'grok', parsed: 0, total: changed.length });
3886
+ const scanEntries = [];
3887
+ const touched = [];
3888
+ const seen = new Set();
3889
+ let parsed = 0;
3890
+ for (const { filePath, scan } of changed) {
3891
+ try {
3892
+ const result = readGrokMeta(filePath, currentVersion);
3893
+ if (result && !seen.has(result.meta.id)) {
3894
+ seen.add(result.meta.id);
3895
+ scanEntries.push({ meta: result.meta, content: result.content, scan });
3896
+ }
3897
+ else {
3898
+ touched.push({ filePath, scan });
2687
3899
  }
2688
3900
  }
3901
+ catch {
3902
+ touched.push({ filePath, scan });
3903
+ }
3904
+ parsed++;
3905
+ onProgress?.({ agent: 'grok', parsed, total: changed.length });
3906
+ }
3907
+ upsertSessionsBatch(scanEntries);
3908
+ recordScans(touched);
3909
+ }
3910
+ /** Parse a single Grok session summary.json into session metadata. */
3911
+ export function readGrokMeta(filePath, currentVersion) {
3912
+ let summary;
3913
+ try {
3914
+ summary = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
2689
3915
  }
2690
3916
  catch {
2691
- // If wire.jsonl can't be read, return 0s (graceful degradation)
3917
+ return null;
3918
+ }
3919
+ const sessionDir = path.dirname(filePath);
3920
+ // The uuid directory name is the canonical id; summary.info.id mirrors it.
3921
+ const sessionId = (typeof summary?.info?.id === 'string' && summary.info.id) || path.basename(sessionDir);
3922
+ if (!sessionId)
3923
+ return null;
3924
+ const cwd = normalizeCwd(typeof summary?.info?.cwd === 'string' ? summary.info.cwd : '');
3925
+ const topic = (typeof summary?.generated_title === 'string' && summary.generated_title.trim()) ||
3926
+ (typeof summary?.session_summary === 'string' && summary.session_summary.trim()) ||
3927
+ undefined;
3928
+ // created_at is the session start; last_active_at/updated_at is the latest
3929
+ // activity. Coerce timestamp to never-null (NOT NULL column) via the file mtime,
3930
+ // matching how the other dir-per-session parsers (Kimi) fall back.
3931
+ const createdAt = typeof summary?.created_at === 'string' ? summary.created_at : undefined;
3932
+ const lastActivity = (typeof summary?.last_active_at === 'string' && summary.last_active_at) ||
3933
+ (typeof summary?.updated_at === 'string' && summary.updated_at) ||
3934
+ undefined;
3935
+ const stat = safeStatSync(filePath);
3936
+ const timestamp = createdAt || lastActivity || (stat ? stat.mtime.toISOString() : new Date().toISOString());
3937
+ const messageCount = typeof summary?.num_chat_messages === 'number'
3938
+ ? summary.num_chat_messages
3939
+ : typeof summary?.num_messages === 'number'
3940
+ ? summary.num_messages
3941
+ : undefined;
3942
+ // Grok records its managed home in summary.grok_home
3943
+ // (…/versions/grok/<version>/home/.grok) — recover the version from it.
3944
+ let embeddedVersion;
3945
+ if (typeof summary?.grok_home === 'string') {
3946
+ embeddedVersion = summary.grok_home.match(/versions\/grok\/([^/]+)\//)?.[1];
2692
3947
  }
2693
- return { messageCount, tokenCount, outputTokens };
3948
+ const meta = {
3949
+ id: sessionId,
3950
+ shortId: deriveShortId(sessionId),
3951
+ agent: 'grok',
3952
+ timestamp,
3953
+ lastActivity,
3954
+ project: cwd ? path.basename(cwd) : undefined,
3955
+ cwd: cwd || undefined,
3956
+ filePath,
3957
+ version: resolveSessionVersion('grok', filePath, embeddedVersion, currentVersion),
3958
+ topic,
3959
+ messageCount,
3960
+ };
3961
+ return { meta, content: topic || '' };
2694
3962
  }
2695
3963
  /** Parse a time filter string (relative like '7d' or ISO timestamp) into epoch milliseconds. */
2696
3964
  export function parseTimeFilter(input) {