@phnx-labs/agents-cli 1.20.76 → 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 (119) hide show
  1. package/CHANGELOG.md +264 -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 +87 -19
  7. package/dist/commands/repo.js +8 -0
  8. package/dist/commands/routines.js +275 -15
  9. package/dist/commands/secrets.js +65 -17
  10. package/dist/commands/sessions-browser.d.ts +45 -3
  11. package/dist/commands/sessions-browser.js +128 -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 +28 -1
  15. package/dist/commands/sessions-picker.js +222 -15
  16. package/dist/commands/sessions.d.ts +117 -4
  17. package/dist/commands/sessions.js +350 -62
  18. package/dist/commands/ssh.d.ts +13 -0
  19. package/dist/commands/ssh.js +48 -3
  20. package/dist/index.js +5 -6
  21. package/dist/lib/activity.d.ts +5 -4
  22. package/dist/lib/activity.js +450 -36
  23. package/dist/lib/agents.d.ts +0 -21
  24. package/dist/lib/agents.js +0 -37
  25. package/dist/lib/auto-pull.d.ts +16 -8
  26. package/dist/lib/auto-pull.js +23 -28
  27. package/dist/lib/cloud/session-index.js +2 -2
  28. package/dist/lib/daemon.d.ts +9 -32
  29. package/dist/lib/daemon.js +70 -84
  30. package/dist/lib/devices/fleet-divergence.d.ts +101 -0
  31. package/dist/lib/devices/fleet-divergence.js +188 -0
  32. package/dist/lib/devices/fleet-inventory.d.ts +19 -0
  33. package/dist/lib/devices/fleet-inventory.js +57 -0
  34. package/dist/lib/devices/fleet.d.ts +16 -1
  35. package/dist/lib/devices/fleet.js +10 -2
  36. package/dist/lib/devices/health-report.d.ts +10 -2
  37. package/dist/lib/devices/health-report.js +32 -1
  38. package/dist/lib/events.d.ts +1 -1
  39. package/dist/lib/exec.d.ts +16 -0
  40. package/dist/lib/exec.js +27 -4
  41. package/dist/lib/feed.d.ts +7 -1
  42. package/dist/lib/feed.js +96 -6
  43. package/dist/lib/git.d.ts +13 -0
  44. package/dist/lib/git.js +102 -4
  45. package/dist/lib/heal.d.ts +7 -4
  46. package/dist/lib/heal.js +10 -22
  47. package/dist/lib/hosts/dispatch.d.ts +9 -0
  48. package/dist/lib/hosts/dispatch.js +24 -4
  49. package/dist/lib/hosts/passthrough.js +7 -2
  50. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  51. package/dist/lib/hosts/remote-cmd.js +33 -8
  52. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  53. package/dist/lib/hosts/remote-session-id.js +84 -0
  54. package/dist/lib/hosts/run-target.d.ts +4 -0
  55. package/dist/lib/hosts/run-target.js +5 -1
  56. package/dist/lib/hosts/session-index.js +3 -3
  57. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
  58. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  59. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  60. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  61. package/dist/lib/menubar/install-menubar.d.ts +9 -0
  62. package/dist/lib/menubar/install-menubar.js +14 -0
  63. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  64. package/dist/lib/menubar/notify-desktop.js +78 -0
  65. package/dist/lib/overdue.d.ts +4 -5
  66. package/dist/lib/overdue.js +8 -41
  67. package/dist/lib/routine-notify.d.ts +76 -0
  68. package/dist/lib/routine-notify.js +190 -0
  69. package/dist/lib/routines-placement.d.ts +38 -0
  70. package/dist/lib/routines-placement.js +79 -0
  71. package/dist/lib/routines-project.d.ts +97 -0
  72. package/dist/lib/routines-project.js +349 -0
  73. package/dist/lib/routines.d.ts +68 -0
  74. package/dist/lib/routines.js +74 -7
  75. package/dist/lib/runner.d.ts +12 -2
  76. package/dist/lib/runner.js +156 -28
  77. package/dist/lib/sandbox.js +0 -6
  78. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  79. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  80. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  81. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  82. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  83. package/dist/lib/secrets/agent.d.ts +11 -4
  84. package/dist/lib/secrets/agent.js +46 -24
  85. package/dist/lib/secrets/bundles.d.ts +21 -7
  86. package/dist/lib/secrets/bundles.js +82 -38
  87. package/dist/lib/secrets/index.d.ts +11 -2
  88. package/dist/lib/secrets/index.js +21 -9
  89. package/dist/lib/secrets/scope.d.ts +26 -0
  90. package/dist/lib/secrets/scope.js +29 -0
  91. package/dist/lib/secrets/session-store.d.ts +16 -2
  92. package/dist/lib/secrets/session-store.js +118 -15
  93. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  94. package/dist/lib/secrets/unlock-hints.js +36 -0
  95. package/dist/lib/session/active.d.ts +17 -0
  96. package/dist/lib/session/active.js +105 -7
  97. package/dist/lib/session/db.d.ts +43 -0
  98. package/dist/lib/session/db.js +191 -6
  99. package/dist/lib/session/digest.js +126 -21
  100. package/dist/lib/session/discover.d.ts +28 -3
  101. package/dist/lib/session/discover.js +110 -14
  102. package/dist/lib/session/origin-machine.d.ts +18 -0
  103. package/dist/lib/session/origin-machine.js +34 -0
  104. package/dist/lib/session/parse.js +11 -2
  105. package/dist/lib/session/prompt.d.ts +7 -0
  106. package/dist/lib/session/prompt.js +37 -0
  107. package/dist/lib/session/provenance.d.ts +7 -0
  108. package/dist/lib/session/render.js +18 -16
  109. package/dist/lib/session/state.d.ts +5 -0
  110. package/dist/lib/session/state.js +94 -12
  111. package/dist/lib/session/sync/config.js +2 -2
  112. package/dist/lib/session/types.d.ts +25 -1
  113. package/dist/lib/session/types.js +8 -0
  114. package/dist/lib/smart-launch.d.ts +86 -0
  115. package/dist/lib/smart-launch.js +172 -0
  116. package/dist/lib/state.d.ts +7 -3
  117. package/dist/lib/state.js +20 -5
  118. package/dist/lib/types.d.ts +10 -0
  119. package/package.json +1 -1
@@ -26,20 +26,21 @@ 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 } from '../lib/session/render.js';
35
+ import { renderConversationMarkdown, renderSummary, renderSummaryHeader, computeSummaryStats, renderJson, filterEvents, parseRoleList, linkUrl } from '../lib/session/render.js';
36
+ import { linearIssueUrl } from '../lib/session/linear.js';
36
37
  import { renderMarkdown } from '../lib/markdown.js';
37
38
  import { AGENTS, colorAgent, resolveAgentName } from '../lib/agents.js';
38
39
  import { getShimsDir } from '../lib/state.js';
39
40
  import { fuzzyMatch, FUZZY_PRESETS } from '../lib/fuzzy.js';
40
41
  import { resolveVersionAliasLoose } from '../lib/versions.js';
41
42
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
42
- import { sessionPicker, buildPreview } from './sessions-picker.js';
43
+ import { sessionPicker, buildPreview, formatTodoCompact, githubRepoUrlFromCwd, } from './sessions-picker.js';
43
44
  import { setHelpSections } from '../lib/help.js';
44
45
  import { registerSessionsTailCommand } from './sessions-tail.js';
45
46
  import { registerSessionsSyncCommand } from './sessions-sync.js';
@@ -226,15 +227,24 @@ export function cleanPreview(text) {
226
227
  .trim();
227
228
  }
228
229
  /**
229
- * Build the live description for an active session: prefer the state engine's
230
- * preview (the latest turn), then a user label, then the first-prompt topic.
230
+ * Build the live description for an active session: checklist progress (when
231
+ * present) plus the state engine's preview (the latest turn), a user label, or
232
+ * the first-prompt topic. Used by both the flat listing's `doing` cell and as
233
+ * the snippet half of the --active row (identity is layered on in printActiveRow).
234
+ *
235
+ * Covers every ActiveSession context: terminal (interactive), headless, teams,
236
+ * cloud, and sub-agent rows that share the same ActiveSession.todos field.
231
237
  */
232
238
  function buildSessionDescription(s) {
239
+ const todo = formatTodoCompact(s.todos);
233
240
  if (s.context === 'cloud') {
234
- return cleanPreview(s.preview || `${s.cloudProvider ?? ''}${s.cloudTaskId ? ` · ${s.cloudTaskId.slice(0, 12)}` : ''}`);
241
+ const base = s.preview || `${s.cloudProvider ?? ''}${s.cloudTaskId ? ` · ${s.cloudTaskId.slice(0, 12)}` : ''}`;
242
+ return cleanPreview([todo, base].filter(Boolean).join(' · '));
235
243
  }
236
244
  if (s.context === 'teams') {
237
245
  const parts = [s.teamName];
246
+ if (todo)
247
+ parts.push(todo);
238
248
  if (s.preview)
239
249
  parts.push(s.preview);
240
250
  else if (s.label)
@@ -243,8 +253,53 @@ function buildSessionDescription(s) {
243
253
  parts.push(s.topic);
244
254
  return cleanPreview(parts.filter(Boolean).join(' · '));
245
255
  }
246
- // Terminal or headless: prefer the live preview, then label, then topic.
247
- return cleanPreview(s.preview || s.label || s.topic || '');
256
+ // Terminal, headless, or sub-agent: todos + live preview, then label, then topic.
257
+ const base = s.preview || s.label || s.topic || '';
258
+ return cleanPreview([todo, base].filter(Boolean).join(' · '));
259
+ }
260
+ /**
261
+ * Identity + checklist + live snippet for an --active / cross-machine row.
262
+ * Surfaces agent-adjacent identity the flat table already has (label, project)
263
+ * with a clickable project when a GitHub URL is resolvable, then the checklist
264
+ * tally and the latest-turn snippet.
265
+ *
266
+ * Free-text fields are cleaned individually; OSC 8 hyperlinks are applied
267
+ * *after* cleaning so `cleanPreview` does not strip the clickable targets
268
+ * (RUSH-2045 review). Ticket is clickable via {@link signalBadges}, not here,
269
+ * so the id is not printed twice.
270
+ */
271
+ export function formatActiveRowDescription(s) {
272
+ const parts = [];
273
+ const pushText = (t) => {
274
+ const c = t ? cleanPreview(t) : '';
275
+ if (c)
276
+ parts.push(c);
277
+ };
278
+ if (s.context === 'teams' && s.teamName)
279
+ pushText(s.teamName);
280
+ if (s.label)
281
+ pushText(s.label);
282
+ // Project = basename(cwd), same derivation as serializeActiveSessionsForJson.
283
+ const project = s.cwd ? path.basename(s.cwd) : '';
284
+ if (project && project !== s.label && project !== s.teamName) {
285
+ const label = cleanPreview(project);
286
+ const repoUrl = githubRepoUrlFromCwd(s.cwd);
287
+ parts.push(repoUrl ? linkUrl(repoUrl, label) : label);
288
+ }
289
+ const todo = formatTodoCompact(s.todos);
290
+ if (todo)
291
+ parts.push(todo);
292
+ // Latest-turn snippet — avoid repeating label/topic when already used as identity.
293
+ if (s.context === 'cloud') {
294
+ pushText(s.preview || `${s.cloudProvider ?? ''}${s.cloudTaskId ? ` · ${s.cloudTaskId.slice(0, 12)}` : ''}`);
295
+ }
296
+ else if (s.preview) {
297
+ pushText(s.preview);
298
+ }
299
+ else if (!s.label && s.topic) {
300
+ pushText(s.topic);
301
+ }
302
+ return parts.filter(Boolean).join(' · ');
248
303
  }
249
304
  /** Short human word for a session's activity (falls back to the coarse status). */
250
305
  function activityLabel(s) {
@@ -325,10 +380,15 @@ function signalBadges(s) {
325
380
  parts.push(chalk.yellow('ask'));
326
381
  else if (s.awaitingReason === 'permission')
327
382
  parts.push(chalk.yellow('perm'));
328
- if (s.ticket)
329
- parts.push(chalk.cyan(s.ticket.id));
330
- if (s.pr)
331
- parts.push(chalk.blue(`PR#${s.pr.number ?? '?'}`));
383
+ if (s.ticket) {
384
+ // Clickable when Linear workspace is resolvable (same helper as the picker header).
385
+ const url = linearIssueUrl(s.ticket.id);
386
+ parts.push(chalk.cyan(url ? linkUrl(url, s.ticket.id) : s.ticket.id));
387
+ }
388
+ if (s.pr) {
389
+ const label = `PR#${s.pr.number ?? '?'}`;
390
+ parts.push(chalk.blue(s.pr.url ? linkUrl(s.pr.url, label) : label));
391
+ }
332
392
  if (s.worktree)
333
393
  parts.push(chalk.magenta(`wt:${s.worktree.slug}`));
334
394
  return parts.join(' ');
@@ -343,8 +403,10 @@ function signalBadges(s) {
343
403
  function locatorBadge(s) {
344
404
  const p = s.provenance;
345
405
  const parts = [];
406
+ // An ssh-launched session shows where it was launched FROM when the client IP
407
+ // resolves to a registered device (`ssh←zion`); bare `ssh` when it doesn't.
346
408
  if (p?.transport === 'ssh')
347
- parts.push(chalk.red('ssh'));
409
+ parts.push(chalk.red(p.origin ? `ssh←${p.origin.device}` : 'ssh'));
348
410
  if (p?.mux?.kind === 'tmux' && (s.tmuxTarget || p.mux.pane)) {
349
411
  parts.push(chalk.green(s.tmuxTarget ?? p.mux.pane));
350
412
  // For a tmux-hosted session, say which app+tab is looking at it right now
@@ -373,13 +435,17 @@ function locatorBadge(s) {
373
435
  * terminal width so the row never wraps.
374
436
  */
375
437
  function printActiveRow(s, indent) {
438
+ // shortId (8-char) · agent · host · status · badges · identity+todos+snippet
376
439
  const idCol = chalk.dim(padToWidth((s.sessionId?.slice(0, 8)) ?? '-', 9));
377
440
  const kindCol = colorAgent(s.kind)(padToWidth(truncateToWidth(s.kind, 8), 9));
378
441
  const hostCol = chalk.gray(padToWidth(truncateToWidth(s.host ?? '-', 8), 9));
379
442
  const statusCol = statusColor(s.status)(padToWidth(truncateToWidth(activityLabel(s), 8), 9));
380
443
  const fork = s.pidCount && s.pidCount > 1 ? chalk.dim(`×${s.pidCount} `) : '';
381
444
  const badges = (fork ? fork : '') + [signalBadges(s), locatorBadge(s)].filter(Boolean).join(' ');
382
- const desc = buildSessionDescription(s) || '-';
445
+ // Identity (label/project/ticket clickable) + checklist + live snippet.
446
+ // Cross-machine rows use the same path — remote ActiveSession fields arrive
447
+ // via the SSH fan-out already populated (including todos when the peer has them).
448
+ const desc = formatActiveRowDescription(s) || '-';
383
449
  // Fill the remaining width with the preview so nothing wraps under tmux/SSH.
384
450
  const fixed = stringWidth(indent) + 9 + 9 + 9 + 9 + (badges ? stringWidth(badges) + 1 : 0);
385
451
  const room = Math.max(12, terminalWidth() - fixed - 1);
@@ -747,15 +813,18 @@ export function remoteHostsToDial(hosts, self) {
747
813
  return hosts.filter(h => hostToken(h) !== self);
748
814
  }
749
815
  /**
750
- * Render the unified active-session view, grouped by machine. With no `--host`,
751
- * local sessions come from `getActiveSessions()` and (unless `--local`) the
752
- * registered online devices from `ag devices` are folded in over SSH. An
753
- * explicit `--host`/`--device` list SCOPES the view to exactly those machines —
754
- * the local machine is included only when it is itself named — so `--host` is a
755
- * filter, not an addition (matching the non-`--active` listing path). A tip is
756
- * 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).
757
826
  */
758
- async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
827
+ export async function gatherActiveSessions(opts = {}) {
759
828
  const self = machineId();
760
829
  // An explicit --host/--device list scopes the view: seed local sessions only
761
830
  // when no hosts are named, or when this machine is one of the named targets.
@@ -775,6 +844,17 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
775
844
  merged = dedupeByMachineSession([...local, ...remote.sessions]);
776
845
  }
777
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);
778
858
  // --waiting: only sessions blocked on the user. Exits non-zero when any are
779
859
  // present so a supervising agent or hook can poll it as a gate.
780
860
  const sessions = waitingOnly ? merged.filter(s => s.status === 'input_required') : merged;
@@ -957,6 +1037,7 @@ async function sessionsAction(query, options) {
957
1037
  agent: options.agent,
958
1038
  host: options.host,
959
1039
  since: options.since,
1040
+ all: options.all,
960
1041
  }), { local: options.local === true, hosts: options.host });
961
1042
  return;
962
1043
  }
@@ -1038,7 +1119,7 @@ async function sessionsAction(query, options) {
1038
1119
  // When the user explicitly asks to render (via mode flag), resolve the
1039
1120
  // query globally so sessions outside the default cwd/30d window are found.
1040
1121
  if (wantsRender && searchQuery) {
1041
- 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 });
1042
1123
  return;
1043
1124
  }
1044
1125
  // Interactive picker loads a deep pool but shows only recent sessions
@@ -1105,19 +1186,34 @@ async function sessionsAction(query, options) {
1105
1186
  // Smart ID routing: a bare query that resolves to one session renders
1106
1187
  // directly. If nothing matches in the scoped window and the query looks
1107
1188
  // like a session ID, widen to global scope (incl. Claude /resume history).
1108
- 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) {
1109
1197
  const idMatches = resolveSessionById(sessions, searchQuery);
1110
1198
  if (idMatches.length === 1) {
1111
1199
  await renderSession(idMatches[0], mode, filterOpts, options);
1112
1200
  return;
1113
1201
  }
1114
1202
  if (idMatches.length === 0 && looksLikeSessionId(searchQuery)) {
1115
- 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 });
1116
1204
  return;
1117
1205
  }
1118
1206
  }
1119
1207
  if (options.json) {
1120
- 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;
1121
1217
  process.stdout.write(serializeSessionsJson(filtered));
1122
1218
  return;
1123
1219
  }
@@ -1205,15 +1301,6 @@ async function sessionsAction(query, options) {
1205
1301
  process.exit(1);
1206
1302
  }
1207
1303
  }
1208
- /** Whether a query should route to the single-session render rather than the
1209
- * listing. The hex-ish test catches a bare id prefix; `isCompleteSessionId`
1210
- * additionally catches the prefixed whole ids (`session_…`, `ses_…`) that the
1211
- * hex test rejects — without it those never reach the id-only resolution and
1212
- * still content-search. */
1213
- function looksLikeSessionId(query) {
1214
- const trimmed = query.trim();
1215
- return /^[0-9a-f-]{6,}$/i.test(trimmed) || isCompleteSessionId(trimmed);
1216
- }
1217
1304
  function teamTag(session) {
1218
1305
  const origin = session.teamOrigin;
1219
1306
  if (!origin)
@@ -1247,9 +1334,12 @@ function flatSessionRow(session, live, showTicket = false, cols = {}) {
1247
1334
  const tag = originTag(session) || teamTag(session);
1248
1335
  const label = session.label;
1249
1336
  const { glyph, preview } = liveGlyphAndPreview(live);
1250
- // A running session's live preview says what the agent is doing now; a
1251
- // resting one falls back to its opening topic.
1252
- const doing = preview || (tag ? `${tag}${session.topic ?? ''}` : session.topic);
1337
+ // A running session's live preview (via liveGlyphAndPreview buildSessionDescription)
1338
+ // already folds in ActiveSession.todos. For resting / not-live rows, surface
1339
+ // SessionMeta.todos when the scan attached it.
1340
+ const restingTodo = !live ? formatTodoCompact(session.todos) : '';
1341
+ const topicBase = tag ? `${tag}${session.topic ?? ''}` : session.topic;
1342
+ const doing = [restingTodo, preview || topicBase].filter(Boolean).join(' · ') || undefined;
1253
1343
  const wt = session.worktreeSlug ? chalk.magenta(`wt:${session.worktreeSlug}`) : '';
1254
1344
  // The machine column only earns its width when the listing spans more than one
1255
1345
  // box (i.e. the cross-machine fan-out folded remotes in) — same rule and
@@ -1285,7 +1375,11 @@ function treeSessionRow(session, live) {
1285
1375
  const tag = originTag(session) || teamTag(session);
1286
1376
  const label = session.label;
1287
1377
  const { glyph, preview } = liveGlyphAndPreview(live);
1288
- const topic = (preview || (tag ? `${tag}${session.topic ?? ''}` : session.topic)) || '-';
1378
+ // Match flatSessionRow: live preview already folds ActiveSession.todos; resting
1379
+ // rows surface SessionMeta.todos when present (RUSH-2045).
1380
+ const restingTodo = !live ? formatTodoCompact(session.todos) : '';
1381
+ const topicBase = preview || (tag ? `${tag}${session.topic ?? ''}` : session.topic);
1382
+ const topic = [restingTodo, topicBase].filter(Boolean).join(' · ') || '-';
1289
1383
  const badges = signalBadges(metaSignals(session));
1290
1384
  const badgeW = badges ? stringWidth(badges) + 1 : 0;
1291
1385
  const head = label ? `${label} · ${topic}` : topic;
@@ -1652,10 +1746,34 @@ export function pickerColumnsFor(sessions) {
1652
1746
  showTicket: sessions.some((s) => ticketLabel(s) !== ''),
1653
1747
  };
1654
1748
  }
1655
- export function formatPickerLabel(s, query, cols = {}) {
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 = '') {
1656
1766
  const agentColor = colorAgent(s.agent);
1657
1767
  const when = formatRelativeTime(s.lastActivity ?? s.timestamp);
1658
1768
  const project = s.project || '-';
1769
+ // SSH-launch origin (live rows only): mirrors the flat listing's `ssh←<device>`
1770
+ // badge. Rendered as its OWN red segment before the topic cell — folding it into
1771
+ // the topic string loses the colour, because renderTopicCell strips ANSI and
1772
+ // re-whitens every slice. Its width is reserved from the topic budget below
1773
+ // (exactly like `wt`), so the fixed-width columns stay aligned.
1774
+ const sshPlain = ssh ? (ssh.device ? `ssh←${ssh.device} ` : 'ssh ') : '';
1775
+ const sshSeg = sshPlain ? chalk.red(sshPlain) : '';
1776
+ const sshW = sshPlain ? stringWidth(sshPlain) : 0;
1659
1777
  const tag = originTag(s) || teamTag(s);
1660
1778
  const label = s.label;
1661
1779
  const topic = tag ? `${tag}${s.topic ?? ''}` : s.topic;
@@ -1669,19 +1787,31 @@ export function formatPickerLabel(s, query, cols = {}) {
1669
1787
  const ticketCell = cols.showTicket
1670
1788
  ? chalk.blue(padRight(truncate(ticketLabel(s) || '-', TICKET_W), TICKET_W + 1))
1671
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
+ : '';
1672
1795
  // The picker prepends a gutter (cursor, plus a checkbox in multi-select mode);
1673
1796
  // reserve it, plus the conditional columns, so the topic shrinks to fit and
1674
1797
  // rows never wrap.
1675
1798
  const gutter = cols.gutter ?? 2;
1676
1799
  const machineColW = cols.showMachine ? machineW : 0;
1677
1800
  const ticketW = cols.showTicket ? TICKET_W + 1 : 0;
1801
+ const hostW = cols.showHost ? PICKER_HOST_W : 0;
1678
1802
  const wtW = wt ? stringWidth(wt) + 1 : 0;
1679
- const topicW = Math.max(16, terminalWidth() - gutter - (10 + 9 + 8 + 16) - machineColW - ticketW - wtW - stringWidth(when) - 1);
1680
- 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)) +
1681
1809
  agentColor(padRight(truncate(s.agent, 8), 9)) +
1682
1810
  chalk.yellow(padRight(truncate(versionStr, 7), 8)) +
1683
1811
  machineCell +
1812
+ hostCell +
1684
1813
  chalk.cyan(padRight(truncate(project, 14), 16)) +
1814
+ sshSeg +
1685
1815
  renderTopicCell(label, topic, query, topicW, topicW) +
1686
1816
  ticketCell +
1687
1817
  (wt ? wt + ' ' : '') +
@@ -1745,7 +1875,25 @@ function warnNoPeerTarget(machine, session) {
1745
1875
  console.log(chalk.yellow(`Session ${session.shortId} lives on ${machine}, which isn't a reachable device right now.`));
1746
1876
  console.log(chalk.gray(`Register/wake it (ag devices), or run there: agents ssh ${machine}`));
1747
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
+ }
1748
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
+ }
1749
1897
  // A session on another machine is read/resumed ON that machine over SSH — its
1750
1898
  // transcript and agent binary live there. Both actions execute on the peer
1751
1899
  // (not a local `--host` hop, which would discover locally and dead-end for a
@@ -2062,9 +2210,10 @@ function formatSearchMessage(options) {
2062
2210
  /**
2063
2211
  * The single entry point for turning a `sessions <query>` argument into rows.
2064
2212
  *
2065
- * A complete session id resolves by id alone. Anything else keeps the existing
2066
- * ladder: id lookup first (so a short id still wins), then the ranked
2067
- * metadata+content search.
2213
+ * An id-shaped query a complete id OR a hex short-id/prefix (looksLikeSessionId)
2214
+ * resolves by id alone, through the index, and never falls back to content
2215
+ * search (a bare id must not surface every transcript that merely mentions it).
2216
+ * A genuine search phrase keeps the ranked metadata+content search.
2068
2217
  */
2069
2218
  export function resolveSessionQuery(pool, query) {
2070
2219
  // Normalize ONCE here. isCompleteSessionId trims but resolveSessionById does
@@ -2076,12 +2225,16 @@ export function resolveSessionQuery(pool, query) {
2076
2225
  const byIdMatches = resolveSessionById(pool, normalized);
2077
2226
  if (byIdMatches.length > 0)
2078
2227
  return { matches: byIdMatches, byId: true, completeId };
2079
- if (completeId) {
2080
- // The pool is only what discoverSessions walked a minority of the index
2081
- // (measured: 2,798 of 7,614 rows), because it re-reads live agent homes and
2082
- // skips whole classes of indexed session. Declaring absence from the pool
2083
- // alone denied 1,315 sessions whose transcript is on this disk right now.
2084
- // Ask the index itself, the same authoritative lookup `fork` and `exec` use.
2228
+ if (looksLikeSessionId(normalized)) {
2229
+ // Any id-shaped query a complete id OR a bare hex short-id/prefix
2230
+ // resolves by id ONLY, never by content. The pool is a minority of the
2231
+ // index (measured: 2,798 of 7,614 rows) because it re-reads live agent homes
2232
+ // and skips whole classes of indexed session, so a pool miss isn't absence:
2233
+ // ask the index directly, the same authoritative lookup `fork` and `exec`
2234
+ // use. And an id that resolves to nothing must report "no session with that
2235
+ // id" — NOT fall back to fuzzy content search. A short id like "d3470b57"
2236
+ // otherwise surfaces every transcript that merely MENTIONS the string (a
2237
+ // resume prompt echoes the parent id into the body of many later sessions).
2085
2238
  return { matches: findSessionsById(normalized), byId: true, completeId };
2086
2239
  }
2087
2240
  return { matches: filterSessionsByQuery(pool, normalized), byId: false, completeId };
@@ -2237,7 +2390,7 @@ async function renderArtifactsGlobal(query, listAll, name, scope) {
2237
2390
  const { matches: queryMatches, byId, completeId } = resolveSessionQuery(allSessions, query);
2238
2391
  if (queryMatches.length === 0) {
2239
2392
  spinner.stop();
2240
- if (completeId)
2393
+ if (byId)
2241
2394
  notFoundByIdMessage(query).forEach(l => console.error(l));
2242
2395
  else
2243
2396
  console.error(chalk.red(`No session found matching: ${query}`));
@@ -2281,11 +2434,14 @@ async function renderOneSession(query, mode, scope) {
2281
2434
  let queryMatches = resolution.matches;
2282
2435
  let byId = resolution.byId;
2283
2436
  const completeId = resolution.completeId;
2284
- // Widen to the transcript content index only for a genuine search phrase. A
2285
- // complete id is unique, so widening could only ever surface a DIFFERENT
2286
- // session that happens to mention the id which is what made `sessions
2287
- // <uuid>` render an unrelated transcript.
2288
- if (queryMatches.length === 0 && !completeId) {
2437
+ // Widen to the transcript content index only for a genuine search phrase.
2438
+ // ANY id-shaped query (a complete id OR a hex short-id/prefix) names a
2439
+ // specific session; widening could only surface a DIFFERENT session that
2440
+ // happens to MENTION the id — which is what made `sessions <uuid>` render an
2441
+ // unrelated transcript and `sessions <shortid>` list every session that
2442
+ // echoes the id in a resume prompt. Gate on looksLikeSessionId, not just
2443
+ // completeId, so a short id resolves to "no match" rather than fuzzy content.
2444
+ if (queryMatches.length === 0 && !looksLikeSessionId(query)) {
2289
2445
  const contentResults = searchContentIndex(allSessions, query);
2290
2446
  if (contentResults.size > 0) {
2291
2447
  const matchedSessions = Array.from(contentResults.values())
@@ -2312,7 +2468,22 @@ async function renderOneSession(query, mode, scope) {
2312
2468
  process.exit(1);
2313
2469
  }
2314
2470
  }
2315
- else if (completeId) {
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
+ }
2316
2487
  notFoundByIdMessage(query).forEach(l => console.error(l));
2317
2488
  process.exit(1);
2318
2489
  }
@@ -2351,6 +2522,123 @@ async function renderOneSession(query, mode, scope) {
2351
2522
  process.exit(1);
2352
2523
  }
2353
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
+ }
2354
2642
  /** Register the `agents sessions` command with all its options and help text. */
2355
2643
  export function registerSessionsCommands(program) {
2356
2644
  const sessionsCmd = program
@@ -2365,7 +2653,7 @@ export function registerSessionsCommands(program) {
2365
2653
  .option('--antigravity', 'Shorthand for --agent antigravity')
2366
2654
  .option('--grok', 'Shorthand for --agent grok')
2367
2655
  .option('--opencode', 'Shorthand for --agent opencode')
2368
- .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.')
2369
2657
  .option('--unmanaged', "Also show sessions from your own ~/.<agent> installs (hidden once agents-cli manages that agent)")
2370
2658
  .option('--teams', 'Include team-spawned sessions (hidden by default)')
2371
2659
  .option('--routine', 'Show only sessions archived from routine runs')
@@ -2377,10 +2665,10 @@ export function registerSessionsCommands(program) {
2377
2665
  .option('--markdown', 'Render the session as markdown (user, assistant, thinking, tool calls)')
2378
2666
  .option('--no-redact', 'Disable default secret redaction in rendered session output (--markdown and --json)')
2379
2667
  .option('--json', 'Output JSON (session list when browsing, event array when rendering one session)')
2380
- .option('--include <roles>', 'Only include these roles (comma-separated): user, assistant, thinking, tools')
2668
+ .option('--include <roles>', 'Only include these roles (comma-separated): user, assistant, thinking, tools. "user" is genuine user turns only, not harness-injected scaffolding (bash-input, system-reminder)')
2381
2669
  .option('--exclude <roles>', 'Exclude these roles (comma-separated): user, assistant, thinking, tools')
2382
- .option('--first <n>', 'Keep only the first N turns (a turn starts at each user message)')
2383
- .option('--last <n>', 'Keep only the last N turns (a turn starts at each user message)')
2670
+ .option('--first <n>', 'Keep only the first N turns (a turn starts at each genuine user message, not harness-injected scaffolding)')
2671
+ .option('--last <n>', 'Keep only the last N turns (a turn starts at each genuine user message, not harness-injected scaffolding)')
2384
2672
  .option('--artifacts', 'List all files written or edited during a session')
2385
2673
  .option('--artifact <name>', 'Read a specific artifact by filename or path (outputs to stdout)')
2386
2674
  .option('--active', 'Show only sessions running right now across terminals, teams, cloud, and headless agents')