@phnx-labs/agents-cli 1.20.43 → 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.
@@ -19,6 +19,7 @@ import { detectOverdueJobs, notifyOverdue } from './overdue.js';
19
19
  import { BrowserService } from './browser/service.js';
20
20
  import { BrowserIPCServer } from './browser/ipc.js';
21
21
  import { readAndResolveBundleEnv } from './secrets/bundles.js';
22
+ import { redactSecrets } from './redact.js';
22
23
  const PID_FILE = 'daemon.pid';
23
24
  const LOCK_FILE = 'daemon.lock';
24
25
  const LOG_FILE = 'logs.jsonl';
@@ -207,15 +208,6 @@ export function reapStrayDaemons(keepPid = process.pid) {
207
208
  }
208
209
  return { reaped, details };
209
210
  }
210
- /** Redact values that look like tokens or credentials in a log message. */
211
- function redactSecrets(message) {
212
- let safe = message;
213
- safe = safe.replace(/eyJ[A-Za-z0-9_-]{20,}/g, '[REDACTED_TOKEN]');
214
- safe = safe.replace(/Bearer\s+\S+/gi, 'Bearer [REDACTED]');
215
- safe = safe.replace(/(sk-[a-zA-Z0-9]{20,})/g, '[REDACTED_KEY]');
216
- safe = safe.replace(/(ANTHROPIC_API_KEY|OPENAI_API_KEY|API_KEY|SECRET|TOKEN|PASSWORD)=\S+/gi, '$1=[REDACTED]');
217
- return safe;
218
- }
219
211
  function rotateLogsIfNeeded(logPath) {
220
212
  try {
221
213
  const stat = fs.statSync(logPath);
@@ -428,6 +420,37 @@ export async function runDaemon() {
428
420
  };
429
421
  const deviceProbeInterval = setInterval(() => { void runDeviceProbe(); }, 3 * 60_000);
430
422
  const deviceProbeKickoff = setTimeout(() => { void runDeviceProbe(); }, 15_000);
423
+ // tmux hook reconcile: retrofit the guarded `pane-died` hook onto managed
424
+ // `agents run` sessions a pre-fix binary left with the old unconditional hook
425
+ // (which detached the whole client — kicking the user out of the view — when
426
+ // they exited a split they'd opened). Non-destructive: set-hook only, never a
427
+ // kill or detach. A per-session schema marker makes steady-state a no-op, so
428
+ // this stays cheap at ~every 5 min, plus once ~20s after startup so a
429
+ // just-upgraded daemon heals still-running sessions without waiting for them to
430
+ // cycle or the shared server to be recycled.
431
+ let reconcilingTmux = false;
432
+ const runTmuxReconcile = async () => {
433
+ if (reconcilingTmux)
434
+ return;
435
+ reconcilingTmux = true;
436
+ try {
437
+ const { isTmuxInstalled } = await import('./tmux/binary.js');
438
+ if (!isTmuxInstalled())
439
+ return;
440
+ const { reconcileSessionHooks } = await import('./tmux/session.js');
441
+ const r = await reconcileSessionHooks();
442
+ if (r.reconciled > 0)
443
+ log('INFO', `tmux: retrofitted pane-died hook on ${r.reconciled} session(s)`);
444
+ }
445
+ catch (err) {
446
+ log('ERROR', `tmux reconcile failed: ${err.message}`);
447
+ }
448
+ finally {
449
+ reconcilingTmux = false;
450
+ }
451
+ };
452
+ const tmuxReconcileInterval = setInterval(() => { void runTmuxReconcile(); }, 5 * 60_000);
453
+ const tmuxReconcileKickoff = setTimeout(() => { void runTmuxReconcile(); }, 20_000);
431
454
  const handleReload = () => {
432
455
  log('INFO', 'Reloading jobs (SIGHUP)');
433
456
  scheduler.reloadAll();
@@ -447,6 +470,8 @@ export async function runDaemon() {
447
470
  clearTimeout(healKickoff);
448
471
  clearInterval(deviceProbeInterval);
449
472
  clearTimeout(deviceProbeKickoff);
473
+ clearInterval(tmuxReconcileInterval);
474
+ clearTimeout(tmuxReconcileKickoff);
450
475
  removeDaemonPid();
451
476
  process.exit(0);
452
477
  };
@@ -272,6 +272,14 @@ export declare function shouldWrapInTmux(ctx: TmuxWrapContext): boolean;
272
272
  * (`BASH_FUNC_*%%`) can't make `env` choke.
273
273
  */
274
274
  export declare function buildTmuxAgentCommand(executable: string, args: string[], env: NodeJS.ProcessEnv): string;
275
+ /**
276
+ * Trim a raw `tmux capture-pane` dump to its last `maxLines` non-empty lines
277
+ * (right-stripping each). Used by runInTmux to recap a fast-failed agent's
278
+ * output into the caller's shell so a launch crash (e.g. a gutted install that
279
+ * dies with ENOENT the instant it spawns) isn't swallowed by the bare
280
+ * `[detached]` the pane-died hook otherwise leaves behind.
281
+ */
282
+ export declare function formatPaneTail(raw: string, maxLines?: number): string;
275
283
  /** Exit code spawnAgent resolves with when a run is killed for crossing a budget cap. */
276
284
  export declare const BUDGET_KILL_EXIT_CODE = 7;
277
285
  /**
package/dist/lib/exec.js CHANGED
@@ -515,7 +515,13 @@ export function buildExecCommand(options) {
515
515
  cmd[0] = absPath;
516
516
  }
517
517
  else {
518
- cmd[0] = versionedName;
518
+ // No versioned shim on disk. Prefer the version's REAL launch binary
519
+ // (node_modules/.bin/<cli>) over the bare `<cli>@<version>` name — that
520
+ // literal is not on PATH and spawns as ENOENT (the `kimi@0.19.2` failure).
521
+ // Fall back to the literal only if the binary is absent (the run path's
522
+ // ensureAgentRunnable normally repairs/creates the alias before we reach here).
523
+ const realBinary = options.agent ? getBinaryPath(options.agent, options.version) : undefined;
524
+ cmd[0] = realBinary && fs.existsSync(realBinary) ? realBinary : versionedName;
519
525
  }
520
526
  }
521
527
  // Add reasoning effort flags (before mode flags for codex -c positioning)
@@ -814,6 +820,21 @@ export function buildTmuxAgentCommand(executable, args, env) {
814
820
  const agentCmd = [executable, ...args].map(shellQuote).join(' ');
815
821
  return `exec env ${envPrefix} ${agentCmd}`;
816
822
  }
823
+ /**
824
+ * Trim a raw `tmux capture-pane` dump to its last `maxLines` non-empty lines
825
+ * (right-stripping each). Used by runInTmux to recap a fast-failed agent's
826
+ * output into the caller's shell so a launch crash (e.g. a gutted install that
827
+ * dies with ENOENT the instant it spawns) isn't swallowed by the bare
828
+ * `[detached]` the pane-died hook otherwise leaves behind.
829
+ */
830
+ export function formatPaneTail(raw, maxLines = 30) {
831
+ return raw
832
+ .split('\n')
833
+ .map(l => l.replace(/\s+$/, ''))
834
+ .filter(l => l.length > 0)
835
+ .slice(-maxLines)
836
+ .join('\n');
837
+ }
817
838
  /**
818
839
  * Run an interactive agent inside a detached tmux session on the shared socket,
819
840
  * attach the current TTY, and propagate the wrapped agent's exit code.
@@ -834,7 +855,7 @@ export function buildTmuxAgentCommand(executable, args, env) {
834
855
  * (Ctrl-b d) — return 0 and LEAVE the session for `agents focus` to re-attach.
835
856
  */
836
857
  async function runInTmux(options, executable, args) {
837
- const { createSession, killSession, paneExitStatus, setSessionHook, slugifyName } = await import('./tmux/session.js');
858
+ const { createSession, killSession, paneExitStatus, setSessionHook, slugifyName, agentPaneDiedHook, markSessionHookSchema } = await import('./tmux/session.js');
838
859
  const { getDefaultSocketPath } = await import('./tmux/paths.js');
839
860
  const { attachTmux, runTmux } = await import('./tmux/binary.js');
840
861
  const socket = getDefaultSocketPath();
@@ -855,7 +876,10 @@ async function runInTmux(options, executable, args) {
855
876
  // that split in place instead of detaching everyone (the pane-died hook runs
856
877
  // in the dead pane's context, so bare `kill-pane` targets it). Without the
857
878
  // guard, exiting any split kicked the user clean out of tmux.
858
- await setSessionHook(name, 'pane-died', `if -F '#{==:#{hook_pane},${pane}}' 'detach-client -s =${name}' 'kill-pane'`, socket);
879
+ await setSessionHook(name, 'pane-died', agentPaneDiedHook(name, pane), socket);
880
+ // Stamp the schema marker so the daemon reconcile (which retrofits older
881
+ // sessions) recognizes this one as already current and skips it.
882
+ await markSessionHookSchema(name, socket);
859
883
  // Record the agent's OS pid (the pane leaf, thanks to `exec`) WITH its tmux
860
884
  // pane so the active-scan attributes it exactly and shows the %pane.
861
885
  let panePid = 0;
@@ -873,14 +897,54 @@ async function runInTmux(options, executable, args) {
873
897
  startedAtMs: Date.now(),
874
898
  });
875
899
  }
900
+ // Recap a dead pane's tail into THIS shell's stderr. The pane-died hook
901
+ // detaches the client the instant the agent exits, so a fast failure (a
902
+ // gutted install that dies with ENOENT, a bad flag, a crash on startup) would
903
+ // otherwise leave only a bare `[detached]` with no clue why. Must run BEFORE
904
+ // killSession — capture-pane needs the session still alive (remain-on-exit
905
+ // keeps the dead pane readable until we tear it down). Best-effort throughout.
906
+ const surfacePaneFailure = async (status, headline) => {
907
+ if (!pane)
908
+ return;
909
+ let tail = '';
910
+ try {
911
+ const r = await runTmux({ socket, args: ['capture-pane', '-p', '-t', pane, '-S', '-200'], throwOnError: false });
912
+ if (r.code === 0)
913
+ tail = formatPaneTail(r.stdout);
914
+ }
915
+ catch { /* best-effort — a missing pane just means no recap */ }
916
+ const RED = '\x1b[31m', GRAY = '\x1b[90m', OFF = '\x1b[0m';
917
+ process.stderr.write(`\n${RED}agents: ${headline} (exit ${status ?? 1}).${OFF}\n`);
918
+ if (tail) {
919
+ process.stderr.write(`${GRAY} ── last output from ${options.agent} ──${OFF}\n`);
920
+ process.stderr.write(tail.replace(/^/gm, ' ') + '\n');
921
+ process.stderr.write(`${GRAY} ${'─'.repeat(30)}${OFF}\n`);
922
+ }
923
+ process.stderr.write(`${GRAY} Tip: re-run with --no-tmux to launch the agent directly and see its full output.${OFF}\n\n`);
924
+ };
876
925
  // The agent could exit before we attach (fast failure). Don't attach to an
877
- // already-dead pane — read its status directly and tear down.
926
+ // already-dead pane — surface its output + status directly and tear down.
878
927
  const before = pane ? await paneExitStatus(pane, socket) : { dead: false };
879
- if (!before.dead) {
880
- await attachTmux({ socket, args: ['attach-session', '-t', name] });
928
+ if (before.dead) {
929
+ // Only recap a FAILURE. A clean (0) exit before we attached is a successful
930
+ // quick run, not a crash — a red banner there would be spurious (mirrors the
931
+ // post-attach guard below).
932
+ if ((before.status ?? 0) !== 0) {
933
+ await surfacePaneFailure(before.status, `${options.agent} exited before it could start`);
934
+ }
935
+ await killSession(name, socket).catch(() => { });
936
+ return { exitCode: before.status ?? 0, stderr: '' };
881
937
  }
938
+ await attachTmux({ socket, args: ['attach-session', '-t', name] });
882
939
  const after = pane ? await paneExitStatus(pane, socket) : { dead: false };
883
940
  if (after.dead) {
941
+ // Nonzero exit after attach → the agent crashed rather than the user
942
+ // detaching cleanly (a clean detach leaves the pane ALIVE, handled below).
943
+ // The pane-died hook may have yanked the view before the error was readable,
944
+ // so recap it into the shell. A clean (0) exit stays quiet — nothing to say.
945
+ if ((after.status ?? 0) !== 0) {
946
+ await surfacePaneFailure(after.status, `${options.agent} exited`);
947
+ }
884
948
  await killSession(name, socket).catch(() => { });
885
949
  return { exitCode: after.status ?? 0, stderr: '' };
886
950
  }
@@ -2,9 +2,13 @@
2
2
  * Shared host-task log viewer — the show-or-follow core behind both
3
3
  * `agents hosts logs <id>` and the top-level `agents logs <id>`.
4
4
  *
5
- * A running task with follow re-enters the offset-tail (`followHostTask`);
6
- * otherwise the captured local mirror (`localLogPath`) is printed. Kept in one
7
- * place so the two commands can never drift.
5
+ * A running task with follow re-enters the offset-tail (`followHostTask`).
6
+ * Otherwise the view is **concise by default**: a bounded tail of the captured
7
+ * combined-stdout, so an agent glancing at a dispatched run never pulls the whole
8
+ * log. `full` opts into the entire raw log. (A host run's real transcript lives
9
+ * on the remote, not the local index — surfacing its rich summary needs remote
10
+ * runs to be discoverable there first; until then the bounded tail is the safe
11
+ * concise default.) Kept in one place so the two commands can never drift.
8
12
  */
9
13
  export interface HostLogResult {
10
14
  /** False when no host task with this id exists (caller may fall through to sessions). */
@@ -12,5 +16,10 @@ export interface HostLogResult {
12
16
  /** Process exit code to adopt when the task was shown/followed. */
13
17
  exitCode?: number;
14
18
  }
15
- /** Show (or follow, when running) a dispatched host task's combined-stdout log. */
16
- export declare function showHostTaskLog(id: string, follow: boolean): Promise<HostLogResult>;
19
+ /**
20
+ * Show (or follow, when running) a dispatched host task. Bounded-tail summary by
21
+ * default; `full` dumps the entire raw combined-stdout log.
22
+ */
23
+ export declare function showHostTaskLog(id: string, follow: boolean, full?: boolean): Promise<HostLogResult>;
24
+ /** Last `n` lines of `text`, prefixed with an elision note when truncated. */
25
+ export declare function tailLines(text: string, n: number): string;
@@ -2,9 +2,13 @@
2
2
  * Shared host-task log viewer — the show-or-follow core behind both
3
3
  * `agents hosts logs <id>` and the top-level `agents logs <id>`.
4
4
  *
5
- * A running task with follow re-enters the offset-tail (`followHostTask`);
6
- * otherwise the captured local mirror (`localLogPath`) is printed. Kept in one
7
- * place so the two commands can never drift.
5
+ * A running task with follow re-enters the offset-tail (`followHostTask`).
6
+ * Otherwise the view is **concise by default**: a bounded tail of the captured
7
+ * combined-stdout, so an agent glancing at a dispatched run never pulls the whole
8
+ * log. `full` opts into the entire raw log. (A host run's real transcript lives
9
+ * on the remote, not the local index — surfacing its rich summary needs remote
10
+ * runs to be discoverable there first; until then the bounded tail is the safe
11
+ * concise default.) Kept in one place so the two commands can never drift.
8
12
  */
9
13
  import * as fs from 'fs';
10
14
  import chalk from 'chalk';
@@ -12,8 +16,13 @@ import { loadTask, localLogPath, updateTask, terminalPatch } from './tasks.js';
12
16
  import { followHostTask } from './progress.js';
13
17
  import { reconcileTask } from './reconcile.js';
14
18
  import { sshExecRaw } from '../ssh-exec.js';
15
- /** Show (or follow, when running) a dispatched host task's combined-stdout log. */
16
- export async function showHostTaskLog(id, follow) {
19
+ /** Lines of raw combined-stdout to show in the concise (non-`full`) view. */
20
+ const HOST_LOG_TAIL_LINES = 40;
21
+ /**
22
+ * Show (or follow, when running) a dispatched host task. Bounded-tail summary by
23
+ * default; `full` dumps the entire raw combined-stdout log.
24
+ */
25
+ export async function showHostTaskLog(id, follow, full = false) {
17
26
  const task = loadTask(id);
18
27
  if (!task)
19
28
  return { found: false };
@@ -36,21 +45,38 @@ export async function showHostTaskLog(id, follow) {
36
45
  // plain `logs <id>` also unsticks a task whose follower was killed. No-op (no
37
46
  // ssh) once the record is already terminal.
38
47
  reconcileTask(task);
48
+ // Raw combined-stdout: the whole log with `full`, else a bounded tail.
49
+ const raw = readTaskLog(task);
50
+ if (raw === null) {
51
+ process.stdout.write(chalk.gray('(no local log captured for this task)\n'));
52
+ return { found: true, exitCode: 0 };
53
+ }
54
+ process.stdout.write(full ? raw : tailLines(raw, HOST_LOG_TAIL_LINES));
55
+ return { found: true, exitCode: 0 };
56
+ }
57
+ /** Read the task's combined-stdout — local mirror first, else fetch+cache remote. */
58
+ function readTaskLog(task) {
39
59
  try {
40
- process.stdout.write(fs.readFileSync(localLogPath(id), 'utf-8'));
60
+ return fs.readFileSync(localLogPath(task.id), 'utf-8');
41
61
  }
42
62
  catch {
43
63
  // No local log — task was dispatched with --no-follow. Fetch from the remote
44
64
  // on demand and cache locally so subsequent calls are instant.
45
65
  const remote = fetchAndCacheRemoteLog(task);
46
- if (remote !== null) {
47
- process.stdout.write(remote);
48
- }
49
- else {
50
- process.stdout.write(chalk.gray('(no local log captured for this task)\n'));
51
- }
66
+ return remote !== null ? remote.toString('utf-8') : null;
52
67
  }
53
- return { found: true, exitCode: 0 };
68
+ }
69
+ /** Last `n` lines of `text`, prefixed with an elision note when truncated. */
70
+ export function tailLines(text, n) {
71
+ const lines = text.split('\n');
72
+ // A trailing newline yields a final empty element — drop it from the count.
73
+ if (lines.length > 0 && lines[lines.length - 1] === '')
74
+ lines.pop();
75
+ if (lines.length <= n)
76
+ return lines.join('\n') + '\n';
77
+ const hidden = lines.length - n;
78
+ const note = chalk.gray(`… ${hidden} earlier line${hidden === 1 ? '' : 's'} hidden — pass --full for the whole log\n`);
79
+ return note + lines.slice(-n).join('\n') + '\n';
54
80
  }
55
81
  /**
56
82
  * Fetch a task's remote log over SSH, write it to the local mirror path (for
@@ -7,6 +7,7 @@ const SECRET_PATTERNS = [
7
7
  [/\bsk-[A-Za-z0-9]{20,}\b/g, '[REDACTED_API_KEY]'],
8
8
  [/\bnpm_[A-Za-z0-9]{36}\b/g, '[REDACTED_NPM_TOKEN]'],
9
9
  [/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[REDACTED_JWT]'],
10
+ [/Bearer\s+\S+/gi, 'Bearer [REDACTED]'],
10
11
  [/\b([A-Z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD)[A-Z0-9_]*)=("[^"]*"|'[^']*'|\S+)/gi, '$1=[REDACTED]'],
11
12
  ];
12
13
  export function redactSecrets(text) {
@@ -102,6 +102,12 @@ export interface ActiveQueryOptions {
102
102
  /** Skip the `ps` scan for ad-hoc headless agents. */
103
103
  skipHeadless?: boolean;
104
104
  }
105
+ /**
106
+ * Resolve an agent kind from a process's reported executable. `comm` may be an
107
+ * absolute path (shim-launched agents), and Windows image names carry an
108
+ * `.exe` suffix (`claude.exe`), so basename + suffix-strip before the lookup.
109
+ */
110
+ export declare function agentKindFromComm(commRaw: string): string | undefined;
105
111
  /**
106
112
  * Pick a Claude transcript file within a project dir.
107
113
  *
@@ -64,7 +64,16 @@ const AGENT_CLI_NAMES = {
64
64
  * absolute path (shim-launched agents), and Windows image names carry an
65
65
  * `.exe` suffix (`claude.exe`), so basename + suffix-strip before the lookup.
66
66
  */
67
- function agentKindFromComm(commRaw) {
67
+ export function agentKindFromComm(commRaw) {
68
+ // A GUI desktop app can bundle a binary with the SAME name as an agent CLI: the
69
+ // Codex desktop app ships `/Applications/Codex.app/Contents/Resources/codex` (its
70
+ // `app-server`), whose basename `codex` would otherwise match the codex CLI and
71
+ // surface the app's background server as a phantom agent session — running at cwd
72
+ // '/', so it shows up unattributed in the feed. A real agent CLI is never inside a
73
+ // `.app` bundle, so exclude those. (The Claude desktop app is a separate case,
74
+ // already excluded by name below: its process is 'Claude', not the CLI's 'claude'.)
75
+ if (commRaw.includes('.app/Contents/'))
76
+ return undefined;
68
77
  const base = path.basename(commRaw);
69
78
  const stripped = base.replace(/\.exe$/i, '');
70
79
  // Windows image names compare case-insensitively; POSIX comms stay exact —
@@ -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,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 executable = AGENTS[agentType]?.cliCommand;
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
- if (fsSync.existsSync(shimPath)) {
335
- return [true, shimPath];
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
- const resolved = findExecutable(executable);
338
- return resolved
339
- ? [true, resolved]
340
- : [false, `CLI tool '${executable}' not found in PATH. Install it first.`];
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)
@@ -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)