@phnx-labs/agents-cli 1.22.22 → 1.22.24

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 (138) hide show
  1. package/CHANGELOG.md +402 -0
  2. package/README.md +13 -6
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/attach.d.ts +2 -0
  5. package/dist/commands/attach.js +32 -7
  6. package/dist/commands/defaults.js +2 -0
  7. package/dist/commands/doctor.js +20 -7
  8. package/dist/commands/exec.js +100 -34
  9. package/dist/commands/feed.d.ts +18 -0
  10. package/dist/commands/feed.js +44 -1
  11. package/dist/commands/focus.d.ts +108 -5
  12. package/dist/commands/focus.js +455 -32
  13. package/dist/commands/fork.d.ts +20 -2
  14. package/dist/commands/fork.js +91 -64
  15. package/dist/commands/go.d.ts +39 -1
  16. package/dist/commands/go.js +112 -7
  17. package/dist/commands/harness-wizard.d.ts +206 -0
  18. package/dist/commands/harness-wizard.js +403 -0
  19. package/dist/commands/harness.d.ts +12 -0
  20. package/dist/commands/harness.js +97 -101
  21. package/dist/commands/insights.js +6 -2
  22. package/dist/commands/inspect.js +39 -5
  23. package/dist/commands/menubar.js +6 -1
  24. package/dist/commands/models.js +1 -0
  25. package/dist/commands/modes.d.ts +12 -0
  26. package/dist/commands/modes.js +147 -0
  27. package/dist/commands/resume.js +10 -3
  28. package/dist/commands/secrets-sync.js +11 -13
  29. package/dist/commands/secrets.d.ts +2 -0
  30. package/dist/commands/secrets.js +79 -47
  31. package/dist/commands/sessions-browser.d.ts +35 -0
  32. package/dist/commands/sessions-browser.js +140 -14
  33. package/dist/commands/sessions-resume.d.ts +23 -3
  34. package/dist/commands/sessions-resume.js +73 -19
  35. package/dist/commands/sessions.d.ts +91 -5
  36. package/dist/commands/sessions.js +359 -125
  37. package/dist/commands/setup-secrets.js +1 -1
  38. package/dist/commands/sync.js +246 -42
  39. package/dist/commands/view.js +2 -0
  40. package/dist/commands/watchdog.js +13 -2
  41. package/dist/index.js +2 -1
  42. package/dist/lib/agent-modes.d.ts +49 -0
  43. package/dist/lib/agent-modes.js +70 -0
  44. package/dist/lib/agents.d.ts +1 -1
  45. package/dist/lib/agents.js +132 -0
  46. package/dist/lib/codex-policy.d.ts +17 -0
  47. package/dist/lib/codex-policy.js +48 -0
  48. package/dist/lib/crabbox/lease.d.ts +25 -0
  49. package/dist/lib/crabbox/lease.js +62 -0
  50. package/dist/lib/daemon.js +70 -0
  51. package/dist/lib/doctor-diff.d.ts +3 -0
  52. package/dist/lib/doctor-diff.js +15 -13
  53. package/dist/lib/event-stream.d.ts +3 -1
  54. package/dist/lib/event-stream.js +14 -1
  55. package/dist/lib/exec.d.ts +16 -0
  56. package/dist/lib/exec.js +175 -62
  57. package/dist/lib/feed-broadcast.d.ts +1 -20
  58. package/dist/lib/feed-broadcast.js +31 -1
  59. package/dist/lib/hooks/cache.js +36 -3
  60. package/dist/lib/hooks.d.ts +19 -7
  61. package/dist/lib/hooks.js +100 -40
  62. package/dist/lib/hosts/session-index.d.ts +4 -0
  63. package/dist/lib/hosts/session-index.js +7 -0
  64. package/dist/lib/manifest.d.ts +12 -2
  65. package/dist/lib/manifest.js +60 -5
  66. package/dist/lib/mcp.js +44 -0
  67. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  68. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  69. package/dist/lib/menubar/install-menubar.d.ts +52 -2
  70. package/dist/lib/menubar/install-menubar.js +128 -6
  71. package/dist/lib/models.d.ts +0 -5
  72. package/dist/lib/models.js +48 -0
  73. package/dist/lib/plugin-marketplace.js +9 -0
  74. package/dist/lib/pricing/prices.json +119 -92
  75. package/dist/lib/pricing/table.js +13 -0
  76. package/dist/lib/refresh.d.ts +5 -0
  77. package/dist/lib/refresh.js +37 -33
  78. package/dist/lib/remote-agents-json.d.ts +29 -1
  79. package/dist/lib/remote-agents-json.js +47 -10
  80. package/dist/lib/resource-inventory.d.ts +79 -0
  81. package/dist/lib/resource-inventory.js +122 -0
  82. package/dist/lib/resources/mcp.js +2 -0
  83. package/dist/lib/resources/permissions.js +3 -0
  84. package/dist/lib/resources/types.d.ts +2 -1
  85. package/dist/lib/resources.js +8 -5
  86. package/dist/lib/run-defaults.d.ts +2 -0
  87. package/dist/lib/run-defaults.js +23 -2
  88. package/dist/lib/runner.js +50 -36
  89. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  90. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  91. package/dist/lib/secrets/bundles.js +157 -65
  92. package/dist/lib/secrets/filestore.d.ts +5 -3
  93. package/dist/lib/secrets/filestore.js +12 -8
  94. package/dist/lib/secrets/index.d.ts +15 -1
  95. package/dist/lib/secrets/index.js +148 -53
  96. package/dist/lib/secrets/reaper.d.ts +97 -0
  97. package/dist/lib/secrets/reaper.js +219 -0
  98. package/dist/lib/secrets/remote.d.ts +29 -0
  99. package/dist/lib/secrets/remote.js +37 -1
  100. package/dist/lib/secrets/sync-passphrase.d.ts +27 -0
  101. package/dist/lib/secrets/sync-passphrase.js +78 -0
  102. package/dist/lib/session/active.d.ts +36 -1
  103. package/dist/lib/session/active.js +60 -19
  104. package/dist/lib/session/actor-sidecar.d.ts +14 -0
  105. package/dist/lib/session/actor-sidecar.js +67 -4
  106. package/dist/lib/session/db.d.ts +1 -1
  107. package/dist/lib/session/db.js +32 -1
  108. package/dist/lib/session/discover.js +168 -0
  109. package/dist/lib/session/parse.d.ts +10 -0
  110. package/dist/lib/session/parse.js +98 -0
  111. package/dist/lib/session/recovery.d.ts +37 -0
  112. package/dist/lib/session/recovery.js +95 -0
  113. package/dist/lib/session/remote-list.d.ts +10 -1
  114. package/dist/lib/session/remote-list.js +2 -8
  115. package/dist/lib/session/remote.d.ts +57 -3
  116. package/dist/lib/session/remote.js +90 -26
  117. package/dist/lib/session/resume-command.d.ts +6 -0
  118. package/dist/lib/session/resume-command.js +8 -0
  119. package/dist/lib/session/session-cache.d.ts +173 -0
  120. package/dist/lib/session/session-cache.js +399 -0
  121. package/dist/lib/session/types.d.ts +1 -1
  122. package/dist/lib/session/types.js +1 -1
  123. package/dist/lib/session/width.d.ts +1 -1
  124. package/dist/lib/session/width.js +12 -2
  125. package/dist/lib/shims.d.ts +2 -2
  126. package/dist/lib/shims.js +62 -8
  127. package/dist/lib/staleness/writers/hooks.js +1 -1
  128. package/dist/lib/startup/command-registry.d.ts +1 -0
  129. package/dist/lib/startup/command-registry.js +2 -0
  130. package/dist/lib/sync-umbrella.d.ts +5 -0
  131. package/dist/lib/sync-umbrella.js +5 -4
  132. package/dist/lib/tmux/session.d.ts +4 -2
  133. package/dist/lib/tmux/session.js +5 -5
  134. package/dist/lib/types.d.ts +3 -1
  135. package/dist/lib/usage.d.ts +13 -0
  136. package/dist/lib/usage.js +215 -0
  137. package/dist/lib/versions.js +13 -2
  138. package/package.json +1 -1
@@ -20,34 +20,39 @@ import { listProjectDefs, resolveProjectNameForCwd } from '../lib/projects.js';
20
20
  import ora from 'ora';
21
21
  import { SESSION_AGENTS } from '../lib/session/types.js';
22
22
  import { discoverArtifacts, readArtifact, resolveArtifact } from '../lib/session/artifacts.js';
23
- import { looksLikePath, toComparablePath, homeDir, needsWindowsShell, findExecutable, composeWin32CommandLine } from '../lib/platform/index.js';
23
+ import { looksLikePath, toComparablePath, homeDir, needsWindowsShell, composeWin32CommandLine } from '../lib/platform/index.js';
24
24
  import { getActiveSessions } from '../lib/session/active.js';
25
25
  import { enumerateGhosttyTabs, assignGhosttyTabs } from '../lib/session/ghostty-tabs.js';
26
26
  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';
48
+ import { getAgentsInvocation } from '../lib/daemon.js';
49
+ import { sessionRecoveryRunArgs } from '../lib/session/recovery.js';
46
50
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
47
51
  import { sessionPicker, buildPreview, formatTodoCompact, githubRepoUrlFromCwd, } from './sessions-picker.js';
48
52
  import { setHelpSections } from '../lib/help.js';
49
53
  import { registerSessionsTailCommand } from './sessions-tail.js';
50
54
  import { registerSessionsResumeCommand } from './sessions-resume.js';
55
+ import { registerSessionsForkCommand } from './fork.js';
51
56
  import { registerSessionsFavoriteCommand } from './sessions-favorite.js';
52
57
  import { isFavorite, listFavorites } from '../lib/session/favorites.js';
53
58
  import { registerGoCommand } from './go.js';
@@ -378,6 +383,58 @@ export function indexActiveBySessionId(active) {
378
383
  }
379
384
  return byId;
380
385
  }
386
+ /**
387
+ * Backfill display-only fields onto live rows from the indexed SessionMeta, by
388
+ * full session id (RUSH-2205). A running process reports no agent version, and a
389
+ * live orphan row usually carries no ticket/PR/label/start-time; the historical
390
+ * index does. Only a field the live row LACKS is filled — the live signal always
391
+ * wins when present. Pure (no I/O) so the join is unit-tested against fixtures;
392
+ * the DB read that builds `metaById` is the caller's concern.
393
+ */
394
+ export function backfillActiveRowsFromMeta(sessions, metaById) {
395
+ for (const s of sessions) {
396
+ if (!s.sessionId)
397
+ continue;
398
+ const m = metaById.get(s.sessionId);
399
+ if (!m)
400
+ continue;
401
+ if (!s.version && m.version)
402
+ s.version = m.version;
403
+ if (!s.label && m.label)
404
+ s.label = m.label;
405
+ if (!s.ticket && m.ticketId)
406
+ s.ticket = { id: m.ticketId, url: linearIssueUrl(m.ticketId) };
407
+ if (!s.pr && m.prUrl)
408
+ s.pr = { url: m.prUrl, number: m.prNumber };
409
+ if (!s.startedAtMs && m.timestamp) {
410
+ const ts = new Date(m.timestamp).getTime();
411
+ if (!Number.isNaN(ts))
412
+ s.startedAtMs = ts;
413
+ }
414
+ }
415
+ }
416
+ /**
417
+ * Build the id→meta index the live-row backfill needs, reading the historical
418
+ * session DB by full id. Best-effort: a missing or locked DB yields an empty map
419
+ * so the live view still renders (mirrors {@link maybeLiveIndex}). Deduplicates
420
+ * ids so N rows in one session cost one query.
421
+ */
422
+ function loadBackfillMetaFor(sessions) {
423
+ const byId = new Map();
424
+ try {
425
+ for (const s of sessions) {
426
+ if (!s.sessionId || byId.has(s.sessionId))
427
+ continue;
428
+ const m = getSessionById(s.sessionId);
429
+ if (m)
430
+ byId.set(s.sessionId, m);
431
+ }
432
+ }
433
+ catch {
434
+ /* enrichment is best-effort — an unavailable DB leaves rows un-backfilled */
435
+ }
436
+ return byId;
437
+ }
381
438
  /**
382
439
  * The live decoration for a listing row: a status glyph and the latest-turn
383
440
  * preview, when the session is still running. `●` running / `◐` waiting on the
@@ -582,30 +639,109 @@ function locatorBadge(s) {
582
639
  return parts.join(' ');
583
640
  }
584
641
  /**
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.
642
+ * The `created X · idle Y` time cell for a live row (RUSH-2205). `created` is the
643
+ * age of the session start ({@link ActiveSession.startedAtMs}); `idle` is the age
644
+ * of the last transcript write ({@link ActiveSession.lastActivityMs}) i.e. how
645
+ * long it has been quiet. Compact ("6d", "3h", "now") so the row stays width-safe;
646
+ * either half is omitted when its epoch is unknown.
590
647
  */
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));
648
+ function activeTimeCell(s) {
649
+ const parts = [];
650
+ if (s.startedAtMs)
651
+ parts.push(`created ${formatCompactAge(new Date(s.startedAtMs).toISOString())}`);
652
+ if (s.lastActivityMs)
653
+ parts.push(`idle ${formatCompactAge(new Date(s.lastActivityMs).toISOString())}`);
654
+ return parts.join(' · ');
655
+ }
656
+ /** Column widths for line 1 of an active row (id · agent · version · status · owner). */
657
+ const ROW_ID_W = 9;
658
+ const ROW_AGENT_W = 8;
659
+ const ROW_VERSION_W = 8;
660
+ const ROW_STATUS_W = 9;
661
+ const ROW_OWNER_W = 9;
662
+ /**
663
+ * Fit pre-styled content (which may carry SGR colour AND OSC-8 hyperlinks — the
664
+ * clickable ticket/PR/project badges) into `room` display cells. Returns it raw
665
+ * when it already fits, preserving colour and clickable links; otherwise falls
666
+ * back to a width-safe truncation. `truncateToWidth` strips OSC-8 before cutting
667
+ * (see width.ts), so a too-narrow cell drops the hyperlink cleanly rather than
668
+ * slicing a hyperlink escape in half and corrupting the terminal (RUSH-2205
669
+ * review). Never run pre-linked content through `truncateToWidth` directly — that
670
+ * strips its links even when it fits; go through this helper.
671
+ */
672
+ function fitCell(content, room) {
673
+ if (room <= 0)
674
+ return '';
675
+ return stringWidth(content) <= room ? content : truncateToWidth(content, room);
676
+ }
677
+ /**
678
+ * Build the (one or two) rendered lines for a single active-session row.
679
+ * Indent is the leading whitespace (2 spaces for flat groups, 4 inside a window
680
+ * sub-group). Sized to `termW` so no line ever wraps under tmux/SSH (RUSH-2205):
681
+ *
682
+ * line 1: id · agent version · status · owner · created X · idle Y · ticket/PR
683
+ * line 2: └ label/topic (+ checklist) · jump locator (ssh/tmux/detached)
684
+ *
685
+ * The label/topic gets its own line so it is no longer buried in a truncated grey
686
+ * snippet, and the actionable ticket/PR badges ride line 1. Version and the
687
+ * ticket/PR/label are backfilled onto the {@link ActiveSession} from the indexed
688
+ * SessionMeta before this renders (a live process reports none of them). Pure +
689
+ * exported so the row layout is unit-tested for content and width without a
690
+ * captured stdout.
691
+ */
692
+ export function renderActiveRowLines(s, indent, termW) {
693
+ const idCol = chalk.dim(padToWidth((s.sessionId?.slice(0, 8)) ?? '-', ROW_ID_W));
694
+ const kindCol = colorAgent(s.kind)(padToWidth(truncateToWidth(s.kind, ROW_AGENT_W), ROW_AGENT_W + 1));
695
+ const versionCol = chalk.gray(padToWidth(truncateToWidth(s.version ?? '', ROW_VERSION_W), ROW_VERSION_W + 1));
696
+ const statusCol = statusColor(s.status)(padToWidth(truncateToWidth(activityLabel(s), ROW_STATUS_W - 1), ROW_STATUS_W));
697
+ const ownerCol = chalk.cyan(padToWidth(truncateToWidth(ownerLabel(s), ROW_OWNER_W - 1), ROW_OWNER_W));
698
+ const fixedCols = idCol + kindCol + versionCol + statusCol + ownerCol;
699
+ const fixedW = stringWidth(indent) + ROW_ID_W + (ROW_AGENT_W + 1) + (ROW_VERSION_W + 1) + ROW_STATUS_W + ROW_OWNER_W;
700
+ const remaining = Math.max(0, termW - fixedW - 1);
701
+ // Line 1 right side: created/idle time + actionable badges (fork count,
702
+ // plan/ask/perm, ticket, PR, worktree). Badges are the jump-to-work signal, so
703
+ // they win the width fight — fitCell keeps them raw+clickable when they fit and
704
+ // drops the link cleanly when narrow; the time cell takes whatever is left.
598
705
  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);
706
+ const badgesCell = fitCell(fork + signalBadges(s), remaining);
707
+ const badgesW = stringWidth(badgesCell);
708
+ const timeRoom = Math.max(0, remaining - (badgesW ? badgesW + 2 : 0));
709
+ const timeCell = chalk.gray(truncateToWidth(activeTimeCell(s), timeRoom));
710
+ let right = timeCell;
711
+ if (badgesCell)
712
+ right += (stringWidth(timeCell) ? ' ' : '') + badgesCell;
713
+ // right's visible width is <= remaining by construction, so the assembled line
714
+ // is <= termW-1 whenever the fixed columns fit; the clamp only bites a terminal
715
+ // narrower than the fixed columns (where right is empty no link to corrupt).
716
+ let line1 = indent + fixedCols + right;
717
+ if (stringWidth(line1) > termW)
718
+ line1 = truncateToWidth(line1, termW);
719
+ const lines = [line1];
720
+ // Line 2: label/topic + checklist (the identity, no longer buried) then the
721
+ // jump locator. Skipped entirely when there is nothing to say. desc may carry a
722
+ // clickable project link, so it also goes through fitCell.
723
+ const desc = formatActiveRowDescription(s);
724
+ const loc = locatorBadge(s);
725
+ if (!desc && !loc)
726
+ return lines;
727
+ const contIndent = indent + ' '.repeat(ROW_ID_W);
728
+ const room2 = Math.max(0, termW - stringWidth(contIndent) - 2);
729
+ const locCell = fitCell(loc, room2);
730
+ const locW = stringWidth(locCell);
731
+ const descRoom = Math.max(0, room2 - (locW ? locW + 2 : 0));
732
+ const descCell = chalk.white(fitCell(desc || '-', descRoom));
733
+ let line2 = contIndent + chalk.dim('└ ') + descCell;
734
+ if (locCell)
735
+ line2 += ' ' + locCell;
736
+ if (stringWidth(line2) > termW)
737
+ line2 = truncateToWidth(line2, termW);
738
+ lines.push(line2);
739
+ return lines;
740
+ }
741
+ /** Render a single agent-session row inside an already-printed group header. */
742
+ function printActiveRow(s, indent) {
743
+ for (const line of renderActiveRowLines(s, indent, terminalWidth()))
744
+ console.log(line);
609
745
  }
610
746
  /**
611
747
  * Compact owner display for the `--active` owner column: the local-part of a
@@ -1048,8 +1184,48 @@ export function remoteHostsToDial(hosts, self) {
1048
1184
  * call it, so the browser can never disagree with `--active --json` about which
1049
1185
  * sessions are live (it used to call the local-only `getActiveSessions()` directly
1050
1186
  * and silently hid every remote session).
1187
+ *
1188
+ * RUSH-2062: default path is cache-first against the daemon-warmed shared
1189
+ * snapshot (`session-cache.ts`). Menubar / Factory / watchdog / CLI share one
1190
+ * warm result instead of each re-running the full SSH fan-out. `forceRefresh`
1191
+ * (or `AGENTS_SESSIONS_FORCE_REFRESH=1`) re-gathers live; scoped `--host` lists
1192
+ * always gather live so a filter never returns a wrong unscoped snapshot.
1051
1193
  */
1052
1194
  export async function gatherActiveSessions(opts = {}) {
1195
+ const forceRefresh = opts.forceRefresh === true
1196
+ || process.env.AGENTS_SESSIONS_FORCE_REFRESH === '1';
1197
+ // Scoped host lists are not represented in the unscoped fleet/local snapshot
1198
+ // — always gather live so a filter cannot return the wrong set.
1199
+ const scoped = (opts.hosts?.length ?? 0) > 0;
1200
+ if (opts.local && !scoped) {
1201
+ const loaded = await loadLocalActiveSessions({
1202
+ forceRefresh,
1203
+ gather: async () => {
1204
+ const rows = await getActiveSessions({ localOnly: true });
1205
+ const self = machineId();
1206
+ for (const s of rows)
1207
+ if (!s.machine)
1208
+ s.machine = self;
1209
+ return rows;
1210
+ },
1211
+ });
1212
+ return { sessions: loaded.sessions, remoteDeviceCount: 0 };
1213
+ }
1214
+ if (!opts.local && !scoped) {
1215
+ const loaded = await loadFleetActiveSessions({
1216
+ forceRefresh,
1217
+ gather: () => gatherActiveSessionsLive({ local: false }),
1218
+ });
1219
+ return { sessions: loaded.sessions, remoteDeviceCount: loaded.remoteDeviceCount };
1220
+ }
1221
+ return gatherActiveSessionsLive(opts);
1222
+ }
1223
+ /**
1224
+ * Live (uncached) gather — the work the daemon / force-refresh path pays once
1225
+ * so other surfaces can share the snapshot. Kept separate so the cache layer
1226
+ * never recursively re-enters itself.
1227
+ */
1228
+ async function gatherActiveSessionsLive(opts = {}) {
1053
1229
  const self = machineId();
1054
1230
  // An explicit --host/--device list scopes the view: seed local sessions only
1055
1231
  // when no hosts are named, or when this machine is one of the named targets.
@@ -1057,7 +1233,9 @@ export async function gatherActiveSessions(opts = {}) {
1057
1233
  // remote-host teammate over ssh even for this machine's OWN local gather —
1058
1234
  // "this machine only" must mean zero ssh, not just "skip the cross-machine
1059
1235
  // device fan-out below".
1060
- const local = shouldIncludeLocal(opts.hosts, self) ? await getActiveSessions({ localOnly: opts.local }) : [];
1236
+ const local = shouldIncludeLocal(opts.hosts, self)
1237
+ ? await getActiveSessions({ localOnly: opts.local })
1238
+ : [];
1061
1239
  for (const s of local)
1062
1240
  if (!s.machine)
1063
1241
  s.machine = self;
@@ -1099,6 +1277,12 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
1099
1277
  ? merged.filter((session) => opts.statuses.some((status) => matchesLiveStatus(session, status)))
1100
1278
  : merged;
1101
1279
  const sessions = statusFiltered;
1280
+ // Backfill agent version + ticket/PR/label/created onto the live rows from the
1281
+ // historical index (RUSH-2205) — a running process reports none of these, and
1282
+ // an orphan row usually lacks them. Done before both the JSON and human paths
1283
+ // so every consumer (incl. the SSH fan-out's remote --json) sees enriched rows;
1284
+ // transcripts sync across the fleet, so a remote row resolves from the local DB.
1285
+ backfillActiveRowsFromMeta(sessions, loadBackfillMetaFor(sessions));
1102
1286
  if (asJson) {
1103
1287
  // Resolve who is watching each local tmux pane before serializing: `viewingIn`
1104
1288
  // is how a consumer distinguishes a session someone is looking at from one
@@ -1542,8 +1726,13 @@ limitSource) {
1542
1726
  // shorthands fold into --agent, and --device is an alias for --host (both
1543
1727
  // resolve against the same device registry).
1544
1728
  applyAgentShorthands(options);
1545
- if (options.device && options.device.length > 0) {
1546
- options.host = [...(options.host ?? []), ...options.device];
1729
+ // --device / --devices both alias --host. A bare `all` / `fleet` sentinel means
1730
+ // "search every peer" which is already the default — so it resolves to no
1731
+ // explicit host set rather than erroring on a device literally named "all".
1732
+ const deviceTargets = [...(options.device ?? []), ...(options.devices ?? [])]
1733
+ .filter(t => t.toLowerCase() !== 'all' && t.toLowerCase() !== 'fleet');
1734
+ if (deviceTargets.length > 0) {
1735
+ options.host = [...(options.host ?? []), ...deviceTargets];
1547
1736
  }
1548
1737
  // --print-cmd: echo the canonical `ag sessions …` for the given flags and exit.
1549
1738
  // The non-interactive twin of the browser's `y` hotkey — lets an agent compose
@@ -2830,34 +3019,23 @@ export async function resumeSessionInPlace(session) {
2830
3019
  const cwd = session.cwd && fs.existsSync(session.cwd)
2831
3020
  ? session.cwd
2832
3021
  : process.cwd();
2833
- const resume = buildResumeCommand(session);
2834
- if (!resume) {
2835
- console.log(chalk.yellow(`Resume is not supported for ${session.agent} sessions yet. Showing summary instead.`));
2836
- await renderSession(session, 'summary', {});
2837
- return;
2838
- }
3022
+ const resume = buildSessionRecoveryCommand(session);
2839
3023
  console.log(chalk.gray(`Resuming: ${resume.join(' ')} (cwd: ${cwd})`));
2840
- // Resolve the (possibly version-pinned) launcher up front. On Windows the
2841
- // agent shim is a `.cmd`/`.ps1` and, under the shell needed to run it (see
2842
- // spawnResumeCommand), a missing command exits non-zero rather than emitting
2843
- // an ENOENT `error` event — so detect a removed version here instead of
2844
- // relying on that event, keeping the fallback working on every OS.
2845
- // `resume[0]` is an absolute alias path when one exists on disk, so only a bare
2846
- // name still needs a PATH lookup. Checking existsSync first is what keeps an
2847
- // isolated install (shims deliberately off PATH) out of the fallback.
2848
- const launcherFound = path.isAbsolute(resume[0])
2849
- ? fs.existsSync(resume[0])
2850
- : !!findExecutable(resume[0]);
2851
- if (!launcherFound && session.version) {
2852
- const fallback = buildFallbackCommand(session);
2853
- if (fallback) {
2854
- console.log(chalk.gray(`Version ${session.version} is not installed. Resuming with the current version instead...`));
2855
- await spawnResumeCommand(fallback, cwd);
2856
- return;
2857
- }
2858
- }
2859
3024
  await spawnResumeCommand(resume, cwd);
2860
3025
  }
3026
+ /**
3027
+ * Relaunch this CLI's one recovery path. The owning device resolves account
3028
+ * health and either performs exact-home native resume or same-harness
3029
+ * `/continue`; callers never guess which version home can see the transcript.
3030
+ * `portable` is for an SSH/terminal-engine command that executes on a peer.
3031
+ */
3032
+ export function buildSessionRecoveryCommand(session, portable = false) {
3033
+ const args = sessionRecoveryRunArgs(session);
3034
+ if (portable)
3035
+ return ['agents', ...args];
3036
+ const invocation = getAgentsInvocation(args);
3037
+ return [invocation.command, ...invocation.args];
3038
+ }
2861
3039
  /**
2862
3040
  * Map a resume argv to the spawn(command, args, {shell}) triple.
2863
3041
  *
@@ -2925,8 +3103,6 @@ function spawnResumeCommand(cmd, cwd) {
2925
3103
  * isolated HOME where the JSONL was written — regardless of which version is
2926
3104
  * currently the default. Falls back to the bare shim when version is unknown.
2927
3105
  *
2928
- * If the versioned binary is missing (version was removed), the ENOENT
2929
- * handler in handlePickedSession retries via buildFallbackCommand.
2930
3106
  */
2931
3107
  /**
2932
3108
  * The agent's own resume invocation, given whichever launcher we resolved.
@@ -2939,6 +3115,8 @@ function resumeArgv(agent, id, launcher) {
2939
3115
  case 'claude': return [launcher, '--resume', id];
2940
3116
  case 'codex': return [launcher, 'resume', id];
2941
3117
  case 'opencode': return [launcher, '--session', id];
3118
+ // Muse interactive resume is a subcommand: `muse resume <uuid>`.
3119
+ case 'muse': return [launcher, 'resume', id];
2942
3120
  default: return null;
2943
3121
  }
2944
3122
  }
@@ -2967,7 +3145,8 @@ export function buildResumeCommand(session) {
2967
3145
  case 'opencode':
2968
3146
  return resumeArgv('opencode', session.id, 'opencode');
2969
3147
  case 'claude':
2970
- case 'codex': {
3148
+ case 'codex':
3149
+ case 'muse': {
2971
3150
  const cli = AGENTS[session.agent]?.cliCommand ?? session.agent;
2972
3151
  if (session.version) {
2973
3152
  const alias = versionedAliasIfPresent(session.agent, session.version);
@@ -2990,19 +3169,6 @@ export function buildResumeCommand(session) {
2990
3169
  return null;
2991
3170
  }
2992
3171
  }
2993
- /**
2994
- * Fallback when the pinned version really is gone: the same resume invocation
2995
- * against the current version.
2996
- *
2997
- * This used to spawn `<cli> "/continue <id>"`, feeding a slash command into the
2998
- * TUI as a prompt. Neither CLI has `/continue` — codex documents `/resume` — so
2999
- * the agent received an unrecognised command and the session was not resumed at
3000
- * all. Reusing resumeArgv keeps the two paths from drifting apart again.
3001
- */
3002
- function buildFallbackCommand(session) {
3003
- const cli = AGENTS[session.agent]?.cliCommand ?? session.agent;
3004
- return resumeArgv(session.agent, session.id, cli);
3005
- }
3006
3172
  // ---------------------------------------------------------------------------
3007
3173
  // Cloud session source (--cloud)
3008
3174
  // ---------------------------------------------------------------------------
@@ -3073,25 +3239,24 @@ async function runCloudSessions(query, options) {
3073
3239
  // Ensure the SessionMeta points at the local cache path for renderSession.
3074
3240
  await renderSession({ ...meta, filePath: cachedPath }, mode, filterOpts, options);
3075
3241
  }
3242
+ /** Resolve the harness portion of a sessions selector with the CLI's canonical
3243
+ * alias and single-typo rules. Kept pure so focus and the browser cannot drift. */
3244
+ export function resolveSessionAgentName(name) {
3245
+ const normalized = name.toLowerCase();
3246
+ if (SESSION_AGENTS.includes(normalized)) {
3247
+ return normalized;
3248
+ }
3249
+ const resolved = resolveAgentName(normalized);
3250
+ if (resolved && SESSION_AGENTS.includes(resolved)) {
3251
+ return resolved;
3252
+ }
3253
+ return fuzzyMatch(normalized, SESSION_AGENTS, FUZZY_PRESETS.agents);
3254
+ }
3076
3255
  export function parseAgentFilter(agentName) {
3077
3256
  if (!agentName)
3078
3257
  return {};
3079
3258
  const [name, version] = agentName.split('@', 2);
3080
- let agent = SESSION_AGENTS.includes(name)
3081
- ? name
3082
- : null;
3083
- if (!agent) {
3084
- // Aliases and single-typo corrections (cladue -> claude). SESSION_AGENTS
3085
- // includes ids (rush, hermes) that resolveAgentName doesn't know, so fall
3086
- // back to fuzzy-matching the session list directly.
3087
- const resolved = resolveAgentName(name);
3088
- if (resolved && SESSION_AGENTS.includes(resolved)) {
3089
- agent = resolved;
3090
- }
3091
- else {
3092
- agent = fuzzyMatch(name, SESSION_AGENTS, FUZZY_PRESETS.agents);
3093
- }
3094
- }
3259
+ const agent = resolveSessionAgentName(name);
3095
3260
  if (!agent) {
3096
3261
  console.error(chalk.red(`Unknown agent: ${name}. Use: ${SESSION_AGENTS.join(', ')}`));
3097
3262
  process.exit(1);
@@ -3151,13 +3316,31 @@ function ambiguityHint(byId, completeId) {
3151
3316
  : 'That matched on text, not an id. Pass a session id, or narrow the search.';
3152
3317
  }
3153
3318
  /** 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. */
3319
+ * normalized id so a pasted, padded argument doesn't produce an unrunnable hint.
3320
+ * Used only where NO fleet sweep ran (a `--local` lookup, an `--artifacts`
3321
+ * listing, or a peer answering a parent's sweep) — the fleet-swept miss uses
3322
+ * {@link fleetNotFoundMessage}. Never tells the user to pass `--device`: fleet
3323
+ * search is already the default, so a device flag can't find what the id missed. */
3155
3324
  function notFoundByIdMessage(query) {
3325
+ return [chalk.red(`No session with id ${query.trim()} on this machine.`)];
3326
+ }
3327
+ /** Honest result of a fleet sweep that found nothing: it says what the sweep
3328
+ * actually did — how many peers answered, which were unreachable — instead of
3329
+ * dead-ending on "search with --device <host>" after the search already ran. */
3330
+ export function fleetNotFoundMessage(query, deviceCount, unreachable) {
3156
3331
  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
- ];
3332
+ if (deviceCount === 0) {
3333
+ return [
3334
+ chalk.red(`No session with id ${id} on this machine.`),
3335
+ chalk.gray('No other reachable devices to search.'),
3336
+ ];
3337
+ }
3338
+ const searched = `${deviceCount} device${deviceCount === 1 ? '' : 's'}`;
3339
+ const lines = [chalk.red(`No session with id ${id} on this machine or ${searched} searched.`)];
3340
+ if (unreachable.length > 0) {
3341
+ lines.push(chalk.gray(`Unreachable (not searched): ${unreachable.join(', ')}`));
3342
+ }
3343
+ return lines;
3161
3344
  }
3162
3345
  /** Filter and rank sessions by a multi-term search query across metadata and content. */
3163
3346
  export function filterSessionsByQuery(sessions, query) {
@@ -3380,11 +3563,15 @@ async function renderOneSession(query, mode, scope) {
3380
3563
  // local not-found message. No FTS fallback either way.
3381
3564
  if (shouldFanOutForId(query, scope.local)) {
3382
3565
  const outcome = await resolveSessionAcrossFleet(query, mode, scope.hosts);
3383
- if (outcome === 'rendered')
3566
+ if (outcome.kind === 'rendered')
3384
3567
  return;
3385
- if (outcome === 'conflict')
3568
+ if (outcome.kind === 'conflict')
3386
3569
  process.exit(1);
3387
- // 'not-found' falls through to the local message below.
3570
+ // The fleet sweep already ran and found nothing — report what actually
3571
+ // happened, never "search with --device <host>" (fleet search is the
3572
+ // default, so a device flag would only re-run the same miss).
3573
+ fleetNotFoundMessage(query, outcome.deviceCount, outcome.unreachable).forEach(l => console.error(l));
3574
+ process.exit(1);
3388
3575
  }
3389
3576
  notFoundByIdMessage(query).forEach(l => console.error(l));
3390
3577
  process.exit(1);
@@ -3455,16 +3642,41 @@ function modeFlag(mode) {
3455
3642
  return undefined; // summary — the peer's default render
3456
3643
  }
3457
3644
  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;
3645
+ /**
3646
+ * A match unique enough to resolve on the FIRST peer that returns it and cancel
3647
+ * the rest of the fleet sweep: a full session UUID, which is globally unique.
3648
+ *
3649
+ * Only a full UUID qualifies. A session **label** is deliberately NOT definitive
3650
+ * here even on an exact match: labels are free-form and can collide, so a
3651
+ * distinct session may carry the same label on a peer that has not answered yet
3652
+ * — early-exiting on the first would silently, and nondeterministically, resume
3653
+ * the wrong session (whichever peer replied first). Labels therefore stay
3654
+ * all-settle so a cross-machine label conflict surfaces as an ambiguity, and a
3655
+ * short-id PREFIX stays all-settle for the same reason (RUSH-2203).
3656
+ */
3657
+ export function isDefinitiveMatch(session, selector) {
3658
+ return FULL_SESSION_ID_RE.test(selector) && session.id.toLowerCase() === selector.trim().toLowerCase();
3659
+ }
3660
+ /**
3661
+ * Whether a selector may enable early-exit on the cancellable fan-out — only a
3662
+ * full UUID, which is globally unique so the first hit is the only hit. Labels,
3663
+ * keywords, and short-id prefixes stay all-settle: their uniqueness (or
3664
+ * conflict) is only knowable once every peer has answered. See
3665
+ * {@link isDefinitiveMatch}.
3666
+ */
3667
+ export function selectorAllowsEarlyExit(selector) {
3668
+ return FULL_SESSION_ID_RE.test(selector);
3669
+ }
3458
3670
  /** Resolve a fleet sweep through the same canonical full-id / prefix resolver as
3459
3671
  * local lookups, then group copies by logical session id. Synced mirrors of one
3460
3672
  * session therefore stay one candidate even when several machines report them;
3461
3673
  * distinct ids sharing a prefix remain distinct ambiguity candidates. */
3462
- export function fleetCandidatesByQuery(rows, query) {
3674
+ export function fleetCandidatesByQuery(rows, query, trustResolvedRows = false) {
3463
3675
  // An id selector must still be prefix-filtered defensively against older peers
3464
3676
  // returning content mentioners. Keyword rows, however, were already matched by
3465
3677
  // each peer's own FTS index; the parent does not own that transcript/index and
3466
3678
  // must not re-run metadata-only filtering that could discard a content hit.
3467
- const matched = looksLikeSessionId(query)
3679
+ const matched = !trustResolvedRows && looksLikeSessionId(query)
3468
3680
  ? resolveSessionQuery(rows, query, { indexFallback: false }).matches
3469
3681
  : rows;
3470
3682
  const byId = new Map();
@@ -3488,6 +3700,14 @@ export function fleetCandidatesByQuery(rows, query) {
3488
3700
  }
3489
3701
  /** Resolve through the canonical metadata+content union used by keyword search. */
3490
3702
  function resolveIndexedMetadataRows(indexed, selector) {
3703
+ const alias = resolveSessionAlias(selector);
3704
+ if (alias.kind === 'resolved') {
3705
+ return resolveSessionQuery(indexed, alias.sessionId, { indexFallback: false }).matches;
3706
+ }
3707
+ if (alias.kind === 'ambiguous') {
3708
+ const ids = new Set(alias.sessionIds.map(id => id.toLowerCase()));
3709
+ return indexed.filter(session => ids.has(session.id.toLowerCase()));
3710
+ }
3491
3711
  return resolveSessionQuery(indexed, selector, { indexFallback: false }).matches;
3492
3712
  }
3493
3713
  /** Fixed peer argv for the metadata resolver. Scope flags compose identically on
@@ -3513,7 +3733,18 @@ export function toolSearchForwardedArgs(argv, hosts) {
3513
3733
  * A full UUID is globally unique, so one exact hit is sufficient even when an
3514
3734
  * unrelated registered device is offline. */
3515
3735
  export function metadataResolveOutcome(localMatches, remote, selector) {
3516
- const candidates = fleetCandidatesByQuery([...localMatches, ...remote.sessions], selector);
3736
+ // Every peer answered through --resolve-safe-v1, so its native-id row is
3737
+ // already the result of resolving the original selector. Re-filtering by the
3738
+ // selector would discard alias suffixes such as c1f3d813 because the native
3739
+ // UUID intentionally has a different prefix.
3740
+ const candidates = fleetCandidatesByQuery([...localMatches, ...remote.sessions], selector, true);
3741
+ // A full UUID is globally unique, so one exact hit resolves even when an
3742
+ // unrelated registered device is offline. A label is NOT globally unique — a
3743
+ // distinct session may carry the same label on an unreachable peer — so it
3744
+ // stays fail-closed: a unique label auto-resumes only once every peer has
3745
+ // answered (candidates.length === 1 below), and an unreachable peer forces
3746
+ // `partial` rather than guessing. (RUSH-2203: early-exit is UUID-only for the
3747
+ // same reason — see isDefinitiveMatch.)
3517
3748
  if (FULL_SESSION_ID_RE.test(selector) && candidates.length === 1 && candidates[0].id.toLowerCase() === selector.toLowerCase()) {
3518
3749
  return { kind: 'resolved', session: candidates[0].hits[0].session };
3519
3750
  }
@@ -3533,6 +3764,12 @@ export async function resolveSessionMetadataValue(selector, scope = {}, deps = {
3533
3764
  const indexed = includeLocal ? applyScopeFilters(querySessions(), scope) : [];
3534
3765
  const localMatches = resolveIndexedMetadataRows(indexed, selector)
3535
3766
  .map(session => ({ ...session, machine: session.machine || localMachine }));
3767
+ // A full-UUID local hit resolves with ZERO SSH: a UUID is globally unique, so
3768
+ // finding it locally is the whole answer and no peer is dialed. (A label is
3769
+ // NOT resolved local-only here — it can collide with a same-label session on a
3770
+ // peer, so it must consult the fleet; see isDefinitiveMatch, RUSH-2203.) The
3771
+ // local index lookup is synchronous and completes before any fan-out spawns,
3772
+ // so this local hit never waits on a peer.
3536
3773
  if (FULL_SESSION_ID_RE.test(selector)) {
3537
3774
  const localOutcome = metadataResolveOutcome(localMatches, { sessions: [], unreachable: [] }, selector);
3538
3775
  if (localOutcome.kind === 'resolved')
@@ -3542,7 +3779,13 @@ export async function resolveSessionMetadataValue(selector, scope = {}, deps = {
3542
3779
  return metadataResolveOutcome(localMatches, { sessions: [], unreachable: [] }, selector);
3543
3780
  try {
3544
3781
  const forwarded = metadataResolveForwardedArgs(selector, scope);
3545
- const remote = await deps.gatherRemoteList(forwarded, scope.hosts);
3782
+ // Definitive lookups (full UUID / exact label) opt into early-exit: the
3783
+ // first peer holding the match resolves the sweep and SIGTERMs the rest, so
3784
+ // a fast peer's hit is not bounded by the slowest/unreachable peer. Short-id
3785
+ // prefixes stay all-settle (ambiguity is only known once every peer answers).
3786
+ const remote = await deps.gatherRemoteList(forwarded, scope.hosts, selectorAllowsEarlyExit(selector)
3787
+ ? { isDefinitive: (session) => isDefinitiveMatch(session, selector) }
3788
+ : undefined);
3546
3789
  return metadataResolveOutcome(localMatches, remote, selector);
3547
3790
  }
3548
3791
  catch (error) {
@@ -3586,26 +3829,11 @@ async function resolveSessionMetadata(selector, scope, deps = { gatherRemoteList
3586
3829
  }
3587
3830
  process.stdout.write(serializeResolvedSessionsJson([outcome.session]));
3588
3831
  }
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
3832
  export async function resolveSessionAcrossFleet(query, mode, hosts, deps = { gatherRemoteList, runOnPeer }) {
3607
3833
  const spinner = isInteractiveTerminal() ? ora('Searching the fleet...').start() : null;
3608
- let candidates;
3834
+ let candidates = [];
3835
+ let deviceCount = 0;
3836
+ let unreachable = [];
3609
3837
  try {
3610
3838
  // Force whole-index scope (--all): the peer runs in its SSH-login home dir,
3611
3839
  // whose cwd would otherwise silently narrow the lookup and hide the row.
@@ -3613,19 +3841,23 @@ export async function resolveSessionAcrossFleet(query, mode, hosts, deps = { gat
3613
3841
  // itself and never re-fans-out (belt-and-suspenders with the parent's
3614
3842
  // AGENTS_SESSIONS_LOCAL, which remote-list also sets on the peer).
3615
3843
  const forwarded = ['sessions', query, '--json', '--all', '--local'];
3616
- const { sessions } = await deps.gatherRemoteList(forwarded, hosts);
3617
- candidates = fleetCandidatesByQuery(sessions, query);
3844
+ const remote = await deps.gatherRemoteList(forwarded, hosts, selectorAllowsEarlyExit(query)
3845
+ ? { isDefinitive: (session) => isDefinitiveMatch(session, query) }
3846
+ : undefined);
3847
+ candidates = fleetCandidatesByQuery(remote.sessions, query);
3848
+ deviceCount = remote.deviceCount;
3849
+ unreachable = remote.unreachable;
3618
3850
  }
3619
3851
  catch {
3620
3852
  // 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.
3853
+ // caller prints the honest message rather than a half-answer.
3622
3854
  candidates = [];
3623
3855
  }
3624
3856
  finally {
3625
3857
  spinner?.stop();
3626
3858
  }
3627
3859
  if (candidates.length === 0)
3628
- return 'not-found';
3860
+ return { kind: 'not-found', deviceCount, unreachable };
3629
3861
  if (candidates.length > 1) {
3630
3862
  console.error(chalk.red(`Multiple sessions match "${query}" across the fleet:`));
3631
3863
  for (const candidate of candidates) {
@@ -3635,7 +3867,7 @@ export async function resolveSessionAcrossFleet(query, mode, hosts, deps = { gat
3635
3867
  console.error(chalk.cyan(` ${s.shortId} ${s.id}`) + chalk.gray(` ${machines} ${s.agent}${s.version ? ` ${s.version}` : ''} ${label}`));
3636
3868
  }
3637
3869
  console.error(chalk.gray('Pass a longer ID to narrow it down.'));
3638
- return 'conflict';
3870
+ return { kind: 'conflict' };
3639
3871
  }
3640
3872
  const candidate = candidates[0];
3641
3873
  const { machine } = candidate.hits[0];
@@ -3651,9 +3883,9 @@ export async function resolveSessionAcrossFleet(query, mode, hosts, deps = { gat
3651
3883
  if (result === 'no-target') {
3652
3884
  console.error(chalk.red(`Session ${candidate.id} is on ${machine}, but it is not a reachable registered device.`));
3653
3885
  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
3886
+ return { kind: 'conflict' }; // a definitive answer (found, un-renderable) — do NOT fall to the local not-found line
3655
3887
  }
3656
- return 'rendered';
3888
+ return { kind: 'rendered' };
3657
3889
  }
3658
3890
  /** Register the `agents sessions` command with all its options and help text. */
3659
3891
  export function registerSessionsCommands(program) {
@@ -3673,7 +3905,7 @@ export function registerSessionsCommands(program) {
3673
3905
  .option('--opencode', 'Shorthand for --agent opencode')
3674
3906
  .option('--all', 'Widen every non-status filter to "all": every directory (not just this project) and all time (no window cap). Status filters like --active still compose; -a/--device/--since still narrow their axis.')
3675
3907
  .option('--unmanaged', "Also show sessions from your own ~/.<agent> installs (hidden once agents-cli manages that agent)")
3676
- .option('--teams', 'Include team-spawned sessions (hidden by default)')
3908
+ .option('--team, --teams', 'Include team-spawned sessions (hidden by default)')
3677
3909
  .option('--in-team <name>', "Only this team: the session that spawned it plus (with --teams) its teammates. Spans every directory and all time, since a team's worktrees and history sit outside the default window.")
3678
3910
  .option('--routine', 'Show only sessions archived from routine runs')
3679
3911
  .option('-p, --project <name>', 'Filter by project name (searches across all directories)')
@@ -3711,7 +3943,8 @@ export function registerSessionsCommands(program) {
3711
3943
  .option('--no-live', 'Do not enrich the listing with live status/preview for running sessions')
3712
3944
  .option('--cloud', 'Source sessions from Rush Cloud (captured runs) instead of local disk')
3713
3945
  .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)')
3946
+ .option('--device <target...>', 'Alias for --host (device alias from `agents devices`; repeatable). `--device all` searches the whole fleet (the default).')
3947
+ .addOption(new Option('--devices <target...>', 'Plural alias for --device (accepts `all`/`fleet`).').hideHelp())
3715
3948
  .option('--fleet', 'With --include tools: query every registered online compute device and merge compact matches')
3716
3949
  .option('--count', 'With one program:<name> tool query: count static occurrences, containing calls, and sessions')
3717
3950
  .option('--browser', 'List browser-profile captures (screenshots, PDFs, recordings, downloads) instead of agent transcripts — alias of `agents browser sessions`')
@@ -3739,7 +3972,7 @@ export function registerSessionsCommands(program) {
3739
3972
  agents sessions --crashed
3740
3973
 
3741
3974
  # --- Session lifecycle (one verb per intent) ---
3742
- # Jump to a live session (attach its terminal, or open a tab + resume)
3975
+ # Focus a session (attach its living terminal, or recover an ended one)
3743
3976
  agents sessions focus a1b2c3d4
3744
3977
  # Attach only — never fork a copy (old: sessions go)
3745
3978
  agents sessions focus a1b2c3d4 --attach-only
@@ -3791,7 +4024,7 @@ export function registerSessionsCommands(program) {
3791
4024
  `,
3792
4025
  notes: `
3793
4026
  Session lifecycle (pick one verb — they are not synonyms):
3794
- focus [id] jump to a live session (attach, or open tab + resume)
4027
+ focus [selector] attach a living pane, or recover on the origin device
3795
4028
  focus [id] --attach-only attach only; never fork (replaces sessions go)
3796
4029
  detach <id> interactive → headless continuation
3797
4030
  attach <id> headless → interactive in this terminal
@@ -3823,6 +4056,7 @@ export function registerSessionsCommands(program) {
3823
4056
  });
3824
4057
  registerSessionsTailCommand(sessionsCmd);
3825
4058
  registerSessionsResumeCommand(sessionsCmd);
4059
+ registerSessionsForkCommand(sessionsCmd);
3826
4060
  registerSessionsFavoriteCommand(sessionsCmd);
3827
4061
  registerGoCommand(sessionsCmd);
3828
4062
  registerFocusCommand(sessionsCmd);