@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +4 -3
  3. package/dist/commands/exec.js +84 -13
  4. package/dist/commands/hosts.js +5 -4
  5. package/dist/commands/logs.d.ts +4 -0
  6. package/dist/commands/logs.js +19 -13
  7. package/dist/commands/routines.d.ts +6 -0
  8. package/dist/commands/routines.js +70 -12
  9. package/dist/commands/secrets.d.ts +18 -0
  10. package/dist/commands/secrets.js +105 -30
  11. package/dist/commands/sessions.d.ts +6 -5
  12. package/dist/commands/sessions.js +50 -22
  13. package/dist/commands/teams.js +104 -8
  14. package/dist/lib/daemon.js +34 -9
  15. package/dist/lib/exec.d.ts +8 -0
  16. package/dist/lib/exec.js +70 -6
  17. package/dist/lib/hosts/dispatch.d.ts +29 -0
  18. package/dist/lib/hosts/dispatch.js +46 -1
  19. package/dist/lib/hosts/logs.d.ts +14 -5
  20. package/dist/lib/hosts/logs.js +39 -13
  21. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  22. package/dist/lib/hosts/remote-cmd.js +27 -0
  23. package/dist/lib/hosts/session-index.d.ts +15 -0
  24. package/dist/lib/hosts/session-index.js +28 -2
  25. package/dist/lib/redact.js +1 -0
  26. package/dist/lib/rotate.d.ts +33 -0
  27. package/dist/lib/rotate.js +37 -0
  28. package/dist/lib/secrets/remote.d.ts +14 -0
  29. package/dist/lib/secrets/remote.js +18 -1
  30. package/dist/lib/session/active.d.ts +10 -0
  31. package/dist/lib/session/active.js +12 -1
  32. package/dist/lib/session/db.d.ts +16 -9
  33. package/dist/lib/session/db.js +66 -44
  34. package/dist/lib/session/discover.d.ts +4 -0
  35. package/dist/lib/session/discover.js +84 -13
  36. package/dist/lib/session/run-names.d.ts +9 -7
  37. package/dist/lib/session/run-names.js +9 -7
  38. package/dist/lib/session/state.d.ts +29 -3
  39. package/dist/lib/session/state.js +84 -5
  40. package/dist/lib/session/types.d.ts +19 -8
  41. package/dist/lib/shims.d.ts +1 -1
  42. package/dist/lib/shims.js +17 -3
  43. package/dist/lib/teams/agents.js +25 -7
  44. package/dist/lib/tmux/session.d.ts +40 -0
  45. package/dist/lib/tmux/session.js +92 -0
  46. package/dist/lib/versions.d.ts +54 -1
  47. package/dist/lib/versions.js +138 -1
  48. 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 (PR opened, ticket) that aren't
185
- * about the cwd. Correlates each `gh pr create` with the nearest following
186
- * tool_result URL; keeps the last PR found.
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 { pr, ticket };
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
- * 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`.
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
- name?: string;
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
@@ -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 = 24;
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
- export const SHIM_SCHEMA_VERSION = 24;
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
- echo "agents: agents-cli entrypoint missing or not executable: $AGENTS_BIN" >&2
295
- exit 127
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
@@ -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 executable = AGENTS[agentType]?.cliCommand;
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
- if (fsSync.existsSync(shimPath)) {
335
- return [true, shimPath];
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
- const resolved = findExecutable(executable);
338
- return resolved
339
- ? [true, resolved]
340
- : [false, `CLI tool '${executable}' not found in PATH. Install it first.`];
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)
@@ -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)
@@ -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): Promise<{
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;