@phnx-labs/agents-cli 1.20.48 → 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.
@@ -2,7 +2,7 @@ import chalk from 'chalk';
2
2
  import * as fs from 'fs/promises';
3
3
  import { addHostOption } from '../lib/hosts/option.js';
4
4
  import * as path from 'path';
5
- import { AgentManager, checkAllClis, checkCliSignedIn, getAgentsDir, resolveSignInAdvisory, VALID_TASK_TYPES, } from '../lib/teams/agents.js';
5
+ import { AgentManager, checkCliSignedIn, collectTeamsDoctorData, getAgentsDir, VALID_TASK_TYPES, } from '../lib/teams/agents.js';
6
6
  import { resolveProvider } from '../lib/cloud/registry.js';
7
7
  import { runSupervisor } from '../lib/teams/supervisor.js';
8
8
  import { debug } from '../lib/teams/debug.js';
@@ -11,7 +11,15 @@ import { handleSpawn, handleStatus, handleStop, handleTasks, toTaskStatusSummary
11
11
  import { createTeam, ensureTeam, getTeam, loadTeams, removeTeam, teamExists, } from '../lib/teams/registry.js';
12
12
  import { setHelpSections } from '../lib/help.js';
13
13
  import { createWorktree, isGitRepo, hasUncommittedChanges, removeWorktree, } from '../lib/teams/worktree.js';
14
- import { isVersionInstalled, resolveVersion, resolveVersionAlias, resolveVersionAliasLoose, verifyInstalledBinaryLaunches } from '../lib/versions.js';
14
+ import { resolveHost } from '../lib/hosts/registry.js';
15
+ import { sshTargetFor } from '../lib/hosts/types.js';
16
+ import { ensureHostReady } from '../lib/hosts/ready.js';
17
+ import { remoteShellFor } from '../lib/hosts/remote-cmd.js';
18
+ import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
19
+ import { remoteWorktreeDirty, removeRemoteWorktree, ensureRemoteRepo } from '../lib/teams/remoteWorktree.js';
20
+ import { getRemoteUrl } from '../lib/git.js';
21
+ import { machineId } from '../lib/session/sync/config.js';
22
+ import { isVersionInstalled, resolveVersionAlias, resolveVersionAliasLoose } from '../lib/versions.js';
15
23
  import { AGENTS, warnAgentDeprecated } from '../lib/agents.js';
16
24
  import { discoverSessions, parseTimeFilter, resolveSessionById } from '../lib/session/discover.js';
17
25
  import { renderSessionLog } from './sessions.js';
@@ -486,6 +494,9 @@ function printAgentDetail(a, session) {
486
494
  if (a.after && a.after.length) {
487
495
  console.log(` ${chalk.gray('after ')} ${a.after.join(', ')}`);
488
496
  }
497
+ if (a.host) {
498
+ console.log(` ${chalk.gray('host ')} ${chalk.cyan(a.host)}`);
499
+ }
489
500
  // If the agent's internal session id differs from ours (non-Claude), show
490
501
  // it as a hint for `agents sessions <id>`.
491
502
  if (a.remote_session_id && a.remote_session_id !== a.agent_id) {
@@ -569,7 +580,8 @@ function printAgentSummary(s) {
569
580
  const duration = s.duration ? `${chalk.gray(' · ')}${chalk.white(s.duration)}` : '';
570
581
  const errBadge = s.has_errors ? chalk.red(' !') : '';
571
582
  const tools = chalk.gray(` · ${s.tool_count} tools`);
572
- console.log(` ${chalk.cyan(handle.padEnd(14))} ${ident.padEnd(11)} ${label}${duration}${tools}${errBadge}`);
583
+ const hostBadge = s.host ? chalk.gray(' · on ') + chalk.cyan(s.host) : '';
584
+ console.log(` ${chalk.cyan(handle.padEnd(14))} ${ident.padEnd(11)} ${label}${duration}${tools}${hostBadge}${errBadge}`);
573
585
  // Files: counts + basenames. Read is count only.
574
586
  const fileLines = [];
575
587
  const renderCat = (label, cat) => {
@@ -965,13 +977,55 @@ export function registerTeamsCommands(program) {
965
977
  .option('-d, --description <text>', 'One-line summary of what this team is working on')
966
978
  .option('--enable-worktrees', 'Each teammate works in its own git worktree (requires --worktree on add)')
967
979
  .option('--use-worktree <path>', 'All teammates share this existing worktree path (mutually exclusive with --enable-worktrees)')
980
+ .option('--devices <list>', 'Pool of machines this team may run teammates on (comma-separated). Enables distributed auto-scheduling.')
981
+ .option('--hosts <list>', 'Alias for --devices.')
982
+ .option('--repo <urlOrPath>', 'How each device gets the code (git URL to clone, or a path). Defaults to the local checkout origin.')
968
983
  .option('--json', 'Output machine-readable JSON')
969
984
  .action(async (team, opts) => {
970
985
  try {
986
+ // --devices / --hosts are aliases; commander can't express a two-name
987
+ // option that isn't a short flag, so merge them here. Split on comma,
988
+ // trim, drop blanks, dedupe (preserving first-seen order).
989
+ const rawPool = [opts.devices, opts.hosts].filter(Boolean).join(',');
990
+ const devices = [];
991
+ for (const d of rawPool.split(',').map((s) => s.trim()).filter(Boolean)) {
992
+ if (!devices.includes(d))
993
+ devices.push(d);
994
+ }
995
+ // Validate every pooled device resolves + is POSIX (v1 remote monitor is
996
+ // POSIX-only). A device equal to the local machine is fine (runs local),
997
+ // so skip the resolve/POSIX check for it.
998
+ for (const name of devices) {
999
+ if (name.toLowerCase() === machineId())
1000
+ continue;
1001
+ const host = await resolveHost(name);
1002
+ if (!host) {
1003
+ die(`Couldn't resolve pool device "${name}". Register it with \`agents devices\`, ` +
1004
+ `enroll it with \`agents hosts add ${name}\`, or pass user@host.`);
1005
+ }
1006
+ if (remoteShellFor(host.os ?? resolveRemoteOsSync(host.name)) === 'powershell') {
1007
+ die(`Distributed teams on Windows device "${host.name}" are not supported yet — ` +
1008
+ `the teams remote monitor is POSIX-only. Use a Linux/macOS device.`);
1009
+ }
1010
+ }
1011
+ // --repo: how each device gets the code. Default to the local checkout's
1012
+ // origin when a pool is declared and we're inside a git repo, so the user
1013
+ // never hand-manages a path per box. A poolless team leaves repo unset.
1014
+ let repo = opts.repo;
1015
+ if (!repo && devices.length > 0) {
1016
+ const cwd = process.cwd();
1017
+ if (await isGitRepo(cwd)) {
1018
+ const origin = await getRemoteUrl(cwd);
1019
+ if (origin)
1020
+ repo = origin;
1021
+ }
1022
+ }
971
1023
  const meta = await createTeam(team, {
972
1024
  description: opts.description,
973
1025
  enableWorktrees: opts.enableWorktrees,
974
1026
  useWorktree: opts.useWorktree,
1027
+ devices,
1028
+ repo,
975
1029
  });
976
1030
  if (isJsonMode(opts)) {
977
1031
  console.log(JSON.stringify({ team, ...meta }, null, 2));
@@ -984,6 +1038,10 @@ export function registerTeamsCommands(program) {
984
1038
  console.log(chalk.gray(` worktrees: per-teammate`));
985
1039
  if (meta.use_worktree)
986
1040
  console.log(chalk.gray(` worktree: ${meta.use_worktree}`));
1041
+ if (meta.devices && meta.devices.length)
1042
+ console.log(chalk.gray(` devices: ${meta.devices.join(', ')}`));
1043
+ if (meta.repo)
1044
+ console.log(chalk.gray(` repo: ${meta.repo}`));
987
1045
  console.log();
988
1046
  console.log(chalk.gray('Add your first teammate:'));
989
1047
  if (meta.enable_worktrees) {
@@ -1039,9 +1097,88 @@ export function registerTeamsCommands(program) {
1039
1097
  die(`--cloud rush requires --repo <owner/repo>`);
1040
1098
  }
1041
1099
  }
1100
+ // Auto-create the team if it doesn't exist yet (friendlier UX than erroring),
1101
+ // then load its metadata — needed here for the distributed --repo (how each
1102
+ // device gets the code) before we resolve a per-teammate --device pin.
1103
+ await ensureTeam(team);
1104
+ const teamMeta = await getTeam(team);
1105
+ // `--device`/`--host` are aliases (addHostOption registers both). For `teams
1106
+ // add` the passthrough special-cases them as PLACEMENT, not routing, so the
1107
+ // local action reads them here. Reject a conflicting pair.
1108
+ const explicitDevice = (() => {
1109
+ const h = opts.host;
1110
+ const d = opts.device;
1111
+ if (h && d && h !== d) {
1112
+ die('Conflicting --host/--device values — pass just one.');
1113
+ }
1114
+ return h ?? d ?? null;
1115
+ })();
1116
+ // Distributed teams: --device <name> PINS this teammate to a machine over
1117
+ // SSH. Resolve + validate the placement here so a bad target fails at `add`
1118
+ // time, not silently at launch. Persisted (hostName/hostTarget/repoPath) so
1119
+ // startReady()/launchRemoteProcess dispatch over SSH. Unpinned teammates
1120
+ // leave these null — the launch-time scheduler resolves the pool cascade.
1121
+ let hostName = null;
1122
+ let hostTarget = null;
1123
+ let hostRepoPath = null;
1124
+ if (explicitDevice && explicitDevice.toLowerCase() !== machineId()) {
1125
+ if (cloudProviderId) {
1126
+ die(`--device and --cloud are mutually exclusive (two different remote backends). Pick one.`);
1127
+ }
1128
+ const host = await resolveHost(explicitDevice);
1129
+ if (!host) {
1130
+ die(`Couldn't resolve --device "${explicitDevice}". Register it with \`agents devices\`, ` +
1131
+ `enroll it with \`agents hosts add ${explicitDevice}\`, or pass user@host.`);
1132
+ }
1133
+ // POSIX-only in v1: the remote follow/monitor layer offset-tails the log
1134
+ // with tail/cat/kill, which don't exist under PowerShell. Refuse Windows
1135
+ // up front, mirroring dispatch.ts launchDetached.
1136
+ if (remoteShellFor(host.os ?? resolveRemoteOsSync(host.name)) === 'powershell') {
1137
+ die(`Distributed teammates on Windows host "${host.name}" are not supported yet — ` +
1138
+ `the teams remote monitor is POSIX-only (offset-tails the remote log with tail/cat/kill). ` +
1139
+ `Use a Linux/macOS host, or run this teammate locally.`);
1140
+ }
1141
+ try {
1142
+ hostTarget = sshTargetFor(host);
1143
+ }
1144
+ catch (err) {
1145
+ die(`Can't resolve an ssh target for "${host.name}": ${err.message}`);
1146
+ }
1147
+ // Ensure agents-cli is present + version-matched on the host; surface
1148
+ // (not fail on) an agent-not-installed warning, like the run --host path.
1149
+ try {
1150
+ const { warnings } = ensureHostReady(host, { agent: parseTeammate(teammate).agent });
1151
+ for (const w of warnings)
1152
+ process.stderr.write(chalk.yellow(`[teams] warning: ${w}\n`));
1153
+ }
1154
+ catch (err) {
1155
+ die(`Host "${host.name}" is not ready: ${err.message}`);
1156
+ }
1157
+ // Provision the repo on the host from the team's --repo (clone into
1158
+ // ~/.agents/repos/<team> or reuse an existing checkout), resolving to the
1159
+ // ABSOLUTE git root so every later remote command works from an absolute
1160
+ // path (dispatch `cd`, worktree create, polling). When the team has no
1161
+ // --repo (the common "just send one teammate elsewhere" case, created
1162
+ // without a pool), fall back to THIS checkout's origin so the headline case
1163
+ // works with zero extra flags whenever you run `add` inside a git repo.
1164
+ let effectiveRepo = teamMeta?.repo ?? '';
1165
+ if (!effectiveRepo && (await isGitRepo(process.cwd()))) {
1166
+ effectiveRepo = (await getRemoteUrl(process.cwd())) ?? '';
1167
+ }
1168
+ try {
1169
+ hostRepoPath = ensureRemoteRepo(hostTarget, effectiveRepo, team);
1170
+ }
1171
+ catch (err) {
1172
+ die(`Couldn't provision the repo on "${host.name}": ${err.message}\n` +
1173
+ ` Set how each device gets the code with: agents teams create ${team} --repo <url|path>`);
1174
+ }
1175
+ hostName = host.name;
1176
+ }
1042
1177
  const { agent, version, profileName } = parseTeammate(teammate);
1043
1178
  warnAgentDeprecated(agent);
1044
- if (version && !isVersionInstalled(agent, version)) {
1179
+ // Version-installed check is about the LOCAL machine — a distributed (--on)
1180
+ // teammate's agent/version lives on the host, verified by ensureHostReady.
1181
+ if (version && !hostName && !isVersionInstalled(agent, version)) {
1045
1182
  die(`${AGENT_NAMES[agent]} ${version} isn't installed.\n` +
1046
1183
  ` Install it: agents add ${agent}@${version}\n` +
1047
1184
  ` Or see what's installed (incl. @latest): agents view ${agent}`);
@@ -1049,7 +1186,8 @@ export function registerTeamsCommands(program) {
1049
1186
  // Advisory sign-in check: warn but NEVER block. Detection is unreliable
1050
1187
  // for opaque-cred agents, so a false negative must not stop a team. Cloud
1051
1188
  // dispatch authenticates through the provider, not the local CLI — skip it.
1052
- if (!opts.force && !cloudProviderId && !(await checkCliSignedIn(agent))) {
1189
+ // Distributed (--on) teammates authenticate on the host, not locally — skip.
1190
+ if (!opts.force && !cloudProviderId && !hostName && !(await checkCliSignedIn(agent))) {
1053
1191
  console.error(chalk.yellow(`⚠ ${AGENT_NAMES[agent]} may not be signed in (detection is unreliable). Adding anyway.`) +
1054
1192
  chalk.gray(`\n If it fails to start, run \`${AGENTS[agent].cliCommand}\` to log in, or pass --force to silence this.`));
1055
1193
  }
@@ -1058,7 +1196,7 @@ export function registerTeamsCommands(program) {
1058
1196
  // out-of-credits / signed-out account (see throttleWarningLine). Skip bare
1059
1197
  // targets (rotation handles them), profiles (auth-injected account isn't
1060
1198
  // the version-home one we can read), and cloud dispatch. Warn, never block.
1061
- if (!opts.force && !cloudProviderId && !profileName && version) {
1199
+ if (!opts.force && !cloudProviderId && !hostName && !profileName && version) {
1062
1200
  const readiness = await checkRunAccountReadiness(agent, version);
1063
1201
  if (!readiness.ready)
1064
1202
  console.error(throttleWarningLine(agent, version, readiness));
@@ -1086,15 +1224,34 @@ export function registerTeamsCommands(program) {
1086
1224
  catch (err) {
1087
1225
  die(err.message);
1088
1226
  }
1089
- // Auto-create the team if it doesn't exist yet (friendlier UX than erroring).
1090
- await ensureTeam(team);
1091
- // Check if team has worktrees enabled or a shared worktree
1092
- const teamMeta = await getTeam(team);
1227
+ // Team already ensured + loaded above (teamMeta) for the --repo provisioning.
1093
1228
  const worktreesEnabled = teamMeta?.enable_worktrees ?? false;
1094
1229
  const sharedWorktree = teamMeta?.use_worktree ?? null;
1095
1230
  let worktreeName = null;
1096
1231
  let worktreePath = null;
1097
- if (sharedWorktree) {
1232
+ if (hostName) {
1233
+ // Distributed teammate: the checkout lives on the host, so we NEVER touch
1234
+ // the local filesystem here. A shared local worktree makes no sense for a
1235
+ // remote teammate; a per-teammate worktree is created ON THE HOST at launch
1236
+ // (createRemoteWorktree in launchRemoteProcess) — we just capture its name.
1237
+ if (sharedWorktree) {
1238
+ die(`Team '${team}' uses a shared local --use-worktree, which can't apply to a --device (remote) teammate.`);
1239
+ }
1240
+ if (worktreesEnabled) {
1241
+ if (!opts.worktree) {
1242
+ die(`Team '${team}' has worktrees enabled. Use --worktree <name> for the remote teammate (created on ${hostName}).`);
1243
+ }
1244
+ if (!opts.name) {
1245
+ die(`Team '${team}' has worktrees enabled. Use --name <name> to identify this teammate.`);
1246
+ }
1247
+ worktreeName = opts.worktree;
1248
+ }
1249
+ else if (opts.worktree) {
1250
+ die(`--worktree requires --enable-worktrees on the team. Recreate the team with: agents teams create ${team} --enable-worktrees`);
1251
+ }
1252
+ // Local cwd stays null — the remote cwd is repoPath / the remote worktree.
1253
+ }
1254
+ else if (sharedWorktree) {
1098
1255
  // Team uses a shared worktree for all teammates
1099
1256
  const fsp = await import('fs/promises');
1100
1257
  try {
@@ -1133,7 +1290,10 @@ export function registerTeamsCommands(program) {
1133
1290
  else if (opts.worktree) {
1134
1291
  die(`--worktree requires --enable-worktrees on the team. Recreate the team with: agents teams create ${team} --enable-worktrees`);
1135
1292
  }
1136
- const cwd = worktreePath ?? opts.cwd ?? process.cwd();
1293
+ // Distributed teammates have no LOCAL cwd their working dir lives on the
1294
+ // host (repoPath / the remote worktree). Local teammates default to the
1295
+ // worktree path, then --cwd, then the current directory.
1296
+ const cwd = hostName ? null : (worktreePath ?? opts.cwd ?? process.cwd());
1137
1297
  const mgr = mkManager();
1138
1298
  // Factory teammates: prepend the worker-skill preamble to every task
1139
1299
  // prompt so implementers/testers/reviewers know about the Ledger, the
@@ -1182,7 +1342,7 @@ export function registerTeamsCommands(program) {
1182
1342
  }
1183
1343
  }
1184
1344
  try {
1185
- const result = await handleSpawn(mgr, team, agent, effectiveTask, cwd, opts.mode, opts.effort, null, cwd, version, opts.name ?? null, after, opts.model ?? null, envOverrides ?? null, taskType, cloudProviderId, cloudSessionId, opts.repo ?? null, opts.branch ?? null, worktreeName, worktreePath, profileName);
1345
+ const result = await handleSpawn(mgr, team, agent, effectiveTask, cwd, opts.mode, opts.effort, null, cwd, version, opts.name ?? null, after, opts.model ?? null, envOverrides ?? null, taskType, cloudProviderId, cloudSessionId, opts.repo ?? null, opts.branch ?? null, worktreeName, worktreePath, profileName, hostName, hostTarget, hostRepoPath);
1186
1346
  if (isJsonMode(opts)) {
1187
1347
  console.log(JSON.stringify(result, null, 2));
1188
1348
  return;
@@ -1200,7 +1360,10 @@ export function registerTeamsCommands(program) {
1200
1360
  console.log(` ${chalk.gray('agent_id')} ${chalk.cyan(shortId(result.agent_id))} ${chalk.gray(`(${result.agent_id})`)}`);
1201
1361
  console.log(` ${chalk.gray('status ')} ${statusColor(result.status)(result.status)}`);
1202
1362
  console.log(` ${chalk.gray('mode ')} ${opts.mode}`);
1203
- console.log(` ${chalk.gray('working ')} ${cwd}`);
1363
+ console.log(` ${chalk.gray('working ')} ${hostName ? hostRepoPath : cwd}`);
1364
+ if (hostName) {
1365
+ console.log(` ${chalk.gray('host ')} ${chalk.cyan(hostName)}${chalk.gray(` (${hostTarget})`)}`);
1366
+ }
1204
1367
  if (worktreeName) {
1205
1368
  console.log(` ${chalk.gray('worktree')} ${chalk.cyan(worktreeName)}`);
1206
1369
  }
@@ -1300,6 +1463,7 @@ export function registerTeamsCommands(program) {
1300
1463
  started_at: a.startedAt.toISOString(),
1301
1464
  cwd: a.cwd,
1302
1465
  version: a.version,
1466
+ host: a.hostName,
1303
1467
  })) }, null, 2));
1304
1468
  return;
1305
1469
  }
@@ -1317,7 +1481,10 @@ export function registerTeamsCommands(program) {
1317
1481
  console.log(chalk.bold(`Team ${chalk.cyan(team)} ${chalk.gray(`(${agents.length} working)`)}`));
1318
1482
  for (const a of agents) {
1319
1483
  const ident = a.name || shortId(a.agentId);
1320
- const pidStr = a.pid ? chalk.yellow(`pid ${a.pid}`) : chalk.gray('pid ?');
1484
+ // A distributed teammate has no local pid; show its host + remote pid.
1485
+ const pidStr = a.hostName
1486
+ ? chalk.cyan(`on ${a.hostName}`) + (a.remotePid ? chalk.gray(` (pid ${a.remotePid})`) : '')
1487
+ : a.pid ? chalk.yellow(`pid ${a.pid}`) : chalk.gray('pid ?');
1321
1488
  const started = chalk.gray(relTime(a.startedAt.toISOString()));
1322
1489
  console.log(` ${chalk.magenta(padRight(fullName(a.agentType, a.version), 18))} ${chalk.white(padRight(ident, 20))} ${pidStr} ${started}`);
1323
1490
  }
@@ -1544,13 +1711,24 @@ export function registerTeamsCommands(program) {
1544
1711
  let worktreeKept = false;
1545
1712
  if (agent?.worktreeName && agent?.worktreePath) {
1546
1713
  try {
1547
- const dirty = await hasUncommittedChanges(agent.worktreePath);
1548
- if (dirty) {
1549
- worktreeKept = true;
1714
+ if (agent.hostName && agent.hostTarget && agent.repoPath) {
1715
+ // Distributed teammate: guard + remove the worktree ON THE HOST.
1716
+ if (remoteWorktreeDirty(agent.hostTarget, agent.worktreePath)) {
1717
+ worktreeKept = true;
1718
+ }
1719
+ else {
1720
+ removeRemoteWorktree(agent.hostTarget, agent.repoPath, agent.worktreeName);
1721
+ }
1550
1722
  }
1551
1723
  else {
1552
- const baseCwd = process.cwd();
1553
- await removeWorktree(baseCwd, agent.worktreeName);
1724
+ const dirty = await hasUncommittedChanges(agent.worktreePath);
1725
+ if (dirty) {
1726
+ worktreeKept = true;
1727
+ }
1728
+ else {
1729
+ const baseCwd = process.cwd();
1730
+ await removeWorktree(baseCwd, agent.worktreeName);
1731
+ }
1554
1732
  }
1555
1733
  }
1556
1734
  catch {
@@ -1667,12 +1845,23 @@ export function registerTeamsCommands(program) {
1667
1845
  const agent = await mgr.get(a.agent_id);
1668
1846
  if (agent?.worktreeName && agent?.worktreePath) {
1669
1847
  try {
1670
- const dirty = await hasUncommittedChanges(agent.worktreePath);
1671
- if (dirty) {
1672
- keptWorktrees.push(agent.worktreeName);
1848
+ if (agent.hostName && agent.hostTarget && agent.repoPath) {
1849
+ // Distributed teammate: guard + remove the worktree ON THE HOST.
1850
+ if (remoteWorktreeDirty(agent.hostTarget, agent.worktreePath)) {
1851
+ keptWorktrees.push(agent.worktreeName);
1852
+ }
1853
+ else {
1854
+ removeRemoteWorktree(agent.hostTarget, agent.repoPath, agent.worktreeName);
1855
+ }
1673
1856
  }
1674
1857
  else {
1675
- await removeWorktree(baseCwd, agent.worktreeName);
1858
+ const dirty = await hasUncommittedChanges(agent.worktreePath);
1859
+ if (dirty) {
1860
+ keptWorktrees.push(agent.worktreeName);
1861
+ }
1862
+ else {
1863
+ await removeWorktree(baseCwd, agent.worktreeName);
1864
+ }
1676
1865
  }
1677
1866
  }
1678
1867
  catch { /* best-effort */ }
@@ -1778,58 +1967,16 @@ export function registerTeamsCommands(program) {
1778
1967
  .description('Check which agents are installed and available to join a team. Verifies CLI paths and shows an advisory sign-in hint.')
1779
1968
  .option('--json', 'Output machine-readable JSON')
1780
1969
  .action(async (opts) => {
1781
- const info = checkAllClis();
1782
- // Deep integrity probe. `checkAllClis` reports presence (shim + stub guard),
1783
- // but a GUTTED native binary (JS wrapper present, platform binary missing —
1784
- // the codex/kimi optional-dep partial-extract failure) still passes that. So
1785
- // actually launch the resolved default version and, if it won't run, flip the
1786
- // agent to not-installed with a repair hint — otherwise doctor says "ready"
1787
- // and the teammate ENOENTs at spawn. Parallel; win32 is treated as healthy by
1788
- // verifyInstalledBinaryLaunches.
1789
- await Promise.all(Object.entries(info).map(async ([name, entry]) => {
1790
- if (!entry.installed)
1791
- return;
1792
- const agent = name;
1793
- const version = resolveVersion(agent);
1794
- if (!version)
1795
- return;
1796
- const health = await verifyInstalledBinaryLaunches(agent, version);
1797
- if (!health.ok) {
1798
- entry.installed = false;
1799
- entry.path = null;
1800
- entry.error = `${AGENTS[agent]?.cliCommand ?? name}@${version} is installed but its binary won't launch`
1801
- + `${health.detail ? ` (${health.detail})` : ''}. Repair: agents add ${agent}@${version}`;
1802
- }
1803
- }));
1804
- // Advisory enrichment only. Sign-in detection is UNRELIABLE, so it never
1805
- // changes the authoritative installed/ready column — it annotates. And an
1806
- // agent that is actually running in a team is treated as signed in
1807
- // regardless of the probe, so doctor never reports a working agent as
1808
- // logged out ("don't show wrong stuff").
1809
- const running = new Set();
1810
- try {
1811
- for (const a of await mkManager().listRunning())
1812
- running.add(a.agentType);
1813
- }
1814
- catch { /* no teams yet — leave running empty */ }
1815
- const auth = {};
1816
- await Promise.all(Object.entries(info).map(async ([name, entry]) => {
1817
- const isRunning = running.has(name);
1818
- const probe = entry.installed && !isRunning ? await checkCliSignedIn(name) : false;
1819
- auth[name] = resolveSignInAdvisory(entry.installed, isRunning, probe);
1820
- }));
1970
+ const data = await collectTeamsDoctorData();
1821
1971
  if (isJsonMode(opts)) {
1822
- const merged = {};
1823
- for (const [name, entry] of Object.entries(info))
1824
- merged[name] = { ...entry, ...auth[name] };
1825
- console.log(JSON.stringify(merged, null, 2));
1972
+ console.log(JSON.stringify(data, null, 2));
1826
1973
  return;
1827
1974
  }
1828
1975
  console.log(chalk.bold('Who can join a team:'));
1829
- for (const [name, entry] of Object.entries(info)) {
1976
+ for (const [name, entry] of Object.entries(data)) {
1830
1977
  const pretty = AGENT_NAMES[name] || name;
1831
1978
  if (entry.installed) {
1832
- const { signedIn, running: isRunning } = auth[name];
1979
+ const { signedIn, running: isRunning } = entry;
1833
1980
  const hint = isRunning
1834
1981
  ? chalk.gray('in use')
1835
1982
  : signedIn
@@ -525,7 +525,11 @@ export class RushCloudProvider {
525
525
  }
526
526
  async cancel(taskId) {
527
527
  const token = readToken();
528
- const res = await api('DELETE', `/api/v1/cloud-runs/${encodeURIComponent(taskId)}`, token);
528
+ // The cancel ACTION endpoint (POST .../cancel) is what the backend implements;
529
+ // it works on paused runs too (queued / needs_review / input_required). A bare
530
+ // DELETE on the run 404s, so `agents cloud cancel` silently failed on anything
531
+ // that wasn't actively running.
532
+ const res = await api('POST', `/api/v1/cloud-runs/${encodeURIComponent(taskId)}/cancel`, token);
529
533
  if (!res.ok) {
530
534
  throw new Error(`Failed to cancel task (${res.status}).`);
531
535
  }
@@ -40,10 +40,16 @@ export declare function headlessPlanStallCommand(args: {
40
40
  *
41
41
  * - `auto` on an agent without auto support silently degrades to `edit`
42
42
  * (every agent supports edit-like behavior as its default).
43
+ * - `plan` on an agent without a read-only mode degrades to the agent's
44
+ * safest native mode (`capabilities.modes[0]`, typically `edit`). Agents
45
+ * like antigravity/cursor/kiro have no plan flag; hard-failing made
46
+ * multi-agent scripts (`--mode plan` for everyone) unusable and diverged
47
+ * from `agents teams add`, which already defaults to `edit`. Callers that
48
+ * care (the `agents run` CLI) must surface a warning when requested ≠
49
+ * resolved so the elevation is not silent.
43
50
  * - `skip` on an agent without skip support throws with a clear message
44
51
  * naming the agent's supported modes. No silent fallback — the user
45
52
  * explicitly asked to bypass permissions; pretending we did is unsafe.
46
- * - `plan` on an agent without plan support throws the same way.
47
53
  */
48
54
  export declare function resolveMode(agent: AgentId, requested: Mode): Mode;
49
55
  /**
@@ -54,10 +60,8 @@ export declare function resolveMode(agent: AgentId, requested: Mode): Mode;
54
60
  * supports." Agents that include `plan` list it first; agents like
55
61
  * antigravity that have no read-only mode list `edit` first.
56
62
  *
57
- * Use this when the user did not pass `--mode` explicitly. When the user
58
- * *did* pass `--mode plan` and the agent doesn't support it, call
59
- * `resolveMode` instead so the user sees a loud error rather than a silent
60
- * elevation from read-only to writable.
63
+ * Prefer this over a hard-coded `'plan'` when the agent is known. `resolveMode`
64
+ * also maps an unsupported `'plan'` request onto this same value.
61
65
  */
62
66
  export declare function defaultModeFor(agent: AgentId): Mode;
63
67
  /** Reasoning effort levels passed to agents that support them. 'auto' defers to the agent's default. */
package/dist/lib/exec.js CHANGED
@@ -16,6 +16,7 @@ import { resolveModel, buildReasoningFlags } from './models.js';
16
16
  import { maybeRotate, createTimer, redactPrompt, redactArgs } from './events.js';
17
17
  import { sanitizeProcessEnv } from './secrets/bundles.js';
18
18
  import { getShimsDir } from './state.js';
19
+ import { readCodexConfiguredModel } from './shims.js';
19
20
  import { writePidSessionEntry, extractSessionIdArg } from './session/pid-registry.js';
20
21
  import { recordRunName } from './session/run-names.js';
21
22
  import { mailboxDir, isValidMailboxId } from './mailbox.js';
@@ -75,10 +76,16 @@ export function headlessPlanStallCommand(args) {
75
76
  *
76
77
  * - `auto` on an agent without auto support silently degrades to `edit`
77
78
  * (every agent supports edit-like behavior as its default).
79
+ * - `plan` on an agent without a read-only mode degrades to the agent's
80
+ * safest native mode (`capabilities.modes[0]`, typically `edit`). Agents
81
+ * like antigravity/cursor/kiro have no plan flag; hard-failing made
82
+ * multi-agent scripts (`--mode plan` for everyone) unusable and diverged
83
+ * from `agents teams add`, which already defaults to `edit`. Callers that
84
+ * care (the `agents run` CLI) must surface a warning when requested ≠
85
+ * resolved so the elevation is not silent.
78
86
  * - `skip` on an agent without skip support throws with a clear message
79
87
  * naming the agent's supported modes. No silent fallback — the user
80
88
  * explicitly asked to bypass permissions; pretending we did is unsafe.
81
- * - `plan` on an agent without plan support throws the same way.
82
89
  */
83
90
  export function resolveMode(agent, requested) {
84
91
  const supported = AGENTS[agent].capabilities.modes;
@@ -89,6 +96,12 @@ export function resolveMode(agent, requested) {
89
96
  // at least 'edit' in its modes table, since that's the default behavior).
90
97
  return 'edit';
91
98
  }
99
+ if (requested === 'plan') {
100
+ // No read-only mode on this agent. modes[0] is the declared safest mode
101
+ // (edit for antigravity/cursor/kiro/…). Prefer that over hard-fail so
102
+ // uniform multi-agent `--mode plan` dispatches still run.
103
+ return supported[0];
104
+ }
92
105
  throw new Error(`${agent} does not support '${requested}' mode. Supported modes: ${supported.join(', ')}.`);
93
106
  }
94
107
  /**
@@ -99,10 +112,8 @@ export function resolveMode(agent, requested) {
99
112
  * supports." Agents that include `plan` list it first; agents like
100
113
  * antigravity that have no read-only mode list `edit` first.
101
114
  *
102
- * Use this when the user did not pass `--mode` explicitly. When the user
103
- * *did* pass `--mode plan` and the agent doesn't support it, call
104
- * `resolveMode` instead so the user sees a loud error rather than a silent
105
- * elevation from read-only to writable.
115
+ * Prefer this over a hard-coded `'plan'` when the agent is known. `resolveMode`
116
+ * also maps an unsupported `'plan'` request onto this same value.
106
117
  */
107
118
  export function defaultModeFor(agent) {
108
119
  return AGENTS[agent].capabilities.modes[0];
@@ -541,7 +552,8 @@ export function buildExecCommand(options) {
541
552
  }
542
553
  // Resolve the requested mode against the agent's capability table.
543
554
  // - `auto` on an agent without auto support → silently degrades to `edit`
544
- // - `skip`/`plan` on an unsupported agent → throws a clear error
555
+ // - `plan` on an agent without a read-only mode degrades to modes[0]
556
+ // - `skip` on an unsupported agent → throws a clear error
545
557
  // After resolveMode, the chosen mode is guaranteed to be in template.modeFlags.
546
558
  const resolvedMode = resolveMode(options.agent, normalizeMode(options.mode));
547
559
  const modeFlags = template.modeFlags[resolvedMode];
@@ -599,18 +611,25 @@ export function buildExecCommand(options) {
599
611
  else if (options.sessionId && options.agent === 'claude') {
600
612
  cmd.push('--session-id', options.sessionId);
601
613
  }
602
- // Add model (only if explicitly provided by user)
603
- if (options.model && template.modelFlag) {
614
+ // Add model. Prefer the user's explicit --model. Otherwise, for Codex, fall
615
+ // back to the model configured in the user's active ~/.codex/config.toml:
616
+ // Codex runs under a per-version CODEX_HOME (see buildExecEnv) that may not
617
+ // carry that setting, so without this it silently defaults to gpt-5.3-codex,
618
+ // which a ChatGPT-tier account can't use (HTTP 400). Forwarding keeps the
619
+ // user's default model setup for both `agents run` and `agents teams`.
620
+ const effectiveModel = options.model
621
+ ?? (options.agent === 'codex' ? readCodexConfiguredModel() : undefined);
622
+ if (effectiveModel && template.modelFlag) {
604
623
  const effectiveVersion = options.version || resolveVersion(options.agent, options.cwd || process.cwd());
605
624
  if (effectiveVersion) {
606
- const resolved = resolveModel(options.agent, effectiveVersion, options.model);
625
+ const resolved = resolveModel(options.agent, effectiveVersion, effectiveModel);
607
626
  if (resolved.warning) {
608
627
  process.stderr.write(`[agents] ${resolved.warning}\n`);
609
628
  }
610
629
  cmd.push(template.modelFlag, resolved.forwarded);
611
630
  }
612
631
  else {
613
- cmd.push(template.modelFlag, options.model);
632
+ cmd.push(template.modelFlag, effectiveModel);
614
633
  }
615
634
  }
616
635
  // Add JSON output flags if requested
@@ -91,6 +91,20 @@ export async function maybeRunOnHost(command, allArgs) {
91
91
  const spec = REMOTE_PASSTHROUGH[command];
92
92
  if (!spec)
93
93
  return false;
94
+ // Placement, not routing: `teams add`/`teams create` read `--device`/`--devices`
95
+ // (and `--host`/`--hosts`) as WHERE to place a teammate / the team pool — the
96
+ // command itself always runs locally on the orchestrator. Bail before the
97
+ // generic teams routing below so those flags reach the local action. Every
98
+ // other teams subcommand (`status`/`logs`/`stop`/…) keeps `--host` routing.
99
+ // Find the subcommand = the first non-flag token AFTER `teams` (robust to any
100
+ // leading global flags), then bail for the add/create aliases.
101
+ if (command === 'teams') {
102
+ const teamsIdx = allArgs.indexOf('teams');
103
+ const sub = teamsIdx >= 0 ? allArgs.slice(teamsIdx + 1).find((a) => !a.startsWith('-')) : undefined;
104
+ if (sub === 'add' || sub === 'a' || sub === 'create' || sub === 'c' || sub === 'new') {
105
+ return false;
106
+ }
107
+ }
94
108
  // `--device` is a first-class alias of `--host` (mirrors `agents run`); the
95
109
  // device registry is the source of truth for machine identity. Reject a
96
110
  // conflicting pair rather than silently preferring one — same rule as run.
@@ -101,6 +115,11 @@ export async function maybeRunOnHost(command, allArgs) {
101
115
  process.exitCode = 1;
102
116
  return true;
103
117
  }
118
+ // `--devices` / `--hosts` fan out to every registered device locally; don't
119
+ // let a per-host passthrough turn it into a cascading remote fan-out.
120
+ const fleetFlag = allArgs.includes('--devices') || allArgs.includes('--hosts');
121
+ if (fleetFlag)
122
+ return false;
104
123
  const hostName = hostFlag ?? deviceFlag;
105
124
  if (!hostName)
106
125
  return false;
@@ -142,7 +161,17 @@ export async function maybeRunOnHost(command, allArgs) {
142
161
  }
143
162
  return true;
144
163
  }
145
- const remoteCmd = buildRemoteAgentsInvocation(forwarded, remoteCwd, resolveRemoteOsSync(host.name));
164
+ // Doctor commands probe the agent CLIs; remote POSIX login shells often don't
165
+ // have the agents shims on PATH, which produces false "not installed" negatives.
166
+ // Bootstrap PATH with the canonical shim locations before the remote command.
167
+ // Windows is skipped: PowerShell usually has the shim dir via the install
168
+ // profile, and single-quoted env values would not expand $HOME/$PATH.
169
+ const isDoctorCommand = command === 'doctor' || (command === 'teams' && forwarded[1] === 'doctor');
170
+ const remoteOs = resolveRemoteOsSync(host.name);
171
+ const env = isDoctorCommand && !/^win/i.test((remoteOs ?? '').trim())
172
+ ? { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' }
173
+ : undefined;
174
+ const remoteCmd = buildRemoteAgentsInvocation(forwarded, remoteCwd, remoteOs, env);
146
175
  const code = sshStream(target, remoteCmd, { tty: interactive, multiplex: true });
147
176
  if (code === 255) {
148
177
  console.error(chalk.red(`${host.name}: unreachable over SSH (asleep, offline, or host key changed?).`) +