@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.
Files changed (36) hide show
  1. package/dist/commands/computer-actions.d.ts +10 -0
  2. package/dist/commands/computer-actions.js +47 -17
  3. package/dist/commands/doctor.js +48 -1
  4. package/dist/commands/go.d.ts +28 -0
  5. package/dist/commands/go.js +238 -0
  6. package/dist/commands/sessions-picker.d.ts +2 -0
  7. package/dist/commands/sessions-picker.js +10 -1
  8. package/dist/commands/sessions-sync.d.ts +3 -0
  9. package/dist/commands/sessions-sync.js +44 -4
  10. package/dist/commands/sessions.d.ts +8 -1
  11. package/dist/commands/sessions.js +155 -36
  12. package/dist/index.js +59 -68
  13. package/dist/lib/daemon.js +4 -2
  14. package/dist/lib/devices/resolve-target.d.ts +24 -0
  15. package/dist/lib/devices/resolve-target.js +80 -0
  16. package/dist/lib/session/active.d.ts +25 -0
  17. package/dist/lib/session/active.js +11 -5
  18. package/dist/lib/session/db.d.ts +2 -1
  19. package/dist/lib/session/db.js +41 -5
  20. package/dist/lib/session/discover.d.ts +2 -0
  21. package/dist/lib/session/discover.js +16 -1
  22. package/dist/lib/session/ghostty-tabs.d.ts +33 -0
  23. package/dist/lib/session/ghostty-tabs.js +126 -0
  24. package/dist/lib/session/relative-time.js +6 -2
  25. package/dist/lib/session/remote-active.js +4 -14
  26. package/dist/lib/session/remote-list.js +4 -12
  27. package/dist/lib/session/remote.js +4 -2
  28. package/dist/lib/session/sync/config.d.ts +13 -0
  29. package/dist/lib/session/sync/config.js +56 -0
  30. package/dist/lib/session/types.d.ts +6 -0
  31. package/dist/lib/shims.d.ts +65 -1
  32. package/dist/lib/shims.js +237 -20
  33. package/dist/lib/sync-umbrella.js +4 -4
  34. package/dist/lib/tmux/session.d.ts +10 -0
  35. package/dist/lib/tmux/session.js +31 -0
  36. 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
- // --raise flag: app-level focus_window before the main action so coordinate
258
- // clicks and keystrokes land on a visible, key window.
259
- async function raiseIfRequested(client, pid, raise) {
260
- if (raise)
257
+ // Focus-safety policy for input verbs. Element mode (--id) drives an app through
258
+ // Accessibility actions (AXPress / set AXValue) that never activate the app or move
259
+ // the cursor, so the user keeps working while an agent acts. The two paths that DO
260
+ // take over the screen are (a) --raise (brings the app to the front + steals keyboard
261
+ // focus) and (b) coordinate mode (--x/--y warps the physical cursor and needs the app
262
+ // frontmost). Both are surfaced as notes, and --raise is ignored in element mode so a
263
+ // reflexive flag cannot hijack the user's session.
264
+ // Pure, unit-tested: the focus/cursor costs an action will impose, as human notes.
265
+ export function focusStealNotes(opts) {
266
+ const notes = [];
267
+ const elementMode = opts.id != null;
268
+ if (opts.raise) {
269
+ notes.push(elementMode
270
+ ? 'note: --raise ignored in element mode (--id) — element actions do not need the app frontmost, so your focus is left alone.'
271
+ : 'note: --raise brings the target app to the front and takes keyboard focus from you. Element mode (`describe` then --id) drives apps without stealing focus.');
272
+ }
273
+ if (!elementMode && (opts.x != null || opts.y != null)) {
274
+ notes.push('note: coordinate mode moves your real cursor and needs the app frontmost. Prefer element mode (`describe` then --id) to act without moving your pointer.');
275
+ }
276
+ return notes;
277
+ }
278
+ // Pure, unit-tested: whether --raise is actually honored. Element mode suppresses it
279
+ // so an element-targeted action never steals the user's foreground.
280
+ export function shouldRaise(opts) {
281
+ return Boolean(opts.raise) && opts.id == null;
282
+ }
283
+ // Apply the focus-safety policy for an input verb: print the cost notes, and raise
284
+ // only when raising is actually warranted (non-element mode with --raise).
285
+ async function applyFocusPolicy(client, pid, opts) {
286
+ for (const note of focusStealNotes(opts))
287
+ console.error(note);
288
+ if (shouldRaise(opts))
261
289
  unwrap(await client.call('focus_window', { pid }));
262
290
  }
263
291
  function emit(result, json, human) {
@@ -281,9 +309,9 @@ function addTargetOpts(cmd) {
281
309
  // Add the shared --id/--x/--y element-or-coords options to a verb.
282
310
  function addElementOrCoordOpts(cmd) {
283
311
  return cmd
284
- .option('--id <@eN>', 'Element id from `describe`')
285
- .option('--x <n>', 'X coordinate (global, points)', (v) => parseInt(v, 10))
286
- .option('--y <n>', 'Y coordinate (global, points)', (v) => parseInt(v, 10));
312
+ .option('--id <@eN>', 'Element id from `describe` (focus-safe: no foreground steal, no cursor move)')
313
+ .option('--x <n>', 'X coordinate (global, points; moves your real cursor, needs app frontmost)', (v) => parseInt(v, 10))
314
+ .option('--y <n>', 'Y coordinate (global, points; moves your real cursor, needs app frontmost)', (v) => parseInt(v, 10));
287
315
  }
288
316
  export function registerActionCommands(program) {
289
317
  // apps — list_apps
@@ -323,7 +351,7 @@ export function registerActionCommands(program) {
323
351
  .description('Click an element (--id) or screen coordinate (--x --y)')
324
352
  .option('--count <n>', 'Click count (2 = double-click)', (v) => parseInt(v, 10))
325
353
  .option('--background', 'Focus-safe postToPid delivery (plain AppKit only; skips HID tap)')
326
- .option('--raise', 'Bring the target app to the front first')
354
+ .option('--raise', 'Bring the target app to the front first (steals your foreground + keyboard focus; ignored in element mode --id)')
327
355
  .option('--json', 'Emit JSON'))).action(async (opts) => {
328
356
  await withClient(async (client) => {
329
357
  const pid = await resolveTargetPid(client, opts, { verb: 'click' });
@@ -332,7 +360,7 @@ export function registerActionCommands(program) {
332
360
  console.error(spec.error);
333
361
  process.exit(1);
334
362
  }
335
- await raiseIfRequested(client, pid, opts.raise);
363
+ await applyFocusPolicy(client, pid, opts);
336
364
  const params = { pid, ...spec.params };
337
365
  if (opts.count != null)
338
366
  params.count = opts.count;
@@ -354,6 +382,7 @@ export function registerActionCommands(program) {
354
382
  console.error(spec.error);
355
383
  process.exit(1);
356
384
  }
385
+ await applyFocusPolicy(client, pid, opts);
357
386
  const res = unwrap(await client.call('right_click', { pid, ...spec.params }));
358
387
  emit(res, Boolean(opts.json), () => `right-clicked (${res.method ?? 'ok'})`);
359
388
  });
@@ -373,6 +402,7 @@ export function registerActionCommands(program) {
373
402
  console.error(spec.error);
374
403
  process.exit(1);
375
404
  }
405
+ await applyFocusPolicy(client, pid, opts);
376
406
  const params = { pid, ...spec.params, text: opts.text };
377
407
  if (opts.commit)
378
408
  params.commit = true;
@@ -388,13 +418,13 @@ export function registerActionCommands(program) {
388
418
  .description('Type an arbitrary unicode string into the focused field (focus first via click/focus)')
389
419
  .requiredOption('--text <s>', 'Text to type')
390
420
  .option('--commit', 'Press Return after typing')
391
- .option('--raise', 'Bring the target app to the front first')
421
+ .option('--raise', 'Bring the target app to the front first (steals your foreground + keyboard focus; ignored in element mode --id)')
392
422
  .option('--require-frontmost', 'Fail (not warn) if the target is not the frontmost app')
393
423
  .option('--char-delay <ms>', 'Inter-character delay in ms (default 4; raise for lossy keyboard relays like VM guests, e.g. 25). Clamped to [1, 250].', (v) => parseInt(v, 10))
394
424
  .option('--json', 'Emit JSON')).action(async (opts) => {
395
425
  await withClient(async (client) => {
396
426
  const pid = await resolveTargetPid(client, opts, { verb: 'type-text' });
397
- await raiseIfRequested(client, pid, opts.raise);
427
+ await applyFocusPolicy(client, pid, opts);
398
428
  const params = { pid, text: opts.text };
399
429
  if (opts.commit)
400
430
  params.commit = true;
@@ -413,12 +443,12 @@ export function registerActionCommands(program) {
413
443
  .command('key')
414
444
  .description('Send a key chord, e.g. "cmd+shift+s", "enter", "esc"')
415
445
  .requiredOption('--keys <chord>', 'Key chord')
416
- .option('--raise', 'Bring the target app to the front first')
446
+ .option('--raise', 'Bring the target app to the front first (steals your foreground + keyboard focus; ignored in element mode --id)')
417
447
  .option('--require-frontmost', 'Fail (not warn) if the target is not the frontmost app')
418
448
  .option('--json', 'Emit JSON')).action(async (opts) => {
419
449
  await withClient(async (client) => {
420
450
  const pid = await resolveTargetPid(client, opts, { verb: 'key' });
421
- await raiseIfRequested(client, pid, opts.raise);
451
+ await applyFocusPolicy(client, pid, opts);
422
452
  const params = { pid, keys: opts.keys };
423
453
  if (opts.requireFrontmost)
424
454
  params.require_frontmost = true;
@@ -435,7 +465,7 @@ export function registerActionCommands(program) {
435
465
  .requiredOption('--to <x,y>', 'End coordinate "x,y"')
436
466
  .option('--button <left|right>', 'Mouse button', 'left')
437
467
  .option('--background', 'Focus-safe postToPid delivery (plain AppKit only)')
438
- .option('--raise', 'Bring the target app to the front first')
468
+ .option('--raise', 'Bring the target app to the front first (steals your foreground + keyboard focus; ignored in element mode --id)')
439
469
  .option('--json', 'Emit JSON')).action(async (opts) => {
440
470
  let from;
441
471
  let to;
@@ -449,7 +479,7 @@ export function registerActionCommands(program) {
449
479
  }
450
480
  await withClient(async (client) => {
451
481
  const pid = await resolveTargetPid(client, opts, { verb: 'drag' });
452
- await raiseIfRequested(client, pid, opts.raise);
482
+ await applyFocusPolicy(client, pid, opts);
453
483
  const params = {
454
484
  pid,
455
485
  from: [from.x, from.y],
@@ -468,11 +498,11 @@ export function registerActionCommands(program) {
468
498
  .description('Scroll by a pixel delta at an element or coordinate')
469
499
  .option('--dy <n>', 'Vertical delta (negative = down)', (v) => parseInt(v, 10))
470
500
  .option('--dx <n>', 'Horizontal delta', (v) => parseInt(v, 10))
471
- .option('--raise', 'Bring the target app to the front first')
501
+ .option('--raise', 'Bring the target app to the front first (steals your foreground + keyboard focus; ignored in element mode --id)')
472
502
  .option('--json', 'Emit JSON'))).action(async (opts) => {
473
503
  await withClient(async (client) => {
474
504
  const pid = await resolveTargetPid(client, opts, { verb: 'scroll' });
475
- await raiseIfRequested(client, pid, opts.raise);
505
+ await applyFocusPolicy(client, pid, opts);
476
506
  const params = { pid };
477
507
  if (opts.id)
478
508
  params.element_id = opts.id;
@@ -482,7 +482,9 @@ export function registerDoctorCommand(program) {
482
482
  .option('--diff', 'In target mode, include unified diffs for divergent files')
483
483
  .option('--fix', 'Heal gaps: install missing resources, repair invalid plugin manifests, refresh stale plugins, and reconcile drift (all installed versions, or just the target)')
484
484
  .option('--kind <kinds>', 'Restrict to comma-separated resource kinds (commands,skills,hooks,rules,mcp,permissions,subagents,plugins,promptcuts)')
485
- .option('--cwd <path>', 'Resolution cwd for project layer detection (default: process.cwd())');
485
+ .option('--cwd <path>', 'Resolution cwd for project layer detection (default: process.cwd())')
486
+ .option('--adopt <agent>', "Take over the agent's native launcher that shadows the shim (symlink it to the version-managed shim; reversible with --release)")
487
+ .option('--release <agent>', 'Undo --adopt: restore the native launcher agents-cli previously adopted');
486
488
  setHelpSections(doctorCmd, {
487
489
  examples: `
488
490
  # Overview: CLI availability + sync status + orphans across all defaults
@@ -509,6 +511,51 @@ export function registerDoctorCommand(program) {
509
511
  });
510
512
  doctorCmd.action(async (target, opts) => {
511
513
  const cwd = opts.cwd ? opts.cwd : process.cwd();
514
+ // Launcher adoption escape hatch. `--adopt <agent>` forces the take-over
515
+ // even for a non-default agent; `--release <agent>` reverses it.
516
+ if (opts.adopt || opts.release) {
517
+ if (opts.adopt && opts.release) {
518
+ console.error(chalk.red('--adopt and --release are mutually exclusive; pass only one.'));
519
+ process.exit(1);
520
+ }
521
+ const { adoptShadowingLauncher, releaseAdoptedLauncher } = await import('../lib/shims.js');
522
+ const raw = (opts.adopt || opts.release);
523
+ const agent = resolveAgentName(raw);
524
+ if (!agent) {
525
+ console.error(chalk.red(formatAgentError(raw)));
526
+ process.exit(1);
527
+ }
528
+ if (opts.release) {
529
+ const restored = releaseAdoptedLauncher(agent);
530
+ if (restored) {
531
+ console.log(chalk.green(`Released ${AGENTS[agent].cliCommand}: launcher restored to ${restored}.`));
532
+ }
533
+ else {
534
+ console.log(chalk.gray(`${AGENTS[agent].cliCommand} has no adopted launcher to release.`));
535
+ }
536
+ return;
537
+ }
538
+ // adoptShadowingLauncher resolves the launcher itself (PATH shadow, then
539
+ // the durable ~/.local/bin symlink), so it forces the take-over even when
540
+ // this shell's PATH already has the shim first.
541
+ const result = adoptShadowingLauncher(agent);
542
+ if (result.adopted) {
543
+ console.log(chalk.green(`Adopted ${AGENTS[agent].cliCommand} launcher (${result.launcher} -> shim). Original recorded for --release; version management now wins regardless of PATH order.`));
544
+ }
545
+ else if (result.reason === 'already-adopted') {
546
+ console.log(chalk.gray(`${AGENTS[agent].cliCommand} launcher is already adopted.`));
547
+ }
548
+ else if (result.reason === 'no-shadow') {
549
+ console.log(chalk.gray(`Nothing to adopt — no ${AGENTS[agent].cliCommand} launcher found shadowing the shim (checked PATH and ~/.local/bin).`));
550
+ }
551
+ else if (result.reason === 'not-a-symlink') {
552
+ console.log(chalk.yellow(`${AGENTS[agent].cliCommand} is shadowed by a real binary (${result.launcher}), not a symlink. agents-cli won't move a real binary — remove/reorder it or reorder PATH.`));
553
+ }
554
+ else {
555
+ console.log(chalk.yellow(`Could not adopt ${AGENTS[agent].cliCommand} (${result.reason}).`));
556
+ }
557
+ return;
558
+ }
512
559
  // --fix turns the read-only diagnosis into a heal. With no target it heals
513
560
  // every installed version; with a target it scopes to that agent.
514
561
  if (opts.fix) {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `agents sessions go [id]` — jump to a LIVE agent session's terminal.
3
+ *
4
+ * No id -> the SAME rich interactive picker as `agents sessions` (worktree, PR,
5
+ * changed files, tools, tests, last response — this-machine first),
6
+ * filtered to sessions that are running right now.
7
+ * With id -> jump directly.
8
+ *
9
+ * "Jump" is not "resume" (which spawns a new process from the transcript). It walks
10
+ * you to the already-running terminal:
11
+ * local tmux -> attach (switch-client when already inside tmux)
12
+ * local Ghostty -> focus its tab (Cmd+<n> via System Events; tab # from ghostty-tabs)
13
+ * remote tmux -> ssh -tt + tmux attach (pane->session resolved on the remote)
14
+ * otherwise -> refuse with a reason + resume hint (cloud / no attach rail)
15
+ */
16
+ import type { Command } from 'commander';
17
+ import { type ActiveSession } from '../lib/session/active.js';
18
+ export declare function registerGoCommand(program: Command): void;
19
+ export interface Where {
20
+ label: string;
21
+ action: string;
22
+ }
23
+ /**
24
+ * Pure, testable mirror of `jumpTo`'s path selection (jumpTo itself has side
25
+ * effects — process.exit / ssh / osascript). Keep the branch ORDER in sync with
26
+ * `jumpTo` below: remote-tmux, then local-tmux, then ghostty, then refuse.
27
+ */
28
+ export declare function describeWhere(s: ActiveSession, self: string): Where;
@@ -0,0 +1,238 @@
1
+ /**
2
+ * `agents sessions go [id]` — jump to a LIVE agent session's terminal.
3
+ *
4
+ * No id -> the SAME rich interactive picker as `agents sessions` (worktree, PR,
5
+ * changed files, tools, tests, last response — this-machine first),
6
+ * filtered to sessions that are running right now.
7
+ * With id -> jump directly.
8
+ *
9
+ * "Jump" is not "resume" (which spawns a new process from the transcript). It walks
10
+ * you to the already-running terminal:
11
+ * local tmux -> attach (switch-client when already inside tmux)
12
+ * local Ghostty -> focus its tab (Cmd+<n> via System Events; tab # from ghostty-tabs)
13
+ * remote tmux -> ssh -tt + tmux attach (pane->session resolved on the remote)
14
+ * otherwise -> refuse with a reason + resume hint (cloud / no attach rail)
15
+ */
16
+ import chalk from 'chalk';
17
+ import path from 'path';
18
+ import { execFile } from 'child_process';
19
+ import { promisify } from 'util';
20
+ import { getActiveSessions, findSessionFileForKind } from '../lib/session/active.js';
21
+ import { gatherRemoteActive } from '../lib/session/remote-active.js';
22
+ import { discoverSessions } from '../lib/session/discover.js';
23
+ import { dedupeByMachineSession, mergeLocalFirst, pickSessionInteractive } from './sessions.js';
24
+ import { machineId } from '../lib/session/sync/config.js';
25
+ import { isInteractiveTerminal } from './utils.js';
26
+ import { attachTmux, runTmux } from '../lib/tmux/binary.js';
27
+ import { getDefaultSocketPath } from '../lib/tmux/paths.js';
28
+ import { sshStream, assertValidSshTarget, shellQuote } from '../lib/ssh-exec.js';
29
+ import { enumerateGhosttyTabs, assignGhosttyTabs } from '../lib/session/ghostty-tabs.js';
30
+ const execFileAsync = promisify(execFile);
31
+ export function registerGoCommand(program) {
32
+ program
33
+ .command('go')
34
+ .argument('[id]', 'Short/full session id to jump to; omit for an interactive picker')
35
+ .option('--local', 'Only this machine (skip the cross-host sweep)')
36
+ .description('Jump to a live agent session — attach its tmux, or focus its terminal tab')
37
+ .action(async (id, opts) => {
38
+ await goAction(id, opts);
39
+ });
40
+ }
41
+ async function goAction(id, opts) {
42
+ const self = machineId();
43
+ // Live jump targets (local + remote), keyed by session id.
44
+ const localActive = await getActiveSessions();
45
+ for (const s of localActive)
46
+ if (!s.machine)
47
+ s.machine = self;
48
+ let active = localActive;
49
+ if (!opts.local) {
50
+ try {
51
+ const remote = await gatherRemoteActive();
52
+ active = dedupeByMachineSession([...localActive, ...remote.sessions]);
53
+ }
54
+ catch { /* remote sweep is best-effort */ }
55
+ }
56
+ const activeById = new Map();
57
+ for (const s of active)
58
+ if (s.context !== 'cloud' && s.sessionId)
59
+ activeById.set(s.sessionId, s);
60
+ if (activeById.size === 0) {
61
+ console.log(chalk.gray('No live agent sessions to jump to.'));
62
+ return;
63
+ }
64
+ // Direct jump by id — no picker.
65
+ if (id) {
66
+ const q = id.toLowerCase();
67
+ const matches = [...activeById.values()].filter((s) => s.sessionId.toLowerCase().startsWith(q));
68
+ if (matches.length === 0) {
69
+ console.error(chalk.red(`No live session matching "${id}".`));
70
+ process.exitCode = 1;
71
+ return;
72
+ }
73
+ if (matches.length > 1) {
74
+ console.error(chalk.red(`"${id}" is ambiguous (${matches.length} matches). Use more of the id.`));
75
+ process.exitCode = 1;
76
+ return;
77
+ }
78
+ await jumpTo(matches[0], self);
79
+ return;
80
+ }
81
+ if (!isInteractiveTerminal()) {
82
+ console.error(chalk.red('go needs an interactive terminal, or pass a session id.'));
83
+ process.exitCode = 1;
84
+ return;
85
+ }
86
+ // Reuse the rich `sessions` picker over the live sessions' full SessionMeta.
87
+ const pool = await buildLivePool(activeById, self);
88
+ if (pool.length === 0) {
89
+ console.log(chalk.gray('No live sessions to jump to.'));
90
+ return;
91
+ }
92
+ const picked = await pickSessionInteractive(pool, 'Jump to a live session:', undefined, 0, 'jump');
93
+ if (!picked)
94
+ return;
95
+ const target = activeById.get(picked.session.id);
96
+ if (!target) {
97
+ console.log(chalk.yellow(`${picked.session.shortId} is no longer live — try: `) + chalk.gray(`agents sessions resume ${picked.session.shortId}`));
98
+ return;
99
+ }
100
+ await jumpTo(target, self);
101
+ }
102
+ /**
103
+ * Map each live session to its rich SessionMeta (worktree/PR/changes/tools/tests
104
+ * via the shared picker), reusing `discoverSessions`. Remote or unindexed live
105
+ * sessions get a minimal synthesized meta so they still appear and jump.
106
+ */
107
+ async function buildLivePool(activeById, self) {
108
+ let metas = [];
109
+ try {
110
+ metas = await discoverSessions({ all: true, since: '30d', limit: 1000 });
111
+ }
112
+ catch { /* fall back to synthesized metas */ }
113
+ const byId = new Map();
114
+ for (const m of metas)
115
+ byId.set(m.id, m);
116
+ const pool = [];
117
+ for (const [sid, s] of activeById) {
118
+ pool.push(byId.get(sid) ?? synthMeta(s, self));
119
+ }
120
+ return mergeLocalFirst(pool, self);
121
+ }
122
+ function synthMeta(s, self) {
123
+ const remote = !!s.machine && s.machine !== self;
124
+ // For a local session, locate the real transcript on disk so the picker's
125
+ // buildPreview parses it directly (rich Prompt/Changes/Tools/Last response) —
126
+ // independent of the sessions DB. Remote transcripts live on the peer, so leave
127
+ // filePath empty and the preview shows a clean "not indexed here" note.
128
+ const filePath = remote ? '' : (findSessionFileForKind(s.kind, s.cwd, s.sessionId) ?? '');
129
+ return {
130
+ id: s.sessionId,
131
+ shortId: s.sessionId.slice(0, 8),
132
+ agent: s.kind,
133
+ timestamp: new Date(s.startedAtMs ?? Date.now()).toISOString(),
134
+ filePath,
135
+ cwd: s.cwd,
136
+ project: s.cwd ? path.basename(s.cwd) : undefined,
137
+ topic: s.topic,
138
+ machine: s.machine,
139
+ _remote: remote,
140
+ };
141
+ }
142
+ function shortId(s) {
143
+ return (s.sessionId ?? '').slice(0, 8) || '-';
144
+ }
145
+ /**
146
+ * Pure, testable mirror of `jumpTo`'s path selection (jumpTo itself has side
147
+ * effects — process.exit / ssh / osascript). Keep the branch ORDER in sync with
148
+ * `jumpTo` below: remote-tmux, then local-tmux, then ghostty, then refuse.
149
+ */
150
+ export function describeWhere(s, self) {
151
+ const remote = s.machine && s.machine !== self ? s.machine : undefined;
152
+ const mux = s.provenance?.mux;
153
+ if (mux?.kind === 'tmux' && mux.pane) {
154
+ return remote
155
+ ? { label: `tmux ${mux.pane} on ${remote}`, action: `ssh + attach on ${remote}` }
156
+ : { label: `tmux ${mux.pane}`, action: 'attach its tmux' };
157
+ }
158
+ if (!remote && s.host === 'ghostty')
159
+ return { label: 'Ghostty', action: 'focus its Ghostty tab' };
160
+ if (remote)
161
+ return { label: `${s.host ?? 'shell'} on ${remote}`, action: `open a shell on ${remote}` };
162
+ return { label: s.host ?? 'unknown terminal', action: 'resume it (no live attach rail)' };
163
+ }
164
+ async function jumpTo(s, self) {
165
+ const remote = s.machine && s.machine !== self ? s.machine : undefined;
166
+ const mux = s.provenance?.mux;
167
+ // Path C: remote tmux — ssh in and attach, resolving the pane's session on the remote.
168
+ if (remote) {
169
+ if (mux?.kind === 'tmux' && mux.pane) {
170
+ assertValidSshTarget(remote);
171
+ const sock = mux.socket ? `-S ${shellQuote(mux.socket)} ` : '';
172
+ const p = shellQuote(mux.pane);
173
+ const remoteCmd = `w=$(tmux ${sock}display-message -pt ${p} '#{session_name}:#{window_index}' 2>/dev/null); ` +
174
+ `sess=$(tmux ${sock}display-message -pt ${p} '#{session_name}' 2>/dev/null); ` +
175
+ `[ -n "$w" ] && tmux ${sock}select-window -t "$w" 2>/dev/null; ` +
176
+ `exec tmux ${sock}attach-session -t "\${sess:-${p}}"`;
177
+ console.log(chalk.gray(`Attaching ${shortId(s)} on ${remote} over SSH — Ctrl-b d to detach.`));
178
+ process.exit(sshStream(remote, remoteCmd, { tty: true }));
179
+ }
180
+ console.log(chalk.yellow(`${shortId(s)} on ${remote} isn't inside tmux — opening a shell on ${remote} instead.`));
181
+ assertValidSshTarget(remote);
182
+ process.exit(sshStream(remote, 'exec "${SHELL:-/bin/sh}" -l', { tty: true }));
183
+ }
184
+ // Path B: local tmux — attach (or switch-client if we're already inside tmux).
185
+ if (mux?.kind === 'tmux' && mux.pane) {
186
+ const socket = mux.socket ?? getDefaultSocketPath();
187
+ const { session, window } = await resolveLocalPane(socket, mux.pane);
188
+ if (session && window != null) {
189
+ await runTmux({ socket, args: ['select-window', '-t', `${session}:${window}`], throwOnError: false }).catch(() => { });
190
+ }
191
+ const tgt = session ?? mux.pane;
192
+ if (process.env.TMUX) {
193
+ await runTmux({ socket, args: ['switch-client', '-t', tgt], throwOnError: false }).catch(() => { });
194
+ console.log(chalk.gray(`Switched this tmux client to ${shortId(s)} (${tgt}).`));
195
+ return;
196
+ }
197
+ console.log(chalk.gray(`Attaching ${shortId(s)} (tmux ${tgt}) — Ctrl-b d to detach.`));
198
+ process.exit(await attachTmux({ socket, args: ['attach-session', '-t', tgt] }));
199
+ }
200
+ // Path A: local Ghostty — focus its tab (Cmd+N via System Events).
201
+ if (s.host === 'ghostty') {
202
+ let tab;
203
+ try {
204
+ const surfaces = await enumerateGhosttyTabs();
205
+ tab = assignGhosttyTabs([s], surfaces).get(s);
206
+ }
207
+ catch { /* best-effort */ }
208
+ if (tab != null && tab <= 9) {
209
+ const script = `tell application "Ghostty" to activate\n` +
210
+ `delay 0.15\n` +
211
+ `tell application "System Events" to keystroke "${tab}" using command down`;
212
+ await execFileAsync('osascript', ['-e', script]).catch(() => { });
213
+ console.log(chalk.gray(`Focused ${shortId(s)} → Ghostty tab ${tab}.`));
214
+ return;
215
+ }
216
+ await execFileAsync('osascript', ['-e', 'tell application "Ghostty" to activate']).catch(() => { });
217
+ console.log(chalk.yellow(`Raised Ghostty for ${shortId(s)}`) +
218
+ chalk.gray(tab != null ? ` — switch to tab ${tab} (Cmd+${tab}).` : " — couldn't pinpoint its tab (same-repo forks are ambiguous); switch tabs manually."));
219
+ return;
220
+ }
221
+ // Path D: refuse with a reason.
222
+ console.log(chalk.yellow(`Can't jump to ${shortId(s)} — it's in ${s.host ?? 'an unknown terminal'} with no attach rail (not tmux/Ghostty).`) +
223
+ chalk.gray(`\nTry: agents sessions resume ${shortId(s)}`));
224
+ }
225
+ /** Resolve a local tmux pane id to its session name + window index. */
226
+ async function resolveLocalPane(socket, pane) {
227
+ try {
228
+ const res = await runTmux({ socket, args: ['display-message', '-pt', pane, '-p', '#{session_name}\t#{window_index}'], throwOnError: false });
229
+ if (res.code !== 0)
230
+ return {};
231
+ const [session, win] = res.stdout.trim().split('\t');
232
+ const window = Number.parseInt(win, 10);
233
+ return { session: session || undefined, window: Number.isFinite(window) ? window : undefined };
234
+ }
235
+ catch {
236
+ return {};
237
+ }
238
+ }
@@ -12,6 +12,8 @@ export interface SessionPickerConfig {
12
12
  labelFor: (s: SessionMeta, query: string) => string;
13
13
  pageSize?: number;
14
14
  initialSearch?: string;
15
+ /** Verb shown on the Enter key in the footer (default 'resume'). */
16
+ enterHint?: string;
15
17
  }
16
18
  /** Build a cached multi-line preview string for display in the session picker. */
17
19
  export declare function buildPreview(session: SessionMeta): string;
@@ -5,6 +5,7 @@
5
5
  * Builds a compact preview for each session (prompt, activity summary, last
6
6
  * response) and delegates to the generic `itemPicker` for the interactive UI.
7
7
  */
8
+ import fs from 'node:fs';
8
9
  import chalk from 'chalk';
9
10
  import { parseSession, sanitizeForTerminal } from '../lib/session/parse.js';
10
11
  import { cleanSessionPrompt, extractSessionTopic } from '../lib/session/prompt.js';
@@ -61,6 +62,14 @@ export function buildPreview(session) {
61
62
  previewCache.set(cacheKey, output);
62
63
  return output;
63
64
  }
65
+ // No transcript on disk — a live session not indexed locally, or a synthesized
66
+ // entry (e.g. `sessions go`). Show the header + a clean note, not a parse error.
67
+ if (!session.filePath || !fs.existsSync(session.filePath)) {
68
+ const note = ' ' + chalk.gray('Live session — full transcript not indexed here.');
69
+ const output = [formatHeader(safe, []), '', note].filter(Boolean).join('\n');
70
+ previewCache.set(cacheKey, output);
71
+ return output;
72
+ }
64
73
  let events = [];
65
74
  let parseError;
66
75
  try {
@@ -371,7 +380,7 @@ export async function sessionPicker(config) {
371
380
  pageSize: config.pageSize,
372
381
  initialSearch: config.initialSearch,
373
382
  emptyMessage: 'No sessions match.',
374
- enterHint: 'resume',
383
+ enterHint: config.enterHint ?? 'resume',
375
384
  });
376
385
  if (!picked)
377
386
  return null;
@@ -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
- syncCmd.action(async (options) => {
71
- await runSessionsSync(options);
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