@phnx-labs/agents-cli 1.20.42 → 1.20.44
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 +17 -0
- package/README.md +4 -3
- package/dist/commands/exec.js +46 -8
- package/dist/commands/hosts.js +14 -9
- package/dist/commands/logs.d.ts +4 -0
- package/dist/commands/logs.js +19 -13
- package/dist/commands/routines.d.ts +6 -0
- package/dist/commands/routines.js +70 -12
- package/dist/commands/sessions.d.ts +6 -5
- package/dist/commands/sessions.js +50 -22
- package/dist/commands/teams.js +43 -5
- package/dist/lib/browser/chrome.d.ts +22 -0
- package/dist/lib/browser/chrome.js +53 -13
- package/dist/lib/browser/service.js +13 -0
- package/dist/lib/daemon.js +34 -9
- package/dist/lib/exec.d.ts +15 -0
- package/dist/lib/exec.js +83 -6
- package/dist/lib/hosts/dispatch.d.ts +5 -0
- package/dist/lib/hosts/dispatch.js +4 -0
- package/dist/lib/hosts/logs.d.ts +14 -5
- package/dist/lib/hosts/logs.js +39 -13
- package/dist/lib/hosts/session-index.js +1 -0
- package/dist/lib/hosts/tasks.d.ts +15 -0
- package/dist/lib/hosts/tasks.js +16 -0
- package/dist/lib/redact.js +1 -0
- package/dist/lib/rotate.d.ts +11 -6
- package/dist/lib/rotate.js +25 -11
- package/dist/lib/session/active.d.ts +8 -0
- package/dist/lib/session/active.js +17 -1
- package/dist/lib/session/db.d.ts +11 -0
- package/dist/lib/session/db.js +84 -19
- package/dist/lib/session/discover.js +5 -1
- package/dist/lib/session/remote.d.ts +4 -6
- package/dist/lib/session/remote.js +5 -12
- package/dist/lib/session/run-names.d.ts +32 -0
- package/dist/lib/session/run-names.js +63 -0
- package/dist/lib/session/types.d.ts +8 -0
- package/dist/lib/shims.d.ts +1 -1
- package/dist/lib/shims.js +17 -3
- package/dist/lib/teams/agents.js +16 -7
- package/dist/lib/tmux/session.d.ts +40 -0
- package/dist/lib/tmux/session.js +92 -0
- package/dist/lib/usage.d.ts +5 -3
- package/dist/lib/usage.js +5 -3
- package/dist/lib/versions.d.ts +54 -1
- package/dist/lib/versions.js +138 -1
- package/package.json +1 -1
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run-name index: the join between a `agents run --name <slug>` handle and the
|
|
3
|
+
* session id of the run it named.
|
|
4
|
+
*
|
|
5
|
+
* `agents run` records `<sessionId>.json` here at launch whenever both a name
|
|
6
|
+
* and a session id are known up front (Claude pre-mints its id — see
|
|
7
|
+
* spawnAgent). The session-discovery pass reads these sidecars and applies the
|
|
8
|
+
* names onto the SQLite index by id (via syncNames), the same idempotent,
|
|
9
|
+
* re-applied-every-scan pattern as Claude `/rename` labels. Names therefore
|
|
10
|
+
* survive transcript rescans without being parsed out of the transcript itself.
|
|
11
|
+
*
|
|
12
|
+
* Mirrors the host-task sidecar convention (`~/.agents/.cache/hosts/<id>.json`),
|
|
13
|
+
* one small JSON per run under `~/.agents/.cache/run-names/`.
|
|
14
|
+
*/
|
|
15
|
+
export interface RunNameRecord {
|
|
16
|
+
sessionId: string;
|
|
17
|
+
name: string;
|
|
18
|
+
agent: string;
|
|
19
|
+
cwd?: string;
|
|
20
|
+
ts: number;
|
|
21
|
+
}
|
|
22
|
+
export declare function runNamesDir(): string;
|
|
23
|
+
/**
|
|
24
|
+
* Record a run's `--name` handle keyed by its session id. Best-effort: a failed
|
|
25
|
+
* write must never break the run itself. No-op without both a name and id.
|
|
26
|
+
*/
|
|
27
|
+
export declare function recordRunName(rec: Omit<RunNameRecord, 'ts'>): void;
|
|
28
|
+
/**
|
|
29
|
+
* Build the sessionId → name map from every run-name sidecar, for syncNames to
|
|
30
|
+
* apply onto the index. Returns an empty map when the dir doesn't exist yet.
|
|
31
|
+
*/
|
|
32
|
+
export declare function buildRunNameMap(): Map<string, string | null>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run-name index: the join between a `agents run --name <slug>` handle and the
|
|
3
|
+
* session id of the run it named.
|
|
4
|
+
*
|
|
5
|
+
* `agents run` records `<sessionId>.json` here at launch whenever both a name
|
|
6
|
+
* and a session id are known up front (Claude pre-mints its id — see
|
|
7
|
+
* spawnAgent). The session-discovery pass reads these sidecars and applies the
|
|
8
|
+
* names onto the SQLite index by id (via syncNames), the same idempotent,
|
|
9
|
+
* re-applied-every-scan pattern as Claude `/rename` labels. Names therefore
|
|
10
|
+
* survive transcript rescans without being parsed out of the transcript itself.
|
|
11
|
+
*
|
|
12
|
+
* Mirrors the host-task sidecar convention (`~/.agents/.cache/hosts/<id>.json`),
|
|
13
|
+
* one small JSON per run under `~/.agents/.cache/run-names/`.
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from 'fs';
|
|
16
|
+
import * as path from 'path';
|
|
17
|
+
import { getCacheDir } from '../state.js';
|
|
18
|
+
export function runNamesDir() {
|
|
19
|
+
return path.join(getCacheDir(), 'run-names');
|
|
20
|
+
}
|
|
21
|
+
function recordFile(sessionId) {
|
|
22
|
+
return path.join(runNamesDir(), `${sessionId}.json`);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Record a run's `--name` handle keyed by its session id. Best-effort: a failed
|
|
26
|
+
* write must never break the run itself. No-op without both a name and id.
|
|
27
|
+
*/
|
|
28
|
+
export function recordRunName(rec) {
|
|
29
|
+
if (!rec.sessionId || !rec.name)
|
|
30
|
+
return;
|
|
31
|
+
try {
|
|
32
|
+
fs.mkdirSync(runNamesDir(), { recursive: true });
|
|
33
|
+
fs.writeFileSync(recordFile(rec.sessionId), JSON.stringify({ ...rec, ts: Date.now() }, null, 2));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
/* the run is already launching; the name is a convenience, not load-bearing */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Build the sessionId → name map from every run-name sidecar, for syncNames to
|
|
41
|
+
* apply onto the index. Returns an empty map when the dir doesn't exist yet.
|
|
42
|
+
*/
|
|
43
|
+
export function buildRunNameMap() {
|
|
44
|
+
const map = new Map();
|
|
45
|
+
let files;
|
|
46
|
+
try {
|
|
47
|
+
files = fs.readdirSync(runNamesDir()).filter((f) => f.endsWith('.json'));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return map;
|
|
51
|
+
}
|
|
52
|
+
for (const f of files) {
|
|
53
|
+
try {
|
|
54
|
+
const rec = JSON.parse(fs.readFileSync(path.join(runNamesDir(), f), 'utf-8'));
|
|
55
|
+
if (rec.sessionId && rec.name)
|
|
56
|
+
map.set(rec.sessionId, rec.name);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
/* skip a corrupt sidecar */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return map;
|
|
63
|
+
}
|
|
@@ -67,6 +67,14 @@ export interface SessionMeta {
|
|
|
67
67
|
topic?: string;
|
|
68
68
|
/** Custom name the user gave the session (e.g. Claude Code /rename). */
|
|
69
69
|
label?: string;
|
|
70
|
+
/**
|
|
71
|
+
* Durable launch handle from `agents run --name <slug>` — an alias chosen at
|
|
72
|
+
* launch (not derived from the session id), used to resolve the run in
|
|
73
|
+
* `agents sessions <name>`. Distinct from `label` (post-hoc /rename): a run's
|
|
74
|
+
* name is immutable; both are searchable. Absent for runs launched without
|
|
75
|
+
* `--name`.
|
|
76
|
+
*/
|
|
77
|
+
name?: string;
|
|
70
78
|
/** Set when this session was spawned by `agents teams`. */
|
|
71
79
|
teamOrigin?: TeamOrigin;
|
|
72
80
|
/** Durable state signals extracted at scan time by the session-state engine. */
|
package/dist/lib/shims.d.ts
CHANGED
|
@@ -77,7 +77,7 @@ export interface ConflictInfo {
|
|
|
77
77
|
* top-level entry add/remove — deep edits to plugin contents won't
|
|
78
78
|
* trigger auto-resync, run `agents sync` for that.
|
|
79
79
|
*/
|
|
80
|
-
export declare const SHIM_SCHEMA_VERSION =
|
|
80
|
+
export declare const SHIM_SCHEMA_VERSION = 25;
|
|
81
81
|
/**
|
|
82
82
|
* Generate the full bash shim script for the given agent. The returned string
|
|
83
83
|
* is written to ~/.agents/shims/{cliCommand} and made executable.
|
package/dist/lib/shims.js
CHANGED
|
@@ -211,7 +211,10 @@ async function promptConflictStrategy(conflictInfos) {
|
|
|
211
211
|
// v22 — export DISABLE_AUTOUPDATER=1 for claude shims so a pinned per-version
|
|
212
212
|
// install can't self-mutate: Claude Code's background auto-updater would
|
|
213
213
|
// otherwise rewrite the pinned binary in place. Explicit user value wins.
|
|
214
|
-
|
|
214
|
+
// v25 — dispatcher self-recovery: if the baked AGENTS_BIN is gone (a removed/moved
|
|
215
|
+
// dev build that generated the shim), resolve `agents` on PATH instead of
|
|
216
|
+
// exiting 127, so a stale/vanished dev build can't brick every launch.
|
|
217
|
+
export const SHIM_SCHEMA_VERSION = 25;
|
|
215
218
|
/** Internal marker string used to embed the schema version in shim scripts. */
|
|
216
219
|
const SHIM_VERSION_MARKER = 'agents-shim-version:';
|
|
217
220
|
function shellQuote(value) {
|
|
@@ -291,8 +294,19 @@ AGENT="${agent}"
|
|
|
291
294
|
CLI_COMMAND="${cliCommand}"
|
|
292
295
|
|
|
293
296
|
if [ -z "$AGENTS_BIN" ] || [ ! -x "$AGENTS_BIN" ]; then
|
|
294
|
-
|
|
295
|
-
|
|
297
|
+
# The baked dispatcher is gone — e.g. the build that generated this shim (often
|
|
298
|
+
# a dev build under ~/.local/agents-cli-dev) was removed, moved, or its version
|
|
299
|
+
# dir rotated. Self-recover to whatever 'agents' now resolves to on PATH instead
|
|
300
|
+
# of bricking every managed launch. 'agents' is the CLI itself, never a per-agent
|
|
301
|
+
# shim, so this cannot re-enter this dispatcher.
|
|
302
|
+
RECOVERED_BIN="$(command -v agents 2>/dev/null || true)"
|
|
303
|
+
if [ -n "$RECOVERED_BIN" ] && [ -x "$RECOVERED_BIN" ]; then
|
|
304
|
+
AGENTS_BIN="$RECOVERED_BIN"
|
|
305
|
+
else
|
|
306
|
+
echo "agents: agents-cli entrypoint missing or not executable: $AGENTS_BIN" >&2
|
|
307
|
+
echo "agents: could not resolve 'agents' on PATH to recover. Reinstall: npm i -g @phnx-labs/agents-cli" >&2
|
|
308
|
+
exit 127
|
|
309
|
+
fi
|
|
296
310
|
fi
|
|
297
311
|
|
|
298
312
|
# When agents-cli "adopts" a harness's own launcher (symlinks the native binary
|
package/dist/lib/teams/agents.js
CHANGED
|
@@ -20,6 +20,7 @@ import { debug } from './debug.js';
|
|
|
20
20
|
import { setGeminiAutoUpdateDisabled, updateGeminiSettings } from '../gemini-settings.js';
|
|
21
21
|
import { getAgentsDir as getSystemAgentsDir, getShimsDir } from '../state.js';
|
|
22
22
|
import { AGENTS, getAccountInfo } from '../agents.js';
|
|
23
|
+
import { resolveVersion, isVersionInstalled } from '../versions.js';
|
|
23
24
|
import { sanitizeProcessEnv } from '../secrets/bundles.js';
|
|
24
25
|
let lastMemoryWarnAt = 0;
|
|
25
26
|
// On macOS, os.freemem() returns only the truly-free pool and ignores the
|
|
@@ -326,18 +327,26 @@ export async function ensureGeminiPlanMode() {
|
|
|
326
327
|
* (for CLIs the user installed outside agents-cli).
|
|
327
328
|
*/
|
|
328
329
|
export function checkCliAvailable(agentType) {
|
|
329
|
-
const
|
|
330
|
+
const agent = agentType;
|
|
331
|
+
const executable = AGENTS[agent]?.cliCommand;
|
|
330
332
|
if (!executable) {
|
|
331
333
|
return [false, `Unknown agent type: ${agentType}`];
|
|
332
334
|
}
|
|
333
335
|
const shimPath = path.join(getShimsDir(), executable);
|
|
334
|
-
|
|
335
|
-
|
|
336
|
+
const dispatch = fsSync.existsSync(shimPath) ? shimPath : findExecutable(executable);
|
|
337
|
+
if (!dispatch) {
|
|
338
|
+
return [false, `CLI tool '${executable}' not found in PATH. Install it first.`];
|
|
336
339
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
340
|
+
// A shim file (or a PATH entry) existing does NOT mean the agent is runnable:
|
|
341
|
+
// the managed default version's binary can be a stub or gutted (a partial/raced
|
|
342
|
+
// npm extract leaves the version dir + JS wrapper but no real binary). Verify
|
|
343
|
+
// the resolved default version is actually installed so `teams doctor` reports
|
|
344
|
+
// the truth instead of a false `installed: true` that ENOENTs at spawn.
|
|
345
|
+
const version = resolveVersion(agent);
|
|
346
|
+
if (version && !isVersionInstalled(agent, version)) {
|
|
347
|
+
return [false, `${executable}@${version} is not runnable — its binary is missing/incomplete. Repair: agents add ${agent}@${version}`];
|
|
348
|
+
}
|
|
349
|
+
return [true, dispatch];
|
|
341
350
|
}
|
|
342
351
|
/** Check availability of all known agent CLIs. Returns a map of agent type to install status. */
|
|
343
352
|
export function checkAllClis() {
|
|
@@ -126,6 +126,46 @@ export declare function paneExitStatus(pane: string, socket?: string): Promise<P
|
|
|
126
126
|
* Best-effort — a failed hook just means the user Ctrl-b d's out manually.
|
|
127
127
|
*/
|
|
128
128
|
export declare function setSessionHook(name: string, hook: string, command: string, socket?: string): Promise<void>;
|
|
129
|
+
/**
|
|
130
|
+
* Schema version of the `pane-died` hook installed on managed `agents run`
|
|
131
|
+
* sessions. Bump whenever the hook's SHAPE changes so the daemon reconcile
|
|
132
|
+
* (reconcileSessionHooks) knows to re-stamp live sessions a prior binary left on
|
|
133
|
+
* an older shape.
|
|
134
|
+
* v1 — the original unconditional `detach-client`: ANY pane death (including a
|
|
135
|
+
* user exiting a split they opened) tore down the whole client.
|
|
136
|
+
* v2 — `#{hook_pane}`-guarded: only the AGENT pane dying detaches; a user
|
|
137
|
+
* split's death runs `kill-pane`, closing just that split.
|
|
138
|
+
*/
|
|
139
|
+
export declare const AGENT_HOOK_SCHEMA = 2;
|
|
140
|
+
/**
|
|
141
|
+
* The guarded `pane-died` hook. Detach the client ONLY when the agent pane dies
|
|
142
|
+
* (so the blocking attach in runInTmux returns and the exit status can be read);
|
|
143
|
+
* a user split's death falls through to `kill-pane`, which — because the hook
|
|
144
|
+
* runs in the dead pane's context — closes that split in place. Single source of
|
|
145
|
+
* truth: both the spawn-wrap (exec.ts) and the daemon reconcile build the hook
|
|
146
|
+
* here, so the two can never drift.
|
|
147
|
+
*/
|
|
148
|
+
export declare function agentPaneDiedHook(sessionName: string, agentPane: string): string;
|
|
149
|
+
/** Stamp a session's hook-schema marker to the current version. */
|
|
150
|
+
export declare function markSessionHookSchema(name: string, socket?: string): Promise<void>;
|
|
151
|
+
/**
|
|
152
|
+
* Retrofit the current guarded `pane-died` hook onto every managed `agents run`
|
|
153
|
+
* session whose hook predates AGENT_HOOK_SCHEMA. Idempotent and NON-DESTRUCTIVE:
|
|
154
|
+
* it only `set-hook`s (never kills a pane or detaches a client), so a long-lived
|
|
155
|
+
* shared server started by a pre-fix binary — whose still-running sessions carry
|
|
156
|
+
* the old unconditional hook that kicked the user out of the whole view when they
|
|
157
|
+
* exited a split — self-heals in place, without waiting for those agents to exit
|
|
158
|
+
* or for the server to be recycled.
|
|
159
|
+
*
|
|
160
|
+
* The daemon calls this on a light interval. The per-session `@ag_hook_schema`
|
|
161
|
+
* marker makes steady-state a cheap no-op: a session already at the current
|
|
162
|
+
* schema is skipped. Only run-wrapped sessions (`ag-` prefix) are touched — an
|
|
163
|
+
* externally-created session on the socket keeps whatever hook it set.
|
|
164
|
+
*/
|
|
165
|
+
export declare function reconcileSessionHooks(socket?: string): Promise<{
|
|
166
|
+
scanned: number;
|
|
167
|
+
reconciled: number;
|
|
168
|
+
}>;
|
|
129
169
|
/**
|
|
130
170
|
* List live sessions on the socket. Reconciles meta JSONs against tmux's view:
|
|
131
171
|
* - tmux session with no meta → returned without `meta` (external session)
|
package/dist/lib/tmux/session.js
CHANGED
|
@@ -269,6 +269,98 @@ export async function setSessionHook(name, hook, command, socket) {
|
|
|
269
269
|
const sock = socket ?? getDefaultSocketPath();
|
|
270
270
|
await runTmux({ socket: sock, args: ['set-hook', '-t', name, hook, command], throwOnError: false }).catch(() => { });
|
|
271
271
|
}
|
|
272
|
+
/**
|
|
273
|
+
* Schema version of the `pane-died` hook installed on managed `agents run`
|
|
274
|
+
* sessions. Bump whenever the hook's SHAPE changes so the daemon reconcile
|
|
275
|
+
* (reconcileSessionHooks) knows to re-stamp live sessions a prior binary left on
|
|
276
|
+
* an older shape.
|
|
277
|
+
* v1 — the original unconditional `detach-client`: ANY pane death (including a
|
|
278
|
+
* user exiting a split they opened) tore down the whole client.
|
|
279
|
+
* v2 — `#{hook_pane}`-guarded: only the AGENT pane dying detaches; a user
|
|
280
|
+
* split's death runs `kill-pane`, closing just that split.
|
|
281
|
+
*/
|
|
282
|
+
export const AGENT_HOOK_SCHEMA = 2;
|
|
283
|
+
/** Per-session tmux user-option that records which AGENT_HOOK_SCHEMA a session's hook is at. */
|
|
284
|
+
const HOOK_SCHEMA_OPTION = '@ag_hook_schema';
|
|
285
|
+
/**
|
|
286
|
+
* The guarded `pane-died` hook. Detach the client ONLY when the agent pane dies
|
|
287
|
+
* (so the blocking attach in runInTmux returns and the exit status can be read);
|
|
288
|
+
* a user split's death falls through to `kill-pane`, which — because the hook
|
|
289
|
+
* runs in the dead pane's context — closes that split in place. Single source of
|
|
290
|
+
* truth: both the spawn-wrap (exec.ts) and the daemon reconcile build the hook
|
|
291
|
+
* here, so the two can never drift.
|
|
292
|
+
*/
|
|
293
|
+
export function agentPaneDiedHook(sessionName, agentPane) {
|
|
294
|
+
return `if -F '#{==:#{hook_pane},${agentPane}}' 'detach-client -s =${sessionName}' 'kill-pane'`;
|
|
295
|
+
}
|
|
296
|
+
/** Stamp a session's hook-schema marker to the current version. */
|
|
297
|
+
export async function markSessionHookSchema(name, socket) {
|
|
298
|
+
const sock = socket ?? getDefaultSocketPath();
|
|
299
|
+
await runTmux({ socket: sock, args: ['set-option', '-t', name, HOOK_SCHEMA_OPTION, String(AGENT_HOOK_SCHEMA)], throwOnError: false }).catch(() => { });
|
|
300
|
+
}
|
|
301
|
+
/** Read a session's hook-schema marker; undefined when unset (pre-marker sessions). */
|
|
302
|
+
async function readHookSchema(name, socket) {
|
|
303
|
+
const res = await runTmux({ socket, args: ['show-options', '-v', '-t', name, HOOK_SCHEMA_OPTION], throwOnError: false }).catch(() => null);
|
|
304
|
+
if (!res || res.code !== 0)
|
|
305
|
+
return undefined;
|
|
306
|
+
const v = res.stdout.trim();
|
|
307
|
+
return v === '' ? undefined : v;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Lowest pane id (`%N`) in a session — the first pane created, i.e. the agent
|
|
311
|
+
* pane, since user splits are always created later and get higher ids. Fallback
|
|
312
|
+
* for sessions whose SessionMeta (which records the agent pane) predates meta
|
|
313
|
+
* persistence. Undefined when the session has no panes (already torn down).
|
|
314
|
+
*/
|
|
315
|
+
async function lowestPaneId(name, socket) {
|
|
316
|
+
const res = await runTmux({ socket, args: ['list-panes', '-t', name, '-F', '#{pane_id}'], throwOnError: false }).catch(() => null);
|
|
317
|
+
if (!res || res.code !== 0)
|
|
318
|
+
return undefined;
|
|
319
|
+
const ids = res.stdout.split('\n').map(l => l.trim()).filter(id => /^%\d+$/.test(id));
|
|
320
|
+
if (!ids.length)
|
|
321
|
+
return undefined;
|
|
322
|
+
return ids.reduce((lo, id) => (parseInt(id.slice(1), 10) < parseInt(lo.slice(1), 10) ? id : lo));
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Retrofit the current guarded `pane-died` hook onto every managed `agents run`
|
|
326
|
+
* session whose hook predates AGENT_HOOK_SCHEMA. Idempotent and NON-DESTRUCTIVE:
|
|
327
|
+
* it only `set-hook`s (never kills a pane or detaches a client), so a long-lived
|
|
328
|
+
* shared server started by a pre-fix binary — whose still-running sessions carry
|
|
329
|
+
* the old unconditional hook that kicked the user out of the whole view when they
|
|
330
|
+
* exited a split — self-heals in place, without waiting for those agents to exit
|
|
331
|
+
* or for the server to be recycled.
|
|
332
|
+
*
|
|
333
|
+
* The daemon calls this on a light interval. The per-session `@ag_hook_schema`
|
|
334
|
+
* marker makes steady-state a cheap no-op: a session already at the current
|
|
335
|
+
* schema is skipped. Only run-wrapped sessions (`ag-` prefix) are touched — an
|
|
336
|
+
* externally-created session on the socket keeps whatever hook it set.
|
|
337
|
+
*/
|
|
338
|
+
export async function reconcileSessionHooks(socket) {
|
|
339
|
+
const sock = socket ?? getDefaultSocketPath();
|
|
340
|
+
if (!fs.existsSync(sock))
|
|
341
|
+
return { scanned: 0, reconciled: 0 };
|
|
342
|
+
let sessions;
|
|
343
|
+
try {
|
|
344
|
+
sessions = await listSessions({ socket: sock });
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return { scanned: 0, reconciled: 0 };
|
|
348
|
+
}
|
|
349
|
+
let reconciled = 0;
|
|
350
|
+
for (const s of sessions) {
|
|
351
|
+
if (!s.name.startsWith('ag-'))
|
|
352
|
+
continue; // only run-wrapped sessions
|
|
353
|
+
if (await readHookSchema(s.name, sock) === String(AGENT_HOOK_SCHEMA))
|
|
354
|
+
continue;
|
|
355
|
+
const agentPane = s.meta?.pane ?? await lowestPaneId(s.name, sock);
|
|
356
|
+
if (!agentPane)
|
|
357
|
+
continue;
|
|
358
|
+
await setSessionHook(s.name, 'pane-died', agentPaneDiedHook(s.name, agentPane), sock);
|
|
359
|
+
await markSessionHookSchema(s.name, sock);
|
|
360
|
+
reconciled++;
|
|
361
|
+
}
|
|
362
|
+
return { scanned: sessions.length, reconciled };
|
|
363
|
+
}
|
|
272
364
|
/**
|
|
273
365
|
* List live sessions on the socket. Reconciles meta JSONs against tmux's view:
|
|
274
366
|
* - tmux session with no meta → returned without `meta` (external session)
|
package/dist/lib/usage.d.ts
CHANGED
|
@@ -88,9 +88,11 @@ export declare function getUsageInfoForIdentity(input: UsageIdentityInput): Prom
|
|
|
88
88
|
export declare function formatUsageSummary(plan: string | null, snapshot: UsageSnapshot | null, planWidth?: number): string;
|
|
89
89
|
/**
|
|
90
90
|
* Derive an account's real throttle state from its live usage windows — the
|
|
91
|
-
*
|
|
92
|
-
* (`
|
|
93
|
-
* the account is throttled until
|
|
91
|
+
* single signal both the `agents view` badge and run-rotation eligibility share
|
|
92
|
+
* (`hasUsageAvailable` in rotate.ts treats a `rate_limited` verdict here as
|
|
93
|
+
* ineligible). A window at 100% utilization means the account is throttled until
|
|
94
|
+
* that window resets. Rotation *weighting* still ranks eligible accounts by
|
|
95
|
+
* weekly headroom (`getRoutingUsedPercent`); this function is the yes/no gate.
|
|
94
96
|
*
|
|
95
97
|
* Returns `null` when there is no snapshot, so callers render no badge rather
|
|
96
98
|
* than a misleading one. This deliberately never consults
|
package/dist/lib/usage.js
CHANGED
|
@@ -212,9 +212,11 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3) {
|
|
|
212
212
|
}
|
|
213
213
|
/**
|
|
214
214
|
* Derive an account's real throttle state from its live usage windows — the
|
|
215
|
-
*
|
|
216
|
-
* (`
|
|
217
|
-
* the account is throttled until
|
|
215
|
+
* single signal both the `agents view` badge and run-rotation eligibility share
|
|
216
|
+
* (`hasUsageAvailable` in rotate.ts treats a `rate_limited` verdict here as
|
|
217
|
+
* ineligible). A window at 100% utilization means the account is throttled until
|
|
218
|
+
* that window resets. Rotation *weighting* still ranks eligible accounts by
|
|
219
|
+
* weekly headroom (`getRoutingUsedPercent`); this function is the yes/no gate.
|
|
218
220
|
*
|
|
219
221
|
* Returns `null` when there is no snapshot, so callers render no badge rather
|
|
220
222
|
* than a misleading one. This deliberately never consults
|
package/dist/lib/versions.d.ts
CHANGED
|
@@ -180,7 +180,9 @@ export declare function setGlobalDefault(agent: AgentId, version: string | undef
|
|
|
180
180
|
/**
|
|
181
181
|
* Install a specific version of an agent.
|
|
182
182
|
*/
|
|
183
|
-
export declare function installVersion(agent: AgentId, version: string, onProgress?: (message: string) => void
|
|
183
|
+
export declare function installVersion(agent: AgentId, version: string, onProgress?: (message: string) => void, opts?: {
|
|
184
|
+
clean?: boolean;
|
|
185
|
+
}): Promise<{
|
|
184
186
|
success: boolean;
|
|
185
187
|
installedVersion: string;
|
|
186
188
|
error?: string;
|
|
@@ -291,6 +293,57 @@ export { compareVersions };
|
|
|
291
293
|
* Get actual version from an installed 'latest' directory.
|
|
292
294
|
*/
|
|
293
295
|
export declare function getInstalledVersion(agent: AgentId, version: string): Promise<string | null>;
|
|
296
|
+
/**
|
|
297
|
+
* True when a probe's combined output/error looks like the runnable binary (or a
|
|
298
|
+
* native sub-binary it execs) is MISSING — the "gutted install" signature. This
|
|
299
|
+
* is the exact class of failure behind the ENOENT crash: an npm package whose JS
|
|
300
|
+
* wrapper landed at node_modules/.bin/<cli> while its native platform binary
|
|
301
|
+
* (shipped via an optional per-arch dependency, e.g. @openai/codex-<platform>)
|
|
302
|
+
* did not — so the wrapper spawns and immediately dies with `spawn … ENOENT`.
|
|
303
|
+
*
|
|
304
|
+
* Deliberately narrow: only the missing-file signature counts. An agent that
|
|
305
|
+
* merely dislikes `--version` (nonzero exit, ordinary error text) or ignores it
|
|
306
|
+
* (times out) must NOT match, so a healthy install is never falsely condemned.
|
|
307
|
+
*/
|
|
308
|
+
export declare function isMissingBinarySignature(output: string): boolean;
|
|
309
|
+
/**
|
|
310
|
+
* Verify a freshly-installed agent can actually LAUNCH — not merely that its JS
|
|
311
|
+
* wrapper exists. getBinaryPath()/isVersionInstalled() only check the wrapper, so
|
|
312
|
+
* a gutted install (wrapper present, native binary missing) reads as healthy,
|
|
313
|
+
* gets pinned as the default, and gets picked to run — then dies with ENOENT the
|
|
314
|
+
* instant it spawns (which, wrapped in tmux, showed up as a silent `[detached]`).
|
|
315
|
+
*
|
|
316
|
+
* We probe `<binary> --version` under the version's isolated HOME so config
|
|
317
|
+
* resolution matches a real launch. Because the ENOENT originates in the child
|
|
318
|
+
* (the wrapper spawns fine, then fails to exec the absent native binary), we
|
|
319
|
+
* inspect the child's OUTPUT, not just whether our spawn succeeded. Only the
|
|
320
|
+
* missing-binary signature (see isMissingBinarySignature) fails the check; a
|
|
321
|
+
* plain nonzero exit or a timeout is treated as healthy so we never false-fail.
|
|
322
|
+
*/
|
|
323
|
+
export declare function verifyInstalledBinaryLaunches(agent: AgentId, version: string): Promise<{
|
|
324
|
+
ok: boolean;
|
|
325
|
+
detail?: string;
|
|
326
|
+
}>;
|
|
327
|
+
/**
|
|
328
|
+
* Launch-path self-heal. Given the concrete version `agents run` is about to
|
|
329
|
+
* spawn, make sure it will actually run — and if not, repair it instead of
|
|
330
|
+
* letting the agent die with a raw `ENOENT` deep inside its own wrapper.
|
|
331
|
+
*
|
|
332
|
+
* Steps, cheapest first:
|
|
333
|
+
* 1. Probe the version (verifyInstalledBinaryLaunches). Healthy → return it.
|
|
334
|
+
* 2. Broken → **clean** reinstall in place (wipes the partial node_modules so
|
|
335
|
+
* npm actually re-fetches the platform binary). Re-probe; good → return it.
|
|
336
|
+
* 3. Still broken → fall back to another INSTALLED version that launches,
|
|
337
|
+
* re-pinning it as the default so the shim path heals too. Return it.
|
|
338
|
+
* 4. Nothing runnable installed → install `latest`, pin it, return it.
|
|
339
|
+
* 5. Give up → return null (caller surfaces a clear error).
|
|
340
|
+
*
|
|
341
|
+
* Gated to npm-package agents: their native binary ships as an optional per-arch
|
|
342
|
+
* dependency whose tarball can extract partially (interrupted/raced install) —
|
|
343
|
+
* the exact failure this repairs. Agents with a global/native binary (grok,
|
|
344
|
+
* droid) have no such tarball and are returned unchanged.
|
|
345
|
+
*/
|
|
346
|
+
export declare function ensureAgentRunnable(agent: AgentId, version: string, log?: (message: string) => void): Promise<string | null>;
|
|
294
347
|
/** Outcome of syncing resources to a version home, keyed by resource type. */
|
|
295
348
|
export interface SyncResult {
|
|
296
349
|
commands: boolean;
|
package/dist/lib/versions.js
CHANGED
|
@@ -1054,7 +1054,7 @@ export function setGlobalDefault(agent, version) {
|
|
|
1054
1054
|
/**
|
|
1055
1055
|
* Install a specific version of an agent.
|
|
1056
1056
|
*/
|
|
1057
|
-
export async function installVersion(agent, version, onProgress) {
|
|
1057
|
+
export async function installVersion(agent, version, onProgress, opts) {
|
|
1058
1058
|
const agentConfig = AGENTS[agent];
|
|
1059
1059
|
// Validate before deriving filesystem paths or npm package specs. The CLI
|
|
1060
1060
|
// parser already enforces this for user input; this guard protects direct
|
|
@@ -1143,6 +1143,14 @@ export async function installVersion(agent, version, onProgress) {
|
|
|
1143
1143
|
}
|
|
1144
1144
|
ensureAgentsDir();
|
|
1145
1145
|
const versionDir = getVersionDir(agent, version);
|
|
1146
|
+
// A `clean` (repair) reinstall wipes a possibly partially-extracted
|
|
1147
|
+
// node_modules first. npm treats a present-but-gutted platform package (its
|
|
1148
|
+
// package.json landed, its vendored native binary did not) as already
|
|
1149
|
+
// installed and would skip re-fetching it — so without this the corrupt
|
|
1150
|
+
// vendor/ survives the reinstall and the ENOENT persists. home/ is preserved.
|
|
1151
|
+
if (opts?.clean && fs.existsSync(versionDir)) {
|
|
1152
|
+
removeInstallArtifacts(versionDir);
|
|
1153
|
+
}
|
|
1146
1154
|
// Create version directory and isolated home
|
|
1147
1155
|
fs.mkdirSync(versionDir, { recursive: true });
|
|
1148
1156
|
fs.mkdirSync(path.join(versionDir, 'home'), { recursive: true });
|
|
@@ -1218,6 +1226,24 @@ export async function installVersion(agent, version, onProgress) {
|
|
|
1218
1226
|
/* non-fatal; the install itself succeeded */
|
|
1219
1227
|
}
|
|
1220
1228
|
}
|
|
1229
|
+
// Integrity gate: confirm the install actually launches, not just that the
|
|
1230
|
+
// JS wrapper landed. A gutted install (wrapper present, native platform
|
|
1231
|
+
// binary missing) otherwise gets silently pinned as the default and crashes
|
|
1232
|
+
// with ENOENT on run. Fail loudly here so `agents add` never records a
|
|
1233
|
+
// broken version as healthy — the caller then won't set it as default.
|
|
1234
|
+
const health = await verifyInstalledBinaryLaunches(agent, installedVersion);
|
|
1235
|
+
if (!health.ok) {
|
|
1236
|
+
if (fs.existsSync(versionDir))
|
|
1237
|
+
removeInstallArtifacts(versionDir);
|
|
1238
|
+
const detail = health.detail ? ` (${health.detail})` : '';
|
|
1239
|
+
emit('version.install', { agent, version: installedVersion, error: `binary failed to launch${detail}` });
|
|
1240
|
+
return {
|
|
1241
|
+
success: false,
|
|
1242
|
+
installedVersion,
|
|
1243
|
+
error: `${agentConfig.name}@${installedVersion} installed but its binary failed to launch${detail}. `
|
|
1244
|
+
+ `The install is incomplete — the platform binary is missing. Re-run: agents add ${agent}@${installedVersion}`,
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1221
1247
|
emit('version.install', { agent, version: installedVersion });
|
|
1222
1248
|
return { success: true, installedVersion };
|
|
1223
1249
|
}
|
|
@@ -1566,6 +1592,117 @@ export async function getInstalledVersion(agent, version) {
|
|
|
1566
1592
|
return version;
|
|
1567
1593
|
}
|
|
1568
1594
|
}
|
|
1595
|
+
/**
|
|
1596
|
+
* True when a probe's combined output/error looks like the runnable binary (or a
|
|
1597
|
+
* native sub-binary it execs) is MISSING — the "gutted install" signature. This
|
|
1598
|
+
* is the exact class of failure behind the ENOENT crash: an npm package whose JS
|
|
1599
|
+
* wrapper landed at node_modules/.bin/<cli> while its native platform binary
|
|
1600
|
+
* (shipped via an optional per-arch dependency, e.g. @openai/codex-<platform>)
|
|
1601
|
+
* did not — so the wrapper spawns and immediately dies with `spawn … ENOENT`.
|
|
1602
|
+
*
|
|
1603
|
+
* Deliberately narrow: only the missing-file signature counts. An agent that
|
|
1604
|
+
* merely dislikes `--version` (nonzero exit, ordinary error text) or ignores it
|
|
1605
|
+
* (times out) must NOT match, so a healthy install is never falsely condemned.
|
|
1606
|
+
*/
|
|
1607
|
+
export function isMissingBinarySignature(output) {
|
|
1608
|
+
return /\bENOENT\b|no such file|cannot find|command not found|is not recognized/i.test(output);
|
|
1609
|
+
}
|
|
1610
|
+
/**
|
|
1611
|
+
* Verify a freshly-installed agent can actually LAUNCH — not merely that its JS
|
|
1612
|
+
* wrapper exists. getBinaryPath()/isVersionInstalled() only check the wrapper, so
|
|
1613
|
+
* a gutted install (wrapper present, native binary missing) reads as healthy,
|
|
1614
|
+
* gets pinned as the default, and gets picked to run — then dies with ENOENT the
|
|
1615
|
+
* instant it spawns (which, wrapped in tmux, showed up as a silent `[detached]`).
|
|
1616
|
+
*
|
|
1617
|
+
* We probe `<binary> --version` under the version's isolated HOME so config
|
|
1618
|
+
* resolution matches a real launch. Because the ENOENT originates in the child
|
|
1619
|
+
* (the wrapper spawns fine, then fails to exec the absent native binary), we
|
|
1620
|
+
* inspect the child's OUTPUT, not just whether our spawn succeeded. Only the
|
|
1621
|
+
* missing-binary signature (see isMissingBinarySignature) fails the check; a
|
|
1622
|
+
* plain nonzero exit or a timeout is treated as healthy so we never false-fail.
|
|
1623
|
+
*/
|
|
1624
|
+
export async function verifyInstalledBinaryLaunches(agent, version) {
|
|
1625
|
+
// Windows: `getBinaryPath` returns the extensionless `.bin/<cli>` (a shell
|
|
1626
|
+
// wrapper), NOT the `.cmd`/`.exe` that actually launches there — `execFile`ing
|
|
1627
|
+
// it would ENOENT on a perfectly healthy install, and the integrity gate would
|
|
1628
|
+
// then WIPE it. The gutted-native-binary failure this guards against is a POSIX
|
|
1629
|
+
// concern in practice; treat win32 as healthy rather than risk destroying a
|
|
1630
|
+
// good install. (isVersionInstalled already validates presence on Windows.)
|
|
1631
|
+
if (process.platform === 'win32')
|
|
1632
|
+
return { ok: true };
|
|
1633
|
+
const binary = getBinaryPath(agent, version);
|
|
1634
|
+
if (!fs.existsSync(binary)) {
|
|
1635
|
+
return { ok: false, detail: `binary not found at ${binary}` };
|
|
1636
|
+
}
|
|
1637
|
+
try {
|
|
1638
|
+
await execFileAsync(binary, ['--version'], {
|
|
1639
|
+
timeout: 15000,
|
|
1640
|
+
env: { ...process.env, HOME: getVersionHomePath(agent, version) },
|
|
1641
|
+
});
|
|
1642
|
+
return { ok: true };
|
|
1643
|
+
}
|
|
1644
|
+
catch (err) {
|
|
1645
|
+
const blob = `${err?.code ?? ''} ${err?.stdout ?? ''} ${err?.stderr ?? ''} ${err?.message ?? ''}`;
|
|
1646
|
+
if (err?.code === 'ENOENT' || isMissingBinarySignature(blob)) {
|
|
1647
|
+
const detail = String(err?.stderr || err?.message || '')
|
|
1648
|
+
.split('\n').map((s) => s.trim()).filter(Boolean)[0];
|
|
1649
|
+
return { ok: false, detail: detail || 'native binary missing (ENOENT)' };
|
|
1650
|
+
}
|
|
1651
|
+
// Launched but exited nonzero without a missing-file signature, or timed out
|
|
1652
|
+
// waiting for input: the binary is present and runnable. Healthy.
|
|
1653
|
+
return { ok: true };
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
/**
|
|
1657
|
+
* Launch-path self-heal. Given the concrete version `agents run` is about to
|
|
1658
|
+
* spawn, make sure it will actually run — and if not, repair it instead of
|
|
1659
|
+
* letting the agent die with a raw `ENOENT` deep inside its own wrapper.
|
|
1660
|
+
*
|
|
1661
|
+
* Steps, cheapest first:
|
|
1662
|
+
* 1. Probe the version (verifyInstalledBinaryLaunches). Healthy → return it.
|
|
1663
|
+
* 2. Broken → **clean** reinstall in place (wipes the partial node_modules so
|
|
1664
|
+
* npm actually re-fetches the platform binary). Re-probe; good → return it.
|
|
1665
|
+
* 3. Still broken → fall back to another INSTALLED version that launches,
|
|
1666
|
+
* re-pinning it as the default so the shim path heals too. Return it.
|
|
1667
|
+
* 4. Nothing runnable installed → install `latest`, pin it, return it.
|
|
1668
|
+
* 5. Give up → return null (caller surfaces a clear error).
|
|
1669
|
+
*
|
|
1670
|
+
* Gated to npm-package agents: their native binary ships as an optional per-arch
|
|
1671
|
+
* dependency whose tarball can extract partially (interrupted/raced install) —
|
|
1672
|
+
* the exact failure this repairs. Agents with a global/native binary (grok,
|
|
1673
|
+
* droid) have no such tarball and are returned unchanged.
|
|
1674
|
+
*/
|
|
1675
|
+
export async function ensureAgentRunnable(agent, version, log) {
|
|
1676
|
+
const cfg = AGENTS[agent];
|
|
1677
|
+
if (!cfg?.npmPackage)
|
|
1678
|
+
return version;
|
|
1679
|
+
if ((await verifyInstalledBinaryLaunches(agent, version)).ok)
|
|
1680
|
+
return version;
|
|
1681
|
+
log?.(`${cfg.name}@${version} is broken (platform binary missing) — repairing…`);
|
|
1682
|
+
const repair = await installVersion(agent, version, undefined, { clean: true });
|
|
1683
|
+
if (repair.success && (await verifyInstalledBinaryLaunches(agent, version)).ok) {
|
|
1684
|
+
log?.(`repaired ${cfg.name}@${version}.`);
|
|
1685
|
+
return version;
|
|
1686
|
+
}
|
|
1687
|
+
// In-place repair failed → adopt another installed version that launches.
|
|
1688
|
+
const others = listInstalledVersions(agent).filter(v => v !== version).sort(compareVersions).reverse();
|
|
1689
|
+
for (const cand of others) {
|
|
1690
|
+
if ((await verifyInstalledBinaryLaunches(agent, cand)).ok) {
|
|
1691
|
+
setGlobalDefault(agent, cand);
|
|
1692
|
+
log?.(`${cfg.name}@${version} could not be repaired — using ${cfg.name}@${cand} instead (now the default).`);
|
|
1693
|
+
return cand;
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
// Nothing runnable installed → last resort: install latest and pin it.
|
|
1697
|
+
log?.(`no runnable ${cfg.name} version installed — installing ${cfg.name}@latest…`);
|
|
1698
|
+
const latest = await installVersion(agent, 'latest', undefined, { clean: true });
|
|
1699
|
+
if (latest.success) {
|
|
1700
|
+
setGlobalDefault(agent, latest.installedVersion);
|
|
1701
|
+
log?.(`installed ${cfg.name}@${latest.installedVersion} and set it as the default.`);
|
|
1702
|
+
return latest.installedVersion;
|
|
1703
|
+
}
|
|
1704
|
+
return null;
|
|
1705
|
+
}
|
|
1569
1706
|
async function getCliVersionFromPath(agent) {
|
|
1570
1707
|
const agentConfig = AGENTS[agent];
|
|
1571
1708
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.44",
|
|
4
4
|
"description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|