@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.
- package/CHANGELOG.md +209 -0
- package/README.md +8 -2
- package/dist/commands/doctor.js +15 -7
- package/dist/commands/exec.js +20 -6
- package/dist/commands/focus.d.ts +76 -4
- package/dist/commands/focus.js +219 -40
- package/dist/commands/fork.d.ts +20 -2
- package/dist/commands/fork.js +91 -64
- package/dist/commands/go.d.ts +25 -0
- package/dist/commands/go.js +63 -2
- package/dist/commands/harness-wizard.d.ts +206 -0
- package/dist/commands/harness-wizard.js +403 -0
- package/dist/commands/harness.d.ts +12 -0
- package/dist/commands/harness.js +97 -101
- package/dist/commands/resume.js +10 -3
- package/dist/commands/secrets.js +25 -30
- package/dist/commands/sessions-resume.d.ts +21 -3
- package/dist/commands/sessions-resume.js +50 -11
- package/dist/commands/sessions.d.ts +81 -5
- package/dist/commands/sessions.js +325 -66
- package/dist/commands/watchdog.js +13 -2
- package/dist/lib/agents.d.ts +1 -1
- package/dist/lib/agents.js +132 -0
- package/dist/lib/codex-policy.d.ts +17 -0
- package/dist/lib/codex-policy.js +48 -0
- package/dist/lib/crabbox/lease.d.ts +25 -0
- package/dist/lib/crabbox/lease.js +62 -0
- package/dist/lib/daemon.js +70 -0
- package/dist/lib/exec.d.ts +4 -0
- package/dist/lib/exec.js +88 -54
- package/dist/lib/feed-broadcast.d.ts +1 -20
- package/dist/lib/feed-broadcast.js +31 -1
- package/dist/lib/hooks.js +12 -2
- package/dist/lib/mcp.js +44 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +1 -5
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -15
- package/dist/lib/models.d.ts +0 -5
- package/dist/lib/models.js +48 -0
- package/dist/lib/plugin-marketplace.js +9 -0
- package/dist/lib/pricing/prices.json +119 -92
- package/dist/lib/pricing/table.js +13 -0
- package/dist/lib/remote-agents-json.d.ts +29 -1
- package/dist/lib/remote-agents-json.js +47 -10
- package/dist/lib/resources/mcp.js +2 -0
- package/dist/lib/resources/permissions.js +3 -0
- package/dist/lib/resources/types.d.ts +2 -1
- package/dist/lib/runner.js +28 -19
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -2
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -13
- package/dist/lib/secrets/index.d.ts +15 -1
- package/dist/lib/secrets/index.js +118 -49
- package/dist/lib/secrets/reaper.d.ts +87 -0
- package/dist/lib/secrets/reaper.js +184 -0
- package/dist/lib/secrets/remote.d.ts +29 -0
- package/dist/lib/secrets/remote.js +37 -1
- package/dist/lib/session/active.d.ts +36 -1
- package/dist/lib/session/active.js +60 -19
- package/dist/lib/session/actor-sidecar.d.ts +14 -0
- package/dist/lib/session/actor-sidecar.js +67 -4
- package/dist/lib/session/db.d.ts +1 -1
- package/dist/lib/session/db.js +32 -1
- package/dist/lib/session/discover.js +168 -0
- package/dist/lib/session/parse.d.ts +10 -0
- package/dist/lib/session/parse.js +98 -0
- package/dist/lib/session/remote-list.d.ts +10 -1
- package/dist/lib/session/remote-list.js +2 -8
- package/dist/lib/session/remote.d.ts +57 -3
- package/dist/lib/session/remote.js +90 -26
- package/dist/lib/session/resume-command.d.ts +6 -0
- package/dist/lib/session/resume-command.js +8 -0
- package/dist/lib/session/session-cache.d.ts +173 -0
- package/dist/lib/session/session-cache.js +399 -0
- package/dist/lib/session/types.d.ts +1 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/session/width.d.ts +1 -1
- package/dist/lib/session/width.js +12 -2
- package/dist/lib/shims.d.ts +2 -2
- package/dist/lib/shims.js +40 -5
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/types.d.ts +1 -1
- package/dist/lib/usage.d.ts +13 -0
- package/dist/lib/usage.js +215 -0
- package/dist/lib/versions.js +13 -2
- package/package.json +1 -1
- package/dist/bin/agents +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
package/dist/commands/focus.js
CHANGED
|
@@ -14,20 +14,51 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import fs from 'node:fs';
|
|
16
16
|
import chalk from 'chalk';
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
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
|
-
.
|
|
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
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 (
|
|
74
|
-
console.error(chalk.red(`"${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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
|
94
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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 =
|
|
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
|
|
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.`));
|
package/dist/commands/fork.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 {};
|
package/dist/commands/fork.js
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
}
|
package/dist/commands/go.d.ts
CHANGED
|
@@ -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
|