@phnx-labs/agents-cli 1.22.22 → 1.22.23

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 (91) hide show
  1. package/CHANGELOG.md +209 -0
  2. package/README.md +8 -2
  3. package/dist/commands/doctor.js +15 -7
  4. package/dist/commands/exec.js +20 -6
  5. package/dist/commands/focus.d.ts +76 -4
  6. package/dist/commands/focus.js +219 -40
  7. package/dist/commands/fork.d.ts +20 -2
  8. package/dist/commands/fork.js +91 -64
  9. package/dist/commands/go.d.ts +25 -0
  10. package/dist/commands/go.js +63 -2
  11. package/dist/commands/harness-wizard.d.ts +206 -0
  12. package/dist/commands/harness-wizard.js +403 -0
  13. package/dist/commands/harness.d.ts +12 -0
  14. package/dist/commands/harness.js +97 -101
  15. package/dist/commands/resume.js +10 -3
  16. package/dist/commands/secrets.js +25 -30
  17. package/dist/commands/sessions-resume.d.ts +21 -3
  18. package/dist/commands/sessions-resume.js +50 -11
  19. package/dist/commands/sessions.d.ts +81 -5
  20. package/dist/commands/sessions.js +325 -66
  21. package/dist/commands/watchdog.js +13 -2
  22. package/dist/lib/agents.d.ts +1 -1
  23. package/dist/lib/agents.js +132 -0
  24. package/dist/lib/codex-policy.d.ts +17 -0
  25. package/dist/lib/codex-policy.js +48 -0
  26. package/dist/lib/crabbox/lease.d.ts +25 -0
  27. package/dist/lib/crabbox/lease.js +62 -0
  28. package/dist/lib/daemon.js +70 -0
  29. package/dist/lib/exec.d.ts +4 -0
  30. package/dist/lib/exec.js +88 -54
  31. package/dist/lib/feed-broadcast.d.ts +1 -20
  32. package/dist/lib/feed-broadcast.js +31 -1
  33. package/dist/lib/hooks.js +12 -2
  34. package/dist/lib/mcp.js +44 -0
  35. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +1 -5
  36. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  37. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -15
  38. package/dist/lib/models.d.ts +0 -5
  39. package/dist/lib/models.js +48 -0
  40. package/dist/lib/plugin-marketplace.js +9 -0
  41. package/dist/lib/pricing/prices.json +119 -92
  42. package/dist/lib/pricing/table.js +13 -0
  43. package/dist/lib/remote-agents-json.d.ts +29 -1
  44. package/dist/lib/remote-agents-json.js +47 -10
  45. package/dist/lib/resources/mcp.js +2 -0
  46. package/dist/lib/resources/permissions.js +3 -0
  47. package/dist/lib/resources/types.d.ts +2 -1
  48. package/dist/lib/runner.js +28 -19
  49. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  50. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -2
  51. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  52. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -13
  53. package/dist/lib/secrets/index.d.ts +15 -1
  54. package/dist/lib/secrets/index.js +118 -49
  55. package/dist/lib/secrets/reaper.d.ts +87 -0
  56. package/dist/lib/secrets/reaper.js +184 -0
  57. package/dist/lib/secrets/remote.d.ts +29 -0
  58. package/dist/lib/secrets/remote.js +37 -1
  59. package/dist/lib/session/active.d.ts +36 -1
  60. package/dist/lib/session/active.js +60 -19
  61. package/dist/lib/session/actor-sidecar.d.ts +14 -0
  62. package/dist/lib/session/actor-sidecar.js +67 -4
  63. package/dist/lib/session/db.d.ts +1 -1
  64. package/dist/lib/session/db.js +32 -1
  65. package/dist/lib/session/discover.js +168 -0
  66. package/dist/lib/session/parse.d.ts +10 -0
  67. package/dist/lib/session/parse.js +98 -0
  68. package/dist/lib/session/remote-list.d.ts +10 -1
  69. package/dist/lib/session/remote-list.js +2 -8
  70. package/dist/lib/session/remote.d.ts +57 -3
  71. package/dist/lib/session/remote.js +90 -26
  72. package/dist/lib/session/resume-command.d.ts +6 -0
  73. package/dist/lib/session/resume-command.js +8 -0
  74. package/dist/lib/session/session-cache.d.ts +173 -0
  75. package/dist/lib/session/session-cache.js +399 -0
  76. package/dist/lib/session/types.d.ts +1 -1
  77. package/dist/lib/session/types.js +1 -1
  78. package/dist/lib/session/width.d.ts +1 -1
  79. package/dist/lib/session/width.js +12 -2
  80. package/dist/lib/shims.d.ts +2 -2
  81. package/dist/lib/shims.js +40 -5
  82. package/dist/lib/staleness/writers/hooks.js +1 -1
  83. package/dist/lib/types.d.ts +1 -1
  84. package/dist/lib/usage.d.ts +13 -0
  85. package/dist/lib/usage.js +215 -0
  86. package/dist/lib/versions.js +13 -2
  87. package/package.json +1 -1
  88. package/dist/bin/agents +0 -0
  89. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  90. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  91. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
@@ -14,20 +14,51 @@
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';
19
- import { runOnPeer } from '../lib/session/remote-list.js';
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';
20
21
  import { discoverSessions } from '../lib/session/discover.js';
22
+ import { shellQuote, assertValidSshTarget } from '../lib/ssh-exec.js';
21
23
  import { openSurfaces, currentContext, availableBackends, detectCurrentBackend, } from '../lib/terminal/index.js';
22
24
  import { isInteractiveTerminal } from './utils.js';
23
25
  import { setHelpSections } from '../lib/help.js';
26
+ import { buildCanonicalResumeCommand } from '../lib/session/resume-command.js';
27
+ /** `--device` is an alias of `--host`; both are repeatable. Merge into one host list. */
28
+ export function mergeFocusHosts(opts) {
29
+ return [...(opts.host ?? []), ...(opts.device ?? [])];
30
+ }
31
+ /** Picker header that reflects the active filter + device, e.g. "Focus orphaned sessions on yosemite-s0:". */
32
+ export function focusHeader(statuses, hosts) {
33
+ const where = hosts.length ? ` on ${hosts.join(', ')}` : '';
34
+ if (statuses.length === 0)
35
+ return `Focus a live session${where}:`;
36
+ const word = statuses.length === 1 ? statusWord(statuses[0]) : 'filtered';
37
+ return `Focus ${word} sessions${where}:`;
38
+ }
39
+ /** The adjective shown in the header for a single live-state filter. */
40
+ function statusWord(status) {
41
+ return status === 'orphaned' ? 'orphaned' : status;
42
+ }
24
43
  export function registerFocusCommand(program) {
25
44
  const cmd = program
26
45
  .command('focus')
27
46
  .argument('[id]', 'Short/full session id to focus; omit for an interactive picker')
28
47
  .option('--local', 'Only this machine (skip the cross-host sweep)')
29
48
  .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')
49
+ .option('-H, --host <target...>', 'Scope the picker to live sessions on these devices (host alias or user@host; repeatable)')
50
+ .option('--device <target...>', 'Alias for --host (device alias from `agents devices`; repeatable)')
51
+ .option('--working', 'Only live sessions currently doing work')
52
+ .option('--idle', 'Only live sessions that have stopped between turns')
53
+ .option('--waiting', 'Only live sessions waiting on your input')
54
+ .option('--orphan', 'Only sessions whose process outlived its terminal client')
55
+ .option('--orphaned', 'Alias for --orphan')
56
+ .option('--crashed', 'Only sessions whose terminal disappeared with the process')
57
+ .option('--closed', 'Only recently observed sessions whose process exited normally')
58
+ .option('--abandoned', 'Only sessions with no transcript progress for the abandonment window')
59
+ .option('--queued', 'Only queued sessions that have not started running')
60
+ .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)')
31
62
  .action(async (id, opts) => {
32
63
  await focusAction(id, opts);
33
64
  });
@@ -36,6 +67,12 @@ export function registerFocusCommand(program) {
36
67
  # Jump to a live session (attach pane/tab, or open a new tab and resume)
37
68
  agents sessions focus a1b2c3d4
38
69
 
70
+ # Multi-select live sessions; each opens as a tab in this terminal
71
+ agents sessions focus
72
+
73
+ # Scope the picker to one device's orphaned sessions
74
+ agents sessions focus --orphan --device yosemite-s0
75
+
39
76
  # Attach only — refuse if nothing is joinable (old sessions go)
40
77
  agents sessions focus a1b2c3d4 --attach-only
41
78
 
@@ -43,6 +80,10 @@ export function registerFocusCommand(program) {
43
80
  agents sessions focus --local
44
81
  `,
45
82
  notes: `
83
+ - 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.
46
87
  Lifecycle siblings (not synonyms):
47
88
  focus live jump (default "take me there")
48
89
  focus --attach-only attach only; never fork (replaces go)
@@ -61,24 +102,40 @@ export function selectFallback(attachOnly) {
61
102
  return attachOnly ? refuseFallback : resumeInNewTab;
62
103
  }
63
104
  export async function focusAction(id, opts) {
64
- const { self, activeById } = await gatherLiveTargets(!!opts.local);
105
+ const hosts = mergeFocusHosts(opts);
106
+ const statuses = requestedLiveStatuses(opts);
107
+ // A device scope needs the cross-host sweep; --local only wins when no host is named.
108
+ const local = !!opts.local && hosts.length === 0;
109
+ const { self, activeById } = await gatherLiveTargets(local, { hosts, statuses });
65
110
  const fallback = selectFallback(opts.attachOnly);
66
111
  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);
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;
71
116
  return;
72
117
  }
73
- if (matches.length > 1) {
74
- console.error(chalk.red(`"${id}" is ambiguous (${matches.length} live matches). Use more of the id.`));
118
+ if (outcome.kind === 'ambiguous') {
119
+ console.error(chalk.red(`"${id}" matches ${outcome.candidates.length} sessions. Pass a longer id or alias.`));
75
120
  process.exitCode = 1;
76
121
  return;
77
122
  }
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;
123
+ if (outcome.kind === 'not-found') {
124
+ console.error(chalk.red(`No session matching "${id}".`));
125
+ process.exitCode = 1;
126
+ return;
127
+ }
128
+ const live = activeById.get(outcome.session.id);
129
+ if (live && isAttachableLiveSession(live)) {
130
+ await jumpTo(live, self, fallback);
131
+ return;
132
+ }
133
+ if (opts.attachOnly) {
134
+ console.error(chalk.red(`${outcome.session.shortId} has no live terminal to attach.`));
135
+ process.exitCode = 1;
136
+ return;
137
+ }
138
+ await recoverResolvedSession(outcome.session);
82
139
  return;
83
140
  }
84
141
  if (!isInteractiveTerminal()) {
@@ -87,13 +144,152 @@ export async function focusAction(id, opts) {
87
144
  return;
88
145
  }
89
146
  if (activeById.size === 0) {
90
- console.log(chalk.gray('No live sessions to focus. To resume a past one: agents sessions resume'));
147
+ const scope = describeScope(statuses, hosts);
148
+ console.log(chalk.gray(`No live sessions to focus${scope}. To resume a past one: agents sessions resume`));
91
149
  return;
92
150
  }
93
- const target = await pickLiveTarget(activeById, self, 'Focus a live session:', 'focus');
94
- if (!target)
151
+ const header = focusHeader(statuses, hosts);
152
+ // --attach-only keeps the old `go` single-jump: pick one, attach it in place (or refuse).
153
+ if (opts.attachOnly) {
154
+ const target = await pickLiveTarget(activeById, self, header, 'focus');
155
+ if (!target)
156
+ return;
157
+ await jumpTo(target, self, fallback);
158
+ return;
159
+ }
160
+ // Default: multi-select → open each selected session as a tab in this terminal.
161
+ const targets = await pickLiveTargets(activeById, self, header);
162
+ if (targets.length === 0)
163
+ return;
164
+ await openFocusTabs(targets, self);
165
+ }
166
+ /** A retained pane is not attachable merely because tmux can still display it. */
167
+ export function isAttachableLiveSession(session) {
168
+ return session.pidAlive !== false && session.status !== 'closed' && session.status !== 'crashed';
169
+ }
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);
95
177
  return;
96
- await jumpTo(target, self, fallback);
178
+ }
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'}`));
183
+ process.exitCode = 1;
184
+ }
185
+ }
186
+ /** Human scope suffix for the empty-pool message, e.g. " (orphaned on yosemite-s0)". */
187
+ function describeScope(statuses, hosts) {
188
+ const parts = [];
189
+ if (statuses.length)
190
+ parts.push(statuses.map(statusWord).join('/'));
191
+ if (hosts.length)
192
+ parts.push(`on ${hosts.join(', ')}`);
193
+ return parts.length ? ` (${parts.join(' ')})` : '';
194
+ }
195
+ /**
196
+ * Shell that resolves a tmux pane to its session and attaches it — the exact form
197
+ * `jumpTo` uses, minus the pre-select-window nicety, so a batch tab joins the live
198
+ * session (a second client) without forking. Reused for local and (wrapped in ssh)
199
+ * remote panes.
200
+ */
201
+ export function tmuxAttachScript(mux) {
202
+ const sock = mux.socket ? `-S ${shellQuote(mux.socket)} ` : '';
203
+ const p = shellQuote(mux.pane);
204
+ return (`sess=$(tmux ${sock}display-message -pt ${p} '#{session_name}' 2>/dev/null); ` +
205
+ `exec tmux ${sock}attach-session -t "\${sess:-${p}}"`);
206
+ }
207
+ /**
208
+ * Decide how one live session opens as a tab. Pure over the session + a
209
+ * `resumeCommandFor` resolver (injected so the local version-pinned resume command
210
+ * and the tests stay decoupled from `discoverSessions`).
211
+ */
212
+ export function planFocusSurface(s, self, resumeCommandFor) {
213
+ const remote = s.machine && s.machine !== self ? s.machine : undefined;
214
+ const mux = s.provenance?.mux;
215
+ const sid = shortId(s);
216
+ // Join rail = tmux only (local or remote over SSH). A new tab attaching the live
217
+ // tmux session is a second client: join, no fork.
218
+ if (mux?.kind === 'tmux' && mux.pane) {
219
+ const script = tmuxAttachScript({ socket: mux.socket, pane: mux.pane });
220
+ if (remote) {
221
+ assertValidSshTarget(remote);
222
+ return {
223
+ kind: 'attach',
224
+ command: ['ssh', '-tt', remote, shellQuote(script)],
225
+ note: `attach ${mux.pane} on ${remote}`,
226
+ };
227
+ }
228
+ return { kind: 'attach', command: ['sh', '-c', shellQuote(script)], note: `attach ${mux.pane}` };
229
+ }
230
+ // No join rail → the canonical command resolves the owner device itself.
231
+ const cmd = resumeCommandFor(s);
232
+ if (!cmd)
233
+ return { kind: 'skip', note: `${sid} — ${s.kind} sessions can't be resumed, and it has no live tmux to join` };
234
+ return { kind: 'resume', command: cmd, note: 'resume a copy (no live tmux to join)' };
235
+ }
236
+ /**
237
+ * Open each selected live session as a tab: attach its live pane where one exists,
238
+ * else resume a copy. Reuses `resume`'s backend resolution + flood guard.
239
+ */
240
+ export async function openFocusTabs(targets, self, deps = {}) {
241
+ 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) }));
247
+ // Skips are reported, never silently dropped.
248
+ for (const p of planned)
249
+ if (p.plan.kind === 'skip')
250
+ console.log(chalk.yellow(` skip ${p.plan.note}`));
251
+ const openable = planned.filter((p) => p.plan.kind !== 'skip');
252
+ if (openable.length === 0) {
253
+ console.log(chalk.gray('Nothing to open in the selection.'));
254
+ return;
255
+ }
256
+ const backend = deps.backend ?? (await resolveBackend({}, currentContext(), openable.length));
257
+ if (backend === 'cancel')
258
+ return;
259
+ // Guard against opening a flood of live agents at once.
260
+ if (openable.length > CONFIRM_THRESHOLD) {
261
+ const proceed = await confirm({ message: `Open ${openable.length} sessions at once?`, default: false }).catch(() => false);
262
+ if (!proceed)
263
+ return;
264
+ }
265
+ // No tab-capable terminal (off-macOS, not in tmux): fall back to the single
266
+ // foreground jump for the first, and say the rest need a tab-capable terminal.
267
+ if (backend === 'inplace') {
268
+ if (openable.length > 1) {
269
+ 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
+ }
271
+ await jumpTo(openable[0].s, self, selectFallback(false));
272
+ return;
273
+ }
274
+ console.log(chalk.gray(`Opening ${openable.length} session${openable.length === 1 ? '' : 's'} in ${backend} (tabs)…`));
275
+ const results = await open(openable.map((p) => ({ cwd: cwdFor(p.s, byId), command: p.plan.command })), { backend, packing: 'tabs' });
276
+ let opened = 0;
277
+ results.forEach((r, i) => {
278
+ const p = openable[i];
279
+ if (r.ok) {
280
+ opened++;
281
+ console.log(chalk.green(` opened ${shortId(p.s)}`) + chalk.gray(` — tab — (${p.plan.note})`));
282
+ }
283
+ else {
284
+ console.log(chalk.red(` failed ${shortId(p.s)} — ${r.error}`));
285
+ }
286
+ });
287
+ console.log(chalk.gray(`\nOpened ${opened}/${openable.length} in ${backend}.`));
288
+ }
289
+ /** A real cwd for the tab: the session's indexed cwd if it still exists, else here. */
290
+ function cwdFor(s, byId) {
291
+ const cwd = byId.get(s.sessionId ?? '')?.cwd ?? s.cwd;
292
+ return cwd && fs.existsSync(cwd) ? cwd : process.cwd();
97
293
  }
98
294
  function shortId(s) {
99
295
  return (s.sessionId ?? '').slice(0, 8) || '-';
@@ -127,38 +323,21 @@ async function richMetaById(id) {
127
323
  * Note: for a session that's still mid-run, this opens a COPY (the original keeps going);
128
324
  * only tmux can *join* a live one without forking (see the header).
129
325
  */
130
- const resumeInNewTab = async (s, remote) => {
326
+ const resumeInNewTab = async (s) => {
131
327
  const id = s.sessionId ?? '';
132
328
  if (!id) {
133
329
  console.log(chalk.yellow('This session has no id to resume.'));
134
330
  return;
135
331
  }
136
- // Remote: the transcript + pinned version live on the peer, so resume THERE over SSH.
137
- // runOnPeer runs `agents sessions resume <id>` with a real TTY (`-tt`) in the foreground —
138
- // it actually delivers you to the session (the peer picks the right version + HOME).
139
- if (remote) {
140
- 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 });
142
- if (rc === 'no-target') {
143
- 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)}'`));
145
- }
146
- return;
147
- }
148
- // Local: resume in a new tab. Use the indexed meta so the version-pinned binary
149
- // resumes in the same isolated HOME the transcript was written in.
332
+ // The canonical command resolves the source device and pinned launch metadata.
150
333
  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
- }
334
+ const command = buildCanonicalResumeCommand(meta.id);
156
335
  const cwd = meta.cwd && fs.existsSync(meta.cwd) ? meta.cwd : process.cwd();
157
336
  const ctx = currentContext();
158
337
  const backend = detectCurrentBackend(ctx) ?? availableBackends(ctx)[0]?.id;
159
338
  if (!backend) {
160
339
  // No tab-capable surface (off-macOS, not in tmux) — resume in this process.
161
- await resumeSessionInPlace(meta);
340
+ await resumeSelectorInPlace(meta.id);
162
341
  return;
163
342
  }
164
343
  console.log(chalk.gray(`${shortId(s)} has no live terminal to attach — opening a new ${backend} tab and resuming a copy.`));
@@ -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 {};
@@ -3,71 +3,98 @@ import { setHelpSections } from '../lib/help.js';
3
3
  import { findSessionsById } from '../lib/session/db.js';
4
4
  import { discoverSessions } from '../lib/session/discover.js';
5
5
  import { forkSession, isForkableAgent, FORKABLE_AGENTS } from '../lib/session/fork.js';
6
- /** Register the top-level `agents fork` command. */
7
- export function registerForkCommand(program) {
8
- const cmd = program
6
+ const FORK_HELP = {
7
+ examples: `
8
+ # Fork a session by (partial) id, then continue the fork
9
+ agents sessions fork 4f3a9c21
10
+ agents sessions resume <new-id>
11
+
12
+ # Give the fork a name
13
+ agents sessions fork 4f3a9c21 --name "try redis instead"
14
+ `,
15
+ notes: `
16
+ - 'resume' continues the SAME conversation; 'fork' copies it under a new id so the two diverge.
17
+ - The fork is a full copy of the conversation so far; continuing it never touches the original.
18
+ - Resolve the session the same way as resume: an exact or prefix id fragment.
19
+ - Native copy currently supports: ${FORKABLE_AGENTS.join(', ')}. For other harnesses, branch by
20
+ starting a fresh agent and seeding it with '/continue <id>' — the source stays put.
21
+ `,
22
+ };
23
+ /**
24
+ * Resolve the source session, copy it under a fresh id, and print how to
25
+ * continue the fork. Shared by `agents sessions fork` and the `agents fork` alias.
26
+ */
27
+ export async function runFork(sessionArg, options) {
28
+ // Resolve the source. Try the index first; only pay for a rescan if the id
29
+ // isn't found yet (mirrors the resume path's freshen-then-lookup).
30
+ let matches = findSessionsById(sessionArg, {});
31
+ if (matches.length === 0) {
32
+ await discoverSessions({});
33
+ matches = findSessionsById(sessionArg, {});
34
+ }
35
+ if (matches.length === 0) {
36
+ // Errors go to stderr and set a non-zero exit code so a script chaining on
37
+ // `agents sessions fork <id> && agents sessions resume <new>` doesn't proceed
38
+ // on a failed fork.
39
+ console.error(chalk.red(`No session matching "${sessionArg}".`));
40
+ console.error(chalk.gray('List candidates with: agents sessions'));
41
+ process.exitCode = 1;
42
+ return;
43
+ }
44
+ if (matches.length > 1) {
45
+ console.error(chalk.yellow(`"${sessionArg}" is ambiguous — ${matches.length} sessions match. Use a longer id:`));
46
+ for (const m of matches.slice(0, 8)) {
47
+ console.error(chalk.gray(` ${m.shortId} ${m.agent} ${m.label || m.topic || ''}`));
48
+ }
49
+ process.exitCode = 1;
50
+ return;
51
+ }
52
+ const source = matches[0];
53
+ if (!isForkableAgent(source.agent)) {
54
+ // A native copy needs the agent's transcript to be resumable by id; harnesses
55
+ // without that can still be branched by hand. Fail loud with the manual path
56
+ // rather than a silent no-op or a fake copy.
57
+ console.error(chalk.yellow(`A native fork copy isn't supported for ${source.agent} sessions yet (supported: ${FORKABLE_AGENTS.join(', ')}).`));
58
+ console.error(chalk.gray(` Branch it by hand — start a fresh ${source.agent} and seed it with the source's context:`));
59
+ console.error(chalk.gray(` agents run ${source.agent} --terminal # then, in the new session:`));
60
+ console.error(chalk.gray(` /continue ${source.shortId}`));
61
+ process.exitCode = 1;
62
+ return;
63
+ }
64
+ let result;
65
+ try {
66
+ result = forkSession(source, { name: options.name });
67
+ }
68
+ catch (err) {
69
+ console.error(chalk.red(`Could not fork ${source.shortId}: ${err.message}`));
70
+ process.exitCode = 1;
71
+ return;
72
+ }
73
+ console.log(chalk.green(`Forked ${source.shortId} -> ${result.shortId}`));
74
+ console.log(chalk.gray(` Label: ${result.label}`));
75
+ console.log(chalk.gray(` Continue: agents sessions resume ${result.shortId}`));
76
+ console.log(chalk.gray(` Original ${source.shortId} is untouched.`));
77
+ }
78
+ /**
79
+ * Register `agents sessions fork <session>` — the canonical surface (fork is a
80
+ * session operation, so it lives under the `sessions` group).
81
+ */
82
+ export function registerSessionsForkCommand(sessionsCmd) {
83
+ const cmd = sessionsCmd
9
84
  .command('fork <session>')
10
85
  .description('Branch a session into a new, independent copy you can continue separately. The original is untouched.')
11
86
  .option('--name <label>', 'Label for the fork (default: "fork of <original>")');
12
- setHelpSections(cmd, {
13
- examples: `
14
- # Fork a session by (partial) id, then continue the fork
15
- agents fork 4f3a9c21
16
- agents resume <new-id>
17
-
18
- # Give the fork a name
19
- agents fork 4f3a9c21 --name "try redis instead"
20
- `,
21
- notes: `
22
- - 'resume' continues the SAME conversation; 'fork' copies it under a new id so the two diverge.
23
- - The fork is a full copy of the conversation so far; continuing it never touches the original.
24
- - Resolve the session the same way as resume: an exact or prefix id fragment.
25
- - Currently supports: ${FORKABLE_AGENTS.join(', ')}. Other agents are a planned follow-up.
26
- `,
27
- });
28
- cmd.action(async (sessionArg, options) => {
29
- // Resolve the source. Try the index first; only pay for a rescan if the id
30
- // isn't found yet (mirrors the resume path's freshen-then-lookup).
31
- let matches = findSessionsById(sessionArg, {});
32
- if (matches.length === 0) {
33
- await discoverSessions({});
34
- matches = findSessionsById(sessionArg, {});
35
- }
36
- if (matches.length === 0) {
37
- // Errors go to stderr and set a non-zero exit code so a script chaining on
38
- // `agents fork <id> && agents resume <new>` doesn't proceed on a failed fork.
39
- console.error(chalk.red(`No session matching "${sessionArg}".`));
40
- console.error(chalk.gray('List candidates with: agents sessions'));
41
- process.exitCode = 1;
42
- return;
43
- }
44
- if (matches.length > 1) {
45
- console.error(chalk.yellow(`"${sessionArg}" is ambiguous — ${matches.length} sessions match. Use a longer id:`));
46
- for (const m of matches.slice(0, 8)) {
47
- console.error(chalk.gray(` ${m.shortId} ${m.agent} ${m.label || m.topic || ''}`));
48
- }
49
- process.exitCode = 1;
50
- return;
51
- }
52
- const source = matches[0];
53
- if (!isForkableAgent(source.agent)) {
54
- console.error(chalk.yellow(`fork does not support ${source.agent} sessions yet.`));
55
- console.error(chalk.gray(` Supported: ${FORKABLE_AGENTS.join(', ')}.`));
56
- process.exitCode = 1;
57
- return;
58
- }
59
- let result;
60
- try {
61
- result = forkSession(source, { name: options.name });
62
- }
63
- catch (err) {
64
- console.error(chalk.red(`Could not fork ${source.shortId}: ${err.message}`));
65
- process.exitCode = 1;
66
- return;
67
- }
68
- console.log(chalk.green(`Forked ${source.shortId} -> ${result.shortId}`));
69
- console.log(chalk.gray(` Label: ${result.label}`));
70
- console.log(chalk.gray(` Continue: agents resume ${result.shortId}`));
71
- console.log(chalk.gray(` Original ${source.shortId} is untouched.`));
72
- });
87
+ setHelpSections(cmd, FORK_HELP);
88
+ cmd.action(runFork);
89
+ }
90
+ /**
91
+ * Register the hidden top-level `agents fork` alias. Kept working for back-compat
92
+ * and muscle memory; the canonical, discoverable surface is `agents sessions fork`.
93
+ */
94
+ export function registerForkCommand(program) {
95
+ const cmd = program
96
+ .command('fork <session>', { hidden: true })
97
+ .description('Alias for `agents sessions fork` branch a session into a new, independent copy.')
98
+ .option('--name <label>', 'Label for the fork (default: "fork of <original>")');
99
+ cmd.action(runFork);
73
100
  }
@@ -16,21 +16,46 @@
16
16
  import type { Command } from 'commander';
17
17
  import { type ActiveSession } from '../lib/session/active.js';
18
18
  import type { SessionMeta } from '../lib/session/types.js';
19
+ import { type LiveStatusFilter } from './sessions.js';
19
20
  export declare function registerGoCommand(program: Command): void;
21
+ /**
22
+ * Scope a live-session pool by device and live status. Pure so the `focus`
23
+ * device/status filters are unit-testable without touching the sweep. `hosts`
24
+ * keeps only sessions whose `machine` is in the set (local rows carry `self`,
25
+ * remote rows carry their peer tag); `statuses` reuses `--active`'s exact
26
+ * `matchesLiveStatus` derivation rather than a parallel status table.
27
+ */
28
+ export declare function filterLivePool(sessions: ActiveSession[], opts?: {
29
+ hosts?: string[];
30
+ statuses?: LiveStatusFilter[];
31
+ }): ActiveSession[];
20
32
  /**
21
33
  * Live jump targets (local + remote), keyed by session id. Cloud is excluded by
22
34
  * default (it has no local pid to attach), but `detach` opts in with
23
35
  * `includeCloud` so it can resolve a cloud id and refuse it with a clear message
24
36
  * instead of a bare "no live session".
37
+ *
38
+ * `hosts` scopes the sweep to named devices — the fan-out only dials them, and the
39
+ * pool is then filtered to `s.machine ∈ hosts` so a stray local row can't leak in.
40
+ * `statuses` narrows to the live-state words `--active` uses (orphan/crashed/…).
25
41
  */
26
42
  export declare function gatherLiveTargets(local: boolean, opts?: {
27
43
  includeCloud?: boolean;
44
+ hosts?: string[];
45
+ statuses?: LiveStatusFilter[];
28
46
  }): Promise<{
29
47
  self: string;
30
48
  activeById: Map<string, ActiveSession>;
31
49
  }>;
32
50
  /** Interactive pick over the live sessions' rich SessionMeta; returns the chosen live session. */
33
51
  export declare function pickLiveTarget(activeById: Map<string, ActiveSession>, self: string, message: string, enterHint: string): Promise<ActiveSession | null>;
52
+ /**
53
+ * Multi-select over the live sessions' rich SessionMeta (same rows as
54
+ * `pickLiveTarget`, but a checkbox picker) — the plural sibling that lets `focus`
55
+ * open several sessions at once. Mirrors `sessions resume`'s `multiItemPicker`
56
+ * wiring; returns the chosen live sessions in pick order, or `[]` on cancel.
57
+ */
58
+ export declare function pickLiveTargets(activeById: Map<string, ActiveSession>, self: string, message: string): Promise<ActiveSession[]>;
34
59
  /**
35
60
  * Map each live session to its rich SessionMeta (worktree/PR/changes/tools/tests
36
61
  * via the shared picker), reusing `discoverSessions`. Remote or unindexed live