@phnx-labs/agents-cli 1.22.26 → 1.22.28

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 (183) hide show
  1. package/CHANGELOG.md +293 -0
  2. package/README.md +80 -27
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/bench.d.ts +2 -0
  5. package/dist/commands/bench.js +101 -0
  6. package/dist/commands/doctor.js +24 -10
  7. package/dist/commands/exec.js +71 -26
  8. package/dist/commands/hosts.js +4 -3
  9. package/dist/commands/insights.js +39 -5
  10. package/dist/commands/output.js +100 -25
  11. package/dist/commands/perf.d.ts +10 -0
  12. package/dist/commands/perf.js +14 -6
  13. package/dist/commands/projects.js +24 -10
  14. package/dist/commands/resume.d.ts +11 -0
  15. package/dist/commands/resume.js +51 -0
  16. package/dist/commands/routines.js +31 -4
  17. package/dist/commands/run-account-picker.d.ts +37 -0
  18. package/dist/commands/run-account-picker.js +101 -17
  19. package/dist/commands/secrets.js +86 -2
  20. package/dist/commands/sessions-picker.js +12 -7
  21. package/dist/commands/sessions.d.ts +73 -7
  22. package/dist/commands/sessions.js +391 -49
  23. package/dist/commands/ssh.js +184 -11
  24. package/dist/commands/teams.js +22 -11
  25. package/dist/index.js +16 -20
  26. package/dist/lib/activity.js +45 -99
  27. package/dist/lib/auth-health.d.ts +47 -3
  28. package/dist/lib/auth-health.js +74 -15
  29. package/dist/lib/bench/index.d.ts +4 -0
  30. package/dist/lib/bench/index.js +4 -0
  31. package/dist/lib/bench/runner.d.ts +16 -0
  32. package/dist/lib/bench/runner.js +111 -0
  33. package/dist/lib/bench/schema.d.ts +5 -0
  34. package/dist/lib/bench/schema.js +91 -0
  35. package/dist/lib/bench/storage.d.ts +5 -0
  36. package/dist/lib/bench/storage.js +32 -0
  37. package/dist/lib/bench/types.d.ts +40 -0
  38. package/dist/lib/bench/types.js +1 -0
  39. package/dist/lib/claude-account-token.d.ts +2 -0
  40. package/dist/lib/claude-account-token.js +56 -3
  41. package/dist/lib/cloud/host.js +1 -0
  42. package/dist/lib/crabbox/cli.d.ts +2 -0
  43. package/dist/lib/crabbox/cli.js +2 -0
  44. package/dist/lib/crabbox/lease.js +7 -1
  45. package/dist/lib/daemon.js +20 -0
  46. package/dist/lib/devices/connect.d.ts +2 -0
  47. package/dist/lib/devices/connect.js +7 -0
  48. package/dist/lib/devices/doctor-findings.d.ts +4 -1
  49. package/dist/lib/devices/doctor-findings.js +15 -1
  50. package/dist/lib/devices/harness-inventory.d.ts +97 -0
  51. package/dist/lib/devices/harness-inventory.js +0 -0
  52. package/dist/lib/devices/registry.d.ts +2 -0
  53. package/dist/lib/devices/resolve-target.d.ts +1 -0
  54. package/dist/lib/devices/resolve-target.js +9 -2
  55. package/dist/lib/devices/ssh-config.js +3 -0
  56. package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
  57. package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
  58. package/dist/lib/events.d.ts +1 -1
  59. package/dist/lib/events.js +2 -2
  60. package/dist/lib/exec.js +18 -8
  61. package/dist/lib/fleet/apply.js +9 -7
  62. package/dist/lib/fleet/remote-login.d.ts +4 -3
  63. package/dist/lib/fleet/remote-login.js +11 -9
  64. package/dist/lib/gemini-settings.d.ts +0 -1
  65. package/dist/lib/gemini-settings.js +12 -7
  66. package/dist/lib/git.d.ts +9 -3
  67. package/dist/lib/git.js +39 -22
  68. package/dist/lib/hooks/profile.d.ts +12 -1
  69. package/dist/lib/hooks/profile.js +6 -1
  70. package/dist/lib/hooks.d.ts +8 -5
  71. package/dist/lib/hooks.js +14 -7
  72. package/dist/lib/hosts/dispatch.d.ts +10 -0
  73. package/dist/lib/hosts/dispatch.js +85 -25
  74. package/dist/lib/hosts/logs.js +11 -1
  75. package/dist/lib/hosts/passthrough.d.ts +27 -0
  76. package/dist/lib/hosts/passthrough.js +19 -10
  77. package/dist/lib/hosts/progress.d.ts +12 -1
  78. package/dist/lib/hosts/progress.js +41 -8
  79. package/dist/lib/hosts/providers/devices.js +1 -0
  80. package/dist/lib/hosts/ready.d.ts +45 -4
  81. package/dist/lib/hosts/ready.js +124 -20
  82. package/dist/lib/hosts/reconcile.d.ts +2 -1
  83. package/dist/lib/hosts/reconcile.js +24 -7
  84. package/dist/lib/hosts/reconnect.d.ts +45 -12
  85. package/dist/lib/hosts/reconnect.js +94 -36
  86. package/dist/lib/hosts/registry.d.ts +2 -2
  87. package/dist/lib/hosts/registry.js +4 -5
  88. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  89. package/dist/lib/hosts/remote-cmd.js +29 -0
  90. package/dist/lib/hosts/tasks.d.ts +3 -0
  91. package/dist/lib/hosts/types.d.ts +1 -0
  92. package/dist/lib/hosts/types.js +3 -0
  93. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  94. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  95. package/dist/lib/menubar/install-menubar.d.ts +9 -6
  96. package/dist/lib/menubar/install-menubar.js +20 -9
  97. package/dist/lib/menubar/snapshot.js +15 -2
  98. package/dist/lib/models.d.ts +42 -6
  99. package/dist/lib/models.js +55 -153
  100. package/dist/lib/perf/db.js +16 -3
  101. package/dist/lib/perf/types.d.ts +12 -1
  102. package/dist/lib/pricing/cost.d.ts +9 -0
  103. package/dist/lib/pricing/cost.js +24 -0
  104. package/dist/lib/pricing/index.d.ts +1 -1
  105. package/dist/lib/pricing/index.js +1 -1
  106. package/dist/lib/project-probe.d.ts +22 -3
  107. package/dist/lib/project-probe.js +105 -17
  108. package/dist/lib/project-status.d.ts +9 -0
  109. package/dist/lib/project-status.js +15 -0
  110. package/dist/lib/redact.js +8 -3
  111. package/dist/lib/refresh.js +22 -11
  112. package/dist/lib/remote-agents-json.d.ts +32 -0
  113. package/dist/lib/remote-agents-json.js +47 -16
  114. package/dist/lib/resource-profiles.js +1 -2
  115. package/dist/lib/rotate.d.ts +22 -0
  116. package/dist/lib/rotate.js +26 -0
  117. package/dist/lib/routine-notify-owner.d.ts +102 -0
  118. package/dist/lib/routine-notify-owner.js +232 -0
  119. package/dist/lib/routines.d.ts +10 -0
  120. package/dist/lib/runner.d.ts +9 -1
  121. package/dist/lib/runner.js +182 -17
  122. package/dist/lib/sandbox.d.ts +0 -2
  123. package/dist/lib/sandbox.js +2 -19
  124. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  125. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  126. package/dist/lib/secrets/agent.d.ts +19 -3
  127. package/dist/lib/secrets/agent.js +61 -22
  128. package/dist/lib/secrets/audit.d.ts +1 -1
  129. package/dist/lib/secrets/audit.js +2 -0
  130. package/dist/lib/secrets/bundles.d.ts +3 -3
  131. package/dist/lib/secrets/bundles.js +15 -5
  132. package/dist/lib/secrets/filestore.d.ts +2 -0
  133. package/dist/lib/secrets/filestore.js +4 -0
  134. package/dist/lib/secrets/lease.d.ts +25 -0
  135. package/dist/lib/secrets/lease.js +44 -0
  136. package/dist/lib/secrets/session-store.d.ts +7 -0
  137. package/dist/lib/secrets/session-store.js +21 -0
  138. package/dist/lib/self-update.d.ts +20 -5
  139. package/dist/lib/self-update.js +93 -16
  140. package/dist/lib/session/active.d.ts +49 -0
  141. package/dist/lib/session/active.js +175 -11
  142. package/dist/lib/session/bash-command.d.ts +16 -0
  143. package/dist/lib/session/bash-command.js +65 -0
  144. package/dist/lib/session/db.d.ts +23 -2
  145. package/dist/lib/session/db.js +86 -20
  146. package/dist/lib/session/discover.d.ts +21 -1
  147. package/dist/lib/session/discover.js +124 -32
  148. package/dist/lib/session/insights.d.ts +19 -0
  149. package/dist/lib/session/insights.js +78 -10
  150. package/dist/lib/session/recovery.d.ts +17 -1
  151. package/dist/lib/session/recovery.js +111 -4
  152. package/dist/lib/session/remote-bundle.js +1 -1
  153. package/dist/lib/session/remote-list.d.ts +10 -9
  154. package/dist/lib/session/remote-list.js +25 -23
  155. package/dist/lib/session/resume-owner.d.ts +55 -0
  156. package/dist/lib/session/resume-owner.js +69 -0
  157. package/dist/lib/session/team-filter.d.ts +65 -0
  158. package/dist/lib/session/team-filter.js +98 -3
  159. package/dist/lib/session/types.d.ts +33 -0
  160. package/dist/lib/smart-launch.d.ts +31 -5
  161. package/dist/lib/smart-launch.js +43 -7
  162. package/dist/lib/ssh-exec.d.ts +27 -0
  163. package/dist/lib/ssh-exec.js +34 -1
  164. package/dist/lib/ssh-tunnel.d.ts +3 -2
  165. package/dist/lib/ssh-tunnel.js +25 -16
  166. package/dist/lib/staleness/detectors/skills.d.ts +2 -0
  167. package/dist/lib/staleness/detectors/skills.js +17 -2
  168. package/dist/lib/staleness/index.d.ts +10 -1
  169. package/dist/lib/staleness/index.js +28 -3
  170. package/dist/lib/startup/command-registry.d.ts +1 -0
  171. package/dist/lib/startup/command-registry.js +2 -0
  172. package/dist/lib/teams/agents.d.ts +1 -0
  173. package/dist/lib/teams/agents.js +38 -11
  174. package/dist/lib/teams/placement-probe.d.ts +1 -1
  175. package/dist/lib/teams/placement-probe.js +24 -24
  176. package/dist/lib/teams/remoteWorktree.d.ts +11 -7
  177. package/dist/lib/teams/remoteWorktree.js +29 -27
  178. package/dist/lib/tmux/session.d.ts +8 -0
  179. package/dist/lib/tmux/session.js +22 -0
  180. package/dist/lib/types.d.ts +2 -0
  181. package/dist/lib/versions.d.ts +1 -0
  182. package/dist/lib/versions.js +40 -22
  183. package/package.json +3 -1
@@ -327,9 +327,89 @@ function packageRootForEntry(real) {
327
327
  }
328
328
  return null;
329
329
  }
330
+ export function buildMultiInstallInventory(runningRoot, runningVersion, installs) {
331
+ const byRoot = new Map();
332
+ byRoot.set(runningRoot, { packageRoot: runningRoot, version: runningVersion, note: 'running' });
333
+ for (const install of installs) {
334
+ const notes = [install.packageRoot === runningRoot
335
+ ? 'running'
336
+ : install.binPath
337
+ ? `agents on PATH: ${install.binPath}`
338
+ : 'discovered install'];
339
+ if (!install.atomicHelperInstall)
340
+ notes.push('unsafe legacy helper installer — remove this copy');
341
+ byRoot.set(install.packageRoot, {
342
+ packageRoot: install.packageRoot,
343
+ version: install.version,
344
+ note: notes.join('; '),
345
+ });
346
+ }
347
+ return [...byRoot.values()];
348
+ }
349
+ function childDirectories(parent) {
350
+ try {
351
+ return fs.readdirSync(parent, { withFileTypes: true })
352
+ .filter((entry) => entry.isDirectory())
353
+ .map((entry) => path.join(parent, entry.name));
354
+ }
355
+ catch {
356
+ return [];
357
+ }
358
+ }
359
+ function knownPackageRoots(opts) {
360
+ const homeDir = opts.homeDir ?? os.homedir();
361
+ const fnmRoots = opts.fnmDir !== undefined
362
+ ? [opts.fnmDir]
363
+ : [process.env.FNM_DIR, path.join(homeDir, '.local', 'share', 'fnm')]
364
+ .filter((value) => Boolean(value));
365
+ const roots = [];
366
+ const packageTail = path.join('lib', 'node_modules', ...NPM_PACKAGE_NAME.split('/'));
367
+ for (const nodeDir of childDirectories(path.join(homeDir, '.nvm', 'versions', 'node'))) {
368
+ roots.push(path.join(nodeDir, packageTail));
369
+ }
370
+ for (const fnmRoot of fnmRoots) {
371
+ for (const versionDir of childDirectories(path.join(fnmRoot, 'node-versions'))) {
372
+ roots.push(path.join(versionDir, 'installation', packageTail));
373
+ }
374
+ }
375
+ roots.push(path.join(homeDir, '.volta', 'tools', 'image', 'packages', ...NPM_PACKAGE_NAME.split('/'), packageTail), path.join(homeDir, '.local', packageTail), path.join(homeDir, '.bun', 'install', 'global', 'node_modules', ...NPM_PACKAGE_NAME.split('/')));
376
+ const npmCacheDir = opts.npmCacheDir ?? process.env.npm_config_cache ?? path.join(homeDir, '.npm');
377
+ for (const npxRunDir of childDirectories(path.join(npmCacheDir, '_npx'))) {
378
+ roots.push(path.join(npxRunDir, 'node_modules', ...NPM_PACKAGE_NAME.split('/')));
379
+ }
380
+ const globalNodeModulesDirs = opts.globalNodeModulesDirs ?? [
381
+ '/opt/homebrew/lib/node_modules',
382
+ '/usr/local/lib/node_modules',
383
+ '/usr/lib/node_modules',
384
+ ];
385
+ for (const nodeModulesDir of globalNodeModulesDirs) {
386
+ roots.push(path.join(nodeModulesDir, ...NPM_PACKAGE_NAME.split('/')));
387
+ }
388
+ return roots;
389
+ }
390
+ function readAgentsCliInstall(packageRoot, binPath) {
391
+ let canonicalRoot;
392
+ let pkg;
393
+ try {
394
+ canonicalRoot = fs.realpathSync(packageRoot);
395
+ pkg = JSON.parse(fs.readFileSync(path.join(canonicalRoot, 'package.json'), 'utf-8'));
396
+ }
397
+ catch {
398
+ return null;
399
+ }
400
+ if (pkg.name !== NPM_PACKAGE_NAME || typeof pkg.version !== 'string')
401
+ return null;
402
+ return {
403
+ ...(binPath ? { binPath } : {}),
404
+ packageRoot: canonicalRoot,
405
+ version: pkg.version,
406
+ atomicHelperInstall: fs.existsSync(path.join(canonicalRoot, 'dist', 'lib', 'app-bundle-install.js')),
407
+ };
408
+ }
330
409
  /**
331
- * Scan PATH for `agents` entrypoints and resolve each to the agents-cli
332
- * package root it executes. More than one distinct root means upgrades,
410
+ * Resolve every `agents` entrypoint on PATH, then inspect the bounded global
411
+ * install layouts used by NVM, fnm, Volta, Bun, npm, and npx. More than one
412
+ * distinct package root means upgrades,
333
413
  * shims, and the command the user types can act on different copies — the
334
414
  * divergence behind silently-failing self-updates.
335
415
  *
@@ -343,11 +423,17 @@ function packageRootForEntry(real) {
343
423
  * @phnx-labs/agents-cli is some other tool and is skipped.
344
424
  * POSIX-only: Windows npm bins are .cmd wrappers, not symlinks.
345
425
  */
346
- export function findAgentsCliInstalls(pathEnv) {
426
+ export function findAgentsCliInstalls(pathEnv, opts = {}) {
347
427
  if (process.platform === 'win32')
348
428
  return [];
349
429
  const installs = [];
350
430
  const seenRoots = new Set();
431
+ const addInstall = (install) => {
432
+ if (!install || seenRoots.has(install.packageRoot))
433
+ return;
434
+ seenRoots.add(install.packageRoot);
435
+ installs.push(install);
436
+ };
351
437
  for (const dir of pathEnv.split(path.delimiter).filter(Boolean)) {
352
438
  const candidate = path.join(dir, 'agents');
353
439
  let real;
@@ -360,19 +446,10 @@ export function findAgentsCliInstalls(pathEnv) {
360
446
  const packageRoot = packageRootForEntry(real);
361
447
  if (!packageRoot)
362
448
  continue;
363
- if (seenRoots.has(packageRoot))
364
- continue;
365
- let pkg;
366
- try {
367
- pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf-8'));
368
- }
369
- catch {
370
- continue;
371
- }
372
- if (pkg.name !== NPM_PACKAGE_NAME || typeof pkg.version !== 'string')
373
- continue;
374
- seenRoots.add(packageRoot);
375
- installs.push({ binPath: candidate, packageRoot, version: pkg.version });
449
+ addInstall(readAgentsCliInstall(packageRoot, candidate));
450
+ }
451
+ for (const packageRoot of knownPackageRoots(opts)) {
452
+ addInstall(readAgentsCliInstall(packageRoot));
376
453
  }
377
454
  return installs;
378
455
  }
@@ -23,6 +23,42 @@ export declare function resolveOwner(pidActor: string | null | undefined, sessio
23
23
  * cwds are just gathered at a bounded spawn rate instead of a single burst.
24
24
  */
25
25
  export declare const LSOF_CONCURRENCY = 4;
26
+ /**
27
+ * Process-local process-table memo (#2047). One `getActiveSessions` scan can
28
+ * call `ps -A` from the terminal path, the headless path, and host ancestry —
29
+ * without this, each is a full process-table snapshot. 5s is well under the
30
+ * ~10–30s menu-bar / `--active` poll so a quiet re-poll usually reuses the
31
+ * snapshot; a brand-new agent pid still appears on the next full refresh.
32
+ */
33
+ export declare const PROCESS_TABLE_FRESH_MS = 5000;
34
+ /**
35
+ * How long {@link listUnattributedActive} may reuse its last full `ps`+`lsof`
36
+ * result (#2047). Between full rescans the previous rows are re-emitted after
37
+ * dropping dead PIDs and PIDs that are now attributed. A *shrinking* attributed
38
+ * set forces a rescan so a process that just left teams/terminals can reappear
39
+ * as headless instead of vanishing until the next cold poll.
40
+ */
41
+ export declare const UNATTRIBUTED_RESCAN_MS = 15000;
42
+ /** Test seam: override the clock used by process-table / unattributed memos. */
43
+ export declare function setActiveScanClockForTest(fn?: () => number): void;
44
+ /** Test seam: drop the process-table + unattributed memos and their counters. */
45
+ export declare function clearActiveScanCachesForTest(): void;
46
+ /** Test seam: how many times the live `ps` / CIM path actually ran. */
47
+ export declare function processTableLiveReadCountForTest(): number;
48
+ /** Test seam: how many full unattributed (ps + lsof) rescans ran. */
49
+ export declare function unattributedFullRescanCountForTest(): number;
50
+ /**
51
+ * True when any pid in `prev` is absent from `next` — the attributed set
52
+ * shrank, so a process may need to reappear as unattributed. Pure for tests.
53
+ */
54
+ export declare function attributedSetLostPids(prev: Set<number>, next: Set<number>): boolean;
55
+ /**
56
+ * Drop rows whose pid is now attributed or no longer alive. Pure over the
57
+ * inputs so the unattributed TTL reuse path is unit-testable without a live
58
+ * process table. `alive` receives the row's `startedAtMs` so callers can apply
59
+ * the same pid-reuse guard {@link isPidAlive} uses elsewhere.
60
+ */
61
+ export declare function filterCachedUnattributed(sessions: ActiveSession[], attributed: Set<number>, alive: (pid: number, startedAtMs?: number) => boolean): ActiveSession[];
26
62
  export type ActiveContext = 'terminal' | 'teams' | 'cloud' | 'headless';
27
63
  /**
28
64
  * Every status here is COMPUTED by the framework from observable signals — PID
@@ -348,6 +384,8 @@ export declare function resolveNamesToSessionIds(sessionNames: string[], deps: {
348
384
  * the start time can't be read, we keep the existence answer.
349
385
  */
350
386
  export declare function isPidAlive(pid: number, startedAtMs?: number): boolean;
387
+ /** Test seam: drop the Claude session-file path memo. */
388
+ export declare function clearClaudeSessionFileCacheForTest(): void;
351
389
  /**
352
390
  * Resolve a Claude transcript for `cwd` across the given project roots, newest
353
391
  * mtime winning when the same id/cwd resolves in more than one version home
@@ -431,6 +469,8 @@ interface LiveSignals {
431
469
  /** Rolling output-token throughput (tokens/sec). Undefined when no usage in the tail. */
432
470
  tokPerSec?: number;
433
471
  }
472
+ /** Test seam: drop the in-process live-signals memo. */
473
+ export declare function clearLiveSignalsCacheForTest(): void;
434
474
  /**
435
475
  * Derive the inferred state (working / waiting / idle + preview/badges) and, for
436
476
  * the two harnesses whose raw lines carry it, the output-token throughput.
@@ -444,6 +484,9 @@ interface LiveSignals {
444
484
  * `unknown` it used to fall through to. An opaque/untracked kind or an
445
485
  * unreadable/empty transcript yields an empty signal set, and the caller's
446
486
  * {@link resolveFallbackStatus} reports the honest live floor (`running`).
487
+ *
488
+ * Unchanged-mtime hits reuse the previous result (see {@link liveSignalsCache})
489
+ * so a 30s active-session poll does not re-tail every quiet transcript.
447
490
  */
448
491
  export declare function computeLiveSignals(kind: string, sessionFile: string | undefined, cwd: string | undefined, pidAlive: boolean): LiveSignals;
449
492
  /**
@@ -523,6 +566,12 @@ export declare function foldSubordinateAgents(candidates: AgentCandidate[], ppid
523
566
  * Classified by walking the ppid chain: any recognised UI ancestor (IDE
524
567
  * helper, terminal-app, or multiplexer) means `terminal`; nothing of the
525
568
  * sort means `headless` (daemon, launchd-spawned, orphan).
569
+ *
570
+ * Full `ps`+`lsof` rescans are throttled to {@link UNATTRIBUTED_RESCAN_MS}
571
+ * (#2047). Between rescans the previous rows are re-emitted after dropping
572
+ * dead PIDs and PIDs that are now attributed. A shrinking attributed set
573
+ * forces a rescan so a process that just left teams/terminals can reappear
574
+ * as headless.
526
575
  */
527
576
  export declare function listUnattributedActive(attributed: Set<number>): Promise<ActiveSession[]>;
528
577
  /** One tmux pane's resolved agent identity for the authoritative source. */
@@ -71,6 +71,74 @@ const LSOF_STAGGER_MS = 10;
71
71
  */
72
72
  const LSOF_TIMEOUT_MS = 5_000;
73
73
  const PS_SNAPSHOT_TIMEOUT_MS = 10_000;
74
+ /**
75
+ * Process-local process-table memo (#2047). One `getActiveSessions` scan can
76
+ * call `ps -A` from the terminal path, the headless path, and host ancestry —
77
+ * without this, each is a full process-table snapshot. 5s is well under the
78
+ * ~10–30s menu-bar / `--active` poll so a quiet re-poll usually reuses the
79
+ * snapshot; a brand-new agent pid still appears on the next full refresh.
80
+ */
81
+ export const PROCESS_TABLE_FRESH_MS = 5_000;
82
+ /**
83
+ * How long {@link listUnattributedActive} may reuse its last full `ps`+`lsof`
84
+ * result (#2047). Between full rescans the previous rows are re-emitted after
85
+ * dropping dead PIDs and PIDs that are now attributed. A *shrinking* attributed
86
+ * set forces a rescan so a process that just left teams/terminals can reappear
87
+ * as headless instead of vanishing until the next cold poll.
88
+ */
89
+ export const UNATTRIBUTED_RESCAN_MS = 15_000;
90
+ /** Injectable clock for the active-scan memos (tests only). */
91
+ let activeScanNow = () => Date.now();
92
+ /** Test seam: override the clock used by process-table / unattributed memos. */
93
+ export function setActiveScanClockForTest(fn) {
94
+ activeScanNow = fn ?? (() => Date.now());
95
+ }
96
+ let processTableCache;
97
+ let processTableLiveReads = 0;
98
+ let unattributedCache;
99
+ let unattributedFullRescans = 0;
100
+ /** Test seam: drop the process-table + unattributed memos and their counters. */
101
+ export function clearActiveScanCachesForTest() {
102
+ processTableCache = undefined;
103
+ processTableLiveReads = 0;
104
+ unattributedCache = undefined;
105
+ unattributedFullRescans = 0;
106
+ activeScanNow = () => Date.now();
107
+ }
108
+ /** Test seam: how many times the live `ps` / CIM path actually ran. */
109
+ export function processTableLiveReadCountForTest() {
110
+ return processTableLiveReads;
111
+ }
112
+ /** Test seam: how many full unattributed (ps + lsof) rescans ran. */
113
+ export function unattributedFullRescanCountForTest() {
114
+ return unattributedFullRescans;
115
+ }
116
+ /**
117
+ * True when any pid in `prev` is absent from `next` — the attributed set
118
+ * shrank, so a process may need to reappear as unattributed. Pure for tests.
119
+ */
120
+ export function attributedSetLostPids(prev, next) {
121
+ for (const p of prev) {
122
+ if (!next.has(p))
123
+ return true;
124
+ }
125
+ return false;
126
+ }
127
+ /**
128
+ * Drop rows whose pid is now attributed or no longer alive. Pure over the
129
+ * inputs so the unattributed TTL reuse path is unit-testable without a live
130
+ * process table. `alive` receives the row's `startedAtMs` so callers can apply
131
+ * the same pid-reuse guard {@link isPidAlive} uses elsewhere.
132
+ */
133
+ export function filterCachedUnattributed(sessions, attributed, alive) {
134
+ return sessions.filter((s) => {
135
+ if (s.pid == null)
136
+ return false;
137
+ if (attributed.has(s.pid))
138
+ return false;
139
+ return alive(s.pid, s.startedAtMs);
140
+ });
141
+ }
74
142
  export function activeStatusFromCloudStatus(status) {
75
143
  switch (status) {
76
144
  case 'running':
@@ -373,6 +441,19 @@ function readLiveTerminals() {
373
441
  function claudeProjectDirName(cwd) {
374
442
  return cwd.replace(/[/.]/g, '-');
375
443
  }
444
+ /**
445
+ * Process-local memo for Claude transcript path resolution. Each active-session
446
+ * poll re-walks every Claude version-home `projects/` tree for every live pid
447
+ * (#2047). A resolved path is sticky while the file still exists; a miss or a
448
+ * vanished file re-walks. Cap size so a long-lived process cannot retain every
449
+ * cwd it has ever seen.
450
+ */
451
+ const CLAUDE_SESSION_FILE_CACHE_MAX = 256;
452
+ const claudeSessionFileCache = new Map();
453
+ /** Test seam: drop the Claude session-file path memo. */
454
+ export function clearClaudeSessionFileCacheForTest() {
455
+ claudeSessionFileCache.clear();
456
+ }
376
457
  /**
377
458
  * Locate the active Claude session file for a process. If we know the session
378
459
  * UUID (from terminal env or team parent), prefer the exact match. Otherwise
@@ -390,6 +471,28 @@ function claudeProjectDirName(cwd) {
390
471
  * the rest of the CLI uses, so this stays in lockstep with discovery.
391
472
  */
392
473
  function findClaudeSessionFile(cwd, sessionId) {
474
+ // Only memoize when the exact session UUID is known. Without an id the
475
+ // resolver picks newest-by-mtime under the project dir; caching that path
476
+ // would stick to an old transcript after a new session starts in the same cwd.
477
+ if (sessionId) {
478
+ const cacheKey = `${cwd}\0${sessionId}`;
479
+ const hit = claudeSessionFileCache.get(cacheKey);
480
+ if (hit !== undefined) {
481
+ try {
482
+ if (fs.existsSync(hit))
483
+ return hit;
484
+ }
485
+ catch { /* re-resolve */ }
486
+ claudeSessionFileCache.delete(cacheKey);
487
+ }
488
+ const resolved = pickClaudeSessionFileAcrossRoots(getAgentSessionDirs('claude', 'projects'), cwd, sessionId);
489
+ if (resolved) {
490
+ if (claudeSessionFileCache.size >= CLAUDE_SESSION_FILE_CACHE_MAX)
491
+ claudeSessionFileCache.clear();
492
+ claudeSessionFileCache.set(cacheKey, resolved);
493
+ }
494
+ return resolved;
495
+ }
393
496
  return pickClaudeSessionFileAcrossRoots(getAgentSessionDirs('claude', 'projects'), cwd, sessionId);
394
497
  }
395
498
  /**
@@ -562,6 +665,19 @@ function parseTailEventsForKind(agent, sessionFile) {
562
665
  }
563
666
  return events.length > LIVE_STATE_MAX_EVENTS ? events.slice(-LIVE_STATE_MAX_EVENTS) : events;
564
667
  }
668
+ /**
669
+ * Process-local memo for {@link computeLiveSignals}. The menu-bar / `--active`
670
+ * poll re-reads every live session on a ~30s tick (#2047). When a transcript's
671
+ * mtime (and pidAlive / kind / cwd) are unchanged, re-parsing the tail yields
672
+ * the same signals — skip the parse. Bound the map so a long-lived daemon that
673
+ * sees thousands of sessions over its life cannot retain them forever.
674
+ */
675
+ const LIVE_SIGNALS_CACHE_MAX = 512;
676
+ const liveSignalsCache = new Map();
677
+ /** Test seam: drop the in-process live-signals memo. */
678
+ export function clearLiveSignalsCacheForTest() {
679
+ liveSignalsCache.clear();
680
+ }
565
681
  /**
566
682
  * Derive the inferred state (working / waiting / idle + preview/badges) and, for
567
683
  * the two harnesses whose raw lines carry it, the output-token throughput.
@@ -575,6 +691,9 @@ function parseTailEventsForKind(agent, sessionFile) {
575
691
  * `unknown` it used to fall through to. An opaque/untracked kind or an
576
692
  * unreadable/empty transcript yields an empty signal set, and the caller's
577
693
  * {@link resolveFallbackStatus} reports the honest live floor (`running`).
694
+ *
695
+ * Unchanged-mtime hits reuse the previous result (see {@link liveSignalsCache})
696
+ * so a 30s active-session poll does not re-tail every quiet transcript.
578
697
  */
579
698
  export function computeLiveSignals(kind, sessionFile, cwd, pidAlive) {
580
699
  if (!sessionFile)
@@ -584,21 +703,31 @@ export function computeLiveSignals(kind, sessionFile, cwd, pidAlive) {
584
703
  mtimeMs = fs.statSync(sessionFile).mtimeMs;
585
704
  }
586
705
  catch { /* vanished between calls */ }
706
+ const cacheKey = `${kind}\0${sessionFile}\0${cwd ?? ''}`;
707
+ const cached = liveSignalsCache.get(cacheKey);
708
+ if (cached && cached.mtimeMs === mtimeMs && cached.pidAlive === pidAlive) {
709
+ return cached.signals;
710
+ }
587
711
  const ctx = { cwd, pidAlive, mtimeMs, activeWindowMs: ACTIVE_MTIME_WINDOW_MS };
712
+ let signals = {};
588
713
  if (kind === 'claude' || kind === 'codex') {
589
714
  const { events, content } = readSessionTailWithRaw(sessionFile, kind);
590
- if (events.length === 0)
591
- return {};
592
- const state = inferSessionState(events, ctx);
593
- const tokPerSec = computeTokPerSec(content, kind);
594
- return { state, tokPerSec: tokPerSec > 0 ? tokPerSec : undefined };
715
+ if (events.length > 0) {
716
+ const state = inferSessionState(events, ctx);
717
+ const tokPerSec = computeTokPerSec(content, kind);
718
+ signals = { state, tokPerSec: tokPerSec > 0 ? tokPerSec : undefined };
719
+ }
595
720
  }
596
- if (!isSessionTrackedAgent(kind))
597
- return {};
598
- const events = parseTailEventsForKind(kind, sessionFile);
599
- if (events.length === 0)
600
- return {};
601
- return { state: inferSessionState(events, ctx) };
721
+ else if (isSessionTrackedAgent(kind)) {
722
+ const events = parseTailEventsForKind(kind, sessionFile);
723
+ if (events.length > 0) {
724
+ signals = { state: inferSessionState(events, ctx) };
725
+ }
726
+ }
727
+ if (liveSignalsCache.size >= LIVE_SIGNALS_CACHE_MAX)
728
+ liveSignalsCache.clear();
729
+ liveSignalsCache.set(cacheKey, { mtimeMs, pidAlive, signals });
730
+ return signals;
602
731
  }
603
732
  /** Map inferred activity onto the coarse ActiveStatus used by the renderer and counts. */
604
733
  function statusFromActivity(activity) {
@@ -880,8 +1009,24 @@ const HOST_MATCHERS = [
880
1009
  * walk ancestry chains to attribute child processes to their terminal hosts.
881
1010
  * `comm` may be an absolute path for shim-launched agents, so basename before
882
1011
  * matching against AGENT_CLI_NAMES.
1012
+ *
1013
+ * Memoized for {@link PROCESS_TABLE_FRESH_MS} so one active-session scan (and a
1014
+ * quiet re-poll within the window) does not re-shell `ps -A` / CIM for every
1015
+ * caller — terminal host detection, headless attribution, and `hostFromPid`
1016
+ * all share the same snapshot (#2047).
883
1017
  */
884
1018
  async function readProcessTable() {
1019
+ const now = activeScanNow();
1020
+ if (processTableCache && now - processTableCache.at < PROCESS_TABLE_FRESH_MS) {
1021
+ return processTableCache.rows;
1022
+ }
1023
+ const rows = await readProcessTableLive();
1024
+ processTableCache = { at: now, rows };
1025
+ return rows;
1026
+ }
1027
+ /** Uncached process-table snapshot (the live `ps` / CIM path). */
1028
+ async function readProcessTableLive() {
1029
+ processTableLiveReads += 1;
885
1030
  if (process.platform === 'win32')
886
1031
  return readProcessTableWin32();
887
1032
  let out;
@@ -1138,8 +1283,27 @@ export function foldSubordinateAgents(candidates, ppidMap, readEntry) {
1138
1283
  * Classified by walking the ppid chain: any recognised UI ancestor (IDE
1139
1284
  * helper, terminal-app, or multiplexer) means `terminal`; nothing of the
1140
1285
  * sort means `headless` (daemon, launchd-spawned, orphan).
1286
+ *
1287
+ * Full `ps`+`lsof` rescans are throttled to {@link UNATTRIBUTED_RESCAN_MS}
1288
+ * (#2047). Between rescans the previous rows are re-emitted after dropping
1289
+ * dead PIDs and PIDs that are now attributed. A shrinking attributed set
1290
+ * forces a rescan so a process that just left teams/terminals can reappear
1291
+ * as headless.
1141
1292
  */
1142
1293
  export async function listUnattributedActive(attributed) {
1294
+ const now = activeScanNow();
1295
+ if (unattributedCache &&
1296
+ now - unattributedCache.at < UNATTRIBUTED_RESCAN_MS &&
1297
+ !attributedSetLostPids(unattributedCache.attributed, attributed)) {
1298
+ return filterCachedUnattributed(unattributedCache.sessions, attributed, isPidAlive);
1299
+ }
1300
+ const out = await listUnattributedActiveLive(attributed);
1301
+ unattributedCache = { at: now, attributed: new Set(attributed), sessions: out };
1302
+ return out;
1303
+ }
1304
+ /** Unthrottled headless scan (live process table + cwd probes). */
1305
+ async function listUnattributedActiveLive(attributed) {
1306
+ unattributedFullRescans += 1;
1143
1307
  const table = await readProcessTable();
1144
1308
  const procByPid = new Map();
1145
1309
  const ppidMap = new Map();
@@ -19,6 +19,22 @@ export interface BashCommandInfo {
19
19
  summary: string;
20
20
  signal: 'high' | 'mid' | 'low';
21
21
  }
22
+ /**
23
+ * TOOL_REGISTRY (category + action, aliases flattened to their own keys)
24
+ * serialized as the body of a Python dict literal. The embedded activity-log
25
+ * hook script (`ACTIVITY_LOG_HOOK_SCRIPT` in `activity.ts`) runs standalone
26
+ * under `python3` and can't import this module, so its taxonomy is generated
27
+ * from this single source instead of hand-duplicated (#1889 — the two had
28
+ * already drifted: the hand-authored copy was missing `rmdir`).
29
+ */
30
+ export declare function pythonToolRegistryLiteral(indent?: string): string;
31
+ /**
32
+ * VALUE_FLAGS serialized as the body of a Python dict of sets. Same reason as
33
+ * {@link pythonToolRegistryLiteral}: the activity-log hook script embeds this
34
+ * table and must not hand-drift from the TypeScript source (#1889 — the Python
35
+ * copy was missing `agents`/`linear` two-level tools).
36
+ */
37
+ export declare function pythonValueFlagsLiteral(indent?: string): string;
22
38
  export declare function unwrapCommand(cmd: string): string;
23
39
  /**
24
40
  * Split a possibly-compound Bash command into simple commands, then tokenize each
@@ -95,6 +95,25 @@ const TOOL_REGISTRY = {
95
95
  sleep: { category: 'wait', signal: 'low', action: 'sleeping' },
96
96
  wait: { category: 'wait', signal: 'low', action: 'waiting' },
97
97
  };
98
+ /**
99
+ * TOOL_REGISTRY (category + action, aliases flattened to their own keys)
100
+ * serialized as the body of a Python dict literal. The embedded activity-log
101
+ * hook script (`ACTIVITY_LOG_HOOK_SCRIPT` in `activity.ts`) runs standalone
102
+ * under `python3` and can't import this module, so its taxonomy is generated
103
+ * from this single source instead of hand-duplicated (#1889 — the two had
104
+ * already drifted: the hand-authored copy was missing `rmdir`).
105
+ */
106
+ export function pythonToolRegistryLiteral(indent = ' ') {
107
+ const lines = [];
108
+ for (const [name, info] of Object.entries(TOOL_REGISTRY)) {
109
+ const entry = `{"category": ${JSON.stringify(info.category)}, "action": ${JSON.stringify(info.action)}}`;
110
+ lines.push(`${indent}${JSON.stringify(name)}: ${entry},`);
111
+ for (const alias of info.aliases ?? []) {
112
+ lines.push(`${indent}${JSON.stringify(alias)}: ${entry},`);
113
+ }
114
+ }
115
+ return lines.join('\n');
116
+ }
98
117
  /**
99
118
  * Two-level tools (bucket key includes the subcommand) mapped to the flags that
100
119
  * consume the *following* token as their value, per tool. The subcommand scan
@@ -124,6 +143,25 @@ const VALUE_FLAGS = {
124
143
  agents: new Set(['-H', '--host', '--device']),
125
144
  linear: new Set(),
126
145
  };
146
+ /**
147
+ * VALUE_FLAGS serialized as the body of a Python dict of sets. Same reason as
148
+ * {@link pythonToolRegistryLiteral}: the activity-log hook script embeds this
149
+ * table and must not hand-drift from the TypeScript source (#1889 — the Python
150
+ * copy was missing `agents`/`linear` two-level tools).
151
+ */
152
+ export function pythonValueFlagsLiteral(indent = ' ') {
153
+ const lines = [];
154
+ for (const [name, flags] of Object.entries(VALUE_FLAGS)) {
155
+ if (flags.size === 0) {
156
+ lines.push(`${indent}${JSON.stringify(name)}: set(),`);
157
+ continue;
158
+ }
159
+ // Stable order for golden-string equality (install/write checks).
160
+ const items = [...flags].sort().map((f) => JSON.stringify(f)).join(', ');
161
+ lines.push(`${indent}${JSON.stringify(name)}: {${items}},`);
162
+ }
163
+ return lines.join('\n');
164
+ }
127
165
  /** Tools whose bucket key includes the second token (subcommand). */
128
166
  const TWO_LEVEL_TOOLS = new Set(Object.keys(VALUE_FLAGS));
129
167
  /** Remote wrappers whose bucket key carries an `ssh→` prefix on the inner command. */
@@ -145,10 +183,20 @@ export function unwrapCommand(cmd) {
145
183
  const env = s.match(/^([A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S+)\s+)+(.+)$/);
146
184
  if (env)
147
185
  return unwrapCommand(env[2]);
186
+ // export VAR=value prefix, same shape as the bare env-var prefix above (#1889)
187
+ const exportPrefix = s.match(/^export\s+(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S+)\s+)*[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S+)\s*(?:&&|;|\n)\s*([\s\S]+)$/);
188
+ if (exportPrefix)
189
+ return unwrapCommand(exportPrefix[1]);
148
190
  // sudo / time prefix (value-taking flags like `-u user` consume their argument)
149
191
  const prefix = s.match(/^(?:sudo|time)(?:\s+(?:-[uUgGhpCrtDR]\s+\S+|-\S+))*\s+(.+)$/);
150
192
  if (prefix)
151
193
  return unwrapCommand(prefix[1]);
194
+ // set -euo pipefail && command / set -x; command (shell-option prefix; `-o`
195
+ // mode names like `pipefail` trail a flag without their own `-`, so consume
196
+ // everything up to the first operator rather than itemizing flag shapes)
197
+ const setPrefix = s.match(/^set\s+[^&;\n]+?(?:&&|;|\n)\s*([\s\S]+)$/);
198
+ if (setPrefix)
199
+ return unwrapCommand(setPrefix[1]);
152
200
  // cd foo && command (also `cd foo; command` and newline-separated `cd foo\ncommand`)
153
201
  const cd = s.match(/^cd\s+\S+\s*(?:&&|;|\n)\s*([\s\S]+)$/);
154
202
  if (cd)
@@ -157,6 +205,23 @@ export function unwrapCommand(cmd) {
157
205
  const npx = s.match(/^(?:npx|bunx)\s+(?:-\S+\s+)*(.+)$/);
158
206
  if (npx)
159
207
  return unwrapCommand(npx[1]);
208
+ // for X in ...; do command; done — unwrap to the loop body, not the `for` keyword
209
+ const forLoop = s.match(/^for\s+[\s\S]+?\bdo\b\s*([\s\S]+?)\s*;?\s*done\b[\s\S]*$/);
210
+ if (forLoop)
211
+ return unwrapCommand(forLoop[1]);
212
+ // until condition; do command; done
213
+ const untilLoop = s.match(/^until\s+[\s\S]+?\bdo\b\s*([\s\S]+?)\s*;?\s*done\b[\s\S]*$/);
214
+ if (untilLoop)
215
+ return unwrapCommand(untilLoop[1]);
216
+ // if condition; then command; fi — unwrap to the `then` branch, the real work;
217
+ // the condition itself is usually a test/comparison, not the command of interest
218
+ const ifCond = s.match(/^if\s+[\s\S]+?\bthen\b\s*([\s\S]+?)\s*;?\s*(?:elif\b|else\b|fi\b)[\s\S]*$/);
219
+ if (ifCond)
220
+ return unwrapCommand(ifCond[1]);
221
+ // (command) subshell, e.g. `(gh pr create --title x)` or `(cd /repo && npm i)`
222
+ const subshell = s.match(/^\(\s*([\s\S]+?)\s*\)\s*(?:&&|;|\|\|)[\s\S]*$/) ?? s.match(/^\(\s*([\s\S]+?)\s*\)\s*$/);
223
+ if (subshell)
224
+ return unwrapCommand(subshell[1]);
160
225
  return s;
161
226
  }
162
227
  function splitOnOperators(cmd) {
@@ -12,7 +12,7 @@ import { type IndexedToolCall } from './tool-calls.js';
12
12
  /** Current schema version; bumped when migrations are added. Exported so tests
13
13
  * assert against the constant instead of hardcoding a number that every bump
14
14
  * then has to chase (docs/05-sessions.md calls the constant the source of truth). */
15
- export declare const SCHEMA_VERSION = 36;
15
+ export declare const SCHEMA_VERSION = 37;
16
16
  /**
17
17
  * Bump to force `agents sessions backfill resources` to re-derive every
18
18
  * session's skill/slash-command tallies on its next run (resource_scan_ledger
@@ -26,7 +26,8 @@ export declare const RESOURCE_INDEX_VERSION = 1;
26
26
  * re-derives on the next `agents insights` instead of silently reporting stale
27
27
  * numbers alongside fresh ones. Same role as RESOURCE_INDEX_VERSION.
28
28
  */
29
- export declare const INSIGHTS_EXTRACTOR_VERSION = 4;
29
+ /** Bump when facet extraction changes so cached rows recompute (stalls-by-model v6). */
30
+ export declare const INSIGHTS_EXTRACTOR_VERSION = 6;
30
31
  /** Raw row shape returned from the sessions table. */
31
32
  export interface SessionRow {
32
33
  id: string;
@@ -50,7 +51,11 @@ export interface SessionRow {
50
51
  message_count: number | null;
51
52
  token_count: number | null;
52
53
  output_tokens: number | null;
54
+ input_tokens: number | null;
55
+ cache_read_tokens: number | null;
56
+ cache_write_tokens: number | null;
53
57
  cost_usd: number | null;
58
+ cost_usd_nocache: number | null;
54
59
  duration_ms: number | null;
55
60
  model: string | null;
56
61
  tool_call_count: number | null;
@@ -318,6 +323,10 @@ export declare function latestSessionFileForCwd(agent: SessionAgentId, cwd: stri
318
323
  maxAgeMs?: number;
319
324
  nowMs?: number;
320
325
  }): string | undefined;
326
+ /** Process-local diagnostics for the real-filesystem existence-cache tests. */
327
+ export declare function getSessionExistenceCacheStats(): {
328
+ sweeps: number;
329
+ };
321
330
  /** Query sessions from the database, applying filters and ordering by last-activity descending (default). */
322
331
  export declare function querySessions(options?: QueryOptions): SessionMeta[];
323
332
  /** Count sessions matching the given filter options. */
@@ -335,11 +344,23 @@ export interface UsageRollupRow {
335
344
  */
336
345
  label?: string;
337
346
  costUsd: number;
347
+ /**
348
+ * USD cost priced as if caching were off (cache read/write at the input rate),
349
+ * summed from `cost_usd_nocache`. Backs `agents output --pricing no-cache`.
350
+ * Equals `costUsd` for rows whose sessions record no cache split (RUSH-2287).
351
+ */
352
+ costUsdNoCache: number;
338
353
  durationMs: number;
339
354
  sessionCount: number;
340
355
  tokenCount: number;
341
356
  /** Real generated (output) tokens — excludes cache-read/-write context. */
342
357
  outputTokens: number;
358
+ /** Uncached input tokens summed across the group (0 where no harness recorded a split). */
359
+ inputTokens: number;
360
+ /** Cache-read tokens summed across the group. */
361
+ cacheReadTokens: number;
362
+ /** Cache-write (cache-creation) tokens summed across the group. */
363
+ cacheWriteTokens: number;
343
364
  }
344
365
  /** What to group a usage rollup by. */
345
366
  /**