@phnx-labs/agents-cli 1.22.21 → 1.22.23

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 (105) hide show
  1. package/CHANGELOG.md +256 -0
  2. package/README.md +8 -2
  3. package/dist/commands/doctor.js +15 -7
  4. package/dist/commands/exec.js +20 -6
  5. package/dist/commands/focus.d.ts +76 -4
  6. package/dist/commands/focus.js +219 -40
  7. package/dist/commands/fork.d.ts +20 -2
  8. package/dist/commands/fork.js +91 -64
  9. package/dist/commands/go.d.ts +25 -0
  10. package/dist/commands/go.js +63 -2
  11. package/dist/commands/harness-wizard.d.ts +206 -0
  12. package/dist/commands/harness-wizard.js +403 -0
  13. package/dist/commands/harness.d.ts +12 -0
  14. package/dist/commands/harness.js +97 -101
  15. package/dist/commands/insights.d.ts +32 -0
  16. package/dist/commands/insights.js +478 -0
  17. package/dist/commands/resume.js +10 -3
  18. package/dist/commands/secrets.js +25 -30
  19. package/dist/commands/sessions-picker.d.ts +2 -0
  20. package/dist/commands/sessions-picker.js +1 -0
  21. package/dist/commands/sessions-resume.d.ts +21 -3
  22. package/dist/commands/sessions-resume.js +50 -11
  23. package/dist/commands/sessions.d.ts +81 -5
  24. package/dist/commands/sessions.js +331 -66
  25. package/dist/commands/watchdog.js +13 -2
  26. package/dist/index.js +3 -1
  27. package/dist/lib/agents.d.ts +1 -1
  28. package/dist/lib/agents.js +132 -0
  29. package/dist/lib/codex-policy.d.ts +17 -0
  30. package/dist/lib/codex-policy.js +48 -0
  31. package/dist/lib/crabbox/lease.d.ts +25 -0
  32. package/dist/lib/crabbox/lease.js +62 -0
  33. package/dist/lib/daemon.js +70 -0
  34. package/dist/lib/exec.d.ts +4 -0
  35. package/dist/lib/exec.js +88 -54
  36. package/dist/lib/feed-broadcast.d.ts +1 -20
  37. package/dist/lib/feed-broadcast.js +31 -1
  38. package/dist/lib/hooks.js +12 -2
  39. package/dist/lib/hosts/passthrough.js +1 -0
  40. package/dist/lib/mcp.js +44 -0
  41. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +1 -5
  42. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  43. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -15
  44. package/dist/lib/models.d.ts +0 -5
  45. package/dist/lib/models.js +48 -0
  46. package/dist/lib/picker.d.ts +45 -0
  47. package/dist/lib/picker.js +75 -6
  48. package/dist/lib/plugin-marketplace.js +9 -0
  49. package/dist/lib/pricing/prices.json +119 -92
  50. package/dist/lib/pricing/table.js +13 -0
  51. package/dist/lib/remote-agents-json.d.ts +29 -1
  52. package/dist/lib/remote-agents-json.js +47 -10
  53. package/dist/lib/resources/mcp.js +2 -0
  54. package/dist/lib/resources/permissions.js +3 -0
  55. package/dist/lib/resources/types.d.ts +2 -1
  56. package/dist/lib/runner.js +28 -19
  57. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  58. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -2
  59. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  60. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -13
  61. package/dist/lib/secrets/index.d.ts +15 -1
  62. package/dist/lib/secrets/index.js +118 -49
  63. package/dist/lib/secrets/reaper.d.ts +87 -0
  64. package/dist/lib/secrets/reaper.js +184 -0
  65. package/dist/lib/secrets/remote.d.ts +29 -0
  66. package/dist/lib/secrets/remote.js +37 -1
  67. package/dist/lib/session/active.d.ts +36 -1
  68. package/dist/lib/session/active.js +60 -19
  69. package/dist/lib/session/actor-sidecar.d.ts +14 -0
  70. package/dist/lib/session/actor-sidecar.js +67 -4
  71. package/dist/lib/session/db.d.ts +35 -1
  72. package/dist/lib/session/db.js +132 -1
  73. package/dist/lib/session/digest.d.ts +3 -0
  74. package/dist/lib/session/digest.js +3 -3
  75. package/dist/lib/session/discover.js +168 -0
  76. package/dist/lib/session/insights.d.ts +126 -0
  77. package/dist/lib/session/insights.js +330 -0
  78. package/dist/lib/session/parse.d.ts +29 -2
  79. package/dist/lib/session/parse.js +111 -5
  80. package/dist/lib/session/remote-list.d.ts +10 -1
  81. package/dist/lib/session/remote-list.js +2 -8
  82. package/dist/lib/session/remote.d.ts +57 -3
  83. package/dist/lib/session/remote.js +90 -26
  84. package/dist/lib/session/resume-command.d.ts +6 -0
  85. package/dist/lib/session/resume-command.js +8 -0
  86. package/dist/lib/session/session-cache.d.ts +173 -0
  87. package/dist/lib/session/session-cache.js +399 -0
  88. package/dist/lib/session/types.d.ts +2 -2
  89. package/dist/lib/session/types.js +1 -1
  90. package/dist/lib/session/width.d.ts +1 -1
  91. package/dist/lib/session/width.js +12 -2
  92. package/dist/lib/shims.d.ts +2 -2
  93. package/dist/lib/shims.js +40 -5
  94. package/dist/lib/staleness/writers/hooks.js +1 -1
  95. package/dist/lib/startup/command-registry.d.ts +1 -0
  96. package/dist/lib/startup/command-registry.js +2 -0
  97. package/dist/lib/types.d.ts +1 -1
  98. package/dist/lib/usage.d.ts +13 -0
  99. package/dist/lib/usage.js +254 -3
  100. package/dist/lib/versions.js +13 -2
  101. package/package.json +1 -1
  102. package/dist/bin/agents +0 -0
  103. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  104. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  105. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
@@ -27,27 +27,30 @@ import { mapPanesToTargets, listClients } from '../lib/tmux/session.js';
27
27
  import { resolveViewingIn, viewingInLabel } from '../lib/session/viewing-in.js';
28
28
  import { machineId, normalizeHost } from '../lib/session/sync/config.js';
29
29
  import { gatherRemoteActive, NO_FANOUT_ENV } from '../lib/session/remote-active.js';
30
+ import { loadFleetActiveSessions, loadLocalActiveSessions, } from '../lib/session/session-cache.js';
30
31
  import { gatherRemoteList, gatherRemoteToolProgramCounts, gatherRemoteToolSearch, runOnPeer } from '../lib/session/remote-list.js';
31
32
  import { stringWidth, truncateToWidth, padToWidth, terminalWidth } from '../lib/session/width.js';
32
33
  import { inferSessionState } from '../lib/session/state.js';
33
34
  import { discoverSessions, queryIndexedSessions, countSessionsInScope, resolveSessionById, isCompleteSessionId, looksLikeSessionId, searchContentIndex, getSessionRoots, scopeToManaged } from '../lib/session/discover.js';
34
- import { findSessionsById, querySessions } from '../lib/session/db.js';
35
+ import { findSessionsById, querySessions, getSessionById } from '../lib/session/db.js';
35
36
  import { filterTeamSessions, safeTeamText } from '../lib/session/team-filter.js';
36
37
  import { parseSession } from '../lib/session/parse.js';
37
38
  import { runRemoteSessions, buildForwardedArgs, ensureWholeIndex } from '../lib/session/remote.js';
38
- import { formatRelativeTime, sessionAgeParts } from '../lib/session/relative-time.js';
39
+ import { formatRelativeTime, formatCompactAge, sessionAgeParts } from '../lib/session/relative-time.js';
39
40
  import { renderConversationMarkdown, renderSummary, renderSummaryHeader, computeSummaryStats, renderJson, filterEvents, parseRoleList, linkPath, linkUrl, shortenModel } from '../lib/session/render.js';
40
41
  import { linearIssueUrl } from '../lib/session/linear.js';
41
42
  import { renderMarkdown } from '../lib/markdown.js';
42
43
  import { AGENTS, colorAgent, resolveAgentName } from '../lib/agents.js';
43
44
  import { getShimsDir } from '../lib/state.js';
44
45
  import { fuzzyMatch, FUZZY_PRESETS } from '../lib/fuzzy.js';
46
+ import { resolveSessionAlias } from '../lib/session/actor-sidecar.js';
45
47
  import { resolveVersionAliasLoose } from '../lib/versions.js';
46
48
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
47
49
  import { sessionPicker, buildPreview, formatTodoCompact, githubRepoUrlFromCwd, } from './sessions-picker.js';
48
50
  import { setHelpSections } from '../lib/help.js';
49
51
  import { registerSessionsTailCommand } from './sessions-tail.js';
50
52
  import { registerSessionsResumeCommand } from './sessions-resume.js';
53
+ import { registerSessionsForkCommand } from './fork.js';
51
54
  import { registerSessionsFavoriteCommand } from './sessions-favorite.js';
52
55
  import { isFavorite, listFavorites } from '../lib/session/favorites.js';
53
56
  import { registerGoCommand } from './go.js';
@@ -378,6 +381,58 @@ export function indexActiveBySessionId(active) {
378
381
  }
379
382
  return byId;
380
383
  }
384
+ /**
385
+ * Backfill display-only fields onto live rows from the indexed SessionMeta, by
386
+ * full session id (RUSH-2205). A running process reports no agent version, and a
387
+ * live orphan row usually carries no ticket/PR/label/start-time; the historical
388
+ * index does. Only a field the live row LACKS is filled — the live signal always
389
+ * wins when present. Pure (no I/O) so the join is unit-tested against fixtures;
390
+ * the DB read that builds `metaById` is the caller's concern.
391
+ */
392
+ export function backfillActiveRowsFromMeta(sessions, metaById) {
393
+ for (const s of sessions) {
394
+ if (!s.sessionId)
395
+ continue;
396
+ const m = metaById.get(s.sessionId);
397
+ if (!m)
398
+ continue;
399
+ if (!s.version && m.version)
400
+ s.version = m.version;
401
+ if (!s.label && m.label)
402
+ s.label = m.label;
403
+ if (!s.ticket && m.ticketId)
404
+ s.ticket = { id: m.ticketId, url: linearIssueUrl(m.ticketId) };
405
+ if (!s.pr && m.prUrl)
406
+ s.pr = { url: m.prUrl, number: m.prNumber };
407
+ if (!s.startedAtMs && m.timestamp) {
408
+ const ts = new Date(m.timestamp).getTime();
409
+ if (!Number.isNaN(ts))
410
+ s.startedAtMs = ts;
411
+ }
412
+ }
413
+ }
414
+ /**
415
+ * Build the id→meta index the live-row backfill needs, reading the historical
416
+ * session DB by full id. Best-effort: a missing or locked DB yields an empty map
417
+ * so the live view still renders (mirrors {@link maybeLiveIndex}). Deduplicates
418
+ * ids so N rows in one session cost one query.
419
+ */
420
+ function loadBackfillMetaFor(sessions) {
421
+ const byId = new Map();
422
+ try {
423
+ for (const s of sessions) {
424
+ if (!s.sessionId || byId.has(s.sessionId))
425
+ continue;
426
+ const m = getSessionById(s.sessionId);
427
+ if (m)
428
+ byId.set(s.sessionId, m);
429
+ }
430
+ }
431
+ catch {
432
+ /* enrichment is best-effort — an unavailable DB leaves rows un-backfilled */
433
+ }
434
+ return byId;
435
+ }
381
436
  /**
382
437
  * The live decoration for a listing row: a status glyph and the latest-turn
383
438
  * preview, when the session is still running. `●` running / `◐` waiting on the
@@ -582,30 +637,109 @@ function locatorBadge(s) {
582
637
  return parts.join(' ');
583
638
  }
584
639
  /**
585
- * Render a single agent-session row inside an already-printed group header.
586
- * Indent is the leading whitespace (2 spaces for flat groups, 4 inside a
587
- * window sub-group). Leads with the 8-char session id (the address to read or
588
- * resume it); status, badges, and the live preview fill the rest, sized to the
589
- * terminal width so the row never wraps.
640
+ * The `created X · idle Y` time cell for a live row (RUSH-2205). `created` is the
641
+ * age of the session start ({@link ActiveSession.startedAtMs}); `idle` is the age
642
+ * of the last transcript write ({@link ActiveSession.lastActivityMs}) i.e. how
643
+ * long it has been quiet. Compact ("6d", "3h", "now") so the row stays width-safe;
644
+ * either half is omitted when its epoch is unknown.
590
645
  */
591
- function printActiveRow(s, indent) {
592
- // shortId (8-char) · agent · host · status · owner · badges · identity+todos+snippet
593
- const idCol = chalk.dim(padToWidth((s.sessionId?.slice(0, 8)) ?? '-', 9));
594
- const kindCol = colorAgent(s.kind)(padToWidth(truncateToWidth(s.kind, 8), 9));
595
- const hostCol = chalk.gray(padToWidth(truncateToWidth(s.host ?? '-', 8), 9));
596
- const statusCol = statusColor(s.status)(padToWidth(truncateToWidth(activityLabel(s), 8), 9));
597
- const ownerCol = chalk.cyan(padToWidth(truncateToWidth(ownerLabel(s), 8), 9));
646
+ function activeTimeCell(s) {
647
+ const parts = [];
648
+ if (s.startedAtMs)
649
+ parts.push(`created ${formatCompactAge(new Date(s.startedAtMs).toISOString())}`);
650
+ if (s.lastActivityMs)
651
+ parts.push(`idle ${formatCompactAge(new Date(s.lastActivityMs).toISOString())}`);
652
+ return parts.join(' · ');
653
+ }
654
+ /** Column widths for line 1 of an active row (id · agent · version · status · owner). */
655
+ const ROW_ID_W = 9;
656
+ const ROW_AGENT_W = 8;
657
+ const ROW_VERSION_W = 8;
658
+ const ROW_STATUS_W = 9;
659
+ const ROW_OWNER_W = 9;
660
+ /**
661
+ * Fit pre-styled content (which may carry SGR colour AND OSC-8 hyperlinks — the
662
+ * clickable ticket/PR/project badges) into `room` display cells. Returns it raw
663
+ * when it already fits, preserving colour and clickable links; otherwise falls
664
+ * back to a width-safe truncation. `truncateToWidth` strips OSC-8 before cutting
665
+ * (see width.ts), so a too-narrow cell drops the hyperlink cleanly rather than
666
+ * slicing a hyperlink escape in half and corrupting the terminal (RUSH-2205
667
+ * review). Never run pre-linked content through `truncateToWidth` directly — that
668
+ * strips its links even when it fits; go through this helper.
669
+ */
670
+ function fitCell(content, room) {
671
+ if (room <= 0)
672
+ return '';
673
+ return stringWidth(content) <= room ? content : truncateToWidth(content, room);
674
+ }
675
+ /**
676
+ * Build the (one or two) rendered lines for a single active-session row.
677
+ * Indent is the leading whitespace (2 spaces for flat groups, 4 inside a window
678
+ * sub-group). Sized to `termW` so no line ever wraps under tmux/SSH (RUSH-2205):
679
+ *
680
+ * line 1: id · agent version · status · owner · created X · idle Y · ticket/PR
681
+ * line 2: └ label/topic (+ checklist) · jump locator (ssh/tmux/detached)
682
+ *
683
+ * The label/topic gets its own line so it is no longer buried in a truncated grey
684
+ * snippet, and the actionable ticket/PR badges ride line 1. Version and the
685
+ * ticket/PR/label are backfilled onto the {@link ActiveSession} from the indexed
686
+ * SessionMeta before this renders (a live process reports none of them). Pure +
687
+ * exported so the row layout is unit-tested for content and width without a
688
+ * captured stdout.
689
+ */
690
+ export function renderActiveRowLines(s, indent, termW) {
691
+ const idCol = chalk.dim(padToWidth((s.sessionId?.slice(0, 8)) ?? '-', ROW_ID_W));
692
+ const kindCol = colorAgent(s.kind)(padToWidth(truncateToWidth(s.kind, ROW_AGENT_W), ROW_AGENT_W + 1));
693
+ const versionCol = chalk.gray(padToWidth(truncateToWidth(s.version ?? '', ROW_VERSION_W), ROW_VERSION_W + 1));
694
+ const statusCol = statusColor(s.status)(padToWidth(truncateToWidth(activityLabel(s), ROW_STATUS_W - 1), ROW_STATUS_W));
695
+ const ownerCol = chalk.cyan(padToWidth(truncateToWidth(ownerLabel(s), ROW_OWNER_W - 1), ROW_OWNER_W));
696
+ const fixedCols = idCol + kindCol + versionCol + statusCol + ownerCol;
697
+ const fixedW = stringWidth(indent) + ROW_ID_W + (ROW_AGENT_W + 1) + (ROW_VERSION_W + 1) + ROW_STATUS_W + ROW_OWNER_W;
698
+ const remaining = Math.max(0, termW - fixedW - 1);
699
+ // Line 1 right side: created/idle time + actionable badges (fork count,
700
+ // plan/ask/perm, ticket, PR, worktree). Badges are the jump-to-work signal, so
701
+ // they win the width fight — fitCell keeps them raw+clickable when they fit and
702
+ // drops the link cleanly when narrow; the time cell takes whatever is left.
598
703
  const fork = s.pidCount && s.pidCount > 1 ? chalk.dim(`×${s.pidCount} `) : '';
599
- const badges = (fork ? fork : '') + [signalBadges(s), locatorBadge(s)].filter(Boolean).join(' ');
600
- // Identity (label/project/ticket clickable) + checklist + live snippet.
601
- // Cross-machine rows use the same path remote ActiveSession fields arrive
602
- // via the SSH fan-out already populated (including todos when the peer has them).
603
- const desc = formatActiveRowDescription(s) || '-';
604
- // Fill the remaining width with the preview so nothing wraps under tmux/SSH.
605
- const fixed = stringWidth(indent) + 9 + 9 + 9 + 9 + 9 + (badges ? stringWidth(badges) + 1 : 0);
606
- const room = Math.max(12, terminalWidth() - fixed - 1);
607
- const descCol = chalk.white(truncateToWidth(desc, room));
608
- console.log(indent + idCol + kindCol + hostCol + statusCol + ownerCol + (badges ? badges + ' ' : '') + descCol);
704
+ const badgesCell = fitCell(fork + signalBadges(s), remaining);
705
+ const badgesW = stringWidth(badgesCell);
706
+ const timeRoom = Math.max(0, remaining - (badgesW ? badgesW + 2 : 0));
707
+ const timeCell = chalk.gray(truncateToWidth(activeTimeCell(s), timeRoom));
708
+ let right = timeCell;
709
+ if (badgesCell)
710
+ right += (stringWidth(timeCell) ? ' ' : '') + badgesCell;
711
+ // right's visible width is <= remaining by construction, so the assembled line
712
+ // is <= termW-1 whenever the fixed columns fit; the clamp only bites a terminal
713
+ // narrower than the fixed columns (where right is empty no link to corrupt).
714
+ let line1 = indent + fixedCols + right;
715
+ if (stringWidth(line1) > termW)
716
+ line1 = truncateToWidth(line1, termW);
717
+ const lines = [line1];
718
+ // Line 2: label/topic + checklist (the identity, no longer buried) then the
719
+ // jump locator. Skipped entirely when there is nothing to say. desc may carry a
720
+ // clickable project link, so it also goes through fitCell.
721
+ const desc = formatActiveRowDescription(s);
722
+ const loc = locatorBadge(s);
723
+ if (!desc && !loc)
724
+ return lines;
725
+ const contIndent = indent + ' '.repeat(ROW_ID_W);
726
+ const room2 = Math.max(0, termW - stringWidth(contIndent) - 2);
727
+ const locCell = fitCell(loc, room2);
728
+ const locW = stringWidth(locCell);
729
+ const descRoom = Math.max(0, room2 - (locW ? locW + 2 : 0));
730
+ const descCell = chalk.white(fitCell(desc || '-', descRoom));
731
+ let line2 = contIndent + chalk.dim('└ ') + descCell;
732
+ if (locCell)
733
+ line2 += ' ' + locCell;
734
+ if (stringWidth(line2) > termW)
735
+ line2 = truncateToWidth(line2, termW);
736
+ lines.push(line2);
737
+ return lines;
738
+ }
739
+ /** Render a single agent-session row inside an already-printed group header. */
740
+ function printActiveRow(s, indent) {
741
+ for (const line of renderActiveRowLines(s, indent, terminalWidth()))
742
+ console.log(line);
609
743
  }
610
744
  /**
611
745
  * Compact owner display for the `--active` owner column: the local-part of a
@@ -1048,8 +1182,48 @@ export function remoteHostsToDial(hosts, self) {
1048
1182
  * call it, so the browser can never disagree with `--active --json` about which
1049
1183
  * sessions are live (it used to call the local-only `getActiveSessions()` directly
1050
1184
  * and silently hid every remote session).
1185
+ *
1186
+ * RUSH-2062: default path is cache-first against the daemon-warmed shared
1187
+ * snapshot (`session-cache.ts`). Menubar / Factory / watchdog / CLI share one
1188
+ * warm result instead of each re-running the full SSH fan-out. `forceRefresh`
1189
+ * (or `AGENTS_SESSIONS_FORCE_REFRESH=1`) re-gathers live; scoped `--host` lists
1190
+ * always gather live so a filter never returns a wrong unscoped snapshot.
1051
1191
  */
1052
1192
  export async function gatherActiveSessions(opts = {}) {
1193
+ const forceRefresh = opts.forceRefresh === true
1194
+ || process.env.AGENTS_SESSIONS_FORCE_REFRESH === '1';
1195
+ // Scoped host lists are not represented in the unscoped fleet/local snapshot
1196
+ // — always gather live so a filter cannot return the wrong set.
1197
+ const scoped = (opts.hosts?.length ?? 0) > 0;
1198
+ if (opts.local && !scoped) {
1199
+ const loaded = await loadLocalActiveSessions({
1200
+ forceRefresh,
1201
+ gather: async () => {
1202
+ const rows = await getActiveSessions({ localOnly: true });
1203
+ const self = machineId();
1204
+ for (const s of rows)
1205
+ if (!s.machine)
1206
+ s.machine = self;
1207
+ return rows;
1208
+ },
1209
+ });
1210
+ return { sessions: loaded.sessions, remoteDeviceCount: 0 };
1211
+ }
1212
+ if (!opts.local && !scoped) {
1213
+ const loaded = await loadFleetActiveSessions({
1214
+ forceRefresh,
1215
+ gather: () => gatherActiveSessionsLive({ local: false }),
1216
+ });
1217
+ return { sessions: loaded.sessions, remoteDeviceCount: loaded.remoteDeviceCount };
1218
+ }
1219
+ return gatherActiveSessionsLive(opts);
1220
+ }
1221
+ /**
1222
+ * Live (uncached) gather — the work the daemon / force-refresh path pays once
1223
+ * so other surfaces can share the snapshot. Kept separate so the cache layer
1224
+ * never recursively re-enters itself.
1225
+ */
1226
+ async function gatherActiveSessionsLive(opts = {}) {
1053
1227
  const self = machineId();
1054
1228
  // An explicit --host/--device list scopes the view: seed local sessions only
1055
1229
  // when no hosts are named, or when this machine is one of the named targets.
@@ -1057,7 +1231,9 @@ export async function gatherActiveSessions(opts = {}) {
1057
1231
  // remote-host teammate over ssh even for this machine's OWN local gather —
1058
1232
  // "this machine only" must mean zero ssh, not just "skip the cross-machine
1059
1233
  // device fan-out below".
1060
- const local = shouldIncludeLocal(opts.hosts, self) ? await getActiveSessions({ localOnly: opts.local }) : [];
1234
+ const local = shouldIncludeLocal(opts.hosts, self)
1235
+ ? await getActiveSessions({ localOnly: opts.local })
1236
+ : [];
1061
1237
  for (const s of local)
1062
1238
  if (!s.machine)
1063
1239
  s.machine = self;
@@ -1099,6 +1275,12 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
1099
1275
  ? merged.filter((session) => opts.statuses.some((status) => matchesLiveStatus(session, status)))
1100
1276
  : merged;
1101
1277
  const sessions = statusFiltered;
1278
+ // Backfill agent version + ticket/PR/label/created onto the live rows from the
1279
+ // historical index (RUSH-2205) — a running process reports none of these, and
1280
+ // an orphan row usually lacks them. Done before both the JSON and human paths
1281
+ // so every consumer (incl. the SSH fan-out's remote --json) sees enriched rows;
1282
+ // transcripts sync across the fleet, so a remote row resolves from the local DB.
1283
+ backfillActiveRowsFromMeta(sessions, loadBackfillMetaFor(sessions));
1102
1284
  if (asJson) {
1103
1285
  // Resolve who is watching each local tmux pane before serializing: `viewingIn`
1104
1286
  // is how a consumer distinguishes a session someone is looking at from one
@@ -1542,8 +1724,13 @@ limitSource) {
1542
1724
  // shorthands fold into --agent, and --device is an alias for --host (both
1543
1725
  // resolve against the same device registry).
1544
1726
  applyAgentShorthands(options);
1545
- if (options.device && options.device.length > 0) {
1546
- options.host = [...(options.host ?? []), ...options.device];
1727
+ // --device / --devices both alias --host. A bare `all` / `fleet` sentinel means
1728
+ // "search every peer" which is already the default — so it resolves to no
1729
+ // explicit host set rather than erroring on a device literally named "all".
1730
+ const deviceTargets = [...(options.device ?? []), ...(options.devices ?? [])]
1731
+ .filter(t => t.toLowerCase() !== 'all' && t.toLowerCase() !== 'fleet');
1732
+ if (deviceTargets.length > 0) {
1733
+ options.host = [...(options.host ?? []), ...deviceTargets];
1547
1734
  }
1548
1735
  // --print-cmd: echo the canonical `ag sessions …` for the given flags and exit.
1549
1736
  // The non-interactive twin of the browser's `y` hotkey — lets an agent compose
@@ -2726,8 +2913,13 @@ export function formatPickerTip(sessions) {
2726
2913
  return chalk.gray(PICKER_TIPS[sessions.length % PICKER_TIPS.length]);
2727
2914
  }
2728
2915
  export async function pickSessionInteractive(sessions, message = 'Search sessions:', initialSearch, hiddenCount = 0, enterHint) {
2916
+ // The hidden-session footer is console.log'd above the Inquirer prompt, so it
2917
+ // scrolls the viewport the picker can't measure; tell the picker to reserve for
2918
+ // it (see pickerPageSize) so the preview and the footer stay on screen together.
2919
+ let linesAbovePrompt = 0;
2729
2920
  if (hiddenCount > 0) {
2730
2921
  console.log(chalk.gray(formatTeamHiddenFooter(hiddenCount)));
2922
+ linesAbovePrompt += 1;
2731
2923
  }
2732
2924
  const cols = pickerColumnsFor(sessions);
2733
2925
  try {
@@ -2746,6 +2938,7 @@ export async function pickSessionInteractive(sessions, message = 'Search session
2746
2938
  pageSize: PICKER_RECENT_COUNT,
2747
2939
  initialSearch,
2748
2940
  enterHint,
2941
+ linesAbovePrompt,
2749
2942
  });
2750
2943
  }
2751
2944
  catch (err) {
@@ -2933,6 +3126,8 @@ function resumeArgv(agent, id, launcher) {
2933
3126
  case 'claude': return [launcher, '--resume', id];
2934
3127
  case 'codex': return [launcher, 'resume', id];
2935
3128
  case 'opencode': return [launcher, '--session', id];
3129
+ // Muse interactive resume is a subcommand: `muse resume <uuid>`.
3130
+ case 'muse': return [launcher, 'resume', id];
2936
3131
  default: return null;
2937
3132
  }
2938
3133
  }
@@ -2961,7 +3156,8 @@ export function buildResumeCommand(session) {
2961
3156
  case 'opencode':
2962
3157
  return resumeArgv('opencode', session.id, 'opencode');
2963
3158
  case 'claude':
2964
- case 'codex': {
3159
+ case 'codex':
3160
+ case 'muse': {
2965
3161
  const cli = AGENTS[session.agent]?.cliCommand ?? session.agent;
2966
3162
  if (session.version) {
2967
3163
  const alias = versionedAliasIfPresent(session.agent, session.version);
@@ -3145,13 +3341,31 @@ function ambiguityHint(byId, completeId) {
3145
3341
  : 'That matched on text, not an id. Pass a session id, or narrow the search.';
3146
3342
  }
3147
3343
  /** Explain a complete-id miss, which no local rephrasing can fix. Echoes the
3148
- * normalized id so a pasted, padded argument doesn't produce an unrunnable hint. */
3344
+ * normalized id so a pasted, padded argument doesn't produce an unrunnable hint.
3345
+ * Used only where NO fleet sweep ran (a `--local` lookup, an `--artifacts`
3346
+ * listing, or a peer answering a parent's sweep) — the fleet-swept miss uses
3347
+ * {@link fleetNotFoundMessage}. Never tells the user to pass `--device`: fleet
3348
+ * search is already the default, so a device flag can't find what the id missed. */
3149
3349
  function notFoundByIdMessage(query) {
3350
+ return [chalk.red(`No session with id ${query.trim()} on this machine.`)];
3351
+ }
3352
+ /** Honest result of a fleet sweep that found nothing: it says what the sweep
3353
+ * actually did — how many peers answered, which were unreachable — instead of
3354
+ * dead-ending on "search with --device <host>" after the search already ran. */
3355
+ export function fleetNotFoundMessage(query, deviceCount, unreachable) {
3150
3356
  const id = query.trim();
3151
- return [
3152
- chalk.red(`No session with id ${id} on this machine.`),
3153
- chalk.gray(`Search the fleet with: agents sessions ${id} --device <host>`),
3154
- ];
3357
+ if (deviceCount === 0) {
3358
+ return [
3359
+ chalk.red(`No session with id ${id} on this machine.`),
3360
+ chalk.gray('No other reachable devices to search.'),
3361
+ ];
3362
+ }
3363
+ const searched = `${deviceCount} device${deviceCount === 1 ? '' : 's'}`;
3364
+ const lines = [chalk.red(`No session with id ${id} on this machine or ${searched} searched.`)];
3365
+ if (unreachable.length > 0) {
3366
+ lines.push(chalk.gray(`Unreachable (not searched): ${unreachable.join(', ')}`));
3367
+ }
3368
+ return lines;
3155
3369
  }
3156
3370
  /** Filter and rank sessions by a multi-term search query across metadata and content. */
3157
3371
  export function filterSessionsByQuery(sessions, query) {
@@ -3374,11 +3588,15 @@ async function renderOneSession(query, mode, scope) {
3374
3588
  // local not-found message. No FTS fallback either way.
3375
3589
  if (shouldFanOutForId(query, scope.local)) {
3376
3590
  const outcome = await resolveSessionAcrossFleet(query, mode, scope.hosts);
3377
- if (outcome === 'rendered')
3591
+ if (outcome.kind === 'rendered')
3378
3592
  return;
3379
- if (outcome === 'conflict')
3593
+ if (outcome.kind === 'conflict')
3380
3594
  process.exit(1);
3381
- // 'not-found' falls through to the local message below.
3595
+ // The fleet sweep already ran and found nothing — report what actually
3596
+ // happened, never "search with --device <host>" (fleet search is the
3597
+ // default, so a device flag would only re-run the same miss).
3598
+ fleetNotFoundMessage(query, outcome.deviceCount, outcome.unreachable).forEach(l => console.error(l));
3599
+ process.exit(1);
3382
3600
  }
3383
3601
  notFoundByIdMessage(query).forEach(l => console.error(l));
3384
3602
  process.exit(1);
@@ -3449,16 +3667,41 @@ function modeFlag(mode) {
3449
3667
  return undefined; // summary — the peer's default render
3450
3668
  }
3451
3669
  const FULL_SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3670
+ /**
3671
+ * A match unique enough to resolve on the FIRST peer that returns it and cancel
3672
+ * the rest of the fleet sweep: a full session UUID, which is globally unique.
3673
+ *
3674
+ * Only a full UUID qualifies. A session **label** is deliberately NOT definitive
3675
+ * here even on an exact match: labels are free-form and can collide, so a
3676
+ * distinct session may carry the same label on a peer that has not answered yet
3677
+ * — early-exiting on the first would silently, and nondeterministically, resume
3678
+ * the wrong session (whichever peer replied first). Labels therefore stay
3679
+ * all-settle so a cross-machine label conflict surfaces as an ambiguity, and a
3680
+ * short-id PREFIX stays all-settle for the same reason (RUSH-2203).
3681
+ */
3682
+ export function isDefinitiveMatch(session, selector) {
3683
+ return FULL_SESSION_ID_RE.test(selector) && session.id.toLowerCase() === selector.trim().toLowerCase();
3684
+ }
3685
+ /**
3686
+ * Whether a selector may enable early-exit on the cancellable fan-out — only a
3687
+ * full UUID, which is globally unique so the first hit is the only hit. Labels,
3688
+ * keywords, and short-id prefixes stay all-settle: their uniqueness (or
3689
+ * conflict) is only knowable once every peer has answered. See
3690
+ * {@link isDefinitiveMatch}.
3691
+ */
3692
+ export function selectorAllowsEarlyExit(selector) {
3693
+ return FULL_SESSION_ID_RE.test(selector);
3694
+ }
3452
3695
  /** Resolve a fleet sweep through the same canonical full-id / prefix resolver as
3453
3696
  * local lookups, then group copies by logical session id. Synced mirrors of one
3454
3697
  * session therefore stay one candidate even when several machines report them;
3455
3698
  * distinct ids sharing a prefix remain distinct ambiguity candidates. */
3456
- export function fleetCandidatesByQuery(rows, query) {
3699
+ export function fleetCandidatesByQuery(rows, query, trustResolvedRows = false) {
3457
3700
  // An id selector must still be prefix-filtered defensively against older peers
3458
3701
  // returning content mentioners. Keyword rows, however, were already matched by
3459
3702
  // each peer's own FTS index; the parent does not own that transcript/index and
3460
3703
  // must not re-run metadata-only filtering that could discard a content hit.
3461
- const matched = looksLikeSessionId(query)
3704
+ const matched = !trustResolvedRows && looksLikeSessionId(query)
3462
3705
  ? resolveSessionQuery(rows, query, { indexFallback: false }).matches
3463
3706
  : rows;
3464
3707
  const byId = new Map();
@@ -3482,6 +3725,14 @@ export function fleetCandidatesByQuery(rows, query) {
3482
3725
  }
3483
3726
  /** Resolve through the canonical metadata+content union used by keyword search. */
3484
3727
  function resolveIndexedMetadataRows(indexed, selector) {
3728
+ const alias = resolveSessionAlias(selector);
3729
+ if (alias.kind === 'resolved') {
3730
+ return resolveSessionQuery(indexed, alias.sessionId, { indexFallback: false }).matches;
3731
+ }
3732
+ if (alias.kind === 'ambiguous') {
3733
+ const ids = new Set(alias.sessionIds.map(id => id.toLowerCase()));
3734
+ return indexed.filter(session => ids.has(session.id.toLowerCase()));
3735
+ }
3485
3736
  return resolveSessionQuery(indexed, selector, { indexFallback: false }).matches;
3486
3737
  }
3487
3738
  /** Fixed peer argv for the metadata resolver. Scope flags compose identically on
@@ -3507,7 +3758,18 @@ export function toolSearchForwardedArgs(argv, hosts) {
3507
3758
  * A full UUID is globally unique, so one exact hit is sufficient even when an
3508
3759
  * unrelated registered device is offline. */
3509
3760
  export function metadataResolveOutcome(localMatches, remote, selector) {
3510
- const candidates = fleetCandidatesByQuery([...localMatches, ...remote.sessions], selector);
3761
+ // Every peer answered through --resolve-safe-v1, so its native-id row is
3762
+ // already the result of resolving the original selector. Re-filtering by the
3763
+ // selector would discard alias suffixes such as c1f3d813 because the native
3764
+ // UUID intentionally has a different prefix.
3765
+ const candidates = fleetCandidatesByQuery([...localMatches, ...remote.sessions], selector, true);
3766
+ // A full UUID is globally unique, so one exact hit resolves even when an
3767
+ // unrelated registered device is offline. A label is NOT globally unique — a
3768
+ // distinct session may carry the same label on an unreachable peer — so it
3769
+ // stays fail-closed: a unique label auto-resumes only once every peer has
3770
+ // answered (candidates.length === 1 below), and an unreachable peer forces
3771
+ // `partial` rather than guessing. (RUSH-2203: early-exit is UUID-only for the
3772
+ // same reason — see isDefinitiveMatch.)
3511
3773
  if (FULL_SESSION_ID_RE.test(selector) && candidates.length === 1 && candidates[0].id.toLowerCase() === selector.toLowerCase()) {
3512
3774
  return { kind: 'resolved', session: candidates[0].hits[0].session };
3513
3775
  }
@@ -3527,6 +3789,12 @@ export async function resolveSessionMetadataValue(selector, scope = {}, deps = {
3527
3789
  const indexed = includeLocal ? applyScopeFilters(querySessions(), scope) : [];
3528
3790
  const localMatches = resolveIndexedMetadataRows(indexed, selector)
3529
3791
  .map(session => ({ ...session, machine: session.machine || localMachine }));
3792
+ // A full-UUID local hit resolves with ZERO SSH: a UUID is globally unique, so
3793
+ // finding it locally is the whole answer and no peer is dialed. (A label is
3794
+ // NOT resolved local-only here — it can collide with a same-label session on a
3795
+ // peer, so it must consult the fleet; see isDefinitiveMatch, RUSH-2203.) The
3796
+ // local index lookup is synchronous and completes before any fan-out spawns,
3797
+ // so this local hit never waits on a peer.
3530
3798
  if (FULL_SESSION_ID_RE.test(selector)) {
3531
3799
  const localOutcome = metadataResolveOutcome(localMatches, { sessions: [], unreachable: [] }, selector);
3532
3800
  if (localOutcome.kind === 'resolved')
@@ -3536,7 +3804,13 @@ export async function resolveSessionMetadataValue(selector, scope = {}, deps = {
3536
3804
  return metadataResolveOutcome(localMatches, { sessions: [], unreachable: [] }, selector);
3537
3805
  try {
3538
3806
  const forwarded = metadataResolveForwardedArgs(selector, scope);
3539
- const remote = await deps.gatherRemoteList(forwarded, scope.hosts);
3807
+ // Definitive lookups (full UUID / exact label) opt into early-exit: the
3808
+ // first peer holding the match resolves the sweep and SIGTERMs the rest, so
3809
+ // a fast peer's hit is not bounded by the slowest/unreachable peer. Short-id
3810
+ // prefixes stay all-settle (ambiguity is only known once every peer answers).
3811
+ const remote = await deps.gatherRemoteList(forwarded, scope.hosts, selectorAllowsEarlyExit(selector)
3812
+ ? { isDefinitive: (session) => isDefinitiveMatch(session, selector) }
3813
+ : undefined);
3540
3814
  return metadataResolveOutcome(localMatches, remote, selector);
3541
3815
  }
3542
3816
  catch (error) {
@@ -3580,26 +3854,11 @@ async function resolveSessionMetadata(selector, scope, deps = { gatherRemoteList
3580
3854
  }
3581
3855
  process.stdout.write(serializeResolvedSessionsJson([outcome.session]));
3582
3856
  }
3583
- /**
3584
- * Locate a full session id or short id prefix across the online fleet and render it from the machine
3585
- * that holds it. The local disk already missed; this fans `sessions <id> --json
3586
- * --all` out to every registered online peer (or the explicit `hosts` set),
3587
- * groups the rows to distinct machines, then:
3588
- *
3589
- * - exactly one logical session → delegate rendering to one peer via `runOnPeer`
3590
- * (its transcript and agent binary live there — a local `--host` hop would
3591
- * re-discover locally and dead-end), returning `'rendered'`.
3592
- * - more than one logical session → print every full-id candidate with its
3593
- * machine labels, returning `'conflict'`.
3594
- * - none → `'not-found'`, letting the caller print the local
3595
- * "no session on this machine" message.
3596
- *
3597
- * No fuzzy/content fallback: the sweep forwards the id selector and every result
3598
- * is resolved through `resolveSessionQuery`, the same id-only resolver used locally.
3599
- */
3600
3857
  export async function resolveSessionAcrossFleet(query, mode, hosts, deps = { gatherRemoteList, runOnPeer }) {
3601
3858
  const spinner = isInteractiveTerminal() ? ora('Searching the fleet...').start() : null;
3602
- let candidates;
3859
+ let candidates = [];
3860
+ let deviceCount = 0;
3861
+ let unreachable = [];
3603
3862
  try {
3604
3863
  // Force whole-index scope (--all): the peer runs in its SSH-login home dir,
3605
3864
  // whose cwd would otherwise silently narrow the lookup and hide the row.
@@ -3607,19 +3866,23 @@ export async function resolveSessionAcrossFleet(query, mode, hosts, deps = { gat
3607
3866
  // itself and never re-fans-out (belt-and-suspenders with the parent's
3608
3867
  // AGENTS_SESSIONS_LOCAL, which remote-list also sets on the peer).
3609
3868
  const forwarded = ['sessions', query, '--json', '--all', '--local'];
3610
- const { sessions } = await deps.gatherRemoteList(forwarded, hosts);
3611
- candidates = fleetCandidatesByQuery(sessions, query);
3869
+ const remote = await deps.gatherRemoteList(forwarded, hosts, selectorAllowsEarlyExit(query)
3870
+ ? { isDefinitive: (session) => isDefinitiveMatch(session, query) }
3871
+ : undefined);
3872
+ candidates = fleetCandidatesByQuery(remote.sessions, query);
3873
+ deviceCount = remote.deviceCount;
3874
+ unreachable = remote.unreachable;
3612
3875
  }
3613
3876
  catch {
3614
3877
  // A fan-out failure is not an exact resolution — treat as not-found so the
3615
- // caller prints the honest local message rather than a half-answer.
3878
+ // caller prints the honest message rather than a half-answer.
3616
3879
  candidates = [];
3617
3880
  }
3618
3881
  finally {
3619
3882
  spinner?.stop();
3620
3883
  }
3621
3884
  if (candidates.length === 0)
3622
- return 'not-found';
3885
+ return { kind: 'not-found', deviceCount, unreachable };
3623
3886
  if (candidates.length > 1) {
3624
3887
  console.error(chalk.red(`Multiple sessions match "${query}" across the fleet:`));
3625
3888
  for (const candidate of candidates) {
@@ -3629,7 +3892,7 @@ export async function resolveSessionAcrossFleet(query, mode, hosts, deps = { gat
3629
3892
  console.error(chalk.cyan(` ${s.shortId} ${s.id}`) + chalk.gray(` ${machines} ${s.agent}${s.version ? ` ${s.version}` : ''} ${label}`));
3630
3893
  }
3631
3894
  console.error(chalk.gray('Pass a longer ID to narrow it down.'));
3632
- return 'conflict';
3895
+ return { kind: 'conflict' };
3633
3896
  }
3634
3897
  const candidate = candidates[0];
3635
3898
  const { machine } = candidate.hits[0];
@@ -3645,9 +3908,9 @@ export async function resolveSessionAcrossFleet(query, mode, hosts, deps = { gat
3645
3908
  if (result === 'no-target') {
3646
3909
  console.error(chalk.red(`Session ${candidate.id} is on ${machine}, but it is not a reachable registered device.`));
3647
3910
  console.error(chalk.gray('Register it with `agents devices` or run the command on that machine.'));
3648
- return 'conflict'; // a definitive answer (found, un-renderable) — do NOT fall to the local not-found line
3911
+ return { kind: 'conflict' }; // a definitive answer (found, un-renderable) — do NOT fall to the local not-found line
3649
3912
  }
3650
- return 'rendered';
3913
+ return { kind: 'rendered' };
3651
3914
  }
3652
3915
  /** Register the `agents sessions` command with all its options and help text. */
3653
3916
  export function registerSessionsCommands(program) {
@@ -3705,7 +3968,8 @@ export function registerSessionsCommands(program) {
3705
3968
  .option('--no-live', 'Do not enrich the listing with live status/preview for running sessions')
3706
3969
  .option('--cloud', 'Source sessions from Rush Cloud (captured runs) instead of local disk')
3707
3970
  .option('-H, --host <target...>', 'Run this query on remote machine(s) over SSH (host alias or user@host; repeatable)')
3708
- .option('--device <target...>', 'Alias for --host (device alias from `agents devices`; repeatable)')
3971
+ .option('--device <target...>', 'Alias for --host (device alias from `agents devices`; repeatable). `--device all` searches the whole fleet (the default).')
3972
+ .addOption(new Option('--devices <target...>', 'Plural alias for --device (accepts `all`/`fleet`).').hideHelp())
3709
3973
  .option('--fleet', 'With --include tools: query every registered online compute device and merge compact matches')
3710
3974
  .option('--count', 'With one program:<name> tool query: count static occurrences, containing calls, and sessions')
3711
3975
  .option('--browser', 'List browser-profile captures (screenshots, PDFs, recordings, downloads) instead of agent transcripts — alias of `agents browser sessions`')
@@ -3817,6 +4081,7 @@ export function registerSessionsCommands(program) {
3817
4081
  });
3818
4082
  registerSessionsTailCommand(sessionsCmd);
3819
4083
  registerSessionsResumeCommand(sessionsCmd);
4084
+ registerSessionsForkCommand(sessionsCmd);
3820
4085
  registerSessionsFavoriteCommand(sessionsCmd);
3821
4086
  registerGoCommand(sessionsCmd);
3822
4087
  registerFocusCommand(sessionsCmd);
@@ -24,6 +24,7 @@ import { parseDuration } from '../lib/hooks/cache.js';
24
24
  import { getRuntimeStateDir } from '../lib/state.js';
25
25
  import { readJob, setJobEnabled } from '../lib/routines.js';
26
26
  import { getActiveSessions } from '../lib/session/active.js';
27
+ import { loadLocalActiveSessions } from '../lib/session/session-cache.js';
27
28
  import { mailboxIdForActiveSession } from '../lib/mailbox-target.js';
28
29
  import { gcMailbox } from '../lib/mailbox-gc.js';
29
30
  import { devicesWithRoutineEnabled, routineEnabledOnThisDevice } from '../lib/routine-activation.js';
@@ -151,9 +152,19 @@ export function registerWatchdogCommand(program) {
151
152
  stateDir: stateDir(),
152
153
  sessions,
153
154
  });
155
+ // RUSH-2062: share the daemon-warmed local active-session snapshot with
156
+ // menubar/CLI/Factory instead of re-running a full gather every tick.
157
+ const loadWatchdogSessions = async () => {
158
+ const loaded = await loadLocalActiveSessions({
159
+ // Force a live gather only when the warm path is unavailable; the
160
+ // cache layer already re-gathers past DEFAULT_ACTIVE_CACHE_MAX_AGE_MS.
161
+ gather: () => getActiveSessions({ localOnly: true }),
162
+ });
163
+ return loaded.sessions;
164
+ };
154
165
  if (!opts.watch) {
155
166
  const willInject = computeWillInject();
156
- const sessions = await getActiveSessions();
167
+ const sessions = await loadWatchdogSessions();
157
168
  const result = await tickOnce(willInject, sessions);
158
169
  await runMailboxGc(sessions);
159
170
  if (opts.json)
@@ -172,7 +183,7 @@ export function registerWatchdogCommand(program) {
172
183
  while (true) {
173
184
  // Re-evaluated each tick: picks up enable/disable flips mid-run.
174
185
  const willInject = computeWillInject();
175
- const sessions = await getActiveSessions();
186
+ const sessions = await loadWatchdogSessions();
176
187
  const result = await tickOnce(willInject, sessions);
177
188
  await runMailboxGc(sessions);
178
189
  if (opts.json)