@phnx-labs/agents-cli 1.22.27 → 1.22.29

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 (89) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +62 -26
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +54 -10
  5. package/dist/commands/hosts.js +4 -3
  6. package/dist/commands/perf.d.ts +10 -0
  7. package/dist/commands/perf.js +14 -6
  8. package/dist/commands/projects.js +24 -10
  9. package/dist/commands/routines.js +31 -4
  10. package/dist/commands/run-account-picker.d.ts +37 -0
  11. package/dist/commands/run-account-picker.js +101 -17
  12. package/dist/commands/secrets.js +86 -2
  13. package/dist/commands/sessions.d.ts +26 -6
  14. package/dist/commands/sessions.js +46 -12
  15. package/dist/commands/teams.js +19 -8
  16. package/dist/lib/activity.js +45 -99
  17. package/dist/lib/claude-account-token.js +38 -11
  18. package/dist/lib/cloud/host.js +1 -0
  19. package/dist/lib/devices/resolve-target.d.ts +1 -0
  20. package/dist/lib/devices/resolve-target.js +9 -2
  21. package/dist/lib/events.d.ts +1 -1
  22. package/dist/lib/events.js +2 -2
  23. package/dist/lib/git.d.ts +9 -3
  24. package/dist/lib/git.js +39 -22
  25. package/dist/lib/hooks/profile.d.ts +12 -1
  26. package/dist/lib/hooks/profile.js +6 -1
  27. package/dist/lib/hooks.js +4 -1
  28. package/dist/lib/hosts/dispatch.d.ts +10 -0
  29. package/dist/lib/hosts/dispatch.js +78 -22
  30. package/dist/lib/hosts/logs.js +11 -1
  31. package/dist/lib/hosts/passthrough.d.ts +5 -0
  32. package/dist/lib/hosts/passthrough.js +5 -2
  33. package/dist/lib/hosts/progress.d.ts +10 -0
  34. package/dist/lib/hosts/progress.js +37 -5
  35. package/dist/lib/hosts/ready.d.ts +41 -2
  36. package/dist/lib/hosts/ready.js +100 -12
  37. package/dist/lib/hosts/reconcile.d.ts +2 -1
  38. package/dist/lib/hosts/reconcile.js +13 -5
  39. package/dist/lib/hosts/reconnect.js +5 -3
  40. package/dist/lib/hosts/registry.js +1 -0
  41. package/dist/lib/hosts/tasks.d.ts +1 -0
  42. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  43. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  44. package/dist/lib/menubar/snapshot.d.ts +16 -0
  45. package/dist/lib/menubar/snapshot.js +37 -3
  46. package/dist/lib/models.d.ts +18 -11
  47. package/dist/lib/models.js +19 -15
  48. package/dist/lib/perf/db.js +16 -3
  49. package/dist/lib/perf/types.d.ts +12 -1
  50. package/dist/lib/project-probe.d.ts +22 -3
  51. package/dist/lib/project-probe.js +105 -17
  52. package/dist/lib/project-status.d.ts +9 -0
  53. package/dist/lib/project-status.js +15 -0
  54. package/dist/lib/refresh.js +22 -11
  55. package/dist/lib/rotate.d.ts +22 -0
  56. package/dist/lib/rotate.js +26 -0
  57. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  58. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  59. package/dist/lib/secrets/agent.d.ts +19 -3
  60. package/dist/lib/secrets/agent.js +58 -20
  61. package/dist/lib/secrets/audit.d.ts +1 -1
  62. package/dist/lib/secrets/audit.js +2 -0
  63. package/dist/lib/secrets/bundles.d.ts +3 -3
  64. package/dist/lib/secrets/bundles.js +15 -5
  65. package/dist/lib/secrets/filestore.d.ts +2 -0
  66. package/dist/lib/secrets/filestore.js +4 -0
  67. package/dist/lib/secrets/session-store.d.ts +7 -0
  68. package/dist/lib/secrets/session-store.js +21 -0
  69. package/dist/lib/session/active.d.ts +49 -0
  70. package/dist/lib/session/active.js +175 -11
  71. package/dist/lib/session/bash-command.d.ts +16 -0
  72. package/dist/lib/session/bash-command.js +65 -0
  73. package/dist/lib/session/db.d.ts +4 -0
  74. package/dist/lib/session/db.js +32 -15
  75. package/dist/lib/session/remote-bundle.js +1 -1
  76. package/dist/lib/staleness/detectors/skills.d.ts +2 -0
  77. package/dist/lib/staleness/detectors/skills.js +17 -2
  78. package/dist/lib/staleness/index.d.ts +10 -1
  79. package/dist/lib/staleness/index.js +28 -3
  80. package/dist/lib/teams/agents.d.ts +1 -0
  81. package/dist/lib/teams/agents.js +38 -11
  82. package/dist/lib/teams/remoteWorktree.d.ts +11 -7
  83. package/dist/lib/teams/remoteWorktree.js +29 -27
  84. package/dist/lib/usage.js +18 -10
  85. package/dist/lib/versions.d.ts +1 -0
  86. package/dist/lib/versions.js +40 -22
  87. package/package.json +3 -1
  88. package/dist/lib/exec.bench.d.ts +0 -1
  89. package/dist/lib/exec.bench.js +0 -186
@@ -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) {
@@ -323,6 +323,10 @@ export declare function latestSessionFileForCwd(agent: SessionAgentId, cwd: stri
323
323
  maxAgeMs?: number;
324
324
  nowMs?: number;
325
325
  }): string | undefined;
326
+ /** Process-local diagnostics for the real-filesystem existence-cache tests. */
327
+ export declare function getSessionExistenceCacheStats(): {
328
+ sweeps: number;
329
+ };
326
330
  /** Query sessions from the database, applying filters and ordering by last-activity descending (default). */
327
331
  export declare function querySessions(options?: QueryOptions): SessionMeta[];
328
332
  /** Count sessions matching the given filter options. */
@@ -1043,6 +1043,7 @@ export function getDB() {
1043
1043
  }
1044
1044
  /** Close the cached database connection. */
1045
1045
  export function closeDB() {
1046
+ clearSessionExistenceCache();
1046
1047
  if (dbInstance) {
1047
1048
  dbInstance.close();
1048
1049
  dbInstance = null;
@@ -2277,17 +2278,18 @@ function buildSessionWhere(options) {
2277
2278
  const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
2278
2279
  return { clause, params };
2279
2280
  }
2280
- /**
2281
- * Resolve which of the given file paths no longer exist, batching the check
2282
- * per directory instead of one `fs.existsSync` stat syscall per file.
2283
- * Transcript trees put many sessions in the same directory (one Claude
2284
- * `~/.claude/projects/<slug>/` holds every session for that project), so
2285
- * `readdirSync` once per directory and a Set membership test collapses what
2286
- * used to be N stat syscalls into (number of distinct directories) readdir
2287
- * syscalls the same existence answer, far fewer syscalls on a large index
2288
- * (RUSH-2211). Falls back to per-file existsSync only when the directory
2289
- * itself can't be listed (permissions, race with a concurrent delete).
2290
- */
2281
+ const directoryMembershipCache = new Map();
2282
+ let directoryMembershipSweepCount = 0;
2283
+ const DIRECTORY_MTIME_SETTLE_MS = 2_000;
2284
+ /** Process-local diagnostics for the real-filesystem existence-cache tests. */
2285
+ export function getSessionExistenceCacheStats() {
2286
+ return { sweeps: directoryMembershipSweepCount };
2287
+ }
2288
+ /** Clear process-local directory membership state when the session DB closes. */
2289
+ function clearSessionExistenceCache() {
2290
+ directoryMembershipCache.clear();
2291
+ directoryMembershipSweepCount = 0;
2292
+ }
2291
2293
  function findMissingFilePaths(filePaths) {
2292
2294
  const byDir = new Map();
2293
2295
  for (const p of filePaths) {
@@ -2303,21 +2305,36 @@ function findMissingFilePaths(filePaths) {
2303
2305
  for (const [dir, basenames] of byDir) {
2304
2306
  let entries;
2305
2307
  try {
2306
- entries = new Set(fs.readdirSync(dir));
2308
+ const stat = fs.statSync(dir);
2309
+ const cached = directoryMembershipCache.get(dir);
2310
+ const now = Date.now();
2311
+ const settled = now - stat.mtimeMs > DIRECTORY_MTIME_SETTLE_MS;
2312
+ const fresh = cached && now - cached.cachedAtMs <= DIRECTORY_MTIME_SETTLE_MS;
2313
+ if (settled && fresh && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
2314
+ entries = cached.entries;
2315
+ }
2316
+ else {
2317
+ entries = new Set(fs.readdirSync(dir));
2318
+ directoryMembershipCache.set(dir, { mtimeMs: stat.mtimeMs, size: stat.size, cachedAtMs: now, entries });
2319
+ directoryMembershipSweepCount++;
2320
+ }
2307
2321
  }
2308
2322
  catch {
2323
+ directoryMembershipCache.delete(dir);
2309
2324
  // Directory itself is gone (or unreadable) — every file in it is missing.
2310
2325
  // Also covers the race where readdir loses to a concurrent delete: fall
2311
2326
  // back to a direct stat rather than assuming existence.
2312
2327
  for (const base of basenames) {
2313
- if (!fs.existsSync(path.join(dir, base)))
2314
- missing.add(path.join(dir, base));
2328
+ const filePath = path.join(dir, base);
2329
+ if (!fs.existsSync(filePath))
2330
+ missing.add(filePath);
2315
2331
  }
2316
2332
  continue;
2317
2333
  }
2318
2334
  for (const base of basenames) {
2335
+ const filePath = path.join(dir, base);
2319
2336
  if (!entries.has(base))
2320
- missing.add(path.join(dir, base));
2337
+ missing.add(filePath);
2321
2338
  }
2322
2339
  }
2323
2340
  return missing;
@@ -40,7 +40,7 @@ export async function pullBundlesFromHosts(hosts, exportArgs) {
40
40
  for (const t of targets) {
41
41
  const cmd = remoteAgentsCommand(['sessions', 'export', ...exportArgs, '--stdout'], t.os);
42
42
  process.stderr.write(chalk.dim(`Pulling sessions from ${t.name}…\n`));
43
- const res = sshExec(t.target, cmd, { timeoutMs: REMOTE_EXPORT_TIMEOUT_MS });
43
+ const res = sshExec(t.target, cmd, { timeoutMs: REMOTE_EXPORT_TIMEOUT_MS, extraSshArgs: t.extraSshArgs });
44
44
  if (res.timedOut) {
45
45
  errors.push(`${t.name}: timed out after ${Math.round(REMOTE_EXPORT_TIMEOUT_MS / 1000)}s`);
46
46
  continue;
@@ -1,3 +1,5 @@
1
1
  import type { AgentId } from '../../types.js';
2
2
  import type { ResourceDetector } from './types.js';
3
+ /** Exported for unit tests (RUSH-2320 #2). Production callers: list() below. */
4
+ export declare function skillDirsMatch(src: string, dest: string): boolean;
3
5
  export declare const skillsDetectors: Partial<Record<AgentId, ResourceDetector>>;
@@ -9,7 +9,8 @@ import { capableAgents } from '../../capabilities.js';
9
9
  import { resolveSkillSource } from '../writers/sources.js';
10
10
  import { lazyAgentMap } from '../writers/lazy-map.js';
11
11
  const SKILL_COPY_IGNORE = new Set(['.DS_Store', '.git', '.gitignore', '.venv', '__pycache__', 'node_modules']);
12
- function skillDirsMatch(src, dest) {
12
+ /** Exported for unit tests (RUSH-2320 #2). Production callers: list() below. */
13
+ export function skillDirsMatch(src, dest) {
13
14
  const entries = fs.readdirSync(src, { withFileTypes: true });
14
15
  for (const entry of entries) {
15
16
  if (entry.isSymbolicLink())
@@ -25,7 +26,21 @@ function skillDirsMatch(src, dest) {
25
26
  return false;
26
27
  }
27
28
  else {
28
- if (!fs.existsSync(destPath))
29
+ // Stat-first (RUSH-2320 #2): size mismatch is a definitive miss with no
30
+ // content reads. Do NOT treat equal mtimes as equal content — src and
31
+ // dest are different trees (version home vs source), and copyFileSync
32
+ // does not preserve mtime, so mtime equality is accidental and unsafe.
33
+ // Content compare only when sizes match.
34
+ let srcStat;
35
+ let destStat;
36
+ try {
37
+ srcStat = fs.statSync(srcPath);
38
+ destStat = fs.statSync(destPath);
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ if (srcStat.size !== destStat.size)
29
44
  return false;
30
45
  if (fs.readFileSync(srcPath, 'utf-8') !== fs.readFileSync(destPath, 'utf-8'))
31
46
  return false;
@@ -16,7 +16,16 @@ export type { SyncManifest } from './types.js';
16
16
  export { MANIFEST_VERSION } from './types.js';
17
17
  export declare function loadManifest(agent: AgentId, version: string): SyncManifest | null;
18
18
  export declare function saveManifest(agent: AgentId, version: string, manifest: SyncManifest): void;
19
- export declare function buildManifest(agent: AgentId, version: string, cwd: string): SyncManifest;
19
+ /**
20
+ * Snapshot current resource fingerprints for `agent@version`.
21
+ *
22
+ * When `previous` is supplied (the still-loaded post-guard manifest), any
23
+ * entry whose stored path/mtime/size still match via the checker's `isFresh`
24
+ * is carried forward without re-hashing. Measured: full rebuild ~716 ms →
25
+ * carry-forward near the 5.67 ms `isStale` cost on an unchanged tree
26
+ * (RUSH-2320 #3).
27
+ */
28
+ export declare function buildManifest(agent: AgentId, version: string, cwd: string, previous?: SyncManifest | null): SyncManifest;
20
29
  /**
21
30
  * True when any tracked resource has drifted from the stored manifest.
22
31
  * Walks every resource type in turn and returns true at the first miss —
@@ -69,7 +69,16 @@ export function saveManifest(agent, version, manifest) {
69
69
  catch { /* ignore */ }
70
70
  }
71
71
  }
72
- export function buildManifest(agent, version, cwd) {
72
+ /**
73
+ * Snapshot current resource fingerprints for `agent@version`.
74
+ *
75
+ * When `previous` is supplied (the still-loaded post-guard manifest), any
76
+ * entry whose stored path/mtime/size still match via the checker's `isFresh`
77
+ * is carried forward without re-hashing. Measured: full rebuild ~716 ms →
78
+ * carry-forward near the 5.67 ms `isStale` cost on an unchanged tree
79
+ * (RUSH-2320 #3).
80
+ */
81
+ export function buildManifest(agent, version, cwd, previous) {
73
82
  const manifest = {
74
83
  v: MANIFEST_VERSION,
75
84
  syncedAt: new Date().toISOString(),
@@ -85,14 +94,30 @@ export function buildManifest(agent, version, cwd) {
85
94
  };
86
95
  for (const { checker, field } of STANDARD_CHECKERS) {
87
96
  const target = manifest[field];
97
+ const prevMap = (previous?.[field] ?? {});
88
98
  for (const name of checker.listNames(cwd)) {
99
+ const prev = prevMap[name];
100
+ if (prev !== undefined && checker.isFresh(name, prev, cwd)) {
101
+ target[name] = prev;
102
+ continue;
103
+ }
89
104
  const entry = checker.build(name, cwd);
90
105
  if (entry !== null)
91
106
  target[name] = entry;
92
107
  }
93
108
  }
94
- manifest.rules = buildRules(agent, version, cwd);
95
- manifest.permissions = buildPermissions();
109
+ if (previous?.rules && !isRulesStale(previous.rules, agent, version, cwd)) {
110
+ manifest.rules = previous.rules;
111
+ }
112
+ else {
113
+ manifest.rules = buildRules(agent, version, cwd);
114
+ }
115
+ if (previous?.permissions && !isPermissionsStale(previous.permissions)) {
116
+ manifest.permissions = previous.permissions;
117
+ }
118
+ else {
119
+ manifest.permissions = buildPermissions();
120
+ }
96
121
  return manifest;
97
122
  }
98
123
  /**
@@ -156,6 +156,7 @@ export declare class AgentProcess {
156
156
  worktreePath: string | null;
157
157
  hostName: string | null;
158
158
  hostTarget: string | null;
159
+ hostIdentityFile: string | null;
159
160
  repoPath: string | null;
160
161
  remotePid: number | null;
161
162
  remoteLog: string | null;