@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.
Files changed (85) hide show
  1. package/CHANGELOG.md +219 -0
  2. package/dist/bootstrap.js +1 -1
  3. package/dist/commands/attach.js +7 -0
  4. package/dist/commands/browser.d.ts +10 -0
  5. package/dist/commands/browser.js +191 -54
  6. package/dist/commands/config.js +20 -0
  7. package/dist/commands/daemon.d.ts +2 -0
  8. package/dist/commands/daemon.js +8 -4
  9. package/dist/commands/detach.js +1 -1
  10. package/dist/commands/feedback.js +1 -1
  11. package/dist/commands/focus.d.ts +1 -10
  12. package/dist/commands/focus.js +14 -79
  13. package/dist/commands/go.d.ts +26 -0
  14. package/dist/commands/go.js +63 -5
  15. package/dist/commands/menubar.js +6 -4
  16. package/dist/commands/monitors.js +1 -1
  17. package/dist/commands/repo.js +31 -3
  18. package/dist/commands/sessions-resume.d.ts +1 -0
  19. package/dist/commands/sessions-resume.js +13 -2
  20. package/dist/commands/sessions-stop.js +1 -1
  21. package/dist/commands/sessions.d.ts +23 -13
  22. package/dist/commands/sessions.js +40 -20
  23. package/dist/commands/setup-browser.d.ts +5 -2
  24. package/dist/commands/setup-browser.js +14 -29
  25. package/dist/commands/setup-preferences.d.ts +22 -3
  26. package/dist/commands/setup-preferences.js +25 -8
  27. package/dist/commands/share.js +12 -8
  28. package/dist/commands/status.js +5 -0
  29. package/dist/commands/sync.js +58 -2
  30. package/dist/commands/tmux.d.ts +8 -1
  31. package/dist/commands/tmux.js +167 -17
  32. package/dist/commands/traces.js +32 -4
  33. package/dist/lib/browser/ipc.d.ts +44 -0
  34. package/dist/lib/browser/ipc.js +120 -8
  35. package/dist/lib/browser/profiles.d.ts +39 -17
  36. package/dist/lib/browser/profiles.js +51 -52
  37. package/dist/lib/browser/runtime-state.d.ts +4 -2
  38. package/dist/lib/browser/runtime-state.js +4 -2
  39. package/dist/lib/browser/service.js +4 -3
  40. package/dist/lib/channels/owner-forward.d.ts +88 -0
  41. package/dist/lib/channels/owner-forward.js +116 -0
  42. package/dist/lib/channels/owner-sink.js +7 -0
  43. package/dist/lib/claude-statusline.d.ts +9 -0
  44. package/dist/lib/claude-statusline.js +45 -4
  45. package/dist/lib/computer/ssh-tunnel.d.ts +7 -6
  46. package/dist/lib/computer/ssh-tunnel.js +13 -8
  47. package/dist/lib/config-keys.d.ts +4 -3
  48. package/dist/lib/config-keys.js +9 -2
  49. package/dist/lib/device-config.js +23 -0
  50. package/dist/lib/exec.d.ts +8 -0
  51. package/dist/lib/exec.js +7 -0
  52. package/dist/lib/factory/snapshot.d.ts +1 -1
  53. package/dist/lib/factory/snapshot.js +1 -1
  54. package/dist/lib/feed-broadcast.js +15 -1
  55. package/dist/lib/git.d.ts +93 -0
  56. package/dist/lib/git.js +232 -0
  57. package/dist/lib/helper-download.d.ts +12 -2
  58. package/dist/lib/helper-download.js +12 -2
  59. package/dist/lib/installations/migrate.js +4 -4
  60. package/dist/lib/menubar/install-menubar.d.ts +71 -8
  61. package/dist/lib/menubar/install-menubar.js +183 -24
  62. package/dist/lib/monitors/remote.d.ts +18 -1
  63. package/dist/lib/monitors/remote.js +15 -2
  64. package/dist/lib/notify.d.ts +7 -0
  65. package/dist/lib/notify.js +15 -1
  66. package/dist/lib/session/local-tmux-attach.d.ts +69 -0
  67. package/dist/lib/session/local-tmux-attach.js +164 -0
  68. package/dist/lib/session/remote-active.d.ts +8 -0
  69. package/dist/lib/session/remote-active.js +1 -0
  70. package/dist/lib/share/publish.d.ts +8 -11
  71. package/dist/lib/share/publish.js +16 -20
  72. package/dist/lib/share/worker-template.js +99 -12
  73. package/dist/lib/star-nudge.d.ts +2 -2
  74. package/dist/lib/star-nudge.js +2 -2
  75. package/dist/lib/state.d.ts +1 -1
  76. package/dist/lib/state.js +2 -2
  77. package/dist/lib/sync-status.d.ts +17 -0
  78. package/dist/lib/sync-status.js +21 -2
  79. package/dist/lib/tmux/index.d.ts +1 -1
  80. package/dist/lib/tmux/index.js +1 -1
  81. package/dist/lib/tmux/session.d.ts +10 -0
  82. package/dist/lib/tmux/session.js +29 -0
  83. package/dist/lib/traces/sync.d.ts +30 -0
  84. package/dist/lib/traces/sync.js +91 -10
  85. package/package.json +3 -3
@@ -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>;
@@ -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
- if (!local) {
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
- process.exit(await attachTmux({ socket, args: ['attach-session', '-t', tgt] }));
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') {
@@ -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 version ${s.installedVersion ? chalk.gray(s.installedVersion) : chalk.gray('unknown')}`);
31
- console.log(` current version ${chalk.gray(s.currentVersion)}`);
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 version ${r.installedVersion ? chalk.gray(r.installedVersion) : chalk.gray('unknown')}`);
84
- console.log(` CLI version ${chalk.gray(r.currentVersion)}${r.versionMatches ? '' : chalk.yellow(' (mismatch — `agents menubar setup` updates it)')}`);
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.`));
@@ -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
- console.log(chalk.yellow(` ${t.alias}: not a git repo, skipping`));
1130
- continue;
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, which is globally unique.
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
- * Only a full UUID qualifies. A session **label** is deliberately NOT definitive
832
- * here even on an exact match: labels are free-form and can collide, so a
833
- * distinct session may carry the same label on a peer that has not answered yet
834
- * — early-exiting on the first would silently, and nondeterministically, resume
835
- * the wrong session (whichever peer replied first). Labels therefore stay
836
- * all-settle so a cross-machine label conflict surfaces as an ambiguity, and a
837
- * short-id PREFIX stays all-settle for the same reason (RUSH-2203).
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 — only a
847
- * full UUID, which is globally unique so the first hit is the only hit. Labels,
848
- * keywords, and short-id prefixes stay all-settle: their uniqueness (or
849
- * conflict) is only knowable once every peer has answered. See
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, which is globally unique.
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
- * Only a full UUID qualifies. A session **label** is deliberately NOT definitive
4587
- * here even on an exact match: labels are free-form and can collide, so a
4588
- * distinct session may carry the same label on a peer that has not answered yet
4589
- * — early-exiting on the first would silently, and nondeterministically, resume
4590
- * the wrong session (whichever peer replied first). Labels therefore stay
4591
- * all-settle so a cross-machine label conflict surfaces as an ambiguity, and a
4592
- * short-id PREFIX stays all-settle for the same reason (RUSH-2203).
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
- return FULL_SESSION_ID_RE.test(selector) && session.id.toLowerCase() === selector.trim().toLowerCase();
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 — only a
4604
- * full UUID, which is globally unique so the first hit is the only hit. Labels,
4605
- * keywords, and short-id prefixes stay all-settle: their uniqueness (or
4606
- * conflict) is only knowable once every peer has answered. See
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
- return FULL_SESSION_ID_RE.test(selector);
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 stays UUID-only — see isDefinitiveMatch — because
4729
- // that cancels the sweep mid-flight, where a peer that has not answered YET is
4730
- // still expected to answer. Here the sweep is already over.)
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 the machine has no supported browser or the user backed
14
- * out. Never throws on cancel — the `agents setup` hub relies on that.
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 { findFirstInstalledBrowser, listInstalledBrowsers } from '../lib/browser/chrome.js';
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 the machine has no supported browser or the user backed
22
- * out. Never throws on cancel — the `agents setup` hub relies on that.
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: do the safe, deterministic thing (auto-detect + pin a
27
- // default) without prompting, or print the install hint and bail.
28
- if (!findFirstInstalledBrowser()) {
29
- console.error(chalk.red('No supported browser found.\n') + chalk.dim(INSTALL_HINT));
30
- return false;
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
- const created = await createAutoDefault();
38
- console.log(chalk.green(`Created browser profile "${created.name}" → ${created.browser}.`));
39
- return true;
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). Returns true when a profile was
44
- * created and set as the device default. Silent no-op non-TTY.
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(): Promise<boolean>;
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