@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
@@ -14,28 +14,116 @@
14
14
  */
15
15
  import fs from 'node:fs';
16
16
  import chalk from 'chalk';
17
- import { gatherLiveTargets, pickLiveTarget, jumpTo, refuseFallback } from './go.js';
18
- import { buildResumeCommand, resumeSessionInPlace } from './sessions.js';
17
+ import { confirm } from '@inquirer/prompts';
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';
19
21
  import { runOnPeer } from '../lib/session/remote-list.js';
20
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';
27
+ import { shellQuote, assertValidSshTarget } from '../lib/ssh-exec.js';
21
28
  import { openSurfaces, currentContext, availableBackends, detectCurrentBackend, } from '../lib/terminal/index.js';
22
- import { isInteractiveTerminal } from './utils.js';
29
+ import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
23
30
  import { setHelpSections } from '../lib/help.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
+ }
54
+ /** `--device` is an alias of `--host`; both are repeatable. Merge into one host list. */
55
+ export function mergeFocusHosts(opts) {
56
+ return [...(opts.host ?? []), ...(opts.device ?? [])];
57
+ }
58
+ /** Picker header that reflects the active filter + device, e.g. "Focus orphaned sessions on yosemite-s0:". */
59
+ export function focusHeader(statuses, hosts) {
60
+ const where = hosts.length ? ` on ${hosts.join(', ')}` : '';
61
+ if (statuses.length === 0)
62
+ return `Focus a live session${where}:`;
63
+ const word = statuses.length === 1 ? statusWord(statuses[0]) : 'filtered';
64
+ return `Focus ${word} sessions${where}:`;
65
+ }
66
+ /** The adjective shown in the header for a single live-state filter. */
67
+ function statusWord(status) {
68
+ return status === 'orphaned' ? 'orphaned' : status;
69
+ }
24
70
  export function registerFocusCommand(program) {
25
71
  const cmd = program
26
72
  .command('focus')
27
- .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')
28
74
  .option('--local', 'Only this machine (skip the cross-host sweep)')
29
75
  .option('--attach-only', 'Attach only — never open a new tab / resume a copy (the old `go` behavior)')
30
- .description('Focus a live session attach its terminal, or open a new tab and resume it')
31
- .action(async (id, opts) => {
32
- await focusAction(id, opts);
76
+ .option('-H, --host <target...>', 'Scope the picker to live sessions on these devices (host alias or user@host; repeatable)')
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')
99
+ .option('--working', 'Only live sessions currently doing work')
100
+ .option('--idle', 'Only live sessions that have stopped between turns')
101
+ .option('--waiting', 'Only live sessions waiting on your input')
102
+ .option('--orphan', 'Only sessions whose process outlived its terminal client')
103
+ .option('--orphaned', 'Alias for --orphan')
104
+ .option('--crashed', 'Only sessions whose terminal disappeared with the process')
105
+ .option('--closed', 'Only recently observed sessions whose process exited normally')
106
+ .option('--abandoned', 'Only sessions with no transcript progress for the abandonment window')
107
+ .option('--queued', 'Only queued sessions that have not started running')
108
+ .option('--unknown', 'Only sessions whose live state cannot be determined')
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));
33
112
  });
34
113
  setHelpSections(cmd, {
35
114
  examples: `
36
- # 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)
37
116
  agents sessions focus a1b2c3d4
38
117
 
118
+ # Multi-select live sessions; each opens as a tab in this terminal
119
+ agents sessions focus
120
+
121
+ # Scope the picker to one device's orphaned sessions
122
+ agents sessions focus --orphan --device yosemite-s0
123
+
124
+ # Resolve latest on yosemite-s0, then pick from that version's sessions
125
+ agents sessions focus claude@latest --device yosemite-s0
126
+
39
127
  # Attach only — refuse if nothing is joinable (old sessions go)
40
128
  agents sessions focus a1b2c3d4 --attach-only
41
129
 
@@ -43,8 +131,12 @@ export function registerFocusCommand(program) {
43
131
  agents sessions focus --local
44
132
  `,
45
133
  notes: `
134
+ - space toggles a session, enter opens the selected set; a single check + enter opens just one.
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.
46
138
  Lifecycle siblings (not synonyms):
47
- focus live jump (default "take me there")
139
+ focus attach if alive, otherwise recover (default "take me there")
48
140
  focus --attach-only attach only; never fork (replaces go)
49
141
  detach / attach interactive ↔ headless presence
50
142
  resume multi-select history → tabs
@@ -61,39 +153,374 @@ export function selectFallback(attachOnly) {
61
153
  return attachOnly ? refuseFallback : resumeInNewTab;
62
154
  }
63
155
  export async function focusAction(id, opts) {
64
- const { self, activeById } = await gatherLiveTargets(!!opts.local);
156
+ const hosts = mergeFocusHosts(opts);
157
+ const statuses = requestedLiveStatuses(opts);
158
+ // A device scope needs the cross-host sweep; --local only wins when no host is named.
159
+ const local = !!opts.local && hosts.length === 0;
65
160
  const fallback = selectFallback(opts.attachOnly);
66
- if (id) {
67
- const q = id.toLowerCase();
68
- const matches = [...activeById.values()].filter((s) => s.sessionId.toLowerCase().startsWith(q));
69
- if (matches.length === 1) {
70
- await jumpTo(matches[0], self, fallback);
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;
71
171
  return;
72
172
  }
73
- if (matches.length > 1) {
74
- console.error(chalk.red(`"${id}" is ambiguous (${matches.length} live matches). Use more of the id.`));
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.`));
75
178
  process.exitCode = 1;
76
179
  return;
77
180
  }
78
- // Not live it's a past session; resume is the right tool (multi-select + placement).
79
- console.log(chalk.yellow(`No live session matching "${id}".`) +
80
- chalk.gray(`\nTo resume a past session: agents sessions resume ${id}`));
81
- 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);
233
+ return;
234
+ }
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;
238
+ return;
239
+ }
240
+ if (!isInteractiveTerminal()) {
241
+ console.error(chalk.red(`No session matching "${textSelector}" in the selected scope.`));
242
+ process.exitCode = 1;
243
+ return;
244
+ }
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 });
82
249
  return;
83
250
  }
251
+ const { self, activeById } = await gatherLiveTargets(local, { hosts, statuses });
84
252
  if (!isInteractiveTerminal()) {
85
253
  console.error(chalk.red('focus needs an interactive terminal, or pass a session id.'));
86
254
  process.exitCode = 1;
87
255
  return;
88
256
  }
89
257
  if (activeById.size === 0) {
90
- console.log(chalk.gray('No live sessions to focus. To resume a past one: agents sessions resume'));
258
+ const scope = describeScope(statuses, hosts);
259
+ console.log(chalk.gray(`No live sessions to focus${scope}. To resume a past one: agents sessions resume`));
260
+ return;
261
+ }
262
+ const header = focusHeader(statuses, hosts);
263
+ // --attach-only keeps the old `go` single-jump: pick one, attach it in place (or refuse).
264
+ if (opts.attachOnly) {
265
+ const target = await pickLiveTarget(activeById, self, header, 'focus');
266
+ if (!target)
267
+ return;
268
+ await jumpTo(target, self, fallback);
269
+ return;
270
+ }
271
+ // Default: multi-select → open each selected session as a tab in this terminal.
272
+ const targets = await pickLiveTargets(activeById, self, header);
273
+ if (targets.length === 0)
274
+ return;
275
+ await openFocusTabs(targets, self);
276
+ }
277
+ /** A retained pane is not attachable merely because tmux can still display it. */
278
+ export function isAttachableLiveSession(session) {
279
+ return session.pidAlive !== false && session.status !== 'closed' && session.status !== 'crashed';
280
+ }
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);
91
354
  return;
92
355
  }
93
- const target = await pickLiveTarget(activeById, self, 'Focus a live session:', 'focus');
94
- if (!target)
356
+ if (attachOnly) {
357
+ console.log(chalk.yellow(`${meta.shortId} has no living process or pane to attach.`));
358
+ process.exitCode = 1;
95
359
  return;
96
- await jumpTo(target, self, fallback);
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;
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
+ };
385
+ }
386
+ /** Human scope suffix for the empty-pool message, e.g. " (orphaned on yosemite-s0)". */
387
+ function describeScope(statuses, hosts) {
388
+ const parts = [];
389
+ if (statuses.length)
390
+ parts.push(statuses.map(statusWord).join('/'));
391
+ if (hosts.length)
392
+ parts.push(`on ${hosts.join(', ')}`);
393
+ return parts.length ? ` (${parts.join(' ')})` : '';
394
+ }
395
+ /**
396
+ * Shell that resolves a tmux pane to its session and attaches it — the exact form
397
+ * `jumpTo` uses, minus the pre-select-window nicety, so a batch tab joins the live
398
+ * session (a second client) without forking. Reused for local and (wrapped in ssh)
399
+ * remote panes.
400
+ */
401
+ export function tmuxAttachScript(mux) {
402
+ const sock = mux.socket ? `-S ${shellQuote(mux.socket)} ` : '';
403
+ const p = shellQuote(mux.pane);
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); ` +
407
+ `exec tmux ${sock}attach-session -t "\${sess:-${p}}"`);
408
+ }
409
+ /**
410
+ * Decide how one live session opens as a tab. Pure over the session + a
411
+ * `resumeCommandFor` resolver (injected so the local version-pinned resume command
412
+ * and the tests stay decoupled from `discoverSessions`).
413
+ */
414
+ export function planFocusSurface(s, self, resumeCommandFor, rail = { state: 'alive' }) {
415
+ const remote = s.machine && s.machine !== self ? s.machine : undefined;
416
+ const mux = s.provenance?.mux;
417
+ const sid = shortId(s);
418
+ // Join rail = tmux only (local or remote over SSH). A new tab attaching the live
419
+ // tmux session is a second client: join, no fork.
420
+ if (mux?.kind === 'tmux' && mux.pane && rail.state === 'alive') {
421
+ const script = tmuxAttachScript({ socket: mux.socket, pane: mux.pane });
422
+ if (remote) {
423
+ assertValidSshTarget(remote);
424
+ return {
425
+ kind: 'attach',
426
+ command: ['ssh', '-tt', remote, shellQuote(script)],
427
+ note: `attach ${mux.pane} on ${remote}`,
428
+ };
429
+ }
430
+ return { kind: 'attach', command: ['sh', '-c', shellQuote(script)], note: `attach ${mux.pane}` };
431
+ }
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
+ }
446
+ const cmd = resumeCommandFor(s);
447
+ if (!cmd)
448
+ return { kind: 'skip', note: `${sid} — ${s.kind} sessions can't be resumed, and it has no live tmux to join` };
449
+ return { kind: 'resume', command: cmd, note: 'resume a copy (no live tmux to join)' };
450
+ }
451
+ /**
452
+ * Open each selected live session as a tab: attach its live pane where one exists,
453
+ * else resume a copy. Reuses `resume`'s backend resolution + flood guard.
454
+ */
455
+ export async function openFocusTabs(targets, self, deps = {}) {
456
+ const open = deps.open ?? openSurfaces;
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
+ }));
478
+ // Skips are reported, never silently dropped.
479
+ for (const p of planned)
480
+ if (p.plan.kind === 'skip')
481
+ console.log(chalk.yellow(` skip ${p.plan.note}`));
482
+ const openable = planned.filter((p) => p.plan.kind !== 'skip');
483
+ if (openable.length === 0) {
484
+ console.log(chalk.gray('Nothing to open in the selection.'));
485
+ return;
486
+ }
487
+ const backend = deps.backend ?? (await resolveBackend({}, currentContext(), openable.length));
488
+ if (backend === 'cancel')
489
+ return;
490
+ // Guard against opening a flood of live agents at once.
491
+ if (openable.length > CONFIRM_THRESHOLD) {
492
+ const proceed = await confirm({ message: `Open ${openable.length} sessions at once?`, default: false }).catch(() => false);
493
+ if (!proceed)
494
+ return;
495
+ }
496
+ // No tab-capable terminal (off-macOS, not in tmux): fall back to the single
497
+ // foreground jump for the first, and say the rest need a tab-capable terminal.
498
+ if (backend === 'inplace') {
499
+ if (openable.length > 1) {
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.`));
501
+ }
502
+ await jumpTo(openable[0].s, self, selectFallback(deps.attachOnly));
503
+ return;
504
+ }
505
+ console.log(chalk.gray(`Opening ${openable.length} session${openable.length === 1 ? '' : 's'} in ${backend} (tabs)…`));
506
+ const results = await open(openable.map((p) => ({ cwd: cwdFor(p.s, byId), command: p.plan.command })), { backend, packing: 'tabs' });
507
+ let opened = 0;
508
+ results.forEach((r, i) => {
509
+ const p = openable[i];
510
+ if (r.ok) {
511
+ opened++;
512
+ console.log(chalk.green(` opened ${shortId(p.s)}`) + chalk.gray(` — tab — (${p.plan.note})`));
513
+ }
514
+ else {
515
+ console.log(chalk.red(` failed ${shortId(p.s)} — ${r.error}`));
516
+ }
517
+ });
518
+ console.log(chalk.gray(`\nOpened ${opened}/${openable.length} in ${backend}.`));
519
+ }
520
+ /** A real cwd for the tab: the session's indexed cwd if it still exists, else here. */
521
+ function cwdFor(s, byId) {
522
+ const cwd = byId.get(s.sessionId ?? '')?.cwd ?? s.cwd;
523
+ return cwd && fs.existsSync(cwd) ? cwd : process.cwd();
97
524
  }
98
525
  function shortId(s) {
99
526
  return (s.sessionId ?? '').slice(0, 8) || '-';
@@ -138,21 +565,17 @@ const resumeInNewTab = async (s, remote) => {
138
565
  // it actually delivers you to the session (the peer picks the right version + HOME).
139
566
  if (remote) {
140
567
  console.log(chalk.gray(`${shortId(s)} has no live terminal on ${remote} — resuming it there over SSH…`));
141
- const rc = await runOnPeer(['sessions', 'resume', id], remote, { tty: true });
568
+ const rc = await runOnPeer(sessionRecoveryRunArgs({ id }), remote, { tty: true });
142
569
  if (rc === 'no-target') {
143
570
  console.log(chalk.red(`${remote} isn't reachable as a device. Try: agents devices sync`));
144
- console.log(chalk.gray(` or run it yourself: ssh ${remote} 'agents sessions resume ${shortId(s)}'`));
571
+ console.log(chalk.gray(` recovery must run on ${remote}, where the indexed session originated`));
145
572
  }
146
573
  return;
147
574
  }
148
575
  // Local: resume in a new tab. Use the indexed meta so the version-pinned binary
149
576
  // resumes in the same isolated HOME the transcript was written in.
150
577
  const meta = (await richMetaById(id)) ?? metaFromActive(s);
151
- const command = buildResumeCommand(meta);
152
- if (!command) {
153
- console.log(chalk.yellow(`${meta.shortId} — ${meta.agent} sessions aren't resumable, so there's no way to reopen it.`));
154
- return;
155
- }
578
+ const command = buildSessionRecoveryCommand(meta);
156
579
  const cwd = meta.cwd && fs.existsSync(meta.cwd) ? meta.cwd : process.cwd();
157
580
  const ctx = currentContext();
158
581
  const backend = detectCurrentBackend(ctx) ?? availableBackends(ctx)[0]?.id;
@@ -1,9 +1,27 @@
1
1
  /**
2
- * `agents fork <session>` — branch an existing conversation into a new,
2
+ * `agents sessions fork <session>` — branch an existing conversation into a new,
3
3
  * independent session you can continue separately. The original is untouched.
4
+ * Also exposed as the hidden top-level alias `agents fork` (back-compat).
4
5
  *
5
6
  * Thin command layer; the copy/register logic lives in `lib/session/fork.ts`.
6
7
  */
7
8
  import type { Command } from 'commander';
8
- /** Register the top-level `agents fork` command. */
9
+ interface ForkOptions {
10
+ name?: string;
11
+ }
12
+ /**
13
+ * Resolve the source session, copy it under a fresh id, and print how to
14
+ * continue the fork. Shared by `agents sessions fork` and the `agents fork` alias.
15
+ */
16
+ export declare function runFork(sessionArg: string, options: ForkOptions): Promise<void>;
17
+ /**
18
+ * Register `agents sessions fork <session>` — the canonical surface (fork is a
19
+ * session operation, so it lives under the `sessions` group).
20
+ */
21
+ export declare function registerSessionsForkCommand(sessionsCmd: Command): void;
22
+ /**
23
+ * Register the hidden top-level `agents fork` alias. Kept working for back-compat
24
+ * and muscle memory; the canonical, discoverable surface is `agents sessions fork`.
25
+ */
9
26
  export declare function registerForkCommand(program: Command): void;
27
+ export {};