@phnx-labs/agents-cli 1.20.35 → 1.20.37

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 (236) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/LICENSE +185 -21
  3. package/README.md +8 -4
  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.js +81 -0
  11. package/dist/commands/daemon.js +4 -1
  12. package/dist/commands/doctor.js +1 -89
  13. package/dist/commands/events.js +3 -3
  14. package/dist/commands/exec.d.ts +19 -0
  15. package/dist/commands/exec.js +277 -11
  16. package/dist/commands/hosts.js +10 -6
  17. package/dist/commands/inspect.js +8 -9
  18. package/dist/commands/lock.d.ts +12 -0
  19. package/dist/commands/lock.js +70 -0
  20. package/dist/commands/message.d.ts +15 -0
  21. package/dist/commands/message.js +56 -0
  22. package/dist/commands/routines.js +101 -5
  23. package/dist/commands/secrets-migrate.js +106 -57
  24. package/dist/commands/secrets.d.ts +31 -18
  25. package/dist/commands/secrets.js +156 -75
  26. package/dist/commands/serve.d.ts +10 -0
  27. package/dist/commands/serve.js +37 -0
  28. package/dist/commands/sessions-inject.d.ts +14 -0
  29. package/dist/commands/sessions-inject.js +111 -0
  30. package/dist/commands/sessions-picker.d.ts +2 -0
  31. package/dist/commands/sessions-picker.js +24 -3
  32. package/dist/commands/sessions-resume.js +20 -8
  33. package/dist/commands/sessions-sync.d.ts +3 -0
  34. package/dist/commands/sessions-sync.js +44 -4
  35. package/dist/commands/sessions.d.ts +78 -1
  36. package/dist/commands/sessions.js +506 -66
  37. package/dist/commands/setup.js +4 -2
  38. package/dist/commands/sync.d.ts +3 -1
  39. package/dist/commands/sync.js +156 -4
  40. package/dist/commands/teams.js +217 -0
  41. package/dist/commands/versions.js +2 -4
  42. package/dist/commands/watchdog.d.ts +18 -0
  43. package/dist/commands/watchdog.js +238 -0
  44. package/dist/index.js +25 -2
  45. package/dist/lib/audit/log.d.ts +92 -0
  46. package/dist/lib/audit/log.js +177 -0
  47. package/dist/lib/auto-pull.js +2 -1
  48. package/dist/lib/browser/chrome.d.ts +10 -0
  49. package/dist/lib/browser/chrome.js +18 -7
  50. package/dist/lib/browser/drivers/ssh.js +2 -1
  51. package/dist/lib/browser/har.d.ts +84 -0
  52. package/dist/lib/browser/har.js +77 -0
  53. package/dist/lib/browser/ipc.js +24 -3
  54. package/dist/lib/browser/profiles.d.ts +1 -1
  55. package/dist/lib/browser/profiles.js +8 -10
  56. package/dist/lib/browser/refs.d.ts +65 -0
  57. package/dist/lib/browser/refs.js +73 -1
  58. package/dist/lib/browser/runtime-state.js +1 -0
  59. package/dist/lib/browser/service.d.ts +38 -2
  60. package/dist/lib/browser/service.js +112 -8
  61. package/dist/lib/browser/types.d.ts +14 -1
  62. package/dist/lib/budget/live-cloud.d.ts +42 -0
  63. package/dist/lib/budget/live-cloud.js +79 -0
  64. package/dist/lib/budget/live-team.d.ts +31 -0
  65. package/dist/lib/budget/live-team.js +115 -0
  66. package/dist/lib/cloud/codex.js +4 -0
  67. package/dist/lib/cloud/rush.d.ts +12 -1
  68. package/dist/lib/cloud/rush.js +13 -3
  69. package/dist/lib/cloud/types.d.ts +9 -0
  70. package/dist/lib/computer/dispatch.d.ts +8 -0
  71. package/dist/lib/computer/dispatch.js +125 -0
  72. package/dist/lib/computer/loop.d.ts +62 -0
  73. package/dist/lib/computer/loop.js +98 -0
  74. package/dist/lib/computer/model.d.ts +44 -0
  75. package/dist/lib/computer/model.js +157 -0
  76. package/dist/lib/concurrency.d.ts +19 -0
  77. package/dist/lib/concurrency.js +33 -0
  78. package/dist/lib/daemon.d.ts +57 -0
  79. package/dist/lib/daemon.js +196 -18
  80. package/dist/lib/devices/registry.d.ts +7 -0
  81. package/dist/lib/devices/registry.js +24 -0
  82. package/dist/lib/devices/resolve-target.d.ts +24 -0
  83. package/dist/lib/devices/resolve-target.js +80 -0
  84. package/dist/lib/devices/tailscale.js +1 -1
  85. package/dist/lib/drift.d.ts +52 -0
  86. package/dist/lib/drift.js +112 -0
  87. package/dist/lib/events.d.ts +1 -1
  88. package/dist/lib/events.js +31 -13
  89. package/dist/lib/exec.d.ts +17 -0
  90. package/dist/lib/exec.js +79 -13
  91. package/dist/lib/git.d.ts +27 -0
  92. package/dist/lib/git.js +56 -1
  93. package/dist/lib/hooks/cache.d.ts +6 -0
  94. package/dist/lib/hooks/cache.js +54 -12
  95. package/dist/lib/hooks.d.ts +27 -0
  96. package/dist/lib/hooks.js +127 -8
  97. package/dist/lib/hosts/dispatch.d.ts +15 -0
  98. package/dist/lib/hosts/dispatch.js +39 -6
  99. package/dist/lib/hosts/logs.js +30 -1
  100. package/dist/lib/hosts/option.js +1 -1
  101. package/dist/lib/hosts/passthrough.js +3 -1
  102. package/dist/lib/hosts/ready.d.ts +29 -6
  103. package/dist/lib/hosts/ready.js +66 -15
  104. package/dist/lib/hosts/registry.d.ts +19 -2
  105. package/dist/lib/hosts/registry.js +58 -2
  106. package/dist/lib/hosts/remote-cmd.d.ts +62 -1
  107. package/dist/lib/hosts/remote-cmd.js +70 -1
  108. package/dist/lib/hosts/remote-os.d.ts +17 -0
  109. package/dist/lib/hosts/remote-os.js +30 -0
  110. package/dist/lib/hosts/session-index.d.ts +34 -0
  111. package/dist/lib/hosts/session-index.js +56 -0
  112. package/dist/lib/hosts/tasks.d.ts +14 -0
  113. package/dist/lib/hosts/tasks.js +15 -0
  114. package/dist/lib/lock.d.ts +93 -0
  115. package/dist/lib/lock.js +207 -0
  116. package/dist/lib/loop.js +16 -1
  117. package/dist/lib/machine-id.d.ts +21 -0
  118. package/dist/lib/machine-id.js +26 -0
  119. package/dist/lib/mailbox-target.d.ts +36 -0
  120. package/dist/lib/mailbox-target.js +45 -0
  121. package/dist/lib/mailbox.d.ts +47 -0
  122. package/dist/lib/mailbox.js +194 -0
  123. package/dist/lib/mcp.d.ts +5 -0
  124. package/dist/lib/mcp.js +24 -8
  125. package/dist/lib/migrate.d.ts +19 -0
  126. package/dist/lib/migrate.js +134 -26
  127. package/dist/lib/overdue.js +3 -0
  128. package/dist/lib/picker.d.ts +2 -0
  129. package/dist/lib/picker.js +4 -1
  130. package/dist/lib/platform/exec.d.ts +46 -0
  131. package/dist/lib/platform/exec.js +74 -0
  132. package/dist/lib/platform/process.d.ts +31 -0
  133. package/dist/lib/platform/process.js +34 -1
  134. package/dist/lib/platform/winpath.js +2 -0
  135. package/dist/lib/plugins.js +16 -6
  136. package/dist/lib/profiles.d.ts +25 -0
  137. package/dist/lib/profiles.js +22 -6
  138. package/dist/lib/pty-client.js +2 -1
  139. package/dist/lib/rotate.d.ts +61 -0
  140. package/dist/lib/rotate.js +52 -0
  141. package/dist/lib/routines.d.ts +40 -2
  142. package/dist/lib/routines.js +66 -8
  143. package/dist/lib/runner.d.ts +11 -2
  144. package/dist/lib/runner.js +49 -7
  145. package/dist/lib/scheduler.js +6 -1
  146. package/dist/lib/secrets/bundles.d.ts +60 -4
  147. package/dist/lib/secrets/bundles.js +131 -12
  148. package/dist/lib/secrets/filestore.d.ts +3 -0
  149. package/dist/lib/secrets/filestore.js +42 -16
  150. package/dist/lib/secrets/index.d.ts +43 -2
  151. package/dist/lib/secrets/index.js +102 -3
  152. package/dist/lib/secrets/mcp.d.ts +93 -0
  153. package/dist/lib/secrets/mcp.js +205 -0
  154. package/dist/lib/secrets/remote.js +12 -5
  155. package/dist/lib/secrets/sync.js +83 -4
  156. package/dist/lib/secrets/windows.js +14 -3
  157. package/dist/lib/serve/data.d.ts +81 -0
  158. package/dist/lib/serve/data.js +91 -0
  159. package/dist/lib/serve/page.d.ts +7 -0
  160. package/dist/lib/serve/page.js +140 -0
  161. package/dist/lib/serve/server.d.ts +46 -0
  162. package/dist/lib/serve/server.js +115 -0
  163. package/dist/lib/session/active.d.ts +73 -0
  164. package/dist/lib/session/active.js +199 -22
  165. package/dist/lib/session/db.d.ts +2 -1
  166. package/dist/lib/session/db.js +41 -5
  167. package/dist/lib/session/discover.d.ts +39 -0
  168. package/dist/lib/session/discover.js +127 -29
  169. package/dist/lib/session/ghostty-tabs.d.ts +33 -0
  170. package/dist/lib/session/ghostty-tabs.js +126 -0
  171. package/dist/lib/session/inject.d.ts +18 -0
  172. package/dist/lib/session/inject.js +21 -0
  173. package/dist/lib/session/parse.js +23 -20
  174. package/dist/lib/session/pid-registry.d.ts +1 -0
  175. package/dist/lib/session/pid-registry.js +24 -0
  176. package/dist/lib/session/provenance.d.ts +14 -2
  177. package/dist/lib/session/provenance.js +39 -8
  178. package/dist/lib/session/relative-time.js +6 -2
  179. package/dist/lib/session/remote-active.js +19 -17
  180. package/dist/lib/session/remote-list.d.ts +51 -0
  181. package/dist/lib/session/remote-list.js +205 -0
  182. package/dist/lib/session/remote.d.ts +7 -1
  183. package/dist/lib/session/remote.js +20 -4
  184. package/dist/lib/session/sync/config.d.ts +14 -15
  185. package/dist/lib/session/sync/config.js +60 -20
  186. package/dist/lib/session/types.d.ts +23 -0
  187. package/dist/lib/shims.d.ts +36 -6
  188. package/dist/lib/shims.js +91 -29
  189. package/dist/lib/ssh-exec.js +2 -0
  190. package/dist/lib/ssh-tunnel.js +2 -1
  191. package/dist/lib/startup/command-registry.d.ts +6 -0
  192. package/dist/lib/startup/command-registry.js +13 -1
  193. package/dist/lib/state.d.ts +13 -0
  194. package/dist/lib/state.js +103 -9
  195. package/dist/lib/sync-umbrella.d.ts +14 -7
  196. package/dist/lib/sync-umbrella.js +21 -13
  197. package/dist/lib/teams/forEach.d.ts +110 -0
  198. package/dist/lib/teams/forEach.js +186 -0
  199. package/dist/lib/teams/index.d.ts +1 -0
  200. package/dist/lib/teams/index.js +1 -0
  201. package/dist/lib/teams/pr-watch.d.ts +226 -0
  202. package/dist/lib/teams/pr-watch.js +371 -0
  203. package/dist/lib/teams/supervisor.d.ts +14 -1
  204. package/dist/lib/teams/supervisor.js +19 -0
  205. package/dist/lib/teams/worktree.d.ts +9 -0
  206. package/dist/lib/teams/worktree.js +32 -0
  207. package/dist/lib/terminal/backends/index.d.ts +2 -1
  208. package/dist/lib/terminal/backends/index.js +3 -1
  209. package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
  210. package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
  211. package/dist/lib/terminal/index.d.ts +4 -1
  212. package/dist/lib/terminal/index.js +4 -1
  213. package/dist/lib/terminal/inject.d.ts +204 -0
  214. package/dist/lib/terminal/inject.js +247 -0
  215. package/dist/lib/terminal/resolve.d.ts +64 -0
  216. package/dist/lib/terminal/resolve.js +90 -0
  217. package/dist/lib/terminal/types.d.ts +1 -1
  218. package/dist/lib/tmux/session.d.ts +10 -0
  219. package/dist/lib/tmux/session.js +31 -0
  220. package/dist/lib/triggers/webhook.d.ts +85 -0
  221. package/dist/lib/triggers/webhook.js +141 -0
  222. package/dist/lib/versions.d.ts +23 -0
  223. package/dist/lib/versions.js +119 -13
  224. package/dist/lib/watchdog/index.d.ts +3 -0
  225. package/dist/lib/watchdog/index.js +5 -0
  226. package/dist/lib/watchdog/read.d.ts +35 -0
  227. package/dist/lib/watchdog/read.js +149 -0
  228. package/dist/lib/watchdog/runner.d.ts +127 -0
  229. package/dist/lib/watchdog/runner.js +322 -0
  230. package/dist/lib/watchdog/watchdog.d.ts +40 -0
  231. package/dist/lib/watchdog/watchdog.js +166 -0
  232. package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
  233. package/dist/lib/watchdog/watchdogTail.js +154 -0
  234. package/dist/lib/workflows.d.ts +166 -0
  235. package/dist/lib/workflows.js +193 -0
  236. package/package.json +5 -4
@@ -15,15 +15,18 @@ 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 { enumerateGhosttyTabs, assignGhosttyTabs } from '../lib/session/ghostty-tabs.js';
21
+ import { mapPanesToTargets } from '../lib/tmux/session.js';
20
22
  import { machineId, normalizeHost } from '../lib/session/sync/config.js';
21
- import { gatherRemoteActive } from '../lib/session/remote-active.js';
23
+ import { gatherRemoteActive, NO_FANOUT_ENV } from '../lib/session/remote-active.js';
24
+ import { gatherRemoteList, runOnPeer } from '../lib/session/remote-list.js';
22
25
  import { stringWidth, truncateToWidth, padToWidth, terminalWidth } from '../lib/session/width.js';
23
26
  import { discoverSessions, countSessionsInScope, resolveSessionById, searchContentIndex } from '../lib/session/discover.js';
24
27
  import { filterTeamSessions } from '../lib/session/team-filter.js';
25
28
  import { parseSession } from '../lib/session/parse.js';
26
- import { runRemoteSessions } from '../lib/session/remote.js';
29
+ import { runRemoteSessions, buildForwardedArgs } from '../lib/session/remote.js';
27
30
  import { formatRelativeTime } from '../lib/session/relative-time.js';
28
31
  import { renderConversationMarkdown, renderSummary, renderSummaryHeader, computeSummaryStats, renderJson, filterEvents, parseRoleList } from '../lib/session/render.js';
29
32
  import { renderMarkdown } from '../lib/markdown.js';
@@ -36,7 +39,26 @@ import { setHelpSections } from '../lib/help.js';
36
39
  import { registerSessionsTailCommand } from './sessions-tail.js';
37
40
  import { registerSessionsSyncCommand } from './sessions-sync.js';
38
41
  import { registerSessionsResumeCommand } from './sessions-resume.js';
42
+ import { registerSessionsInjectCommand } from './sessions-inject.js';
39
43
  const SESSION_AGENT_FILTER_HELP = `Filter by agent, e.g. claude, codex, claude@2.0.65`;
44
+ /**
45
+ * The prioritized harnesses that get a boolean shorthand flag (e.g. `--claude`
46
+ * === `--agent claude`). The rest stay reachable via `--agent <name>`, which
47
+ * also carries version pins like `codex@0.116.0`.
48
+ */
49
+ const AGENT_SHORTHANDS = ['claude', 'codex', 'kimi', 'antigravity', 'grok', 'opencode'];
50
+ /**
51
+ * Resolve a per-agent shorthand (`--claude`, `--kimi`, …) into `options.agent`.
52
+ * An explicit `--agent` wins; if two shorthands are passed we take the first and
53
+ * ignore the rest (commander gives no ordering, so this is a best-effort alias).
54
+ */
55
+ function applyAgentShorthands(options) {
56
+ if (options.agent)
57
+ return;
58
+ const hit = AGENT_SHORTHANDS.find((name) => options[name] === true);
59
+ if (hit)
60
+ options.agent = hit;
61
+ }
40
62
  const CLAUDE_RESUME_MATCH_WINDOW_MS = 10 * 60_000;
41
63
  const LOAD_VERBS = ['Loading', 'Scanning', 'Gathering', 'Indexing', 'Reading'];
42
64
  const FIND_VERBS = ['Finding', 'Searching', 'Locating', 'Matching'];
@@ -78,6 +100,12 @@ function createScanProgressTracker(verbs, suffix, spinner) {
78
100
  }
79
101
  const PICKER_RECENT_COUNT = 15;
80
102
  const PICKER_POOL_LIMIT = 200;
103
+ // The grouped default view ("overview"): fetch a generous recency-ordered pool
104
+ // for accurate per-project totals, show each project's most-recent rows grouped
105
+ // by project, newest-active project first.
106
+ const OVERVIEW_ROWS_PER_PROJECT = 5; // recent rows shown per project before "· N more"
107
+ const OVERVIEW_POOL_LIMIT = 1000; // fetch cap — accurate per-project totals up to this
108
+ const OVERVIEW_MAX_PROJECTS = 12; // project groups shown before "+N more projects"
81
109
  /**
82
110
  * Resolve a path-like query to an absolute directory path.
83
111
  */
@@ -166,13 +194,28 @@ function formatStartedAt(startedAtMs) {
166
194
  return '-';
167
195
  return formatRelativeTime(new Date(startedAtMs).toISOString());
168
196
  }
197
+ /**
198
+ * Strip terminal/harness noise from a preview so the column stays a single line
199
+ * of plain prose: OSC title escapes, CSI/SGR ANSI, and the harness wrapper tags
200
+ * (`<local-command-stdout>`, `<task-notification>`, `<command-*>`) that leak from
201
+ * a captured transcript tail. Collapses runs of whitespace.
202
+ */
203
+ export function cleanPreview(text) {
204
+ // eslint-disable-next-line no-control-regex
205
+ return text
206
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC (title) sequences
207
+ .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '') // CSI / SGR ANSI
208
+ .replace(/<\/?(?:local-command-stdout|command-name|command-message|command-args|task-notification|system-reminder)>/g, '')
209
+ .replace(/\s+/g, ' ')
210
+ .trim();
211
+ }
169
212
  /**
170
213
  * Build the live description for an active session: prefer the state engine's
171
214
  * preview (the latest turn), then a user label, then the first-prompt topic.
172
215
  */
173
216
  function buildSessionDescription(s) {
174
217
  if (s.context === 'cloud') {
175
- return s.preview || `${s.cloudProvider ?? ''}${s.cloudTaskId ? ` · ${s.cloudTaskId.slice(0, 12)}` : ''}`;
218
+ return cleanPreview(s.preview || `${s.cloudProvider ?? ''}${s.cloudTaskId ? ` · ${s.cloudTaskId.slice(0, 12)}` : ''}`);
176
219
  }
177
220
  if (s.context === 'teams') {
178
221
  const parts = [s.teamName];
@@ -182,10 +225,10 @@ function buildSessionDescription(s) {
182
225
  parts.push(s.label);
183
226
  else if (s.topic)
184
227
  parts.push(s.topic);
185
- return parts.filter(Boolean).join(' · ');
228
+ return cleanPreview(parts.filter(Boolean).join(' · '));
186
229
  }
187
230
  // Terminal or headless: prefer the live preview, then label, then topic.
188
- return s.preview || s.label || s.topic || '';
231
+ return cleanPreview(s.preview || s.label || s.topic || '');
189
232
  }
190
233
  /** Short human word for a session's activity (falls back to the coarse status). */
191
234
  function activityLabel(s) {
@@ -256,20 +299,25 @@ function signalBadges(s) {
256
299
  return parts.join(' ');
257
300
  }
258
301
  /**
259
- * Compact provenance badge: how to reach the session, not what it's doing.
260
- * `ssh` flags a remote host; the tmux pane id is the send-keys target the feed
261
- * would type back into. Local, non-tmux sessions add nothing (the common case).
302
+ * Compact locator badge: how to JUMP to the session, not what it's doing.
303
+ * `ssh` flags a remote host. For tmux, prefer the resolved `session:window.pane`
304
+ * (a real `tmux attach -t <session:window>` target) over the raw `%pane` id. For
305
+ * a local Ghostty session we know the tab, show `tab N`. Local, unlocatable
306
+ * sessions add nothing (the common case).
262
307
  */
263
- function provenanceBadge(p) {
264
- if (!p)
265
- return '';
308
+ function locatorBadge(s) {
309
+ const p = s.provenance;
266
310
  const parts = [];
267
- if (p.transport === 'ssh')
311
+ if (p?.transport === 'ssh')
268
312
  parts.push(chalk.red('ssh'));
269
- if (p.mux?.kind === 'tmux' && p.mux.pane)
270
- parts.push(chalk.green(`tmux ${p.mux.pane}`));
271
- else if (p.mux?.kind === 'screen')
313
+ if (p?.mux?.kind === 'tmux' && (s.tmuxTarget || p.mux.pane)) {
314
+ parts.push(chalk.green(s.tmuxTarget ?? p.mux.pane));
315
+ }
316
+ else if (p?.mux?.kind === 'screen') {
272
317
  parts.push(chalk.green('screen'));
318
+ }
319
+ if (s.ghosttyTab != null)
320
+ parts.push(chalk.green(`tab ${s.ghosttyTab}`));
273
321
  return parts.join(' ');
274
322
  }
275
323
  /**
@@ -285,7 +333,7 @@ function printActiveRow(s, indent) {
285
333
  const hostCol = chalk.gray(padToWidth(truncateToWidth(s.host ?? '-', 8), 9));
286
334
  const statusCol = statusColor(s.status)(padToWidth(truncateToWidth(activityLabel(s), 8), 9));
287
335
  const fork = s.pidCount && s.pidCount > 1 ? chalk.dim(`×${s.pidCount} `) : '';
288
- const badges = (fork ? fork : '') + [signalBadges(s), provenanceBadge(s.provenance)].filter(Boolean).join(' ');
336
+ const badges = (fork ? fork : '') + [signalBadges(s), locatorBadge(s)].filter(Boolean).join(' ');
289
337
  const desc = buildSessionDescription(s) || '-';
290
338
  // Fill the remaining width with the preview so nothing wraps under tmux/SSH.
291
339
  const fixed = stringWidth(indent) + 9 + 9 + 9 + 9 + (badges ? stringWidth(badges) + 1 : 0);
@@ -423,6 +471,62 @@ export function dedupeByMachineSession(sessions) {
423
471
  }
424
472
  return out;
425
473
  }
474
+ /**
475
+ * Order a merged listing so the local machine's sessions come first, then each
476
+ * remote machine as a contiguous block (more sessions first, then name), with
477
+ * every machine keeping its incoming order (timestamp) within the block. Also
478
+ * dedupes: a session present both locally (a synced mirror copy) and via live
479
+ * fan-out collapses to one, keyed by machine + session id. Rows are keyed by
480
+ * `machine` (discover tags local rows with the local id; fan-out tags remote
481
+ * rows with the peer id) falling back to `localMachine` when untagged. Pure —
482
+ * `localMachine` is injected so the ordering is testable without os.hostname().
483
+ */
484
+ export function mergeLocalFirst(sessions, localMachine) {
485
+ const byMachine = new Map();
486
+ const seen = new Set();
487
+ for (const s of sessions) {
488
+ const machine = s.machine || localMachine;
489
+ if (s.id) {
490
+ const dedupeKey = `${machine}:${s.id}`;
491
+ if (seen.has(dedupeKey))
492
+ continue;
493
+ seen.add(dedupeKey);
494
+ }
495
+ (byMachine.get(machine) ?? byMachine.set(machine, []).get(machine)).push(s);
496
+ }
497
+ const keys = Array.from(byMachine.keys()).sort((a, b) => {
498
+ if (a === localMachine)
499
+ return -1;
500
+ if (b === localMachine)
501
+ return 1;
502
+ const ac = byMachine.get(a).length, bc = byMachine.get(b).length;
503
+ if (ac !== bc)
504
+ return bc - ac;
505
+ return a.localeCompare(b);
506
+ });
507
+ return keys.flatMap((k) => byMachine.get(k));
508
+ }
509
+ /**
510
+ * `running N · idle N · waiting N · queued N` for a bucket of sessions (zero
511
+ * buckets omitted). Same bucketing as the grand-total summary so per-group
512
+ * counts reconcile with the `(total)` beside the header. Empty when nothing.
513
+ */
514
+ function groupTally(sessions) {
515
+ const running = sessions.filter(s => s.status === 'running').length;
516
+ const idle = sessions.filter(s => s.status === 'idle').length;
517
+ const waiting = sessions.filter(s => s.status === 'input_required').length;
518
+ const queued = sessions.filter(s => s.status === 'queued').length;
519
+ const parts = [];
520
+ if (running)
521
+ parts.push(`${running} running`);
522
+ if (idle)
523
+ parts.push(`${idle} idle`);
524
+ if (waiting)
525
+ parts.push(`${waiting} waiting`);
526
+ if (queued)
527
+ parts.push(`${queued} queued`);
528
+ return parts.join(' · ');
529
+ }
426
530
  /** Print one machine's workspace tree, indented under its machine header. */
427
531
  function renderWorkspaceLayout(layout, base) {
428
532
  let first = true;
@@ -435,7 +539,9 @@ function renderWorkspaceLayout(layout, base) {
435
539
  : ws.key === '__unknown__'
436
540
  ? chalk.gray.bold('unknown')
437
541
  : chalk.cyan.bold(shortCwd(ws.key));
438
- console.log(`${base}${header} ${chalk.gray(`(${ws.total})`)}`);
542
+ const wsSessions = [...ws.windows.flatMap(w => w.sessions), ...ws.flat];
543
+ const tally = groupTally(wsSessions);
544
+ console.log(`${base}${header} ${chalk.gray(`(${ws.total})`)}${tally ? chalk.gray(` ${tally}`) : ''}`);
439
545
  for (const win of ws.windows) {
440
546
  // Host is per-process, but every terminal in the same IDE window shares
441
547
  // an ancestor — take the first non-empty host as the window's label.
@@ -457,6 +563,43 @@ function printMachineHeader(mg) {
457
563
  const here = mg.isLocal ? chalk.cyan(' ← this machine') : '';
458
564
  console.log(`${marker}${name} ${chalk.gray(`(${mg.total})`)}${here}`);
459
565
  }
566
+ /**
567
+ * Attach display-only jump locators onto LOCAL sessions: the Ghostty tab number
568
+ * (one batched read-only osascript, only when a local ghostty session exists)
569
+ * and the tmux `session:window.pane` target (one `list-panes -a` per socket).
570
+ * Every step is best-effort and swallowed — a failure just leaves the raw pane
571
+ * id / no tab number, and the rows render as before. Mutates the sessions.
572
+ */
573
+ async function enrichLocalLocators(local) {
574
+ // Ghostty tab numbers.
575
+ try {
576
+ const ghostty = local.filter(s => s.host === 'ghostty' && s.provenance?.transport !== 'ssh');
577
+ if (ghostty.length > 0) {
578
+ const surfaces = await enumerateGhosttyTabs();
579
+ for (const [sess, tab] of assignGhosttyTabs(ghostty, surfaces))
580
+ sess.ghosttyTab = tab;
581
+ }
582
+ }
583
+ catch { /* non-fatal */ }
584
+ // tmux attach targets, one batched query per distinct socket.
585
+ try {
586
+ const tmux = local.filter(s => s.provenance?.mux?.kind === 'tmux' && s.provenance.mux.pane);
587
+ const sockets = new Set(tmux.map(s => s.provenance.mux.socket));
588
+ for (const socket of sockets) {
589
+ const paneMap = await mapPanesToTargets(socket);
590
+ if (paneMap.size === 0)
591
+ continue;
592
+ for (const s of tmux) {
593
+ if (s.provenance.mux.socket !== socket)
594
+ continue;
595
+ const target = paneMap.get(s.provenance.mux.pane);
596
+ if (target)
597
+ s.tmuxTarget = target;
598
+ }
599
+ }
600
+ }
601
+ catch { /* non-fatal */ }
602
+ }
460
603
  /**
461
604
  * Render the unified active-session view, grouped by machine. Local sessions
462
605
  * come from `getActiveSessions()`; unless `--local`, sessions from other
@@ -492,6 +635,10 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
492
635
  printCrossMachineTip();
493
636
  return;
494
637
  }
638
+ // Enrich LOCAL sessions with jump locators (display-only, after the --json /
639
+ // --waiting gates so scriptable output stays osascript-free). Remote sessions
640
+ // keep their raw pane id — their tmux/Ghostty live on the other machine.
641
+ await enrichLocalLocators(sessions.filter(s => !s.machine || s.machine === self));
495
642
  const grouped = groupSessionsByMachine(sessions, self);
496
643
  let firstMachine = true;
497
644
  for (const mg of grouped.machines) {
@@ -501,16 +648,7 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
501
648
  printMachineHeader(mg);
502
649
  renderWorkspaceLayout(mg.layout, ' ');
503
650
  }
504
- const runningCount = sessions.filter(s => s.status === 'running').length;
505
- const idleCount = sessions.filter(s => s.status === 'idle').length;
506
- const queuedCount = sessions.filter(s => s.status === 'queued' || s.status === 'input_required').length;
507
- const parts = [];
508
- if (runningCount > 0)
509
- parts.push(`${runningCount} running`);
510
- if (idleCount > 0)
511
- parts.push(`${idleCount} idle`);
512
- if (queuedCount > 0)
513
- parts.push(`${queuedCount} queued`);
651
+ const parts = groupTally(sessions).split(' · ').filter(Boolean);
514
652
  const machineWord = grouped.machines.length === 1 ? 'machine' : 'machines';
515
653
  console.log(chalk.gray(`\n${sessions.length} active (${parts.join(', ')}) across ${grouped.machines.length} ${machineWord}.`));
516
654
  // Tip only when nothing else could be included and the user didn't opt out.
@@ -526,6 +664,13 @@ function printCrossMachineTip() {
526
664
  }
527
665
  /** Main action handler for `agents sessions`. Routes to picker, table, or single-session render. */
528
666
  async function sessionsAction(query, options) {
667
+ // Normalize convenience flags before any routing reads them: per-agent
668
+ // shorthands fold into --agent, and --device is an alias for --host (both
669
+ // resolve against the same device registry).
670
+ applyAgentShorthands(options);
671
+ if (options.device && options.device.length > 0) {
672
+ options.host = [...(options.host ?? []), ...options.device];
673
+ }
529
674
  // --host WITHOUT --active keeps the legacy per-host stream (each remote's raw
530
675
  // stdout under a `── host ──` banner). With --active, the hosts are folded
531
676
  // into the merged machine-grouped view instead (handled below).
@@ -599,8 +744,18 @@ async function sessionsAction(query, options) {
599
744
  // Interactive picker loads a deep pool but shows only recent sessions
600
745
  // until the user starts typing. Non-interactive/JSON uses the explicit limit.
601
746
  const isInteractive = !options.json && isInteractiveTerminal();
602
- const limit = parseInt(options.limit || (isInteractive ? String(PICKER_POOL_LIMIT) : '50'), 10);
603
- const since = options.since ?? (isInteractive && !options.all ? '30d' : undefined);
747
+ // The grouped project overview is the default for a bare interactive listing:
748
+ // no query, no path drill-in, not explicitly --flat/--tree. It drops the silent
749
+ // cwd-scope + 50-cap + 30-day window that hide most of a large index.
750
+ const wantsOverview = isInteractive && !searchQuery && !pathFilter && !options.flat && !options.tree;
751
+ const limit = wantsOverview
752
+ ? OVERVIEW_POOL_LIMIT
753
+ : parseInt(options.limit || (isInteractive ? String(PICKER_POOL_LIMIT) : '50'), 10);
754
+ // Overview: recency order across the whole index, no default window; an explicit
755
+ // --since still narrows. Non-overview keeps the prior interactive-30d default.
756
+ const since = wantsOverview
757
+ ? options.since
758
+ : (options.since ?? (isInteractive && !options.all ? '30d' : undefined));
604
759
  const spinner = options.json ? null : ora().start();
605
760
  const tracker = createScanProgressTracker(LOAD_VERBS, 'sessions', spinner);
606
761
  try {
@@ -615,7 +770,10 @@ async function sessionsAction(query, options) {
615
770
  version,
616
771
  all: pathFilter ? undefined : options.all,
617
772
  cwd: process.cwd(),
618
- cwdPrefix: pathFilter,
773
+ // Default overview scopes to the current repo SUBTREE (prefix match), so a
774
+ // monorepo shows its sub-projects grouped instead of collapsing to the one
775
+ // exact-cwd project. `--all` clears the prefix and spans the whole index.
776
+ cwdPrefix: pathFilter ?? (wantsOverview && !options.all ? process.cwd() : undefined),
619
777
  project: options.project,
620
778
  since,
621
779
  until: options.until,
@@ -657,12 +815,42 @@ async function sessionsAction(query, options) {
657
815
  if (options.json) {
658
816
  const filtered = searchQuery ? filterSessionsByQuery(sessions, searchQuery) : sessions;
659
817
  const serializable = filtered.map(s => {
660
- const { _matchedTerms, _bm25Score, ...rest } = s;
818
+ const { _matchedTerms, _bm25Score, _remote, ...rest } = s;
661
819
  return rest;
662
820
  });
663
821
  process.stdout.write(JSON.stringify(serializable, null, 2) + '\n');
664
822
  return;
665
823
  }
824
+ // Cross-machine fan-out: unless --local (or we ARE a peer answering a
825
+ // parent's sweep), fold in other online machines' sessions live over SSH so
826
+ // the list spans the fleet without any sync — each remote row carries the
827
+ // machine it came from, and the picker/table label + group by it. Only the
828
+ // interactive picker and the printed table get this; --json and single-id
829
+ // resolution above stay local (a peer answers for itself; scripts get a
830
+ // deterministic local slice). Best-effort: a fan-out failure leaves the
831
+ // local list intact rather than erroring the whole command.
832
+ const forceLocal = options.local === true || process.env[NO_FANOUT_ENV] === '1';
833
+ if (!forceLocal) {
834
+ // Pass the hosts set so a variadic `--host a b` never leaks a host as a
835
+ // query (defensive: the --host-without-active early return above already
836
+ // means we only get here in auto-discovery mode, with no --host in argv).
837
+ const forwarded = buildForwardedArgs(process.argv, new Set(options.host ?? []));
838
+ if (!forwarded.includes('--json'))
839
+ forwarded.push('--json');
840
+ const fanSpinner = isInteractiveTerminal() ? ora('Reaching other machines...').start() : null;
841
+ try {
842
+ const { sessions: remoteSessions } = await gatherRemoteList(forwarded, options.host);
843
+ if (remoteSessions.length > 0) {
844
+ sessions = mergeLocalFirst([...sessions, ...remoteSessions], machineId());
845
+ }
846
+ }
847
+ catch {
848
+ // fan-out is an enrichment, never a hard dependency
849
+ }
850
+ finally {
851
+ fanSpinner?.stop();
852
+ }
853
+ }
666
854
  if (sessions.length === 0) {
667
855
  if (pathFilter) {
668
856
  console.log(chalk.gray(`No sessions found for ${pathFilter}.`));
@@ -675,9 +863,19 @@ async function sessionsAction(query, options) {
675
863
  }
676
864
  return;
677
865
  }
678
- // --tree is a printed grouped listing, not an interactive pick render it
679
- // directly even in a TTY.
680
- if (isInteractiveTerminal() && !options.tree) {
866
+ // The grouped project overview is the bare interactive default: a scannable
867
+ // dashboard of the whole fleet grouped by project, newest-active first.
868
+ // Interact/resume via `agents sessions <project>` or `agents sessions resume`.
869
+ if (wantsOverview) {
870
+ const liveIndex = await maybeLiveIndex(options);
871
+ // Per-project row cap is fixed (--limit carries a default of 50 and drives
872
+ // the fetch pool, not the display); `--all` expands every group instead.
873
+ printSessionOverview(sessions, hiddenCount, liveIndex, { perProjectCap: OVERVIEW_ROWS_PER_PROJECT, expand: !!options.all });
874
+ return;
875
+ }
876
+ // --tree / --flat are printed listings, not an interactive pick — render them
877
+ // directly even in a TTY. A search query keeps the interactive picker.
878
+ if (isInteractiveTerminal() && !options.tree && !options.flat) {
681
879
  const message = pathFilter
682
880
  ? `Search sessions (${path.basename(pathFilter)}):`
683
881
  : formatSearchMessage(options);
@@ -688,7 +886,7 @@ async function sessionsAction(query, options) {
688
886
  }
689
887
  return;
690
888
  }
691
- // Non-interactive fallback (piped output)
889
+ // Non-interactive fallback (piped output) or --flat/--tree.
692
890
  const filtered = searchQuery ? filterSessionsByQuery(sessions, searchQuery) : sessions;
693
891
  const liveIndex = await maybeLiveIndex(options);
694
892
  printSessionTable(filtered, hiddenCount, options.tree === true, liveIndex);
@@ -724,9 +922,9 @@ function metaSignals(s) {
724
922
  * (tracker/PR ref, pulled out of the badge blob so refs align) is only rendered
725
923
  * when `showTicket` — otherwise a listing with no refs would waste a column of
726
924
  * dashes and needlessly truncate the topic. Worktree stays a trailing badge. */
727
- function flatSessionRow(session, live, showTicket = false) {
925
+ function flatSessionRow(session, live, showTicket = false, cols = {}) {
728
926
  const agentColor = colorAgent(session.agent);
729
- const when = formatRelativeTime(session.timestamp);
927
+ const when = formatRelativeTime(session.lastActivity ?? session.timestamp);
730
928
  const project = session.project || '-';
731
929
  const tag = teamTag(session);
732
930
  const label = session.label;
@@ -735,17 +933,26 @@ function flatSessionRow(session, live, showTicket = false) {
735
933
  // resting one falls back to its opening topic.
736
934
  const doing = preview || (tag ? `${tag}${session.topic ?? ''}` : session.topic);
737
935
  const wt = session.worktreeSlug ? chalk.magenta(`wt:${session.worktreeSlug}`) : '';
936
+ // The machine column only earns its width when the listing spans more than one
937
+ // box (i.e. the cross-machine fan-out folded remotes in) — same rule and
938
+ // pool-derived width as the picker.
939
+ const machineColW = cols.machineWidth ?? PICKER_MACHINE_W;
940
+ const machineCell = cols.showMachine
941
+ ? chalk.gray(padToWidth(truncateToWidth((cols.machineLabel?.(session.machine ?? '') ?? session.machine ?? '') || '-', machineColW - 1), machineColW))
942
+ : '';
738
943
  const TICKET_W = 10;
739
944
  const ticketCell = showTicket
740
945
  ? chalk.blue(padToWidth(truncateToWidth(ticketLabel(session) || '-', TICKET_W), TICKET_W + 1))
741
946
  : '';
742
947
  const glyphW = glyph ? 2 : 0;
948
+ const machineW = cols.showMachine ? machineColW : 0;
743
949
  const ticketW = showTicket ? TICKET_W + 1 : 0;
744
950
  const wtW = wt ? stringWidth(wt) + 1 : 0;
745
- const topicW = Math.max(16, terminalWidth() - (10 + 9 + 8 + 16) - glyphW - ticketW - wtW - stringWidth(when) - 1);
951
+ const topicW = Math.max(16, terminalWidth() - (10 + 9 + 8 + 16) - glyphW - machineW - ticketW - wtW - stringWidth(when) - 1);
746
952
  return (chalk.white(padToWidth(truncateToWidth(session.shortId, 9), 10)) +
747
953
  agentColor(padToWidth(truncateToWidth(session.agent, 8), 9)) +
748
954
  chalk.yellow(padToWidth(truncateToWidth(session.version || '-', 7), 8)) +
955
+ machineCell +
749
956
  chalk.cyan(padToWidth(truncateToWidth(project, 14), 16)) +
750
957
  (glyph ? glyph + ' ' : '') +
751
958
  renderTopicCell(label, doing, '', topicW, topicW) +
@@ -756,7 +963,7 @@ function flatSessionRow(session, live, showTicket = false) {
756
963
  /** One tree-mode row (grouped under a dir header): id · agent · badges · topic · time. No version/project column. */
757
964
  function treeSessionRow(session, live) {
758
965
  const agentColor = colorAgent(session.agent);
759
- const when = formatRelativeTime(session.timestamp);
966
+ const when = formatRelativeTime(session.lastActivity ?? session.timestamp);
760
967
  const tag = teamTag(session);
761
968
  const label = session.label;
762
969
  const { glyph, preview } = liveGlyphAndPreview(live);
@@ -793,6 +1000,79 @@ async function maybeLiveIndex(options) {
793
1000
  return undefined;
794
1001
  }
795
1002
  }
1003
+ /**
1004
+ * Group key for the overview: prefer the indexed project name; else fold the cwd
1005
+ * to its repo — a worktree (`.../<repo>/.agents/worktrees/<slug>`) folds to the
1006
+ * repo, and a monorepo subdir falls back to its leaf dir basename. Pure.
1007
+ */
1008
+ export function overviewProjectKey(s) {
1009
+ if (s.project && s.project.trim())
1010
+ return s.project.trim();
1011
+ const cwd = (s.cwd ?? '').replace(/\/+$/, '');
1012
+ if (!cwd)
1013
+ return '(no project)';
1014
+ const wt = cwd.match(/\/([^/]+)\/\.agents\/worktrees\//);
1015
+ if (wt)
1016
+ return wt[1];
1017
+ const parts = cwd.split('/');
1018
+ return parts[parts.length - 1] || cwd;
1019
+ }
1020
+ /**
1021
+ * Turn a recency-descending pool into project groups: each group shows its
1022
+ * `perProjectCap` most-recent sessions (the rest become `· N more`), and groups
1023
+ * are ordered by their most-recent session so the newest-active project leads.
1024
+ * `perProjectCap = Infinity` expands every group. Pure — unit-tested.
1025
+ */
1026
+ export function buildOverviewGroups(pool, perProjectCap) {
1027
+ const byKey = new Map();
1028
+ for (const s of pool) {
1029
+ const k = overviewProjectKey(s);
1030
+ (byKey.get(k) ?? byKey.set(k, []).get(k)).push(s);
1031
+ }
1032
+ const cap = Math.max(1, perProjectCap);
1033
+ const groups = [];
1034
+ for (const [key, rows] of byKey) {
1035
+ const shown = rows.slice(0, cap); // rows are recency-desc (pool was sorted)
1036
+ groups.push({ key, total: rows.length, shown, more: rows.length - shown.length, maxTs: rows[0].lastActivity ?? rows[0].timestamp });
1037
+ }
1038
+ groups.sort((a, b) => (a.maxTs < b.maxTs ? 1 : a.maxTs > b.maxTs ? -1 : a.key.localeCompare(b.key)));
1039
+ return { groups, projectCount: byKey.size };
1040
+ }
1041
+ /**
1042
+ * The grouped project overview — the bare interactive default. Shows the latest
1043
+ * sessions grouped under their project, newest-active project first, with a
1044
+ * `· N more` per project and a `+N more projects` when the list is capped.
1045
+ */
1046
+ function printSessionOverview(pool, hiddenCount, liveIndex, opts) {
1047
+ const { groups } = buildOverviewGroups(pool, opts.expand ? Infinity : opts.perProjectCap);
1048
+ const shownGroups = opts.expand ? groups : groups.slice(0, OVERVIEW_MAX_PROJECTS);
1049
+ const hiddenProjects = groups.length - shownGroups.length;
1050
+ const total = pool.length;
1051
+ const projWord = groups.length === 1 ? 'project' : 'projects';
1052
+ console.log(chalk.gray(`${total} session${total === 1 ? '' : 's'} · ${groups.length} ${projWord} · recent activity\n`));
1053
+ let first = true;
1054
+ for (const g of shownGroups) {
1055
+ if (!first)
1056
+ console.log();
1057
+ first = false;
1058
+ const { glyph } = liveGlyphAndPreview(liveIndex?.get(g.shown[0].id));
1059
+ const head = `${chalk.cyan('▸')} ${chalk.cyan.bold(g.key)} ${chalk.gray(String(g.total))}` +
1060
+ `${glyph ? ' ' + glyph : ''} ${chalk.gray(formatRelativeTime(g.maxTs))}`;
1061
+ console.log(head);
1062
+ for (const s of g.shown)
1063
+ console.log(treeSessionRow(s, liveIndex?.get(s.id)));
1064
+ if (g.more > 0)
1065
+ console.log(' ' + chalk.gray(`· ${g.more} more`));
1066
+ }
1067
+ console.log();
1068
+ const parts = [chalk.gray('newest first (by last activity)')];
1069
+ if (hiddenProjects > 0)
1070
+ parts.push(chalk.gray(`+${hiddenProjects} more project${hiddenProjects === 1 ? '' : 's'}`));
1071
+ parts.push(chalk.gray('agents sessions --all spans every project on disk · <project> to drill in · --flat for the plain list'));
1072
+ console.log(parts.join(chalk.gray(' · ')));
1073
+ if (hiddenCount > 0)
1074
+ console.log(chalk.gray(formatTeamHiddenFooter(hiddenCount)));
1075
+ }
796
1076
  function printSessionTable(sessions, hiddenCount = 0, tree = false, liveIndex) {
797
1077
  if (tree) {
798
1078
  // Group by directory; drop the id/version columns from view. The short id
@@ -823,10 +1103,12 @@ function printSessionTable(sessions, hiddenCount = 0, tree = false, liveIndex) {
823
1103
  return;
824
1104
  }
825
1105
  // Only show the ticket column when at least one row carries a ref — otherwise
826
- // it's a column of dashes that steals width from every topic.
1106
+ // it's a column of dashes that steals width from every topic. The machine
1107
+ // column (and its compact labels) is computed the same way the picker does it.
827
1108
  const showTicket = sessions.some((s) => ticketLabel(s) !== '');
1109
+ const cols = pickerColumnsFor(sessions);
828
1110
  for (const session of sessions)
829
- console.log(flatSessionRow(session, liveIndex?.get(session.id), showTicket));
1111
+ console.log(flatSessionRow(session, liveIndex?.get(session.id), showTicket, cols));
830
1112
  const countLine = `${sessions.length} session${sessions.length === 1 ? '' : 's'}.`;
831
1113
  console.log(chalk.gray(`\n${countLine}`));
832
1114
  if (hiddenCount > 0) {
@@ -983,28 +1265,115 @@ function renderTopicCell(label, topic, query, visibleWidth, paddedWidth) {
983
1265
  }
984
1266
  return out + padding;
985
1267
  }
986
- export function formatPickerLabel(s, query) {
1268
+ /** Fallback machine-column width when a pool-derived width isn't supplied.
1269
+ * `pickerColumnsFor` normally computes `machineWidth` sized to the actual
1270
+ * hostnames, floored/capped by these bounds so common ids like `yosemite-s0`
1271
+ * (11) fit whole while a pathological hostname can't devour the topic column. */
1272
+ const PICKER_MACHINE_W = 11;
1273
+ const PICKER_MACHINE_MIN = 8;
1274
+ const PICKER_MACHINE_MAX = 18;
1275
+ /** Column width that shows every compacted hostname in `machines` whole (one
1276
+ * trailing space for separation), bounded by MIN/MAX. */
1277
+ function machineColumnWidth(machines, label) {
1278
+ const widest = machines.reduce((w, m) => Math.max(w, stringWidth(label(m))), 0);
1279
+ return Math.min(PICKER_MACHINE_MAX, Math.max(PICKER_MACHINE_MIN, widest + 1));
1280
+ }
1281
+ /**
1282
+ * Compact display form for machine ids: strip the longest shared dash-delimited
1283
+ * prefix so "yosemite-s0"/"yosemite-s1" read as "s0"/"s1" while unrelated ids
1284
+ * ("zion", "mac-mini") stay whole. Stripping a *common* prefix can't collide,
1285
+ * and at least one segment is always kept.
1286
+ */
1287
+ export function machineLabeler(machines) {
1288
+ const uniq = [...new Set(machines.filter(Boolean))];
1289
+ if (uniq.length < 2)
1290
+ return (m) => m;
1291
+ const parts = uniq.map((m) => m.split('-'));
1292
+ const min = Math.min(...parts.map((p) => p.length));
1293
+ let shared = 0;
1294
+ while (shared < min - 1 && parts.every((p) => p[shared] === parts[0][shared]))
1295
+ shared++;
1296
+ if (shared === 0)
1297
+ return (m) => m;
1298
+ return (m) => {
1299
+ const p = m.split('-');
1300
+ return p.length > shared ? p.slice(shared).join('-') : m;
1301
+ };
1302
+ }
1303
+ /**
1304
+ * Column flags for a picker, computed once over the whole pool so every row
1305
+ * aligns: the machine column only earns its width when the listing spans more
1306
+ * than one box, the ticket column only when some row carries a PR/ticket ref.
1307
+ */
1308
+ export function pickerColumnsFor(sessions) {
1309
+ const machines = sessions.map((s) => s.machine).filter((m) => !!m);
1310
+ const distinct = [...new Set(machines)];
1311
+ const machineLabel = machineLabeler(machines);
1312
+ return {
1313
+ showMachine: distinct.length > 1,
1314
+ machineLabel,
1315
+ machineWidth: machineColumnWidth(distinct, machineLabel),
1316
+ showTicket: sessions.some((s) => ticketLabel(s) !== ''),
1317
+ };
1318
+ }
1319
+ export function formatPickerLabel(s, query, cols = {}) {
987
1320
  const agentColor = colorAgent(s.agent);
988
- const when = formatRelativeTime(s.timestamp);
1321
+ const when = formatRelativeTime(s.lastActivity ?? s.timestamp);
989
1322
  const project = s.project || '-';
990
1323
  const tag = teamTag(s);
991
1324
  const label = s.label;
992
1325
  const topic = tag ? `${tag}${s.topic ?? ''}` : s.topic;
993
1326
  const versionStr = s.version || '-';
1327
+ const wt = s.worktreeSlug ? chalk.magenta(`wt:${s.worktreeSlug}`) : '';
1328
+ const machineW = cols.machineWidth ?? PICKER_MACHINE_W;
1329
+ const machineCell = cols.showMachine
1330
+ ? chalk.gray(padRight(truncate((cols.machineLabel?.(s.machine ?? '') ?? s.machine ?? '') || '-', machineW - 1), machineW))
1331
+ : '';
1332
+ const TICKET_W = 10;
1333
+ const ticketCell = cols.showTicket
1334
+ ? chalk.blue(padRight(truncate(ticketLabel(s) || '-', TICKET_W), TICKET_W + 1))
1335
+ : '';
1336
+ // The picker prepends a gutter (cursor, plus a checkbox in multi-select mode);
1337
+ // reserve it, plus the conditional columns, so the topic shrinks to fit and
1338
+ // rows never wrap.
1339
+ const gutter = cols.gutter ?? 2;
1340
+ const machineColW = cols.showMachine ? machineW : 0;
1341
+ const ticketW = cols.showTicket ? TICKET_W + 1 : 0;
1342
+ const wtW = wt ? stringWidth(wt) + 1 : 0;
1343
+ const topicW = Math.max(16, terminalWidth() - gutter - (10 + 9 + 8 + 16) - machineColW - ticketW - wtW - stringWidth(when) - 1);
994
1344
  return (chalk.white(padRight(s.shortId, 10)) +
995
1345
  agentColor(padRight(truncate(s.agent, 8), 9)) +
996
1346
  chalk.yellow(padRight(truncate(versionStr, 7), 8)) +
1347
+ machineCell +
997
1348
  chalk.cyan(padRight(truncate(project, 14), 16)) +
998
- renderTopicCell(label, topic, query, 48, 50) +
1349
+ renderTopicCell(label, topic, query, topicW, topicW) +
1350
+ ticketCell +
1351
+ (wt ? wt + ' ' : '') +
999
1352
  chalk.gray(when));
1000
1353
  }
1354
+ /** Hints rotated above the picker so the flags/features stay discoverable. */
1355
+ const PICKER_TIPS = [
1356
+ 'Tip: narrow with -a/--agent (e.g. -a codex), or --project <name> for another folder.',
1357
+ "Tip: --all searches every directory; -H/--host <machine> folds in another box's sessions.",
1358
+ 'Tip: just type to fuzzy-search prompts and responses; press space to preview a session.',
1359
+ 'Tip: --since 2d / --until <date> bound the time window; pass a session id to open it directly.',
1360
+ ];
1361
+ /**
1362
+ * Pick a hint to show above the picker. Deterministic (keys off the pool size)
1363
+ * so it stays fixed across the picker's re-renders within a single run.
1364
+ */
1365
+ export function formatPickerTip(sessions) {
1366
+ return chalk.gray(PICKER_TIPS[sessions.length % PICKER_TIPS.length]);
1367
+ }
1001
1368
  export async function pickSessionInteractive(sessions, message = 'Search sessions:', initialSearch, hiddenCount = 0) {
1002
1369
  if (hiddenCount > 0) {
1003
1370
  console.log(chalk.gray(formatTeamHiddenFooter(hiddenCount)));
1004
1371
  }
1372
+ const cols = pickerColumnsFor(sessions);
1005
1373
  try {
1006
1374
  return await sessionPicker({
1007
1375
  message,
1376
+ subtitle: formatPickerTip(sessions),
1008
1377
  sessions,
1009
1378
  filter: (query) => {
1010
1379
  // No query: show the full pool (picker viewport still paginates via pageSize).
@@ -1013,7 +1382,7 @@ export async function pickSessionInteractive(sessions, message = 'Search session
1013
1382
  return sessions;
1014
1383
  return filterSessionsByQuery(sessions, query);
1015
1384
  },
1016
- labelFor: (s, query) => formatPickerLabel(s, query),
1385
+ labelFor: (s, query) => formatPickerLabel(s, query, cols),
1017
1386
  pageSize: PICKER_RECENT_COUNT,
1018
1387
  initialSearch,
1019
1388
  });
@@ -1024,7 +1393,41 @@ export async function pickSessionInteractive(sessions, message = 'Search session
1024
1393
  throw err;
1025
1394
  }
1026
1395
  }
1396
+ /**
1397
+ * The machine a picked session lives on when its transcript is on that peer's
1398
+ * disk (folded in over the live fan-out), else undefined. Keys off `_remote`,
1399
+ * NOT `machine !== local`: a synced mirror is machine-tagged too, but its file
1400
+ * is a local mirror path, so it must be read/resumed locally like any other.
1401
+ */
1402
+ function remoteMachineOf(session) {
1403
+ return session._remote ? session.machine : undefined;
1404
+ }
1405
+ /** True when the peer wasn't a dialable device; prints one clear line so a
1406
+ * remote pick never dead-ends silently. */
1407
+ function warnNoPeerTarget(machine, session) {
1408
+ console.log(chalk.yellow(`Session ${session.shortId} lives on ${machine}, which isn't a reachable device right now.`));
1409
+ console.log(chalk.gray(`Register/wake it (ag devices), or run there: agents ssh ${machine}`));
1410
+ }
1027
1411
  async function handlePickedSession(picked) {
1412
+ // A session on another machine is read/resumed ON that machine over SSH — its
1413
+ // transcript and agent binary live there. Both actions execute on the peer
1414
+ // (not a local `--host` hop, which would discover locally and dead-end for a
1415
+ // session that exists only on the peer).
1416
+ const remote = remoteMachineOf(picked.session);
1417
+ if (remote) {
1418
+ if (picked.action === 'view') {
1419
+ const rc = await runOnPeer(['sessions', picked.session.shortId, '--markdown'], remote);
1420
+ if (rc === 'no-target')
1421
+ warnNoPeerTarget(remote, picked.session);
1422
+ }
1423
+ else {
1424
+ console.log(chalk.gray(`Resuming ${picked.session.shortId} on ${remote} over SSH...`));
1425
+ const rc = await runOnPeer(['sessions', 'resume', picked.session.shortId], remote, { tty: true });
1426
+ if (rc === 'no-target')
1427
+ warnNoPeerTarget(remote, picked.session);
1428
+ }
1429
+ return;
1430
+ }
1028
1431
  if (picked.action === 'view') {
1029
1432
  await renderSession(picked.session, 'summary', {});
1030
1433
  return;
@@ -1049,26 +1452,49 @@ export async function resumeSessionInPlace(session) {
1049
1452
  return;
1050
1453
  }
1051
1454
  console.log(chalk.gray(`Resuming: ${resume.join(' ')} (cwd: ${cwd})`));
1052
- await new Promise((resolve) => {
1053
- const child = spawn(resume[0], resume.slice(1), {
1054
- cwd,
1055
- stdio: 'inherit',
1056
- shell: false,
1057
- });
1455
+ // Resolve the (possibly version-pinned) launcher up front. On Windows the
1456
+ // agent shim is a `.cmd`/`.ps1` and, under the shell needed to run it (see
1457
+ // spawnResumeCommand), a missing command exits non-zero rather than emitting
1458
+ // an ENOENT `error` event — so detect a removed version here instead of
1459
+ // relying on that event, keeping the /continue fallback working on every OS.
1460
+ if (!findExecutable(resume[0]) && session.version) {
1461
+ const fallback = buildFallbackCommand(session);
1462
+ if (fallback) {
1463
+ console.log(chalk.gray(`Version ${session.version} is not installed. Falling back to current version via /continue...`));
1464
+ await spawnResumeCommand(fallback, cwd);
1465
+ return;
1466
+ }
1467
+ }
1468
+ await spawnResumeCommand(resume, cwd);
1469
+ }
1470
+ /**
1471
+ * Spawn a resume command as a foreground takeover (inherited stdio), resolving
1472
+ * when it exits. On Windows the agent launcher is a `.cmd`/PATHEXT shim that
1473
+ * `spawn` can't exec directly — a bare-name `shell:false` spawn throws
1474
+ * `EFTYPE`/`ENOENT` there — so we go through the shell via `needsWindowsShell`.
1475
+ * The spawn is guarded because such a failure can be thrown synchronously;
1476
+ * without the guard it would surface under an unrelated "Failed to discover
1477
+ * sessions" catch upstream instead of a truthful launch error.
1478
+ */
1479
+ function spawnResumeCommand(cmd, cwd) {
1480
+ return new Promise((resolve) => {
1481
+ let child;
1482
+ try {
1483
+ child = spawn(cmd[0], cmd.slice(1), {
1484
+ cwd,
1485
+ stdio: 'inherit',
1486
+ shell: needsWindowsShell(cmd[0]),
1487
+ });
1488
+ }
1489
+ catch (err) {
1490
+ console.error(chalk.red(`Failed to launch ${cmd[0]}: ${err.message}`));
1491
+ resolve();
1492
+ return;
1493
+ }
1058
1494
  child.on('error', (err) => {
1059
- if (err.code === 'ENOENT' && session.version) {
1060
- const fallback = buildFallbackCommand(session);
1061
- if (fallback) {
1062
- console.log(chalk.gray(`Version ${session.version} is not installed. Falling back to current version via /continue...`));
1063
- const fb = spawn(fallback[0], fallback.slice(1), { cwd, stdio: 'inherit', shell: false });
1064
- fb.on('error', (e) => { console.error(chalk.red(`Failed: ${e.message}`)); resolve(); });
1065
- fb.on('close', () => resolve());
1066
- return;
1067
- }
1068
- }
1069
- console.error(chalk.red(`Failed to launch ${resume[0]}: ${err.message}`));
1495
+ console.error(chalk.red(`Failed to launch ${cmd[0]}: ${err.message}`));
1070
1496
  if (err.code === 'ENOENT') {
1071
- console.error(chalk.gray(`Make sure '${resume[0]}' is on your PATH.`));
1497
+ console.error(chalk.gray(`Make sure '${cmd[0]}' is on your PATH.`));
1072
1498
  }
1073
1499
  resolve();
1074
1500
  });
@@ -1464,6 +1890,12 @@ export function registerSessionsCommands(program) {
1464
1890
  .argument('[query]', 'Session ID, search query, or path (., ../, /path) to filter by project')
1465
1891
  .description('Find, browse, and read agent conversation transcripts across Claude, Codex, Gemini, and OpenCode.')
1466
1892
  .option('-a, --agent <agent>', 'Filter by agent type and version (e.g., claude, codex@0.116.0)')
1893
+ .option('--claude', 'Shorthand for --agent claude')
1894
+ .option('--codex', 'Shorthand for --agent codex')
1895
+ .option('--kimi', 'Shorthand for --agent kimi')
1896
+ .option('--antigravity', 'Shorthand for --agent antigravity')
1897
+ .option('--grok', 'Shorthand for --agent grok')
1898
+ .option('--opencode', 'Shorthand for --agent opencode')
1467
1899
  .option('--all', 'Include sessions from every directory (not just current project)')
1468
1900
  .option('--teams', 'Include team-spawned sessions (hidden by default)')
1469
1901
  .option('--project <name>', 'Filter by project name (searches across all directories)')
@@ -1481,12 +1913,14 @@ export function registerSessionsCommands(program) {
1481
1913
  .option('--artifacts', 'List all files written or edited during a session')
1482
1914
  .option('--artifact <name>', 'Read a specific artifact by filename or path (outputs to stdout)')
1483
1915
  .option('--active', 'Show only sessions running right now across terminals, teams, cloud, and headless agents')
1484
- .option('--local', 'With --active: only this machine — skip the cross-machine SSH fan-out')
1916
+ .option('--local', 'Only this machine — skip the cross-machine SSH fan-out (default listing and --active)')
1485
1917
  .option('--waiting', 'With --active: show only sessions waiting on your input (exits non-zero if any)')
1486
1918
  .option('--tree', 'Group the listing by directory; drops the id/version columns for readability')
1919
+ .option('--flat', 'Plain flat table (one row per session) instead of the grouped project overview')
1487
1920
  .option('--no-live', 'Do not enrich the listing with live status/preview for running sessions')
1488
1921
  .option('--cloud', 'Source sessions from Rush Cloud (captured runs) instead of local disk')
1489
- .option('-H, --host <target...>', 'Run this query on remote machine(s) over SSH (host alias or user@host; repeatable)');
1922
+ .option('-H, --host <target...>', 'Run this query on remote machine(s) over SSH (host alias or user@host; repeatable)')
1923
+ .option('--device <target...>', 'Alias for --host (device alias from `agents devices`; repeatable)');
1490
1924
  setHelpSections(sessionsCmd, {
1491
1925
  examples: `
1492
1926
  # Search prior sessions in this project by topic, file path, or command
@@ -1501,6 +1935,10 @@ export function registerSessionsCommands(program) {
1501
1935
  # Show only what's running right now (terminals, teams, cloud, headless)
1502
1936
  agents sessions --active
1503
1937
 
1938
+ # The interactive list folds in other online machines automatically,
1939
+ # labelled by host with this machine first. Stay local with --local:
1940
+ agents sessions --local
1941
+
1504
1942
  # Search across every directory, not just this project
1505
1943
  agents sessions "topic" --all
1506
1944
 
@@ -1514,6 +1952,7 @@ export function registerSessionsCommands(program) {
1514
1952
  agents sessions --all "deploy script" --host box-a --host box-b
1515
1953
  `,
1516
1954
  notes: `
1955
+ - 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.
1517
1956
  - --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.
1518
1957
  - --include and --exclude are mutually exclusive.
1519
1958
  - --first and --last are mutually exclusive.
@@ -1528,6 +1967,7 @@ export function registerSessionsCommands(program) {
1528
1967
  registerSessionsTailCommand(sessionsCmd);
1529
1968
  registerSessionsSyncCommand(sessionsCmd);
1530
1969
  registerSessionsResumeCommand(sessionsCmd);
1970
+ registerSessionsInjectCommand(sessionsCmd);
1531
1971
  }
1532
1972
  function formatNoSessionsMessage(showAll, project) {
1533
1973
  const projectQuery = project?.trim();