@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.
- package/CHANGELOG.md +402 -0
- package/README.md +13 -6
- package/dist/bin/agents +0 -0
- package/dist/commands/attach.d.ts +2 -0
- package/dist/commands/attach.js +32 -7
- package/dist/commands/defaults.js +2 -0
- package/dist/commands/doctor.js +20 -7
- package/dist/commands/exec.js +100 -34
- package/dist/commands/feed.d.ts +18 -0
- package/dist/commands/feed.js +44 -1
- package/dist/commands/focus.d.ts +108 -5
- package/dist/commands/focus.js +455 -32
- package/dist/commands/fork.d.ts +20 -2
- package/dist/commands/fork.js +91 -64
- package/dist/commands/go.d.ts +39 -1
- package/dist/commands/go.js +112 -7
- 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/insights.js +6 -2
- package/dist/commands/inspect.js +39 -5
- package/dist/commands/menubar.js +6 -1
- package/dist/commands/models.js +1 -0
- package/dist/commands/modes.d.ts +12 -0
- package/dist/commands/modes.js +147 -0
- package/dist/commands/resume.js +10 -3
- package/dist/commands/secrets-sync.js +11 -13
- package/dist/commands/secrets.d.ts +2 -0
- package/dist/commands/secrets.js +79 -47
- package/dist/commands/sessions-browser.d.ts +35 -0
- package/dist/commands/sessions-browser.js +140 -14
- package/dist/commands/sessions-resume.d.ts +23 -3
- package/dist/commands/sessions-resume.js +73 -19
- package/dist/commands/sessions.d.ts +91 -5
- package/dist/commands/sessions.js +359 -125
- package/dist/commands/setup-secrets.js +1 -1
- package/dist/commands/sync.js +246 -42
- package/dist/commands/view.js +2 -0
- package/dist/commands/watchdog.js +13 -2
- package/dist/index.js +2 -1
- package/dist/lib/agent-modes.d.ts +49 -0
- package/dist/lib/agent-modes.js +70 -0
- 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/doctor-diff.d.ts +3 -0
- package/dist/lib/doctor-diff.js +15 -13
- package/dist/lib/event-stream.d.ts +3 -1
- package/dist/lib/event-stream.js +14 -1
- package/dist/lib/exec.d.ts +16 -0
- package/dist/lib/exec.js +175 -62
- package/dist/lib/feed-broadcast.d.ts +1 -20
- package/dist/lib/feed-broadcast.js +31 -1
- package/dist/lib/hooks/cache.js +36 -3
- package/dist/lib/hooks.d.ts +19 -7
- package/dist/lib/hooks.js +100 -40
- package/dist/lib/hosts/session-index.d.ts +4 -0
- package/dist/lib/hosts/session-index.js +7 -0
- package/dist/lib/manifest.d.ts +12 -2
- package/dist/lib/manifest.js +60 -5
- package/dist/lib/mcp.js +44 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +52 -2
- package/dist/lib/menubar/install-menubar.js +128 -6
- 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/refresh.d.ts +5 -0
- package/dist/lib/refresh.js +37 -33
- package/dist/lib/remote-agents-json.d.ts +29 -1
- package/dist/lib/remote-agents-json.js +47 -10
- package/dist/lib/resource-inventory.d.ts +79 -0
- package/dist/lib/resource-inventory.js +122 -0
- 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/resources.js +8 -5
- package/dist/lib/run-defaults.d.ts +2 -0
- package/dist/lib/run-defaults.js +23 -2
- package/dist/lib/runner.js +50 -36
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/bundles.js +157 -65
- package/dist/lib/secrets/filestore.d.ts +5 -3
- package/dist/lib/secrets/filestore.js +12 -8
- package/dist/lib/secrets/index.d.ts +15 -1
- package/dist/lib/secrets/index.js +148 -53
- package/dist/lib/secrets/reaper.d.ts +97 -0
- package/dist/lib/secrets/reaper.js +219 -0
- package/dist/lib/secrets/remote.d.ts +29 -0
- package/dist/lib/secrets/remote.js +37 -1
- package/dist/lib/secrets/sync-passphrase.d.ts +27 -0
- package/dist/lib/secrets/sync-passphrase.js +78 -0
- 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/recovery.d.ts +37 -0
- package/dist/lib/session/recovery.js +95 -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 +62 -8
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/sync-umbrella.d.ts +5 -0
- package/dist/lib/sync-umbrella.js +5 -4
- package/dist/lib/tmux/session.d.ts +4 -2
- package/dist/lib/tmux/session.js +5 -5
- package/dist/lib/types.d.ts +3 -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/commands/focus.js
CHANGED
|
@@ -14,28 +14,116 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import fs from 'node:fs';
|
|
16
16
|
import chalk from 'chalk';
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
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('[
|
|
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
|
-
.
|
|
31
|
-
.
|
|
32
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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(
|
|
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(`
|
|
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 =
|
|
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;
|
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 {};
|