@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.
- package/CHANGELOG.md +55 -0
- package/README.md +62 -26
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +54 -10
- package/dist/commands/hosts.js +4 -3
- package/dist/commands/perf.d.ts +10 -0
- package/dist/commands/perf.js +14 -6
- package/dist/commands/projects.js +24 -10
- package/dist/commands/routines.js +31 -4
- package/dist/commands/run-account-picker.d.ts +37 -0
- package/dist/commands/run-account-picker.js +101 -17
- package/dist/commands/secrets.js +86 -2
- package/dist/commands/sessions.d.ts +26 -6
- package/dist/commands/sessions.js +46 -12
- package/dist/commands/teams.js +19 -8
- package/dist/lib/activity.js +45 -99
- package/dist/lib/claude-account-token.js +38 -11
- package/dist/lib/cloud/host.js +1 -0
- package/dist/lib/devices/resolve-target.d.ts +1 -0
- package/dist/lib/devices/resolve-target.js +9 -2
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/events.js +2 -2
- package/dist/lib/git.d.ts +9 -3
- package/dist/lib/git.js +39 -22
- package/dist/lib/hooks/profile.d.ts +12 -1
- package/dist/lib/hooks/profile.js +6 -1
- package/dist/lib/hooks.js +4 -1
- package/dist/lib/hosts/dispatch.d.ts +10 -0
- package/dist/lib/hosts/dispatch.js +78 -22
- package/dist/lib/hosts/logs.js +11 -1
- package/dist/lib/hosts/passthrough.d.ts +5 -0
- package/dist/lib/hosts/passthrough.js +5 -2
- package/dist/lib/hosts/progress.d.ts +10 -0
- package/dist/lib/hosts/progress.js +37 -5
- package/dist/lib/hosts/ready.d.ts +41 -2
- package/dist/lib/hosts/ready.js +100 -12
- package/dist/lib/hosts/reconcile.d.ts +2 -1
- package/dist/lib/hosts/reconcile.js +13 -5
- package/dist/lib/hosts/reconnect.js +5 -3
- package/dist/lib/hosts/registry.js +1 -0
- package/dist/lib/hosts/tasks.d.ts +1 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/snapshot.d.ts +16 -0
- package/dist/lib/menubar/snapshot.js +37 -3
- package/dist/lib/models.d.ts +18 -11
- package/dist/lib/models.js +19 -15
- package/dist/lib/perf/db.js +16 -3
- package/dist/lib/perf/types.d.ts +12 -1
- package/dist/lib/project-probe.d.ts +22 -3
- package/dist/lib/project-probe.js +105 -17
- package/dist/lib/project-status.d.ts +9 -0
- package/dist/lib/project-status.js +15 -0
- package/dist/lib/refresh.js +22 -11
- package/dist/lib/rotate.d.ts +22 -0
- package/dist/lib/rotate.js +26 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/agent.d.ts +19 -3
- package/dist/lib/secrets/agent.js +58 -20
- package/dist/lib/secrets/audit.d.ts +1 -1
- package/dist/lib/secrets/audit.js +2 -0
- package/dist/lib/secrets/bundles.d.ts +3 -3
- package/dist/lib/secrets/bundles.js +15 -5
- package/dist/lib/secrets/filestore.d.ts +2 -0
- package/dist/lib/secrets/filestore.js +4 -0
- package/dist/lib/secrets/session-store.d.ts +7 -0
- package/dist/lib/secrets/session-store.js +21 -0
- package/dist/lib/session/active.d.ts +49 -0
- package/dist/lib/session/active.js +175 -11
- package/dist/lib/session/bash-command.d.ts +16 -0
- package/dist/lib/session/bash-command.js +65 -0
- package/dist/lib/session/db.d.ts +4 -0
- package/dist/lib/session/db.js +32 -15
- package/dist/lib/session/remote-bundle.js +1 -1
- package/dist/lib/staleness/detectors/skills.d.ts +2 -0
- package/dist/lib/staleness/detectors/skills.js +17 -2
- package/dist/lib/staleness/index.d.ts +10 -1
- package/dist/lib/staleness/index.js +28 -3
- package/dist/lib/teams/agents.d.ts +1 -0
- package/dist/lib/teams/agents.js +38 -11
- package/dist/lib/teams/remoteWorktree.d.ts +11 -7
- package/dist/lib/teams/remoteWorktree.js +29 -27
- package/dist/lib/usage.js +18 -10
- package/dist/lib/versions.d.ts +1 -0
- package/dist/lib/versions.js +40 -22
- package/package.json +3 -1
- package/dist/lib/exec.bench.d.ts +0 -1
- package/dist/lib/exec.bench.js +0 -186
package/dist/lib/teams/agents.js
CHANGED
|
@@ -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, {
|
|
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
|
-
|
|
1644
|
-
//
|
|
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, {
|
|
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
|
|
1868
|
-
|
|
1869
|
-
|
|
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
|
|
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
|
|
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
|
}
|
package/dist/lib/usage.js
CHANGED
|
@@ -1186,29 +1186,37 @@ async function readLatestCodexRateLimits(filePath) {
|
|
|
1186
1186
|
}
|
|
1187
1187
|
/** Normalize Codex rate-limit windows into the common UsageWindow shape. */
|
|
1188
1188
|
function normalizeCodexWindows(rateLimits) {
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
const secondary = normalizeCodexWindow(rateLimits.secondary, 'week', 'Current week', 'W');
|
|
1194
|
-
if (secondary)
|
|
1195
|
-
windows.push(secondary);
|
|
1196
|
-
return windows;
|
|
1189
|
+
return [rateLimits.primary, rateLimits.secondary]
|
|
1190
|
+
.map(normalizeCodexWindow)
|
|
1191
|
+
.filter((window) => window !== null)
|
|
1192
|
+
.sort((a, b) => (a.windowMinutes ?? 0) - (b.windowMinutes ?? 0));
|
|
1197
1193
|
}
|
|
1198
1194
|
/** Normalize a single Codex rate-limit window. */
|
|
1199
|
-
function normalizeCodexWindow(window
|
|
1195
|
+
function normalizeCodexWindow(window) {
|
|
1200
1196
|
const usedPercent = normalizePercent(window?.used_percent);
|
|
1201
1197
|
if (usedPercent === null)
|
|
1202
1198
|
return null;
|
|
1199
|
+
const windowMinutes = normalizeWindowMinutes(window?.window_minutes);
|
|
1200
|
+
const { key, label, shortLabel } = classifyCodexWindow(windowMinutes);
|
|
1203
1201
|
return {
|
|
1204
1202
|
key,
|
|
1205
1203
|
label,
|
|
1206
1204
|
shortLabel,
|
|
1207
1205
|
usedPercent,
|
|
1208
1206
|
resetsAt: parseDateValue(window?.resets_at),
|
|
1209
|
-
windowMinutes
|
|
1207
|
+
windowMinutes,
|
|
1210
1208
|
};
|
|
1211
1209
|
}
|
|
1210
|
+
/** Codex assigns quota windows to primary/secondary by plan, so duration carries their meaning. */
|
|
1211
|
+
function classifyCodexWindow(windowMinutes) {
|
|
1212
|
+
if (windowMinutes !== null && windowMinutes >= 28 * 24 * 60) {
|
|
1213
|
+
return { key: 'month', label: 'Current month', shortLabel: 'M' };
|
|
1214
|
+
}
|
|
1215
|
+
if (windowMinutes !== null && windowMinutes >= 7 * 24 * 60) {
|
|
1216
|
+
return { key: 'week', label: 'Current week', shortLabel: 'W' };
|
|
1217
|
+
}
|
|
1218
|
+
return { key: 'session', label: 'Current session', shortLabel: 'S' };
|
|
1219
|
+
}
|
|
1212
1220
|
/** Normalize Claude API usage windows into the common UsageWindow shape. */
|
|
1213
1221
|
function normalizeClaudeWindows(data) {
|
|
1214
1222
|
const windows = [
|
package/dist/lib/versions.d.ts
CHANGED
|
@@ -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.
|
package/dist/lib/versions.js
CHANGED
|
@@ -346,11 +346,21 @@ function skillDirsMatch(src, dest) {
|
|
|
346
346
|
return false;
|
|
347
347
|
}
|
|
348
348
|
else {
|
|
349
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
3
|
+
"version": "1.22.29",
|
|
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"
|
package/dist/lib/exec.bench.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|