@phnx-labs/agents-cli 1.22.105 → 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.
Files changed (60) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +28 -6
  3. package/dist/browser.js +0 -0
  4. package/dist/commands/exec.d.ts +78 -8
  5. package/dist/commands/exec.js +407 -284
  6. package/dist/commands/resume.d.ts +6 -21
  7. package/dist/commands/resume.js +18 -55
  8. package/dist/commands/run-account-picker.d.ts +11 -0
  9. package/dist/commands/run-account-picker.js +11 -1
  10. package/dist/commands/sessions-resume.d.ts +4 -0
  11. package/dist/commands/sessions-resume.js +132 -49
  12. package/dist/commands/sessions.js +32 -5
  13. package/dist/index.js +0 -0
  14. package/dist/lib/accounting/account-launch.d.ts +54 -0
  15. package/dist/lib/accounting/account-launch.js +117 -0
  16. package/dist/lib/accounting/account-pool-collect.js +2 -1
  17. package/dist/lib/accounting/account-pool.d.ts +2 -0
  18. package/dist/lib/accounting/account-pool.js +1 -0
  19. package/dist/lib/accounting/rotate.d.ts +32 -6
  20. package/dist/lib/accounting/rotate.js +70 -42
  21. package/dist/lib/accounting/usage.d.ts +71 -0
  22. package/dist/lib/accounting/usage.js +160 -11
  23. package/dist/lib/exec-account-home.d.ts +3 -1
  24. package/dist/lib/exec-account-home.js +2 -2
  25. package/dist/lib/exec.d.ts +27 -1
  26. package/dist/lib/exec.js +150 -27
  27. package/dist/lib/hosts/dispatch.d.ts +1 -1
  28. package/dist/lib/hosts/dispatch.js +1 -1
  29. package/dist/lib/models.d.ts +1 -1
  30. package/dist/lib/models.js +4 -4
  31. package/dist/lib/session/actor-sidecar.d.ts +3 -11
  32. package/dist/lib/session/actor-sidecar.js +3 -0
  33. package/dist/lib/session/claude-accounts.d.ts +12 -73
  34. package/dist/lib/session/claude-accounts.js +32 -70
  35. package/dist/lib/session/db.d.ts +1 -1
  36. package/dist/lib/session/db.js +18 -5
  37. package/dist/lib/session/discover.d.ts +4 -0
  38. package/dist/lib/session/discover.js +116 -15
  39. package/dist/lib/session/recovery.d.ts +30 -34
  40. package/dist/lib/session/recovery.js +212 -76
  41. package/dist/lib/session/types.d.ts +2 -0
  42. package/dist/lib/teams/placement-probe.js +1 -1
  43. package/dist/session-tracker/dist/adapters/claude.d.ts +10 -0
  44. package/dist/session-tracker/dist/adapters/claude.js +45 -0
  45. package/dist/session-tracker/dist/hook.sh +191 -0
  46. package/dist/session-tracker/dist/index.d.ts +19 -0
  47. package/dist/session-tracker/dist/index.js +67 -0
  48. package/dist/session-tracker/dist/install-hook.d.ts +19 -0
  49. package/dist/session-tracker/dist/install-hook.js +245 -0
  50. package/dist/session-tracker/dist/prune-state.d.ts +2 -0
  51. package/dist/session-tracker/dist/prune-state.js +7 -0
  52. package/dist/session-tracker/dist/reader.d.ts +7 -0
  53. package/dist/session-tracker/dist/reader.js +151 -0
  54. package/dist/session-tracker/dist/state-file.d.ts +10 -0
  55. package/dist/session-tracker/dist/state-file.js +119 -0
  56. package/dist/session-tracker/dist/types.d.ts +32 -0
  57. package/dist/session-tracker/dist/types.js +1 -0
  58. package/dist/session-tracker/dist/writer.d.ts +12 -0
  59. package/dist/session-tracker/dist/writer.js +27 -0
  60. 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'),
@@ -521,7 +611,7 @@ export function registerRunCommand(program) {
521
611
  .option('--list-tasks', 'With --broadcast: list available broadcast task ids')
522
612
  .option('--results [run-id]', 'With --broadcast: show one saved matrix run, or list saved runs newest first')
523
613
  .option('--concurrency <n>', 'With --broadcast: maximum cells running at once', '3')
524
- .option('--resume [id]', 'Recover a previous conversation on its origin device. The exact healthy origin uses native resume; otherwise a healthy version of the same harness replays via /continue. Pair with a prompt to continue headlessly.')
614
+ .option('--resume [id]', 'Resume a conversation with its account on the origin device. Omit the id for the shared session picker; #account filters the history. Pair an id with a prompt to continue headlessly.')
525
615
  .option('--session-id <id>', 'Force a NEW conversation to use this exact session UUID (Claude only). This CREATES a session — to resume an existing one, use --resume.', parseExplicitSessionId)
526
616
  .option('--name <slug>', 'Name the run — seeds the session label so it shows up as `<name>` in `agents sessions` and resolves by it (and `agents hosts logs <name>` for --device runs) instead of an opaque id. An agent-generated title later refines the label; your name shows until then. Optional.')
527
617
  .option('--notify', 'Post a desktop notification when a headless run finishes. Fired by this process on exit, so it survives whatever launched the run (the menu bar dispatching it, a terminal you closed).')
@@ -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
 
@@ -692,7 +804,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
692
804
  given. --cloud is mutually exclusive with --device/--lease and with
693
805
  local-run flags (--loop, --resume, --secrets, --terminal, …).
694
806
 
695
- Resume: --resume <id> resolves full IDs locally first, then fleet-wide, and recovers on the source device with its cwd/mode. The exact healthy origin version uses native resume; otherwise a healthy version of the same harness replays via /continue. agents sessions resume <id> infers the harness too.
807
+ Resume: --resume <id> resolves full IDs locally first, then fleet-wide, and recovers on the source device with its cwd/mode. Resume preserves the conversation account and uses the installed binary; starting a new conversation from archived context requires an explicit choice. agents sessions resume <id> infers the harness too.
696
808
 
697
809
  Passthrough: everything after -- is forwarded verbatim to the underlying agent CLI.
698
810
  agents run kimi -- --plan --some-native-flag value
@@ -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] === '') {
@@ -861,6 +979,27 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
861
979
  if (specAccountLabel)
862
980
  options.account = specAccountLabel;
863
981
  }
982
+ // Bare resume is a session selection request, before fresh-run defaults or placement.
983
+ // Concrete IDs re-enter the executor below, so this cannot recurse.
984
+ if (options.resume === true || options.resume === '') {
985
+ if (options.sessionId || options.loop || options.fallback || options.resumeCheckpoint || options.lease) {
986
+ throw new Error('--resume cannot be combined with --session-id, --loop, --fallback, --resume-checkpoint, or --lease.');
987
+ }
988
+ const { sessionsResumeAction } = await import('./sessions-resume.js');
989
+ await sessionsResumeAction(undefined, prompt, {
990
+ agent: normalizedAgentSpec.split('#')[0] === RUN_AUTO_KEYWORD ? undefined : normalizedAgentSpec.split('#')[0],
991
+ account: options.account,
992
+ model: options.model,
993
+ mode: command.getOptionValueSource('mode') === 'default' ? undefined : options.mode,
994
+ interactive: options.interactive,
995
+ headless: options.headless,
996
+ cwd: options.cwd,
997
+ quiet: options.quiet,
998
+ device: options.host || options.device || options.on || options.computer,
999
+ runArgs: rawArgs.slice(2),
1000
+ });
1001
+ return;
1002
+ }
864
1003
  // Hard-deprecated harnesses cannot be run — point the user at the successor.
865
1004
  const runBaseAgentName = normalizedAgentSpec.split('#')[0].split('@')[0];
866
1005
  const runBaseAgentId = resolveAgentName(runBaseAgentName);
@@ -869,14 +1008,22 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
869
1008
  console.error(chalk.red(hardDeprecationError(runBaseAgentId)));
870
1009
  process.exit(1);
871
1010
  }
872
- // Account-picker conflict check runs BEFORE device=auto may set balanced,
873
- // so an implicit balanced preference never surfaces as a fake
874
- // "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.
875
1014
  if (accountPickerRequested) {
876
1015
  const conflicts = runAccountPickerConflicts(options);
877
1016
  if (conflicts.length > 0) {
878
1017
  console.error(chalk.red(`Account selection with ${agentSpec} cannot be combined with ${conflicts.join(', ')}. ` +
879
- '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.'));
880
1027
  process.exit(1);
881
1028
  }
882
1029
  }
@@ -908,7 +1055,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
908
1055
  delete process.env.AGENTS_RESUME_SOURCE_JSON;
909
1056
  const outcome = injectedSource
910
1057
  ? { kind: 'resolved', session: injectedSource }
911
- : await (await import('./sessions.js')).resolveSessionMetadataValue(selector);
1058
+ : await (await import('./sessions.js')).resolveSessionMetadataValue(selector, { agent: runBaseAgentId ?? undefined });
912
1059
  if (outcome.kind === 'partial') {
913
1060
  // RUSH-2492: an unreachable peer is a warning, not a hard failure. The
914
1061
  // resolver already resolves an id found on the reachable fleet (SES-9a),
@@ -929,17 +1076,12 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
929
1076
  process.exit(1);
930
1077
  }
931
1078
  resolvedResumeSource = outcome.session;
932
- const [requestedAgent, requestedVersion] = normalizedAgentSpec.split('#')[0].split('@');
1079
+ const [requestedAgent] = normalizedAgentSpec.split('#')[0].split('@');
933
1080
  if (!autoHarnessRequested && requestedAgent !== resolvedResumeSource.agent) {
934
1081
  console.error(chalk.red(`Session ${resolvedResumeSource.shortId} belongs to ${resolvedResumeSource.agent}, not ${requestedAgent}. ` +
935
1082
  `Use: agents sessions resume ${resolvedResumeSource.id}`));
936
1083
  process.exit(1);
937
1084
  }
938
- if (!autoHarnessRequested && requestedVersion && requestedVersion !== resolvedResumeSource.version) {
939
- console.error(chalk.red(`Session ${resolvedResumeSource.shortId} started with ${resolvedResumeSource.agent}@${resolvedResumeSource.version ?? 'unknown'}, ` +
940
- `not @${requestedVersion}.`));
941
- process.exit(1);
942
- }
943
1085
  // Omitted --mode inherits the effective source mode. Commander keeps
944
1086
  // the normal new-run default as a real value, so consult its provenance
945
1087
  // rather than mistaking the default for an explicit override.
@@ -960,7 +1102,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
960
1102
  options.host = sourcePeer;
961
1103
  }
962
1104
  else if (sourcePeer && explicitPlacement && !hostTargetGiven(options).some((host) => sessionRecoveryDestinationMatches(resolvedResumeSource, host))) {
963
- console.error(chalk.red(`Session ${resolvedResumeSource.shortId} must recover on ${sourcePeer}, where its indexed transcript and version history are owned; ` +
1105
+ console.error(chalk.red(`Session ${resolvedResumeSource.shortId} must recover on ${sourcePeer}, where its conversation state is stored; ` +
964
1106
  `the requested device was ${hostTargetGiven(options).join(', ')}.`));
965
1107
  process.exit(1);
966
1108
  }
@@ -972,7 +1114,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
972
1114
  if (!sourcePeer) {
973
1115
  try {
974
1116
  const { resolveSessionRecovery } = await import('../lib/session/recovery.js');
975
- resolvedRecoveryTarget = await resolveSessionRecovery(resolvedResumeSource);
1117
+ resolvedRecoveryTarget = await resolveSessionRecovery(resolvedResumeSource, undefined, { account: options.account, model: options.model ?? resolvedResumeSource.model });
976
1118
  }
977
1119
  catch (err) {
978
1120
  console.error(chalk.red(err.message));
@@ -981,7 +1123,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
981
1123
  normalizedAgentSpec = `${resolvedRecoveryTarget.agent}@${resolvedRecoveryTarget.version}`;
982
1124
  autoHarnessRequested = false;
983
1125
  if (!options.quiet)
984
- process.stderr.write(chalk.gray(`[agents] session recovery → ${resolvedRecoveryTarget.mode} ${normalizedAgentSpec} on ${sourceMachine ?? machineId()} · ${resolvedRecoveryTarget.reason}\n`));
1126
+ process.stderr.write(chalk.gray(`[agents] session recovery → ${resolvedRecoveryTarget.mode} ${resolvedRecoveryTarget.agent} on ${sourceMachine ?? machineId()} · ${resolvedRecoveryTarget.reason}\n`));
985
1127
  }
986
1128
  else if (autoHarnessRequested) {
987
1129
  normalizedAgentSpec = sourceAgent;
@@ -997,7 +1139,11 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
997
1139
  process.exit(1);
998
1140
  }
999
1141
  if (accountPickerRequested) {
1000
- 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>@).`));
1001
1147
  process.exit(1);
1002
1148
  }
1003
1149
  // Host layer: with no explicit --device, default to the
@@ -1006,19 +1152,100 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1006
1152
  if (!resolvedResumeSource && runAutoDefaultsToAffinity(options))
1007
1153
  options.device = 'auto';
1008
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';
1009
1223
  // --device auto (and deprecated --smart): live fleet pick.
1010
1224
  // Harness is always the agent the user typed — never auto-picked.
1011
1225
  // Placement failure propagates; an automatic request never becomes local.
1012
1226
  {
1013
1227
  const { applyDeviceAutoToOptions } = await import('../lib/smart-launch.js');
1014
- const result = await applyDeviceAutoToOptions(options, {
1015
- accountPickerRequested,
1016
- // `run auto` selects its harness after placement, so do not filter
1017
- // candidates against an arbitrary proxy harness at this stage.
1018
- agent: normalizedAgentSpec.split('#')[0].split('@')[0] === RUN_AUTO_KEYWORD
1019
- ? undefined
1020
- : (resolveAgentName(normalizedAgentSpec.split('#')[0].split('@')[0]) ?? undefined),
1021
- });
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
+ }
1022
1249
  if (!options.quiet && result.deprecationSmart) {
1023
1250
  process.stderr.write(chalk.yellow('[agents] --smart is deprecated; use --device auto\n'));
1024
1251
  }
@@ -1419,8 +1646,6 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1419
1646
  hostRejects.push(RUN_OPTION_REJECT_MESSAGES.allowExpired);
1420
1647
  if (options.resumeCheckpoint)
1421
1648
  hostRejects.push(RUN_OPTION_REJECT_MESSAGES.resumeCheckpoint);
1422
- if (options.resume === true)
1423
- hostRejects.push(RUN_OPTION_REJECT_MESSAGES.resumeBare);
1424
1649
  if (hostRejects.length > 0) {
1425
1650
  for (const msg of hostRejects)
1426
1651
  console.error(chalk.red(msg));
@@ -1498,9 +1723,11 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1498
1723
  process.exit(1);
1499
1724
  }
1500
1725
  const interactiveHost = options.interactive === true || (prompt === undefined && options.headless !== true);
1501
- 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) {
1502
1729
  console.error(chalk.red(`Account selection with ${agentSpec} requires an interactive host run. ` +
1503
- `Use agents run ${runAgent}@ --device ${host.name} --interactive.`));
1730
+ `Use agents run ${runAgent}# --device ${host.name} --interactive.`));
1504
1731
  process.exit(1);
1505
1732
  }
1506
1733
  if (interactiveHost) {
@@ -1562,9 +1789,9 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1562
1789
  const exitCode = await runInteractiveOnHost(host, {
1563
1790
  agent: runAgent,
1564
1791
  version: resumeId ? undefined : runVersion,
1565
- accountPicker: accountPickerRequested,
1792
+ accountPicker: accountPickerRequested && !upFrontAccountPick,
1566
1793
  strategy: resumeId ? undefined : runStrategy,
1567
- account: resumeId ? undefined : options.account,
1794
+ account: options.account,
1568
1795
  fallback: options.fallback,
1569
1796
  prompt,
1570
1797
  mode: forwardedMode,
@@ -1672,7 +1899,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1672
1899
  agent: runAgent,
1673
1900
  version: resumeId ? undefined : runVersion,
1674
1901
  strategy: resumeId ? undefined : runStrategy,
1675
- account: resumeId ? undefined : options.account,
1902
+ account: options.account,
1676
1903
  fallback: options.fallback,
1677
1904
  prompt,
1678
1905
  mode: forwardedMode,
@@ -1894,12 +2121,12 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1894
2121
  let workflowHasSubagents = false;
1895
2122
  const cwd = options.cwd ?? process.cwd();
1896
2123
  if (accountPickerRequested && profileExists(rawAgent)) {
1897
- 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.`));
1898
2125
  process.exit(1);
1899
2126
  }
1900
2127
  if (accountPickerRequested && !isValidAgent(rawAgent)) {
1901
2128
  if (resolveWorkflowRef(rawAgent, cwd)) {
1902
- 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.`));
1903
2130
  process.exit(1);
1904
2131
  }
1905
2132
  }
@@ -2180,7 +2407,10 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2180
2407
  process.exit(1);
2181
2408
  }
2182
2409
  }
2183
- 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) {
2184
2414
  if (!supportsAccountInspection(agent)) {
2185
2415
  console.error(chalk.red(`${agentLabel(agent)} does not expose local account state, so agents-cli cannot safely select an account.`));
2186
2416
  console.error(chalk.gray(`Supported account pickers: ${ACCOUNT_INSPECTION_AGENT_IDS.join(', ')}`));
@@ -2206,88 +2436,101 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2206
2436
  process.exit(1);
2207
2437
  }
2208
2438
  }
2439
+ if (upFrontAccountPick) {
2440
+ version = upFrontAccountPick.version;
2441
+ if (upFrontAccountPick.slotDir)
2442
+ execHome = upFrontAccountPick.slotDir;
2443
+ }
2209
2444
  version = resolveVersionAlias(agent, version);
2210
- // Account selection follows the binding order: explicit --account → exact
2211
- // `agent@version` binding → device-scoped `agent` binding → per-harness
2212
- // default. The exact-installation binding needs the concrete launch
2213
- // version, so resolve it (default when the run named no version).
2214
2445
  const { resolveSpawnAccount } = await import('../lib/account-registry.js');
2215
- // Binding key: a custom harness keys on its own profile name; a native /
2216
- // global run keys on the exact `agent@version` (default when unpinned).
2217
- const bindingTarget = fromProfile ? rawAgent : (version ? `${agent}@${version}` : `${agent}@${getGlobalDefault(agent) ?? ''}`);
2218
2446
  let spawnAccount = null;
2219
- try {
2220
- spawnAccount = resolveSpawnAccount(options.account, agent, version, readMeta(), { useDefault: !fromProfile, provider: profileProvider, target: bindingTarget });
2221
- }
2222
- catch (err) {
2223
- console.error(chalk.red(err.message));
2224
- process.exit(1);
2447
+ let launchAccount;
2448
+ if (resolvedRecoveryTarget) {
2449
+ const { resolveLocalAccountLaunch } = await import('../lib/accounting/account-launch.js');
2450
+ launchAccount = await resolveLocalAccountLaunch({
2451
+ agent,
2452
+ executableVersion: resolvedRecoveryTarget.version,
2453
+ candidate: resolvedRecoveryTarget.candidate,
2454
+ useDefault: false,
2455
+ });
2456
+ execHome = resolvedRecoveryTarget.mode === 'native' ? (resolvedRecoveryTarget.execHome ?? launchAccount.execHome) : launchAccount.execHome;
2457
+ accountConfigVersion = resolvedRecoveryTarget.mode === 'native' ? (resolvedRecoveryTarget.configVersion ?? launchAccount.configVersion) : launchAccount.configVersion;
2458
+ accountEnv = { ...accountEnv, ...launchAccount.env };
2225
2459
  }
2226
- // Downstream rotation gating asks only "was an account selected?".
2227
- const configuredAccount = spawnAccount?.name;
2228
- if (spawnAccount) {
2229
- if (options.cloud || options.provider || options.lease) {
2230
- console.error(chalk.red('--account selects a device-local credential and cannot be combined with cloud or lease placement.'));
2460
+ else {
2461
+ // Binding key: a custom harness keys on its own profile name; a native /
2462
+ // global run keys on the exact `agent@version` (default when unpinned).
2463
+ const bindingTarget = fromProfile ? rawAgent : (version ? `${agent}@${version}` : `${agent}@${getGlobalDefault(agent) ?? ''}`);
2464
+ try {
2465
+ spawnAccount = resolveSpawnAccount(options.account, agent, version, readMeta(), { useDefault: !fromProfile, provider: profileProvider, target: bindingTarget });
2466
+ }
2467
+ catch (err) {
2468
+ console.error(chalk.red(err.message));
2231
2469
  process.exit(1);
2232
2470
  }
2233
- if (spawnAccount.kind === 'native') {
2234
- // `#name` / `--account` on a --device run is forwarded unchanged at
2235
- // dispatch time; the peer resolves ITS slot. This block is local-only.
2236
- const remoteTarget = options.host || options.device;
2237
- if (!remoteTarget) {
2238
- const { adoptedConfigPointsAtHome, adoptedSymlinkMismatchError, durableSlotEnv, isSymlinkAdoptedHarness, resolveNativeSpawnHome, symlinkAdoptedAccountError, } = await import('../lib/exec-account-home.js');
2239
- const { readMeta } = await import('../lib/state.js');
2240
- const meta = readMeta();
2241
- if (isSymlinkAdoptedHarness(agent)) {
2242
- const defaultName = meta.accounts?.defaults?.[agent];
2243
- if (spawnAccount.name !== defaultName) {
2244
- console.error(chalk.red(symlinkAdoptedAccountError(agent, spawnAccount.name, defaultName)));
2245
- process.exit(1);
2471
+ // Downstream rotation gating asks only "was an account selected?".
2472
+ if (spawnAccount) {
2473
+ if (options.cloud || options.provider || options.lease) {
2474
+ console.error(chalk.red('--account selects a device-local credential and cannot be combined with cloud or lease placement.'));
2475
+ process.exit(1);
2476
+ }
2477
+ if (spawnAccount.kind === 'native') {
2478
+ // `#name` / `--account` on a --device run is forwarded unchanged at
2479
+ // dispatch time; the peer resolves ITS slot. This block is local-only.
2480
+ const remoteTarget = options.host || options.device;
2481
+ if (!remoteTarget) {
2482
+ const { adoptedConfigPointsAtHome, adoptedSymlinkMismatchError, durableSlotEnv, isSymlinkAdoptedHarness, resolveNativeSpawnHome, symlinkAdoptedAccountError, } = await import('../lib/exec-account-home.js');
2483
+ const { readMeta } = await import('../lib/state.js');
2484
+ const meta = readMeta();
2485
+ if (isSymlinkAdoptedHarness(agent)) {
2486
+ const defaultName = meta.accounts?.defaults?.[agent];
2487
+ if (spawnAccount.name !== defaultName) {
2488
+ console.error(chalk.red(symlinkAdoptedAccountError(agent, spawnAccount.name, defaultName)));
2489
+ process.exit(1);
2490
+ }
2246
2491
  }
2247
- }
2248
- try {
2249
- const resolved = await resolveNativeSpawnHome(agent, spawnAccount, meta);
2250
- if (isSymlinkAdoptedHarness(agent) && !adoptedConfigPointsAtHome(agent, resolved.execHome)) {
2251
- console.error(chalk.red(adoptedSymlinkMismatchError(agent, spawnAccount.name, resolved.execHome)));
2492
+ try {
2493
+ const resolved = await resolveNativeSpawnHome(agent, spawnAccount, meta);
2494
+ if (isSymlinkAdoptedHarness(agent) && !adoptedConfigPointsAtHome(agent, resolved.execHome)) {
2495
+ console.error(chalk.red(adoptedSymlinkMismatchError(agent, spawnAccount.name, resolved.execHome)));
2496
+ process.exit(1);
2497
+ }
2498
+ execHome = resolved.execHome;
2499
+ // A worker's durable api-key slot holds no file: the pushed key
2500
+ // rides the harness env var (CURSOR_API_KEY, …) on this launch.
2501
+ const durable = durableSlotEnv(agent, spawnAccount, resolved, meta);
2502
+ if (Object.keys(durable).length > 0)
2503
+ accountEnv = { ...accountEnv, ...durable };
2504
+ if (resolved.source === 'legacy-home') {
2505
+ accountConfigVersion = resolved.label;
2506
+ // A leftover version-labeled home is both the spawn HOME and
2507
+ // the account's installation — the pre-T5 `resolveAccountVersion`
2508
+ // behavior. An explicit `@<label>` still controls the binary.
2509
+ if (!version && !fromProfile && resolved.label)
2510
+ version = resolved.label;
2511
+ }
2512
+ }
2513
+ catch (err) {
2514
+ console.error(chalk.red(err.message));
2252
2515
  process.exit(1);
2253
2516
  }
2254
- execHome = resolved.execHome;
2255
- // A worker's durable api-key slot holds no file: the pushed key
2256
- // rides the harness env var (CURSOR_API_KEY, …) on this launch.
2257
- const durable = durableSlotEnv(agent, spawnAccount, resolved, meta);
2258
- if (Object.keys(durable).length > 0)
2259
- accountEnv = { ...accountEnv, ...durable };
2260
- if (resolved.source === 'legacy-home') {
2261
- accountConfigVersion = resolved.label;
2262
- // A leftover version-labeled home is both the spawn HOME and
2263
- // the account's installation — the pre-T5 `resolveAccountVersion`
2264
- // behavior. An explicit `@<label>` still controls the binary.
2265
- if (!version && !fromProfile && resolved.label)
2266
- version = resolved.label;
2517
+ // Slot / provisioned: binary comes from the one managed install unless `@<label>` pins it.
2518
+ if (!version && !fromProfile) {
2519
+ const { ensureHarnessInstallation } = await import('../lib/installations/store.js');
2520
+ const { installation } = await ensureHarnessInstallation(agent);
2521
+ version = installation.label;
2267
2522
  }
2523
+ if (!options.quiet)
2524
+ process.stderr.write(chalk.gray(`[agents] account '${spawnAccount.name}' · ${agent}\n`));
2268
2525
  }
2269
- catch (err) {
2270
- console.error(chalk.red(err.message));
2271
- process.exit(1);
2272
- }
2273
- // Slot / provisioned: binary comes from the one managed install unless `@<label>` pins it.
2274
- if (!version && !fromProfile) {
2275
- const { ensureHarnessInstallation } = await import('../lib/installations/store.js');
2276
- const { installation } = await ensureHarnessInstallation(agent);
2277
- version = installation.label;
2278
- }
2279
- if (!options.quiet)
2280
- process.stderr.write(chalk.gray(`[agents] account '${spawnAccount.name}' · ${agent}\n`));
2281
2526
  }
2282
- }
2283
- else {
2284
- accountEnv = spawnAccount.env;
2527
+ else {
2528
+ accountEnv = spawnAccount.env;
2529
+ }
2285
2530
  }
2286
2531
  }
2287
- // --resume: resolve a prior conversation and rewrite the run target to
2288
- // continue it. `version` here is already the alias-resolved candidate-version
2289
- // FILTER (undefined for default/any, concrete for @latest/@oldest/@x.y.z);
2290
- // it is replaced below by the chosen session's OWN version (isolation).
2532
+ const configuredAccount = launchAccount?.account?.name ?? spawnAccount?.name;
2533
+ // The account and native context were resolved before launch defaults.
2291
2534
  let resumeNative = false;
2292
2535
  let resumeSessionId;
2293
2536
  let forceInteractive = false;
@@ -2300,136 +2543,23 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2300
2543
  console.error(chalk.red('--resume cannot be combined with --loop, --fallback, or --resume-checkpoint (those are separate continuation mechanisms).'));
2301
2544
  process.exit(1);
2302
2545
  }
2303
- const { findSessionsById } = await import('../lib/session/db.js');
2304
- const { discoverSessions } = await import('../lib/session/discover.js');
2305
- const { pickSessionInteractive } = await import('./sessions.js');
2306
2546
  const { buildContinuePrompt } = await import('../lib/loop.js');
2307
- // Freshen the index for this agent before any lookup (incremental, cached).
2308
- // AgentId is wider than SessionAgentId (amp/goose/copilot keep no transcripts);
2309
- // those simply yield no matches and fall through to the not-found error.
2310
- const sessionAgent = agent;
2311
- if (!resolvedResumeSource)
2312
- await discoverSessions({ agent: sessionAgent, version });
2313
- // Resume is interactive unless a follow-on prompt makes it headless.
2314
- const wantsInteractive = resolveInteractive({ interactive: options.interactive, headless: options.headless, prompt });
2315
- const idArg = typeof options.resume === 'string' ? options.resume.trim() : '';
2316
- let scopeCwd;
2317
- try {
2318
- scopeCwd = fs.realpathSync(cwd);
2319
- }
2320
- catch {
2321
- scopeCwd = cwd;
2322
- }
2323
- let session = resolvedResumeSource;
2324
- if (idArg) {
2325
- let matches = session ? [session] : findSessionsById(idArg, { agent: sessionAgent, version, cwd: scopeCwd });
2326
- if (matches.length === 0) {
2327
- const wide = findSessionsById(idArg, { agent: sessionAgent, version });
2328
- if (wide.length > 0) {
2329
- if (!options.quiet)
2330
- process.stderr.write(chalk.gray(`No match for "${idArg}" in this project; widened to all projects.\n`));
2331
- matches = wide;
2332
- }
2333
- }
2334
- if (matches.length === 0) {
2335
- console.error(chalk.red(`No ${agent} session matching "${idArg}".`));
2336
- console.error(chalk.gray(`Browse sessions: agents sessions ${idArg}`));
2337
- process.exit(1);
2338
- }
2339
- else if (matches.length === 1) {
2340
- session = matches[0];
2341
- }
2342
- else if (wantsInteractive) {
2343
- const picked = await pickSessionInteractive(matches, `Multiple sessions match "${idArg}":`);
2344
- if (!picked)
2345
- process.exit(0);
2346
- session = picked.session;
2347
- }
2348
- else {
2349
- console.error(chalk.red(`"${idArg}" is ambiguous — ${matches.length} sessions match:`));
2350
- for (const m of matches.slice(0, 10)) {
2351
- console.error(chalk.gray(` ${m.shortId} ${m.timestamp.slice(0, 16).replace('T', ' ')} ${m.topic ?? m.label ?? ''}`));
2352
- }
2353
- console.error(chalk.gray('Pass more of the id, or resume interactively (drop the prompt).'));
2354
- process.exit(1);
2355
- }
2356
- }
2357
- else {
2358
- // Bare --resume: pick from recent sessions in scope. Needs a TTY.
2359
- if (!wantsInteractive) {
2360
- console.error(chalk.red('--resume with no id needs an interactive terminal. Pass a session id (full or prefix), or run without --headless.'));
2361
- process.exit(1);
2362
- }
2363
- const recent = await discoverSessions({ agent: sessionAgent, version, limit: 200 });
2364
- if (recent.length === 0) {
2365
- console.error(chalk.red(`No ${agent} sessions found to resume in this project.`));
2366
- console.error(chalk.gray('Browse all: agents sessions'));
2367
- process.exit(1);
2368
- }
2369
- const picked = await pickSessionInteractive(recent, `Resume which ${agent} session?`);
2370
- if (!picked)
2371
- process.exit(0);
2372
- session = picked.session;
2373
- forceInteractive = true; // bare resume always lands in the agent's TUI
2374
- }
2375
- // Bare `run <harness> --resume` learns the chosen SessionMeta only after
2376
- // the host-placement phase above. If the picker chose a synced session
2377
- // from another device, route the recovery command now; resolving local
2378
- // candidates would otherwise native-resume through this device's
2379
- // unrelated isolated home.
2380
- if (!resolvedResumeSource) {
2381
- const { sessionRecoveryPeer, sessionRecoveryRunArgs, } = await import('../lib/session/recovery.js');
2382
- const peer = sessionRecoveryPeer(session);
2383
- if (peer) {
2384
- const { runOnPeer } = await import('../lib/session/remote-list.js');
2385
- const routed = await runOnPeer(sessionRecoveryRunArgs(session), peer, { tty: true, sessionId: session.id });
2386
- if (routed === 'no-target') {
2387
- console.error(chalk.red(`Cannot recover session ${session.shortId}: origin device ${peer} is not a registered reachable peer.`));
2388
- process.exitCode = 1;
2389
- }
2390
- return;
2391
- }
2392
- }
2393
- // Bare interactive --resume selects the SessionMeta only down here, so
2394
- // it has not gone through the early concrete-id resolver. Resolve it now;
2395
- // concrete ids reuse the exact same target chosen above.
2396
- if (!resolvedRecoveryTarget) {
2397
- try {
2398
- const { resolveSessionRecovery } = await import('../lib/session/recovery.js');
2399
- resolvedRecoveryTarget = await resolveSessionRecovery(session);
2400
- }
2401
- catch (err) {
2402
- console.error(chalk.red(err.message));
2403
- process.exit(1);
2404
- }
2405
- }
2547
+ const session = resolvedResumeSource;
2548
+ if (!session || !resolvedRecoveryTarget) {
2549
+ throw new Error('Session recovery did not resolve a conversation on this device.');
2550
+ }
2551
+ if (!options.cwd)
2552
+ options.cwd = resolvedRecoveryTarget.mode === 'native'
2553
+ ? resolvedRecoveryTarget.cwd ?? session.cwd
2554
+ : session.cwd;
2555
+ forceInteractive = resolveInteractive({ interactive: options.interactive, headless: options.headless, prompt });
2406
2556
  if (resolvedRecoveryTarget.agent !== agent) {
2407
2557
  console.error(chalk.red(`Session ${session.shortId} belongs to ${resolvedRecoveryTarget.agent}, not ${agent}. ` +
2408
2558
  `Use: agents run auto --resume ${session.id}`));
2409
2559
  process.exit(1);
2410
2560
  }
2411
2561
  version = resolvedRecoveryTarget.version;
2412
- // Account rotation (PHNX-3626 / PHNX-3674): recovery picked a healthy
2413
- // provider of the SAME harness. Inject that credential through the
2414
- // `--account` path so spawn does not authenticate as the version home's
2415
- // native login (the exhausted origin when the continue pick is a
2416
- // provider). An explicit --account (configuredAccount) always wins.
2417
- const rotatedAccount = resolvedRecoveryTarget.account;
2418
- if (rotatedAccount && !configuredAccount) {
2419
- try {
2420
- const picked = resolveSpawnAccount(rotatedAccount.providerAccount, agent, version, readMeta(), { useDefault: false });
2421
- if (picked?.kind === 'provider')
2422
- accountEnv = picked.env;
2423
- }
2424
- catch {
2425
- // Account-registry errors can carry provider credential material;
2426
- // never relay them onto the terminal from this automatic path.
2427
- console.error(chalk.red('Could not prepare the rotated provider account for session recovery.'));
2428
- process.exit(1);
2429
- }
2430
- }
2431
2562
  if (resolvedRecoveryTarget.mode === 'native') {
2432
- version = session.version;
2433
2563
  resumeNative = true;
2434
2564
  resumeSessionId = session.id;
2435
2565
  // The centralized recovery decision proves the transcript belongs to
@@ -2438,23 +2568,23 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2438
2568
  // differ from the earlier cwd that selected projects/<cwd-key>.
2439
2569
  if (!options.cwd && resolvedRecoveryTarget.cwd)
2440
2570
  options.cwd = resolvedRecoveryTarget.cwd;
2441
- if (rotatedAccount && !configuredAccount && !options.quiet) {
2442
- process.stderr.write(chalk.gray(`[agents] origin ${agent} account limited → rotated to ${rotatedAccount.label} (native resume)\n`));
2443
- }
2444
2571
  if (!options.quiet)
2445
- process.stderr.write(chalk.gray(`Resuming ${agent} ${session.shortId} (native)${version ? ` @${version}` : ''} in ${options.cwd ?? cwd}\n`));
2572
+ process.stderr.write(chalk.gray(`Resuming ${agent} ${session.shortId} (native) in ${options.cwd ?? cwd}\n`));
2446
2573
  }
2447
2574
  else {
2448
- // Tier-2: launch fresh with a /continue <id> first message; the agent
2449
- // loads the transcript via `agents sessions <id>` and picks up.
2575
+ if (!isInteractiveTerminal() || options.headless) {
2576
+ console.error(chalk.red(`Native resume is unavailable: ${resolvedRecoveryTarget.reason}. To start a new conversation with this context, explicitly run: agents run ${agent} "/continue ${session.id}"`));
2577
+ process.exit(1);
2578
+ }
2579
+ const { confirm } = await import('@inquirer/prompts');
2580
+ const replay = await confirm({ message: `${resolvedRecoveryTarget.reason}. Start a new conversation using this transcript?`, default: false }).catch(() => false);
2581
+ if (!replay)
2582
+ return;
2450
2583
  prompt = buildContinuePrompt(session.id, prompt);
2451
2584
  if (prompt.trim() === `/continue ${session.id}`)
2452
2585
  forceInteractive = true;
2453
- if (rotatedAccount && !configuredAccount && !options.quiet) {
2454
- process.stderr.write(chalk.gray(`[agents] origin ${agent} account limited → rotated to ${rotatedAccount.label} (/continue)\n`));
2455
- }
2456
2586
  if (!options.quiet)
2457
- process.stderr.write(chalk.gray(`Resuming ${agent} ${session.shortId} (/continue replay)${version ? ` @${version}` : ''}\n`));
2587
+ process.stderr.write(chalk.gray(`Resuming ${agent} ${session.shortId} (/continue replay)\n`));
2458
2588
  }
2459
2589
  }
2460
2590
  const configuredStrategy = getConfiguredRunStrategy(agent, cwd);
@@ -2511,7 +2641,9 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2511
2641
  // picker's other callers keep the native-only collector.
2512
2642
  const { collectRunCandidatesForRun } = await import('../lib/accounting/account-pool-collect.js');
2513
2643
  bootMark('resolve-version:start');
2514
- const resolved = await resolveRunVersion(agent, strategy, cwd, collectRunCandidatesForRun);
2644
+ const routingModel = options.model ?? resolvedResumeSource?.model ?? workflowModel
2645
+ ?? (options.fallback ? undefined : resolveRunDefaults(agent, resolveVersion(agent, cwd), cwd).model);
2646
+ const resolved = await resolveRunVersion(agent, strategy, cwd, collectRunCandidatesForRun, routingModel);
2515
2647
  bootMark('resolve-version:done');
2516
2648
  if (resolved.exhausted) {
2517
2649
  // Zero healthy accounts splits two ways, and conflating them is what
@@ -2526,7 +2658,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2526
2658
  // - NEEDS A SIGN-IN (signed_out / revoked) -> launching IS the fix,
2527
2659
  // because the harness's own TUI is the login surface. So on a TTY we
2528
2660
  // carry the user into that login instead of erroring. Exiting here
2529
- // made `agents run <agent>`, `agents run <agent>@`, and `agents use`
2661
+ // made `agents run <agent>`, `agents run <agent>#`, and `agents use`
2530
2662
  // all dead-end with no reachable way to authenticate.
2531
2663
  const recoverable = signInRecoverableCandidates(resolved.exhausted);
2532
2664
  const { signInLaunchDecision } = await import('./run-account-picker.js');
@@ -2539,7 +2671,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2539
2671
  const { pickSignInLaunchVersion } = await import('./run-account-picker.js');
2540
2672
  const signInVersion = await pickSignInLaunchVersion(agent, recoverable, !!options.quiet);
2541
2673
  // A cancelled prompt launches nothing — same contract as the
2542
- // trailing-@ account picker above.
2674
+ // trailing-# account picker above.
2543
2675
  if (!signInVersion)
2544
2676
  return;
2545
2677
  version = signInVersion;
@@ -2577,7 +2709,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2577
2709
  if (decision === 'picker') {
2578
2710
  const selected = await pickRunAccountCandidate(agent);
2579
2711
  // A cancelled picker launches nothing — same contract as the
2580
- // trailing-@ account picker and the sign-in launch above.
2712
+ // trailing-# account picker and the sign-in launch above.
2581
2713
  if (!selected)
2582
2714
  return;
2583
2715
  version = selected.version;
@@ -2665,17 +2797,6 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2665
2797
  console.error(chalk.red(`agents: ${agent}@${launchTarget} is not runnable and could not be repaired. Try: ${hint}`));
2666
2798
  process.exit(1);
2667
2799
  }
2668
- if (resolvedRecoveryTarget && healed !== launchTarget) {
2669
- console.error(chalk.red(`agents: session recovery target ${agent}@${launchTarget} became unavailable on ` +
2670
- `${resolvedResumeSource?.machine ?? 'this device'}; refusing to resume through another version home. ` +
2671
- `Retry the command so recovery can select a healthy ${agent} version.`));
2672
- process.exit(1);
2673
- }
2674
- // Always adopt the healed version explicitly. In the version-undefined
2675
- // path a fallback re-pins the GLOBAL default, but `resolveVersion`
2676
- // prefers a PROJECT pin — so leaving `version` undefined would let the
2677
- // shim re-resolve the still-broken project pin and crash anyway. Pinning
2678
- // the runnable version here is a no-op when nothing changed.
2679
2800
  version = healed;
2680
2801
  }
2681
2802
  }
@@ -2741,13 +2862,13 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2741
2862
  // `signInLaunch` means the zero-healthy path already reported this exact
2742
2863
  // account as logged out and named the login command, so re-probing here
2743
2864
  // only prints a second, near-identical warning.
2744
- rotated: !!rotationResult || accountPickerRequested || signInLaunch || spawnAccount?.kind === 'provider',
2865
+ rotated: !!rotationResult || accountPickerRequested || signInLaunch || (launchAccount?.account?.kind ?? spawnAccount?.kind) === 'provider',
2745
2866
  });
2746
2867
  if (preflight) {
2747
2868
  try {
2748
2869
  const { getAccountInfo } = await import('../lib/agents.js');
2749
2870
  const authVersion = accountConfigVersion ?? version;
2750
- const info = await getAccountInfo(agent, authVersion ? getVersionHomePath(agent, authVersion) : undefined);
2871
+ const info = await getAccountInfo(agent, execHome ?? (authVersion ? getVersionHomePath(agent, authVersion) : undefined));
2751
2872
  // Claude authenticates interactively from a per-version setup-token on a
2752
2873
  // keychain-less worker (the shim's .oauth_token fallback), which the
2753
2874
  // native-credential probe above can't see — so don't warn "logged out" when
@@ -2761,8 +2882,8 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2761
2882
  }
2762
2883
  if (!info.signedIn && !authedViaSetupToken) {
2763
2884
  const { addSupported } = await import('../lib/accounts/add.js');
2764
- const hint = spawnAccount?.kind === 'native'
2765
- ? `agents accounts login ${agent}#${spawnAccount.name}`
2885
+ const hint = (launchAccount?.account?.kind ?? spawnAccount?.kind) === 'native'
2886
+ ? `agents accounts login ${agent}#${configuredAccount}`
2766
2887
  : addSupported(agent)
2767
2888
  ? `agents accounts add ${agent} <name>`
2768
2889
  : loginHint(agent);
@@ -2823,7 +2944,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2823
2944
  // `--mode plan` is respected for genuine read-only command runs.
2824
2945
  const stallCmd = headlessPlanStallCommand({
2825
2946
  prompt,
2826
- interactive: options.interactive,
2947
+ interactive: resolveInteractive({ prompt, headless: options.headless, interactive: options.interactive || forceInteractive }),
2827
2948
  mode: resolvedMode,
2828
2949
  modeIsDefault,
2829
2950
  });
@@ -2918,6 +3039,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2918
3039
  : undefined;
2919
3040
  const modelSource = runCmd.getOptionValueSource('model');
2920
3041
  let model = options.model
3042
+ ?? resolvedResumeSource?.model
2921
3043
  ?? (!fromProfile && modelSource === undefined
2922
3044
  ? (workflowModel ?? (options.fallback ? undefined : runDefaults.model))
2923
3045
  : undefined);
@@ -2933,6 +3055,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2933
3055
  const execOptions = {
2934
3056
  agent,
2935
3057
  harnessName: profileName,
3058
+ accountId: launchAccount?.account?.kind === 'legacy-native' ? undefined : (launchAccount?.account?.id ?? spawnAccount?.id),
2936
3059
  version,
2937
3060
  configVersion: accountConfigVersion,
2938
3061
  execHome,