@phnx-labs/agents-cli 1.20.77 → 1.20.78

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 (70) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +2 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.d.ts +9 -0
  5. package/dist/commands/doctor.js +143 -9
  6. package/dist/commands/exec.js +29 -3
  7. package/dist/commands/repo.js +8 -0
  8. package/dist/commands/routines.js +4 -2
  9. package/dist/commands/secrets.js +8 -2
  10. package/dist/commands/sessions-browser.d.ts +45 -3
  11. package/dist/commands/sessions-browser.js +118 -12
  12. package/dist/commands/sessions-export.d.ts +3 -0
  13. package/dist/commands/sessions-export.js +13 -10
  14. package/dist/commands/sessions-picker.d.ts +15 -1
  15. package/dist/commands/sessions-picker.js +58 -4
  16. package/dist/commands/sessions.d.ts +95 -1
  17. package/dist/commands/sessions.js +224 -26
  18. package/dist/commands/ssh.js +9 -1
  19. package/dist/index.js +3 -5
  20. package/dist/lib/agents.d.ts +0 -21
  21. package/dist/lib/agents.js +0 -37
  22. package/dist/lib/auto-pull.d.ts +16 -8
  23. package/dist/lib/auto-pull.js +23 -28
  24. package/dist/lib/daemon.d.ts +9 -41
  25. package/dist/lib/daemon.js +16 -117
  26. package/dist/lib/devices/fleet-divergence.d.ts +101 -0
  27. package/dist/lib/devices/fleet-divergence.js +188 -0
  28. package/dist/lib/devices/fleet-inventory.d.ts +19 -0
  29. package/dist/lib/devices/fleet-inventory.js +57 -0
  30. package/dist/lib/devices/health-report.d.ts +10 -2
  31. package/dist/lib/devices/health-report.js +32 -1
  32. package/dist/lib/git.d.ts +13 -0
  33. package/dist/lib/git.js +102 -4
  34. package/dist/lib/hosts/remote-cmd.js +2 -0
  35. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
  36. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  37. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  38. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  39. package/dist/lib/runner.js +29 -26
  40. package/dist/lib/sandbox.js +0 -16
  41. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  42. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  43. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  44. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  45. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  46. package/dist/lib/secrets/agent.d.ts +2 -0
  47. package/dist/lib/secrets/agent.js +26 -14
  48. package/dist/lib/secrets/bundles.d.ts +1 -1
  49. package/dist/lib/secrets/bundles.js +16 -8
  50. package/dist/lib/secrets/scope.d.ts +26 -0
  51. package/dist/lib/secrets/scope.js +29 -0
  52. package/dist/lib/secrets/session-store.d.ts +10 -0
  53. package/dist/lib/secrets/session-store.js +64 -11
  54. package/dist/lib/session/active.d.ts +7 -0
  55. package/dist/lib/session/active.js +38 -3
  56. package/dist/lib/session/db.d.ts +37 -0
  57. package/dist/lib/session/db.js +112 -4
  58. package/dist/lib/session/digest.js +126 -21
  59. package/dist/lib/session/discover.d.ts +15 -2
  60. package/dist/lib/session/discover.js +19 -11
  61. package/dist/lib/session/origin-machine.d.ts +18 -0
  62. package/dist/lib/session/origin-machine.js +34 -0
  63. package/dist/lib/session/state.d.ts +1 -0
  64. package/dist/lib/session/state.js +1 -1
  65. package/dist/lib/session/sync/config.js +2 -2
  66. package/dist/lib/smart-launch.d.ts +86 -0
  67. package/dist/lib/smart-launch.js +172 -0
  68. package/package.json +1 -1
  69. package/dist/lib/secrets/account-token.d.ts +0 -20
  70. package/dist/lib/secrets/account-token.js +0 -64
@@ -26,7 +26,7 @@ import { gatherRemoteActive, NO_FANOUT_ENV } from '../lib/session/remote-active.
26
26
  import { gatherRemoteList, runOnPeer } from '../lib/session/remote-list.js';
27
27
  import { stringWidth, truncateToWidth, padToWidth, terminalWidth } from '../lib/session/width.js';
28
28
  import { inferSessionState } from '../lib/session/state.js';
29
- import { discoverSessions, countSessionsInScope, resolveSessionById, isCompleteSessionId, searchContentIndex, getSessionRoots } from '../lib/session/discover.js';
29
+ import { discoverSessions, countSessionsInScope, resolveSessionById, isCompleteSessionId, looksLikeSessionId, searchContentIndex, getSessionRoots } from '../lib/session/discover.js';
30
30
  import { findSessionsById } from '../lib/session/db.js';
31
31
  import { filterTeamSessions } from '../lib/session/team-filter.js';
32
32
  import { parseSession } from '../lib/session/parse.js';
@@ -813,15 +813,18 @@ export function remoteHostsToDial(hosts, self) {
813
813
  return hosts.filter(h => hostToken(h) !== self);
814
814
  }
815
815
  /**
816
- * Render the unified active-session view, grouped by machine. With no `--host`,
817
- * local sessions come from `getActiveSessions()` and (unless `--local`) the
818
- * registered online devices from `ag devices` are folded in over SSH. An
819
- * explicit `--host`/`--device` list SCOPES the view to exactly those machines —
820
- * the local machine is included only when it is itself named — so `--host` is a
821
- * filter, not an addition (matching the non-`--active` listing path). A tip is
822
- * shown when there are no other machines to include.
816
+ * The fleet-wide live-session set behind every `--active` surface. Local sessions
817
+ * come from `getActiveSessions()` and (unless `--local`) the registered online
818
+ * devices from `ag devices` are folded in over SSH. An explicit `--host`/`--device`
819
+ * list SCOPES the sweep to exactly those machines — the local machine is included
820
+ * only when it is itself named — so `--host` is a filter, not an addition.
821
+ *
822
+ * This is the single gather: the static renderer AND the interactive browser both
823
+ * call it, so the browser can never disagree with `--active --json` about which
824
+ * sessions are live (it used to call the local-only `getActiveSessions()` directly
825
+ * and silently hid every remote session).
823
826
  */
824
- async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
827
+ export async function gatherActiveSessions(opts = {}) {
825
828
  const self = machineId();
826
829
  // An explicit --host/--device list scopes the view: seed local sessions only
827
830
  // when no hosts are named, or when this machine is one of the named targets.
@@ -841,6 +844,17 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
841
844
  merged = dedupeByMachineSession([...local, ...remote.sessions]);
842
845
  }
843
846
  }
847
+ return { sessions: merged, remoteDeviceCount };
848
+ }
849
+ /**
850
+ * Render the unified active-session view, grouped by machine. Scoping and the
851
+ * fleet sweep live in {@link gatherActiveSessions}; this owns the presentation
852
+ * (the `--waiting` gate, JSON, and the grouped table). A tip is shown when there
853
+ * are no other machines to include.
854
+ */
855
+ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
856
+ const self = machineId();
857
+ const { sessions: merged, remoteDeviceCount } = await gatherActiveSessions(opts);
844
858
  // --waiting: only sessions blocked on the user. Exits non-zero when any are
845
859
  // present so a supervising agent or hook can poll it as a gate.
846
860
  const sessions = waitingOnly ? merged.filter(s => s.status === 'input_required') : merged;
@@ -1023,6 +1037,7 @@ async function sessionsAction(query, options) {
1023
1037
  agent: options.agent,
1024
1038
  host: options.host,
1025
1039
  since: options.since,
1040
+ all: options.all,
1026
1041
  }), { local: options.local === true, hosts: options.host });
1027
1042
  return;
1028
1043
  }
@@ -1104,7 +1119,7 @@ async function sessionsAction(query, options) {
1104
1119
  // When the user explicitly asks to render (via mode flag), resolve the
1105
1120
  // query globally so sessions outside the default cwd/30d window are found.
1106
1121
  if (wantsRender && searchQuery) {
1107
- await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, redact: options.redact });
1122
+ await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, redact: options.redact, local: options.local, hosts: options.host });
1108
1123
  return;
1109
1124
  }
1110
1125
  // Interactive picker loads a deep pool but shows only recent sessions
@@ -1171,19 +1186,34 @@ async function sessionsAction(query, options) {
1171
1186
  // Smart ID routing: a bare query that resolves to one session renders
1172
1187
  // directly. If nothing matches in the scoped window and the query looks
1173
1188
  // like a session ID, widen to global scope (incl. Claude /resume history).
1174
- if (searchQuery) {
1189
+ //
1190
+ // Exception: a PEER answering a parent's `--json` locate sweep
1191
+ // (AGENTS_SESSIONS_LOCAL=1) must return the SessionMeta[] ROW for the id, not
1192
+ // render its transcript — the sweep parses an array (parseRemoteList). So when
1193
+ // we are that peer and --json is set, skip the single-session short-circuit and
1194
+ // fall through to the array-emitting --json block below.
1195
+ const answeringJsonSweep = options.json === true && process.env[NO_FANOUT_ENV] === '1';
1196
+ if (searchQuery && !answeringJsonSweep) {
1175
1197
  const idMatches = resolveSessionById(sessions, searchQuery);
1176
1198
  if (idMatches.length === 1) {
1177
1199
  await renderSession(idMatches[0], mode, filterOpts, options);
1178
1200
  return;
1179
1201
  }
1180
1202
  if (idMatches.length === 0 && looksLikeSessionId(searchQuery)) {
1181
- await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, redact: options.redact });
1203
+ await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, redact: options.redact, local: options.local, hosts: options.host });
1182
1204
  return;
1183
1205
  }
1184
1206
  }
1185
1207
  if (options.json) {
1186
- const filtered = searchQuery ? filterSessionsByQuery(sessions, searchQuery) : sessions;
1208
+ // An id-shaped query resolves by id ONLY — the same rule the render path
1209
+ // uses (resolveSessionQuery). Without this a `--json <uuid>` (as issued by
1210
+ // the fleet locate sweep, which runs `sessions <uuid> --json --local` on
1211
+ // each peer) would fall to FTS content search and return every transcript
1212
+ // that merely MENTIONS the id, defeating exact remote resolution. A genuine
1213
+ // search phrase keeps the ranked metadata+content path.
1214
+ const filtered = searchQuery
1215
+ ? resolveSessionQuery(sessions, searchQuery).matches
1216
+ : sessions;
1187
1217
  process.stdout.write(serializeSessionsJson(filtered));
1188
1218
  return;
1189
1219
  }
@@ -1271,15 +1301,6 @@ async function sessionsAction(query, options) {
1271
1301
  process.exit(1);
1272
1302
  }
1273
1303
  }
1274
- /** Whether a query should route to the single-session render rather than the
1275
- * listing. The hex-ish test catches a bare id prefix; `isCompleteSessionId`
1276
- * additionally catches the prefixed whole ids (`session_…`, `ses_…`) that the
1277
- * hex test rejects — without it those never reach the id-only resolution and
1278
- * still content-search. */
1279
- function looksLikeSessionId(query) {
1280
- const trimmed = query.trim();
1281
- return /^[0-9a-f-]{6,}$/i.test(trimmed) || isCompleteSessionId(trimmed);
1282
- }
1283
1304
  function teamTag(session) {
1284
1305
  const origin = session.teamOrigin;
1285
1306
  if (!origin)
@@ -1725,7 +1746,23 @@ export function pickerColumnsFor(sessions) {
1725
1746
  showTicket: sessions.some((s) => ticketLabel(s) !== ''),
1726
1747
  };
1727
1748
  }
1728
- export function formatPickerLabel(s, query, cols = {}, ssh) {
1749
+ /** Width of the host-program column (`tmux→ghostty` is the long realistic case). */
1750
+ const PICKER_HOST_W = 14;
1751
+ /**
1752
+ * Which program a live session is running in, for the picker's host column: the
1753
+ * immediate host app (`codium`, `ghostty`, `tmux`, `iterm`, …) and — when a tmux
1754
+ * session is currently being watched through a different app — the app it is
1755
+ * viewed in, as `tmux→ghostty`. A tmux session with no attached client stays a
1756
+ * bare `tmux`, which is exactly "running detached". Empty when the session has no
1757
+ * resolvable host (cloud rows, an unreadable process env).
1758
+ */
1759
+ export function liveHostLabel(a) {
1760
+ if (!a?.host)
1761
+ return '';
1762
+ const viewer = a.viewingIn?.app;
1763
+ return viewer && viewer !== a.host ? `${a.host}→${viewer}` : a.host;
1764
+ }
1765
+ export function formatPickerLabel(s, query, cols = {}, ssh, host = '') {
1729
1766
  const agentColor = colorAgent(s.agent);
1730
1767
  const when = formatRelativeTime(s.lastActivity ?? s.timestamp);
1731
1768
  const project = s.project || '-';
@@ -1750,18 +1787,29 @@ export function formatPickerLabel(s, query, cols = {}, ssh) {
1750
1787
  const ticketCell = cols.showTicket
1751
1788
  ? chalk.blue(padRight(truncate(ticketLabel(s) || '-', TICKET_W), TICKET_W + 1))
1752
1789
  : '';
1790
+ // Which terminal/editor the session runs in — a tab in Ghostty vs a VS Code
1791
+ // panel vs a detached tmux pane is the thing you need to know to go find it.
1792
+ const hostCell = cols.showHost
1793
+ ? chalk.gray(padRight(truncate(host || '-', PICKER_HOST_W - 1), PICKER_HOST_W))
1794
+ : '';
1753
1795
  // The picker prepends a gutter (cursor, plus a checkbox in multi-select mode);
1754
1796
  // reserve it, plus the conditional columns, so the topic shrinks to fit and
1755
1797
  // rows never wrap.
1756
1798
  const gutter = cols.gutter ?? 2;
1757
1799
  const machineColW = cols.showMachine ? machineW : 0;
1758
1800
  const ticketW = cols.showTicket ? TICKET_W + 1 : 0;
1801
+ const hostW = cols.showHost ? PICKER_HOST_W : 0;
1759
1802
  const wtW = wt ? stringWidth(wt) + 1 : 0;
1760
- const topicW = Math.max(16, terminalWidth() - gutter - (10 + 9 + 8 + 16) - machineColW - ticketW - wtW - sshW - stringWidth(when) - 1);
1761
- return (chalk.white(padRight(s.shortId, 10)) +
1803
+ const topicW = Math.max(16, terminalWidth() - gutter - (10 + 9 + 8 + 16) - machineColW - hostW - ticketW - wtW - sshW - stringWidth(when) - 1);
1804
+ return (
1805
+ // Truncated, not just padded: an indexed shortId is always 8 chars, but a
1806
+ // live row with no session id is named by its pid or cloud task, which can
1807
+ // run past the column and shunt every later column out of alignment.
1808
+ chalk.white(padRight(truncate(s.shortId, 9), 10)) +
1762
1809
  agentColor(padRight(truncate(s.agent, 8), 9)) +
1763
1810
  chalk.yellow(padRight(truncate(versionStr, 7), 8)) +
1764
1811
  machineCell +
1812
+ hostCell +
1765
1813
  chalk.cyan(padRight(truncate(project, 14), 16)) +
1766
1814
  sshSeg +
1767
1815
  renderTopicCell(label, topic, query, topicW, topicW) +
@@ -1827,7 +1875,25 @@ function warnNoPeerTarget(machine, session) {
1827
1875
  console.log(chalk.yellow(`Session ${session.shortId} lives on ${machine}, which isn't a reachable device right now.`));
1828
1876
  console.log(chalk.gray(`Register/wake it (ag devices), or run there: agents ssh ${machine}`));
1829
1877
  }
1878
+ /**
1879
+ * Row-id prefix for a live session whose agent has not reported a session id yet
1880
+ * (a booting harness, a queued teammate). The browser lists these so nothing live
1881
+ * is hidden, but they address no transcript — {@link isIdlessLiveRow} is what the
1882
+ * pick handler checks before trying to read or resume one.
1883
+ */
1884
+ export const LIVE_ROW_PREFIX = 'live:';
1885
+ export function isIdlessLiveRow(s) {
1886
+ return s.id.startsWith(LIVE_ROW_PREFIX);
1887
+ }
1830
1888
  export async function handlePickedSession(picked) {
1889
+ // A live row with no session id has no transcript to open and no id to resume
1890
+ // by; say where the process is instead of dead-ending on an unreadable path.
1891
+ if (isIdlessLiveRow(picked.session)) {
1892
+ const where = picked.session.machine ? ` on ${picked.session.machine}` : '';
1893
+ console.log(chalk.yellow(`This session hasn't reported a session id yet — nothing to open${where}.`));
1894
+ console.log(chalk.gray(`Watch for it with: agents sessions --active${picked.session.machine ? ` --host ${picked.session.machine}` : ''}`));
1895
+ return;
1896
+ }
1831
1897
  // A session on another machine is read/resumed ON that machine over SSH — its
1832
1898
  // transcript and agent binary live there. Both actions execute on the peer
1833
1899
  // (not a local `--host` hop, which would discover locally and dead-end for a
@@ -2403,6 +2469,21 @@ async function renderOneSession(query, mode, scope) {
2403
2469
  }
2404
2470
  }
2405
2471
  else if (byId) {
2472
+ // Not on this machine. A UUID names ONE session, so before giving up ask
2473
+ // the fleet — the session may live only on a peer (the whole point of
2474
+ // RUSH-2024). Skip the sweep when the caller pinned --local, or when we
2475
+ // are ourselves a peer answering a parent's sweep (AGENTS_SESSIONS_LOCAL),
2476
+ // so a locate never recurses. On a single remote hit we hand rendering to
2477
+ // that peer; a multi-host hit surfaces the conflict; a miss keeps the
2478
+ // local not-found message. No FTS fallback either way.
2479
+ if (shouldFanOutForId(query, scope.local)) {
2480
+ const outcome = await resolveSessionAcrossFleet(query, mode, scope.hosts);
2481
+ if (outcome === 'rendered')
2482
+ return;
2483
+ if (outcome === 'conflict')
2484
+ process.exit(1);
2485
+ // 'not-found' falls through to the local message below.
2486
+ }
2406
2487
  notFoundByIdMessage(query).forEach(l => console.error(l));
2407
2488
  process.exit(1);
2408
2489
  }
@@ -2441,6 +2522,123 @@ async function renderOneSession(query, mode, scope) {
2441
2522
  process.exit(1);
2442
2523
  }
2443
2524
  }
2525
+ /**
2526
+ * Whether a missed local id lookup should widen to a cross-machine sweep.
2527
+ *
2528
+ * Gate (all must hold):
2529
+ * - the query is id-shaped (`looksLikeSessionId`) — only an identifier resolves
2530
+ * across the fleet; a search phrase never does.
2531
+ * - not `--local` — the caller opted out of cross-machine lookup (deterministic
2532
+ * local behavior for scripts, RUSH-2024 acceptance: "--local still restricts").
2533
+ * - `AGENTS_SESSIONS_LOCAL` is unset — we are not ourselves a peer answering a
2534
+ * parent's sweep, so a locate can never recurse (RUSH-2024: avoid double-fan-out).
2535
+ *
2536
+ * Pure + exported so the gate is unit-tested without driving discovery / SSH.
2537
+ */
2538
+ export function shouldFanOutForId(query, local) {
2539
+ if (local === true)
2540
+ return false;
2541
+ if (process.env[NO_FANOUT_ENV] === '1')
2542
+ return false;
2543
+ return looksLikeSessionId(query);
2544
+ }
2545
+ /** The render-mode flag a peer's `agents sessions <id>` must carry so the remote
2546
+ * summary matches the mode the user asked for. `summary` is the peer's default,
2547
+ * so it needs no flag; the others map 1:1 to a CLI flag. */
2548
+ function modeFlag(mode) {
2549
+ if (mode === 'markdown')
2550
+ return '--markdown';
2551
+ if (mode === 'json')
2552
+ return '--json';
2553
+ return undefined; // summary — the peer's default render
2554
+ }
2555
+ /** Group a fleet sweep's rows to the DISTINCT machines that hold the id. Each
2556
+ * peer answered `sessions <id> --json --local`, which (post-fix) id-resolves and
2557
+ * so returns the matching row(s); a peer with a synced MIRROR of the same id can
2558
+ * emit more than one row, so we keep the first per machine. Rows the peer somehow
2559
+ * returned that do NOT match the id (defensive against version skew) are dropped
2560
+ * so a stray content hit can never masquerade as an exact resolution. */
2561
+ export function fleetHitsById(rows, id) {
2562
+ const q = id.trim().toLowerCase();
2563
+ const byMachine = new Map();
2564
+ for (const s of rows) {
2565
+ const machine = s.machine;
2566
+ if (!machine)
2567
+ continue; // an untagged row can't be routed back to a peer
2568
+ if (s.id?.toLowerCase() !== q)
2569
+ continue; // exact id only — never a content hit
2570
+ if (!byMachine.has(machine))
2571
+ byMachine.set(machine, s);
2572
+ }
2573
+ return Array.from(byMachine.entries()).map(([machine, session]) => ({ machine, session }));
2574
+ }
2575
+ /**
2576
+ * Locate a full session id across the online fleet and render it from the machine
2577
+ * that holds it. The local disk already missed; this fans `sessions <id> --json
2578
+ * --all` out to every registered online peer (or the explicit `hosts` set),
2579
+ * groups the rows to distinct machines, then:
2580
+ *
2581
+ * - exactly one machine → delegate rendering to that peer via `runOnPeer`
2582
+ * (its transcript and agent binary live there — a local `--host` hop would
2583
+ * re-discover locally and dead-end), returning `'rendered'`.
2584
+ * - more than one machine → print the conflict with machine labels so the user
2585
+ * can disambiguate with `--device <host>`, returning `'conflict'`.
2586
+ * - none → `'not-found'`, letting the caller print the local
2587
+ * "no session on this machine" message.
2588
+ *
2589
+ * No fuzzy/content fallback: the sweep forwards a UUID, each peer id-resolves it,
2590
+ * and `fleetHitsById` drops anything that isn't an exact id match.
2591
+ */
2592
+ export async function resolveSessionAcrossFleet(id, mode, hosts, deps = { gatherRemoteList, runOnPeer }) {
2593
+ const spinner = isInteractiveTerminal() ? ora('Searching the fleet...').start() : null;
2594
+ let hits;
2595
+ try {
2596
+ // Force whole-index scope (--all): the peer runs in its SSH-login home dir,
2597
+ // whose cwd would otherwise silently narrow the lookup and hide the row.
2598
+ // --json so each peer answers a parseable array; --local so it answers for
2599
+ // itself and never re-fans-out (belt-and-suspenders with the parent's
2600
+ // AGENTS_SESSIONS_LOCAL, which remote-list also sets on the peer).
2601
+ const forwarded = ['sessions', id, '--json', '--all', '--local'];
2602
+ const { sessions } = await deps.gatherRemoteList(forwarded, hosts);
2603
+ hits = fleetHitsById(sessions, id);
2604
+ }
2605
+ catch {
2606
+ // A fan-out failure is not an exact resolution — treat as not-found so the
2607
+ // caller prints the honest local message rather than a half-answer.
2608
+ hits = [];
2609
+ }
2610
+ finally {
2611
+ spinner?.stop();
2612
+ }
2613
+ if (hits.length === 0)
2614
+ return 'not-found';
2615
+ if (hits.length > 1) {
2616
+ console.error(chalk.red(`Session ${id} exists on multiple machines:`));
2617
+ for (const h of hits) {
2618
+ const s = h.session;
2619
+ const label = s.label ?? s.topic ?? '';
2620
+ console.error(chalk.cyan(` ${h.machine}`) + chalk.gray(` ${s.agent}${s.version ? ` ${s.version}` : ''} ${label}`));
2621
+ }
2622
+ console.error(chalk.gray(`Pick one with: agents sessions ${id} --device <host>`));
2623
+ return 'conflict';
2624
+ }
2625
+ const { machine } = hits[0];
2626
+ // Render the remote summary by re-running `sessions <id>` ON the peer. --local
2627
+ // keeps that render on the peer (it owns the transcript); the mode flag matches
2628
+ // the mode the user asked for. No TTY: a summary/markdown/json render is a
2629
+ // one-shot capture, not an interactive resume.
2630
+ const peerArgs = ['sessions', id, '--local'];
2631
+ const flag = modeFlag(mode);
2632
+ if (flag)
2633
+ peerArgs.push(flag);
2634
+ const result = await deps.runOnPeer(peerArgs, machine);
2635
+ if (result === 'no-target') {
2636
+ console.error(chalk.red(`Session ${id} is on ${machine}, but it is not a reachable registered device.`));
2637
+ console.error(chalk.gray('Register it with `agents devices` or run the command on that machine.'));
2638
+ return 'conflict'; // a definitive answer (found, un-renderable) — do NOT fall to the local not-found line
2639
+ }
2640
+ return 'rendered';
2641
+ }
2444
2642
  /** Register the `agents sessions` command with all its options and help text. */
2445
2643
  export function registerSessionsCommands(program) {
2446
2644
  const sessionsCmd = program
@@ -2455,7 +2653,7 @@ export function registerSessionsCommands(program) {
2455
2653
  .option('--antigravity', 'Shorthand for --agent antigravity')
2456
2654
  .option('--grok', 'Shorthand for --agent grok')
2457
2655
  .option('--opencode', 'Shorthand for --agent opencode')
2458
- .option('--all', 'Include sessions from every directory (not just current project)')
2656
+ .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.')
2459
2657
  .option('--unmanaged', "Also show sessions from your own ~/.<agent> installs (hidden once agents-cli manages that agent)")
2460
2658
  .option('--teams', 'Include team-spawned sessions (hidden by default)')
2461
2659
  .option('--routine', 'Show only sessions archived from routine runs')
@@ -37,6 +37,7 @@ import { fanOutDevices, fleetHealthSkip, planFleetTargets, remoteFleetTargets, r
37
37
  import { fleetCapacity, fmtBytes, headroom, } from '../lib/devices/health.js';
38
38
  import { buildFleetHealthReport, renderFleetMatrix, renderFleetSummary, renderFleetWarnings, } from '../lib/devices/health-report.js';
39
39
  import { loadFleetStats, readStatsCache } from '../lib/devices/stats-cache.js';
40
+ import { collectLocalFleetInventory } from '../lib/devices/fleet-inventory.js';
40
41
  import { checkSyncStatus, countOrphans } from '../lib/drift.js';
41
42
  import { checkAllClis } from '../lib/teams/agents.js';
42
43
  import { buildRemoteAgentsInvocation } from '../lib/hosts/remote-cmd.js';
@@ -321,6 +322,9 @@ function localHealthRow(self, stats) {
321
322
  clis: checkAllClis(),
322
323
  sync: checkSyncStatus(process.cwd()),
323
324
  orphans: countOrphans(),
325
+ // Local baseline inventory for cross-device divergence (RUSH-2027) — the
326
+ // yardstick every remote box is compared against.
327
+ inventory: collectLocalFleetInventory(process.cwd()),
324
328
  };
325
329
  }
326
330
  async function probeRemoteHealth(target) {
@@ -344,6 +348,10 @@ async function probeRemoteHealth(target) {
344
348
  // so the Auth column is current without a prior fleet-wide `fleet ping`.
345
349
  // Older remotes that don't emit it fall back to this host's cache below.
346
350
  auth: parsed.auth,
351
+ // Harness inventory (resources / agent versions / repo state) for
352
+ // cross-device divergence detection (RUSH-2027). Undefined on an older CLI
353
+ // that doesn't emit the `fleet` field — the comparator skips it.
354
+ inventory: parsed.fleet,
347
355
  };
348
356
  }
349
357
  async function runFleetStatus(opts) {
@@ -416,7 +424,7 @@ async function runFleetStatus(opts) {
416
424
  row.lastSeen = profile.tailscale?.lastSeen ?? profile.reachability?.checkedAt;
417
425
  }
418
426
  }
419
- const report = buildFleetHealthReport(rows);
427
+ const report = buildFleetHealthReport(rows, new Date(), { self });
420
428
  if (opts.json) {
421
429
  console.log(JSON.stringify(report, null, 2));
422
430
  }
package/dist/index.js CHANGED
@@ -960,12 +960,10 @@ if (brandDisabled.size > 0) {
960
960
  if (!helpOrVersionRequested) {
961
961
  // Run update check before parsing so the upgrade notice/prompt precedes output.
962
962
  await checkForUpdates();
963
- // Surface any "behind upstream" notices from the previous detached sync, then
964
- // fire-and-forget the next background sync. System repo gets a real fast-forward
963
+ // Fire-and-forget the background sync. System repo gets a real fast-forward
965
964
  // pull (read-only locally, safe). User repo and extras get fetch-only + a
966
- // status marker that we'll print on the *next* invocation.
967
- const { spawnDetachedSync, printPendingUpdateNotices } = await import('./lib/auto-pull.js');
968
- printPendingUpdateNotices();
965
+ // status marker that `agents doctor` surfaces as a repo-behind warning.
966
+ const { spawnDetachedSync } = await import('./lib/auto-pull.js');
969
967
  spawnDetachedSync();
970
968
  }
971
969
  // First-run experience: no args + no config yet + TTY -> launch interactive setup.
@@ -265,27 +265,6 @@ export declare function __resetAntigravityKeychainCacheForTest(): void;
265
265
  * Sync, no Keychain, no network — safe on the `agents run` hot path.
266
266
  */
267
267
  export declare function isClaudeCredentialFileBlank(base: string, platform?: NodeJS.Platform): boolean;
268
- /**
269
- * Whether a Claude version home holds its OWN on-disk credential — a non-blank
270
- * `.claude/.credentials.json` (the store Claude Code writes on login off macOS)
271
- * or a `.claude/.oauth_token` file (the keychain-less shim fallback, shims.ts).
272
- *
273
- * RUSH-1979: the routines daemon injects one ambient `CLAUDE_CODE_OAUTH_TOKEN`
274
- * (RUSH-1759) so a token-less default account still authenticates. But Claude —
275
- * and the Linux shim's own `-z CLAUDE_CODE_OAUTH_TOKEN` guard — both prefer that
276
- * env var over a pinned account's config dir, so when balanced rotation pins a
277
- * specific account's `CLAUDE_CONFIG_DIR` the ambient token shadows it: the pool
278
- * is inert and every fire 401s on the one stale token. A routine spawn drops the
279
- * ambient token when the pinned account has its own credential (this returns
280
- * true) so the account authenticates itself; when it does not (the RUSH-1759
281
- * default), the injected token is kept.
282
- *
283
- * Off-darwin only, mirroring {@link isClaudeCredentialFileBlank}: on macOS the
284
- * login Keychain is the canonical store and probing it raises an auth sheet, and
285
- * there the daemon injects no token to shadow (broker-only, usually absent), so
286
- * returning false changes nothing. Sync, no Keychain, no network.
287
- */
288
- export declare function claudeHomeHasOwnCredential(base: string, platform?: NodeJS.Platform): boolean;
289
268
  export declare function getAccountInfo(agentId: AgentId, home?: string): Promise<AccountInfo>;
290
269
  /**
291
270
  * Determine when the agent was last used by checking session file mtimes,
@@ -1404,43 +1404,6 @@ export function isClaudeCredentialFileBlank(base, platform = process.platform) {
1404
1404
  return false;
1405
1405
  }
1406
1406
  }
1407
- /**
1408
- * Whether a Claude version home holds its OWN on-disk credential — a non-blank
1409
- * `.claude/.credentials.json` (the store Claude Code writes on login off macOS)
1410
- * or a `.claude/.oauth_token` file (the keychain-less shim fallback, shims.ts).
1411
- *
1412
- * RUSH-1979: the routines daemon injects one ambient `CLAUDE_CODE_OAUTH_TOKEN`
1413
- * (RUSH-1759) so a token-less default account still authenticates. But Claude —
1414
- * and the Linux shim's own `-z CLAUDE_CODE_OAUTH_TOKEN` guard — both prefer that
1415
- * env var over a pinned account's config dir, so when balanced rotation pins a
1416
- * specific account's `CLAUDE_CONFIG_DIR` the ambient token shadows it: the pool
1417
- * is inert and every fire 401s on the one stale token. A routine spawn drops the
1418
- * ambient token when the pinned account has its own credential (this returns
1419
- * true) so the account authenticates itself; when it does not (the RUSH-1759
1420
- * default), the injected token is kept.
1421
- *
1422
- * Off-darwin only, mirroring {@link isClaudeCredentialFileBlank}: on macOS the
1423
- * login Keychain is the canonical store and probing it raises an auth sheet, and
1424
- * there the daemon injects no token to shadow (broker-only, usually absent), so
1425
- * returning false changes nothing. Sync, no Keychain, no network.
1426
- */
1427
- export function claudeHomeHasOwnCredential(base, platform = process.platform) {
1428
- if (platform === 'darwin')
1429
- return false;
1430
- if (fs.existsSync(path.join(base, '.claude', '.oauth_token')))
1431
- return true;
1432
- try {
1433
- const raw = fs.readFileSync(path.join(base, '.claude', '.credentials.json'), 'utf-8');
1434
- const oauth = JSON.parse(raw).claudeAiOauth;
1435
- if (!oauth)
1436
- return false;
1437
- const nonEmpty = (v) => typeof v === 'string' && v.trim().length > 0;
1438
- return nonEmpty(oauth.accessToken) || nonEmpty(oauth.refreshToken);
1439
- }
1440
- catch {
1441
- return false;
1442
- }
1443
- }
1444
1407
  export async function getAccountInfo(agentId, home) {
1445
1408
  const base = home || os.homedir();
1446
1409
  const empty = {
@@ -2,12 +2,13 @@
2
2
  * Background sync for tracked git repos:
3
3
  * - System repo (~/.agents/.system/) is read-only locally — fast-forward auto-pull is safe.
4
4
  * - User repo (~/.agents/) and enabled extras may have local commits, so we only
5
- * `git fetch` and write a status marker. Next CLI invocation surfaces a one-line
6
- * notice if upstream is ahead. Pulling is left to the user via `agents repo pull`.
5
+ * `git fetch` and write a status marker. `agents doctor` surfaces these markers
6
+ * as a "Repo updates" section instead of printing to stderr on every command.
7
+ * Pulling is left to the user via `agents repo pull`.
7
8
  *
8
9
  * Public API:
9
- * spawnDetachedSync() — fire-and-forget; never blocks the foreground command.
10
- * printPendingUpdateNotices() — synchronous; reads markers and prints + consumes them.
10
+ * spawnDetachedSync() — fire-and-forget; never blocks the foreground command.
11
+ * readRepoBehindMarkers() — synchronous; reads markers without consuming them.
11
12
  */
12
13
  /** Per-repo lock file path. mtime acts as a recency check. */
13
14
  export declare function lockFilePath(alias: string): string;
@@ -24,8 +25,15 @@ export interface FetchStatusMarker {
24
25
  /** Spawn the detached worker. No-op when AGENTS_NO_AUTOPULL=1 is set. */
25
26
  export declare function spawnDetachedSync(): void;
26
27
  /**
27
- * Read any pending status markers and print one-line notices for repos that
28
- * are behind upstream. Markers are deleted after printing so notices don't
29
- * repeat on every invocation. Synchronous, cheap (small JSON files).
28
+ * Read all current status markers and return those where the local repo is
29
+ * behind upstream. Markers are NOT deleted they persist until the next
30
+ * background fetch overwrites them with fresh data.
31
+ *
32
+ * Synchronous, cheap (small JSON files). Used by `agents doctor` to surface
33
+ * repo-behind warnings in one place instead of printing to stderr on every
34
+ * command.
35
+ *
36
+ * @param fetchDir - Override the fetch state dir (for tests). Defaults to the
37
+ * production getFetchCacheDir() path.
30
38
  */
31
- export declare function printPendingUpdateNotices(): void;
39
+ export declare function readRepoBehindMarkers(fetchDir?: string): FetchStatusMarker[];
@@ -2,12 +2,13 @@
2
2
  * Background sync for tracked git repos:
3
3
  * - System repo (~/.agents/.system/) is read-only locally — fast-forward auto-pull is safe.
4
4
  * - User repo (~/.agents/) and enabled extras may have local commits, so we only
5
- * `git fetch` and write a status marker. Next CLI invocation surfaces a one-line
6
- * notice if upstream is ahead. Pulling is left to the user via `agents repo pull`.
5
+ * `git fetch` and write a status marker. `agents doctor` surfaces these markers
6
+ * as a "Repo updates" section instead of printing to stderr on every command.
7
+ * Pulling is left to the user via `agents repo pull`.
7
8
  *
8
9
  * Public API:
9
- * spawnDetachedSync() — fire-and-forget; never blocks the foreground command.
10
- * printPendingUpdateNotices() — synchronous; reads markers and prints + consumes them.
10
+ * spawnDetachedSync() — fire-and-forget; never blocks the foreground command.
11
+ * readRepoBehindMarkers() — synchronous; reads markers without consuming them.
11
12
  */
12
13
  import * as fs from 'fs';
13
14
  import * as path from 'path';
@@ -57,21 +58,29 @@ export function spawnDetachedSync() {
57
58
  }
58
59
  }
59
60
  /**
60
- * Read any pending status markers and print one-line notices for repos that
61
- * are behind upstream. Markers are deleted after printing so notices don't
62
- * repeat on every invocation. Synchronous, cheap (small JSON files).
61
+ * Read all current status markers and return those where the local repo is
62
+ * behind upstream. Markers are NOT deleted they persist until the next
63
+ * background fetch overwrites them with fresh data.
64
+ *
65
+ * Synchronous, cheap (small JSON files). Used by `agents doctor` to surface
66
+ * repo-behind warnings in one place instead of printing to stderr on every
67
+ * command.
68
+ *
69
+ * @param fetchDir - Override the fetch state dir (for tests). Defaults to the
70
+ * production getFetchCacheDir() path.
63
71
  */
64
- export function printPendingUpdateNotices() {
65
- const dir = fetchStateDir();
72
+ export function readRepoBehindMarkers(fetchDir) {
73
+ const dir = fetchDir ?? fetchStateDir();
66
74
  if (!fs.existsSync(dir))
67
- return;
75
+ return [];
68
76
  let entries = [];
69
77
  try {
70
78
  entries = fs.readdirSync(dir);
71
79
  }
72
80
  catch {
73
- return;
81
+ return [];
74
82
  }
83
+ const result = [];
75
84
  for (const name of entries) {
76
85
  if (!name.endsWith('.status.json'))
77
86
  continue;
@@ -81,25 +90,11 @@ export function printPendingUpdateNotices() {
81
90
  marker = JSON.parse(fs.readFileSync(file, 'utf-8'));
82
91
  }
83
92
  catch {
84
- try {
85
- fs.unlinkSync(file);
86
- }
87
- catch { /* ignore */ }
88
93
  continue;
89
94
  }
90
- if (!marker || marker.behind <= 0) {
91
- try {
92
- fs.unlinkSync(file);
93
- }
94
- catch { /* ignore */ }
95
+ if (!marker || marker.behind <= 0)
95
96
  continue;
96
- }
97
- const repoLabel = marker.alias === 'user' ? '~/.agents/' : marker.alias;
98
- process.stderr.write(`agents-cli: ${repoLabel} is ${marker.behind} commit${marker.behind === 1 ? '' : 's'} ` +
99
- `behind ${marker.branch} — run 'agents repo pull ${marker.alias}' to update.\n`);
100
- try {
101
- fs.unlinkSync(file);
102
- }
103
- catch { /* ignore */ }
97
+ result.push(marker);
104
98
  }
99
+ return result;
105
100
  }