@phnx-labs/agents-cli 1.22.32 → 1.22.34

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 (76) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +2 -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-browser.js +2 -2
  13. package/dist/commands/sessions.d.ts +54 -4
  14. package/dist/commands/sessions.js +274 -58
  15. package/dist/commands/ssh.js +17 -5
  16. package/dist/commands/teams.d.ts +28 -0
  17. package/dist/commands/teams.js +148 -13
  18. package/dist/commands/upgrade.d.ts +7 -0
  19. package/dist/commands/upgrade.js +10 -0
  20. package/dist/commands/watchdog.d.ts +2 -0
  21. package/dist/commands/watchdog.js +112 -27
  22. package/dist/index.js +51 -59
  23. package/dist/lib/agents.js +6 -0
  24. package/dist/lib/browser/sessions-list.d.ts +81 -0
  25. package/dist/lib/browser/sessions-list.js +179 -4
  26. package/dist/lib/daemon.js +45 -5
  27. package/dist/lib/devices/connect.d.ts +33 -0
  28. package/dist/lib/devices/connect.js +61 -3
  29. package/dist/lib/devices/doctor-findings.d.ts +4 -2
  30. package/dist/lib/devices/doctor-findings.js +4 -2
  31. package/dist/lib/exec.js +63 -6
  32. package/dist/lib/help.d.ts +3 -2
  33. package/dist/lib/help.js +4 -0
  34. package/dist/lib/hosts/dispatch.d.ts +2 -32
  35. package/dist/lib/hosts/dispatch.js +6 -61
  36. package/dist/lib/hosts/tasks.d.ts +7 -0
  37. package/dist/lib/hosts/tasks.js +9 -0
  38. package/dist/lib/mailbox-target.d.ts +27 -0
  39. package/dist/lib/mailbox-target.js +21 -0
  40. package/dist/lib/mcp.d.ts +10 -0
  41. package/dist/lib/mcp.js +21 -2
  42. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  43. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  44. package/dist/lib/migrate.d.ts +11 -0
  45. package/dist/lib/migrate.js +40 -0
  46. package/dist/lib/project-root.d.ts +47 -0
  47. package/dist/lib/project-root.js +68 -0
  48. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  49. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  50. package/dist/lib/secrets/agent.d.ts +9 -2
  51. package/dist/lib/secrets/agent.js +52 -7
  52. package/dist/lib/secrets/reaper.d.ts +24 -3
  53. package/dist/lib/secrets/reaper.js +55 -6
  54. package/dist/lib/session/discover.js +12 -0
  55. package/dist/lib/session/render.d.ts +2 -0
  56. package/dist/lib/session/render.js +1 -1
  57. package/dist/lib/session/team-filter.d.ts +11 -0
  58. package/dist/lib/session/team-filter.js +10 -0
  59. package/dist/lib/shims.js +9 -2
  60. package/dist/lib/startup/command-registry.d.ts +1 -0
  61. package/dist/lib/startup/command-registry.js +8 -3
  62. package/dist/lib/startup/root-command.d.ts +3 -0
  63. package/dist/lib/startup/root-command.js +10 -0
  64. package/dist/lib/teams/agents.d.ts +136 -6
  65. package/dist/lib/teams/agents.js +324 -58
  66. package/dist/lib/teams/worktree.d.ts +39 -2
  67. package/dist/lib/teams/worktree.js +60 -4
  68. package/dist/lib/types.d.ts +11 -0
  69. package/dist/lib/versions.js +2 -2
  70. package/dist/lib/watchdog/history.d.ts +20 -0
  71. package/dist/lib/watchdog/history.js +46 -0
  72. package/dist/lib/watchdog/log.d.ts +16 -1
  73. package/dist/lib/watchdog/log.js +82 -2
  74. package/dist/lib/watchdog/runner.d.ts +12 -0
  75. package/dist/lib/watchdog/runner.js +20 -0
  76. package/package.json +1 -1
@@ -760,6 +760,9 @@ export async function runSecretsAgent(opts = {}) {
760
760
  }
761
761
  let watcher = null;
762
762
  let sweepTimer = null;
763
+ // Sync cleanup for SIGTERM/SIGINT → process.exit: we cannot await the
764
+ // server's 'close' event before exit, so fire-and-forget close + unlink.
765
+ // The public close() path below awaits with a bounded timeout (RUSH-2421).
763
766
  cleanupActive = () => {
764
767
  store.clear();
765
768
  if (sweepTimer)
@@ -806,12 +809,25 @@ export async function runSecretsAgent(opts = {}) {
806
809
  watcher = null;
807
810
  }
808
811
  return {
809
- close() {
812
+ async close() {
810
813
  if (shuttingDown)
811
814
  return;
812
815
  shuttingDown = true;
813
816
  detachSignals();
814
- cleanupActive?.();
817
+ store.clear();
818
+ if (sweepTimer)
819
+ clearInterval(sweepTimer);
820
+ try {
821
+ watcher?.kill();
822
+ }
823
+ catch { /* already gone */ }
824
+ await closeServerBounded(server);
825
+ try {
826
+ fs.unlinkSync(sock);
827
+ }
828
+ catch { /* gone */ }
829
+ releaseBrokerPid();
830
+ releasePid();
815
831
  },
816
832
  };
817
833
  }
@@ -877,17 +893,18 @@ export async function startHostedBroker() {
877
893
  watcher = null;
878
894
  }
879
895
  return {
880
- close() {
896
+ // RUSH-2421: await net.Server's 'close' (bounded) so a caller relying on
897
+ // close() cannot proceed past a socket that is not actually released yet.
898
+ // The daemon may fire-and-forget this promise; tests and any awaiter get
899
+ // the real release boundary.
900
+ async close() {
881
901
  store.clear();
882
902
  clearInterval(sweepTimer);
883
903
  try {
884
904
  watcher?.kill();
885
905
  }
886
906
  catch { /* already gone */ }
887
- try {
888
- server.close();
889
- }
890
- catch { /* not listening */ }
907
+ await closeServerBounded(server);
891
908
  try {
892
909
  fs.unlinkSync(sock);
893
910
  }
@@ -896,6 +913,34 @@ export async function startHostedBroker() {
896
913
  },
897
914
  };
898
915
  }
916
+ /** How long to wait for net.Server.close()'s 'close' event before giving up. */
917
+ const SERVER_CLOSE_TIMEOUT_MS = 2_000;
918
+ /**
919
+ * Call Node's net.Server.close() and wait for the 'close' event (or a bounded
920
+ * timeout). Without this, close() returned while the listen socket could still
921
+ * be held — a successor bind could race EADDRINUSE against a half-closed server
922
+ * (RUSH-2421). Pure side-effect helper; never throws.
923
+ */
924
+ export function closeServerBounded(server, timeoutMs = SERVER_CLOSE_TIMEOUT_MS) {
925
+ return new Promise((resolve) => {
926
+ let settled = false;
927
+ const finish = () => {
928
+ if (settled)
929
+ return;
930
+ settled = true;
931
+ clearTimeout(timer);
932
+ resolve();
933
+ };
934
+ const timer = setTimeout(finish, timeoutMs);
935
+ try {
936
+ server.close(() => finish());
937
+ }
938
+ catch {
939
+ // Already closed / not listening — treat as released.
940
+ finish();
941
+ }
942
+ });
943
+ }
899
944
  // ─── Client ──────────────────────────────────────────────────────────────────
900
945
  /** Open the socket, send one request, resolve the one response. Async path —
901
946
  * used by the unlock/lock/status commands, which already run in async actions. */
@@ -31,9 +31,17 @@ export interface KeychainProcessSnapshot {
31
31
  * True when this process is a REAP-ELIGIBLE helper invocation — the installed
32
32
  * helper binary running a short-lived keychain verb. False for a non-helper
33
33
  * process AND for the long-lived `watch-lock` watcher (see
34
- * {@link isReapableHelperCommand}), which must never be reaped.
34
+ * {@link isReapableHelperCommand}).
35
35
  */
36
36
  isHelper: boolean;
37
+ /**
38
+ * True when this process is the deliberately long-lived `watch-lock` watcher
39
+ * (auto-lock-on-sleep). Mutually exclusive with {@link isHelper}:
40
+ * {@link isReapableHelperCommand} keeps live-parent watch-locks out of the
41
+ * stuck-helper path, and a SEPARATE orphan path in {@link planKeychainReap}
42
+ * reaps them only once the owning daemon is provably dead (RUSH-2419).
43
+ */
44
+ isWatchLock?: boolean;
37
45
  }
38
46
  /**
39
47
  * Tracked state for a stuck `agents` parent that has a live helper child.
@@ -59,7 +67,7 @@ export interface ReapPlan {
59
67
  * Pure predicate: decide which processes to kill given a `ps`-like snapshot.
60
68
  *
61
69
  * Mirrors the shape of {@link isExpiredPoolStray} in `lib/crabbox/lease.ts`:
62
- * a side-effect-free classifier that the impure driver shells `ps` for. Two
70
+ * a side-effect-free classifier that the impure driver shells `ps` for. Three
63
71
  * conservative reap classes:
64
72
  *
65
73
  * 1. Orphaned helper: PPID == 1, path-matches the helper, alive longer than
@@ -68,9 +76,15 @@ export interface ReapPlan {
68
76
  * {@link STUCK_GRACE_SEC}. Recorded on first sight, child killed on the
69
77
  * second consecutive sweep with the same PID + startTime, parent killed on
70
78
  * the third sweep if the helper child is still present.
79
+ * 3. Orphaned `watch-lock` watcher (RUSH-2419): the long-lived auto-lock
80
+ * child whose owning daemon is provably dead. {@link isReapableHelperCommand}
81
+ * still excludes live-parent watch-locks from class 1/2; this path only
82
+ * reaps when the parent is gone from the snapshot (Unix reparents to init
83
+ * after daemon death) and the watcher itself has a start-time fingerprint.
71
84
  *
72
85
  * Never reaps a process whose start time could not be captured, whose path does
73
- * not match the helper, or whose parent is no longer in the snapshot.
86
+ * not match the helper, or (for stuck parents) whose parent is no longer in the
87
+ * snapshot. A watch-lock whose parent IS still in the snapshot is never touched.
74
88
  */
75
89
  export declare function planKeychainReap(snapshots: KeychainProcessSnapshot[], now: number, prevCandidates: ReadonlyMap<number, StuckParentCandidate>): ReapPlan;
76
90
  /**
@@ -92,6 +106,13 @@ export declare function parseEtimeToSeconds(raw: string): number | null;
92
106
  * mistaken for a stuck read and killed. Pure; unit-tested.
93
107
  */
94
108
  export declare function isReapableHelperCommand(command: string, helperPath: string): boolean;
109
+ /**
110
+ * Whether a `ps` command line is the deliberately long-lived `watch-lock`
111
+ * watcher (auto-lock-on-sleep). Inverse of {@link isReapableHelperCommand} for
112
+ * the watch-lock verb only — used by the orphaned-watch-lock reaper path
113
+ * (RUSH-2419). Pure; unit-tested.
114
+ */
115
+ export declare function isWatchLockHelperCommand(command: string, helperPath: string): boolean;
95
116
  /** Test seam: reset the persisted candidate state. */
96
117
  export declare function resetKeychainReaperCandidatesForTest(): void;
97
118
  /**
@@ -23,7 +23,7 @@ export const STUCK_GRACE_SEC = 90;
23
23
  * Pure predicate: decide which processes to kill given a `ps`-like snapshot.
24
24
  *
25
25
  * Mirrors the shape of {@link isExpiredPoolStray} in `lib/crabbox/lease.ts`:
26
- * a side-effect-free classifier that the impure driver shells `ps` for. Two
26
+ * a side-effect-free classifier that the impure driver shells `ps` for. Three
27
27
  * conservative reap classes:
28
28
  *
29
29
  * 1. Orphaned helper: PPID == 1, path-matches the helper, alive longer than
@@ -32,9 +32,15 @@ export const STUCK_GRACE_SEC = 90;
32
32
  * {@link STUCK_GRACE_SEC}. Recorded on first sight, child killed on the
33
33
  * second consecutive sweep with the same PID + startTime, parent killed on
34
34
  * the third sweep if the helper child is still present.
35
+ * 3. Orphaned `watch-lock` watcher (RUSH-2419): the long-lived auto-lock
36
+ * child whose owning daemon is provably dead. {@link isReapableHelperCommand}
37
+ * still excludes live-parent watch-locks from class 1/2; this path only
38
+ * reaps when the parent is gone from the snapshot (Unix reparents to init
39
+ * after daemon death) and the watcher itself has a start-time fingerprint.
35
40
  *
36
41
  * Never reaps a process whose start time could not be captured, whose path does
37
- * not match the helper, or whose parent is no longer in the snapshot.
42
+ * not match the helper, or (for stuck parents) whose parent is no longer in the
43
+ * snapshot. A watch-lock whose parent IS still in the snapshot is never touched.
38
44
  */
39
45
  export function planKeychainReap(snapshots, now, prevCandidates) {
40
46
  const pidMap = new Map(snapshots.map((s) => [s.pid, s]));
@@ -88,6 +94,34 @@ export function planKeychainReap(snapshots, now, prevCandidates) {
88
94
  stage: 'watch',
89
95
  });
90
96
  }
97
+ // Separate path for orphaned watch-lock watchers (RUSH-2419). Does NOT
98
+ // weaken {@link isReapableHelperCommand}: a watch-lock with a live parent
99
+ // stays isHelper=false and is skipped above. Here we only kill when the
100
+ // owning daemon is provably absent from the process table.
101
+ for (const s of snapshots) {
102
+ if (!s.isWatchLock)
103
+ continue;
104
+ // Fail closed: no start-time fingerprint → refuse to kill (pid-reuse guard
105
+ // for the process we are about to target, same as orphan helpers).
106
+ if (s.startTime == null)
107
+ continue;
108
+ if (s.elapsedSec <= ORPHAN_GRACE_SEC)
109
+ continue;
110
+ // Live parent in this snapshot → owning daemon is still up. Leave it alone
111
+ // (this is the property isReapableHelperCommand protects for stuck-helper
112
+ // reaping; re-assert it here so a mis-tagged row cannot be killed either).
113
+ if (s.ppid !== 1) {
114
+ const parent = pidMap.get(s.ppid);
115
+ if (parent)
116
+ continue;
117
+ // Parent pid not listed: the process occupying that slot is gone. On Unix
118
+ // a dead parent reparents the child to init; a missing parent with a
119
+ // non-1 ppid is the race window before reparenting. Either way the owner
120
+ // is dead — safe to reap after grace + fingerprint.
121
+ }
122
+ // ppid===1 (reparented to init/launchd) or parent missing → orphaned.
123
+ kill.push(s.pid);
124
+ }
91
125
  return { kill, nextCandidates };
92
126
  }
93
127
  /**
@@ -159,6 +193,18 @@ export function isReapableHelperCommand(command, helperPath) {
159
193
  const firstArg = command.slice(helperPath.length + 1).trimStart().split(/\s+/)[0];
160
194
  return firstArg !== HELPER_WATCH_LOCK_VERB;
161
195
  }
196
+ /**
197
+ * Whether a `ps` command line is the deliberately long-lived `watch-lock`
198
+ * watcher (auto-lock-on-sleep). Inverse of {@link isReapableHelperCommand} for
199
+ * the watch-lock verb only — used by the orphaned-watch-lock reaper path
200
+ * (RUSH-2419). Pure; unit-tested.
201
+ */
202
+ export function isWatchLockHelperCommand(command, helperPath) {
203
+ if (!command.startsWith(`${helperPath} `))
204
+ return false;
205
+ const firstArg = command.slice(helperPath.length + 1).trimStart().split(/\s+/)[0];
206
+ return firstArg === HELPER_WATCH_LOCK_VERB;
207
+ }
162
208
  /** Module-state for the two-sweep stuck-parent debounce. */
163
209
  let stuckParentCandidates = new Map();
164
210
  /** Test seam: reset the persisted candidate state. */
@@ -206,16 +252,18 @@ export function reapOrphanedKeychainProcesses() {
206
252
  // Reap-eligible = the helper binary running a short-lived keychain verb. The
207
253
  // full-argv match excludes the deliberately long-lived `watch-lock` watcher,
208
254
  // whose live-parent child would otherwise be killed as if it were stuck
209
- // (RUSH-2232 — that silently disabled auto-lock-on-sleep).
255
+ // (RUSH-2232 — that silently disabled auto-lock-on-sleep). Orphaned
256
+ // watch-locks are tracked separately via isWatchLock (RUSH-2419).
210
257
  const isHelper = isReapableHelperCommand(command, helperPath);
211
- rows.push({ pid, ppid, elapsedSec, isHelper, startTime: null });
258
+ const isWatchLock = isWatchLockHelperCommand(command, helperPath);
259
+ rows.push({ pid, ppid, elapsedSec, isHelper, isWatchLock, startTime: null });
212
260
  }
213
261
  const rowByPid = new Map(rows.map((r) => [r.pid, r]));
214
262
  for (const row of rows) {
215
- if (!row.isHelper)
263
+ if (!row.isHelper && !row.isWatchLock)
216
264
  continue;
217
265
  row.startTime = captureProcessStartTime(row.pid);
218
- if (row.ppid !== 1 && row.elapsedSec > STUCK_GRACE_SEC) {
266
+ if (row.isHelper && row.ppid !== 1 && row.elapsedSec > STUCK_GRACE_SEC) {
219
267
  const parent = rowByPid.get(row.ppid);
220
268
  if (parent && parent.startTime === null) {
221
269
  parent.startTime = captureProcessStartTime(row.ppid);
@@ -228,6 +276,7 @@ export function reapOrphanedKeychainProcesses() {
228
276
  elapsedSec: r.elapsedSec,
229
277
  startTime: r.startTime,
230
278
  isHelper: r.isHelper,
279
+ isWatchLock: r.isWatchLock,
231
280
  }));
232
281
  const plan = planKeychainReap(snapshots, Date.now(), stuckParentCandidates);
233
282
  stuckParentCandidates = plan.nextCandidates;
@@ -442,6 +442,18 @@ function normalizeCwd(cwd) {
442
442
  if (process.platform === 'win32' && /^\//.test(cwd) && !/^[a-zA-Z]:/.test(cwd)) {
443
443
  return stripTrailingSep(path.posix.normalize(cwd));
444
444
  }
445
+ // The mirror case (RUSH-2358): a Windows-rooted path (`C:\...`, `C:/...`, or a
446
+ // UNC `\\server\share\...`) read on POSIX belongs to another machine too, but
447
+ // `path.isAbsolute()` here uses POSIX rules and doesn't recognize a drive
448
+ // letter — without this branch such a cwd falls into the `path.resolve()` arm
449
+ // below and gets silently prefixed with THIS process's own cwd, corrupting the
450
+ // path (and, via WORKTREE_RE, can misattribute the worktree slug to whatever
451
+ // worktree the reading process happens to be running in). Normalize with
452
+ // win32 rules so separators survive; never realpath it, for the same
453
+ // cross-drive reason as the mirror branch above.
454
+ if (process.platform !== 'win32' && /^([a-zA-Z]:[\\/]|\\\\)/.test(cwd)) {
455
+ return stripTrailingSep(path.win32.normalize(cwd));
456
+ }
445
457
  const normalized = path.isAbsolute(cwd) ? stripTrailingSep(path.normalize(cwd)) : path.resolve(cwd);
446
458
  return safeRealpathSync(normalized) || normalized;
447
459
  }
@@ -67,6 +67,8 @@ export interface SessionStats {
67
67
  export declare function computeSummaryStats(events: SessionEvent[]): SessionStats;
68
68
  /** Strip the 'claude-' prefix and date suffix from a model identifier. */
69
69
  export declare function shortenModel(model: string): string;
70
+ /** Format a token count as a human-readable string (e.g. 67.5K, 1.2M). */
71
+ export declare function formatTokenCount(n: number): string;
70
72
  /** Format a duration in milliseconds as a human-readable string (e.g. '12 min', '2h 30min'). */
71
73
  export declare function formatDuration(ms: number): string;
72
74
  /**
@@ -219,7 +219,7 @@ export function shortenModel(model) {
219
219
  return model.replace(/^claude-/, '').replace(/-\d{8}$/, '');
220
220
  }
221
221
  /** Format a token count as a human-readable string (e.g. 67.5K, 1.2M). */
222
- function formatTokenCount(n) {
222
+ export function formatTokenCount(n) {
223
223
  if (n === 0)
224
224
  return '0';
225
225
  if (n < 1000)
@@ -44,6 +44,17 @@ export interface FilterResult {
44
44
  visible: SessionMeta[];
45
45
  hiddenCount: number;
46
46
  }
47
+ /**
48
+ * Whether a listing scope must retain team-origin sessions.
49
+ *
50
+ * A routine owns every session produced during its run, including teammates and
51
+ * SDK-launched children. Requiring callers to add `--teams` would make a routine
52
+ * catalog incomplete and can make team-heavy routines appear to have no runs.
53
+ */
54
+ export declare function shouldShowTeamSessions(filters: {
55
+ teams?: boolean;
56
+ routine?: boolean | string;
57
+ }): boolean;
47
58
  /**
48
59
  * Split `sessions` into visible and hidden (team-origin) groups.
49
60
  * When `showTeams` is true every session is visible and `teamOrigin` is
@@ -142,6 +142,16 @@ export function enrichTeamOrigins(sessions) {
142
142
  return origin ? { ...session, teamOrigin: origin } : session;
143
143
  });
144
144
  }
145
+ /**
146
+ * Whether a listing scope must retain team-origin sessions.
147
+ *
148
+ * A routine owns every session produced during its run, including teammates and
149
+ * SDK-launched children. Requiring callers to add `--teams` would make a routine
150
+ * catalog incomplete and can make team-heavy routines appear to have no runs.
151
+ */
152
+ export function shouldShowTeamSessions(filters) {
153
+ return !!filters.teams || !!filters.routine;
154
+ }
145
155
  /**
146
156
  * Split `sessions` into visible and hidden (team-origin) groups.
147
157
  * When `showTeams` is true every session is visible and `teamOrigin` is
package/dist/lib/shims.js CHANGED
@@ -933,7 +933,7 @@ function assertSafeVersion(version) {
933
933
  * KEEP IN SYNC with the `managedEnv` switch in `generateVersionedAliasScript`.
934
934
  * The colocated test `shims.isolation-capability.test.ts` enforces this.
935
935
  */
936
- export const CONFIG_ENV_ISOLATED_AGENTS = ['claude', 'codex', 'copilot', 'grok', 'kimi', 'opencode', 'muse'];
936
+ export const CONFIG_ENV_ISOLATED_AGENTS = ['claude', 'codex', 'copilot', 'cursor', 'grok', 'kimi', 'opencode', 'muse'];
937
937
  /**
938
938
  * Whether an agent supports a clean `--isolated` install — i.e. its config
939
939
  * location can be redirected by an env var so the isolated copy stays fully
@@ -999,7 +999,14 @@ export KIMI_CODE_HOME="$HOME/.agents/.history/versions/${agent}/${version}/home/
999
999
  export XDG_CONFIG_HOME="$HOME/.agents/.history/versions/${agent}/${version}/home/.config"
1000
1000
  export XDG_DATA_HOME="$HOME/.agents/.history/versions/${agent}/${version}/home/.local/share"
1001
1001
  `
1002
- : '';
1002
+ : agent === 'cursor'
1003
+ ? `
1004
+ # Cursor: no config-dir env var. Its OAuth token (the login gate) lives at
1005
+ # $XDG_CONFIG_HOME/cursor/auth.json, so pin XDG_CONFIG_HOME at the version home
1006
+ # to isolate each account's login for direct aliases (parity with buildExecEnv).
1007
+ export XDG_CONFIG_HOME="$HOME/.agents/.history/versions/${agent}/${version}/home/.config"
1008
+ `
1009
+ : '';
1003
1010
  const launchArgs = agent === 'codex' ? ` ${codexShimLaunchArgs()}` : '';
1004
1011
  // Resolve the binary the same way the main shim does (see generateShimScript).
1005
1012
  // Grok and Droid do NOT ship into node_modules/.bin — Grok downloads a native
@@ -98,6 +98,7 @@ export declare const loadPush: ModuleLoader;
98
98
  export declare const loadRepo: ModuleLoader;
99
99
  export declare const loadSetup: ModuleLoader;
100
100
  export declare const loadUninstall: ModuleLoader;
101
+ export declare const loadUpgrade: ModuleLoader;
101
102
  export declare const loadSessions: ModuleLoader;
102
103
  export declare const loadTeams: ModuleLoader;
103
104
  export declare const loadCloud: ModuleLoader;
@@ -19,6 +19,9 @@
19
19
  * `cleanup` subcommand to it), in that order — see commands/prune.ts.
20
20
  */
21
21
  import { Command } from 'commander';
22
+ import { readFileSync } from 'node:fs';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { configureRootCommand } from './root-command.js';
22
25
  // One loader per command module. Each dynamically imports the module and hands
23
26
  // back its register function. Kept as named consts so src/index.ts can compose
24
27
  // them into the exact main-branch registration order for the slow path.
@@ -98,6 +101,7 @@ export const loadPush = async () => (await import('../../commands/push.js')).reg
98
101
  export const loadRepo = async () => (await import('../../commands/repo.js')).registerRepoCommands;
99
102
  export const loadSetup = async () => (await import('../../commands/setup.js')).registerSetupCommand;
100
103
  export const loadUninstall = async () => (await import('../../commands/uninstall.js')).registerUninstallCommands;
104
+ export const loadUpgrade = async () => (await import('../../commands/upgrade.js')).registerUpgradeCommand;
101
105
  export const loadSessions = async () => (await import('../../commands/sessions.js')).registerSessionsCommands;
102
106
  export const loadTeams = async () => (await import('../../commands/teams.js')).registerTeamsCommands;
103
107
  export const loadCloud = async () => (await import('../../commands/cloud.js')).registerCloudCommands;
@@ -247,6 +251,7 @@ export const COMMAND_LOADERS = {
247
251
  repo: [loadRepo],
248
252
  setup: [loadSetup],
249
253
  uninstall: [loadUninstall],
254
+ upgrade: [loadUpgrade],
250
255
  sessions: [loadSessions],
251
256
  // Observe-umbrella alias of sessions --active (same lazy module).
252
257
  roster: [loadSessions],
@@ -272,7 +277,7 @@ export const COMMAND_LOADERS = {
272
277
  /**
273
278
  * Top-level names that {@link COMMAND_LOADERS} does not carry because they are
274
279
  * registered inline in src/index.ts — closures over entry-point state (the
275
- * deprecated aliases and tombstones) plus the internal/upgrade commands. They are
280
+ * deprecated aliases and tombstones) plus the internal command. They are
276
281
  * real commands, so anything that asks "does this command exist?" must count them.
277
282
  */
278
283
  const INLINE_COMMAND_NAMES = [
@@ -284,7 +289,6 @@ const INLINE_COMMAND_NAMES = [
284
289
  'resources', // tombstone -> view --merged
285
290
  'hq', // tombstone
286
291
  '_internal',
287
- 'upgrade',
288
292
  ];
289
293
  /**
290
294
  * Every top-level command name the CLI answers to — the loader table plus the
@@ -318,7 +322,8 @@ export function isKnownTopLevelCommand(name) {
318
322
  * are closures over entry-point state that src/index.ts registers directly.
319
323
  */
320
324
  export async function buildFullCommandTree() {
321
- const program = new Command();
325
+ const packageJson = JSON.parse(readFileSync(fileURLToPath(new URL('../../../package.json', import.meta.url)), 'utf8'));
326
+ const program = configureRootCommand(new Command(), 'agents', packageJson.version);
322
327
  const done = new Set();
323
328
  for (const loaders of Object.values(COMMAND_LOADERS)) {
324
329
  for (const loader of loaders) {
@@ -0,0 +1,3 @@
1
+ import type { Command } from 'commander';
2
+ /** Configure the public root surface shared by the live CLI and reference generator. */
3
+ export declare function configureRootCommand(program: Command, name: string, version: string): Command;
@@ -0,0 +1,10 @@
1
+ /** Configure the public root surface shared by the live CLI and reference generator. */
2
+ export function configureRootCommand(program, name, version) {
3
+ return program
4
+ .name(name)
5
+ .description('Environment manager for AI agents')
6
+ .version(version)
7
+ .option('--verbose', 'Show startup self-heal details on stderr')
8
+ .helpOption('-h, --help', 'Show help')
9
+ .addHelpCommand(false);
10
+ }
@@ -15,6 +15,45 @@ export declare enum AgentStatus {
15
15
  FAILED = "failed",
16
16
  STOPPED = "stopped"
17
17
  }
18
+ /**
19
+ * The statuses a teammate can never leave — its process has run and finished
20
+ * (or been stopped). Everything else (pending, running) is still live work.
21
+ *
22
+ * This is the ONLY set that retention (cleanupOldAgents) may reap: a `pending`
23
+ * teammate has not launched yet and a `running` one is doing work, so deleting
24
+ * either is data loss. Treating "not running" as "completed" was the RUSH-2356
25
+ * bug — it swept live `pending` `--after` teammates past the 50-record cap.
26
+ */
27
+ export declare const TERMINAL_STATUSES: ReadonlySet<AgentStatus>;
28
+ /** True when a teammate has reached a terminal (completed/failed/stopped) status. */
29
+ export declare function isTerminalStatus(status: AgentStatus): boolean;
30
+ /**
31
+ * One remote teammate's liveness, resolved by a single host probe. Three states,
32
+ * kept distinct on purpose (RUSH-2366):
33
+ * - alive=true → process still running.
34
+ * - exitFilePresent=true → the `.exit` sentinel exists;
35
+ * `exit` is its (possibly empty, mid-write) contents.
36
+ * - alive=false && !exitFilePresent ("GONE") → the process is gone AND the
37
+ * wrapper never recorded a sentinel — it was killed / the box died. There is
38
+ * no exit code coming, so this MUST resolve terminal instead of "running
39
+ * forever". Collapsing GONE into the empty-`.exit` case is exactly the bug
40
+ * that left a dead `--device` teammate RUNNING indefinitely.
41
+ */
42
+ export interface RemoteLivenessSnapshot {
43
+ alive: boolean;
44
+ exit: string | null;
45
+ exitFilePresent: boolean;
46
+ }
47
+ /**
48
+ * The per-teammate shell that emits `<id> <ALIVE|EXITED|GONE> <codeOrEmpty>`.
49
+ * Shared by the batched prefetch (many teammates, one round-trip) and the
50
+ * direct single-teammate probe, so both classify liveness identically.
51
+ * `exitFile` is interpolated UNQUOTED so `$HOME` in the dispatch path expands on
52
+ * the remote shell (shellQuote would defeat the `[ -f ]` test).
53
+ */
54
+ export declare function remoteLivenessSnippet(id: string, exitFile: string, pid: number): string;
55
+ /** Parse one `<STATE> <codeOrEmpty>` reading into a snapshot. */
56
+ export declare function parseRemoteLivenessState(state: string, code: string | undefined): RemoteLivenessSnapshot;
18
57
  /** Task type label for Software Factory workflows. Drives planner fan-out. Optional — teammates without a task_type work exactly as before. */
19
58
  export type TaskType = 'plan' | 'implement' | 'test' | 'review' | 'bugfix' | 'docs';
20
59
  export declare const VALID_TASK_TYPES: readonly TaskType[];
@@ -162,10 +201,7 @@ export declare class AgentProcess {
162
201
  remoteLog: string | null;
163
202
  remoteExit: string | null;
164
203
  remoteLogOffset: number;
165
- remotePollSnapshot: {
166
- alive: boolean;
167
- exit: string | null;
168
- } | null;
204
+ remotePollSnapshot: RemoteLivenessSnapshot | null;
169
205
  private eventsCache;
170
206
  private lastReadPos;
171
207
  private baseDir;
@@ -231,6 +267,13 @@ export declare class AgentProcess {
231
267
  * --active --local` take ~4.3s on a box with 30 completed teammates.
232
268
  */
233
269
  private syncRemoteMirror;
270
+ /**
271
+ * One-shot direct liveness probe for a single remote teammate — the fallback
272
+ * used outside a batched supervisor wave (a bare `teams status`, `mgr.get()`
273
+ * for `teams resume`). Returns null on a transient ssh failure so the caller
274
+ * leaves the teammate RUNNING rather than reaping it on a dropped connection.
275
+ */
276
+ private probeRemoteLiveness;
234
277
  /** Reset the local stdout cursor for a newly truncated resume log. */
235
278
  resetLogReadPosition(): number;
236
279
  /** Restore the cursor when a resume transaction puts the prior log back. */
@@ -263,6 +306,24 @@ export declare class AgentProcess {
263
306
  saveMeta(): Promise<void>;
264
307
  static loadFromDisk(agentId: string, baseDir?: string | null): Promise<AgentProcess | null>;
265
308
  isProcessAlive(): boolean;
309
+ /**
310
+ * Read just the persisted status + completion time from meta.json, without
311
+ * reconstructing the whole teammate. Returns null when there is no readable
312
+ * record on disk. Used to detect that ANOTHER process (a `teams stop`, a
313
+ * sibling supervisor) has already moved this teammate to a terminal status.
314
+ */
315
+ private readDiskStatus;
316
+ /**
317
+ * If this in-memory teammate is still non-terminal but disk already shows a
318
+ * terminal status, adopt the disk state. Returns true when it did.
319
+ *
320
+ * This is the guard against the stale-manager race (RUSH-2366): a long-lived
321
+ * supervisor holding a teammate as `running` must never re-persist that stale
322
+ * `running` over a `stopped`/`failed`/`completed` another process just wrote
323
+ * (e.g. an explicit `teams stop` in a separate CLI invocation). A terminal
324
+ * status is a one-way latch, so disk-terminal always wins over memory-running.
325
+ */
326
+ private adoptDiskTerminalIfNewer;
266
327
  /**
267
328
  * @param opts.skipRemote A `--local` caller (RUSH-2118): a distributed
268
329
  * teammate is never dialed — its in-memory state (already loaded from
@@ -333,6 +394,13 @@ export declare class AgentManager {
333
394
  */
334
395
  private localOnly;
335
396
  private constructorAgentsDir;
397
+ /**
398
+ * One-shot memo of the last `validateAddPreconditions` result, so the
399
+ * command-layer pre-worktree call and spawn()'s own call don't each pay a
400
+ * full `listAll()` status refresh (a round of SSH probes on a `--device`
401
+ * team). Consumed by the first matching call — see that method.
402
+ */
403
+ private validatedAdd;
336
404
  constructor(maxAgents?: number, agentsDir?: string | null, defaultMode?: Mode | null, filterByCwd?: string | null, cleanupAgeDays?: number, localOnly?: boolean);
337
405
  private initialize;
338
406
  private doInitialize;
@@ -350,11 +418,66 @@ export declare class AgentManager {
350
418
  * manager is alive — the supervisor loop calls this each wave so
351
419
  * dynamically-added teammates get picked up.
352
420
  *
353
- * Does not modify or re-load agents already in the cache; that path is
354
- * covered by updateStatusFromProcess() which re-reads stdout.log.
421
+ * For a teammate ALREADY cached, refreshes it only when disk has latched it
422
+ * terminal while the cache still holds it non-terminal — the case where
423
+ * another process (e.g. `agents teams stop` in a separate CLI invocation)
424
+ * moved it to `stopped`/`failed` and this long-lived manager would otherwise
425
+ * never see it and re-persist a stale `running` (RUSH-2366). A still-live
426
+ * cached teammate is left untouched; updateStatusFromProcess() owns that path.
355
427
  */
356
428
  rescanFromDisk(): Promise<number>;
357
429
  private loadExistingAgents;
430
+ /**
431
+ * Validate an add's name uniqueness and `--after` dependency graph, without
432
+ * any side effects. Throws a user-facing error on: a duplicate name, `--after`
433
+ * without `--name`, an unknown dependency, or a cycle. Returns the cleaned
434
+ * (whitespace-filtered) `after` list.
435
+ *
436
+ * Extracted from spawn() so the command layer can run it BEFORE creating a
437
+ * worktree — a rejected add must not leave an orphan `agents/<name>` branch
438
+ * that then breaks the retry with `fatal: a branch ... already exists`
439
+ * (RUSH-2356). spawn() calls it too, so validation lives in exactly one place.
440
+ *
441
+ * The result is cached for exactly ONE subsequent call with the same
442
+ * arguments, which spawn() then consumes. `listByTask()` → `listAll()`
443
+ * refreshes every sibling's status, and on a `--device` team that is a full
444
+ * round of SSH liveness probes — running it twice per `teams add` would
445
+ * double that cost for no gain, since the second pass reads the same snapshot
446
+ * and cannot catch anything the first missed. The cache is single-use so any
447
+ * later spawn (a `teams start --watch` supervisor launching staged teammates)
448
+ * still validates against fresh state and still rejects a duplicate name.
449
+ */
450
+ validateAddPreconditions(taskName: string, name: string | null, after: string[]): Promise<string[]>;
451
+ /**
452
+ * Does any LIVE teammate — in any team — already own `worktreeName`?
453
+ *
454
+ * A RAW disk scan: no status probing, no cache, no `listAll()`. The caller is
455
+ * the `teams add` failure path, where the manager's own status refresh can be
456
+ * the very thing that threw (`cleanupOldAgents()` → `listAll()` →
457
+ * `updateStatusFromProcess()` runs AFTER the staged record is saved), so a
458
+ * check that re-entered that machinery would throw again and answer nothing.
459
+ *
460
+ * `teams add` asks this before removing a worktree, to tell an ORPHAN from
461
+ * someone's live checkout (RUSH-2356). Two deliberate scoping choices:
462
+ *
463
+ * - **Any team, not just the one being added to.** Worktree names are global
464
+ * to the repo but records are per-team, so a same-named worktree owned by
465
+ * another team's teammate must also block the removal.
466
+ * - **Non-terminal records only.** A completed/failed/stopped teammate's
467
+ * worktree was already cleaned up at `teams stop`, and its record lingers
468
+ * until retention reaps it — counting those would leave a genuine orphan
469
+ * branch stranded forever, which is the bug this all exists to fix.
470
+ * - **Fails CLOSED.** This guards a `git worktree remove --force`, so the two
471
+ * errors are not symmetric: a false "claimed" strands an orphan branch that
472
+ * a human can delete, while a false "unclaimed" deletes a live agent's
473
+ * checkout and its uncommitted work. Only `ENOENT` proves absence — no
474
+ * agents dir means no records, and a record with no `meta.json` is not a
475
+ * record. Any other failure (EACCES, EIO, half-written or invalid JSON,
476
+ * a race with a writer) means we could not READ the records, which is not
477
+ * the same as there being none, so it answers `true`. This is deliberate
478
+ * asymmetry, not defensive coding: the caller acts destructively on `false`.
479
+ */
480
+ isWorktreeClaimed(worktreeName: string): Promise<boolean>;
358
481
  spawn(taskName: string, agentType: AgentType, prompt: string, cwd?: string | null, mode?: Mode | null, effort?: EffortLevel, parentSessionId?: string | null, workspaceDir?: string | null, version?: string | null, name?: string | null, after?: string[], model?: string | null, envOverrides?: Record<string, string> | null, taskType?: TaskType | null, cloudProvider?: string | null, cloudSessionId?: string | null, cloudRepo?: string | null, cloudBranch?: string | null, worktreeName?: string | null, worktreePath?: string | null, profileName?: string | null, hostName?: string | null, hostTarget?: string | null, repoPath?: string | null): Promise<AgentProcess>;
359
482
  /**
360
483
  * Resume a STOPPED teammate (completed / failed / stopped) by re-entering its
@@ -481,6 +604,13 @@ export declare class AgentManager {
481
604
  }>;
482
605
  listAll(): Promise<AgentProcess[]>;
483
606
  listRunning(): Promise<AgentProcess[]>;
607
+ /**
608
+ * Teammates that have reached a terminal status (completed/failed/stopped) —
609
+ * the ONLY records retention may reap. A `pending` teammate has not launched
610
+ * and a `running` one is working, so neither is "completed"; classifying them
611
+ * as such let cleanupOldAgents sweep live `pending` `--after` teammates past
612
+ * the cap (RUSH-2356). Filter on `isTerminalStatus`, never `!== RUNNING`.
613
+ */
484
614
  listCompleted(): Promise<AgentProcess[]>;
485
615
  listByTask(taskName: string): Promise<AgentProcess[]>;
486
616
  listByParentSession(parentSessionId: string): Promise<AgentProcess[]>;