@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.
@@ -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>;
@@ -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;
@@ -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
  /**
@@ -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.49",
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",