@phnx-labs/agents-cli 1.20.35 → 1.20.37

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 (236) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/LICENSE +185 -21
  3. package/README.md +8 -4
  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.js +81 -0
  11. package/dist/commands/daemon.js +4 -1
  12. package/dist/commands/doctor.js +1 -89
  13. package/dist/commands/events.js +3 -3
  14. package/dist/commands/exec.d.ts +19 -0
  15. package/dist/commands/exec.js +277 -11
  16. package/dist/commands/hosts.js +10 -6
  17. package/dist/commands/inspect.js +8 -9
  18. package/dist/commands/lock.d.ts +12 -0
  19. package/dist/commands/lock.js +70 -0
  20. package/dist/commands/message.d.ts +15 -0
  21. package/dist/commands/message.js +56 -0
  22. package/dist/commands/routines.js +101 -5
  23. package/dist/commands/secrets-migrate.js +106 -57
  24. package/dist/commands/secrets.d.ts +31 -18
  25. package/dist/commands/secrets.js +156 -75
  26. package/dist/commands/serve.d.ts +10 -0
  27. package/dist/commands/serve.js +37 -0
  28. package/dist/commands/sessions-inject.d.ts +14 -0
  29. package/dist/commands/sessions-inject.js +111 -0
  30. package/dist/commands/sessions-picker.d.ts +2 -0
  31. package/dist/commands/sessions-picker.js +24 -3
  32. package/dist/commands/sessions-resume.js +20 -8
  33. package/dist/commands/sessions-sync.d.ts +3 -0
  34. package/dist/commands/sessions-sync.js +44 -4
  35. package/dist/commands/sessions.d.ts +78 -1
  36. package/dist/commands/sessions.js +506 -66
  37. package/dist/commands/setup.js +4 -2
  38. package/dist/commands/sync.d.ts +3 -1
  39. package/dist/commands/sync.js +156 -4
  40. package/dist/commands/teams.js +217 -0
  41. package/dist/commands/versions.js +2 -4
  42. package/dist/commands/watchdog.d.ts +18 -0
  43. package/dist/commands/watchdog.js +238 -0
  44. package/dist/index.js +25 -2
  45. package/dist/lib/audit/log.d.ts +92 -0
  46. package/dist/lib/audit/log.js +177 -0
  47. package/dist/lib/auto-pull.js +2 -1
  48. package/dist/lib/browser/chrome.d.ts +10 -0
  49. package/dist/lib/browser/chrome.js +18 -7
  50. package/dist/lib/browser/drivers/ssh.js +2 -1
  51. package/dist/lib/browser/har.d.ts +84 -0
  52. package/dist/lib/browser/har.js +77 -0
  53. package/dist/lib/browser/ipc.js +24 -3
  54. package/dist/lib/browser/profiles.d.ts +1 -1
  55. package/dist/lib/browser/profiles.js +8 -10
  56. package/dist/lib/browser/refs.d.ts +65 -0
  57. package/dist/lib/browser/refs.js +73 -1
  58. package/dist/lib/browser/runtime-state.js +1 -0
  59. package/dist/lib/browser/service.d.ts +38 -2
  60. package/dist/lib/browser/service.js +112 -8
  61. package/dist/lib/browser/types.d.ts +14 -1
  62. package/dist/lib/budget/live-cloud.d.ts +42 -0
  63. package/dist/lib/budget/live-cloud.js +79 -0
  64. package/dist/lib/budget/live-team.d.ts +31 -0
  65. package/dist/lib/budget/live-team.js +115 -0
  66. package/dist/lib/cloud/codex.js +4 -0
  67. package/dist/lib/cloud/rush.d.ts +12 -1
  68. package/dist/lib/cloud/rush.js +13 -3
  69. package/dist/lib/cloud/types.d.ts +9 -0
  70. package/dist/lib/computer/dispatch.d.ts +8 -0
  71. package/dist/lib/computer/dispatch.js +125 -0
  72. package/dist/lib/computer/loop.d.ts +62 -0
  73. package/dist/lib/computer/loop.js +98 -0
  74. package/dist/lib/computer/model.d.ts +44 -0
  75. package/dist/lib/computer/model.js +157 -0
  76. package/dist/lib/concurrency.d.ts +19 -0
  77. package/dist/lib/concurrency.js +33 -0
  78. package/dist/lib/daemon.d.ts +57 -0
  79. package/dist/lib/daemon.js +196 -18
  80. package/dist/lib/devices/registry.d.ts +7 -0
  81. package/dist/lib/devices/registry.js +24 -0
  82. package/dist/lib/devices/resolve-target.d.ts +24 -0
  83. package/dist/lib/devices/resolve-target.js +80 -0
  84. package/dist/lib/devices/tailscale.js +1 -1
  85. package/dist/lib/drift.d.ts +52 -0
  86. package/dist/lib/drift.js +112 -0
  87. package/dist/lib/events.d.ts +1 -1
  88. package/dist/lib/events.js +31 -13
  89. package/dist/lib/exec.d.ts +17 -0
  90. package/dist/lib/exec.js +79 -13
  91. package/dist/lib/git.d.ts +27 -0
  92. package/dist/lib/git.js +56 -1
  93. package/dist/lib/hooks/cache.d.ts +6 -0
  94. package/dist/lib/hooks/cache.js +54 -12
  95. package/dist/lib/hooks.d.ts +27 -0
  96. package/dist/lib/hooks.js +127 -8
  97. package/dist/lib/hosts/dispatch.d.ts +15 -0
  98. package/dist/lib/hosts/dispatch.js +39 -6
  99. package/dist/lib/hosts/logs.js +30 -1
  100. package/dist/lib/hosts/option.js +1 -1
  101. package/dist/lib/hosts/passthrough.js +3 -1
  102. package/dist/lib/hosts/ready.d.ts +29 -6
  103. package/dist/lib/hosts/ready.js +66 -15
  104. package/dist/lib/hosts/registry.d.ts +19 -2
  105. package/dist/lib/hosts/registry.js +58 -2
  106. package/dist/lib/hosts/remote-cmd.d.ts +62 -1
  107. package/dist/lib/hosts/remote-cmd.js +70 -1
  108. package/dist/lib/hosts/remote-os.d.ts +17 -0
  109. package/dist/lib/hosts/remote-os.js +30 -0
  110. package/dist/lib/hosts/session-index.d.ts +34 -0
  111. package/dist/lib/hosts/session-index.js +56 -0
  112. package/dist/lib/hosts/tasks.d.ts +14 -0
  113. package/dist/lib/hosts/tasks.js +15 -0
  114. package/dist/lib/lock.d.ts +93 -0
  115. package/dist/lib/lock.js +207 -0
  116. package/dist/lib/loop.js +16 -1
  117. package/dist/lib/machine-id.d.ts +21 -0
  118. package/dist/lib/machine-id.js +26 -0
  119. package/dist/lib/mailbox-target.d.ts +36 -0
  120. package/dist/lib/mailbox-target.js +45 -0
  121. package/dist/lib/mailbox.d.ts +47 -0
  122. package/dist/lib/mailbox.js +194 -0
  123. package/dist/lib/mcp.d.ts +5 -0
  124. package/dist/lib/mcp.js +24 -8
  125. package/dist/lib/migrate.d.ts +19 -0
  126. package/dist/lib/migrate.js +134 -26
  127. package/dist/lib/overdue.js +3 -0
  128. package/dist/lib/picker.d.ts +2 -0
  129. package/dist/lib/picker.js +4 -1
  130. package/dist/lib/platform/exec.d.ts +46 -0
  131. package/dist/lib/platform/exec.js +74 -0
  132. package/dist/lib/platform/process.d.ts +31 -0
  133. package/dist/lib/platform/process.js +34 -1
  134. package/dist/lib/platform/winpath.js +2 -0
  135. package/dist/lib/plugins.js +16 -6
  136. package/dist/lib/profiles.d.ts +25 -0
  137. package/dist/lib/profiles.js +22 -6
  138. package/dist/lib/pty-client.js +2 -1
  139. package/dist/lib/rotate.d.ts +61 -0
  140. package/dist/lib/rotate.js +52 -0
  141. package/dist/lib/routines.d.ts +40 -2
  142. package/dist/lib/routines.js +66 -8
  143. package/dist/lib/runner.d.ts +11 -2
  144. package/dist/lib/runner.js +49 -7
  145. package/dist/lib/scheduler.js +6 -1
  146. package/dist/lib/secrets/bundles.d.ts +60 -4
  147. package/dist/lib/secrets/bundles.js +131 -12
  148. package/dist/lib/secrets/filestore.d.ts +3 -0
  149. package/dist/lib/secrets/filestore.js +42 -16
  150. package/dist/lib/secrets/index.d.ts +43 -2
  151. package/dist/lib/secrets/index.js +102 -3
  152. package/dist/lib/secrets/mcp.d.ts +93 -0
  153. package/dist/lib/secrets/mcp.js +205 -0
  154. package/dist/lib/secrets/remote.js +12 -5
  155. package/dist/lib/secrets/sync.js +83 -4
  156. package/dist/lib/secrets/windows.js +14 -3
  157. package/dist/lib/serve/data.d.ts +81 -0
  158. package/dist/lib/serve/data.js +91 -0
  159. package/dist/lib/serve/page.d.ts +7 -0
  160. package/dist/lib/serve/page.js +140 -0
  161. package/dist/lib/serve/server.d.ts +46 -0
  162. package/dist/lib/serve/server.js +115 -0
  163. package/dist/lib/session/active.d.ts +73 -0
  164. package/dist/lib/session/active.js +199 -22
  165. package/dist/lib/session/db.d.ts +2 -1
  166. package/dist/lib/session/db.js +41 -5
  167. package/dist/lib/session/discover.d.ts +39 -0
  168. package/dist/lib/session/discover.js +127 -29
  169. package/dist/lib/session/ghostty-tabs.d.ts +33 -0
  170. package/dist/lib/session/ghostty-tabs.js +126 -0
  171. package/dist/lib/session/inject.d.ts +18 -0
  172. package/dist/lib/session/inject.js +21 -0
  173. package/dist/lib/session/parse.js +23 -20
  174. package/dist/lib/session/pid-registry.d.ts +1 -0
  175. package/dist/lib/session/pid-registry.js +24 -0
  176. package/dist/lib/session/provenance.d.ts +14 -2
  177. package/dist/lib/session/provenance.js +39 -8
  178. package/dist/lib/session/relative-time.js +6 -2
  179. package/dist/lib/session/remote-active.js +19 -17
  180. package/dist/lib/session/remote-list.d.ts +51 -0
  181. package/dist/lib/session/remote-list.js +205 -0
  182. package/dist/lib/session/remote.d.ts +7 -1
  183. package/dist/lib/session/remote.js +20 -4
  184. package/dist/lib/session/sync/config.d.ts +14 -15
  185. package/dist/lib/session/sync/config.js +60 -20
  186. package/dist/lib/session/types.d.ts +23 -0
  187. package/dist/lib/shims.d.ts +36 -6
  188. package/dist/lib/shims.js +91 -29
  189. package/dist/lib/ssh-exec.js +2 -0
  190. package/dist/lib/ssh-tunnel.js +2 -1
  191. package/dist/lib/startup/command-registry.d.ts +6 -0
  192. package/dist/lib/startup/command-registry.js +13 -1
  193. package/dist/lib/state.d.ts +13 -0
  194. package/dist/lib/state.js +103 -9
  195. package/dist/lib/sync-umbrella.d.ts +14 -7
  196. package/dist/lib/sync-umbrella.js +21 -13
  197. package/dist/lib/teams/forEach.d.ts +110 -0
  198. package/dist/lib/teams/forEach.js +186 -0
  199. package/dist/lib/teams/index.d.ts +1 -0
  200. package/dist/lib/teams/index.js +1 -0
  201. package/dist/lib/teams/pr-watch.d.ts +226 -0
  202. package/dist/lib/teams/pr-watch.js +371 -0
  203. package/dist/lib/teams/supervisor.d.ts +14 -1
  204. package/dist/lib/teams/supervisor.js +19 -0
  205. package/dist/lib/teams/worktree.d.ts +9 -0
  206. package/dist/lib/teams/worktree.js +32 -0
  207. package/dist/lib/terminal/backends/index.d.ts +2 -1
  208. package/dist/lib/terminal/backends/index.js +3 -1
  209. package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
  210. package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
  211. package/dist/lib/terminal/index.d.ts +4 -1
  212. package/dist/lib/terminal/index.js +4 -1
  213. package/dist/lib/terminal/inject.d.ts +204 -0
  214. package/dist/lib/terminal/inject.js +247 -0
  215. package/dist/lib/terminal/resolve.d.ts +64 -0
  216. package/dist/lib/terminal/resolve.js +90 -0
  217. package/dist/lib/terminal/types.d.ts +1 -1
  218. package/dist/lib/tmux/session.d.ts +10 -0
  219. package/dist/lib/tmux/session.js +31 -0
  220. package/dist/lib/triggers/webhook.d.ts +85 -0
  221. package/dist/lib/triggers/webhook.js +141 -0
  222. package/dist/lib/versions.d.ts +23 -0
  223. package/dist/lib/versions.js +119 -13
  224. package/dist/lib/watchdog/index.d.ts +3 -0
  225. package/dist/lib/watchdog/index.js +5 -0
  226. package/dist/lib/watchdog/read.d.ts +35 -0
  227. package/dist/lib/watchdog/read.js +149 -0
  228. package/dist/lib/watchdog/runner.d.ts +127 -0
  229. package/dist/lib/watchdog/runner.js +322 -0
  230. package/dist/lib/watchdog/watchdog.d.ts +40 -0
  231. package/dist/lib/watchdog/watchdog.js +166 -0
  232. package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
  233. package/dist/lib/watchdog/watchdogTail.js +154 -0
  234. package/dist/lib/workflows.d.ts +166 -0
  235. package/dist/lib/workflows.js +193 -0
  236. package/package.json +5 -4
@@ -31,7 +31,16 @@ import { extractSessionTopic } from './prompt.js';
31
31
  import { readSessionTail } from './tail.js';
32
32
  import { inferSessionState } from './state.js';
33
33
  import { detectProvenance } from './provenance.js';
34
+ import { mapBounded } from '../concurrency.js';
34
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;
35
44
  const HOME = os.homedir();
36
45
  const LIVE_TERMINALS_FILE = path.join(getTerminalsDir(), 'live-terminals.json');
37
46
  /**
@@ -49,6 +58,19 @@ const AGENT_CLI_NAMES = {
49
58
  opencode: 'opencode',
50
59
  droid: 'droid',
51
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
+ }
52
74
  function isPidAlive(pid) {
53
75
  if (!pid || pid < 1)
54
76
  return false;
@@ -314,6 +336,7 @@ export async function listTerminalsActive() {
314
336
  context: 'terminal',
315
337
  kind: t.kind,
316
338
  host: detectHost(t.pid, procByPid),
339
+ tty: procByPid.get(t.pid)?.tty,
317
340
  pid: t.pid,
318
341
  sessionId: t.sessionId ?? sessionIdFromFile(sessionFile),
319
342
  cwd: t.cwd ?? undefined,
@@ -355,14 +378,14 @@ export function listCloudActive() {
355
378
  * and a terminal-app is preferred over the multiplexer inside it.
356
379
  */
357
380
  const HOST_MATCHERS = [
358
- // IDE renderers (Electron helper processes)
359
- { host: 'code', tokens: ['Code Helper', 'Code - Insiders Helper'] },
360
- { host: 'cursor', tokens: ['Cursor Helper'] },
361
- { host: 'codium', tokens: ['VSCodium Helper'] },
362
- { host: 'windsurf', tokens: ['Windsurf Helper'] },
381
+ // IDE renderers (Electron helper processes on macOS, image names on Windows)
382
+ { host: 'code', tokens: ['Code Helper', 'Code - Insiders Helper', 'Code.exe'] },
383
+ { host: 'cursor', tokens: ['Cursor Helper', 'Cursor.exe'] },
384
+ { host: 'codium', tokens: ['VSCodium Helper', 'VSCodium.exe'] },
385
+ { host: 'windsurf', tokens: ['Windsurf Helper', 'Windsurf.exe'] },
363
386
  // Native terminal apps
364
387
  { host: 'iterm', tokens: ['iTerm2', 'iTermServer', 'iTerm'] },
365
- { host: 'terminal', tokens: ['Terminal.app', '/Applications/Utilities/Terminal.app'] },
388
+ { host: 'terminal', tokens: ['Terminal.app', '/Applications/Utilities/Terminal.app', 'WindowsTerminal.exe'] },
366
389
  { host: 'warp', tokens: ['Warp.app', 'stable_'] },
367
390
  { host: 'alacritty', tokens: ['alacritty', 'Alacritty'] },
368
391
  { host: 'kitty', tokens: ['kitty'] },
@@ -380,25 +403,64 @@ const HOST_MATCHERS = [
380
403
  * matching against AGENT_CLI_NAMES.
381
404
  */
382
405
  async function readProcessTable() {
406
+ if (process.platform === 'win32')
407
+ return readProcessTableWin32();
383
408
  let out;
384
409
  try {
385
- ({ stdout: out } = await execFileAsync('ps', ['-A', '-o', 'pid=,ppid=,comm='], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }));
410
+ ({ stdout: out } = await execFileAsync('ps', ['-A', '-o', 'pid=,ppid=,tty=,comm='], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }));
386
411
  }
387
412
  catch {
388
413
  return [];
389
414
  }
390
415
  const rows = [];
391
416
  for (const line of out.split('\n')) {
392
- const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
417
+ // pid ppid tty comm — tty is a single token ('ttys003', 's003', or '??'/'?'
418
+ // for none); comm stays last so it may contain spaces.
419
+ const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/);
420
+ if (!m)
421
+ continue;
422
+ const pid = parseInt(m[1], 10);
423
+ const ppid = parseInt(m[2], 10);
424
+ if (!Number.isFinite(pid) || !Number.isFinite(ppid))
425
+ continue;
426
+ const ttyRaw = m[3];
427
+ const tty = ttyRaw === '??' || ttyRaw === '?' || ttyRaw === '-' ? undefined : ttyRaw;
428
+ const commRaw = m[4].trim();
429
+ rows.push({ pid, ppid, tty, comm: commRaw, kind: agentKindFromComm(commRaw) });
430
+ }
431
+ return rows;
432
+ }
433
+ /**
434
+ * Windows process table in one CIM query (`wmic` is removed on current
435
+ * Windows 11, so PowerShell is the stable interface). Same pid/ppid/comm
436
+ * shape as the POSIX `ps` snapshot; `Name` is the image name (`claude.exe`).
437
+ */
438
+ async function readProcessTableWin32() {
439
+ let out;
440
+ try {
441
+ ({ stdout: out } = await execFileAsync('powershell.exe', [
442
+ '-NoProfile', '-NonInteractive', '-Command',
443
+ 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name | ConvertTo-Csv -NoTypeInformation',
444
+ ], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, windowsHide: true }));
445
+ }
446
+ catch {
447
+ return [];
448
+ }
449
+ return parseWin32ProcessCsv(out);
450
+ }
451
+ /** Parse `ConvertTo-Csv` output of Win32_Process rows. Exported for tests. */
452
+ export function parseWin32ProcessCsv(out) {
453
+ const rows = [];
454
+ for (const line of out.split(/\r?\n/)) {
455
+ const m = line.trim().match(/^"(\d+)","(\d+)","(.*)"$/);
393
456
  if (!m)
394
457
  continue;
395
458
  const pid = parseInt(m[1], 10);
396
459
  const ppid = parseInt(m[2], 10);
397
460
  if (!Number.isFinite(pid) || !Number.isFinite(ppid))
398
461
  continue;
399
- const commRaw = m[3].trim();
400
- const kind = AGENT_CLI_NAMES[path.basename(commRaw)];
401
- rows.push({ pid, ppid, comm: commRaw, kind });
462
+ const comm = m[3].replace(/""/g, '"');
463
+ rows.push({ pid, ppid, comm, kind: agentKindFromComm(comm) });
402
464
  }
403
465
  return rows;
404
466
  }
@@ -418,12 +480,25 @@ function hasAttributedAncestor(pid, ppidMap, attributed) {
418
480
  }
419
481
  return false;
420
482
  }
483
+ /**
484
+ * Resolve every candidate PID's cwd, bounded and staggered so the probes no
485
+ * longer fan out as one simultaneous system-wide `lsof` burst (a behavioral-EDR
486
+ * recon trigger). Order matches the input `pids`. The `probe` seam is injectable
487
+ * for testing the bound; production always uses the real `lsof`-backed probe.
488
+ */
489
+ export function resolveCwds(pids, probe = getCwdForPid) {
490
+ return mapBounded(pids, probe, { concurrency: LSOF_CONCURRENCY, staggerMs: LSOF_STAGGER_MS });
491
+ }
421
492
  /**
422
493
  * Resolve a process's current working directory via `lsof`. The `-a` flag
423
494
  * ANDs the filters; without it macOS treats `-p` and `-d` as a union and
424
495
  * returns the cwd of every process on the system.
425
496
  */
426
497
  async function getCwdForPid(pid) {
498
+ // No lsof on Windows and no cheap foreign-process cwd API; the pid registry
499
+ // written by `ag run` supplies the cwd for registry-launched agents instead.
500
+ if (process.platform === 'win32')
501
+ return undefined;
427
502
  let out;
428
503
  try {
429
504
  const res = await execFileAsync('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], {
@@ -470,6 +545,99 @@ const UI_HOSTS = new Set([
470
545
  'iterm', 'terminal', 'warp', 'alacritty', 'kitty', 'hyper', 'wezterm', 'ghostty',
471
546
  'tmux', 'screen',
472
547
  ]);
548
+ /**
549
+ * Find the launch registry entry recorded by a WRAPPER of this process. The
550
+ * shim delegate records the pid it spawned, but on Windows the `.cmd` shell
551
+ * path makes that a cmd.exe intermediary whose child is the real agent binary
552
+ * — so the agent pid itself has no entry and the wrapper one ancestor up does.
553
+ * The nearest entry wins, and only if its agent matches the candidate's kind:
554
+ * a claude session shelling out to codex must not hand codex its identity.
555
+ */
556
+ export function readAncestorSessionEntry(pid, ppidMap, kind, readEntry = readPidSessionEntry) {
557
+ let cur = ppidMap.get(pid);
558
+ const seen = new Set();
559
+ while (cur && cur > 1 && !seen.has(cur)) {
560
+ const entry = readEntry(cur);
561
+ if (entry)
562
+ return entry.agent === kind ? entry : undefined;
563
+ seen.add(cur);
564
+ cur = ppidMap.get(cur);
565
+ }
566
+ return undefined;
567
+ }
568
+ /**
569
+ * Collapse agent processes spawned by another live agent process of the same
570
+ * kind onto their nearest kept ancestor. Claude runs subagents, forks, and
571
+ * even its bundled ripgrep as child `claude` processes — on POSIX those
572
+ * children resolve to the parent's cwd and collapse in dedupeBySession, but
573
+ * where no cwd can be recovered (Windows has no lsof) every fork would print
574
+ * as its own headless row. Two exceptions keep their own row: a candidate with
575
+ * its own registry entry — on its pid OR on a wrapper ancestor strictly below
576
+ * the pid it would fold into (the shim's entry lands on the cmd.exe
577
+ * intermediary on Windows) — and a child of a *different* agent kind (claude
578
+ * shelling out to codex is a real second session, not a fork).
579
+ * Returns the kept roots plus, per root pid, how many descendants folded in.
580
+ */
581
+ export function foldSubordinateAgents(candidates, ppidMap, readEntry) {
582
+ const kindByPid = new Map(candidates.map(c => [c.pid, c.kind]));
583
+ const nearestSameKindAncestor = (pid, kind) => {
584
+ let cur = ppidMap.get(pid);
585
+ const seen = new Set();
586
+ while (cur && cur > 1 && !seen.has(cur)) {
587
+ if (kindByPid.get(cur) === kind)
588
+ return cur;
589
+ seen.add(cur);
590
+ cur = ppidMap.get(cur);
591
+ }
592
+ return undefined;
593
+ };
594
+ // Own launch identity: a matching-kind registry entry on the candidate or on
595
+ // any wrapper between it and the pid it would fold into (exclusive). Entries
596
+ // above the fold target belong to that ancestor's session, not this one.
597
+ const hasOwnSession = (c, stopPid) => {
598
+ if (readEntry(c.pid)?.agent === c.kind)
599
+ return true;
600
+ let cur = ppidMap.get(c.pid);
601
+ const seen = new Set();
602
+ while (cur && cur > 1 && cur !== stopPid && !seen.has(cur)) {
603
+ if (readEntry(cur)?.agent === c.kind)
604
+ return true;
605
+ seen.add(cur);
606
+ cur = ppidMap.get(cur);
607
+ }
608
+ return false;
609
+ };
610
+ const keptPids = new Set();
611
+ for (const c of candidates) {
612
+ const foldTarget = nearestSameKindAncestor(c.pid, c.kind);
613
+ if (foldTarget === undefined || hasOwnSession(c, foldTarget)) {
614
+ keptPids.add(c.pid);
615
+ }
616
+ }
617
+ const kept = [];
618
+ const foldedByRoot = new Map();
619
+ for (const c of candidates) {
620
+ if (keptPids.has(c.pid)) {
621
+ kept.push(c);
622
+ continue;
623
+ }
624
+ // Walk up through folded intermediates to the nearest kept same-kind pid.
625
+ let cur = nearestSameKindAncestor(c.pid, c.kind);
626
+ const seen = new Set();
627
+ while (cur !== undefined && !keptPids.has(cur) && !seen.has(cur)) {
628
+ seen.add(cur);
629
+ cur = nearestSameKindAncestor(cur, c.kind);
630
+ }
631
+ // A fold target must itself be a kept row (a ppid cycle from pid reuse
632
+ // can orphan the whole chain) — otherwise keep the row rather than drop it.
633
+ if (cur === undefined || !keptPids.has(cur)) {
634
+ kept.push(c);
635
+ continue;
636
+ }
637
+ foldedByRoot.set(cur, (foldedByRoot.get(cur) ?? 0) + 1);
638
+ }
639
+ return { kept, foldedByRoot };
640
+ }
473
641
  /**
474
642
  * Agent processes not attributed to a team or the runtime registry.
475
643
  * Classified by walking the ppid chain: any recognised UI ancestor (IDE
@@ -495,16 +663,22 @@ export async function listUnattributedActive(attributed) {
495
663
  continue;
496
664
  candidates.push({ pid, kind });
497
665
  }
498
- // Run lsof probes in parallel so N spawns take one round-trip, not N.
499
- const cwds = await Promise.all(candidates.map(c => getCwdForPid(c.pid)));
666
+ // Forks/subagents of a live agent process collapse onto their root before
667
+ // the cwd probes fewer spawns, and one session stays one row even when
668
+ // cwd-based dedupe is unavailable (Windows).
669
+ const { kept, foldedByRoot } = foldSubordinateAgents(candidates, ppidMap, readPidSessionEntry);
670
+ // Bounded + staggered lsof probes: same cwds, but a trickle of spawns instead
671
+ // of one simultaneous system-wide burst that behavioral EDR flags as recon.
672
+ const cwds = await resolveCwds(kept.map(c => c.pid));
500
673
  const out = [];
501
- for (let i = 0; i < candidates.length; i++) {
502
- const { pid, kind } = candidates[i];
503
- // The per-pid registry (written by `ag run`) gives the EXACT session id this
504
- // pid was launched with — so N agents in one cwd resolve to N distinct
505
- // sessions instead of all collapsing onto the newest .jsonl. Absent (agent
506
- // not launched via `ag run`, or one that takes no session id) → heuristic.
507
- const entry = readPidSessionEntry(pid);
674
+ for (let i = 0; i < kept.length; i++) {
675
+ const { pid, kind } = kept[i];
676
+ // The per-pid registry (written by `ag run` and the shim delegate) gives
677
+ // the EXACT session id this pid was launched with — so N agents in one cwd
678
+ // resolve to N distinct sessions instead of all collapsing onto the newest
679
+ // .jsonl. The shim's entry may sit on a wrapper ancestor (Windows .cmd
680
+ // path). Absent entirely (direct launch outside agents-cli) → heuristic.
681
+ const entry = readPidSessionEntry(pid) ?? readAncestorSessionEntry(pid, ppidMap, kind);
508
682
  const cwd = cwds[i] ?? entry?.cwd ?? undefined;
509
683
  const sessionFile = findSessionFileForKind(kind, cwd, entry?.sessionId);
510
684
  const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
@@ -515,11 +689,13 @@ export async function listUnattributedActive(attributed) {
515
689
  context,
516
690
  kind,
517
691
  host,
692
+ tty: procByPid.get(pid)?.tty,
518
693
  pid,
519
694
  cwd,
520
695
  sessionId: entry?.sessionId ?? sessionIdFromFile(sessionFile),
521
696
  topic,
522
697
  sessionFile,
698
+ pidCount: 1 + (foldedByRoot.get(pid) ?? 0),
523
699
  }, state, sessionFile));
524
700
  }
525
701
  // Housekeeping: drop registry files for pids that have since died.
@@ -581,10 +757,11 @@ function dedupeBySession(sessions) {
581
757
  }
582
758
  const existing = byKey.get(key);
583
759
  if (existing) {
584
- existing.pidCount = (existing.pidCount ?? 1) + 1;
760
+ // Carry pre-folded fork counts (headless rows arrive with pidCount set).
761
+ existing.pidCount = (existing.pidCount ?? 1) + (s.pidCount ?? 1);
585
762
  }
586
763
  else {
587
- s.pidCount = 1;
764
+ s.pidCount = s.pidCount ?? 1;
588
765
  byKey.set(key, s);
589
766
  out.push(s);
590
767
  }
@@ -16,6 +16,7 @@ export interface SessionRow {
16
16
  version: string | null;
17
17
  account: string | null;
18
18
  timestamp: string;
19
+ last_activity: string | null;
19
20
  project: string | null;
20
21
  cwd: string | null;
21
22
  git_branch: string | null;
@@ -144,7 +145,7 @@ export declare function syncTopics(topicMap: Map<string, string>): number;
144
145
  * session hasn't been scanned yet — the caller degrades to no live state.
145
146
  */
146
147
  export declare function latestSessionFileForCwd(agent: SessionAgentId, cwd: string): string | undefined;
147
- /** Query sessions from the database, applying filters and ordering by timestamp descending. */
148
+ /** Query sessions from the database, applying filters and ordering by last-activity descending (default). */
148
149
  export declare function querySessions(options?: QueryOptions): SessionMeta[];
149
150
  /** Count sessions matching the given filter options. */
150
151
  export declare function countSessions(options?: QueryOptions): number;
@@ -13,7 +13,7 @@ import { getSessionsDir, getSessionsDbPath } from '../state.js';
13
13
  const SESSIONS_DIR = getSessionsDir();
14
14
  const DB_PATH = getSessionsDbPath();
15
15
  /** Current schema version; bumped when migrations are added. */
16
- const SCHEMA_VERSION = 7;
16
+ const SCHEMA_VERSION = 8;
17
17
  /**
18
18
  * Canonicalize a file path for use as a scan_ledger key. The same physical
19
19
  * session file is reachable via multiple aliases — `~/.claude/projects/x.jsonl`
@@ -46,6 +46,7 @@ CREATE TABLE IF NOT EXISTS sessions (
46
46
  version TEXT,
47
47
  account TEXT,
48
48
  timestamp TEXT NOT NULL,
49
+ last_activity TEXT,
49
50
  project TEXT,
50
51
  cwd TEXT,
51
52
  git_branch TEXT,
@@ -176,6 +177,17 @@ function migrateSchema(db, fromVersion) {
176
177
  db.exec(`ALTER TABLE sessions ADD COLUMN ticket_id TEXT`);
177
178
  db.exec(`DELETE FROM scan_ledger;`);
178
179
  }
180
+ if (fromVersion < 8) {
181
+ // v7 → v8: the listing now sorts and labels by last-activity (last message
182
+ // time) instead of creation time. Add the column, seed it to `timestamp` so
183
+ // no row sorts as NULL before the rescan lands, then force a full rescan so
184
+ // every session gets its true last_activity (from lastTsMs) repopulated.
185
+ const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
186
+ if (!cols.some(c => c.name === 'last_activity'))
187
+ db.exec(`ALTER TABLE sessions ADD COLUMN last_activity TEXT`);
188
+ db.exec(`UPDATE sessions SET last_activity = timestamp WHERE last_activity IS NULL`);
189
+ db.exec(`DELETE FROM scan_ledger;`);
190
+ }
179
191
  }
180
192
  /** Open (or return the cached) sessions database, applying migrations as needed. */
181
193
  export function getDB() {
@@ -202,6 +214,11 @@ export function getDB() {
202
214
  migrateSchema(db, currentVersion);
203
215
  db.prepare(`INSERT OR REPLACE INTO meta(key, value) VALUES ('schema_version', ?)`).run(String(SCHEMA_VERSION));
204
216
  }
217
+ // Index last_activity only after the column is guaranteed to exist — fresh DBs
218
+ // get it from CREATE TABLE above, existing pre-v8 DBs from the migration just
219
+ // run. It must NOT live in SCHEMA (executed before migration) or an existing
220
+ // DB would fail the index build on a column it doesn't have yet.
221
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_last_activity ON sessions(last_activity DESC)`);
205
222
  // One-shot cleanup of the pre-SQLite JSONL indexes. Safe — nothing reads
206
223
  // them anymore. Guarded by a meta flag so we only try once.
207
224
  const cleaned = db.prepare(`SELECT value FROM meta WHERE key = 'legacy_indexes_removed'`).get();
@@ -383,13 +400,13 @@ export function recordScans(entries) {
383
400
  }
384
401
  const upsertSessionStmt = (db) => db.prepare(`
385
402
  INSERT INTO sessions (
386
- id, short_id, agent, version, account, timestamp,
403
+ id, short_id, agent, version, account, timestamp, last_activity,
387
404
  project, cwd, git_branch, topic, label, message_count, token_count,
388
405
  cost_usd, duration_ms,
389
406
  file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
390
407
  pr_url, pr_number, worktree_slug, ticket_id
391
408
  ) VALUES (
392
- @id, @short_id, @agent, @version, @account, @timestamp,
409
+ @id, @short_id, @agent, @version, @account, @timestamp, @last_activity,
393
410
  @project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
394
411
  @cost_usd, @duration_ms,
395
412
  @file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
@@ -401,6 +418,7 @@ const upsertSessionStmt = (db) => db.prepare(`
401
418
  version = excluded.version,
402
419
  account = excluded.account,
403
420
  timestamp = excluded.timestamp,
421
+ last_activity = excluded.last_activity,
404
422
  project = excluded.project,
405
423
  cwd = excluded.cwd,
406
424
  git_branch = excluded.git_branch,
@@ -447,6 +465,7 @@ export function upsertSession(meta, content, scan) {
447
465
  version: meta.version ?? null,
448
466
  account: meta.account ?? null,
449
467
  timestamp: meta.timestamp,
468
+ last_activity: resolveLastActivity(meta, scan),
450
469
  project: meta.project ?? null,
451
470
  cwd: meta.cwd ?? null,
452
471
  git_branch: meta.gitBranch ?? null,
@@ -526,6 +545,7 @@ export function upsertSessionsBatch(entries) {
526
545
  version: meta.version ?? null,
527
546
  account: meta.account ?? null,
528
547
  timestamp: meta.timestamp,
548
+ last_activity: resolveLastActivity(meta, scan),
529
549
  project: meta.project ?? null,
530
550
  cwd: meta.cwd ?? null,
531
551
  git_branch: meta.gitBranch ?? null,
@@ -641,6 +661,7 @@ function rowToMeta(row) {
641
661
  shortId: row.short_id,
642
662
  agent: row.agent,
643
663
  timestamp: row.timestamp,
664
+ lastActivity: row.last_activity ?? undefined,
644
665
  project: row.project ?? undefined,
645
666
  cwd: row.cwd ?? undefined,
646
667
  filePath: row.file_path,
@@ -660,6 +681,21 @@ function rowToMeta(row) {
660
681
  ticketId: row.ticket_id ?? undefined,
661
682
  };
662
683
  }
684
+ /**
685
+ * The recency signal used to sort and label the listing: last-message time when
686
+ * a parser computed it (`meta.lastActivity` from `lastTsMs`), else the file's
687
+ * mtime (its last write), else creation time. Guarded on `filePath` so synthetic
688
+ * / cloud rows (no local file) fall to their creation timestamp rather than a
689
+ * bogus scan-time mtime. Always an ISO string, so it sorts lexicographically
690
+ * against `timestamp` and feeds `formatRelativeTime` unchanged.
691
+ */
692
+ function resolveLastActivity(meta, scan) {
693
+ if (meta.lastActivity)
694
+ return meta.lastActivity;
695
+ if (scan?.fileMtimeMs && meta.filePath)
696
+ return new Date(scan.fileMtimeMs).toISOString();
697
+ return meta.timestamp;
698
+ }
663
699
  /**
664
700
  * Newest indexed session file for an agent working in `cwd`. Lets the live
665
701
  * `--active` scanner locate a Codex transcript (whose files are date-partitioned,
@@ -741,7 +777,7 @@ function buildSessionWhere(options) {
741
777
  const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
742
778
  return { clause, params };
743
779
  }
744
- /** Query sessions from the database, applying filters and ordering by timestamp descending. */
780
+ /** Query sessions from the database, applying filters and ordering by last-activity descending (default). */
745
781
  export function querySessions(options = {}) {
746
782
  const db = getDB();
747
783
  const { clause, params } = buildSessionWhere(options);
@@ -757,7 +793,7 @@ export function querySessions(options = {}) {
757
793
  ? 'ORDER BY cost_usd IS NULL, cost_usd DESC, timestamp DESC'
758
794
  : options.sortBy === 'duration'
759
795
  ? 'ORDER BY duration_ms IS NULL, duration_ms DESC, timestamp DESC'
760
- : 'ORDER BY timestamp DESC';
796
+ : 'ORDER BY IFNULL(last_activity, timestamp) DESC, timestamp DESC';
761
797
  const sql = `SELECT * FROM sessions ${clause} ${orderClause} ${limitClause}`;
762
798
  const rows = db.prepare(sql).all(...params);
763
799
  // Belt-and-suspenders: drop rows whose JSONL no longer exists on disk. The
@@ -50,6 +50,8 @@ interface ClaudeSessionScan {
50
50
  costUsd?: number;
51
51
  /** Wall-clock duration in ms between the first and last timestamped event. */
52
52
  durationMs?: number;
53
+ /** ISO time of the last timestamped event — the session's last activity. */
54
+ lastActivity?: string;
53
55
  /**
54
56
  * Value of the JSONL `entrypoint` field on the first event that carries it.
55
57
  * 'cli' for real interactive sessions, 'sdk-cli' for team-spawned ones.
@@ -74,6 +76,22 @@ interface ClaudeSessionScan {
74
76
  * TTL, no external lock files needed.
75
77
  */
76
78
  export declare function discoverSessions(options?: DiscoverOptions): Promise<SessionMeta[]>;
79
+ /**
80
+ * How many agents' dotfile dirs we scan at once, and the minimum spacing between
81
+ * successive scan starts. A small bound + stagger turns a simultaneous bulk
82
+ * multi-dotfile sweep (a behavioral-EDR file-enumeration trigger) into a trickle.
83
+ */
84
+ export declare const DOTFILE_SCAN_CONCURRENCY = 2;
85
+ /** Run each agent's incremental scan, bounded + staggered. Order is irrelevant (each scan writes its own rows). */
86
+ export declare function scanAgentsBounded<T>(items: readonly T[], run: (item: T) => Promise<void>): Promise<void[]>;
87
+ /**
88
+ * The machine a discovered session originated on. Cross-machine sync mirrors a
89
+ * remote transcript to backups/<agent>/<machine>/<subdir>/… (see mirrorPath in
90
+ * sync/agents.ts); every other transcript is a live-home file on this box. So:
91
+ * when the path sits under the agent's backups root, the first segment below it
92
+ * is the origin machine id; otherwise it's the local machine.
93
+ */
94
+ export declare function machineForSessionFile(filePath: string, agent: string): string;
77
95
  /**
78
96
  * Count sessions in scope without running an incremental scan. Assumes the DB
79
97
  * is already fresh (typically true because `discoverSessions` ran first this
@@ -105,8 +123,29 @@ export declare function getAgentSessionDirs(agent: string, subdir: string): stri
105
123
  * recent startedAt.
106
124
  */
107
125
  export declare function buildClaudeLabelMap(): Map<string, string | null>;
126
+ /**
127
+ * Base64url-decode a JWT and return its `email` claim, if present. Split out so
128
+ * the decode is a single, testable step — and so it only runs when someone
129
+ * actually reads the Codex account (see the lazy resolution below).
130
+ */
131
+ export declare function decodeJwtEmail(idToken: string): string | undefined;
132
+ /** Test seam: how many times getCodexAccount has actually resolved (decoded) since the last reset. */
133
+ export declare function __codexAccountResolveCountForTest(): number;
134
+ /** Test seam: clear the memoized account + resolve counter so laziness can be observed from a clean slate. */
135
+ export declare function __resetCodexAccountCacheForTest(): void;
108
136
  /** Parse the lines of a Codex `session_index.jsonl` into a session id -> title map. */
109
137
  export declare function parseCodexThreadNameIndex(raw: string): Map<string, string>;
138
+ /**
139
+ * Stream-parse a single Codex JSONL file to extract session metadata.
140
+ *
141
+ * `resolveAccount` is a lazy thunk (not a resolved string): the JWT decode it
142
+ * performs is deferred until we know this file is a real session worth building
143
+ * metadata for, and only then — never during the file walk / stat phase.
144
+ */
145
+ export declare function readCodexMeta(filePath: string, resolveAccount?: () => string | undefined, currentVersion?: string): Promise<{
146
+ meta: SessionMeta;
147
+ content: string;
148
+ } | null>;
110
149
  /** Stream a Claude JSONL file and extract scan-level metadata (timestamp, cwd, topic, tokens). */
111
150
  export declare function scanClaudeSession(filePath: string): Promise<ClaudeSessionScan>;
112
151
  /** Read up to maxLines non-empty lines from the beginning of a file. */