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