@phnx-labs/agents-cli 1.20.89 → 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 (58) hide show
  1. package/CHANGELOG.md +240 -0
  2. package/README.md +6 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +7 -1
  5. package/dist/commands/harness.d.ts +27 -0
  6. package/dist/commands/harness.js +120 -13
  7. package/dist/commands/profiles.d.ts +3 -0
  8. package/dist/commands/profiles.js +1 -1
  9. package/dist/commands/routines.d.ts +19 -0
  10. package/dist/commands/routines.js +28 -6
  11. package/dist/commands/secrets.d.ts +10 -1
  12. package/dist/commands/secrets.js +18 -6
  13. package/dist/commands/sessions-browser.d.ts +4 -0
  14. package/dist/commands/sessions-browser.js +51 -9
  15. package/dist/commands/sessions-favorite.d.ts +20 -0
  16. package/dist/commands/sessions-favorite.js +120 -0
  17. package/dist/commands/sessions.d.ts +103 -20
  18. package/dist/commands/sessions.js +356 -62
  19. package/dist/commands/setup-secrets.d.ts +7 -0
  20. package/dist/commands/setup-secrets.js +12 -9
  21. package/dist/commands/versions.js +12 -4
  22. package/dist/commands/view.d.ts +14 -1
  23. package/dist/commands/view.js +103 -128
  24. package/dist/lib/agents.d.ts +4 -2
  25. package/dist/lib/agents.js +21 -6
  26. package/dist/lib/hosts/dispatch.js +19 -1
  27. package/dist/lib/hq/floor.js +12 -0
  28. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  29. package/dist/lib/picker.d.ts +27 -2
  30. package/dist/lib/picker.js +71 -7
  31. package/dist/lib/profiles.d.ts +48 -0
  32. package/dist/lib/profiles.js +67 -0
  33. package/dist/lib/rotate.d.ts +24 -2
  34. package/dist/lib/rotate.js +63 -6
  35. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  36. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  37. package/dist/lib/session/active.d.ts +109 -3
  38. package/dist/lib/session/active.js +269 -13
  39. package/dist/lib/session/db.d.ts +14 -0
  40. package/dist/lib/session/db.js +35 -0
  41. package/dist/lib/session/favorites.d.ts +39 -0
  42. package/dist/lib/session/favorites.js +101 -0
  43. package/dist/lib/session/host-link.d.ts +68 -0
  44. package/dist/lib/session/host-link.js +64 -0
  45. package/dist/lib/session/presence.d.ts +85 -0
  46. package/dist/lib/session/presence.js +150 -0
  47. package/dist/lib/session/remote-list.d.ts +10 -0
  48. package/dist/lib/session/remote-list.js +47 -9
  49. package/dist/lib/tmux/binary.d.ts +7 -0
  50. package/dist/lib/tmux/binary.js +11 -1
  51. package/dist/lib/types.d.ts +4 -3
  52. package/dist/lib/usage-backoff.d.ts +29 -0
  53. package/dist/lib/usage-backoff.js +165 -0
  54. package/dist/lib/usage.d.ts +112 -5
  55. package/dist/lib/usage.js +464 -46
  56. package/dist/lib/watchdog/runner.d.ts +13 -0
  57. package/dist/lib/watchdog/runner.js +16 -1
  58. 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';
@@ -27,7 +28,7 @@ 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,
@@ -745,6 +803,25 @@ export function serializeSessionsJson(sessions) {
745
803
  });
746
804
  return JSON.stringify(serializable, null, 2) + '\n';
747
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
+ }
748
825
  /**
749
826
  * `agents sessions --json --host <h>` — fan the RECENT (non-active) listing out
750
827
  * to the named host(s) and emit ONE clean merged `SessionMeta[]` JSON array,
@@ -777,9 +854,15 @@ function groupTally(sessions) {
777
854
  const running = sessions.filter(s => s.status === 'running').length;
778
855
  const idle = sessions.filter(s => s.status === 'idle').length;
779
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.
780
859
  const queued = sessions.filter(s => s.status === 'queued').length;
781
860
  const closed = sessions.filter(s => s.status === 'closed').length;
782
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;
783
866
  const unknown = sessions.filter(s => s.status === 'unknown').length;
784
867
  const parts = [];
785
868
  if (running)
@@ -794,6 +877,10 @@ function groupTally(sessions) {
794
877
  parts.push(`${closed} closed`);
795
878
  if (abandoned)
796
879
  parts.push(`${abandoned} abandoned`);
880
+ if (orphaned)
881
+ parts.push(`${orphaned} orphaned`);
882
+ if (crashed)
883
+ parts.push(`${crashed} crashed`);
797
884
  if (unknown)
798
885
  parts.push(`${unknown} unknown`);
799
886
  return parts.join(' · ');
@@ -981,10 +1068,19 @@ export async function gatherActiveSessions(opts = {}) {
981
1068
  */
982
1069
  async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
983
1070
  const self = machineId();
984
- 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;
985
1081
  // --waiting: only sessions blocked on the user. Exits non-zero when any are
986
1082
  // present so a supervising agent or hook can poll it as a gate.
987
- const sessions = waitingOnly ? merged.filter(s => s.status === 'input_required') : merged;
1083
+ const sessions = waitingOnly ? merged.filter(isAwaitingUser) : merged;
988
1084
  if (asJson) {
989
1085
  // Resolve who is watching each local tmux pane before serializing: `viewingIn`
990
1086
  // is how a consumer distinguishes a session someone is looking at from one
@@ -1111,6 +1207,8 @@ function canonicalSessionsCommand(query, options) {
1111
1207
  a.push('--local');
1112
1208
  if (options.waiting)
1113
1209
  a.push('--waiting');
1210
+ if (options.favorites)
1211
+ a.push('--favorites');
1114
1212
  const q = (query ?? '').trim();
1115
1213
  if (q)
1116
1214
  a.push(JSON.stringify(q));
@@ -1139,16 +1237,48 @@ async function renderSessionPreview(query, scope) {
1139
1237
  live = indexActiveBySessionId(await getActiveSessions()).get(session.id);
1140
1238
  }
1141
1239
  catch { /* plain preview on any probe failure */ }
1142
- if (live) {
1143
- const { glyph } = liveGlyphAndPreview(live);
1144
- const word = liveStatusWord(live) || live.status;
1145
- const needsYou = live.status === 'input_required' || live.activity === 'waiting_input';
1146
- const reason = live.awaitingReason ? ` (${live.awaitingReason.replace('_', ' ')})` : '';
1147
- const suffix = needsYou ? chalk.yellow(` ← needs you${reason}`) : '';
1148
- console.log(`${glyph} ${statusColor(live.status)(word)}${suffix}`);
1149
- }
1240
+ const headline = formatLiveStatusHeadline(live, isFavorite(session.id));
1241
+ if (headline)
1242
+ console.log(headline);
1150
1243
  console.log(buildPreview(session));
1151
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
+ }
1152
1282
  /** Main action handler for `agents sessions`. Routes to picker, table, or single-session render. */
1153
1283
  async function sessionsAction(query, options,
1154
1284
  /**
@@ -1182,6 +1312,39 @@ limitSource) {
1182
1312
  process.stdout.write(JSON.stringify(getSessionRoots(), null, 2) + '\n');
1183
1313
  return;
1184
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
+ }
1185
1348
  // --host WITHOUT --active. `--json` fans the recent listing out and emits ONE
1186
1349
  // clean merged SessionMeta[] array (same shape as the local --json path), for
1187
1350
  // scripts/extensions that JSON.parse a remote's history. Without --json it
@@ -1256,6 +1419,7 @@ limitSource) {
1256
1419
  host: options.host,
1257
1420
  since: options.since,
1258
1421
  all: options.all,
1422
+ favorites: options.favorites,
1259
1423
  }), { local: options.local === true, hosts: options.host });
1260
1424
  return;
1261
1425
  }
@@ -1265,6 +1429,7 @@ limitSource) {
1265
1429
  await renderActiveSessions(options.json === true, options.waiting === true, {
1266
1430
  local: forceLocal,
1267
1431
  hosts: options.host,
1432
+ favoritesOnly: options.favorites === true,
1268
1433
  });
1269
1434
  return;
1270
1435
  }
@@ -1286,6 +1451,7 @@ limitSource) {
1286
1451
  since: options.since,
1287
1452
  host: options.host,
1288
1453
  inTeam: options.inTeam,
1454
+ favorites: options.favorites,
1289
1455
  }), { local: options.local === true, hosts: options.host });
1290
1456
  return;
1291
1457
  }
@@ -1410,6 +1576,12 @@ limitSource) {
1410
1576
  // read. Match either, after that pass has populated `teamOrigin`.
1411
1577
  if (options.inTeam)
1412
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
+ }
1413
1585
  // Under --in-team the visible list is one team, so the whole-index team-origin
1414
1586
  // count would be a non-sequitur next to it.
1415
1587
  const hiddenCount = options.teams || options.inTeam
@@ -1602,7 +1774,7 @@ function metaSignals(s) {
1602
1774
  * (tracker/PR ref, pulled out of the badge blob so refs align) is only rendered
1603
1775
  * when `showTicket` — otherwise a listing with no refs would waste a column of
1604
1776
  * dashes and needlessly truncate the topic. Worktree stays a trailing badge. */
1605
- export function flatSessionRow(session, live, showTicket = false, cols = {}) {
1777
+ export function flatSessionRow(session, live, showTicket = false, cols = {}, favorite = false) {
1606
1778
  const agentColor = colorAgent(session.agent);
1607
1779
  const when = formatRelativeTime(session.lastActivity ?? session.timestamp);
1608
1780
  const project = session.project || '-';
@@ -1638,13 +1810,17 @@ export function flatSessionRow(session, live, showTicket = false, cols = {}) {
1638
1810
  const wtW = wt ? stringWidth(wt) + 1 : 0;
1639
1811
  const width = terminalWidth();
1640
1812
  const requestedModelW = cols.showModel ? (cols.modelWidth ?? PICKER_MODEL_MAX) : 0;
1641
- 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;
1642
1817
  const modelSlack = width - fixedW - 16;
1643
1818
  const modelW = requestedModelW <= modelSlack
1644
1819
  ? requestedModelW
1645
1820
  : modelSlack >= PICKER_MODEL_MIN ? modelSlack : 0;
1646
1821
  const topicW = Math.max(16, width - fixedW - modelW);
1647
- return (chalk.white(padToWidth(truncateToWidth(session.shortId, 9), 10)) +
1822
+ return (favCell +
1823
+ chalk.white(padToWidth(truncateToWidth(session.shortId, 9), 10)) +
1648
1824
  agentColor(padToWidth(truncateToWidth(session.agent, 8), 9)) +
1649
1825
  chalk.yellow(padToWidth(truncateToWidth(session.version || '-', 7), 8)) +
1650
1826
  (modelW ? chalk.yellow(padToWidth(truncateToWidth(modelLabel(session.model), modelW - 1), modelW)) : '') +
@@ -1818,8 +1994,10 @@ function printSessionTable(sessions, hiddenCount = 0, tree = false, liveIndex) {
1818
1994
  // column (and its compact labels) is computed the same way the picker does it.
1819
1995
  const showTicket = sessions.some((s) => ticketLabel(s) !== '');
1820
1996
  const cols = pickerColumnsFor(sessions);
1821
- for (const session of sessions)
1822
- 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
+ }
1823
2001
  const countLine = `${sessions.length} session${sessions.length === 1 ? '' : 's'}.`;
1824
2002
  console.log(chalk.gray(`\n${countLine}`));
1825
2003
  if (hiddenCount > 0) {
@@ -2049,6 +2227,11 @@ export function pickerColumnsFor(sessions) {
2049
2227
  showModel: sessions.some((s) => !!s.model),
2050
2228
  modelWidth: modelColumnWidth(sessions),
2051
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
+ })(),
2052
2235
  };
2053
2236
  }
2054
2237
  /** Width of the host-program column (`tmux→ghostty` is the long realistic case). */
@@ -2067,7 +2250,7 @@ export function liveHostLabel(a) {
2067
2250
  const viewer = a.viewingIn?.app;
2068
2251
  return viewer && viewer !== a.host ? `${a.host}→${viewer}` : a.host;
2069
2252
  }
2070
- export function formatPickerLabel(s, query, cols = {}, ssh, host = '') {
2253
+ export function formatPickerLabel(s, query, cols = {}, ssh, host = '', favorite = false, live) {
2071
2254
  const agentColor = colorAgent(s.agent);
2072
2255
  const when = formatRelativeTime(s.lastActivity ?? s.timestamp);
2073
2256
  const project = s.project || '-';
@@ -2109,17 +2292,32 @@ export function formatPickerLabel(s, query, cols = {}, ssh, host = '') {
2109
2292
  const ticketW = cols.showTicket ? TICKET_W + 1 : 0;
2110
2293
  const hostW = cols.showHost ? PICKER_HOST_W : 0;
2111
2294
  const wtW = wt ? stringWidth(wt) + 1 : 0;
2112
- const topicW = Math.max(16, terminalWidth() - gutter - (10 + 9 + 8 + 16) - machineColW - hostW - ticketW - wtW - sshW - team.width - stringWidth(when) - 1);
2113
- return (
2114
- // Truncated, not just padded: an indexed shortId is always 8 chars, but a
2115
- // live row with no session id is named by its pid or cloud task, which can
2116
- // run past the column and shunt every later column out of alignment.
2117
- 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)) +
2118
2315
  agentColor(padRight(truncate(s.agent, 8), 9)) +
2119
2316
  chalk.yellow(padRight(truncate(versionStr, 7), 8)) +
2120
2317
  machineCell +
2121
2318
  hostCell +
2122
2319
  chalk.cyan(padRight(truncate(project, 14), 16)) +
2320
+ statusCell +
2123
2321
  sshSeg +
2124
2322
  teamSeg +
2125
2323
  renderTopicCell(label, topic, query, topicW, topicW) +
@@ -2526,7 +2724,7 @@ function formatSearchMessage(options) {
2526
2724
  * search (a bare id must not surface every transcript that merely mentions it).
2527
2725
  * A genuine search phrase keeps the ranked metadata+content search.
2528
2726
  */
2529
- export function resolveSessionQuery(pool, query) {
2727
+ export function resolveSessionQuery(pool, query, options = {}) {
2530
2728
  // Normalize ONCE here. isCompleteSessionId trims but resolveSessionById does
2531
2729
  // not, so a padded id ("<uuid> ", e.g. pasted from a terminal) would classify
2532
2730
  // as complete and then miss the id lookup — reporting a session that IS on
@@ -2546,7 +2744,8 @@ export function resolveSessionQuery(pool, query) {
2546
2744
  // id" — NOT fall back to fuzzy content search. A short id like "d3470b57"
2547
2745
  // otherwise surfaces every transcript that merely MENTIONS the string (a
2548
2746
  // resume prompt echoes the parent id into the body of many later sessions).
2549
- return { matches: findSessionsById(normalized), byId: true, completeId };
2747
+ const matches = options.indexFallback === false ? [] : findSessionsById(normalized);
2748
+ return { matches, byId: true, completeId };
2550
2749
  }
2551
2750
  return { matches: filterSessionsByQuery(pool, normalized), byId: false, completeId };
2552
2751
  }
@@ -2863,88 +3062,176 @@ function modeFlag(mode) {
2863
3062
  return '--json';
2864
3063
  return undefined; // summary — the peer's default render
2865
3064
  }
2866
- /** Group a fleet sweep's rows to the DISTINCT machines that hold the id. Each
2867
- * peer answered `sessions <id> --json --local`, which (post-fix) id-resolves and
2868
- * so returns the matching row(s); a peer with a synced MIRROR of the same id can
2869
- * emit more than one row, so we keep the first per machine. Rows the peer somehow
2870
- * returned that do NOT match the id (defensive against version skew) are dropped
2871
- * so a stray content hit can never masquerade as an exact resolution. */
2872
- export function fleetHitsById(rows, id) {
2873
- const q = id.trim().toLowerCase();
2874
- const byMachine = new Map();
2875
- for (const s of rows) {
2876
- 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;
2877
3080
  if (!machine)
2878
3081
  continue; // an untagged row can't be routed back to a peer
2879
- if (s.id?.toLowerCase() !== q)
2880
- 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
+ }
2881
3088
  if (!byMachine.has(machine))
2882
- byMachine.set(machine, s);
3089
+ byMachine.set(machine, session);
2883
3090
  }
2884
- 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]));
2885
3170
  }
2886
3171
  /**
2887
- * 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
2888
3173
  * that holds it. The local disk already missed; this fans `sessions <id> --json
2889
3174
  * --all` out to every registered online peer (or the explicit `hosts` set),
2890
3175
  * groups the rows to distinct machines, then:
2891
3176
  *
2892
- * - exactly one machine → delegate rendering to that peer via `runOnPeer`
3177
+ * - exactly one logical session → delegate rendering to one peer via `runOnPeer`
2893
3178
  * (its transcript and agent binary live there — a local `--host` hop would
2894
3179
  * re-discover locally and dead-end), returning `'rendered'`.
2895
- * - more than one machine → print the conflict with machine labels so the user
2896
- * 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'`.
2897
3182
  * - none → `'not-found'`, letting the caller print the local
2898
3183
  * "no session on this machine" message.
2899
3184
  *
2900
- * No fuzzy/content fallback: the sweep forwards a UUID, each peer id-resolves it,
2901
- * 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.
2902
3187
  */
2903
- export async function resolveSessionAcrossFleet(id, mode, hosts, deps = { gatherRemoteList, runOnPeer }) {
3188
+ export async function resolveSessionAcrossFleet(query, mode, hosts, deps = { gatherRemoteList, runOnPeer }) {
2904
3189
  const spinner = isInteractiveTerminal() ? ora('Searching the fleet...').start() : null;
2905
- let hits;
3190
+ let candidates;
2906
3191
  try {
2907
3192
  // Force whole-index scope (--all): the peer runs in its SSH-login home dir,
2908
3193
  // whose cwd would otherwise silently narrow the lookup and hide the row.
2909
3194
  // --json so each peer answers a parseable array; --local so it answers for
2910
3195
  // itself and never re-fans-out (belt-and-suspenders with the parent's
2911
3196
  // AGENTS_SESSIONS_LOCAL, which remote-list also sets on the peer).
2912
- const forwarded = ['sessions', id, '--json', '--all', '--local'];
3197
+ const forwarded = ['sessions', query, '--json', '--all', '--local'];
2913
3198
  const { sessions } = await deps.gatherRemoteList(forwarded, hosts);
2914
- hits = fleetHitsById(sessions, id);
3199
+ candidates = fleetCandidatesByQuery(sessions, query);
2915
3200
  }
2916
3201
  catch {
2917
3202
  // A fan-out failure is not an exact resolution — treat as not-found so the
2918
3203
  // caller prints the honest local message rather than a half-answer.
2919
- hits = [];
3204
+ candidates = [];
2920
3205
  }
2921
3206
  finally {
2922
3207
  spinner?.stop();
2923
3208
  }
2924
- if (hits.length === 0)
3209
+ if (candidates.length === 0)
2925
3210
  return 'not-found';
2926
- if (hits.length > 1) {
2927
- console.error(chalk.red(`Session ${id} exists on multiple machines:`));
2928
- for (const h of hits) {
2929
- 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;
2930
3215
  const label = s.label ?? s.topic ?? '';
2931
- 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}`));
2932
3218
  }
2933
- 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.'));
2934
3220
  return 'conflict';
2935
3221
  }
2936
- const { machine } = hits[0];
3222
+ const candidate = candidates[0];
3223
+ const { machine } = candidate.hits[0];
2937
3224
  // Render the remote summary by re-running `sessions <id>` ON the peer. --local
2938
3225
  // keeps that render on the peer (it owns the transcript); the mode flag matches
2939
3226
  // the mode the user asked for. No TTY: a summary/markdown/json render is a
2940
3227
  // one-shot capture, not an interactive resume.
2941
- const peerArgs = ['sessions', id, '--local'];
3228
+ const peerArgs = ['sessions', candidate.id, '--local'];
2942
3229
  const flag = modeFlag(mode);
2943
3230
  if (flag)
2944
3231
  peerArgs.push(flag);
2945
3232
  const result = await deps.runOnPeer(peerArgs, machine);
2946
3233
  if (result === 'no-target') {
2947
- 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.`));
2948
3235
  console.error(chalk.gray('Register it with `agents devices` or run the command on that machine.'));
2949
3236
  return 'conflict'; // a definitive answer (found, un-renderable) — do NOT fall to the local not-found line
2950
3237
  }
@@ -2956,6 +3243,8 @@ export function registerSessionsCommands(program) {
2956
3243
  .command('sessions')
2957
3244
  .argument('[query]', 'Session ID, search query, or path (., ../, /path) to filter by project')
2958
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())
2959
3248
  .description('Find, browse, and read agent conversation transcripts across Claude, Codex, Gemini, and OpenCode.')
2960
3249
  .option('-a, --agent <agent>', 'Filter by agent type and version (e.g., claude, codex@0.116.0)')
2961
3250
  .option('--claude', 'Shorthand for --agent claude')
@@ -2987,6 +3276,7 @@ export function registerSessionsCommands(program) {
2987
3276
  .option('--roots', 'With --json: emit the on-disk directories scanned for session transcripts, per agent (for external watchers)')
2988
3277
  .option('--local', 'Only this machine — skip the cross-machine SSH fan-out (default listing and --active)')
2989
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>`')
2990
3280
  .option('--tree', 'Group the listing by directory; drops the id/version columns for readability')
2991
3281
  .option('--flat', 'Plain flat table (one row per session) instead of the grouped project overview')
2992
3282
  .option('--no-live', 'Do not enrich the listing with live status/preview for running sessions')
@@ -3029,6 +3319,9 @@ export function registerSessionsCommands(program) {
3029
3319
  # Export for analysis
3030
3320
  agents sessions --since 30d --limit 200 --json > sessions.json
3031
3321
 
3322
+ # Resolve one historical selector to metadata only, across the fleet
3323
+ agents sessions --resolve d3470b57 --json
3324
+
3032
3325
  # Search another machine's sessions live over SSH (no sync needed)
3033
3326
  agents sessions "auth bug" --last 3 --host yosemite-s1
3034
3327
 
@@ -3058,6 +3351,7 @@ export function registerSessionsCommands(program) {
3058
3351
  registerSessionsTailCommand(sessionsCmd);
3059
3352
  registerSessionsSyncCommand(sessionsCmd);
3060
3353
  registerSessionsResumeCommand(sessionsCmd);
3354
+ registerSessionsFavoriteCommand(sessionsCmd);
3061
3355
  registerGoCommand(sessionsCmd);
3062
3356
  registerFocusCommand(sessionsCmd);
3063
3357
  registerDetachCommand(sessionsCmd);