@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.
- package/dist/commands/computer-actions.d.ts +10 -0
- package/dist/commands/computer-actions.js +47 -17
- package/dist/commands/doctor.js +48 -1
- package/dist/commands/go.d.ts +28 -0
- package/dist/commands/go.js +238 -0
- package/dist/commands/sessions-picker.d.ts +2 -0
- package/dist/commands/sessions-picker.js +10 -1
- package/dist/commands/sessions.d.ts +1 -1
- package/dist/commands/sessions.js +8 -1
- package/dist/index.js +59 -68
- package/dist/lib/session/active.d.ts +6 -0
- package/dist/lib/session/active.js +1 -1
- package/dist/lib/shims.d.ts +65 -1
- package/dist/lib/shims.js +237 -20
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
258
|
-
//
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
505
|
+
await applyFocusPolicy(client, pid, opts);
|
|
476
506
|
const params = { pid };
|
|
477
507
|
if (opts.id)
|
|
478
508
|
params.element_id = opts.id;
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
-
//
|
|
526
|
-
//
|
|
527
|
-
|
|
528
|
-
|
|
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
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
//
|
|
581
|
-
//
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
-
|
|
596
|
-
|
|
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')
|
package/dist/lib/shims.d.ts
CHANGED
|
@@ -77,7 +77,7 @@ export interface ConflictInfo {
|
|
|
77
77
|
* top-level entry add/remove — deep edits to plugin contents won't
|
|
78
78
|
* trigger auto-resync, run `agents sync` for that.
|
|
79
79
|
*/
|
|
80
|
-
export declare const SHIM_SCHEMA_VERSION =
|
|
80
|
+
export declare const SHIM_SCHEMA_VERSION = 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 =
|
|
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:
|
|
418
|
-
#
|
|
419
|
-
#
|
|
420
|
-
# exec-ing it would re-enter and spin
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
"$
|
|
426
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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.
|
|
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",
|