@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
@@ -17,6 +17,8 @@ import { SESSION_AGENTS } from '../lib/session/types.js';
17
17
  import { discoverArtifacts, readArtifact, resolveArtifact } from '../lib/session/artifacts.js';
18
18
  import { looksLikePath, toComparablePath, homeDir, needsWindowsShell, findExecutable } from '../lib/platform/index.js';
19
19
  import { getActiveSessions } from '../lib/session/active.js';
20
+ import { enumerateGhosttyTabs, assignGhosttyTabs } from '../lib/session/ghostty-tabs.js';
21
+ import { mapPanesToTargets } from '../lib/tmux/session.js';
20
22
  import { machineId, normalizeHost } from '../lib/session/sync/config.js';
21
23
  import { gatherRemoteActive, NO_FANOUT_ENV } from '../lib/session/remote-active.js';
22
24
  import { gatherRemoteList, runOnPeer } from '../lib/session/remote-list.js';
@@ -37,8 +39,27 @@ import { setHelpSections } from '../lib/help.js';
37
39
  import { registerSessionsTailCommand } from './sessions-tail.js';
38
40
  import { registerSessionsSyncCommand } from './sessions-sync.js';
39
41
  import { registerSessionsResumeCommand } from './sessions-resume.js';
42
+ import { registerGoCommand } from './go.js';
40
43
  import { registerSessionsInjectCommand } from './sessions-inject.js';
41
44
  const SESSION_AGENT_FILTER_HELP = `Filter by agent, e.g. claude, codex, claude@2.0.65`;
45
+ /**
46
+ * The prioritized harnesses that get a boolean shorthand flag (e.g. `--claude`
47
+ * === `--agent claude`). The rest stay reachable via `--agent <name>`, which
48
+ * also carries version pins like `codex@0.116.0`.
49
+ */
50
+ const AGENT_SHORTHANDS = ['claude', 'codex', 'kimi', 'antigravity', 'grok', 'opencode'];
51
+ /**
52
+ * Resolve a per-agent shorthand (`--claude`, `--kimi`, …) into `options.agent`.
53
+ * An explicit `--agent` wins; if two shorthands are passed we take the first and
54
+ * ignore the rest (commander gives no ordering, so this is a best-effort alias).
55
+ */
56
+ function applyAgentShorthands(options) {
57
+ if (options.agent)
58
+ return;
59
+ const hit = AGENT_SHORTHANDS.find((name) => options[name] === true);
60
+ if (hit)
61
+ options.agent = hit;
62
+ }
42
63
  const CLAUDE_RESUME_MATCH_WINDOW_MS = 10 * 60_000;
43
64
  const LOAD_VERBS = ['Loading', 'Scanning', 'Gathering', 'Indexing', 'Reading'];
44
65
  const FIND_VERBS = ['Finding', 'Searching', 'Locating', 'Matching'];
@@ -174,13 +195,28 @@ function formatStartedAt(startedAtMs) {
174
195
  return '-';
175
196
  return formatRelativeTime(new Date(startedAtMs).toISOString());
176
197
  }
198
+ /**
199
+ * Strip terminal/harness noise from a preview so the column stays a single line
200
+ * of plain prose: OSC title escapes, CSI/SGR ANSI, and the harness wrapper tags
201
+ * (`<local-command-stdout>`, `<task-notification>`, `<command-*>`) that leak from
202
+ * a captured transcript tail. Collapses runs of whitespace.
203
+ */
204
+ export function cleanPreview(text) {
205
+ // eslint-disable-next-line no-control-regex
206
+ return text
207
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC (title) sequences
208
+ .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '') // CSI / SGR ANSI
209
+ .replace(/<\/?(?:local-command-stdout|command-name|command-message|command-args|task-notification|system-reminder)>/g, '')
210
+ .replace(/\s+/g, ' ')
211
+ .trim();
212
+ }
177
213
  /**
178
214
  * Build the live description for an active session: prefer the state engine's
179
215
  * preview (the latest turn), then a user label, then the first-prompt topic.
180
216
  */
181
217
  function buildSessionDescription(s) {
182
218
  if (s.context === 'cloud') {
183
- return s.preview || `${s.cloudProvider ?? ''}${s.cloudTaskId ? ` · ${s.cloudTaskId.slice(0, 12)}` : ''}`;
219
+ return cleanPreview(s.preview || `${s.cloudProvider ?? ''}${s.cloudTaskId ? ` · ${s.cloudTaskId.slice(0, 12)}` : ''}`);
184
220
  }
185
221
  if (s.context === 'teams') {
186
222
  const parts = [s.teamName];
@@ -190,10 +226,10 @@ function buildSessionDescription(s) {
190
226
  parts.push(s.label);
191
227
  else if (s.topic)
192
228
  parts.push(s.topic);
193
- return parts.filter(Boolean).join(' · ');
229
+ return cleanPreview(parts.filter(Boolean).join(' · '));
194
230
  }
195
231
  // Terminal or headless: prefer the live preview, then label, then topic.
196
- return s.preview || s.label || s.topic || '';
232
+ return cleanPreview(s.preview || s.label || s.topic || '');
197
233
  }
198
234
  /** Short human word for a session's activity (falls back to the coarse status). */
199
235
  function activityLabel(s) {
@@ -264,20 +300,25 @@ function signalBadges(s) {
264
300
  return parts.join(' ');
265
301
  }
266
302
  /**
267
- * Compact provenance badge: how to reach the session, not what it's doing.
268
- * `ssh` flags a remote host; the tmux pane id is the send-keys target the feed
269
- * would type back into. Local, non-tmux sessions add nothing (the common case).
303
+ * Compact locator badge: how to JUMP to the session, not what it's doing.
304
+ * `ssh` flags a remote host. For tmux, prefer the resolved `session:window.pane`
305
+ * (a real `tmux attach -t <session:window>` target) over the raw `%pane` id. For
306
+ * a local Ghostty session we know the tab, show `tab N`. Local, unlocatable
307
+ * sessions add nothing (the common case).
270
308
  */
271
- function provenanceBadge(p) {
272
- if (!p)
273
- return '';
309
+ function locatorBadge(s) {
310
+ const p = s.provenance;
274
311
  const parts = [];
275
- if (p.transport === 'ssh')
312
+ if (p?.transport === 'ssh')
276
313
  parts.push(chalk.red('ssh'));
277
- if (p.mux?.kind === 'tmux' && p.mux.pane)
278
- parts.push(chalk.green(`tmux ${p.mux.pane}`));
279
- else if (p.mux?.kind === 'screen')
314
+ if (p?.mux?.kind === 'tmux' && (s.tmuxTarget || p.mux.pane)) {
315
+ parts.push(chalk.green(s.tmuxTarget ?? p.mux.pane));
316
+ }
317
+ else if (p?.mux?.kind === 'screen') {
280
318
  parts.push(chalk.green('screen'));
319
+ }
320
+ if (s.ghosttyTab != null)
321
+ parts.push(chalk.green(`tab ${s.ghosttyTab}`));
281
322
  return parts.join(' ');
282
323
  }
283
324
  /**
@@ -293,7 +334,7 @@ function printActiveRow(s, indent) {
293
334
  const hostCol = chalk.gray(padToWidth(truncateToWidth(s.host ?? '-', 8), 9));
294
335
  const statusCol = statusColor(s.status)(padToWidth(truncateToWidth(activityLabel(s), 8), 9));
295
336
  const fork = s.pidCount && s.pidCount > 1 ? chalk.dim(`×${s.pidCount} `) : '';
296
- const badges = (fork ? fork : '') + [signalBadges(s), provenanceBadge(s.provenance)].filter(Boolean).join(' ');
337
+ const badges = (fork ? fork : '') + [signalBadges(s), locatorBadge(s)].filter(Boolean).join(' ');
297
338
  const desc = buildSessionDescription(s) || '-';
298
339
  // Fill the remaining width with the preview so nothing wraps under tmux/SSH.
299
340
  const fixed = stringWidth(indent) + 9 + 9 + 9 + 9 + (badges ? stringWidth(badges) + 1 : 0);
@@ -466,6 +507,27 @@ export function mergeLocalFirst(sessions, localMachine) {
466
507
  });
467
508
  return keys.flatMap((k) => byMachine.get(k));
468
509
  }
510
+ /**
511
+ * `running N · idle N · waiting N · queued N` for a bucket of sessions (zero
512
+ * buckets omitted). Same bucketing as the grand-total summary so per-group
513
+ * counts reconcile with the `(total)` beside the header. Empty when nothing.
514
+ */
515
+ function groupTally(sessions) {
516
+ const running = sessions.filter(s => s.status === 'running').length;
517
+ const idle = sessions.filter(s => s.status === 'idle').length;
518
+ const waiting = sessions.filter(s => s.status === 'input_required').length;
519
+ const queued = sessions.filter(s => s.status === 'queued').length;
520
+ const parts = [];
521
+ if (running)
522
+ parts.push(`${running} running`);
523
+ if (idle)
524
+ parts.push(`${idle} idle`);
525
+ if (waiting)
526
+ parts.push(`${waiting} waiting`);
527
+ if (queued)
528
+ parts.push(`${queued} queued`);
529
+ return parts.join(' · ');
530
+ }
469
531
  /** Print one machine's workspace tree, indented under its machine header. */
470
532
  function renderWorkspaceLayout(layout, base) {
471
533
  let first = true;
@@ -478,7 +540,9 @@ function renderWorkspaceLayout(layout, base) {
478
540
  : ws.key === '__unknown__'
479
541
  ? chalk.gray.bold('unknown')
480
542
  : chalk.cyan.bold(shortCwd(ws.key));
481
- console.log(`${base}${header} ${chalk.gray(`(${ws.total})`)}`);
543
+ const wsSessions = [...ws.windows.flatMap(w => w.sessions), ...ws.flat];
544
+ const tally = groupTally(wsSessions);
545
+ console.log(`${base}${header} ${chalk.gray(`(${ws.total})`)}${tally ? chalk.gray(` ${tally}`) : ''}`);
482
546
  for (const win of ws.windows) {
483
547
  // Host is per-process, but every terminal in the same IDE window shares
484
548
  // an ancestor — take the first non-empty host as the window's label.
@@ -500,6 +564,43 @@ function printMachineHeader(mg) {
500
564
  const here = mg.isLocal ? chalk.cyan(' ← this machine') : '';
501
565
  console.log(`${marker}${name} ${chalk.gray(`(${mg.total})`)}${here}`);
502
566
  }
567
+ /**
568
+ * Attach display-only jump locators onto LOCAL sessions: the Ghostty tab number
569
+ * (one batched read-only osascript, only when a local ghostty session exists)
570
+ * and the tmux `session:window.pane` target (one `list-panes -a` per socket).
571
+ * Every step is best-effort and swallowed — a failure just leaves the raw pane
572
+ * id / no tab number, and the rows render as before. Mutates the sessions.
573
+ */
574
+ async function enrichLocalLocators(local) {
575
+ // Ghostty tab numbers.
576
+ try {
577
+ const ghostty = local.filter(s => s.host === 'ghostty' && s.provenance?.transport !== 'ssh');
578
+ if (ghostty.length > 0) {
579
+ const surfaces = await enumerateGhosttyTabs();
580
+ for (const [sess, tab] of assignGhosttyTabs(ghostty, surfaces))
581
+ sess.ghosttyTab = tab;
582
+ }
583
+ }
584
+ catch { /* non-fatal */ }
585
+ // tmux attach targets, one batched query per distinct socket.
586
+ try {
587
+ const tmux = local.filter(s => s.provenance?.mux?.kind === 'tmux' && s.provenance.mux.pane);
588
+ const sockets = new Set(tmux.map(s => s.provenance.mux.socket));
589
+ for (const socket of sockets) {
590
+ const paneMap = await mapPanesToTargets(socket);
591
+ if (paneMap.size === 0)
592
+ continue;
593
+ for (const s of tmux) {
594
+ if (s.provenance.mux.socket !== socket)
595
+ continue;
596
+ const target = paneMap.get(s.provenance.mux.pane);
597
+ if (target)
598
+ s.tmuxTarget = target;
599
+ }
600
+ }
601
+ }
602
+ catch { /* non-fatal */ }
603
+ }
503
604
  /**
504
605
  * Render the unified active-session view, grouped by machine. Local sessions
505
606
  * come from `getActiveSessions()`; unless `--local`, sessions from other
@@ -535,6 +636,10 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
535
636
  printCrossMachineTip();
536
637
  return;
537
638
  }
639
+ // Enrich LOCAL sessions with jump locators (display-only, after the --json /
640
+ // --waiting gates so scriptable output stays osascript-free). Remote sessions
641
+ // keep their raw pane id — their tmux/Ghostty live on the other machine.
642
+ await enrichLocalLocators(sessions.filter(s => !s.machine || s.machine === self));
538
643
  const grouped = groupSessionsByMachine(sessions, self);
539
644
  let firstMachine = true;
540
645
  for (const mg of grouped.machines) {
@@ -544,16 +649,7 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
544
649
  printMachineHeader(mg);
545
650
  renderWorkspaceLayout(mg.layout, ' ');
546
651
  }
547
- const runningCount = sessions.filter(s => s.status === 'running').length;
548
- const idleCount = sessions.filter(s => s.status === 'idle').length;
549
- const queuedCount = sessions.filter(s => s.status === 'queued' || s.status === 'input_required').length;
550
- const parts = [];
551
- if (runningCount > 0)
552
- parts.push(`${runningCount} running`);
553
- if (idleCount > 0)
554
- parts.push(`${idleCount} idle`);
555
- if (queuedCount > 0)
556
- parts.push(`${queuedCount} queued`);
652
+ const parts = groupTally(sessions).split(' · ').filter(Boolean);
557
653
  const machineWord = grouped.machines.length === 1 ? 'machine' : 'machines';
558
654
  console.log(chalk.gray(`\n${sessions.length} active (${parts.join(', ')}) across ${grouped.machines.length} ${machineWord}.`));
559
655
  // Tip only when nothing else could be included and the user didn't opt out.
@@ -569,6 +665,16 @@ function printCrossMachineTip() {
569
665
  }
570
666
  /** Main action handler for `agents sessions`. Routes to picker, table, or single-session render. */
571
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;
671
+ // Normalize convenience flags before any routing reads them: per-agent
672
+ // shorthands fold into --agent, and --device is an alias for --host (both
673
+ // resolve against the same device registry).
674
+ applyAgentShorthands(options);
675
+ if (options.device && options.device.length > 0) {
676
+ options.host = [...(options.host ?? []), ...options.device];
677
+ }
572
678
  // --host WITHOUT --active keeps the legacy per-host stream (each remote's raw
573
679
  // stdout under a `── host ──` banner). With --active, the hosts are folded
574
680
  // into the merged machine-grouped view instead (handled below).
@@ -666,9 +772,12 @@ async function sessionsAction(query, options) {
666
772
  const scope = {
667
773
  agent,
668
774
  version,
669
- all: pathFilter ? undefined : (wantsOverview ? true : options.all),
775
+ all: pathFilter ? undefined : options.all,
670
776
  cwd: process.cwd(),
671
- cwdPrefix: pathFilter,
777
+ // Default overview scopes to the current repo SUBTREE (prefix match), so a
778
+ // monorepo shows its sub-projects grouped instead of collapsing to the one
779
+ // exact-cwd project. `--all` clears the prefix and spans the whole index.
780
+ cwdPrefix: pathFilter ?? (wantsOverview && !options.all ? process.cwd() : undefined),
672
781
  project: options.project,
673
782
  since,
674
783
  until: options.until,
@@ -819,7 +928,7 @@ function metaSignals(s) {
819
928
  * dashes and needlessly truncate the topic. Worktree stays a trailing badge. */
820
929
  function flatSessionRow(session, live, showTicket = false, cols = {}) {
821
930
  const agentColor = colorAgent(session.agent);
822
- const when = formatRelativeTime(session.timestamp);
931
+ const when = formatRelativeTime(session.lastActivity ?? session.timestamp);
823
932
  const project = session.project || '-';
824
933
  const tag = teamTag(session);
825
934
  const label = session.label;
@@ -858,7 +967,7 @@ function flatSessionRow(session, live, showTicket = false, cols = {}) {
858
967
  /** One tree-mode row (grouped under a dir header): id · agent · badges · topic · time. No version/project column. */
859
968
  function treeSessionRow(session, live) {
860
969
  const agentColor = colorAgent(session.agent);
861
- const when = formatRelativeTime(session.timestamp);
970
+ const when = formatRelativeTime(session.lastActivity ?? session.timestamp);
862
971
  const tag = teamTag(session);
863
972
  const label = session.label;
864
973
  const { glyph, preview } = liveGlyphAndPreview(live);
@@ -928,7 +1037,7 @@ export function buildOverviewGroups(pool, perProjectCap) {
928
1037
  const groups = [];
929
1038
  for (const [key, rows] of byKey) {
930
1039
  const shown = rows.slice(0, cap); // rows are recency-desc (pool was sorted)
931
- groups.push({ key, total: rows.length, shown, more: rows.length - shown.length, maxTs: rows[0].timestamp });
1040
+ groups.push({ key, total: rows.length, shown, more: rows.length - shown.length, maxTs: rows[0].lastActivity ?? rows[0].timestamp });
932
1041
  }
933
1042
  groups.sort((a, b) => (a.maxTs < b.maxTs ? 1 : a.maxTs > b.maxTs ? -1 : a.key.localeCompare(b.key)));
934
1043
  return { groups, projectCount: byKey.size };
@@ -960,10 +1069,10 @@ function printSessionOverview(pool, hiddenCount, liveIndex, opts) {
960
1069
  console.log(' ' + chalk.gray(`· ${g.more} more`));
961
1070
  }
962
1071
  console.log();
963
- const parts = [chalk.gray('newest first')];
1072
+ const parts = [chalk.gray('newest first (by last activity)')];
964
1073
  if (hiddenProjects > 0)
965
- parts.push(chalk.gray(`+${hiddenProjects} more project${hiddenProjects === 1 ? '' : 's'} · agents sessions --all`));
966
- parts.push(chalk.gray('agents sessions <project> to drill in · --flat for the plain list'));
1074
+ parts.push(chalk.gray(`+${hiddenProjects} more project${hiddenProjects === 1 ? '' : 's'}`));
1075
+ parts.push(chalk.gray('agents sessions --all spans every project on disk · <project> to drill in · --flat for the plain list'));
967
1076
  console.log(parts.join(chalk.gray(' · ')));
968
1077
  if (hiddenCount > 0)
969
1078
  console.log(chalk.gray(formatTeamHiddenFooter(hiddenCount)));
@@ -1213,7 +1322,7 @@ export function pickerColumnsFor(sessions) {
1213
1322
  }
1214
1323
  export function formatPickerLabel(s, query, cols = {}) {
1215
1324
  const agentColor = colorAgent(s.agent);
1216
- const when = formatRelativeTime(s.timestamp);
1325
+ const when = formatRelativeTime(s.lastActivity ?? s.timestamp);
1217
1326
  const project = s.project || '-';
1218
1327
  const tag = teamTag(s);
1219
1328
  const label = s.label;
@@ -1260,7 +1369,7 @@ const PICKER_TIPS = [
1260
1369
  export function formatPickerTip(sessions) {
1261
1370
  return chalk.gray(PICKER_TIPS[sessions.length % PICKER_TIPS.length]);
1262
1371
  }
1263
- export async function pickSessionInteractive(sessions, message = 'Search sessions:', initialSearch, hiddenCount = 0) {
1372
+ export async function pickSessionInteractive(sessions, message = 'Search sessions:', initialSearch, hiddenCount = 0, enterHint) {
1264
1373
  if (hiddenCount > 0) {
1265
1374
  console.log(chalk.gray(formatTeamHiddenFooter(hiddenCount)));
1266
1375
  }
@@ -1280,6 +1389,7 @@ export async function pickSessionInteractive(sessions, message = 'Search session
1280
1389
  labelFor: (s, query) => formatPickerLabel(s, query, cols),
1281
1390
  pageSize: PICKER_RECENT_COUNT,
1282
1391
  initialSearch,
1392
+ enterHint,
1283
1393
  });
1284
1394
  }
1285
1395
  catch (err) {
@@ -1783,8 +1893,15 @@ export function registerSessionsCommands(program) {
1783
1893
  const sessionsCmd = program
1784
1894
  .command('sessions')
1785
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")')
1786
1897
  .description('Find, browse, and read agent conversation transcripts across Claude, Codex, Gemini, and OpenCode.')
1787
1898
  .option('-a, --agent <agent>', 'Filter by agent type and version (e.g., claude, codex@0.116.0)')
1899
+ .option('--claude', 'Shorthand for --agent claude')
1900
+ .option('--codex', 'Shorthand for --agent codex')
1901
+ .option('--kimi', 'Shorthand for --agent kimi')
1902
+ .option('--antigravity', 'Shorthand for --agent antigravity')
1903
+ .option('--grok', 'Shorthand for --agent grok')
1904
+ .option('--opencode', 'Shorthand for --agent opencode')
1788
1905
  .option('--all', 'Include sessions from every directory (not just current project)')
1789
1906
  .option('--teams', 'Include team-spawned sessions (hidden by default)')
1790
1907
  .option('--project <name>', 'Filter by project name (searches across all directories)')
@@ -1808,7 +1925,8 @@ export function registerSessionsCommands(program) {
1808
1925
  .option('--flat', 'Plain flat table (one row per session) instead of the grouped project overview')
1809
1926
  .option('--no-live', 'Do not enrich the listing with live status/preview for running sessions')
1810
1927
  .option('--cloud', 'Source sessions from Rush Cloud (captured runs) instead of local disk')
1811
- .option('-H, --host <target...>', 'Run this query on remote machine(s) over SSH (host alias or user@host; repeatable)');
1928
+ .option('-H, --host <target...>', 'Run this query on remote machine(s) over SSH (host alias or user@host; repeatable)')
1929
+ .option('--device <target...>', 'Alias for --host (device alias from `agents devices`; repeatable)');
1812
1930
  setHelpSections(sessionsCmd, {
1813
1931
  examples: `
1814
1932
  # Search prior sessions in this project by topic, file path, or command
@@ -1855,6 +1973,7 @@ export function registerSessionsCommands(program) {
1855
1973
  registerSessionsTailCommand(sessionsCmd);
1856
1974
  registerSessionsSyncCommand(sessionsCmd);
1857
1975
  registerSessionsResumeCommand(sessionsCmd);
1976
+ registerGoCommand(sessionsCmd);
1858
1977
  registerSessionsInjectCommand(sessionsCmd);
1859
1978
  }
1860
1979
  function formatNoSessionsMessage(showAll, project) {
package/dist/index.js CHANGED
@@ -483,7 +483,7 @@ async function maybeBootstrapShimIntegration(requestedCommand, helpOrVersionRequ
483
483
  const { confirm } = await import('@inquirer/prompts');
484
484
  const { AGENTS } = await import('./lib/agents.js');
485
485
  const { getGlobalDefault, listInstalledVersions } = await import('./lib/versions.js');
486
- const { addShimsToPath, ensureShimCurrent, ensureVersionedAliasCurrent, getPathShadowingExecutable, getPathSetupInstructions, getShimsDir, isShimsInPath, listAgentsWithInstalledVersions, removeLegacyUserShim, } = await import('./lib/shims.js');
486
+ const { addShimsToPath, adoptShadowingLauncher, ensureShimCurrent, ensureVersionedAliasCurrent, getPathShadowingExecutable, getPathSetupInstructions, getShimsDir, isShimsInPath, listAgentsWithInstalledVersions, removeLegacyUserShim, } = await import('./lib/shims.js');
487
487
  const installedAgents = listAgentsWithInstalledVersions();
488
488
  if (installedAgents.length === 0) {
489
489
  return;
@@ -519,87 +519,78 @@ async function maybeBootstrapShimIntegration(requestedCommand, helpOrVersionRequ
519
519
  return;
520
520
  }
521
521
  const defaultAgents = installedAgents.filter((agent) => getGlobalDefault(agent));
522
+ // Auto-adopt any harness launcher that shadows our shim. PATH-order repair
523
+ // (below) cannot win against `~/.local/bin` — it's prepended in .zshenv for
524
+ // every shell while our prepend only lands in .zshrc — so for symlink
525
+ // launchers we *become* the launcher instead. Detection keys on the launcher
526
+ // symlink EXISTING (via adoptShadowingLauncher's own fallback), not on this
527
+ // shell's PATH order, so it also heals the GUI/non-interactive shadow an
528
+ // interactive run can't see. Reversible; only ever rewrites a symlink.
529
+ for (const agent of defaultAgents) {
530
+ const result = adoptShadowingLauncher(agent);
531
+ if (result.adopted) {
532
+ console.log(chalk.green(`Adopted ${AGENTS[agent].cliCommand} launcher (${result.launcher}) — version management now wins regardless of PATH order.`));
533
+ }
534
+ }
535
+ // Recompute AFTER adoption so anything we just took over drops out. What
536
+ // remains is a real binary we deliberately don't touch (adoption is
537
+ // symlink-only) — those get an honest one-time note, never a looping prompt.
522
538
  const shadowed = defaultAgents
523
539
  .map((agent) => ({ agent, shadowedBy: getPathShadowingExecutable(agent) }))
524
540
  .filter((item) => Boolean(item.shadowedBy));
525
- // Shell aliases that call the same command with extra flags are intentional
526
- // customization and don't break shim integration `addShimsToPath` cannot
527
- // touch them, so they don't belong in the repair prompt. We previously
528
- // computed an `aliased` list here and inserted it into `affected`, which
529
- // contradicted the comment below and surfaced false positives (e.g. an
530
- // earlier `alias codex=...` cancelled by a later `unalias codex` was
531
- // reported because the detector did a static rc-file regex).
532
- if (shadowed.length === 0 && isShimsInPath()) {
541
+ // After adoption, the only things left are (a) real-binary shadows we won't
542
+ // touch, and (b) a genuinely missing PATH entry. Nothing else needs the user.
543
+ const pathMissing = !isShimsInPath();
544
+ if (shadowed.length === 0 && !pathMissing) {
533
545
  return;
534
546
  }
535
- // Suppress repeated prompts within the same shell. A successful rc-file
536
- // edit doesn't reload the parent shell, so the next invocation sees the
537
- // same PATH and re-fires detection. The sentinel survives only as long as
538
- // the parent shell process once the user opens a new terminal, the
539
- // PPID changes and the prompt is allowed again.
547
+ // Suppress repeated notices within the same shell. A successful rc-file edit
548
+ // doesn't reload the parent shell, so the next invocation re-fires detection.
549
+ // The sentinel survives only as long as the parent shell process — a new
550
+ // terminal (new PPID) is allowed to surface it again.
540
551
  const sentinelPath = path.join(os.tmpdir(), `agents-shim-prompted-${process.ppid}`);
541
552
  if (fs.existsSync(sentinelPath)) {
542
553
  return;
543
554
  }
544
- const affected = [];
545
- for (const { agent, shadowedBy } of shadowed) {
546
- affected.push(`${AGENTS[agent].cliCommand} -> ${shadowedBy}`);
547
- }
548
- if (affected.length === 0) {
549
- // Pure PATH-not-loaded case: rc may already have the shim block, but the
550
- // running shell hasn't sourced it. Don't list agents here — they aren't
551
- // broken; only the PATH is stale. The prompt + post-message handle it.
552
- affected.push('PATH entry missing');
553
- }
554
- const shouldRepair = await confirm({
555
- message: `Repair shim integration now? ${affected.join(', ')}`,
556
- default: true,
557
- });
558
- if (!shouldRepair) {
559
- console.log(chalk.yellow('Shim integration still needs attention.'));
560
- console.log(chalk.gray(getPathSetupInstructions()));
561
- try {
562
- fs.writeFileSync(sentinelPath, '1');
563
- }
564
- catch { /* best-effort */ }
565
- return;
566
- }
567
- const pathResult = addShimsToPath();
568
- if (!pathResult.success) {
569
- console.log(chalk.yellow('Could not repair shim PATH setup automatically.'));
570
- console.log(chalk.gray(pathResult.error || getPathSetupInstructions()));
571
- // Write the sentinel even on failure — otherwise an unwritable rc file
572
- // re-prompts every invocation in the same shell. The user opens a new
573
- // terminal (new PPID) to retry.
574
- try {
575
- fs.writeFileSync(sentinelPath, '1');
576
- }
577
- catch { /* best-effort */ }
578
- return;
555
+ // Real-binary shadows: adoption is symlink-only (we never rename a real native
556
+ // binary), and `addShimsToPath` provably can't outrank an early-PATH dir like
557
+ // ~/.local/bin across zsh's whole sourcing chain. So DON'T offer a "Repair?"
558
+ // prompt here — that was the infinite-loop bug (Yes was always a no-op).
559
+ // Inform once and point at the real levers.
560
+ if (shadowed.length > 0) {
561
+ const targets = shadowed
562
+ .map(({ agent, shadowedBy }) => ` ${AGENTS[agent].cliCommand}: ${shadowedBy}`)
563
+ .join('\n');
564
+ console.log(chalk.yellow('These agent commands run a native binary instead of the version-managed shim:'));
565
+ console.log(chalk.gray(targets));
566
+ console.log(chalk.gray(`It's a real binary (not a symlink), so agents-cli won't move it. To hand it to agents-cli, remove/reorder it, or put ${getShimsDir()} earlier in PATH.`));
579
567
  }
580
- // When the rc file already has the canonical shim block, `addShimsToPath`
581
- // is a no-op re-emitting produced byte-identical content. In this branch
582
- // the user clicked "Yes" but nothing changed on disk, AND the underlying
583
- // cause (a real binary shadow, or a stale shell PATH) is unaffected by
584
- // this command. Be honest about it and point at the actual action.
585
- if (pathResult.alreadyPresent) {
586
- if (shadowed.length > 0) {
587
- const targets = shadowed
588
- .map(({ agent, shadowedBy }) => ` ${AGENTS[agent].cliCommand}: ${shadowedBy}`)
589
- .join('\n');
590
- console.log(chalk.yellow('Repair could not change anything — the shim is shadowed by another binary on PATH:'));
591
- console.log(chalk.gray(targets));
592
- console.log(chalk.gray(`Fix it by removing or reordering that binary, or making sure ${getShimsDir()} appears earlier in PATH than its parent dir.`));
568
+ // Genuinely-missing PATH entry is the one thing addShimsToPath actually fixes,
569
+ // so it's the only case that still earns an interactive prompt.
570
+ if (pathMissing) {
571
+ const shouldRepair = await confirm({
572
+ message: 'Add the agents-cli shims directory to your PATH now?',
573
+ default: true,
574
+ });
575
+ if (!shouldRepair) {
576
+ console.log(chalk.gray(getPathSetupInstructions()));
593
577
  }
594
578
  else {
595
- console.log(chalk.yellow(`Shim PATH entry is already in ~/${pathResult.rcFile} this shell just needs to reload it.`));
596
- console.log(chalk.gray(`Run: source ~/${pathResult.rcFile} (or open a new terminal)`));
579
+ const pathResult = addShimsToPath();
580
+ if (!pathResult.success) {
581
+ console.log(chalk.yellow('Could not update PATH automatically.'));
582
+ console.log(chalk.gray(pathResult.error || getPathSetupInstructions()));
583
+ }
584
+ else if (pathResult.alreadyPresent) {
585
+ console.log(chalk.yellow(`Shim PATH entry is already in ~/${pathResult.rcFile} — this shell just needs to reload it.`));
586
+ console.log(chalk.gray(`Run: source ~/${pathResult.rcFile} (or open a new terminal)`));
587
+ }
588
+ else {
589
+ console.log(chalk.green(`Added shims to PATH in ~/${pathResult.rcFile}`));
590
+ console.log(chalk.gray(getPathSetupInstructions()));
591
+ }
597
592
  }
598
593
  }
599
- else {
600
- console.log(chalk.green(`Repaired shim PATH setup in ~/${pathResult.rcFile}`));
601
- console.log(chalk.gray(getPathSetupInstructions()));
602
- }
603
594
  try {
604
595
  fs.writeFileSync(sentinelPath, '1');
605
596
  }
@@ -343,8 +343,10 @@ export async function runDaemon() {
343
343
  return;
344
344
  syncing = true;
345
345
  try {
346
- const { isSyncConfigured } = await import('./session/sync/config.js');
347
- if (!isSyncConfigured())
346
+ const { isSyncConfigured, isSyncEnabled } = await import('./session/sync/config.js');
347
+ // isSyncEnabled() first: a machine the operator turned off must skip the
348
+ // keychain read entirely, not just the network cycle.
349
+ if (!isSyncEnabled() || !isSyncConfigured())
348
350
  return;
349
351
  const { syncSessions } = await import('./session/sync/sync.js');
350
352
  const r = await syncSessions();
@@ -0,0 +1,24 @@
1
+ import { type DeviceRegistry } from './registry.js';
2
+ /** A dialable peer: the ssh target, the machine id used to tag its rows, a
3
+ * display name, and the OS family that picks the remote shell dialect. */
4
+ export interface ResolvedSshTarget {
5
+ target: string;
6
+ machine: string;
7
+ name: string;
8
+ os?: string;
9
+ }
10
+ /**
11
+ * Resolve one `--host`/`--device` token to a concrete ssh target through the
12
+ * registry. Registry hit → the device's real address + platform (so the machine
13
+ * id, route, and OS all match the auto-discovery sweep). Miss → a literal
14
+ * `user@host` fallback, its OS taken from the host overlay if enrolled. Returns
15
+ * undefined only when the token fails the shared ssh-target injection guard.
16
+ */
17
+ export declare function resolveSshTarget(token: string, reg: DeviceRegistry): ResolvedSshTarget | undefined;
18
+ /**
19
+ * Resolve an explicit `--host`/`--device` list to dialable targets, reading the
20
+ * registry once. A token that fails the injection guard is skipped with a
21
+ * stderr note (never fatal — one bad token must not blank the fan-out). Shared
22
+ * by every cross-machine fan-out so they can never diverge onto two routes.
23
+ */
24
+ export declare function resolveExplicitTargets(hosts: string[]): Promise<ResolvedSshTarget[]>;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The one place a `--host` / `--device` token becomes a real ssh target.
3
+ *
4
+ * A device and a host are the same thing addressed two ways, so resolution must
5
+ * go through the device registry — the single source of truth. A token that
6
+ * names a registered device dials that device's real address (its Tailscale
7
+ * dnsName/ip + user, via `sshTargetFor`), *identical* to the auto-discovery
8
+ * sweep and `agents ssh`. Before this module, the explicit `--host` fan-out
9
+ * instead passed the bare token straight to `ssh`, so `--host yosemite-s0`
10
+ * dialed whatever `~/.ssh/config`/LAN DNS resolved `yosemite-s0` to — a
11
+ * different route than the sweep's `yosemite-s0.<tailnet>.ts.net`. That
12
+ * divergence broke ControlMaster socket reuse (different target → different
13
+ * `%C` hash → a cold dial every time) and could read a perfectly reachable box
14
+ * as "unreachable" when only the non-Tailscale route was down.
15
+ *
16
+ * A raw `user@host` that matches no registered device falls back to a literal
17
+ * target so ad-hoc boxes still work.
18
+ */
19
+ import chalk from 'chalk';
20
+ import { assertValidSshTarget } from '../ssh-exec.js';
21
+ import { normalizeHost } from '../machine-id.js';
22
+ import { resolveRemoteOsSync } from '../hosts/remote-os.js';
23
+ import { sshTargetFor } from './connect.js';
24
+ import { loadDevices } from './registry.js';
25
+ /**
26
+ * Resolve one `--host`/`--device` token to a concrete ssh target through the
27
+ * registry. Registry hit → the device's real address + platform (so the machine
28
+ * id, route, and OS all match the auto-discovery sweep). Miss → a literal
29
+ * `user@host` fallback, its OS taken from the host overlay if enrolled. Returns
30
+ * undefined only when the token fails the shared ssh-target injection guard.
31
+ */
32
+ export function resolveSshTarget(token, reg) {
33
+ try {
34
+ assertValidSshTarget(token);
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ const bare = token.split('@').pop() || token;
40
+ // An explicit `user@host` names an exact account/target — honour it literally.
41
+ // A bare alias (`yosemite-s0`) resolves through the registry to the device's
42
+ // real address, so it never diverges from the auto-discovery sweep.
43
+ const device = token.includes('@')
44
+ ? undefined
45
+ : reg[token] ?? Object.values(reg).find((d) => normalizeHost(d.name) === normalizeHost(bare));
46
+ if (device) {
47
+ try {
48
+ return { target: sshTargetFor(device), machine: normalizeHost(device.name), name: device.name, os: device.platform };
49
+ }
50
+ catch {
51
+ // Registered but has no address to dial — fall through to the literal token.
52
+ }
53
+ }
54
+ return { target: token, machine: normalizeHost(bare), name: token, os: resolveRemoteOsSync(token) };
55
+ }
56
+ /**
57
+ * Resolve an explicit `--host`/`--device` list to dialable targets, reading the
58
+ * registry once. A token that fails the injection guard is skipped with a
59
+ * stderr note (never fatal — one bad token must not blank the fan-out). Shared
60
+ * by every cross-machine fan-out so they can never diverge onto two routes.
61
+ */
62
+ export async function resolveExplicitTargets(hosts) {
63
+ let reg;
64
+ try {
65
+ reg = await loadDevices();
66
+ }
67
+ catch {
68
+ reg = {};
69
+ }
70
+ const out = [];
71
+ for (const h of hosts) {
72
+ const resolved = resolveSshTarget(h, reg);
73
+ if (!resolved) {
74
+ process.stderr.write(chalk.gray(` ${h}: not a valid ssh target — skipped\n`));
75
+ continue;
76
+ }
77
+ out.push(resolved);
78
+ }
79
+ return out;
80
+ }