@phnx-labs/agents-cli 1.20.34 → 1.20.36

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 (291) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/LICENSE +185 -21
  3. package/README.md +13 -5
  4. package/dist/commands/audit.d.ts +14 -0
  5. package/dist/commands/audit.js +68 -0
  6. package/dist/commands/browser.js +82 -8
  7. package/dist/commands/check.d.ts +15 -0
  8. package/dist/commands/check.js +84 -0
  9. package/dist/commands/cloud.js +143 -3
  10. package/dist/commands/computer-actions.d.ts +15 -0
  11. package/dist/commands/computer-actions.js +120 -22
  12. package/dist/commands/computer.js +81 -0
  13. package/dist/commands/cost.js +17 -12
  14. package/dist/commands/daemon.js +4 -1
  15. package/dist/commands/doctor.d.ts +1 -0
  16. package/dist/commands/doctor.js +54 -97
  17. package/dist/commands/events.d.ts +16 -0
  18. package/dist/commands/events.js +173 -0
  19. package/dist/commands/exec.d.ts +19 -0
  20. package/dist/commands/exec.js +283 -12
  21. package/dist/commands/hosts.js +37 -29
  22. package/dist/commands/inspect.d.ts +2 -0
  23. package/dist/commands/inspect.js +62 -16
  24. package/dist/commands/lock.d.ts +12 -0
  25. package/dist/commands/lock.js +70 -0
  26. package/dist/commands/logs.d.ts +17 -0
  27. package/dist/commands/logs.js +139 -0
  28. package/dist/commands/message.d.ts +15 -0
  29. package/dist/commands/message.js +56 -0
  30. package/dist/commands/models.d.ts +3 -0
  31. package/dist/commands/models.js +27 -8
  32. package/dist/commands/permissions.js +9 -2
  33. package/dist/commands/repo.d.ts +34 -0
  34. package/dist/commands/repo.js +243 -65
  35. package/dist/commands/resource-view.d.ts +20 -0
  36. package/dist/commands/resource-view.js +90 -28
  37. package/dist/commands/routines.js +101 -5
  38. package/dist/commands/secrets-migrate.js +106 -57
  39. package/dist/commands/secrets.d.ts +31 -18
  40. package/dist/commands/secrets.js +196 -91
  41. package/dist/commands/serve.d.ts +10 -0
  42. package/dist/commands/serve.js +37 -0
  43. package/dist/commands/sessions-inject.d.ts +14 -0
  44. package/dist/commands/sessions-inject.js +111 -0
  45. package/dist/commands/sessions-picker.d.ts +2 -0
  46. package/dist/commands/sessions-picker.js +24 -3
  47. package/dist/commands/sessions-resume.d.ts +2 -0
  48. package/dist/commands/sessions-resume.js +221 -0
  49. package/dist/commands/sessions-tail.d.ts +10 -0
  50. package/dist/commands/sessions-tail.js +11 -0
  51. package/dist/commands/sessions.d.ts +143 -1
  52. package/dist/commands/sessions.js +628 -80
  53. package/dist/commands/setup.js +4 -2
  54. package/dist/commands/sync.d.ts +3 -1
  55. package/dist/commands/sync.js +156 -4
  56. package/dist/commands/teams.js +217 -0
  57. package/dist/commands/versions.js +2 -4
  58. package/dist/commands/view.d.ts +3 -0
  59. package/dist/commands/view.js +19 -8
  60. package/dist/commands/wallet.d.ts +6 -0
  61. package/dist/commands/wallet.js +22 -5
  62. package/dist/commands/watchdog.d.ts +18 -0
  63. package/dist/commands/watchdog.js +238 -0
  64. package/dist/index.js +82 -32
  65. package/dist/lib/agents.js +30 -9
  66. package/dist/lib/audit/log.d.ts +92 -0
  67. package/dist/lib/audit/log.js +177 -0
  68. package/dist/lib/auto-pull.js +2 -1
  69. package/dist/lib/browser/cdp.js +5 -1
  70. package/dist/lib/browser/chrome.d.ts +10 -0
  71. package/dist/lib/browser/chrome.js +18 -7
  72. package/dist/lib/browser/drivers/ssh.d.ts +8 -0
  73. package/dist/lib/browser/drivers/ssh.js +77 -16
  74. package/dist/lib/browser/har.d.ts +84 -0
  75. package/dist/lib/browser/har.js +77 -0
  76. package/dist/lib/browser/ipc.d.ts +3 -0
  77. package/dist/lib/browser/ipc.js +37 -12
  78. package/dist/lib/browser/profiles.d.ts +1 -1
  79. package/dist/lib/browser/profiles.js +8 -10
  80. package/dist/lib/browser/refs.d.ts +65 -0
  81. package/dist/lib/browser/refs.js +73 -1
  82. package/dist/lib/browser/runtime-state.js +1 -0
  83. package/dist/lib/browser/service.d.ts +38 -2
  84. package/dist/lib/browser/service.js +183 -23
  85. package/dist/lib/browser/types.d.ts +14 -1
  86. package/dist/lib/budget/live-cloud.d.ts +42 -0
  87. package/dist/lib/budget/live-cloud.js +79 -0
  88. package/dist/lib/budget/live-team.d.ts +31 -0
  89. package/dist/lib/budget/live-team.js +115 -0
  90. package/dist/lib/cloud/codex.js +4 -0
  91. package/dist/lib/cloud/rush.d.ts +12 -1
  92. package/dist/lib/cloud/rush.js +13 -3
  93. package/dist/lib/cloud/types.d.ts +9 -0
  94. package/dist/lib/computer/dispatch.d.ts +8 -0
  95. package/dist/lib/computer/dispatch.js +125 -0
  96. package/dist/lib/computer/loop.d.ts +62 -0
  97. package/dist/lib/computer/loop.js +98 -0
  98. package/dist/lib/computer/model.d.ts +44 -0
  99. package/dist/lib/computer/model.js +157 -0
  100. package/dist/lib/concurrency.d.ts +19 -0
  101. package/dist/lib/concurrency.js +33 -0
  102. package/dist/lib/daemon.d.ts +92 -0
  103. package/dist/lib/daemon.js +222 -18
  104. package/dist/lib/devices/connect.d.ts +3 -2
  105. package/dist/lib/devices/connect.js +5 -3
  106. package/dist/lib/devices/registry.d.ts +7 -0
  107. package/dist/lib/devices/registry.js +24 -0
  108. package/dist/lib/devices/tailscale.js +1 -1
  109. package/dist/lib/drift.d.ts +52 -0
  110. package/dist/lib/drift.js +112 -0
  111. package/dist/lib/events.d.ts +10 -3
  112. package/dist/lib/events.js +101 -24
  113. package/dist/lib/exec.d.ts +17 -0
  114. package/dist/lib/exec.js +100 -12
  115. package/dist/lib/git.d.ts +27 -0
  116. package/dist/lib/git.js +56 -1
  117. package/dist/lib/hooks/cache.d.ts +6 -0
  118. package/dist/lib/hooks/cache.js +54 -12
  119. package/dist/lib/hooks.d.ts +27 -0
  120. package/dist/lib/hooks.js +135 -10
  121. package/dist/lib/hosts/dispatch.d.ts +15 -0
  122. package/dist/lib/hosts/dispatch.js +45 -13
  123. package/dist/lib/hosts/logs.d.ts +16 -0
  124. package/dist/lib/hosts/logs.js +74 -0
  125. package/dist/lib/hosts/option.js +1 -1
  126. package/dist/lib/hosts/passthrough.js +3 -1
  127. package/dist/lib/hosts/progress.d.ts +66 -0
  128. package/dist/lib/hosts/progress.js +125 -17
  129. package/dist/lib/hosts/ready.d.ts +51 -7
  130. package/dist/lib/hosts/ready.js +98 -25
  131. package/dist/lib/hosts/reconcile.d.ts +53 -0
  132. package/dist/lib/hosts/reconcile.js +81 -0
  133. package/dist/lib/hosts/registry.d.ts +19 -2
  134. package/dist/lib/hosts/registry.js +58 -2
  135. package/dist/lib/hosts/remote-cmd.d.ts +62 -1
  136. package/dist/lib/hosts/remote-cmd.js +70 -1
  137. package/dist/lib/hosts/remote-os.d.ts +17 -0
  138. package/dist/lib/hosts/remote-os.js +30 -0
  139. package/dist/lib/hosts/session-index.d.ts +34 -0
  140. package/dist/lib/hosts/session-index.js +56 -0
  141. package/dist/lib/hosts/tasks.d.ts +22 -0
  142. package/dist/lib/hosts/tasks.js +29 -0
  143. package/dist/lib/lock.d.ts +93 -0
  144. package/dist/lib/lock.js +207 -0
  145. package/dist/lib/loop.js +16 -1
  146. package/dist/lib/machine-id.d.ts +21 -0
  147. package/dist/lib/machine-id.js +26 -0
  148. package/dist/lib/mailbox-target.d.ts +36 -0
  149. package/dist/lib/mailbox-target.js +45 -0
  150. package/dist/lib/mailbox.d.ts +47 -0
  151. package/dist/lib/mailbox.js +194 -0
  152. package/dist/lib/mcp.d.ts +5 -0
  153. package/dist/lib/mcp.js +24 -8
  154. package/dist/lib/migrate.d.ts +19 -0
  155. package/dist/lib/migrate.js +134 -26
  156. package/dist/lib/overdue.js +3 -0
  157. package/dist/lib/permissions.d.ts +4 -0
  158. package/dist/lib/permissions.js +35 -0
  159. package/dist/lib/picker.d.ts +26 -0
  160. package/dist/lib/picker.js +127 -0
  161. package/dist/lib/platform/exec.d.ts +46 -0
  162. package/dist/lib/platform/exec.js +74 -0
  163. package/dist/lib/platform/process.d.ts +31 -0
  164. package/dist/lib/platform/process.js +34 -1
  165. package/dist/lib/platform/winpath.js +2 -0
  166. package/dist/lib/plugin-marketplace.d.ts +30 -0
  167. package/dist/lib/plugin-marketplace.js +215 -2
  168. package/dist/lib/plugins.d.ts +5 -0
  169. package/dist/lib/plugins.js +61 -10
  170. package/dist/lib/profiles.d.ts +25 -0
  171. package/dist/lib/profiles.js +22 -6
  172. package/dist/lib/pty-client.js +2 -1
  173. package/dist/lib/rotate.d.ts +61 -0
  174. package/dist/lib/rotate.js +52 -0
  175. package/dist/lib/routines.d.ts +40 -2
  176. package/dist/lib/routines.js +66 -8
  177. package/dist/lib/runner.d.ts +11 -2
  178. package/dist/lib/runner.js +49 -7
  179. package/dist/lib/scheduler.js +6 -1
  180. package/dist/lib/secrets/bundles.d.ts +60 -4
  181. package/dist/lib/secrets/bundles.js +131 -12
  182. package/dist/lib/secrets/filestore.d.ts +3 -0
  183. package/dist/lib/secrets/filestore.js +42 -16
  184. package/dist/lib/secrets/index.d.ts +43 -2
  185. package/dist/lib/secrets/index.js +102 -3
  186. package/dist/lib/secrets/mcp.d.ts +93 -0
  187. package/dist/lib/secrets/mcp.js +205 -0
  188. package/dist/lib/secrets/remote.js +12 -5
  189. package/dist/lib/secrets/sync.js +83 -4
  190. package/dist/lib/secrets/windows.js +14 -3
  191. package/dist/lib/serve/data.d.ts +81 -0
  192. package/dist/lib/serve/data.js +91 -0
  193. package/dist/lib/serve/page.d.ts +7 -0
  194. package/dist/lib/serve/page.js +140 -0
  195. package/dist/lib/serve/server.d.ts +46 -0
  196. package/dist/lib/serve/server.js +115 -0
  197. package/dist/lib/session/active.d.ts +61 -0
  198. package/dist/lib/session/active.js +196 -17
  199. package/dist/lib/session/discover.d.ts +37 -0
  200. package/dist/lib/session/discover.js +204 -27
  201. package/dist/lib/session/inject.d.ts +18 -0
  202. package/dist/lib/session/inject.js +21 -0
  203. package/dist/lib/session/parse.d.ts +6 -0
  204. package/dist/lib/session/parse.js +286 -2
  205. package/dist/lib/session/pid-registry.d.ts +22 -0
  206. package/dist/lib/session/pid-registry.js +104 -0
  207. package/dist/lib/session/provenance.d.ts +14 -2
  208. package/dist/lib/session/provenance.js +39 -8
  209. package/dist/lib/session/remote-active.d.ts +26 -0
  210. package/dist/lib/session/remote-active.js +153 -0
  211. package/dist/lib/session/remote-list.d.ts +51 -0
  212. package/dist/lib/session/remote-list.js +213 -0
  213. package/dist/lib/session/remote.d.ts +7 -1
  214. package/dist/lib/session/remote.js +18 -8
  215. package/dist/lib/session/render.js +1 -1
  216. package/dist/lib/session/sync/config.d.ts +1 -7
  217. package/dist/lib/session/sync/config.js +4 -11
  218. package/dist/lib/session/types.d.ts +18 -1
  219. package/dist/lib/session/types.js +1 -1
  220. package/dist/lib/shims.d.ts +44 -6
  221. package/dist/lib/shims.js +143 -57
  222. package/dist/lib/ssh-exec.d.ts +26 -3
  223. package/dist/lib/ssh-exec.js +47 -3
  224. package/dist/lib/ssh-tunnel.d.ts +24 -5
  225. package/dist/lib/ssh-tunnel.js +62 -63
  226. package/dist/lib/staleness/writers/hooks.js +1 -1
  227. package/dist/lib/startup/command-registry.d.ts +8 -0
  228. package/dist/lib/startup/command-registry.js +17 -1
  229. package/dist/lib/state.d.ts +13 -0
  230. package/dist/lib/state.js +103 -9
  231. package/dist/lib/sync-umbrella.d.ts +14 -7
  232. package/dist/lib/sync-umbrella.js +17 -9
  233. package/dist/lib/teams/forEach.d.ts +110 -0
  234. package/dist/lib/teams/forEach.js +186 -0
  235. package/dist/lib/teams/index.d.ts +1 -0
  236. package/dist/lib/teams/index.js +1 -0
  237. package/dist/lib/teams/pr-watch.d.ts +226 -0
  238. package/dist/lib/teams/pr-watch.js +371 -0
  239. package/dist/lib/teams/registry.js +25 -9
  240. package/dist/lib/teams/supervisor.d.ts +14 -1
  241. package/dist/lib/teams/supervisor.js +19 -0
  242. package/dist/lib/teams/worktree.d.ts +9 -0
  243. package/dist/lib/teams/worktree.js +32 -0
  244. package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
  245. package/dist/lib/terminal/backends/ghostty.js +69 -0
  246. package/dist/lib/terminal/backends/index.d.ts +19 -0
  247. package/dist/lib/terminal/backends/index.js +31 -0
  248. package/dist/lib/terminal/backends/iterm.d.ts +6 -0
  249. package/dist/lib/terminal/backends/iterm.js +62 -0
  250. package/dist/lib/terminal/backends/tmux.d.ts +14 -0
  251. package/dist/lib/terminal/backends/tmux.js +23 -0
  252. package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
  253. package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
  254. package/dist/lib/terminal/engine.d.ts +39 -0
  255. package/dist/lib/terminal/engine.js +54 -0
  256. package/dist/lib/terminal/index.d.ts +17 -0
  257. package/dist/lib/terminal/index.js +8 -0
  258. package/dist/lib/terminal/inject.d.ts +204 -0
  259. package/dist/lib/terminal/inject.js +247 -0
  260. package/dist/lib/terminal/policy.d.ts +11 -0
  261. package/dist/lib/terminal/policy.js +11 -0
  262. package/dist/lib/terminal/quote.d.ts +11 -0
  263. package/dist/lib/terminal/quote.js +13 -0
  264. package/dist/lib/terminal/resolve.d.ts +64 -0
  265. package/dist/lib/terminal/resolve.js +90 -0
  266. package/dist/lib/terminal/shell.d.ts +6 -0
  267. package/dist/lib/terminal/shell.js +23 -0
  268. package/dist/lib/terminal/transport.d.ts +15 -0
  269. package/dist/lib/terminal/transport.js +40 -0
  270. package/dist/lib/terminal/types.d.ts +59 -0
  271. package/dist/lib/terminal/types.js +13 -0
  272. package/dist/lib/triggers/webhook.d.ts +85 -0
  273. package/dist/lib/triggers/webhook.js +141 -0
  274. package/dist/lib/types.d.ts +7 -0
  275. package/dist/lib/versions.d.ts +30 -3
  276. package/dist/lib/versions.js +129 -18
  277. package/dist/lib/watchdog/index.d.ts +3 -0
  278. package/dist/lib/watchdog/index.js +5 -0
  279. package/dist/lib/watchdog/read.d.ts +35 -0
  280. package/dist/lib/watchdog/read.js +149 -0
  281. package/dist/lib/watchdog/runner.d.ts +127 -0
  282. package/dist/lib/watchdog/runner.js +322 -0
  283. package/dist/lib/watchdog/watchdog.d.ts +40 -0
  284. package/dist/lib/watchdog/watchdog.js +166 -0
  285. package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
  286. package/dist/lib/watchdog/watchdogTail.js +154 -0
  287. package/dist/lib/whats-new.d.ts +9 -0
  288. package/dist/lib/whats-new.js +35 -0
  289. package/dist/lib/workflows.d.ts +166 -0
  290. package/dist/lib/workflows.js +193 -0
  291. package/package.json +5 -4
@@ -24,13 +24,23 @@ import { promisify } from 'util';
24
24
  import { listActiveTasks } from '../cloud/store.js';
25
25
  import { AgentManager } from '../teams/agents.js';
26
26
  import { getTerminalsDir } from '../state.js';
27
+ import { readPidSessionEntry, prunePidSessionRegistry } from './pid-registry.js';
27
28
  import { buildClaudeLabelMap } from './discover.js';
28
29
  import { latestSessionFileForCwd } from './db.js';
29
30
  import { extractSessionTopic } from './prompt.js';
30
31
  import { readSessionTail } from './tail.js';
31
32
  import { inferSessionState } from './state.js';
32
33
  import { detectProvenance } from './provenance.js';
34
+ import { mapBounded } from '../concurrency.js';
33
35
  const execFileAsync = promisify(execFile);
36
+ /**
37
+ * Per-PID `lsof` probes run bounded and staggered rather than as one parallel
38
+ * fan-out: a simultaneous system-wide `lsof` burst reads to behavioral EDR
39
+ * (CrowdStrike Falcon) as lateral-movement recon. Results are identical — the
40
+ * cwds are just gathered at a bounded spawn rate instead of a single burst.
41
+ */
42
+ export const LSOF_CONCURRENCY = 4;
43
+ const LSOF_STAGGER_MS = 10;
34
44
  const HOME = os.homedir();
35
45
  const LIVE_TERMINALS_FILE = path.join(getTerminalsDir(), 'live-terminals.json');
36
46
  /**
@@ -48,6 +58,19 @@ const AGENT_CLI_NAMES = {
48
58
  opencode: 'opencode',
49
59
  droid: 'droid',
50
60
  };
61
+ /**
62
+ * Resolve an agent kind from a process's reported executable. `comm` may be an
63
+ * absolute path (shim-launched agents), and Windows image names carry an
64
+ * `.exe` suffix (`claude.exe`), so basename + suffix-strip before the lookup.
65
+ */
66
+ function agentKindFromComm(commRaw) {
67
+ const base = path.basename(commRaw);
68
+ const stripped = base.replace(/\.exe$/i, '');
69
+ // Windows image names compare case-insensitively; POSIX comms stay exact —
70
+ // macOS's Claude desktop app process is named 'Claude' and must NOT match.
71
+ const key = stripped === base ? base : stripped.toLowerCase();
72
+ return AGENT_CLI_NAMES[key];
73
+ }
51
74
  function isPidAlive(pid) {
52
75
  if (!pid || pid < 1)
53
76
  return false;
@@ -354,14 +377,14 @@ export function listCloudActive() {
354
377
  * and a terminal-app is preferred over the multiplexer inside it.
355
378
  */
356
379
  const HOST_MATCHERS = [
357
- // IDE renderers (Electron helper processes)
358
- { host: 'code', tokens: ['Code Helper', 'Code - Insiders Helper'] },
359
- { host: 'cursor', tokens: ['Cursor Helper'] },
360
- { host: 'codium', tokens: ['VSCodium Helper'] },
361
- { host: 'windsurf', tokens: ['Windsurf Helper'] },
380
+ // IDE renderers (Electron helper processes on macOS, image names on Windows)
381
+ { host: 'code', tokens: ['Code Helper', 'Code - Insiders Helper', 'Code.exe'] },
382
+ { host: 'cursor', tokens: ['Cursor Helper', 'Cursor.exe'] },
383
+ { host: 'codium', tokens: ['VSCodium Helper', 'VSCodium.exe'] },
384
+ { host: 'windsurf', tokens: ['Windsurf Helper', 'Windsurf.exe'] },
362
385
  // Native terminal apps
363
386
  { host: 'iterm', tokens: ['iTerm2', 'iTermServer', 'iTerm'] },
364
- { host: 'terminal', tokens: ['Terminal.app', '/Applications/Utilities/Terminal.app'] },
387
+ { host: 'terminal', tokens: ['Terminal.app', '/Applications/Utilities/Terminal.app', 'WindowsTerminal.exe'] },
365
388
  { host: 'warp', tokens: ['Warp.app', 'stable_'] },
366
389
  { host: 'alacritty', tokens: ['alacritty', 'Alacritty'] },
367
390
  { host: 'kitty', tokens: ['kitty'] },
@@ -379,6 +402,8 @@ const HOST_MATCHERS = [
379
402
  * matching against AGENT_CLI_NAMES.
380
403
  */
381
404
  async function readProcessTable() {
405
+ if (process.platform === 'win32')
406
+ return readProcessTableWin32();
382
407
  let out;
383
408
  try {
384
409
  ({ stdout: out } = await execFileAsync('ps', ['-A', '-o', 'pid=,ppid=,comm='], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }));
@@ -396,8 +421,41 @@ async function readProcessTable() {
396
421
  if (!Number.isFinite(pid) || !Number.isFinite(ppid))
397
422
  continue;
398
423
  const commRaw = m[3].trim();
399
- const kind = AGENT_CLI_NAMES[path.basename(commRaw)];
400
- rows.push({ pid, ppid, comm: commRaw, kind });
424
+ rows.push({ pid, ppid, comm: commRaw, kind: agentKindFromComm(commRaw) });
425
+ }
426
+ return rows;
427
+ }
428
+ /**
429
+ * Windows process table in one CIM query (`wmic` is removed on current
430
+ * Windows 11, so PowerShell is the stable interface). Same pid/ppid/comm
431
+ * shape as the POSIX `ps` snapshot; `Name` is the image name (`claude.exe`).
432
+ */
433
+ async function readProcessTableWin32() {
434
+ let out;
435
+ try {
436
+ ({ stdout: out } = await execFileAsync('powershell.exe', [
437
+ '-NoProfile', '-NonInteractive', '-Command',
438
+ 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name | ConvertTo-Csv -NoTypeInformation',
439
+ ], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, windowsHide: true }));
440
+ }
441
+ catch {
442
+ return [];
443
+ }
444
+ return parseWin32ProcessCsv(out);
445
+ }
446
+ /** Parse `ConvertTo-Csv` output of Win32_Process rows. Exported for tests. */
447
+ export function parseWin32ProcessCsv(out) {
448
+ const rows = [];
449
+ for (const line of out.split(/\r?\n/)) {
450
+ const m = line.trim().match(/^"(\d+)","(\d+)","(.*)"$/);
451
+ if (!m)
452
+ continue;
453
+ const pid = parseInt(m[1], 10);
454
+ const ppid = parseInt(m[2], 10);
455
+ if (!Number.isFinite(pid) || !Number.isFinite(ppid))
456
+ continue;
457
+ const comm = m[3].replace(/""/g, '"');
458
+ rows.push({ pid, ppid, comm, kind: agentKindFromComm(comm) });
401
459
  }
402
460
  return rows;
403
461
  }
@@ -417,12 +475,25 @@ function hasAttributedAncestor(pid, ppidMap, attributed) {
417
475
  }
418
476
  return false;
419
477
  }
478
+ /**
479
+ * Resolve every candidate PID's cwd, bounded and staggered so the probes no
480
+ * longer fan out as one simultaneous system-wide `lsof` burst (a behavioral-EDR
481
+ * recon trigger). Order matches the input `pids`. The `probe` seam is injectable
482
+ * for testing the bound; production always uses the real `lsof`-backed probe.
483
+ */
484
+ export function resolveCwds(pids, probe = getCwdForPid) {
485
+ return mapBounded(pids, probe, { concurrency: LSOF_CONCURRENCY, staggerMs: LSOF_STAGGER_MS });
486
+ }
420
487
  /**
421
488
  * Resolve a process's current working directory via `lsof`. The `-a` flag
422
489
  * ANDs the filters; without it macOS treats `-p` and `-d` as a union and
423
490
  * returns the cwd of every process on the system.
424
491
  */
425
492
  async function getCwdForPid(pid) {
493
+ // No lsof on Windows and no cheap foreign-process cwd API; the pid registry
494
+ // written by `ag run` supplies the cwd for registry-launched agents instead.
495
+ if (process.platform === 'win32')
496
+ return undefined;
426
497
  let out;
427
498
  try {
428
499
  const res = await execFileAsync('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], {
@@ -469,6 +540,99 @@ const UI_HOSTS = new Set([
469
540
  'iterm', 'terminal', 'warp', 'alacritty', 'kitty', 'hyper', 'wezterm', 'ghostty',
470
541
  'tmux', 'screen',
471
542
  ]);
543
+ /**
544
+ * Find the launch registry entry recorded by a WRAPPER of this process. The
545
+ * shim delegate records the pid it spawned, but on Windows the `.cmd` shell
546
+ * path makes that a cmd.exe intermediary whose child is the real agent binary
547
+ * — so the agent pid itself has no entry and the wrapper one ancestor up does.
548
+ * The nearest entry wins, and only if its agent matches the candidate's kind:
549
+ * a claude session shelling out to codex must not hand codex its identity.
550
+ */
551
+ export function readAncestorSessionEntry(pid, ppidMap, kind, readEntry = readPidSessionEntry) {
552
+ let cur = ppidMap.get(pid);
553
+ const seen = new Set();
554
+ while (cur && cur > 1 && !seen.has(cur)) {
555
+ const entry = readEntry(cur);
556
+ if (entry)
557
+ return entry.agent === kind ? entry : undefined;
558
+ seen.add(cur);
559
+ cur = ppidMap.get(cur);
560
+ }
561
+ return undefined;
562
+ }
563
+ /**
564
+ * Collapse agent processes spawned by another live agent process of the same
565
+ * kind onto their nearest kept ancestor. Claude runs subagents, forks, and
566
+ * even its bundled ripgrep as child `claude` processes — on POSIX those
567
+ * children resolve to the parent's cwd and collapse in dedupeBySession, but
568
+ * where no cwd can be recovered (Windows has no lsof) every fork would print
569
+ * as its own headless row. Two exceptions keep their own row: a candidate with
570
+ * its own registry entry — on its pid OR on a wrapper ancestor strictly below
571
+ * the pid it would fold into (the shim's entry lands on the cmd.exe
572
+ * intermediary on Windows) — and a child of a *different* agent kind (claude
573
+ * shelling out to codex is a real second session, not a fork).
574
+ * Returns the kept roots plus, per root pid, how many descendants folded in.
575
+ */
576
+ export function foldSubordinateAgents(candidates, ppidMap, readEntry) {
577
+ const kindByPid = new Map(candidates.map(c => [c.pid, c.kind]));
578
+ const nearestSameKindAncestor = (pid, kind) => {
579
+ let cur = ppidMap.get(pid);
580
+ const seen = new Set();
581
+ while (cur && cur > 1 && !seen.has(cur)) {
582
+ if (kindByPid.get(cur) === kind)
583
+ return cur;
584
+ seen.add(cur);
585
+ cur = ppidMap.get(cur);
586
+ }
587
+ return undefined;
588
+ };
589
+ // Own launch identity: a matching-kind registry entry on the candidate or on
590
+ // any wrapper between it and the pid it would fold into (exclusive). Entries
591
+ // above the fold target belong to that ancestor's session, not this one.
592
+ const hasOwnSession = (c, stopPid) => {
593
+ if (readEntry(c.pid)?.agent === c.kind)
594
+ return true;
595
+ let cur = ppidMap.get(c.pid);
596
+ const seen = new Set();
597
+ while (cur && cur > 1 && cur !== stopPid && !seen.has(cur)) {
598
+ if (readEntry(cur)?.agent === c.kind)
599
+ return true;
600
+ seen.add(cur);
601
+ cur = ppidMap.get(cur);
602
+ }
603
+ return false;
604
+ };
605
+ const keptPids = new Set();
606
+ for (const c of candidates) {
607
+ const foldTarget = nearestSameKindAncestor(c.pid, c.kind);
608
+ if (foldTarget === undefined || hasOwnSession(c, foldTarget)) {
609
+ keptPids.add(c.pid);
610
+ }
611
+ }
612
+ const kept = [];
613
+ const foldedByRoot = new Map();
614
+ for (const c of candidates) {
615
+ if (keptPids.has(c.pid)) {
616
+ kept.push(c);
617
+ continue;
618
+ }
619
+ // Walk up through folded intermediates to the nearest kept same-kind pid.
620
+ let cur = nearestSameKindAncestor(c.pid, c.kind);
621
+ const seen = new Set();
622
+ while (cur !== undefined && !keptPids.has(cur) && !seen.has(cur)) {
623
+ seen.add(cur);
624
+ cur = nearestSameKindAncestor(cur, c.kind);
625
+ }
626
+ // A fold target must itself be a kept row (a ppid cycle from pid reuse
627
+ // can orphan the whole chain) — otherwise keep the row rather than drop it.
628
+ if (cur === undefined || !keptPids.has(cur)) {
629
+ kept.push(c);
630
+ continue;
631
+ }
632
+ foldedByRoot.set(cur, (foldedByRoot.get(cur) ?? 0) + 1);
633
+ }
634
+ return { kept, foldedByRoot };
635
+ }
472
636
  /**
473
637
  * Agent processes not attributed to a team or the runtime registry.
474
638
  * Classified by walking the ppid chain: any recognised UI ancestor (IDE
@@ -494,13 +658,24 @@ export async function listUnattributedActive(attributed) {
494
658
  continue;
495
659
  candidates.push({ pid, kind });
496
660
  }
497
- // Run lsof probes in parallel so N spawns take one round-trip, not N.
498
- const cwds = await Promise.all(candidates.map(c => getCwdForPid(c.pid)));
661
+ // Forks/subagents of a live agent process collapse onto their root before
662
+ // the cwd probes fewer spawns, and one session stays one row even when
663
+ // cwd-based dedupe is unavailable (Windows).
664
+ const { kept, foldedByRoot } = foldSubordinateAgents(candidates, ppidMap, readPidSessionEntry);
665
+ // Bounded + staggered lsof probes: same cwds, but a trickle of spawns instead
666
+ // of one simultaneous system-wide burst that behavioral EDR flags as recon.
667
+ const cwds = await resolveCwds(kept.map(c => c.pid));
499
668
  const out = [];
500
- for (let i = 0; i < candidates.length; i++) {
501
- const { pid, kind } = candidates[i];
502
- const cwd = cwds[i];
503
- const sessionFile = findSessionFileForKind(kind, cwd);
669
+ for (let i = 0; i < kept.length; i++) {
670
+ const { pid, kind } = kept[i];
671
+ // The per-pid registry (written by `ag run` and the shim delegate) gives
672
+ // the EXACT session id this pid was launched with — so N agents in one cwd
673
+ // resolve to N distinct sessions instead of all collapsing onto the newest
674
+ // .jsonl. The shim's entry may sit on a wrapper ancestor (Windows .cmd
675
+ // path). Absent entirely (direct launch outside agents-cli) → heuristic.
676
+ const entry = readPidSessionEntry(pid) ?? readAncestorSessionEntry(pid, ppidMap, kind);
677
+ const cwd = cwds[i] ?? entry?.cwd ?? undefined;
678
+ const sessionFile = findSessionFileForKind(kind, cwd, entry?.sessionId);
504
679
  const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
505
680
  const host = detectHost(pid, procByPid);
506
681
  const context = host && UI_HOSTS.has(host) ? 'terminal' : 'headless';
@@ -511,11 +686,14 @@ export async function listUnattributedActive(attributed) {
511
686
  host,
512
687
  pid,
513
688
  cwd,
514
- sessionId: sessionIdFromFile(sessionFile),
689
+ sessionId: entry?.sessionId ?? sessionIdFromFile(sessionFile),
515
690
  topic,
516
691
  sessionFile,
692
+ pidCount: 1 + (foldedByRoot.get(pid) ?? 0),
517
693
  }, state, sessionFile));
518
694
  }
695
+ // Housekeeping: drop registry files for pids that have since died.
696
+ prunePidSessionRegistry(isPidAlive);
519
697
  return out;
520
698
  }
521
699
  /**
@@ -573,10 +751,11 @@ function dedupeBySession(sessions) {
573
751
  }
574
752
  const existing = byKey.get(key);
575
753
  if (existing) {
576
- existing.pidCount = (existing.pidCount ?? 1) + 1;
754
+ // Carry pre-folded fork counts (headless rows arrive with pidCount set).
755
+ existing.pidCount = (existing.pidCount ?? 1) + (s.pidCount ?? 1);
577
756
  }
578
757
  else {
579
- s.pidCount = 1;
758
+ s.pidCount = s.pidCount ?? 1;
580
759
  byKey.set(key, s);
581
760
  out.push(s);
582
761
  }
@@ -74,6 +74,22 @@ interface ClaudeSessionScan {
74
74
  * TTL, no external lock files needed.
75
75
  */
76
76
  export declare function discoverSessions(options?: DiscoverOptions): Promise<SessionMeta[]>;
77
+ /**
78
+ * How many agents' dotfile dirs we scan at once, and the minimum spacing between
79
+ * successive scan starts. A small bound + stagger turns a simultaneous bulk
80
+ * multi-dotfile sweep (a behavioral-EDR file-enumeration trigger) into a trickle.
81
+ */
82
+ export declare const DOTFILE_SCAN_CONCURRENCY = 2;
83
+ /** Run each agent's incremental scan, bounded + staggered. Order is irrelevant (each scan writes its own rows). */
84
+ export declare function scanAgentsBounded<T>(items: readonly T[], run: (item: T) => Promise<void>): Promise<void[]>;
85
+ /**
86
+ * The machine a discovered session originated on. Cross-machine sync mirrors a
87
+ * remote transcript to backups/<agent>/<machine>/<subdir>/… (see mirrorPath in
88
+ * sync/agents.ts); every other transcript is a live-home file on this box. So:
89
+ * when the path sits under the agent's backups root, the first segment below it
90
+ * is the origin machine id; otherwise it's the local machine.
91
+ */
92
+ export declare function machineForSessionFile(filePath: string, agent: string): string;
77
93
  /**
78
94
  * Count sessions in scope without running an incremental scan. Assumes the DB
79
95
  * is already fresh (typically true because `discoverSessions` ran first this
@@ -105,8 +121,29 @@ export declare function getAgentSessionDirs(agent: string, subdir: string): stri
105
121
  * recent startedAt.
106
122
  */
107
123
  export declare function buildClaudeLabelMap(): Map<string, string | null>;
124
+ /**
125
+ * Base64url-decode a JWT and return its `email` claim, if present. Split out so
126
+ * the decode is a single, testable step — and so it only runs when someone
127
+ * actually reads the Codex account (see the lazy resolution below).
128
+ */
129
+ export declare function decodeJwtEmail(idToken: string): string | undefined;
130
+ /** Test seam: how many times getCodexAccount has actually resolved (decoded) since the last reset. */
131
+ export declare function __codexAccountResolveCountForTest(): number;
132
+ /** Test seam: clear the memoized account + resolve counter so laziness can be observed from a clean slate. */
133
+ export declare function __resetCodexAccountCacheForTest(): void;
108
134
  /** Parse the lines of a Codex `session_index.jsonl` into a session id -> title map. */
109
135
  export declare function parseCodexThreadNameIndex(raw: string): Map<string, string>;
136
+ /**
137
+ * Stream-parse a single Codex JSONL file to extract session metadata.
138
+ *
139
+ * `resolveAccount` is a lazy thunk (not a resolved string): the JWT decode it
140
+ * performs is deferred until we know this file is a real session worth building
141
+ * metadata for, and only then — never during the file walk / stat phase.
142
+ */
143
+ export declare function readCodexMeta(filePath: string, resolveAccount?: () => string | undefined, currentVersion?: string): Promise<{
144
+ meta: SessionMeta;
145
+ content: string;
146
+ } | null>;
110
147
  /** Stream a Claude JSONL file and extract scan-level metadata (timestamp, cwd, topic, tokens). */
111
148
  export declare function scanClaudeSession(filePath: string): Promise<ClaudeSessionScan>;
112
149
  /** Read up to maxLines non-empty lines from the beginning of a file. */
@@ -20,8 +20,11 @@ import { walkForFiles } from '../fs-walk.js';
20
20
  import { getConfigSymlinkVersion } from '../shims.js';
21
21
  import { SESSION_AGENTS } from './types.js';
22
22
  import { extractSessionTopic } from './prompt.js';
23
+ import { parseAntigravity } from './parse.js';
23
24
  import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand } from './state.js';
24
25
  import { costOfUsage } from '../pricing/index.js';
26
+ import { machineId } from './sync/config.js';
27
+ import { mapBounded } from '../concurrency.js';
25
28
  import { getDB, getScanStampByPath, getScanStampsForPaths, recordScans, syncLabels, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, } from './db.js';
26
29
  const HOME = os.homedir();
27
30
  // Versions can live under either repo: the user repo (current canonical
@@ -55,27 +58,68 @@ export async function discoverSessions(options) {
55
58
  const onProgress = options?.onProgress;
56
59
  if (tryClaimScan(process.pid)) {
57
60
  try {
58
- await Promise.all(agents.map(agent => {
59
- switch (agent) {
60
- case 'claude': return scanClaudeIncremental(onProgress);
61
- case 'codex': return scanCodexIncremental(onProgress);
62
- case 'gemini': return scanGeminiIncremental(onProgress);
63
- case 'opencode': return scanOpenCodeIncremental();
64
- case 'openclaw': return scanOpenClawIncremental();
65
- case 'rush': return scanRushIncremental(onProgress);
66
- case 'hermes': return scanHermesIncremental(onProgress);
67
- case 'kimi': return scanKimiIncremental(onProgress);
68
- case 'droid': return scanDroidIncremental(onProgress);
69
- }
70
- }));
61
+ // Bounded + staggered instead of a single Promise.all: scanning every
62
+ // agent's dotfile dir (~/.claude, ~/.codex, ~/.gemini, …) simultaneously
63
+ // reads to behavioral EDR (CrowdStrike Falcon) as a ransomware-style bulk
64
+ // file-enumeration sweep. Same dirs, same results — just not all at once.
65
+ await scanAgentsBounded(agents, agent => dispatchAgentScan(agent, onProgress));
71
66
  }
72
67
  finally {
73
68
  releaseScan(process.pid);
74
69
  }
75
70
  }
76
71
  const sessions = querySessions(buildQueryOptions(options, agents, { includeLimit: true }));
72
+ for (const s of sessions)
73
+ s.machine = machineForSessionFile(s.filePath, s.agent);
77
74
  return sessions;
78
75
  }
76
+ /**
77
+ * How many agents' dotfile dirs we scan at once, and the minimum spacing between
78
+ * successive scan starts. A small bound + stagger turns a simultaneous bulk
79
+ * multi-dotfile sweep (a behavioral-EDR file-enumeration trigger) into a trickle.
80
+ */
81
+ export const DOTFILE_SCAN_CONCURRENCY = 2;
82
+ const DOTFILE_SCAN_STAGGER_MS = 15;
83
+ /** Run each agent's incremental scan, bounded + staggered. Order is irrelevant (each scan writes its own rows). */
84
+ export function scanAgentsBounded(items, run) {
85
+ return mapBounded(items, run, {
86
+ concurrency: DOTFILE_SCAN_CONCURRENCY,
87
+ staggerMs: DOTFILE_SCAN_STAGGER_MS,
88
+ });
89
+ }
90
+ /** Dispatch a single agent's incremental dotfile scan. */
91
+ function dispatchAgentScan(agent, onProgress) {
92
+ switch (agent) {
93
+ case 'claude': return scanClaudeIncremental(onProgress);
94
+ case 'codex': return scanCodexIncremental(onProgress);
95
+ case 'gemini': return scanGeminiIncremental(onProgress);
96
+ case 'antigravity': return scanAntigravityIncremental(onProgress);
97
+ case 'opencode': return scanOpenCodeIncremental();
98
+ case 'openclaw': return scanOpenClawIncremental();
99
+ case 'rush': return scanRushIncremental(onProgress);
100
+ case 'hermes': return scanHermesIncremental(onProgress);
101
+ case 'kimi': return scanKimiIncremental(onProgress);
102
+ case 'droid': return scanDroidIncremental(onProgress);
103
+ default: return Promise.resolve();
104
+ }
105
+ }
106
+ let _localMachineId;
107
+ /**
108
+ * The machine a discovered session originated on. Cross-machine sync mirrors a
109
+ * remote transcript to backups/<agent>/<machine>/<subdir>/… (see mirrorPath in
110
+ * sync/agents.ts); every other transcript is a live-home file on this box. So:
111
+ * when the path sits under the agent's backups root, the first segment below it
112
+ * is the origin machine id; otherwise it's the local machine.
113
+ */
114
+ export function machineForSessionFile(filePath, agent) {
115
+ const base = path.join(getHistoryDir(), 'backups', agent) + path.sep;
116
+ if (filePath.startsWith(base)) {
117
+ const seg = filePath.slice(base.length).split(path.sep)[0];
118
+ if (seg)
119
+ return seg;
120
+ }
121
+ return (_localMachineId ??= machineId());
122
+ }
79
123
  /**
80
124
  * Count sessions in scope without running an incremental scan. Assumes the DB
81
125
  * is already fresh (typically true because `discoverSessions` ran first this
@@ -462,10 +506,37 @@ async function readClaudeMeta(filePath, sessionId, account, label) {
462
506
  // Codex account info
463
507
  // ---------------------------------------------------------------------------
464
508
  let cachedCodexAccount;
465
- /** Extract the Codex account email from the JWT id_token in auth.json. */
509
+ /** Number of times the auth.json JWT was actually base64-decoded. Test seam for the lazy-decode contract. */
510
+ let codexAccountResolveCount = 0;
511
+ /**
512
+ * Base64url-decode a JWT and return its `email` claim, if present. Split out so
513
+ * the decode is a single, testable step — and so it only runs when someone
514
+ * actually reads the Codex account (see the lazy resolution below).
515
+ */
516
+ export function decodeJwtEmail(idToken) {
517
+ const parts = idToken.split('.');
518
+ if (parts.length < 2)
519
+ return undefined;
520
+ try {
521
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
522
+ return typeof payload.email === 'string' ? payload.email : undefined;
523
+ }
524
+ catch {
525
+ return undefined; // malformed JWT
526
+ }
527
+ }
528
+ /**
529
+ * Extract the Codex account email from the JWT id_token in auth.json.
530
+ *
531
+ * Memoized and resolved LAZILY: the credential-harvesting-shaped JWT decode
532
+ * (base64-decoding ~/.codex/auth.json) only runs when the account is actually
533
+ * needed to build a session's metadata — never eagerly during the bulk scan.
534
+ * A scan with no changed Codex files never touches the auth file.
535
+ */
466
536
  function getCodexAccount() {
467
537
  if (cachedCodexAccount !== undefined)
468
538
  return cachedCodexAccount || undefined;
539
+ codexAccountResolveCount++;
469
540
  const candidates = [path.join(HOME, '.codex', 'auth.json')];
470
541
  for (const root of VERSIONS_ROOTS) {
471
542
  const versionsBase = path.join(root, 'versions', 'codex');
@@ -485,27 +556,35 @@ function getCodexAccount() {
485
556
  const data = JSON.parse(fs.readFileSync(candidate, 'utf-8'));
486
557
  const idToken = data.tokens?.id_token;
487
558
  if (idToken) {
488
- const parts = idToken.split('.');
489
- if (parts.length >= 2) {
490
- const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
491
- if (payload.email) {
492
- cachedCodexAccount = payload.email;
493
- return payload.email;
494
- }
559
+ const email = decodeJwtEmail(idToken);
560
+ if (email) {
561
+ cachedCodexAccount = email;
562
+ return email;
495
563
  }
496
564
  }
497
565
  }
498
- catch { /* auth file or JWT malformed */ }
566
+ catch { /* auth file malformed */ }
499
567
  }
500
568
  cachedCodexAccount = '';
501
569
  return undefined;
502
570
  }
571
+ /** Test seam: how many times getCodexAccount has actually resolved (decoded) since the last reset. */
572
+ export function __codexAccountResolveCountForTest() {
573
+ return codexAccountResolveCount;
574
+ }
575
+ /** Test seam: clear the memoized account + resolve counter so laziness can be observed from a clean slate. */
576
+ export function __resetCodexAccountCacheForTest() {
577
+ cachedCodexAccount = undefined;
578
+ codexAccountResolveCount = 0;
579
+ }
503
580
  // ---------------------------------------------------------------------------
504
581
  // Codex
505
582
  // ---------------------------------------------------------------------------
506
583
  /** Incrementally re-scan changed Codex session files and upsert into the DB. */
507
584
  async function scanCodexIncremental(onProgress) {
508
- const account = getCodexAccount();
585
+ // Lazy: getCodexAccount (the auth.json JWT decode) is only resolved by
586
+ // readCodexMeta when a changed session actually needs it — never eagerly here,
587
+ // so a no-op scan (changed.length === 0) never touches the credential file.
509
588
  const currentVersion = await getCurrentAgentVersion('codex');
510
589
  const filePaths = [];
511
590
  for (const sessionsDir of getAgentSessionDirs('codex', 'sessions')) {
@@ -530,7 +609,7 @@ async function scanCodexIncremental(onProgress) {
530
609
  let parsed = 0;
531
610
  for (const { filePath, scan } of changed) {
532
611
  try {
533
- const result = await readCodexMeta(filePath, account, currentVersion);
612
+ const result = await readCodexMeta(filePath, getCodexAccount, currentVersion);
534
613
  if (result && !seen.has(result.meta.id)) {
535
614
  seen.add(result.meta.id);
536
615
  // Prefer the Codex-generated title over the first-prompt fallback.
@@ -594,8 +673,14 @@ function readCodexThreadNames() {
594
673
  }
595
674
  return titles;
596
675
  }
597
- /** Stream-parse a single Codex JSONL file to extract session metadata. */
598
- async function readCodexMeta(filePath, account, currentVersion) {
676
+ /**
677
+ * Stream-parse a single Codex JSONL file to extract session metadata.
678
+ *
679
+ * `resolveAccount` is a lazy thunk (not a resolved string): the JWT decode it
680
+ * performs is deferred until we know this file is a real session worth building
681
+ * metadata for, and only then — never during the file walk / stat phase.
682
+ */
683
+ export async function readCodexMeta(filePath, resolveAccount, currentVersion) {
599
684
  const scan = await scanCodexSession(filePath);
600
685
  const sessionId = scan.sessionId || '';
601
686
  if (!sessionId)
@@ -618,7 +703,7 @@ async function readCodexMeta(filePath, account, currentVersion) {
618
703
  tokenCount: scan.tokenCount,
619
704
  costUsd: scan.costUsd,
620
705
  durationMs: scan.durationMs,
621
- account,
706
+ account: resolveAccount?.(),
622
707
  prUrl: scan.prUrl,
623
708
  prNumber: scan.prNumber,
624
709
  worktreeSlug: scan.worktreeSlug,
@@ -864,6 +949,98 @@ function buildGeminiProjectMap() {
864
949
  return map;
865
950
  }
866
951
  // ---------------------------------------------------------------------------
952
+ // Antigravity
953
+ //
954
+ // Antigravity stores one SQLite DB per conversation at
955
+ // ~/.gemini/antigravity-cli/conversations/<trajectory-uuid>.db. The filename
956
+ // (minus .db) is the canonical session id. Each DB is stat'd against the ledger;
957
+ // only changed DBs are re-parsed (via parseAntigravity, which shells out to
958
+ // sqlite3). Tool count doubles as the message count; the toolSummary of the
959
+ // first tool call becomes the topic, and any run_command's Cwd fills in cwd.
960
+ // ---------------------------------------------------------------------------
961
+ /** Incrementally re-scan changed Antigravity conversation DBs and upsert into the DB. */
962
+ async function scanAntigravityIncremental(onProgress) {
963
+ const currentVersion = await getCurrentAgentVersion('antigravity');
964
+ const filePaths = [];
965
+ const seenPaths = new Set();
966
+ for (const conversationsDir of getAgentSessionDirs('antigravity', 'conversations')) {
967
+ let files;
968
+ try {
969
+ files = fs.readdirSync(conversationsDir).filter(f => f.endsWith('.db'));
970
+ }
971
+ catch {
972
+ continue;
973
+ }
974
+ for (const file of files) {
975
+ const fp = path.join(conversationsDir, file);
976
+ if (seenPaths.has(fp))
977
+ continue;
978
+ seenPaths.add(fp);
979
+ filePaths.push(fp);
980
+ }
981
+ }
982
+ const changed = filterChangedFiles(filePaths);
983
+ if (changed.length === 0)
984
+ return;
985
+ onProgress?.({ agent: 'antigravity', parsed: 0, total: changed.length });
986
+ const entries = [];
987
+ const touched = [];
988
+ const seen = new Set();
989
+ let parsed = 0;
990
+ for (const { filePath, scan } of changed) {
991
+ try {
992
+ const result = readAntigravityMeta(filePath, currentVersion);
993
+ if (result && !seen.has(result.meta.id)) {
994
+ seen.add(result.meta.id);
995
+ entries.push({ meta: result.meta, content: result.content, scan });
996
+ }
997
+ else {
998
+ touched.push({ filePath, scan });
999
+ }
1000
+ }
1001
+ catch {
1002
+ touched.push({ filePath, scan });
1003
+ }
1004
+ parsed++;
1005
+ onProgress?.({ agent: 'antigravity', parsed, total: changed.length });
1006
+ }
1007
+ upsertSessionsBatch(entries);
1008
+ recordScans(touched);
1009
+ }
1010
+ /** Parse a single Antigravity conversation DB to extract session metadata. */
1011
+ function readAntigravityMeta(filePath, currentVersion) {
1012
+ const sessionId = path.basename(filePath).replace(/\.db$/, '');
1013
+ if (!sessionId)
1014
+ return null;
1015
+ const events = parseAntigravity(filePath);
1016
+ // cwd: first run_command carries the working directory in its Cwd arg.
1017
+ let cwd;
1018
+ const contentParts = [];
1019
+ for (const e of events) {
1020
+ if (!cwd && typeof e.args?.Cwd === 'string' && e.args.Cwd)
1021
+ cwd = e.args.Cwd;
1022
+ if (e.content)
1023
+ contentParts.push(e.content);
1024
+ }
1025
+ const normalizedCwd = cwd ? normalizeCwd(cwd) : undefined;
1026
+ // Topic: the first tool's human summary is a decent one-line label.
1027
+ const topic = events.find(e => e.content)?.content;
1028
+ const stat = safeStatSync(filePath);
1029
+ const meta = {
1030
+ id: sessionId,
1031
+ shortId: sessionId.slice(0, 8),
1032
+ agent: 'antigravity',
1033
+ timestamp: stat ? stat.mtime.toISOString() : new Date().toISOString(),
1034
+ project: normalizedCwd ? path.basename(normalizedCwd) : undefined,
1035
+ cwd: normalizedCwd,
1036
+ filePath,
1037
+ version: resolveSessionVersion('antigravity', filePath, undefined, currentVersion),
1038
+ topic: topic ? topic.slice(0, 120) : undefined,
1039
+ messageCount: events.length,
1040
+ };
1041
+ return { meta, content: contentParts.join('\n') };
1042
+ }
1043
+ // ---------------------------------------------------------------------------
867
1044
  // OpenCode
868
1045
  // ---------------------------------------------------------------------------
869
1046
  const OPENCODE_DB = path.join(HOME, '.local', 'share', 'opencode', 'opencode.db');