@phnx-labs/agents-cli 1.20.34 → 1.20.36

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 (291) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/LICENSE +185 -21
  3. package/README.md +13 -5
  4. package/dist/commands/audit.d.ts +14 -0
  5. package/dist/commands/audit.js +68 -0
  6. package/dist/commands/browser.js +82 -8
  7. package/dist/commands/check.d.ts +15 -0
  8. package/dist/commands/check.js +84 -0
  9. package/dist/commands/cloud.js +143 -3
  10. package/dist/commands/computer-actions.d.ts +15 -0
  11. package/dist/commands/computer-actions.js +120 -22
  12. package/dist/commands/computer.js +81 -0
  13. package/dist/commands/cost.js +17 -12
  14. package/dist/commands/daemon.js +4 -1
  15. package/dist/commands/doctor.d.ts +1 -0
  16. package/dist/commands/doctor.js +54 -97
  17. package/dist/commands/events.d.ts +16 -0
  18. package/dist/commands/events.js +173 -0
  19. package/dist/commands/exec.d.ts +19 -0
  20. package/dist/commands/exec.js +283 -12
  21. package/dist/commands/hosts.js +37 -29
  22. package/dist/commands/inspect.d.ts +2 -0
  23. package/dist/commands/inspect.js +62 -16
  24. package/dist/commands/lock.d.ts +12 -0
  25. package/dist/commands/lock.js +70 -0
  26. package/dist/commands/logs.d.ts +17 -0
  27. package/dist/commands/logs.js +139 -0
  28. package/dist/commands/message.d.ts +15 -0
  29. package/dist/commands/message.js +56 -0
  30. package/dist/commands/models.d.ts +3 -0
  31. package/dist/commands/models.js +27 -8
  32. package/dist/commands/permissions.js +9 -2
  33. package/dist/commands/repo.d.ts +34 -0
  34. package/dist/commands/repo.js +243 -65
  35. package/dist/commands/resource-view.d.ts +20 -0
  36. package/dist/commands/resource-view.js +90 -28
  37. package/dist/commands/routines.js +101 -5
  38. package/dist/commands/secrets-migrate.js +106 -57
  39. package/dist/commands/secrets.d.ts +31 -18
  40. package/dist/commands/secrets.js +196 -91
  41. package/dist/commands/serve.d.ts +10 -0
  42. package/dist/commands/serve.js +37 -0
  43. package/dist/commands/sessions-inject.d.ts +14 -0
  44. package/dist/commands/sessions-inject.js +111 -0
  45. package/dist/commands/sessions-picker.d.ts +2 -0
  46. package/dist/commands/sessions-picker.js +24 -3
  47. package/dist/commands/sessions-resume.d.ts +2 -0
  48. package/dist/commands/sessions-resume.js +221 -0
  49. package/dist/commands/sessions-tail.d.ts +10 -0
  50. package/dist/commands/sessions-tail.js +11 -0
  51. package/dist/commands/sessions.d.ts +143 -1
  52. package/dist/commands/sessions.js +628 -80
  53. package/dist/commands/setup.js +4 -2
  54. package/dist/commands/sync.d.ts +3 -1
  55. package/dist/commands/sync.js +156 -4
  56. package/dist/commands/teams.js +217 -0
  57. package/dist/commands/versions.js +2 -4
  58. package/dist/commands/view.d.ts +3 -0
  59. package/dist/commands/view.js +19 -8
  60. package/dist/commands/wallet.d.ts +6 -0
  61. package/dist/commands/wallet.js +22 -5
  62. package/dist/commands/watchdog.d.ts +18 -0
  63. package/dist/commands/watchdog.js +238 -0
  64. package/dist/index.js +82 -32
  65. package/dist/lib/agents.js +30 -9
  66. package/dist/lib/audit/log.d.ts +92 -0
  67. package/dist/lib/audit/log.js +177 -0
  68. package/dist/lib/auto-pull.js +2 -1
  69. package/dist/lib/browser/cdp.js +5 -1
  70. package/dist/lib/browser/chrome.d.ts +10 -0
  71. package/dist/lib/browser/chrome.js +18 -7
  72. package/dist/lib/browser/drivers/ssh.d.ts +8 -0
  73. package/dist/lib/browser/drivers/ssh.js +77 -16
  74. package/dist/lib/browser/har.d.ts +84 -0
  75. package/dist/lib/browser/har.js +77 -0
  76. package/dist/lib/browser/ipc.d.ts +3 -0
  77. package/dist/lib/browser/ipc.js +37 -12
  78. package/dist/lib/browser/profiles.d.ts +1 -1
  79. package/dist/lib/browser/profiles.js +8 -10
  80. package/dist/lib/browser/refs.d.ts +65 -0
  81. package/dist/lib/browser/refs.js +73 -1
  82. package/dist/lib/browser/runtime-state.js +1 -0
  83. package/dist/lib/browser/service.d.ts +38 -2
  84. package/dist/lib/browser/service.js +183 -23
  85. package/dist/lib/browser/types.d.ts +14 -1
  86. package/dist/lib/budget/live-cloud.d.ts +42 -0
  87. package/dist/lib/budget/live-cloud.js +79 -0
  88. package/dist/lib/budget/live-team.d.ts +31 -0
  89. package/dist/lib/budget/live-team.js +115 -0
  90. package/dist/lib/cloud/codex.js +4 -0
  91. package/dist/lib/cloud/rush.d.ts +12 -1
  92. package/dist/lib/cloud/rush.js +13 -3
  93. package/dist/lib/cloud/types.d.ts +9 -0
  94. package/dist/lib/computer/dispatch.d.ts +8 -0
  95. package/dist/lib/computer/dispatch.js +125 -0
  96. package/dist/lib/computer/loop.d.ts +62 -0
  97. package/dist/lib/computer/loop.js +98 -0
  98. package/dist/lib/computer/model.d.ts +44 -0
  99. package/dist/lib/computer/model.js +157 -0
  100. package/dist/lib/concurrency.d.ts +19 -0
  101. package/dist/lib/concurrency.js +33 -0
  102. package/dist/lib/daemon.d.ts +92 -0
  103. package/dist/lib/daemon.js +222 -18
  104. package/dist/lib/devices/connect.d.ts +3 -2
  105. package/dist/lib/devices/connect.js +5 -3
  106. package/dist/lib/devices/registry.d.ts +7 -0
  107. package/dist/lib/devices/registry.js +24 -0
  108. package/dist/lib/devices/tailscale.js +1 -1
  109. package/dist/lib/drift.d.ts +52 -0
  110. package/dist/lib/drift.js +112 -0
  111. package/dist/lib/events.d.ts +10 -3
  112. package/dist/lib/events.js +101 -24
  113. package/dist/lib/exec.d.ts +17 -0
  114. package/dist/lib/exec.js +100 -12
  115. package/dist/lib/git.d.ts +27 -0
  116. package/dist/lib/git.js +56 -1
  117. package/dist/lib/hooks/cache.d.ts +6 -0
  118. package/dist/lib/hooks/cache.js +54 -12
  119. package/dist/lib/hooks.d.ts +27 -0
  120. package/dist/lib/hooks.js +135 -10
  121. package/dist/lib/hosts/dispatch.d.ts +15 -0
  122. package/dist/lib/hosts/dispatch.js +45 -13
  123. package/dist/lib/hosts/logs.d.ts +16 -0
  124. package/dist/lib/hosts/logs.js +74 -0
  125. package/dist/lib/hosts/option.js +1 -1
  126. package/dist/lib/hosts/passthrough.js +3 -1
  127. package/dist/lib/hosts/progress.d.ts +66 -0
  128. package/dist/lib/hosts/progress.js +125 -17
  129. package/dist/lib/hosts/ready.d.ts +51 -7
  130. package/dist/lib/hosts/ready.js +98 -25
  131. package/dist/lib/hosts/reconcile.d.ts +53 -0
  132. package/dist/lib/hosts/reconcile.js +81 -0
  133. package/dist/lib/hosts/registry.d.ts +19 -2
  134. package/dist/lib/hosts/registry.js +58 -2
  135. package/dist/lib/hosts/remote-cmd.d.ts +62 -1
  136. package/dist/lib/hosts/remote-cmd.js +70 -1
  137. package/dist/lib/hosts/remote-os.d.ts +17 -0
  138. package/dist/lib/hosts/remote-os.js +30 -0
  139. package/dist/lib/hosts/session-index.d.ts +34 -0
  140. package/dist/lib/hosts/session-index.js +56 -0
  141. package/dist/lib/hosts/tasks.d.ts +22 -0
  142. package/dist/lib/hosts/tasks.js +29 -0
  143. package/dist/lib/lock.d.ts +93 -0
  144. package/dist/lib/lock.js +207 -0
  145. package/dist/lib/loop.js +16 -1
  146. package/dist/lib/machine-id.d.ts +21 -0
  147. package/dist/lib/machine-id.js +26 -0
  148. package/dist/lib/mailbox-target.d.ts +36 -0
  149. package/dist/lib/mailbox-target.js +45 -0
  150. package/dist/lib/mailbox.d.ts +47 -0
  151. package/dist/lib/mailbox.js +194 -0
  152. package/dist/lib/mcp.d.ts +5 -0
  153. package/dist/lib/mcp.js +24 -8
  154. package/dist/lib/migrate.d.ts +19 -0
  155. package/dist/lib/migrate.js +134 -26
  156. package/dist/lib/overdue.js +3 -0
  157. package/dist/lib/permissions.d.ts +4 -0
  158. package/dist/lib/permissions.js +35 -0
  159. package/dist/lib/picker.d.ts +26 -0
  160. package/dist/lib/picker.js +127 -0
  161. package/dist/lib/platform/exec.d.ts +46 -0
  162. package/dist/lib/platform/exec.js +74 -0
  163. package/dist/lib/platform/process.d.ts +31 -0
  164. package/dist/lib/platform/process.js +34 -1
  165. package/dist/lib/platform/winpath.js +2 -0
  166. package/dist/lib/plugin-marketplace.d.ts +30 -0
  167. package/dist/lib/plugin-marketplace.js +215 -2
  168. package/dist/lib/plugins.d.ts +5 -0
  169. package/dist/lib/plugins.js +61 -10
  170. package/dist/lib/profiles.d.ts +25 -0
  171. package/dist/lib/profiles.js +22 -6
  172. package/dist/lib/pty-client.js +2 -1
  173. package/dist/lib/rotate.d.ts +61 -0
  174. package/dist/lib/rotate.js +52 -0
  175. package/dist/lib/routines.d.ts +40 -2
  176. package/dist/lib/routines.js +66 -8
  177. package/dist/lib/runner.d.ts +11 -2
  178. package/dist/lib/runner.js +49 -7
  179. package/dist/lib/scheduler.js +6 -1
  180. package/dist/lib/secrets/bundles.d.ts +60 -4
  181. package/dist/lib/secrets/bundles.js +131 -12
  182. package/dist/lib/secrets/filestore.d.ts +3 -0
  183. package/dist/lib/secrets/filestore.js +42 -16
  184. package/dist/lib/secrets/index.d.ts +43 -2
  185. package/dist/lib/secrets/index.js +102 -3
  186. package/dist/lib/secrets/mcp.d.ts +93 -0
  187. package/dist/lib/secrets/mcp.js +205 -0
  188. package/dist/lib/secrets/remote.js +12 -5
  189. package/dist/lib/secrets/sync.js +83 -4
  190. package/dist/lib/secrets/windows.js +14 -3
  191. package/dist/lib/serve/data.d.ts +81 -0
  192. package/dist/lib/serve/data.js +91 -0
  193. package/dist/lib/serve/page.d.ts +7 -0
  194. package/dist/lib/serve/page.js +140 -0
  195. package/dist/lib/serve/server.d.ts +46 -0
  196. package/dist/lib/serve/server.js +115 -0
  197. package/dist/lib/session/active.d.ts +61 -0
  198. package/dist/lib/session/active.js +196 -17
  199. package/dist/lib/session/discover.d.ts +37 -0
  200. package/dist/lib/session/discover.js +204 -27
  201. package/dist/lib/session/inject.d.ts +18 -0
  202. package/dist/lib/session/inject.js +21 -0
  203. package/dist/lib/session/parse.d.ts +6 -0
  204. package/dist/lib/session/parse.js +286 -2
  205. package/dist/lib/session/pid-registry.d.ts +22 -0
  206. package/dist/lib/session/pid-registry.js +104 -0
  207. package/dist/lib/session/provenance.d.ts +14 -2
  208. package/dist/lib/session/provenance.js +39 -8
  209. package/dist/lib/session/remote-active.d.ts +26 -0
  210. package/dist/lib/session/remote-active.js +153 -0
  211. package/dist/lib/session/remote-list.d.ts +51 -0
  212. package/dist/lib/session/remote-list.js +213 -0
  213. package/dist/lib/session/remote.d.ts +7 -1
  214. package/dist/lib/session/remote.js +18 -8
  215. package/dist/lib/session/render.js +1 -1
  216. package/dist/lib/session/sync/config.d.ts +1 -7
  217. package/dist/lib/session/sync/config.js +4 -11
  218. package/dist/lib/session/types.d.ts +18 -1
  219. package/dist/lib/session/types.js +1 -1
  220. package/dist/lib/shims.d.ts +44 -6
  221. package/dist/lib/shims.js +143 -57
  222. package/dist/lib/ssh-exec.d.ts +26 -3
  223. package/dist/lib/ssh-exec.js +47 -3
  224. package/dist/lib/ssh-tunnel.d.ts +24 -5
  225. package/dist/lib/ssh-tunnel.js +62 -63
  226. package/dist/lib/staleness/writers/hooks.js +1 -1
  227. package/dist/lib/startup/command-registry.d.ts +8 -0
  228. package/dist/lib/startup/command-registry.js +17 -1
  229. package/dist/lib/state.d.ts +13 -0
  230. package/dist/lib/state.js +103 -9
  231. package/dist/lib/sync-umbrella.d.ts +14 -7
  232. package/dist/lib/sync-umbrella.js +17 -9
  233. package/dist/lib/teams/forEach.d.ts +110 -0
  234. package/dist/lib/teams/forEach.js +186 -0
  235. package/dist/lib/teams/index.d.ts +1 -0
  236. package/dist/lib/teams/index.js +1 -0
  237. package/dist/lib/teams/pr-watch.d.ts +226 -0
  238. package/dist/lib/teams/pr-watch.js +371 -0
  239. package/dist/lib/teams/registry.js +25 -9
  240. package/dist/lib/teams/supervisor.d.ts +14 -1
  241. package/dist/lib/teams/supervisor.js +19 -0
  242. package/dist/lib/teams/worktree.d.ts +9 -0
  243. package/dist/lib/teams/worktree.js +32 -0
  244. package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
  245. package/dist/lib/terminal/backends/ghostty.js +69 -0
  246. package/dist/lib/terminal/backends/index.d.ts +19 -0
  247. package/dist/lib/terminal/backends/index.js +31 -0
  248. package/dist/lib/terminal/backends/iterm.d.ts +6 -0
  249. package/dist/lib/terminal/backends/iterm.js +62 -0
  250. package/dist/lib/terminal/backends/tmux.d.ts +14 -0
  251. package/dist/lib/terminal/backends/tmux.js +23 -0
  252. package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
  253. package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
  254. package/dist/lib/terminal/engine.d.ts +39 -0
  255. package/dist/lib/terminal/engine.js +54 -0
  256. package/dist/lib/terminal/index.d.ts +17 -0
  257. package/dist/lib/terminal/index.js +8 -0
  258. package/dist/lib/terminal/inject.d.ts +204 -0
  259. package/dist/lib/terminal/inject.js +247 -0
  260. package/dist/lib/terminal/policy.d.ts +11 -0
  261. package/dist/lib/terminal/policy.js +11 -0
  262. package/dist/lib/terminal/quote.d.ts +11 -0
  263. package/dist/lib/terminal/quote.js +13 -0
  264. package/dist/lib/terminal/resolve.d.ts +64 -0
  265. package/dist/lib/terminal/resolve.js +90 -0
  266. package/dist/lib/terminal/shell.d.ts +6 -0
  267. package/dist/lib/terminal/shell.js +23 -0
  268. package/dist/lib/terminal/transport.d.ts +15 -0
  269. package/dist/lib/terminal/transport.js +40 -0
  270. package/dist/lib/terminal/types.d.ts +59 -0
  271. package/dist/lib/terminal/types.js +13 -0
  272. package/dist/lib/triggers/webhook.d.ts +85 -0
  273. package/dist/lib/triggers/webhook.js +141 -0
  274. package/dist/lib/types.d.ts +7 -0
  275. package/dist/lib/versions.d.ts +30 -3
  276. package/dist/lib/versions.js +129 -18
  277. package/dist/lib/watchdog/index.d.ts +3 -0
  278. package/dist/lib/watchdog/index.js +5 -0
  279. package/dist/lib/watchdog/read.d.ts +35 -0
  280. package/dist/lib/watchdog/read.js +149 -0
  281. package/dist/lib/watchdog/runner.d.ts +127 -0
  282. package/dist/lib/watchdog/runner.js +322 -0
  283. package/dist/lib/watchdog/watchdog.d.ts +40 -0
  284. package/dist/lib/watchdog/watchdog.js +166 -0
  285. package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
  286. package/dist/lib/watchdog/watchdogTail.js +154 -0
  287. package/dist/lib/whats-new.d.ts +9 -0
  288. package/dist/lib/whats-new.js +35 -0
  289. package/dist/lib/workflows.d.ts +166 -0
  290. package/dist/lib/workflows.js +193 -0
  291. package/package.json +5 -4
@@ -15,13 +15,16 @@ import chalk from 'chalk';
15
15
  import ora from 'ora';
16
16
  import { SESSION_AGENTS } from '../lib/session/types.js';
17
17
  import { discoverArtifacts, readArtifact, resolveArtifact } from '../lib/session/artifacts.js';
18
- import { looksLikePath, toComparablePath, homeDir } from '../lib/platform/index.js';
18
+ import { looksLikePath, toComparablePath, homeDir, needsWindowsShell, findExecutable } from '../lib/platform/index.js';
19
19
  import { getActiveSessions } from '../lib/session/active.js';
20
+ import { machineId, normalizeHost } from '../lib/session/sync/config.js';
21
+ import { gatherRemoteActive, NO_FANOUT_ENV } from '../lib/session/remote-active.js';
22
+ import { gatherRemoteList, runOnPeer } from '../lib/session/remote-list.js';
20
23
  import { stringWidth, truncateToWidth, padToWidth, terminalWidth } from '../lib/session/width.js';
21
24
  import { discoverSessions, countSessionsInScope, resolveSessionById, searchContentIndex } from '../lib/session/discover.js';
22
25
  import { filterTeamSessions } from '../lib/session/team-filter.js';
23
26
  import { parseSession } from '../lib/session/parse.js';
24
- import { runRemoteSessions } from '../lib/session/remote.js';
27
+ import { runRemoteSessions, buildForwardedArgs } from '../lib/session/remote.js';
25
28
  import { formatRelativeTime } from '../lib/session/relative-time.js';
26
29
  import { renderConversationMarkdown, renderSummary, renderSummaryHeader, computeSummaryStats, renderJson, filterEvents, parseRoleList } from '../lib/session/render.js';
27
30
  import { renderMarkdown } from '../lib/markdown.js';
@@ -33,6 +36,8 @@ import { sessionPicker } from './sessions-picker.js';
33
36
  import { setHelpSections } from '../lib/help.js';
34
37
  import { registerSessionsTailCommand } from './sessions-tail.js';
35
38
  import { registerSessionsSyncCommand } from './sessions-sync.js';
39
+ import { registerSessionsResumeCommand } from './sessions-resume.js';
40
+ import { registerSessionsInjectCommand } from './sessions-inject.js';
36
41
  const SESSION_AGENT_FILTER_HELP = `Filter by agent, e.g. claude, codex, claude@2.0.65`;
37
42
  const CLAUDE_RESUME_MATCH_WINDOW_MS = 10 * 60_000;
38
43
  const LOAD_VERBS = ['Loading', 'Scanning', 'Gathering', 'Indexing', 'Reading'];
@@ -75,6 +80,12 @@ function createScanProgressTracker(verbs, suffix, spinner) {
75
80
  }
76
81
  const PICKER_RECENT_COUNT = 15;
77
82
  const PICKER_POOL_LIMIT = 200;
83
+ // The grouped default view ("overview"): fetch a generous recency-ordered pool
84
+ // for accurate per-project totals, show each project's most-recent rows grouped
85
+ // by project, newest-active project first.
86
+ const OVERVIEW_ROWS_PER_PROJECT = 5; // recent rows shown per project before "· N more"
87
+ const OVERVIEW_POOL_LIMIT = 1000; // fetch cap — accurate per-project totals up to this
88
+ const OVERVIEW_MAX_PROJECTS = 12; // project groups shown before "+N more projects"
78
89
  /**
79
90
  * Resolve a path-like query to an absolute directory path.
80
91
  */
@@ -194,6 +205,43 @@ function activityLabel(s) {
194
205
  return 'idle';
195
206
  return s.status === 'input_required' ? 'waiting' : s.status;
196
207
  }
208
+ /**
209
+ * Index live sessions by their full session UUID so a historical `SessionMeta`
210
+ * row (`meta.id`) can be matched to the session that is still running now.
211
+ * Rows without a sessionId (some cloud/headless probes) are skipped — they
212
+ * can't be correlated back to a transcript on disk.
213
+ */
214
+ export function indexActiveBySessionId(active) {
215
+ const byId = new Map();
216
+ for (const a of active) {
217
+ if (a.sessionId)
218
+ byId.set(a.sessionId, a);
219
+ }
220
+ return byId;
221
+ }
222
+ /**
223
+ * The live decoration for a listing row: a status glyph and the latest-turn
224
+ * preview, when the session is still running. `●` running / `◐` waiting on the
225
+ * user / `○` idle, colored by the same `statusColor` the --active view uses.
226
+ * Returns empty strings when there is no live match, so callers render the
227
+ * plain historical row unchanged.
228
+ */
229
+ export function liveGlyphAndPreview(a) {
230
+ if (!a)
231
+ return { glyph: '', preview: '' };
232
+ const waiting = a.status === 'input_required' || a.activity === 'waiting_input';
233
+ const running = a.status === 'running' || a.activity === 'working';
234
+ const shape = waiting ? '◐' : running ? '●' : '○';
235
+ return { glyph: statusColor(a.status)(shape), preview: buildSessionDescription(a) };
236
+ }
237
+ /**
238
+ * The tracker/PR ref for a session's dedicated column: the ticket id when known,
239
+ * else `PR#<n>`, else empty. Pulled out of the trailing badge blob so refs align
240
+ * into a scannable column instead of jamming against a truncated topic.
241
+ */
242
+ export function ticketLabel(s) {
243
+ return s.ticketId ?? (s.prNumber ? `PR#${s.prNumber}` : '');
244
+ }
197
245
  /**
198
246
  * Compact, colour-coded badges for the durable/awaiting signals. Text-only (no
199
247
  * emoji, per repo convention): `plan` / `ask` / `perm` for why it's waiting,
@@ -318,48 +366,183 @@ export function groupActiveSessions(sessions) {
318
366
  });
319
367
  return { workspaces };
320
368
  }
321
- /** Render the unified active-session view. */
322
- async function renderActiveSessions(asJson, waitingOnly = false) {
323
- const all = await getActiveSessions();
324
- // --waiting: only sessions blocked on the user. Exits non-zero when any are
325
- // present so a supervising agent or hook can poll it as a gate.
326
- const sessions = waitingOnly
327
- ? all.filter(s => s.status === 'input_required')
328
- : all;
329
- if (asJson) {
330
- process.stdout.write(JSON.stringify(sessions, null, 2) + '\n');
331
- if (waitingOnly && sessions.length > 0)
332
- process.exitCode = 1;
333
- return;
334
- }
335
- if (sessions.length === 0) {
336
- console.log(chalk.gray(waitingOnly ? 'No sessions waiting on input.' : 'No active agent sessions.'));
337
- return;
369
+ /**
370
+ * The machine a session belongs to: an explicit tag (set when merging
371
+ * cross-machine results) wins; else the process's provenance host (normalized
372
+ * to the same id form); else the local machine. Never keys off `ActiveSession.host`
373
+ * that is the terminal *app* (code/tmux), not the computer.
374
+ */
375
+ function machineKeyFor(s, localMachine) {
376
+ if (s.machine)
377
+ return s.machine;
378
+ if (s.provenance?.host)
379
+ return normalizeHost(s.provenance.host);
380
+ return localMachine;
381
+ }
382
+ /**
383
+ * Group active sessions by machine, then delegate each machine's sessions to the
384
+ * existing workspace/window grouping. Local machine is pinned first and flagged;
385
+ * the rest sort by session count descending, then name. Pure — `localMachine` is
386
+ * injected so the function stays testable without reading os.hostname().
387
+ */
388
+ export function groupSessionsByMachine(sessions, localMachine) {
389
+ const byMachine = new Map();
390
+ for (const s of sessions) {
391
+ const key = machineKeyFor(s, localMachine);
392
+ (byMachine.get(key) ?? byMachine.set(key, []).get(key)).push(s);
393
+ }
394
+ const keys = Array.from(byMachine.keys()).sort((a, b) => {
395
+ if (a === localMachine)
396
+ return -1;
397
+ if (b === localMachine)
398
+ return 1;
399
+ const ac = byMachine.get(a).length, bc = byMachine.get(b).length;
400
+ if (ac !== bc)
401
+ return bc - ac;
402
+ return a.localeCompare(b);
403
+ });
404
+ const machines = keys.map((machine) => ({
405
+ machine,
406
+ isLocal: machine === localMachine,
407
+ total: byMachine.get(machine).length,
408
+ layout: groupActiveSessions(byMachine.get(machine)),
409
+ }));
410
+ return { machines };
411
+ }
412
+ /**
413
+ * Collapse duplicate sessions after a cross-machine merge. Two rows collapse
414
+ * only when they share both machine and session UUID (the same host listed
415
+ * twice, or a local/remote overlap); rows without a sessionId can't be
416
+ * correlated, so they're all kept.
417
+ */
418
+ export function dedupeByMachineSession(sessions) {
419
+ const seen = new Set();
420
+ const out = [];
421
+ for (const s of sessions) {
422
+ if (!s.sessionId) {
423
+ out.push(s);
424
+ continue;
425
+ }
426
+ const key = `${s.machine ?? ''}:${s.sessionId}`;
427
+ if (seen.has(key))
428
+ continue;
429
+ seen.add(key);
430
+ out.push(s);
338
431
  }
339
- const layout = groupActiveSessions(sessions);
432
+ return out;
433
+ }
434
+ /**
435
+ * Order a merged listing so the local machine's sessions come first, then each
436
+ * remote machine as a contiguous block (more sessions first, then name), with
437
+ * every machine keeping its incoming order (timestamp) within the block. Also
438
+ * dedupes: a session present both locally (a synced mirror copy) and via live
439
+ * fan-out collapses to one, keyed by machine + session id. Rows are keyed by
440
+ * `machine` (discover tags local rows with the local id; fan-out tags remote
441
+ * rows with the peer id) falling back to `localMachine` when untagged. Pure —
442
+ * `localMachine` is injected so the ordering is testable without os.hostname().
443
+ */
444
+ export function mergeLocalFirst(sessions, localMachine) {
445
+ const byMachine = new Map();
446
+ const seen = new Set();
447
+ for (const s of sessions) {
448
+ const machine = s.machine || localMachine;
449
+ if (s.id) {
450
+ const dedupeKey = `${machine}:${s.id}`;
451
+ if (seen.has(dedupeKey))
452
+ continue;
453
+ seen.add(dedupeKey);
454
+ }
455
+ (byMachine.get(machine) ?? byMachine.set(machine, []).get(machine)).push(s);
456
+ }
457
+ const keys = Array.from(byMachine.keys()).sort((a, b) => {
458
+ if (a === localMachine)
459
+ return -1;
460
+ if (b === localMachine)
461
+ return 1;
462
+ const ac = byMachine.get(a).length, bc = byMachine.get(b).length;
463
+ if (ac !== bc)
464
+ return bc - ac;
465
+ return a.localeCompare(b);
466
+ });
467
+ return keys.flatMap((k) => byMachine.get(k));
468
+ }
469
+ /** Print one machine's workspace tree, indented under its machine header. */
470
+ function renderWorkspaceLayout(layout, base) {
340
471
  let first = true;
341
472
  for (const ws of layout.workspaces) {
342
473
  if (!first)
343
474
  console.log();
344
475
  first = false;
345
- // Print workspace header
346
476
  const header = ws.key === '__cloud__'
347
477
  ? chalk.magenta.bold('cloud')
348
478
  : ws.key === '__unknown__'
349
479
  ? chalk.gray.bold('unknown')
350
480
  : chalk.cyan.bold(shortCwd(ws.key));
351
- console.log(`${header} ${chalk.gray(`(${ws.total})`)}`);
481
+ console.log(`${base}${header} ${chalk.gray(`(${ws.total})`)}`);
352
482
  for (const win of ws.windows) {
353
483
  // Host is per-process, but every terminal in the same IDE window shares
354
484
  // an ancestor — take the first non-empty host as the window's label.
355
485
  const host = win.sessions.find((s) => s.host)?.host ?? 'terminal';
356
486
  const winHeader = `${chalk.gray(host)} ${chalk.gray('·')} ${chalk.gray(shortWindowLabel(win.windowId))} ${chalk.gray(`(${win.sessions.length})`)}`;
357
- console.log(' ' + winHeader);
487
+ console.log(base + ' ' + winHeader);
358
488
  for (const s of win.sessions)
359
- printActiveRow(s, ' ');
489
+ printActiveRow(s, base + ' ');
360
490
  }
361
491
  for (const s of ws.flat)
362
- printActiveRow(s, ' ');
492
+ printActiveRow(s, base + ' ');
493
+ }
494
+ }
495
+ /** Machine header: `▸ <name> ← this machine` for the local box (cyan), matching
496
+ * the `ag devices list` treatment; a plain `▸ <name>` for remotes. */
497
+ function printMachineHeader(mg) {
498
+ const marker = mg.isLocal ? chalk.cyan('▸ ') : chalk.gray('▸ ');
499
+ const name = mg.isLocal ? chalk.bold.cyan(mg.machine) : chalk.bold(mg.machine);
500
+ const here = mg.isLocal ? chalk.cyan(' ← this machine') : '';
501
+ console.log(`${marker}${name} ${chalk.gray(`(${mg.total})`)}${here}`);
502
+ }
503
+ /**
504
+ * Render the unified active-session view, grouped by machine. Local sessions
505
+ * come from `getActiveSessions()`; unless `--local`, sessions from other
506
+ * machines are folded in over SSH (explicit `--host` targets, else the
507
+ * registered online devices from `ag devices`). A tip is shown when there are
508
+ * no other machines to include.
509
+ */
510
+ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
511
+ const self = machineId();
512
+ const local = await getActiveSessions();
513
+ for (const s of local)
514
+ if (!s.machine)
515
+ s.machine = self;
516
+ let remoteDeviceCount = 0;
517
+ let merged = local;
518
+ if (!opts.local) {
519
+ const remote = await gatherRemoteActive(opts.hosts);
520
+ remoteDeviceCount = remote.deviceCount;
521
+ merged = dedupeByMachineSession([...local, ...remote.sessions]);
522
+ }
523
+ // --waiting: only sessions blocked on the user. Exits non-zero when any are
524
+ // present so a supervising agent or hook can poll it as a gate.
525
+ const sessions = waitingOnly ? merged.filter(s => s.status === 'input_required') : merged;
526
+ if (asJson) {
527
+ process.stdout.write(JSON.stringify(sessions, null, 2) + '\n');
528
+ if (waitingOnly && sessions.length > 0)
529
+ process.exitCode = 1;
530
+ return;
531
+ }
532
+ if (sessions.length === 0) {
533
+ console.log(chalk.gray(waitingOnly ? 'No sessions waiting on input.' : 'No active agent sessions.'));
534
+ if (!opts.local && !opts.hosts?.length && remoteDeviceCount === 0)
535
+ printCrossMachineTip();
536
+ return;
537
+ }
538
+ const grouped = groupSessionsByMachine(sessions, self);
539
+ let firstMachine = true;
540
+ for (const mg of grouped.machines) {
541
+ if (!firstMachine)
542
+ console.log();
543
+ firstMachine = false;
544
+ printMachineHeader(mg);
545
+ renderWorkspaceLayout(mg.layout, ' ');
363
546
  }
364
547
  const runningCount = sessions.filter(s => s.status === 'running').length;
365
548
  const idleCount = sessions.filter(s => s.status === 'idle').length;
@@ -371,14 +554,25 @@ async function renderActiveSessions(asJson, waitingOnly = false) {
371
554
  parts.push(`${idleCount} idle`);
372
555
  if (queuedCount > 0)
373
556
  parts.push(`${queuedCount} queued`);
374
- console.log(chalk.gray(`\n${sessions.length} active (${parts.join(', ')}).`));
557
+ const machineWord = grouped.machines.length === 1 ? 'machine' : 'machines';
558
+ console.log(chalk.gray(`\n${sessions.length} active (${parts.join(', ')}) across ${grouped.machines.length} ${machineWord}.`));
559
+ // Tip only when nothing else could be included and the user didn't opt out.
560
+ if (!opts.local && !opts.hosts?.length && remoteDeviceCount === 0)
561
+ printCrossMachineTip();
375
562
  // Scriptable gate: a non-zero exit when anything is waiting on the user.
376
563
  if (waitingOnly && sessions.length > 0)
377
564
  process.exitCode = 1;
378
565
  }
566
+ /** Nudge shown when `--active` has no other machines to fold in. */
567
+ function printCrossMachineTip() {
568
+ console.log(chalk.gray("\nTip: include sessions from your other machines — register them with 'ag devices sync', then rerun. Use --local to skip."));
569
+ }
379
570
  /** Main action handler for `agents sessions`. Routes to picker, table, or single-session render. */
380
571
  async function sessionsAction(query, options) {
381
- if (options.host && options.host.length > 0) {
572
+ // --host WITHOUT --active keeps the legacy per-host stream (each remote's raw
573
+ // stdout under a `── host ──` banner). With --active, the hosts are folded
574
+ // into the merged machine-grouped view instead (handled below).
575
+ if (options.host && options.host.length > 0 && !options.active) {
382
576
  try {
383
577
  runRemoteSessions(options.host);
384
578
  }
@@ -389,7 +583,13 @@ async function sessionsAction(query, options) {
389
583
  return;
390
584
  }
391
585
  if (options.active) {
392
- await renderActiveSessions(options.json === true, options.waiting === true);
586
+ // AGENTS_SESSIONS_LOCAL is set by a parent fan-out invocation (see
587
+ // remote-active.ts) so a peer answers for itself without recursing.
588
+ const forceLocal = options.local === true || process.env.AGENTS_SESSIONS_LOCAL === '1';
589
+ await renderActiveSessions(options.json === true, options.waiting === true, {
590
+ local: forceLocal,
591
+ hosts: options.host,
592
+ });
393
593
  return;
394
594
  }
395
595
  if (options.cloud) {
@@ -442,8 +642,18 @@ async function sessionsAction(query, options) {
442
642
  // Interactive picker loads a deep pool but shows only recent sessions
443
643
  // until the user starts typing. Non-interactive/JSON uses the explicit limit.
444
644
  const isInteractive = !options.json && isInteractiveTerminal();
445
- const limit = parseInt(options.limit || (isInteractive ? String(PICKER_POOL_LIMIT) : '50'), 10);
446
- const since = options.since ?? (isInteractive && !options.all ? '30d' : undefined);
645
+ // The grouped project overview is the default for a bare interactive listing:
646
+ // no query, no path drill-in, not explicitly --flat/--tree. It drops the silent
647
+ // cwd-scope + 50-cap + 30-day window that hide most of a large index.
648
+ const wantsOverview = isInteractive && !searchQuery && !pathFilter && !options.flat && !options.tree;
649
+ const limit = wantsOverview
650
+ ? OVERVIEW_POOL_LIMIT
651
+ : parseInt(options.limit || (isInteractive ? String(PICKER_POOL_LIMIT) : '50'), 10);
652
+ // Overview: recency order across the whole index, no default window; an explicit
653
+ // --since still narrows. Non-overview keeps the prior interactive-30d default.
654
+ const since = wantsOverview
655
+ ? options.since
656
+ : (options.since ?? (isInteractive && !options.all ? '30d' : undefined));
447
657
  const spinner = options.json ? null : ora().start();
448
658
  const tracker = createScanProgressTracker(LOAD_VERBS, 'sessions', spinner);
449
659
  try {
@@ -456,7 +666,7 @@ async function sessionsAction(query, options) {
456
666
  const scope = {
457
667
  agent,
458
668
  version,
459
- all: pathFilter ? undefined : options.all,
669
+ all: pathFilter ? undefined : (wantsOverview ? true : options.all),
460
670
  cwd: process.cwd(),
461
671
  cwdPrefix: pathFilter,
462
672
  project: options.project,
@@ -500,12 +710,42 @@ async function sessionsAction(query, options) {
500
710
  if (options.json) {
501
711
  const filtered = searchQuery ? filterSessionsByQuery(sessions, searchQuery) : sessions;
502
712
  const serializable = filtered.map(s => {
503
- const { _matchedTerms, _bm25Score, ...rest } = s;
713
+ const { _matchedTerms, _bm25Score, _remote, ...rest } = s;
504
714
  return rest;
505
715
  });
506
716
  process.stdout.write(JSON.stringify(serializable, null, 2) + '\n');
507
717
  return;
508
718
  }
719
+ // Cross-machine fan-out: unless --local (or we ARE a peer answering a
720
+ // parent's sweep), fold in other online machines' sessions live over SSH so
721
+ // the list spans the fleet without any sync — each remote row carries the
722
+ // machine it came from, and the picker/table label + group by it. Only the
723
+ // interactive picker and the printed table get this; --json and single-id
724
+ // resolution above stay local (a peer answers for itself; scripts get a
725
+ // deterministic local slice). Best-effort: a fan-out failure leaves the
726
+ // local list intact rather than erroring the whole command.
727
+ const forceLocal = options.local === true || process.env[NO_FANOUT_ENV] === '1';
728
+ if (!forceLocal) {
729
+ // Pass the hosts set so a variadic `--host a b` never leaks a host as a
730
+ // query (defensive: the --host-without-active early return above already
731
+ // means we only get here in auto-discovery mode, with no --host in argv).
732
+ const forwarded = buildForwardedArgs(process.argv, new Set(options.host ?? []));
733
+ if (!forwarded.includes('--json'))
734
+ forwarded.push('--json');
735
+ const fanSpinner = isInteractiveTerminal() ? ora('Reaching other machines...').start() : null;
736
+ try {
737
+ const { sessions: remoteSessions } = await gatherRemoteList(forwarded, options.host);
738
+ if (remoteSessions.length > 0) {
739
+ sessions = mergeLocalFirst([...sessions, ...remoteSessions], machineId());
740
+ }
741
+ }
742
+ catch {
743
+ // fan-out is an enrichment, never a hard dependency
744
+ }
745
+ finally {
746
+ fanSpinner?.stop();
747
+ }
748
+ }
509
749
  if (sessions.length === 0) {
510
750
  if (pathFilter) {
511
751
  console.log(chalk.gray(`No sessions found for ${pathFilter}.`));
@@ -518,9 +758,19 @@ async function sessionsAction(query, options) {
518
758
  }
519
759
  return;
520
760
  }
521
- // --tree is a printed grouped listing, not an interactive pick render it
522
- // directly even in a TTY.
523
- if (isInteractiveTerminal() && !options.tree) {
761
+ // The grouped project overview is the bare interactive default: a scannable
762
+ // dashboard of the whole fleet grouped by project, newest-active first.
763
+ // Interact/resume via `agents sessions <project>` or `agents sessions resume`.
764
+ if (wantsOverview) {
765
+ const liveIndex = await maybeLiveIndex(options);
766
+ // Per-project row cap is fixed (--limit carries a default of 50 and drives
767
+ // the fetch pool, not the display); `--all` expands every group instead.
768
+ printSessionOverview(sessions, hiddenCount, liveIndex, { perProjectCap: OVERVIEW_ROWS_PER_PROJECT, expand: !!options.all });
769
+ return;
770
+ }
771
+ // --tree / --flat are printed listings, not an interactive pick — render them
772
+ // directly even in a TTY. A search query keeps the interactive picker.
773
+ if (isInteractiveTerminal() && !options.tree && !options.flat) {
524
774
  const message = pathFilter
525
775
  ? `Search sessions (${path.basename(pathFilter)}):`
526
776
  : formatSearchMessage(options);
@@ -531,9 +781,10 @@ async function sessionsAction(query, options) {
531
781
  }
532
782
  return;
533
783
  }
534
- // Non-interactive fallback (piped output)
784
+ // Non-interactive fallback (piped output) or --flat/--tree.
535
785
  const filtered = searchQuery ? filterSessionsByQuery(sessions, searchQuery) : sessions;
536
- printSessionTable(filtered, hiddenCount, options.tree === true);
786
+ const liveIndex = await maybeLiveIndex(options);
787
+ printSessionTable(filtered, hiddenCount, options.tree === true, liveIndex);
537
788
  }
538
789
  catch (err) {
539
790
  tracker.stop();
@@ -560,45 +811,164 @@ function metaSignals(s) {
560
811
  ticket: s.ticketId ? { id: s.ticketId } : undefined,
561
812
  };
562
813
  }
563
- /** One flat table row: shortId · agent · version · project · topic(+badges) · time. */
564
- function flatSessionRow(session) {
814
+ /** One flat table row:
815
+ * shortId · agent · version · project · [glyph] label·doing · [ticket] · [wt] · time
816
+ * `doing` is the live preview when running, else the topic. The `ticket` column
817
+ * (tracker/PR ref, pulled out of the badge blob so refs align) is only rendered
818
+ * when `showTicket` — otherwise a listing with no refs would waste a column of
819
+ * dashes and needlessly truncate the topic. Worktree stays a trailing badge. */
820
+ function flatSessionRow(session, live, showTicket = false, cols = {}) {
565
821
  const agentColor = colorAgent(session.agent);
566
822
  const when = formatRelativeTime(session.timestamp);
567
823
  const project = session.project || '-';
568
824
  const tag = teamTag(session);
569
825
  const label = session.label;
570
- const topic = tag ? `${tag}${session.topic ?? ''}` : session.topic;
571
- const versionStr = session.version || '-';
572
- const badges = signalBadges(metaSignals(session));
573
- const badgeW = badges ? stringWidth(badges) + 1 : 0;
574
- const topicW = Math.max(16, terminalWidth() - (10 + 9 + 8 + 16) - badgeW - stringWidth(when) - 1);
826
+ const { glyph, preview } = liveGlyphAndPreview(live);
827
+ // A running session's live preview says what the agent is doing now; a
828
+ // resting one falls back to its opening topic.
829
+ const doing = preview || (tag ? `${tag}${session.topic ?? ''}` : session.topic);
830
+ const wt = session.worktreeSlug ? chalk.magenta(`wt:${session.worktreeSlug}`) : '';
831
+ // The machine column only earns its width when the listing spans more than one
832
+ // box (i.e. the cross-machine fan-out folded remotes in) — same rule and
833
+ // pool-derived width as the picker.
834
+ const machineColW = cols.machineWidth ?? PICKER_MACHINE_W;
835
+ const machineCell = cols.showMachine
836
+ ? chalk.gray(padToWidth(truncateToWidth((cols.machineLabel?.(session.machine ?? '') ?? session.machine ?? '') || '-', machineColW - 1), machineColW))
837
+ : '';
838
+ const TICKET_W = 10;
839
+ const ticketCell = showTicket
840
+ ? chalk.blue(padToWidth(truncateToWidth(ticketLabel(session) || '-', TICKET_W), TICKET_W + 1))
841
+ : '';
842
+ const glyphW = glyph ? 2 : 0;
843
+ const machineW = cols.showMachine ? machineColW : 0;
844
+ const ticketW = showTicket ? TICKET_W + 1 : 0;
845
+ const wtW = wt ? stringWidth(wt) + 1 : 0;
846
+ const topicW = Math.max(16, terminalWidth() - (10 + 9 + 8 + 16) - glyphW - machineW - ticketW - wtW - stringWidth(when) - 1);
575
847
  return (chalk.white(padToWidth(truncateToWidth(session.shortId, 9), 10)) +
576
848
  agentColor(padToWidth(truncateToWidth(session.agent, 8), 9)) +
577
- chalk.yellow(padToWidth(truncateToWidth(versionStr, 7), 8)) +
849
+ chalk.yellow(padToWidth(truncateToWidth(session.version || '-', 7), 8)) +
850
+ machineCell +
578
851
  chalk.cyan(padToWidth(truncateToWidth(project, 14), 16)) +
579
- renderTopicCell(label, topic, '', topicW, topicW) +
580
- (badges ? badges + ' ' : '') +
852
+ (glyph ? glyph + ' ' : '') +
853
+ renderTopicCell(label, doing, '', topicW, topicW) +
854
+ ticketCell +
855
+ (wt ? wt + ' ' : '') +
581
856
  chalk.gray(when));
582
857
  }
583
858
  /** One tree-mode row (grouped under a dir header): id · agent · badges · topic · time. No version/project column. */
584
- function treeSessionRow(session) {
859
+ function treeSessionRow(session, live) {
585
860
  const agentColor = colorAgent(session.agent);
586
861
  const when = formatRelativeTime(session.timestamp);
587
862
  const tag = teamTag(session);
588
863
  const label = session.label;
589
- const topic = (tag ? `${tag}${session.topic ?? ''}` : session.topic) || '-';
864
+ const { glyph, preview } = liveGlyphAndPreview(live);
865
+ const topic = (preview || (tag ? `${tag}${session.topic ?? ''}` : session.topic)) || '-';
590
866
  const badges = signalBadges(metaSignals(session));
591
867
  const badgeW = badges ? stringWidth(badges) + 1 : 0;
592
868
  const head = label ? `${label} · ${topic}` : topic;
593
- const topicW = Math.max(12, terminalWidth() - (2 + 9 + 8) - badgeW - stringWidth(when) - 1);
869
+ const glyphW = glyph ? 2 : 0;
870
+ const topicW = Math.max(12, terminalWidth() - (2 + 9 + 8) - glyphW - badgeW - stringWidth(when) - 1);
594
871
  return (' ' +
595
872
  chalk.dim(padToWidth(session.shortId, 9)) +
596
873
  agentColor(padToWidth(truncateToWidth(session.agent, 7), 8)) +
597
874
  (badges ? badges + ' ' : '') +
875
+ (glyph ? glyph + ' ' : '') +
598
876
  padToWidth(chalk.white(truncateToWidth(head, topicW)), topicW) +
599
877
  ' ' + chalk.gray(when));
600
878
  }
601
- function printSessionTable(sessions, hiddenCount = 0, tree = false) {
879
+ /**
880
+ * Live-session index for enriching the default listing, or undefined when
881
+ * enrichment is off (`--no-live`) or irrelevant (`--json`, which serializes
882
+ * SessionMeta). Full detection (incl. the headless `ps` scan) is deliberate:
883
+ * bare-CLI and tmux agents are the common case here, and skipping them would
884
+ * leave the glyph almost never showing. The listing is a one-shot user action,
885
+ * not a hot loop, so the `ps`/`lsof` cost is acceptable; `--no-live` is the
886
+ * escape hatch. Never throws — a probe failure just yields a plain listing.
887
+ */
888
+ async function maybeLiveIndex(options) {
889
+ if (options.live === false || options.json)
890
+ return undefined;
891
+ try {
892
+ return indexActiveBySessionId(await getActiveSessions());
893
+ }
894
+ catch {
895
+ return undefined;
896
+ }
897
+ }
898
+ /**
899
+ * Group key for the overview: prefer the indexed project name; else fold the cwd
900
+ * to its repo — a worktree (`.../<repo>/.agents/worktrees/<slug>`) folds to the
901
+ * repo, and a monorepo subdir falls back to its leaf dir basename. Pure.
902
+ */
903
+ export function overviewProjectKey(s) {
904
+ if (s.project && s.project.trim())
905
+ return s.project.trim();
906
+ const cwd = (s.cwd ?? '').replace(/\/+$/, '');
907
+ if (!cwd)
908
+ return '(no project)';
909
+ const wt = cwd.match(/\/([^/]+)\/\.agents\/worktrees\//);
910
+ if (wt)
911
+ return wt[1];
912
+ const parts = cwd.split('/');
913
+ return parts[parts.length - 1] || cwd;
914
+ }
915
+ /**
916
+ * Turn a recency-descending pool into project groups: each group shows its
917
+ * `perProjectCap` most-recent sessions (the rest become `· N more`), and groups
918
+ * are ordered by their most-recent session so the newest-active project leads.
919
+ * `perProjectCap = Infinity` expands every group. Pure — unit-tested.
920
+ */
921
+ export function buildOverviewGroups(pool, perProjectCap) {
922
+ const byKey = new Map();
923
+ for (const s of pool) {
924
+ const k = overviewProjectKey(s);
925
+ (byKey.get(k) ?? byKey.set(k, []).get(k)).push(s);
926
+ }
927
+ const cap = Math.max(1, perProjectCap);
928
+ const groups = [];
929
+ for (const [key, rows] of byKey) {
930
+ const shown = rows.slice(0, cap); // rows are recency-desc (pool was sorted)
931
+ groups.push({ key, total: rows.length, shown, more: rows.length - shown.length, maxTs: rows[0].timestamp });
932
+ }
933
+ groups.sort((a, b) => (a.maxTs < b.maxTs ? 1 : a.maxTs > b.maxTs ? -1 : a.key.localeCompare(b.key)));
934
+ return { groups, projectCount: byKey.size };
935
+ }
936
+ /**
937
+ * The grouped project overview — the bare interactive default. Shows the latest
938
+ * sessions grouped under their project, newest-active project first, with a
939
+ * `· N more` per project and a `+N more projects` when the list is capped.
940
+ */
941
+ function printSessionOverview(pool, hiddenCount, liveIndex, opts) {
942
+ const { groups } = buildOverviewGroups(pool, opts.expand ? Infinity : opts.perProjectCap);
943
+ const shownGroups = opts.expand ? groups : groups.slice(0, OVERVIEW_MAX_PROJECTS);
944
+ const hiddenProjects = groups.length - shownGroups.length;
945
+ const total = pool.length;
946
+ const projWord = groups.length === 1 ? 'project' : 'projects';
947
+ console.log(chalk.gray(`${total} session${total === 1 ? '' : 's'} · ${groups.length} ${projWord} · recent activity\n`));
948
+ let first = true;
949
+ for (const g of shownGroups) {
950
+ if (!first)
951
+ console.log();
952
+ first = false;
953
+ const { glyph } = liveGlyphAndPreview(liveIndex?.get(g.shown[0].id));
954
+ const head = `${chalk.cyan('▸')} ${chalk.cyan.bold(g.key)} ${chalk.gray(String(g.total))}` +
955
+ `${glyph ? ' ' + glyph : ''} ${chalk.gray(formatRelativeTime(g.maxTs))}`;
956
+ console.log(head);
957
+ for (const s of g.shown)
958
+ console.log(treeSessionRow(s, liveIndex?.get(s.id)));
959
+ if (g.more > 0)
960
+ console.log(' ' + chalk.gray(`· ${g.more} more`));
961
+ }
962
+ console.log();
963
+ const parts = [chalk.gray('newest first')];
964
+ if (hiddenProjects > 0)
965
+ parts.push(chalk.gray(`+${hiddenProjects} more project${hiddenProjects === 1 ? '' : 's'} · agents sessions --all`));
966
+ parts.push(chalk.gray('agents sessions <project> to drill in · --flat for the plain list'));
967
+ console.log(parts.join(chalk.gray(' · ')));
968
+ if (hiddenCount > 0)
969
+ console.log(chalk.gray(formatTeamHiddenFooter(hiddenCount)));
970
+ }
971
+ function printSessionTable(sessions, hiddenCount = 0, tree = false, liveIndex) {
602
972
  if (tree) {
603
973
  // Group by directory; drop the id/version columns from view. The short id
604
974
  // stays as each row's leading handle (the address to read/resume it).
@@ -619,7 +989,7 @@ function printSessionTable(sessions, hiddenCount = 0, tree = false) {
619
989
  const group = byDir.get(key);
620
990
  console.log(`${chalk.cyan.bold(shortCwd(key))} ${chalk.gray(`(${group.length})`)}`);
621
991
  for (const s of group)
622
- console.log(treeSessionRow(s));
992
+ console.log(treeSessionRow(s, liveIndex?.get(s.id)));
623
993
  }
624
994
  const dirWord = keys.length === 1 ? 'directory' : 'directories';
625
995
  console.log(chalk.gray(`\n${sessions.length} session${sessions.length === 1 ? '' : 's'} across ${keys.length} ${dirWord}.`));
@@ -627,8 +997,13 @@ function printSessionTable(sessions, hiddenCount = 0, tree = false) {
627
997
  console.log(chalk.gray(formatTeamHiddenFooter(hiddenCount)));
628
998
  return;
629
999
  }
1000
+ // Only show the ticket column when at least one row carries a ref — otherwise
1001
+ // it's a column of dashes that steals width from every topic. The machine
1002
+ // column (and its compact labels) is computed the same way the picker does it.
1003
+ const showTicket = sessions.some((s) => ticketLabel(s) !== '');
1004
+ const cols = pickerColumnsFor(sessions);
630
1005
  for (const session of sessions)
631
- console.log(flatSessionRow(session));
1006
+ console.log(flatSessionRow(session, liveIndex?.get(session.id), showTicket, cols));
632
1007
  const countLine = `${sessions.length} session${sessions.length === 1 ? '' : 's'}.`;
633
1008
  console.log(chalk.gray(`\n${countLine}`));
634
1009
  if (hiddenCount > 0) {
@@ -677,6 +1052,14 @@ function resolveViewMode(options, filters) {
677
1052
  return 'markdown';
678
1053
  return 'summary';
679
1054
  }
1055
+ /**
1056
+ * Render a session's full transcript to stdout — the non-follow view behind
1057
+ * `agents logs <sessionId>`. Reuses the same markdown renderer as
1058
+ * `agents sessions <id> --markdown`.
1059
+ */
1060
+ export async function renderSessionLog(session) {
1061
+ await renderSession(session, 'markdown', {});
1062
+ }
680
1063
  async function renderSession(session, mode, filters, options = {}) {
681
1064
  // OpenCode stores sessions in SQLite; filePath is "db_path#session_id"
682
1065
  const realPath = session.filePath.split('#')[0];
@@ -777,7 +1160,58 @@ function renderTopicCell(label, topic, query, visibleWidth, paddedWidth) {
777
1160
  }
778
1161
  return out + padding;
779
1162
  }
780
- function formatPickerLabel(s, query) {
1163
+ /** Fallback machine-column width when a pool-derived width isn't supplied.
1164
+ * `pickerColumnsFor` normally computes `machineWidth` sized to the actual
1165
+ * hostnames, floored/capped by these bounds so common ids like `yosemite-s0`
1166
+ * (11) fit whole while a pathological hostname can't devour the topic column. */
1167
+ const PICKER_MACHINE_W = 11;
1168
+ const PICKER_MACHINE_MIN = 8;
1169
+ const PICKER_MACHINE_MAX = 18;
1170
+ /** Column width that shows every compacted hostname in `machines` whole (one
1171
+ * trailing space for separation), bounded by MIN/MAX. */
1172
+ function machineColumnWidth(machines, label) {
1173
+ const widest = machines.reduce((w, m) => Math.max(w, stringWidth(label(m))), 0);
1174
+ return Math.min(PICKER_MACHINE_MAX, Math.max(PICKER_MACHINE_MIN, widest + 1));
1175
+ }
1176
+ /**
1177
+ * Compact display form for machine ids: strip the longest shared dash-delimited
1178
+ * prefix so "yosemite-s0"/"yosemite-s1" read as "s0"/"s1" while unrelated ids
1179
+ * ("zion", "mac-mini") stay whole. Stripping a *common* prefix can't collide,
1180
+ * and at least one segment is always kept.
1181
+ */
1182
+ export function machineLabeler(machines) {
1183
+ const uniq = [...new Set(machines.filter(Boolean))];
1184
+ if (uniq.length < 2)
1185
+ return (m) => m;
1186
+ const parts = uniq.map((m) => m.split('-'));
1187
+ const min = Math.min(...parts.map((p) => p.length));
1188
+ let shared = 0;
1189
+ while (shared < min - 1 && parts.every((p) => p[shared] === parts[0][shared]))
1190
+ shared++;
1191
+ if (shared === 0)
1192
+ return (m) => m;
1193
+ return (m) => {
1194
+ const p = m.split('-');
1195
+ return p.length > shared ? p.slice(shared).join('-') : m;
1196
+ };
1197
+ }
1198
+ /**
1199
+ * Column flags for a picker, computed once over the whole pool so every row
1200
+ * aligns: the machine column only earns its width when the listing spans more
1201
+ * than one box, the ticket column only when some row carries a PR/ticket ref.
1202
+ */
1203
+ export function pickerColumnsFor(sessions) {
1204
+ const machines = sessions.map((s) => s.machine).filter((m) => !!m);
1205
+ const distinct = [...new Set(machines)];
1206
+ const machineLabel = machineLabeler(machines);
1207
+ return {
1208
+ showMachine: distinct.length > 1,
1209
+ machineLabel,
1210
+ machineWidth: machineColumnWidth(distinct, machineLabel),
1211
+ showTicket: sessions.some((s) => ticketLabel(s) !== ''),
1212
+ };
1213
+ }
1214
+ export function formatPickerLabel(s, query, cols = {}) {
781
1215
  const agentColor = colorAgent(s.agent);
782
1216
  const when = formatRelativeTime(s.timestamp);
783
1217
  const project = s.project || '-';
@@ -785,20 +1219,56 @@ function formatPickerLabel(s, query) {
785
1219
  const label = s.label;
786
1220
  const topic = tag ? `${tag}${s.topic ?? ''}` : s.topic;
787
1221
  const versionStr = s.version || '-';
1222
+ const wt = s.worktreeSlug ? chalk.magenta(`wt:${s.worktreeSlug}`) : '';
1223
+ const machineW = cols.machineWidth ?? PICKER_MACHINE_W;
1224
+ const machineCell = cols.showMachine
1225
+ ? chalk.gray(padRight(truncate((cols.machineLabel?.(s.machine ?? '') ?? s.machine ?? '') || '-', machineW - 1), machineW))
1226
+ : '';
1227
+ const TICKET_W = 10;
1228
+ const ticketCell = cols.showTicket
1229
+ ? chalk.blue(padRight(truncate(ticketLabel(s) || '-', TICKET_W), TICKET_W + 1))
1230
+ : '';
1231
+ // The picker prepends a gutter (cursor, plus a checkbox in multi-select mode);
1232
+ // reserve it, plus the conditional columns, so the topic shrinks to fit and
1233
+ // rows never wrap.
1234
+ const gutter = cols.gutter ?? 2;
1235
+ const machineColW = cols.showMachine ? machineW : 0;
1236
+ const ticketW = cols.showTicket ? TICKET_W + 1 : 0;
1237
+ const wtW = wt ? stringWidth(wt) + 1 : 0;
1238
+ const topicW = Math.max(16, terminalWidth() - gutter - (10 + 9 + 8 + 16) - machineColW - ticketW - wtW - stringWidth(when) - 1);
788
1239
  return (chalk.white(padRight(s.shortId, 10)) +
789
1240
  agentColor(padRight(truncate(s.agent, 8), 9)) +
790
1241
  chalk.yellow(padRight(truncate(versionStr, 7), 8)) +
1242
+ machineCell +
791
1243
  chalk.cyan(padRight(truncate(project, 14), 16)) +
792
- renderTopicCell(label, topic, query, 48, 50) +
1244
+ renderTopicCell(label, topic, query, topicW, topicW) +
1245
+ ticketCell +
1246
+ (wt ? wt + ' ' : '') +
793
1247
  chalk.gray(when));
794
1248
  }
1249
+ /** Hints rotated above the picker so the flags/features stay discoverable. */
1250
+ const PICKER_TIPS = [
1251
+ 'Tip: narrow with -a/--agent (e.g. -a codex), or --project <name> for another folder.',
1252
+ "Tip: --all searches every directory; -H/--host <machine> folds in another box's sessions.",
1253
+ 'Tip: just type to fuzzy-search prompts and responses; press space to preview a session.',
1254
+ 'Tip: --since 2d / --until <date> bound the time window; pass a session id to open it directly.',
1255
+ ];
1256
+ /**
1257
+ * Pick a hint to show above the picker. Deterministic (keys off the pool size)
1258
+ * so it stays fixed across the picker's re-renders within a single run.
1259
+ */
1260
+ export function formatPickerTip(sessions) {
1261
+ return chalk.gray(PICKER_TIPS[sessions.length % PICKER_TIPS.length]);
1262
+ }
795
1263
  export async function pickSessionInteractive(sessions, message = 'Search sessions:', initialSearch, hiddenCount = 0) {
796
1264
  if (hiddenCount > 0) {
797
1265
  console.log(chalk.gray(formatTeamHiddenFooter(hiddenCount)));
798
1266
  }
1267
+ const cols = pickerColumnsFor(sessions);
799
1268
  try {
800
1269
  return await sessionPicker({
801
1270
  message,
1271
+ subtitle: formatPickerTip(sessions),
802
1272
  sessions,
803
1273
  filter: (query) => {
804
1274
  // No query: show the full pool (picker viewport still paginates via pageSize).
@@ -807,7 +1277,7 @@ export async function pickSessionInteractive(sessions, message = 'Search session
807
1277
  return sessions;
808
1278
  return filterSessionsByQuery(sessions, query);
809
1279
  },
810
- labelFor: (s, query) => formatPickerLabel(s, query),
1280
+ labelFor: (s, query) => formatPickerLabel(s, query, cols),
811
1281
  pageSize: PICKER_RECENT_COUNT,
812
1282
  initialSearch,
813
1283
  });
@@ -818,41 +1288,108 @@ export async function pickSessionInteractive(sessions, message = 'Search session
818
1288
  throw err;
819
1289
  }
820
1290
  }
1291
+ /**
1292
+ * The machine a picked session lives on when its transcript is on that peer's
1293
+ * disk (folded in over the live fan-out), else undefined. Keys off `_remote`,
1294
+ * NOT `machine !== local`: a synced mirror is machine-tagged too, but its file
1295
+ * is a local mirror path, so it must be read/resumed locally like any other.
1296
+ */
1297
+ function remoteMachineOf(session) {
1298
+ return session._remote ? session.machine : undefined;
1299
+ }
1300
+ /** True when the peer wasn't a dialable device; prints one clear line so a
1301
+ * remote pick never dead-ends silently. */
1302
+ function warnNoPeerTarget(machine, session) {
1303
+ console.log(chalk.yellow(`Session ${session.shortId} lives on ${machine}, which isn't a reachable device right now.`));
1304
+ console.log(chalk.gray(`Register/wake it (ag devices), or run there: agents ssh ${machine}`));
1305
+ }
821
1306
  async function handlePickedSession(picked) {
1307
+ // A session on another machine is read/resumed ON that machine over SSH — its
1308
+ // transcript and agent binary live there. Both actions execute on the peer
1309
+ // (not a local `--host` hop, which would discover locally and dead-end for a
1310
+ // session that exists only on the peer).
1311
+ const remote = remoteMachineOf(picked.session);
1312
+ if (remote) {
1313
+ if (picked.action === 'view') {
1314
+ const rc = await runOnPeer(['sessions', picked.session.shortId, '--markdown'], remote);
1315
+ if (rc === 'no-target')
1316
+ warnNoPeerTarget(remote, picked.session);
1317
+ }
1318
+ else {
1319
+ console.log(chalk.gray(`Resuming ${picked.session.shortId} on ${remote} over SSH...`));
1320
+ const rc = await runOnPeer(['sessions', 'resume', picked.session.shortId], remote, { tty: true });
1321
+ if (rc === 'no-target')
1322
+ warnNoPeerTarget(remote, picked.session);
1323
+ }
1324
+ return;
1325
+ }
822
1326
  if (picked.action === 'view') {
823
1327
  await renderSession(picked.session, 'summary', {});
824
1328
  return;
825
1329
  }
826
- const cwd = picked.session.cwd && fs.existsSync(picked.session.cwd)
827
- ? picked.session.cwd
1330
+ await resumeSessionInPlace(picked.session);
1331
+ }
1332
+ /**
1333
+ * Resume a session in the current terminal — a foreground takeover of this
1334
+ * process. Used by the single-select picker and by `sessions resume` when the
1335
+ * chosen destination is "in place" (unknown emulator / off-macOS, single pick).
1336
+ * Falls back to the current version via `/continue` when the version-pinned
1337
+ * binary is missing (ENOENT).
1338
+ */
1339
+ export async function resumeSessionInPlace(session) {
1340
+ const cwd = session.cwd && fs.existsSync(session.cwd)
1341
+ ? session.cwd
828
1342
  : process.cwd();
829
- const resume = buildResumeCommand(picked.session);
1343
+ const resume = buildResumeCommand(session);
830
1344
  if (!resume) {
831
- console.log(chalk.yellow(`Resume is not supported for ${picked.session.agent} sessions yet. Showing summary instead.`));
832
- await renderSession(picked.session, 'summary', {});
1345
+ console.log(chalk.yellow(`Resume is not supported for ${session.agent} sessions yet. Showing summary instead.`));
1346
+ await renderSession(session, 'summary', {});
833
1347
  return;
834
1348
  }
835
1349
  console.log(chalk.gray(`Resuming: ${resume.join(' ')} (cwd: ${cwd})`));
836
- await new Promise((resolve) => {
837
- const child = spawn(resume[0], resume.slice(1), {
838
- cwd,
839
- stdio: 'inherit',
840
- shell: false,
841
- });
1350
+ // Resolve the (possibly version-pinned) launcher up front. On Windows the
1351
+ // agent shim is a `.cmd`/`.ps1` and, under the shell needed to run it (see
1352
+ // spawnResumeCommand), a missing command exits non-zero rather than emitting
1353
+ // an ENOENT `error` event — so detect a removed version here instead of
1354
+ // relying on that event, keeping the /continue fallback working on every OS.
1355
+ if (!findExecutable(resume[0]) && session.version) {
1356
+ const fallback = buildFallbackCommand(session);
1357
+ if (fallback) {
1358
+ console.log(chalk.gray(`Version ${session.version} is not installed. Falling back to current version via /continue...`));
1359
+ await spawnResumeCommand(fallback, cwd);
1360
+ return;
1361
+ }
1362
+ }
1363
+ await spawnResumeCommand(resume, cwd);
1364
+ }
1365
+ /**
1366
+ * Spawn a resume command as a foreground takeover (inherited stdio), resolving
1367
+ * when it exits. On Windows the agent launcher is a `.cmd`/PATHEXT shim that
1368
+ * `spawn` can't exec directly — a bare-name `shell:false` spawn throws
1369
+ * `EFTYPE`/`ENOENT` there — so we go through the shell via `needsWindowsShell`.
1370
+ * The spawn is guarded because such a failure can be thrown synchronously;
1371
+ * without the guard it would surface under an unrelated "Failed to discover
1372
+ * sessions" catch upstream instead of a truthful launch error.
1373
+ */
1374
+ function spawnResumeCommand(cmd, cwd) {
1375
+ return new Promise((resolve) => {
1376
+ let child;
1377
+ try {
1378
+ child = spawn(cmd[0], cmd.slice(1), {
1379
+ cwd,
1380
+ stdio: 'inherit',
1381
+ shell: needsWindowsShell(cmd[0]),
1382
+ });
1383
+ }
1384
+ catch (err) {
1385
+ console.error(chalk.red(`Failed to launch ${cmd[0]}: ${err.message}`));
1386
+ resolve();
1387
+ return;
1388
+ }
842
1389
  child.on('error', (err) => {
843
- if (err.code === 'ENOENT' && picked.session.version) {
844
- const fallback = buildFallbackCommand(picked.session);
845
- if (fallback) {
846
- console.log(chalk.gray(`Version ${picked.session.version} is not installed. Falling back to current version via /continue...`));
847
- const fb = spawn(fallback[0], fallback.slice(1), { cwd, stdio: 'inherit', shell: false });
848
- fb.on('error', (e) => { console.error(chalk.red(`Failed: ${e.message}`)); resolve(); });
849
- fb.on('close', () => resolve());
850
- return;
851
- }
852
- }
853
- console.error(chalk.red(`Failed to launch ${resume[0]}: ${err.message}`));
1390
+ console.error(chalk.red(`Failed to launch ${cmd[0]}: ${err.message}`));
854
1391
  if (err.code === 'ENOENT') {
855
- console.error(chalk.gray(`Make sure '${resume[0]}' is on your PATH.`));
1392
+ console.error(chalk.gray(`Make sure '${cmd[0]}' is on your PATH.`));
856
1393
  }
857
1394
  resolve();
858
1395
  });
@@ -883,6 +1420,7 @@ export function buildResumeCommand(session) {
883
1420
  case 'opencode':
884
1421
  return ['opencode', '--session', session.id];
885
1422
  case 'gemini':
1423
+ case 'antigravity':
886
1424
  case 'openclaw':
887
1425
  case 'rush':
888
1426
  case 'hermes':
@@ -971,7 +1509,7 @@ async function runCloudSessions(query, options) {
971
1509
  // Ensure the SessionMeta points at the local cache path for renderSession.
972
1510
  await renderSession({ ...meta, filePath: cachedPath }, mode, filterOpts, options);
973
1511
  }
974
- function parseAgentFilter(agentName) {
1512
+ export function parseAgentFilter(agentName) {
975
1513
  if (!agentName)
976
1514
  return {};
977
1515
  const [name, version] = agentName.split('@', 2);
@@ -1264,8 +1802,11 @@ export function registerSessionsCommands(program) {
1264
1802
  .option('--artifacts', 'List all files written or edited during a session')
1265
1803
  .option('--artifact <name>', 'Read a specific artifact by filename or path (outputs to stdout)')
1266
1804
  .option('--active', 'Show only sessions running right now across terminals, teams, cloud, and headless agents')
1805
+ .option('--local', 'Only this machine — skip the cross-machine SSH fan-out (default listing and --active)')
1267
1806
  .option('--waiting', 'With --active: show only sessions waiting on your input (exits non-zero if any)')
1268
1807
  .option('--tree', 'Group the listing by directory; drops the id/version columns for readability')
1808
+ .option('--flat', 'Plain flat table (one row per session) instead of the grouped project overview')
1809
+ .option('--no-live', 'Do not enrich the listing with live status/preview for running sessions')
1269
1810
  .option('--cloud', 'Source sessions from Rush Cloud (captured runs) instead of local disk')
1270
1811
  .option('-H, --host <target...>', 'Run this query on remote machine(s) over SSH (host alias or user@host; repeatable)');
1271
1812
  setHelpSections(sessionsCmd, {
@@ -1282,6 +1823,10 @@ export function registerSessionsCommands(program) {
1282
1823
  # Show only what's running right now (terminals, teams, cloud, headless)
1283
1824
  agents sessions --active
1284
1825
 
1826
+ # The interactive list folds in other online machines automatically,
1827
+ # labelled by host with this machine first. Stay local with --local:
1828
+ agents sessions --local
1829
+
1285
1830
  # Search across every directory, not just this project
1286
1831
  agents sessions "topic" --all
1287
1832
 
@@ -1295,6 +1840,7 @@ export function registerSessionsCommands(program) {
1295
1840
  agents sessions --all "deploy script" --host box-a --host box-b
1296
1841
  `,
1297
1842
  notes: `
1843
+ - The interactive listing folds in your other online machines automatically (live over SSH, no sync) — each row is labelled by host, this machine first. Use --local to skip the fan-out; --json and single-id lookups stay local.
1298
1844
  - --host runs the query on the remote's own index over SSH (host alias or user@host); repeat or pass several to fan out. SSH access is the only auth.
1299
1845
  - --include and --exclude are mutually exclusive.
1300
1846
  - --first and --last are mutually exclusive.
@@ -1308,6 +1854,8 @@ export function registerSessionsCommands(program) {
1308
1854
  });
1309
1855
  registerSessionsTailCommand(sessionsCmd);
1310
1856
  registerSessionsSyncCommand(sessionsCmd);
1857
+ registerSessionsResumeCommand(sessionsCmd);
1858
+ registerSessionsInjectCommand(sessionsCmd);
1311
1859
  }
1312
1860
  function formatNoSessionsMessage(showAll, project) {
1313
1861
  const projectQuery = project?.trim();