@phnx-labs/agents-cli 1.22.33 → 1.22.35

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.
Files changed (81) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +9 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/accounts.js +3 -3
  5. package/dist/commands/browser-sessions-picker.d.ts +16 -0
  6. package/dist/commands/browser-sessions-picker.js +179 -0
  7. package/dist/commands/browser.js +9 -4
  8. package/dist/commands/hosts.js +1 -5
  9. package/dist/commands/inspect.js +174 -41
  10. package/dist/commands/message.d.ts +6 -1
  11. package/dist/commands/message.js +60 -3
  12. package/dist/commands/sessions.d.ts +54 -4
  13. package/dist/commands/sessions.js +252 -46
  14. package/dist/commands/share.d.ts +18 -0
  15. package/dist/commands/share.js +108 -0
  16. package/dist/commands/ssh.js +17 -5
  17. package/dist/commands/teams.d.ts +28 -0
  18. package/dist/commands/teams.js +148 -13
  19. package/dist/commands/upgrade.d.ts +7 -0
  20. package/dist/commands/upgrade.js +10 -0
  21. package/dist/commands/watchdog.d.ts +2 -0
  22. package/dist/commands/watchdog.js +112 -27
  23. package/dist/index.js +51 -59
  24. package/dist/lib/agents.js +6 -0
  25. package/dist/lib/browser/sessions-list.d.ts +81 -0
  26. package/dist/lib/browser/sessions-list.js +179 -4
  27. package/dist/lib/codex-policy.d.ts +9 -1
  28. package/dist/lib/codex-policy.js +17 -2
  29. package/dist/lib/daemon.js +45 -5
  30. package/dist/lib/devices/connect.d.ts +33 -0
  31. package/dist/lib/devices/connect.js +61 -3
  32. package/dist/lib/devices/doctor-findings.d.ts +4 -2
  33. package/dist/lib/devices/doctor-findings.js +4 -2
  34. package/dist/lib/exec.js +65 -8
  35. package/dist/lib/help.d.ts +3 -2
  36. package/dist/lib/help.js +4 -0
  37. package/dist/lib/hosts/dispatch.d.ts +2 -32
  38. package/dist/lib/hosts/dispatch.js +6 -61
  39. package/dist/lib/hosts/tasks.d.ts +7 -0
  40. package/dist/lib/hosts/tasks.js +9 -0
  41. package/dist/lib/mailbox-target.d.ts +27 -0
  42. package/dist/lib/mailbox-target.js +21 -0
  43. package/dist/lib/mcp.d.ts +10 -0
  44. package/dist/lib/mcp.js +21 -2
  45. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  46. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  47. package/dist/lib/migrate.d.ts +11 -0
  48. package/dist/lib/migrate.js +40 -0
  49. package/dist/lib/project-key.d.ts +17 -0
  50. package/dist/lib/project-key.js +26 -0
  51. package/dist/lib/project-root.d.ts +47 -0
  52. package/dist/lib/project-root.js +68 -0
  53. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  54. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  55. package/dist/lib/secrets/agent.d.ts +9 -2
  56. package/dist/lib/secrets/agent.js +52 -7
  57. package/dist/lib/secrets/reaper.d.ts +24 -3
  58. package/dist/lib/secrets/reaper.js +55 -6
  59. package/dist/lib/session/discover.js +12 -0
  60. package/dist/lib/session/render.d.ts +2 -0
  61. package/dist/lib/session/render.js +1 -1
  62. package/dist/lib/share/delete.d.ts +93 -0
  63. package/dist/lib/share/delete.js +127 -0
  64. package/dist/lib/shims.js +48 -4
  65. package/dist/lib/startup/command-registry.d.ts +1 -0
  66. package/dist/lib/startup/command-registry.js +11 -3
  67. package/dist/lib/startup/root-command.d.ts +3 -0
  68. package/dist/lib/startup/root-command.js +10 -0
  69. package/dist/lib/teams/agents.d.ts +136 -6
  70. package/dist/lib/teams/agents.js +324 -58
  71. package/dist/lib/teams/worktree.d.ts +39 -2
  72. package/dist/lib/teams/worktree.js +60 -4
  73. package/dist/lib/types.d.ts +11 -0
  74. package/dist/lib/versions.js +2 -2
  75. package/dist/lib/watchdog/history.d.ts +20 -0
  76. package/dist/lib/watchdog/history.js +46 -0
  77. package/dist/lib/watchdog/log.d.ts +16 -1
  78. package/dist/lib/watchdog/log.js +82 -2
  79. package/dist/lib/watchdog/runner.d.ts +12 -0
  80. package/dist/lib/watchdog/runner.js +20 -0
  81. package/package.json +1 -1
@@ -129,6 +129,46 @@ export var AgentStatus;
129
129
  AgentStatus["FAILED"] = "failed";
130
130
  AgentStatus["STOPPED"] = "stopped";
131
131
  })(AgentStatus || (AgentStatus = {}));
132
+ /**
133
+ * The statuses a teammate can never leave — its process has run and finished
134
+ * (or been stopped). Everything else (pending, running) is still live work.
135
+ *
136
+ * This is the ONLY set that retention (cleanupOldAgents) may reap: a `pending`
137
+ * teammate has not launched yet and a `running` one is doing work, so deleting
138
+ * either is data loss. Treating "not running" as "completed" was the RUSH-2356
139
+ * bug — it swept live `pending` `--after` teammates past the 50-record cap.
140
+ */
141
+ export const TERMINAL_STATUSES = new Set([
142
+ AgentStatus.COMPLETED,
143
+ AgentStatus.FAILED,
144
+ AgentStatus.STOPPED,
145
+ ]);
146
+ /** True when a teammate has reached a terminal (completed/failed/stopped) status. */
147
+ export function isTerminalStatus(status) {
148
+ return TERMINAL_STATUSES.has(status);
149
+ }
150
+ /**
151
+ * The per-teammate shell that emits `<id> <ALIVE|EXITED|GONE> <codeOrEmpty>`.
152
+ * Shared by the batched prefetch (many teammates, one round-trip) and the
153
+ * direct single-teammate probe, so both classify liveness identically.
154
+ * `exitFile` is interpolated UNQUOTED so `$HOME` in the dispatch path expands on
155
+ * the remote shell (shellQuote would defeat the `[ -f ]` test).
156
+ */
157
+ export function remoteLivenessSnippet(id, exitFile, pid) {
158
+ return (`printf '%s ' ${shellQuote(id)}; ` +
159
+ `if [ -f ${exitFile} ]; then printf 'EXITED '; cat ${exitFile} 2>/dev/null | tr -d '\\n'; printf '\\n'; ` +
160
+ `elif kill -0 ${pid} 2>/dev/null; then printf 'ALIVE\\n'; ` +
161
+ `else printf 'GONE\\n'; fi`);
162
+ }
163
+ /** Parse one `<STATE> <codeOrEmpty>` reading into a snapshot. */
164
+ export function parseRemoteLivenessState(state, code) {
165
+ if (state === 'ALIVE')
166
+ return { alive: true, exit: null, exitFilePresent: false };
167
+ if (state === 'EXITED')
168
+ return { alive: false, exit: code ?? '', exitFilePresent: true };
169
+ // GONE (or an unrecognised token): process not alive, no sentinel recorded.
170
+ return { alive: false, exit: null, exitFilePresent: false };
171
+ }
132
172
  export const VALID_TASK_TYPES = [
133
173
  'plan', 'implement', 'test', 'review', 'bugfix', 'docs',
134
174
  ];
@@ -705,36 +745,60 @@ export class AgentProcess {
705
745
  // best-effort mirror — leave the offset unadvanced so we retry next poll
706
746
  }
707
747
  }
708
- // Resolve terminal status from the remote `.exit` sentinel (mirror
709
- // reapProcess). Prefer this wave's batched snapshot; else fetch the exit file
710
- // directly. The snapshot is left in place (refreshed each wave by prefetch),
711
- // so a second poll pass within the same wave reuses it.
712
- let exit = null;
713
- const snap = this.remotePollSnapshot;
714
- if (snap) {
715
- exit = snap.exit;
716
- }
717
- else if (this.remoteExit) {
718
- // UNQUOTED so `$HOME` in the dispatch exit path expands on the remote shell.
719
- const res = sshExec(this.hostTarget, `cat ${this.remoteExit} 2>/dev/null`, {
720
- timeoutMs: 8000,
721
- multiplex: true,
722
- extraSshArgs: this.hostIdentityFile ? ['-i', this.hostIdentityFile, '-o', 'IdentitiesOnly=yes'] : [],
723
- });
724
- exit = res.code === 0 && res.stdout.trim() !== '' ? res.stdout.trim() : null;
725
- }
748
+ // Resolve terminal status from the host. Prefer this wave's batched snapshot;
749
+ // else probe this teammate directly. The snapshot is left in place (refreshed
750
+ // each wave by prefetch), so a second poll pass within the same wave reuses it.
751
+ const snap = this.remotePollSnapshot ?? (await this.probeRemoteLiveness());
752
+ if (!snap)
753
+ return; // transient ssh failure — leave RUNNING, retry next poll
726
754
  // Only latch terminal on a PARSEABLE exit code. A `.exit` that exists but is
727
755
  // momentarily empty (created, not yet written) or garbage must NOT force a
728
756
  // spurious FAILED — leave the teammate RUNNING and let the next poll resolve
729
- // it once the code lands. Matches the direct-cat guard above.
730
- if (exit !== null && exit.trim() !== '' && this.status === AgentStatus.RUNNING) {
731
- const code = Number.parseInt(exit.trim(), 10);
757
+ // it once the code lands.
758
+ if (snap.exit !== null && snap.exit.trim() !== '' && this.status === AgentStatus.RUNNING) {
759
+ const code = Number.parseInt(snap.exit.trim(), 10);
732
760
  if (Number.isFinite(code)) {
733
761
  this.status = code === 0 ? AgentStatus.COMPLETED : AgentStatus.FAILED;
734
762
  if (!this.completedAt)
735
763
  this.completedAt = new Date();
764
+ return;
736
765
  }
737
766
  }
767
+ // No exit code resolved it. If the remote process is GONE with NO sentinel at
768
+ // all, the wrapper died before recording `$?` (killed, box lost, OOM) — it can
769
+ // never write a code, so this teammate is FAILED, not "running forever"
770
+ // (RUSH-2366). This is the remote analog of reapProcess()'s "sentinel absent
771
+ // -> 1 -> FAILED". An EXITED-but-empty `.exit` (wrapper mid-write) is left
772
+ // RUNNING above precisely so this branch does not misfire on that race.
773
+ if (this.status === AgentStatus.RUNNING && !snap.alive && !snap.exitFilePresent) {
774
+ this.status = AgentStatus.FAILED;
775
+ if (!this.completedAt)
776
+ this.completedAt = this.getLatestEventTime() || this.startedAt || new Date();
777
+ }
778
+ }
779
+ /**
780
+ * One-shot direct liveness probe for a single remote teammate — the fallback
781
+ * used outside a batched supervisor wave (a bare `teams status`, `mgr.get()`
782
+ * for `teams resume`). Returns null on a transient ssh failure so the caller
783
+ * leaves the teammate RUNNING rather than reaping it on a dropped connection.
784
+ */
785
+ async probeRemoteLiveness() {
786
+ if (!this.hostTarget || !this.remotePid || !this.remoteExit)
787
+ return null;
788
+ const res = sshExec(this.hostTarget, remoteLivenessSnippet(this.agentId, this.remoteExit, this.remotePid), {
789
+ timeoutMs: 8000,
790
+ multiplex: true,
791
+ extraSshArgs: this.hostIdentityFile ? ['-i', this.hostIdentityFile, '-o', 'IdentitiesOnly=yes'] : [],
792
+ });
793
+ if (res.code === null)
794
+ return null; // transient ssh failure — don't reap early
795
+ const trimmed = res.stdout.trim();
796
+ if (!trimmed)
797
+ return null;
798
+ const [, state, code] = trimmed.split(/\s+/);
799
+ if (!state)
800
+ return null;
801
+ return parseRemoteLivenessState(state, code);
738
802
  }
739
803
  /** Reset the local stdout cursor for a newly truncated resume log. */
740
804
  resetLogReadPosition() {
@@ -1015,12 +1079,64 @@ export class AgentProcess {
1015
1079
  }
1016
1080
  return true;
1017
1081
  }
1082
+ /**
1083
+ * Read just the persisted status + completion time from meta.json, without
1084
+ * reconstructing the whole teammate. Returns null when there is no readable
1085
+ * record on disk. Used to detect that ANOTHER process (a `teams stop`, a
1086
+ * sibling supervisor) has already moved this teammate to a terminal status.
1087
+ */
1088
+ async readDiskStatus() {
1089
+ let raw;
1090
+ try {
1091
+ raw = await fs.readFile(await this.getMetaPath(), 'utf-8');
1092
+ }
1093
+ catch {
1094
+ return null;
1095
+ }
1096
+ try {
1097
+ const meta = JSON.parse(raw);
1098
+ const validStatuses = Object.values(AgentStatus);
1099
+ const status = validStatuses.includes(meta.status)
1100
+ ? meta.status
1101
+ : AgentStatus.RUNNING;
1102
+ const completedAt = meta.completed_at ? new Date(meta.completed_at) : null;
1103
+ return { status, completedAt };
1104
+ }
1105
+ catch {
1106
+ return null;
1107
+ }
1108
+ }
1109
+ /**
1110
+ * If this in-memory teammate is still non-terminal but disk already shows a
1111
+ * terminal status, adopt the disk state. Returns true when it did.
1112
+ *
1113
+ * This is the guard against the stale-manager race (RUSH-2366): a long-lived
1114
+ * supervisor holding a teammate as `running` must never re-persist that stale
1115
+ * `running` over a `stopped`/`failed`/`completed` another process just wrote
1116
+ * (e.g. an explicit `teams stop` in a separate CLI invocation). A terminal
1117
+ * status is a one-way latch, so disk-terminal always wins over memory-running.
1118
+ */
1119
+ async adoptDiskTerminalIfNewer() {
1120
+ if (isTerminalStatus(this.status))
1121
+ return false;
1122
+ const disk = await this.readDiskStatus();
1123
+ if (!disk || !isTerminalStatus(disk.status))
1124
+ return false;
1125
+ this.status = disk.status;
1126
+ this.completedAt = disk.completedAt ?? this.completedAt ?? new Date();
1127
+ return true;
1128
+ }
1018
1129
  /**
1019
1130
  * @param opts.skipRemote A `--local` caller (RUSH-2118): a distributed
1020
1131
  * teammate is never dialed — its in-memory state (already loaded from
1021
1132
  * meta.json) stands as-is, no ssh, no re-save.
1022
1133
  */
1023
1134
  async updateStatusFromProcess(opts = {}) {
1135
+ // Stale-manager guard (RUSH-2366): if disk has already latched this teammate
1136
+ // terminal, adopt that and stop — a poll of a process that no longer exists
1137
+ // must not re-persist `running` over the newer on-disk terminal status.
1138
+ if (await this.adoptDiskTerminalIfNewer())
1139
+ return;
1024
1140
  if (!this.pid) {
1025
1141
  // Distributed (remote-host) teammates have no local PID by design; their
1026
1142
  // lifecycle lives on the host. readNewEvents() mirrors the remote log and
@@ -1030,6 +1146,15 @@ export class AgentProcess {
1030
1146
  if (this.hostName) {
1031
1147
  if (opts.skipRemote)
1032
1148
  return;
1149
+ // Staged (--after) distributed teammates also have hostName set but no
1150
+ // PID yet (RUSH-2356 sibling bug): without this guard the `!== RUNNING`
1151
+ // fallback below stamps a completedAt on a teammate that hasn't even
1152
+ // launched, which the age-based reap in loadExistingAgents() would
1153
+ // later delete outright once it aged past cleanupAgeDays. Leave it
1154
+ // alone until startReady() launches it — matches the local-only guard
1155
+ // further below.
1156
+ if (this.status === AgentStatus.PENDING)
1157
+ return;
1033
1158
  await this.readNewEvents();
1034
1159
  if (this.status !== AgentStatus.RUNNING && !this.completedAt) {
1035
1160
  this.completedAt = this.getLatestEventTime() || this.startedAt || new Date();
@@ -1041,6 +1166,10 @@ export class AgentProcess {
1041
1166
  // Cloud-backed teammates have no local PID by design; their lifecycle
1042
1167
  // is driven by the remote provider instead of a local process.
1043
1168
  if (this.cloudProvider) {
1169
+ // Same staged-teammate guard as the hostName branch above — a staged
1170
+ // cloud teammate is PENDING with no PID until its deps resolve.
1171
+ if (this.status === AgentStatus.PENDING)
1172
+ return;
1044
1173
  if (!this.completedAt && this.status !== AgentStatus.RUNNING) {
1045
1174
  const fallbackCompletion = this.getLatestEventTime() || this.startedAt || new Date();
1046
1175
  this.completedAt = fallbackCompletion;
@@ -1207,6 +1336,13 @@ export class AgentManager {
1207
1336
  */
1208
1337
  localOnly;
1209
1338
  constructorAgentsDir = null;
1339
+ /**
1340
+ * One-shot memo of the last `validateAddPreconditions` result, so the
1341
+ * command-layer pre-worktree call and spawn()'s own call don't each pay a
1342
+ * full `listAll()` status refresh (a round of SSH probes on a `--device`
1343
+ * team). Consumed by the first matching call — see that method.
1344
+ */
1345
+ validatedAdd = null;
1210
1346
  constructor(maxAgents = 50, agentsDir = null, defaultMode = null, filterByCwd = null, cleanupAgeDays = 7, localOnly = false) {
1211
1347
  this.maxAgents = maxAgents;
1212
1348
  this.constructorAgentsDir = agentsDir;
@@ -1251,8 +1387,12 @@ export class AgentManager {
1251
1387
  * manager is alive — the supervisor loop calls this each wave so
1252
1388
  * dynamically-added teammates get picked up.
1253
1389
  *
1254
- * Does not modify or re-load agents already in the cache; that path is
1255
- * covered by updateStatusFromProcess() which re-reads stdout.log.
1390
+ * For a teammate ALREADY cached, refreshes it only when disk has latched it
1391
+ * terminal while the cache still holds it non-terminal — the case where
1392
+ * another process (e.g. `agents teams stop` in a separate CLI invocation)
1393
+ * moved it to `stopped`/`failed` and this long-lived manager would otherwise
1394
+ * never see it and re-persist a stale `running` (RUSH-2366). A still-live
1395
+ * cached teammate is left untouched; updateStatusFromProcess() owns that path.
1256
1396
  */
1257
1397
  async rescanFromDisk() {
1258
1398
  await this.initialize();
@@ -1265,12 +1405,23 @@ export class AgentManager {
1265
1405
  const entries = await fs.readdir(this.agentsDir);
1266
1406
  let added = 0;
1267
1407
  for (const entry of entries) {
1268
- if (this.agents.has(entry))
1269
- continue;
1270
1408
  const agentDir = path.join(this.agentsDir, entry);
1271
1409
  const stat = await fs.stat(agentDir).catch(() => null);
1272
1410
  if (!stat || !stat.isDirectory())
1273
1411
  continue;
1412
+ const cached = this.agents.get(entry);
1413
+ if (cached) {
1414
+ // Adopt a disk-terminal status the cache hasn't seen; never overwrite a
1415
+ // cached teammate that is still live with a stale disk read. Terminal is
1416
+ // a one-way latch, so this can only move a teammate forward.
1417
+ if (!isTerminalStatus(cached.status)) {
1418
+ const fresh = await AgentProcess.loadFromDisk(entry, this.agentsDir);
1419
+ if (fresh && isTerminalStatus(fresh.status)) {
1420
+ this.agents.set(entry, fresh);
1421
+ }
1422
+ }
1423
+ continue;
1424
+ }
1274
1425
  const agent = await AgentProcess.loadFromDisk(entry, this.agentsDir);
1275
1426
  if (!agent)
1276
1427
  continue;
@@ -1302,7 +1453,13 @@ export class AgentManager {
1302
1453
  const agent = await AgentProcess.loadFromDisk(agentId, this.agentsDir);
1303
1454
  if (!agent)
1304
1455
  continue;
1305
- if (agent.completedAt && agent.completedAt < cutoffDate) {
1456
+ // Age-based reap is a SECOND retention mechanism, independent of
1457
+ // cleanupOldAgents()'s cap-based one — and must obey the same invariant
1458
+ // (RUSH-2356): a non-terminal teammate is never a reap candidate,
1459
+ // however old its (possibly spuriously stamped) completedAt is. Belt and
1460
+ // suspenders alongside the PENDING guards above that stop completedAt
1461
+ // from getting set on a staged teammate in the first place.
1462
+ if (agent.completedAt && agent.completedAt < cutoffDate && isTerminalStatus(agent.status)) {
1306
1463
  try {
1307
1464
  await fs.rm(agentDir, { recursive: true });
1308
1465
  cleanedOld++;
@@ -1331,23 +1488,38 @@ export class AgentManager {
1331
1488
  }
1332
1489
  debug(`Loaded ${loadedCount} agents from disk`);
1333
1490
  }
1334
- 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) {
1491
+ /**
1492
+ * Validate an add's name uniqueness and `--after` dependency graph, without
1493
+ * any side effects. Throws a user-facing error on: a duplicate name, `--after`
1494
+ * without `--name`, an unknown dependency, or a cycle. Returns the cleaned
1495
+ * (whitespace-filtered) `after` list.
1496
+ *
1497
+ * Extracted from spawn() so the command layer can run it BEFORE creating a
1498
+ * worktree — a rejected add must not leave an orphan `agents/<name>` branch
1499
+ * that then breaks the retry with `fatal: a branch ... already exists`
1500
+ * (RUSH-2356). spawn() calls it too, so validation lives in exactly one place.
1501
+ *
1502
+ * The result is cached for exactly ONE subsequent call with the same
1503
+ * arguments, which spawn() then consumes. `listByTask()` → `listAll()`
1504
+ * refreshes every sibling's status, and on a `--device` team that is a full
1505
+ * round of SSH liveness probes — running it twice per `teams add` would
1506
+ * double that cost for no gain, since the second pass reads the same snapshot
1507
+ * and cannot catch anything the first missed. The cache is single-use so any
1508
+ * later spawn (a `teams start --watch` supervisor launching staged teammates)
1509
+ * still validates against fresh state and still rejects a duplicate name.
1510
+ */
1511
+ async validateAddPreconditions(taskName, name, after) {
1335
1512
  await this.initialize();
1336
- const resolvedMode = resolveMode(mode, this.defaultMode);
1337
- // Lineage (RUSH-2019): when the caller didn't name a parent, inherit the
1338
- // orchestrator's own session id from its env (exec.ts stamps AGENTS_SESSION_ID
1339
- // onto every agent process). A team spawned from inside a running agent then
1340
- // records which session created it, so the spawn chain traces back to a parent
1341
- // session; a team started outside any agent simply carries none.
1342
- if (!parentSessionId) {
1343
- parentSessionId = process.env.AGENTS_SESSION_ID ?? null;
1513
+ const key = JSON.stringify([taskName, name, after]);
1514
+ if (this.validatedAdd?.key === key) {
1515
+ const cached = this.validatedAdd.cleanAfter;
1516
+ this.validatedAdd = null; // single use
1517
+ return cached;
1344
1518
  }
1345
- // Enforce: teammate names are unique within a team.
1346
1519
  const siblings = await this.listByTask(taskName);
1347
1520
  if (name && siblings.some((a) => a.name === name)) {
1348
1521
  throw new Error(`Team '${taskName}' already has a teammate named '${name}'. Pick another name or leave --name off.`);
1349
1522
  }
1350
- // --- dependency validation ---
1351
1523
  const cleanAfter = after.filter((s) => s && s.trim());
1352
1524
  if (cleanAfter.length > 0) {
1353
1525
  if (!name) {
@@ -1369,6 +1541,88 @@ export class AgentManager {
1369
1541
  }
1370
1542
  }
1371
1543
  }
1544
+ this.validatedAdd = { key, cleanAfter };
1545
+ return cleanAfter;
1546
+ }
1547
+ /**
1548
+ * Does any LIVE teammate — in any team — already own `worktreeName`?
1549
+ *
1550
+ * A RAW disk scan: no status probing, no cache, no `listAll()`. The caller is
1551
+ * the `teams add` failure path, where the manager's own status refresh can be
1552
+ * the very thing that threw (`cleanupOldAgents()` → `listAll()` →
1553
+ * `updateStatusFromProcess()` runs AFTER the staged record is saved), so a
1554
+ * check that re-entered that machinery would throw again and answer nothing.
1555
+ *
1556
+ * `teams add` asks this before removing a worktree, to tell an ORPHAN from
1557
+ * someone's live checkout (RUSH-2356). Two deliberate scoping choices:
1558
+ *
1559
+ * - **Any team, not just the one being added to.** Worktree names are global
1560
+ * to the repo but records are per-team, so a same-named worktree owned by
1561
+ * another team's teammate must also block the removal.
1562
+ * - **Non-terminal records only.** A completed/failed/stopped teammate's
1563
+ * worktree was already cleaned up at `teams stop`, and its record lingers
1564
+ * until retention reaps it — counting those would leave a genuine orphan
1565
+ * branch stranded forever, which is the bug this all exists to fix.
1566
+ * - **Fails CLOSED.** This guards a `git worktree remove --force`, so the two
1567
+ * errors are not symmetric: a false "claimed" strands an orphan branch that
1568
+ * a human can delete, while a false "unclaimed" deletes a live agent's
1569
+ * checkout and its uncommitted work. Only `ENOENT` proves absence — no
1570
+ * agents dir means no records, and a record with no `meta.json` is not a
1571
+ * record. Any other failure (EACCES, EIO, half-written or invalid JSON,
1572
+ * a race with a writer) means we could not READ the records, which is not
1573
+ * the same as there being none, so it answers `true`. This is deliberate
1574
+ * asymmetry, not defensive coding: the caller acts destructively on `false`.
1575
+ */
1576
+ async isWorktreeClaimed(worktreeName) {
1577
+ const base = this.agentsDir ?? (await getAgentsDir());
1578
+ let entries;
1579
+ try {
1580
+ entries = await fs.readdir(base);
1581
+ }
1582
+ catch (err) {
1583
+ // ENOENT is the only error that PROVES nothing claims the worktree: there
1584
+ // are no records at all. Every other failure (EACCES, EIO, a transient
1585
+ // races with a writer) means we could not read the records, which is not
1586
+ // the same as there being none — fail closed.
1587
+ if (err?.code === 'ENOENT')
1588
+ return false;
1589
+ return true;
1590
+ }
1591
+ for (const entry of entries) {
1592
+ try {
1593
+ const raw = await fs.readFile(path.join(base, entry, 'meta.json'), 'utf-8');
1594
+ const meta = JSON.parse(raw);
1595
+ if (meta?.worktree_name !== worktreeName)
1596
+ continue;
1597
+ if (!isTerminalStatus(meta?.status))
1598
+ return true;
1599
+ }
1600
+ catch (err) {
1601
+ // A record without a meta.json is not a record — skip it. Anything else
1602
+ // (unreadable, half-written, invalid JSON) may be the very record that
1603
+ // claims this worktree, and we cannot tell. Fail closed.
1604
+ if (err?.code === 'ENOENT')
1605
+ continue;
1606
+ return true;
1607
+ }
1608
+ }
1609
+ return false;
1610
+ }
1611
+ 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) {
1612
+ await this.initialize();
1613
+ const resolvedMode = resolveMode(mode, this.defaultMode);
1614
+ // Lineage (RUSH-2019): when the caller didn't name a parent, inherit the
1615
+ // orchestrator's own session id from its env (exec.ts stamps AGENTS_SESSION_ID
1616
+ // onto every agent process). A team spawned from inside a running agent then
1617
+ // records which session created it, so the spawn chain traces back to a parent
1618
+ // session; a team started outside any agent simply carries none.
1619
+ if (!parentSessionId) {
1620
+ parentSessionId = process.env.AGENTS_SESSION_ID ?? null;
1621
+ }
1622
+ // Validate name uniqueness + --after deps. Throws on any violation. The
1623
+ // command layer calls this BEFORE creating a worktree so a rejected add
1624
+ // never leaves an orphan `agents/<name>` branch behind (RUSH-2356).
1625
+ const cleanAfter = await this.validateAddPreconditions(taskName, name, after);
1372
1626
  // Resolve and validate cwd
1373
1627
  let resolvedCwd = null;
1374
1628
  if (cwd !== null) {
@@ -1454,6 +1708,18 @@ export class AgentManager {
1454
1708
  await this.launchProcess(agent);
1455
1709
  }
1456
1710
  await this.cleanupOldAgents();
1711
+ // Postcondition: the teammate MUST be durably on disk before we report
1712
+ // success. saveMeta() ran above and cleanupOldAgents() can no longer reap a
1713
+ // non-terminal record, but a failed write (full disk, permissions) or any
1714
+ // future retention regression would otherwise let `teams add` print a full
1715
+ // success block for a teammate that does not exist — the RUSH-2356
1716
+ // silent-success class. Assert the outcome, not the exit code.
1717
+ const persisted = await AgentProcess.loadFromDisk(agentId, this.agentsDir);
1718
+ if (!persisted) {
1719
+ this.agents.delete(agentId);
1720
+ throw new Error(`Teammate '${name ?? agentId}' was not durably persisted to disk after add ` +
1721
+ `(no meta.json under ${this.agentsDir}/${agentId}). The add did not take effect.`);
1722
+ }
1457
1723
  return agent;
1458
1724
  }
1459
1725
  /**
@@ -1890,20 +2156,12 @@ export class AgentManager {
1890
2156
  byTarget.set(key, group);
1891
2157
  }
1892
2158
  for (const { target, agents } of byTarget.values()) {
1893
- // Emit one line per teammate: "<agentId> ALIVE|DEAD <exitOrEmpty>". A single
1894
- // round-trip over the multiplexed socket, regardless of teammate count.
1895
- const parts = agents.map((a) => {
1896
- const id = a.agentId;
1897
- // remoteExit is a dispatch `$HOME/.agents/.cache/hosts/<hex>.exit` path —
1898
- // interpolate UNQUOTED so `$HOME` expands (shellQuote would make `[ -f ]`
1899
- // always miss, so a finished teammate would never resolve terminal).
1900
- const exitFile = a.remoteExit;
1901
- // exit code (if the sentinel exists) OR empty, then liveness.
1902
- return (`printf '%s ' ${shellQuote(id)}; ` +
1903
- `if [ -f ${exitFile} ]; then printf 'DEAD '; cat ${exitFile} 2>/dev/null | tr -d '\\n'; printf '\\n'; ` +
1904
- `elif kill -0 ${a.remotePid} 2>/dev/null; then printf 'ALIVE\\n'; ` +
1905
- `else printf 'DEAD\\n'; fi`);
1906
- });
2159
+ // Emit one line per teammate: "<agentId> <ALIVE|EXITED|GONE> <codeOrEmpty>".
2160
+ // A single round-trip over the multiplexed socket, regardless of teammate
2161
+ // count. GONE (process gone, no `.exit`) is kept distinct from EXITED so a
2162
+ // teammate killed without recording `$?` resolves terminal instead of
2163
+ // reporting RUNNING forever (RUSH-2366).
2164
+ const parts = agents.map((a) => remoteLivenessSnippet(a.agentId, a.remoteExit, a.remotePid));
1907
2165
  const identityFile = agents[0]?.hostIdentityFile;
1908
2166
  const res = sshExec(target, parts.join('; '), {
1909
2167
  timeoutMs: 12000,
@@ -1917,13 +2175,10 @@ export class AgentManager {
1917
2175
  const trimmed = line.trim();
1918
2176
  if (!trimmed)
1919
2177
  continue;
1920
- const [id, state, exit] = trimmed.split(/\s+/);
1921
- if (!id)
2178
+ const [id, state, code] = trimmed.split(/\s+/);
2179
+ if (!id || !state)
1922
2180
  continue;
1923
- snapshots.set(id, {
1924
- alive: state === 'ALIVE',
1925
- exit: state === 'DEAD' ? (exit ?? '') : null,
1926
- });
2181
+ snapshots.set(id, parseRemoteLivenessState(state, code));
1927
2182
  }
1928
2183
  for (const a of agents) {
1929
2184
  const snap = snapshots.get(a.agentId);
@@ -2137,9 +2392,16 @@ export class AgentManager {
2137
2392
  const all = await this.listAll();
2138
2393
  return all.filter(a => a.status === AgentStatus.RUNNING);
2139
2394
  }
2395
+ /**
2396
+ * Teammates that have reached a terminal status (completed/failed/stopped) —
2397
+ * the ONLY records retention may reap. A `pending` teammate has not launched
2398
+ * and a `running` one is working, so neither is "completed"; classifying them
2399
+ * as such let cleanupOldAgents sweep live `pending` `--after` teammates past
2400
+ * the cap (RUSH-2356). Filter on `isTerminalStatus`, never `!== RUNNING`.
2401
+ */
2140
2402
  async listCompleted() {
2141
2403
  const all = await this.listAll();
2142
- return all.filter(a => a.status !== AgentStatus.RUNNING);
2404
+ return all.filter(a => isTerminalStatus(a.status));
2143
2405
  }
2144
2406
  async listByTask(taskName) {
2145
2407
  const all = await this.listAll();
@@ -2236,6 +2498,10 @@ export class AgentManager {
2236
2498
  }
2237
2499
  }
2238
2500
  async cleanupOldAgents() {
2501
+ // listCompleted() is terminal-only (isTerminalStatus), so a pending or
2502
+ // running teammate is never a reap candidate — retention can only delete a
2503
+ // record whose process has finished. (RUSH-2356: the old `!== RUNNING`
2504
+ // filter reaped live `pending` `--after` teammates.)
2239
2505
  const completed = await this.listCompleted();
2240
2506
  if (completed.length > this.maxAgents) {
2241
2507
  completed.sort((a, b) => {
@@ -34,7 +34,16 @@ export declare function localDefaultBranch(gitRoot: string): Promise<string>;
34
34
  * code. Matches `createRemoteWorktree` — local and remote isolation share one
35
35
  * base policy.
36
36
  *
37
- * @param repoDir - Directory inside the git repository
37
+ * Resolves the placement root with {@link getMainRepoRoot}, NOT the local
38
+ * `getGitRoot`/`--show-toplevel`, on purpose: when `repoDir` (the caller's
39
+ * ambient cwd) is itself inside another teammate's linked worktree,
40
+ * `--show-toplevel` returns THAT worktree's own root, so the new worktree
41
+ * lands nested inside it (observed: `.../worktrees/A/.agents/worktrees/B`,
42
+ * destroyed along with A's cleanup). `getMainRepoRoot` follows
43
+ * `--git-common-dir`, which always points at the primary checkout's `.git`
44
+ * regardless of which worktree the caller is standing in.
45
+ *
46
+ * @param repoDir - Directory inside the git repository (main checkout or any linked worktree)
38
47
  * @param worktreeName - Name for the worktree (used in path and branch)
39
48
  * @returns The absolute path to the created worktree
40
49
  */
@@ -42,7 +51,12 @@ export declare function createWorktree(repoDir: string, worktreeName: string): P
42
51
  /**
43
52
  * Remove a git worktree and optionally its branch.
44
53
  *
45
- * @param repoDir - Directory inside the main git repository (not the worktree)
54
+ * Resolves via {@link getMainRepoRoot}, same as {@link createWorktree} a
55
+ * caller standing inside a DIFFERENT linked worktree (e.g. a `teams stop`
56
+ * run from within another teammate's checkout) must still target the main
57
+ * repo's `.agents/worktrees/<name>`, not `--show-toplevel`'s local answer.
58
+ *
59
+ * @param repoDir - Directory inside the git repository (main checkout or any linked worktree)
46
60
  * @param worktreeName - Name of the worktree to remove
47
61
  * @param deleteBranch - Whether to delete the associated branch
48
62
  */
@@ -55,3 +69,26 @@ export declare function getWorktreePath(gitRoot: string, worktreeName: string):
55
69
  * Get the branch name for a worktree.
56
70
  */
57
71
  export declare function getWorktreeBranch(worktreeName: string): string;
72
+ /**
73
+ * Does the CHECKOUT DIRECTORY for this worktree name exist?
74
+ *
75
+ * Narrower than {@link worktreeExists}, and the difference is load-bearing:
76
+ * `teams add` only ever cleans up after a failed create when there is NO
77
+ * checkout — i.e. when all that can be left is a dangling `agents/<name>`
78
+ * branch ref. That keeps the cleanup incapable of deleting anybody's files,
79
+ * including a concurrent add's freshly-created worktree, whatever the
80
+ * pre-flight probe saw a `git fetch` ago. (RUSH-2356)
81
+ */
82
+ export declare function worktreeCheckoutExists(repoDir: string, worktreeName: string): Promise<boolean>;
83
+ /**
84
+ * Does anything already exist under this worktree name — the checkout directory
85
+ * or its `agents/<name>` branch?
86
+ *
87
+ * Answered from git and the filesystem, never from teammate records: it is asked
88
+ * by `teams add` immediately BEFORE {@link createWorktree}, so that if the create
89
+ * fails the command can tell "the branch ref I half-created" (safe to remove)
90
+ * from "something that was already here" (never remove — `teams stop` deliberately
91
+ * KEEPS a worktree holding uncommitted changes, and its teammate record is
92
+ * terminal by then, so no record-based check can protect it). (RUSH-2356)
93
+ */
94
+ export declare function worktreeExists(repoDir: string, worktreeName: string): Promise<boolean>;