@phnx-labs/agents-cli 1.22.27 → 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 (87) hide show
  1. package/CHANGELOG.md +49 -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.js +15 -2
  45. package/dist/lib/models.d.ts +18 -11
  46. package/dist/lib/models.js +19 -15
  47. package/dist/lib/perf/db.js +16 -3
  48. package/dist/lib/perf/types.d.ts +12 -1
  49. package/dist/lib/project-probe.d.ts +22 -3
  50. package/dist/lib/project-probe.js +105 -17
  51. package/dist/lib/project-status.d.ts +9 -0
  52. package/dist/lib/project-status.js +15 -0
  53. package/dist/lib/refresh.js +22 -11
  54. package/dist/lib/rotate.d.ts +22 -0
  55. package/dist/lib/rotate.js +26 -0
  56. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  57. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  58. package/dist/lib/secrets/agent.d.ts +19 -3
  59. package/dist/lib/secrets/agent.js +58 -20
  60. package/dist/lib/secrets/audit.d.ts +1 -1
  61. package/dist/lib/secrets/audit.js +2 -0
  62. package/dist/lib/secrets/bundles.d.ts +3 -3
  63. package/dist/lib/secrets/bundles.js +15 -5
  64. package/dist/lib/secrets/filestore.d.ts +2 -0
  65. package/dist/lib/secrets/filestore.js +4 -0
  66. package/dist/lib/secrets/session-store.d.ts +7 -0
  67. package/dist/lib/secrets/session-store.js +21 -0
  68. package/dist/lib/session/active.d.ts +49 -0
  69. package/dist/lib/session/active.js +175 -11
  70. package/dist/lib/session/bash-command.d.ts +16 -0
  71. package/dist/lib/session/bash-command.js +65 -0
  72. package/dist/lib/session/db.d.ts +4 -0
  73. package/dist/lib/session/db.js +32 -15
  74. package/dist/lib/session/remote-bundle.js +1 -1
  75. package/dist/lib/staleness/detectors/skills.d.ts +2 -0
  76. package/dist/lib/staleness/detectors/skills.js +17 -2
  77. package/dist/lib/staleness/index.d.ts +10 -1
  78. package/dist/lib/staleness/index.js +28 -3
  79. package/dist/lib/teams/agents.d.ts +1 -0
  80. package/dist/lib/teams/agents.js +38 -11
  81. package/dist/lib/teams/remoteWorktree.d.ts +11 -7
  82. package/dist/lib/teams/remoteWorktree.js +29 -27
  83. package/dist/lib/versions.d.ts +1 -0
  84. package/dist/lib/versions.js +40 -22
  85. package/package.json +3 -1
  86. package/dist/lib/exec.bench.d.ts +0 -1
  87. package/dist/lib/exec.bench.js +0 -186
@@ -493,6 +493,7 @@ export class AgentProcess {
493
493
  // (remotePid/remoteLog/remoteExit) — so the giant constructor stays untouched.
494
494
  hostName = null;
495
495
  hostTarget = null;
496
+ hostIdentityFile = null;
496
497
  repoPath = null;
497
498
  remotePid = null;
498
499
  remoteLog = null;
@@ -692,6 +693,7 @@ export class AgentProcess {
692
693
  const delta = pullRemoteLogDelta(this.hostTarget, {
693
694
  remoteLog: this.remoteLog,
694
695
  offset: this.remoteLogOffset,
696
+ extraSshArgs: this.hostIdentityFile ? ['-i', this.hostIdentityFile, '-o', 'IdentitiesOnly=yes'] : [],
695
697
  });
696
698
  if (delta && delta.bytes.length > 0) {
697
699
  const stdoutPath = await this.getStdoutPath();
@@ -717,6 +719,7 @@ export class AgentProcess {
717
719
  const res = sshExec(this.hostTarget, `cat ${this.remoteExit} 2>/dev/null`, {
718
720
  timeoutMs: 8000,
719
721
  multiplex: true,
722
+ extraSshArgs: this.hostIdentityFile ? ['-i', this.hostIdentityFile, '-o', 'IdentitiesOnly=yes'] : [],
720
723
  });
721
724
  exit = res.code === 0 && res.stdout.trim() !== '' ? res.stdout.trim() : null;
722
725
  }
@@ -898,6 +901,7 @@ export class AgentProcess {
898
901
  worktree_path: this.worktreePath,
899
902
  host_name: this.hostName,
900
903
  host_target: this.hostTarget,
904
+ host_identity_file: this.hostIdentityFile,
901
905
  repo_path: this.repoPath,
902
906
  remote_pid: this.remotePid,
903
907
  remote_log: this.remoteLog,
@@ -952,6 +956,7 @@ export class AgentProcess {
952
956
  // constructor signature stays fixed. Null on every pre-existing teammate.
953
957
  agent.hostName = meta.host_name || null;
954
958
  agent.hostTarget = meta.host_target || null;
959
+ agent.hostIdentityFile = meta.host_identity_file || null;
955
960
  agent.repoPath = meta.repo_path || null;
956
961
  agent.remotePid = typeof meta.remote_pid === 'number' ? meta.remote_pid : null;
957
962
  agent.remoteLog = meta.remote_log || null;
@@ -980,7 +985,11 @@ export class AgentProcess {
980
985
  // interpolate UNQUOTED so `$HOME` expands (shellQuote would defeat it).
981
986
  const probe = `test -f ${this.remoteExit} && echo DEAD || ` +
982
987
  `(kill -0 ${this.remotePid} 2>/dev/null && echo ALIVE || echo DEAD)`;
983
- const res = sshExec(this.hostTarget, probe, { timeoutMs: 8000, multiplex: true });
988
+ const res = sshExec(this.hostTarget, probe, {
989
+ timeoutMs: 8000,
990
+ multiplex: true,
991
+ extraSshArgs: this.hostIdentityFile ? ['-i', this.hostIdentityFile, '-o', 'IdentitiesOnly=yes'] : [],
992
+ });
984
993
  if (res.code === null)
985
994
  return true; // transient ssh failure — don't reap early
986
995
  return res.stdout.trim().endsWith('ALIVE');
@@ -1640,10 +1649,16 @@ export class AgentManager {
1640
1649
  if (!host) {
1641
1650
  throw new Error(`Cannot launch remote teammate ${agent.agentId}: device "${agent.hostName}" no longer resolves.`);
1642
1651
  }
1643
- // Ensure agents-cli is present + version-matched on the host; surface (not
1644
- // fail on) an agent-not-installed warning like dispatch.ts does.
1652
+ agent.hostIdentityFile = host.identityFile ?? null;
1653
+ // Ensure agents-cli is present + the pin is installed on the host. A bare
1654
+ // agent name still warns (like dispatch.ts); a concrete agent.version pin
1655
+ // fails loud so the teammate never reports launched against a missing pin
1656
+ // (RUSH-2313).
1645
1657
  try {
1646
- const { warnings } = ensureHostReady(host, { agent: agent.agentType });
1658
+ const { warnings } = ensureHostReady(host, {
1659
+ agent: agent.agentType,
1660
+ version: agent.version ?? undefined,
1661
+ });
1647
1662
  for (const w of warnings)
1648
1663
  process.stderr.write(`[teams] warning: ${w}\n`);
1649
1664
  }
@@ -1661,7 +1676,9 @@ export class AgentManager {
1661
1676
  remoteCwd = agent.worktreePath;
1662
1677
  }
1663
1678
  else {
1664
- const worktreePath = createRemoteWorktree(agent.hostTarget, agent.repoPath, agent.worktreeName);
1679
+ const worktreePath = createRemoteWorktree(agent.hostTarget, agent.repoPath, agent.worktreeName, {
1680
+ extraSshArgs: agent.hostIdentityFile ? ['-i', agent.hostIdentityFile, '-o', 'IdentitiesOnly=yes'] : [],
1681
+ });
1665
1682
  agent.worktreePath = worktreePath;
1666
1683
  remoteCwd = worktreePath;
1667
1684
  }
@@ -1743,9 +1760,12 @@ export class AgentManager {
1743
1760
  }
1744
1761
  const target = sshTargetFor(host);
1745
1762
  const teamMeta = await getTeam(taskName);
1746
- const repoRoot = ensureRemoteRepo(target, teamMeta?.repo ?? '', taskName);
1763
+ const repoRoot = ensureRemoteRepo(target, teamMeta?.repo ?? '', taskName, {
1764
+ extraSshArgs: host.identityFile ? ['-i', host.identityFile, '-o', 'IdentitiesOnly=yes'] : [],
1765
+ });
1747
1766
  agent.hostName = host.name;
1748
1767
  agent.hostTarget = target;
1768
+ agent.hostIdentityFile = host.identityFile ?? null;
1749
1769
  agent.repoPath = repoRoot;
1750
1770
  await agent.saveMeta();
1751
1771
  }
@@ -1864,11 +1884,12 @@ export class AgentManager {
1864
1884
  return;
1865
1885
  const byTarget = new Map();
1866
1886
  for (const a of teammates) {
1867
- const arr = byTarget.get(a.hostTarget) || [];
1868
- arr.push(a);
1869
- byTarget.set(a.hostTarget, arr);
1887
+ const key = `${a.hostTarget}\0${a.hostIdentityFile ?? ''}`;
1888
+ const group = byTarget.get(key) || { target: a.hostTarget, agents: [] };
1889
+ group.agents.push(a);
1890
+ byTarget.set(key, group);
1870
1891
  }
1871
- for (const [target, agents] of byTarget) {
1892
+ for (const { target, agents } of byTarget.values()) {
1872
1893
  // Emit one line per teammate: "<agentId> ALIVE|DEAD <exitOrEmpty>". A single
1873
1894
  // round-trip over the multiplexed socket, regardless of teammate count.
1874
1895
  const parts = agents.map((a) => {
@@ -1883,7 +1904,12 @@ export class AgentManager {
1883
1904
  `elif kill -0 ${a.remotePid} 2>/dev/null; then printf 'ALIVE\\n'; ` +
1884
1905
  `else printf 'DEAD\\n'; fi`);
1885
1906
  });
1886
- const res = sshExec(target, parts.join('; '), { timeoutMs: 12000, multiplex: true });
1907
+ const identityFile = agents[0]?.hostIdentityFile;
1908
+ const res = sshExec(target, parts.join('; '), {
1909
+ timeoutMs: 12000,
1910
+ multiplex: true,
1911
+ extraSshArgs: identityFile ? ['-i', identityFile, '-o', 'IdentitiesOnly=yes'] : [],
1912
+ });
1887
1913
  if (res.code === null)
1888
1914
  continue; // transient ssh failure — skip this wave, no snapshot
1889
1915
  const snapshots = new Map();
@@ -2155,6 +2181,7 @@ export class AgentManager {
2155
2181
  sshExec(agent.hostTarget, `kill -TERM -- -${agent.remotePid} 2>/dev/null`, {
2156
2182
  timeoutMs: 10000,
2157
2183
  multiplex: true,
2184
+ extraSshArgs: agent.hostIdentityFile ? ['-i', agent.hostIdentityFile, '-o', 'IdentitiesOnly=yes'] : [],
2158
2185
  });
2159
2186
  }
2160
2187
  catch {
@@ -1,3 +1,6 @@
1
+ interface RemoteSshOptions {
2
+ extraSshArgs?: string[];
3
+ }
1
4
  /**
2
5
  * Render a path for safe interpolation into a REMOTE shell, expanding a leading
3
6
  * `~`/`~/` to `"$HOME"` so it resolves on the host. A plain `shellQuote('~/x')`
@@ -13,15 +16,15 @@ export declare function remotePathExpr(p: string): string;
13
16
  * downstream remote command (launch `cd`, worktree create, polling) works from an
14
17
  * absolute path with no tilde-expansion hazard.
15
18
  */
16
- export declare function resolveRemoteRepoRoot(target: string, repoPath: string): string | null;
19
+ export declare function resolveRemoteRepoRoot(target: string, repoPath: string, opts?: RemoteSshOptions): string | null;
17
20
  /** True when `repoPath` is a git working tree on the host. */
18
- export declare function isRemoteGitRepo(target: string, repoPath: string): boolean;
21
+ export declare function isRemoteGitRepo(target: string, repoPath: string, opts?: RemoteSshOptions): boolean;
19
22
  /**
20
23
  * Resolve the host repo's default branch name (origin/HEAD → `main`/`master`/…).
21
24
  * Falls back to `main` when origin/HEAD isn't set, matching the local recipe's
22
25
  * `remote set-head` step.
23
26
  */
24
- export declare function remoteDefaultBranch(target: string, repoPath: string): string;
27
+ export declare function remoteDefaultBranch(target: string, repoPath: string, opts?: RemoteSshOptions): string;
25
28
  /**
26
29
  * Create a worktree on the host for a teammate, branched off the freshly-fetched
27
30
  * default branch. Returns the absolute worktree path on the host.
@@ -30,7 +33,7 @@ export declare function remoteDefaultBranch(target: string, repoPath: string): s
30
33
  * `worktree add -b … origin/<default>` so a stale checkout can't fork the
31
34
  * teammate off old code.
32
35
  */
33
- export declare function createRemoteWorktree(target: string, repoPath: string, worktreeName: string): string;
36
+ export declare function createRemoteWorktree(target: string, repoPath: string, worktreeName: string, opts?: RemoteSshOptions): string;
34
37
  /**
35
38
  * Ensure the team's repo is present on the host and return its absolute git
36
39
  * root. The canonical location is `~/.agents/repos/<slug>` (slug = sanitized
@@ -45,12 +48,13 @@ export declare function createRemoteWorktree(target: string, repoPath: string, w
45
48
  * expands `$HOME` — single-quoting the tilde would defeat expansion, the same
46
49
  * hazard dispatch.ts avoids.
47
50
  */
48
- export declare function ensureRemoteRepo(target: string, repo: string, slug: string): string;
51
+ export declare function ensureRemoteRepo(target: string, repo: string, slug: string, opts?: RemoteSshOptions): string;
49
52
  /** True when the host worktree has uncommitted changes (staged or unstaged). */
50
- export declare function remoteWorktreeDirty(target: string, worktreePath: string): boolean;
53
+ export declare function remoteWorktreeDirty(target: string, worktreePath: string, opts?: RemoteSshOptions): boolean;
51
54
  /**
52
55
  * Remove a host worktree and (optionally) its branch. Best-effort: prunes on a
53
56
  * "not a working tree" error and ignores a missing branch, matching local
54
57
  * `removeWorktree` semantics so cleanup never throws on a half-gone worktree.
55
58
  */
56
- export declare function removeRemoteWorktree(target: string, repoPath: string, worktreeName: string, deleteBranch?: boolean): void;
59
+ export declare function removeRemoteWorktree(target: string, repoPath: string, worktreeName: string, deleteBranch?: boolean, opts?: RemoteSshOptions): void;
60
+ export {};
@@ -40,30 +40,30 @@ export function remotePathExpr(p) {
40
40
  * downstream remote command (launch `cd`, worktree create, polling) works from an
41
41
  * absolute path with no tilde-expansion hazard.
42
42
  */
43
- export function resolveRemoteRepoRoot(target, repoPath) {
43
+ export function resolveRemoteRepoRoot(target, repoPath, opts = {}) {
44
44
  assertValidSshTarget(target);
45
45
  const cmd = `git -C ${remotePathExpr(repoPath)} rev-parse --show-toplevel 2>/dev/null`;
46
- const res = sshExec(target, cmd, { timeoutMs: 15000, multiplex: true });
46
+ const res = sshExec(target, cmd, { timeoutMs: 15000, multiplex: true, extraSshArgs: opts.extraSshArgs });
47
47
  const root = res.stdout.trim();
48
48
  return res.code === 0 && root ? root : null;
49
49
  }
50
50
  /** Run one git command in `repoPath` on the host; throw with stderr on failure. */
51
- function remoteGit(target, repoPath, args, timeoutMs = 60000) {
51
+ function remoteGit(target, repoPath, args, timeoutMs = 60000, opts = {}) {
52
52
  const cmd = ['git', '-C', repoPath, ...args].map(shellQuote).join(' ');
53
- const res = sshExec(target, cmd, { timeoutMs, multiplex: true });
53
+ const res = sshExec(target, cmd, { timeoutMs, multiplex: true, extraSshArgs: opts.extraSshArgs });
54
54
  if (res.code !== 0) {
55
55
  throw new Error(`remote git failed on ${target} (${args[0]}): ${(res.stderr || res.stdout).trim() || 'ssh error'}`);
56
56
  }
57
57
  return res.stdout.trim();
58
58
  }
59
59
  /** True when `repoPath` is a git working tree on the host. */
60
- export function isRemoteGitRepo(target, repoPath) {
60
+ export function isRemoteGitRepo(target, repoPath, opts = {}) {
61
61
  assertValidSshTarget(target);
62
62
  // remotePathExpr so a `~`/`$HOME`-relative path (the canonical repos dir) expands
63
63
  // on the host — plain shellQuote would single-quote the tilde into a literal,
64
64
  // making an existing checkout look absent and forcing a doomed re-clone.
65
65
  const cmd = `git -C ${remotePathExpr(repoPath)} rev-parse --git-dir 2>/dev/null`;
66
- const res = sshExec(target, cmd, { timeoutMs: 15000, multiplex: true });
66
+ const res = sshExec(target, cmd, { timeoutMs: 15000, multiplex: true, extraSshArgs: opts.extraSshArgs });
67
67
  return res.code === 0;
68
68
  }
69
69
  /**
@@ -71,12 +71,12 @@ export function isRemoteGitRepo(target, repoPath) {
71
71
  * Falls back to `main` when origin/HEAD isn't set, matching the local recipe's
72
72
  * `remote set-head` step.
73
73
  */
74
- export function remoteDefaultBranch(target, repoPath) {
74
+ export function remoteDefaultBranch(target, repoPath, opts = {}) {
75
75
  assertValidSshTarget(target);
76
76
  try {
77
77
  // Refresh origin/HEAD first so a repo cloned before the default was set resolves.
78
- sshExec(target, ['git', '-C', repoPath, 'remote', 'set-head', 'origin', '--auto'].map(shellQuote).join(' '), { timeoutMs: 20000, multiplex: true });
79
- const ref = remoteGit(target, repoPath, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
78
+ sshExec(target, ['git', '-C', repoPath, 'remote', 'set-head', 'origin', '--auto'].map(shellQuote).join(' '), { timeoutMs: 20000, multiplex: true, extraSshArgs: opts.extraSshArgs });
79
+ const ref = remoteGit(target, repoPath, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], 60000, opts);
80
80
  return ref.replace(/^origin\//, '') || 'main';
81
81
  }
82
82
  catch {
@@ -91,15 +91,15 @@ export function remoteDefaultBranch(target, repoPath) {
91
91
  * `worktree add -b … origin/<default>` so a stale checkout can't fork the
92
92
  * teammate off old code.
93
93
  */
94
- export function createRemoteWorktree(target, repoPath, worktreeName) {
94
+ export function createRemoteWorktree(target, repoPath, worktreeName, opts = {}) {
95
95
  assertValidSshTarget(target);
96
96
  assertName(worktreeName);
97
- const gitRoot = remoteGit(target, repoPath, ['rev-parse', '--show-toplevel']);
98
- const base = remoteDefaultBranch(target, gitRoot);
97
+ const gitRoot = remoteGit(target, repoPath, ['rev-parse', '--show-toplevel'], 60000, opts);
98
+ const base = remoteDefaultBranch(target, gitRoot, opts);
99
99
  const worktreePath = `${gitRoot}/.agents/worktrees/${worktreeName}`;
100
100
  const branchName = `agents/${worktreeName}`;
101
- remoteGit(target, gitRoot, ['fetch', 'origin'], 120000);
102
- remoteGit(target, gitRoot, ['worktree', 'add', '-b', branchName, worktreePath, `origin/${base}`], 120000);
101
+ remoteGit(target, gitRoot, ['fetch', 'origin'], 120000, opts);
102
+ remoteGit(target, gitRoot, ['worktree', 'add', '-b', branchName, worktreePath, `origin/${base}`], 120000, opts);
103
103
  return worktreePath;
104
104
  }
105
105
  // Team-name → repos-dir slug: same allowlist a worktree name uses, so it lands
@@ -126,24 +126,24 @@ function repoSlug(name) {
126
126
  * expands `$HOME` — single-quoting the tilde would defeat expansion, the same
127
127
  * hazard dispatch.ts avoids.
128
128
  */
129
- export function ensureRemoteRepo(target, repo, slug) {
129
+ export function ensureRemoteRepo(target, repo, slug, opts = {}) {
130
130
  assertValidSshTarget(target);
131
131
  const safeSlug = repoSlug(slug);
132
132
  const canonical = `~/.agents/repos/${safeSlug}`;
133
133
  // 1. Canonical checkout already exists → fetch and reuse.
134
- if (isRemoteGitRepo(target, canonical)) {
134
+ if (isRemoteGitRepo(target, canonical, opts)) {
135
135
  // Best-effort refresh; a fetch failure (offline origin) shouldn't block reuse.
136
- sshExec(target, `git -C ${remotePathExpr(canonical)} fetch origin`, { timeoutMs: 120000, multiplex: true });
137
- const root = resolveRemoteRepoRoot(target, canonical);
136
+ sshExec(target, `git -C ${remotePathExpr(canonical)} fetch origin`, { timeoutMs: 120000, multiplex: true, extraSshArgs: opts.extraSshArgs });
137
+ const root = resolveRemoteRepoRoot(target, canonical, opts);
138
138
  if (!root)
139
139
  throw new Error(`Repo at ${canonical} on ${target} vanished mid-provision.`);
140
140
  return root;
141
141
  }
142
142
  // 2. `repo` is a path that already exists on the host as a git repo → use it.
143
143
  if (repo && !looksLikeUrl(repo)) {
144
- const existing = resolveRemoteRepoRoot(target, repo);
144
+ const existing = resolveRemoteRepoRoot(target, repo, opts);
145
145
  if (existing) {
146
- sshExec(target, `git -C ${remotePathExpr(existing)} fetch origin`, { timeoutMs: 120000, multiplex: true });
146
+ sshExec(target, `git -C ${remotePathExpr(existing)} fetch origin`, { timeoutMs: 120000, multiplex: true, extraSshArgs: opts.extraSshArgs });
147
147
  return existing;
148
148
  }
149
149
  }
@@ -159,12 +159,12 @@ export function ensureRemoteRepo(target, repo, slug) {
159
159
  // blocks *shell* injection and is orthogonal to *git-transport* injection.
160
160
  assertSafeGitTransport(repo);
161
161
  const clone = sshExec(target, `mkdir -p ${remotePathExpr('~/.agents/repos')} && ` +
162
- `git clone -- ${shellQuote(repo)} ${remotePathExpr(canonical)}`, { timeoutMs: 600000, multiplex: true });
162
+ `git clone -- ${shellQuote(repo)} ${remotePathExpr(canonical)}`, { timeoutMs: 600000, multiplex: true, extraSshArgs: opts.extraSshArgs });
163
163
  if (clone.code !== 0) {
164
164
  throw new Error(`git clone ${repo} into ${canonical} on ${target} failed: ` +
165
165
  `${(clone.stderr || clone.stdout).trim() || 'ssh error'}`);
166
166
  }
167
- const root = resolveRemoteRepoRoot(target, canonical);
167
+ const root = resolveRemoteRepoRoot(target, canonical, opts);
168
168
  if (!root)
169
169
  throw new Error(`Cloned ${repo} into ${canonical} on ${target} but it isn't a git repo.`);
170
170
  return root;
@@ -176,10 +176,10 @@ function looksLikeUrl(repo) {
176
176
  repo.startsWith('git@'));
177
177
  }
178
178
  /** True when the host worktree has uncommitted changes (staged or unstaged). */
179
- export function remoteWorktreeDirty(target, worktreePath) {
179
+ export function remoteWorktreeDirty(target, worktreePath, opts = {}) {
180
180
  assertValidSshTarget(target);
181
181
  const cmd = ['git', '-C', worktreePath, 'status', '--porcelain'].map(shellQuote).join(' ');
182
- const res = sshExec(target, cmd, { timeoutMs: 20000, multiplex: true });
182
+ const res = sshExec(target, cmd, { timeoutMs: 20000, multiplex: true, extraSshArgs: opts.extraSshArgs });
183
183
  if (res.code !== 0)
184
184
  return false;
185
185
  return res.stdout.trim().length > 0;
@@ -189,17 +189,18 @@ export function remoteWorktreeDirty(target, worktreePath) {
189
189
  * "not a working tree" error and ignores a missing branch, matching local
190
190
  * `removeWorktree` semantics so cleanup never throws on a half-gone worktree.
191
191
  */
192
- export function removeRemoteWorktree(target, repoPath, worktreeName, deleteBranch = true) {
192
+ export function removeRemoteWorktree(target, repoPath, worktreeName, deleteBranch = true, opts = {}) {
193
193
  assertValidSshTarget(target);
194
194
  assertName(worktreeName);
195
- const gitRoot = remoteGit(target, repoPath, ['rev-parse', '--show-toplevel']);
195
+ const gitRoot = remoteGit(target, repoPath, ['rev-parse', '--show-toplevel'], 60000, opts);
196
196
  const worktreePath = `${gitRoot}/.agents/worktrees/${worktreeName}`;
197
197
  const branchName = `agents/${worktreeName}`;
198
- const rm = sshExec(target, ['git', '-C', gitRoot, 'worktree', 'remove', '--force', worktreePath].map(shellQuote).join(' '), { timeoutMs: 60000, multiplex: true });
198
+ const rm = sshExec(target, ['git', '-C', gitRoot, 'worktree', 'remove', '--force', worktreePath].map(shellQuote).join(' '), { timeoutMs: 60000, multiplex: true, extraSshArgs: opts.extraSshArgs });
199
199
  if (rm.code !== 0 && /is not a working tree/.test(rm.stderr + rm.stdout)) {
200
200
  sshExec(target, ['git', '-C', gitRoot, 'worktree', 'prune'].map(shellQuote).join(' '), {
201
201
  timeoutMs: 30000,
202
202
  multiplex: true,
203
+ extraSshArgs: opts.extraSshArgs,
203
204
  });
204
205
  }
205
206
  if (deleteBranch) {
@@ -207,6 +208,7 @@ export function removeRemoteWorktree(target, repoPath, worktreeName, deleteBranc
207
208
  sshExec(target, ['git', '-C', gitRoot, 'branch', '-D', branchName].map(shellQuote).join(' '), {
208
209
  timeoutMs: 20000,
209
210
  multiplex: true,
211
+ extraSshArgs: opts.extraSshArgs,
210
212
  });
211
213
  }
212
214
  }
@@ -525,6 +525,7 @@ export declare function syncResourcesToVersion(agent: AgentId, version: string,
525
525
  projectDir?: string;
526
526
  cwd?: string;
527
527
  force?: boolean;
528
+ available?: AvailableResources;
528
529
  }): SyncResult;
529
530
  /**
530
531
  * Get the effective HOME directory for an agent.
@@ -346,11 +346,21 @@ function skillDirsMatch(src, dest) {
346
346
  return false;
347
347
  }
348
348
  else {
349
- if (!fs.existsSync(destPath))
349
+ // Stat-first (RUSH-2320 #2): size mismatch = definitive miss, no reads.
350
+ // Equal mtimes across different trees are accidental — content-compare
351
+ // whenever sizes match.
352
+ let srcStat;
353
+ let destStat;
354
+ try {
355
+ srcStat = fs.statSync(srcPath);
356
+ destStat = fs.statSync(destPath);
357
+ }
358
+ catch {
350
359
  return false;
351
- const srcContent = fs.readFileSync(srcPath, 'utf-8');
352
- const destContent = fs.readFileSync(destPath, 'utf-8');
353
- if (srcContent !== destContent)
360
+ }
361
+ if (srcStat.size !== destStat.size)
362
+ return false;
363
+ if (fs.readFileSync(srcPath, 'utf-8') !== fs.readFileSync(destPath, 'utf-8'))
354
364
  return false;
355
365
  }
356
366
  }
@@ -2580,7 +2590,28 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2580
2590
  // Extra DotAgent repos registered via `agents repo add`. Looked up last so
2581
2591
  // project/user/system repos win on name collisions.
2582
2592
  const extraRepos = getEnabledExtraRepos();
2583
- const available = getAvailableResources(cwd);
2593
+ // Project-layer fan-out always runs — even on the early guard hit — so the
2594
+ // `projectSkipped` contract is preserved for callers (RUSH-2320 #4).
2595
+ if (projectAgentsDir) {
2596
+ result.projectSkipped = syncProjectResourcesToAgent(agent, version, projectAgentsDir).skipped;
2597
+ }
2598
+ // Fast guard BEFORE getAvailableResources / pattern expansion /
2599
+ // ensureVersionResourcePatterns. Guard needs only loadManifest + isStale
2600
+ // (~6 ms); the work that used to sit ahead of it was ~12.5 ms of the 21.5 ms
2601
+ // guard-hit path and is discarded when nothing changed (RUSH-2320 #4).
2602
+ // Behavior note: a valid manifest implies a prior full sync already wrote
2603
+ // version resource patterns, so skipping ensureVersionResourcePatterns on
2604
+ // the hit path is safe in practice but is not byte-identical to the old
2605
+ // order (that call always wrote missing pattern defaults).
2606
+ if (!userPassedSelection && !options.force) {
2607
+ const manifest = loadManifest(agent, version);
2608
+ if (manifest && !isStale(manifest, agent, version, cwd)) {
2609
+ return { ...result };
2610
+ }
2611
+ }
2612
+ // Prefer a caller-supplied inventory (refresh already built one) so multi-
2613
+ // version fan-out does not re-scan resource trees per version (RUSH-2320 #5).
2614
+ const available = options.available ?? getAvailableResources(cwd);
2584
2615
  // Write default resource selection patterns for this version (idempotent —
2585
2616
  // only sets fields that aren't already present, preserving user edits).
2586
2617
  {
@@ -2640,21 +2671,6 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2640
2671
  }
2641
2672
  }
2642
2673
  }
2643
- if (projectAgentsDir) {
2644
- result.projectSkipped = syncProjectResourcesToAgent(agent, version, projectAgentsDir).skipped;
2645
- }
2646
- // Fast guard: skip the entire sync when the caller requested a full sync and
2647
- // nothing has changed since the last full sync. Pattern-derived selections
2648
- // still count as full syncs because they are the persisted intended scope,
2649
- // not a one-off caller override.
2650
- if (!userPassedSelection && !options.force) {
2651
- const manifest = loadManifest(agent, version);
2652
- if (manifest && !isStale(manifest, agent, version, cwd)) {
2653
- // Nothing synced, but the project sync above already ran — carry its
2654
- // skipped files out so the caller can still report them.
2655
- return { ...result };
2656
- }
2657
- }
2658
2674
  // Helper: remove a path (symlink or real) if it exists
2659
2675
  const removePath = (p) => {
2660
2676
  try {
@@ -3023,9 +3039,11 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
3023
3039
  // launch can skip the slow path. Pattern-derived selections still count as
3024
3040
  // "full" — the agents.yaml patterns describe the intended scope, not a
3025
3041
  // one-off override, so the resulting state matches what the manifest
3026
- // records as the synced set.
3042
+ // records as the synced set. Carry forward still-fresh fingerprints from
3043
+ // the previous manifest so we do not re-hash an unchanged tree (RUSH-2320 #3).
3027
3044
  if (!userPassedSelection) {
3028
- const manifest = buildSyncManifest(agent, version, cwd);
3045
+ const previous = loadManifest(agent, version);
3046
+ const manifest = buildSyncManifest(agent, version, cwd, previous);
3029
3047
  manifest.writtenCommands = writtenCommands;
3030
3048
  saveManifest(agent, version, manifest);
3031
3049
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.27",
3
+ "version": "1.22.28",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -54,6 +54,8 @@
54
54
  "changelog": "bun scripts/gen-changelog.ts",
55
55
  "start": "node dist/index.js",
56
56
  "test": "node ./node_modules/vitest/vitest.mjs run",
57
+ "bench": "node ./node_modules/vitest/vitest.mjs bench --run",
58
+ "typecheck:bench": "tsc --noEmit --ignoreConfig --skipLibCheck --module esnext --moduleResolution bundler --target es2022 --strict --esModuleInterop --lib es2022 --types node src/lib/*.bench.ts src/lib/**/*.bench.ts",
57
59
  "test:remote": "scripts/sandbox.sh 'bun install && bun run build && bun run test'",
58
60
  "verify-docs": "scripts/verify-docs.sh",
59
61
  "test:watch": "node ./node_modules/vitest/vitest.mjs"
@@ -1 +0,0 @@
1
- export {};