@phnx-labs/agents-cli 1.20.43 → 1.20.45
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 +18 -0
- package/README.md +4 -3
- package/dist/commands/exec.js +84 -13
- package/dist/commands/hosts.js +5 -4
- 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/secrets.d.ts +18 -0
- package/dist/commands/secrets.js +105 -30
- package/dist/commands/sessions.d.ts +6 -5
- package/dist/commands/sessions.js +50 -22
- package/dist/commands/teams.js +104 -8
- package/dist/lib/daemon.js +34 -9
- package/dist/lib/exec.d.ts +8 -0
- package/dist/lib/exec.js +70 -6
- package/dist/lib/hosts/dispatch.d.ts +29 -0
- package/dist/lib/hosts/dispatch.js +46 -1
- package/dist/lib/hosts/logs.d.ts +14 -5
- package/dist/lib/hosts/logs.js +39 -13
- package/dist/lib/hosts/remote-cmd.d.ts +17 -0
- package/dist/lib/hosts/remote-cmd.js +27 -0
- package/dist/lib/hosts/session-index.d.ts +15 -0
- package/dist/lib/hosts/session-index.js +28 -2
- package/dist/lib/redact.js +1 -0
- package/dist/lib/rotate.d.ts +33 -0
- package/dist/lib/rotate.js +37 -0
- package/dist/lib/secrets/remote.d.ts +14 -0
- package/dist/lib/secrets/remote.js +18 -1
- package/dist/lib/session/active.d.ts +10 -0
- package/dist/lib/session/active.js +12 -1
- package/dist/lib/session/db.d.ts +16 -9
- package/dist/lib/session/db.js +66 -44
- package/dist/lib/session/discover.d.ts +4 -0
- package/dist/lib/session/discover.js +84 -13
- package/dist/lib/session/run-names.d.ts +9 -7
- package/dist/lib/session/run-names.js +9 -7
- package/dist/lib/session/state.d.ts +29 -3
- package/dist/lib/session/state.js +84 -5
- package/dist/lib/session/types.d.ts +19 -8
- package/dist/lib/shims.d.ts +1 -1
- package/dist/lib/shims.js +17 -3
- package/dist/lib/teams/agents.js +25 -7
- package/dist/lib/tmux/session.d.ts +40 -0
- package/dist/lib/tmux/session.js +92 -0
- package/dist/lib/versions.d.ts +54 -1
- package/dist/lib/versions.js +138 -1
- package/package.json +1 -1
|
@@ -37,6 +37,16 @@ const PR_URL_RE = /https:\/\/github\.com\/[^\s"'()<>]+\/pull\/(\d+)/;
|
|
|
37
37
|
const WORKTREE_RE = /\/\.agents\/worktrees\/([^/]+)/;
|
|
38
38
|
/** gh invocations that create/open a PR. */
|
|
39
39
|
const GH_PR_CREATE_RE = /\bgh\s+pr\s+(?:create|new)\b/;
|
|
40
|
+
/** gh invocation that opens an issue — the created number is read from its result. */
|
|
41
|
+
const GH_ISSUE_CREATE_RE = /\bgh\s+issue\s+create\b/;
|
|
42
|
+
/** A created GitHub issue URL (…/issues/123) in tool-result output. */
|
|
43
|
+
const GH_ISSUE_URL_RE = /https:\/\/github\.com\/[^\s"'()<>]+\/issues\/(\d+)/;
|
|
44
|
+
/**
|
|
45
|
+
* `agents teams create <name>` / `agents teams add <team> …` (also the `ag` alias).
|
|
46
|
+
* The team NAME is the first bareword after the sub-verb, skipping any flags. This
|
|
47
|
+
* is the structural signal that a session SPAWNED a team (vs. was spawned by one).
|
|
48
|
+
*/
|
|
49
|
+
const TEAMS_SPAWN_RE = /\bag(?:ents)?\s+teams?\s+(?:create|add)\s+(?:--?[a-z][\w-]*(?:[= ]\S+)?\s+)*([A-Za-z0-9][\w-]*)/;
|
|
40
50
|
/** Collapse to a single trimmed line for a one-row preview cell. */
|
|
41
51
|
function oneLine(s) {
|
|
42
52
|
return s.replace(/\s+/g, ' ').trim();
|
|
@@ -80,6 +90,46 @@ export function extractPrUrl(output) {
|
|
|
80
90
|
export function isPrCreateCommand(command) {
|
|
81
91
|
return !!command && GH_PR_CREATE_RE.test(command);
|
|
82
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* The team a session SPAWNED, from an `agents teams create/add <name>` command.
|
|
95
|
+
* Returns the team name, or undefined if the command isn't a team spawn. Note this
|
|
96
|
+
* is the opposite of `isTeamOrigin` (which marks sessions spawned BY a team).
|
|
97
|
+
*/
|
|
98
|
+
export function detectSpawnedTeam(command) {
|
|
99
|
+
if (!command)
|
|
100
|
+
return undefined;
|
|
101
|
+
const m = command.match(TEAMS_SPAWN_RE);
|
|
102
|
+
return m ? m[1] : undefined;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* True when a tool_use call CREATES a tracker ticket — a Linear MCP `create_issue`
|
|
106
|
+
* tool, or a Bash `gh issue create`. The created id is then read from the matching
|
|
107
|
+
* tool_result via {@link extractCreatedTicket}.
|
|
108
|
+
*/
|
|
109
|
+
export function isTicketCreateTool(name, command) {
|
|
110
|
+
if (typeof name === 'string' && /linear/i.test(name) && /create[_-]?issue/i.test(name))
|
|
111
|
+
return true;
|
|
112
|
+
// Any shell tool (Bash / shell / local_shell) running `gh issue create`.
|
|
113
|
+
if (!!command && GH_ISSUE_CREATE_RE.test(command))
|
|
114
|
+
return true;
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Pull a created ticket ref out of a create-issue tool_result. Linear returns a
|
|
119
|
+
* key like `RUSH-1234`; `gh issue create` returns the issue URL, from which we
|
|
120
|
+
* take `#<number>`. Returns undefined when neither shape is present.
|
|
121
|
+
*/
|
|
122
|
+
export function extractCreatedTicket(text) {
|
|
123
|
+
if (!text)
|
|
124
|
+
return undefined;
|
|
125
|
+
const lin = text.match(TICKET_RE);
|
|
126
|
+
if (lin && !TICKET_DENYLIST.has(lin[1].split('-')[0]))
|
|
127
|
+
return lin[1];
|
|
128
|
+
const gh = text.match(GH_ISSUE_URL_RE);
|
|
129
|
+
if (gh)
|
|
130
|
+
return `#${gh[1]}`;
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
83
133
|
/** Does an assistant message read as a question directed at the user? */
|
|
84
134
|
function looksLikeQuestion(text) {
|
|
85
135
|
const t = text.trim();
|
|
@@ -181,14 +231,19 @@ export function inferActivity(events, ctx = {}) {
|
|
|
181
231
|
return base;
|
|
182
232
|
}
|
|
183
233
|
/**
|
|
184
|
-
* Scan an event slice for the durable signals
|
|
185
|
-
*
|
|
186
|
-
*
|
|
234
|
+
* Scan an event slice for the durable signals that aren't about the cwd: the PR
|
|
235
|
+
* opened, the injected ticket, plus the artifacts the session PRODUCED — tracker
|
|
236
|
+
* refs it created and any team it spawned. Each `gh pr create` / create-issue tool
|
|
237
|
+
* call is correlated with the nearest following tool_result; the team name comes
|
|
238
|
+
* straight off the `agents teams create/add` command.
|
|
187
239
|
*/
|
|
188
240
|
export function detectDurableSignals(events) {
|
|
189
241
|
let pr;
|
|
190
242
|
let sawPrCreate = false;
|
|
191
243
|
let ticket;
|
|
244
|
+
let sawTicketCreate = false;
|
|
245
|
+
let spawnedTeam;
|
|
246
|
+
const createdTickets = new Set();
|
|
192
247
|
for (const e of events) {
|
|
193
248
|
// Structural PR signal: a real `gh pr create` tool call, then the pull URL
|
|
194
249
|
// from a following tool_result — never a bare URL mentioned in prose.
|
|
@@ -201,21 +256,45 @@ export function detectDurableSignals(events) {
|
|
|
201
256
|
sawPrCreate = false;
|
|
202
257
|
}
|
|
203
258
|
}
|
|
259
|
+
// Produced artifacts: a team spawn is read off the command; a created ticket
|
|
260
|
+
// is a create-issue tool call whose following tool_result carries the new ref.
|
|
261
|
+
if (e.type === 'tool_use') {
|
|
262
|
+
if (!spawnedTeam) {
|
|
263
|
+
const team = detectSpawnedTeam(e.command);
|
|
264
|
+
if (team)
|
|
265
|
+
spawnedTeam = team;
|
|
266
|
+
}
|
|
267
|
+
if (isTicketCreateTool(e.tool, e.command))
|
|
268
|
+
sawTicketCreate = true;
|
|
269
|
+
}
|
|
270
|
+
if (sawTicketCreate && e.type === 'tool_result') {
|
|
271
|
+
const t = extractCreatedTicket(e.output);
|
|
272
|
+
if (t)
|
|
273
|
+
createdTickets.add(t);
|
|
274
|
+
sawTicketCreate = false;
|
|
275
|
+
}
|
|
204
276
|
if (!ticket && e.type === 'message' && e.role === 'user') {
|
|
205
277
|
ticket = detectTicket(e.content);
|
|
206
278
|
}
|
|
207
279
|
}
|
|
208
|
-
return {
|
|
280
|
+
return {
|
|
281
|
+
pr,
|
|
282
|
+
ticket,
|
|
283
|
+
createdTickets: createdTickets.size > 0 ? [...createdTickets] : undefined,
|
|
284
|
+
spawnedTeam,
|
|
285
|
+
};
|
|
209
286
|
}
|
|
210
287
|
/** Full inference: activity + preview + durable signals + worktree/ticket from ctx. */
|
|
211
288
|
export function inferSessionState(events, ctx = {}) {
|
|
212
289
|
const state = inferActivity(events, ctx);
|
|
213
|
-
const { pr, ticket } = detectDurableSignals(events);
|
|
290
|
+
const { pr, ticket, createdTickets, spawnedTeam } = detectDurableSignals(events);
|
|
214
291
|
const worktree = detectWorktree(ctx.cwd, ctx.gitBranch);
|
|
215
292
|
return {
|
|
216
293
|
...state,
|
|
217
294
|
pr: pr ?? state.pr,
|
|
218
295
|
worktree: worktree ?? state.worktree,
|
|
219
296
|
ticket: ticket ?? detectTicket(undefined, ctx.gitBranch) ?? state.ticket,
|
|
297
|
+
createdTickets,
|
|
298
|
+
spawnedTeam,
|
|
220
299
|
};
|
|
221
300
|
}
|
|
@@ -65,16 +65,16 @@ export interface SessionMeta {
|
|
|
65
65
|
version?: string;
|
|
66
66
|
account?: string;
|
|
67
67
|
topic?: string;
|
|
68
|
-
/** Custom name the user gave the session (e.g. Claude Code /rename). */
|
|
69
|
-
label?: string;
|
|
70
68
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* `agents
|
|
74
|
-
*
|
|
75
|
-
*
|
|
69
|
+
* The session's human-readable name — one field, several sources with a plain
|
|
70
|
+
* priority: an agent-generated title / Claude `/rename` wins; else the launch
|
|
71
|
+
* handle seeded from `agents run --name <slug>` (interactive, headless, remote
|
|
72
|
+
* host, or a teams teammate); else it stays unset and the listing falls back to
|
|
73
|
+
* `topic`. Searchable via `agents sessions <label>`. (Before v10 the launch
|
|
74
|
+
* handle lived in a separate immutable `name` column; `--name` now just seeds
|
|
75
|
+
* this label.)
|
|
76
76
|
*/
|
|
77
|
-
|
|
77
|
+
label?: string;
|
|
78
78
|
/** Set when this session was spawned by `agents teams`. */
|
|
79
79
|
teamOrigin?: TeamOrigin;
|
|
80
80
|
/** Durable state signals extracted at scan time by the session-state engine. */
|
|
@@ -86,6 +86,17 @@ export interface SessionMeta {
|
|
|
86
86
|
worktreeSlug?: string;
|
|
87
87
|
/** Tracker ticket ref (e.g. RUSH-1234) from the prompt or branch. */
|
|
88
88
|
ticketId?: string;
|
|
89
|
+
/**
|
|
90
|
+
* Tracker refs the session CREATED during its run — Linear `create_issue` MCP
|
|
91
|
+
* calls or `gh issue create` shell commands — read from the tool result. Distinct
|
|
92
|
+
* from `ticketId` (the injected/worked-on ticket from the prompt or branch).
|
|
93
|
+
*/
|
|
94
|
+
createdTickets?: string[];
|
|
95
|
+
/**
|
|
96
|
+
* Team name this session SPAWNED via `agents teams create/add`. The inverse of
|
|
97
|
+
* `isTeamOrigin` (which marks sessions spawned BY a team).
|
|
98
|
+
*/
|
|
99
|
+
spawnedTeam?: string;
|
|
89
100
|
/**
|
|
90
101
|
* True when the session was spawned programmatically (SDK entrypoint) rather
|
|
91
102
|
* than by a human at the Claude CLI. Captured at scan time from the JSONL
|
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,7 +20,9 @@ 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';
|
|
25
|
+
import { recordRunName } from '../session/run-names.js';
|
|
24
26
|
let lastMemoryWarnAt = 0;
|
|
25
27
|
// On macOS, os.freemem() returns only the truly-free pool and ignores the
|
|
26
28
|
// large inactive+purgeable cache the kernel will reclaim under pressure, so
|
|
@@ -326,18 +328,26 @@ export async function ensureGeminiPlanMode() {
|
|
|
326
328
|
* (for CLIs the user installed outside agents-cli).
|
|
327
329
|
*/
|
|
328
330
|
export function checkCliAvailable(agentType) {
|
|
329
|
-
const
|
|
331
|
+
const agent = agentType;
|
|
332
|
+
const executable = AGENTS[agent]?.cliCommand;
|
|
330
333
|
if (!executable) {
|
|
331
334
|
return [false, `Unknown agent type: ${agentType}`];
|
|
332
335
|
}
|
|
333
336
|
const shimPath = path.join(getShimsDir(), executable);
|
|
334
|
-
|
|
335
|
-
|
|
337
|
+
const dispatch = fsSync.existsSync(shimPath) ? shimPath : findExecutable(executable);
|
|
338
|
+
if (!dispatch) {
|
|
339
|
+
return [false, `CLI tool '${executable}' not found in PATH. Install it first.`];
|
|
336
340
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
+
// A shim file (or a PATH entry) existing does NOT mean the agent is runnable:
|
|
342
|
+
// the managed default version's binary can be a stub or gutted (a partial/raced
|
|
343
|
+
// npm extract leaves the version dir + JS wrapper but no real binary). Verify
|
|
344
|
+
// the resolved default version is actually installed so `teams doctor` reports
|
|
345
|
+
// the truth instead of a false `installed: true` that ENOENTs at spawn.
|
|
346
|
+
const version = resolveVersion(agent);
|
|
347
|
+
if (version && !isVersionInstalled(agent, version)) {
|
|
348
|
+
return [false, `${executable}@${version} is not runnable — its binary is missing/incomplete. Repair: agents add ${agent}@${version}`];
|
|
349
|
+
}
|
|
350
|
+
return [true, dispatch];
|
|
341
351
|
}
|
|
342
352
|
/** Check availability of all known agent CLIs. Returns a map of agent type to install status. */
|
|
343
353
|
export function checkAllClis() {
|
|
@@ -1058,6 +1068,14 @@ export class AgentManager {
|
|
|
1058
1068
|
throw new Error(`Failed to create agent directory: ${err.message}`);
|
|
1059
1069
|
}
|
|
1060
1070
|
this.agents.set(agentId, agent);
|
|
1071
|
+
// Seed the teammate's session label with its friendly team name, so the run
|
|
1072
|
+
// shows up as `<name>` in `agents sessions` and resolves by it — consistent
|
|
1073
|
+
// with `agents run --name`. For Claude the agent id IS the session id (passed
|
|
1074
|
+
// via --session-id in buildCommand); other agents don't expose a launch-time
|
|
1075
|
+
// id, so they're seeded once discovery captures one. Best-effort.
|
|
1076
|
+
if (agentType === 'claude' && name && !isCloudBacked) {
|
|
1077
|
+
recordRunName({ sessionId: agentId, name, agent: agentType, cwd: resolvedCwd ?? undefined });
|
|
1078
|
+
}
|
|
1061
1079
|
if (isStaged) {
|
|
1062
1080
|
await agent.saveMeta();
|
|
1063
1081
|
debug(`Staged ${agentType} teammate '${name}' in team '${taskName}' (after: ${cleanAfter.join(', ')})`);
|
|
@@ -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/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;
|