@phnx-labs/agents-cli 1.20.36 → 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-sync.d.ts +3 -0
- package/dist/commands/sessions-sync.js +44 -4
- package/dist/commands/sessions.d.ts +8 -1
- package/dist/commands/sessions.js +155 -36
- package/dist/index.js +59 -68
- package/dist/lib/daemon.js +4 -2
- package/dist/lib/devices/resolve-target.d.ts +24 -0
- package/dist/lib/devices/resolve-target.js +80 -0
- package/dist/lib/session/active.d.ts +25 -0
- package/dist/lib/session/active.js +11 -5
- package/dist/lib/session/db.d.ts +2 -1
- package/dist/lib/session/db.js +41 -5
- package/dist/lib/session/discover.d.ts +2 -0
- package/dist/lib/session/discover.js +16 -1
- package/dist/lib/session/ghostty-tabs.d.ts +33 -0
- package/dist/lib/session/ghostty-tabs.js +126 -0
- package/dist/lib/session/relative-time.js +6 -2
- package/dist/lib/session/remote-active.js +4 -14
- package/dist/lib/session/remote-list.js +4 -12
- package/dist/lib/session/remote.js +4 -2
- package/dist/lib/session/sync/config.d.ts +13 -0
- package/dist/lib/session/sync/config.js +56 -0
- package/dist/lib/session/types.d.ts +6 -0
- package/dist/lib/shims.d.ts +65 -1
- package/dist/lib/shims.js +237 -20
- package/dist/lib/sync-umbrella.js +4 -4
- package/dist/lib/tmux/session.d.ts +10 -0
- package/dist/lib/tmux/session.js +31 -0
- 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;
|
|
@@ -7,6 +7,9 @@ import type { Command } from 'commander';
|
|
|
7
7
|
interface SyncCmdOptions {
|
|
8
8
|
verbose?: boolean;
|
|
9
9
|
json?: boolean;
|
|
10
|
+
enable?: boolean;
|
|
11
|
+
disable?: boolean;
|
|
12
|
+
status?: boolean;
|
|
10
13
|
}
|
|
11
14
|
export declare function runSessionsSync(options: SyncCmdOptions): Promise<void>;
|
|
12
15
|
export declare function registerSessionsSyncCommand(sessionsCmd: Command): void;
|
|
@@ -5,9 +5,34 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import chalk from 'chalk';
|
|
7
7
|
import { setHelpSections } from '../lib/help.js';
|
|
8
|
-
import { isSyncConfigured, SYNC_BUNDLE } from '../lib/session/sync/config.js';
|
|
8
|
+
import { isSyncConfigured, isSyncEnabled, setSyncEnabled, SYNC_BUNDLE, } from '../lib/session/sync/config.js';
|
|
9
9
|
import { syncSessions } from '../lib/session/sync/sync.js';
|
|
10
10
|
export async function runSessionsSync(options) {
|
|
11
|
+
// Toggle / status actions short-circuit before any network cycle.
|
|
12
|
+
if (options.disable) {
|
|
13
|
+
setSyncEnabled(false);
|
|
14
|
+
console.log(chalk.yellow('Automatic session sync disabled') +
|
|
15
|
+
chalk.dim(' — the daemon stops pushing/pulling within ~90s. Re-enable: agents sessions sync --enable'));
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (options.enable) {
|
|
19
|
+
setSyncEnabled(true);
|
|
20
|
+
console.log(chalk.green('Automatic session sync enabled') + chalk.dim(' — the daemon resumes on its next cycle.'));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (options.status) {
|
|
24
|
+
const enabled = isSyncEnabled();
|
|
25
|
+
const configured = isSyncConfigured();
|
|
26
|
+
if (options.json) {
|
|
27
|
+
console.log(JSON.stringify({ enabled, configured }, null, 2));
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
console.log(`automatic sync: ${enabled ? chalk.green('enabled') : chalk.yellow('disabled')}` +
|
|
31
|
+
chalk.dim(' · ') +
|
|
32
|
+
`credentials: ${configured ? chalk.green('configured') : chalk.yellow(`missing (${SYNC_BUNDLE})`)}`);
|
|
33
|
+
}
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
11
36
|
if (!isSyncConfigured()) {
|
|
12
37
|
console.error(chalk.red(`Sessions sync is not configured.`) +
|
|
13
38
|
`\nAdd R2 credentials to the '${SYNC_BUNDLE}' bundle:\n` +
|
|
@@ -51,7 +76,10 @@ export function registerSessionsSyncCommand(sessionsCmd) {
|
|
|
51
76
|
.command('sync')
|
|
52
77
|
.description('Sync session transcripts across machines via R2 (CRDT merge). Claude and Codex.')
|
|
53
78
|
.option('-v, --verbose', 'Log each pushed and pulled session')
|
|
54
|
-
.option('--json', 'Output the sync result as JSON')
|
|
79
|
+
.option('--json', 'Output the sync result as JSON')
|
|
80
|
+
.option('--enable', 'Turn ON automatic background sync on this machine (persisted)')
|
|
81
|
+
.option('--disable', 'Turn OFF automatic background sync on this machine (persisted)')
|
|
82
|
+
.option('--status', 'Show whether automatic sync is enabled and configured');
|
|
55
83
|
setHelpSections(syncCmd, {
|
|
56
84
|
examples: `
|
|
57
85
|
# One sync cycle (push local changes, pull + merge from other machines)
|
|
@@ -59,15 +87,27 @@ export function registerSessionsSyncCommand(sessionsCmd) {
|
|
|
59
87
|
|
|
60
88
|
# See exactly what moved
|
|
61
89
|
agents sessions sync --verbose
|
|
90
|
+
|
|
91
|
+
# Stop this machine's daemon from auto-syncing (prefer on-demand --host reads)
|
|
92
|
+
agents sessions sync --disable
|
|
93
|
+
|
|
94
|
+
# Check the current switch + credential state
|
|
95
|
+
agents sessions sync --status
|
|
62
96
|
`,
|
|
63
97
|
notes: `
|
|
64
98
|
- Credentials come from the '${SYNC_BUNDLE}' secrets bundle (R2 S3 API, read+write).
|
|
65
99
|
- Each machine writes only its own prefix; conflicts are impossible by construction.
|
|
66
100
|
- The daemon runs this automatically (~90s); this command forces an immediate cycle.
|
|
67
101
|
- Sessions present locally always win; synced-in copies fill in other machines' sessions.
|
|
102
|
+
- --disable/--enable persist a machine-local switch (~/.agents/.history) that gates the
|
|
103
|
+
daemon's automatic sync; a bare 'agents sessions sync' still forces a manual cycle.
|
|
104
|
+
The AGENTS_SESSIONS_SYNC env var (on/off) overrides the switch for one invocation.
|
|
68
105
|
`,
|
|
69
106
|
});
|
|
70
|
-
|
|
71
|
-
|
|
107
|
+
// `--json` is also declared on the parent `sessions` command, so a bare
|
|
108
|
+
// `options` arg would miss it (Commander binds the shared flag to the parent).
|
|
109
|
+
// optsWithGlobals() merges ancestor + local options so --json resolves here.
|
|
110
|
+
syncCmd.action(async (_options, cmd) => {
|
|
111
|
+
await runSessionsSync(cmd.optsWithGlobals());
|
|
72
112
|
});
|
|
73
113
|
}
|
|
@@ -2,6 +2,13 @@ import type { Command } from 'commander';
|
|
|
2
2
|
import type { SessionAgentId, SessionMeta } from '../lib/session/types.js';
|
|
3
3
|
import { type ActiveSession } from '../lib/session/active.js';
|
|
4
4
|
import { type PickedSession } from './sessions-picker.js';
|
|
5
|
+
/**
|
|
6
|
+
* Strip terminal/harness noise from a preview so the column stays a single line
|
|
7
|
+
* of plain prose: OSC title escapes, CSI/SGR ANSI, and the harness wrapper tags
|
|
8
|
+
* (`<local-command-stdout>`, `<task-notification>`, `<command-*>`) that leak from
|
|
9
|
+
* a captured transcript tail. Collapses runs of whitespace.
|
|
10
|
+
*/
|
|
11
|
+
export declare function cleanPreview(text: string): string;
|
|
5
12
|
/**
|
|
6
13
|
* Index live sessions by their full session UUID so a historical `SessionMeta`
|
|
7
14
|
* row (`meta.id`) can be matched to the session that is still running now.
|
|
@@ -157,7 +164,7 @@ export declare function formatPickerLabel(s: SessionMeta, query: string, cols?:
|
|
|
157
164
|
* so it stays fixed across the picker's re-renders within a single run.
|
|
158
165
|
*/
|
|
159
166
|
export declare function formatPickerTip(sessions: SessionMeta[]): string;
|
|
160
|
-
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>;
|
|
161
168
|
/**
|
|
162
169
|
* Resume a session in the current terminal — a foreground takeover of this
|
|
163
170
|
* process. Used by the single-select picker and by `sessions resume` when the
|