@phnx-labs/agents-cli 1.20.88 → 1.20.90

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 (102) hide show
  1. package/CHANGELOG.md +503 -0
  2. package/README.md +15 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/commands.js +7 -7
  5. package/dist/commands/exec.js +7 -1
  6. package/dist/commands/factory.js +26 -2
  7. package/dist/commands/funnel.js +16 -1
  8. package/dist/commands/harness.d.ts +27 -0
  9. package/dist/commands/harness.js +120 -13
  10. package/dist/commands/menubar.js +117 -34
  11. package/dist/commands/profiles.d.ts +3 -0
  12. package/dist/commands/profiles.js +1 -1
  13. package/dist/commands/routines.d.ts +19 -0
  14. package/dist/commands/routines.js +51 -7
  15. package/dist/commands/secrets-rotate-passphrase.d.ts +17 -0
  16. package/dist/commands/secrets-rotate-passphrase.js +96 -0
  17. package/dist/commands/secrets.d.ts +10 -1
  18. package/dist/commands/secrets.js +20 -6
  19. package/dist/commands/sessions-browser.d.ts +4 -0
  20. package/dist/commands/sessions-browser.js +51 -9
  21. package/dist/commands/sessions-favorite.d.ts +20 -0
  22. package/dist/commands/sessions-favorite.js +120 -0
  23. package/dist/commands/sessions.d.ts +110 -21
  24. package/dist/commands/sessions.js +395 -74
  25. package/dist/commands/setup-secrets.d.ts +7 -0
  26. package/dist/commands/setup-secrets.js +12 -9
  27. package/dist/commands/versions.js +12 -4
  28. package/dist/commands/view.d.ts +14 -1
  29. package/dist/commands/view.js +103 -128
  30. package/dist/commands/webhook.js +7 -2
  31. package/dist/lib/agents.d.ts +4 -2
  32. package/dist/lib/agents.js +21 -6
  33. package/dist/lib/commands.js +9 -1
  34. package/dist/lib/daemon.d.ts +29 -0
  35. package/dist/lib/daemon.js +58 -4
  36. package/dist/lib/events.d.ts +1 -1
  37. package/dist/lib/factory/snapshot.d.ts +78 -0
  38. package/dist/lib/factory/snapshot.js +209 -0
  39. package/dist/lib/fs-atomic.d.ts +14 -1
  40. package/dist/lib/fs-atomic.js +35 -3
  41. package/dist/lib/funnel.d.ts +1 -0
  42. package/dist/lib/funnel.js +8 -0
  43. package/dist/lib/hosts/dispatch.js +19 -1
  44. package/dist/lib/hq/floor.js +12 -0
  45. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  46. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  47. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -2
  48. package/dist/lib/menubar/install-menubar.d.ts +53 -2
  49. package/dist/lib/menubar/install-menubar.js +183 -28
  50. package/dist/lib/picker.d.ts +27 -2
  51. package/dist/lib/picker.js +71 -7
  52. package/dist/lib/platform/process.d.ts +2 -0
  53. package/dist/lib/platform/process.js +5 -3
  54. package/dist/lib/profiles.d.ts +48 -0
  55. package/dist/lib/profiles.js +67 -0
  56. package/dist/lib/resources.d.ts +8 -0
  57. package/dist/lib/resources.js +34 -1
  58. package/dist/lib/rotate.d.ts +24 -2
  59. package/dist/lib/rotate.js +63 -6
  60. package/dist/lib/routines-placement.d.ts +2 -1
  61. package/dist/lib/routines-placement.js +8 -4
  62. package/dist/lib/routines.d.ts +57 -1
  63. package/dist/lib/routines.js +74 -1
  64. package/dist/lib/runner.d.ts +2 -0
  65. package/dist/lib/runner.js +21 -8
  66. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  67. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  68. package/dist/lib/secrets/bundles.js +9 -34
  69. package/dist/lib/secrets/filestore.d.ts +152 -34
  70. package/dist/lib/secrets/filestore.js +676 -123
  71. package/dist/lib/session/active.d.ts +109 -3
  72. package/dist/lib/session/active.js +269 -13
  73. package/dist/lib/session/db.d.ts +14 -0
  74. package/dist/lib/session/db.js +35 -0
  75. package/dist/lib/session/favorites.d.ts +39 -0
  76. package/dist/lib/session/favorites.js +101 -0
  77. package/dist/lib/session/host-link.d.ts +68 -0
  78. package/dist/lib/session/host-link.js +64 -0
  79. package/dist/lib/session/presence.d.ts +85 -0
  80. package/dist/lib/session/presence.js +150 -0
  81. package/dist/lib/session/remote-active.d.ts +4 -1
  82. package/dist/lib/session/remote-active.js +8 -2
  83. package/dist/lib/session/remote-list.d.ts +10 -0
  84. package/dist/lib/session/remote-list.js +47 -9
  85. package/dist/lib/session/viewing-in.d.ts +31 -0
  86. package/dist/lib/session/viewing-in.js +47 -0
  87. package/dist/lib/state.d.ts +17 -0
  88. package/dist/lib/state.js +30 -2
  89. package/dist/lib/tmux/binary.d.ts +7 -0
  90. package/dist/lib/tmux/binary.js +11 -1
  91. package/dist/lib/triggers/handlers.d.ts +95 -0
  92. package/dist/lib/triggers/handlers.js +384 -0
  93. package/dist/lib/triggers/webhook.d.ts +10 -2
  94. package/dist/lib/triggers/webhook.js +65 -11
  95. package/dist/lib/types.d.ts +4 -3
  96. package/dist/lib/usage-backoff.d.ts +29 -0
  97. package/dist/lib/usage-backoff.js +165 -0
  98. package/dist/lib/usage.d.ts +112 -5
  99. package/dist/lib/usage.js +464 -46
  100. package/dist/lib/watchdog/runner.d.ts +13 -0
  101. package/dist/lib/watchdog/runner.js +16 -1
  102. package/package.json +1 -1
@@ -11,6 +11,7 @@ import * as fs from 'fs';
11
11
  import * as os from 'os';
12
12
  import * as path from 'path';
13
13
  import { spawn } from 'child_process';
14
+ import { Option } from 'commander';
14
15
  import chalk from 'chalk';
15
16
  import { truncate, padRight } from '../lib/format.js';
16
17
  import ora from 'ora';
@@ -20,14 +21,14 @@ import { looksLikePath, toComparablePath, homeDir, needsWindowsShell, findExecut
20
21
  import { getActiveSessions } from '../lib/session/active.js';
21
22
  import { enumerateGhosttyTabs, assignGhosttyTabs } from '../lib/session/ghostty-tabs.js';
22
23
  import { mapPanesToTargets, listClients } from '../lib/tmux/session.js';
23
- import { resolveViewingIn } from '../lib/session/viewing-in.js';
24
+ import { resolveViewingIn, viewingInLabel } from '../lib/session/viewing-in.js';
24
25
  import { machineId, normalizeHost } from '../lib/session/sync/config.js';
25
26
  import { gatherRemoteActive, NO_FANOUT_ENV } from '../lib/session/remote-active.js';
26
27
  import { gatherRemoteList, runOnPeer } from '../lib/session/remote-list.js';
27
28
  import { stringWidth, truncateToWidth, padToWidth, terminalWidth } from '../lib/session/width.js';
28
29
  import { inferSessionState } from '../lib/session/state.js';
29
30
  import { discoverSessions, countSessionsInScope, resolveSessionById, isCompleteSessionId, looksLikeSessionId, searchContentIndex, getSessionRoots } from '../lib/session/discover.js';
30
- import { findSessionsById } from '../lib/session/db.js';
31
+ import { findSessionsById, querySessions } from '../lib/session/db.js';
31
32
  import { filterTeamSessions, safeTeamText } from '../lib/session/team-filter.js';
32
33
  import { parseSession } from '../lib/session/parse.js';
33
34
  import { runRemoteSessions, buildForwardedArgs, ensureWholeIndex } from '../lib/session/remote.js';
@@ -45,6 +46,8 @@ import { setHelpSections } from '../lib/help.js';
45
46
  import { registerSessionsTailCommand } from './sessions-tail.js';
46
47
  import { registerSessionsSyncCommand } from './sessions-sync.js';
47
48
  import { registerSessionsResumeCommand } from './sessions-resume.js';
49
+ import { registerSessionsFavoriteCommand } from './sessions-favorite.js';
50
+ import { isFavorite, listFavorites } from '../lib/session/favorites.js';
48
51
  import { registerGoCommand } from './go.js';
49
52
  import { registerFocusCommand } from './focus.js';
50
53
  import { registerDetachCommand } from './detach.js';
@@ -195,6 +198,12 @@ function statusColor(status) {
195
198
  case 'closed': return chalk.dim;
196
199
  // Days-stale / dangling: red so a session nobody is driving stands out.
197
200
  case 'abandoned': return chalk.red;
201
+ // The host window died and took the agent with it — an unclean exit, not the
202
+ // dimmed `closed` of a normal one. Red-bright so a crash reads as an event.
203
+ case 'crashed': return chalk.redBright;
204
+ // Alive with nobody attached. Yellow, like `input_required`: both mean the
205
+ // session is stuck waiting on a human, and this one has no human to wait for.
206
+ case 'orphaned': return chalk.yellow;
198
207
  // Alive but un-introspectable (a harness whose transcript we can't parse).
199
208
  // Magenta so it never reads as the gray "idle" it used to be faked as.
200
209
  case 'unknown': return chalk.magenta;
@@ -329,8 +338,14 @@ export function formatActiveRowDescription(s) {
329
338
  function activityLabel(s) {
330
339
  // Lifecycle status wins over any residual parsed activity — a dead/dangling
331
340
  // session must read `closed`/`abandoned`, not the `idle` its stale tail infers.
341
+ // `crashed`/`orphaned` are the same kind of claim about the session as a whole,
342
+ // and both outrank the `idle` its last parsed turn would otherwise show.
332
343
  if (s.status === 'closed' || s.status === 'abandoned')
333
344
  return s.status;
345
+ if (s.status === 'crashed')
346
+ return 'crashed';
347
+ if (s.status === 'orphaned')
348
+ return 'orphan';
334
349
  if (s.activity === 'waiting_input')
335
350
  return 'waiting';
336
351
  if (s.activity === 'working')
@@ -367,10 +382,17 @@ export function liveGlyphAndPreview(a) {
367
382
  // so an opaque harness is never mistaken for a finished one. `⊘` = abandoned /
368
383
  // dangling; `×` = closed (dead pid) — both distinct from the `○` idle a live,
369
384
  // stopped session shows, so a gone session never masquerades as a resting one.
385
+ // `✗` = crashed (died WITH its host window, uncleanly) — deliberately louder
386
+ // than the `×` of a clean close. `◍` = orphaned (alive, nothing attached): a
387
+ // filled ring, so it reads as "still burning" unlike the hollow `○` idle.
370
388
  if (a.status === 'abandoned')
371
389
  return { glyph: statusColor(a.status)('⊘'), preview: buildSessionDescription(a) };
372
390
  if (a.status === 'closed')
373
391
  return { glyph: statusColor(a.status)('×'), preview: buildSessionDescription(a) };
392
+ if (a.status === 'crashed')
393
+ return { glyph: statusColor(a.status)('✗'), preview: buildSessionDescription(a) };
394
+ if (a.status === 'orphaned')
395
+ return { glyph: statusColor(a.status)('◍'), preview: buildSessionDescription(a) };
374
396
  const waiting = a.status === 'input_required' || a.activity === 'waiting_input';
375
397
  const running = a.status === 'running' || a.activity === 'working';
376
398
  const unknown = a.status === 'unknown';
@@ -394,6 +416,11 @@ export function liveStatusWord(a) {
394
416
  // Lifecycle status is definitive — surface it ahead of any parsed activity.
395
417
  if (a.status === 'closed' || a.status === 'abandoned')
396
418
  return a.status;
419
+ // Same rank: a lost host is a fact about the session, not about its last turn.
420
+ if (a.status === 'crashed')
421
+ return 'crashed';
422
+ if (a.status === 'orphaned')
423
+ return 'orphan';
397
424
  if (a.status === 'input_required' || a.activity === 'waiting_input')
398
425
  return 'waiting';
399
426
  if (a.status === 'running' || a.activity === 'working')
@@ -404,12 +431,43 @@ export function liveStatusWord(a) {
404
431
  return 'queued';
405
432
  return '';
406
433
  }
434
+ /**
435
+ * True when a session is blocked on a human — the `--waiting` contract.
436
+ *
437
+ * NOT `status === 'input_required'`. `foldHostLink` rewrites that status to
438
+ * `orphaned` when nothing is attached, and a session waiting on a question with
439
+ * NOBODY watching is the most acute case `--waiting` exists to surface, not one
440
+ * it should drop. The underlying `activity` is never rewritten, so it is the
441
+ * honest signal here.
442
+ *
443
+ * But `activity` is never rewritten for a DEAD session either: one that died
444
+ * mid-question keeps `waiting_input` forever, and answering it is not a thing a
445
+ * human can do — it needs a relaunch. `--waiting` is a scriptable gate ("does
446
+ * anything need me?"), so a corpse must not trip it.
447
+ *
448
+ * `closed` and `crashed` are unconditionally dead, so they are excluded outright.
449
+ * `abandoned` is NOT: it fires on transcript staleness before the liveness check,
450
+ * so it also covers the live-but-forgotten case — an interactive session that
451
+ * asked a question and sat untouched over a long weekend is still answerable, and
452
+ * is exactly what this gate exists for. It is excluded only when we positively
453
+ * know its process is gone; unknown liveness (an older peer, a row with no pid)
454
+ * stays excluded rather than inventing a human who can answer.
455
+ */
456
+ export function isAwaitingUser(s) {
457
+ if (s.status === 'crashed' || s.status === 'closed')
458
+ return false;
459
+ if (s.status === 'abandoned' && s.pidAlive !== true)
460
+ return false;
461
+ return s.status === 'input_required' || s.activity === 'waiting_input';
462
+ }
463
+ /** Width of the live status column — `crashed` is the longest word it renders. */
464
+ const LIVE_STATUS_W = 8;
407
465
  /** The colored, space-padded status cell for a listing row (empty when not live). */
408
466
  function liveStatusCell(live) {
409
467
  const word = liveStatusWord(live);
410
468
  if (!word || !live)
411
469
  return { cell: '', width: 0 };
412
- return { cell: statusColor(live.status)(padToWidth(word, 8)), width: 8 };
470
+ return { cell: statusColor(live.status)(padToWidth(word, LIVE_STATUS_W)), width: LIVE_STATUS_W };
413
471
  }
414
472
  /**
415
473
  * The tracker/PR ref for a session's dedicated column: the ticket id when known,
@@ -442,6 +500,11 @@ function modelLabel(model) {
442
500
  * (null when unknown) alongside the raw fields, so every active row is joinable.
443
501
  * `project` uses the same derivation SessionMeta does — basename(cwd) (see
444
502
  * discover.ts) — so the active view and the history view join identically.
503
+ *
504
+ * `viewingIn` flattens to the same display string the row renderer prints —
505
+ * `'codium tab 3'` / `'detached'` / null — so a consumer can tell a watched
506
+ * session from an orphaned one (its terminal died, the agent is still running)
507
+ * without re-implementing the tmux client lookup.
445
508
  */
446
509
  export function serializeActiveSessionsForJson(sessions) {
447
510
  return sessions.map((s) => ({
@@ -449,6 +512,7 @@ export function serializeActiveSessionsForJson(sessions) {
449
512
  ticketId: s.ticket?.id ?? null,
450
513
  project: s.cwd ? path.basename(s.cwd) : null,
451
514
  prLink: s.pr?.url ?? null,
515
+ viewingIn: viewingInLabel(s) ?? null,
452
516
  }));
453
517
  }
454
518
  /**
@@ -496,13 +560,9 @@ function locatorBadge(s) {
496
560
  // For a tmux-hosted session, say which app+tab is looking at it right now
497
561
  // (or that it's running detached). Only meaningful for tmux (the pane is the
498
562
  // durable handle; the viewer is transient).
499
- if (s.viewingIn) {
500
- const tab = s.viewingIn.tab != null ? ` tab ${s.viewingIn.tab}` : '';
501
- parts.push(chalk.gray(`viewing in ${s.viewingIn.app}${tab}`));
502
- }
503
- else {
504
- parts.push(chalk.gray('detached'));
505
- }
563
+ const label = viewingInLabel(s);
564
+ if (label)
565
+ parts.push(chalk.gray(label === 'detached' ? label : `viewing in ${label}`));
506
566
  }
507
567
  else if (p?.mux?.kind === 'screen') {
508
568
  parts.push(chalk.green('screen'));
@@ -743,6 +803,25 @@ export function serializeSessionsJson(sessions) {
743
803
  });
744
804
  return JSON.stringify(serializable, null, 2) + '\n';
745
805
  }
806
+ /** The intentionally small metadata contract emitted by `sessions --resolve`.
807
+ * Transcript locations, extracted plans, account data, costs, and other indexed
808
+ * payload stay local to the machine that owns them. */
809
+ export function serializeResolvedSessionsJson(sessions) {
810
+ const safe = sessions.map((session) => ({
811
+ id: session.id,
812
+ shortId: session.shortId,
813
+ agent: session.agent,
814
+ origin: session.origin,
815
+ timestamp: session.timestamp,
816
+ lastActivity: session.lastActivity,
817
+ project: session.project,
818
+ version: session.version,
819
+ label: session.label,
820
+ topic: session.topic,
821
+ machine: session.machine,
822
+ }));
823
+ return JSON.stringify(safe, null, 2) + '\n';
824
+ }
746
825
  /**
747
826
  * `agents sessions --json --host <h>` — fan the RECENT (non-active) listing out
748
827
  * to the named host(s) and emit ONE clean merged `SessionMeta[]` JSON array,
@@ -775,9 +854,15 @@ function groupTally(sessions) {
775
854
  const running = sessions.filter(s => s.status === 'running').length;
776
855
  const idle = sessions.filter(s => s.status === 'idle').length;
777
856
  const waiting = sessions.filter(s => s.status === 'input_required').length;
857
+ // Counted by status, deliberately: an orphaned session gets its own bucket
858
+ // below, so counting it here too would double-count it in the tally.
778
859
  const queued = sessions.filter(s => s.status === 'queued').length;
779
860
  const closed = sessions.filter(s => s.status === 'closed').length;
780
861
  const abandoned = sessions.filter(s => s.status === 'abandoned').length;
862
+ // Without these two the tally silently loses rows: every status must have a
863
+ // bucket or "N active (…)" stops adding up to what the list shows.
864
+ const orphaned = sessions.filter(s => s.status === 'orphaned').length;
865
+ const crashed = sessions.filter(s => s.status === 'crashed').length;
781
866
  const unknown = sessions.filter(s => s.status === 'unknown').length;
782
867
  const parts = [];
783
868
  if (running)
@@ -792,6 +877,10 @@ function groupTally(sessions) {
792
877
  parts.push(`${closed} closed`);
793
878
  if (abandoned)
794
879
  parts.push(`${abandoned} abandoned`);
880
+ if (orphaned)
881
+ parts.push(`${orphaned} orphaned`);
882
+ if (crashed)
883
+ parts.push(`${crashed} crashed`);
795
884
  if (unknown)
796
885
  parts.push(`${unknown} unknown`);
797
886
  return parts.join(' · ');
@@ -860,13 +949,33 @@ async function enrichLocalLocators(local) {
860
949
  }
861
950
  }
862
951
  catch { /* non-fatal */ }
863
- // tmux attach targets + "viewing in <app> tab N", one batched query per socket.
952
+ // One Ghostty enumeration shared across every socket's viewing-in resolve
953
+ // (a tmux client can be attached from a Ghostty tab).
954
+ await enrichTmuxLocators(local, await enumerateGhosttyTabsQuietly());
955
+ }
956
+ /** {@link enumerateGhosttyTabs}, best-effort — an osascript failure yields no surfaces. */
957
+ async function enumerateGhosttyTabsQuietly() {
958
+ try {
959
+ return await enumerateGhosttyTabs();
960
+ }
961
+ catch {
962
+ return [];
963
+ }
964
+ }
965
+ /**
966
+ * The tmux half of {@link enrichLocalLocators}: the `session:window.pane` attach
967
+ * target and "viewing in <app> tab N" / detached, one batched query per socket.
968
+ *
969
+ * Split out because it is the only locator the `--json` path can afford. It costs
970
+ * tmux queries and a `ps` read — no osascript — so scriptable output stays cheap
971
+ * while still answering the question a consumer actually needs: is anyone looking
972
+ * at this session, or is it running orphaned? Without `surfaces`, a Ghostty-attached
973
+ * client still resolves as attached, just without its tab number.
974
+ */
975
+ async function enrichTmuxLocators(local, surfaces = []) {
864
976
  try {
865
977
  const tmux = local.filter(s => s.provenance?.mux?.kind === 'tmux' && s.provenance.mux.pane);
866
978
  if (tmux.length > 0) {
867
- // One Ghostty enumeration shared across every socket's viewing-in resolve
868
- // (a tmux client can be attached from a Ghostty tab).
869
- const surfaces = await enumerateGhosttyTabs();
870
979
  const sockets = new Set(tmux.map(s => s.provenance.mux.socket));
871
980
  for (const socket of sockets) {
872
981
  const paneMap = await mapPanesToTargets(socket);
@@ -959,11 +1068,25 @@ export async function gatherActiveSessions(opts = {}) {
959
1068
  */
960
1069
  async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
961
1070
  const self = machineId();
962
- const { sessions: merged, remoteDeviceCount } = await gatherActiveSessions(opts);
1071
+ const gathered = await gatherActiveSessions(opts);
1072
+ const { remoteDeviceCount } = gathered;
1073
+ // --favorites narrows the live view too. Applied HERE, not only in the
1074
+ // browser: the browser is skipped for --json, --waiting, a pipe, a multi-host
1075
+ // scope, and an SSH-fanout peer, and the flag silently did nothing on every
1076
+ // one of those paths — including `--active --favorites --json`, which is
1077
+ // exactly what the browser's own `y` copy-cmd hands to an agent.
1078
+ const merged = opts.favoritesOnly
1079
+ ? gathered.sessions.filter((s) => !!s.sessionId && listFavorites().has(s.sessionId))
1080
+ : gathered.sessions;
963
1081
  // --waiting: only sessions blocked on the user. Exits non-zero when any are
964
1082
  // present so a supervising agent or hook can poll it as a gate.
965
- const sessions = waitingOnly ? merged.filter(s => s.status === 'input_required') : merged;
1083
+ const sessions = waitingOnly ? merged.filter(isAwaitingUser) : merged;
966
1084
  if (asJson) {
1085
+ // Resolve who is watching each local tmux pane before serializing: `viewingIn`
1086
+ // is how a consumer distinguishes a session someone is looking at from one
1087
+ // running orphaned after its terminal died. tmux-only (no osascript) so the
1088
+ // scriptable path stays cheap — see enrichTmuxLocators.
1089
+ await enrichTmuxLocators(sessions.filter(s => !s.machine || s.machine === self));
967
1090
  process.stdout.write(JSON.stringify(serializeActiveSessionsForJson(sessions), null, 2) + '\n');
968
1091
  if (waitingOnly && sessions.length > 0)
969
1092
  process.exitCode = 1;
@@ -1084,6 +1207,8 @@ function canonicalSessionsCommand(query, options) {
1084
1207
  a.push('--local');
1085
1208
  if (options.waiting)
1086
1209
  a.push('--waiting');
1210
+ if (options.favorites)
1211
+ a.push('--favorites');
1087
1212
  const q = (query ?? '').trim();
1088
1213
  if (q)
1089
1214
  a.push(JSON.stringify(q));
@@ -1112,16 +1237,48 @@ async function renderSessionPreview(query, scope) {
1112
1237
  live = indexActiveBySessionId(await getActiveSessions()).get(session.id);
1113
1238
  }
1114
1239
  catch { /* plain preview on any probe failure */ }
1115
- if (live) {
1116
- const { glyph } = liveGlyphAndPreview(live);
1117
- const word = liveStatusWord(live) || live.status;
1118
- const needsYou = live.status === 'input_required' || live.activity === 'waiting_input';
1119
- const reason = live.awaitingReason ? ` (${live.awaitingReason.replace('_', ' ')})` : '';
1120
- const suffix = needsYou ? chalk.yellow(` ← needs you${reason}`) : '';
1121
- console.log(`${glyph} ${statusColor(live.status)(word)}${suffix}`);
1122
- }
1240
+ const headline = formatLiveStatusHeadline(live, isFavorite(session.id));
1241
+ if (headline)
1242
+ console.log(headline);
1123
1243
  console.log(buildPreview(session));
1124
1244
  }
1245
+ /**
1246
+ * The one-line live status banner shown above a session preview: the glyph, the
1247
+ * status word, and — when the session needs a human or has LOST one — a plain
1248
+ * sentence saying so. Shared by `--preview` and the interactive browser's preview
1249
+ * pane so both explain a state the same way.
1250
+ *
1251
+ * `crashed` and `orphaned` are the states a glyph alone cannot carry: nobody
1252
+ * reads "orphan" and knows it means "still running in tmux with no window
1253
+ * attached", so those two spell it out.
1254
+ */
1255
+ export function formatLiveStatusHeadline(live, favorite = false) {
1256
+ const star = favorite ? chalk.yellow('★ ') : '';
1257
+ // With no live row there is no status to lead with, so the star has to say
1258
+ // what it means on its own — a bare `★` above a preview reads as noise.
1259
+ if (!live)
1260
+ return favorite ? chalk.yellow('★ favorited') : '';
1261
+ const { glyph } = liveGlyphAndPreview(live);
1262
+ const word = liveStatusWord(live) || live.status;
1263
+ // One definition, shared with `--waiting`. A second local copy drifted: the
1264
+ // preview said "needs you" for a dead session that `--waiting` had (rightly)
1265
+ // stopped counting, so the human and the script disagreed about the same row.
1266
+ const needsYou = isAwaitingUser(live);
1267
+ const reason = live.awaitingReason ? ` (${live.awaitingReason.replace('_', ' ')})` : '';
1268
+ let suffix = needsYou ? chalk.yellow(` ← needs you${reason}`) : '';
1269
+ if (live.status === 'crashed') {
1270
+ suffix = chalk.redBright(' ← the host app or connection went away and took the agent with it');
1271
+ }
1272
+ else if (live.status === 'orphaned') {
1273
+ // Keep the needs-you half. A session sitting on a real question with nobody
1274
+ // attached is strictly worse than either fact alone, and replacing the
1275
+ // question with the generic orphan line undersells exactly that case.
1276
+ suffix = needsYou
1277
+ ? chalk.yellow(` ← waiting on you${reason}, and no client is attached to answer it`)
1278
+ : chalk.yellow(' ← still running, but no client is attached — nothing is showing it');
1279
+ }
1280
+ return `${star}${glyph} ${statusColor(live.status)(word)}${suffix}`;
1281
+ }
1125
1282
  /** Main action handler for `agents sessions`. Routes to picker, table, or single-session render. */
1126
1283
  async function sessionsAction(query, options,
1127
1284
  /**
@@ -1155,6 +1312,39 @@ limitSource) {
1155
1312
  process.stdout.write(JSON.stringify(getSessionRoots(), null, 2) + '\n');
1156
1313
  return;
1157
1314
  }
1315
+ // --resolve is the metadata-only contract for downstream context workflows.
1316
+ // It reads indexed SessionMeta rows and never calls renderSession, buildPreview,
1317
+ // parseSession, or any other transcript renderer/parser.
1318
+ if (options.resolve !== undefined || options.resolveSafeV1 !== undefined) {
1319
+ if (options.resolveSafeV1 !== undefined && process.env[NO_FANOUT_ENV] !== '1') {
1320
+ console.error(chalk.red('--resolve-safe-v1 is an internal fleet protocol.'));
1321
+ process.exit(1);
1322
+ }
1323
+ if (!options.json) {
1324
+ console.error(chalk.red('--resolve requires --json.'));
1325
+ process.exit(1);
1326
+ }
1327
+ const selector = (options.resolveSafeV1 ?? options.resolve ?? '').trim();
1328
+ if (!selector) {
1329
+ console.error(chalk.red('--resolve requires a non-empty selector.'));
1330
+ process.exit(1);
1331
+ }
1332
+ if (query) {
1333
+ console.error(chalk.red('Pass the selector to --resolve, not as a positional query.'));
1334
+ process.exit(1);
1335
+ }
1336
+ if (options.local === true && options.host && !shouldIncludeLocal(options.host, machineId())) {
1337
+ console.error(chalk.red('--local and --device name opposite scopes: --local skips the SSH fan-out that --device needs.'));
1338
+ process.exit(1);
1339
+ }
1340
+ await resolveSessionMetadata(selector, {
1341
+ agent: options.agent,
1342
+ project: options.project,
1343
+ local: options.local,
1344
+ hosts: options.host,
1345
+ });
1346
+ return;
1347
+ }
1158
1348
  // --host WITHOUT --active. `--json` fans the recent listing out and emits ONE
1159
1349
  // clean merged SessionMeta[] array (same shape as the local --json path), for
1160
1350
  // scripts/extensions that JSON.parse a remote's history. Without --json it
@@ -1229,6 +1419,7 @@ limitSource) {
1229
1419
  host: options.host,
1230
1420
  since: options.since,
1231
1421
  all: options.all,
1422
+ favorites: options.favorites,
1232
1423
  }), { local: options.local === true, hosts: options.host });
1233
1424
  return;
1234
1425
  }
@@ -1238,6 +1429,7 @@ limitSource) {
1238
1429
  await renderActiveSessions(options.json === true, options.waiting === true, {
1239
1430
  local: forceLocal,
1240
1431
  hosts: options.host,
1432
+ favoritesOnly: options.favorites === true,
1241
1433
  });
1242
1434
  return;
1243
1435
  }
@@ -1259,6 +1451,7 @@ limitSource) {
1259
1451
  since: options.since,
1260
1452
  host: options.host,
1261
1453
  inTeam: options.inTeam,
1454
+ favorites: options.favorites,
1262
1455
  }), { local: options.local === true, hosts: options.host });
1263
1456
  return;
1264
1457
  }
@@ -1383,6 +1576,12 @@ limitSource) {
1383
1576
  // read. Match either, after that pass has populated `teamOrigin`.
1384
1577
  if (options.inTeam)
1385
1578
  sessions = sessions.filter((s) => matchesTeam(s, options.inTeam));
1579
+ // --favorites narrows to the starred set. Applied here, before the JSON
1580
+ // emit, so `--favorites --json` is the machine-readable twin of the `f` key.
1581
+ if (options.favorites) {
1582
+ const starred = listFavorites();
1583
+ sessions = sessions.filter((s) => starred.has(s.id));
1584
+ }
1386
1585
  // Under --in-team the visible list is one team, so the whole-index team-origin
1387
1586
  // count would be a non-sequitur next to it.
1388
1587
  const hiddenCount = options.teams || options.inTeam
@@ -1575,7 +1774,7 @@ function metaSignals(s) {
1575
1774
  * (tracker/PR ref, pulled out of the badge blob so refs align) is only rendered
1576
1775
  * when `showTicket` — otherwise a listing with no refs would waste a column of
1577
1776
  * dashes and needlessly truncate the topic. Worktree stays a trailing badge. */
1578
- export function flatSessionRow(session, live, showTicket = false, cols = {}) {
1777
+ export function flatSessionRow(session, live, showTicket = false, cols = {}, favorite = false) {
1579
1778
  const agentColor = colorAgent(session.agent);
1580
1779
  const when = formatRelativeTime(session.lastActivity ?? session.timestamp);
1581
1780
  const project = session.project || '-';
@@ -1611,13 +1810,17 @@ export function flatSessionRow(session, live, showTicket = false, cols = {}) {
1611
1810
  const wtW = wt ? stringWidth(wt) + 1 : 0;
1612
1811
  const width = terminalWidth();
1613
1812
  const requestedModelW = cols.showModel ? (cols.modelWidth ?? PICKER_MODEL_MAX) : 0;
1614
- const fixedW = (10 + 9 + 8 + 16) + glyphW + statusW + machineW + ticketW + wtW + team.width + stringWidth(when) + 1;
1813
+ // Same conditional 2 cells as the picker's marker, for the same reason.
1814
+ const favW = cols.showFavorite ? 2 : 0;
1815
+ const favCell = cols.showFavorite ? (favorite ? chalk.yellow('★ ') : ' ') : '';
1816
+ const fixedW = favW + (10 + 9 + 8 + 16) + glyphW + statusW + machineW + ticketW + wtW + team.width + stringWidth(when) + 1;
1615
1817
  const modelSlack = width - fixedW - 16;
1616
1818
  const modelW = requestedModelW <= modelSlack
1617
1819
  ? requestedModelW
1618
1820
  : modelSlack >= PICKER_MODEL_MIN ? modelSlack : 0;
1619
1821
  const topicW = Math.max(16, width - fixedW - modelW);
1620
- return (chalk.white(padToWidth(truncateToWidth(session.shortId, 9), 10)) +
1822
+ return (favCell +
1823
+ chalk.white(padToWidth(truncateToWidth(session.shortId, 9), 10)) +
1621
1824
  agentColor(padToWidth(truncateToWidth(session.agent, 8), 9)) +
1622
1825
  chalk.yellow(padToWidth(truncateToWidth(session.version || '-', 7), 8)) +
1623
1826
  (modelW ? chalk.yellow(padToWidth(truncateToWidth(modelLabel(session.model), modelW - 1), modelW)) : '') +
@@ -1791,8 +1994,10 @@ function printSessionTable(sessions, hiddenCount = 0, tree = false, liveIndex) {
1791
1994
  // column (and its compact labels) is computed the same way the picker does it.
1792
1995
  const showTicket = sessions.some((s) => ticketLabel(s) !== '');
1793
1996
  const cols = pickerColumnsFor(sessions);
1794
- for (const session of sessions)
1795
- console.log(flatSessionRow(session, liveIndex?.get(session.id), showTicket, cols));
1997
+ const favorites = listFavorites();
1998
+ for (const session of sessions) {
1999
+ console.log(flatSessionRow(session, liveIndex?.get(session.id), showTicket, cols, favorites.has(session.id)));
2000
+ }
1796
2001
  const countLine = `${sessions.length} session${sessions.length === 1 ? '' : 's'}.`;
1797
2002
  console.log(chalk.gray(`\n${countLine}`));
1798
2003
  if (hiddenCount > 0) {
@@ -2022,6 +2227,11 @@ export function pickerColumnsFor(sessions) {
2022
2227
  showModel: sessions.some((s) => !!s.model),
2023
2228
  modelWidth: modelColumnWidth(sessions),
2024
2229
  showTicket: sessions.some((s) => ticketLabel(s) !== ''),
2230
+ showFavorite: (() => {
2231
+ // One read of the store per pool, not one per row.
2232
+ const starred = listFavorites();
2233
+ return starred.size > 0 && sessions.some((s) => starred.has(s.id));
2234
+ })(),
2025
2235
  };
2026
2236
  }
2027
2237
  /** Width of the host-program column (`tmux→ghostty` is the long realistic case). */
@@ -2040,7 +2250,7 @@ export function liveHostLabel(a) {
2040
2250
  const viewer = a.viewingIn?.app;
2041
2251
  return viewer && viewer !== a.host ? `${a.host}→${viewer}` : a.host;
2042
2252
  }
2043
- export function formatPickerLabel(s, query, cols = {}, ssh, host = '') {
2253
+ export function formatPickerLabel(s, query, cols = {}, ssh, host = '', favorite = false, live) {
2044
2254
  const agentColor = colorAgent(s.agent);
2045
2255
  const when = formatRelativeTime(s.lastActivity ?? s.timestamp);
2046
2256
  const project = s.project || '-';
@@ -2082,17 +2292,32 @@ export function formatPickerLabel(s, query, cols = {}, ssh, host = '') {
2082
2292
  const ticketW = cols.showTicket ? TICKET_W + 1 : 0;
2083
2293
  const hostW = cols.showHost ? PICKER_HOST_W : 0;
2084
2294
  const wtW = wt ? stringWidth(wt) + 1 : 0;
2085
- const topicW = Math.max(16, terminalWidth() - gutter - (10 + 9 + 8 + 16) - machineColW - hostW - ticketW - wtW - sshW - team.width - stringWidth(when) - 1);
2086
- return (
2087
- // Truncated, not just padded: an indexed shortId is always 8 chars, but a
2088
- // live row with no session id is named by its pid or cloud task, which can
2089
- // run past the column and shunt every later column out of alignment.
2090
- chalk.white(padRight(truncate(s.shortId, 9), 10)) +
2295
+ // Within a pool that HAS starred rows the marker holds its 2 cells whether this
2296
+ // row is starred or not, so the columns after it never jog; a pool with none
2297
+ // drops the column entirely (`showFavorite`) and costs nothing.
2298
+ const favW = cols.showFavorite ? 2 : 0;
2299
+ const favCell = cols.showFavorite ? (favorite ? chalk.yellow('★ ') : ' ') : '';
2300
+ // The same status word the flat listing shows, so a session that is `orphan`
2301
+ // or `crashed` reads that way in the browser too — not only in its preview.
2302
+ // Constant width whenever the column is on. `liveStatusCell` already pads its
2303
+ // word to LIVE_STATUS_W but returns an EMPTY cell (width 0) for a row with no
2304
+ // live match — blanks fill in for those, so the topic column does not jog on
2305
+ // exactly the rows that are not running.
2306
+ const status = cols.showStatus ? liveStatusCell(live) : { cell: '', width: 0 };
2307
+ const statusW = cols.showStatus ? LIVE_STATUS_W : 0;
2308
+ const statusCell = cols.showStatus ? (status.cell || ' '.repeat(LIVE_STATUS_W)) : '';
2309
+ const topicW = Math.max(16, terminalWidth() - gutter - favW - statusW - (10 + 9 + 8 + 16) - machineColW - hostW - ticketW - wtW - sshW - team.width - stringWidth(when) - 1);
2310
+ return (favCell +
2311
+ // Truncated, not just padded: an indexed shortId is always 8 chars, but a
2312
+ // live row with no session id is named by its pid or cloud task, which can
2313
+ // run past the column and shunt every later column out of alignment.
2314
+ chalk.white(padRight(truncate(s.shortId, 9), 10)) +
2091
2315
  agentColor(padRight(truncate(s.agent, 8), 9)) +
2092
2316
  chalk.yellow(padRight(truncate(versionStr, 7), 8)) +
2093
2317
  machineCell +
2094
2318
  hostCell +
2095
2319
  chalk.cyan(padRight(truncate(project, 14), 16)) +
2320
+ statusCell +
2096
2321
  sshSeg +
2097
2322
  teamSeg +
2098
2323
  renderTopicCell(label, topic, query, topicW, topicW) +
@@ -2499,7 +2724,7 @@ function formatSearchMessage(options) {
2499
2724
  * search (a bare id must not surface every transcript that merely mentions it).
2500
2725
  * A genuine search phrase keeps the ranked metadata+content search.
2501
2726
  */
2502
- export function resolveSessionQuery(pool, query) {
2727
+ export function resolveSessionQuery(pool, query, options = {}) {
2503
2728
  // Normalize ONCE here. isCompleteSessionId trims but resolveSessionById does
2504
2729
  // not, so a padded id ("<uuid> ", e.g. pasted from a terminal) would classify
2505
2730
  // as complete and then miss the id lookup — reporting a session that IS on
@@ -2519,7 +2744,8 @@ export function resolveSessionQuery(pool, query) {
2519
2744
  // id" — NOT fall back to fuzzy content search. A short id like "d3470b57"
2520
2745
  // otherwise surfaces every transcript that merely MENTIONS the string (a
2521
2746
  // resume prompt echoes the parent id into the body of many later sessions).
2522
- return { matches: findSessionsById(normalized), byId: true, completeId };
2747
+ const matches = options.indexFallback === false ? [] : findSessionsById(normalized);
2748
+ return { matches, byId: true, completeId };
2523
2749
  }
2524
2750
  return { matches: filterSessionsByQuery(pool, normalized), byId: false, completeId };
2525
2751
  }
@@ -2836,88 +3062,176 @@ function modeFlag(mode) {
2836
3062
  return '--json';
2837
3063
  return undefined; // summary — the peer's default render
2838
3064
  }
2839
- /** Group a fleet sweep's rows to the DISTINCT machines that hold the id. Each
2840
- * peer answered `sessions <id> --json --local`, which (post-fix) id-resolves and
2841
- * so returns the matching row(s); a peer with a synced MIRROR of the same id can
2842
- * emit more than one row, so we keep the first per machine. Rows the peer somehow
2843
- * returned that do NOT match the id (defensive against version skew) are dropped
2844
- * so a stray content hit can never masquerade as an exact resolution. */
2845
- export function fleetHitsById(rows, id) {
2846
- const q = id.trim().toLowerCase();
2847
- const byMachine = new Map();
2848
- for (const s of rows) {
2849
- const machine = s.machine;
3065
+ /** Resolve a fleet sweep through the same canonical full-id / prefix resolver as
3066
+ * local lookups, then group copies by logical session id. Synced mirrors of one
3067
+ * session therefore stay one candidate even when several machines report them;
3068
+ * distinct ids sharing a prefix remain distinct ambiguity candidates. */
3069
+ export function fleetCandidatesByQuery(rows, query) {
3070
+ // An id selector must still be prefix-filtered defensively against older peers
3071
+ // returning content mentioners. Keyword rows, however, were already matched by
3072
+ // each peer's own FTS index; the parent does not own that transcript/index and
3073
+ // must not re-run metadata-only filtering that could discard a content hit.
3074
+ const matched = looksLikeSessionId(query)
3075
+ ? resolveSessionQuery(rows, query, { indexFallback: false }).matches
3076
+ : rows;
3077
+ const byId = new Map();
3078
+ for (const session of matched) {
3079
+ const machine = session.machine;
2850
3080
  if (!machine)
2851
3081
  continue; // an untagged row can't be routed back to a peer
2852
- if (s.id?.toLowerCase() !== q)
2853
- continue; // exact id only — never a content hit
3082
+ const logicalId = session.id.toLowerCase();
3083
+ let byMachine = byId.get(logicalId);
3084
+ if (!byMachine) {
3085
+ byMachine = new Map();
3086
+ byId.set(logicalId, byMachine);
3087
+ }
2854
3088
  if (!byMachine.has(machine))
2855
- byMachine.set(machine, s);
3089
+ byMachine.set(machine, session);
2856
3090
  }
2857
- return Array.from(byMachine.entries()).map(([machine, session]) => ({ machine, session }));
3091
+ return Array.from(byId.values()).map(byMachine => {
3092
+ const hits = Array.from(byMachine.entries()).map(([machine, session]) => ({ machine, session }));
3093
+ return { id: hits[0].session.id, hits };
3094
+ });
3095
+ }
3096
+ /** Resolve through the canonical metadata+content union used by keyword search. */
3097
+ function resolveIndexedMetadataRows(indexed, selector) {
3098
+ return resolveSessionQuery(indexed, selector, { indexFallback: false }).matches;
3099
+ }
3100
+ /** Fixed peer argv for the metadata resolver. Scope flags compose identically on
3101
+ * every host; `--all` removes the SSH login cwd/time window, not agent/project filters. */
3102
+ export function metadataResolveForwardedArgs(selector, scope) {
3103
+ const args = ['sessions', '--resolve-safe-v1', selector, '--json', '--all', '--local'];
3104
+ if (scope.agent)
3105
+ args.push('--agent', scope.agent);
3106
+ if (scope.project)
3107
+ args.push('--project', scope.project);
3108
+ return args;
3109
+ }
3110
+ /** Resolution must fail closed when any selected peer did not answer. Choosing
3111
+ * from a partial fleet can turn an unseen candidate into a false unique match. */
3112
+ export function metadataResolveOutcome(localMatches, remote, selector) {
3113
+ if (remote.unreachable.length > 0) {
3114
+ return { kind: 'partial', failedPeers: remote.unreachable };
3115
+ }
3116
+ const candidates = fleetCandidatesByQuery([...localMatches, ...remote.sessions], selector);
3117
+ if (candidates.length === 0)
3118
+ return { kind: 'not-found' };
3119
+ if (candidates.length > 1)
3120
+ return { kind: 'ambiguous', candidates };
3121
+ return { kind: 'resolved', session: candidates[0].hits[0].session };
3122
+ }
3123
+ /** Resolve one selector to indexed metadata across the fleet without reading or
3124
+ * rendering transcript events. A peer answering the parent sweep returns every
3125
+ * local candidate; the parent performs the one logical-session uniqueness gate. */
3126
+ async function resolveSessionMetadata(selector, scope, deps = { gatherRemoteList }) {
3127
+ const localMachine = machineId();
3128
+ const includeLocal = !scope.hosts?.length || shouldIncludeLocal(scope.hosts, localMachine);
3129
+ const indexed = includeLocal ? applyScopeFilters(querySessions(), scope) : [];
3130
+ const localMatches = resolveIndexedMetadataRows(indexed, selector)
3131
+ .map(session => ({ ...session, machine: session.machine || localMachine }));
3132
+ // A peer is already inside gatherRemoteList. Return all local candidates so
3133
+ // the parent can distinguish a fleet-wide unique match from an ambiguity.
3134
+ if (process.env[NO_FANOUT_ENV] === '1') {
3135
+ process.stdout.write(serializeResolvedSessionsJson(localMatches));
3136
+ return;
3137
+ }
3138
+ let remoteResult = { sessions: [], unreachable: [] };
3139
+ if (scope.local !== true) {
3140
+ try {
3141
+ const forwarded = metadataResolveForwardedArgs(selector, scope);
3142
+ const result = await deps.gatherRemoteList(forwarded, scope.hosts);
3143
+ remoteResult = { sessions: result.sessions, unreachable: result.unreachable };
3144
+ }
3145
+ catch (error) {
3146
+ remoteResult = { sessions: [], unreachable: [error?.message ?? 'fleet fan-out'] };
3147
+ }
3148
+ }
3149
+ const outcome = metadataResolveOutcome(localMatches, remoteResult, selector);
3150
+ if (outcome.kind === 'partial') {
3151
+ console.error(chalk.red(`Partial session resolution: ${outcome.failedPeers.join(', ')} did not answer.`));
3152
+ console.error(chalk.gray('No unique/no-match decision was made. Upgrade or reconnect every peer, then retry.'));
3153
+ process.exit(2);
3154
+ }
3155
+ if (outcome.kind === 'not-found') {
3156
+ console.error(chalk.red(`No session found matching: ${selector}`));
3157
+ process.exit(1);
3158
+ }
3159
+ if (outcome.kind === 'ambiguous') {
3160
+ console.error(chalk.red(`Multiple sessions match "${selector}" across the fleet:`));
3161
+ for (const candidate of outcome.candidates) {
3162
+ const session = candidate.hits[0].session;
3163
+ const machines = candidate.hits.map(hit => hit.machine).join(', ');
3164
+ console.error(chalk.cyan(` ${session.shortId} ${session.id}`) + chalk.gray(` ${machines} ${session.label ?? session.topic ?? ''}`));
3165
+ }
3166
+ console.error(chalk.gray(looksLikeSessionId(selector) ? 'Pass a longer ID to narrow it down.' : 'Narrow the keywords to one session.'));
3167
+ process.exit(1);
3168
+ }
3169
+ process.stdout.write(serializeResolvedSessionsJson([outcome.session]));
2858
3170
  }
2859
3171
  /**
2860
- * Locate a full session id across the online fleet and render it from the machine
3172
+ * Locate a full session id or short id prefix across the online fleet and render it from the machine
2861
3173
  * that holds it. The local disk already missed; this fans `sessions <id> --json
2862
3174
  * --all` out to every registered online peer (or the explicit `hosts` set),
2863
3175
  * groups the rows to distinct machines, then:
2864
3176
  *
2865
- * - exactly one machine → delegate rendering to that peer via `runOnPeer`
3177
+ * - exactly one logical session → delegate rendering to one peer via `runOnPeer`
2866
3178
  * (its transcript and agent binary live there — a local `--host` hop would
2867
3179
  * re-discover locally and dead-end), returning `'rendered'`.
2868
- * - more than one machine → print the conflict with machine labels so the user
2869
- * can disambiguate with `--device <host>`, returning `'conflict'`.
3180
+ * - more than one logical session → print every full-id candidate with its
3181
+ * machine labels, returning `'conflict'`.
2870
3182
  * - none → `'not-found'`, letting the caller print the local
2871
3183
  * "no session on this machine" message.
2872
3184
  *
2873
- * No fuzzy/content fallback: the sweep forwards a UUID, each peer id-resolves it,
2874
- * and `fleetHitsById` drops anything that isn't an exact id match.
3185
+ * No fuzzy/content fallback: the sweep forwards the id selector and every result
3186
+ * is resolved through `resolveSessionQuery`, the same id-only resolver used locally.
2875
3187
  */
2876
- export async function resolveSessionAcrossFleet(id, mode, hosts, deps = { gatherRemoteList, runOnPeer }) {
3188
+ export async function resolveSessionAcrossFleet(query, mode, hosts, deps = { gatherRemoteList, runOnPeer }) {
2877
3189
  const spinner = isInteractiveTerminal() ? ora('Searching the fleet...').start() : null;
2878
- let hits;
3190
+ let candidates;
2879
3191
  try {
2880
3192
  // Force whole-index scope (--all): the peer runs in its SSH-login home dir,
2881
3193
  // whose cwd would otherwise silently narrow the lookup and hide the row.
2882
3194
  // --json so each peer answers a parseable array; --local so it answers for
2883
3195
  // itself and never re-fans-out (belt-and-suspenders with the parent's
2884
3196
  // AGENTS_SESSIONS_LOCAL, which remote-list also sets on the peer).
2885
- const forwarded = ['sessions', id, '--json', '--all', '--local'];
3197
+ const forwarded = ['sessions', query, '--json', '--all', '--local'];
2886
3198
  const { sessions } = await deps.gatherRemoteList(forwarded, hosts);
2887
- hits = fleetHitsById(sessions, id);
3199
+ candidates = fleetCandidatesByQuery(sessions, query);
2888
3200
  }
2889
3201
  catch {
2890
3202
  // A fan-out failure is not an exact resolution — treat as not-found so the
2891
3203
  // caller prints the honest local message rather than a half-answer.
2892
- hits = [];
3204
+ candidates = [];
2893
3205
  }
2894
3206
  finally {
2895
3207
  spinner?.stop();
2896
3208
  }
2897
- if (hits.length === 0)
3209
+ if (candidates.length === 0)
2898
3210
  return 'not-found';
2899
- if (hits.length > 1) {
2900
- console.error(chalk.red(`Session ${id} exists on multiple machines:`));
2901
- for (const h of hits) {
2902
- const s = h.session;
3211
+ if (candidates.length > 1) {
3212
+ console.error(chalk.red(`Multiple sessions match "${query}" across the fleet:`));
3213
+ for (const candidate of candidates) {
3214
+ const s = candidate.hits[0].session;
2903
3215
  const label = s.label ?? s.topic ?? '';
2904
- console.error(chalk.cyan(` ${h.machine}`) + chalk.gray(` ${s.agent}${s.version ? ` ${s.version}` : ''} ${label}`));
3216
+ const machines = candidate.hits.map(hit => hit.machine).join(', ');
3217
+ console.error(chalk.cyan(` ${s.shortId} ${s.id}`) + chalk.gray(` ${machines} ${s.agent}${s.version ? ` ${s.version}` : ''} ${label}`));
2905
3218
  }
2906
- console.error(chalk.gray(`Pick one with: agents sessions ${id} --device <host>`));
3219
+ console.error(chalk.gray('Pass a longer ID to narrow it down.'));
2907
3220
  return 'conflict';
2908
3221
  }
2909
- const { machine } = hits[0];
3222
+ const candidate = candidates[0];
3223
+ const { machine } = candidate.hits[0];
2910
3224
  // Render the remote summary by re-running `sessions <id>` ON the peer. --local
2911
3225
  // keeps that render on the peer (it owns the transcript); the mode flag matches
2912
3226
  // the mode the user asked for. No TTY: a summary/markdown/json render is a
2913
3227
  // one-shot capture, not an interactive resume.
2914
- const peerArgs = ['sessions', id, '--local'];
3228
+ const peerArgs = ['sessions', candidate.id, '--local'];
2915
3229
  const flag = modeFlag(mode);
2916
3230
  if (flag)
2917
3231
  peerArgs.push(flag);
2918
3232
  const result = await deps.runOnPeer(peerArgs, machine);
2919
3233
  if (result === 'no-target') {
2920
- console.error(chalk.red(`Session ${id} is on ${machine}, but it is not a reachable registered device.`));
3234
+ console.error(chalk.red(`Session ${candidate.id} is on ${machine}, but it is not a reachable registered device.`));
2921
3235
  console.error(chalk.gray('Register it with `agents devices` or run the command on that machine.'));
2922
3236
  return 'conflict'; // a definitive answer (found, un-renderable) — do NOT fall to the local not-found line
2923
3237
  }
@@ -2929,6 +3243,8 @@ export function registerSessionsCommands(program) {
2929
3243
  .command('sessions')
2930
3244
  .argument('[query]', 'Session ID, search query, or path (., ../, /path) to filter by project')
2931
3245
  .option('--query <text>', 'Search text — use when the term collides with a subcommand name (e.g. "go")')
3246
+ .option('--resolve <selector>', 'Resolve one full ID, unique prefix, or keyword query to safe session metadata (requires --json; searches the fleet unless --local)')
3247
+ .addOption(new Option('--resolve-safe-v1 <selector>').hideHelp())
2932
3248
  .description('Find, browse, and read agent conversation transcripts across Claude, Codex, Gemini, and OpenCode.')
2933
3249
  .option('-a, --agent <agent>', 'Filter by agent type and version (e.g., claude, codex@0.116.0)')
2934
3250
  .option('--claude', 'Shorthand for --agent claude')
@@ -2960,6 +3276,7 @@ export function registerSessionsCommands(program) {
2960
3276
  .option('--roots', 'With --json: emit the on-disk directories scanned for session transcripts, per agent (for external watchers)')
2961
3277
  .option('--local', 'Only this machine — skip the cross-machine SSH fan-out (default listing and --active)')
2962
3278
  .option('--waiting', 'With --active: show only sessions waiting on your input (exits non-zero if any)')
3279
+ .option('--favorites', 'Show only favorited (starred) sessions — star them with `*` in the browser or `agents sessions favorite <id>`')
2963
3280
  .option('--tree', 'Group the listing by directory; drops the id/version columns for readability')
2964
3281
  .option('--flat', 'Plain flat table (one row per session) instead of the grouped project overview')
2965
3282
  .option('--no-live', 'Do not enrich the listing with live status/preview for running sessions')
@@ -3002,6 +3319,9 @@ export function registerSessionsCommands(program) {
3002
3319
  # Export for analysis
3003
3320
  agents sessions --since 30d --limit 200 --json > sessions.json
3004
3321
 
3322
+ # Resolve one historical selector to metadata only, across the fleet
3323
+ agents sessions --resolve d3470b57 --json
3324
+
3005
3325
  # Search another machine's sessions live over SSH (no sync needed)
3006
3326
  agents sessions "auth bug" --last 3 --host yosemite-s1
3007
3327
 
@@ -3031,6 +3351,7 @@ export function registerSessionsCommands(program) {
3031
3351
  registerSessionsTailCommand(sessionsCmd);
3032
3352
  registerSessionsSyncCommand(sessionsCmd);
3033
3353
  registerSessionsResumeCommand(sessionsCmd);
3354
+ registerSessionsFavoriteCommand(sessionsCmd);
3034
3355
  registerGoCommand(sessionsCmd);
3035
3356
  registerFocusCommand(sessionsCmd);
3036
3357
  registerDetachCommand(sessionsCmd);