@phnx-labs/agents-cli 1.20.74 → 1.20.77

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 (231) hide show
  1. package/CHANGELOG.md +1055 -0
  2. package/README.md +36 -10
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.js +21 -3
  5. package/dist/commands/attach.d.ts +10 -0
  6. package/dist/commands/attach.js +41 -0
  7. package/dist/commands/computer.js +3 -3
  8. package/dist/commands/detach-core.d.ts +46 -0
  9. package/dist/commands/detach-core.js +61 -0
  10. package/dist/commands/detach.d.ts +5 -0
  11. package/dist/commands/detach.js +171 -0
  12. package/dist/commands/doctor.js +18 -0
  13. package/dist/commands/exec.js +83 -13
  14. package/dist/commands/feed.d.ts +7 -1
  15. package/dist/commands/feed.js +47 -4
  16. package/dist/commands/go.d.ts +9 -2
  17. package/dist/commands/go.js +16 -6
  18. package/dist/commands/harness.d.ts +14 -0
  19. package/dist/commands/harness.js +145 -0
  20. package/dist/commands/import.js +38 -8
  21. package/dist/commands/inspect.js +5 -2
  22. package/dist/commands/profiles.d.ts +20 -0
  23. package/dist/commands/profiles.js +68 -21
  24. package/dist/commands/repo.js +14 -0
  25. package/dist/commands/routines.js +286 -15
  26. package/dist/commands/secrets.js +102 -37
  27. package/dist/commands/sessions-browser.js +12 -2
  28. package/dist/commands/sessions-export.js +19 -5
  29. package/dist/commands/sessions-migrate.d.ts +29 -0
  30. package/dist/commands/sessions-migrate.js +596 -0
  31. package/dist/commands/sessions-picker.d.ts +14 -1
  32. package/dist/commands/sessions-picker.js +184 -16
  33. package/dist/commands/sessions.d.ts +56 -14
  34. package/dist/commands/sessions.js +322 -62
  35. package/dist/commands/setup-computer.js +2 -2
  36. package/dist/commands/ssh.d.ts +13 -0
  37. package/dist/commands/ssh.js +110 -37
  38. package/dist/commands/status.js +10 -2
  39. package/dist/commands/versions.js +15 -6
  40. package/dist/commands/view.d.ts +5 -0
  41. package/dist/commands/view.js +24 -4
  42. package/dist/commands/watchdog.d.ts +7 -2
  43. package/dist/commands/watchdog.js +73 -57
  44. package/dist/index.js +59 -7
  45. package/dist/lib/activity.d.ts +17 -6
  46. package/dist/lib/activity.js +466 -40
  47. package/dist/lib/actor.d.ts +50 -0
  48. package/dist/lib/actor.js +166 -0
  49. package/dist/lib/agent-spec/provider.js +2 -1
  50. package/dist/lib/agent-spec/resolve.js +19 -5
  51. package/dist/lib/agent-spec/types.d.ts +9 -1
  52. package/dist/lib/agents.d.ts +21 -0
  53. package/dist/lib/agents.js +70 -6
  54. package/dist/lib/cloud/codex.d.ts +2 -0
  55. package/dist/lib/cloud/codex.js +14 -3
  56. package/dist/lib/cloud/session-index.d.ts +32 -0
  57. package/dist/lib/cloud/session-index.js +58 -0
  58. package/dist/lib/cloud/store.d.ts +7 -0
  59. package/dist/lib/cloud/store.js +25 -0
  60. package/dist/lib/config-transfer.js +4 -0
  61. package/dist/lib/daemon.d.ts +10 -1
  62. package/dist/lib/daemon.js +114 -11
  63. package/dist/lib/devices/connect.d.ts +15 -1
  64. package/dist/lib/devices/connect.js +15 -1
  65. package/dist/lib/devices/fleet.d.ts +37 -1
  66. package/dist/lib/devices/fleet.js +36 -2
  67. package/dist/lib/devices/health-report.d.ts +38 -0
  68. package/dist/lib/devices/health-report.js +214 -0
  69. package/dist/lib/devices/health.js +4 -1
  70. package/dist/lib/devices/reachability.d.ts +31 -0
  71. package/dist/lib/devices/reachability.js +40 -0
  72. package/dist/lib/devices/registry.d.ts +33 -0
  73. package/dist/lib/devices/registry.js +37 -0
  74. package/dist/lib/devices/resolve-target.d.ts +15 -27
  75. package/dist/lib/devices/resolve-target.js +63 -102
  76. package/dist/lib/devices/sync.d.ts +18 -0
  77. package/dist/lib/devices/sync.js +23 -1
  78. package/dist/lib/devices/tailscale.d.ts +3 -0
  79. package/dist/lib/devices/tailscale.js +1 -0
  80. package/dist/lib/events.d.ts +1 -1
  81. package/dist/lib/exec.d.ts +82 -0
  82. package/dist/lib/exec.js +218 -6
  83. package/dist/lib/feed-post.d.ts +63 -0
  84. package/dist/lib/feed-post.js +204 -0
  85. package/dist/lib/feed.d.ts +7 -1
  86. package/dist/lib/feed.js +96 -6
  87. package/dist/lib/fleet/apply.d.ts +32 -2
  88. package/dist/lib/fleet/apply.js +97 -10
  89. package/dist/lib/fleet/types.d.ts +11 -0
  90. package/dist/lib/fs-walk.d.ts +13 -0
  91. package/dist/lib/fs-walk.js +16 -7
  92. package/dist/lib/heal.d.ts +7 -4
  93. package/dist/lib/heal.js +10 -22
  94. package/dist/lib/hooks.d.ts +1 -0
  95. package/dist/lib/hooks.js +156 -0
  96. package/dist/lib/hosts/dispatch.d.ts +16 -0
  97. package/dist/lib/hosts/dispatch.js +26 -4
  98. package/dist/lib/hosts/passthrough.js +9 -2
  99. package/dist/lib/hosts/reconnect.d.ts +78 -0
  100. package/dist/lib/hosts/reconnect.js +127 -0
  101. package/dist/lib/hosts/registry.d.ts +75 -14
  102. package/dist/lib/hosts/registry.js +205 -30
  103. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  104. package/dist/lib/hosts/remote-cmd.js +32 -8
  105. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  106. package/dist/lib/hosts/remote-session-id.js +84 -0
  107. package/dist/lib/hosts/run-target.d.ts +17 -5
  108. package/dist/lib/hosts/run-target.js +30 -10
  109. package/dist/lib/hosts/session-index.d.ts +18 -1
  110. package/dist/lib/hosts/session-index.js +40 -5
  111. package/dist/lib/hosts/session-marker.d.ts +33 -0
  112. package/dist/lib/hosts/session-marker.js +51 -0
  113. package/dist/lib/hosts/tasks.d.ts +8 -4
  114. package/dist/lib/import.d.ts +2 -0
  115. package/dist/lib/import.js +35 -2
  116. package/dist/lib/menubar/install-menubar.d.ts +29 -0
  117. package/dist/lib/menubar/install-menubar.js +59 -1
  118. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  119. package/dist/lib/menubar/notify-desktop.js +78 -0
  120. package/dist/lib/migrate.d.ts +16 -0
  121. package/dist/lib/migrate.js +36 -0
  122. package/dist/lib/models.d.ts +27 -0
  123. package/dist/lib/models.js +54 -1
  124. package/dist/lib/overdue.d.ts +6 -2
  125. package/dist/lib/overdue.js +10 -36
  126. package/dist/lib/picker.js +4 -1
  127. package/dist/lib/platform/process.d.ts +17 -0
  128. package/dist/lib/platform/process.js +70 -0
  129. package/dist/lib/profiles-presets.js +9 -7
  130. package/dist/lib/profiles.d.ts +31 -0
  131. package/dist/lib/profiles.js +70 -0
  132. package/dist/lib/pty-server.d.ts +2 -10
  133. package/dist/lib/pty-server.js +4 -38
  134. package/dist/lib/registry.d.ts +1 -1
  135. package/dist/lib/registry.js +48 -8
  136. package/dist/lib/rotate.d.ts +18 -0
  137. package/dist/lib/rotate.js +28 -0
  138. package/dist/lib/routine-notify.d.ts +76 -0
  139. package/dist/lib/routine-notify.js +190 -0
  140. package/dist/lib/routines-placement.d.ts +38 -0
  141. package/dist/lib/routines-placement.js +79 -0
  142. package/dist/lib/routines-project.d.ts +97 -0
  143. package/dist/lib/routines-project.js +349 -0
  144. package/dist/lib/routines.d.ts +94 -0
  145. package/dist/lib/routines.js +93 -9
  146. package/dist/lib/runner.d.ts +12 -2
  147. package/dist/lib/runner.js +242 -20
  148. package/dist/lib/sandbox.js +10 -0
  149. package/dist/lib/secrets/account-token.d.ts +20 -0
  150. package/dist/lib/secrets/account-token.js +64 -0
  151. package/dist/lib/secrets/agent.d.ts +74 -4
  152. package/dist/lib/secrets/agent.js +181 -107
  153. package/dist/lib/secrets/bundles.d.ts +54 -6
  154. package/dist/lib/secrets/bundles.js +230 -34
  155. package/dist/lib/secrets/index.d.ts +26 -3
  156. package/dist/lib/secrets/index.js +42 -12
  157. package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
  158. package/dist/lib/secrets/rc-hygiene.js +154 -0
  159. package/dist/lib/secrets/remote.d.ts +7 -4
  160. package/dist/lib/secrets/remote.js +7 -4
  161. package/dist/lib/secrets/session-store.d.ts +6 -2
  162. package/dist/lib/secrets/session-store.js +65 -15
  163. package/dist/lib/secrets/sync-commands.d.ts +21 -0
  164. package/dist/lib/secrets/sync-commands.js +21 -0
  165. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  166. package/dist/lib/secrets/unlock-hints.js +36 -0
  167. package/dist/lib/self-update.d.ts +23 -2
  168. package/dist/lib/self-update.js +86 -5
  169. package/dist/lib/session/active.d.ts +124 -7
  170. package/dist/lib/session/active.js +390 -56
  171. package/dist/lib/session/cloud.js +2 -1
  172. package/dist/lib/session/db.d.ts +54 -0
  173. package/dist/lib/session/db.js +320 -19
  174. package/dist/lib/session/detached.d.ts +30 -0
  175. package/dist/lib/session/detached.js +92 -0
  176. package/dist/lib/session/discover.d.ts +478 -3
  177. package/dist/lib/session/discover.js +1740 -472
  178. package/dist/lib/session/fork.js +2 -1
  179. package/dist/lib/session/hook-sessions.d.ts +43 -0
  180. package/dist/lib/session/hook-sessions.js +135 -0
  181. package/dist/lib/session/linear.d.ts +6 -0
  182. package/dist/lib/session/linear.js +55 -0
  183. package/dist/lib/session/migrate-targets.d.ts +65 -0
  184. package/dist/lib/session/migrate-targets.js +94 -0
  185. package/dist/lib/session/migrations.d.ts +37 -0
  186. package/dist/lib/session/migrations.js +60 -0
  187. package/dist/lib/session/parse.d.ts +18 -0
  188. package/dist/lib/session/parse.js +141 -32
  189. package/dist/lib/session/pid-registry.d.ts +34 -2
  190. package/dist/lib/session/pid-registry.js +49 -2
  191. package/dist/lib/session/prompt.d.ts +7 -0
  192. package/dist/lib/session/prompt.js +37 -0
  193. package/dist/lib/session/provenance.d.ts +7 -0
  194. package/dist/lib/session/render.d.ts +7 -2
  195. package/dist/lib/session/render.js +40 -26
  196. package/dist/lib/session/short-id.d.ts +17 -0
  197. package/dist/lib/session/short-id.js +20 -0
  198. package/dist/lib/session/state.d.ts +4 -0
  199. package/dist/lib/session/state.js +99 -13
  200. package/dist/lib/session/types.d.ts +25 -1
  201. package/dist/lib/session/types.js +8 -0
  202. package/dist/lib/shims.d.ts +1 -1
  203. package/dist/lib/shims.js +20 -6
  204. package/dist/lib/sqlite.d.ts +3 -2
  205. package/dist/lib/sqlite.js +27 -4
  206. package/dist/lib/staleness/detectors/commands.js +1 -1
  207. package/dist/lib/staleness/detectors/workflows.js +13 -2
  208. package/dist/lib/staleness/writers/commands.js +7 -7
  209. package/dist/lib/staleness/writers/workflows.d.ts +4 -2
  210. package/dist/lib/startup/command-registry.d.ts +1 -0
  211. package/dist/lib/startup/command-registry.js +3 -0
  212. package/dist/lib/state.d.ts +8 -4
  213. package/dist/lib/state.js +21 -37
  214. package/dist/lib/teams/agents.d.ts +19 -10
  215. package/dist/lib/teams/agents.js +40 -39
  216. package/dist/lib/types.d.ts +47 -2
  217. package/dist/lib/usage.d.ts +33 -1
  218. package/dist/lib/usage.js +172 -12
  219. package/dist/lib/versions.d.ts +9 -2
  220. package/dist/lib/versions.js +37 -8
  221. package/dist/lib/watchdog/log.d.ts +43 -0
  222. package/dist/lib/watchdog/log.js +69 -0
  223. package/dist/lib/watchdog/routine.d.ts +44 -0
  224. package/dist/lib/watchdog/routine.js +69 -0
  225. package/dist/lib/watchdog/runner.d.ts +51 -7
  226. package/dist/lib/watchdog/runner.js +239 -64
  227. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  228. package/dist/lib/watchdog/watchdog.js +31 -16
  229. package/dist/lib/workflows.d.ts +16 -0
  230. package/dist/lib/workflows.js +110 -1
  231. package/package.json +1 -1
@@ -26,27 +26,33 @@ 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, searchContentIndex, getSessionRoots } from '../lib/session/discover.js';
29
+ import { discoverSessions, countSessionsInScope, resolveSessionById, isCompleteSessionId, searchContentIndex, getSessionRoots } from '../lib/session/discover.js';
30
+ import { findSessionsById } from '../lib/session/db.js';
30
31
  import { filterTeamSessions } from '../lib/session/team-filter.js';
31
32
  import { parseSession } from '../lib/session/parse.js';
32
33
  import { runRemoteSessions, buildForwardedArgs, ensureWholeIndex } from '../lib/session/remote.js';
33
34
  import { formatRelativeTime } from '../lib/session/relative-time.js';
34
- 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';
35
37
  import { renderMarkdown } from '../lib/markdown.js';
36
- import { colorAgent, resolveAgentName } from '../lib/agents.js';
38
+ import { AGENTS, colorAgent, resolveAgentName } from '../lib/agents.js';
39
+ import { getShimsDir } from '../lib/state.js';
37
40
  import { fuzzyMatch, FUZZY_PRESETS } from '../lib/fuzzy.js';
38
41
  import { resolveVersionAliasLoose } from '../lib/versions.js';
39
42
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
40
- import { sessionPicker, buildPreview } from './sessions-picker.js';
43
+ import { sessionPicker, buildPreview, formatTodoCompact, githubRepoUrlFromCwd, } from './sessions-picker.js';
41
44
  import { setHelpSections } from '../lib/help.js';
42
45
  import { registerSessionsTailCommand } from './sessions-tail.js';
43
46
  import { registerSessionsSyncCommand } from './sessions-sync.js';
44
47
  import { registerSessionsResumeCommand } from './sessions-resume.js';
45
48
  import { registerGoCommand } from './go.js';
46
49
  import { registerFocusCommand } from './focus.js';
50
+ import { registerDetachCommand } from './detach.js';
51
+ import { registerAttachCommand } from './attach.js';
47
52
  import { registerSessionsInjectCommand } from './sessions-inject.js';
48
53
  import { registerSessionsExportCommand } from './sessions-export.js';
49
54
  import { registerSessionsImportCommand } from './sessions-import.js';
55
+ import { registerSessionsMigrateCommand, registerSessionsMigrationsCommand } from './sessions-migrate.js';
50
56
  import { runBrowserSessions } from '../lib/browser/sessions-list.js';
51
57
  const SESSION_AGENT_FILTER_HELP = `Filter by agent, e.g. claude, codex, claude@2.0.65`;
52
58
  /**
@@ -176,6 +182,9 @@ function statusColor(status) {
176
182
  case 'idle': return chalk.gray;
177
183
  case 'queued': return chalk.blue;
178
184
  case 'input_required': return chalk.yellow;
185
+ // Alive but un-introspectable (a harness whose transcript we can't parse).
186
+ // Magenta so it never reads as the gray "idle" it used to be faked as.
187
+ case 'unknown': return chalk.magenta;
179
188
  }
180
189
  }
181
190
  function contextColor(context) {
@@ -218,15 +227,24 @@ export function cleanPreview(text) {
218
227
  .trim();
219
228
  }
220
229
  /**
221
- * Build the live description for an active session: prefer the state engine's
222
- * 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.
223
237
  */
224
238
  function buildSessionDescription(s) {
239
+ const todo = formatTodoCompact(s.todos);
225
240
  if (s.context === 'cloud') {
226
- 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(' · '));
227
243
  }
228
244
  if (s.context === 'teams') {
229
245
  const parts = [s.teamName];
246
+ if (todo)
247
+ parts.push(todo);
230
248
  if (s.preview)
231
249
  parts.push(s.preview);
232
250
  else if (s.label)
@@ -235,8 +253,53 @@ function buildSessionDescription(s) {
235
253
  parts.push(s.topic);
236
254
  return cleanPreview(parts.filter(Boolean).join(' · '));
237
255
  }
238
- // Terminal or headless: prefer the live preview, then label, then topic.
239
- 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(' · ');
240
303
  }
241
304
  /** Short human word for a session's activity (falls back to the coarse status). */
242
305
  function activityLabel(s) {
@@ -274,7 +337,10 @@ export function liveGlyphAndPreview(a) {
274
337
  return { glyph: '', preview: '' };
275
338
  const waiting = a.status === 'input_required' || a.activity === 'waiting_input';
276
339
  const running = a.status === 'running' || a.activity === 'working';
277
- const shape = waiting ? '◐' : running ? '●' : '○';
340
+ // `◌` (dotted) = alive but un-introspectable visually distinct from `○` idle
341
+ // so an opaque harness is never mistaken for a finished one.
342
+ const unknown = a.status === 'unknown';
343
+ const shape = waiting ? '◐' : running ? '●' : unknown ? '◌' : '○';
278
344
  return { glyph: statusColor(a.status)(shape), preview: buildSessionDescription(a) };
279
345
  }
280
346
  /**
@@ -285,6 +351,22 @@ export function liveGlyphAndPreview(a) {
285
351
  export function ticketLabel(s) {
286
352
  return s.ticketId ?? (s.prNumber ? `PR#${s.prNumber}` : '');
287
353
  }
354
+ /**
355
+ * The row shape `agents sessions --active --json` emits. RUSH-1981: a watcher
356
+ * joins active sessions on ticketId + project, but the raw ActiveSession nests
357
+ * the ticket (`ticket.id`) and carries no `project` at all — so a naive join
358
+ * silently drops every row. Emit both as flat, always-present top-level keys
359
+ * (null when unknown) alongside the raw fields, so every active row is joinable.
360
+ * `project` uses the same derivation SessionMeta does — basename(cwd) (see
361
+ * discover.ts) — so the active view and the history view join identically.
362
+ */
363
+ export function serializeActiveSessionsForJson(sessions) {
364
+ return sessions.map((s) => ({
365
+ ...s,
366
+ ticketId: s.ticket?.id ?? null,
367
+ project: s.cwd ? path.basename(s.cwd) : null,
368
+ }));
369
+ }
288
370
  /**
289
371
  * Compact, colour-coded badges for the durable/awaiting signals. Text-only (no
290
372
  * emoji, per repo convention): `plan` / `ask` / `perm` for why it's waiting,
@@ -298,10 +380,15 @@ function signalBadges(s) {
298
380
  parts.push(chalk.yellow('ask'));
299
381
  else if (s.awaitingReason === 'permission')
300
382
  parts.push(chalk.yellow('perm'));
301
- if (s.ticket)
302
- parts.push(chalk.cyan(s.ticket.id));
303
- if (s.pr)
304
- 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
+ }
305
392
  if (s.worktree)
306
393
  parts.push(chalk.magenta(`wt:${s.worktree.slug}`));
307
394
  return parts.join(' ');
@@ -316,8 +403,10 @@ function signalBadges(s) {
316
403
  function locatorBadge(s) {
317
404
  const p = s.provenance;
318
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.
319
408
  if (p?.transport === 'ssh')
320
- parts.push(chalk.red('ssh'));
409
+ parts.push(chalk.red(p.origin ? `ssh←${p.origin.device}` : 'ssh'));
321
410
  if (p?.mux?.kind === 'tmux' && (s.tmuxTarget || p.mux.pane)) {
322
411
  parts.push(chalk.green(s.tmuxTarget ?? p.mux.pane));
323
412
  // For a tmux-hosted session, say which app+tab is looking at it right now
@@ -346,13 +435,17 @@ function locatorBadge(s) {
346
435
  * terminal width so the row never wraps.
347
436
  */
348
437
  function printActiveRow(s, indent) {
438
+ // shortId (8-char) · agent · host · status · badges · identity+todos+snippet
349
439
  const idCol = chalk.dim(padToWidth((s.sessionId?.slice(0, 8)) ?? '-', 9));
350
440
  const kindCol = colorAgent(s.kind)(padToWidth(truncateToWidth(s.kind, 8), 9));
351
441
  const hostCol = chalk.gray(padToWidth(truncateToWidth(s.host ?? '-', 8), 9));
352
442
  const statusCol = statusColor(s.status)(padToWidth(truncateToWidth(activityLabel(s), 8), 9));
353
443
  const fork = s.pidCount && s.pidCount > 1 ? chalk.dim(`×${s.pidCount} `) : '';
354
444
  const badges = (fork ? fork : '') + [signalBadges(s), locatorBadge(s)].filter(Boolean).join(' ');
355
- 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) || '-';
356
449
  // Fill the remaining width with the preview so nothing wraps under tmux/SSH.
357
450
  const fixed = stringWidth(indent) + 9 + 9 + 9 + 9 + (badges ? stringWidth(badges) + 1 : 0);
358
451
  const room = Math.max(12, terminalWidth() - fixed - 1);
@@ -573,15 +666,18 @@ async function runRemoteSessionsJson(hosts) {
573
666
  process.stdout.write(serializeSessionsJson(sessions));
574
667
  }
575
668
  /**
576
- * `running N · idle N · waiting N · queued N` for a bucket of sessions (zero
577
- * buckets omitted). Same bucketing as the grand-total summary so per-group
578
- * counts reconcile with the `(total)` beside the header. Empty when nothing.
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
671
+ * per-group counts reconcile with the `(total)` beside the header the `unknown`
672
+ * bucket is what keeps an alive-but-opaque row from silently vanishing from the
673
+ * tally. Empty when nothing.
579
674
  */
580
675
  function groupTally(sessions) {
581
676
  const running = sessions.filter(s => s.status === 'running').length;
582
677
  const idle = sessions.filter(s => s.status === 'idle').length;
583
678
  const waiting = sessions.filter(s => s.status === 'input_required').length;
584
679
  const queued = sessions.filter(s => s.status === 'queued').length;
680
+ const unknown = sessions.filter(s => s.status === 'unknown').length;
585
681
  const parts = [];
586
682
  if (running)
587
683
  parts.push(`${running} running`);
@@ -591,6 +687,8 @@ function groupTally(sessions) {
591
687
  parts.push(`${waiting} waiting`);
592
688
  if (queued)
593
689
  parts.push(`${queued} queued`);
690
+ if (unknown)
691
+ parts.push(`${unknown} unknown`);
594
692
  return parts.join(' · ');
595
693
  }
596
694
  /** Print one machine's workspace tree, indented under its machine header. */
@@ -747,7 +845,7 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
747
845
  // present so a supervising agent or hook can poll it as a gate.
748
846
  const sessions = waitingOnly ? merged.filter(s => s.status === 'input_required') : merged;
749
847
  if (asJson) {
750
- process.stdout.write(JSON.stringify(sessions, null, 2) + '\n');
848
+ process.stdout.write(JSON.stringify(serializeActiveSessionsForJson(sessions), null, 2) + '\n');
751
849
  if (waitingOnly && sessions.length > 0)
752
850
  process.exitCode = 1;
753
851
  return;
@@ -835,10 +933,15 @@ function canonicalSessionsCommand(query, options) {
835
933
  async function renderSessionPreview(query, scope) {
836
934
  const discovered = await discoverSessions({ all: true, cwd: process.cwd(), limit: 5000 });
837
935
  const pool = applyScopeFilters(discovered, scope);
838
- const matches = resolveSessionById(pool, query);
839
- const session = (matches.length > 0 ? matches : filterSessionsByQuery(pool, query))[0];
936
+ const { matches, completeId } = resolveSessionQuery(pool, query);
937
+ const session = matches[0];
840
938
  if (!session) {
841
- console.log(chalk.gray(`No session matches "${query}".`));
939
+ // A complete id that missed is not "no match for this text" — say which, and
940
+ // give the same fleet pointer the render paths give.
941
+ if (completeId)
942
+ notFoundByIdMessage(query).forEach(l => console.log(l));
943
+ else
944
+ console.log(chalk.gray(`No session matches "${query}".`));
842
945
  return;
843
946
  }
844
947
  console.log(buildPreview(session));
@@ -1043,11 +1146,14 @@ async function sessionsAction(query, options) {
1043
1146
  sortBy,
1044
1147
  origin: options.routine ? 'routine' : undefined,
1045
1148
  };
1149
+ let hiddenUnmanaged = 0;
1046
1150
  let sessions = await discoverSessions({
1047
1151
  ...scope,
1048
1152
  limit,
1049
1153
  excludeTeamOrigin: !options.teams,
1050
1154
  onProgress: tracker.onProgress,
1155
+ includeUnmanaged: options.unmanaged,
1156
+ onHiddenUnmanaged: (n) => { hiddenUnmanaged = n; },
1051
1157
  });
1052
1158
  tracker.stop();
1053
1159
  spinner?.stop();
@@ -1121,6 +1227,9 @@ async function sessionsAction(query, options) {
1121
1227
  if (hiddenCount > 0) {
1122
1228
  console.log(chalk.gray(formatTeamHiddenFooter(hiddenCount)));
1123
1229
  }
1230
+ if (hiddenUnmanaged > 0) {
1231
+ console.log(chalk.gray(formatUnmanagedHiddenFooter(hiddenUnmanaged)));
1232
+ }
1124
1233
  return;
1125
1234
  }
1126
1235
  // The grouped project overview is the bare interactive default: a scannable
@@ -1130,7 +1239,7 @@ async function sessionsAction(query, options) {
1130
1239
  const liveIndex = await maybeLiveIndex(options);
1131
1240
  // Per-project row cap is fixed (--limit carries a default of 50 and drives
1132
1241
  // the fetch pool, not the display); `--all` expands every group instead.
1133
- printSessionOverview(sessions, hiddenCount, liveIndex, { perProjectCap: OVERVIEW_ROWS_PER_PROJECT, expand: !!options.all });
1242
+ printSessionOverview(sessions, hiddenCount, liveIndex, { perProjectCap: OVERVIEW_ROWS_PER_PROJECT, expand: !!options.all, hiddenUnmanaged });
1134
1243
  return;
1135
1244
  }
1136
1245
  // --tree / --flat are printed listings, not an interactive pick — render them
@@ -1150,6 +1259,10 @@ async function sessionsAction(query, options) {
1150
1259
  const filtered = searchQuery ? filterSessionsByQuery(sessions, searchQuery) : sessions;
1151
1260
  const liveIndex = await maybeLiveIndex(options);
1152
1261
  printSessionTable(filtered, hiddenCount, options.tree === true, liveIndex);
1262
+ // Every listing path must say what it dropped — a hidden default that stays
1263
+ // silent in one render mode is the failure this footer exists to prevent.
1264
+ if (hiddenUnmanaged > 0)
1265
+ console.log(chalk.gray(formatUnmanagedHiddenFooter(hiddenUnmanaged)));
1153
1266
  }
1154
1267
  catch (err) {
1155
1268
  tracker.stop();
@@ -1158,8 +1271,14 @@ async function sessionsAction(query, options) {
1158
1271
  process.exit(1);
1159
1272
  }
1160
1273
  }
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. */
1161
1279
  function looksLikeSessionId(query) {
1162
- return /^[0-9a-f-]{6,}$/i.test(query.trim());
1280
+ const trimmed = query.trim();
1281
+ return /^[0-9a-f-]{6,}$/i.test(trimmed) || isCompleteSessionId(trimmed);
1163
1282
  }
1164
1283
  function teamTag(session) {
1165
1284
  const origin = session.teamOrigin;
@@ -1194,9 +1313,12 @@ function flatSessionRow(session, live, showTicket = false, cols = {}) {
1194
1313
  const tag = originTag(session) || teamTag(session);
1195
1314
  const label = session.label;
1196
1315
  const { glyph, preview } = liveGlyphAndPreview(live);
1197
- // A running session's live preview says what the agent is doing now; a
1198
- // resting one falls back to its opening topic.
1199
- const doing = preview || (tag ? `${tag}${session.topic ?? ''}` : session.topic);
1316
+ // A running session's live preview (via liveGlyphAndPreview buildSessionDescription)
1317
+ // already folds in ActiveSession.todos. For resting / not-live rows, surface
1318
+ // SessionMeta.todos when the scan attached it.
1319
+ const restingTodo = !live ? formatTodoCompact(session.todos) : '';
1320
+ const topicBase = tag ? `${tag}${session.topic ?? ''}` : session.topic;
1321
+ const doing = [restingTodo, preview || topicBase].filter(Boolean).join(' · ') || undefined;
1200
1322
  const wt = session.worktreeSlug ? chalk.magenta(`wt:${session.worktreeSlug}`) : '';
1201
1323
  // The machine column only earns its width when the listing spans more than one
1202
1324
  // box (i.e. the cross-machine fan-out folded remotes in) — same rule and
@@ -1232,7 +1354,11 @@ function treeSessionRow(session, live) {
1232
1354
  const tag = originTag(session) || teamTag(session);
1233
1355
  const label = session.label;
1234
1356
  const { glyph, preview } = liveGlyphAndPreview(live);
1235
- const topic = (preview || (tag ? `${tag}${session.topic ?? ''}` : session.topic)) || '-';
1357
+ // Match flatSessionRow: live preview already folds ActiveSession.todos; resting
1358
+ // rows surface SessionMeta.todos when present (RUSH-2045).
1359
+ const restingTodo = !live ? formatTodoCompact(session.todos) : '';
1360
+ const topicBase = preview || (tag ? `${tag}${session.topic ?? ''}` : session.topic);
1361
+ const topic = [restingTodo, topicBase].filter(Boolean).join(' · ') || '-';
1236
1362
  const badges = signalBadges(metaSignals(session));
1237
1363
  const badgeW = badges ? stringWidth(badges) + 1 : 0;
1238
1364
  const head = label ? `${label} · ${topic}` : topic;
@@ -1337,6 +1463,8 @@ function printSessionOverview(pool, hiddenCount, liveIndex, opts) {
1337
1463
  console.log(parts.join(chalk.gray(' · ')));
1338
1464
  if (hiddenCount > 0)
1339
1465
  console.log(chalk.gray(formatTeamHiddenFooter(hiddenCount)));
1466
+ if (opts.hiddenUnmanaged)
1467
+ console.log(chalk.gray(formatUnmanagedHiddenFooter(opts.hiddenUnmanaged)));
1340
1468
  }
1341
1469
  function printSessionTable(sessions, hiddenCount = 0, tree = false, liveIndex) {
1342
1470
  if (tree) {
@@ -1597,10 +1725,18 @@ export function pickerColumnsFor(sessions) {
1597
1725
  showTicket: sessions.some((s) => ticketLabel(s) !== ''),
1598
1726
  };
1599
1727
  }
1600
- export function formatPickerLabel(s, query, cols = {}) {
1728
+ export function formatPickerLabel(s, query, cols = {}, ssh) {
1601
1729
  const agentColor = colorAgent(s.agent);
1602
1730
  const when = formatRelativeTime(s.lastActivity ?? s.timestamp);
1603
1731
  const project = s.project || '-';
1732
+ // SSH-launch origin (live rows only): mirrors the flat listing's `ssh←<device>`
1733
+ // badge. Rendered as its OWN red segment before the topic cell — folding it into
1734
+ // the topic string loses the colour, because renderTopicCell strips ANSI and
1735
+ // re-whitens every slice. Its width is reserved from the topic budget below
1736
+ // (exactly like `wt`), so the fixed-width columns stay aligned.
1737
+ const sshPlain = ssh ? (ssh.device ? `ssh←${ssh.device} ` : 'ssh ') : '';
1738
+ const sshSeg = sshPlain ? chalk.red(sshPlain) : '';
1739
+ const sshW = sshPlain ? stringWidth(sshPlain) : 0;
1604
1740
  const tag = originTag(s) || teamTag(s);
1605
1741
  const label = s.label;
1606
1742
  const topic = tag ? `${tag}${s.topic ?? ''}` : s.topic;
@@ -1621,12 +1757,13 @@ export function formatPickerLabel(s, query, cols = {}) {
1621
1757
  const machineColW = cols.showMachine ? machineW : 0;
1622
1758
  const ticketW = cols.showTicket ? TICKET_W + 1 : 0;
1623
1759
  const wtW = wt ? stringWidth(wt) + 1 : 0;
1624
- const topicW = Math.max(16, terminalWidth() - gutter - (10 + 9 + 8 + 16) - machineColW - ticketW - wtW - stringWidth(when) - 1);
1760
+ const topicW = Math.max(16, terminalWidth() - gutter - (10 + 9 + 8 + 16) - machineColW - ticketW - wtW - sshW - stringWidth(when) - 1);
1625
1761
  return (chalk.white(padRight(s.shortId, 10)) +
1626
1762
  agentColor(padRight(truncate(s.agent, 8), 9)) +
1627
1763
  chalk.yellow(padRight(truncate(versionStr, 7), 8)) +
1628
1764
  machineCell +
1629
1765
  chalk.cyan(padRight(truncate(project, 14), 16)) +
1766
+ sshSeg +
1630
1767
  renderTopicCell(label, topic, query, topicW, topicW) +
1631
1768
  ticketCell +
1632
1769
  (wt ? wt + ' ' : '') +
@@ -1720,8 +1857,8 @@ export async function handlePickedSession(picked) {
1720
1857
  * Resume a session in the current terminal — a foreground takeover of this
1721
1858
  * process. Used by the single-select picker and by `sessions resume` when the
1722
1859
  * chosen destination is "in place" (unknown emulator / off-macOS, single pick).
1723
- * Falls back to the current version via `/continue` when the version-pinned
1724
- * binary is missing (ENOENT).
1860
+ * Falls back to the same resume invocation against the current version when the
1861
+ * version-pinned launcher is genuinely missing.
1725
1862
  */
1726
1863
  export async function resumeSessionInPlace(session) {
1727
1864
  const cwd = session.cwd && fs.existsSync(session.cwd)
@@ -1738,11 +1875,17 @@ export async function resumeSessionInPlace(session) {
1738
1875
  // agent shim is a `.cmd`/`.ps1` and, under the shell needed to run it (see
1739
1876
  // spawnResumeCommand), a missing command exits non-zero rather than emitting
1740
1877
  // an ENOENT `error` event — so detect a removed version here instead of
1741
- // relying on that event, keeping the /continue fallback working on every OS.
1742
- if (!findExecutable(resume[0]) && session.version) {
1878
+ // relying on that event, keeping the fallback working on every OS.
1879
+ // `resume[0]` is an absolute alias path when one exists on disk, so only a bare
1880
+ // name still needs a PATH lookup. Checking existsSync first is what keeps an
1881
+ // isolated install (shims deliberately off PATH) out of the fallback.
1882
+ const launcherFound = path.isAbsolute(resume[0])
1883
+ ? fs.existsSync(resume[0])
1884
+ : !!findExecutable(resume[0]);
1885
+ if (!launcherFound && session.version) {
1743
1886
  const fallback = buildFallbackCommand(session);
1744
1887
  if (fallback) {
1745
- console.log(chalk.gray(`Version ${session.version} is not installed. Falling back to current version via /continue...`));
1888
+ console.log(chalk.gray(`Version ${session.version} is not installed. Resuming with the current version instead...`));
1746
1889
  await spawnResumeCommand(fallback, cwd);
1747
1890
  return;
1748
1891
  }
@@ -1819,18 +1962,55 @@ function spawnResumeCommand(cmd, cwd) {
1819
1962
  * If the versioned binary is missing (version was removed), the ENOENT
1820
1963
  * handler in handlePickedSession retries via buildFallbackCommand.
1821
1964
  */
1965
+ /**
1966
+ * The agent's own resume invocation, given whichever launcher we resolved.
1967
+ * Keeping the verb in one place is what lets the version-pinned and fallback
1968
+ * paths stay in agreement — they previously drifted into `/continue`, which is
1969
+ * not a command either CLI has.
1970
+ */
1971
+ function resumeArgv(agent, id, launcher) {
1972
+ switch (agent) {
1973
+ case 'claude': return [launcher, '--resume', id];
1974
+ case 'codex': return [launcher, 'resume', id];
1975
+ case 'opencode': return [launcher, '--session', id];
1976
+ default: return null;
1977
+ }
1978
+ }
1979
+ /**
1980
+ * Absolute path of the on-disk versioned alias, or null when it isn't there.
1981
+ *
1982
+ * Resume must not resolve `<cli>@<version>` by bare name. The shims directory is
1983
+ * deliberately absent from PATH for an isolated install — that is precisely what
1984
+ * `--isolated` promises — so a PATH lookup can never find the alias, and resume
1985
+ * degraded to the fallback 100% of the time for isolated copies. Mirrors the
1986
+ * resolution `buildExecCommand` already does for `agents run`.
1987
+ */
1988
+ function versionedAliasIfPresent(agent, version) {
1989
+ const cli = AGENTS[agent]?.cliCommand ?? agent;
1990
+ const base = path.join(getShimsDir(), `${cli}@${version}`);
1991
+ if (process.platform === 'win32' && fs.existsSync(`${base}.cmd`))
1992
+ return `${base}.cmd`;
1993
+ if (fs.existsSync(base))
1994
+ return base;
1995
+ return null;
1996
+ }
1822
1997
  export function buildResumeCommand(session) {
1823
1998
  switch (session.agent) {
1824
- case 'claude':
1825
- if (session.version)
1826
- return [`claude@${session.version}`, '--resume', session.id];
1827
- return ['claude', '--resume', session.id];
1828
- case 'codex':
1829
- if (session.version)
1830
- return [`codex@${session.version}`, 'resume', session.id];
1831
- return ['codex', 'resume', session.id];
1999
+ // opencode sessions are shared across versions, so resume is deliberately NOT
2000
+ // version-pinned — it always goes through the plain launcher.
1832
2001
  case 'opencode':
1833
- return ['opencode', '--session', session.id];
2002
+ return resumeArgv('opencode', session.id, 'opencode');
2003
+ case 'claude':
2004
+ case 'codex': {
2005
+ const cli = AGENTS[session.agent]?.cliCommand ?? session.agent;
2006
+ if (session.version) {
2007
+ const alias = versionedAliasIfPresent(session.agent, session.version);
2008
+ // Absolute path when the alias exists; otherwise the bare versioned name,
2009
+ // which still resolves for a non-isolated install whose shims are on PATH.
2010
+ return resumeArgv(session.agent, session.id, alias ?? `${cli}@${session.version}`);
2011
+ }
2012
+ return resumeArgv(session.agent, session.id, cli);
2013
+ }
1834
2014
  case 'gemini':
1835
2015
  case 'antigravity':
1836
2016
  case 'openclaw':
@@ -1843,13 +2023,18 @@ export function buildResumeCommand(session) {
1843
2023
  return null;
1844
2024
  }
1845
2025
  }
1846
- /** Fallback resume command when the versioned binary is unavailable (ENOENT). */
2026
+ /**
2027
+ * Fallback when the pinned version really is gone: the same resume invocation
2028
+ * against the current version.
2029
+ *
2030
+ * This used to spawn `<cli> "/continue <id>"`, feeding a slash command into the
2031
+ * TUI as a prompt. Neither CLI has `/continue` — codex documents `/resume` — so
2032
+ * the agent received an unrecognised command and the session was not resumed at
2033
+ * all. Reusing resumeArgv keeps the two paths from drifting apart again.
2034
+ */
1847
2035
  function buildFallbackCommand(session) {
1848
- switch (session.agent) {
1849
- case 'claude': return ['claude', `/continue ${session.id}`];
1850
- case 'codex': return ['codex', `/continue ${session.id}`];
1851
- default: return null;
1852
- }
2036
+ const cli = AGENTS[session.agent]?.cliCommand ?? session.agent;
2037
+ return resumeArgv(session.agent, session.id, cli);
1853
2038
  }
1854
2039
  // ---------------------------------------------------------------------------
1855
2040
  // Cloud session source (--cloud)
@@ -1956,6 +2141,56 @@ function formatSearchMessage(options) {
1956
2141
  return 'Search sessions:';
1957
2142
  return `Search sessions (${filters.join(', ')}):`;
1958
2143
  }
2144
+ /**
2145
+ * The single entry point for turning a `sessions <query>` argument into rows.
2146
+ *
2147
+ * An id-shaped query — a complete id OR a hex short-id/prefix (looksLikeSessionId)
2148
+ * — resolves by id alone, through the index, and never falls back to content
2149
+ * search (a bare id must not surface every transcript that merely mentions it).
2150
+ * A genuine search phrase keeps the ranked metadata+content search.
2151
+ */
2152
+ export function resolveSessionQuery(pool, query) {
2153
+ // Normalize ONCE here. isCompleteSessionId trims but resolveSessionById does
2154
+ // not, so a padded id ("<uuid> ", e.g. pasted from a terminal) would classify
2155
+ // as complete and then miss the id lookup — reporting a session that IS on
2156
+ // this machine as absent.
2157
+ const normalized = query.trim();
2158
+ const completeId = isCompleteSessionId(normalized);
2159
+ const byIdMatches = resolveSessionById(pool, normalized);
2160
+ if (byIdMatches.length > 0)
2161
+ return { matches: byIdMatches, byId: true, completeId };
2162
+ if (looksLikeSessionId(normalized)) {
2163
+ // Any id-shaped query — a complete id OR a bare hex short-id/prefix —
2164
+ // resolves by id ONLY, never by content. The pool is a minority of the
2165
+ // index (measured: 2,798 of 7,614 rows) because it re-reads live agent homes
2166
+ // and skips whole classes of indexed session, so a pool miss isn't absence:
2167
+ // ask the index directly, the same authoritative lookup `fork` and `exec`
2168
+ // use. And an id that resolves to nothing must report "no session with that
2169
+ // id" — NOT fall back to fuzzy content search. A short id like "d3470b57"
2170
+ // otherwise surfaces every transcript that merely MENTIONS the string (a
2171
+ // resume prompt echoes the parent id into the body of many later sessions).
2172
+ return { matches: findSessionsById(normalized), byId: true, completeId };
2173
+ }
2174
+ return { matches: filterSessionsByQuery(pool, normalized), byId: false, completeId };
2175
+ }
2176
+ /** Explain an ambiguous resolution. Only a short id can be lengthened: a complete
2177
+ * id is already maximal, and a search phrase was never an id to begin with. */
2178
+ function ambiguityHint(byId, completeId) {
2179
+ if (completeId)
2180
+ return 'That is already a complete id — these rows share it as a prefix.';
2181
+ return byId
2182
+ ? 'Pass a longer ID to narrow it down.'
2183
+ : 'That matched on text, not an id. Pass a session id, or narrow the search.';
2184
+ }
2185
+ /** Explain a complete-id miss, which no local rephrasing can fix. Echoes the
2186
+ * normalized id so a pasted, padded argument doesn't produce an unrunnable hint. */
2187
+ function notFoundByIdMessage(query) {
2188
+ const id = query.trim();
2189
+ return [
2190
+ chalk.red(`No session with id ${id} on this machine.`),
2191
+ chalk.gray(`Search the fleet with: agents sessions ${id} --device <host>`),
2192
+ ];
2193
+ }
1959
2194
  /** Filter and rank sessions by a multi-term search query across metadata and content. */
1960
2195
  export function filterSessionsByQuery(sessions, query) {
1961
2196
  const trimmed = query?.trim().toLowerCase() || '';
@@ -2086,11 +2321,13 @@ async function renderArtifactsGlobal(query, listAll, name, scope) {
2086
2321
  });
2087
2322
  tracker.stop();
2088
2323
  const allSessions = applyScopeFilters(discovered, scope);
2089
- const matches = resolveSessionById(allSessions, query);
2090
- const queryMatches = matches.length > 0 ? matches : filterSessionsByQuery(allSessions, query);
2324
+ const { matches: queryMatches, byId, completeId } = resolveSessionQuery(allSessions, query);
2091
2325
  if (queryMatches.length === 0) {
2092
2326
  spinner.stop();
2093
- console.error(chalk.red(`No session found matching: ${query}`));
2327
+ if (byId)
2328
+ notFoundByIdMessage(query).forEach(l => console.error(l));
2329
+ else
2330
+ console.error(chalk.red(`No session found matching: ${query}`));
2094
2331
  process.exit(1);
2095
2332
  }
2096
2333
  if (queryMatches.length > 1) {
@@ -2099,7 +2336,7 @@ async function renderArtifactsGlobal(query, listAll, name, scope) {
2099
2336
  for (const m of queryMatches.slice(0, 10)) {
2100
2337
  console.error(chalk.cyan(` ${m.shortId} ${m.id} ${m.label ?? m.topic ?? ''}`));
2101
2338
  }
2102
- console.error(chalk.gray('Pass a longer ID to narrow it down.'));
2339
+ console.error(chalk.gray(ambiguityHint(byId, completeId)));
2103
2340
  process.exit(1);
2104
2341
  }
2105
2342
  spinner.stop();
@@ -2127,13 +2364,23 @@ async function renderOneSession(query, mode, scope) {
2127
2364
  tracker.stop();
2128
2365
  const allSessions = applyScopeFilters(discovered, scope);
2129
2366
  let session;
2130
- const matches = resolveSessionById(allSessions, query);
2131
- let queryMatches = matches.length > 0 ? matches : filterSessionsByQuery(allSessions, query);
2132
- if (queryMatches.length === 0) {
2367
+ const resolution = resolveSessionQuery(allSessions, query);
2368
+ let queryMatches = resolution.matches;
2369
+ let byId = resolution.byId;
2370
+ const completeId = resolution.completeId;
2371
+ // Widen to the transcript content index only for a genuine search phrase.
2372
+ // ANY id-shaped query (a complete id OR a hex short-id/prefix) names a
2373
+ // specific session; widening could only surface a DIFFERENT session that
2374
+ // happens to MENTION the id — which is what made `sessions <uuid>` render an
2375
+ // unrelated transcript and `sessions <shortid>` list every session that
2376
+ // echoes the id in a resume prompt. Gate on looksLikeSessionId, not just
2377
+ // completeId, so a short id resolves to "no match" rather than fuzzy content.
2378
+ if (queryMatches.length === 0 && !looksLikeSessionId(query)) {
2133
2379
  const contentResults = searchContentIndex(allSessions, query);
2134
2380
  if (contentResults.size > 0) {
2135
2381
  const matchedSessions = Array.from(contentResults.values())
2136
2382
  .sort((a, b) => (b._bm25Score ?? 0) - (a._bm25Score ?? 0));
2383
+ byId = false;
2137
2384
  if (matchedSessions.length === 1) {
2138
2385
  session = matchedSessions[0];
2139
2386
  }
@@ -2155,6 +2402,10 @@ async function renderOneSession(query, mode, scope) {
2155
2402
  process.exit(1);
2156
2403
  }
2157
2404
  }
2405
+ else if (byId) {
2406
+ notFoundByIdMessage(query).forEach(l => console.error(l));
2407
+ process.exit(1);
2408
+ }
2158
2409
  else {
2159
2410
  console.error(chalk.red(`No session found matching: ${query}`));
2160
2411
  console.error(chalk.gray('Run "agents sessions" to browse sessions.'));
@@ -2168,7 +2419,7 @@ async function renderOneSession(query, mode, scope) {
2168
2419
  for (const match of queryMatches.slice(0, 10)) {
2169
2420
  console.error(chalk.cyan(` ${match.shortId} ${match.id} ${match.label ?? match.topic ?? ''}`));
2170
2421
  }
2171
- console.error(chalk.gray('Pass a longer ID to narrow it down.'));
2422
+ console.error(chalk.gray(ambiguityHint(byId, completeId)));
2172
2423
  process.exit(1);
2173
2424
  }
2174
2425
  else {
@@ -2205,6 +2456,7 @@ export function registerSessionsCommands(program) {
2205
2456
  .option('--grok', 'Shorthand for --agent grok')
2206
2457
  .option('--opencode', 'Shorthand for --agent opencode')
2207
2458
  .option('--all', 'Include sessions from every directory (not just current project)')
2459
+ .option('--unmanaged', "Also show sessions from your own ~/.<agent> installs (hidden once agents-cli manages that agent)")
2208
2460
  .option('--teams', 'Include team-spawned sessions (hidden by default)')
2209
2461
  .option('--routine', 'Show only sessions archived from routine runs')
2210
2462
  .option('-p, --project <name>', 'Filter by project name (searches across all directories)')
@@ -2215,10 +2467,10 @@ export function registerSessionsCommands(program) {
2215
2467
  .option('--markdown', 'Render the session as markdown (user, assistant, thinking, tool calls)')
2216
2468
  .option('--no-redact', 'Disable default secret redaction in rendered session output (--markdown and --json)')
2217
2469
  .option('--json', 'Output JSON (session list when browsing, event array when rendering one session)')
2218
- .option('--include <roles>', 'Only include these roles (comma-separated): user, assistant, thinking, tools')
2470
+ .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)')
2219
2471
  .option('--exclude <roles>', 'Exclude these roles (comma-separated): user, assistant, thinking, tools')
2220
- .option('--first <n>', 'Keep only the first N turns (a turn starts at each user message)')
2221
- .option('--last <n>', 'Keep only the last N turns (a turn starts at each user message)')
2472
+ .option('--first <n>', 'Keep only the first N turns (a turn starts at each genuine user message, not harness-injected scaffolding)')
2473
+ .option('--last <n>', 'Keep only the last N turns (a turn starts at each genuine user message, not harness-injected scaffolding)')
2222
2474
  .option('--artifacts', 'List all files written or edited during a session')
2223
2475
  .option('--artifact <name>', 'Read a specific artifact by filename or path (outputs to stdout)')
2224
2476
  .option('--active', 'Show only sessions running right now across terminals, teams, cloud, and headless agents')
@@ -2293,9 +2545,13 @@ export function registerSessionsCommands(program) {
2293
2545
  registerSessionsResumeCommand(sessionsCmd);
2294
2546
  registerGoCommand(sessionsCmd);
2295
2547
  registerFocusCommand(sessionsCmd);
2548
+ registerDetachCommand(sessionsCmd);
2549
+ registerAttachCommand(sessionsCmd);
2296
2550
  registerSessionsInjectCommand(sessionsCmd);
2297
2551
  registerSessionsExportCommand(sessionsCmd);
2298
2552
  registerSessionsImportCommand(sessionsCmd);
2553
+ registerSessionsMigrateCommand(sessionsCmd);
2554
+ registerSessionsMigrationsCommand(sessionsCmd);
2299
2555
  }
2300
2556
  function formatNoSessionsMessage(showAll, project) {
2301
2557
  const projectQuery = project?.trim();
@@ -2307,6 +2563,10 @@ function formatNoSessionsMessage(showAll, project) {
2307
2563
  const command = 'agents sessions --all';
2308
2564
  return `No sessions found for ${process.cwd()}. Run "${command}" to see sessions from every directory.`;
2309
2565
  }
2566
+ function formatUnmanagedHiddenFooter(hiddenCount) {
2567
+ const noun = hiddenCount === 1 ? 'session' : 'sessions';
2568
+ return `(${hiddenCount} ${noun} from your own unmanaged installs hidden — use --unmanaged to show)`;
2569
+ }
2310
2570
  function formatTeamHiddenFooter(hiddenCount) {
2311
2571
  const noun = hiddenCount === 1 ? 'team session' : 'team sessions';
2312
2572
  return `(${hiddenCount} ${noun} hidden — use --teams to show, or \`agents teams status\`)`;