@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
@@ -13,9 +13,9 @@
13
13
  * why `--tmux`-wrapped launches are worth it for sessions you'll want back live.
14
14
  */
15
15
  import type { Command } from 'commander';
16
- import { type UnreachableFallback } from './go.js';
16
+ import { probeAttachRail, type AttachRailLiveness, type UnreachableFallback } from './go.js';
17
17
  import type { ActiveSession } from '../lib/session/active.js';
18
- import type { SessionMeta } from '../lib/session/types.js';
18
+ import { type SessionMeta } from '../lib/session/types.js';
19
19
  import { type LiveStatusFilter } from './sessions.js';
20
20
  import { openSurfaces, type Backend } from '../lib/terminal/index.js';
21
21
  /** Options for `sessions focus` — device/host scope + the `--active` live-state filters. */
@@ -24,6 +24,27 @@ export interface FocusOptions {
24
24
  attachOnly?: boolean;
25
25
  host?: string[];
26
26
  device?: string[];
27
+ active?: boolean;
28
+ agent?: string;
29
+ claude?: boolean;
30
+ codex?: boolean;
31
+ kimi?: boolean;
32
+ antigravity?: boolean;
33
+ grok?: boolean;
34
+ opencode?: boolean;
35
+ all?: boolean;
36
+ teams?: boolean;
37
+ inTeam?: string;
38
+ routine?: boolean;
39
+ project?: string;
40
+ skill?: string;
41
+ plugin?: string;
42
+ since?: string;
43
+ until?: string;
44
+ limit?: string;
45
+ favorites?: boolean;
46
+ unmanaged?: boolean;
47
+ sort?: string;
27
48
  working?: boolean;
28
49
  idle?: boolean;
29
50
  waiting?: boolean;
@@ -35,6 +56,10 @@ export interface FocusOptions {
35
56
  queued?: boolean;
36
57
  unknown?: boolean;
37
58
  }
59
+ /** Commander gives overlapping `sessions` flags to the parent command, even
60
+ * when they appear after `focus`. Fold only values explicitly provided there
61
+ * into the child options; child defaults (limit/sort) otherwise stay intact. */
62
+ export declare function inheritFocusOptions(child: FocusOptions, parent?: Command): FocusOptions;
38
63
  /** `--device` is an alias of `--host`; both are repeatable. Merge into one host list. */
39
64
  export declare function mergeFocusHosts(opts: FocusOptions): string[];
40
65
  /** Picker header that reflects the active filter + device, e.g. "Focus orphaned sessions on yosemite-s0:". */
@@ -85,7 +110,7 @@ export declare function tmuxAttachScript(mux: {
85
110
  * `resumeCommandFor` resolver (injected so the local version-pinned resume command
86
111
  * and the tests stay decoupled from `discoverSessions`).
87
112
  */
88
- export declare function planFocusSurface(s: ActiveSession, self: string, resumeCommandFor: (s: ActiveSession) => string[] | null): FocusSurfacePlan;
113
+ export declare function planFocusSurface(s: ActiveSession, self: string, resumeCommandFor: (s: ActiveSession) => string[] | null, rail?: AttachRailLiveness): FocusSurfacePlan;
89
114
  /** The engine seam — real `openSurfaces`, overridable in tests to assert the tab requests. */
90
115
  export type OpenSurfacesFn = typeof openSurfaces;
91
116
  /** Test seams for `openFocusTabs`: inject the engine boundary + force a backend. */
@@ -93,6 +118,12 @@ export interface OpenFocusTabsDeps {
93
118
  open?: OpenSurfacesFn;
94
119
  /** Skip `resolveBackend` (which needs a live terminal) when set — tests pass 'tmux'. */
95
120
  backend?: Backend | 'inplace';
121
+ /** Rich rows selected by the shared browser pipeline (including remote history). */
122
+ metas?: SessionMeta[];
123
+ /** Liveness boundary; production probes tmux, tests exercise planning deterministically. */
124
+ probe?: typeof probeAttachRail;
125
+ /** Strict focus mode: open living attach rails only; never recover a copy. */
126
+ attachOnly?: boolean;
96
127
  }
97
128
  /**
98
129
  * Open each selected live session as a tab: attach its live pane where one exists,
@@ -15,15 +15,42 @@
15
15
  import fs from 'node:fs';
16
16
  import chalk from 'chalk';
17
17
  import { confirm } from '@inquirer/prompts';
18
- import { gatherLiveTargets, pickLiveTarget, pickLiveTargets, jumpTo, refuseFallback } from './go.js';
19
- import { requestedLiveStatuses, resolveSessionMetadataValue } from './sessions.js';
20
- import { resolveBackend, CONFIRM_THRESHOLD, resumeSelectorInPlace } from './sessions-resume.js';
18
+ import { gatherLiveTargets, pickLiveTarget, pickLiveTargets, jumpTo, probeAttachRail, refuseFallback } from './go.js';
19
+ import { buildSessionRecoveryCommand, filterSessionsByQuery, formatLiveStatusHeadline, formatPickerLabel, pickerColumnsFor, resumeSessionInPlace, resolveSessionMetadataValue, resolveSessionAgentName, requestedLiveStatuses, } from './sessions.js';
20
+ import { resolveBackend, CONFIRM_THRESHOLD } from './sessions-resume.js';
21
+ import { runOnPeer } from '../lib/session/remote-list.js';
21
22
  import { discoverSessions } from '../lib/session/discover.js';
23
+ import { collectSessionCandidates, normalizeDeviceSeed } from './sessions-browser.js';
24
+ import { buildPreview } from './sessions-picker.js';
25
+ import { multiItemPicker } from '../lib/picker.js';
26
+ import { sessionRecoveryRunArgs } from '../lib/session/recovery.js';
22
27
  import { shellQuote, assertValidSshTarget } from '../lib/ssh-exec.js';
23
28
  import { openSurfaces, currentContext, availableBackends, detectCurrentBackend, } from '../lib/terminal/index.js';
24
- import { isInteractiveTerminal } from './utils.js';
29
+ import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
25
30
  import { setHelpSections } from '../lib/help.js';
26
- import { buildCanonicalResumeCommand } from '../lib/session/resume-command.js';
31
+ const INHERITED_FOCUS_OPTIONS = [
32
+ 'local', 'host', 'device', 'active', 'agent',
33
+ 'claude', 'codex', 'kimi', 'antigravity', 'grok', 'opencode',
34
+ 'all', 'teams', 'inTeam', 'routine', 'project', 'skill', 'plugin',
35
+ 'since', 'until', 'limit', 'favorites', 'unmanaged', 'sort',
36
+ 'working', 'idle', 'waiting', 'orphan', 'orphaned', 'crashed',
37
+ 'closed', 'abandoned', 'queued', 'unknown',
38
+ ];
39
+ /** Commander gives overlapping `sessions` flags to the parent command, even
40
+ * when they appear after `focus`. Fold only values explicitly provided there
41
+ * into the child options; child defaults (limit/sort) otherwise stay intact. */
42
+ export function inheritFocusOptions(child, parent) {
43
+ if (!parent)
44
+ return child;
45
+ const merged = { ...child };
46
+ for (const key of INHERITED_FOCUS_OPTIONS) {
47
+ const source = parent.getOptionValueSource(String(key));
48
+ if (source && source !== 'default') {
49
+ merged[key] = parent.getOptionValue(String(key));
50
+ }
51
+ }
52
+ return merged;
53
+ }
27
54
  /** `--device` is an alias of `--host`; both are repeatable. Merge into one host list. */
28
55
  export function mergeFocusHosts(opts) {
29
56
  return [...(opts.host ?? []), ...(opts.device ?? [])];
@@ -43,11 +70,32 @@ function statusWord(status) {
43
70
  export function registerFocusCommand(program) {
44
71
  const cmd = program
45
72
  .command('focus')
46
- .argument('[id]', 'Short/full session id to focus; omit for an interactive picker')
73
+ .argument('[selector]', 'Session id/prefix, agent@version, or topic/path search')
47
74
  .option('--local', 'Only this machine (skip the cross-host sweep)')
48
75
  .option('--attach-only', 'Attach only — never open a new tab / resume a copy (the old `go` behavior)')
49
76
  .option('-H, --host <target...>', 'Scope the picker to live sessions on these devices (host alias or user@host; repeatable)')
50
77
  .option('--device <target...>', 'Alias for --host (device alias from `agents devices`; repeatable)')
78
+ .option('--active', 'Only sessions present in the live roster')
79
+ .option('-a, --agent <agent>', 'Filter by harness and recorded version (for example claude@latest)')
80
+ .option('--claude', 'Shorthand for --agent claude')
81
+ .option('--codex', 'Shorthand for --agent codex')
82
+ .option('--kimi', 'Shorthand for --agent kimi')
83
+ .option('--antigravity', 'Shorthand for --agent antigravity')
84
+ .option('--grok', 'Shorthand for --agent grok')
85
+ .option('--opencode', 'Shorthand for --agent opencode')
86
+ .option('--all', 'Include every directory and all time')
87
+ .option('--teams', 'Include team-spawned sessions')
88
+ .option('--in-team <name>', 'Only one team lineage')
89
+ .option('--routine', 'Only routine-run sessions')
90
+ .option('-p, --project <name>', 'Only a named project')
91
+ .option('--skill <name>', 'Only sessions that invoked this skill')
92
+ .option('--plugin <name>', 'Only sessions that used this plugin')
93
+ .option('--since <time>', 'Only sessions newer than this (for example 7d)')
94
+ .option('--until <time>', 'Only sessions older than this timestamp')
95
+ .option('-n, --limit <n>', 'Maximum candidates to load', '500')
96
+ .option('--favorites', 'Only favorited sessions')
97
+ .option('--unmanaged', 'Also include native-home sessions outside managed versions')
98
+ .option('--sort <field>', 'Order candidates by recent, cost, or duration', 'recent')
51
99
  .option('--working', 'Only live sessions currently doing work')
52
100
  .option('--idle', 'Only live sessions that have stopped between turns')
53
101
  .option('--waiting', 'Only live sessions waiting on your input')
@@ -58,13 +106,13 @@ export function registerFocusCommand(program) {
58
106
  .option('--abandoned', 'Only sessions with no transcript progress for the abandonment window')
59
107
  .option('--queued', 'Only queued sessions that have not started running')
60
108
  .option('--unknown', 'Only sessions whose live state cannot be determined')
61
- .description('Focus live sessions multi-select and open each as a tab (attach its pane, or resume a copy)')
62
- .action(async (id, opts) => {
63
- await focusAction(id, opts);
109
+ .description('Focus sessions by id, harness/version, topic, device, or live state; attach living panes and recover ended ones')
110
+ .action(async (id, opts, command) => {
111
+ await focusAction(id, inheritFocusOptions(opts, command.parent ?? undefined));
64
112
  });
65
113
  setHelpSections(cmd, {
66
114
  examples: `
67
- # Jump to a live session (attach pane/tab, or open a new tab and resume)
115
+ # Focus a session directly (attach a living pane, otherwise recover it)
68
116
  agents sessions focus a1b2c3d4
69
117
 
70
118
  # Multi-select live sessions; each opens as a tab in this terminal
@@ -73,6 +121,9 @@ export function registerFocusCommand(program) {
73
121
  # Scope the picker to one device's orphaned sessions
74
122
  agents sessions focus --orphan --device yosemite-s0
75
123
 
124
+ # Resolve latest on yosemite-s0, then pick from that version's sessions
125
+ agents sessions focus claude@latest --device yosemite-s0
126
+
76
127
  # Attach only — refuse if nothing is joinable (old sessions go)
77
128
  agents sessions focus a1b2c3d4 --attach-only
78
129
 
@@ -81,11 +132,11 @@ export function registerFocusCommand(program) {
81
132
  `,
82
133
  notes: `
83
134
  - space toggles a session, enter opens the selected set; a single check + enter opens just one.
84
- - Each selected session opens as a new tab in the terminal you're in (Ghostty / iTerm / tmux, auto-detected).
85
- - A live tmux session is JOINED in the tab (a second client, no fork) local or remote over SSH; a session with no attach rail resumes a copy in the tab, reported never silently dropped.
86
- - --host/--device scopes the pool to those devices; the live-state filters (--orphan/--crashed/…) narrow by status and compose with the device scope.
135
+ - With no selector/filter, the picker shows the live fleet. An id focuses directly; agent@version and text selectors always show the preview picker.
136
+ - A living tmux pane is JOINED (a second client, no fork). Dead/missing panes recover on the origin device: exact healthy origin uses native resume; otherwise a healthy version of the same harness receives /continue <id>.
137
+ - --host/--device and the sessions-browser filters compose. latest/oldest resolve against each selected device's installed versions.
87
138
  Lifecycle siblings (not synonyms):
88
- focus live jump (default "take me there")
139
+ focus attach if alive, otherwise recover (default "take me there")
89
140
  focus --attach-only attach only; never fork (replaces go)
90
141
  detach / attach interactive ↔ headless presence
91
142
  resume multi-select history → tabs
@@ -106,38 +157,98 @@ export async function focusAction(id, opts) {
106
157
  const statuses = requestedLiveStatuses(opts);
107
158
  // A device scope needs the cross-host sweep; --local only wins when no host is named.
108
159
  const local = !!opts.local && hosts.length === 0;
109
- const { self, activeById } = await gatherLiveTargets(local, { hosts, statuses });
110
160
  const fallback = selectFallback(opts.attachOnly);
111
- if (id) {
112
- const outcome = await resolveSessionMetadataValue(id.trim(), { local, hosts });
113
- if (outcome.kind === 'partial') {
114
- console.error(chalk.red(`Could not resolve session while these devices were unavailable: ${outcome.failedPeers.join(', ')}`));
115
- process.exitCode = 2;
161
+ const agentSelector = focusAgentSelector(id, opts);
162
+ const textSelector = id && !agentSelector ? id : undefined;
163
+ const filtered = !!id || hasFocusFilters(opts, statuses);
164
+ // Preserve the fast live multi-picker for an unqualified `focus`. Every
165
+ // selector/filter path below uses the sessions browser's shared candidate
166
+ // pipeline and rich preview.
167
+ if (filtered) {
168
+ if (!isInteractiveTerminal() && !looksLikeIdentitySelector(textSelector)) {
169
+ console.error(chalk.red('focus selectors need an interactive terminal; pass a session id for direct focus.'));
170
+ process.exitCode = 1;
116
171
  return;
117
172
  }
118
- if (outcome.kind === 'ambiguous') {
119
- console.error(chalk.red(`"${id}" matches ${outcome.candidates.length} sessions. Pass a longer id or alias.`));
173
+ const limit = Number.parseInt(opts.limit ?? '500', 10);
174
+ const idLookup = looksLikeIdentitySelector(textSelector);
175
+ const sort = focusSort(opts.sort);
176
+ if (!sort) {
177
+ console.error(chalk.red(`Invalid sort: ${opts.sort}. Use recent, cost, or duration.`));
120
178
  process.exitCode = 1;
121
179
  return;
122
180
  }
123
- if (outcome.kind === 'not-found') {
124
- console.error(chalk.red(`No session matching "${id}".`));
125
- process.exitCode = 1;
181
+ const { sessions, liveById, self, unreachable } = await collectSessionCandidates({
182
+ running: opts.active === true || statuses.length > 0,
183
+ statuses,
184
+ agent: agentSelector ?? focusOptionAgent(opts),
185
+ teams: opts.teams === true,
186
+ team: opts.inTeam,
187
+ favorites: opts.favorites === true,
188
+ projectScope: opts.all || hosts.length > 0 || !!opts.project || idLookup ? 'all' : 'repo',
189
+ project: opts.project,
190
+ window: opts.since ?? (opts.all || idLookup ? undefined : '30d'),
191
+ until: opts.until,
192
+ routine: opts.routine === true,
193
+ skill: opts.skill,
194
+ plugin: opts.plugin,
195
+ limit: idLookup && limit === 500 ? 5000 : (Number.isFinite(limit) && limit > 0 ? limit : 500),
196
+ unmanaged: opts.unmanaged === true,
197
+ sort,
198
+ device: hosts.length === 1 ? normalizeDeviceSeed(hosts[0]) : undefined,
199
+ }, { local, hosts, includeLive: true });
200
+ if (unreachable.length > 0) {
201
+ console.error(chalk.yellow(`Unavailable devices: ${unreachable.join(', ')}`));
202
+ }
203
+ let exact = textSelector && looksLikeIdSelector(textSelector)
204
+ ? sessions.filter((session) => session.id.toLowerCase().startsWith(textSelector.toLowerCase()))
205
+ : [];
206
+ if (exact.length === 0 && textSelector && looksLikeIdentitySelector(textSelector)) {
207
+ const outcome = await resolveSessionMetadataValue(textSelector, { local, hosts });
208
+ if (outcome.kind === 'partial') {
209
+ console.error(chalk.red(`Could not resolve session while these devices were unavailable: ${outcome.failedPeers.join(', ')}`));
210
+ process.exitCode = 2;
211
+ return;
212
+ }
213
+ if (outcome.kind === 'ambiguous') {
214
+ console.error(chalk.red(`"${textSelector}" matches ${outcome.candidates.length} sessions. Pass a longer id or alias.`));
215
+ process.exitCode = 1;
216
+ return;
217
+ }
218
+ if (outcome.kind === 'not-found') {
219
+ console.error(chalk.red(`No session matching "${textSelector}".`));
220
+ process.exitCode = 1;
221
+ return;
222
+ }
223
+ const filteredMatch = sessions.find((session) => session.id === outcome.session.id);
224
+ if (!filteredMatch) {
225
+ console.error(chalk.red(`Session ${outcome.session.shortId} does not match the selected focus filters.`));
226
+ process.exitCode = 1;
227
+ return;
228
+ }
229
+ exact = [filteredMatch];
230
+ }
231
+ if (exact.length === 1) {
232
+ await focusResolvedSession(exact[0], liveById, self, fallback, opts.attachOnly === true);
126
233
  return;
127
234
  }
128
- const live = activeById.get(outcome.session.id);
129
- if (live && isAttachableLiveSession(live)) {
130
- await jumpTo(live, self, fallback);
235
+ if (exact.length > 1) {
236
+ console.error(chalk.red(`"${textSelector}" is ambiguous (${exact.length} sessions). Use more of the id.`));
237
+ process.exitCode = 1;
131
238
  return;
132
239
  }
133
- if (opts.attachOnly) {
134
- console.error(chalk.red(`${outcome.session.shortId} has no live terminal to attach.`));
240
+ if (!isInteractiveTerminal()) {
241
+ console.error(chalk.red(`No session matching "${textSelector}" in the selected scope.`));
135
242
  process.exitCode = 1;
136
243
  return;
137
244
  }
138
- await recoverResolvedSession(outcome.session);
245
+ const chosen = await pickFocusCandidates(sessions, liveById, textSelector);
246
+ if (chosen.length === 0)
247
+ return;
248
+ await openFocusTabs(chosen.map((meta) => liveById.get(meta.id) ?? activeFromMeta(meta)), self, { metas: chosen, attachOnly: opts.attachOnly === true });
139
249
  return;
140
250
  }
251
+ const { self, activeById } = await gatherLiveTargets(local, { hosts, statuses });
141
252
  if (!isInteractiveTerminal()) {
142
253
  console.error(chalk.red('focus needs an interactive terminal, or pass a session id.'));
143
254
  process.exitCode = 1;
@@ -167,21 +278,110 @@ export async function focusAction(id, opts) {
167
278
  export function isAttachableLiveSession(session) {
168
279
  return session.pidAlive !== false && session.status !== 'closed' && session.status !== 'crashed';
169
280
  }
170
- async function recoverResolvedSession(session) {
171
- const command = buildCanonicalResumeCommand(session.id);
172
- const cwd = session.cwd && fs.existsSync(session.cwd) ? session.cwd : process.cwd();
173
- const ctx = currentContext();
174
- const backend = detectCurrentBackend(ctx) ?? availableBackends(ctx)[0]?.id;
175
- if (!backend) {
176
- await resumeSelectorInPlace(session.id);
281
+ const FOCUS_AGENT_SHORTHANDS = ['claude', 'codex', 'kimi', 'antigravity', 'grok', 'opencode'];
282
+ function focusOptionAgent(opts) {
283
+ if (opts.agent)
284
+ return opts.agent;
285
+ return FOCUS_AGENT_SHORTHANDS.find((agent) => opts[agent] === true);
286
+ }
287
+ function focusAgentSelector(selector, opts) {
288
+ const candidate = selector ?? focusOptionAgent(opts);
289
+ if (!candidate)
290
+ return undefined;
291
+ const [name, version] = candidate.split('@', 2);
292
+ const agent = resolveSessionAgentName(name);
293
+ if (!agent)
294
+ return undefined;
295
+ return version === undefined ? agent : `${agent}@${version}`;
296
+ }
297
+ function looksLikeIdSelector(selector) {
298
+ return !!selector && /^[0-9a-f][0-9a-f-]{5,}$/i.test(selector);
299
+ }
300
+ function looksLikeIdentitySelector(selector) {
301
+ return !!selector && (/^[0-9a-f][0-9a-f-]{5,}$/i.test(selector) ||
302
+ /^ag-[a-z][a-z0-9-]*-[0-9a-f]{8}$/i.test(selector));
303
+ }
304
+ function hasFocusFilters(opts, statuses) {
305
+ return statuses.length > 0 || !!(opts.active || opts.local || opts.host?.length || opts.device?.length || focusOptionAgent(opts) ||
306
+ opts.all || opts.teams || opts.inTeam || opts.routine || opts.project || opts.skill || opts.plugin ||
307
+ opts.since || opts.until || opts.favorites || opts.unmanaged || (opts.sort && opts.sort !== 'recent'));
308
+ }
309
+ function focusSort(value) {
310
+ if (!value || value === 'recent')
311
+ return 'timestamp';
312
+ return value === 'cost' || value === 'duration' ? value : null;
313
+ }
314
+ async function pickFocusCandidates(sessions, liveById, initialSearch) {
315
+ if (sessions.length === 0) {
316
+ console.log(chalk.gray('No sessions match the focus filters.'));
317
+ return [];
318
+ }
319
+ const cols = {
320
+ ...pickerColumnsFor(sessions),
321
+ gutter: 6,
322
+ showStatus: liveById.size > 0,
323
+ showHost: liveById.size > 0,
324
+ };
325
+ try {
326
+ const chosen = await multiItemPicker({
327
+ message: 'Focus sessions:',
328
+ items: sessions,
329
+ filter: (query) => query.trim() ? filterSessionsByQuery(sessions, query) : sessions,
330
+ labelFor: (session, query) => formatPickerLabel(session, query, cols, undefined, liveById.get(session.id)?.host, false, liveById.get(session.id)),
331
+ keyFor: (session) => session.id,
332
+ buildPreview: (session) => {
333
+ const headline = formatLiveStatusHeadline(liveById.get(session.id));
334
+ const preview = buildPreview(session);
335
+ return headline ? `${headline}\n${preview}` : preview;
336
+ },
337
+ pageSize: 15,
338
+ initialSearch,
339
+ emptyMessage: 'No sessions match.',
340
+ enterHint: 'focus',
341
+ });
342
+ return chosen ?? [];
343
+ }
344
+ catch (err) {
345
+ if (isPromptCancelled(err))
346
+ return [];
347
+ throw err;
348
+ }
349
+ }
350
+ async function focusResolvedSession(meta, liveById, self, fallback, attachOnly) {
351
+ const active = liveById.get(meta.id);
352
+ if (active && isAttachableLiveSession(active)) {
353
+ await jumpTo(active, self, fallback);
177
354
  return;
178
355
  }
179
- console.log(chalk.gray(`${session.shortId} is not live — opening a ${backend} tab and resuming it.`));
180
- const [result] = await openSurfaces([{ cwd, command }], { backend, packing: 'tabs' });
181
- if (!result?.ok) {
182
- console.error(chalk.red(`Failed to open ${session.shortId}: ${result?.error ?? 'unknown error'}`));
356
+ if (attachOnly) {
357
+ console.log(chalk.yellow(`${meta.shortId} has no living process or pane to attach.`));
183
358
  process.exitCode = 1;
359
+ return;
360
+ }
361
+ const remote = meta.machine && meta.machine !== self ? meta.machine : undefined;
362
+ if (remote) {
363
+ console.log(chalk.gray(`Recovering ${meta.shortId} on ${remote}…`));
364
+ const rc = await runOnPeer(sessionRecoveryRunArgs(meta), remote, { tty: true });
365
+ if (rc === 'no-target') {
366
+ console.error(chalk.red(`Cannot recover ${meta.shortId}: ${remote} is unreachable.`));
367
+ process.exitCode = 1;
368
+ }
369
+ return;
184
370
  }
371
+ await resumeSessionInPlace(meta);
372
+ }
373
+ function activeFromMeta(meta) {
374
+ return {
375
+ context: 'headless',
376
+ kind: meta.agent,
377
+ sessionId: meta.id,
378
+ cwd: meta.cwd,
379
+ project: meta.project,
380
+ topic: meta.topic,
381
+ startedAtMs: Date.parse(meta.timestamp),
382
+ status: 'closed',
383
+ machine: meta.machine,
384
+ };
185
385
  }
186
386
  /** Human scope suffix for the empty-pool message, e.g. " (orphaned on yosemite-s0)". */
187
387
  function describeScope(statuses, hosts) {
@@ -201,7 +401,9 @@ function describeScope(statuses, hosts) {
201
401
  export function tmuxAttachScript(mux) {
202
402
  const sock = mux.socket ? `-S ${shellQuote(mux.socket)} ` : '';
203
403
  const p = shellQuote(mux.pane);
204
- return (`sess=$(tmux ${sock}display-message -pt ${p} '#{session_name}' 2>/dev/null); ` +
404
+ return (`dead=$(tmux ${sock}display-message -pt ${p} -p '#{pane_dead}' 2>/dev/null) || { echo 'agents: tmux pane is missing'; exit 42; }; ` +
405
+ `[ "$dead" = 0 ] || { echo 'agents: tmux pane is dead'; exit 42; }; ` +
406
+ `sess=$(tmux ${sock}display-message -pt ${p} '#{session_name}' 2>/dev/null); ` +
205
407
  `exec tmux ${sock}attach-session -t "\${sess:-${p}}"`);
206
408
  }
207
409
  /**
@@ -209,13 +411,13 @@ export function tmuxAttachScript(mux) {
209
411
  * `resumeCommandFor` resolver (injected so the local version-pinned resume command
210
412
  * and the tests stay decoupled from `discoverSessions`).
211
413
  */
212
- export function planFocusSurface(s, self, resumeCommandFor) {
414
+ export function planFocusSurface(s, self, resumeCommandFor, rail = { state: 'alive' }) {
213
415
  const remote = s.machine && s.machine !== self ? s.machine : undefined;
214
416
  const mux = s.provenance?.mux;
215
417
  const sid = shortId(s);
216
418
  // Join rail = tmux only (local or remote over SSH). A new tab attaching the live
217
419
  // tmux session is a second client: join, no fork.
218
- if (mux?.kind === 'tmux' && mux.pane) {
420
+ if (mux?.kind === 'tmux' && mux.pane && rail.state === 'alive') {
219
421
  const script = tmuxAttachScript({ socket: mux.socket, pane: mux.pane });
220
422
  if (remote) {
221
423
  assertValidSshTarget(remote);
@@ -227,7 +429,20 @@ export function planFocusSurface(s, self, resumeCommandFor) {
227
429
  }
228
430
  return { kind: 'attach', command: ['sh', '-c', shellQuote(script)], note: `attach ${mux.pane}` };
229
431
  }
230
- // No join rail → the canonical command resolves the owner device itself.
432
+ // No join rail → resume a copy (never a silent drop).
433
+ if (remote) {
434
+ // Recover ON the peer so health and installed versions are resolved where
435
+ // the transcript actually originated.
436
+ const command = resumeCommandFor(s);
437
+ if (!command)
438
+ return { kind: 'skip', note: `${sid} has no recovery command` };
439
+ assertValidSshTarget(remote);
440
+ return {
441
+ kind: 'resume',
442
+ command: ['ssh', '-tt', remote, shellQuote(command.map(shellQuote).join(' '))],
443
+ note: `recover on ${remote} (no living tmux pane to join)`,
444
+ };
445
+ }
231
446
  const cmd = resumeCommandFor(s);
232
447
  if (!cmd)
233
448
  return { kind: 'skip', note: `${sid} — ${s.kind} sessions can't be resumed, and it has no live tmux to join` };
@@ -239,11 +454,27 @@ export function planFocusSurface(s, self, resumeCommandFor) {
239
454
  */
240
455
  export async function openFocusTabs(targets, self, deps = {}) {
241
456
  const open = deps.open ?? openSurfaces;
242
- // The canonical resume command resolves metadata itself; active cwd is enough
243
- // to place the terminal without another transcript scan.
244
- const byId = new Map();
245
- const resumeCommandFor = (s) => s.sessionId ? buildCanonicalResumeCommand(s.sessionId) : null;
246
- const planned = targets.map((s) => ({ s, plan: planFocusSurface(s, self, resumeCommandFor) }));
457
+ const probe = deps.probe ?? probeAttachRail;
458
+ // Resolve rich indexed metas ONCE so local resume commands stay version-pinned.
459
+ let byId = new Map((deps.metas ?? []).map((m) => [m.id, m]));
460
+ if (!deps.metas) {
461
+ try {
462
+ const metas = await discoverSessions({ all: true, since: '90d', limit: 2000 });
463
+ byId = new Map(metas.map((m) => [m.id, m]));
464
+ }
465
+ catch { /* fall back to synthesized metas per session */ }
466
+ }
467
+ const metaFor = (s) => byId.get(s.sessionId ?? '') ?? metaFromActive(s);
468
+ const resumeCommandFor = (s) => {
469
+ if (deps.attachOnly)
470
+ return null;
471
+ const remote = !!s.machine && s.machine !== self;
472
+ return buildSessionRecoveryCommand(metaFor(s), remote);
473
+ };
474
+ const planned = await Promise.all(targets.map(async (s) => {
475
+ const rail = await probe(s, self);
476
+ return { s, plan: planFocusSurface(s, self, resumeCommandFor, rail) };
477
+ }));
247
478
  // Skips are reported, never silently dropped.
248
479
  for (const p of planned)
249
480
  if (p.plan.kind === 'skip')
@@ -268,7 +499,7 @@ export async function openFocusTabs(targets, self, deps = {}) {
268
499
  if (openable.length > 1) {
269
500
  console.log(chalk.yellow(`This terminal can't open tabs — jumping to the first; open in Ghostty/iTerm/tmux to focus several at once.`));
270
501
  }
271
- await jumpTo(openable[0].s, self, selectFallback(false));
502
+ await jumpTo(openable[0].s, self, selectFallback(deps.attachOnly));
272
503
  return;
273
504
  }
274
505
  console.log(chalk.gray(`Opening ${openable.length} session${openable.length === 1 ? '' : 's'} in ${backend} (tabs)…`));
@@ -323,21 +554,34 @@ async function richMetaById(id) {
323
554
  * Note: for a session that's still mid-run, this opens a COPY (the original keeps going);
324
555
  * only tmux can *join* a live one without forking (see the header).
325
556
  */
326
- const resumeInNewTab = async (s) => {
557
+ const resumeInNewTab = async (s, remote) => {
327
558
  const id = s.sessionId ?? '';
328
559
  if (!id) {
329
560
  console.log(chalk.yellow('This session has no id to resume.'));
330
561
  return;
331
562
  }
332
- // The canonical command resolves the source device and pinned launch metadata.
563
+ // Remote: the transcript + pinned version live on the peer, so resume THERE over SSH.
564
+ // runOnPeer runs `agents sessions resume <id>` with a real TTY (`-tt`) in the foreground —
565
+ // it actually delivers you to the session (the peer picks the right version + HOME).
566
+ if (remote) {
567
+ console.log(chalk.gray(`${shortId(s)} has no live terminal on ${remote} — resuming it there over SSH…`));
568
+ const rc = await runOnPeer(sessionRecoveryRunArgs({ id }), remote, { tty: true });
569
+ if (rc === 'no-target') {
570
+ console.log(chalk.red(`${remote} isn't reachable as a device. Try: agents devices sync`));
571
+ console.log(chalk.gray(` recovery must run on ${remote}, where the indexed session originated`));
572
+ }
573
+ return;
574
+ }
575
+ // Local: resume in a new tab. Use the indexed meta so the version-pinned binary
576
+ // resumes in the same isolated HOME the transcript was written in.
333
577
  const meta = (await richMetaById(id)) ?? metaFromActive(s);
334
- const command = buildCanonicalResumeCommand(meta.id);
578
+ const command = buildSessionRecoveryCommand(meta);
335
579
  const cwd = meta.cwd && fs.existsSync(meta.cwd) ? meta.cwd : process.cwd();
336
580
  const ctx = currentContext();
337
581
  const backend = detectCurrentBackend(ctx) ?? availableBackends(ctx)[0]?.id;
338
582
  if (!backend) {
339
583
  // No tab-capable surface (off-macOS, not in tmux) — resume in this process.
340
- await resumeSelectorInPlace(meta.id);
584
+ await resumeSessionInPlace(meta);
341
585
  return;
342
586
  }
343
587
  console.log(chalk.gray(`${shortId(s)} has no live terminal to attach — opening a new ${backend} tab and resuming a copy.`));
@@ -78,6 +78,19 @@ export declare function describeWhere(s: ActiveSession, self: string): Where;
78
78
  * the session lives on another machine, else undefined.
79
79
  */
80
80
  export type UnreachableFallback = (s: ActiveSession, remote: string | undefined) => void | Promise<void>;
81
- /** Default (attach-only): open a login shell on the remote, or refuse locally. */
81
+ export type AttachRailLiveness = {
82
+ state: 'alive';
83
+ } | {
84
+ state: 'dead';
85
+ exitStatus?: number;
86
+ } | {
87
+ state: 'missing';
88
+ };
89
+ /** Probe the tmux process, not just retained provenance. This is deliberately
90
+ * called immediately before an attach so remain-on-exit panes cannot masquerade
91
+ * as living agent sessions. */
92
+ export declare function probeAttachRail(s: ActiveSession, self: string): Promise<AttachRailLiveness>;
93
+ /** Strict attach-only fallback: no pane means no attach. Never open a shell or
94
+ * start recovery, because both would violate the caller's no-fork intent. */
82
95
  export declare function refuseFallback(s: ActiveSession, remote: string | undefined): Promise<void>;
83
96
  export declare function jumpTo(s: ActiveSession, self: string, fallback?: UnreachableFallback): Promise<void>;