@phnx-labs/agents-cli 1.22.106 → 1.22.109

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 (44) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +9 -5
  3. package/dist/commands/daemon.js +45 -40
  4. package/dist/commands/doctor.js +7 -0
  5. package/dist/commands/exec.d.ts +78 -8
  6. package/dist/commands/exec.js +276 -53
  7. package/dist/commands/monitors.js +15 -2
  8. package/dist/commands/routines.js +46 -6
  9. package/dist/commands/run-account-picker.d.ts +11 -0
  10. package/dist/commands/run-account-picker.js +11 -1
  11. package/dist/commands/sessions-backup-setup.d.ts +12 -0
  12. package/dist/commands/sessions-backup-setup.js +65 -0
  13. package/dist/commands/sessions-resume.d.ts +14 -1
  14. package/dist/commands/sessions-resume.js +56 -9
  15. package/dist/commands/sessions.js +2 -0
  16. package/dist/commands/share.js +15 -2
  17. package/dist/commands/sync.js +30 -1
  18. package/dist/lib/accounts/slots.js +7 -0
  19. package/dist/lib/agent-spec/agents.d.ts +60 -8
  20. package/dist/lib/agent-spec/agents.js +118 -45
  21. package/dist/lib/daemon/daemon.d.ts +34 -1
  22. package/dist/lib/daemon/daemon.js +63 -9
  23. package/dist/lib/daemon/leaked-daemons.d.ts +60 -0
  24. package/dist/lib/daemon/leaked-daemons.js +180 -0
  25. package/dist/lib/devices/doctor-findings.d.ts +7 -1
  26. package/dist/lib/devices/doctor-findings.js +32 -1
  27. package/dist/lib/hooks/install.js +70 -63
  28. package/dist/lib/hosts/dispatch.d.ts +1 -1
  29. package/dist/lib/hosts/dispatch.js +1 -1
  30. package/dist/lib/models.js +76 -5
  31. package/dist/lib/session/cloud.js +3 -1
  32. package/dist/lib/session/parse.d.ts +1 -0
  33. package/dist/lib/session/parse.js +136 -2
  34. package/dist/lib/session/recovery.d.ts +9 -1
  35. package/dist/lib/session/recovery.js +14 -4
  36. package/dist/lib/session/tool-calls.d.ts +1 -1
  37. package/dist/lib/session/tool-calls.js +40 -5
  38. package/dist/lib/share/backend.d.ts +23 -0
  39. package/dist/lib/share/backend.js +24 -0
  40. package/dist/lib/share/provision.d.ts +12 -0
  41. package/dist/lib/share/provision.js +30 -0
  42. package/dist/lib/share/worker-template.js +273 -0
  43. package/dist/lib/terminal/engine.js +13 -1
  44. package/package.json +1 -1
@@ -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@)
592
684
 
593
- # Pick a signed-in account/version for only this run
685
+ # Pick a signed-in account/version for only this run (# = account picker)
686
+ agents run claude#
687
+
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] === '') {
@@ -868,6 +986,8 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
868
986
  throw new Error('--resume cannot be combined with --session-id, --loop, --fallback, --resume-checkpoint, or --lease.');
869
987
  }
870
988
  const { sessionsResumeAction } = await import('./sessions-resume.js');
989
+ const { toRemotePortable } = await import('../lib/project-root.js');
990
+ const device = options.host || options.device || options.on || options.computer;
871
991
  await sessionsResumeAction(undefined, prompt, {
872
992
  agent: normalizedAgentSpec.split('#')[0] === RUN_AUTO_KEYWORD ? undefined : normalizedAgentSpec.split('#')[0],
873
993
  account: options.account,
@@ -875,9 +995,9 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
875
995
  mode: command.getOptionValueSource('mode') === 'default' ? undefined : options.mode,
876
996
  interactive: options.interactive,
877
997
  headless: options.headless,
878
- cwd: options.cwd,
998
+ cwd: device ? options.remoteCwd ?? (options.cwd ? toRemotePortable(options.cwd) : undefined) : options.cwd,
879
999
  quiet: options.quiet,
880
- device: options.host || options.device || options.on || options.computer,
1000
+ device,
881
1001
  runArgs: rawArgs.slice(2),
882
1002
  });
883
1003
  return;
@@ -890,14 +1010,22 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
890
1010
  console.error(chalk.red(hardDeprecationError(runBaseAgentId)));
891
1011
  process.exit(1);
892
1012
  }
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 @.
1013
+ // Picker conflict checks run BEFORE --resume/--device auto may place the
1014
+ // run implicitly, so an implied placement never surfaces as a fake
1015
+ // "cannot be combined with --device" when the user only typed a marker.
896
1016
  if (accountPickerRequested) {
897
1017
  const conflicts = runAccountPickerConflicts(options);
898
1018
  if (conflicts.length > 0) {
899
1019
  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.'));
1020
+ 'Remove the conflicting selector, or pin the target explicitly (agent@version or agent#label).'));
1021
+ process.exit(1);
1022
+ }
1023
+ }
1024
+ if (devicePickerRequested) {
1025
+ const conflicts = runDevicePickerConflicts(options);
1026
+ if (conflicts.length > 0) {
1027
+ console.error(chalk.red(`Device selection with ${agentSpec} cannot be combined with ${conflicts.join(', ')}. ` +
1028
+ 'Remove one — the picker already chooses where the run lands.'));
901
1029
  process.exit(1);
902
1030
  }
903
1031
  }
@@ -1013,7 +1141,11 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1013
1141
  process.exit(1);
1014
1142
  }
1015
1143
  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>@).`));
1144
+ console.error(chalk.red(`agents run auto picks the harness and account itself — the trailing-# account picker needs a concrete harness (agents run <harness>#).`));
1145
+ process.exit(1);
1146
+ }
1147
+ if (devicePickerRequested) {
1148
+ 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
1149
  process.exit(1);
1018
1150
  }
1019
1151
  // Host layer: with no explicit --device, default to the
@@ -1022,19 +1154,100 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1022
1154
  if (!resolvedResumeSource && runAutoDefaultsToAffinity(options))
1023
1155
  options.device = 'auto';
1024
1156
  }
1157
+ // The picker menus run HERE — after every conflict check, before
1158
+ // placement resolves — so the picked account labels the device rows and
1159
+ // the picked device is concrete for every host branch below.
1160
+ // `#@`/`@#`: account first, against THIS machine's slots; the picked
1161
+ // account becomes options.account and the device menu shows its
1162
+ // ✓/– per-device mark. A remote device pick dispatches with
1163
+ // `<agent>#<label>` so the peer resolves its own slot.
1164
+ // `@`: just the device menu; picking this machine is a plain local run.
1165
+ // Either menu cancelled (Esc/Ctrl-C) launches nothing and exits 0.
1166
+ let upFrontAccountPick = null;
1167
+ if (accountPickerRequested && devicePickerRequested) {
1168
+ const baseName = normalizedAgentSpec.split('#')[0].split('@')[0];
1169
+ const baseAgentId = resolveAgentName(baseName);
1170
+ const { profileExists: baseProfileExists } = await import('../lib/profiles.js');
1171
+ if (!baseAgentId || baseProfileExists(baseName)) {
1172
+ console.error(chalk.red(baseProfileExists(baseName)
1173
+ ? `Account selection is not available for custom harness '${baseName}'. Run its concrete host agent with # instead.`
1174
+ : `Account selection is not available for '${baseName}'. Run a concrete agent with # instead.`));
1175
+ process.exit(1);
1176
+ }
1177
+ const { supportsAccountInspection: baseSupportsInspection, agentLabel: baseAgentLabel, ACCOUNT_INSPECTION_AGENT_IDS: inspectionAgentIds } = await import('../lib/agents.js');
1178
+ if (!baseSupportsInspection(baseAgentId)) {
1179
+ console.error(chalk.red(`${baseAgentLabel(baseAgentId)} does not expose local account state, so agents-cli cannot safely select an account.`));
1180
+ console.error(chalk.gray(`Supported account pickers: ${inspectionAgentIds.join(', ')}`));
1181
+ process.exit(1);
1182
+ }
1183
+ const { pickRunAccountCandidate } = await import('./run-account-picker.js');
1184
+ const selected = await pickRunAccountCandidate(baseAgentId);
1185
+ if (!selected)
1186
+ return; // Esc/Ctrl-C: launch nothing.
1187
+ if (selected.nativeAccount)
1188
+ options.account = selected.nativeAccount;
1189
+ upFrontAccountPick = selected;
1190
+ if (!options.quiet) {
1191
+ const identity = selected.accountLabel || 'signed-in account';
1192
+ process.stderr.write(chalk.gray(`[agents] selected ${identity} · ${baseAgentId}@${selected.version} for this run\n`));
1193
+ }
1194
+ }
1195
+ if (devicePickerRequested) {
1196
+ // --resume above may have pinned the host implicitly (recovery to the
1197
+ // source peer); a device menu against it would be a silent no-op.
1198
+ const impliedHost = hostTargetGiven(options);
1199
+ if (impliedHost.length > 0) {
1200
+ console.error(chalk.red(`Device selection with ${agentSpec} cannot be combined with the placement --resume already chose (${impliedHost.join(', ')}). ` +
1201
+ 'Remove one — both pick where the run lands.'));
1202
+ process.exit(1);
1203
+ }
1204
+ const { pickRunDevice } = await import('./run-device-picker.js');
1205
+ const baseName = normalizedAgentSpec.split('#')[0].split('@')[0];
1206
+ const pickedDevice = await pickRunDevice({
1207
+ agent: (resolveAgentName(baseName) ?? baseName),
1208
+ accountLabel: options.account,
1209
+ });
1210
+ if (pickedDevice === null)
1211
+ return; // Esc/Ctrl-C: launch nothing.
1212
+ const { isSelfHost } = await import('../lib/devices/self-host.js');
1213
+ // Picking this machine is a plain local run — leave options.device unset.
1214
+ if (!isSelfHost(pickedDevice))
1215
+ options.device = pickedDevice;
1216
+ }
1217
+ // Default placement (PHNX-4083): a bare human-facing `agents run
1218
+ // <harness>` (no prompt, real TTY, no placement flags, not a dispatched
1219
+ // hop) places itself like `--device auto` — a marker left off is decided
1220
+ // for you. Headless runs (a prompt, --json, teams/routines/hooks) keep
1221
+ // running in place, unchanged.
1222
+ const defaultPlacement = bareInteractiveRunDefaultsToDeviceAuto(options, { prompt, devicePickerRequested }, { tty: isInteractiveTerminal(), json: options.json });
1223
+ if (defaultPlacement)
1224
+ options.device = 'auto';
1025
1225
  // --device auto (and deprecated --smart): live fleet pick.
1026
1226
  // Harness is always the agent the user typed — never auto-picked.
1027
1227
  // Placement failure propagates; an automatic request never becomes local.
1028
1228
  {
1029
1229
  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
- });
1230
+ let result;
1231
+ try {
1232
+ result = await applyDeviceAutoToOptions(options, {
1233
+ accountPickerRequested,
1234
+ // `run auto` selects its harness after placement, so do not filter
1235
+ // candidates against an arbitrary proxy harness at this stage.
1236
+ agent: normalizedAgentSpec.split('#')[0].split('@')[0] === RUN_AUTO_KEYWORD
1237
+ ? undefined
1238
+ : (resolveAgentName(normalizedAgentSpec.split('#')[0].split('@')[0]) ?? undefined),
1239
+ });
1240
+ }
1241
+ catch (err) {
1242
+ // Placement the DEFAULT chose must fail loud AND name the local
1243
+ // escape hatch — never silently fall back to a local launch.
1244
+ if (!defaultPlacement)
1245
+ throw err;
1246
+ console.error(chalk.red(err.message));
1247
+ const { machineId } = await import('../lib/machine-id.js');
1248
+ console.error(chalk.gray(`Run here instead: agents run ${runBaseAgentName} --device ${machineId()}`));
1249
+ process.exit(1);
1250
+ }
1038
1251
  if (!options.quiet && result.deprecationSmart) {
1039
1252
  process.stderr.write(chalk.yellow('[agents] --smart is deprecated; use --device auto\n'));
1040
1253
  }
@@ -1512,9 +1725,11 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1512
1725
  process.exit(1);
1513
1726
  }
1514
1727
  const interactiveHost = options.interactive === true || (prompt === undefined && options.headless !== true);
1515
- if (accountPickerRequested && !interactiveHost) {
1728
+ // When the account was already picked up front (`#@`), the dispatch
1729
+ // forwards `#label` and the peer needs no interactive picker.
1730
+ if (accountPickerRequested && !upFrontAccountPick && !interactiveHost) {
1516
1731
  console.error(chalk.red(`Account selection with ${agentSpec} requires an interactive host run. ` +
1517
- `Use agents run ${runAgent}@ --device ${host.name} --interactive.`));
1732
+ `Use agents run ${runAgent}# --device ${host.name} --interactive.`));
1518
1733
  process.exit(1);
1519
1734
  }
1520
1735
  if (interactiveHost) {
@@ -1576,7 +1791,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1576
1791
  const exitCode = await runInteractiveOnHost(host, {
1577
1792
  agent: runAgent,
1578
1793
  version: resumeId ? undefined : runVersion,
1579
- accountPicker: accountPickerRequested,
1794
+ accountPicker: accountPickerRequested && !upFrontAccountPick,
1580
1795
  strategy: resumeId ? undefined : runStrategy,
1581
1796
  account: options.account,
1582
1797
  fallback: options.fallback,
@@ -1908,12 +2123,12 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1908
2123
  let workflowHasSubagents = false;
1909
2124
  const cwd = options.cwd ?? process.cwd();
1910
2125
  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.`));
2126
+ console.error(chalk.red(`Account selection is not available for custom harness '${rawAgent}'. Run its concrete host agent with # instead.`));
1912
2127
  process.exit(1);
1913
2128
  }
1914
2129
  if (accountPickerRequested && !isValidAgent(rawAgent)) {
1915
2130
  if (resolveWorkflowRef(rawAgent, cwd)) {
1916
- console.error(chalk.red(`Account selection is not available for workflow '${rawAgent}'. Run a concrete agent with @ instead.`));
2131
+ console.error(chalk.red(`Account selection is not available for workflow '${rawAgent}'. Run a concrete agent with # instead.`));
1917
2132
  process.exit(1);
1918
2133
  }
1919
2134
  }
@@ -2194,7 +2409,10 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2194
2409
  process.exit(1);
2195
2410
  }
2196
2411
  }
2197
- if (accountPickerRequested) {
2412
+ // `#@` already asked the account up front (before the device menu) — a
2413
+ // remote device pick never reaches here, so apply the local pick's
2414
+ // version/home and skip the on-the-spot menu.
2415
+ if (accountPickerRequested && !upFrontAccountPick) {
2198
2416
  if (!supportsAccountInspection(agent)) {
2199
2417
  console.error(chalk.red(`${agentLabel(agent)} does not expose local account state, so agents-cli cannot safely select an account.`));
2200
2418
  console.error(chalk.gray(`Supported account pickers: ${ACCOUNT_INSPECTION_AGENT_IDS.join(', ')}`));
@@ -2220,6 +2438,11 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2220
2438
  process.exit(1);
2221
2439
  }
2222
2440
  }
2441
+ if (upFrontAccountPick) {
2442
+ version = upFrontAccountPick.version;
2443
+ if (upFrontAccountPick.slotDir)
2444
+ execHome = upFrontAccountPick.slotDir;
2445
+ }
2223
2446
  version = resolveVersionAlias(agent, version);
2224
2447
  const { resolveSpawnAccount } = await import('../lib/account-registry.js');
2225
2448
  let spawnAccount = null;
@@ -2437,7 +2660,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2437
2660
  // - NEEDS A SIGN-IN (signed_out / revoked) -> launching IS the fix,
2438
2661
  // because the harness's own TUI is the login surface. So on a TTY we
2439
2662
  // carry the user into that login instead of erroring. Exiting here
2440
- // made `agents run <agent>`, `agents run <agent>@`, and `agents use`
2663
+ // made `agents run <agent>`, `agents run <agent>#`, and `agents use`
2441
2664
  // all dead-end with no reachable way to authenticate.
2442
2665
  const recoverable = signInRecoverableCandidates(resolved.exhausted);
2443
2666
  const { signInLaunchDecision } = await import('./run-account-picker.js');
@@ -2450,7 +2673,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2450
2673
  const { pickSignInLaunchVersion } = await import('./run-account-picker.js');
2451
2674
  const signInVersion = await pickSignInLaunchVersion(agent, recoverable, !!options.quiet);
2452
2675
  // A cancelled prompt launches nothing — same contract as the
2453
- // trailing-@ account picker above.
2676
+ // trailing-# account picker above.
2454
2677
  if (!signInVersion)
2455
2678
  return;
2456
2679
  version = signInVersion;
@@ -2488,7 +2711,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2488
2711
  if (decision === 'picker') {
2489
2712
  const selected = await pickRunAccountCandidate(agent);
2490
2713
  // A cancelled picker launches nothing — same contract as the
2491
- // trailing-@ account picker and the sign-in launch above.
2714
+ // trailing-# account picker and the sign-in launch above.
2492
2715
  if (!selected)
2493
2716
  return;
2494
2717
  version = selected.version;
@@ -12,7 +12,7 @@ import { withAliases } from '../lib/verbs.js';
12
12
  import * as fs from 'fs';
13
13
  import * as path from 'path';
14
14
  import * as yaml from 'yaml';
15
- import { isDaemonRunning, signalDaemonReload, startDaemon, } from '../lib/daemon/daemon.js';
15
+ import { isDaemonRunning, signalDaemonReload, startDaemon, RedirectedHomeDaemonError, } from '../lib/daemon/daemon.js';
16
16
  import { findDuplicateMonitor, monitorFingerprint } from '../lib/monitors/fingerprint.js';
17
17
  import { gatherFleetMonitors, NO_MONITOR_FANOUT_ENV } from '../lib/monitors/remote.js';
18
18
  import { listMonitors, readMonitor, writeMonitor, deleteMonitor, setMonitorEnabled, getMonitorPath, validateMonitor, monitorRunsOnThisDevice, requiresSingleOwner, monitorSharedInputOwner, parseInterval, } from '../lib/monitors/config.js';
@@ -246,7 +246,20 @@ function ensureDaemonRunning() {
246
246
  stderrLine(chalk.gray('Daemon reloaded'));
247
247
  return true;
248
248
  }
249
- const result = startDaemon();
249
+ let result;
250
+ try {
251
+ result = startDaemon();
252
+ }
253
+ catch (err) {
254
+ // The redirected-HOME refusal (W4) is an auto-start policy, not a failure
255
+ // of the monitor just added — state it and leave the foreground command
256
+ // green. Anything else (a genuinely unspawnable binary) still fails loud.
257
+ if (err instanceof RedirectedHomeDaemonError) {
258
+ stderrLine(chalk.yellow(err.message));
259
+ return true;
260
+ }
261
+ throw err;
262
+ }
250
263
  if (result.pid) {
251
264
  console.log(chalk.green(`Daemon started (PID: ${result.pid}). It will watch monitors in the background.`));
252
265
  console.log(chalk.gray('Disable only monitor polling with: agents daemon services disable monitors'));
@@ -10,7 +10,7 @@ import ora from 'ora';
10
10
  import * as fs from 'fs';
11
11
  import * as path from 'path';
12
12
  import * as yaml from 'yaml';
13
- import { isDaemonRunning, signalDaemonReload, startDaemon, readDaemonLog, getDaemonStatus, getDaemonLogPath, } from '../lib/daemon/daemon.js';
13
+ import { isDaemonRunning, signalDaemonReload, startDaemon, readDaemonLog, getDaemonStatus, getDaemonLogPath, RedirectedHomeDaemonError, } from '../lib/daemon/daemon.js';
14
14
  import { isDaemonServiceEnabled, setDaemonServiceEnabled } from '../lib/daemon-services.js';
15
15
  import { assertSchedulerEnabled, assertDaemonEnabled, isDaemonEnabled, isSchedulerEnabled, } from '../lib/device-config.js';
16
16
  import { parseAgentVersionSpec, isAgentHardDeprecated, hardDeprecationError, ROUTINE_AGENT_IDS } from '../lib/agents.js';
@@ -327,7 +327,22 @@ function ensureSchedulerRunning(opts = {}) {
327
327
  }
328
328
  return;
329
329
  }
330
- const result = startDaemon();
330
+ let result;
331
+ try {
332
+ result = startDaemon();
333
+ }
334
+ catch (err) {
335
+ // The redirected-HOME refusal (W4) is an auto-start policy, not a failure
336
+ // of the routine just added — state it and leave the foreground command
337
+ // green, the same tier split as the auto-start circuit breaker. Anything
338
+ // else (a genuinely unspawnable binary) still fails loud.
339
+ if (err instanceof RedirectedHomeDaemonError) {
340
+ if (!opts.quiet)
341
+ log(chalk.yellow(err.message));
342
+ return;
343
+ }
344
+ throw err;
345
+ }
331
346
  if (opts.quiet)
332
347
  return;
333
348
  if (result.pid) {
@@ -1851,9 +1866,22 @@ export function registerRoutinesCommands(program) {
1851
1866
  console.log(chalk.yellow(`Daemon is disabled (daemon.enabled=false) — run(s) below fire but are not monitored. Re-enable with: agents daemon enable`));
1852
1867
  }
1853
1868
  else {
1854
- const started = startDaemon();
1855
- if (started.pid) {
1856
- console.log(chalk.gray(`Started scheduler (PID: ${started.pid}) so webhook runs are monitored.`));
1869
+ try {
1870
+ const started = startDaemon();
1871
+ if (started.pid) {
1872
+ console.log(chalk.gray(`Started scheduler (PID: ${started.pid}) so webhook runs are monitored.`));
1873
+ }
1874
+ }
1875
+ catch (err) {
1876
+ // Same contract as the daemon.enabled=false branch above: a refused
1877
+ // auto-start (W4's redirected-HOME guard) never cancels the fire —
1878
+ // the runs below fire but are not monitored. Anything else rethrows.
1879
+ if (err instanceof RedirectedHomeDaemonError) {
1880
+ console.log(chalk.yellow(err.message));
1881
+ }
1882
+ else {
1883
+ throw err;
1884
+ }
1857
1885
  }
1858
1886
  }
1859
1887
  }
@@ -2185,7 +2213,19 @@ export function registerRoutinesCommands(program) {
2185
2213
  process.exit(1);
2186
2214
  }
2187
2215
  setDaemonServiceEnabled('scheduler', true);
2188
- const result = startDaemon();
2216
+ let result;
2217
+ try {
2218
+ result = startDaemon();
2219
+ }
2220
+ catch (err) {
2221
+ // The redirected-HOME refusal (W4) is user-actionable — print it
2222
+ // without a stack, same as the asserts above.
2223
+ if (err instanceof RedirectedHomeDaemonError) {
2224
+ console.error(chalk.red(err.message));
2225
+ process.exit(1);
2226
+ }
2227
+ throw err;
2228
+ }
2189
2229
  if (result.method === 'already-running') {
2190
2230
  // Signal a reload even here: if the daemon booted while this device had
2191
2231
  // scheduler.enabled=false, the reload re-evaluates the gate and boots
@@ -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: