@phnx-labs/agents-cli 1.20.37 → 1.20.38

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.
@@ -70,4 +70,14 @@ export declare function resolveTargetPidDecision(client: ComputerClient, opts: {
70
70
  ok: false;
71
71
  error: string;
72
72
  }>;
73
+ export declare function focusStealNotes(opts: {
74
+ id?: string;
75
+ x?: number;
76
+ y?: number;
77
+ raise?: boolean;
78
+ }): string[];
79
+ export declare function shouldRaise(opts: {
80
+ id?: string;
81
+ raise?: boolean;
82
+ }): boolean;
73
83
  export declare function registerActionCommands(program: Command): void;
@@ -254,10 +254,38 @@ async function resolveTargetPid(client, opts, gate) {
254
254
  }
255
255
  return resolved.pid;
256
256
  }
257
- // --raise flag: app-level focus_window before the main action so coordinate
258
- // clicks and keystrokes land on a visible, key window.
259
- async function raiseIfRequested(client, pid, raise) {
260
- if (raise)
257
+ // Focus-safety policy for input verbs. Element mode (--id) drives an app through
258
+ // Accessibility actions (AXPress / set AXValue) that never activate the app or move
259
+ // the cursor, so the user keeps working while an agent acts. The two paths that DO
260
+ // take over the screen are (a) --raise (brings the app to the front + steals keyboard
261
+ // focus) and (b) coordinate mode (--x/--y warps the physical cursor and needs the app
262
+ // frontmost). Both are surfaced as notes, and --raise is ignored in element mode so a
263
+ // reflexive flag cannot hijack the user's session.
264
+ // Pure, unit-tested: the focus/cursor costs an action will impose, as human notes.
265
+ export function focusStealNotes(opts) {
266
+ const notes = [];
267
+ const elementMode = opts.id != null;
268
+ if (opts.raise) {
269
+ notes.push(elementMode
270
+ ? 'note: --raise ignored in element mode (--id) — element actions do not need the app frontmost, so your focus is left alone.'
271
+ : 'note: --raise brings the target app to the front and takes keyboard focus from you. Element mode (`describe` then --id) drives apps without stealing focus.');
272
+ }
273
+ if (!elementMode && (opts.x != null || opts.y != null)) {
274
+ notes.push('note: coordinate mode moves your real cursor and needs the app frontmost. Prefer element mode (`describe` then --id) to act without moving your pointer.');
275
+ }
276
+ return notes;
277
+ }
278
+ // Pure, unit-tested: whether --raise is actually honored. Element mode suppresses it
279
+ // so an element-targeted action never steals the user's foreground.
280
+ export function shouldRaise(opts) {
281
+ return Boolean(opts.raise) && opts.id == null;
282
+ }
283
+ // Apply the focus-safety policy for an input verb: print the cost notes, and raise
284
+ // only when raising is actually warranted (non-element mode with --raise).
285
+ async function applyFocusPolicy(client, pid, opts) {
286
+ for (const note of focusStealNotes(opts))
287
+ console.error(note);
288
+ if (shouldRaise(opts))
261
289
  unwrap(await client.call('focus_window', { pid }));
262
290
  }
263
291
  function emit(result, json, human) {
@@ -281,9 +309,9 @@ function addTargetOpts(cmd) {
281
309
  // Add the shared --id/--x/--y element-or-coords options to a verb.
282
310
  function addElementOrCoordOpts(cmd) {
283
311
  return cmd
284
- .option('--id <@eN>', 'Element id from `describe`')
285
- .option('--x <n>', 'X coordinate (global, points)', (v) => parseInt(v, 10))
286
- .option('--y <n>', 'Y coordinate (global, points)', (v) => parseInt(v, 10));
312
+ .option('--id <@eN>', 'Element id from `describe` (focus-safe: no foreground steal, no cursor move)')
313
+ .option('--x <n>', 'X coordinate (global, points; moves your real cursor, needs app frontmost)', (v) => parseInt(v, 10))
314
+ .option('--y <n>', 'Y coordinate (global, points; moves your real cursor, needs app frontmost)', (v) => parseInt(v, 10));
287
315
  }
288
316
  export function registerActionCommands(program) {
289
317
  // apps — list_apps
@@ -323,7 +351,7 @@ export function registerActionCommands(program) {
323
351
  .description('Click an element (--id) or screen coordinate (--x --y)')
324
352
  .option('--count <n>', 'Click count (2 = double-click)', (v) => parseInt(v, 10))
325
353
  .option('--background', 'Focus-safe postToPid delivery (plain AppKit only; skips HID tap)')
326
- .option('--raise', 'Bring the target app to the front first')
354
+ .option('--raise', 'Bring the target app to the front first (steals your foreground + keyboard focus; ignored in element mode --id)')
327
355
  .option('--json', 'Emit JSON'))).action(async (opts) => {
328
356
  await withClient(async (client) => {
329
357
  const pid = await resolveTargetPid(client, opts, { verb: 'click' });
@@ -332,7 +360,7 @@ export function registerActionCommands(program) {
332
360
  console.error(spec.error);
333
361
  process.exit(1);
334
362
  }
335
- await raiseIfRequested(client, pid, opts.raise);
363
+ await applyFocusPolicy(client, pid, opts);
336
364
  const params = { pid, ...spec.params };
337
365
  if (opts.count != null)
338
366
  params.count = opts.count;
@@ -354,6 +382,7 @@ export function registerActionCommands(program) {
354
382
  console.error(spec.error);
355
383
  process.exit(1);
356
384
  }
385
+ await applyFocusPolicy(client, pid, opts);
357
386
  const res = unwrap(await client.call('right_click', { pid, ...spec.params }));
358
387
  emit(res, Boolean(opts.json), () => `right-clicked (${res.method ?? 'ok'})`);
359
388
  });
@@ -373,6 +402,7 @@ export function registerActionCommands(program) {
373
402
  console.error(spec.error);
374
403
  process.exit(1);
375
404
  }
405
+ await applyFocusPolicy(client, pid, opts);
376
406
  const params = { pid, ...spec.params, text: opts.text };
377
407
  if (opts.commit)
378
408
  params.commit = true;
@@ -388,13 +418,13 @@ export function registerActionCommands(program) {
388
418
  .description('Type an arbitrary unicode string into the focused field (focus first via click/focus)')
389
419
  .requiredOption('--text <s>', 'Text to type')
390
420
  .option('--commit', 'Press Return after typing')
391
- .option('--raise', 'Bring the target app to the front first')
421
+ .option('--raise', 'Bring the target app to the front first (steals your foreground + keyboard focus; ignored in element mode --id)')
392
422
  .option('--require-frontmost', 'Fail (not warn) if the target is not the frontmost app')
393
423
  .option('--char-delay <ms>', 'Inter-character delay in ms (default 4; raise for lossy keyboard relays like VM guests, e.g. 25). Clamped to [1, 250].', (v) => parseInt(v, 10))
394
424
  .option('--json', 'Emit JSON')).action(async (opts) => {
395
425
  await withClient(async (client) => {
396
426
  const pid = await resolveTargetPid(client, opts, { verb: 'type-text' });
397
- await raiseIfRequested(client, pid, opts.raise);
427
+ await applyFocusPolicy(client, pid, opts);
398
428
  const params = { pid, text: opts.text };
399
429
  if (opts.commit)
400
430
  params.commit = true;
@@ -413,12 +443,12 @@ export function registerActionCommands(program) {
413
443
  .command('key')
414
444
  .description('Send a key chord, e.g. "cmd+shift+s", "enter", "esc"')
415
445
  .requiredOption('--keys <chord>', 'Key chord')
416
- .option('--raise', 'Bring the target app to the front first')
446
+ .option('--raise', 'Bring the target app to the front first (steals your foreground + keyboard focus; ignored in element mode --id)')
417
447
  .option('--require-frontmost', 'Fail (not warn) if the target is not the frontmost app')
418
448
  .option('--json', 'Emit JSON')).action(async (opts) => {
419
449
  await withClient(async (client) => {
420
450
  const pid = await resolveTargetPid(client, opts, { verb: 'key' });
421
- await raiseIfRequested(client, pid, opts.raise);
451
+ await applyFocusPolicy(client, pid, opts);
422
452
  const params = { pid, keys: opts.keys };
423
453
  if (opts.requireFrontmost)
424
454
  params.require_frontmost = true;
@@ -435,7 +465,7 @@ export function registerActionCommands(program) {
435
465
  .requiredOption('--to <x,y>', 'End coordinate "x,y"')
436
466
  .option('--button <left|right>', 'Mouse button', 'left')
437
467
  .option('--background', 'Focus-safe postToPid delivery (plain AppKit only)')
438
- .option('--raise', 'Bring the target app to the front first')
468
+ .option('--raise', 'Bring the target app to the front first (steals your foreground + keyboard focus; ignored in element mode --id)')
439
469
  .option('--json', 'Emit JSON')).action(async (opts) => {
440
470
  let from;
441
471
  let to;
@@ -449,7 +479,7 @@ export function registerActionCommands(program) {
449
479
  }
450
480
  await withClient(async (client) => {
451
481
  const pid = await resolveTargetPid(client, opts, { verb: 'drag' });
452
- await raiseIfRequested(client, pid, opts.raise);
482
+ await applyFocusPolicy(client, pid, opts);
453
483
  const params = {
454
484
  pid,
455
485
  from: [from.x, from.y],
@@ -468,11 +498,11 @@ export function registerActionCommands(program) {
468
498
  .description('Scroll by a pixel delta at an element or coordinate')
469
499
  .option('--dy <n>', 'Vertical delta (negative = down)', (v) => parseInt(v, 10))
470
500
  .option('--dx <n>', 'Horizontal delta', (v) => parseInt(v, 10))
471
- .option('--raise', 'Bring the target app to the front first')
501
+ .option('--raise', 'Bring the target app to the front first (steals your foreground + keyboard focus; ignored in element mode --id)')
472
502
  .option('--json', 'Emit JSON'))).action(async (opts) => {
473
503
  await withClient(async (client) => {
474
504
  const pid = await resolveTargetPid(client, opts, { verb: 'scroll' });
475
- await raiseIfRequested(client, pid, opts.raise);
505
+ await applyFocusPolicy(client, pid, opts);
476
506
  const params = { pid };
477
507
  if (opts.id)
478
508
  params.element_id = opts.id;
@@ -482,7 +482,9 @@ export function registerDoctorCommand(program) {
482
482
  .option('--diff', 'In target mode, include unified diffs for divergent files')
483
483
  .option('--fix', 'Heal gaps: install missing resources, repair invalid plugin manifests, refresh stale plugins, and reconcile drift (all installed versions, or just the target)')
484
484
  .option('--kind <kinds>', 'Restrict to comma-separated resource kinds (commands,skills,hooks,rules,mcp,permissions,subagents,plugins,promptcuts)')
485
- .option('--cwd <path>', 'Resolution cwd for project layer detection (default: process.cwd())');
485
+ .option('--cwd <path>', 'Resolution cwd for project layer detection (default: process.cwd())')
486
+ .option('--adopt <agent>', "Take over the agent's native launcher that shadows the shim (symlink it to the version-managed shim; reversible with --release)")
487
+ .option('--release <agent>', 'Undo --adopt: restore the native launcher agents-cli previously adopted');
486
488
  setHelpSections(doctorCmd, {
487
489
  examples: `
488
490
  # Overview: CLI availability + sync status + orphans across all defaults
@@ -509,6 +511,51 @@ export function registerDoctorCommand(program) {
509
511
  });
510
512
  doctorCmd.action(async (target, opts) => {
511
513
  const cwd = opts.cwd ? opts.cwd : process.cwd();
514
+ // Launcher adoption escape hatch. `--adopt <agent>` forces the take-over
515
+ // even for a non-default agent; `--release <agent>` reverses it.
516
+ if (opts.adopt || opts.release) {
517
+ if (opts.adopt && opts.release) {
518
+ console.error(chalk.red('--adopt and --release are mutually exclusive; pass only one.'));
519
+ process.exit(1);
520
+ }
521
+ const { adoptShadowingLauncher, releaseAdoptedLauncher } = await import('../lib/shims.js');
522
+ const raw = (opts.adopt || opts.release);
523
+ const agent = resolveAgentName(raw);
524
+ if (!agent) {
525
+ console.error(chalk.red(formatAgentError(raw)));
526
+ process.exit(1);
527
+ }
528
+ if (opts.release) {
529
+ const restored = releaseAdoptedLauncher(agent);
530
+ if (restored) {
531
+ console.log(chalk.green(`Released ${AGENTS[agent].cliCommand}: launcher restored to ${restored}.`));
532
+ }
533
+ else {
534
+ console.log(chalk.gray(`${AGENTS[agent].cliCommand} has no adopted launcher to release.`));
535
+ }
536
+ return;
537
+ }
538
+ // adoptShadowingLauncher resolves the launcher itself (PATH shadow, then
539
+ // the durable ~/.local/bin symlink), so it forces the take-over even when
540
+ // this shell's PATH already has the shim first.
541
+ const result = adoptShadowingLauncher(agent);
542
+ if (result.adopted) {
543
+ console.log(chalk.green(`Adopted ${AGENTS[agent].cliCommand} launcher (${result.launcher} -> shim). Original recorded for --release; version management now wins regardless of PATH order.`));
544
+ }
545
+ else if (result.reason === 'already-adopted') {
546
+ console.log(chalk.gray(`${AGENTS[agent].cliCommand} launcher is already adopted.`));
547
+ }
548
+ else if (result.reason === 'no-shadow') {
549
+ console.log(chalk.gray(`Nothing to adopt — no ${AGENTS[agent].cliCommand} launcher found shadowing the shim (checked PATH and ~/.local/bin).`));
550
+ }
551
+ else if (result.reason === 'not-a-symlink') {
552
+ console.log(chalk.yellow(`${AGENTS[agent].cliCommand} is shadowed by a real binary (${result.launcher}), not a symlink. agents-cli won't move a real binary — remove/reorder it or reorder PATH.`));
553
+ }
554
+ else {
555
+ console.log(chalk.yellow(`Could not adopt ${AGENTS[agent].cliCommand} (${result.reason}).`));
556
+ }
557
+ return;
558
+ }
512
559
  // --fix turns the read-only diagnosis into a heal. With no target it heals
513
560
  // every installed version; with a target it scopes to that agent.
514
561
  if (opts.fix) {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `agents sessions go [id]` — jump to a LIVE agent session's terminal.
3
+ *
4
+ * No id -> the SAME rich interactive picker as `agents sessions` (worktree, PR,
5
+ * changed files, tools, tests, last response — this-machine first),
6
+ * filtered to sessions that are running right now.
7
+ * With id -> jump directly.
8
+ *
9
+ * "Jump" is not "resume" (which spawns a new process from the transcript). It walks
10
+ * you to the already-running terminal:
11
+ * local tmux -> attach (switch-client when already inside tmux)
12
+ * local Ghostty -> focus its tab (Cmd+<n> via System Events; tab # from ghostty-tabs)
13
+ * remote tmux -> ssh -tt + tmux attach (pane->session resolved on the remote)
14
+ * otherwise -> refuse with a reason + resume hint (cloud / no attach rail)
15
+ */
16
+ import type { Command } from 'commander';
17
+ import { type ActiveSession } from '../lib/session/active.js';
18
+ export declare function registerGoCommand(program: Command): void;
19
+ export interface Where {
20
+ label: string;
21
+ action: string;
22
+ }
23
+ /**
24
+ * Pure, testable mirror of `jumpTo`'s path selection (jumpTo itself has side
25
+ * effects — process.exit / ssh / osascript). Keep the branch ORDER in sync with
26
+ * `jumpTo` below: remote-tmux, then local-tmux, then ghostty, then refuse.
27
+ */
28
+ export declare function describeWhere(s: ActiveSession, self: string): Where;
@@ -0,0 +1,238 @@
1
+ /**
2
+ * `agents sessions go [id]` — jump to a LIVE agent session's terminal.
3
+ *
4
+ * No id -> the SAME rich interactive picker as `agents sessions` (worktree, PR,
5
+ * changed files, tools, tests, last response — this-machine first),
6
+ * filtered to sessions that are running right now.
7
+ * With id -> jump directly.
8
+ *
9
+ * "Jump" is not "resume" (which spawns a new process from the transcript). It walks
10
+ * you to the already-running terminal:
11
+ * local tmux -> attach (switch-client when already inside tmux)
12
+ * local Ghostty -> focus its tab (Cmd+<n> via System Events; tab # from ghostty-tabs)
13
+ * remote tmux -> ssh -tt + tmux attach (pane->session resolved on the remote)
14
+ * otherwise -> refuse with a reason + resume hint (cloud / no attach rail)
15
+ */
16
+ import chalk from 'chalk';
17
+ import path from 'path';
18
+ import { execFile } from 'child_process';
19
+ import { promisify } from 'util';
20
+ import { getActiveSessions, findSessionFileForKind } from '../lib/session/active.js';
21
+ import { gatherRemoteActive } from '../lib/session/remote-active.js';
22
+ import { discoverSessions } from '../lib/session/discover.js';
23
+ import { dedupeByMachineSession, mergeLocalFirst, pickSessionInteractive } from './sessions.js';
24
+ import { machineId } from '../lib/session/sync/config.js';
25
+ import { isInteractiveTerminal } from './utils.js';
26
+ import { attachTmux, runTmux } from '../lib/tmux/binary.js';
27
+ import { getDefaultSocketPath } from '../lib/tmux/paths.js';
28
+ import { sshStream, assertValidSshTarget, shellQuote } from '../lib/ssh-exec.js';
29
+ import { enumerateGhosttyTabs, assignGhosttyTabs } from '../lib/session/ghostty-tabs.js';
30
+ const execFileAsync = promisify(execFile);
31
+ export function registerGoCommand(program) {
32
+ program
33
+ .command('go')
34
+ .argument('[id]', 'Short/full session id to jump to; omit for an interactive picker')
35
+ .option('--local', 'Only this machine (skip the cross-host sweep)')
36
+ .description('Jump to a live agent session — attach its tmux, or focus its terminal tab')
37
+ .action(async (id, opts) => {
38
+ await goAction(id, opts);
39
+ });
40
+ }
41
+ async function goAction(id, opts) {
42
+ const self = machineId();
43
+ // Live jump targets (local + remote), keyed by session id.
44
+ const localActive = await getActiveSessions();
45
+ for (const s of localActive)
46
+ if (!s.machine)
47
+ s.machine = self;
48
+ let active = localActive;
49
+ if (!opts.local) {
50
+ try {
51
+ const remote = await gatherRemoteActive();
52
+ active = dedupeByMachineSession([...localActive, ...remote.sessions]);
53
+ }
54
+ catch { /* remote sweep is best-effort */ }
55
+ }
56
+ const activeById = new Map();
57
+ for (const s of active)
58
+ if (s.context !== 'cloud' && s.sessionId)
59
+ activeById.set(s.sessionId, s);
60
+ if (activeById.size === 0) {
61
+ console.log(chalk.gray('No live agent sessions to jump to.'));
62
+ return;
63
+ }
64
+ // Direct jump by id — no picker.
65
+ if (id) {
66
+ const q = id.toLowerCase();
67
+ const matches = [...activeById.values()].filter((s) => s.sessionId.toLowerCase().startsWith(q));
68
+ if (matches.length === 0) {
69
+ console.error(chalk.red(`No live session matching "${id}".`));
70
+ process.exitCode = 1;
71
+ return;
72
+ }
73
+ if (matches.length > 1) {
74
+ console.error(chalk.red(`"${id}" is ambiguous (${matches.length} matches). Use more of the id.`));
75
+ process.exitCode = 1;
76
+ return;
77
+ }
78
+ await jumpTo(matches[0], self);
79
+ return;
80
+ }
81
+ if (!isInteractiveTerminal()) {
82
+ console.error(chalk.red('go needs an interactive terminal, or pass a session id.'));
83
+ process.exitCode = 1;
84
+ return;
85
+ }
86
+ // Reuse the rich `sessions` picker over the live sessions' full SessionMeta.
87
+ const pool = await buildLivePool(activeById, self);
88
+ if (pool.length === 0) {
89
+ console.log(chalk.gray('No live sessions to jump to.'));
90
+ return;
91
+ }
92
+ const picked = await pickSessionInteractive(pool, 'Jump to a live session:', undefined, 0, 'jump');
93
+ if (!picked)
94
+ return;
95
+ const target = activeById.get(picked.session.id);
96
+ if (!target) {
97
+ console.log(chalk.yellow(`${picked.session.shortId} is no longer live — try: `) + chalk.gray(`agents sessions resume ${picked.session.shortId}`));
98
+ return;
99
+ }
100
+ await jumpTo(target, self);
101
+ }
102
+ /**
103
+ * Map each live session to its rich SessionMeta (worktree/PR/changes/tools/tests
104
+ * via the shared picker), reusing `discoverSessions`. Remote or unindexed live
105
+ * sessions get a minimal synthesized meta so they still appear and jump.
106
+ */
107
+ async function buildLivePool(activeById, self) {
108
+ let metas = [];
109
+ try {
110
+ metas = await discoverSessions({ all: true, since: '30d', limit: 1000 });
111
+ }
112
+ catch { /* fall back to synthesized metas */ }
113
+ const byId = new Map();
114
+ for (const m of metas)
115
+ byId.set(m.id, m);
116
+ const pool = [];
117
+ for (const [sid, s] of activeById) {
118
+ pool.push(byId.get(sid) ?? synthMeta(s, self));
119
+ }
120
+ return mergeLocalFirst(pool, self);
121
+ }
122
+ function synthMeta(s, self) {
123
+ const remote = !!s.machine && s.machine !== self;
124
+ // For a local session, locate the real transcript on disk so the picker's
125
+ // buildPreview parses it directly (rich Prompt/Changes/Tools/Last response) —
126
+ // independent of the sessions DB. Remote transcripts live on the peer, so leave
127
+ // filePath empty and the preview shows a clean "not indexed here" note.
128
+ const filePath = remote ? '' : (findSessionFileForKind(s.kind, s.cwd, s.sessionId) ?? '');
129
+ return {
130
+ id: s.sessionId,
131
+ shortId: s.sessionId.slice(0, 8),
132
+ agent: s.kind,
133
+ timestamp: new Date(s.startedAtMs ?? Date.now()).toISOString(),
134
+ filePath,
135
+ cwd: s.cwd,
136
+ project: s.cwd ? path.basename(s.cwd) : undefined,
137
+ topic: s.topic,
138
+ machine: s.machine,
139
+ _remote: remote,
140
+ };
141
+ }
142
+ function shortId(s) {
143
+ return (s.sessionId ?? '').slice(0, 8) || '-';
144
+ }
145
+ /**
146
+ * Pure, testable mirror of `jumpTo`'s path selection (jumpTo itself has side
147
+ * effects — process.exit / ssh / osascript). Keep the branch ORDER in sync with
148
+ * `jumpTo` below: remote-tmux, then local-tmux, then ghostty, then refuse.
149
+ */
150
+ export function describeWhere(s, self) {
151
+ const remote = s.machine && s.machine !== self ? s.machine : undefined;
152
+ const mux = s.provenance?.mux;
153
+ if (mux?.kind === 'tmux' && mux.pane) {
154
+ return remote
155
+ ? { label: `tmux ${mux.pane} on ${remote}`, action: `ssh + attach on ${remote}` }
156
+ : { label: `tmux ${mux.pane}`, action: 'attach its tmux' };
157
+ }
158
+ if (!remote && s.host === 'ghostty')
159
+ return { label: 'Ghostty', action: 'focus its Ghostty tab' };
160
+ if (remote)
161
+ return { label: `${s.host ?? 'shell'} on ${remote}`, action: `open a shell on ${remote}` };
162
+ return { label: s.host ?? 'unknown terminal', action: 'resume it (no live attach rail)' };
163
+ }
164
+ async function jumpTo(s, self) {
165
+ const remote = s.machine && s.machine !== self ? s.machine : undefined;
166
+ const mux = s.provenance?.mux;
167
+ // Path C: remote tmux — ssh in and attach, resolving the pane's session on the remote.
168
+ if (remote) {
169
+ if (mux?.kind === 'tmux' && mux.pane) {
170
+ assertValidSshTarget(remote);
171
+ const sock = mux.socket ? `-S ${shellQuote(mux.socket)} ` : '';
172
+ const p = shellQuote(mux.pane);
173
+ const remoteCmd = `w=$(tmux ${sock}display-message -pt ${p} '#{session_name}:#{window_index}' 2>/dev/null); ` +
174
+ `sess=$(tmux ${sock}display-message -pt ${p} '#{session_name}' 2>/dev/null); ` +
175
+ `[ -n "$w" ] && tmux ${sock}select-window -t "$w" 2>/dev/null; ` +
176
+ `exec tmux ${sock}attach-session -t "\${sess:-${p}}"`;
177
+ console.log(chalk.gray(`Attaching ${shortId(s)} on ${remote} over SSH — Ctrl-b d to detach.`));
178
+ process.exit(sshStream(remote, remoteCmd, { tty: true }));
179
+ }
180
+ console.log(chalk.yellow(`${shortId(s)} on ${remote} isn't inside tmux — opening a shell on ${remote} instead.`));
181
+ assertValidSshTarget(remote);
182
+ process.exit(sshStream(remote, 'exec "${SHELL:-/bin/sh}" -l', { tty: true }));
183
+ }
184
+ // Path B: local tmux — attach (or switch-client if we're already inside tmux).
185
+ if (mux?.kind === 'tmux' && mux.pane) {
186
+ const socket = mux.socket ?? getDefaultSocketPath();
187
+ const { session, window } = await resolveLocalPane(socket, mux.pane);
188
+ if (session && window != null) {
189
+ await runTmux({ socket, args: ['select-window', '-t', `${session}:${window}`], throwOnError: false }).catch(() => { });
190
+ }
191
+ const tgt = session ?? mux.pane;
192
+ if (process.env.TMUX) {
193
+ await runTmux({ socket, args: ['switch-client', '-t', tgt], throwOnError: false }).catch(() => { });
194
+ console.log(chalk.gray(`Switched this tmux client to ${shortId(s)} (${tgt}).`));
195
+ return;
196
+ }
197
+ console.log(chalk.gray(`Attaching ${shortId(s)} (tmux ${tgt}) — Ctrl-b d to detach.`));
198
+ process.exit(await attachTmux({ socket, args: ['attach-session', '-t', tgt] }));
199
+ }
200
+ // Path A: local Ghostty — focus its tab (Cmd+N via System Events).
201
+ if (s.host === 'ghostty') {
202
+ let tab;
203
+ try {
204
+ const surfaces = await enumerateGhosttyTabs();
205
+ tab = assignGhosttyTabs([s], surfaces).get(s);
206
+ }
207
+ catch { /* best-effort */ }
208
+ if (tab != null && tab <= 9) {
209
+ const script = `tell application "Ghostty" to activate\n` +
210
+ `delay 0.15\n` +
211
+ `tell application "System Events" to keystroke "${tab}" using command down`;
212
+ await execFileAsync('osascript', ['-e', script]).catch(() => { });
213
+ console.log(chalk.gray(`Focused ${shortId(s)} → Ghostty tab ${tab}.`));
214
+ return;
215
+ }
216
+ await execFileAsync('osascript', ['-e', 'tell application "Ghostty" to activate']).catch(() => { });
217
+ console.log(chalk.yellow(`Raised Ghostty for ${shortId(s)}`) +
218
+ chalk.gray(tab != null ? ` — switch to tab ${tab} (Cmd+${tab}).` : " — couldn't pinpoint its tab (same-repo forks are ambiguous); switch tabs manually."));
219
+ return;
220
+ }
221
+ // Path D: refuse with a reason.
222
+ console.log(chalk.yellow(`Can't jump to ${shortId(s)} — it's in ${s.host ?? 'an unknown terminal'} with no attach rail (not tmux/Ghostty).`) +
223
+ chalk.gray(`\nTry: agents sessions resume ${shortId(s)}`));
224
+ }
225
+ /** Resolve a local tmux pane id to its session name + window index. */
226
+ async function resolveLocalPane(socket, pane) {
227
+ try {
228
+ const res = await runTmux({ socket, args: ['display-message', '-pt', pane, '-p', '#{session_name}\t#{window_index}'], throwOnError: false });
229
+ if (res.code !== 0)
230
+ return {};
231
+ const [session, win] = res.stdout.trim().split('\t');
232
+ const window = Number.parseInt(win, 10);
233
+ return { session: session || undefined, window: Number.isFinite(window) ? window : undefined };
234
+ }
235
+ catch {
236
+ return {};
237
+ }
238
+ }
@@ -12,6 +12,8 @@ export interface SessionPickerConfig {
12
12
  labelFor: (s: SessionMeta, query: string) => string;
13
13
  pageSize?: number;
14
14
  initialSearch?: string;
15
+ /** Verb shown on the Enter key in the footer (default 'resume'). */
16
+ enterHint?: string;
15
17
  }
16
18
  /** Build a cached multi-line preview string for display in the session picker. */
17
19
  export declare function buildPreview(session: SessionMeta): string;
@@ -5,6 +5,7 @@
5
5
  * Builds a compact preview for each session (prompt, activity summary, last
6
6
  * response) and delegates to the generic `itemPicker` for the interactive UI.
7
7
  */
8
+ import fs from 'node:fs';
8
9
  import chalk from 'chalk';
9
10
  import { parseSession, sanitizeForTerminal } from '../lib/session/parse.js';
10
11
  import { cleanSessionPrompt, extractSessionTopic } from '../lib/session/prompt.js';
@@ -61,6 +62,14 @@ export function buildPreview(session) {
61
62
  previewCache.set(cacheKey, output);
62
63
  return output;
63
64
  }
65
+ // No transcript on disk — a live session not indexed locally, or a synthesized
66
+ // entry (e.g. `sessions go`). Show the header + a clean note, not a parse error.
67
+ if (!session.filePath || !fs.existsSync(session.filePath)) {
68
+ const note = ' ' + chalk.gray('Live session — full transcript not indexed here.');
69
+ const output = [formatHeader(safe, []), '', note].filter(Boolean).join('\n');
70
+ previewCache.set(cacheKey, output);
71
+ return output;
72
+ }
64
73
  let events = [];
65
74
  let parseError;
66
75
  try {
@@ -371,7 +380,7 @@ export async function sessionPicker(config) {
371
380
  pageSize: config.pageSize,
372
381
  initialSearch: config.initialSearch,
373
382
  emptyMessage: 'No sessions match.',
374
- enterHint: 'resume',
383
+ enterHint: config.enterHint ?? 'resume',
375
384
  });
376
385
  if (!picked)
377
386
  return null;
@@ -164,7 +164,7 @@ export declare function formatPickerLabel(s: SessionMeta, query: string, cols?:
164
164
  * so it stays fixed across the picker's re-renders within a single run.
165
165
  */
166
166
  export declare function formatPickerTip(sessions: SessionMeta[]): string;
167
- export declare function pickSessionInteractive(sessions: SessionMeta[], message?: string, initialSearch?: string, hiddenCount?: number): Promise<PickedSession | null>;
167
+ export declare function pickSessionInteractive(sessions: SessionMeta[], message?: string, initialSearch?: string, hiddenCount?: number, enterHint?: string): Promise<PickedSession | null>;
168
168
  /**
169
169
  * Resume a session in the current terminal — a foreground takeover of this
170
170
  * process. Used by the single-select picker and by `sessions resume` when the
@@ -39,6 +39,7 @@ import { setHelpSections } from '../lib/help.js';
39
39
  import { registerSessionsTailCommand } from './sessions-tail.js';
40
40
  import { registerSessionsSyncCommand } from './sessions-sync.js';
41
41
  import { registerSessionsResumeCommand } from './sessions-resume.js';
42
+ import { registerGoCommand } from './go.js';
42
43
  import { registerSessionsInjectCommand } from './sessions-inject.js';
43
44
  const SESSION_AGENT_FILTER_HELP = `Filter by agent, e.g. claude, codex, claude@2.0.65`;
44
45
  /**
@@ -664,6 +665,9 @@ function printCrossMachineTip() {
664
665
  }
665
666
  /** Main action handler for `agents sessions`. Routes to picker, table, or single-session render. */
666
667
  async function sessionsAction(query, options) {
668
+ // Explicit --query is interchangeable with the positional; it's how you search
669
+ // for text that collides with a subcommand name (e.g. `sessions --query go`).
670
+ query = query ?? options.query;
667
671
  // Normalize convenience flags before any routing reads them: per-agent
668
672
  // shorthands fold into --agent, and --device is an alias for --host (both
669
673
  // resolve against the same device registry).
@@ -1365,7 +1369,7 @@ const PICKER_TIPS = [
1365
1369
  export function formatPickerTip(sessions) {
1366
1370
  return chalk.gray(PICKER_TIPS[sessions.length % PICKER_TIPS.length]);
1367
1371
  }
1368
- export async function pickSessionInteractive(sessions, message = 'Search sessions:', initialSearch, hiddenCount = 0) {
1372
+ export async function pickSessionInteractive(sessions, message = 'Search sessions:', initialSearch, hiddenCount = 0, enterHint) {
1369
1373
  if (hiddenCount > 0) {
1370
1374
  console.log(chalk.gray(formatTeamHiddenFooter(hiddenCount)));
1371
1375
  }
@@ -1385,6 +1389,7 @@ export async function pickSessionInteractive(sessions, message = 'Search session
1385
1389
  labelFor: (s, query) => formatPickerLabel(s, query, cols),
1386
1390
  pageSize: PICKER_RECENT_COUNT,
1387
1391
  initialSearch,
1392
+ enterHint,
1388
1393
  });
1389
1394
  }
1390
1395
  catch (err) {
@@ -1888,6 +1893,7 @@ export function registerSessionsCommands(program) {
1888
1893
  const sessionsCmd = program
1889
1894
  .command('sessions')
1890
1895
  .argument('[query]', 'Session ID, search query, or path (., ../, /path) to filter by project')
1896
+ .option('--query <text>', 'Search text — use when the term collides with a subcommand name (e.g. "go")')
1891
1897
  .description('Find, browse, and read agent conversation transcripts across Claude, Codex, Gemini, and OpenCode.')
1892
1898
  .option('-a, --agent <agent>', 'Filter by agent type and version (e.g., claude, codex@0.116.0)')
1893
1899
  .option('--claude', 'Shorthand for --agent claude')
@@ -1967,6 +1973,7 @@ export function registerSessionsCommands(program) {
1967
1973
  registerSessionsTailCommand(sessionsCmd);
1968
1974
  registerSessionsSyncCommand(sessionsCmd);
1969
1975
  registerSessionsResumeCommand(sessionsCmd);
1976
+ registerGoCommand(sessionsCmd);
1970
1977
  registerSessionsInjectCommand(sessionsCmd);
1971
1978
  }
1972
1979
  function formatNoSessionsMessage(showAll, project) {
package/dist/index.js CHANGED
@@ -483,7 +483,7 @@ async function maybeBootstrapShimIntegration(requestedCommand, helpOrVersionRequ
483
483
  const { confirm } = await import('@inquirer/prompts');
484
484
  const { AGENTS } = await import('./lib/agents.js');
485
485
  const { getGlobalDefault, listInstalledVersions } = await import('./lib/versions.js');
486
- const { addShimsToPath, ensureShimCurrent, ensureVersionedAliasCurrent, getPathShadowingExecutable, getPathSetupInstructions, getShimsDir, isShimsInPath, listAgentsWithInstalledVersions, removeLegacyUserShim, } = await import('./lib/shims.js');
486
+ const { addShimsToPath, adoptShadowingLauncher, ensureShimCurrent, ensureVersionedAliasCurrent, getPathShadowingExecutable, getPathSetupInstructions, getShimsDir, isShimsInPath, listAgentsWithInstalledVersions, removeLegacyUserShim, } = await import('./lib/shims.js');
487
487
  const installedAgents = listAgentsWithInstalledVersions();
488
488
  if (installedAgents.length === 0) {
489
489
  return;
@@ -519,87 +519,78 @@ async function maybeBootstrapShimIntegration(requestedCommand, helpOrVersionRequ
519
519
  return;
520
520
  }
521
521
  const defaultAgents = installedAgents.filter((agent) => getGlobalDefault(agent));
522
+ // Auto-adopt any harness launcher that shadows our shim. PATH-order repair
523
+ // (below) cannot win against `~/.local/bin` — it's prepended in .zshenv for
524
+ // every shell while our prepend only lands in .zshrc — so for symlink
525
+ // launchers we *become* the launcher instead. Detection keys on the launcher
526
+ // symlink EXISTING (via adoptShadowingLauncher's own fallback), not on this
527
+ // shell's PATH order, so it also heals the GUI/non-interactive shadow an
528
+ // interactive run can't see. Reversible; only ever rewrites a symlink.
529
+ for (const agent of defaultAgents) {
530
+ const result = adoptShadowingLauncher(agent);
531
+ if (result.adopted) {
532
+ console.log(chalk.green(`Adopted ${AGENTS[agent].cliCommand} launcher (${result.launcher}) — version management now wins regardless of PATH order.`));
533
+ }
534
+ }
535
+ // Recompute AFTER adoption so anything we just took over drops out. What
536
+ // remains is a real binary we deliberately don't touch (adoption is
537
+ // symlink-only) — those get an honest one-time note, never a looping prompt.
522
538
  const shadowed = defaultAgents
523
539
  .map((agent) => ({ agent, shadowedBy: getPathShadowingExecutable(agent) }))
524
540
  .filter((item) => Boolean(item.shadowedBy));
525
- // Shell aliases that call the same command with extra flags are intentional
526
- // customization and don't break shim integration `addShimsToPath` cannot
527
- // touch them, so they don't belong in the repair prompt. We previously
528
- // computed an `aliased` list here and inserted it into `affected`, which
529
- // contradicted the comment below and surfaced false positives (e.g. an
530
- // earlier `alias codex=...` cancelled by a later `unalias codex` was
531
- // reported because the detector did a static rc-file regex).
532
- if (shadowed.length === 0 && isShimsInPath()) {
541
+ // After adoption, the only things left are (a) real-binary shadows we won't
542
+ // touch, and (b) a genuinely missing PATH entry. Nothing else needs the user.
543
+ const pathMissing = !isShimsInPath();
544
+ if (shadowed.length === 0 && !pathMissing) {
533
545
  return;
534
546
  }
535
- // Suppress repeated prompts within the same shell. A successful rc-file
536
- // edit doesn't reload the parent shell, so the next invocation sees the
537
- // same PATH and re-fires detection. The sentinel survives only as long as
538
- // the parent shell process once the user opens a new terminal, the
539
- // PPID changes and the prompt is allowed again.
547
+ // Suppress repeated notices within the same shell. A successful rc-file edit
548
+ // doesn't reload the parent shell, so the next invocation re-fires detection.
549
+ // The sentinel survives only as long as the parent shell process — a new
550
+ // terminal (new PPID) is allowed to surface it again.
540
551
  const sentinelPath = path.join(os.tmpdir(), `agents-shim-prompted-${process.ppid}`);
541
552
  if (fs.existsSync(sentinelPath)) {
542
553
  return;
543
554
  }
544
- const affected = [];
545
- for (const { agent, shadowedBy } of shadowed) {
546
- affected.push(`${AGENTS[agent].cliCommand} -> ${shadowedBy}`);
547
- }
548
- if (affected.length === 0) {
549
- // Pure PATH-not-loaded case: rc may already have the shim block, but the
550
- // running shell hasn't sourced it. Don't list agents here — they aren't
551
- // broken; only the PATH is stale. The prompt + post-message handle it.
552
- affected.push('PATH entry missing');
553
- }
554
- const shouldRepair = await confirm({
555
- message: `Repair shim integration now? ${affected.join(', ')}`,
556
- default: true,
557
- });
558
- if (!shouldRepair) {
559
- console.log(chalk.yellow('Shim integration still needs attention.'));
560
- console.log(chalk.gray(getPathSetupInstructions()));
561
- try {
562
- fs.writeFileSync(sentinelPath, '1');
563
- }
564
- catch { /* best-effort */ }
565
- return;
566
- }
567
- const pathResult = addShimsToPath();
568
- if (!pathResult.success) {
569
- console.log(chalk.yellow('Could not repair shim PATH setup automatically.'));
570
- console.log(chalk.gray(pathResult.error || getPathSetupInstructions()));
571
- // Write the sentinel even on failure — otherwise an unwritable rc file
572
- // re-prompts every invocation in the same shell. The user opens a new
573
- // terminal (new PPID) to retry.
574
- try {
575
- fs.writeFileSync(sentinelPath, '1');
576
- }
577
- catch { /* best-effort */ }
578
- return;
555
+ // Real-binary shadows: adoption is symlink-only (we never rename a real native
556
+ // binary), and `addShimsToPath` provably can't outrank an early-PATH dir like
557
+ // ~/.local/bin across zsh's whole sourcing chain. So DON'T offer a "Repair?"
558
+ // prompt here — that was the infinite-loop bug (Yes was always a no-op).
559
+ // Inform once and point at the real levers.
560
+ if (shadowed.length > 0) {
561
+ const targets = shadowed
562
+ .map(({ agent, shadowedBy }) => ` ${AGENTS[agent].cliCommand}: ${shadowedBy}`)
563
+ .join('\n');
564
+ console.log(chalk.yellow('These agent commands run a native binary instead of the version-managed shim:'));
565
+ console.log(chalk.gray(targets));
566
+ console.log(chalk.gray(`It's a real binary (not a symlink), so agents-cli won't move it. To hand it to agents-cli, remove/reorder it, or put ${getShimsDir()} earlier in PATH.`));
579
567
  }
580
- // When the rc file already has the canonical shim block, `addShimsToPath`
581
- // is a no-op re-emitting produced byte-identical content. In this branch
582
- // the user clicked "Yes" but nothing changed on disk, AND the underlying
583
- // cause (a real binary shadow, or a stale shell PATH) is unaffected by
584
- // this command. Be honest about it and point at the actual action.
585
- if (pathResult.alreadyPresent) {
586
- if (shadowed.length > 0) {
587
- const targets = shadowed
588
- .map(({ agent, shadowedBy }) => ` ${AGENTS[agent].cliCommand}: ${shadowedBy}`)
589
- .join('\n');
590
- console.log(chalk.yellow('Repair could not change anything — the shim is shadowed by another binary on PATH:'));
591
- console.log(chalk.gray(targets));
592
- console.log(chalk.gray(`Fix it by removing or reordering that binary, or making sure ${getShimsDir()} appears earlier in PATH than its parent dir.`));
568
+ // Genuinely-missing PATH entry is the one thing addShimsToPath actually fixes,
569
+ // so it's the only case that still earns an interactive prompt.
570
+ if (pathMissing) {
571
+ const shouldRepair = await confirm({
572
+ message: 'Add the agents-cli shims directory to your PATH now?',
573
+ default: true,
574
+ });
575
+ if (!shouldRepair) {
576
+ console.log(chalk.gray(getPathSetupInstructions()));
593
577
  }
594
578
  else {
595
- console.log(chalk.yellow(`Shim PATH entry is already in ~/${pathResult.rcFile} this shell just needs to reload it.`));
596
- console.log(chalk.gray(`Run: source ~/${pathResult.rcFile} (or open a new terminal)`));
579
+ const pathResult = addShimsToPath();
580
+ if (!pathResult.success) {
581
+ console.log(chalk.yellow('Could not update PATH automatically.'));
582
+ console.log(chalk.gray(pathResult.error || getPathSetupInstructions()));
583
+ }
584
+ else if (pathResult.alreadyPresent) {
585
+ console.log(chalk.yellow(`Shim PATH entry is already in ~/${pathResult.rcFile} — this shell just needs to reload it.`));
586
+ console.log(chalk.gray(`Run: source ~/${pathResult.rcFile} (or open a new terminal)`));
587
+ }
588
+ else {
589
+ console.log(chalk.green(`Added shims to PATH in ~/${pathResult.rcFile}`));
590
+ console.log(chalk.gray(getPathSetupInstructions()));
591
+ }
597
592
  }
598
593
  }
599
- else {
600
- console.log(chalk.green(`Repaired shim PATH setup in ~/${pathResult.rcFile}`));
601
- console.log(chalk.gray(getPathSetupInstructions()));
602
- }
603
594
  try {
604
595
  fs.writeFileSync(sentinelPath, '1');
605
596
  }
@@ -89,6 +89,12 @@ export interface ActiveQueryOptions {
89
89
  /** Skip the `ps` scan for ad-hoc headless agents. */
90
90
  skipHeadless?: boolean;
91
91
  }
92
+ /**
93
+ * Locate the live transcript for an agent process. Claude files are keyed by
94
+ * cwd (+ optional session uuid); Codex files are date-partitioned, so we resolve
95
+ * the newest indexed Codex session for the cwd instead.
96
+ */
97
+ export declare function findSessionFileForKind(kind: string, cwd?: string, sessionId?: string): string | undefined;
92
98
  /** Live teams teammates. Reuses AgentManager which already polls PIDs via `kill -0`. */
93
99
  export declare function listTeamsActive(): Promise<ActiveSession[]>;
94
100
  /** Live editor-terminal agents across every IDE window. */
@@ -161,7 +161,7 @@ function classifyActivity(sessionFile) {
161
161
  * cwd (+ optional session uuid); Codex files are date-partitioned, so we resolve
162
162
  * the newest indexed Codex session for the cwd instead.
163
163
  */
164
- function findSessionFileForKind(kind, cwd, sessionId) {
164
+ export function findSessionFileForKind(kind, cwd, sessionId) {
165
165
  if (!cwd)
166
166
  return undefined;
167
167
  if (kind === 'claude')
@@ -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 = 22;
80
+ export declare const SHIM_SCHEMA_VERSION = 23;
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.
@@ -311,6 +311,70 @@ export declare function getPathShadowingExecutable(agent: AgentId): string | nul
311
311
  export declare function removeLegacyUserShim(agent: AgentId, overrides?: {
312
312
  homeDir?: string;
313
313
  }): boolean;
314
+ /**
315
+ * Where an adopted launcher's provenance is recorded. Lives under durable
316
+ * `.history` (NOT the regenerable `.cache`) so the reverse pointer to the native
317
+ * binary survives a cache wipe — the shim reads it to fall through to the native
318
+ * binary by absolute path when no managed version resolves. Two lines:
319
+ * line 1 = original binary, line 2 = launcher path (for `--release`).
320
+ */
321
+ export declare function getAdoptedRecordPath(agent: AgentId, historyDir?: string): string;
322
+ /**
323
+ * The launcher a harness's own installer drops in an early-PATH dir. Detection
324
+ * for adoption keys on the launcher *existing as a symlink resolving outside our
325
+ * shims dir* — NOT on current PATH order. That's deliberate: the shim only loses
326
+ * PATH races in non-interactive / GUI-launched shells, which an interactive
327
+ * `agents` run can't observe via its own PATH. Keying on the durable symlink lets
328
+ * auto-adoption fire for those users too. Returns the launcher path or null.
329
+ */
330
+ export declare function findAdoptableLauncher(agent: AgentId, overrides?: {
331
+ homeDir?: string;
332
+ shimsDir?: string;
333
+ }): string | null;
334
+ export type AdoptResult = {
335
+ adopted: true;
336
+ launcher: string;
337
+ original: string;
338
+ } | {
339
+ adopted: false;
340
+ reason: 'no-shadow' | 'already-adopted' | 'not-a-symlink' | 'unsafe-target' | 'error';
341
+ launcher?: string;
342
+ };
343
+ /**
344
+ * Adopt the harness's own launcher that shadows our shim on PATH.
345
+ *
346
+ * PATH-ordering fixes (editing rc files) can never reliably win: `~/.local/bin`
347
+ * (where grok/droid/etc. self-install) is prepended in `.zshenv`/`.zprofile`
348
+ * for *every* shell, while our shims prepend only lands in `.zshrc`
349
+ * (interactive). No single rc file guarantees "last prepend wins" across zsh's
350
+ * whole sourcing chain, so the shim loses in non-interactive / GUI-launched
351
+ * contexts. Instead of fighting PATH order, we *become* the launcher: replace
352
+ * the shadowing symlink with one pointing at our shim, and record the real
353
+ * original so the shim falls through to it when no managed version is selected.
354
+ *
355
+ * Regression bounds:
356
+ * - Only ever touches a **symlink** (never renames/deletes a real binary).
357
+ * - Records the resolved original + launcher path for lossless restore
358
+ * (`releaseAdoptedLauncher`), in durable `.history` so a cache wipe can't
359
+ * orphan the reverse pointer.
360
+ * - Idempotent: a no-op once the launcher already points at our shim.
361
+ * - Never records our own shim as the "original" (would loop).
362
+ */
363
+ export declare function adoptShadowingLauncher(agent: AgentId, overrides?: {
364
+ shadowedBy?: string;
365
+ shimsDir?: string;
366
+ historyDir?: string;
367
+ }): AdoptResult;
368
+ /**
369
+ * Undo `adoptShadowingLauncher`: repoint the launcher back at the recorded
370
+ * original and drop the record. Reversible escape hatch for users who want the
371
+ * native launcher to win. Returns the restored original path, or null if there
372
+ * was nothing to release.
373
+ */
374
+ export declare function releaseAdoptedLauncher(agent: AgentId, overrides?: {
375
+ shimsDir?: string;
376
+ historyDir?: string;
377
+ }): string | null;
314
378
  export declare function hasAliasShadowingShim(agent: AgentId, overrides?: {
315
379
  homeDir?: string;
316
380
  }): boolean;
package/dist/lib/shims.js CHANGED
@@ -14,7 +14,7 @@ import * as os from 'os';
14
14
  import { fileURLToPath } from 'url';
15
15
  import { confirm, select } from '@inquirer/prompts';
16
16
  import { IS_WINDOWS, prependToWindowsUserPath } from './platform/index.js';
17
- import { getShimsDir, getVersionsDir, getBackupsDir, ensureAgentsDir } from './state.js';
17
+ import { getShimsDir, getVersionsDir, getBackupsDir, getHistoryDir, ensureAgentsDir } from './state.js';
18
18
  export { getShimsDir };
19
19
  import { AGENTS, agentConfigDirName } from './agents.js';
20
20
  /**
@@ -211,7 +211,7 @@ 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 = 22;
214
+ export const SHIM_SCHEMA_VERSION = 23;
215
215
  /** Internal marker string used to embed the schema version in shim scripts. */
216
216
  const SHIM_VERSION_MARKER = 'agents-shim-version:';
217
217
  function shellQuote(value) {
@@ -295,6 +295,33 @@ if [ -z "$AGENTS_BIN" ] || [ ! -x "$AGENTS_BIN" ]; then
295
295
  exit 127
296
296
  fi
297
297
 
298
+ # When agents-cli "adopts" a harness's own launcher (symlinks the native binary
299
+ # in ~/.local/bin to this dispatcher so version management wins regardless of
300
+ # PATH order), it records the real original here. Durable (.history, not the
301
+ # regenerable .cache) so the reverse pointer survives a cache wipe. Line 1 is
302
+ # the original binary (what we fall through to); line 2 is the launcher path
303
+ # (used by --release). It is the only safe fall-through target: exec it by
304
+ # ABSOLUTE PATH so we never re-resolve through PATH (which now points back at
305
+ # this dispatcher → infinite re-exec loop).
306
+ ADOPTED_ORIGINAL="$AGENTS_USER_DIR/.history/adopted-launchers/$CLI_COMMAND"
307
+ # Print the recorded original binary iff it is an executable file, else nothing.
308
+ adopted_original_bin() {
309
+ [ -f "$ADOPTED_ORIGINAL" ] || return 1
310
+ local orig
311
+ # First line only — line 2 (launcher path) is for --release, not exec.
312
+ IFS= read -r orig < "$ADOPTED_ORIGINAL" 2>/dev/null || return 1
313
+ [ -n "$orig" ] && [ -x "$orig" ] || return 1
314
+ printf '%s' "$orig"
315
+ }
316
+ # Last-resort fall-through: if a managed version can't be resolved but we've
317
+ # adopted this command's native launcher, run the original so the user's command
318
+ # never breaks. Replaces the process; returns non-zero only when no usable record.
319
+ exec_adopted_original() {
320
+ local orig
321
+ orig=$(adopted_original_bin) || return 1
322
+ exec "$orig" "$@"
323
+ }
324
+
298
325
  # Find project agents.yaml walking up from cwd (skip $HOME/.agents/agents.yaml)
299
326
  find_project_version() {
300
327
  local dir="$PWD"
@@ -378,15 +405,20 @@ if [ -z "$VERSION" ]; then
378
405
  VERSION_SOURCE="default"
379
406
  ;;
380
407
  *)
408
+ exec_adopted_original "$@"
381
409
  echo " Run: agents use $AGENT <version>" >&2
382
410
  exit 1
383
411
  ;;
384
412
  esac
385
413
  else
414
+ exec_adopted_original "$@"
386
415
  echo " Run: agents use $AGENT <version>" >&2
387
416
  exit 1
388
417
  fi
389
418
  else
419
+ # No managed version at all. If we adopted this command's native launcher,
420
+ # run it so the command keeps working; otherwise report it's unconfigured.
421
+ exec_adopted_original "$@"
390
422
  echo "agents: no version of $AGENT configured" >&2
391
423
  echo " Run: agents add $AGENT@<version>" >&2
392
424
  exit 1
@@ -414,16 +446,18 @@ if [ "$AGENT" = "grok" ]; then
414
446
  fi
415
447
  fi
416
448
  if [ -z "$BINARY" ] || [ ! -x "$BINARY" ]; then
417
- # Last resort: whatever is on PATH (user may have installed grok globally).
418
- # Refuse anything under our own shims dir: the shims dir sits ahead of
419
- # ~/.local/bin on PATH, so "command -v grok" resolves to THIS dispatcher.
420
- # exec-ing it would re-enter and spin in an infinite re-exec loop (the same
421
- # bug the droid branch below guards against). Fall through to the clean
422
- # "not installed" error instead.
423
- BINARY=$(command -v grok 2>/dev/null || echo "")
424
- case "$BINARY" in
425
- "$AGENTS_USER_DIR/.cache/shims/"*) BINARY="" ;;
426
- esac
449
+ # Last resort: the adopted native launcher (recorded absolute path) if we
450
+ # adopted grok, else whatever is on PATH. Prefer the adopted record after
451
+ # adoption, "command -v grok" resolves to the ~/.local/bin symlink that now
452
+ # points at THIS dispatcher, so exec-ing it would re-enter and spin forever.
453
+ BINARY=$(adopted_original_bin || echo "")
454
+ if [ -z "$BINARY" ]; then
455
+ BINARY=$(command -v grok 2>/dev/null || echo "")
456
+ # Refuse anything that resolves into our own shims dir (the dispatcher).
457
+ case "$(command -v "$BINARY" 2>/dev/null; readlink -f "$BINARY" 2>/dev/null)" in
458
+ *"$AGENTS_USER_DIR/.cache/shims/"*) BINARY="" ;;
459
+ esac
460
+ fi
427
461
  fi
428
462
  # Kimi is a normal npm agent: "agents add kimi" npm-installs
429
463
  # @moonshot-ai/kimi-code into the version dir and the binary lands at
@@ -440,14 +474,20 @@ if [ "$AGENT" = "grok" ]; then
440
474
  # IS this dispatcher, so exec'ing it would re-enter and spin in an infinite
441
475
  # re-exec loop (the bug this branch fixes).
442
476
  elif [ "$AGENT" = "droid" ]; then
443
- DROID_BINARY="$HOME/.local/bin/droid"
444
- if [ -x "$DROID_BINARY" ]; then
445
- BINARY="$DROID_BINARY"
446
- else
447
- BINARY=$(command -v droid 2>/dev/null || echo "")
448
- case "$BINARY" in
449
- "$AGENTS_USER_DIR/.cache/shims/"*) BINARY="" ;;
450
- esac
477
+ # Prefer the adopted record first: if droid's ~/.local/bin/droid launcher was
478
+ # adopted, that fixed path now points at THIS dispatcher, so using it directly
479
+ # would infinite-loop. The record holds the real original binary.
480
+ BINARY=$(adopted_original_bin || echo "")
481
+ if [ -z "$BINARY" ]; then
482
+ DROID_BINARY="$HOME/.local/bin/droid"
483
+ if [ -x "$DROID_BINARY" ] && [ "$(readlink -f "$DROID_BINARY" 2>/dev/null)" != "$(readlink -f "$AGENTS_USER_DIR/.cache/shims/$CLI_COMMAND" 2>/dev/null)" ]; then
484
+ BINARY="$DROID_BINARY"
485
+ else
486
+ BINARY=$(command -v droid 2>/dev/null || echo "")
487
+ case "$(readlink -f "$BINARY" 2>/dev/null)" in
488
+ "$AGENTS_USER_DIR/.cache/shims/"*) BINARY="" ;;
489
+ esac
490
+ fi
451
491
  fi
452
492
  else
453
493
  BINARY="$VERSION_DIR/node_modules/.bin/$CLI_COMMAND"
@@ -481,6 +521,7 @@ if [ ! -x "$BINARY" ]; then
481
521
  echo " ✔ Installed $AGENT@$VERSION" >&2
482
522
  else
483
523
  echo " ✗ Failed to install $AGENT@$VERSION" >&2
524
+ exec_adopted_original "$@"
484
525
  exit 1
485
526
  fi
486
527
  else
@@ -498,15 +539,18 @@ if [ ! -x "$BINARY" ]; then
498
539
  BINARY="$VERSION_DIR/node_modules/.bin/$CLI_COMMAND"
499
540
  ;;
500
541
  *)
542
+ exec_adopted_original "$@"
501
543
  echo " Run: agents add $AGENT@$VERSION" >&2
502
544
  exit 1
503
545
  ;;
504
546
  esac
505
547
  else
548
+ exec_adopted_original "$@"
506
549
  echo " Run: agents add $AGENT@$VERSION" >&2
507
550
  exit 1
508
551
  fi
509
552
  else
553
+ exec_adopted_original "$@"
510
554
  echo "agents: $AGENT@$VERSION not installed" >&2
511
555
  echo " Run: agents add $AGENT@$VERSION" >&2
512
556
  exit 1
@@ -1630,6 +1674,179 @@ export function removeLegacyUserShim(agent, overrides) {
1630
1674
  return false;
1631
1675
  }
1632
1676
  }
1677
+ /**
1678
+ * Where an adopted launcher's provenance is recorded. Lives under durable
1679
+ * `.history` (NOT the regenerable `.cache`) so the reverse pointer to the native
1680
+ * binary survives a cache wipe — the shim reads it to fall through to the native
1681
+ * binary by absolute path when no managed version resolves. Two lines:
1682
+ * line 1 = original binary, line 2 = launcher path (for `--release`).
1683
+ */
1684
+ export function getAdoptedRecordPath(agent, historyDir = getHistoryDir()) {
1685
+ return path.join(historyDir, 'adopted-launchers', AGENTS[agent].cliCommand);
1686
+ }
1687
+ /**
1688
+ * The launcher a harness's own installer drops in an early-PATH dir. Detection
1689
+ * for adoption keys on the launcher *existing as a symlink resolving outside our
1690
+ * shims dir* — NOT on current PATH order. That's deliberate: the shim only loses
1691
+ * PATH races in non-interactive / GUI-launched shells, which an interactive
1692
+ * `agents` run can't observe via its own PATH. Keying on the durable symlink lets
1693
+ * auto-adoption fire for those users too. Returns the launcher path or null.
1694
+ */
1695
+ export function findAdoptableLauncher(agent, overrides) {
1696
+ const cliCommand = AGENTS[agent].cliCommand;
1697
+ const homeDir = overrides?.homeDir ?? os.homedir();
1698
+ const shimsDirReal = canonical(overrides?.shimsDir ?? getShimsDir());
1699
+ // ~/.local/bin is where grok/kimi/antigravity/claude/codex/droid self-install.
1700
+ const candidate = path.join(homeDir, '.local', 'bin', cliCommand);
1701
+ let stat;
1702
+ try {
1703
+ stat = fs.lstatSync(candidate);
1704
+ }
1705
+ catch {
1706
+ return null;
1707
+ }
1708
+ if (!stat.isSymbolicLink())
1709
+ return null; // real binaries are never auto-adopted
1710
+ let resolved;
1711
+ try {
1712
+ resolved = fs.realpathSync(candidate); // broken symlink throws → skip
1713
+ }
1714
+ catch {
1715
+ return null;
1716
+ }
1717
+ // Already ours, or resolves into our shims dir → not adoptable.
1718
+ if (resolved === shimsDirReal || resolved.startsWith(shimsDirReal + path.sep))
1719
+ return null;
1720
+ return candidate;
1721
+ }
1722
+ /** Canonical path for identity comparison — realpath when it exists (resolves
1723
+ * symlinks AND platform aliases like macOS /var → /private/var), else resolve. */
1724
+ function canonical(p) {
1725
+ try {
1726
+ return fs.realpathSync(p);
1727
+ }
1728
+ catch {
1729
+ return path.resolve(p);
1730
+ }
1731
+ }
1732
+ /**
1733
+ * Adopt the harness's own launcher that shadows our shim on PATH.
1734
+ *
1735
+ * PATH-ordering fixes (editing rc files) can never reliably win: `~/.local/bin`
1736
+ * (where grok/droid/etc. self-install) is prepended in `.zshenv`/`.zprofile`
1737
+ * for *every* shell, while our shims prepend only lands in `.zshrc`
1738
+ * (interactive). No single rc file guarantees "last prepend wins" across zsh's
1739
+ * whole sourcing chain, so the shim loses in non-interactive / GUI-launched
1740
+ * contexts. Instead of fighting PATH order, we *become* the launcher: replace
1741
+ * the shadowing symlink with one pointing at our shim, and record the real
1742
+ * original so the shim falls through to it when no managed version is selected.
1743
+ *
1744
+ * Regression bounds:
1745
+ * - Only ever touches a **symlink** (never renames/deletes a real binary).
1746
+ * - Records the resolved original + launcher path for lossless restore
1747
+ * (`releaseAdoptedLauncher`), in durable `.history` so a cache wipe can't
1748
+ * orphan the reverse pointer.
1749
+ * - Idempotent: a no-op once the launcher already points at our shim.
1750
+ * - Never records our own shim as the "original" (would loop).
1751
+ */
1752
+ export function adoptShadowingLauncher(agent, overrides) {
1753
+ const shimsDir = overrides?.shimsDir ?? getShimsDir();
1754
+ const shimPath = path.join(shimsDir, AGENTS[agent].cliCommand);
1755
+ const shimReal = canonical(shimPath);
1756
+ const shimsDirReal = canonical(shimsDir);
1757
+ const launcher = overrides?.shadowedBy ?? getPathShadowingExecutable(agent) ?? findAdoptableLauncher(agent, { shimsDir });
1758
+ if (!launcher)
1759
+ return { adopted: false, reason: 'no-shadow' };
1760
+ let stat;
1761
+ try {
1762
+ stat = fs.lstatSync(launcher);
1763
+ }
1764
+ catch {
1765
+ return { adopted: false, reason: 'error', launcher };
1766
+ }
1767
+ // Only adopt symlinks. A real binary in an early-PATH dir is left untouched —
1768
+ // renaming a multi-hundred-MB native binary is exactly the kind of surprise
1769
+ // this feature must avoid. (Its shim stays reachable via the versioned name.)
1770
+ if (!stat.isSymbolicLink()) {
1771
+ return { adopted: false, reason: 'not-a-symlink', launcher };
1772
+ }
1773
+ const resolved = canonical(launcher);
1774
+ // Already ours → nothing to do.
1775
+ if (resolved === shimReal) {
1776
+ return { adopted: false, reason: 'already-adopted', launcher };
1777
+ }
1778
+ // Never record a target that resolves back into our shims dir: exec-ing it
1779
+ // from the shim would re-enter this dispatcher and spin forever.
1780
+ if (resolved === shimsDirReal || resolved.startsWith(shimsDirReal + path.sep)) {
1781
+ return { adopted: false, reason: 'unsafe-target', launcher };
1782
+ }
1783
+ try {
1784
+ const recordPath = getAdoptedRecordPath(agent, overrides?.historyDir);
1785
+ fs.mkdirSync(path.dirname(recordPath), { recursive: true });
1786
+ // Line 1: original binary (shim fall-through target). Line 2: launcher path
1787
+ // (release restores this exact symlink, independent of PATH order at release
1788
+ // time — the M3 fix). Absolute launcher path so release never has to
1789
+ // re-derive it from a PATH scan that may miss.
1790
+ fs.writeFileSync(recordPath, `${resolved}\n${path.resolve(launcher)}\n`, 'utf-8');
1791
+ // Repoint the launcher at our shim. rm + symlink (not atomic rename) is fine
1792
+ // here: the record is already written, so a crash between the two leaves a
1793
+ // recoverable state and the next run re-adopts idempotently.
1794
+ fs.rmSync(launcher);
1795
+ fs.symlinkSync(shimPath, launcher);
1796
+ return { adopted: true, launcher, original: resolved };
1797
+ }
1798
+ catch {
1799
+ return { adopted: false, reason: 'error', launcher };
1800
+ }
1801
+ }
1802
+ /**
1803
+ * Undo `adoptShadowingLauncher`: repoint the launcher back at the recorded
1804
+ * original and drop the record. Reversible escape hatch for users who want the
1805
+ * native launcher to win. Returns the restored original path, or null if there
1806
+ * was nothing to release.
1807
+ */
1808
+ export function releaseAdoptedLauncher(agent, overrides) {
1809
+ const shimsDir = overrides?.shimsDir ?? getShimsDir();
1810
+ const recordPath = getAdoptedRecordPath(agent, overrides?.historyDir);
1811
+ let lines;
1812
+ try {
1813
+ lines = fs.readFileSync(recordPath, 'utf-8').split('\n').map((l) => l.trim());
1814
+ }
1815
+ catch {
1816
+ return null;
1817
+ }
1818
+ const original = lines[0] ?? '';
1819
+ if (!original)
1820
+ return null;
1821
+ // Line 2 is the exact launcher we rewrote at adopt time. Restoring it directly
1822
+ // (rather than re-deriving from PATH) means release works regardless of the
1823
+ // current shell's PATH order — the M3 fix. Fall back to a PATH scan only for
1824
+ // records written before this format existed.
1825
+ const launcher = lines[1] || getPathShadowingExecutable(agent) || original;
1826
+ const shimReal = canonical(path.join(shimsDir, AGENTS[agent].cliCommand));
1827
+ try {
1828
+ // Only rewrite the launcher if it currently points at our shim (i.e. we own
1829
+ // it). If the user has since replaced it themselves, leave it alone.
1830
+ let pointsAtShim = false;
1831
+ try {
1832
+ pointsAtShim = fs.lstatSync(launcher).isSymbolicLink()
1833
+ && canonical(launcher) === shimReal;
1834
+ }
1835
+ catch { /* launcher gone — recreate below */ }
1836
+ if (pointsAtShim || !fs.existsSync(launcher)) {
1837
+ try {
1838
+ fs.rmSync(launcher);
1839
+ }
1840
+ catch { /* may not exist */ }
1841
+ fs.symlinkSync(original, launcher);
1842
+ }
1843
+ fs.rmSync(recordPath);
1844
+ return original;
1845
+ }
1846
+ catch {
1847
+ return null;
1848
+ }
1849
+ }
1633
1850
  /**
1634
1851
  * Check if the agent's CLI command is shadowed by a shell alias.
1635
1852
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.37",
3
+ "version": "1.20.38",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",