@phnx-labs/agents-cli 1.22.50 → 1.22.52
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 +219 -0
- package/dist/bootstrap.js +1 -1
- package/dist/commands/attach.js +7 -0
- package/dist/commands/browser.d.ts +10 -0
- package/dist/commands/browser.js +191 -54
- package/dist/commands/config.js +20 -0
- package/dist/commands/daemon.d.ts +2 -0
- package/dist/commands/daemon.js +8 -4
- package/dist/commands/detach.js +1 -1
- package/dist/commands/feedback.js +1 -1
- package/dist/commands/focus.d.ts +1 -10
- package/dist/commands/focus.js +14 -79
- package/dist/commands/go.d.ts +26 -0
- package/dist/commands/go.js +63 -5
- package/dist/commands/menubar.js +6 -4
- package/dist/commands/monitors.js +1 -1
- package/dist/commands/repo.js +31 -3
- package/dist/commands/sessions-resume.d.ts +1 -0
- package/dist/commands/sessions-resume.js +13 -2
- package/dist/commands/sessions-stop.js +1 -1
- package/dist/commands/sessions.d.ts +23 -13
- package/dist/commands/sessions.js +40 -20
- package/dist/commands/setup-browser.d.ts +5 -2
- package/dist/commands/setup-browser.js +14 -29
- package/dist/commands/setup-preferences.d.ts +22 -3
- package/dist/commands/setup-preferences.js +25 -8
- package/dist/commands/share.js +12 -8
- package/dist/commands/status.js +5 -0
- package/dist/commands/sync.js +58 -2
- package/dist/commands/tmux.d.ts +8 -1
- package/dist/commands/tmux.js +167 -17
- package/dist/commands/traces.js +32 -4
- package/dist/lib/browser/ipc.d.ts +44 -0
- package/dist/lib/browser/ipc.js +120 -8
- package/dist/lib/browser/profiles.d.ts +39 -17
- package/dist/lib/browser/profiles.js +51 -52
- package/dist/lib/browser/runtime-state.d.ts +4 -2
- package/dist/lib/browser/runtime-state.js +4 -2
- package/dist/lib/browser/service.js +4 -3
- package/dist/lib/channels/owner-forward.d.ts +88 -0
- package/dist/lib/channels/owner-forward.js +116 -0
- package/dist/lib/channels/owner-sink.js +7 -0
- package/dist/lib/claude-statusline.d.ts +9 -0
- package/dist/lib/claude-statusline.js +45 -4
- package/dist/lib/computer/ssh-tunnel.d.ts +7 -6
- package/dist/lib/computer/ssh-tunnel.js +13 -8
- package/dist/lib/config-keys.d.ts +4 -3
- package/dist/lib/config-keys.js +9 -2
- package/dist/lib/device-config.js +23 -0
- package/dist/lib/exec.d.ts +8 -0
- package/dist/lib/exec.js +7 -0
- package/dist/lib/factory/snapshot.d.ts +1 -1
- package/dist/lib/factory/snapshot.js +1 -1
- package/dist/lib/feed-broadcast.js +15 -1
- package/dist/lib/git.d.ts +93 -0
- package/dist/lib/git.js +232 -0
- package/dist/lib/helper-download.d.ts +12 -2
- package/dist/lib/helper-download.js +12 -2
- package/dist/lib/installations/migrate.js +4 -4
- package/dist/lib/menubar/install-menubar.d.ts +71 -8
- package/dist/lib/menubar/install-menubar.js +183 -24
- package/dist/lib/monitors/remote.d.ts +18 -1
- package/dist/lib/monitors/remote.js +15 -2
- package/dist/lib/notify.d.ts +7 -0
- package/dist/lib/notify.js +15 -1
- package/dist/lib/session/local-tmux-attach.d.ts +69 -0
- package/dist/lib/session/local-tmux-attach.js +164 -0
- package/dist/lib/session/remote-active.d.ts +8 -0
- package/dist/lib/session/remote-active.js +1 -0
- package/dist/lib/share/publish.d.ts +8 -11
- package/dist/lib/share/publish.js +16 -20
- package/dist/lib/share/worker-template.js +99 -12
- package/dist/lib/star-nudge.d.ts +2 -2
- package/dist/lib/star-nudge.js +2 -2
- package/dist/lib/state.d.ts +1 -1
- package/dist/lib/state.js +2 -2
- package/dist/lib/sync-status.d.ts +17 -0
- package/dist/lib/sync-status.js +21 -2
- package/dist/lib/tmux/index.d.ts +1 -1
- package/dist/lib/tmux/index.js +1 -1
- package/dist/lib/tmux/session.d.ts +10 -0
- package/dist/lib/tmux/session.js +29 -0
- package/dist/lib/traces/sync.d.ts +30 -0
- package/dist/lib/traces/sync.js +91 -10
- package/package.json +3 -3
package/dist/commands/go.d.ts
CHANGED
|
@@ -29,6 +29,28 @@ export declare function filterLivePool(sessions: ActiveSession[], opts?: {
|
|
|
29
29
|
hosts?: string[];
|
|
30
30
|
statuses?: LiveStatusFilter[];
|
|
31
31
|
}): ActiveSession[];
|
|
32
|
+
/**
|
|
33
|
+
* Live rows whose session id starts with `selector` (case-insensitive prefix).
|
|
34
|
+
* Same match `resolveOne` uses, so skip-fleet and resolve stay aligned.
|
|
35
|
+
*/
|
|
36
|
+
export declare function localLiveSelectorMatches(sessions: ActiveSession[], selector: string): ActiveSession[];
|
|
37
|
+
/**
|
|
38
|
+
* Skip `gatherRemoteActive` when local already answered the selector:
|
|
39
|
+
* two local matches fail closed without waiting for a sleeping peer that
|
|
40
|
+
* might collide; a single local hit skips only when the selector is a full
|
|
41
|
+
* UUID or unique-enough 8-hex (`isUniqueEnoughSelector`). A shorter unique
|
|
42
|
+
* local prefix still races the fleet so a remote collision can fail closed.
|
|
43
|
+
* Zero matches still race the fleet.
|
|
44
|
+
*/
|
|
45
|
+
export declare function shouldSkipRemoteSweep(localMatches: ActiveSession[], selector: string): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* First-hit abort predicate for a live-id fleet race. A full UUID is globally
|
|
48
|
+
* unique so the first exact hit is the only hit. A unique-enough live prefix
|
|
49
|
+
* (8+ chars, the tmux `ag-<agent>-<8hex>` short id) aborts remaining SSH once
|
|
50
|
+
* a reachable peer answers — unanswered boxes must not delay (PHNX-3298).
|
|
51
|
+
* Shorter prefixes stay all-settle.
|
|
52
|
+
*/
|
|
53
|
+
export declare function isDefinitiveLiveMatch(session: ActiveSession, selector: string): boolean;
|
|
32
54
|
/**
|
|
33
55
|
* Live jump targets (local + remote), keyed by session id. Cloud is excluded by
|
|
34
56
|
* default (it has no local pid to attach), but `detach` opts in with
|
|
@@ -38,11 +60,15 @@ export declare function filterLivePool(sessions: ActiveSession[], opts?: {
|
|
|
38
60
|
* `hosts` scopes the sweep to named devices — the fan-out only dials them, and the
|
|
39
61
|
* pool is then filtered to `s.machine ∈ hosts` so a stray local row can't leak in.
|
|
40
62
|
* `statuses` narrows to the live-state words `--active` uses (orphan/crashed/…).
|
|
63
|
+
* `selector` (detach/stop) skips fleet SSH when local already has a unique live
|
|
64
|
+
* id or a local collision; omit it for browse (`focus` with no id) so the picker
|
|
65
|
+
* still all-settles.
|
|
41
66
|
*/
|
|
42
67
|
export declare function gatherLiveTargets(local: boolean, opts?: {
|
|
43
68
|
includeCloud?: boolean;
|
|
44
69
|
hosts?: string[];
|
|
45
70
|
statuses?: LiveStatusFilter[];
|
|
71
|
+
selector?: string;
|
|
46
72
|
}): Promise<{
|
|
47
73
|
self: string;
|
|
48
74
|
activeById: Map<string, ActiveSession>;
|
package/dist/commands/go.js
CHANGED
|
@@ -18,17 +18,18 @@ import path from 'path';
|
|
|
18
18
|
import { execFile } from 'child_process';
|
|
19
19
|
import { promisify } from 'util';
|
|
20
20
|
import { getActiveSessions, findSessionFileForKind, sessionProcessIsLocal, sessionProcessHost } from '../lib/session/active.js';
|
|
21
|
+
import { isSessionIdShape } from '../lib/session/pid-registry.js';
|
|
21
22
|
import { gatherRemoteActive } from '../lib/session/remote-active.js';
|
|
22
23
|
import { discoverSessions } from '../lib/session/discover.js';
|
|
23
24
|
import { deriveShortId } from '../lib/session/short-id.js';
|
|
24
|
-
import { dedupeByMachineSession, mergeLocalFirst, pickSessionInteractive, matchesLiveStatus, filterSessionsByQuery, formatPickerLabel, pickerColumnsFor, } from './sessions.js';
|
|
25
|
+
import { dedupeByMachineSession, mergeLocalFirst, pickSessionInteractive, matchesLiveStatus, filterSessionsByQuery, formatPickerLabel, pickerColumnsFor, isUniqueEnoughSelector, } from './sessions.js';
|
|
25
26
|
import { buildPreview } from './sessions-picker.js';
|
|
26
27
|
import { multiItemPicker } from '../lib/picker.js';
|
|
27
28
|
import { isPromptCancelled } from './utils.js';
|
|
28
29
|
import { focusAction } from './focus.js';
|
|
29
30
|
import { machineId } from '../lib/session/sync/config.js';
|
|
30
31
|
import { attachTmux, runTmux } from '../lib/tmux/binary.js';
|
|
31
|
-
import { ensureSessionHookRepaired, paneExitStatus } from '../lib/tmux/session.js';
|
|
32
|
+
import { ensureSessionHookRepaired, paneExitStatus, teardownIfAgentExited } from '../lib/tmux/session.js';
|
|
32
33
|
import { getDefaultSocketPath } from '../lib/tmux/paths.js';
|
|
33
34
|
import { sshExec, sshStream, assertValidSshTarget, shellQuote, SSH_CONN_FAILURE_CODE } from '../lib/ssh-exec.js';
|
|
34
35
|
import { connectionEndedNotice } from '../lib/hosts/reconnect.js';
|
|
@@ -64,6 +65,50 @@ export function filterLivePool(sessions, opts = {}) {
|
|
|
64
65
|
}
|
|
65
66
|
return out;
|
|
66
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Live rows whose session id starts with `selector` (case-insensitive prefix).
|
|
70
|
+
* Same match `resolveOne` uses, so skip-fleet and resolve stay aligned.
|
|
71
|
+
*/
|
|
72
|
+
export function localLiveSelectorMatches(sessions, selector) {
|
|
73
|
+
const q = selector.toLowerCase();
|
|
74
|
+
return sessions.filter((s) => (s.sessionId ?? '').toLowerCase().startsWith(q));
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Skip `gatherRemoteActive` when local already answered the selector:
|
|
78
|
+
* two local matches fail closed without waiting for a sleeping peer that
|
|
79
|
+
* might collide; a single local hit skips only when the selector is a full
|
|
80
|
+
* UUID or unique-enough 8-hex (`isUniqueEnoughSelector`). A shorter unique
|
|
81
|
+
* local prefix still races the fleet so a remote collision can fail closed.
|
|
82
|
+
* Zero matches still race the fleet.
|
|
83
|
+
*/
|
|
84
|
+
export function shouldSkipRemoteSweep(localMatches, selector) {
|
|
85
|
+
if (localMatches.length >= 2)
|
|
86
|
+
return true;
|
|
87
|
+
if (localMatches.length === 1)
|
|
88
|
+
return isUniqueEnoughSelector(selector);
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* First-hit abort predicate for a live-id fleet race. A full UUID is globally
|
|
93
|
+
* unique so the first exact hit is the only hit. A unique-enough live prefix
|
|
94
|
+
* (8+ chars, the tmux `ag-<agent>-<8hex>` short id) aborts remaining SSH once
|
|
95
|
+
* a reachable peer answers — unanswered boxes must not delay (PHNX-3298).
|
|
96
|
+
* Shorter prefixes stay all-settle.
|
|
97
|
+
*/
|
|
98
|
+
export function isDefinitiveLiveMatch(session, selector) {
|
|
99
|
+
const id = (session.sessionId ?? '').toLowerCase();
|
|
100
|
+
const q = selector.trim().toLowerCase();
|
|
101
|
+
if (!id || !q)
|
|
102
|
+
return false;
|
|
103
|
+
if (isSessionIdShape(q))
|
|
104
|
+
return id === q;
|
|
105
|
+
return q.length >= 8 && id.startsWith(q);
|
|
106
|
+
}
|
|
107
|
+
function liveSelectorEarlyExit(selector) {
|
|
108
|
+
if (!selector || !isUniqueEnoughSelector(selector))
|
|
109
|
+
return undefined;
|
|
110
|
+
return { isDefinitive: (item) => isDefinitiveLiveMatch(item, selector) };
|
|
111
|
+
}
|
|
67
112
|
/**
|
|
68
113
|
* Live jump targets (local + remote), keyed by session id. Cloud is excluded by
|
|
69
114
|
* default (it has no local pid to attach), but `detach` opts in with
|
|
@@ -73,6 +118,9 @@ export function filterLivePool(sessions, opts = {}) {
|
|
|
73
118
|
* `hosts` scopes the sweep to named devices — the fan-out only dials them, and the
|
|
74
119
|
* pool is then filtered to `s.machine ∈ hosts` so a stray local row can't leak in.
|
|
75
120
|
* `statuses` narrows to the live-state words `--active` uses (orphan/crashed/…).
|
|
121
|
+
* `selector` (detach/stop) skips fleet SSH when local already has a unique live
|
|
122
|
+
* id or a local collision; omit it for browse (`focus` with no id) so the picker
|
|
123
|
+
* still all-settles.
|
|
76
124
|
*/
|
|
77
125
|
export async function gatherLiveTargets(local, opts = {}) {
|
|
78
126
|
const self = machineId();
|
|
@@ -81,9 +129,16 @@ export async function gatherLiveTargets(local, opts = {}) {
|
|
|
81
129
|
if (!s.machine)
|
|
82
130
|
s.machine = self;
|
|
83
131
|
let active = localActive;
|
|
84
|
-
|
|
132
|
+
const skipRemote = local || (opts.selector
|
|
133
|
+
? shouldSkipRemoteSweep(opts.hosts?.length
|
|
134
|
+
? filterLivePool(localLiveSelectorMatches(localActive, opts.selector), { hosts: opts.hosts })
|
|
135
|
+
: localLiveSelectorMatches(localActive, opts.selector), opts.selector)
|
|
136
|
+
: false);
|
|
137
|
+
if (!skipRemote) {
|
|
85
138
|
try {
|
|
86
|
-
const remote = await gatherRemoteActive(opts.hosts
|
|
139
|
+
const remote = await gatherRemoteActive(opts.hosts, {
|
|
140
|
+
earlyExit: liveSelectorEarlyExit(opts.selector),
|
|
141
|
+
});
|
|
87
142
|
active = dedupeByMachineSession([...localActive, ...remote.sessions]);
|
|
88
143
|
}
|
|
89
144
|
catch { /* remote sweep is best-effort */ }
|
|
@@ -320,7 +375,10 @@ export async function jumpTo(s, self, fallback = refuseFallback) {
|
|
|
320
375
|
// attach-time repair is what closes the gap now (RUSH-2435).
|
|
321
376
|
if (session)
|
|
322
377
|
await ensureSessionHookRepaired(session, socket);
|
|
323
|
-
|
|
378
|
+
const code = await attachTmux({ socket, args: ['attach-session', '-t', tgt] });
|
|
379
|
+
if (session)
|
|
380
|
+
await teardownIfAgentExited(session, socket);
|
|
381
|
+
process.exit(code);
|
|
324
382
|
}
|
|
325
383
|
// Path A: local Ghostty — focus its tab (Cmd+N via System Events).
|
|
326
384
|
if (s.host === 'ghostty') {
|
package/dist/commands/menubar.js
CHANGED
|
@@ -27,8 +27,9 @@ function printStatus(s, opts = {}) {
|
|
|
27
27
|
return;
|
|
28
28
|
}
|
|
29
29
|
console.log(` app installed ${s.installedApp ? chalk.gray(s.installedApp) : chalk.gray('no')}`);
|
|
30
|
-
console.log(` installed
|
|
31
|
-
console.log(`
|
|
30
|
+
console.log(` helper installed ${s.installedVersion ? chalk.gray(s.installedVersion) : chalk.gray('unknown')}`);
|
|
31
|
+
console.log(` helper available ${chalk.gray(s.currentVersion)}`);
|
|
32
|
+
console.log(` CLI version ${chalk.gray(s.cliVersion)}`);
|
|
32
33
|
console.log(` bundle source ${s.source ? chalk.gray(s.source) : chalk.red('missing (cannot enable)')}`);
|
|
33
34
|
console.log(` disabled by user ${yn(s.disabledByUser)}`);
|
|
34
35
|
// Two copies of the INSTALLED bundle is the duplicate the user sees as two
|
|
@@ -80,8 +81,9 @@ function printSetupResult(r) {
|
|
|
80
81
|
function printDoctorReport(r) {
|
|
81
82
|
console.log(chalk.bold('AGI Menu doctor\n'));
|
|
82
83
|
console.log(` install path ${r.installPath ? chalk.gray(r.installPath) : chalk.red('not installed')}`);
|
|
83
|
-
console.log(` installed
|
|
84
|
-
console.log(`
|
|
84
|
+
console.log(` helper installed ${r.installedVersion ? chalk.gray(r.installedVersion) : chalk.gray('unknown')}`);
|
|
85
|
+
console.log(` helper available ${chalk.gray(r.currentVersion)}${r.versionMatches ? '' : chalk.yellow(' (newer helper available — `agents menubar setup` installs it)')}`);
|
|
86
|
+
console.log(` CLI version ${chalk.gray(r.cliVersion)}`);
|
|
85
87
|
const identity = r.signingIdentity === 'developer-id'
|
|
86
88
|
? chalk.green('Developer ID (update-stable)')
|
|
87
89
|
: r.signingIdentity === 'ad-hoc'
|
|
@@ -358,8 +358,8 @@ async function guardAgainstDuplicateMonitor(config, force) {
|
|
|
358
358
|
// different arguments (another PR) are not a clash and still pass.
|
|
359
359
|
if (process.env[NO_MONITOR_FANOUT_ENV])
|
|
360
360
|
return;
|
|
361
|
-
const fleet = await gatherFleetMonitors();
|
|
362
361
|
const mine = monitorFingerprint(config);
|
|
362
|
+
const fleet = await gatherFleetMonitors({ againstFingerprint: mine });
|
|
363
363
|
const clash = fleet.monitors.find((r) => monitorFingerprint(r.monitor) === mine);
|
|
364
364
|
if (clash) {
|
|
365
365
|
stderrLine(chalk.red(`Monitor '${clash.monitor.name}' on ${chalk.bold(clash.machine)} already watches this exact source and fires the same action.`));
|
package/dist/commands/repo.js
CHANGED
|
@@ -29,7 +29,7 @@ function resolveRepoPath(target) {
|
|
|
29
29
|
return path.join(HOME, `.agents-${trimmed}`);
|
|
30
30
|
}
|
|
31
31
|
import { applyExtraAliasToVersions, ensureAgentsDir, getExtraRepoDir, getSystemAgentsDir, getUserAgentsDir, readMeta, resolveExtraRepoDir, updateMeta, } from '../lib/state.js';
|
|
32
|
-
import { parseSource, pullRepo, commitAndPush, isGitRepo, isSystemRepoOrigin, adoptRepo, getRemoteUrl, sameGitRemote, displayHomePath, syncRepoGit, } from '../lib/git.js';
|
|
32
|
+
import { parseSource, pullRepo, commitAndPush, isGitRepo, isSystemRepoOrigin, adoptRepo, adoptUserRepoIfNeeded, recordUserRepoRemote, resolveUserRepoRemoteUrl, getRemoteUrl, sameGitRemote, displayHomePath, syncRepoGit, } from '../lib/git.js';
|
|
33
33
|
import { DEFAULT_SYSTEM_REPO } from '../lib/types.js';
|
|
34
34
|
import { ALL_AGENT_IDS, isAgentName, resolveAgentName } from '../lib/agents.js';
|
|
35
35
|
import { refresh } from '../lib/refresh.js';
|
|
@@ -1126,8 +1126,33 @@ Examples:
|
|
|
1126
1126
|
}
|
|
1127
1127
|
for (const t of targets) {
|
|
1128
1128
|
if (!fs.existsSync(t.dir) || !isGitRepo(t.dir)) {
|
|
1129
|
-
|
|
1130
|
-
|
|
1129
|
+
// The user repo self-heals in place instead of being skipped: adopt a
|
|
1130
|
+
// non-git / partial checkout against its remote, preserving runtime
|
|
1131
|
+
// state, then fall through to the normal sync (PHNX-3301). System is
|
|
1132
|
+
// cloned by setup and extras by `repo add`, so those still skip.
|
|
1133
|
+
if (t.alias === 'user' && fs.existsSync(t.dir)) {
|
|
1134
|
+
const spinner = ora(`Adopting ${formatRepoTarget(t.alias, t.dir)} in place...`).start();
|
|
1135
|
+
const adopted = await adoptUserRepoIfNeeded(t.dir);
|
|
1136
|
+
if (!adopted || !adopted.success) {
|
|
1137
|
+
spinner.fail(`${formatRepoTarget(t.alias, t.dir)}: ${adopted?.error ?? 'adopt failed'}`);
|
|
1138
|
+
if (adopted?.needsUrl)
|
|
1139
|
+
console.log(chalk.gray(' git-back it: agents repo pull user <git-url>'));
|
|
1140
|
+
process.exitCode = 1;
|
|
1141
|
+
continue;
|
|
1142
|
+
}
|
|
1143
|
+
spinner.succeed(`${formatRepoTarget(t.alias, t.dir)}: adopted in place → ${adopted.commit} (${adopted.materialized} file(s) materialized${adopted.reconciledAgentsYaml ? ', agents.yaml reconciled' : ''})`);
|
|
1144
|
+
if (adopted.localEdits.length > 0) {
|
|
1145
|
+
console.log(chalk.yellow(` kept ${adopted.localEdits.length} local edit(s): ${adopted.localEdits.slice(0, 5).join(', ')}${adopted.localEdits.length > 5 ? ', …' : ''}`));
|
|
1146
|
+
}
|
|
1147
|
+
if (adopted.agentsYamlBackup) {
|
|
1148
|
+
console.log(chalk.gray(` saved the previous agents.yaml to ${adopted.agentsYamlBackup}`));
|
|
1149
|
+
}
|
|
1150
|
+
// Now a git repo — fall through to the normal sync below.
|
|
1151
|
+
}
|
|
1152
|
+
else {
|
|
1153
|
+
console.log(chalk.yellow(` ${t.alias}: not a git repo, skipping`));
|
|
1154
|
+
continue;
|
|
1155
|
+
}
|
|
1131
1156
|
}
|
|
1132
1157
|
const push = t.alias !== 'system';
|
|
1133
1158
|
const spinner = ora(`Syncing ${formatRepoTarget(t.alias, t.dir)}...`).start();
|
|
@@ -1136,6 +1161,9 @@ Examples:
|
|
|
1136
1161
|
const pushed = result.pushed ? ' (pushed)' : '';
|
|
1137
1162
|
spinner.succeed(`${formatRepoTarget(t.alias, t.dir)}: ${result.commit}${pushed}`);
|
|
1138
1163
|
if (t.alias === 'user') {
|
|
1164
|
+
const u = resolveUserRepoRemoteUrl(t.dir);
|
|
1165
|
+
if (u)
|
|
1166
|
+
recordUserRepoRemote(t.dir, u);
|
|
1139
1167
|
const { reconcileDeviceDiscoveryPolicies } = await import('../lib/devices/discovery-policy.js');
|
|
1140
1168
|
await reconcileDeviceDiscoveryPolicies();
|
|
1141
1169
|
}
|
|
@@ -22,6 +22,7 @@ export interface ResumeOptions extends StrictResumeOptions {
|
|
|
22
22
|
local?: boolean;
|
|
23
23
|
}
|
|
24
24
|
export declare function registerSessionsResumeCommand(sessionsCmd: Command): void;
|
|
25
|
+
export declare function sessionsResumeAction(query: string | undefined, prompt: string | undefined, options: ResumeOptions): Promise<void>;
|
|
25
26
|
/** IDs and tmux aliases are actions, not picker search text. Human phrases keep
|
|
26
27
|
* the existing pre-filtered picker, while an explicit identity resumes directly. */
|
|
27
28
|
export declare function isDirectResumeSelector(query: string): boolean;
|
|
@@ -28,6 +28,7 @@ import { looksLikeSessionId } from '../lib/session/discover.js';
|
|
|
28
28
|
import { machineId } from '../lib/session/sync/config.js';
|
|
29
29
|
import { sessionOriginDevice, sessionRecoveryDestinationMatches } from '../lib/session/recovery.js';
|
|
30
30
|
import { runStrictResume, wantsStrictResume } from './resume.js';
|
|
31
|
+
import { attachLocalLiveSelector } from '../lib/session/local-tmux-attach.js';
|
|
31
32
|
/** Opening more than this many live sessions at once asks for confirmation first. */
|
|
32
33
|
export const CONFIRM_THRESHOLD = 5;
|
|
33
34
|
export function registerSessionsResumeCommand(sessionsCmd) {
|
|
@@ -101,7 +102,7 @@ export function registerSessionsResumeCommand(sessionsCmd) {
|
|
|
101
102
|
await sessionsResumeAction(query, prompt, options);
|
|
102
103
|
});
|
|
103
104
|
}
|
|
104
|
-
async function sessionsResumeAction(query, prompt, options) {
|
|
105
|
+
export async function sessionsResumeAction(query, prompt, options) {
|
|
105
106
|
const strictOpts = {
|
|
106
107
|
mode: options.mode,
|
|
107
108
|
interactive: options.interactive,
|
|
@@ -115,8 +116,18 @@ async function sessionsResumeAction(query, prompt, options) {
|
|
|
115
116
|
// must go through sessions focus so they cannot silently fork a copy
|
|
116
117
|
// (AGI EXT still shells `sessions resume <id> --local`).
|
|
117
118
|
if (query && (isDirectResumeSelector(query) || wantsStrictResume(prompt, strictOpts))) {
|
|
119
|
+
const hosts = options.device ? [options.device] : [];
|
|
120
|
+
// PHNX-3292: a live LOCAL tmux pane (the exact alias, or a unique 8-hex
|
|
121
|
+
// short id) attaches immediately, before any fleet SSH — the product rule
|
|
122
|
+
// is "first unique match wins," and a pane already on this box is the
|
|
123
|
+
// fastest possible match. This applies to bare resume too, not just
|
|
124
|
+
// `--attach-only`: a prompt/mode/headless/cwd override still means the
|
|
125
|
+
// caller wants strict resume semantics (a scripted continue), so that
|
|
126
|
+
// case is excluded via wantsStrictResume.
|
|
127
|
+
if (!wantsStrictResume(prompt, strictOpts) && await attachLocalLiveSelector(query.trim(), hosts)) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
118
130
|
if (resumeUsesLifecycleDispatch(query, prompt, options)) {
|
|
119
|
-
const hosts = options.device ? [options.device] : [];
|
|
120
131
|
await dispatchSessionLifecycleInPlace(query.trim(), hosts, !!options.attachOnly, !!options.local);
|
|
121
132
|
return;
|
|
122
133
|
}
|
|
@@ -32,7 +32,7 @@ export function registerSessionsStopCommand(program) {
|
|
|
32
32
|
});
|
|
33
33
|
}
|
|
34
34
|
export async function stopSessionAction(id, opts = {}) {
|
|
35
|
-
const { self, activeById } = await gatherLiveTargets(!!opts.local, { includeCloud: true });
|
|
35
|
+
const { self, activeById } = await gatherLiveTargets(!!opts.local, { includeCloud: true, selector: id });
|
|
36
36
|
const resolved = resolveOne(activeById, id);
|
|
37
37
|
if ('error' in resolved) {
|
|
38
38
|
console.error(chalk.red(resolved.error));
|
|
@@ -826,27 +826,37 @@ export type MetadataResolveOutcome = {
|
|
|
826
826
|
};
|
|
827
827
|
/**
|
|
828
828
|
* A match unique enough to resolve on the FIRST peer that returns it and cancel
|
|
829
|
-
* the rest of the fleet sweep: a full session UUID
|
|
829
|
+
* the rest of the fleet sweep: a full session UUID (globally unique), a live
|
|
830
|
+
* tmux alias, or a bare 8-hex short id — all three name at most one session PER
|
|
831
|
+
* ANSWERING peer, so the first reachable hit is enough (PHNX-3292: the alias/
|
|
832
|
+
* short-id case is exactly what `agents tmux ls` prints, and it used to stay
|
|
833
|
+
* all-settle right alongside a genuine keyword search).
|
|
830
834
|
*
|
|
831
|
-
*
|
|
832
|
-
*
|
|
833
|
-
*
|
|
834
|
-
*
|
|
835
|
-
*
|
|
836
|
-
*
|
|
837
|
-
*
|
|
835
|
+
* A session **label** is deliberately NOT definitive here even on an exact
|
|
836
|
+
* match: labels are free-form and can collide, so a distinct session may carry
|
|
837
|
+
* the same label on a peer that has not answered yet — early-exiting on the
|
|
838
|
+
* first would silently, and nondeterministically, resume the wrong session
|
|
839
|
+
* (whichever peer replied first). Labels therefore stay all-settle so a
|
|
840
|
+
* cross-machine label conflict surfaces as an ambiguity, and a short-id PREFIX
|
|
841
|
+
* *shorter* than the full 8-hex width stays all-settle for the same reason
|
|
842
|
+
* (RUSH-2203).
|
|
838
843
|
*
|
|
839
844
|
* This is about cancelling a sweep that is still IN FLIGHT, where a peer that
|
|
840
845
|
* has not answered yet is still expected to. It is deliberately stricter than
|
|
841
846
|
* `isUniqueEnoughSelector`, which decides what to do once the sweep is OVER and
|
|
842
|
-
* a peer has definitively not answered — see SES-9a.
|
|
847
|
+
* a peer has definitively not answered — see SES-9a. A collision between two
|
|
848
|
+
* peers that BOTH answer before the abort lands is still caught by the ordinary
|
|
849
|
+
* uniqueness gate in `metadataResolveOutcome`/`fleetCandidatesByQuery`; only a
|
|
850
|
+
* peer that has not answered yet, at the moment of the FIRST hit, is the
|
|
851
|
+
* accepted risk (documented in the PHNX-3292 plan).
|
|
843
852
|
*/
|
|
844
853
|
export declare function isDefinitiveMatch(session: SessionMeta, selector: string): boolean;
|
|
845
854
|
/**
|
|
846
|
-
* Whether a selector may enable early-exit on the cancellable fan-out
|
|
847
|
-
*
|
|
848
|
-
*
|
|
849
|
-
*
|
|
855
|
+
* Whether a selector may enable early-exit on the cancellable fan-out: a full
|
|
856
|
+
* UUID, a tmux alias, or an exact 8-hex short id — every shape that is globally
|
|
857
|
+
* unique enough for the first hit to be the only hit worth waiting for. Labels,
|
|
858
|
+
* keywords, and short-id prefixes narrower than 8 hex stay all-settle: their
|
|
859
|
+
* uniqueness (or conflict) is only knowable once every peer has answered. See
|
|
850
860
|
* {@link isDefinitiveMatch}.
|
|
851
861
|
*/
|
|
852
862
|
export declare function selectorAllowsEarlyExit(selector: string): boolean;
|
|
@@ -18,10 +18,10 @@ import { sanitizeForTerminal, redactSecrets } from '../lib/redact.js';
|
|
|
18
18
|
import { resolveProjectKey } from '../lib/project-key.js';
|
|
19
19
|
import { listProjectDefs, resolveProjectNameForCwd } from '../lib/projects.js';
|
|
20
20
|
import ora from 'ora';
|
|
21
|
-
import { SESSION_AGENTS } from '../lib/session/types.js';
|
|
21
|
+
import { SESSION_AGENTS, isAgentTmuxAlias } from '../lib/session/types.js';
|
|
22
22
|
import { discoverArtifacts, readArtifact, resolveArtifact } from '../lib/session/artifacts.js';
|
|
23
23
|
import { looksLikePath, toComparablePath, homeDir, needsWindowsShell, composeWin32CommandLine } from '../lib/platform/index.js';
|
|
24
|
-
import { getActiveSessions, describeActiveDiscoveryHealth, sessionProcessIsLocal, backfillActiveRowsFromIndex, isRunningLiveSession, serializeActiveSessionsForJson, serializeSessionsJson } from '../lib/session/active.js';
|
|
24
|
+
import { getActiveSessions, describeActiveDiscoveryHealth, sessionProcessIsLocal, backfillActiveRowsFromIndex, isRunningLiveSession, serializeActiveSessionsForJson, serializeSessionsJson, shortIdFromName } from '../lib/session/active.js';
|
|
25
25
|
export { activeSessionProjectKey, backfillActiveRowsFromIndex, backfillActiveRowsFromMeta, isRunningLiveSession, serializeActiveSessionsForJson, serializeSessionsJson } from '../lib/session/active.js';
|
|
26
26
|
import { enumerateGhosttyTabs, assignGhosttyTabs } from '../lib/session/ghostty-tabs.js';
|
|
27
27
|
import { mapPanesToTargets, listClients } from '../lib/tmux/session.js';
|
|
@@ -4579,35 +4579,53 @@ function modeFlag(mode) {
|
|
|
4579
4579
|
return undefined; // summary — the peer's default render
|
|
4580
4580
|
}
|
|
4581
4581
|
const FULL_SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
4582
|
+
/** Width of `SessionMeta.shortId` / the hex a tmux alias embeds (`deriveShortId`). */
|
|
4583
|
+
const SHORT_SESSION_ID_RE = /^[0-9a-f]{8}$/i;
|
|
4582
4584
|
/**
|
|
4583
4585
|
* A match unique enough to resolve on the FIRST peer that returns it and cancel
|
|
4584
|
-
* the rest of the fleet sweep: a full session UUID
|
|
4586
|
+
* the rest of the fleet sweep: a full session UUID (globally unique), a live
|
|
4587
|
+
* tmux alias, or a bare 8-hex short id — all three name at most one session PER
|
|
4588
|
+
* ANSWERING peer, so the first reachable hit is enough (PHNX-3292: the alias/
|
|
4589
|
+
* short-id case is exactly what `agents tmux ls` prints, and it used to stay
|
|
4590
|
+
* all-settle right alongside a genuine keyword search).
|
|
4585
4591
|
*
|
|
4586
|
-
*
|
|
4587
|
-
*
|
|
4588
|
-
*
|
|
4589
|
-
*
|
|
4590
|
-
*
|
|
4591
|
-
*
|
|
4592
|
-
*
|
|
4592
|
+
* A session **label** is deliberately NOT definitive here even on an exact
|
|
4593
|
+
* match: labels are free-form and can collide, so a distinct session may carry
|
|
4594
|
+
* the same label on a peer that has not answered yet — early-exiting on the
|
|
4595
|
+
* first would silently, and nondeterministically, resume the wrong session
|
|
4596
|
+
* (whichever peer replied first). Labels therefore stay all-settle so a
|
|
4597
|
+
* cross-machine label conflict surfaces as an ambiguity, and a short-id PREFIX
|
|
4598
|
+
* *shorter* than the full 8-hex width stays all-settle for the same reason
|
|
4599
|
+
* (RUSH-2203).
|
|
4593
4600
|
*
|
|
4594
4601
|
* This is about cancelling a sweep that is still IN FLIGHT, where a peer that
|
|
4595
4602
|
* has not answered yet is still expected to. It is deliberately stricter than
|
|
4596
4603
|
* `isUniqueEnoughSelector`, which decides what to do once the sweep is OVER and
|
|
4597
|
-
* a peer has definitively not answered — see SES-9a.
|
|
4604
|
+
* a peer has definitively not answered — see SES-9a. A collision between two
|
|
4605
|
+
* peers that BOTH answer before the abort lands is still caught by the ordinary
|
|
4606
|
+
* uniqueness gate in `metadataResolveOutcome`/`fleetCandidatesByQuery`; only a
|
|
4607
|
+
* peer that has not answered yet, at the moment of the FIRST hit, is the
|
|
4608
|
+
* accepted risk (documented in the PHNX-3292 plan).
|
|
4598
4609
|
*/
|
|
4599
4610
|
export function isDefinitiveMatch(session, selector) {
|
|
4600
|
-
|
|
4611
|
+
const trimmed = selector.trim();
|
|
4612
|
+
if (FULL_SESSION_ID_RE.test(trimmed)) {
|
|
4613
|
+
return session.id.toLowerCase() === trimmed.toLowerCase();
|
|
4614
|
+
}
|
|
4615
|
+
const shortId = shortIdFromName(trimmed) ?? (SHORT_SESSION_ID_RE.test(trimmed) ? trimmed : undefined);
|
|
4616
|
+
return !!shortId && session.shortId.toLowerCase() === shortId.toLowerCase();
|
|
4601
4617
|
}
|
|
4602
4618
|
/**
|
|
4603
|
-
* Whether a selector may enable early-exit on the cancellable fan-out
|
|
4604
|
-
*
|
|
4605
|
-
*
|
|
4606
|
-
*
|
|
4619
|
+
* Whether a selector may enable early-exit on the cancellable fan-out: a full
|
|
4620
|
+
* UUID, a tmux alias, or an exact 8-hex short id — every shape that is globally
|
|
4621
|
+
* unique enough for the first hit to be the only hit worth waiting for. Labels,
|
|
4622
|
+
* keywords, and short-id prefixes narrower than 8 hex stay all-settle: their
|
|
4623
|
+
* uniqueness (or conflict) is only knowable once every peer has answered. See
|
|
4607
4624
|
* {@link isDefinitiveMatch}.
|
|
4608
4625
|
*/
|
|
4609
4626
|
export function selectorAllowsEarlyExit(selector) {
|
|
4610
|
-
|
|
4627
|
+
const trimmed = selector.trim();
|
|
4628
|
+
return FULL_SESSION_ID_RE.test(trimmed) || isAgentTmuxAlias(trimmed) || SHORT_SESSION_ID_RE.test(trimmed);
|
|
4611
4629
|
}
|
|
4612
4630
|
/** Resolve a fleet sweep through the same canonical full-id / prefix resolver as
|
|
4613
4631
|
* local lookups, then group copies by logical session id. Synced mirrors of one
|
|
@@ -4725,9 +4743,11 @@ export function metadataResolveOutcome(localMatches, remote, selector) {
|
|
|
4725
4743
|
// 6-char word like `facade` or `decade` is not treated as an identifier.
|
|
4726
4744
|
// A label stays fail-closed at any length: labels are free-form and collide by
|
|
4727
4745
|
// design, so one hit there really is a guess.
|
|
4728
|
-
// (RUSH-2203's early-exit
|
|
4729
|
-
//
|
|
4730
|
-
//
|
|
4746
|
+
// (RUSH-2203's early-exit — see isDefinitiveMatch — cancels the sweep
|
|
4747
|
+
// mid-flight, where a peer that has not answered YET is still expected to
|
|
4748
|
+
// answer, so it stays narrower than this post-sweep gate even though
|
|
4749
|
+
// PHNX-3292 widened it past UUID to alias/8-hex too. Here the sweep is
|
|
4750
|
+
// already over.)
|
|
4731
4751
|
if (isUniqueEnoughSelector(selector) && candidates.length === 1) {
|
|
4732
4752
|
return { kind: 'resolved', session: candidates[0].hits[0].session };
|
|
4733
4753
|
}
|
|
@@ -10,8 +10,11 @@
|
|
|
10
10
|
import type { Command } from 'commander';
|
|
11
11
|
/**
|
|
12
12
|
* Interactive browser setup. Returns true if a usable default profile exists
|
|
13
|
-
* afterwards, false if
|
|
14
|
-
*
|
|
13
|
+
* afterwards, false if none exists / the user backed out. Non-interactively it
|
|
14
|
+
* only RECOGNIZES an existing profile — it never creates one (PHNX-3296), since
|
|
15
|
+
* silently minting a logged-out `auto-chrome` on a headless box is the exact bug
|
|
16
|
+
* that removed; a headless box gets its browser from the fleet hub. Never throws
|
|
17
|
+
* on cancel — the `agents setup` hub relies on that.
|
|
15
18
|
*/
|
|
16
19
|
export declare function runBrowserWizard(): Promise<boolean>;
|
|
17
20
|
/** Register `agents setup browser` under the parent `setup` command. */
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import chalk from 'chalk';
|
|
11
11
|
import { setConfigValue } from '../lib/device-config.js';
|
|
12
|
-
import {
|
|
12
|
+
import { listInstalledBrowsers } from '../lib/browser/chrome.js';
|
|
13
13
|
import { DEFAULT_BROWSER_PROFILE_NAME, getAutoDetectedProfile, createProfile, findFreeProfilePort, getConfiguredDefaultProfileName, } from '../lib/browser/profiles.js';
|
|
14
14
|
import { DEFAULT_VIEWPORT } from '../lib/browser/devices.js';
|
|
15
15
|
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
@@ -18,25 +18,27 @@ const INSTALL_HINT = 'Install one of: Google Chrome, Brave, Microsoft Edge, Chro
|
|
|
18
18
|
'(Safari and Firefox are not supported — agents browser drives over the Chrome DevTools Protocol.)';
|
|
19
19
|
/**
|
|
20
20
|
* Interactive browser setup. Returns true if a usable default profile exists
|
|
21
|
-
* afterwards, false if
|
|
22
|
-
*
|
|
21
|
+
* afterwards, false if none exists / the user backed out. Non-interactively it
|
|
22
|
+
* only RECOGNIZES an existing profile — it never creates one (PHNX-3296), since
|
|
23
|
+
* silently minting a logged-out `auto-chrome` on a headless box is the exact bug
|
|
24
|
+
* that removed; a headless box gets its browser from the fleet hub. Never throws
|
|
25
|
+
* on cancel — the `agents setup` hub relies on that.
|
|
23
26
|
*/
|
|
24
27
|
export async function runBrowserWizard() {
|
|
25
28
|
if (!isInteractiveTerminal()) {
|
|
26
|
-
// Non-interactive
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
29
|
+
// Non-interactive / headless: recognize an existing default, but NEVER mint
|
|
30
|
+
// one. Silently auto-detecting an installed browser and creating an
|
|
31
|
+
// `auto-chrome` here is exactly the bug PHNX-3296 removed — a headless box
|
|
32
|
+
// gets its browser from the fleet hub (`browser.device`), or from an
|
|
33
|
+
// interactive pick later.
|
|
32
34
|
const existing = await getAutoDetectedProfile();
|
|
33
35
|
if (existing) {
|
|
34
36
|
console.log(chalk.dim(`Browser profile "${existing.name}" already exists.`));
|
|
35
37
|
return true;
|
|
36
38
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return
|
|
39
|
+
console.log(chalk.dim('No default browser profile on this machine. Re-run `agents setup browser` in an ' +
|
|
40
|
+
'interactive terminal to pick one, or use the fleet hub: agents config set browser.device <host>.'));
|
|
41
|
+
return false;
|
|
40
42
|
}
|
|
41
43
|
const installed = listInstalledBrowsers();
|
|
42
44
|
if (installed.length === 0) {
|
|
@@ -86,23 +88,6 @@ export async function runBrowserWizard() {
|
|
|
86
88
|
printOnboardingNextStep(profile.name);
|
|
87
89
|
return true;
|
|
88
90
|
}
|
|
89
|
-
/** Build + persist a `default` profile pinned to the first installed browser. */
|
|
90
|
-
async function createAutoDefault() {
|
|
91
|
-
const detected = findFirstInstalledBrowser();
|
|
92
|
-
if (!detected)
|
|
93
|
-
throw new Error('No supported browser found.');
|
|
94
|
-
const freePort = await findFreeProfilePort();
|
|
95
|
-
const profile = {
|
|
96
|
-
name: DEFAULT_BROWSER_PROFILE_NAME,
|
|
97
|
-
description: `Auto-detected ${detected.browserType} profile`,
|
|
98
|
-
browser: detected.browserType,
|
|
99
|
-
binary: detected.binary,
|
|
100
|
-
endpoints: [`cdp://127.0.0.1:${freePort}`],
|
|
101
|
-
viewport: { width: DEFAULT_VIEWPORT.width, height: DEFAULT_VIEWPORT.height },
|
|
102
|
-
};
|
|
103
|
-
await createProfile(profile);
|
|
104
|
-
return profile;
|
|
105
|
-
}
|
|
106
91
|
/** Offer to make `name` this machine's default browser profile (device-local). */
|
|
107
92
|
async function maybeSetDeviceDefault(name, confirm) {
|
|
108
93
|
const current = getConfiguredDefaultProfileName();
|
|
@@ -40,10 +40,29 @@ export declare function maybePickInteractiveHost(): Promise<boolean>;
|
|
|
40
40
|
* Offer to pin this machine's default browser profile to a chosen browser.
|
|
41
41
|
* Only asked when there is nothing to preserve: no configured device default
|
|
42
42
|
* AND no existing `default` profile (an existing profile is the user's earlier
|
|
43
|
-
* choice — never re-pinned behind their back).
|
|
44
|
-
*
|
|
43
|
+
* choice — never re-pinned behind their back). The list is the installed
|
|
44
|
+
* Chromium-family browsers plus a "None — this box uses the fleet hub" opt-out.
|
|
45
|
+
*
|
|
46
|
+
* This is the ONE place a default browser is chosen for a machine (PHNX-3296):
|
|
47
|
+
* a bare `agents browser start` no longer auto-detects and mints one, so picking
|
|
48
|
+
* here (or `agents browser use` / `agents setup browser` later) is how a box
|
|
49
|
+
* gets a local default at all. Picking "None" leaves it to the fleet hub
|
|
50
|
+
* (`browser.device`). Returns true when a profile was created and set as the
|
|
51
|
+
* device default. Silent no-op non-TTY — a headless box relies on the hub.
|
|
45
52
|
*/
|
|
46
|
-
export declare function maybePickBrowserProfile(
|
|
53
|
+
export declare function maybePickBrowserProfile(deps?: {
|
|
54
|
+
/** Force interactivity in a test; defaults to a real TTY probe. */
|
|
55
|
+
interactive?: boolean;
|
|
56
|
+
/** Inject the prompt so the pick is testable without a TTY. */
|
|
57
|
+
select?: (config: {
|
|
58
|
+
message: string;
|
|
59
|
+
default?: string;
|
|
60
|
+
choices: Array<{
|
|
61
|
+
name: string;
|
|
62
|
+
value: string;
|
|
63
|
+
}>;
|
|
64
|
+
}) => Promise<string>;
|
|
65
|
+
}): Promise<boolean>;
|
|
47
66
|
/**
|
|
48
67
|
* The bare-`agents setup` preferences step: interactive host, then browser.
|
|
49
68
|
* Runs AFTER the capability hub so it never delays the bootstrap. Never
|