@phnx-labs/agents-cli 1.20.77 → 1.20.82

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 (212) hide show
  1. package/CHANGELOG.md +589 -0
  2. package/README.md +27 -25
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/activity.d.ts +9 -0
  5. package/dist/commands/activity.js +153 -24
  6. package/dist/commands/apply.js +11 -4
  7. package/dist/commands/cli.js +1 -1
  8. package/dist/commands/commands.d.ts +1 -1
  9. package/dist/commands/commands.js +7 -6
  10. package/dist/commands/defaults.js +3 -11
  11. package/dist/commands/doctor.d.ts +74 -0
  12. package/dist/commands/doctor.js +718 -40
  13. package/dist/commands/events.js +1 -1
  14. package/dist/commands/exec.js +53 -8
  15. package/dist/commands/feed.d.ts +4 -0
  16. package/dist/commands/feed.js +87 -11
  17. package/dist/commands/harness.js +2 -2
  18. package/dist/commands/hooks.js +1 -1
  19. package/dist/commands/import.js +8 -4
  20. package/dist/commands/logs.js +6 -0
  21. package/dist/commands/models.js +1 -1
  22. package/dist/commands/packages.js +6 -6
  23. package/dist/commands/permissions.js +1 -1
  24. package/dist/commands/profiles.js +1 -1
  25. package/dist/commands/pull.d.ts +1 -1
  26. package/dist/commands/pull.js +4 -4
  27. package/dist/commands/repo.d.ts +78 -0
  28. package/dist/commands/repo.js +195 -11
  29. package/dist/commands/routines.js +16 -4
  30. package/dist/commands/rules.js +13 -12
  31. package/dist/commands/secrets.d.ts +3 -1
  32. package/dist/commands/secrets.js +108 -34
  33. package/dist/commands/sessions-browser.d.ts +45 -3
  34. package/dist/commands/sessions-browser.js +118 -12
  35. package/dist/commands/sessions-export.d.ts +3 -0
  36. package/dist/commands/sessions-export.js +13 -10
  37. package/dist/commands/sessions-inject.d.ts +7 -4
  38. package/dist/commands/sessions-inject.js +18 -18
  39. package/dist/commands/sessions-picker.d.ts +15 -1
  40. package/dist/commands/sessions-picker.js +90 -7
  41. package/dist/commands/sessions-resume.d.ts +16 -0
  42. package/dist/commands/sessions-resume.js +14 -13
  43. package/dist/commands/sessions.d.ts +126 -1
  44. package/dist/commands/sessions.js +365 -42
  45. package/dist/commands/set.d.ts +15 -0
  46. package/dist/commands/set.js +79 -0
  47. package/dist/commands/setup-fleet.d.ts +21 -0
  48. package/dist/commands/setup-fleet.js +201 -0
  49. package/dist/commands/setup-secrets.d.ts +20 -0
  50. package/dist/commands/setup-secrets.js +223 -0
  51. package/dist/commands/setup.js +17 -3
  52. package/dist/commands/skills.js +1 -1
  53. package/dist/commands/ssh.js +9 -1
  54. package/dist/commands/sync.js +7 -2
  55. package/dist/commands/teams-picker.js +0 -1
  56. package/dist/commands/teams.d.ts +33 -1
  57. package/dist/commands/teams.js +208 -44
  58. package/dist/commands/versions.js +10 -5
  59. package/dist/commands/view.d.ts +1 -0
  60. package/dist/commands/view.js +11 -0
  61. package/dist/index.js +8 -13
  62. package/dist/lib/acp/harnesses.js +0 -7
  63. package/dist/lib/activity.d.ts +156 -0
  64. package/dist/lib/activity.js +282 -0
  65. package/dist/lib/agents.d.ts +9 -23
  66. package/dist/lib/agents.js +72 -100
  67. package/dist/lib/auto-pull.d.ts +16 -8
  68. package/dist/lib/auto-pull.js +23 -28
  69. package/dist/lib/browser/service.js +3 -0
  70. package/dist/lib/browser/types.d.ts +7 -0
  71. package/dist/lib/capabilities.js +6 -2
  72. package/dist/lib/crabbox/lease.js +2 -2
  73. package/dist/lib/crabbox/setup-copy.d.ts +4 -4
  74. package/dist/lib/crabbox/setup-copy.js +4 -4
  75. package/dist/lib/daemon.d.ts +26 -40
  76. package/dist/lib/daemon.js +63 -123
  77. package/dist/lib/devices/fleet-divergence.d.ts +101 -0
  78. package/dist/lib/devices/fleet-divergence.js +188 -0
  79. package/dist/lib/devices/fleet-inventory.d.ts +19 -0
  80. package/dist/lib/devices/fleet-inventory.js +57 -0
  81. package/dist/lib/devices/fleet.d.ts +1 -1
  82. package/dist/lib/devices/fleet.js +1 -1
  83. package/dist/lib/devices/health-report.d.ts +10 -2
  84. package/dist/lib/devices/health-report.js +32 -1
  85. package/dist/lib/doctor-diff.d.ts +20 -0
  86. package/dist/lib/doctor-diff.js +6 -1
  87. package/dist/lib/drift.d.ts +31 -11
  88. package/dist/lib/drift.js +58 -7
  89. package/dist/lib/events.d.ts +8 -1
  90. package/dist/lib/events.js +10 -1
  91. package/dist/lib/exec.js +35 -8
  92. package/dist/lib/feed-post.d.ts +28 -1
  93. package/dist/lib/feed-post.js +110 -2
  94. package/dist/lib/fleet/apply.js +8 -1
  95. package/dist/lib/fleet/auth-sync.d.ts +18 -1
  96. package/dist/lib/fleet/auth-sync.js +25 -11
  97. package/dist/lib/fleet/remote-login.js +5 -0
  98. package/dist/lib/git.d.ts +27 -14
  99. package/dist/lib/git.js +138 -43
  100. package/dist/lib/hooks.d.ts +42 -0
  101. package/dist/lib/hooks.js +137 -93
  102. package/dist/lib/hosts/dispatch.d.ts +28 -1
  103. package/dist/lib/hosts/dispatch.js +33 -5
  104. package/dist/lib/hosts/option.js +2 -2
  105. package/dist/lib/hosts/passthrough.d.ts +22 -1
  106. package/dist/lib/hosts/passthrough.js +238 -13
  107. package/dist/lib/hosts/remote-cmd.js +2 -0
  108. package/dist/lib/hosts/run-target.d.ts +2 -0
  109. package/dist/lib/hosts/run-target.js +1 -0
  110. package/dist/lib/hq/floor.d.ts +1 -1
  111. package/dist/lib/hq/floor.js +6 -1
  112. package/dist/lib/mcp.js +3 -71
  113. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
  114. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  115. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  116. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  117. package/dist/lib/menubar/notify-desktop.d.ts +15 -0
  118. package/dist/lib/menubar/notify-desktop.js +41 -5
  119. package/dist/lib/merged-resources.d.ts +11 -0
  120. package/dist/{commands/resources.js → lib/merged-resources.js} +11 -19
  121. package/dist/lib/permissions.d.ts +0 -32
  122. package/dist/lib/permissions.js +4 -157
  123. package/dist/lib/picker.js +1 -1
  124. package/dist/lib/platform/process.d.ts +16 -5
  125. package/dist/lib/platform/process.js +54 -0
  126. package/dist/lib/plugins.d.ts +0 -8
  127. package/dist/lib/plugins.js +4 -110
  128. package/dist/lib/project-resources.js +4 -1
  129. package/dist/lib/refresh.d.ts +5 -3
  130. package/dist/lib/refresh.js +7 -5
  131. package/dist/lib/resources/commands.js +4 -1
  132. package/dist/lib/resources/mcp.js +0 -4
  133. package/dist/lib/resources/permissions.d.ts +1 -1
  134. package/dist/lib/resources/permissions.js +1 -5
  135. package/dist/lib/resources/rules.js +4 -1
  136. package/dist/lib/resources/skills.js +4 -1
  137. package/dist/lib/resources/subagents.js +4 -0
  138. package/dist/lib/resources/types.d.ts +1 -1
  139. package/dist/lib/routines.d.ts +20 -0
  140. package/dist/lib/routines.js +6 -0
  141. package/dist/lib/run-defaults.d.ts +1 -0
  142. package/dist/lib/run-defaults.js +9 -0
  143. package/dist/lib/runner.d.ts +0 -12
  144. package/dist/lib/runner.js +71 -29
  145. package/dist/lib/sandbox.js +0 -16
  146. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  147. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  148. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  149. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  150. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  151. package/dist/lib/secrets/agent.d.ts +18 -0
  152. package/dist/lib/secrets/agent.js +131 -17
  153. package/dist/lib/secrets/audit.d.ts +46 -0
  154. package/dist/lib/secrets/audit.js +56 -0
  155. package/dist/lib/secrets/bundles.d.ts +12 -9
  156. package/dist/lib/secrets/bundles.js +47 -31
  157. package/dist/lib/secrets/index.d.ts +2 -1
  158. package/dist/lib/secrets/index.js +8 -2
  159. package/dist/lib/secrets/remote.d.ts +75 -2
  160. package/dist/lib/secrets/remote.js +139 -5
  161. package/dist/lib/secrets/scope.d.ts +26 -0
  162. package/dist/lib/secrets/scope.js +29 -0
  163. package/dist/lib/secrets/session-store.d.ts +10 -0
  164. package/dist/lib/secrets/session-store.js +64 -11
  165. package/dist/lib/secrets/sync.js +3 -3
  166. package/dist/lib/session/active.d.ts +117 -26
  167. package/dist/lib/session/active.js +199 -53
  168. package/dist/lib/session/actor-sidecar.d.ts +23 -0
  169. package/dist/lib/session/actor-sidecar.js +101 -0
  170. package/dist/lib/session/db.d.ts +41 -1
  171. package/dist/lib/session/db.js +173 -8
  172. package/dist/lib/session/digest.js +126 -21
  173. package/dist/lib/session/discover.d.ts +19 -2
  174. package/dist/lib/session/discover.js +32 -11
  175. package/dist/lib/session/hook-sessions.d.ts +9 -0
  176. package/dist/lib/session/hook-sessions.js +57 -8
  177. package/dist/lib/session/origin-machine.d.ts +18 -0
  178. package/dist/lib/session/origin-machine.js +34 -0
  179. package/dist/lib/session/pid-registry.d.ts +13 -0
  180. package/dist/lib/session/render.d.ts +2 -0
  181. package/dist/lib/session/render.js +1 -1
  182. package/dist/lib/session/state.d.ts +1 -0
  183. package/dist/lib/session/state.js +1 -1
  184. package/dist/lib/session/sync/config.js +2 -2
  185. package/dist/lib/session/types.d.ts +16 -0
  186. package/dist/lib/smart-launch.d.ts +86 -0
  187. package/dist/lib/smart-launch.js +172 -0
  188. package/dist/lib/staleness/detectors/commands.js +2 -2
  189. package/dist/lib/staleness/detectors/permissions.js +1 -45
  190. package/dist/lib/staleness/writers/commands.js +4 -5
  191. package/dist/lib/staleness/writers/hooks.js +1 -1
  192. package/dist/lib/startup/command-registry.d.ts +1 -2
  193. package/dist/lib/startup/command-registry.js +2 -4
  194. package/dist/lib/subagents-registry.js +5 -12
  195. package/dist/lib/subagents.d.ts +0 -10
  196. package/dist/lib/subagents.js +0 -12
  197. package/dist/lib/teams/agents.d.ts +0 -2
  198. package/dist/lib/teams/agents.js +10 -27
  199. package/dist/lib/teams/index.d.ts +1 -1
  200. package/dist/lib/teams/index.js +1 -1
  201. package/dist/lib/types.d.ts +17 -9
  202. package/dist/lib/usage.d.ts +28 -5
  203. package/dist/lib/usage.js +271 -8
  204. package/dist/lib/versions.js +7 -1
  205. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  206. package/dist/lib/watchdog/watchdog.js +25 -13
  207. package/package.json +1 -2
  208. package/dist/commands/check.d.ts +0 -15
  209. package/dist/commands/check.js +0 -180
  210. package/dist/commands/resources.d.ts +0 -5
  211. package/dist/lib/secrets/account-token.d.ts +0 -20
  212. package/dist/lib/secrets/account-token.js +0 -64
@@ -26,13 +26,13 @@ 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';
33
33
  import { runRemoteSessions, buildForwardedArgs, ensureWholeIndex } from '../lib/session/remote.js';
34
34
  import { formatRelativeTime } from '../lib/session/relative-time.js';
35
- import { renderConversationMarkdown, renderSummary, renderSummaryHeader, computeSummaryStats, renderJson, filterEvents, parseRoleList, linkUrl } from '../lib/session/render.js';
35
+ import { renderConversationMarkdown, renderSummary, renderSummaryHeader, computeSummaryStats, renderJson, filterEvents, parseRoleList, linkPath, linkUrl, shortenModel } from '../lib/session/render.js';
36
36
  import { linearIssueUrl } from '../lib/session/linear.js';
37
37
  import { renderMarkdown } from '../lib/markdown.js';
38
38
  import { AGENTS, colorAgent, resolveAgentName } from '../lib/agents.js';
@@ -182,6 +182,11 @@ function statusColor(status) {
182
182
  case 'idle': return chalk.gray;
183
183
  case 'queued': return chalk.blue;
184
184
  case 'input_required': return chalk.yellow;
185
+ // Dead process: dimmed so it recedes from the live rows without being mistaken
186
+ // for the gray `idle` (which reads as "done, waiting for you" — a live state).
187
+ case 'closed': return chalk.dim;
188
+ // Days-stale / dangling: red so a session nobody is driving stands out.
189
+ case 'abandoned': return chalk.red;
185
190
  // Alive but un-introspectable (a harness whose transcript we can't parse).
186
191
  // Magenta so it never reads as the gray "idle" it used to be faked as.
187
192
  case 'unknown': return chalk.magenta;
@@ -303,6 +308,10 @@ export function formatActiveRowDescription(s) {
303
308
  }
304
309
  /** Short human word for a session's activity (falls back to the coarse status). */
305
310
  function activityLabel(s) {
311
+ // Lifecycle status wins over any residual parsed activity — a dead/dangling
312
+ // session must read `closed`/`abandoned`, not the `idle` its stale tail infers.
313
+ if (s.status === 'closed' || s.status === 'abandoned')
314
+ return s.status;
306
315
  if (s.activity === 'waiting_input')
307
316
  return 'waiting';
308
317
  if (s.activity === 'working')
@@ -335,14 +344,54 @@ export function indexActiveBySessionId(active) {
335
344
  export function liveGlyphAndPreview(a) {
336
345
  if (!a)
337
346
  return { glyph: '', preview: '' };
347
+ // `◌` (dotted) = alive but un-introspectable — visually distinct from `○` idle
348
+ // so an opaque harness is never mistaken for a finished one. `⊘` = abandoned /
349
+ // dangling; `×` = closed (dead pid) — both distinct from the `○` idle a live,
350
+ // stopped session shows, so a gone session never masquerades as a resting one.
351
+ if (a.status === 'abandoned')
352
+ return { glyph: statusColor(a.status)('⊘'), preview: buildSessionDescription(a) };
353
+ if (a.status === 'closed')
354
+ return { glyph: statusColor(a.status)('×'), preview: buildSessionDescription(a) };
338
355
  const waiting = a.status === 'input_required' || a.activity === 'waiting_input';
339
356
  const running = a.status === 'running' || a.activity === 'working';
340
- // `◌` (dotted) = alive but un-introspectable — visually distinct from `○` idle
341
- // so an opaque harness is never mistaken for a finished one.
342
357
  const unknown = a.status === 'unknown';
343
- const shape = waiting ? '◐' : running ? '●' : unknown ? '◌' : '○';
358
+ const shape = waiting ? '◐'
359
+ : running ? '●'
360
+ : unknown ? '◌'
361
+ : '○';
344
362
  return { glyph: statusColor(a.status)(shape), preview: buildSessionDescription(a) };
345
363
  }
364
+ /**
365
+ * The one-word live status for a listing row — `working` / `waiting` / `idle`,
366
+ * the same three states the `--active` column shows, so the default list is no
367
+ * longer just a glyph. `waiting` is the actionable "needs you" case (a question /
368
+ * permission / plan-review), kept distinct from `idle` (stopped) and `working`.
369
+ * Empty for a not-live row, and for the rare no-signal `unknown`. Pure +
370
+ * exported for the row tests.
371
+ */
372
+ export function liveStatusWord(a) {
373
+ if (!a)
374
+ return '';
375
+ // Lifecycle status is definitive — surface it ahead of any parsed activity.
376
+ if (a.status === 'closed' || a.status === 'abandoned')
377
+ return a.status;
378
+ if (a.status === 'input_required' || a.activity === 'waiting_input')
379
+ return 'waiting';
380
+ if (a.status === 'running' || a.activity === 'working')
381
+ return 'working';
382
+ if (a.status === 'idle' || a.activity === 'idle')
383
+ return 'idle';
384
+ if (a.status === 'queued')
385
+ return 'queued';
386
+ return '';
387
+ }
388
+ /** The colored, space-padded status cell for a listing row (empty when not live). */
389
+ function liveStatusCell(live) {
390
+ const word = liveStatusWord(live);
391
+ if (!word || !live)
392
+ return { cell: '', width: 0 };
393
+ return { cell: statusColor(live.status)(padToWidth(word, 8)), width: 8 };
394
+ }
346
395
  /**
347
396
  * The tracker/PR ref for a session's dedicated column: the ticket id when known,
348
397
  * else `PR#<n>`, else empty. Pulled out of the trailing badge blob so refs align
@@ -351,6 +400,21 @@ export function liveGlyphAndPreview(a) {
351
400
  export function ticketLabel(s) {
352
401
  return s.ticketId ?? (s.prNumber ? `PR#${s.prNumber}` : '');
353
402
  }
403
+ function ticketUrl(s) {
404
+ if (s.ticketId)
405
+ return linearIssueUrl(s.ticketId);
406
+ return s.prNumber ? s.prUrl : undefined;
407
+ }
408
+ export function linkTicketCell(s, label) {
409
+ const url = ticketUrl(s);
410
+ return url && label.trim() !== '-' ? linkUrl(url, label) : label;
411
+ }
412
+ export function linkCwdCell(s, label) {
413
+ return s.cwd && !s._remote ? linkPath(s.cwd, label) : label;
414
+ }
415
+ function modelLabel(model) {
416
+ return model ? shortenModel(model) : '-';
417
+ }
354
418
  /**
355
419
  * The row shape `agents sessions --active --json` emits. RUSH-1981: a watcher
356
420
  * joins active sessions on ticketId + project, but the raw ActiveSession nests
@@ -365,6 +429,7 @@ export function serializeActiveSessionsForJson(sessions) {
365
429
  ...s,
366
430
  ticketId: s.ticket?.id ?? null,
367
431
  project: s.cwd ? path.basename(s.cwd) : null,
432
+ prLink: s.pr?.url ?? null,
368
433
  }));
369
434
  }
370
435
  /**
@@ -435,11 +500,12 @@ function locatorBadge(s) {
435
500
  * terminal width so the row never wraps.
436
501
  */
437
502
  function printActiveRow(s, indent) {
438
- // shortId (8-char) · agent · host · status · badges · identity+todos+snippet
503
+ // shortId (8-char) · agent · host · status · owner · badges · identity+todos+snippet
439
504
  const idCol = chalk.dim(padToWidth((s.sessionId?.slice(0, 8)) ?? '-', 9));
440
505
  const kindCol = colorAgent(s.kind)(padToWidth(truncateToWidth(s.kind, 8), 9));
441
506
  const hostCol = chalk.gray(padToWidth(truncateToWidth(s.host ?? '-', 8), 9));
442
507
  const statusCol = statusColor(s.status)(padToWidth(truncateToWidth(activityLabel(s), 8), 9));
508
+ const ownerCol = chalk.cyan(padToWidth(truncateToWidth(ownerLabel(s), 8), 9));
443
509
  const fork = s.pidCount && s.pidCount > 1 ? chalk.dim(`×${s.pidCount} `) : '';
444
510
  const badges = (fork ? fork : '') + [signalBadges(s), locatorBadge(s)].filter(Boolean).join(' ');
445
511
  // Identity (label/project/ticket clickable) + checklist + live snippet.
@@ -447,10 +513,24 @@ function printActiveRow(s, indent) {
447
513
  // via the SSH fan-out already populated (including todos when the peer has them).
448
514
  const desc = formatActiveRowDescription(s) || '-';
449
515
  // Fill the remaining width with the preview so nothing wraps under tmux/SSH.
450
- const fixed = stringWidth(indent) + 9 + 9 + 9 + 9 + (badges ? stringWidth(badges) + 1 : 0);
516
+ const fixed = stringWidth(indent) + 9 + 9 + 9 + 9 + 9 + (badges ? stringWidth(badges) + 1 : 0);
451
517
  const room = Math.max(12, terminalWidth() - fixed - 1);
452
518
  const descCol = chalk.white(truncateToWidth(desc, room));
453
- console.log(indent + idCol + kindCol + hostCol + statusCol + (badges ? badges + ' ' : '') + descCol);
519
+ console.log(indent + idCol + kindCol + hostCol + statusCol + ownerCol + (badges ? badges + ' ' : '') + descCol);
520
+ }
521
+ /**
522
+ * Compact owner display for the `--active` owner column: the local-part of a
523
+ * resolved actor email/login (`muqsit@getrush.ai` -> `muqsit`), the id as-is
524
+ * when it has no `@`, and `-` when the actor is unresolved (`UNRESOLVED@<host>`)
525
+ * or absent (a launch predating actor stamping). Honest by design — an
526
+ * unresolved local run shows no owner rather than inventing one.
527
+ */
528
+ export function ownerLabel(s) {
529
+ const owner = s.owner;
530
+ if (!owner || owner.startsWith('UNRESOLVED@'))
531
+ return '-';
532
+ const at = owner.indexOf('@');
533
+ return at > 0 ? owner.slice(0, at) : owner;
454
534
  }
455
535
  /**
456
536
  * Short label for an IDE window. The slice key in live-terminals.json is
@@ -666,8 +746,8 @@ async function runRemoteSessionsJson(hosts) {
666
746
  process.stdout.write(serializeSessionsJson(sessions));
667
747
  }
668
748
  /**
669
- * `running N · idle N · waiting N · queued N · unknown N` for a bucket of
670
- * sessions (zero buckets omitted). Same bucketing as the grand-total summary so
749
+ * `running N · idle N · waiting N · queued N · closed N · abandoned N · unknown N`
750
+ * for a bucket of sessions (zero buckets omitted). Same bucketing as the summary so
671
751
  * per-group counts reconcile with the `(total)` beside the header — the `unknown`
672
752
  * bucket is what keeps an alive-but-opaque row from silently vanishing from the
673
753
  * tally. Empty when nothing.
@@ -677,6 +757,8 @@ function groupTally(sessions) {
677
757
  const idle = sessions.filter(s => s.status === 'idle').length;
678
758
  const waiting = sessions.filter(s => s.status === 'input_required').length;
679
759
  const queued = sessions.filter(s => s.status === 'queued').length;
760
+ const closed = sessions.filter(s => s.status === 'closed').length;
761
+ const abandoned = sessions.filter(s => s.status === 'abandoned').length;
680
762
  const unknown = sessions.filter(s => s.status === 'unknown').length;
681
763
  const parts = [];
682
764
  if (running)
@@ -687,6 +769,10 @@ function groupTally(sessions) {
687
769
  parts.push(`${waiting} waiting`);
688
770
  if (queued)
689
771
  parts.push(`${queued} queued`);
772
+ if (closed)
773
+ parts.push(`${closed} closed`);
774
+ if (abandoned)
775
+ parts.push(`${abandoned} abandoned`);
690
776
  if (unknown)
691
777
  parts.push(`${unknown} unknown`);
692
778
  return parts.join(' · ');
@@ -813,15 +899,18 @@ export function remoteHostsToDial(hosts, self) {
813
899
  return hosts.filter(h => hostToken(h) !== self);
814
900
  }
815
901
  /**
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.
902
+ * The fleet-wide live-session set behind every `--active` surface. Local sessions
903
+ * come from `getActiveSessions()` and (unless `--local`) the registered online
904
+ * devices from `ag devices` are folded in over SSH. An explicit `--host`/`--device`
905
+ * list SCOPES the sweep to exactly those machines — the local machine is included
906
+ * only when it is itself named — so `--host` is a filter, not an addition.
907
+ *
908
+ * This is the single gather: the static renderer AND the interactive browser both
909
+ * call it, so the browser can never disagree with `--active --json` about which
910
+ * sessions are live (it used to call the local-only `getActiveSessions()` directly
911
+ * and silently hid every remote session).
823
912
  */
824
- async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
913
+ export async function gatherActiveSessions(opts = {}) {
825
914
  const self = machineId();
826
915
  // An explicit --host/--device list scopes the view: seed local sessions only
827
916
  // when no hosts are named, or when this machine is one of the named targets.
@@ -841,6 +930,17 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
841
930
  merged = dedupeByMachineSession([...local, ...remote.sessions]);
842
931
  }
843
932
  }
933
+ return { sessions: merged, remoteDeviceCount };
934
+ }
935
+ /**
936
+ * Render the unified active-session view, grouped by machine. Scoping and the
937
+ * fleet sweep live in {@link gatherActiveSessions}; this owns the presentation
938
+ * (the `--waiting` gate, JSON, and the grouped table). A tip is shown when there
939
+ * are no other machines to include.
940
+ */
941
+ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
942
+ const self = machineId();
943
+ const { sessions: merged, remoteDeviceCount } = await gatherActiveSessions(opts);
844
944
  // --waiting: only sessions blocked on the user. Exits non-zero when any are
845
945
  // present so a supervising agent or hook can poll it as a gate.
846
946
  const sessions = waitingOnly ? merged.filter(s => s.status === 'input_required') : merged;
@@ -944,6 +1044,21 @@ async function renderSessionPreview(query, scope) {
944
1044
  console.log(chalk.gray(`No session matches "${query}".`));
945
1045
  return;
946
1046
  }
1047
+ // Lead with the live status when the session is still running, so the preview
1048
+ // says working / waiting / idle up front — not just the historical transcript.
1049
+ let live;
1050
+ try {
1051
+ live = indexActiveBySessionId(await getActiveSessions()).get(session.id);
1052
+ }
1053
+ catch { /* plain preview on any probe failure */ }
1054
+ if (live) {
1055
+ const { glyph } = liveGlyphAndPreview(live);
1056
+ const word = liveStatusWord(live) || live.status;
1057
+ const needsYou = live.status === 'input_required' || live.activity === 'waiting_input';
1058
+ const reason = live.awaitingReason ? ` (${live.awaitingReason.replace('_', ' ')})` : '';
1059
+ const suffix = needsYou ? chalk.yellow(` ← needs you${reason}`) : '';
1060
+ console.log(`${glyph} ${statusColor(live.status)(word)}${suffix}`);
1061
+ }
947
1062
  console.log(buildPreview(session));
948
1063
  }
949
1064
  /** Main action handler for `agents sessions`. Routes to picker, table, or single-session render. */
@@ -1023,6 +1138,7 @@ async function sessionsAction(query, options) {
1023
1138
  agent: options.agent,
1024
1139
  host: options.host,
1025
1140
  since: options.since,
1141
+ all: options.all,
1026
1142
  }), { local: options.local === true, hosts: options.host });
1027
1143
  return;
1028
1144
  }
@@ -1104,7 +1220,7 @@ async function sessionsAction(query, options) {
1104
1220
  // When the user explicitly asks to render (via mode flag), resolve the
1105
1221
  // query globally so sessions outside the default cwd/30d window are found.
1106
1222
  if (wantsRender && searchQuery) {
1107
- await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, redact: options.redact });
1223
+ await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, redact: options.redact, local: options.local, hosts: options.host });
1108
1224
  return;
1109
1225
  }
1110
1226
  // Interactive picker loads a deep pool but shows only recent sessions
@@ -1171,19 +1287,34 @@ async function sessionsAction(query, options) {
1171
1287
  // Smart ID routing: a bare query that resolves to one session renders
1172
1288
  // directly. If nothing matches in the scoped window and the query looks
1173
1289
  // like a session ID, widen to global scope (incl. Claude /resume history).
1174
- if (searchQuery) {
1290
+ //
1291
+ // Exception: a PEER answering a parent's `--json` locate sweep
1292
+ // (AGENTS_SESSIONS_LOCAL=1) must return the SessionMeta[] ROW for the id, not
1293
+ // render its transcript — the sweep parses an array (parseRemoteList). So when
1294
+ // we are that peer and --json is set, skip the single-session short-circuit and
1295
+ // fall through to the array-emitting --json block below.
1296
+ const answeringJsonSweep = options.json === true && process.env[NO_FANOUT_ENV] === '1';
1297
+ if (searchQuery && !answeringJsonSweep) {
1175
1298
  const idMatches = resolveSessionById(sessions, searchQuery);
1176
1299
  if (idMatches.length === 1) {
1177
1300
  await renderSession(idMatches[0], mode, filterOpts, options);
1178
1301
  return;
1179
1302
  }
1180
1303
  if (idMatches.length === 0 && looksLikeSessionId(searchQuery)) {
1181
- await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, redact: options.redact });
1304
+ await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, redact: options.redact, local: options.local, hosts: options.host });
1182
1305
  return;
1183
1306
  }
1184
1307
  }
1185
1308
  if (options.json) {
1186
- const filtered = searchQuery ? filterSessionsByQuery(sessions, searchQuery) : sessions;
1309
+ // An id-shaped query resolves by id ONLY — the same rule the render path
1310
+ // uses (resolveSessionQuery). Without this a `--json <uuid>` (as issued by
1311
+ // the fleet locate sweep, which runs `sessions <uuid> --json --local` on
1312
+ // each peer) would fall to FTS content search and return every transcript
1313
+ // that merely MENTIONS the id, defeating exact remote resolution. A genuine
1314
+ // search phrase keeps the ranked metadata+content path.
1315
+ const filtered = searchQuery
1316
+ ? resolveSessionQuery(sessions, searchQuery).matches
1317
+ : sessions;
1187
1318
  process.stdout.write(serializeSessionsJson(filtered));
1188
1319
  return;
1189
1320
  }
@@ -1271,15 +1402,6 @@ async function sessionsAction(query, options) {
1271
1402
  process.exit(1);
1272
1403
  }
1273
1404
  }
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
1405
  function teamTag(session) {
1284
1406
  const origin = session.teamOrigin;
1285
1407
  if (!origin)
@@ -1301,12 +1423,12 @@ function metaSignals(s) {
1301
1423
  };
1302
1424
  }
1303
1425
  /** One flat table row:
1304
- * shortId · agent · version · project · [glyph] label·doing · [ticket] · [wt] · time
1426
+ * shortId · agent · version · model · project · [glyph] label·doing · [ticket] · [wt] · time
1305
1427
  * `doing` is the live preview when running, else the topic. The `ticket` column
1306
1428
  * (tracker/PR ref, pulled out of the badge blob so refs align) is only rendered
1307
1429
  * when `showTicket` — otherwise a listing with no refs would waste a column of
1308
1430
  * dashes and needlessly truncate the topic. Worktree stays a trailing badge. */
1309
- function flatSessionRow(session, live, showTicket = false, cols = {}) {
1431
+ export function flatSessionRow(session, live, showTicket = false, cols = {}) {
1310
1432
  const agentColor = colorAgent(session.agent);
1311
1433
  const when = formatRelativeTime(session.lastActivity ?? session.timestamp);
1312
1434
  const project = session.project || '-';
@@ -1329,19 +1451,31 @@ function flatSessionRow(session, live, showTicket = false, cols = {}) {
1329
1451
  : '';
1330
1452
  const TICKET_W = 10;
1331
1453
  const ticketCell = showTicket
1332
- ? chalk.blue(padToWidth(truncateToWidth(ticketLabel(session) || '-', TICKET_W), TICKET_W + 1))
1454
+ ? chalk.blue(linkTicketCell(session, padToWidth(truncateToWidth(ticketLabel(session) || '-', TICKET_W), TICKET_W + 1)))
1333
1455
  : '';
1456
+ // Live status word (working / waiting / idle) next to the glyph — the default
1457
+ // list is no longer a bare glyph. Empty (zero width) for resting rows.
1458
+ const { cell: statusCell, width: statusW } = liveStatusCell(live);
1334
1459
  const glyphW = glyph ? 2 : 0;
1335
1460
  const machineW = cols.showMachine ? machineColW : 0;
1336
1461
  const ticketW = showTicket ? TICKET_W + 1 : 0;
1337
1462
  const wtW = wt ? stringWidth(wt) + 1 : 0;
1338
- const topicW = Math.max(16, terminalWidth() - (10 + 9 + 8 + 16) - glyphW - machineW - ticketW - wtW - stringWidth(when) - 1);
1463
+ const width = terminalWidth();
1464
+ const requestedModelW = cols.showModel ? (cols.modelWidth ?? PICKER_MODEL_MAX) : 0;
1465
+ const fixedW = (10 + 9 + 8 + 16) + glyphW + statusW + machineW + ticketW + wtW + stringWidth(when) + 1;
1466
+ const modelSlack = width - fixedW - 16;
1467
+ const modelW = requestedModelW <= modelSlack
1468
+ ? requestedModelW
1469
+ : modelSlack >= PICKER_MODEL_MIN ? modelSlack : 0;
1470
+ const topicW = Math.max(16, width - fixedW - modelW);
1339
1471
  return (chalk.white(padToWidth(truncateToWidth(session.shortId, 9), 10)) +
1340
1472
  agentColor(padToWidth(truncateToWidth(session.agent, 8), 9)) +
1341
1473
  chalk.yellow(padToWidth(truncateToWidth(session.version || '-', 7), 8)) +
1474
+ (modelW ? chalk.yellow(padToWidth(truncateToWidth(modelLabel(session.model), modelW - 1), modelW)) : '') +
1342
1475
  machineCell +
1343
- chalk.cyan(padToWidth(truncateToWidth(project, 14), 16)) +
1476
+ chalk.cyan(linkCwdCell(session, padToWidth(truncateToWidth(project, 14), 16))) +
1344
1477
  (glyph ? glyph + ' ' : '') +
1478
+ statusCell +
1345
1479
  renderTopicCell(label, doing, '', topicW, topicW) +
1346
1480
  ticketCell +
1347
1481
  (wt ? wt + ' ' : '') +
@@ -1362,13 +1496,15 @@ function treeSessionRow(session, live) {
1362
1496
  const badges = signalBadges(metaSignals(session));
1363
1497
  const badgeW = badges ? stringWidth(badges) + 1 : 0;
1364
1498
  const head = label ? `${label} · ${topic}` : topic;
1499
+ const { cell: statusCell, width: statusW } = liveStatusCell(live);
1365
1500
  const glyphW = glyph ? 2 : 0;
1366
- const topicW = Math.max(12, terminalWidth() - (2 + 9 + 8) - glyphW - badgeW - stringWidth(when) - 1);
1501
+ const topicW = Math.max(12, terminalWidth() - (2 + 9 + 8) - glyphW - statusW - badgeW - stringWidth(when) - 1);
1367
1502
  return (' ' +
1368
1503
  chalk.dim(padToWidth(session.shortId, 9)) +
1369
1504
  agentColor(padToWidth(truncateToWidth(session.agent, 7), 8)) +
1370
1505
  (badges ? badges + ' ' : '') +
1371
1506
  (glyph ? glyph + ' ' : '') +
1507
+ statusCell +
1372
1508
  padToWidth(chalk.white(truncateToWidth(head, topicW)), topicW) +
1373
1509
  ' ' + chalk.gray(when));
1374
1510
  }
@@ -1485,7 +1621,9 @@ function printSessionTable(sessions, hiddenCount = 0, tree = false, liveIndex) {
1485
1621
  console.log();
1486
1622
  first = false;
1487
1623
  const group = byDir.get(key);
1488
- console.log(`${chalk.cyan.bold(shortCwd(key))} ${chalk.gray(`(${group.length})`)}`);
1624
+ const cwd = group.find((s) => s.cwd && !s._remote)?.cwd;
1625
+ const header = cwd ? linkPath(cwd, shortCwd(key)) : shortCwd(key);
1626
+ console.log(`${chalk.cyan.bold(header)} ${chalk.gray(`(${group.length})`)}`);
1489
1627
  for (const s of group)
1490
1628
  console.log(treeSessionRow(s, liveIndex?.get(s.id)));
1491
1629
  }
@@ -1681,12 +1819,18 @@ function renderTopicCell(label, topic, query, visibleWidth, paddedWidth) {
1681
1819
  const PICKER_MACHINE_W = 11;
1682
1820
  const PICKER_MACHINE_MIN = 8;
1683
1821
  const PICKER_MACHINE_MAX = 18;
1822
+ const PICKER_MODEL_MIN = 6;
1823
+ const PICKER_MODEL_MAX = 13;
1684
1824
  /** Column width that shows every compacted hostname in `machines` whole (one
1685
1825
  * trailing space for separation), bounded by MIN/MAX. */
1686
1826
  function machineColumnWidth(machines, label) {
1687
1827
  const widest = machines.reduce((w, m) => Math.max(w, stringWidth(label(m))), 0);
1688
1828
  return Math.min(PICKER_MACHINE_MAX, Math.max(PICKER_MACHINE_MIN, widest + 1));
1689
1829
  }
1830
+ function modelColumnWidth(sessions) {
1831
+ const widest = sessions.reduce((width, session) => (Math.max(width, session.model ? stringWidth(modelLabel(session.model)) : 0)), 0);
1832
+ return Math.min(PICKER_MODEL_MAX, Math.max(PICKER_MODEL_MIN, widest + 1));
1833
+ }
1690
1834
  /**
1691
1835
  * Compact display form for machine ids: strip the longest shared dash-delimited
1692
1836
  * prefix so "yosemite-s0"/"yosemite-s1" read as "s0"/"s1" while unrelated ids
@@ -1722,10 +1866,28 @@ export function pickerColumnsFor(sessions) {
1722
1866
  showMachine: distinct.length > 1,
1723
1867
  machineLabel,
1724
1868
  machineWidth: machineColumnWidth(distinct, machineLabel),
1869
+ showModel: sessions.some((s) => !!s.model),
1870
+ modelWidth: modelColumnWidth(sessions),
1725
1871
  showTicket: sessions.some((s) => ticketLabel(s) !== ''),
1726
1872
  };
1727
1873
  }
1728
- export function formatPickerLabel(s, query, cols = {}, ssh) {
1874
+ /** Width of the host-program column (`tmux→ghostty` is the long realistic case). */
1875
+ const PICKER_HOST_W = 14;
1876
+ /**
1877
+ * Which program a live session is running in, for the picker's host column: the
1878
+ * immediate host app (`codium`, `ghostty`, `tmux`, `iterm`, …) and — when a tmux
1879
+ * session is currently being watched through a different app — the app it is
1880
+ * viewed in, as `tmux→ghostty`. A tmux session with no attached client stays a
1881
+ * bare `tmux`, which is exactly "running detached". Empty when the session has no
1882
+ * resolvable host (cloud rows, an unreadable process env).
1883
+ */
1884
+ export function liveHostLabel(a) {
1885
+ if (!a?.host)
1886
+ return '';
1887
+ const viewer = a.viewingIn?.app;
1888
+ return viewer && viewer !== a.host ? `${a.host}→${viewer}` : a.host;
1889
+ }
1890
+ export function formatPickerLabel(s, query, cols = {}, ssh, host = '') {
1729
1891
  const agentColor = colorAgent(s.agent);
1730
1892
  const when = formatRelativeTime(s.lastActivity ?? s.timestamp);
1731
1893
  const project = s.project || '-';
@@ -1750,18 +1912,29 @@ export function formatPickerLabel(s, query, cols = {}, ssh) {
1750
1912
  const ticketCell = cols.showTicket
1751
1913
  ? chalk.blue(padRight(truncate(ticketLabel(s) || '-', TICKET_W), TICKET_W + 1))
1752
1914
  : '';
1915
+ // Which terminal/editor the session runs in — a tab in Ghostty vs a VS Code
1916
+ // panel vs a detached tmux pane is the thing you need to know to go find it.
1917
+ const hostCell = cols.showHost
1918
+ ? chalk.gray(padRight(truncate(host || '-', PICKER_HOST_W - 1), PICKER_HOST_W))
1919
+ : '';
1753
1920
  // The picker prepends a gutter (cursor, plus a checkbox in multi-select mode);
1754
1921
  // reserve it, plus the conditional columns, so the topic shrinks to fit and
1755
1922
  // rows never wrap.
1756
1923
  const gutter = cols.gutter ?? 2;
1757
1924
  const machineColW = cols.showMachine ? machineW : 0;
1758
1925
  const ticketW = cols.showTicket ? TICKET_W + 1 : 0;
1926
+ const hostW = cols.showHost ? PICKER_HOST_W : 0;
1759
1927
  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)) +
1928
+ const topicW = Math.max(16, terminalWidth() - gutter - (10 + 9 + 8 + 16) - machineColW - hostW - ticketW - wtW - sshW - stringWidth(when) - 1);
1929
+ return (
1930
+ // Truncated, not just padded: an indexed shortId is always 8 chars, but a
1931
+ // live row with no session id is named by its pid or cloud task, which can
1932
+ // run past the column and shunt every later column out of alignment.
1933
+ chalk.white(padRight(truncate(s.shortId, 9), 10)) +
1762
1934
  agentColor(padRight(truncate(s.agent, 8), 9)) +
1763
1935
  chalk.yellow(padRight(truncate(versionStr, 7), 8)) +
1764
1936
  machineCell +
1937
+ hostCell +
1765
1938
  chalk.cyan(padRight(truncate(project, 14), 16)) +
1766
1939
  sshSeg +
1767
1940
  renderTopicCell(label, topic, query, topicW, topicW) +
@@ -1827,7 +2000,25 @@ function warnNoPeerTarget(machine, session) {
1827
2000
  console.log(chalk.yellow(`Session ${session.shortId} lives on ${machine}, which isn't a reachable device right now.`));
1828
2001
  console.log(chalk.gray(`Register/wake it (ag devices), or run there: agents ssh ${machine}`));
1829
2002
  }
2003
+ /**
2004
+ * Row-id prefix for a live session whose agent has not reported a session id yet
2005
+ * (a booting harness, a queued teammate). The browser lists these so nothing live
2006
+ * is hidden, but they address no transcript — {@link isIdlessLiveRow} is what the
2007
+ * pick handler checks before trying to read or resume one.
2008
+ */
2009
+ export const LIVE_ROW_PREFIX = 'live:';
2010
+ export function isIdlessLiveRow(s) {
2011
+ return s.id.startsWith(LIVE_ROW_PREFIX);
2012
+ }
1830
2013
  export async function handlePickedSession(picked) {
2014
+ // A live row with no session id has no transcript to open and no id to resume
2015
+ // by; say where the process is instead of dead-ending on an unreadable path.
2016
+ if (isIdlessLiveRow(picked.session)) {
2017
+ const where = picked.session.machine ? ` on ${picked.session.machine}` : '';
2018
+ console.log(chalk.yellow(`This session hasn't reported a session id yet — nothing to open${where}.`));
2019
+ console.log(chalk.gray(`Watch for it with: agents sessions --active${picked.session.machine ? ` --host ${picked.session.machine}` : ''}`));
2020
+ return;
2021
+ }
1831
2022
  // A session on another machine is read/resumed ON that machine over SSH — its
1832
2023
  // transcript and agent binary live there. Both actions execute on the peer
1833
2024
  // (not a local `--host` hop, which would discover locally and dead-end for a
@@ -2403,6 +2594,21 @@ async function renderOneSession(query, mode, scope) {
2403
2594
  }
2404
2595
  }
2405
2596
  else if (byId) {
2597
+ // Not on this machine. A UUID names ONE session, so before giving up ask
2598
+ // the fleet — the session may live only on a peer (the whole point of
2599
+ // RUSH-2024). Skip the sweep when the caller pinned --local, or when we
2600
+ // are ourselves a peer answering a parent's sweep (AGENTS_SESSIONS_LOCAL),
2601
+ // so a locate never recurses. On a single remote hit we hand rendering to
2602
+ // that peer; a multi-host hit surfaces the conflict; a miss keeps the
2603
+ // local not-found message. No FTS fallback either way.
2604
+ if (shouldFanOutForId(query, scope.local)) {
2605
+ const outcome = await resolveSessionAcrossFleet(query, mode, scope.hosts);
2606
+ if (outcome === 'rendered')
2607
+ return;
2608
+ if (outcome === 'conflict')
2609
+ process.exit(1);
2610
+ // 'not-found' falls through to the local message below.
2611
+ }
2406
2612
  notFoundByIdMessage(query).forEach(l => console.error(l));
2407
2613
  process.exit(1);
2408
2614
  }
@@ -2441,6 +2647,123 @@ async function renderOneSession(query, mode, scope) {
2441
2647
  process.exit(1);
2442
2648
  }
2443
2649
  }
2650
+ /**
2651
+ * Whether a missed local id lookup should widen to a cross-machine sweep.
2652
+ *
2653
+ * Gate (all must hold):
2654
+ * - the query is id-shaped (`looksLikeSessionId`) — only an identifier resolves
2655
+ * across the fleet; a search phrase never does.
2656
+ * - not `--local` — the caller opted out of cross-machine lookup (deterministic
2657
+ * local behavior for scripts, RUSH-2024 acceptance: "--local still restricts").
2658
+ * - `AGENTS_SESSIONS_LOCAL` is unset — we are not ourselves a peer answering a
2659
+ * parent's sweep, so a locate can never recurse (RUSH-2024: avoid double-fan-out).
2660
+ *
2661
+ * Pure + exported so the gate is unit-tested without driving discovery / SSH.
2662
+ */
2663
+ export function shouldFanOutForId(query, local) {
2664
+ if (local === true)
2665
+ return false;
2666
+ if (process.env[NO_FANOUT_ENV] === '1')
2667
+ return false;
2668
+ return looksLikeSessionId(query);
2669
+ }
2670
+ /** The render-mode flag a peer's `agents sessions <id>` must carry so the remote
2671
+ * summary matches the mode the user asked for. `summary` is the peer's default,
2672
+ * so it needs no flag; the others map 1:1 to a CLI flag. */
2673
+ function modeFlag(mode) {
2674
+ if (mode === 'markdown')
2675
+ return '--markdown';
2676
+ if (mode === 'json')
2677
+ return '--json';
2678
+ return undefined; // summary — the peer's default render
2679
+ }
2680
+ /** Group a fleet sweep's rows to the DISTINCT machines that hold the id. Each
2681
+ * peer answered `sessions <id> --json --local`, which (post-fix) id-resolves and
2682
+ * so returns the matching row(s); a peer with a synced MIRROR of the same id can
2683
+ * emit more than one row, so we keep the first per machine. Rows the peer somehow
2684
+ * returned that do NOT match the id (defensive against version skew) are dropped
2685
+ * so a stray content hit can never masquerade as an exact resolution. */
2686
+ export function fleetHitsById(rows, id) {
2687
+ const q = id.trim().toLowerCase();
2688
+ const byMachine = new Map();
2689
+ for (const s of rows) {
2690
+ const machine = s.machine;
2691
+ if (!machine)
2692
+ continue; // an untagged row can't be routed back to a peer
2693
+ if (s.id?.toLowerCase() !== q)
2694
+ continue; // exact id only — never a content hit
2695
+ if (!byMachine.has(machine))
2696
+ byMachine.set(machine, s);
2697
+ }
2698
+ return Array.from(byMachine.entries()).map(([machine, session]) => ({ machine, session }));
2699
+ }
2700
+ /**
2701
+ * Locate a full session id across the online fleet and render it from the machine
2702
+ * that holds it. The local disk already missed; this fans `sessions <id> --json
2703
+ * --all` out to every registered online peer (or the explicit `hosts` set),
2704
+ * groups the rows to distinct machines, then:
2705
+ *
2706
+ * - exactly one machine → delegate rendering to that peer via `runOnPeer`
2707
+ * (its transcript and agent binary live there — a local `--host` hop would
2708
+ * re-discover locally and dead-end), returning `'rendered'`.
2709
+ * - more than one machine → print the conflict with machine labels so the user
2710
+ * can disambiguate with `--device <host>`, returning `'conflict'`.
2711
+ * - none → `'not-found'`, letting the caller print the local
2712
+ * "no session on this machine" message.
2713
+ *
2714
+ * No fuzzy/content fallback: the sweep forwards a UUID, each peer id-resolves it,
2715
+ * and `fleetHitsById` drops anything that isn't an exact id match.
2716
+ */
2717
+ export async function resolveSessionAcrossFleet(id, mode, hosts, deps = { gatherRemoteList, runOnPeer }) {
2718
+ const spinner = isInteractiveTerminal() ? ora('Searching the fleet...').start() : null;
2719
+ let hits;
2720
+ try {
2721
+ // Force whole-index scope (--all): the peer runs in its SSH-login home dir,
2722
+ // whose cwd would otherwise silently narrow the lookup and hide the row.
2723
+ // --json so each peer answers a parseable array; --local so it answers for
2724
+ // itself and never re-fans-out (belt-and-suspenders with the parent's
2725
+ // AGENTS_SESSIONS_LOCAL, which remote-list also sets on the peer).
2726
+ const forwarded = ['sessions', id, '--json', '--all', '--local'];
2727
+ const { sessions } = await deps.gatherRemoteList(forwarded, hosts);
2728
+ hits = fleetHitsById(sessions, id);
2729
+ }
2730
+ catch {
2731
+ // A fan-out failure is not an exact resolution — treat as not-found so the
2732
+ // caller prints the honest local message rather than a half-answer.
2733
+ hits = [];
2734
+ }
2735
+ finally {
2736
+ spinner?.stop();
2737
+ }
2738
+ if (hits.length === 0)
2739
+ return 'not-found';
2740
+ if (hits.length > 1) {
2741
+ console.error(chalk.red(`Session ${id} exists on multiple machines:`));
2742
+ for (const h of hits) {
2743
+ const s = h.session;
2744
+ const label = s.label ?? s.topic ?? '';
2745
+ console.error(chalk.cyan(` ${h.machine}`) + chalk.gray(` ${s.agent}${s.version ? ` ${s.version}` : ''} ${label}`));
2746
+ }
2747
+ console.error(chalk.gray(`Pick one with: agents sessions ${id} --device <host>`));
2748
+ return 'conflict';
2749
+ }
2750
+ const { machine } = hits[0];
2751
+ // Render the remote summary by re-running `sessions <id>` ON the peer. --local
2752
+ // keeps that render on the peer (it owns the transcript); the mode flag matches
2753
+ // the mode the user asked for. No TTY: a summary/markdown/json render is a
2754
+ // one-shot capture, not an interactive resume.
2755
+ const peerArgs = ['sessions', id, '--local'];
2756
+ const flag = modeFlag(mode);
2757
+ if (flag)
2758
+ peerArgs.push(flag);
2759
+ const result = await deps.runOnPeer(peerArgs, machine);
2760
+ if (result === 'no-target') {
2761
+ console.error(chalk.red(`Session ${id} is on ${machine}, but it is not a reachable registered device.`));
2762
+ console.error(chalk.gray('Register it with `agents devices` or run the command on that machine.'));
2763
+ return 'conflict'; // a definitive answer (found, un-renderable) — do NOT fall to the local not-found line
2764
+ }
2765
+ return 'rendered';
2766
+ }
2444
2767
  /** Register the `agents sessions` command with all its options and help text. */
2445
2768
  export function registerSessionsCommands(program) {
2446
2769
  const sessionsCmd = program
@@ -2455,7 +2778,7 @@ export function registerSessionsCommands(program) {
2455
2778
  .option('--antigravity', 'Shorthand for --agent antigravity')
2456
2779
  .option('--grok', 'Shorthand for --agent grok')
2457
2780
  .option('--opencode', 'Shorthand for --agent opencode')
2458
- .option('--all', 'Include sessions from every directory (not just current project)')
2781
+ .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
2782
  .option('--unmanaged', "Also show sessions from your own ~/.<agent> installs (hidden once agents-cli manages that agent)")
2460
2783
  .option('--teams', 'Include team-spawned sessions (hidden by default)')
2461
2784
  .option('--routine', 'Show only sessions archived from routine runs')