@phnx-labs/agents-cli 1.22.22 → 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 (91) hide show
  1. package/CHANGELOG.md +209 -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/resume.js +10 -3
  16. package/dist/commands/secrets.js +25 -30
  17. package/dist/commands/sessions-resume.d.ts +21 -3
  18. package/dist/commands/sessions-resume.js +50 -11
  19. package/dist/commands/sessions.d.ts +81 -5
  20. package/dist/commands/sessions.js +325 -66
  21. package/dist/commands/watchdog.js +13 -2
  22. package/dist/lib/agents.d.ts +1 -1
  23. package/dist/lib/agents.js +132 -0
  24. package/dist/lib/codex-policy.d.ts +17 -0
  25. package/dist/lib/codex-policy.js +48 -0
  26. package/dist/lib/crabbox/lease.d.ts +25 -0
  27. package/dist/lib/crabbox/lease.js +62 -0
  28. package/dist/lib/daemon.js +70 -0
  29. package/dist/lib/exec.d.ts +4 -0
  30. package/dist/lib/exec.js +88 -54
  31. package/dist/lib/feed-broadcast.d.ts +1 -20
  32. package/dist/lib/feed-broadcast.js +31 -1
  33. package/dist/lib/hooks.js +12 -2
  34. package/dist/lib/mcp.js +44 -0
  35. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +1 -5
  36. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  37. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -15
  38. package/dist/lib/models.d.ts +0 -5
  39. package/dist/lib/models.js +48 -0
  40. package/dist/lib/plugin-marketplace.js +9 -0
  41. package/dist/lib/pricing/prices.json +119 -92
  42. package/dist/lib/pricing/table.js +13 -0
  43. package/dist/lib/remote-agents-json.d.ts +29 -1
  44. package/dist/lib/remote-agents-json.js +47 -10
  45. package/dist/lib/resources/mcp.js +2 -0
  46. package/dist/lib/resources/permissions.js +3 -0
  47. package/dist/lib/resources/types.d.ts +2 -1
  48. package/dist/lib/runner.js +28 -19
  49. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  50. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -2
  51. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  52. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -13
  53. package/dist/lib/secrets/index.d.ts +15 -1
  54. package/dist/lib/secrets/index.js +118 -49
  55. package/dist/lib/secrets/reaper.d.ts +87 -0
  56. package/dist/lib/secrets/reaper.js +184 -0
  57. package/dist/lib/secrets/remote.d.ts +29 -0
  58. package/dist/lib/secrets/remote.js +37 -1
  59. package/dist/lib/session/active.d.ts +36 -1
  60. package/dist/lib/session/active.js +60 -19
  61. package/dist/lib/session/actor-sidecar.d.ts +14 -0
  62. package/dist/lib/session/actor-sidecar.js +67 -4
  63. package/dist/lib/session/db.d.ts +1 -1
  64. package/dist/lib/session/db.js +32 -1
  65. package/dist/lib/session/discover.js +168 -0
  66. package/dist/lib/session/parse.d.ts +10 -0
  67. package/dist/lib/session/parse.js +98 -0
  68. package/dist/lib/session/remote-list.d.ts +10 -1
  69. package/dist/lib/session/remote-list.js +2 -8
  70. package/dist/lib/session/remote.d.ts +57 -3
  71. package/dist/lib/session/remote.js +90 -26
  72. package/dist/lib/session/resume-command.d.ts +6 -0
  73. package/dist/lib/session/resume-command.js +8 -0
  74. package/dist/lib/session/session-cache.d.ts +173 -0
  75. package/dist/lib/session/session-cache.js +399 -0
  76. package/dist/lib/session/types.d.ts +1 -1
  77. package/dist/lib/session/types.js +1 -1
  78. package/dist/lib/session/width.d.ts +1 -1
  79. package/dist/lib/session/width.js +12 -2
  80. package/dist/lib/shims.d.ts +2 -2
  81. package/dist/lib/shims.js +40 -5
  82. package/dist/lib/staleness/writers/hooks.js +1 -1
  83. package/dist/lib/types.d.ts +1 -1
  84. package/dist/lib/usage.d.ts +13 -0
  85. package/dist/lib/usage.js +215 -0
  86. package/dist/lib/versions.js +13 -2
  87. package/package.json +1 -1
  88. package/dist/bin/agents +0 -0
  89. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  90. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  91. 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
@@ -2939,6 +3126,8 @@ function resumeArgv(agent, id, launcher) {
2939
3126
  case 'claude': return [launcher, '--resume', id];
2940
3127
  case 'codex': return [launcher, 'resume', id];
2941
3128
  case 'opencode': return [launcher, '--session', id];
3129
+ // Muse interactive resume is a subcommand: `muse resume <uuid>`.
3130
+ case 'muse': return [launcher, 'resume', id];
2942
3131
  default: return null;
2943
3132
  }
2944
3133
  }
@@ -2967,7 +3156,8 @@ export function buildResumeCommand(session) {
2967
3156
  case 'opencode':
2968
3157
  return resumeArgv('opencode', session.id, 'opencode');
2969
3158
  case 'claude':
2970
- case 'codex': {
3159
+ case 'codex':
3160
+ case 'muse': {
2971
3161
  const cli = AGENTS[session.agent]?.cliCommand ?? session.agent;
2972
3162
  if (session.version) {
2973
3163
  const alias = versionedAliasIfPresent(session.agent, session.version);
@@ -3151,13 +3341,31 @@ function ambiguityHint(byId, completeId) {
3151
3341
  : 'That matched on text, not an id. Pass a session id, or narrow the search.';
3152
3342
  }
3153
3343
  /** Explain a complete-id miss, which no local rephrasing can fix. Echoes the
3154
- * 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. */
3155
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) {
3156
3356
  const id = query.trim();
3157
- return [
3158
- chalk.red(`No session with id ${id} on this machine.`),
3159
- chalk.gray(`Search the fleet with: agents sessions ${id} --device <host>`),
3160
- ];
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;
3161
3369
  }
3162
3370
  /** Filter and rank sessions by a multi-term search query across metadata and content. */
3163
3371
  export function filterSessionsByQuery(sessions, query) {
@@ -3380,11 +3588,15 @@ async function renderOneSession(query, mode, scope) {
3380
3588
  // local not-found message. No FTS fallback either way.
3381
3589
  if (shouldFanOutForId(query, scope.local)) {
3382
3590
  const outcome = await resolveSessionAcrossFleet(query, mode, scope.hosts);
3383
- if (outcome === 'rendered')
3591
+ if (outcome.kind === 'rendered')
3384
3592
  return;
3385
- if (outcome === 'conflict')
3593
+ if (outcome.kind === 'conflict')
3386
3594
  process.exit(1);
3387
- // '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);
3388
3600
  }
3389
3601
  notFoundByIdMessage(query).forEach(l => console.error(l));
3390
3602
  process.exit(1);
@@ -3455,16 +3667,41 @@ function modeFlag(mode) {
3455
3667
  return undefined; // summary — the peer's default render
3456
3668
  }
3457
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
+ }
3458
3695
  /** Resolve a fleet sweep through the same canonical full-id / prefix resolver as
3459
3696
  * local lookups, then group copies by logical session id. Synced mirrors of one
3460
3697
  * session therefore stay one candidate even when several machines report them;
3461
3698
  * distinct ids sharing a prefix remain distinct ambiguity candidates. */
3462
- export function fleetCandidatesByQuery(rows, query) {
3699
+ export function fleetCandidatesByQuery(rows, query, trustResolvedRows = false) {
3463
3700
  // An id selector must still be prefix-filtered defensively against older peers
3464
3701
  // returning content mentioners. Keyword rows, however, were already matched by
3465
3702
  // each peer's own FTS index; the parent does not own that transcript/index and
3466
3703
  // must not re-run metadata-only filtering that could discard a content hit.
3467
- const matched = looksLikeSessionId(query)
3704
+ const matched = !trustResolvedRows && looksLikeSessionId(query)
3468
3705
  ? resolveSessionQuery(rows, query, { indexFallback: false }).matches
3469
3706
  : rows;
3470
3707
  const byId = new Map();
@@ -3488,6 +3725,14 @@ export function fleetCandidatesByQuery(rows, query) {
3488
3725
  }
3489
3726
  /** Resolve through the canonical metadata+content union used by keyword search. */
3490
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
+ }
3491
3736
  return resolveSessionQuery(indexed, selector, { indexFallback: false }).matches;
3492
3737
  }
3493
3738
  /** Fixed peer argv for the metadata resolver. Scope flags compose identically on
@@ -3513,7 +3758,18 @@ export function toolSearchForwardedArgs(argv, hosts) {
3513
3758
  * A full UUID is globally unique, so one exact hit is sufficient even when an
3514
3759
  * unrelated registered device is offline. */
3515
3760
  export function metadataResolveOutcome(localMatches, remote, selector) {
3516
- 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.)
3517
3773
  if (FULL_SESSION_ID_RE.test(selector) && candidates.length === 1 && candidates[0].id.toLowerCase() === selector.toLowerCase()) {
3518
3774
  return { kind: 'resolved', session: candidates[0].hits[0].session };
3519
3775
  }
@@ -3533,6 +3789,12 @@ export async function resolveSessionMetadataValue(selector, scope = {}, deps = {
3533
3789
  const indexed = includeLocal ? applyScopeFilters(querySessions(), scope) : [];
3534
3790
  const localMatches = resolveIndexedMetadataRows(indexed, selector)
3535
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.
3536
3798
  if (FULL_SESSION_ID_RE.test(selector)) {
3537
3799
  const localOutcome = metadataResolveOutcome(localMatches, { sessions: [], unreachable: [] }, selector);
3538
3800
  if (localOutcome.kind === 'resolved')
@@ -3542,7 +3804,13 @@ export async function resolveSessionMetadataValue(selector, scope = {}, deps = {
3542
3804
  return metadataResolveOutcome(localMatches, { sessions: [], unreachable: [] }, selector);
3543
3805
  try {
3544
3806
  const forwarded = metadataResolveForwardedArgs(selector, scope);
3545
- 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);
3546
3814
  return metadataResolveOutcome(localMatches, remote, selector);
3547
3815
  }
3548
3816
  catch (error) {
@@ -3586,26 +3854,11 @@ async function resolveSessionMetadata(selector, scope, deps = { gatherRemoteList
3586
3854
  }
3587
3855
  process.stdout.write(serializeResolvedSessionsJson([outcome.session]));
3588
3856
  }
3589
- /**
3590
- * Locate a full session id or short id prefix across the online fleet and render it from the machine
3591
- * that holds it. The local disk already missed; this fans `sessions <id> --json
3592
- * --all` out to every registered online peer (or the explicit `hosts` set),
3593
- * groups the rows to distinct machines, then:
3594
- *
3595
- * - exactly one logical session → delegate rendering to one peer via `runOnPeer`
3596
- * (its transcript and agent binary live there — a local `--host` hop would
3597
- * re-discover locally and dead-end), returning `'rendered'`.
3598
- * - more than one logical session → print every full-id candidate with its
3599
- * machine labels, returning `'conflict'`.
3600
- * - none → `'not-found'`, letting the caller print the local
3601
- * "no session on this machine" message.
3602
- *
3603
- * No fuzzy/content fallback: the sweep forwards the id selector and every result
3604
- * is resolved through `resolveSessionQuery`, the same id-only resolver used locally.
3605
- */
3606
3857
  export async function resolveSessionAcrossFleet(query, mode, hosts, deps = { gatherRemoteList, runOnPeer }) {
3607
3858
  const spinner = isInteractiveTerminal() ? ora('Searching the fleet...').start() : null;
3608
- let candidates;
3859
+ let candidates = [];
3860
+ let deviceCount = 0;
3861
+ let unreachable = [];
3609
3862
  try {
3610
3863
  // Force whole-index scope (--all): the peer runs in its SSH-login home dir,
3611
3864
  // whose cwd would otherwise silently narrow the lookup and hide the row.
@@ -3613,19 +3866,23 @@ export async function resolveSessionAcrossFleet(query, mode, hosts, deps = { gat
3613
3866
  // itself and never re-fans-out (belt-and-suspenders with the parent's
3614
3867
  // AGENTS_SESSIONS_LOCAL, which remote-list also sets on the peer).
3615
3868
  const forwarded = ['sessions', query, '--json', '--all', '--local'];
3616
- const { sessions } = await deps.gatherRemoteList(forwarded, hosts);
3617
- 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;
3618
3875
  }
3619
3876
  catch {
3620
3877
  // A fan-out failure is not an exact resolution — treat as not-found so the
3621
- // caller prints the honest local message rather than a half-answer.
3878
+ // caller prints the honest message rather than a half-answer.
3622
3879
  candidates = [];
3623
3880
  }
3624
3881
  finally {
3625
3882
  spinner?.stop();
3626
3883
  }
3627
3884
  if (candidates.length === 0)
3628
- return 'not-found';
3885
+ return { kind: 'not-found', deviceCount, unreachable };
3629
3886
  if (candidates.length > 1) {
3630
3887
  console.error(chalk.red(`Multiple sessions match "${query}" across the fleet:`));
3631
3888
  for (const candidate of candidates) {
@@ -3635,7 +3892,7 @@ export async function resolveSessionAcrossFleet(query, mode, hosts, deps = { gat
3635
3892
  console.error(chalk.cyan(` ${s.shortId} ${s.id}`) + chalk.gray(` ${machines} ${s.agent}${s.version ? ` ${s.version}` : ''} ${label}`));
3636
3893
  }
3637
3894
  console.error(chalk.gray('Pass a longer ID to narrow it down.'));
3638
- return 'conflict';
3895
+ return { kind: 'conflict' };
3639
3896
  }
3640
3897
  const candidate = candidates[0];
3641
3898
  const { machine } = candidate.hits[0];
@@ -3651,9 +3908,9 @@ export async function resolveSessionAcrossFleet(query, mode, hosts, deps = { gat
3651
3908
  if (result === 'no-target') {
3652
3909
  console.error(chalk.red(`Session ${candidate.id} is on ${machine}, but it is not a reachable registered device.`));
3653
3910
  console.error(chalk.gray('Register it with `agents devices` or run the command on that machine.'));
3654
- 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
3655
3912
  }
3656
- return 'rendered';
3913
+ return { kind: 'rendered' };
3657
3914
  }
3658
3915
  /** Register the `agents sessions` command with all its options and help text. */
3659
3916
  export function registerSessionsCommands(program) {
@@ -3711,7 +3968,8 @@ export function registerSessionsCommands(program) {
3711
3968
  .option('--no-live', 'Do not enrich the listing with live status/preview for running sessions')
3712
3969
  .option('--cloud', 'Source sessions from Rush Cloud (captured runs) instead of local disk')
3713
3970
  .option('-H, --host <target...>', 'Run this query on remote machine(s) over SSH (host alias or user@host; repeatable)')
3714
- .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())
3715
3973
  .option('--fleet', 'With --include tools: query every registered online compute device and merge compact matches')
3716
3974
  .option('--count', 'With one program:<name> tool query: count static occurrences, containing calls, and sessions')
3717
3975
  .option('--browser', 'List browser-profile captures (screenshots, PDFs, recordings, downloads) instead of agent transcripts — alias of `agents browser sessions`')
@@ -3823,6 +4081,7 @@ export function registerSessionsCommands(program) {
3823
4081
  });
3824
4082
  registerSessionsTailCommand(sessionsCmd);
3825
4083
  registerSessionsResumeCommand(sessionsCmd);
4084
+ registerSessionsForkCommand(sessionsCmd);
3826
4085
  registerSessionsFavoriteCommand(sessionsCmd);
3827
4086
  registerGoCommand(sessionsCmd);
3828
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)
@@ -165,7 +165,7 @@ export declare function formatClaudeOrgLabel(orgType: string | null | undefined)
165
165
  */
166
166
  export declare function accountOrgBadge(info?: Pick<AccountInfo, 'organizationType' | 'organizationName'> | null): string | null;
167
167
  /** Agents whose local credential formats expose enough state for account selection. */
168
- export declare const ACCOUNT_INSPECTION_AGENT_IDS: readonly ["claude", "codex", "gemini", "cursor", "grok", "antigravity", "kimi", "droid", "opencode"];
168
+ export declare const ACCOUNT_INSPECTION_AGENT_IDS: readonly ["claude", "codex", "gemini", "cursor", "grok", "antigravity", "kimi", "droid", "opencode", "muse"];
169
169
  /** Whether agents-cli can determine this agent's per-version sign-in state. */
170
170
  export declare function supportsAccountInspection(agentId: AgentId): boolean;
171
171
  /**