@phnx-labs/agents-cli 1.22.22 → 1.22.24

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 (138) hide show
  1. package/CHANGELOG.md +402 -0
  2. package/README.md +13 -6
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/attach.d.ts +2 -0
  5. package/dist/commands/attach.js +32 -7
  6. package/dist/commands/defaults.js +2 -0
  7. package/dist/commands/doctor.js +20 -7
  8. package/dist/commands/exec.js +100 -34
  9. package/dist/commands/feed.d.ts +18 -0
  10. package/dist/commands/feed.js +44 -1
  11. package/dist/commands/focus.d.ts +108 -5
  12. package/dist/commands/focus.js +455 -32
  13. package/dist/commands/fork.d.ts +20 -2
  14. package/dist/commands/fork.js +91 -64
  15. package/dist/commands/go.d.ts +39 -1
  16. package/dist/commands/go.js +112 -7
  17. package/dist/commands/harness-wizard.d.ts +206 -0
  18. package/dist/commands/harness-wizard.js +403 -0
  19. package/dist/commands/harness.d.ts +12 -0
  20. package/dist/commands/harness.js +97 -101
  21. package/dist/commands/insights.js +6 -2
  22. package/dist/commands/inspect.js +39 -5
  23. package/dist/commands/menubar.js +6 -1
  24. package/dist/commands/models.js +1 -0
  25. package/dist/commands/modes.d.ts +12 -0
  26. package/dist/commands/modes.js +147 -0
  27. package/dist/commands/resume.js +10 -3
  28. package/dist/commands/secrets-sync.js +11 -13
  29. package/dist/commands/secrets.d.ts +2 -0
  30. package/dist/commands/secrets.js +79 -47
  31. package/dist/commands/sessions-browser.d.ts +35 -0
  32. package/dist/commands/sessions-browser.js +140 -14
  33. package/dist/commands/sessions-resume.d.ts +23 -3
  34. package/dist/commands/sessions-resume.js +73 -19
  35. package/dist/commands/sessions.d.ts +91 -5
  36. package/dist/commands/sessions.js +359 -125
  37. package/dist/commands/setup-secrets.js +1 -1
  38. package/dist/commands/sync.js +246 -42
  39. package/dist/commands/view.js +2 -0
  40. package/dist/commands/watchdog.js +13 -2
  41. package/dist/index.js +2 -1
  42. package/dist/lib/agent-modes.d.ts +49 -0
  43. package/dist/lib/agent-modes.js +70 -0
  44. package/dist/lib/agents.d.ts +1 -1
  45. package/dist/lib/agents.js +132 -0
  46. package/dist/lib/codex-policy.d.ts +17 -0
  47. package/dist/lib/codex-policy.js +48 -0
  48. package/dist/lib/crabbox/lease.d.ts +25 -0
  49. package/dist/lib/crabbox/lease.js +62 -0
  50. package/dist/lib/daemon.js +70 -0
  51. package/dist/lib/doctor-diff.d.ts +3 -0
  52. package/dist/lib/doctor-diff.js +15 -13
  53. package/dist/lib/event-stream.d.ts +3 -1
  54. package/dist/lib/event-stream.js +14 -1
  55. package/dist/lib/exec.d.ts +16 -0
  56. package/dist/lib/exec.js +175 -62
  57. package/dist/lib/feed-broadcast.d.ts +1 -20
  58. package/dist/lib/feed-broadcast.js +31 -1
  59. package/dist/lib/hooks/cache.js +36 -3
  60. package/dist/lib/hooks.d.ts +19 -7
  61. package/dist/lib/hooks.js +100 -40
  62. package/dist/lib/hosts/session-index.d.ts +4 -0
  63. package/dist/lib/hosts/session-index.js +7 -0
  64. package/dist/lib/manifest.d.ts +12 -2
  65. package/dist/lib/manifest.js +60 -5
  66. package/dist/lib/mcp.js +44 -0
  67. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  68. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  69. package/dist/lib/menubar/install-menubar.d.ts +52 -2
  70. package/dist/lib/menubar/install-menubar.js +128 -6
  71. package/dist/lib/models.d.ts +0 -5
  72. package/dist/lib/models.js +48 -0
  73. package/dist/lib/plugin-marketplace.js +9 -0
  74. package/dist/lib/pricing/prices.json +119 -92
  75. package/dist/lib/pricing/table.js +13 -0
  76. package/dist/lib/refresh.d.ts +5 -0
  77. package/dist/lib/refresh.js +37 -33
  78. package/dist/lib/remote-agents-json.d.ts +29 -1
  79. package/dist/lib/remote-agents-json.js +47 -10
  80. package/dist/lib/resource-inventory.d.ts +79 -0
  81. package/dist/lib/resource-inventory.js +122 -0
  82. package/dist/lib/resources/mcp.js +2 -0
  83. package/dist/lib/resources/permissions.js +3 -0
  84. package/dist/lib/resources/types.d.ts +2 -1
  85. package/dist/lib/resources.js +8 -5
  86. package/dist/lib/run-defaults.d.ts +2 -0
  87. package/dist/lib/run-defaults.js +23 -2
  88. package/dist/lib/runner.js +50 -36
  89. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  90. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  91. package/dist/lib/secrets/bundles.js +157 -65
  92. package/dist/lib/secrets/filestore.d.ts +5 -3
  93. package/dist/lib/secrets/filestore.js +12 -8
  94. package/dist/lib/secrets/index.d.ts +15 -1
  95. package/dist/lib/secrets/index.js +148 -53
  96. package/dist/lib/secrets/reaper.d.ts +97 -0
  97. package/dist/lib/secrets/reaper.js +219 -0
  98. package/dist/lib/secrets/remote.d.ts +29 -0
  99. package/dist/lib/secrets/remote.js +37 -1
  100. package/dist/lib/secrets/sync-passphrase.d.ts +27 -0
  101. package/dist/lib/secrets/sync-passphrase.js +78 -0
  102. package/dist/lib/session/active.d.ts +36 -1
  103. package/dist/lib/session/active.js +60 -19
  104. package/dist/lib/session/actor-sidecar.d.ts +14 -0
  105. package/dist/lib/session/actor-sidecar.js +67 -4
  106. package/dist/lib/session/db.d.ts +1 -1
  107. package/dist/lib/session/db.js +32 -1
  108. package/dist/lib/session/discover.js +168 -0
  109. package/dist/lib/session/parse.d.ts +10 -0
  110. package/dist/lib/session/parse.js +98 -0
  111. package/dist/lib/session/recovery.d.ts +37 -0
  112. package/dist/lib/session/recovery.js +95 -0
  113. package/dist/lib/session/remote-list.d.ts +10 -1
  114. package/dist/lib/session/remote-list.js +2 -8
  115. package/dist/lib/session/remote.d.ts +57 -3
  116. package/dist/lib/session/remote.js +90 -26
  117. package/dist/lib/session/resume-command.d.ts +6 -0
  118. package/dist/lib/session/resume-command.js +8 -0
  119. package/dist/lib/session/session-cache.d.ts +173 -0
  120. package/dist/lib/session/session-cache.js +399 -0
  121. package/dist/lib/session/types.d.ts +1 -1
  122. package/dist/lib/session/types.js +1 -1
  123. package/dist/lib/session/width.d.ts +1 -1
  124. package/dist/lib/session/width.js +12 -2
  125. package/dist/lib/shims.d.ts +2 -2
  126. package/dist/lib/shims.js +62 -8
  127. package/dist/lib/staleness/writers/hooks.js +1 -1
  128. package/dist/lib/startup/command-registry.d.ts +1 -0
  129. package/dist/lib/startup/command-registry.js +2 -0
  130. package/dist/lib/sync-umbrella.d.ts +5 -0
  131. package/dist/lib/sync-umbrella.js +5 -4
  132. package/dist/lib/tmux/session.d.ts +4 -2
  133. package/dist/lib/tmux/session.js +5 -5
  134. package/dist/lib/types.d.ts +3 -1
  135. package/dist/lib/usage.d.ts +13 -0
  136. package/dist/lib/usage.js +215 -0
  137. package/dist/lib/versions.js +13 -2
  138. package/package.json +1 -1
@@ -16,11 +16,13 @@ import { dynamicPicker } from '../lib/picker.js';
16
16
  import { isSessionTrackedAgent } from '../lib/session/types.js';
17
17
  import { discoverSessions } from '../lib/session/discover.js';
18
18
  import { gatherRemoteList } from '../lib/session/remote-list.js';
19
+ import { resolveVersionAliasLoose } from '../lib/versions.js';
20
+ import { AGENTS } from '../lib/agents.js';
19
21
  import { enrichTeamOrigins, safeTeamText } from '../lib/session/team-filter.js';
20
22
  import { listFavorites, toggleFavorite } from '../lib/session/favorites.js';
21
23
  import { machineId, normalizeHost } from '../lib/session/sync/config.js';
22
24
  import { buildPreview } from './sessions-picker.js';
23
- import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, gatherActiveSessions, liveHostLabel, LIVE_ROW_PREFIX, cleanPreview, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, matchesTeam, formatLiveStatusHeadline, } from './sessions.js';
25
+ import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, gatherActiveSessions, liveHostLabel, LIVE_ROW_PREFIX, cleanPreview, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, matchesTeam, formatLiveStatusHeadline, matchesLiveStatus, parseAgentFilter, } from './sessions.js';
24
26
  /**
25
27
  * Complete a seed into the filter the picker actually runs on.
26
28
  *
@@ -44,6 +46,15 @@ export function buildInitialFilter(initial) {
44
46
  team: initial.team,
45
47
  projectScope: initial.projectScope ?? 'repo',
46
48
  window: 'window' in initial ? initial.window : '30d',
49
+ statuses: initial.statuses ?? [],
50
+ project: initial.project,
51
+ until: initial.until,
52
+ routine: initial.routine ?? false,
53
+ skill: initial.skill,
54
+ plugin: initial.plugin,
55
+ limit: initial.limit ?? 500,
56
+ unmanaged: initial.unmanaged ?? false,
57
+ sort: initial.sort ?? 'timestamp',
47
58
  };
48
59
  }
49
60
  /** Cache key for the transcript pool: every filter that changes what is FETCHED
@@ -54,7 +65,11 @@ function poolCacheKey(f) {
54
65
  // fetches the same deep pool and is then narrowed in memory. Keying on the name
55
66
  // would make `t` the one hotkey that re-fans-out the fleet on every step of the
56
67
  // cycle, at up to REMOTE_TIMEOUT_MS per unreachable peer.
57
- return `${f.window ?? 'all'}|${f.teams}|${f.team ? 'team' : ''}`;
68
+ const versionScoped = f.agent?.includes('@') ? f.agent : '';
69
+ return [
70
+ f.window ?? 'all', f.until ?? '', f.teams, f.team ? 'team' : '', versionScoped,
71
+ f.project ?? '', f.routine, f.skill ?? '', f.plugin ?? '', f.limit, f.unmanaged, f.sort,
72
+ ].join('|');
58
73
  }
59
74
  /** Pool size when a team filter is active; one team's rows can sit anywhere. */
60
75
  const WHOLE_TEAM_POOL_LIMIT = 5000;
@@ -105,8 +120,10 @@ export function sessionMatchesQuery(s, query) {
105
120
  */
106
121
  export function browserFilterToArgv(f, query = '') {
107
122
  const a = ['sessions'];
108
- if (f.running)
123
+ if (f.statuses.length === 0 && f.running)
109
124
  a.push('--active');
125
+ for (const status of f.statuses)
126
+ a.push(`--${status === 'orphaned' ? 'orphan' : status}`);
110
127
  if (f.teams)
111
128
  a.push('--teams');
112
129
  if (f.favorites)
@@ -121,6 +138,22 @@ export function browserFilterToArgv(f, query = '') {
121
138
  a.push('--all');
122
139
  if (f.window)
123
140
  a.push('--since', f.window);
141
+ if (f.until)
142
+ a.push('--until', f.until);
143
+ if (f.project)
144
+ a.push('--project', f.project);
145
+ if (f.routine)
146
+ a.push('--routine');
147
+ if (f.skill)
148
+ a.push('--skill', f.skill);
149
+ if (f.plugin)
150
+ a.push('--plugin', f.plugin);
151
+ if (f.unmanaged)
152
+ a.push('--unmanaged');
153
+ if (f.sort !== 'timestamp')
154
+ a.push('--sort', f.sort === 'cost' ? 'cost' : 'duration');
155
+ if (f.limit !== 500)
156
+ a.push('--limit', String(f.limit));
124
157
  const q = query.trim();
125
158
  if (q)
126
159
  a.push(JSON.stringify(q));
@@ -151,6 +184,7 @@ export function activeBrowserSeed(opts) {
151
184
  // --all widens the window to all-time (project is already 'all' here);
152
185
  // --since still overrides.
153
186
  window: opts.since ?? (opts.all ? undefined : '30d'),
187
+ statuses: [],
154
188
  };
155
189
  }
156
190
  /**
@@ -184,6 +218,7 @@ export function bareBrowserSeed(opts) {
184
218
  // --all maxes every non-status filter: all dirs AND all-time. --since wins.
185
219
  projectScope: opts.all || scoped || wholeTeam ? 'all' : 'repo',
186
220
  window: opts.since ?? (opts.all || wholeTeam ? undefined : '30d'),
221
+ statuses: [],
187
222
  };
188
223
  }
189
224
  /** Copy text to the OS clipboard (best-effort; silently no-ops if unavailable). */
@@ -214,8 +249,13 @@ function copyToClipboard(text) {
214
249
  * `hosts` is the explicit `--host`/`--device` scope (if any): it restricts which
215
250
  * peers are dialed and whether local is included, honoring the flag's "scope,
216
251
  * not add" contract instead of always sweeping the whole fleet. */
217
- async function fetchRawPool(f, self, local, hosts) {
252
+ async function fetchRawPool(f, self, local, hosts, fixedFilters = false) {
218
253
  const since = f.window;
254
+ const selectedAgent = f.agent ? parseAgentFilter(f.agent) : {};
255
+ const parsedAgent = fixedFilters ? selectedAgent : {};
256
+ const localAgentVersion = parsedAgent.agent && parsedAgent.agent in AGENTS
257
+ ? resolveVersionAliasLoose(parsedAgent.agent, parsedAgent.version)
258
+ : parsedAgent.version;
219
259
  let unreachable = [];
220
260
  // Local pool: wide (every directory) — device/agent/project are applied in
221
261
  // memory so a hotkey toggle is instant and doesn't re-hit the disk. Skipped
@@ -223,16 +263,33 @@ async function fetchRawPool(f, self, local, hosts) {
223
263
  let rows = shouldIncludeLocal(hosts, self)
224
264
  ? await discoverSessions({
225
265
  all: true,
266
+ agent: parsedAgent.agent,
267
+ version: localAgentVersion,
268
+ includeUnmanaged: f.unmanaged,
226
269
  cwd: process.cwd(),
227
270
  since,
271
+ until: f.until,
272
+ project: f.project,
273
+ origin: f.routine ? 'routine' : undefined,
274
+ skill: f.skill,
275
+ plugin: f.plugin,
228
276
  excludeTeamOrigin: !f.teams,
229
277
  // A team filter reaches back past the usual browse window, so the pool it
230
278
  // draws from has to as well — otherwise the newest 500 rows decide which
231
279
  // teams exist.
232
- limit: f.team ? WHOLE_TEAM_POOL_LIMIT : 500,
233
- sortBy: 'timestamp',
280
+ limit: f.team ? WHOLE_TEAM_POOL_LIMIT : f.limit,
281
+ sortBy: f.sort,
234
282
  })
235
283
  : [];
284
+ // A synced mirror knows which VERSION produced a session, but it cannot know
285
+ // which version is currently installed as latest/oldest on the origin device.
286
+ // For an alias selector, keep only this device's rows here and accept peer
287
+ // rows only from the live query below, where that peer resolves its own
288
+ // inventory. If the peer is unavailable, an empty/partial result is honest;
289
+ // widening to cached sessions from every version is not.
290
+ if (selectedAgent.version === 'latest' || selectedAgent.version === 'oldest') {
291
+ rows = rows.filter((row) => (row.machine ?? self) === self);
292
+ }
236
293
  // Fleet: fold in peers' own indexes over SSH (no sync), same as the flag path.
237
294
  // Skipped under --local. An explicit --host/--device scopes exactly which peers
238
295
  // are dialed (undefined = sweep every online device). Best-effort — a fan-out
@@ -251,11 +308,7 @@ async function fetchRawPool(f, self, local, hosts) {
251
308
  // the same bug one hop out. A numeric --limit is forwarded rather than
252
309
  // --in-team itself, which a peer on an older build would reject as an
253
310
  // unknown option and fail the whole fan-out.
254
- const forwarded = ['sessions', '--all', '--json', '--limit', String(f.team ? WHOLE_TEAM_POOL_LIMIT : 500)];
255
- if (since)
256
- forwarded.push('--since', since);
257
- if (f.teams)
258
- forwarded.push('--teams');
311
+ const forwarded = remotePoolArgs(f, fixedFilters);
259
312
  const remoteResult = await gatherRemoteList(forwarded, remoteHosts);
260
313
  unreachable = remoteResult.unreachable;
261
314
  if (remoteResult.sessions.length > 0)
@@ -272,6 +325,32 @@ async function fetchRawPool(f, self, local, hosts) {
272
325
  rows = enrichTeamOrigins(rows);
273
326
  return { key: poolCacheKey(f), rows, unreachable };
274
327
  }
328
+ /** Build the static peer query. Keeping it pure pins the per-device alias
329
+ * contract: latest/oldest travel unresolved and are resolved by each peer. */
330
+ export function remotePoolArgs(f, fixedFilters) {
331
+ const forwarded = ['sessions', '--all', '--json', '--limit', String(f.team ? WHOLE_TEAM_POOL_LIMIT : f.limit)];
332
+ if (f.window)
333
+ forwarded.push('--since', f.window);
334
+ if (f.until)
335
+ forwarded.push('--until', f.until);
336
+ if (f.project)
337
+ forwarded.push('--project', f.project);
338
+ if (f.routine)
339
+ forwarded.push('--routine');
340
+ if (f.skill)
341
+ forwarded.push('--skill', f.skill);
342
+ if (f.plugin)
343
+ forwarded.push('--plugin', f.plugin);
344
+ if (f.unmanaged)
345
+ forwarded.push('--unmanaged');
346
+ if (f.sort !== 'timestamp')
347
+ forwarded.push('--sort', f.sort === 'cost' ? 'cost' : 'duration');
348
+ if (f.agent && (fixedFilters || f.agent.includes('@')))
349
+ forwarded.push('--agent', f.agent);
350
+ if (f.teams)
351
+ forwarded.push('--teams');
352
+ return forwarded;
353
+ }
275
354
  /**
276
355
  * A live session's stable row key: its session id when the agent reported one,
277
356
  * else a per-machine handle so an id-less live session (a just-booted harness, a
@@ -366,27 +445,74 @@ export function shouldShowHostColumn(f, live, rows) {
366
445
  return rows.some((r) => liveHostLabel(live.get(r.id)) !== '');
367
446
  }
368
447
  /** Apply the cheap in-memory filters (agent / device / project / running / favorites). */
369
- function applyFilters(rows, live, f, self, favorites) {
448
+ export function applyFilters(rows, live, f, self, favorites) {
370
449
  let out = rows;
371
450
  // A projected live row is keyed by pid/task when it has no session id, and a
372
451
  // favorite is always keyed by a real session id — so an id-less row can never
373
452
  // be favorited and correctly drops out here.
374
453
  if (f.favorites)
375
454
  out = out.filter((r) => favorites.has(r.id));
376
- if (f.agent)
377
- out = out.filter((r) => r.agent === f.agent);
455
+ if (f.agent) {
456
+ const { agent, version: rawVersion } = parseAgentFilter(f.agent);
457
+ const localVersion = agent && agent in AGENTS
458
+ ? resolveVersionAliasLoose(agent, rawVersion)
459
+ : rawVersion;
460
+ const peerResolvedAlias = rawVersion === 'latest' || rawVersion === 'oldest';
461
+ out = out.filter((r) => {
462
+ if (r.agent !== agent)
463
+ return false;
464
+ // Only a LIVE peer result may claim it resolved latest/oldest against that
465
+ // device's installed inventory. A synced mirror is historical data, not a
466
+ // version-inventory oracle.
467
+ if (peerResolvedAlias && (r.machine ?? self) !== self)
468
+ return r._remote === true;
469
+ if (!localVersion)
470
+ return true;
471
+ return r.version === localVersion;
472
+ });
473
+ }
378
474
  if (f.device)
379
475
  out = out.filter((r) => (r.machine ?? self) === f.device);
380
476
  if (f.team)
381
477
  out = out.filter((r) => matchesTeam(r, f.team));
478
+ if (f.project) {
479
+ const q = f.project.toLowerCase();
480
+ out = out.filter((r) => (r.project ?? '').toLowerCase().includes(q) || (r.cwd ?? '').toLowerCase().includes(q));
481
+ }
382
482
  if (f.projectScope === 'repo') {
383
483
  const cwd = process.cwd();
384
484
  out = out.filter((r) => !!r.cwd && (r.cwd === cwd || r.cwd.startsWith(cwd + '/')));
385
485
  }
386
486
  if (f.running)
387
487
  out = out.filter((r) => live.has(r.id));
488
+ if (f.statuses.length > 0) {
489
+ out = out.filter((r) => {
490
+ const active = live.get(r.id);
491
+ return !!active && f.statuses.some((status) => matchesLiveStatus(active, status));
492
+ });
493
+ }
388
494
  return out;
389
495
  }
496
+ /** One-shot form of the browser's canonical candidate pipeline. Focus uses this
497
+ * instead of maintaining a second discovery/filter implementation. */
498
+ export async function collectSessionCandidates(initial, opts = {}) {
499
+ const self = machineId();
500
+ const filter = buildInitialFilter(initial);
501
+ if (filter.statuses.length > 0)
502
+ filter.running = true;
503
+ const hosts = opts.hosts && opts.hosts.length > 0 ? opts.hosts : undefined;
504
+ const pool = await fetchRawPool(filter, self, opts.local ?? false, hosts, true);
505
+ let liveById = new Map();
506
+ if (filter.running || opts.includeLive) {
507
+ const { sessions } = await gatherActiveSessions({ local: opts.local ?? false, hosts });
508
+ liveById = indexLiveRows(sessions, self);
509
+ }
510
+ const rows = filter.running || opts.includeLive
511
+ ? mergeLiveIntoPool(pool.rows, liveById, self)
512
+ : pool.rows;
513
+ const sessions = applyFilters(rows, liveById, filter, self, listFavorites());
514
+ return { sessions, liveById, self, unreachable: pool.unreachable };
515
+ }
390
516
  /** Derive the SSH-launch origin tag for a picker row from the live index. Set
391
517
  * only when the live session's provenance is ssh transport; `device` is the
392
518
  * resolved origin device (absent → the row shows a bare `ssh`). Rows without a
@@ -1,6 +1,9 @@
1
1
  import type { Command } from 'commander';
2
- import { type Packing } from '../lib/terminal/index.js';
3
- interface ResumeOptions {
2
+ import type { SessionMeta } from '../lib/session/types.js';
3
+ import { type Backend, type EngineContext, type Packing } from '../lib/terminal/index.js';
4
+ /** Opening more than this many live sessions at once asks for confirmation first. */
5
+ export declare const CONFIRM_THRESHOLD = 5;
6
+ export interface ResumeOptions {
4
7
  agent?: string;
5
8
  all?: boolean;
6
9
  teams?: boolean;
@@ -16,5 +19,22 @@ interface ResumeOptions {
16
19
  splits?: boolean;
17
20
  }
18
21
  export declare function registerSessionsResumeCommand(sessionsCmd: Command): void;
22
+ /** IDs and tmux aliases are actions, not picker search text. Human phrases keep
23
+ * the existing pre-filtered picker, while an explicit identity resumes directly. */
24
+ export declare function isDirectResumeSelector(query: string): boolean;
25
+ /** Re-enter through the top-level command so fleet routing and harness policy
26
+ * stay centralized. The child inherits this terminal for a real interactive resume. */
27
+ export declare function resumeSelectorInPlace(selector: string): Promise<void>;
28
+ /** Direct identities use focus as the lifecycle dispatcher: it rechecks the
29
+ * live fleet, attaches a healthy pane, and falls through to `agents resume`
30
+ * only when the process is no longer attachable. */
31
+ export declare function dispatchSessionLifecycleInPlace(selector: string, hosts?: string[]): Promise<void>;
32
+ export declare function buildSessionLifecycleArgs(selector: string, hosts?: string[]): string[];
19
33
  export declare function resolveResumePacking(options: Pick<ResumeOptions, 'splits'>): Packing;
20
- export {};
34
+ export declare function resumeHostMismatch(session: Pick<SessionMeta, 'shortId' | 'machine'>, requestedHost: string, self?: string): string | null;
35
+ /**
36
+ * Decide which backend to launch into. Returns a concrete backend, `'inplace'`
37
+ * (resume in the current process — no GUI/tmux available), or `'cancel'` (the
38
+ * user dismissed the chooser).
39
+ */
40
+ export declare function resolveBackend(options: ResumeOptions, ctx: EngineContext, count: number): Promise<Backend | 'inplace' | 'cancel'>;
@@ -14,24 +14,28 @@ import { discoverSessions } from '../lib/session/discover.js';
14
14
  import { filterTeamSessions } from '../lib/session/team-filter.js';
15
15
  import { multiItemPicker, itemPicker } from '../lib/picker.js';
16
16
  import { buildPreview } from './sessions-picker.js';
17
- import { filterSessionsByQuery, formatPickerLabel, pickerColumnsFor, buildResumeCommand, resumeSessionInPlace, parseAgentFilter, } from './sessions.js';
17
+ import { filterSessionsByQuery, formatPickerLabel, pickerColumnsFor, buildSessionRecoveryCommand, resumeSessionInPlace, parseAgentFilter, } from './sessions.js';
18
18
  import { openSurfaces, availableBackends, detectCurrentBackend, currentContext, } from '../lib/terminal/index.js';
19
19
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
20
20
  import { setHelpSections } from '../lib/help.js';
21
21
  import { confirm } from '@inquirer/prompts';
22
+ import { spawn } from 'node:child_process';
23
+ import { looksLikeSessionId } from '../lib/session/discover.js';
24
+ import { machineId } from '../lib/session/sync/config.js';
25
+ import { sessionOriginDevice, sessionRecoveryDestinationMatches } from '../lib/session/recovery.js';
22
26
  /** Opening more than this many live sessions at once asks for confirmation first. */
23
- const CONFIRM_THRESHOLD = 5;
27
+ export const CONFIRM_THRESHOLD = 5;
24
28
  export function registerSessionsResumeCommand(sessionsCmd) {
25
29
  const cmd = sessionsCmd
26
30
  .command('resume')
27
- .argument('[query]', 'Filter sessions before selecting (topic, path, or id fragment)')
28
- .description('Multi-select sessions and resume each in a terminal tab/split (this terminal, iTerm, Ghostty, tmux, VSCodium; local or --host).')
31
+ .argument('[query]', 'Session id/tmux alias to reopen directly, or text that filters the picker')
32
+ .description('Reopen one session by canonical identity, or multi-select history into terminal tabs/splits.')
29
33
  .option('-a, --agent <agent>', 'Filter by agent type and version (e.g., claude, codex@0.116.0)')
30
34
  .option('--all', 'Include sessions from every directory (not just current project)')
31
35
  .option('--teams', 'Include team-spawned sessions (hidden by default)')
32
36
  .option('--since <time>', 'Only sessions newer than this (e.g., 2h, 7d, 4w, or ISO date)')
33
37
  .option('-n, --limit <n>', 'Maximum number of sessions to load into the picker', '200')
34
- .option('--host <alias>', 'Resume on a remote host over SSH (defaults to tmux there)')
38
+ .option('--host <alias>', 'Open on the session origin host over SSH; the host must match every selected session')
35
39
  .option('--iterm', 'Force the iTerm backend')
36
40
  .option('--ghostty', 'Force the Ghostty backend')
37
41
  .option('--tmux', 'Force the tmux backend')
@@ -46,6 +50,10 @@ export function registerSessionsResumeCommand(sessionsCmd) {
46
50
  # Pre-filter the pool before selecting (space in the filter → use [query])
47
51
  agents sessions resume "auth middleware"
48
52
 
53
+ # Reopen one session from any device by UUID prefix or tmux alias
54
+ agents sessions resume 019fd114
55
+ agents sessions resume ag-codex-c1f3d813
56
+
49
57
  # Force a backend / side-by-side splits / a remote host
50
58
  agents sessions resume --ghostty
51
59
  agents sessions resume --vscodium
@@ -53,12 +61,13 @@ export function registerSessionsResumeCommand(sessionsCmd) {
53
61
  agents sessions resume --host zion --tmux
54
62
  `,
55
63
  notes: `
56
- - space toggles a session, enter confirms; tab toggles the preview pane.
64
+ - A UUID/prefix or ag-<agent>-<suffix> alias bypasses the picker: a live pane is attached; an inactive session resumes on its owning device.
65
+ - With no identity selector, space toggles a session, enter confirms, and tab toggles the preview pane.
57
66
  - Layout: one tab per session by default. --splits packs session pairs side by side in each tab.
58
67
  - Backend: auto-detected from the terminal you're in (iTerm / Ghostty / tmux); override with --iterm/--ghostty/--tmux/--vscodium.
59
68
  - --vscodium opens each session as an agent terminal tab in VSCodium via the swarm-ext extension (works with --host too).
60
- - --host <alias> resumes on a remote machine over the same SSH transport as 'sessions --host' (defaults to tmux).
61
- - Each session opens version-pinned, in its own cwd. Non-resumable agents are skipped with a note.
69
+ - --host <alias> opens the terminal surface on that host only when it is the selected sessions' origin; recovery never migrates a session to another device.
70
+ - Recovery runs on the session's origin device: exact healthy origin uses native resume; otherwise a healthy version of the same harness receives /continue <id>.
62
71
  `,
63
72
  });
64
73
  cmd.action(async (query, options) => {
@@ -71,6 +80,10 @@ async function sessionsResumeAction(query, options) {
71
80
  process.exitCode = 1;
72
81
  return;
73
82
  }
83
+ if (query && isDirectResumeSelector(query)) {
84
+ await dispatchSessionLifecycleInPlace(query.trim(), options.host ? [options.host] : []);
85
+ return;
86
+ }
74
87
  const { agent, version } = parseAgentFilter(options.agent);
75
88
  const limit = parseInt(options.limit || '200', 10);
76
89
  const since = options.since ?? (options.all ? undefined : '30d');
@@ -115,21 +128,25 @@ async function sessionsResumeAction(query, options) {
115
128
  }
116
129
  if (!chosen || chosen.length === 0)
117
130
  return;
118
- // 2. Split the selection into resumable surfaces and skipped agents (no silent drop).
131
+ if (options.host) {
132
+ const requestedHost = options.host;
133
+ const mismatches = chosen
134
+ .map((session) => resumeHostMismatch(session, requestedHost))
135
+ .filter((message) => message !== null);
136
+ if (mismatches.length > 0) {
137
+ for (const message of mismatches)
138
+ console.error(chalk.red(message));
139
+ process.exitCode = 1;
140
+ return;
141
+ }
142
+ }
143
+ // 2. Route every selection through the owning device's recovery resolver.
119
144
  const items = [];
120
145
  for (const s of chosen) {
121
- const command = buildResumeCommand(s);
122
- if (!command) {
123
- console.log(chalk.yellow(` skip ${s.shortId} — resume is not supported for ${s.agent} sessions yet`));
124
- continue;
125
- }
146
+ const command = buildSessionRecoveryCommand(s, !!options.host);
126
147
  const cwd = s.cwd && fs.existsSync(s.cwd) ? s.cwd : process.cwd();
127
148
  items.push({ session: s, cwd, command });
128
149
  }
129
- if (items.length === 0) {
130
- console.log(chalk.gray('Nothing resumable in the selection.'));
131
- return;
132
- }
133
150
  // 3. Resolve the backend (and host).
134
151
  const ctx = currentContext();
135
152
  const backend = await resolveBackend(options, ctx, items.length);
@@ -179,15 +196,52 @@ async function sessionsResumeAction(query, options) {
179
196
  });
180
197
  console.log(chalk.gray(`\nOpened ${opened}/${items.length} in ${where}.`));
181
198
  }
199
+ /** IDs and tmux aliases are actions, not picker search text. Human phrases keep
200
+ * the existing pre-filtered picker, while an explicit identity resumes directly. */
201
+ export function isDirectResumeSelector(query) {
202
+ const selector = query.trim();
203
+ return looksLikeSessionId(selector) || /^ag-[a-z][a-z0-9-]*-[0-9a-f]{8}$/i.test(selector);
204
+ }
205
+ /** Re-enter through the top-level command so fleet routing and harness policy
206
+ * stay centralized. The child inherits this terminal for a real interactive resume. */
207
+ export async function resumeSelectorInPlace(selector) {
208
+ await spawnCliInPlace(['resume', selector]);
209
+ }
210
+ /** Direct identities use focus as the lifecycle dispatcher: it rechecks the
211
+ * live fleet, attaches a healthy pane, and falls through to `agents resume`
212
+ * only when the process is no longer attachable. */
213
+ export async function dispatchSessionLifecycleInPlace(selector, hosts = []) {
214
+ await spawnCliInPlace(buildSessionLifecycleArgs(selector, hosts));
215
+ }
216
+ export function buildSessionLifecycleArgs(selector, hosts = []) {
217
+ return ['sessions', 'focus', selector, ...hosts.flatMap(host => ['--host', host])];
218
+ }
219
+ function asyncExitCode(child) {
220
+ return new Promise((resolve) => {
221
+ child.once('error', () => resolve(127));
222
+ child.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)));
223
+ });
224
+ }
225
+ async function spawnCliInPlace(args) {
226
+ const child = spawn(process.execPath, [process.argv[1], ...args], { stdio: 'inherit' });
227
+ const exitCode = await asyncExitCode(child);
228
+ process.exitCode = exitCode;
229
+ }
182
230
  export function resolveResumePacking(options) {
183
231
  return options.splits ? 'two-per-tab' : 'tabs';
184
232
  }
233
+ export function resumeHostMismatch(session, requestedHost, self = machineId()) {
234
+ const origin = sessionOriginDevice(session, self);
235
+ return sessionRecoveryDestinationMatches(session, requestedHost, self)
236
+ ? null
237
+ : `Session ${session.shortId} originated on ${origin}; --host ${requestedHost} cannot move recovery to another device.`;
238
+ }
185
239
  /**
186
240
  * Decide which backend to launch into. Returns a concrete backend, `'inplace'`
187
241
  * (resume in the current process — no GUI/tmux available), or `'cancel'` (the
188
242
  * user dismissed the chooser).
189
243
  */
190
- async function resolveBackend(options, ctx, count) {
244
+ export async function resolveBackend(options, ctx, count) {
191
245
  const forced = options.iterm ? 'iterm'
192
246
  : options.ghostty ? 'ghostty'
193
247
  : options.tmux ? 'tmux'
@@ -66,6 +66,9 @@ interface SessionsOptions extends SessionFilterOptions {
66
66
  local?: boolean;
67
67
  /** --device <target...> — alias for --host; resolves against the device registry. */
68
68
  device?: string[];
69
+ /** --devices <target...> — plural alias for --device; a bare `all`/`fleet` value
70
+ * means "search the whole fleet" (already the default), so it never errors. */
71
+ devices?: string[];
69
72
  /** Query every registered online compute device and merge tool evidence. */
70
73
  fleet?: boolean;
71
74
  /** Aggregate static program sites instead of returning matching call evidence. */
@@ -127,6 +130,17 @@ export declare function formatActiveRowDescription(s: ActiveSession): string;
127
130
  * can't be correlated back to a transcript on disk.
128
131
  */
129
132
  export declare function indexActiveBySessionId(active: ActiveSession[]): Map<string, ActiveSession>;
133
+ /** The SessionMeta fields the live-row backfill reads — the enrichment a running process cannot report. */
134
+ type BackfillMeta = Pick<SessionMeta, 'version' | 'timestamp' | 'label' | 'ticketId' | 'prUrl' | 'prNumber'>;
135
+ /**
136
+ * Backfill display-only fields onto live rows from the indexed SessionMeta, by
137
+ * full session id (RUSH-2205). A running process reports no agent version, and a
138
+ * live orphan row usually carries no ticket/PR/label/start-time; the historical
139
+ * index does. Only a field the live row LACKS is filled — the live signal always
140
+ * wins when present. Pure (no I/O) so the join is unit-tested against fixtures;
141
+ * the DB read that builds `metaById` is the caller's concern.
142
+ */
143
+ export declare function backfillActiveRowsFromMeta(sessions: ActiveSession[], metaById: Map<string, BackfillMeta>): void;
130
144
  /**
131
145
  * The live decoration for a listing row: a status glyph and the latest-turn
132
146
  * preview, when the session is still running. `●` running / `◐` waiting on the
@@ -198,6 +212,22 @@ export declare function serializeActiveSessionsForJson(sessions: ActiveSession[]
198
212
  prLink: string | null;
199
213
  viewingIn: string | null;
200
214
  }>;
215
+ /**
216
+ * Build the (one or two) rendered lines for a single active-session row.
217
+ * Indent is the leading whitespace (2 spaces for flat groups, 4 inside a window
218
+ * sub-group). Sized to `termW` so no line ever wraps under tmux/SSH (RUSH-2205):
219
+ *
220
+ * line 1: id · agent version · status · owner · created X · idle Y · ticket/PR
221
+ * line 2: └ label/topic (+ checklist) · jump locator (ssh/tmux/detached)
222
+ *
223
+ * The label/topic gets its own line so it is no longer buried in a truncated grey
224
+ * snippet, and the actionable ticket/PR badges ride line 1. Version and the
225
+ * ticket/PR/label are backfilled onto the {@link ActiveSession} from the indexed
226
+ * SessionMeta before this renders (a live process reports none of them). Pure +
227
+ * exported so the row layout is unit-tested for content and width without a
228
+ * captured stdout.
229
+ */
230
+ export declare function renderActiveRowLines(s: ActiveSession, indent: string, termW: number): string[];
201
231
  /**
202
232
  * Compact owner display for the `--active` owner column: the local-part of a
203
233
  * resolved actor email/login (`muqsit@getrush.ai` -> `muqsit`), the id as-is
@@ -313,10 +343,17 @@ export declare function remoteHostsToDial(hosts: string[] | undefined, self: str
313
343
  * call it, so the browser can never disagree with `--active --json` about which
314
344
  * sessions are live (it used to call the local-only `getActiveSessions()` directly
315
345
  * and silently hid every remote session).
346
+ *
347
+ * RUSH-2062: default path is cache-first against the daemon-warmed shared
348
+ * snapshot (`session-cache.ts`). Menubar / Factory / watchdog / CLI share one
349
+ * warm result instead of each re-running the full SSH fan-out. `forceRefresh`
350
+ * (or `AGENTS_SESSIONS_FORCE_REFRESH=1`) re-gathers live; scoped `--host` lists
351
+ * always gather live so a filter never returns a wrong unscoped snapshot.
316
352
  */
317
353
  export declare function gatherActiveSessions(opts?: {
318
354
  local?: boolean;
319
355
  hosts?: string[];
356
+ forceRefresh?: boolean;
320
357
  }): Promise<{
321
358
  sessions: ActiveSession[];
322
359
  remoteDeviceCount: number;
@@ -536,6 +573,13 @@ export declare function handlePickedSession(picked: PickedSession): Promise<void
536
573
  * version-pinned launcher is genuinely missing.
537
574
  */
538
575
  export declare function resumeSessionInPlace(session: SessionMeta): Promise<void>;
576
+ /**
577
+ * Relaunch this CLI's one recovery path. The owning device resolves account
578
+ * health and either performs exact-home native resume or same-harness
579
+ * `/continue`; callers never guess which version home can see the transcript.
580
+ * `portable` is for an SSH/terminal-engine command that executes on a peer.
581
+ */
582
+ export declare function buildSessionRecoveryCommand(session: Pick<SessionMeta, 'id'>, portable?: boolean): string[];
539
583
  /**
540
584
  * Map a resume argv to the spawn(command, args, {shell}) triple.
541
585
  *
@@ -557,6 +601,9 @@ interface AgentFilter {
557
601
  agent?: SessionAgentId;
558
602
  version?: string;
559
603
  }
604
+ /** Resolve the harness portion of a sessions selector with the CLI's canonical
605
+ * alias and single-typo rules. Kept pure so focus and the browser cannot drift. */
606
+ export declare function resolveSessionAgentName(name: string): SessionAgentId | null;
560
607
  export declare function parseAgentFilter(agentName?: string): AgentFilter;
561
608
  /**
562
609
  * How a `sessions <query>` argument was resolved against the pool.
@@ -582,6 +629,10 @@ export interface SessionQueryResolution {
582
629
  export declare function resolveSessionQuery(pool: SessionMeta[], query: string, options?: {
583
630
  indexFallback?: boolean;
584
631
  }): SessionQueryResolution;
632
+ /** Honest result of a fleet sweep that found nothing: it says what the sweep
633
+ * actually did — how many peers answered, which were unreachable — instead of
634
+ * dead-ending on "search with --device <host>" after the search already ran. */
635
+ export declare function fleetNotFoundMessage(query: string, deviceCount: number, unreachable: string[]): string[];
585
636
  /** Filter and rank sessions by a multi-term search query across metadata and content. */
586
637
  export declare function filterSessionsByQuery(sessions: SessionMeta[], query: string | undefined): SessionMeta[];
587
638
  /**
@@ -626,11 +677,32 @@ export type MetadataResolveOutcome = {
626
677
  kind: 'partial';
627
678
  failedPeers: string[];
628
679
  };
680
+ /**
681
+ * A match unique enough to resolve on the FIRST peer that returns it and cancel
682
+ * the rest of the fleet sweep: a full session UUID, which is globally unique.
683
+ *
684
+ * Only a full UUID qualifies. A session **label** is deliberately NOT definitive
685
+ * here even on an exact match: labels are free-form and can collide, so a
686
+ * distinct session may carry the same label on a peer that has not answered yet
687
+ * — early-exiting on the first would silently, and nondeterministically, resume
688
+ * the wrong session (whichever peer replied first). Labels therefore stay
689
+ * all-settle so a cross-machine label conflict surfaces as an ambiguity, and a
690
+ * short-id PREFIX stays all-settle for the same reason (RUSH-2203).
691
+ */
692
+ export declare function isDefinitiveMatch(session: SessionMeta, selector: string): boolean;
693
+ /**
694
+ * Whether a selector may enable early-exit on the cancellable fan-out — only a
695
+ * full UUID, which is globally unique so the first hit is the only hit. Labels,
696
+ * keywords, and short-id prefixes stay all-settle: their uniqueness (or
697
+ * conflict) is only knowable once every peer has answered. See
698
+ * {@link isDefinitiveMatch}.
699
+ */
700
+ export declare function selectorAllowsEarlyExit(selector: string): boolean;
629
701
  /** Resolve a fleet sweep through the same canonical full-id / prefix resolver as
630
702
  * local lookups, then group copies by logical session id. Synced mirrors of one
631
703
  * session therefore stay one candidate even when several machines report them;
632
704
  * distinct ids sharing a prefix remain distinct ambiguity candidates. */
633
- export declare function fleetCandidatesByQuery(rows: SessionMeta[], query: string): FleetSessionCandidate[];
705
+ export declare function fleetCandidatesByQuery(rows: SessionMeta[], query: string, trustResolvedRows?: boolean): FleetSessionCandidate[];
634
706
  /** Fixed peer argv for the metadata resolver. Scope flags compose identically on
635
707
  * every host; `--all` removes the SSH login cwd/time window, not agent/project filters. */
636
708
  export declare function metadataResolveForwardedArgs(selector: string, scope: Pick<SessionFilterOptions, 'agent' | 'project'>): string[];
@@ -661,14 +733,28 @@ export declare function resolveSessionMetadataValue(selector: string, scope?: {
661
733
  * (its transcript and agent binary live there — a local `--host` hop would
662
734
  * re-discover locally and dead-end), returning `'rendered'`.
663
735
  * - more than one logical session → print every full-id candidate with its
664
- * machine labels, returning `'conflict'`.
665
- * - none → `'not-found'`, letting the caller print the local
666
- * "no session on this machine" message.
736
+ * machine labels, returning `{ kind: 'conflict' }`.
737
+ * - none → `{ kind: 'not-found', deviceCount, unreachable }`,
738
+ * letting the caller print an honest sweep-aware message.
739
+ *
740
+ * A full-UUID query opts into early-exit: the first peer holding it resolves the
741
+ * sweep and cancels the rest, so a fast peer is not bounded by the slowest one.
742
+ * A short-id prefix stays all-settle — its ambiguity is only known once every
743
+ * peer has answered, so it must wait to surface a conflict.
667
744
  *
668
745
  * No fuzzy/content fallback: the sweep forwards the id selector and every result
669
746
  * is resolved through `resolveSessionQuery`, the same id-only resolver used locally.
670
747
  */
671
- export declare function resolveSessionAcrossFleet(query: string, mode: ViewMode, hosts?: string[], deps?: FleetResolveDeps): Promise<'rendered' | 'conflict' | 'not-found'>;
748
+ export type FleetResolveResult = {
749
+ kind: 'rendered';
750
+ } | {
751
+ kind: 'conflict';
752
+ } | {
753
+ kind: 'not-found';
754
+ deviceCount: number;
755
+ unreachable: string[];
756
+ };
757
+ export declare function resolveSessionAcrossFleet(query: string, mode: ViewMode, hosts?: string[], deps?: FleetResolveDeps): Promise<FleetResolveResult>;
672
758
  /** Register the `agents sessions` command with all its options and help text. */
673
759
  export declare function registerSessionsCommands(program: Command): void;
674
760
  export {};