@phnx-labs/agents-cli 1.22.106 → 1.22.107

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 CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.107
4
+
5
+ - **A bare interactive run places itself like `--device auto` (PHNX-4083).**
6
+ `agents run <harness>` with no prompt on a real TTY (no `--json`) now
7
+ auto-places onto a fleet worker — the same engine, pool, and banner as
8
+ `--device auto` — instead of always running on the machine you typed it at;
9
+ headless runs (any prompt, `--json`, teams/routines/hooks) are unchanged and
10
+ still run in place. To keep a local interactive run, pass
11
+ `--device <this machine>` or pick this machine (listed first) in the
12
+ `<harness>@` device picker; when placement finds no healthy device (empty
13
+ pool, or the PHNX-4051 stale-usage refusal) the run fails loud with the
14
+ placement error plus `Run here instead: agents run <harness> --device
15
+ <this machine>` — never a silent local fallback. Source:
16
+ `src/commands/exec.ts`, `src/commands/run-account-picker.ts`.
17
+
18
+ - **Run pickers move to `#` (account) and `@` (device), `#@` asks both
19
+ (PHNX-4083).** `agents run claude#` now opens the account picker (the old
20
+ `claude@` menu, same rows and code path); `agents run claude@` opens the new
21
+ fleet device picker — this machine first, offline rows disabled, cached-state
22
+ age in the prompt — and `claude#@` asks the account first, then the device,
23
+ dispatching with the picked account label. A picker combined with an explicit
24
+ pin of the same thing (`claude@2.1.218#`, `claude#work#`, `claude@@`) or a
25
+ conflicting flag (`--account`, `--device`/`--on`/`--computer`/`--host`) fails
26
+ loud; a cancelled menu launches nothing. Source: `src/commands/exec.ts`,
27
+ `src/lib/hosts/dispatch.ts`.
28
+
3
29
  ## 1.22.106
4
30
 
5
31
  - Fixed release packages omitting the session-tracking hook. Release qualification now uses the complete CLI build, so installed agents can record the account that owns each native session.
package/README.md CHANGED
@@ -202,11 +202,15 @@ agents run claude "refactor auth module" --mode edit --fallback codex,antigravit
202
202
  # Picks the signed-in account you haven't used recently.
203
203
  agents run claude "summarize recent commits" --strategy balanced
204
204
 
205
- # Or choose one account/version interactively for only this run.
206
- agents run claude@
207
- agents run codex@ "review this branch"
208
- agents run claude@ --device auto # pick the device, then choose there
209
- agents run claude@ --device yosemite-s0 # choose from one device's accounts
205
+ # Or choose one account/version interactively for only this run (# = account picker).
206
+ agents run claude#
207
+ agents run codex# "review this branch"
208
+ agents run claude# --device auto # pick the device, then choose there
209
+ agents run claude# --device yosemite-s0 # choose from one device's accounts
210
+
211
+ # @ picks the device instead; #@ asks both (account first, then device).
212
+ agents run claude@ # pick the device for this run
213
+ agents run claude#@ # pick the account, then the device
210
214
  ```
211
215
 
212
216
  `--strategy balanced` spreads work across available versions of the same agent -- useful when you have multiple accounts and want to avoid burning through one. When a Claude run reports a session limit, agents-cli records the stated reset time, shows `session-limited` in `agents view`, and excludes that account until the reset. When every account is rate-limited, the run exits nonzero naming each excluded account and the earliest window reset (use `--strategy pinned` to force a rate-limited default) -- it never launches into an exhausted account. A logged-out default is never forced: unpinned dispatch picks a signed-in version on the execution device instead of dying on a credential-less default home.
@@ -10,13 +10,24 @@ import type { ExecEffort } from '../lib/exec.js';
10
10
  import { RUN_AUTO_KEYWORD } from '../lib/types.js';
11
11
  /** Validate a caller-supplied session id before it reaches tmux, paths, indexes, or remote dispatch. */
12
12
  export declare function parseExplicitSessionId(value: string): string;
13
- export interface RunAccountPickerRequest {
14
- requested: boolean;
13
+ export interface RunPickerMarkers {
14
+ accountPicker: boolean;
15
+ devicePicker: boolean;
15
16
  normalizedAgentSpec: string;
16
17
  valid: boolean;
18
+ reason?: string;
17
19
  }
18
- /** Distinguish a terminal account-picker marker from an explicit @version pin. */
19
- export declare function parseRunAccountPickerRequest(agentSpec: string): RunAccountPickerRequest;
20
+ /**
21
+ * Parse the trailing picker markers on an `agents run` agent spec. A terminal
22
+ * run of `#`/`@` characters requests the account picker (`#`) and/or the
23
+ * device picker (`@`) — each at most once, in either order (`claude#@`,
24
+ * `claude@#`). Stripping them yields the normalized spec (`claude#work@` →
25
+ * `claude#work`, version pins and `#label` account pins intact). A picker
26
+ * cannot combine with an explicit pin of what it picks: `claude@2.1.218#`
27
+ * (the account picker already chooses the version — the old `claude@2.1.218@`
28
+ * rule), `claude#work#`, `claude@@`.
29
+ */
30
+ export declare function parseRunPickerMarkers(agentSpec: string): RunPickerMarkers;
20
31
  /**
21
32
  * The `--device` alias family — the flags that mean "dispatch this run to another
22
33
  * machine over SSH". `--device` is canonical; `--on`/`--computer` are hidden
@@ -31,11 +42,29 @@ export declare function hostTargetGiven(options: {
31
42
  on?: string;
32
43
  computer?: string;
33
44
  }): string[];
34
- /** Return every option whose selection semantics conflict with an account choice. */
45
+ /**
46
+ * Return every option whose selection semantics conflict with an account
47
+ * choice (`agent#`). Device routing is deliberately absent: the marker rides
48
+ * the hop and the peer picks from ITS slots.
49
+ */
35
50
  export declare function runAccountPickerConflicts(options: {
36
51
  resume?: string | boolean;
37
52
  strategy?: string;
38
53
  balanced?: boolean;
54
+ lease?: string | boolean;
55
+ box?: string;
56
+ account?: string;
57
+ host?: string;
58
+ device?: string;
59
+ on?: string;
60
+ computer?: string;
61
+ }): string[];
62
+ /**
63
+ * Return every option that already decides where the run lands, so a device
64
+ * choice (`agent@`) would be silently ignored: any explicit host flag, and the
65
+ * lease/box paths, which own placement outright.
66
+ */
67
+ export declare function runDevicePickerConflicts(options: {
39
68
  lease?: string | boolean;
40
69
  box?: string;
41
70
  host?: string;
@@ -47,11 +76,15 @@ export { RUN_AUTO_KEYWORD };
47
76
  /**
48
77
  * Whether `run auto` should default its host layer to the affinity pick (the
49
78
  * same machinery as `--device auto`). False when the caller pinned any host
50
- * flag, and false when this process was itself dispatched by a host run — the
51
- * dispatcher exports AGENTS_RUN_AUTO_HOST_RESOLVED=1 into the remote SHELL
79
+ * flag, and false when this process was itself dispatched by a host run —
80
+ * the dispatcher exports AGENTS_RUN_AUTO_HOST_RESOLVED=1 into the remote SHELL
52
81
  * (hosts/dispatch.ts remoteRunShellPrelude) because it already resolved the
53
82
  * host layer, and re-picking here would chain-hop the run across the fleet.
54
- * Pure so the pinning matrix is unit-testable.
83
+ * An INTERACTIVE dispatch of a named harness also reaches the remote as a bare
84
+ * `agents run <harness>` (its argv forwards without the routing flag), so the
85
+ * interactive prelude's AGENTS_REMOTE_INTERACTIVE=1 counts as "already placed"
86
+ * too — without it the remote would re-place the run and ping-pong across the
87
+ * fleet. Pure so the pinning matrix is unit-testable.
55
88
  */
56
89
  export declare function runAutoDefaultsToAffinity(options: {
57
90
  host?: string;
@@ -59,6 +92,43 @@ export declare function runAutoDefaultsToAffinity(options: {
59
92
  on?: string;
60
93
  computer?: string;
61
94
  }, env?: NodeJS.ProcessEnv): boolean;
95
+ /**
96
+ * Whether a bare human-facing `agents run <harness>` — no prompt, so an
97
+ * interactive TUI run — defaults its placement to `--device auto` (PHNX-4083).
98
+ * A marker left off is decided for you: no `#` means balanced rotation, and no
99
+ * `@` (and no other placement flag) now means automatic device placement — the
100
+ * same engine as `--device auto`, whose pool never contains a box marked
101
+ * `personal`. ALL of these must hold:
102
+ *
103
+ * - no prompt (headless runs — teams, routines, hooks, `run <agent> "…"` —
104
+ * keep running in place, unchanged);
105
+ * - a human-facing surface: a real TTY and no `--json`. This is the same
106
+ * two-condition gate `signInLaunchDecision` uses in run-account-picker.ts —
107
+ * reused here through isHumanFacingRun, not re-derived;
108
+ * - no device-picker marker (`agent@` already chose the device; picking this
109
+ * machine there is a plain local run);
110
+ * - no `--resume` / `--lease` / `--box` / `--cloud` (those own placement);
111
+ * - the host layer is unpinned and this process is not itself a dispatched
112
+ * hop — delegated to runAutoDefaultsToAffinity, which encodes both.
113
+ *
114
+ * Pure so the default-placement matrix is unit-testable.
115
+ */
116
+ export declare function bareInteractiveRunDefaultsToDeviceAuto(options: {
117
+ host?: string;
118
+ device?: string;
119
+ on?: string;
120
+ computer?: string;
121
+ resume?: string | boolean;
122
+ lease?: string | boolean;
123
+ box?: string;
124
+ cloud?: boolean;
125
+ }, run: {
126
+ prompt?: string;
127
+ devicePickerRequested?: boolean;
128
+ }, surface: {
129
+ tty: boolean;
130
+ json?: boolean;
131
+ }, env?: NodeJS.ProcessEnv): boolean;
62
132
  /**
63
133
  * Whether an interactive host dispatch must mint a correlation launch id and
64
134
  * resolve the remote session via the launch-id join (RUSH-2034), rather than
@@ -11,6 +11,7 @@ import { isTierToken } from '../lib/model-tiers.js';
11
11
  import { RUN_AUTO_KEYWORD } from '../lib/types.js';
12
12
  import { setHelpSections } from '../lib/help.js';
13
13
  import { isInteractiveTerminal, isPromptCancelled, requireInteractiveSelection } from './utils.js';
14
+ import { isHumanFacingRun } from './run-account-picker.js';
14
15
  import { getUserAgentsDir, readMeta } from '../lib/state.js';
15
16
  import { parseLoopInterval } from '../lib/loop.js';
16
17
  import { AGENTS, resolveAgentName, isAgentHardDeprecated, hardDeprecationError } from '../lib/agents.js';
@@ -36,15 +37,47 @@ export function parseExplicitSessionId(value) {
36
37
  }
37
38
  return value;
38
39
  }
39
- /** Distinguish a terminal account-picker marker from an explicit @version pin. */
40
- export function parseRunAccountPickerRequest(agentSpec) {
41
- const requested = agentSpec.endsWith('@');
42
- const normalizedAgentSpec = requested ? agentSpec.slice(0, -1) : agentSpec;
43
- return {
44
- requested,
45
- normalizedAgentSpec,
46
- valid: !requested || (!!normalizedAgentSpec && !normalizedAgentSpec.includes('@')),
47
- };
40
+ /**
41
+ * Parse the trailing picker markers on an `agents run` agent spec. A terminal
42
+ * run of `#`/`@` characters requests the account picker (`#`) and/or the
43
+ * device picker (`@`) — each at most once, in either order (`claude#@`,
44
+ * `claude@#`). Stripping them yields the normalized spec (`claude#work@` →
45
+ * `claude#work`, version pins and `#label` account pins intact). A picker
46
+ * cannot combine with an explicit pin of what it picks: `claude@2.1.218#`
47
+ * (the account picker already chooses the version — the old `claude@2.1.218@`
48
+ * rule), `claude#work#`, `claude@@`.
49
+ */
50
+ export function parseRunPickerMarkers(agentSpec) {
51
+ let rest = agentSpec;
52
+ let accountPicker = false;
53
+ let devicePicker = false;
54
+ let reason;
55
+ while (rest.endsWith('#') || rest.endsWith('@')) {
56
+ const marker = rest.endsWith('#') ? '#' : '@';
57
+ if (marker === '#') {
58
+ if (accountPicker && reason === undefined)
59
+ reason = `the # picker marker may appear at most once in '${agentSpec}'`;
60
+ accountPicker = true;
61
+ }
62
+ else {
63
+ if (devicePicker && reason === undefined)
64
+ reason = `the @ picker marker may appear at most once in '${agentSpec}'`;
65
+ devicePicker = true;
66
+ }
67
+ rest = rest.slice(0, -1);
68
+ }
69
+ if (reason === undefined) {
70
+ if (!rest) {
71
+ reason = `'${agentSpec}' names no agent before the picker markers`;
72
+ }
73
+ else if (accountPicker && (rest.includes('@') || rest.includes('#'))) {
74
+ reason = `an explicit pin in '${rest}' already selects what the # account picker chooses`;
75
+ }
76
+ else if (devicePicker && rest.includes('@')) {
77
+ reason = `an explicit pin in '${rest}' cannot combine with the @ device picker`;
78
+ }
79
+ }
80
+ return { accountPicker, devicePicker, normalizedAgentSpec: rest, valid: reason === undefined, reason };
48
81
  }
49
82
  /**
50
83
  * The `--device` alias family — the flags that mean "dispatch this run to another
@@ -57,7 +90,11 @@ export function parseRunAccountPickerRequest(agentSpec) {
57
90
  export function hostTargetGiven(options) {
58
91
  return [options.host, options.device, options.on, options.computer].filter((v) => !!v);
59
92
  }
60
- /** Return every option whose selection semantics conflict with an account choice. */
93
+ /**
94
+ * Return every option whose selection semantics conflict with an account
95
+ * choice (`agent#`). Device routing is deliberately absent: the marker rides
96
+ * the hop and the peer picks from ITS slots.
97
+ */
61
98
  export function runAccountPickerConflicts(options) {
62
99
  const conflicts = [];
63
100
  if (options.resume !== undefined)
@@ -66,6 +103,21 @@ export function runAccountPickerConflicts(options) {
66
103
  conflicts.push('--strategy');
67
104
  if (options.balanced)
68
105
  conflicts.push('--balanced');
106
+ if (options.lease)
107
+ conflicts.push('--lease');
108
+ if (options.box)
109
+ conflicts.push('--box');
110
+ if (options.account)
111
+ conflicts.push(`--account ${options.account}`);
112
+ return conflicts;
113
+ }
114
+ /**
115
+ * Return every option that already decides where the run lands, so a device
116
+ * choice (`agent@`) would be silently ignored: any explicit host flag, and the
117
+ * lease/box paths, which own placement outright.
118
+ */
119
+ export function runDevicePickerConflicts(options) {
120
+ const conflicts = hostTargetGiven(options).map((h) => `--device ${h}`);
69
121
  if (options.lease)
70
122
  conflicts.push('--lease');
71
123
  if (options.box)
@@ -82,16 +134,54 @@ export { RUN_AUTO_KEYWORD };
82
134
  /**
83
135
  * Whether `run auto` should default its host layer to the affinity pick (the
84
136
  * same machinery as `--device auto`). False when the caller pinned any host
85
- * flag, and false when this process was itself dispatched by a host run — the
86
- * dispatcher exports AGENTS_RUN_AUTO_HOST_RESOLVED=1 into the remote SHELL
137
+ * flag, and false when this process was itself dispatched by a host run —
138
+ * the dispatcher exports AGENTS_RUN_AUTO_HOST_RESOLVED=1 into the remote SHELL
87
139
  * (hosts/dispatch.ts remoteRunShellPrelude) because it already resolved the
88
140
  * host layer, and re-picking here would chain-hop the run across the fleet.
89
- * Pure so the pinning matrix is unit-testable.
141
+ * An INTERACTIVE dispatch of a named harness also reaches the remote as a bare
142
+ * `agents run <harness>` (its argv forwards without the routing flag), so the
143
+ * interactive prelude's AGENTS_REMOTE_INTERACTIVE=1 counts as "already placed"
144
+ * too — without it the remote would re-place the run and ping-pong across the
145
+ * fleet. Pure so the pinning matrix is unit-testable.
90
146
  */
91
147
  export function runAutoDefaultsToAffinity(options, env = process.env) {
92
148
  if (hostTargetGiven(options).length > 0)
93
149
  return false;
94
- return env.AGENTS_RUN_AUTO_HOST_RESOLVED !== '1';
150
+ if (env.AGENTS_RUN_AUTO_HOST_RESOLVED === '1')
151
+ return false;
152
+ return env.AGENTS_REMOTE_INTERACTIVE !== '1';
153
+ }
154
+ /**
155
+ * Whether a bare human-facing `agents run <harness>` — no prompt, so an
156
+ * interactive TUI run — defaults its placement to `--device auto` (PHNX-4083).
157
+ * A marker left off is decided for you: no `#` means balanced rotation, and no
158
+ * `@` (and no other placement flag) now means automatic device placement — the
159
+ * same engine as `--device auto`, whose pool never contains a box marked
160
+ * `personal`. ALL of these must hold:
161
+ *
162
+ * - no prompt (headless runs — teams, routines, hooks, `run <agent> "…"` —
163
+ * keep running in place, unchanged);
164
+ * - a human-facing surface: a real TTY and no `--json`. This is the same
165
+ * two-condition gate `signInLaunchDecision` uses in run-account-picker.ts —
166
+ * reused here through isHumanFacingRun, not re-derived;
167
+ * - no device-picker marker (`agent@` already chose the device; picking this
168
+ * machine there is a plain local run);
169
+ * - no `--resume` / `--lease` / `--box` / `--cloud` (those own placement);
170
+ * - the host layer is unpinned and this process is not itself a dispatched
171
+ * hop — delegated to runAutoDefaultsToAffinity, which encodes both.
172
+ *
173
+ * Pure so the default-placement matrix is unit-testable.
174
+ */
175
+ export function bareInteractiveRunDefaultsToDeviceAuto(options, run, surface, env = process.env) {
176
+ if (run.prompt !== undefined)
177
+ return false;
178
+ if (!isHumanFacingRun({ tty: surface.tty, json: surface.json === true }))
179
+ return false;
180
+ if (run.devicePickerRequested)
181
+ return false;
182
+ if (options.resume !== undefined || options.lease || options.box || options.cloud)
183
+ return false;
184
+ return runAutoDefaultsToAffinity(options, env);
95
185
  }
96
186
  /**
97
187
  * Whether an interactive host dispatch must mint a correlation launch id and
@@ -426,7 +516,7 @@ async function handleTerminalHandoff(agentSpec, options, prompt) {
426
516
  // isValidAgent / profileExists / resolveWorkflowRef chain below), so this must
427
517
  // accept all three. Gating on the agent table alone rejected every profile —
428
518
  // the whole Kimi/DeepSeek/Qwen/GLM path — for `--terminal` runs only.
429
- const rawTarget = parseRunAccountPickerRequest(agentSpec).normalizedAgentSpec.split('#')[0].split('@')[0];
519
+ const rawTarget = parseRunPickerMarkers(agentSpec).normalizedAgentSpec.split('#')[0].split('@')[0];
430
520
  const knownAgent = resolveAgentName(rawTarget);
431
521
  const [{ profileExists }, { resolveWorkflowRef }] = await Promise.all([
432
522
  import('../lib/profiles.js'),
@@ -587,12 +677,22 @@ export function registerRunCommand(program) {
587
677
  # Headless, can edit: have the agent make changes
588
678
  agents run claude "fix lint errors in src/" --mode edit
589
679
 
590
- # Interactive (TUI) with the pinned default version
680
+ # Interactive (TUI): a bare run places itself like --device auto (a fleet
681
+ # worker, TUI forwarded over SSH); pin the device to stay on this machine
591
682
  agents run claude
683
+ agents run claude --device zion # stay local (or pick this machine in claude@)
684
+
685
+ # Pick a signed-in account/version for only this run (# = account picker)
686
+ agents run claude#
592
687
 
593
- # Pick a signed-in account/version for only this run
688
+ # Pick the device this run lands on (@ = device picker); this machine
689
+ # first, offline rows disabled, fleet state aged in the prompt
594
690
  agents run claude@
595
691
 
692
+ # Ask both, account first, then device — the run dispatches with the
693
+ # picked account to the picked device
694
+ agents run claude#@
695
+
596
696
  # Full-auto: affinity-pick the host, then the harness with the most
597
697
  # account headroom, then a balanced account on it
598
698
  agents run auto "fix the flaky test" --mode edit
@@ -677,9 +777,21 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
677
777
  best-account headroom), and the account (the strategy above). Zero
678
778
  healthy accounts on any harness exits nonzero with the earliest reset.
679
779
 
680
- Account picker: append @ with no version (agents run claude@) to choose one
681
- installed account for this run. Rows show identity, login state, plan,
682
- and available limits; unsafe accounts stay visible but disabled.
780
+ Pickers: a trailing # opens the account picker (agents run claude#) to
781
+ choose one installed account for this run — rows show identity, login
782
+ state, plan, and available limits; unsafe accounts stay visible but
783
+ disabled. A trailing @ opens the device picker (agents run claude@);
784
+ #@ asks both, account first, then device. The pickers cannot combine
785
+ with an explicit pin of the same thing (--account, --device/--on/
786
+ --computer/--host) or with --strategy/--balanced/--resume/--lease/--box.
787
+
788
+ Interactive placement: a bare 'agents run <harness>' (no prompt, real TTY)
789
+ places itself like --device auto — a fleet worker runs it, with the TUI
790
+ forwarded over SSH. Headless runs (any prompt, --json, no TTY) are
791
+ unchanged: they run in place. To stay on this machine, pass
792
+ --device <this machine>, or pick this machine (listed first) in the
793
+ '<harness>@' device picker. When placement finds no healthy device the
794
+ run fails loud and names the local spellings.
683
795
 
684
796
  Fallback: --fallback codex,antigravity retries on rate-limit failure via /continue handoff. Each entry accepts @version.
685
797
 
@@ -834,19 +946,25 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
834
946
  host: options.host,
835
947
  });
836
948
  }
837
- // A trailing @ is an explicit request to choose one installed account.
838
- // Strip only that terminal marker; concrete agent@version pins retain
839
- // their existing meaning in every dispatch path below.
840
- const accountPicker = parseRunAccountPickerRequest(agentSpec);
841
- const accountPickerRequested = accountPicker.requested;
842
- let normalizedAgentSpec = accountPicker.normalizedAgentSpec;
843
- if (!accountPicker.valid) {
844
- console.error(chalk.red(`Invalid account picker target: ${agentSpec}. Use agents run <agent>@.`));
949
+ // Trailing picker markers request an interactive choice: `#` picks the
950
+ // account (and the version it signs in with), `@` picks the device, and
951
+ // `#@`/`@#` ask both. Strip only those terminal markers; concrete
952
+ // agent@version pins and `#label` account pins retain their meaning in
953
+ // every dispatch path below.
954
+ const pickerMarkers = parseRunPickerMarkers(agentSpec);
955
+ const accountPickerRequested = pickerMarkers.accountPicker;
956
+ const devicePickerRequested = pickerMarkers.devicePicker;
957
+ let normalizedAgentSpec = pickerMarkers.normalizedAgentSpec;
958
+ if (!pickerMarkers.valid) {
959
+ console.error(chalk.red(`Invalid run picker target: ${agentSpec}. ` +
960
+ `${pickerMarkers.reason ?? 'unrecognized picker markers'}. ` +
961
+ 'Use agents run <agent># to pick an account, <agent>@ to pick a device, <agent>#@ for both.'));
845
962
  process.exit(1);
846
963
  }
847
964
  // Peel `#name` off before --device dispatch so the selector rides the hop
848
965
  // unchanged and the peer resolves ITS slot (PHNX-3940 T5). The later local
849
- // parse is idempotent when the values match.
966
+ // parse is idempotent when the values match. A bare trailing `#` was the
967
+ // picker marker above, so it can never land here as an empty label.
850
968
  {
851
969
  const labelParts = normalizedAgentSpec.split('#');
852
970
  if (labelParts.length > 2 || labelParts[1] === '') {
@@ -890,14 +1008,22 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
890
1008
  console.error(chalk.red(hardDeprecationError(runBaseAgentId)));
891
1009
  process.exit(1);
892
1010
  }
893
- // Account-picker conflict check runs BEFORE device=auto may set balanced,
894
- // so an implicit balanced preference never surfaces as a fake
895
- // "cannot be combined with --balanced" when the user only typed trailing @.
1011
+ // Picker conflict checks run BEFORE --resume/--device auto may place the
1012
+ // run implicitly, so an implied placement never surfaces as a fake
1013
+ // "cannot be combined with --device" when the user only typed a marker.
896
1014
  if (accountPickerRequested) {
897
1015
  const conflicts = runAccountPickerConflicts(options);
898
1016
  if (conflicts.length > 0) {
899
1017
  console.error(chalk.red(`Account selection with ${agentSpec} cannot be combined with ${conflicts.join(', ')}. ` +
900
- 'Remove the conflicting selector, or use an explicit agent@version target.'));
1018
+ 'Remove the conflicting selector, or pin the target explicitly (agent@version or agent#label).'));
1019
+ process.exit(1);
1020
+ }
1021
+ }
1022
+ if (devicePickerRequested) {
1023
+ const conflicts = runDevicePickerConflicts(options);
1024
+ if (conflicts.length > 0) {
1025
+ console.error(chalk.red(`Device selection with ${agentSpec} cannot be combined with ${conflicts.join(', ')}. ` +
1026
+ 'Remove one — the picker already chooses where the run lands.'));
901
1027
  process.exit(1);
902
1028
  }
903
1029
  }
@@ -1013,7 +1139,11 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1013
1139
  process.exit(1);
1014
1140
  }
1015
1141
  if (accountPickerRequested) {
1016
- console.error(chalk.red(`agents run auto picks the harness and account itself — the trailing-@ account picker needs a concrete harness (agents run <harness>@).`));
1142
+ console.error(chalk.red(`agents run auto picks the harness and account itself — the trailing-# account picker needs a concrete harness (agents run <harness>#).`));
1143
+ process.exit(1);
1144
+ }
1145
+ if (devicePickerRequested) {
1146
+ console.error(chalk.red(`agents run auto picks the harness and its placement itself — the trailing-@ device picker needs a concrete harness (agents run <harness>@).`));
1017
1147
  process.exit(1);
1018
1148
  }
1019
1149
  // Host layer: with no explicit --device, default to the
@@ -1022,19 +1152,100 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1022
1152
  if (!resolvedResumeSource && runAutoDefaultsToAffinity(options))
1023
1153
  options.device = 'auto';
1024
1154
  }
1155
+ // The picker menus run HERE — after every conflict check, before
1156
+ // placement resolves — so the picked account labels the device rows and
1157
+ // the picked device is concrete for every host branch below.
1158
+ // `#@`/`@#`: account first, against THIS machine's slots; the picked
1159
+ // account becomes options.account and the device menu shows its
1160
+ // ✓/– per-device mark. A remote device pick dispatches with
1161
+ // `<agent>#<label>` so the peer resolves its own slot.
1162
+ // `@`: just the device menu; picking this machine is a plain local run.
1163
+ // Either menu cancelled (Esc/Ctrl-C) launches nothing and exits 0.
1164
+ let upFrontAccountPick = null;
1165
+ if (accountPickerRequested && devicePickerRequested) {
1166
+ const baseName = normalizedAgentSpec.split('#')[0].split('@')[0];
1167
+ const baseAgentId = resolveAgentName(baseName);
1168
+ const { profileExists: baseProfileExists } = await import('../lib/profiles.js');
1169
+ if (!baseAgentId || baseProfileExists(baseName)) {
1170
+ console.error(chalk.red(baseProfileExists(baseName)
1171
+ ? `Account selection is not available for custom harness '${baseName}'. Run its concrete host agent with # instead.`
1172
+ : `Account selection is not available for '${baseName}'. Run a concrete agent with # instead.`));
1173
+ process.exit(1);
1174
+ }
1175
+ const { supportsAccountInspection: baseSupportsInspection, agentLabel: baseAgentLabel, ACCOUNT_INSPECTION_AGENT_IDS: inspectionAgentIds } = await import('../lib/agents.js');
1176
+ if (!baseSupportsInspection(baseAgentId)) {
1177
+ console.error(chalk.red(`${baseAgentLabel(baseAgentId)} does not expose local account state, so agents-cli cannot safely select an account.`));
1178
+ console.error(chalk.gray(`Supported account pickers: ${inspectionAgentIds.join(', ')}`));
1179
+ process.exit(1);
1180
+ }
1181
+ const { pickRunAccountCandidate } = await import('./run-account-picker.js');
1182
+ const selected = await pickRunAccountCandidate(baseAgentId);
1183
+ if (!selected)
1184
+ return; // Esc/Ctrl-C: launch nothing.
1185
+ if (selected.nativeAccount)
1186
+ options.account = selected.nativeAccount;
1187
+ upFrontAccountPick = selected;
1188
+ if (!options.quiet) {
1189
+ const identity = selected.accountLabel || 'signed-in account';
1190
+ process.stderr.write(chalk.gray(`[agents] selected ${identity} · ${baseAgentId}@${selected.version} for this run\n`));
1191
+ }
1192
+ }
1193
+ if (devicePickerRequested) {
1194
+ // --resume above may have pinned the host implicitly (recovery to the
1195
+ // source peer); a device menu against it would be a silent no-op.
1196
+ const impliedHost = hostTargetGiven(options);
1197
+ if (impliedHost.length > 0) {
1198
+ console.error(chalk.red(`Device selection with ${agentSpec} cannot be combined with the placement --resume already chose (${impliedHost.join(', ')}). ` +
1199
+ 'Remove one — both pick where the run lands.'));
1200
+ process.exit(1);
1201
+ }
1202
+ const { pickRunDevice } = await import('./run-device-picker.js');
1203
+ const baseName = normalizedAgentSpec.split('#')[0].split('@')[0];
1204
+ const pickedDevice = await pickRunDevice({
1205
+ agent: (resolveAgentName(baseName) ?? baseName),
1206
+ accountLabel: options.account,
1207
+ });
1208
+ if (pickedDevice === null)
1209
+ return; // Esc/Ctrl-C: launch nothing.
1210
+ const { isSelfHost } = await import('../lib/devices/self-host.js');
1211
+ // Picking this machine is a plain local run — leave options.device unset.
1212
+ if (!isSelfHost(pickedDevice))
1213
+ options.device = pickedDevice;
1214
+ }
1215
+ // Default placement (PHNX-4083): a bare human-facing `agents run
1216
+ // <harness>` (no prompt, real TTY, no placement flags, not a dispatched
1217
+ // hop) places itself like `--device auto` — a marker left off is decided
1218
+ // for you. Headless runs (a prompt, --json, teams/routines/hooks) keep
1219
+ // running in place, unchanged.
1220
+ const defaultPlacement = bareInteractiveRunDefaultsToDeviceAuto(options, { prompt, devicePickerRequested }, { tty: isInteractiveTerminal(), json: options.json });
1221
+ if (defaultPlacement)
1222
+ options.device = 'auto';
1025
1223
  // --device auto (and deprecated --smart): live fleet pick.
1026
1224
  // Harness is always the agent the user typed — never auto-picked.
1027
1225
  // Placement failure propagates; an automatic request never becomes local.
1028
1226
  {
1029
1227
  const { applyDeviceAutoToOptions } = await import('../lib/smart-launch.js');
1030
- const result = await applyDeviceAutoToOptions(options, {
1031
- accountPickerRequested,
1032
- // `run auto` selects its harness after placement, so do not filter
1033
- // candidates against an arbitrary proxy harness at this stage.
1034
- agent: normalizedAgentSpec.split('#')[0].split('@')[0] === RUN_AUTO_KEYWORD
1035
- ? undefined
1036
- : (resolveAgentName(normalizedAgentSpec.split('#')[0].split('@')[0]) ?? undefined),
1037
- });
1228
+ let result;
1229
+ try {
1230
+ result = await applyDeviceAutoToOptions(options, {
1231
+ accountPickerRequested,
1232
+ // `run auto` selects its harness after placement, so do not filter
1233
+ // candidates against an arbitrary proxy harness at this stage.
1234
+ agent: normalizedAgentSpec.split('#')[0].split('@')[0] === RUN_AUTO_KEYWORD
1235
+ ? undefined
1236
+ : (resolveAgentName(normalizedAgentSpec.split('#')[0].split('@')[0]) ?? undefined),
1237
+ });
1238
+ }
1239
+ catch (err) {
1240
+ // Placement the DEFAULT chose must fail loud AND name the local
1241
+ // escape hatch — never silently fall back to a local launch.
1242
+ if (!defaultPlacement)
1243
+ throw err;
1244
+ console.error(chalk.red(err.message));
1245
+ const { machineId } = await import('../lib/machine-id.js');
1246
+ console.error(chalk.gray(`Run here instead: agents run ${runBaseAgentName} --device ${machineId()}`));
1247
+ process.exit(1);
1248
+ }
1038
1249
  if (!options.quiet && result.deprecationSmart) {
1039
1250
  process.stderr.write(chalk.yellow('[agents] --smart is deprecated; use --device auto\n'));
1040
1251
  }
@@ -1512,9 +1723,11 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1512
1723
  process.exit(1);
1513
1724
  }
1514
1725
  const interactiveHost = options.interactive === true || (prompt === undefined && options.headless !== true);
1515
- if (accountPickerRequested && !interactiveHost) {
1726
+ // When the account was already picked up front (`#@`), the dispatch
1727
+ // forwards `#label` and the peer needs no interactive picker.
1728
+ if (accountPickerRequested && !upFrontAccountPick && !interactiveHost) {
1516
1729
  console.error(chalk.red(`Account selection with ${agentSpec} requires an interactive host run. ` +
1517
- `Use agents run ${runAgent}@ --device ${host.name} --interactive.`));
1730
+ `Use agents run ${runAgent}# --device ${host.name} --interactive.`));
1518
1731
  process.exit(1);
1519
1732
  }
1520
1733
  if (interactiveHost) {
@@ -1576,7 +1789,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1576
1789
  const exitCode = await runInteractiveOnHost(host, {
1577
1790
  agent: runAgent,
1578
1791
  version: resumeId ? undefined : runVersion,
1579
- accountPicker: accountPickerRequested,
1792
+ accountPicker: accountPickerRequested && !upFrontAccountPick,
1580
1793
  strategy: resumeId ? undefined : runStrategy,
1581
1794
  account: options.account,
1582
1795
  fallback: options.fallback,
@@ -1908,12 +2121,12 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1908
2121
  let workflowHasSubagents = false;
1909
2122
  const cwd = options.cwd ?? process.cwd();
1910
2123
  if (accountPickerRequested && profileExists(rawAgent)) {
1911
- console.error(chalk.red(`Account selection is not available for custom harness '${rawAgent}'. Run its concrete host agent with @ instead.`));
2124
+ console.error(chalk.red(`Account selection is not available for custom harness '${rawAgent}'. Run its concrete host agent with # instead.`));
1912
2125
  process.exit(1);
1913
2126
  }
1914
2127
  if (accountPickerRequested && !isValidAgent(rawAgent)) {
1915
2128
  if (resolveWorkflowRef(rawAgent, cwd)) {
1916
- console.error(chalk.red(`Account selection is not available for workflow '${rawAgent}'. Run a concrete agent with @ instead.`));
2129
+ console.error(chalk.red(`Account selection is not available for workflow '${rawAgent}'. Run a concrete agent with # instead.`));
1917
2130
  process.exit(1);
1918
2131
  }
1919
2132
  }
@@ -2194,7 +2407,10 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2194
2407
  process.exit(1);
2195
2408
  }
2196
2409
  }
2197
- if (accountPickerRequested) {
2410
+ // `#@` already asked the account up front (before the device menu) — a
2411
+ // remote device pick never reaches here, so apply the local pick's
2412
+ // version/home and skip the on-the-spot menu.
2413
+ if (accountPickerRequested && !upFrontAccountPick) {
2198
2414
  if (!supportsAccountInspection(agent)) {
2199
2415
  console.error(chalk.red(`${agentLabel(agent)} does not expose local account state, so agents-cli cannot safely select an account.`));
2200
2416
  console.error(chalk.gray(`Supported account pickers: ${ACCOUNT_INSPECTION_AGENT_IDS.join(', ')}`));
@@ -2220,6 +2436,11 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2220
2436
  process.exit(1);
2221
2437
  }
2222
2438
  }
2439
+ if (upFrontAccountPick) {
2440
+ version = upFrontAccountPick.version;
2441
+ if (upFrontAccountPick.slotDir)
2442
+ execHome = upFrontAccountPick.slotDir;
2443
+ }
2223
2444
  version = resolveVersionAlias(agent, version);
2224
2445
  const { resolveSpawnAccount } = await import('../lib/account-registry.js');
2225
2446
  let spawnAccount = null;
@@ -2437,7 +2658,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2437
2658
  // - NEEDS A SIGN-IN (signed_out / revoked) -> launching IS the fix,
2438
2659
  // because the harness's own TUI is the login surface. So on a TTY we
2439
2660
  // carry the user into that login instead of erroring. Exiting here
2440
- // made `agents run <agent>`, `agents run <agent>@`, and `agents use`
2661
+ // made `agents run <agent>`, `agents run <agent>#`, and `agents use`
2441
2662
  // all dead-end with no reachable way to authenticate.
2442
2663
  const recoverable = signInRecoverableCandidates(resolved.exhausted);
2443
2664
  const { signInLaunchDecision } = await import('./run-account-picker.js');
@@ -2450,7 +2671,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2450
2671
  const { pickSignInLaunchVersion } = await import('./run-account-picker.js');
2451
2672
  const signInVersion = await pickSignInLaunchVersion(agent, recoverable, !!options.quiet);
2452
2673
  // A cancelled prompt launches nothing — same contract as the
2453
- // trailing-@ account picker above.
2674
+ // trailing-# account picker above.
2454
2675
  if (!signInVersion)
2455
2676
  return;
2456
2677
  version = signInVersion;
@@ -2488,7 +2709,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2488
2709
  if (decision === 'picker') {
2489
2710
  const selected = await pickRunAccountCandidate(agent);
2490
2711
  // A cancelled picker launches nothing — same contract as the
2491
- // trailing-@ account picker and the sign-in launch above.
2712
+ // trailing-# account picker and the sign-in launch above.
2492
2713
  if (!selected)
2493
2714
  return;
2494
2715
  version = selected.version;
@@ -35,6 +35,17 @@ export declare function buildSwitchAccountChoices(rows: SwitchAccountRow[]): Run
35
35
  * A cancelled picker writes nothing.
36
36
  */
37
37
  export declare function pickSwitchAccount(agent: AgentId, rows: SwitchAccountRow[]): Promise<string | null>;
38
+ /**
39
+ * The two-condition "human-facing" gate behind signInLaunchDecision and
40
+ * noVerifiedUsageDecision: a real TTY and no `--json`. Off a TTY nobody can
41
+ * answer a prompt, and `--json` marks a MACHINE consumer, which must never be
42
+ * handed a picker or dropped into a login TUI. Mirrors the canonical
43
+ * `Surface.interactive = tty && !json` in `commands/utils.ts`.
44
+ */
45
+ export declare function isHumanFacingRun(input: {
46
+ tty: boolean;
47
+ json: boolean;
48
+ }): boolean;
38
49
  /**
39
50
  * Whether a zero-healthy run may recover by launching for a login, or must keep
40
51
  * failing loud. Three inputs, all of which have to hold:
@@ -204,6 +204,16 @@ export async function pickSwitchAccount(agent, rows) {
204
204
  throw err;
205
205
  }
206
206
  }
207
+ /**
208
+ * The two-condition "human-facing" gate behind signInLaunchDecision and
209
+ * noVerifiedUsageDecision: a real TTY and no `--json`. Off a TTY nobody can
210
+ * answer a prompt, and `--json` marks a MACHINE consumer, which must never be
211
+ * handed a picker or dropped into a login TUI. Mirrors the canonical
212
+ * `Surface.interactive = tty && !json` in `commands/utils.ts`.
213
+ */
214
+ export function isHumanFacingRun(input) {
215
+ return input.tty && !input.json;
216
+ }
207
217
  /**
208
218
  * Whether a zero-healthy run may recover by launching for a login, or must keep
209
219
  * failing loud. Three inputs, all of which have to hold:
@@ -217,7 +227,7 @@ export async function pickSwitchAccount(agent, rows) {
217
227
  * gets the parseable fail-loud error instead.
218
228
  */
219
229
  export function signInLaunchDecision(input) {
220
- const humanPresent = input.tty && !input.json;
230
+ const humanPresent = isHumanFacingRun(input);
221
231
  return input.recoverable > 0 && humanPresent ? 'launch' : 'fail-loud';
222
232
  }
223
233
  /**
@@ -182,7 +182,7 @@ export interface InteractiveDispatchOptions {
182
182
  agent: string;
183
183
  /** Explicit agent version pin (e.g. "2.1.207") to forward as `agent@version`. */
184
184
  version?: string;
185
- /** Preserve the trailing-@ account picker so selection happens on the execution host. */
185
+ /** Preserve the trailing-# account picker so selection happens on the execution host. */
186
186
  accountPicker?: boolean;
187
187
  /** Explicit run strategy (e.g. "balanced") to forward as `--strategy <strategy>`. */
188
188
  strategy?: string;
@@ -362,7 +362,7 @@ async function launchDetached(host, target, opts) {
362
362
  /** Compose `agent[@version][#account]` so the peer resolves ITS slot (PHNX-3940 T5). */
363
363
  export function runAgentSpecArg(opts) {
364
364
  if (opts.accountPicker)
365
- return `${opts.agent}@`;
365
+ return `${opts.agent}#`;
366
366
  let spec = opts.agent;
367
367
  if (opts.version)
368
368
  spec += `@${opts.version}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.106",
3
+ "version": "1.22.107",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",