@phnx-labs/agents-cli 1.20.35 → 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 (225) 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.d.ts +71 -1
  34. package/dist/commands/sessions.js +365 -37
  35. package/dist/commands/setup.js +4 -2
  36. package/dist/commands/sync.d.ts +3 -1
  37. package/dist/commands/sync.js +156 -4
  38. package/dist/commands/teams.js +217 -0
  39. package/dist/commands/versions.js +2 -4
  40. package/dist/commands/watchdog.d.ts +18 -0
  41. package/dist/commands/watchdog.js +238 -0
  42. package/dist/index.js +25 -2
  43. package/dist/lib/audit/log.d.ts +92 -0
  44. package/dist/lib/audit/log.js +177 -0
  45. package/dist/lib/auto-pull.js +2 -1
  46. package/dist/lib/browser/chrome.d.ts +10 -0
  47. package/dist/lib/browser/chrome.js +18 -7
  48. package/dist/lib/browser/drivers/ssh.js +2 -1
  49. package/dist/lib/browser/har.d.ts +84 -0
  50. package/dist/lib/browser/har.js +77 -0
  51. package/dist/lib/browser/ipc.js +24 -3
  52. package/dist/lib/browser/profiles.d.ts +1 -1
  53. package/dist/lib/browser/profiles.js +8 -10
  54. package/dist/lib/browser/refs.d.ts +65 -0
  55. package/dist/lib/browser/refs.js +73 -1
  56. package/dist/lib/browser/runtime-state.js +1 -0
  57. package/dist/lib/browser/service.d.ts +38 -2
  58. package/dist/lib/browser/service.js +112 -8
  59. package/dist/lib/browser/types.d.ts +14 -1
  60. package/dist/lib/budget/live-cloud.d.ts +42 -0
  61. package/dist/lib/budget/live-cloud.js +79 -0
  62. package/dist/lib/budget/live-team.d.ts +31 -0
  63. package/dist/lib/budget/live-team.js +115 -0
  64. package/dist/lib/cloud/codex.js +4 -0
  65. package/dist/lib/cloud/rush.d.ts +12 -1
  66. package/dist/lib/cloud/rush.js +13 -3
  67. package/dist/lib/cloud/types.d.ts +9 -0
  68. package/dist/lib/computer/dispatch.d.ts +8 -0
  69. package/dist/lib/computer/dispatch.js +125 -0
  70. package/dist/lib/computer/loop.d.ts +62 -0
  71. package/dist/lib/computer/loop.js +98 -0
  72. package/dist/lib/computer/model.d.ts +44 -0
  73. package/dist/lib/computer/model.js +157 -0
  74. package/dist/lib/concurrency.d.ts +19 -0
  75. package/dist/lib/concurrency.js +33 -0
  76. package/dist/lib/daemon.d.ts +57 -0
  77. package/dist/lib/daemon.js +192 -16
  78. package/dist/lib/devices/registry.d.ts +7 -0
  79. package/dist/lib/devices/registry.js +24 -0
  80. package/dist/lib/devices/tailscale.js +1 -1
  81. package/dist/lib/drift.d.ts +52 -0
  82. package/dist/lib/drift.js +112 -0
  83. package/dist/lib/events.d.ts +1 -1
  84. package/dist/lib/events.js +31 -13
  85. package/dist/lib/exec.d.ts +17 -0
  86. package/dist/lib/exec.js +79 -13
  87. package/dist/lib/git.d.ts +27 -0
  88. package/dist/lib/git.js +56 -1
  89. package/dist/lib/hooks/cache.d.ts +6 -0
  90. package/dist/lib/hooks/cache.js +54 -12
  91. package/dist/lib/hooks.d.ts +27 -0
  92. package/dist/lib/hooks.js +127 -8
  93. package/dist/lib/hosts/dispatch.d.ts +15 -0
  94. package/dist/lib/hosts/dispatch.js +39 -6
  95. package/dist/lib/hosts/logs.js +30 -1
  96. package/dist/lib/hosts/option.js +1 -1
  97. package/dist/lib/hosts/passthrough.js +3 -1
  98. package/dist/lib/hosts/ready.d.ts +29 -6
  99. package/dist/lib/hosts/ready.js +66 -15
  100. package/dist/lib/hosts/registry.d.ts +19 -2
  101. package/dist/lib/hosts/registry.js +58 -2
  102. package/dist/lib/hosts/remote-cmd.d.ts +62 -1
  103. package/dist/lib/hosts/remote-cmd.js +70 -1
  104. package/dist/lib/hosts/remote-os.d.ts +17 -0
  105. package/dist/lib/hosts/remote-os.js +30 -0
  106. package/dist/lib/hosts/session-index.d.ts +34 -0
  107. package/dist/lib/hosts/session-index.js +56 -0
  108. package/dist/lib/hosts/tasks.d.ts +14 -0
  109. package/dist/lib/hosts/tasks.js +15 -0
  110. package/dist/lib/lock.d.ts +93 -0
  111. package/dist/lib/lock.js +207 -0
  112. package/dist/lib/loop.js +16 -1
  113. package/dist/lib/machine-id.d.ts +21 -0
  114. package/dist/lib/machine-id.js +26 -0
  115. package/dist/lib/mailbox-target.d.ts +36 -0
  116. package/dist/lib/mailbox-target.js +45 -0
  117. package/dist/lib/mailbox.d.ts +47 -0
  118. package/dist/lib/mailbox.js +194 -0
  119. package/dist/lib/mcp.d.ts +5 -0
  120. package/dist/lib/mcp.js +24 -8
  121. package/dist/lib/migrate.d.ts +19 -0
  122. package/dist/lib/migrate.js +134 -26
  123. package/dist/lib/overdue.js +3 -0
  124. package/dist/lib/picker.d.ts +2 -0
  125. package/dist/lib/picker.js +4 -1
  126. package/dist/lib/platform/exec.d.ts +46 -0
  127. package/dist/lib/platform/exec.js +74 -0
  128. package/dist/lib/platform/process.d.ts +31 -0
  129. package/dist/lib/platform/process.js +34 -1
  130. package/dist/lib/platform/winpath.js +2 -0
  131. package/dist/lib/plugins.js +16 -6
  132. package/dist/lib/profiles.d.ts +25 -0
  133. package/dist/lib/profiles.js +22 -6
  134. package/dist/lib/pty-client.js +2 -1
  135. package/dist/lib/rotate.d.ts +61 -0
  136. package/dist/lib/rotate.js +52 -0
  137. package/dist/lib/routines.d.ts +40 -2
  138. package/dist/lib/routines.js +66 -8
  139. package/dist/lib/runner.d.ts +11 -2
  140. package/dist/lib/runner.js +49 -7
  141. package/dist/lib/scheduler.js +6 -1
  142. package/dist/lib/secrets/bundles.d.ts +60 -4
  143. package/dist/lib/secrets/bundles.js +131 -12
  144. package/dist/lib/secrets/filestore.d.ts +3 -0
  145. package/dist/lib/secrets/filestore.js +42 -16
  146. package/dist/lib/secrets/index.d.ts +43 -2
  147. package/dist/lib/secrets/index.js +102 -3
  148. package/dist/lib/secrets/mcp.d.ts +93 -0
  149. package/dist/lib/secrets/mcp.js +205 -0
  150. package/dist/lib/secrets/remote.js +12 -5
  151. package/dist/lib/secrets/sync.js +83 -4
  152. package/dist/lib/secrets/windows.js +14 -3
  153. package/dist/lib/serve/data.d.ts +81 -0
  154. package/dist/lib/serve/data.js +91 -0
  155. package/dist/lib/serve/page.d.ts +7 -0
  156. package/dist/lib/serve/page.js +140 -0
  157. package/dist/lib/serve/server.d.ts +46 -0
  158. package/dist/lib/serve/server.js +115 -0
  159. package/dist/lib/session/active.d.ts +54 -0
  160. package/dist/lib/session/active.js +190 -19
  161. package/dist/lib/session/discover.d.ts +37 -0
  162. package/dist/lib/session/discover.js +111 -28
  163. package/dist/lib/session/inject.d.ts +18 -0
  164. package/dist/lib/session/inject.js +21 -0
  165. package/dist/lib/session/parse.js +23 -20
  166. package/dist/lib/session/pid-registry.d.ts +1 -0
  167. package/dist/lib/session/pid-registry.js +24 -0
  168. package/dist/lib/session/provenance.d.ts +14 -2
  169. package/dist/lib/session/provenance.js +39 -8
  170. package/dist/lib/session/remote-active.js +19 -7
  171. package/dist/lib/session/remote-list.d.ts +51 -0
  172. package/dist/lib/session/remote-list.js +213 -0
  173. package/dist/lib/session/remote.d.ts +7 -1
  174. package/dist/lib/session/remote.js +16 -2
  175. package/dist/lib/session/sync/config.d.ts +1 -15
  176. package/dist/lib/session/sync/config.js +4 -20
  177. package/dist/lib/session/types.d.ts +17 -0
  178. package/dist/lib/shims.d.ts +36 -6
  179. package/dist/lib/shims.js +91 -29
  180. package/dist/lib/ssh-exec.js +2 -0
  181. package/dist/lib/ssh-tunnel.js +2 -1
  182. package/dist/lib/startup/command-registry.d.ts +6 -0
  183. package/dist/lib/startup/command-registry.js +13 -1
  184. package/dist/lib/state.d.ts +13 -0
  185. package/dist/lib/state.js +103 -9
  186. package/dist/lib/sync-umbrella.d.ts +14 -7
  187. package/dist/lib/sync-umbrella.js +17 -9
  188. package/dist/lib/teams/forEach.d.ts +110 -0
  189. package/dist/lib/teams/forEach.js +186 -0
  190. package/dist/lib/teams/index.d.ts +1 -0
  191. package/dist/lib/teams/index.js +1 -0
  192. package/dist/lib/teams/pr-watch.d.ts +226 -0
  193. package/dist/lib/teams/pr-watch.js +371 -0
  194. package/dist/lib/teams/supervisor.d.ts +14 -1
  195. package/dist/lib/teams/supervisor.js +19 -0
  196. package/dist/lib/teams/worktree.d.ts +9 -0
  197. package/dist/lib/teams/worktree.js +32 -0
  198. package/dist/lib/terminal/backends/index.d.ts +2 -1
  199. package/dist/lib/terminal/backends/index.js +3 -1
  200. package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
  201. package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
  202. package/dist/lib/terminal/index.d.ts +4 -1
  203. package/dist/lib/terminal/index.js +4 -1
  204. package/dist/lib/terminal/inject.d.ts +204 -0
  205. package/dist/lib/terminal/inject.js +247 -0
  206. package/dist/lib/terminal/resolve.d.ts +64 -0
  207. package/dist/lib/terminal/resolve.js +90 -0
  208. package/dist/lib/terminal/types.d.ts +1 -1
  209. package/dist/lib/triggers/webhook.d.ts +85 -0
  210. package/dist/lib/triggers/webhook.js +141 -0
  211. package/dist/lib/versions.d.ts +23 -0
  212. package/dist/lib/versions.js +119 -13
  213. package/dist/lib/watchdog/index.d.ts +3 -0
  214. package/dist/lib/watchdog/index.js +5 -0
  215. package/dist/lib/watchdog/read.d.ts +35 -0
  216. package/dist/lib/watchdog/read.js +149 -0
  217. package/dist/lib/watchdog/runner.d.ts +127 -0
  218. package/dist/lib/watchdog/runner.js +322 -0
  219. package/dist/lib/watchdog/watchdog.d.ts +40 -0
  220. package/dist/lib/watchdog/watchdog.js +166 -0
  221. package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
  222. package/dist/lib/watchdog/watchdogTail.js +154 -0
  223. package/dist/lib/workflows.d.ts +166 -0
  224. package/dist/lib/workflows.js +193 -0
  225. package/package.json +5 -4
@@ -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. */
@@ -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
@@ -464,10 +506,37 @@ async function readClaudeMeta(filePath, sessionId, account, label) {
464
506
  // Codex account info
465
507
  // ---------------------------------------------------------------------------
466
508
  let cachedCodexAccount;
467
- /** 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
+ */
468
536
  function getCodexAccount() {
469
537
  if (cachedCodexAccount !== undefined)
470
538
  return cachedCodexAccount || undefined;
539
+ codexAccountResolveCount++;
471
540
  const candidates = [path.join(HOME, '.codex', 'auth.json')];
472
541
  for (const root of VERSIONS_ROOTS) {
473
542
  const versionsBase = path.join(root, 'versions', 'codex');
@@ -487,27 +556,35 @@ function getCodexAccount() {
487
556
  const data = JSON.parse(fs.readFileSync(candidate, 'utf-8'));
488
557
  const idToken = data.tokens?.id_token;
489
558
  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
- }
559
+ const email = decodeJwtEmail(idToken);
560
+ if (email) {
561
+ cachedCodexAccount = email;
562
+ return email;
497
563
  }
498
564
  }
499
565
  }
500
- catch { /* auth file or JWT malformed */ }
566
+ catch { /* auth file malformed */ }
501
567
  }
502
568
  cachedCodexAccount = '';
503
569
  return undefined;
504
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
+ }
505
580
  // ---------------------------------------------------------------------------
506
581
  // Codex
507
582
  // ---------------------------------------------------------------------------
508
583
  /** Incrementally re-scan changed Codex session files and upsert into the DB. */
509
584
  async function scanCodexIncremental(onProgress) {
510
- 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.
511
588
  const currentVersion = await getCurrentAgentVersion('codex');
512
589
  const filePaths = [];
513
590
  for (const sessionsDir of getAgentSessionDirs('codex', 'sessions')) {
@@ -532,7 +609,7 @@ async function scanCodexIncremental(onProgress) {
532
609
  let parsed = 0;
533
610
  for (const { filePath, scan } of changed) {
534
611
  try {
535
- const result = await readCodexMeta(filePath, account, currentVersion);
612
+ const result = await readCodexMeta(filePath, getCodexAccount, currentVersion);
536
613
  if (result && !seen.has(result.meta.id)) {
537
614
  seen.add(result.meta.id);
538
615
  // Prefer the Codex-generated title over the first-prompt fallback.
@@ -596,8 +673,14 @@ function readCodexThreadNames() {
596
673
  }
597
674
  return titles;
598
675
  }
599
- /** Stream-parse a single Codex JSONL file to extract session metadata. */
600
- 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) {
601
684
  const scan = await scanCodexSession(filePath);
602
685
  const sessionId = scan.sessionId || '';
603
686
  if (!sessionId)
@@ -620,7 +703,7 @@ async function readCodexMeta(filePath, account, currentVersion) {
620
703
  tokenCount: scan.tokenCount,
621
704
  costUsd: scan.costUsd,
622
705
  durationMs: scan.durationMs,
623
- account,
706
+ account: resolveAccount?.(),
624
707
  prUrl: scan.prUrl,
625
708
  prNumber: scan.prNumber,
626
709
  worktreeSlug: scan.worktreeSlug,
@@ -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
  /**
@@ -15,9 +15,22 @@
15
15
  * (gone, or owned by another uid) yields `undefined`, never a guess.
16
16
  *
17
17
  * `reply` is a read-only hint, not a send channel: it reports whether a rail
18
- * that can type back into this session exists today (tmux pane => addressable;
19
- * inherited/ignored stdin => null). The feed uses it to decide whether to show
20
- * a Send box. Actually delivering the keystrokes is Gap 2 (pty/tmux send-keys).
18
+ * that can type back into this session exists today and, if so, the exact
19
+ * address to type into. Two env-derived rails, in precedence order:
20
+ *
21
+ * - tmux — `$TMUX_PANE` (+ `$TMUX` socket): `tmux send-keys -t <pane>`. Works
22
+ * inside ANY host app (a tmux pane can live under iTerm/Ghostty/VS
23
+ * Code), so it wins whenever present.
24
+ * - iterm — `$ITERM_SESSION_ID` (`w<n>t<n>p<n>:<UUID>`): the exact iTerm2
25
+ * split, addressable by its session UUID via `tell session id …`
26
+ * (focus-safe, no `activate`). Used when there's no tmux above it.
27
+ *
28
+ * Both are inherited env vars, so the split the agent lives in is recovered with
29
+ * zero cooperation from the agent. Inherited/ignored stdin with neither rail =>
30
+ * null (not env-addressable — resolution may still find an IDE rail off disk,
31
+ * see src/lib/terminal/resolve.ts). The feed uses `reply` to decide whether to
32
+ * show a Send box; the resolver turns it into a concrete inject target (Gap 2,
33
+ * src/lib/terminal/inject.ts).
21
34
  */
22
35
  import * as os from 'os';
23
36
  import { execFile } from 'child_process';
@@ -32,7 +45,20 @@ export const PROVENANCE_ENV_KEYS = [
32
45
  'TMUX_PANE',
33
46
  'TERM_PROGRAM',
34
47
  'STY',
48
+ 'ITERM_SESSION_ID',
35
49
  ];
50
+ /**
51
+ * Extract the iTerm2 session UUID from `$ITERM_SESSION_ID`, whose value is
52
+ * `w<window>t<tab>p<pane>:<UUID>` — the `:`-suffix is exactly what iTerm2's
53
+ * `id of session` returns, so it's the address for `tell session id …`. Returns
54
+ * undefined for an empty/absent value; tolerates a bare value with no `:`.
55
+ */
56
+ export function parseItermSession(value) {
57
+ if (!value)
58
+ return undefined;
59
+ const uuid = value.includes(':') ? value.slice(value.lastIndexOf(':') + 1) : value;
60
+ return uuid.trim() || undefined;
61
+ }
36
62
  /** Parse the NUL-separated body of /proc/<pid>/environ into a plain object. */
37
63
  export function parseProcEnviron(buf) {
38
64
  const env = {};
@@ -99,13 +125,18 @@ export function deriveProvenance(env, hostname) {
99
125
  else if (env.STY) {
100
126
  mux = { kind: 'screen', session: env.STY };
101
127
  }
102
- // A tmux pane is the one rail that lets an external process type into an
103
- // already-running interactive agent (`tmux send-keys -t <pane>`). Everything
104
- // else (inherited stdin from `agents run`, ignored stdin from teams) is not
105
- // externally addressable without relaunching under a pty/tmux rail.
128
+ // Env-addressable rails, in precedence order. A tmux pane wins because it can
129
+ // live inside any host app (`tmux send-keys -t <pane>` reaches it regardless of
130
+ // whether iTerm/Ghostty/VS Code is above it). Absent tmux, an iTerm2 split is
131
+ // addressable by its session UUID. Everything else (inherited stdin from
132
+ // `agents run`, ignored stdin from teams, a plain VS Code integrated terminal)
133
+ // is not env-addressable — the resolver may still find an IDE rail off disk.
134
+ const itermSession = parseItermSession(env.ITERM_SESSION_ID);
106
135
  const reply = mux?.kind === 'tmux' && mux.pane
107
136
  ? { rail: 'tmux', target: mux.pane, socket: mux.socket }
108
- : null;
137
+ : itermSession
138
+ ? { rail: 'iterm', session: itermSession }
139
+ : null;
109
140
  return {
110
141
  host: hostname,
111
142
  transport: ssh ? 'ssh' : 'local',
@@ -19,6 +19,8 @@ import chalk from 'chalk';
19
19
  import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
20
20
  import { sshTargetFor } from '../devices/connect.js';
21
21
  import { loadDevices } from '../devices/registry.js';
22
+ import { remoteShellFor, buildWindowsAgentsCommand } from '../hosts/remote-cmd.js';
23
+ import { resolveRemoteOsSync } from '../hosts/remote-os.js';
22
24
  import { machineId, normalizeHost } from './sync/config.js';
23
25
  /** Per-host SSH budget. Slightly above SSH_OPTS' ConnectTimeout=10 so a
24
26
  * reachable-but-slow remote still answers before we give up. */
@@ -29,8 +31,16 @@ const REMOTE_TIMEOUT_MS = 12_000;
29
31
  * on an unknown option. A remote new enough to fan out reads it and stays local.
30
32
  */
31
33
  export const NO_FANOUT_ENV = 'AGENTS_SESSIONS_LOCAL';
32
- /** The command run on each peer: answer for itself, as JSON, without recursing. */
33
- function remoteActiveCommand() {
34
+ /** The command run on each peer: answer for itself, as JSON, without recursing.
35
+ * A Windows peer gets a PowerShell invocation (ssh lands in cmd.exe/PowerShell
36
+ * there, where `bash -lc` is not a command); every other OS keeps `bash -lc`. */
37
+ function remoteActiveCommand(os) {
38
+ if (remoteShellFor(os) === 'powershell') {
39
+ return buildWindowsAgentsCommand({
40
+ args: ['sessions', '--active', '--json'],
41
+ env: { [NO_FANOUT_ENV]: '1' },
42
+ });
43
+ }
34
44
  const inner = `${NO_FANOUT_ENV}=1 agents sessions --active --json`;
35
45
  return `bash -lc ${shellQuote(inner)}`;
36
46
  }
@@ -80,8 +90,8 @@ function sshCapture(target, remoteCmd, timeoutMs) {
80
90
  child.on('close', (code) => done(code));
81
91
  });
82
92
  }
83
- async function fetchByTarget(target, machine, display) {
84
- const { code, stdout } = await sshCapture(target, remoteActiveCommand(), REMOTE_TIMEOUT_MS);
93
+ async function fetchByTarget(target, machine, display, os) {
94
+ const { code, stdout } = await sshCapture(target, remoteActiveCommand(os), REMOTE_TIMEOUT_MS);
85
95
  if (code !== 0) {
86
96
  process.stderr.write(chalk.gray(` ${display}: unreachable or no agents CLI — skipped\n`));
87
97
  return [];
@@ -107,7 +117,9 @@ export async function gatherRemoteActive(hosts) {
107
117
  continue;
108
118
  }
109
119
  const bareHost = h.split('@').pop() || h;
110
- targets.push({ target: h, machine: normalizeHost(bareHost), name: h });
120
+ // Resolve the OS by the name the user passed (a bare alias like `win-mini`
121
+ // matches a device-registry entry; a raw `user@host` falls back to POSIX).
122
+ targets.push({ target: h, machine: normalizeHost(bareHost), name: h, os: resolveRemoteOsSync(h) });
111
123
  }
112
124
  }
113
125
  else {
@@ -129,13 +141,13 @@ export async function gatherRemoteActive(hosts) {
129
141
  if (d.platform !== 'windows' && d.platform !== 'linux' && d.platform !== 'macos')
130
142
  continue;
131
143
  try {
132
- targets.push({ target: sshTargetFor(d), machine: normalizeHost(d.name), name: d.name });
144
+ targets.push({ target: sshTargetFor(d), machine: normalizeHost(d.name), name: d.name, os: d.platform });
133
145
  }
134
146
  catch {
135
147
  // No address on the profile — nothing to dial; skip silently.
136
148
  }
137
149
  }
138
150
  }
139
- const results = await Promise.all(targets.map((t) => fetchByTarget(t.target, t.machine, t.name)));
151
+ const results = await Promise.all(targets.map((t) => fetchByTarget(t.target, t.machine, t.name, t.os)));
140
152
  return { sessions: results.flat(), deviceCount: targets.length };
141
153
  }
@@ -0,0 +1,51 @@
1
+ import type { SessionMeta } from './types.js';
2
+ /**
3
+ * The command run on each peer: answer for itself, as JSON, without recursing.
4
+ * `forwardedArgs` carry the caller's own query/filters (already including the
5
+ * leading `sessions` and a `--json`) so each peer returns a comparable slice.
6
+ * A Windows peer gets a PowerShell invocation (ssh lands in cmd.exe/PowerShell
7
+ * there, where `bash -lc` is not a command); every other OS keeps `bash -lc`.
8
+ */
9
+ export declare function remoteListCommand(forwardedArgs: string[], os?: string): string;
10
+ /**
11
+ * Parse a peer's `sessions --json` stdout into `SessionMeta[]`, tagging each
12
+ * with `machine`. Defensive against version skew / partial output: non-JSON or
13
+ * a non-array yields `[]`, and non-object entries are dropped rather than
14
+ * throwing. The `machine` we dialed always wins over any value the peer set on
15
+ * its own rows, so grouping keys off the computer we asked. Exported for unit
16
+ * testing without a live tailnet.
17
+ */
18
+ export declare function parseRemoteList(stdout: string, machine: string): SessionMeta[];
19
+ export interface RemoteListResult {
20
+ sessions: SessionMeta[];
21
+ /** How many peer machines we attempted to reach (drives the empty-fleet tip). */
22
+ deviceCount: number;
23
+ }
24
+ /**
25
+ * Gather listing sessions from other machines. With an explicit `hosts` list
26
+ * (from `--host`), fan out to exactly those. Otherwise sweep the registered,
27
+ * online devices from `ag devices`, excluding this machine and any without an
28
+ * address. `forwardedArgs` are the caller's own sessions args (query + filters,
29
+ * already `--json`) so every peer returns the same slice this machine asked for.
30
+ */
31
+ export declare function gatherRemoteList(forwardedArgs: string[], hosts?: string[]): Promise<RemoteListResult>;
32
+ /** Resolve a peer's SSH target (and OS) from the device registry by its
33
+ * normalized machine id — the same id the fan-out tags rows with. Returns
34
+ * undefined when no registered device with an address matches. */
35
+ export declare function resolvePeerTarget(machine: string): Promise<{
36
+ target: string;
37
+ os?: string;
38
+ } | undefined>;
39
+ /**
40
+ * Run `agents <args>` ON a peer over SSH, attached to this terminal (inherited
41
+ * stdio). `args` is the full arg vector after the binary — callers pass e.g.
42
+ * `['sessions', id, '--markdown']` or `['sessions', 'resume', id]`. Used when a
43
+ * picked session lives on another machine: its transcript and agent binary are
44
+ * there, so both reading (no TTY) and resuming (TTY) must execute on the peer —
45
+ * not via a local `--host` hop, which would discover locally and dead-end for a
46
+ * session that exists only on the peer. Resolves 'no-target' when the machine
47
+ * isn't a dialable registered device; the caller surfaces a clear message.
48
+ */
49
+ export declare function runOnPeer(args: string[], machine: string, opts?: {
50
+ tty?: boolean;
51
+ }): Promise<'ok' | 'no-target'>;