@phnx-labs/agents-cli 1.22.23 → 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 (88) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/README.md +5 -4
  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 +5 -0
  8. package/dist/commands/exec.js +80 -28
  9. package/dist/commands/feed.d.ts +18 -0
  10. package/dist/commands/feed.js +44 -1
  11. package/dist/commands/focus.d.ts +34 -3
  12. package/dist/commands/focus.js +300 -56
  13. package/dist/commands/go.d.ts +14 -1
  14. package/dist/commands/go.js +49 -5
  15. package/dist/commands/insights.js +6 -2
  16. package/dist/commands/inspect.js +39 -5
  17. package/dist/commands/menubar.js +6 -1
  18. package/dist/commands/models.js +1 -0
  19. package/dist/commands/modes.d.ts +12 -0
  20. package/dist/commands/modes.js +147 -0
  21. package/dist/commands/secrets-sync.js +11 -13
  22. package/dist/commands/secrets.d.ts +2 -0
  23. package/dist/commands/secrets.js +54 -17
  24. package/dist/commands/sessions-browser.d.ts +35 -0
  25. package/dist/commands/sessions-browser.js +140 -14
  26. package/dist/commands/sessions-resume.d.ts +2 -0
  27. package/dist/commands/sessions-resume.js +26 -11
  28. package/dist/commands/sessions.d.ts +10 -0
  29. package/dist/commands/sessions.js +34 -59
  30. package/dist/commands/setup-secrets.js +1 -1
  31. package/dist/commands/sync.js +246 -42
  32. package/dist/commands/view.js +2 -0
  33. package/dist/index.js +2 -1
  34. package/dist/lib/agent-modes.d.ts +49 -0
  35. package/dist/lib/agent-modes.js +70 -0
  36. package/dist/lib/doctor-diff.d.ts +3 -0
  37. package/dist/lib/doctor-diff.js +15 -13
  38. package/dist/lib/event-stream.d.ts +3 -1
  39. package/dist/lib/event-stream.js +14 -1
  40. package/dist/lib/exec.d.ts +12 -0
  41. package/dist/lib/exec.js +88 -9
  42. package/dist/lib/hooks/cache.js +36 -3
  43. package/dist/lib/hooks.d.ts +19 -7
  44. package/dist/lib/hooks.js +88 -38
  45. package/dist/lib/hosts/session-index.d.ts +4 -0
  46. package/dist/lib/hosts/session-index.js +7 -0
  47. package/dist/lib/manifest.d.ts +12 -2
  48. package/dist/lib/manifest.js +60 -5
  49. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  50. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +5 -1
  51. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  52. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  53. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  54. package/dist/lib/menubar/install-menubar.d.ts +52 -2
  55. package/dist/lib/menubar/install-menubar.js +128 -6
  56. package/dist/lib/refresh.d.ts +5 -0
  57. package/dist/lib/refresh.js +37 -33
  58. package/dist/lib/resource-inventory.d.ts +79 -0
  59. package/dist/lib/resource-inventory.js +122 -0
  60. package/dist/lib/resources.js +8 -5
  61. package/dist/lib/run-defaults.d.ts +2 -0
  62. package/dist/lib/run-defaults.js +23 -2
  63. package/dist/lib/runner.js +22 -17
  64. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  65. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  66. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  67. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  68. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  69. package/dist/lib/secrets/bundles.js +157 -65
  70. package/dist/lib/secrets/filestore.d.ts +5 -3
  71. package/dist/lib/secrets/filestore.js +12 -8
  72. package/dist/lib/secrets/index.js +34 -8
  73. package/dist/lib/secrets/reaper.d.ts +10 -0
  74. package/dist/lib/secrets/reaper.js +50 -15
  75. package/dist/lib/secrets/sync-passphrase.d.ts +27 -0
  76. package/dist/lib/secrets/sync-passphrase.js +78 -0
  77. package/dist/lib/session/recovery.d.ts +37 -0
  78. package/dist/lib/session/recovery.js +95 -0
  79. package/dist/lib/shims.d.ts +2 -2
  80. package/dist/lib/shims.js +24 -5
  81. package/dist/lib/startup/command-registry.d.ts +1 -0
  82. package/dist/lib/startup/command-registry.js +2 -0
  83. package/dist/lib/sync-umbrella.d.ts +5 -0
  84. package/dist/lib/sync-umbrella.js +5 -4
  85. package/dist/lib/tmux/session.d.ts +4 -2
  86. package/dist/lib/tmux/session.js +5 -5
  87. package/dist/lib/types.d.ts +2 -0
  88. package/package.json +1 -1
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { type SessionMeta } from '../lib/session/types.js';
13
13
  import type { ActiveSession } from '../lib/session/active.js';
14
+ import { type LiveStatusFilter } from './sessions.js';
14
15
  /**
15
16
  * The single canonical filter state. Every field has a flag equivalent, so the
16
17
  * same view is reachable interactively (hotkeys) or from the command line (flags).
@@ -32,6 +33,23 @@ export interface BrowserFilter {
32
33
  projectScope: 'repo' | 'all';
33
34
  /** time window (undefined = all time) — the `W` key / `--since`. */
34
35
  window?: string;
36
+ /** individual live states; an OR-union and, like the CLI flags, running-only. */
37
+ statuses: LiveStatusFilter[];
38
+ /** named project scope, independent of the repo/all hotkey. */
39
+ project?: string;
40
+ /** upper time bound from --until. */
41
+ until?: string;
42
+ /** retain only routine-origin sessions. */
43
+ routine: boolean;
44
+ /** indexed resource filters shared by the flag and focus surfaces. */
45
+ skill?: string;
46
+ plugin?: string;
47
+ /** discovery cap. */
48
+ limit: number;
49
+ /** include unmanaged native-home transcripts. */
50
+ unmanaged: boolean;
51
+ /** candidate order shared with --sort. */
52
+ sort: 'timestamp' | 'cost' | 'duration';
35
53
  }
36
54
  /**
37
55
  * Complete a seed into the filter the picker actually runs on.
@@ -97,6 +115,9 @@ export declare function bareBrowserSeed(opts: {
97
115
  inTeam?: string;
98
116
  favorites?: boolean;
99
117
  }): Partial<BrowserFilter>;
118
+ /** Build the static peer query. Keeping it pure pins the per-device alias
119
+ * contract: latest/oldest travel unresolved and are resolved by each peer. */
120
+ export declare function remotePoolArgs(f: BrowserFilter, fixedFilters: boolean): string[];
100
121
  /**
101
122
  * A live session's stable row key: its session id when the agent reported one,
102
123
  * else a per-machine handle so an id-less live session (a just-booted harness, a
@@ -135,6 +156,20 @@ export declare function mergeLiveIntoPool(rows: SessionMeta[], live: Map<string,
135
156
  * listing that has no live rows to explain it.
136
157
  */
137
158
  export declare function shouldShowHostColumn(f: BrowserFilter, live: Map<string, ActiveSession> | null, rows: SessionMeta[]): boolean;
159
+ /** Apply the cheap in-memory filters (agent / device / project / running / favorites). */
160
+ export declare function applyFilters(rows: SessionMeta[], live: Map<string, ActiveSession>, f: BrowserFilter, self: string, favorites: Set<string>): SessionMeta[];
161
+ /** One-shot form of the browser's canonical candidate pipeline. Focus uses this
162
+ * instead of maintaining a second discovery/filter implementation. */
163
+ export declare function collectSessionCandidates(initial: Partial<BrowserFilter>, opts?: {
164
+ local?: boolean;
165
+ hosts?: string[];
166
+ includeLive?: boolean;
167
+ }): Promise<{
168
+ sessions: SessionMeta[];
169
+ liveById: Map<string, ActiveSession>;
170
+ self: string;
171
+ unreachable: string[];
172
+ }>;
138
173
  /**
139
174
  * Launch the interactive session browser. `initial` seeds the filter (e.g.
140
175
  * `{ running: true }` for `--active`). Resolves after the user resumes a session
@@ -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,4 +1,5 @@
1
1
  import type { Command } from 'commander';
2
+ import type { SessionMeta } from '../lib/session/types.js';
2
3
  import { type Backend, type EngineContext, type Packing } from '../lib/terminal/index.js';
3
4
  /** Opening more than this many live sessions at once asks for confirmation first. */
4
5
  export declare const CONFIRM_THRESHOLD = 5;
@@ -30,6 +31,7 @@ export declare function resumeSelectorInPlace(selector: string): Promise<void>;
30
31
  export declare function dispatchSessionLifecycleInPlace(selector: string, hosts?: string[]): Promise<void>;
31
32
  export declare function buildSessionLifecycleArgs(selector: string, hosts?: string[]): string[];
32
33
  export declare function resolveResumePacking(options: Pick<ResumeOptions, 'splits'>): Packing;
34
+ export declare function resumeHostMismatch(session: Pick<SessionMeta, 'shortId' | 'machine'>, requestedHost: string, self?: string): string | null;
33
35
  /**
34
36
  * Decide which backend to launch into. Returns a concrete backend, `'inplace'`
35
37
  * (resume in the current process — no GUI/tmux available), or `'cancel'` (the
@@ -14,14 +14,15 @@ 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, 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
22
  import { spawn } from 'node:child_process';
23
- import { buildCanonicalResumeCommand } from '../lib/session/resume-command.js';
24
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';
25
26
  /** Opening more than this many live sessions at once asks for confirmation first. */
26
27
  export const CONFIRM_THRESHOLD = 5;
27
28
  export function registerSessionsResumeCommand(sessionsCmd) {
@@ -34,7 +35,7 @@ export function registerSessionsResumeCommand(sessionsCmd) {
34
35
  .option('--teams', 'Include team-spawned sessions (hidden by default)')
35
36
  .option('--since <time>', 'Only sessions newer than this (e.g., 2h, 7d, 4w, or ISO date)')
36
37
  .option('-n, --limit <n>', 'Maximum number of sessions to load into the picker', '200')
37
- .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')
38
39
  .option('--iterm', 'Force the iTerm backend')
39
40
  .option('--ghostty', 'Force the Ghostty backend')
40
41
  .option('--tmux', 'Force the tmux backend')
@@ -65,8 +66,8 @@ export function registerSessionsResumeCommand(sessionsCmd) {
65
66
  - Layout: one tab per session by default. --splits packs session pairs side by side in each tab.
66
67
  - Backend: auto-detected from the terminal you're in (iTerm / Ghostty / tmux); override with --iterm/--ghostty/--tmux/--vscodium.
67
68
  - --vscodium opens each session as an agent terminal tab in VSCodium via the swarm-ext extension (works with --host too).
68
- - --host <alias> resumes on a remote machine over the same SSH transport as 'sessions --host' (defaults to tmux).
69
- - 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>.
70
71
  `,
71
72
  });
72
73
  cmd.action(async (query, options) => {
@@ -127,17 +128,25 @@ async function sessionsResumeAction(query, options) {
127
128
  }
128
129
  if (!chosen || chosen.length === 0)
129
130
  return;
130
- // 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.
131
144
  const items = [];
132
145
  for (const s of chosen) {
133
- const command = buildCanonicalResumeCommand(s.id);
146
+ const command = buildSessionRecoveryCommand(s, !!options.host);
134
147
  const cwd = s.cwd && fs.existsSync(s.cwd) ? s.cwd : process.cwd();
135
148
  items.push({ session: s, cwd, command });
136
149
  }
137
- if (items.length === 0) {
138
- console.log(chalk.gray('Nothing resumable in the selection.'));
139
- return;
140
- }
141
150
  // 3. Resolve the backend (and host).
142
151
  const ctx = currentContext();
143
152
  const backend = await resolveBackend(options, ctx, items.length);
@@ -221,6 +230,12 @@ async function spawnCliInPlace(args) {
221
230
  export function resolveResumePacking(options) {
222
231
  return options.splits ? 'two-per-tab' : 'tabs';
223
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
+ }
224
239
  /**
225
240
  * Decide which backend to launch into. Returns a concrete backend, `'inplace'`
226
241
  * (resume in the current process — no GUI/tmux available), or `'cancel'` (the
@@ -573,6 +573,13 @@ export declare function handlePickedSession(picked: PickedSession): Promise<void
573
573
  * version-pinned launcher is genuinely missing.
574
574
  */
575
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[];
576
583
  /**
577
584
  * Map a resume argv to the spawn(command, args, {shell}) triple.
578
585
  *
@@ -594,6 +601,9 @@ interface AgentFilter {
594
601
  agent?: SessionAgentId;
595
602
  version?: string;
596
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;
597
607
  export declare function parseAgentFilter(agentName?: string): AgentFilter;
598
608
  /**
599
609
  * How a `sessions <query>` argument was resolved against the pool.
@@ -20,7 +20,7 @@ import { listProjectDefs, resolveProjectNameForCwd } from '../lib/projects.js';
20
20
  import ora from 'ora';
21
21
  import { SESSION_AGENTS } from '../lib/session/types.js';
22
22
  import { discoverArtifacts, readArtifact, resolveArtifact } from '../lib/session/artifacts.js';
23
- import { looksLikePath, toComparablePath, homeDir, needsWindowsShell, findExecutable, composeWin32CommandLine } from '../lib/platform/index.js';
23
+ import { looksLikePath, toComparablePath, homeDir, needsWindowsShell, composeWin32CommandLine } from '../lib/platform/index.js';
24
24
  import { getActiveSessions } from '../lib/session/active.js';
25
25
  import { enumerateGhosttyTabs, assignGhosttyTabs } from '../lib/session/ghostty-tabs.js';
26
26
  import { mapPanesToTargets, listClients } from '../lib/tmux/session.js';
@@ -45,6 +45,8 @@ import { getShimsDir } from '../lib/state.js';
45
45
  import { fuzzyMatch, FUZZY_PRESETS } from '../lib/fuzzy.js';
46
46
  import { resolveSessionAlias } from '../lib/session/actor-sidecar.js';
47
47
  import { resolveVersionAliasLoose } from '../lib/versions.js';
48
+ import { getAgentsInvocation } from '../lib/daemon.js';
49
+ import { sessionRecoveryRunArgs } from '../lib/session/recovery.js';
48
50
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
49
51
  import { sessionPicker, buildPreview, formatTodoCompact, githubRepoUrlFromCwd, } from './sessions-picker.js';
50
52
  import { setHelpSections } from '../lib/help.js';
@@ -3017,34 +3019,23 @@ export async function resumeSessionInPlace(session) {
3017
3019
  const cwd = session.cwd && fs.existsSync(session.cwd)
3018
3020
  ? session.cwd
3019
3021
  : process.cwd();
3020
- const resume = buildResumeCommand(session);
3021
- if (!resume) {
3022
- console.log(chalk.yellow(`Resume is not supported for ${session.agent} sessions yet. Showing summary instead.`));
3023
- await renderSession(session, 'summary', {});
3024
- return;
3025
- }
3022
+ const resume = buildSessionRecoveryCommand(session);
3026
3023
  console.log(chalk.gray(`Resuming: ${resume.join(' ')} (cwd: ${cwd})`));
3027
- // Resolve the (possibly version-pinned) launcher up front. On Windows the
3028
- // agent shim is a `.cmd`/`.ps1` and, under the shell needed to run it (see
3029
- // spawnResumeCommand), a missing command exits non-zero rather than emitting
3030
- // an ENOENT `error` event — so detect a removed version here instead of
3031
- // relying on that event, keeping the fallback working on every OS.
3032
- // `resume[0]` is an absolute alias path when one exists on disk, so only a bare
3033
- // name still needs a PATH lookup. Checking existsSync first is what keeps an
3034
- // isolated install (shims deliberately off PATH) out of the fallback.
3035
- const launcherFound = path.isAbsolute(resume[0])
3036
- ? fs.existsSync(resume[0])
3037
- : !!findExecutable(resume[0]);
3038
- if (!launcherFound && session.version) {
3039
- const fallback = buildFallbackCommand(session);
3040
- if (fallback) {
3041
- console.log(chalk.gray(`Version ${session.version} is not installed. Resuming with the current version instead...`));
3042
- await spawnResumeCommand(fallback, cwd);
3043
- return;
3044
- }
3045
- }
3046
3024
  await spawnResumeCommand(resume, cwd);
3047
3025
  }
3026
+ /**
3027
+ * Relaunch this CLI's one recovery path. The owning device resolves account
3028
+ * health and either performs exact-home native resume or same-harness
3029
+ * `/continue`; callers never guess which version home can see the transcript.
3030
+ * `portable` is for an SSH/terminal-engine command that executes on a peer.
3031
+ */
3032
+ export function buildSessionRecoveryCommand(session, portable = false) {
3033
+ const args = sessionRecoveryRunArgs(session);
3034
+ if (portable)
3035
+ return ['agents', ...args];
3036
+ const invocation = getAgentsInvocation(args);
3037
+ return [invocation.command, ...invocation.args];
3038
+ }
3048
3039
  /**
3049
3040
  * Map a resume argv to the spawn(command, args, {shell}) triple.
3050
3041
  *
@@ -3112,8 +3103,6 @@ function spawnResumeCommand(cmd, cwd) {
3112
3103
  * isolated HOME where the JSONL was written — regardless of which version is
3113
3104
  * currently the default. Falls back to the bare shim when version is unknown.
3114
3105
  *
3115
- * If the versioned binary is missing (version was removed), the ENOENT
3116
- * handler in handlePickedSession retries via buildFallbackCommand.
3117
3106
  */
3118
3107
  /**
3119
3108
  * The agent's own resume invocation, given whichever launcher we resolved.
@@ -3180,19 +3169,6 @@ export function buildResumeCommand(session) {
3180
3169
  return null;
3181
3170
  }
3182
3171
  }
3183
- /**
3184
- * Fallback when the pinned version really is gone: the same resume invocation
3185
- * against the current version.
3186
- *
3187
- * This used to spawn `<cli> "/continue <id>"`, feeding a slash command into the
3188
- * TUI as a prompt. Neither CLI has `/continue` — codex documents `/resume` — so
3189
- * the agent received an unrecognised command and the session was not resumed at
3190
- * all. Reusing resumeArgv keeps the two paths from drifting apart again.
3191
- */
3192
- function buildFallbackCommand(session) {
3193
- const cli = AGENTS[session.agent]?.cliCommand ?? session.agent;
3194
- return resumeArgv(session.agent, session.id, cli);
3195
- }
3196
3172
  // ---------------------------------------------------------------------------
3197
3173
  // Cloud session source (--cloud)
3198
3174
  // ---------------------------------------------------------------------------
@@ -3263,25 +3239,24 @@ async function runCloudSessions(query, options) {
3263
3239
  // Ensure the SessionMeta points at the local cache path for renderSession.
3264
3240
  await renderSession({ ...meta, filePath: cachedPath }, mode, filterOpts, options);
3265
3241
  }
3242
+ /** Resolve the harness portion of a sessions selector with the CLI's canonical
3243
+ * alias and single-typo rules. Kept pure so focus and the browser cannot drift. */
3244
+ export function resolveSessionAgentName(name) {
3245
+ const normalized = name.toLowerCase();
3246
+ if (SESSION_AGENTS.includes(normalized)) {
3247
+ return normalized;
3248
+ }
3249
+ const resolved = resolveAgentName(normalized);
3250
+ if (resolved && SESSION_AGENTS.includes(resolved)) {
3251
+ return resolved;
3252
+ }
3253
+ return fuzzyMatch(normalized, SESSION_AGENTS, FUZZY_PRESETS.agents);
3254
+ }
3266
3255
  export function parseAgentFilter(agentName) {
3267
3256
  if (!agentName)
3268
3257
  return {};
3269
3258
  const [name, version] = agentName.split('@', 2);
3270
- let agent = SESSION_AGENTS.includes(name)
3271
- ? name
3272
- : null;
3273
- if (!agent) {
3274
- // Aliases and single-typo corrections (cladue -> claude). SESSION_AGENTS
3275
- // includes ids (rush, hermes) that resolveAgentName doesn't know, so fall
3276
- // back to fuzzy-matching the session list directly.
3277
- const resolved = resolveAgentName(name);
3278
- if (resolved && SESSION_AGENTS.includes(resolved)) {
3279
- agent = resolved;
3280
- }
3281
- else {
3282
- agent = fuzzyMatch(name, SESSION_AGENTS, FUZZY_PRESETS.agents);
3283
- }
3284
- }
3259
+ const agent = resolveSessionAgentName(name);
3285
3260
  if (!agent) {
3286
3261
  console.error(chalk.red(`Unknown agent: ${name}. Use: ${SESSION_AGENTS.join(', ')}`));
3287
3262
  process.exit(1);
@@ -3930,7 +3905,7 @@ export function registerSessionsCommands(program) {
3930
3905
  .option('--opencode', 'Shorthand for --agent opencode')
3931
3906
  .option('--all', 'Widen every non-status filter to "all": every directory (not just this project) and all time (no window cap). Status filters like --active still compose; -a/--device/--since still narrow their axis.')
3932
3907
  .option('--unmanaged', "Also show sessions from your own ~/.<agent> installs (hidden once agents-cli manages that agent)")
3933
- .option('--teams', 'Include team-spawned sessions (hidden by default)')
3908
+ .option('--team, --teams', 'Include team-spawned sessions (hidden by default)')
3934
3909
  .option('--in-team <name>', "Only this team: the session that spawned it plus (with --teams) its teammates. Spans every directory and all time, since a team's worktrees and history sit outside the default window.")
3935
3910
  .option('--routine', 'Show only sessions archived from routine runs')
3936
3911
  .option('-p, --project <name>', 'Filter by project name (searches across all directories)')
@@ -3997,7 +3972,7 @@ export function registerSessionsCommands(program) {
3997
3972
  agents sessions --crashed
3998
3973
 
3999
3974
  # --- Session lifecycle (one verb per intent) ---
4000
- # Jump to a live session (attach its terminal, or open a tab + resume)
3975
+ # Focus a session (attach its living terminal, or recover an ended one)
4001
3976
  agents sessions focus a1b2c3d4
4002
3977
  # Attach only — never fork a copy (old: sessions go)
4003
3978
  agents sessions focus a1b2c3d4 --attach-only
@@ -4049,7 +4024,7 @@ export function registerSessionsCommands(program) {
4049
4024
  `,
4050
4025
  notes: `
4051
4026
  Session lifecycle (pick one verb — they are not synonyms):
4052
- focus [id] jump to a live session (attach, or open tab + resume)
4027
+ focus [selector] attach a living pane, or recover on the origin device
4053
4028
  focus [id] --attach-only attach only; never fork (replaces sessions go)
4054
4029
  detach <id> interactive → headless continuation
4055
4030
  attach <id> headless → interactive in this terminal
@@ -66,7 +66,7 @@ function printBackendNotes(backend) {
66
66
  console.log(chalk.gray('backend: keychain — macOS reads may ask for Touch ID or the device password.'));
67
67
  }
68
68
  else if (backend === 'file') {
69
- console.log(chalk.gray('backend: file — set AGENTS_SECRETS_PASSPHRASE for headless encrypted-file reads.'));
69
+ console.log(chalk.gray('backend: file — encrypted at rest; headless reads need no passphrase by default, via a machine-local key at ~/.agents/.secrets-key/passphrase.'));
70
70
  }
71
71
  else {
72
72
  console.log(chalk.gray('backend: vault — synced ~/.agents/vault.age storage; unlock it with agents login.'));