@phnx-labs/agents-cli 1.20.49 → 1.20.50
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 +7 -0
- package/README.md +3 -0
- package/dist/commands/doctor.js +133 -2
- package/dist/commands/teams.js +217 -70
- package/dist/lib/exec.js +12 -4
- package/dist/lib/hosts/passthrough.js +30 -1
- package/dist/lib/hosts/progress.d.ts +31 -0
- package/dist/lib/hosts/progress.js +35 -0
- package/dist/lib/hosts/remote-cmd.d.ts +1 -1
- package/dist/lib/hosts/remote-cmd.js +13 -3
- package/dist/lib/shims.d.ts +17 -0
- package/dist/lib/shims.js +29 -0
- package/dist/lib/teams/agents.d.ts +108 -1
- package/dist/lib/teams/agents.js +511 -11
- package/dist/lib/teams/api.d.ts +7 -1
- package/dist/lib/teams/api.js +5 -2
- package/dist/lib/teams/registry.d.ts +17 -0
- package/dist/lib/teams/registry.js +2 -0
- package/dist/lib/teams/remoteWorktree.d.ts +57 -0
- package/dist/lib/teams/remoteWorktree.js +213 -0
- package/dist/lib/teams/scheduler.d.ts +29 -0
- package/dist/lib/teams/scheduler.js +78 -0
- package/dist/lib/teams/supervisor.js +7 -0
- package/dist/lib/versions.d.ts +7 -0
- package/dist/lib/versions.js +42 -1
- package/package.json +1 -1
package/dist/lib/teams/api.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface SpawnResult {
|
|
|
20
20
|
task_type?: TaskType | null;
|
|
21
21
|
cloud_provider?: string | null;
|
|
22
22
|
cloud_session_id?: string | null;
|
|
23
|
+
/** Device name the teammate runs on for a distributed (--on) teammate; null for local. */
|
|
24
|
+
host?: string | null;
|
|
23
25
|
}
|
|
24
26
|
/** Detailed status of a single teammate, including file ops, commands, and a cursor for delta polling. */
|
|
25
27
|
export interface AgentStatusDetail {
|
|
@@ -50,6 +52,8 @@ export interface AgentStatusDetail {
|
|
|
50
52
|
name?: string | null;
|
|
51
53
|
after?: string[];
|
|
52
54
|
task_type?: TaskType | null;
|
|
55
|
+
/** Device name the teammate runs on for a distributed (--on) teammate; null for local. */
|
|
56
|
+
host?: string | null;
|
|
53
57
|
}
|
|
54
58
|
/** Aggregated status of all teammates in a task, with per-status counts and a global cursor. */
|
|
55
59
|
export interface TaskStatusResult {
|
|
@@ -107,6 +111,8 @@ export interface AgentStatusSummary {
|
|
|
107
111
|
bash_commands: string[];
|
|
108
112
|
/** Last 3 messages, each body trimmed to ~400 chars. */
|
|
109
113
|
last_messages: string[];
|
|
114
|
+
/** Device name for a distributed (--on) teammate; null for local. */
|
|
115
|
+
host: string | null;
|
|
110
116
|
/** ISO timestamp — feed back via --since for delta polling. */
|
|
111
117
|
cursor: string;
|
|
112
118
|
}
|
|
@@ -156,7 +162,7 @@ export interface TasksResult {
|
|
|
156
162
|
tasks: TaskInfo[];
|
|
157
163
|
}
|
|
158
164
|
/** Spawn a new teammate in a task and return its initial metadata. */
|
|
159
|
-
export declare function handleSpawn(manager: AgentManager, taskName: string, agentType: AgentType, prompt: string, cwd: string | null, mode: string | null, effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'auto' | null, parentSessionId?: string | null, workspaceDir?: string | null, version?: string | null, name?: string | null, after?: string[], model?: string | null, envOverrides?: Record<string, string> | null, taskType?: TaskType | null, cloudProvider?: string | null, cloudSessionId?: string | null, cloudRepo?: string | null, cloudBranch?: string | null, worktreeName?: string | null, worktreePath?: string | null, profileName?: string | null): Promise<SpawnResult>;
|
|
165
|
+
export declare function handleSpawn(manager: AgentManager, taskName: string, agentType: AgentType, prompt: string, cwd: string | null, mode: string | null, effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'auto' | null, parentSessionId?: string | null, workspaceDir?: string | null, version?: string | null, name?: string | null, after?: string[], model?: string | null, envOverrides?: Record<string, string> | null, taskType?: TaskType | null, cloudProvider?: string | null, cloudSessionId?: string | null, cloudRepo?: string | null, cloudBranch?: string | null, worktreeName?: string | null, worktreePath?: string | null, profileName?: string | null, hostName?: string | null, hostTarget?: string | null, repoPath?: string | null): Promise<SpawnResult>;
|
|
160
166
|
/** Retrieve the current status of all teammates in a task, with optional timestamp-based delta filtering. */
|
|
161
167
|
export declare function handleStatus(manager: AgentManager, taskName: string | null | undefined, filter?: string, since?: string, // Optional ISO timestamp - return only events after this time
|
|
162
168
|
parentSessionId?: string | null): Promise<TaskStatusResult>;
|
package/dist/lib/teams/api.js
CHANGED
|
@@ -121,6 +121,7 @@ export function toAgentStatusSummary(detail) {
|
|
|
121
121
|
last_messages: detail.last_messages
|
|
122
122
|
.slice(-SUMMARY_MAX_MESSAGES)
|
|
123
123
|
.map((m) => trimMessage(m)),
|
|
124
|
+
host: detail.host ?? null,
|
|
124
125
|
cursor: detail.cursor,
|
|
125
126
|
};
|
|
126
127
|
}
|
|
@@ -134,7 +135,7 @@ export function toTaskStatusSummary(result) {
|
|
|
134
135
|
};
|
|
135
136
|
}
|
|
136
137
|
/** Spawn a new teammate in a task and return its initial metadata. */
|
|
137
|
-
export async function handleSpawn(manager, taskName, agentType, prompt, cwd, mode, effort = 'medium', parentSessionId = null, workspaceDir = null, version = null, name = null, after = [], model = null, envOverrides = null, taskType = null, cloudProvider = null, cloudSessionId = null, cloudRepo = null, cloudBranch = null, worktreeName = null, worktreePath = null, profileName = null) {
|
|
138
|
+
export async function handleSpawn(manager, taskName, agentType, prompt, cwd, mode, effort = 'medium', parentSessionId = null, workspaceDir = null, version = null, name = null, after = [], model = null, envOverrides = null, taskType = null, cloudProvider = null, cloudSessionId = null, cloudRepo = null, cloudBranch = null, worktreeName = null, worktreePath = null, profileName = null, hostName = null, hostTarget = null, repoPath = null) {
|
|
138
139
|
const defaultMode = manager.getDefaultMode();
|
|
139
140
|
const resolvedMode = resolveMode(mode, defaultMode);
|
|
140
141
|
const resolvedEffort = effort ?? 'medium';
|
|
@@ -159,7 +160,7 @@ export async function handleSpawn(manager, taskName, agentType, prompt, cwd, mod
|
|
|
159
160
|
throw new Error(`[budget] BLOCKED teammate "${taskName}" (${agentType}): ${gate.decision.reason}`);
|
|
160
161
|
}
|
|
161
162
|
}
|
|
162
|
-
const agent = await manager.spawn(taskName, agentType, prompt, cwd, resolvedMode, resolvedEffort, parentSessionId, workspaceDir, version, name, after, model, envOverrides, taskType, cloudProvider, cloudSessionId, cloudRepo, cloudBranch, worktreeName, worktreePath, profileName);
|
|
163
|
+
const agent = await manager.spawn(taskName, agentType, prompt, cwd, resolvedMode, resolvedEffort, parentSessionId, workspaceDir, version, name, after, model, envOverrides, taskType, cloudProvider, cloudSessionId, cloudRepo, cloudBranch, worktreeName, worktreePath, profileName, hostName, hostTarget, repoPath);
|
|
163
164
|
debug(`[spawn] Spawned ${agentType} agent ${agent.agentId} for task "${taskName}"`);
|
|
164
165
|
return {
|
|
165
166
|
task_name: taskName,
|
|
@@ -175,6 +176,7 @@ export async function handleSpawn(manager, taskName, agentType, prompt, cwd, mod
|
|
|
175
176
|
task_type: agent.taskType,
|
|
176
177
|
cloud_provider: agent.cloudProvider,
|
|
177
178
|
cloud_session_id: agent.cloudSessionId,
|
|
179
|
+
host: agent.hostName,
|
|
178
180
|
};
|
|
179
181
|
}
|
|
180
182
|
/** Retrieve the current status of all teammates in a task, with optional timestamp-based delta filtering. */
|
|
@@ -245,6 +247,7 @@ parentSessionId) {
|
|
|
245
247
|
name: agent.name,
|
|
246
248
|
after: agent.after,
|
|
247
249
|
task_type: agent.taskType,
|
|
250
|
+
host: agent.hostName,
|
|
248
251
|
mode: agent.mode,
|
|
249
252
|
cloud_session_id: agent.cloudSessionId,
|
|
250
253
|
cloud_provider: agent.cloudProvider,
|
|
@@ -5,6 +5,19 @@ export interface TeamMeta {
|
|
|
5
5
|
enable_worktrees?: boolean;
|
|
6
6
|
/** Shared worktree path for all teammates (mutually exclusive with enable_worktrees). */
|
|
7
7
|
use_worktree?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Distributed teams: the pool of devices this team may auto-schedule teammates
|
|
10
|
+
* onto (from `agents devices`). One device → the whole team runs there; many →
|
|
11
|
+
* unpinned teammates are least-loaded-scheduled across them; empty/absent →
|
|
12
|
+
* every teammate runs locally (today's behavior).
|
|
13
|
+
*/
|
|
14
|
+
devices?: string[];
|
|
15
|
+
/**
|
|
16
|
+
* Distributed teams: how each device gets the code — a git URL to clone or a
|
|
17
|
+
* path that already exists on the host. Defaults to the local checkout's
|
|
18
|
+
* `origin` at create time. Used by `ensureRemoteRepo` to provision the repo.
|
|
19
|
+
*/
|
|
20
|
+
repo?: string;
|
|
8
21
|
}
|
|
9
22
|
/** Map of team name to team metadata. */
|
|
10
23
|
export type TeamRegistry = Record<string, TeamMeta>;
|
|
@@ -20,6 +33,10 @@ export interface CreateTeamOptions {
|
|
|
20
33
|
enableWorktrees?: boolean;
|
|
21
34
|
/** Path to an existing worktree for all teammates to share. */
|
|
22
35
|
useWorktree?: string;
|
|
36
|
+
/** Distributed pool of devices this team may auto-schedule teammates onto. */
|
|
37
|
+
devices?: string[];
|
|
38
|
+
/** How each device gets the code (git URL to clone, or a path on the host). */
|
|
39
|
+
repo?: string;
|
|
23
40
|
}
|
|
24
41
|
/** Create a new team. Throws if a team with the same name already exists. */
|
|
25
42
|
export declare function createTeam(name: string, options?: CreateTeamOptions): Promise<TeamMeta>;
|
|
@@ -109,6 +109,8 @@ export async function createTeam(name, options) {
|
|
|
109
109
|
...(options?.description ? { description: options.description } : {}),
|
|
110
110
|
...(options?.enableWorktrees ? { enable_worktrees: true } : {}),
|
|
111
111
|
...(options?.useWorktree ? { use_worktree: options.useWorktree } : {}),
|
|
112
|
+
...(options?.devices && options.devices.length ? { devices: options.devices } : {}),
|
|
113
|
+
...(options?.repo ? { repo: options.repo } : {}),
|
|
112
114
|
};
|
|
113
115
|
reg[name] = m;
|
|
114
116
|
await saveTeams(reg);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render a path for safe interpolation into a REMOTE shell, expanding a leading
|
|
3
|
+
* `~`/`~/` to `"$HOME"` so it resolves on the host. A plain `shellQuote('~/x')`
|
|
4
|
+
* single-quotes the tilde and the remote shell leaves it literal (the same
|
|
5
|
+
* reason dispatch.ts uses `$HOME`, not `~`). Everything after the tilde is still
|
|
6
|
+
* single-quoted, so odd path characters stay safe.
|
|
7
|
+
*/
|
|
8
|
+
export declare function remotePathExpr(p: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the ABSOLUTE git top-level for a (possibly `~`-relative) repo path on
|
|
11
|
+
* the host, or null when it isn't a git repo / the host is unreachable. Used by
|
|
12
|
+
* `ensureRemoteRepo` to both VALIDATE and CANONICALIZE a repo location, so every
|
|
13
|
+
* downstream remote command (launch `cd`, worktree create, polling) works from an
|
|
14
|
+
* absolute path with no tilde-expansion hazard.
|
|
15
|
+
*/
|
|
16
|
+
export declare function resolveRemoteRepoRoot(target: string, repoPath: string): string | null;
|
|
17
|
+
/** True when `repoPath` is a git working tree on the host. */
|
|
18
|
+
export declare function isRemoteGitRepo(target: string, repoPath: string): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the host repo's default branch name (origin/HEAD → `main`/`master`/…).
|
|
21
|
+
* Falls back to `main` when origin/HEAD isn't set, matching the local recipe's
|
|
22
|
+
* `remote set-head` step.
|
|
23
|
+
*/
|
|
24
|
+
export declare function remoteDefaultBranch(target: string, repoPath: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* Create a worktree on the host for a teammate, branched off the freshly-fetched
|
|
27
|
+
* default branch. Returns the absolute worktree path on the host.
|
|
28
|
+
*
|
|
29
|
+
* Mirrors local `createWorktree`, with two deliberate differences: it fetches
|
|
30
|
+
* origin first (the orchestrator can't reach into the host's tree to do it), and
|
|
31
|
+
* it bases the branch on `origin/<default>` rather than local `HEAD` so a stale
|
|
32
|
+
* checkout on the host can't fork the teammate off old code.
|
|
33
|
+
*/
|
|
34
|
+
export declare function createRemoteWorktree(target: string, repoPath: string, worktreeName: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* Ensure the team's repo is present on the host and return its absolute git
|
|
37
|
+
* root. The canonical location is `~/.agents/repos/<slug>` (slug = sanitized
|
|
38
|
+
* team name). Resolution, in order:
|
|
39
|
+
*
|
|
40
|
+
* 1. `~/.agents/repos/<slug>` already a git repo → `git fetch origin`, use it.
|
|
41
|
+
* 2. `repo` is a path that exists on the host as a git repo → use it in place.
|
|
42
|
+
* 3. `repo` is a URL (or a non-existent path) → `git clone` into the
|
|
43
|
+
* canonical location, then use it.
|
|
44
|
+
*
|
|
45
|
+
* `$HOME`-prefixed paths interpolate UNQUOTED (remotePathExpr) so the host shell
|
|
46
|
+
* expands `$HOME` — single-quoting the tilde would defeat expansion, the same
|
|
47
|
+
* hazard dispatch.ts avoids.
|
|
48
|
+
*/
|
|
49
|
+
export declare function ensureRemoteRepo(target: string, repo: string, slug: string): string;
|
|
50
|
+
/** True when the host worktree has uncommitted changes (staged or unstaged). */
|
|
51
|
+
export declare function remoteWorktreeDirty(target: string, worktreePath: string): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Remove a host worktree and (optionally) its branch. Best-effort: prunes on a
|
|
54
|
+
* "not a working tree" error and ignores a missing branch, matching local
|
|
55
|
+
* `removeWorktree` semantics so cleanup never throws on a half-gone worktree.
|
|
56
|
+
*/
|
|
57
|
+
export declare function removeRemoteWorktree(target: string, repoPath: string, worktreeName: string, deleteBranch?: boolean): void;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote git-worktree utilities for distributed teams — the SSH analog of
|
|
3
|
+
* `teams/worktree.ts`. Each function runs the SAME git argv the local helper
|
|
4
|
+
* runs, but over SSH on the host that owns the teammate's checkout, so a
|
|
5
|
+
* distributed teammate gets its own branch/worktree without a shared local tree.
|
|
6
|
+
*
|
|
7
|
+
* The repo is provisioned on the host by `ensureRemoteRepo` (reuse an existing
|
|
8
|
+
* checkout or clone the team's `--repo`); we fetch origin and branch the worktree
|
|
9
|
+
* off `origin/<default>` so remote teammates build on the freshly-fetched default
|
|
10
|
+
* branch, mirroring the local git-workflow.
|
|
11
|
+
*/
|
|
12
|
+
import { sshExec, shellQuote, assertValidSshTarget } from '../ssh-exec.js';
|
|
13
|
+
import { assertSafeGitTransport } from '../git.js';
|
|
14
|
+
// Same allowlist as the local helper — worktree names land in a branch name and
|
|
15
|
+
// a path, so keep them injection-safe (no shell metacharacters).
|
|
16
|
+
const WORKTREE_NAME_RE = /^[A-Za-z0-9_-]+$/;
|
|
17
|
+
function assertName(worktreeName) {
|
|
18
|
+
if (!WORKTREE_NAME_RE.test(worktreeName)) {
|
|
19
|
+
throw new Error(`Invalid worktree name: ${worktreeName}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Render a path for safe interpolation into a REMOTE shell, expanding a leading
|
|
24
|
+
* `~`/`~/` to `"$HOME"` so it resolves on the host. A plain `shellQuote('~/x')`
|
|
25
|
+
* single-quotes the tilde and the remote shell leaves it literal (the same
|
|
26
|
+
* reason dispatch.ts uses `$HOME`, not `~`). Everything after the tilde is still
|
|
27
|
+
* single-quoted, so odd path characters stay safe.
|
|
28
|
+
*/
|
|
29
|
+
export function remotePathExpr(p) {
|
|
30
|
+
if (p === '~')
|
|
31
|
+
return '"$HOME"';
|
|
32
|
+
if (p.startsWith('~/'))
|
|
33
|
+
return '"$HOME"/' + shellQuote(p.slice(2));
|
|
34
|
+
return shellQuote(p);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the ABSOLUTE git top-level for a (possibly `~`-relative) repo path on
|
|
38
|
+
* the host, or null when it isn't a git repo / the host is unreachable. Used by
|
|
39
|
+
* `ensureRemoteRepo` to both VALIDATE and CANONICALIZE a repo location, so every
|
|
40
|
+
* downstream remote command (launch `cd`, worktree create, polling) works from an
|
|
41
|
+
* absolute path with no tilde-expansion hazard.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveRemoteRepoRoot(target, repoPath) {
|
|
44
|
+
assertValidSshTarget(target);
|
|
45
|
+
const cmd = `git -C ${remotePathExpr(repoPath)} rev-parse --show-toplevel 2>/dev/null`;
|
|
46
|
+
const res = sshExec(target, cmd, { timeoutMs: 15000, multiplex: true });
|
|
47
|
+
const root = res.stdout.trim();
|
|
48
|
+
return res.code === 0 && root ? root : null;
|
|
49
|
+
}
|
|
50
|
+
/** Run one git command in `repoPath` on the host; throw with stderr on failure. */
|
|
51
|
+
function remoteGit(target, repoPath, args, timeoutMs = 60000) {
|
|
52
|
+
const cmd = ['git', '-C', repoPath, ...args].map(shellQuote).join(' ');
|
|
53
|
+
const res = sshExec(target, cmd, { timeoutMs, multiplex: true });
|
|
54
|
+
if (res.code !== 0) {
|
|
55
|
+
throw new Error(`remote git failed on ${target} (${args[0]}): ${(res.stderr || res.stdout).trim() || 'ssh error'}`);
|
|
56
|
+
}
|
|
57
|
+
return res.stdout.trim();
|
|
58
|
+
}
|
|
59
|
+
/** True when `repoPath` is a git working tree on the host. */
|
|
60
|
+
export function isRemoteGitRepo(target, repoPath) {
|
|
61
|
+
assertValidSshTarget(target);
|
|
62
|
+
// remotePathExpr so a `~`/`$HOME`-relative path (the canonical repos dir) expands
|
|
63
|
+
// on the host — plain shellQuote would single-quote the tilde into a literal,
|
|
64
|
+
// making an existing checkout look absent and forcing a doomed re-clone.
|
|
65
|
+
const cmd = `git -C ${remotePathExpr(repoPath)} rev-parse --git-dir 2>/dev/null`;
|
|
66
|
+
const res = sshExec(target, cmd, { timeoutMs: 15000, multiplex: true });
|
|
67
|
+
return res.code === 0;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Resolve the host repo's default branch name (origin/HEAD → `main`/`master`/…).
|
|
71
|
+
* Falls back to `main` when origin/HEAD isn't set, matching the local recipe's
|
|
72
|
+
* `remote set-head` step.
|
|
73
|
+
*/
|
|
74
|
+
export function remoteDefaultBranch(target, repoPath) {
|
|
75
|
+
assertValidSshTarget(target);
|
|
76
|
+
try {
|
|
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']);
|
|
80
|
+
return ref.replace(/^origin\//, '') || 'main';
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return 'main';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Create a worktree on the host for a teammate, branched off the freshly-fetched
|
|
88
|
+
* default branch. Returns the absolute worktree path on the host.
|
|
89
|
+
*
|
|
90
|
+
* Mirrors local `createWorktree`, with two deliberate differences: it fetches
|
|
91
|
+
* origin first (the orchestrator can't reach into the host's tree to do it), and
|
|
92
|
+
* it bases the branch on `origin/<default>` rather than local `HEAD` so a stale
|
|
93
|
+
* checkout on the host can't fork the teammate off old code.
|
|
94
|
+
*/
|
|
95
|
+
export function createRemoteWorktree(target, repoPath, worktreeName) {
|
|
96
|
+
assertValidSshTarget(target);
|
|
97
|
+
assertName(worktreeName);
|
|
98
|
+
const gitRoot = remoteGit(target, repoPath, ['rev-parse', '--show-toplevel']);
|
|
99
|
+
const base = remoteDefaultBranch(target, gitRoot);
|
|
100
|
+
const worktreePath = `${gitRoot}/.agents/worktrees/${worktreeName}`;
|
|
101
|
+
const branchName = `agents/${worktreeName}`;
|
|
102
|
+
remoteGit(target, gitRoot, ['fetch', 'origin'], 120000);
|
|
103
|
+
remoteGit(target, gitRoot, ['worktree', 'add', '-b', branchName, worktreePath, `origin/${base}`], 120000);
|
|
104
|
+
return worktreePath;
|
|
105
|
+
}
|
|
106
|
+
// Team-name → repos-dir slug: same allowlist a worktree name uses, so it lands
|
|
107
|
+
// safely in a path (`~/.agents/repos/<slug>`) with no shell metacharacters.
|
|
108
|
+
const SLUG_RE = /[^A-Za-z0-9_-]/g;
|
|
109
|
+
/** Sanitize a team name into a repos-directory slug (`[A-Za-z0-9_-]` only). */
|
|
110
|
+
function repoSlug(name) {
|
|
111
|
+
const s = name.replace(SLUG_RE, '-');
|
|
112
|
+
if (!s)
|
|
113
|
+
throw new Error(`Cannot derive a repo slug from team name: ${name}`);
|
|
114
|
+
return s;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Ensure the team's repo is present on the host and return its absolute git
|
|
118
|
+
* root. The canonical location is `~/.agents/repos/<slug>` (slug = sanitized
|
|
119
|
+
* team name). Resolution, in order:
|
|
120
|
+
*
|
|
121
|
+
* 1. `~/.agents/repos/<slug>` already a git repo → `git fetch origin`, use it.
|
|
122
|
+
* 2. `repo` is a path that exists on the host as a git repo → use it in place.
|
|
123
|
+
* 3. `repo` is a URL (or a non-existent path) → `git clone` into the
|
|
124
|
+
* canonical location, then use it.
|
|
125
|
+
*
|
|
126
|
+
* `$HOME`-prefixed paths interpolate UNQUOTED (remotePathExpr) so the host shell
|
|
127
|
+
* expands `$HOME` — single-quoting the tilde would defeat expansion, the same
|
|
128
|
+
* hazard dispatch.ts avoids.
|
|
129
|
+
*/
|
|
130
|
+
export function ensureRemoteRepo(target, repo, slug) {
|
|
131
|
+
assertValidSshTarget(target);
|
|
132
|
+
const safeSlug = repoSlug(slug);
|
|
133
|
+
const canonical = `~/.agents/repos/${safeSlug}`;
|
|
134
|
+
// 1. Canonical checkout already exists → fetch and reuse.
|
|
135
|
+
if (isRemoteGitRepo(target, canonical)) {
|
|
136
|
+
// Best-effort refresh; a fetch failure (offline origin) shouldn't block reuse.
|
|
137
|
+
sshExec(target, `git -C ${remotePathExpr(canonical)} fetch origin`, { timeoutMs: 120000, multiplex: true });
|
|
138
|
+
const root = resolveRemoteRepoRoot(target, canonical);
|
|
139
|
+
if (!root)
|
|
140
|
+
throw new Error(`Repo at ${canonical} on ${target} vanished mid-provision.`);
|
|
141
|
+
return root;
|
|
142
|
+
}
|
|
143
|
+
// 2. `repo` is a path that already exists on the host as a git repo → use it.
|
|
144
|
+
if (repo && !looksLikeUrl(repo)) {
|
|
145
|
+
const existing = resolveRemoteRepoRoot(target, repo);
|
|
146
|
+
if (existing) {
|
|
147
|
+
sshExec(target, `git -C ${remotePathExpr(existing)} fetch origin`, { timeoutMs: 120000, multiplex: true });
|
|
148
|
+
return existing;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// 3. Clone the URL (or a path-that-wasn't-there) into the canonical location.
|
|
152
|
+
if (!repo) {
|
|
153
|
+
throw new Error(`No repo configured for team on ${target}: set \`teams create --repo <url|path>\` ` +
|
|
154
|
+
`or run this teammate from a git checkout so origin can be inferred.`);
|
|
155
|
+
}
|
|
156
|
+
// A `--repo` is a git-transport source that clones ON THE REMOTE HOST — guard it
|
|
157
|
+
// like the local clone path (plugins.ts) does: reject remote-helper transports
|
|
158
|
+
// (`ext::sh -c …` runs arbitrary commands at clone time) and a leading `-`, then
|
|
159
|
+
// pass `--` so a leftover `-` can't be parsed as a git option. shellQuote only
|
|
160
|
+
// blocks *shell* injection and is orthogonal to *git-transport* injection.
|
|
161
|
+
assertSafeGitTransport(repo);
|
|
162
|
+
const clone = sshExec(target, `mkdir -p ${remotePathExpr('~/.agents/repos')} && ` +
|
|
163
|
+
`git clone -- ${shellQuote(repo)} ${remotePathExpr(canonical)}`, { timeoutMs: 600000, multiplex: true });
|
|
164
|
+
if (clone.code !== 0) {
|
|
165
|
+
throw new Error(`git clone ${repo} into ${canonical} on ${target} failed: ` +
|
|
166
|
+
`${(clone.stderr || clone.stdout).trim() || 'ssh error'}`);
|
|
167
|
+
}
|
|
168
|
+
const root = resolveRemoteRepoRoot(target, canonical);
|
|
169
|
+
if (!root)
|
|
170
|
+
throw new Error(`Cloned ${repo} into ${canonical} on ${target} but it isn't a git repo.`);
|
|
171
|
+
return root;
|
|
172
|
+
}
|
|
173
|
+
/** Heuristic: does `repo` look like a git URL (vs a filesystem path)? */
|
|
174
|
+
function looksLikeUrl(repo) {
|
|
175
|
+
return (/^(https?|git|ssh):\/\//.test(repo) ||
|
|
176
|
+
/^[^/\s]+@[^/\s]+:/.test(repo) || // scp-style: git@github.com:owner/repo.git
|
|
177
|
+
repo.startsWith('git@'));
|
|
178
|
+
}
|
|
179
|
+
/** True when the host worktree has uncommitted changes (staged or unstaged). */
|
|
180
|
+
export function remoteWorktreeDirty(target, worktreePath) {
|
|
181
|
+
assertValidSshTarget(target);
|
|
182
|
+
const cmd = ['git', '-C', worktreePath, 'status', '--porcelain'].map(shellQuote).join(' ');
|
|
183
|
+
const res = sshExec(target, cmd, { timeoutMs: 20000, multiplex: true });
|
|
184
|
+
if (res.code !== 0)
|
|
185
|
+
return false;
|
|
186
|
+
return res.stdout.trim().length > 0;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Remove a host worktree and (optionally) its branch. Best-effort: prunes on a
|
|
190
|
+
* "not a working tree" error and ignores a missing branch, matching local
|
|
191
|
+
* `removeWorktree` semantics so cleanup never throws on a half-gone worktree.
|
|
192
|
+
*/
|
|
193
|
+
export function removeRemoteWorktree(target, repoPath, worktreeName, deleteBranch = true) {
|
|
194
|
+
assertValidSshTarget(target);
|
|
195
|
+
assertName(worktreeName);
|
|
196
|
+
const gitRoot = remoteGit(target, repoPath, ['rev-parse', '--show-toplevel']);
|
|
197
|
+
const worktreePath = `${gitRoot}/.agents/worktrees/${worktreeName}`;
|
|
198
|
+
const branchName = `agents/${worktreeName}`;
|
|
199
|
+
const rm = sshExec(target, ['git', '-C', gitRoot, 'worktree', 'remove', '--force', worktreePath].map(shellQuote).join(' '), { timeoutMs: 60000, multiplex: true });
|
|
200
|
+
if (rm.code !== 0 && /is not a working tree/.test(rm.stderr + rm.stdout)) {
|
|
201
|
+
sshExec(target, ['git', '-C', gitRoot, 'worktree', 'prune'].map(shellQuote).join(' '), {
|
|
202
|
+
timeoutMs: 30000,
|
|
203
|
+
multiplex: true,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
if (deleteBranch) {
|
|
207
|
+
// Branch may not exist (removed already, or never created) — ignore failure.
|
|
208
|
+
sshExec(target, ['git', '-C', gitRoot, 'branch', '-D', branchName].map(shellQuote).join(' '), {
|
|
209
|
+
timeoutMs: 20000,
|
|
210
|
+
multiplex: true,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Team fields the placement cascade reads (a subset of TeamMeta). */
|
|
2
|
+
export interface PlacementTeam {
|
|
3
|
+
devices?: string[];
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* A roster entry the load counter reads — the shape any teammate satisfies
|
|
7
|
+
* (AgentProcess included). `status` is compared against `'running'` (the
|
|
8
|
+
* AgentStatus.RUNNING value) without importing the enum, keeping this leaf pure.
|
|
9
|
+
*/
|
|
10
|
+
export interface RosterEntry {
|
|
11
|
+
hostName: string | null;
|
|
12
|
+
status: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Pick the least-loaded device from the pool — the one with the fewest RUNNING
|
|
16
|
+
* teammates currently assigned to it. Ties break by pool order (first wins), so
|
|
17
|
+
* an empty pool fills round-robin-ish as teammates launch. Pure: counts the
|
|
18
|
+
* roster, no I/O.
|
|
19
|
+
*/
|
|
20
|
+
export declare function pickLeastLoaded(devices: string[], roster: RosterEntry[]): string;
|
|
21
|
+
/**
|
|
22
|
+
* Resolve where a teammate runs. Returns `{ device: null }` for a local run
|
|
23
|
+
* (no pin, no pool, or the chosen device is the local machine) and
|
|
24
|
+
* `{ device: <name> }` for a remote placement. See the cascade in the module
|
|
25
|
+
* header.
|
|
26
|
+
*/
|
|
27
|
+
export declare function resolvePlacement(team: PlacementTeam, explicitDevice: string | null, roster: RosterEntry[]): {
|
|
28
|
+
device: string | null;
|
|
29
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Placement scheduler for distributed teams.
|
|
3
|
+
*
|
|
4
|
+
* Decides WHERE an unpinned teammate runs, resolving the create→pin→pool→local
|
|
5
|
+
* cascade from the team's device pool and the live roster. Kept pure and
|
|
6
|
+
* I/O-free (plain data in, a device name or null out) so it is trivially
|
|
7
|
+
* testable and can be called from the hot launch path without SSH round-trips.
|
|
8
|
+
*
|
|
9
|
+
* 1. teammate has an explicit `--device` pin → that device
|
|
10
|
+
* 2. else the team pool has exactly one device → that device (whole team)
|
|
11
|
+
* 3. else the team pool has many devices → least-loaded pick
|
|
12
|
+
* 4. else (no pin, no pool) → null == run local
|
|
13
|
+
*
|
|
14
|
+
* A device whose name equals the local machine id is treated as "local" — it
|
|
15
|
+
* resolves to a null placement so the existing local spawn path runs unchanged,
|
|
16
|
+
* letting the local machine participate in a pool as just another member.
|
|
17
|
+
*/
|
|
18
|
+
import { machineId } from '../session/sync/config.js';
|
|
19
|
+
/** True when `device` names the local machine (case-insensitive). */
|
|
20
|
+
function isLocalDevice(device) {
|
|
21
|
+
return device.toLowerCase() === machineId();
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Pick the least-loaded device from the pool — the one with the fewest RUNNING
|
|
25
|
+
* teammates currently assigned to it. Ties break by pool order (first wins), so
|
|
26
|
+
* an empty pool fills round-robin-ish as teammates launch. Pure: counts the
|
|
27
|
+
* roster, no I/O.
|
|
28
|
+
*/
|
|
29
|
+
export function pickLeastLoaded(devices, roster) {
|
|
30
|
+
if (devices.length === 0) {
|
|
31
|
+
throw new Error('pickLeastLoaded called with an empty device pool');
|
|
32
|
+
}
|
|
33
|
+
const load = new Map();
|
|
34
|
+
for (const d of devices)
|
|
35
|
+
load.set(d, 0);
|
|
36
|
+
for (const r of roster) {
|
|
37
|
+
if (!r.hostName)
|
|
38
|
+
continue;
|
|
39
|
+
if (r.status !== 'running')
|
|
40
|
+
continue;
|
|
41
|
+
if (load.has(r.hostName))
|
|
42
|
+
load.set(r.hostName, (load.get(r.hostName) ?? 0) + 1);
|
|
43
|
+
}
|
|
44
|
+
// Iterate the pool in declared order so the first device wins ties.
|
|
45
|
+
let best = devices[0];
|
|
46
|
+
let bestLoad = load.get(best) ?? 0;
|
|
47
|
+
for (const d of devices) {
|
|
48
|
+
const l = load.get(d) ?? 0;
|
|
49
|
+
if (l < bestLoad) {
|
|
50
|
+
best = d;
|
|
51
|
+
bestLoad = l;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return best;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Resolve where a teammate runs. Returns `{ device: null }` for a local run
|
|
58
|
+
* (no pin, no pool, or the chosen device is the local machine) and
|
|
59
|
+
* `{ device: <name> }` for a remote placement. See the cascade in the module
|
|
60
|
+
* header.
|
|
61
|
+
*/
|
|
62
|
+
export function resolvePlacement(team, explicitDevice, roster) {
|
|
63
|
+
// 1. Explicit pin wins — even without a pool.
|
|
64
|
+
if (explicitDevice) {
|
|
65
|
+
return { device: isLocalDevice(explicitDevice) ? null : explicitDevice };
|
|
66
|
+
}
|
|
67
|
+
const pool = team.devices ?? [];
|
|
68
|
+
// 4. No pool → local, exactly like today.
|
|
69
|
+
if (pool.length === 0)
|
|
70
|
+
return { device: null };
|
|
71
|
+
// 2. Pool of one → the whole team runs there.
|
|
72
|
+
if (pool.length === 1) {
|
|
73
|
+
return { device: isLocalDevice(pool[0]) ? null : pool[0] };
|
|
74
|
+
}
|
|
75
|
+
// 3. Many → least-loaded across the pool.
|
|
76
|
+
const picked = pickLeastLoaded(pool, roster);
|
|
77
|
+
return { device: isLocalDevice(picked) ? null : picked };
|
|
78
|
+
}
|
|
@@ -17,6 +17,13 @@ export async function runSupervisor(mgr, opts) {
|
|
|
17
17
|
// `agents teams add` calls). Without this the supervisor only ever
|
|
18
18
|
// sees teammates it created itself.
|
|
19
19
|
await mgr.rescanFromDisk();
|
|
20
|
+
// Distributed teammates: one ssh-per-host liveness/exit pre-pass BEFORE any
|
|
21
|
+
// polling this wave, so every subsequent per-teammate status read
|
|
22
|
+
// (startReady's roster scan + listByTask below) consumes a cached snapshot
|
|
23
|
+
// instead of each opening its own SSH handshake — no N-round-trips-per-wave
|
|
24
|
+
// blowup at 10+ remote teammates. No-op for all-local teams. Reads the
|
|
25
|
+
// in-memory roster directly, so it doesn't itself trigger a poll.
|
|
26
|
+
await mgr.prefetchRemoteStatus(team);
|
|
20
27
|
const launched = await mgr.startReady(team);
|
|
21
28
|
const all = await mgr.listByTask(team);
|
|
22
29
|
let pending = 0, running = 0, completed = 0, failed = 0;
|
package/dist/lib/versions.d.ts
CHANGED
|
@@ -304,6 +304,13 @@ export declare function getInstalledVersion(agent: AgentId, version: string): Pr
|
|
|
304
304
|
* Deliberately narrow: only the missing-file signature counts. An agent that
|
|
305
305
|
* merely dislikes `--version` (nonzero exit, ordinary error text) or ignores it
|
|
306
306
|
* (times out) must NOT match, so a healthy install is never falsely condemned.
|
|
307
|
+
*
|
|
308
|
+
* The trailing phrases catch a gutted install that reports its own breakage
|
|
309
|
+
* *politely* rather than with a raw ENOENT: @anthropic-ai/claude-code's stub
|
|
310
|
+
* (run when its postinstall never copied the native binary in) prints "native
|
|
311
|
+
* binary not installed / postinstall did not run / … optional dependency was not
|
|
312
|
+
* downloaded" and exits nonzero — which the generic patterns above miss. Anchored
|
|
313
|
+
* tightly so a healthy `--version` can never emit them.
|
|
307
314
|
*/
|
|
308
315
|
export declare function isMissingBinarySignature(output: string): boolean;
|
|
309
316
|
/**
|
package/dist/lib/versions.js
CHANGED
|
@@ -1226,6 +1226,40 @@ export async function installVersion(agent, version, onProgress, opts) {
|
|
|
1226
1226
|
/* non-fatal; the install itself succeeded */
|
|
1227
1227
|
}
|
|
1228
1228
|
}
|
|
1229
|
+
// The `npm install` above ran with `--ignore-scripts` — the right posture for
|
|
1230
|
+
// the dependency TREE (never run arbitrary transitive postinstalls), but it
|
|
1231
|
+
// also skips the agent package's OWN postinstall, which for some agents is a
|
|
1232
|
+
// required install step. @anthropic-ai/claude-code ships a ~500-byte stub at
|
|
1233
|
+
// `bin/claude.exe` plus per-arch native binaries as optional deps; its
|
|
1234
|
+
// `postinstall` (`node install.cjs`) is what copies the correct native binary
|
|
1235
|
+
// over the stub. Skip it and every launch dies with "native binary not
|
|
1236
|
+
// installed". So run the first-party package's declared postinstall here —
|
|
1237
|
+
// scoped to that one package, never `prepare` (claude-code's `prepare` is an
|
|
1238
|
+
// unconditional `exit 1` publish guard). Same precedent as the keychain-helper
|
|
1239
|
+
// postinstall re-run after a `--ignore-scripts` upgrade (see index.ts).
|
|
1240
|
+
// Best-effort: the integrity gate below is the real backstop — a still-broken
|
|
1241
|
+
// binary (postinstall failed, or a platform with no published native dep)
|
|
1242
|
+
// fails there with the correct message rather than throwing here.
|
|
1243
|
+
if (agentConfig.npmPackage) {
|
|
1244
|
+
// Recompute from installedVersion, not the (possibly renamed) `versionDir`:
|
|
1245
|
+
// the `latest` branch above may have renamed the dir to its concrete version.
|
|
1246
|
+
const pkgRoot = path.join(getVersionDir(agent, installedVersion), 'node_modules', agentConfig.npmPackage);
|
|
1247
|
+
try {
|
|
1248
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf-8'));
|
|
1249
|
+
const postinstall = pkg?.scripts?.postinstall;
|
|
1250
|
+
if (typeof postinstall === 'string' && postinstall.trim()) {
|
|
1251
|
+
onProgress?.(`Running ${agentConfig.name} postinstall...`);
|
|
1252
|
+
// The declared postinstall is a shell command string (e.g. `node
|
|
1253
|
+
// install.cjs`), so it must run through a shell on ALL platforms —
|
|
1254
|
+
// shell:true, empty args. cwd is the package root; install.cjs anchors
|
|
1255
|
+
// its paths to __dirname, so that is correct and sufficient.
|
|
1256
|
+
await execFileAsync(postinstall, [], { cwd: pkgRoot, shell: true });
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
catch {
|
|
1260
|
+
/* non-fatal; the integrity gate below catches a still-broken binary */
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1229
1263
|
// Integrity gate: confirm the install actually launches, not just that the
|
|
1230
1264
|
// JS wrapper landed. A gutted install (wrapper present, native platform
|
|
1231
1265
|
// binary missing) otherwise gets silently pinned as the default and crashes
|
|
@@ -1603,9 +1637,16 @@ export async function getInstalledVersion(agent, version) {
|
|
|
1603
1637
|
* Deliberately narrow: only the missing-file signature counts. An agent that
|
|
1604
1638
|
* merely dislikes `--version` (nonzero exit, ordinary error text) or ignores it
|
|
1605
1639
|
* (times out) must NOT match, so a healthy install is never falsely condemned.
|
|
1640
|
+
*
|
|
1641
|
+
* The trailing phrases catch a gutted install that reports its own breakage
|
|
1642
|
+
* *politely* rather than with a raw ENOENT: @anthropic-ai/claude-code's stub
|
|
1643
|
+
* (run when its postinstall never copied the native binary in) prints "native
|
|
1644
|
+
* binary not installed / postinstall did not run / … optional dependency was not
|
|
1645
|
+
* downloaded" and exits nonzero — which the generic patterns above miss. Anchored
|
|
1646
|
+
* tightly so a healthy `--version` can never emit them.
|
|
1606
1647
|
*/
|
|
1607
1648
|
export function isMissingBinarySignature(output) {
|
|
1608
|
-
return /\bENOENT\b|no such file|cannot find|command not found|is not recognized/i.test(output);
|
|
1649
|
+
return /\bENOENT\b|no such file|cannot find|command not found|is not recognized|native binary not installed|postinstall did not run|optional dependency was not downloaded/i.test(output);
|
|
1609
1650
|
}
|
|
1610
1651
|
/**
|
|
1611
1652
|
* Verify a freshly-installed agent can actually LAUNCH — not merely that its JS
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.50",
|
|
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",
|