@phnx-labs/agents-cli 1.22.33 → 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.
- package/CHANGELOG.md +49 -0
- package/README.md +2 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/accounts.js +3 -3
- package/dist/commands/browser-sessions-picker.d.ts +16 -0
- package/dist/commands/browser-sessions-picker.js +179 -0
- package/dist/commands/browser.js +9 -4
- package/dist/commands/hosts.js +1 -5
- package/dist/commands/inspect.js +174 -41
- package/dist/commands/message.d.ts +6 -1
- package/dist/commands/message.js +60 -3
- package/dist/commands/sessions.d.ts +54 -4
- package/dist/commands/sessions.js +252 -46
- package/dist/commands/ssh.js +17 -5
- package/dist/commands/teams.d.ts +28 -0
- package/dist/commands/teams.js +148 -13
- package/dist/commands/upgrade.d.ts +7 -0
- package/dist/commands/upgrade.js +10 -0
- package/dist/commands/watchdog.d.ts +2 -0
- package/dist/commands/watchdog.js +112 -27
- package/dist/index.js +51 -59
- package/dist/lib/agents.js +6 -0
- package/dist/lib/browser/sessions-list.d.ts +81 -0
- package/dist/lib/browser/sessions-list.js +179 -4
- package/dist/lib/daemon.js +45 -5
- package/dist/lib/devices/connect.d.ts +33 -0
- package/dist/lib/devices/connect.js +61 -3
- package/dist/lib/devices/doctor-findings.d.ts +4 -2
- package/dist/lib/devices/doctor-findings.js +4 -2
- package/dist/lib/exec.js +63 -6
- package/dist/lib/help.d.ts +3 -2
- package/dist/lib/help.js +4 -0
- package/dist/lib/hosts/dispatch.d.ts +2 -32
- package/dist/lib/hosts/dispatch.js +6 -61
- package/dist/lib/hosts/tasks.d.ts +7 -0
- package/dist/lib/hosts/tasks.js +9 -0
- package/dist/lib/mailbox-target.d.ts +27 -0
- package/dist/lib/mailbox-target.js +21 -0
- package/dist/lib/mcp.d.ts +10 -0
- package/dist/lib/mcp.js +21 -2
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/migrate.d.ts +11 -0
- package/dist/lib/migrate.js +40 -0
- package/dist/lib/project-root.d.ts +47 -0
- package/dist/lib/project-root.js +68 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/agent.d.ts +9 -2
- package/dist/lib/secrets/agent.js +52 -7
- package/dist/lib/secrets/reaper.d.ts +24 -3
- package/dist/lib/secrets/reaper.js +55 -6
- package/dist/lib/session/discover.js +12 -0
- package/dist/lib/session/render.d.ts +2 -0
- package/dist/lib/session/render.js +1 -1
- package/dist/lib/shims.js +9 -2
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +8 -3
- package/dist/lib/startup/root-command.d.ts +3 -0
- package/dist/lib/startup/root-command.js +10 -0
- package/dist/lib/teams/agents.d.ts +136 -6
- package/dist/lib/teams/agents.js +324 -58
- package/dist/lib/teams/worktree.d.ts +39 -2
- package/dist/lib/teams/worktree.js +60 -4
- package/dist/lib/types.d.ts +11 -0
- package/dist/lib/versions.js +2 -2
- package/dist/lib/watchdog/history.d.ts +20 -0
- package/dist/lib/watchdog/history.js +46 -0
- package/dist/lib/watchdog/log.d.ts +16 -1
- package/dist/lib/watchdog/log.js +82 -2
- package/dist/lib/watchdog/runner.d.ts +12 -0
- package/dist/lib/watchdog/runner.js +20 -0
- 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
|
-
|
|
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
|
-
|
|
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})
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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)
|
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
|
|
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
|
|
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,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
|
-
*
|
|
354
|
-
*
|
|
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[]>;
|