@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
@@ -23,6 +23,8 @@ import { extractSessionTopic } from './prompt.js';
23
23
  import { parseAntigravity } from './parse.js';
24
24
  import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand } from './state.js';
25
25
  import { costOfUsage } from '../pricing/index.js';
26
+ import { machineId } from './sync/config.js';
27
+ import { mapBounded } from '../concurrency.js';
26
28
  import { getDB, getScanStampByPath, getScanStampsForPaths, recordScans, syncLabels, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, } from './db.js';
27
29
  const HOME = os.homedir();
28
30
  // Versions can live under either repo: the user repo (current canonical
@@ -56,28 +58,68 @@ export async function discoverSessions(options) {
56
58
  const onProgress = options?.onProgress;
57
59
  if (tryClaimScan(process.pid)) {
58
60
  try {
59
- await Promise.all(agents.map(agent => {
60
- switch (agent) {
61
- case 'claude': return scanClaudeIncremental(onProgress);
62
- case 'codex': return scanCodexIncremental(onProgress);
63
- case 'gemini': return scanGeminiIncremental(onProgress);
64
- case 'antigravity': return scanAntigravityIncremental(onProgress);
65
- case 'opencode': return scanOpenCodeIncremental();
66
- case 'openclaw': return scanOpenClawIncremental();
67
- case 'rush': return scanRushIncremental(onProgress);
68
- case 'hermes': return scanHermesIncremental(onProgress);
69
- case 'kimi': return scanKimiIncremental(onProgress);
70
- case 'droid': return scanDroidIncremental(onProgress);
71
- }
72
- }));
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));
73
66
  }
74
67
  finally {
75
68
  releaseScan(process.pid);
76
69
  }
77
70
  }
78
71
  const sessions = querySessions(buildQueryOptions(options, agents, { includeLimit: true }));
72
+ for (const s of sessions)
73
+ s.machine = machineForSessionFile(s.filePath, s.agent);
79
74
  return sessions;
80
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
+ }
81
123
  /**
82
124
  * Count sessions in scope without running an incremental scan. Assumes the DB
83
125
  * is already fresh (typically true because `discoverSessions` ran first this
@@ -417,6 +459,7 @@ async function readClaudeMeta(filePath, sessionId, account, label) {
417
459
  shortId: sessionId.slice(0, 8),
418
460
  agent: 'claude',
419
461
  timestamp: scan.timestamp,
462
+ lastActivity: scan.lastActivity,
420
463
  project: cwd ? path.basename(cwd) : undefined,
421
464
  cwd,
422
465
  filePath,
@@ -443,6 +486,7 @@ async function readClaudeMeta(filePath, sessionId, account, label) {
443
486
  shortId: sessionId.slice(0, 8),
444
487
  agent: 'claude',
445
488
  timestamp: stat ? stat.mtime.toISOString() : new Date().toISOString(),
489
+ lastActivity: scan.lastActivity,
446
490
  filePath,
447
491
  account,
448
492
  label,
@@ -464,10 +508,37 @@ async function readClaudeMeta(filePath, sessionId, account, label) {
464
508
  // Codex account info
465
509
  // ---------------------------------------------------------------------------
466
510
  let cachedCodexAccount;
467
- /** Extract the Codex account email from the JWT id_token in auth.json. */
511
+ /** Number of times the auth.json JWT was actually base64-decoded. Test seam for the lazy-decode contract. */
512
+ let codexAccountResolveCount = 0;
513
+ /**
514
+ * Base64url-decode a JWT and return its `email` claim, if present. Split out so
515
+ * the decode is a single, testable step — and so it only runs when someone
516
+ * actually reads the Codex account (see the lazy resolution below).
517
+ */
518
+ export function decodeJwtEmail(idToken) {
519
+ const parts = idToken.split('.');
520
+ if (parts.length < 2)
521
+ return undefined;
522
+ try {
523
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
524
+ return typeof payload.email === 'string' ? payload.email : undefined;
525
+ }
526
+ catch {
527
+ return undefined; // malformed JWT
528
+ }
529
+ }
530
+ /**
531
+ * Extract the Codex account email from the JWT id_token in auth.json.
532
+ *
533
+ * Memoized and resolved LAZILY: the credential-harvesting-shaped JWT decode
534
+ * (base64-decoding ~/.codex/auth.json) only runs when the account is actually
535
+ * needed to build a session's metadata — never eagerly during the bulk scan.
536
+ * A scan with no changed Codex files never touches the auth file.
537
+ */
468
538
  function getCodexAccount() {
469
539
  if (cachedCodexAccount !== undefined)
470
540
  return cachedCodexAccount || undefined;
541
+ codexAccountResolveCount++;
471
542
  const candidates = [path.join(HOME, '.codex', 'auth.json')];
472
543
  for (const root of VERSIONS_ROOTS) {
473
544
  const versionsBase = path.join(root, 'versions', 'codex');
@@ -487,27 +558,35 @@ function getCodexAccount() {
487
558
  const data = JSON.parse(fs.readFileSync(candidate, 'utf-8'));
488
559
  const idToken = data.tokens?.id_token;
489
560
  if (idToken) {
490
- const parts = idToken.split('.');
491
- if (parts.length >= 2) {
492
- const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
493
- if (payload.email) {
494
- cachedCodexAccount = payload.email;
495
- return payload.email;
496
- }
561
+ const email = decodeJwtEmail(idToken);
562
+ if (email) {
563
+ cachedCodexAccount = email;
564
+ return email;
497
565
  }
498
566
  }
499
567
  }
500
- catch { /* auth file or JWT malformed */ }
568
+ catch { /* auth file malformed */ }
501
569
  }
502
570
  cachedCodexAccount = '';
503
571
  return undefined;
504
572
  }
573
+ /** Test seam: how many times getCodexAccount has actually resolved (decoded) since the last reset. */
574
+ export function __codexAccountResolveCountForTest() {
575
+ return codexAccountResolveCount;
576
+ }
577
+ /** Test seam: clear the memoized account + resolve counter so laziness can be observed from a clean slate. */
578
+ export function __resetCodexAccountCacheForTest() {
579
+ cachedCodexAccount = undefined;
580
+ codexAccountResolveCount = 0;
581
+ }
505
582
  // ---------------------------------------------------------------------------
506
583
  // Codex
507
584
  // ---------------------------------------------------------------------------
508
585
  /** Incrementally re-scan changed Codex session files and upsert into the DB. */
509
586
  async function scanCodexIncremental(onProgress) {
510
- const account = getCodexAccount();
587
+ // Lazy: getCodexAccount (the auth.json JWT decode) is only resolved by
588
+ // readCodexMeta when a changed session actually needs it — never eagerly here,
589
+ // so a no-op scan (changed.length === 0) never touches the credential file.
511
590
  const currentVersion = await getCurrentAgentVersion('codex');
512
591
  const filePaths = [];
513
592
  for (const sessionsDir of getAgentSessionDirs('codex', 'sessions')) {
@@ -532,7 +611,7 @@ async function scanCodexIncremental(onProgress) {
532
611
  let parsed = 0;
533
612
  for (const { filePath, scan } of changed) {
534
613
  try {
535
- const result = await readCodexMeta(filePath, account, currentVersion);
614
+ const result = await readCodexMeta(filePath, getCodexAccount, currentVersion);
536
615
  if (result && !seen.has(result.meta.id)) {
537
616
  seen.add(result.meta.id);
538
617
  // Prefer the Codex-generated title over the first-prompt fallback.
@@ -596,8 +675,14 @@ function readCodexThreadNames() {
596
675
  }
597
676
  return titles;
598
677
  }
599
- /** Stream-parse a single Codex JSONL file to extract session metadata. */
600
- async function readCodexMeta(filePath, account, currentVersion) {
678
+ /**
679
+ * Stream-parse a single Codex JSONL file to extract session metadata.
680
+ *
681
+ * `resolveAccount` is a lazy thunk (not a resolved string): the JWT decode it
682
+ * performs is deferred until we know this file is a real session worth building
683
+ * metadata for, and only then — never during the file walk / stat phase.
684
+ */
685
+ export async function readCodexMeta(filePath, resolveAccount, currentVersion) {
601
686
  const scan = await scanCodexSession(filePath);
602
687
  const sessionId = scan.sessionId || '';
603
688
  if (!sessionId)
@@ -610,6 +695,7 @@ async function readCodexMeta(filePath, account, currentVersion) {
610
695
  // Codex `session_meta` only carries the start time; use file mtime when
611
696
  // it's newer so long-running sessions register as recently active.
612
697
  timestamp: pickLatestCodexTimestamp(scan.timestamp, filePath),
698
+ lastActivity: scan.lastActivity,
613
699
  project: cwd ? path.basename(cwd) : undefined,
614
700
  cwd,
615
701
  filePath,
@@ -620,7 +706,7 @@ async function readCodexMeta(filePath, account, currentVersion) {
620
706
  tokenCount: scan.tokenCount,
621
707
  costUsd: scan.costUsd,
622
708
  durationMs: scan.durationMs,
623
- account,
709
+ account: resolveAccount?.(),
624
710
  prUrl: scan.prUrl,
625
711
  prNumber: scan.prNumber,
626
712
  worktreeSlug: scan.worktreeSlug,
@@ -804,6 +890,7 @@ function readGeminiMeta(filePath, hashDir, projectMap, currentVersion) {
804
890
  shortId: sessionId.slice(0, 8),
805
891
  agent: 'gemini',
806
892
  timestamp: startTime || (stat ? stat.mtime.toISOString() : new Date().toISOString()),
893
+ lastActivity: lastTsMs !== undefined ? new Date(lastTsMs).toISOString() : undefined,
807
894
  project,
808
895
  cwd,
809
896
  filePath,
@@ -1010,6 +1097,7 @@ async function scanOpenCodeIncremental() {
1010
1097
  s.directory,
1011
1098
  s.version,
1012
1099
  s.time_created,
1100
+ s.time_updated,
1013
1101
  COALESCE(stats.message_count, 0),
1014
1102
  stats.token_count,
1015
1103
  COALESCE(stats.has_token_data, 0)
@@ -1042,20 +1130,26 @@ async function scanOpenCodeIncremental() {
1042
1130
  for (const line of out.split('\n')) {
1043
1131
  if (!line.trim())
1044
1132
  continue;
1045
- const [id, title, directory, version, timeCreatedStr, messageCountStr, tokenCountStr, hasTokenDataStr] = line.split('|||');
1133
+ const [id, title, directory, version, timeCreatedStr, timeUpdatedStr, messageCountStr, tokenCountStr, hasTokenDataStr] = line.split('|||');
1046
1134
  if (!id)
1047
1135
  continue;
1048
1136
  const timeCreated = parseInt(timeCreatedStr, 10);
1137
+ const timeUpdated = parseInt(timeUpdatedStr, 10);
1049
1138
  const messageCount = parseInt(messageCountStr, 10);
1050
1139
  const tokenCount = parseInt(tokenCountStr, 10);
1051
1140
  const hasTokenData = hasTokenDataStr === '1';
1052
1141
  const timestamp = isNaN(timeCreated) ? new Date().toISOString() : new Date(timeCreated).toISOString();
1142
+ // OpenCode is one shared DB, not one file per session — its row carries a
1143
+ // per-session updated time. Set lastActivity explicitly (falling back to
1144
+ // creation, never the whole-DB mtime the ScanStamp would otherwise supply).
1145
+ const lastActivity = Number.isNaN(timeUpdated) ? timestamp : new Date(timeUpdated).toISOString();
1053
1146
  const topic = title || undefined;
1054
1147
  const meta = {
1055
1148
  id,
1056
1149
  shortId: id.replace(/^ses_/, '').slice(0, 8),
1057
1150
  agent: 'opencode',
1058
1151
  timestamp,
1152
+ lastActivity,
1059
1153
  project: directory ? path.basename(directory) : undefined,
1060
1154
  cwd: directory ? normalizeCwd(directory) : undefined,
1061
1155
  filePath: `${OPENCODE_DB}#${id}`,
@@ -1488,6 +1582,7 @@ async function readDroidMeta(filePath, currentVersion) {
1488
1582
  shortId: sessionId.slice(0, 8),
1489
1583
  agent: 'droid',
1490
1584
  timestamp: scan.timestamp || (stat ? stat.mtime.toISOString() : new Date().toISOString()),
1585
+ lastActivity: scan.lastActivity,
1491
1586
  project: cwd ? path.basename(cwd) : undefined,
1492
1587
  cwd,
1493
1588
  filePath,
@@ -1609,6 +1704,7 @@ async function scanDroidSession(filePath) {
1609
1704
  model,
1610
1705
  messageCount,
1611
1706
  durationMs,
1707
+ lastActivity: lastTsMs !== undefined ? new Date(lastTsMs).toISOString() : undefined,
1612
1708
  contentText: userTexts.length > 0 ? userTexts.join('\n') : undefined,
1613
1709
  };
1614
1710
  }
@@ -1794,6 +1890,7 @@ export async function scanClaudeSession(filePath) {
1794
1890
  tokenCount: sawTokenCount ? tokenCount : undefined,
1795
1891
  costUsd: sawCost ? costUsd : undefined,
1796
1892
  durationMs,
1893
+ lastActivity: lastTsMs !== undefined ? new Date(lastTsMs).toISOString() : undefined,
1797
1894
  contentText: userTexts.length > 0 ? userTexts.join('\n') : undefined,
1798
1895
  prUrl,
1799
1896
  prNumber,
@@ -1937,6 +2034,7 @@ async function scanCodexSession(filePath) {
1937
2034
  tokenCount,
1938
2035
  costUsd,
1939
2036
  durationMs,
2037
+ lastActivity: lastTsMs !== undefined ? new Date(lastTsMs).toISOString() : undefined,
1940
2038
  contentText: userTexts.length > 0 ? userTexts.join('\n') : undefined,
1941
2039
  prUrl,
1942
2040
  prNumber,
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Best-effort Ghostty tab-number detection for `agents sessions --active`.
3
+ *
4
+ * Ghostty (macOS) exposes read-only AppleScript: every tab has an `index` and a
5
+ * `name` (title); every surface a `working directory`. It exposes NO per-tab env
6
+ * var and NO tty/pid on a surface — so a session is matched to its tab by
7
+ * `working directory` (with title as a tiebreak). This is display sugar only:
8
+ * one bounded, non-fatal osascript call, run by the renderer, never on the
9
+ * discovery / --json / --waiting path. Any failure (Ghostty not running,
10
+ * Automation permission denied, timeout) degrades silently to no tab number.
11
+ */
12
+ import type { ActiveSession } from './active.js';
13
+ /** One Ghostty surface (terminal), flattened from window -> tab -> surface. */
14
+ export interface GhosttySurface {
15
+ windowIndex: number;
16
+ tabIndex: number;
17
+ cwd: string;
18
+ title: string;
19
+ }
20
+ /**
21
+ * Enumerate every Ghostty surface (window/tab/cwd/title) via one read-only
22
+ * osascript call. Returns [] on ANY failure — Ghostty not running, Automation
23
+ * permission not granted, timeout, or a parse miss. Never throws, never prompts.
24
+ */
25
+ export declare function enumerateGhosttyTabs(timeoutMs?: number): Promise<GhosttySurface[]>;
26
+ /**
27
+ * Assign a Ghostty tab number to each `host === 'ghostty'` session by matching
28
+ * its cwd to a surface's working directory; ties (same cwd) are broken by title
29
+ * containment against the session's label/topic/kind. Deliberately conservative:
30
+ * a session that can't be uniquely resolved gets NO number — a wrong jump target
31
+ * is worse than none. Pure and unit-tested.
32
+ */
33
+ export declare function assignGhosttyTabs(sessions: ActiveSession[], surfaces: GhosttySurface[]): Map<ActiveSession, number>;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Best-effort Ghostty tab-number detection for `agents sessions --active`.
3
+ *
4
+ * Ghostty (macOS) exposes read-only AppleScript: every tab has an `index` and a
5
+ * `name` (title); every surface a `working directory`. It exposes NO per-tab env
6
+ * var and NO tty/pid on a surface — so a session is matched to its tab by
7
+ * `working directory` (with title as a tiebreak). This is display sugar only:
8
+ * one bounded, non-fatal osascript call, run by the renderer, never on the
9
+ * discovery / --json / --waiting path. Any failure (Ghostty not running,
10
+ * Automation permission denied, timeout) degrades silently to no tab number.
11
+ */
12
+ import { execFile } from 'child_process';
13
+ import { promisify } from 'util';
14
+ const execFileAsync = promisify(execFile);
15
+ // Field delimiter: ASCII Unit Separator (0x1F). AppleScript's `tab` keyword does
16
+ // not resolve to a tab byte via `osascript -e`, but `character id 31` does — and
17
+ // 0x1F never appears in a cwd or a tab title, so parsing is unambiguous.
18
+ const ENUM_SCRIPT = `tell application "Ghostty"
19
+ set fd to (character id 31)
20
+ set out to ""
21
+ set wi to 0
22
+ repeat with w in windows
23
+ set wi to wi + 1
24
+ repeat with t in tabs of w
25
+ set ti to index of t
26
+ repeat with s in terminals of t
27
+ set out to out & wi & fd & ti & fd & (working directory of s) & fd & (name of s) & linefeed
28
+ end repeat
29
+ end repeat
30
+ end repeat
31
+ return out
32
+ end tell`;
33
+ /**
34
+ * Enumerate every Ghostty surface (window/tab/cwd/title) via one read-only
35
+ * osascript call. Returns [] on ANY failure — Ghostty not running, Automation
36
+ * permission not granted, timeout, or a parse miss. Never throws, never prompts.
37
+ */
38
+ export async function enumerateGhosttyTabs(timeoutMs = 1500) {
39
+ if (process.platform !== 'darwin')
40
+ return [];
41
+ let stdout;
42
+ try {
43
+ ({ stdout } = await execFileAsync('osascript', ['-e', ENUM_SCRIPT], {
44
+ timeout: timeoutMs,
45
+ killSignal: 'SIGKILL',
46
+ maxBuffer: 1024 * 1024,
47
+ encoding: 'utf8',
48
+ }));
49
+ }
50
+ catch {
51
+ return [];
52
+ }
53
+ const out = [];
54
+ for (const line of stdout.split('\n')) {
55
+ if (!line)
56
+ continue;
57
+ const f = line.split('\u001f');
58
+ if (f.length < 4)
59
+ continue;
60
+ const windowIndex = parseInt(f[0], 10);
61
+ const tabIndex = parseInt(f[1], 10);
62
+ if (!Number.isFinite(windowIndex) || !Number.isFinite(tabIndex))
63
+ continue;
64
+ out.push({ windowIndex, tabIndex, cwd: f[2], title: f[3] });
65
+ }
66
+ return out;
67
+ }
68
+ /** Trailing-slash-insensitive cwd key. */
69
+ function cwdKey(p) {
70
+ return (p ?? '').replace(/\/+$/, '');
71
+ }
72
+ /**
73
+ * Normalize a tab title / hint for containment matching: drop a leading run of
74
+ * non-alphanumerics (Ghostty prefixes the title with a spinner glyph like `⠐ `
75
+ * or `✳ ` while the agent runs) and lowercase. Without this, a title that starts
76
+ * with the session's exact topic still fails a substring test.
77
+ */
78
+ function normText(s) {
79
+ return s.replace(/^[^\p{L}\p{N}]+/u, '').toLowerCase().trim();
80
+ }
81
+ /**
82
+ * Assign a Ghostty tab number to each `host === 'ghostty'` session by matching
83
+ * its cwd to a surface's working directory; ties (same cwd) are broken by title
84
+ * containment against the session's label/topic/kind. Deliberately conservative:
85
+ * a session that can't be uniquely resolved gets NO number — a wrong jump target
86
+ * is worse than none. Pure and unit-tested.
87
+ */
88
+ export function assignGhosttyTabs(sessions, surfaces) {
89
+ const result = new Map();
90
+ if (surfaces.length === 0)
91
+ return result;
92
+ const byCwd = new Map();
93
+ for (const s of surfaces) {
94
+ const k = cwdKey(s.cwd);
95
+ const bucket = byCwd.get(k);
96
+ if (bucket)
97
+ bucket.push(s);
98
+ else
99
+ byCwd.set(k, [s]);
100
+ }
101
+ for (const sess of sessions) {
102
+ if (sess.host !== 'ghostty')
103
+ continue;
104
+ const candidates = byCwd.get(cwdKey(sess.cwd));
105
+ if (!candidates || candidates.length === 0)
106
+ continue;
107
+ if (candidates.length === 1) {
108
+ result.set(sess, candidates[0].tabIndex);
109
+ continue;
110
+ }
111
+ // Tie: break by title containment (glyph-stripped, lowercased). Hints are
112
+ // what the session knows about itself; a good hint is >=8 chars so short
113
+ // fragments don't cause spurious matches.
114
+ const hints = [sess.label, sess.topic, sess.preview]
115
+ .filter((h) => !!h && normText(h).length >= 8)
116
+ .map(normText);
117
+ const matches = candidates.filter(c => {
118
+ const title = normText(c.title);
119
+ return title.length >= 8 && hints.some(h => title.includes(h) || h.includes(title));
120
+ });
121
+ // Only assign when the tiebreak is unambiguous (exactly one title match).
122
+ if (matches.length === 1)
123
+ result.set(sess, matches[0].tabIndex);
124
+ }
125
+ return result;
126
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Session → Terminal-Engine injection adapter.
3
+ *
4
+ * The Terminal Engine owns the injection primitive (`injectIntoTerminal`,
5
+ * src/lib/terminal/inject.ts). This thin adapter maps a session's provenance
6
+ * `ReplyRail` (the addressable terminal the feed already derives — provenance.ts:47)
7
+ * to the engine's `InjectTarget`, so a caller can go session → keystrokes in one
8
+ * hop. Kept on the session side because `ReplyRail` is a session concept; the
9
+ * engine stays agnostic of how a target was discovered.
10
+ */
11
+ import type { ReplyRail } from './provenance.js';
12
+ import type { InjectTarget } from '../terminal/inject.js';
13
+ /**
14
+ * Map a session's `ReplyRail` to an engine `InjectTarget`. Today only tmux rails
15
+ * are externally addressable (provenance.ts:143-149); a null rail yields null and
16
+ * the caller must supply a target another way (a pty id, a macOS window).
17
+ */
18
+ export declare function injectTargetFromReplyRail(rail: ReplyRail): InjectTarget | null;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Session → Terminal-Engine injection adapter.
3
+ *
4
+ * The Terminal Engine owns the injection primitive (`injectIntoTerminal`,
5
+ * src/lib/terminal/inject.ts). This thin adapter maps a session's provenance
6
+ * `ReplyRail` (the addressable terminal the feed already derives — provenance.ts:47)
7
+ * to the engine's `InjectTarget`, so a caller can go session → keystrokes in one
8
+ * hop. Kept on the session side because `ReplyRail` is a session concept; the
9
+ * engine stays agnostic of how a target was discovered.
10
+ */
11
+ /**
12
+ * Map a session's `ReplyRail` to an engine `InjectTarget`. Today only tmux rails
13
+ * are externally addressable (provenance.ts:143-149); a null rail yields null and
14
+ * the caller must supply a target another way (a pty id, a macOS window).
15
+ */
16
+ export function injectTargetFromReplyRail(rail) {
17
+ if (rail && rail.rail === 'tmux') {
18
+ return { backend: 'tmux', pane: rail.target, socket: rail.socket };
19
+ }
20
+ return null;
21
+ }
@@ -8,6 +8,7 @@
8
8
  import * as fs from 'fs';
9
9
  import * as path from 'path';
10
10
  import { execFileSync } from 'child_process';
11
+ import Database from '../sqlite.js';
11
12
  /**
12
13
  * Largest session file we will load into memory. Above this we throw a clean
13
14
  * error instead of OOMing or hitting V8's ERR_STRING_TOO_LONG. Aligns with
@@ -875,19 +876,28 @@ const ANTIGRAVITY_TOOL_MAP = {
875
876
  * request step and a completion step that share the id).
876
877
  */
877
878
  export function parseAntigravity(dbPath) {
879
+ // Read the raw BLOB payloads through the node/bun SQLite wrapper (not the
880
+ // `sqlite3` CLI) so this works on every OS — the CLI is absent on Windows.
878
881
  let rows;
882
+ let db;
879
883
  try {
880
- rows = execFileSync('sqlite3', ['-separator', '\t', dbPath, 'SELECT idx, step_type, quote(step_payload) FROM steps ORDER BY idx;'], {
881
- encoding: 'utf-8',
882
- timeout: 10000,
883
- maxBuffer: 64 * 1024 * 1024,
884
- stdio: ['ignore', 'pipe', 'ignore'],
885
- });
884
+ db = new Database(dbPath);
885
+ rows = db
886
+ .prepare('SELECT idx, step_type, step_payload FROM steps ORDER BY idx;')
887
+ .all();
886
888
  }
887
889
  catch {
888
- /* DB not accessible, sqlite3 missing, or query failed */
890
+ /* DB not accessible, sqlite module unavailable, or query failed */
889
891
  return [];
890
892
  }
893
+ finally {
894
+ try {
895
+ db?.close();
896
+ }
897
+ catch {
898
+ /* best-effort close */
899
+ }
900
+ }
891
901
  // Single timestamp for the whole session: the steps table carries no per-step
892
902
  // time column, so fall back to the DB file's mtime for a stable, sortable value.
893
903
  let timestamp = new Date().toISOString();
@@ -899,20 +909,13 @@ export function parseAntigravity(dbPath) {
899
909
  }
900
910
  const events = [];
901
911
  const seenCallIds = new Set();
902
- for (const line of rows.split('\n')) {
903
- if (!line.trim())
904
- continue;
905
- const tab1 = line.indexOf('\t');
906
- if (tab1 === -1)
907
- continue;
908
- const tab2 = line.indexOf('\t', tab1 + 1);
909
- if (tab2 === -1)
910
- continue;
911
- const hex = line.slice(tab2 + 1).trim();
912
- // sqlite3 quote() renders a BLOB as X'...'; anything else (NULL) is skipped.
913
- if (!hex.startsWith("X'") || !hex.endsWith("'"))
912
+ for (const row of rows) {
913
+ const payload = row.step_payload;
914
+ // Both node:sqlite and bun:sqlite return a BLOB as a Uint8Array (Buffer is
915
+ // a subclass). NULL / non-blob payloads are skipped.
916
+ if (!(payload instanceof Uint8Array))
914
917
  continue;
915
- const bytes = Uint8Array.from(Buffer.from(hex.slice(2, -1), 'hex'));
918
+ const bytes = payload;
916
919
  let fields;
917
920
  try {
918
921
  fields = decodeProtoMessage(bytes);
@@ -13,6 +13,7 @@ export interface PidSessionEntry {
13
13
  tmuxPane?: string;
14
14
  startedAtMs: number;
15
15
  }
16
+ export declare function extractSessionIdArg(args: string[]): string | undefined;
16
17
  /** Record a launched agent process. Never throws — the registry is an optimization. */
17
18
  export declare function writePidSessionEntry(entry: PidSessionEntry): void;
18
19
  /** Look up a live pid's recorded session. Returns undefined if absent/corrupt. */
@@ -19,6 +19,30 @@
19
19
  import fs from 'fs';
20
20
  import path from 'path';
21
21
  import { getTerminalsDir } from '../state.js';
22
+ /**
23
+ * Pull an explicit `--session-id <uuid>` (or `--session-id=<uuid>`) out of a
24
+ * raw agent arg vector. The transparent shim forwards args untouched, but when
25
+ * a launcher (Claude Code background jobs, IDE harnesses) already names the
26
+ * session, recording it gives the same exact pid -> session mapping `ag run`
27
+ * gets from generating the id itself.
28
+ */
29
+ const SESSION_ID_VALUE_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
30
+ export function extractSessionIdArg(args) {
31
+ for (let i = 0; i < args.length; i++) {
32
+ const a = args[i];
33
+ if (a === '--session-id') {
34
+ const v = args[i + 1];
35
+ if (v && SESSION_ID_VALUE_RE.test(v))
36
+ return v;
37
+ }
38
+ else if (a.startsWith('--session-id=')) {
39
+ const v = a.slice('--session-id='.length);
40
+ if (SESSION_ID_VALUE_RE.test(v))
41
+ return v;
42
+ }
43
+ }
44
+ return undefined;
45
+ }
22
46
  function pidRegistryDir() {
23
47
  return path.join(getTerminalsDir(), 'by-pid');
24
48
  }
@@ -13,11 +13,16 @@ export interface MuxLocation {
13
13
  /** screen session name from $STY, e.g. '12345.pts-0.host'. */
14
14
  session?: string;
15
15
  }
16
- /** How the feed can type back into a session, derived from rails that exist today. */
16
+ /** How the feed can type back into a session, derived from env rails that exist today. */
17
17
  export type ReplyRail = {
18
18
  rail: 'tmux';
19
19
  target: string;
20
20
  socket?: string;
21
+ }
22
+ /** iTerm2 split addressed by its session UUID (the part of $ITERM_SESSION_ID after ':'). */
23
+ | {
24
+ rail: 'iterm';
25
+ session: string;
21
26
  } | null;
22
27
  export interface SessionProvenance {
23
28
  /** Machine the process runs on — os.hostname(). Drives HOSTS grouping. */
@@ -34,7 +39,14 @@ export interface SessionProvenance {
34
39
  reply: ReplyRail;
35
40
  }
36
41
  /** Env vars that carry provenance. Kept small so the macOS `ps` scan stays cheap. */
37
- export declare const PROVENANCE_ENV_KEYS: readonly ["SSH_CONNECTION", "SSH_TTY", "TMUX", "TMUX_PANE", "TERM_PROGRAM", "STY"];
42
+ export declare const PROVENANCE_ENV_KEYS: readonly ["SSH_CONNECTION", "SSH_TTY", "TMUX", "TMUX_PANE", "TERM_PROGRAM", "STY", "ITERM_SESSION_ID"];
43
+ /**
44
+ * Extract the iTerm2 session UUID from `$ITERM_SESSION_ID`, whose value is
45
+ * `w<window>t<tab>p<pane>:<UUID>` — the `:`-suffix is exactly what iTerm2's
46
+ * `id of session` returns, so it's the address for `tell session id …`. Returns
47
+ * undefined for an empty/absent value; tolerates a bare value with no `:`.
48
+ */
49
+ export declare function parseItermSession(value?: string): string | undefined;
38
50
  /** Parse the NUL-separated body of /proc/<pid>/environ into a plain object. */
39
51
  export declare function parseProcEnviron(buf: string): Record<string, string>;
40
52
  /**