@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,9 +20,20 @@ import { debug } from './debug.js';
20
20
  import { setGeminiAutoUpdateDisabled, updateGeminiSettings } from '../gemini-settings.js';
21
21
  import { getAgentsDir as getSystemAgentsDir, getShimsDir } from '../state.js';
22
22
  import { AGENTS, getAccountInfo } from '../agents.js';
23
- import { resolveVersion, isVersionInstalled } from '../versions.js';
23
+ import { resolveVersion, isVersionInstalled, verifyInstalledBinaryLaunches } from '../versions.js';
24
24
  import { sanitizeProcessEnv } from '../secrets/bundles.js';
25
25
  import { recordRunName } from '../session/run-names.js';
26
+ import { sshExec, shellQuote } from '../ssh-exec.js';
27
+ import { resolveHost } from '../hosts/registry.js';
28
+ import { sshTargetFor } from '../hosts/types.js';
29
+ import { dispatchAgentsCommand } from '../hosts/dispatch.js';
30
+ import { ensureHostReady } from '../hosts/ready.js';
31
+ import { remoteShellFor } from '../hosts/remote-cmd.js';
32
+ import { resolveRemoteOsSync } from '../hosts/remote-os.js';
33
+ import { pullRemoteLogDelta, REMOTE_MIRROR_MAX_BYTES } from '../hosts/progress.js';
34
+ import { createRemoteWorktree, ensureRemoteRepo } from './remoteWorktree.js';
35
+ import { getTeam } from './registry.js';
36
+ import { resolvePlacement } from './scheduler.js';
26
37
  let lastMemoryWarnAt = 0;
27
38
  // On macOS, os.freemem() returns only the truly-free pool and ignores the
28
39
  // large inactive+purgeable cache the kernel will reclaim under pressure, so
@@ -392,6 +403,50 @@ export function resolveSignInAdvisory(installed, running, probeSignedIn) {
392
403
  return { signedIn: null, running: false };
393
404
  return { signedIn: running ? true : probeSignedIn, running };
394
405
  }
406
+ /**
407
+ * Collect the same data `agents teams doctor` prints: per-agent install status,
408
+ * launch health, and advisory sign-in state. Kept in one place so `agents doctor
409
+ * --devices` can run it locally or compare it against remote JSON without
410
+ * duplicating the probe logic.
411
+ */
412
+ export async function collectTeamsDoctorData() {
413
+ const info = checkAllClis();
414
+ // Deep integrity probe. `checkAllClis` reports presence (shim + stub guard),
415
+ // but a gutted native binary still passes that, so actually launch the default
416
+ // version and flip the agent to not-installed if it won't run.
417
+ await Promise.all(Object.entries(info).map(async ([name, entry]) => {
418
+ if (!entry.installed)
419
+ return;
420
+ const agent = name;
421
+ const version = resolveVersion(agent);
422
+ if (!version)
423
+ return;
424
+ const health = await verifyInstalledBinaryLaunches(agent, version);
425
+ if (!health.ok) {
426
+ entry.installed = false;
427
+ entry.path = null;
428
+ entry.error = `${AGENTS[agent]?.cliCommand ?? name}@${version} is installed but its binary won't launch`
429
+ + `${health.detail ? ` (${health.detail})` : ''}. Repair: agents add ${agent}@${version}`;
430
+ }
431
+ }));
432
+ // Advisory enrichment only. Sign-in detection is unreliable, so it never
433
+ // changes the authoritative installed/ready column — it annotates. A running
434
+ // teammate overrides a negative probe.
435
+ const running = new Set();
436
+ try {
437
+ for (const a of await new AgentManager().listRunning())
438
+ running.add(a.agentType);
439
+ }
440
+ catch { /* no teams yet — leave running empty */ }
441
+ const result = {};
442
+ await Promise.all(Object.entries(info).map(async ([name, entry]) => {
443
+ const isRunning = running.has(name);
444
+ const probe = entry.installed && !isRunning ? await checkCliSignedIn(name) : false;
445
+ const auth = resolveSignInAdvisory(entry.installed, isRunning, probe);
446
+ result[name] = { ...entry, ...auth };
447
+ }));
448
+ return result;
449
+ }
395
450
  let AGENTS_DIR = null;
396
451
  /** Resolve and cache the base directory where teammate process data is stored. */
397
452
  export async function getAgentsDir() {
@@ -456,6 +511,29 @@ export class AgentProcess {
456
511
  // Worktree isolation: when non-null, this teammate runs in its own git worktree.
457
512
  worktreeName = null;
458
513
  worktreePath = null;
514
+ // Distributed teams: when hostName is non-null, this teammate runs on another
515
+ // machine over SSH (the "remote-host" backend), not as a local process. These
516
+ // are set post-construction (like startTime/pid) — placement config at add
517
+ // time (hostName/hostTarget/repoPath) and runtime handles at launch time
518
+ // (remotePid/remoteLog/remoteExit) — so the giant constructor stays untouched.
519
+ hostName = null;
520
+ hostTarget = null;
521
+ repoPath = null;
522
+ remotePid = null;
523
+ remoteLog = null;
524
+ remoteExit = null;
525
+ // Offset-tail cursor into the REMOTE log (bytes already pulled). Distinct from
526
+ // lastReadPos, which tracks the LOCAL mirror the parser consumes.
527
+ remoteLogOffset = 0;
528
+ // Per-wave batched-poll snapshot, refreshed each wave by the supervisor's
529
+ // one-ssh-per-host pre-pass (AgentManager.prefetchRemoteStatus) and read by
530
+ // isProcessAlive()/readNewEvents() so they skip their own SSH round-trip. It is
531
+ // set anew (and cleared for uncovered teammates) at the START of every prefetch,
532
+ // so it persists across BOTH poll passes within one wave (startReady's roster
533
+ // scan + the supervisor's listByTask) yet never carries into the next wave. Null
534
+ // outside a batched wave (e.g. a bare `teams status`), where a direct per-teammate
535
+ // SSH probe is the correctness fallback.
536
+ remotePollSnapshot = null;
459
537
  eventsCache = [];
460
538
  lastReadPos = 0;
461
539
  baseDir = null;
@@ -607,7 +685,72 @@ export class AgentProcess {
607
685
  }
608
686
  return latest;
609
687
  }
688
+ /**
689
+ * For a distributed (remote-host) teammate, pull NEW bytes of the host's log
690
+ * into the LOCAL mirror the parser consumes, advance the remote offset, and
691
+ * resolve terminal status from the remote `.exit` sentinel. Runs BEFORE the
692
+ * local read in readNewEvents(), so the existing stream-json parse path then
693
+ * runs unchanged over the freshly-mirrored bytes.
694
+ *
695
+ * Uses a per-wave batched snapshot (remotePollSnapshot) when the supervisor's
696
+ * one-ssh-per-host pre-pass populated it; otherwise falls back to its own
697
+ * round-trips so a bare `teams status`/`teams logs` is still correct.
698
+ */
699
+ async syncRemoteMirror() {
700
+ if (!this.hostName || !this.hostTarget || !this.remoteLog)
701
+ return;
702
+ // Pull the new remote bytes and append them to the local mirror the parser
703
+ // reads. One offset-tail round-trip; nothing to write when the log is quiet.
704
+ const delta = pullRemoteLogDelta(this.hostTarget, {
705
+ remoteLog: this.remoteLog,
706
+ offset: this.remoteLogOffset,
707
+ });
708
+ if (delta && delta.bytes.length > 0) {
709
+ const stdoutPath = await this.getStdoutPath();
710
+ try {
711
+ await fs.appendFile(stdoutPath, delta.bytes);
712
+ this.remoteLogOffset = delta.newOffset;
713
+ }
714
+ catch {
715
+ // best-effort mirror — leave the offset unadvanced so we retry next poll
716
+ }
717
+ }
718
+ // Resolve terminal status from the remote `.exit` sentinel (mirror
719
+ // reapProcess). Prefer this wave's batched snapshot; else fetch the exit file
720
+ // directly. The snapshot is left in place (refreshed each wave by prefetch),
721
+ // so a second poll pass within the same wave reuses it.
722
+ let exit = null;
723
+ const snap = this.remotePollSnapshot;
724
+ if (snap) {
725
+ exit = snap.exit;
726
+ }
727
+ else if (this.remoteExit) {
728
+ // UNQUOTED so `$HOME` in the dispatch exit path expands on the remote shell.
729
+ const res = sshExec(this.hostTarget, `cat ${this.remoteExit} 2>/dev/null`, {
730
+ timeoutMs: 8000,
731
+ multiplex: true,
732
+ });
733
+ exit = res.code === 0 && res.stdout.trim() !== '' ? res.stdout.trim() : null;
734
+ }
735
+ // Only latch terminal on a PARSEABLE exit code. A `.exit` that exists but is
736
+ // momentarily empty (created, not yet written) or garbage must NOT force a
737
+ // spurious FAILED — leave the teammate RUNNING and let the next poll resolve
738
+ // it once the code lands. Matches the direct-cat guard above.
739
+ if (exit !== null && exit.trim() !== '' && this.status === AgentStatus.RUNNING) {
740
+ const code = Number.parseInt(exit.trim(), 10);
741
+ if (Number.isFinite(code)) {
742
+ this.status = code === 0 ? AgentStatus.COMPLETED : AgentStatus.FAILED;
743
+ if (!this.completedAt)
744
+ this.completedAt = new Date();
745
+ }
746
+ }
747
+ }
610
748
  async readNewEvents() {
749
+ // Distributed teammate: mirror the host's new log bytes locally first, then
750
+ // fall through to the identical local read+parse below.
751
+ if (this.hostName) {
752
+ await this.syncRemoteMirror();
753
+ }
611
754
  const stdoutPath = await this.getStdoutPath();
612
755
  try {
613
756
  const stats = await fs.stat(stdoutPath).catch(() => null);
@@ -663,6 +806,55 @@ export class AgentProcess {
663
806
  catch (err) {
664
807
  console.error(`Error reading events for agent ${this.agentId}:`, err);
665
808
  }
809
+ // Distributed teammate: keep the orchestrator bounded across 10+ remote
810
+ // teammates. The parser has already consumed everything up to lastReadPos
811
+ // (status/digest updated), so both the on-disk mirror tail and the in-memory
812
+ // event backlog are safe to trim. The host keeps the full log.
813
+ if (this.hostName) {
814
+ await this.capMirrorToTail();
815
+ this.capEventsCache();
816
+ }
817
+ }
818
+ /**
819
+ * Truncate the local mirror to its trailing REMOTE_MIRROR_MAX_BYTES and reset
820
+ * lastReadPos to the new (smaller) size so the parser doesn't re-read the kept
821
+ * tail. Only trims when over the cap — a normal-length log is untouched.
822
+ */
823
+ async capMirrorToTail() {
824
+ const stdoutPath = await this.getStdoutPath();
825
+ try {
826
+ const stats = await fs.stat(stdoutPath).catch(() => null);
827
+ if (!stats || stats.size <= REMOTE_MIRROR_MAX_BYTES)
828
+ return;
829
+ const keep = REMOTE_MIRROR_MAX_BYTES;
830
+ const fd = await fs.open(stdoutPath, 'r');
831
+ const buf = Buffer.alloc(keep);
832
+ const { bytesRead } = await fd.read(buf, 0, keep, stats.size - keep);
833
+ await fd.close();
834
+ await fs.writeFile(stdoutPath, buf.subarray(0, bytesRead));
835
+ // The parser consumed up to lastReadPos already; after truncation the file
836
+ // is `bytesRead` long, so clamp the cursor to the new EOF. It never needs
837
+ // to re-read the retained tail (events already cached).
838
+ this.lastReadPos = Math.min(this.lastReadPos, bytesRead);
839
+ }
840
+ catch {
841
+ // best-effort — a failed cap just leaves the mirror larger this wave
842
+ }
843
+ }
844
+ /** Cap on the in-memory event backlog kept per remote teammate. */
845
+ static REMOTE_EVENTS_MAX = 200;
846
+ /**
847
+ * Drop the oldest cached events for a remote teammate once past the cap. The
848
+ * status path only needs recent events (last N messages, recentToolCalls,
849
+ * terminal status) and the getDelta cursor filters by timestamp, so a bounded
850
+ * recent window preserves the digest while bounding the heap. Terminal status
851
+ * is already latched onto `this.status`, so trimming can't lose it.
852
+ */
853
+ capEventsCache() {
854
+ const max = AgentProcess.REMOTE_EVENTS_MAX;
855
+ if (this.eventsCache.length > max) {
856
+ this.eventsCache = this.eventsCache.slice(-max);
857
+ }
666
858
  }
667
859
  async saveMeta() {
668
860
  const agentDir = await this.getAgentDir();
@@ -697,6 +889,13 @@ export class AgentProcess {
697
889
  cloud_branch: this.cloudBranch,
698
890
  worktree_name: this.worktreeName,
699
891
  worktree_path: this.worktreePath,
892
+ host_name: this.hostName,
893
+ host_target: this.hostTarget,
894
+ repo_path: this.repoPath,
895
+ remote_pid: this.remotePid,
896
+ remote_log: this.remoteLog,
897
+ remote_exit: this.remoteExit,
898
+ remote_log_offset: this.remoteLogOffset,
700
899
  };
701
900
  const metaPath = await this.getMetaPath();
702
901
  await fs.writeFile(metaPath, JSON.stringify(meta, null, 2));
@@ -738,6 +937,15 @@ export class AgentProcess {
738
937
  ? meta.task_type
739
938
  : null, meta.cloud_repo || null, meta.cloud_branch || null, meta.worktree_name || null, meta.worktree_path || null, meta.profile_name || null);
740
939
  agent.startTime = typeof meta.start_time === 'string' ? meta.start_time : null;
940
+ // Distributed-team fields: set post-construction (like startTime) so the
941
+ // constructor signature stays fixed. Null on every pre-existing teammate.
942
+ agent.hostName = meta.host_name || null;
943
+ agent.hostTarget = meta.host_target || null;
944
+ agent.repoPath = meta.repo_path || null;
945
+ agent.remotePid = typeof meta.remote_pid === 'number' ? meta.remote_pid : null;
946
+ agent.remoteLog = meta.remote_log || null;
947
+ agent.remoteExit = meta.remote_exit || null;
948
+ agent.remoteLogOffset = typeof meta.remote_log_offset === 'number' ? meta.remote_log_offset : 0;
741
949
  return agent;
742
950
  }
743
951
  catch {
@@ -745,6 +953,27 @@ export class AgentProcess {
745
953
  }
746
954
  }
747
955
  isProcessAlive() {
956
+ // Distributed teammate: a local PID is meaningless. Alive = the remote `.exit`
957
+ // sentinel is absent AND `kill -0 <remotePid>` succeeds on the host, resolved
958
+ // in a single ssh round-trip. Prefer the supervisor's batched snapshot when
959
+ // present (consume it once so it can't go stale); otherwise probe directly.
960
+ if (this.hostName) {
961
+ // Prefer this wave's batched snapshot (persists across the wave's poll
962
+ // passes; the supervisor refreshes it each wave). Fall back to a direct
963
+ // probe outside a wave.
964
+ if (this.remotePollSnapshot)
965
+ return this.remotePollSnapshot.alive;
966
+ if (!this.hostTarget || !this.remotePid || !this.remoteExit)
967
+ return false;
968
+ // remoteExit is a dispatch `$HOME/.agents/.cache/hosts/<hex>.exit` path —
969
+ // interpolate UNQUOTED so `$HOME` expands (shellQuote would defeat it).
970
+ const probe = `test -f ${this.remoteExit} && echo DEAD || ` +
971
+ `(kill -0 ${this.remotePid} 2>/dev/null && echo ALIVE || echo DEAD)`;
972
+ const res = sshExec(this.hostTarget, probe, { timeoutMs: 8000, multiplex: true });
973
+ if (res.code === null)
974
+ return true; // transient ssh failure — don't reap early
975
+ return res.stdout.trim().endsWith('ALIVE');
976
+ }
748
977
  if (!this.pid)
749
978
  return false;
750
979
  try {
@@ -768,6 +997,19 @@ export class AgentProcess {
768
997
  }
769
998
  async updateStatusFromProcess() {
770
999
  if (!this.pid) {
1000
+ // Distributed (remote-host) teammates have no local PID by design; their
1001
+ // lifecycle lives on the host. readNewEvents() mirrors the remote log and
1002
+ // resolves terminal status from the remote `.exit` sentinel (see
1003
+ // syncRemoteMirror), so we just persist and return — never the local
1004
+ // "RUNNING without a PID is impossible" fail path below.
1005
+ if (this.hostName) {
1006
+ await this.readNewEvents();
1007
+ if (this.status !== AgentStatus.RUNNING && !this.completedAt) {
1008
+ this.completedAt = this.getLatestEventTime() || this.startedAt || new Date();
1009
+ }
1010
+ await this.saveMeta();
1011
+ return;
1012
+ }
771
1013
  await this.readNewEvents();
772
1014
  // Cloud-backed teammates have no local PID by design; their lifecycle
773
1015
  // is driven by the remote provider instead of a local process.
@@ -997,7 +1239,7 @@ export class AgentManager {
997
1239
  }
998
1240
  debug(`Loaded ${loadedCount} agents from disk`);
999
1241
  }
1000
- async spawn(taskName, agentType, prompt, cwd = null, mode = null, 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) {
1242
+ async spawn(taskName, agentType, prompt, cwd = null, mode = null, 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) {
1001
1243
  await this.initialize();
1002
1244
  const resolvedMode = resolveMode(mode, this.defaultMode);
1003
1245
  // Enforce: teammate names are unique within a team.
@@ -1043,7 +1285,11 @@ export class AgentManager {
1043
1285
  // local CLI for them (the pod has its own). The caller has already
1044
1286
  // dispatched via the cloud provider and passed us the provider + session.
1045
1287
  const isCloudBacked = Boolean(cloudProvider);
1046
- if (!isCloudBacked) {
1288
+ // Distributed teammates run on another machine over SSH — the agent CLI must
1289
+ // be present on the HOST (checked via ensureHostReady in the command), not
1290
+ // locally. So skip the local availability check for both remote backends.
1291
+ const isRemoteBacked = Boolean(hostName);
1292
+ if (!isCloudBacked && !isRemoteBacked) {
1047
1293
  // Profile-backed teammates still spawn through `agents run`, which
1048
1294
  // resolves the profile to its host harness — so the CLI we need to be
1049
1295
  // present is the underlying agentType, not the profile name.
@@ -1060,6 +1306,12 @@ export class AgentManager {
1060
1306
  ? AgentStatus.PENDING
1061
1307
  : AgentStatus.RUNNING;
1062
1308
  const agent = new AgentProcess(agentId, taskName, agentType, prompt, resolvedCwd, resolvedMode, null, initialStatus, new Date(), null, this.agentsDir, parentSessionId, workspaceDir, cloudSessionId, cloudProvider, null, version, null, name, cleanAfter, effort, model, envOverrides && Object.keys(envOverrides).length > 0 ? envOverrides : null, taskType, cloudRepo, cloudBranch, worktreeName, worktreePath, profileName);
1309
+ // Distributed-team placement: set post-construction (like startTime), so the
1310
+ // giant constructor stays fixed. launchRemoteProcess() reads these to dispatch
1311
+ // over SSH and fills in the runtime handles (remotePid/remoteLog/remoteExit).
1312
+ agent.hostName = hostName;
1313
+ agent.hostTarget = hostTarget;
1314
+ agent.repoPath = repoPath;
1063
1315
  const agentDir = await agent.getAgentDir();
1064
1316
  try {
1065
1317
  await fs.mkdir(agentDir, { recursive: true });
@@ -1086,8 +1338,20 @@ export class AgentManager {
1086
1338
  await agent.saveMeta();
1087
1339
  debug(`Cloud-backed ${agentType} teammate via ${cloudProvider} (session=${cloudSessionId})`);
1088
1340
  }
1341
+ else if (isRemoteBacked) {
1342
+ // Distributed teammate that can run now (no unmet --after deps): dispatch
1343
+ // it onto its host over SSH instead of a local spawn.
1344
+ await this.launchRemoteProcess(agent);
1345
+ }
1089
1346
  else {
1090
- await this.launchProcess(agent);
1347
+ // Unpinned + launching now: consult the pool scheduler before defaulting to
1348
+ // local, so an unpinned teammate on a --devices team auto-schedules even when
1349
+ // added without --after (it wouldn't pass through startReady otherwise).
1350
+ await this.maybeSchedulePlacement(agent, taskName);
1351
+ if (agent.hostName)
1352
+ await this.launchRemoteProcess(agent);
1353
+ else
1354
+ await this.launchProcess(agent);
1091
1355
  }
1092
1356
  await this.cleanupOldAgents();
1093
1357
  return agent;
@@ -1149,6 +1413,182 @@ export class AgentManager {
1149
1413
  }
1150
1414
  debug(`Launched agent ${agent.agentId} with PID ${agent.pid}`);
1151
1415
  }
1416
+ /**
1417
+ * Dispatch a distributed teammate onto its host over SSH — the remote-host
1418
+ * analog of launchProcess(). Symmetric to the cloud path: no local process; the
1419
+ * lifecycle lives on the host and is polled (isProcessAlive/readNewEvents over
1420
+ * SSH via the remote `.exit` sentinel + offset-tailed log).
1421
+ *
1422
+ * When the team uses worktrees (agent.worktreeName set), a git worktree is first
1423
+ * created ON THE HOST off the freshly-fetched default branch; the teammate runs
1424
+ * there. Otherwise it runs in the host repo path directly.
1425
+ */
1426
+ async launchRemoteProcess(agent) {
1427
+ if (!agent.hostName || !agent.hostTarget || !agent.repoPath) {
1428
+ throw new Error(`Remote teammate ${agent.agentId} is missing host placement (host/target/repo).`);
1429
+ }
1430
+ // Re-resolve the device → Host at launch time (it may have moved / changed
1431
+ // address since `add` staged the teammate), matching how the command resolved
1432
+ // it. The target string on the agent stays the launch-time source of truth for
1433
+ // subsequent polling.
1434
+ const host = await resolveHost(agent.hostName);
1435
+ if (!host) {
1436
+ throw new Error(`Cannot launch remote teammate ${agent.agentId}: device "${agent.hostName}" no longer resolves.`);
1437
+ }
1438
+ // Ensure agents-cli is present + version-matched on the host; surface (not
1439
+ // fail on) an agent-not-installed warning like dispatch.ts does.
1440
+ try {
1441
+ const { warnings } = ensureHostReady(host, { agent: agent.agentType });
1442
+ for (const w of warnings)
1443
+ process.stderr.write(`[teams] warning: ${w}\n`);
1444
+ }
1445
+ catch (err) {
1446
+ throw new Error(`Host "${agent.hostName}" not ready for teammate ${agent.agentId}: ${err.message}`);
1447
+ }
1448
+ // Worktree isolation on the host, if the team enables it. createRemoteWorktree
1449
+ // fetches origin and branches off origin/<default>, returning the host path.
1450
+ let remoteCwd = agent.repoPath;
1451
+ if (agent.worktreeName) {
1452
+ const worktreePath = createRemoteWorktree(agent.hostTarget, agent.repoPath, agent.worktreeName);
1453
+ agent.worktreePath = worktreePath;
1454
+ remoteCwd = worktreePath;
1455
+ }
1456
+ // Same run argv the local path builds (shared buildRunArgv keeps the prompt
1457
+ // scaffolding + flags from drifting); dispatched non-blocking (follow:false)
1458
+ // — the supervisor polls the host, we don't block here.
1459
+ const effort = agent.effort ?? 'medium';
1460
+ const forwardedArgs = this.buildRunArgv(agent.agentType, agent.prompt, agent.mode, agent.model ?? null, effort, agent.version, agent.profileName);
1461
+ try {
1462
+ const { task } = await dispatchAgentsCommand(host, {
1463
+ forwardedArgs,
1464
+ remoteCwd,
1465
+ follow: false,
1466
+ });
1467
+ agent.remotePid = task.pid ?? null;
1468
+ agent.remoteLog = task.remoteLog ?? null;
1469
+ agent.remoteExit = task.remoteExit ?? null;
1470
+ agent.remoteLogOffset = 0;
1471
+ agent.status = AgentStatus.RUNNING;
1472
+ agent.startedAt = new Date();
1473
+ await agent.saveMeta();
1474
+ }
1475
+ catch (err) {
1476
+ console.error(`Failed to launch remote teammate ${agent.agentId} on ${agent.hostName}:`, err);
1477
+ throw new Error(`Failed to launch remote teammate: ${err.message}`);
1478
+ }
1479
+ debug(`Launched remote agent ${agent.agentId} on ${agent.hostName} (remote pid ${agent.remotePid})`);
1480
+ }
1481
+ /**
1482
+ * Resolve a scheduler-picked device to host placement fields on an unpinned
1483
+ * teammate at LAUNCH time (the same resolution `teams add --device` runs, minus
1484
+ * the fatal `die()` — a scheduling failure here is per-teammate, not per-add).
1485
+ * Sets hostName/hostTarget/repoPath + persists, so the subsequent
1486
+ * launchRemoteProcess dispatches over SSH. Mirrors the `add`-time pin path:
1487
+ * resolve device → reject Windows (POSIX-only) → ssh target → ensure the repo
1488
+ * is present on the host from the team's --repo (ensureRemoteRepo).
1489
+ */
1490
+ async resolveScheduledPlacement(agent, device, taskName) {
1491
+ const host = await resolveHost(device);
1492
+ if (!host) {
1493
+ throw new Error(`Scheduler picked device "${device}" but it no longer resolves.`);
1494
+ }
1495
+ if (remoteShellFor(host.os ?? resolveRemoteOsSync(host.name)) === 'powershell') {
1496
+ throw new Error(`Scheduler picked Windows device "${host.name}", but distributed teammates are POSIX-only in v1.`);
1497
+ }
1498
+ const target = sshTargetFor(host);
1499
+ const teamMeta = await getTeam(taskName);
1500
+ const repoRoot = ensureRemoteRepo(target, teamMeta?.repo ?? '', taskName);
1501
+ agent.hostName = host.name;
1502
+ agent.hostTarget = target;
1503
+ agent.repoPath = repoRoot;
1504
+ await agent.saveMeta();
1505
+ }
1506
+ /**
1507
+ * Place an UNPINNED, non-cloud teammate onto the team pool via the cascade
1508
+ * (least-loaded), if the team declares one. A no-op for a pinned teammate
1509
+ * (hostName already set from `--device`), a cloud teammate, or a poolless team —
1510
+ * leaving hostName null so the local spawn runs unchanged. Shared by spawn()
1511
+ * (immediate add-launch) and startReady() (staged launch) so an unpinned pool
1512
+ * teammate schedules identically no matter how it was fired.
1513
+ */
1514
+ async maybeSchedulePlacement(agent, taskName) {
1515
+ if (agent.hostName || agent.cloudProvider)
1516
+ return;
1517
+ const teamMeta = await getTeam(taskName);
1518
+ if (!teamMeta)
1519
+ return;
1520
+ const roster = await this.listByTask(taskName);
1521
+ const { device } = resolvePlacement(teamMeta, null, roster);
1522
+ if (device)
1523
+ await this.resolveScheduledPlacement(agent, device, taskName);
1524
+ }
1525
+ /**
1526
+ * One-ssh-per-host batched liveness/exit pre-pass for a team's remote teammates.
1527
+ * The supervisor calls this each wave BEFORE listByTask() so the per-teammate
1528
+ * isProcessAlive()/readNewEvents() consume a cached snapshot instead of each
1529
+ * issuing its own SSH handshake — avoiding N round-trips per wave at 10+ remote
1530
+ * teammates. Groups by hostTarget and, for each host, checks every teammate's
1531
+ * `.exit` + `kill -0` in a single ssh call over the shared ControlMaster socket.
1532
+ */
1533
+ async prefetchRemoteStatus(taskName) {
1534
+ await this.initialize();
1535
+ // Read the in-memory roster directly — going through listByTask()/listAll()
1536
+ // would poll each teammate first (an SSH round-trip apiece), defeating the
1537
+ // batch. The caller (supervisor) has already rescanned from disk this wave.
1538
+ const remotes = Array.from(this.agents.values()).filter((a) => a.taskName === taskName && a.hostName);
1539
+ // Fresh snapshots each wave: clear stale ones first so a teammate that has
1540
+ // since finished (dropped from the RUNNING filter below) can't carry an old
1541
+ // ALIVE reading into this wave's poll.
1542
+ for (const a of remotes)
1543
+ a.remotePollSnapshot = null;
1544
+ const teammates = remotes.filter((a) => a.hostTarget && a.remotePid && a.remoteExit &&
1545
+ a.status === AgentStatus.RUNNING);
1546
+ if (teammates.length === 0)
1547
+ return;
1548
+ const byTarget = new Map();
1549
+ for (const a of teammates) {
1550
+ const arr = byTarget.get(a.hostTarget) || [];
1551
+ arr.push(a);
1552
+ byTarget.set(a.hostTarget, arr);
1553
+ }
1554
+ for (const [target, agents] of byTarget) {
1555
+ // Emit one line per teammate: "<agentId> ALIVE|DEAD <exitOrEmpty>". A single
1556
+ // round-trip over the multiplexed socket, regardless of teammate count.
1557
+ const parts = agents.map((a) => {
1558
+ const id = a.agentId;
1559
+ // remoteExit is a dispatch `$HOME/.agents/.cache/hosts/<hex>.exit` path —
1560
+ // interpolate UNQUOTED so `$HOME` expands (shellQuote would make `[ -f ]`
1561
+ // always miss, so a finished teammate would never resolve terminal).
1562
+ const exitFile = a.remoteExit;
1563
+ // exit code (if the sentinel exists) OR empty, then liveness.
1564
+ return (`printf '%s ' ${shellQuote(id)}; ` +
1565
+ `if [ -f ${exitFile} ]; then printf 'DEAD '; cat ${exitFile} 2>/dev/null | tr -d '\\n'; printf '\\n'; ` +
1566
+ `elif kill -0 ${a.remotePid} 2>/dev/null; then printf 'ALIVE\\n'; ` +
1567
+ `else printf 'DEAD\\n'; fi`);
1568
+ });
1569
+ const res = sshExec(target, parts.join('; '), { timeoutMs: 12000, multiplex: true });
1570
+ if (res.code === null)
1571
+ continue; // transient ssh failure — skip this wave, no snapshot
1572
+ const snapshots = new Map();
1573
+ for (const line of res.stdout.split('\n')) {
1574
+ const trimmed = line.trim();
1575
+ if (!trimmed)
1576
+ continue;
1577
+ const [id, state, exit] = trimmed.split(/\s+/);
1578
+ if (!id)
1579
+ continue;
1580
+ snapshots.set(id, {
1581
+ alive: state === 'ALIVE',
1582
+ exit: state === 'DEAD' ? (exit ?? '') : null,
1583
+ });
1584
+ }
1585
+ for (const a of agents) {
1586
+ const snap = snapshots.get(a.agentId);
1587
+ if (snap)
1588
+ a.remotePollSnapshot = snap;
1589
+ }
1590
+ }
1591
+ }
1152
1592
  /**
1153
1593
  * Fire any pending teammates in the given team whose `after` deps have all
1154
1594
  * completed. Returns the list of teammates just launched. Repeatable:
@@ -1169,8 +1609,24 @@ export class AgentManager {
1169
1609
  });
1170
1610
  if (!depsReady)
1171
1611
  continue;
1612
+ // Auto-scheduling: an UNPINNED teammate (no explicit --device at add time)
1613
+ // gets placed now via the pool cascade — same helper spawn() uses so the
1614
+ // immediate-add and staged paths agree. A null pick keeps hostName null →
1615
+ // local spawn, unchanged. Cloud teammates never schedule.
1616
+ try {
1617
+ await this.maybeSchedulePlacement(agent, taskName);
1618
+ }
1619
+ catch (err) {
1620
+ console.error(`Could not schedule ${agent.agentId} onto the team pool:`, err);
1621
+ continue;
1622
+ }
1172
1623
  try {
1173
- if (agent.cloudProvider) {
1624
+ if (agent.hostName) {
1625
+ // Distributed teammate: dispatch onto its host over SSH.
1626
+ await this.launchRemoteProcess(agent);
1627
+ launched.push(agent);
1628
+ }
1629
+ else if (agent.cloudProvider) {
1174
1630
  if (!this.cloudDispatcher) {
1175
1631
  console.error(`Cannot start cloud-backed teammate ${agent.agentId}: no dispatcher registered.`);
1176
1632
  continue;
@@ -1200,7 +1656,20 @@ export class AgentManager {
1200
1656
  * exec path (src/lib/exec.ts). The team runner just supplies prompt + mode
1201
1657
  * and reads stream-json events off stdout.
1202
1658
  */
1203
- buildCommand(agentType, prompt, mode, model, cwd = null, sessionId = null, effort = 'medium', version = null, profileName = null) {
1659
+ /**
1660
+ * Build the `agents run …` argv AFTER the `agents` binary — the flags + prompt
1661
+ * scaffolding shared by the LOCAL launch (buildCommand, which prefixes
1662
+ * process.execPath + the agents CLI path) and the REMOTE launch
1663
+ * (launchRemoteProcess, which prefixes `agents` on the host via dispatch). Kept
1664
+ * in one place so the PROMPT_SUFFIX / CLAUDE_PLAN_MODE_PREFIX scaffolding and the
1665
+ * flag set can never drift between the two backends.
1666
+ *
1667
+ * `cwd` is intentionally NOT emitted here: the local path passes it as
1668
+ * `--cwd`/`--add-dir` (below), while the remote path `cd`s into the host cwd
1669
+ * before invoking `agents`. `sessionId` is likewise local-only (the remote run
1670
+ * mints its own session on the host).
1671
+ */
1672
+ buildRunArgv(agentType, prompt, mode, model, effort, version, profileName) {
1204
1673
  // Compose the prompt: a plan-mode prefix for Claude (clarifying headless
1205
1674
  // plan-mode restrictions) and a universal summary suffix. These are
1206
1675
  // team-specific prompt scaffolding — `agents run` does not apply them.
@@ -1212,10 +1681,7 @@ export class AgentManager {
1212
1681
  // host harness, version pin, and env injection in one place. Plain
1213
1682
  // version pins only apply when no profile is selected.
1214
1683
  const target = profileName ?? (version ? `${agentType}@${version}` : agentType);
1215
- const agentsCli = process.argv[1];
1216
- const cmd = [
1217
- process.execPath,
1218
- agentsCli,
1684
+ const args = [
1219
1685
  'run',
1220
1686
  target,
1221
1687
  fullPrompt,
@@ -1226,7 +1692,16 @@ export class AgentManager {
1226
1692
  '--quiet',
1227
1693
  ];
1228
1694
  if (model)
1229
- cmd.push('--model', model);
1695
+ args.push('--model', model);
1696
+ return args;
1697
+ }
1698
+ buildCommand(agentType, prompt, mode, model, cwd = null, sessionId = null, effort = 'medium', version = null, profileName = null) {
1699
+ const agentsCli = process.argv[1];
1700
+ const cmd = [
1701
+ process.execPath,
1702
+ agentsCli,
1703
+ ...this.buildRunArgv(agentType, prompt, mode, model, effort, version, profileName),
1704
+ ];
1230
1705
  if (cwd)
1231
1706
  cmd.push('--cwd', cwd);
1232
1707
  // Pin Claude's session UUID to our agent_id so its session file lands at
@@ -1339,6 +1814,31 @@ export class AgentManager {
1339
1814
  if (!agent) {
1340
1815
  return false;
1341
1816
  }
1817
+ // Distributed teammate: no local PID — signal it over SSH. Try the process
1818
+ // GROUP first (negative pid, matching local `kill(-pid)`) to catch the
1819
+ // detached `agents run` and its children; but the remote launcher is
1820
+ // `nohup bash -lc … &` under a non-interactive shell where job control is off,
1821
+ // so `&` may NOT open a new group — fall back to signalling the wrapper pid
1822
+ // directly. Best-effort either way; the `.exit` sentinel is the durable
1823
+ // terminal-status source if a grandchild lingers.
1824
+ if (agent.hostName && agent.status === AgentStatus.RUNNING) {
1825
+ if (agent.hostTarget && agent.remotePid) {
1826
+ try {
1827
+ sshExec(agent.hostTarget, `kill -TERM -- -${agent.remotePid} 2>/dev/null || kill -TERM ${agent.remotePid} 2>/dev/null`, {
1828
+ timeoutMs: 10000,
1829
+ multiplex: true,
1830
+ });
1831
+ }
1832
+ catch {
1833
+ // best-effort — record the stop regardless
1834
+ }
1835
+ }
1836
+ agent.status = AgentStatus.STOPPED;
1837
+ agent.completedAt = new Date();
1838
+ await agent.saveMeta();
1839
+ debug(`Stopped remote agent ${agentId} on ${agent.hostName}`);
1840
+ return true;
1841
+ }
1342
1842
  if (agent.pid && agent.status === AgentStatus.RUNNING) {
1343
1843
  // PID-reuse guard: if the PID we recorded at spawn no longer maps to
1344
1844
  // our process (start-time mismatch), the OS has recycled it. Sending