@phnx-labs/agents-cli 1.22.104 → 1.22.106

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 (58) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +19 -1
  3. package/dist/browser.js +0 -0
  4. package/dist/commands/exec.js +135 -233
  5. package/dist/commands/resume.d.ts +6 -21
  6. package/dist/commands/resume.js +18 -55
  7. package/dist/commands/run-device-picker.d.ts +58 -0
  8. package/dist/commands/run-device-picker.js +222 -0
  9. package/dist/commands/sessions-resume.d.ts +4 -0
  10. package/dist/commands/sessions-resume.js +132 -49
  11. package/dist/commands/sessions.js +32 -5
  12. package/dist/commands/setup-computer.js +2 -2
  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/models.d.ts +1 -1
  28. package/dist/lib/models.js +4 -4
  29. package/dist/lib/session/actor-sidecar.d.ts +3 -11
  30. package/dist/lib/session/actor-sidecar.js +3 -0
  31. package/dist/lib/session/claude-accounts.d.ts +12 -73
  32. package/dist/lib/session/claude-accounts.js +32 -70
  33. package/dist/lib/session/db.d.ts +1 -1
  34. package/dist/lib/session/db.js +18 -5
  35. package/dist/lib/session/discover.d.ts +4 -0
  36. package/dist/lib/session/discover.js +116 -15
  37. package/dist/lib/session/recovery.d.ts +30 -34
  38. package/dist/lib/session/recovery.js +212 -76
  39. package/dist/lib/session/types.d.ts +2 -0
  40. package/dist/lib/teams/placement-probe.js +1 -1
  41. package/dist/session-tracker/dist/adapters/claude.d.ts +10 -0
  42. package/dist/session-tracker/dist/adapters/claude.js +45 -0
  43. package/dist/session-tracker/dist/hook.sh +191 -0
  44. package/dist/session-tracker/dist/index.d.ts +19 -0
  45. package/dist/session-tracker/dist/index.js +67 -0
  46. package/dist/session-tracker/dist/install-hook.d.ts +19 -0
  47. package/dist/session-tracker/dist/install-hook.js +245 -0
  48. package/dist/session-tracker/dist/prune-state.d.ts +2 -0
  49. package/dist/session-tracker/dist/prune-state.js +7 -0
  50. package/dist/session-tracker/dist/reader.d.ts +7 -0
  51. package/dist/session-tracker/dist/reader.js +151 -0
  52. package/dist/session-tracker/dist/state-file.d.ts +10 -0
  53. package/dist/session-tracker/dist/state-file.js +119 -0
  54. package/dist/session-tracker/dist/types.d.ts +32 -0
  55. package/dist/session-tracker/dist/types.js +1 -0
  56. package/dist/session-tracker/dist/writer.d.ts +12 -0
  57. package/dist/session-tracker/dist/writer.js +27 -0
  58. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.106
4
+
5
+ - Fixed release packages omitting the session-tracking hook. Release qualification now uses the complete CLI build, so installed agents can record the account that owns each native session.
6
+
7
+ - **Resume conversations by account with one shared picker (PHNX-3940).** `ag run claude --resume` opens the same history picker as `agents sessions resume --agent claude`; `claude#work` applies `--account work`. Selected conversations retain their account, model, permissions, cwd and run options while using the installed binary. Canonical account homes and short Codex homes are discovered independently of version installs. Explicit terminal backends work with session IDs. Native context is verified after cold discovery, and archived replay requires an explicit choice. Origin account identity survives alias updates and index rescans; a model refusal does not disable unrelated models or accounts.
8
+
9
+ ## 1.22.105
10
+
11
+ - Computer setup installs standalone Computer CLI 0.1.2, whose application code is obfuscated before compilation to increase inspection effort. Agents continues to provide permissions and session tracking through its adapter.
12
+
13
+ - Fleet test runs use separate temporary workspaces and clean up their own copies. Router account fixtures run only with isolated file-backed secret storage, preserving native keyrings.
14
+
15
+ - **Device picker module for `agents run <agent>@` (PHNX-4083).** New
16
+ `run-device-picker` command module builds the fleet device menu entirely from
17
+ on-disk state — the device registry, the cached fleet stats, configured roles
18
+ and descriptions, and the fleet-synced account catalog — with zero SSH, zero
19
+ network, and no re-probing of stale rows. Source: `src/commands/run-device-picker.ts`.
20
+
3
21
  ## 1.22.104
4
22
 
5
23
  - **Interactive Claude runs on a worker fail loud instead of dropping to the login screen (PHNX-3502 sibling).** `agents run claude --interactive --device auto/<worker>` whose selected account had no synced setup-token used to fall through to Claude Code's "Select login method" screen — an interactive OAuth on a headless box that never persists, so Anthropic re-prompted on the next run (the repeated re-login loop). The run is now refused before spawn with the real fix (pin a live account with `agents run claude#<name>`, set the fleet default with `agents accounts default claude <name>`, or mint the account's token on a headed box with `agents accounts login claude#<name>`). Headless runs are unchanged (they already fail loud with a 401), and headed boxes still show their normal native-login prompt. Source: `cli/src/lib/harness/adapters/claude.ts`, `cli/src/lib/exec.ts`.
package/README.md CHANGED
@@ -431,6 +431,24 @@ Both targets are on-demand backups only; neither enables background session sync
431
431
 
432
432
  ### Resume anywhere — and stay resumed
433
433
 
434
+ Omit the session ID to choose from an interactive history picker with descriptions
435
+ and conversation previews. These commands use the same picker and account filter:
436
+
437
+ ```bash
438
+ ag run claude --resume
439
+ agents sessions resume --agent claude
440
+
441
+ ag run claude#work --resume
442
+ agents sessions resume --agent claude --account work
443
+ ```
444
+
445
+ Resume restores the conversation's account, model, mode and working directory with
446
+ the currently installed harness. Updating that binary does not select a different
447
+ account. `agents sessions resume <id> --vscodium` opens the selected conversation in
448
+ VSCodium. If only archived context remains, the CLI asks before starting a new
449
+ conversation from it; a missing transcript fails before launching an agent.
450
+
451
+
434
452
  Pick up any past conversation and drop it back into a terminal:
435
453
 
436
454
  ```bash
@@ -446,7 +464,7 @@ agents run auto --resume 019fd0c8-b3e9-77a2-a1a4-444698c4d897 # adapt if its ac
446
464
 
447
465
  ### Send an agent to the background — and bring it back
448
466
 
449
- Running 30 agents and drowning in terminal tabs? `agents sessions detach <id>` stops a session's interactive process and keeps it working **headless** in the background -- it drives its task to done unattended, no tab, lower cost. `agents sessions resume <id>` brings it back through the same origin-device recovery decision: native resume in the exact origin home (healthy login, or a rotated injectable account on a usage limit), or same-harness `/continue` as last resort when that home is unavailable, with the full indexed history (including whatever it did while backgrounded).
467
+ Running 30 agents and drowning in terminal tabs? `agents sessions detach <id>` stops a session's interactive process and keeps it working **headless** in the background -- it drives its task to done unattended, no tab, lower cost. `agents sessions resume <id>` brings it back through the same origin-device recovery decision: native resume in the owned account home, or an explicitly chosen same-harness `/continue` replay when native context is unavailable.
450
468
 
451
469
  ```
452
470
  agents sessions detach a1b2c3d4 # go headless in the background, keep working
package/dist/browser.js CHANGED
File without changes
@@ -521,7 +521,7 @@ export function registerRunCommand(program) {
521
521
  .option('--list-tasks', 'With --broadcast: list available broadcast task ids')
522
522
  .option('--results [run-id]', 'With --broadcast: show one saved matrix run, or list saved runs newest first')
523
523
  .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.')
524
+ .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
525
  .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
526
  .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
527
  .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).')
@@ -692,7 +692,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
692
692
  given. --cloud is mutually exclusive with --device/--lease and with
693
693
  local-run flags (--loop, --resume, --secrets, --terminal, …).
694
694
 
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.
695
+ 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
696
 
697
697
  Passthrough: everything after -- is forwarded verbatim to the underlying agent CLI.
698
698
  agents run kimi -- --plan --some-native-flag value
@@ -861,6 +861,27 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
861
861
  if (specAccountLabel)
862
862
  options.account = specAccountLabel;
863
863
  }
864
+ // Bare resume is a session selection request, before fresh-run defaults or placement.
865
+ // Concrete IDs re-enter the executor below, so this cannot recurse.
866
+ if (options.resume === true || options.resume === '') {
867
+ if (options.sessionId || options.loop || options.fallback || options.resumeCheckpoint || options.lease) {
868
+ throw new Error('--resume cannot be combined with --session-id, --loop, --fallback, --resume-checkpoint, or --lease.');
869
+ }
870
+ const { sessionsResumeAction } = await import('./sessions-resume.js');
871
+ await sessionsResumeAction(undefined, prompt, {
872
+ agent: normalizedAgentSpec.split('#')[0] === RUN_AUTO_KEYWORD ? undefined : normalizedAgentSpec.split('#')[0],
873
+ account: options.account,
874
+ model: options.model,
875
+ mode: command.getOptionValueSource('mode') === 'default' ? undefined : options.mode,
876
+ interactive: options.interactive,
877
+ headless: options.headless,
878
+ cwd: options.cwd,
879
+ quiet: options.quiet,
880
+ device: options.host || options.device || options.on || options.computer,
881
+ runArgs: rawArgs.slice(2),
882
+ });
883
+ return;
884
+ }
864
885
  // Hard-deprecated harnesses cannot be run — point the user at the successor.
865
886
  const runBaseAgentName = normalizedAgentSpec.split('#')[0].split('@')[0];
866
887
  const runBaseAgentId = resolveAgentName(runBaseAgentName);
@@ -908,7 +929,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
908
929
  delete process.env.AGENTS_RESUME_SOURCE_JSON;
909
930
  const outcome = injectedSource
910
931
  ? { kind: 'resolved', session: injectedSource }
911
- : await (await import('./sessions.js')).resolveSessionMetadataValue(selector);
932
+ : await (await import('./sessions.js')).resolveSessionMetadataValue(selector, { agent: runBaseAgentId ?? undefined });
912
933
  if (outcome.kind === 'partial') {
913
934
  // RUSH-2492: an unreachable peer is a warning, not a hard failure. The
914
935
  // resolver already resolves an id found on the reachable fleet (SES-9a),
@@ -929,17 +950,12 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
929
950
  process.exit(1);
930
951
  }
931
952
  resolvedResumeSource = outcome.session;
932
- const [requestedAgent, requestedVersion] = normalizedAgentSpec.split('#')[0].split('@');
953
+ const [requestedAgent] = normalizedAgentSpec.split('#')[0].split('@');
933
954
  if (!autoHarnessRequested && requestedAgent !== resolvedResumeSource.agent) {
934
955
  console.error(chalk.red(`Session ${resolvedResumeSource.shortId} belongs to ${resolvedResumeSource.agent}, not ${requestedAgent}. ` +
935
956
  `Use: agents sessions resume ${resolvedResumeSource.id}`));
936
957
  process.exit(1);
937
958
  }
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
959
  // Omitted --mode inherits the effective source mode. Commander keeps
944
960
  // the normal new-run default as a real value, so consult its provenance
945
961
  // rather than mistaking the default for an explicit override.
@@ -960,7 +976,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
960
976
  options.host = sourcePeer;
961
977
  }
962
978
  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; ` +
979
+ console.error(chalk.red(`Session ${resolvedResumeSource.shortId} must recover on ${sourcePeer}, where its conversation state is stored; ` +
964
980
  `the requested device was ${hostTargetGiven(options).join(', ')}.`));
965
981
  process.exit(1);
966
982
  }
@@ -972,7 +988,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
972
988
  if (!sourcePeer) {
973
989
  try {
974
990
  const { resolveSessionRecovery } = await import('../lib/session/recovery.js');
975
- resolvedRecoveryTarget = await resolveSessionRecovery(resolvedResumeSource);
991
+ resolvedRecoveryTarget = await resolveSessionRecovery(resolvedResumeSource, undefined, { account: options.account, model: options.model ?? resolvedResumeSource.model });
976
992
  }
977
993
  catch (err) {
978
994
  console.error(chalk.red(err.message));
@@ -981,7 +997,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
981
997
  normalizedAgentSpec = `${resolvedRecoveryTarget.agent}@${resolvedRecoveryTarget.version}`;
982
998
  autoHarnessRequested = false;
983
999
  if (!options.quiet)
984
- process.stderr.write(chalk.gray(`[agents] session recovery → ${resolvedRecoveryTarget.mode} ${normalizedAgentSpec} on ${sourceMachine ?? machineId()} · ${resolvedRecoveryTarget.reason}\n`));
1000
+ process.stderr.write(chalk.gray(`[agents] session recovery → ${resolvedRecoveryTarget.mode} ${resolvedRecoveryTarget.agent} on ${sourceMachine ?? machineId()} · ${resolvedRecoveryTarget.reason}\n`));
985
1001
  }
986
1002
  else if (autoHarnessRequested) {
987
1003
  normalizedAgentSpec = sourceAgent;
@@ -1419,8 +1435,6 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1419
1435
  hostRejects.push(RUN_OPTION_REJECT_MESSAGES.allowExpired);
1420
1436
  if (options.resumeCheckpoint)
1421
1437
  hostRejects.push(RUN_OPTION_REJECT_MESSAGES.resumeCheckpoint);
1422
- if (options.resume === true)
1423
- hostRejects.push(RUN_OPTION_REJECT_MESSAGES.resumeBare);
1424
1438
  if (hostRejects.length > 0) {
1425
1439
  for (const msg of hostRejects)
1426
1440
  console.error(chalk.red(msg));
@@ -1564,7 +1578,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1564
1578
  version: resumeId ? undefined : runVersion,
1565
1579
  accountPicker: accountPickerRequested,
1566
1580
  strategy: resumeId ? undefined : runStrategy,
1567
- account: resumeId ? undefined : options.account,
1581
+ account: options.account,
1568
1582
  fallback: options.fallback,
1569
1583
  prompt,
1570
1584
  mode: forwardedMode,
@@ -1672,7 +1686,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1672
1686
  agent: runAgent,
1673
1687
  version: resumeId ? undefined : runVersion,
1674
1688
  strategy: resumeId ? undefined : runStrategy,
1675
- account: resumeId ? undefined : options.account,
1689
+ account: options.account,
1676
1690
  fallback: options.fallback,
1677
1691
  prompt,
1678
1692
  mode: forwardedMode,
@@ -2207,87 +2221,95 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2207
2221
  }
2208
2222
  }
2209
2223
  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
2224
  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
2225
  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);
2226
+ let launchAccount;
2227
+ if (resolvedRecoveryTarget) {
2228
+ const { resolveLocalAccountLaunch } = await import('../lib/accounting/account-launch.js');
2229
+ launchAccount = await resolveLocalAccountLaunch({
2230
+ agent,
2231
+ executableVersion: resolvedRecoveryTarget.version,
2232
+ candidate: resolvedRecoveryTarget.candidate,
2233
+ useDefault: false,
2234
+ });
2235
+ execHome = resolvedRecoveryTarget.mode === 'native' ? (resolvedRecoveryTarget.execHome ?? launchAccount.execHome) : launchAccount.execHome;
2236
+ accountConfigVersion = resolvedRecoveryTarget.mode === 'native' ? (resolvedRecoveryTarget.configVersion ?? launchAccount.configVersion) : launchAccount.configVersion;
2237
+ accountEnv = { ...accountEnv, ...launchAccount.env };
2225
2238
  }
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.'));
2239
+ else {
2240
+ // Binding key: a custom harness keys on its own profile name; a native /
2241
+ // global run keys on the exact `agent@version` (default when unpinned).
2242
+ const bindingTarget = fromProfile ? rawAgent : (version ? `${agent}@${version}` : `${agent}@${getGlobalDefault(agent) ?? ''}`);
2243
+ try {
2244
+ spawnAccount = resolveSpawnAccount(options.account, agent, version, readMeta(), { useDefault: !fromProfile, provider: profileProvider, target: bindingTarget });
2245
+ }
2246
+ catch (err) {
2247
+ console.error(chalk.red(err.message));
2231
2248
  process.exit(1);
2232
2249
  }
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);
2250
+ // Downstream rotation gating asks only "was an account selected?".
2251
+ if (spawnAccount) {
2252
+ if (options.cloud || options.provider || options.lease) {
2253
+ console.error(chalk.red('--account selects a device-local credential and cannot be combined with cloud or lease placement.'));
2254
+ process.exit(1);
2255
+ }
2256
+ if (spawnAccount.kind === 'native') {
2257
+ // `#name` / `--account` on a --device run is forwarded unchanged at
2258
+ // dispatch time; the peer resolves ITS slot. This block is local-only.
2259
+ const remoteTarget = options.host || options.device;
2260
+ if (!remoteTarget) {
2261
+ const { adoptedConfigPointsAtHome, adoptedSymlinkMismatchError, durableSlotEnv, isSymlinkAdoptedHarness, resolveNativeSpawnHome, symlinkAdoptedAccountError, } = await import('../lib/exec-account-home.js');
2262
+ const { readMeta } = await import('../lib/state.js');
2263
+ const meta = readMeta();
2264
+ if (isSymlinkAdoptedHarness(agent)) {
2265
+ const defaultName = meta.accounts?.defaults?.[agent];
2266
+ if (spawnAccount.name !== defaultName) {
2267
+ console.error(chalk.red(symlinkAdoptedAccountError(agent, spawnAccount.name, defaultName)));
2268
+ process.exit(1);
2269
+ }
2246
2270
  }
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)));
2271
+ try {
2272
+ const resolved = await resolveNativeSpawnHome(agent, spawnAccount, meta);
2273
+ if (isSymlinkAdoptedHarness(agent) && !adoptedConfigPointsAtHome(agent, resolved.execHome)) {
2274
+ console.error(chalk.red(adoptedSymlinkMismatchError(agent, spawnAccount.name, resolved.execHome)));
2275
+ process.exit(1);
2276
+ }
2277
+ execHome = resolved.execHome;
2278
+ // A worker's durable api-key slot holds no file: the pushed key
2279
+ // rides the harness env var (CURSOR_API_KEY, …) on this launch.
2280
+ const durable = durableSlotEnv(agent, spawnAccount, resolved, meta);
2281
+ if (Object.keys(durable).length > 0)
2282
+ accountEnv = { ...accountEnv, ...durable };
2283
+ if (resolved.source === 'legacy-home') {
2284
+ accountConfigVersion = resolved.label;
2285
+ // A leftover version-labeled home is both the spawn HOME and
2286
+ // the account's installation — the pre-T5 `resolveAccountVersion`
2287
+ // behavior. An explicit `@<label>` still controls the binary.
2288
+ if (!version && !fromProfile && resolved.label)
2289
+ version = resolved.label;
2290
+ }
2291
+ }
2292
+ catch (err) {
2293
+ console.error(chalk.red(err.message));
2252
2294
  process.exit(1);
2253
2295
  }
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;
2296
+ // Slot / provisioned: binary comes from the one managed install unless `@<label>` pins it.
2297
+ if (!version && !fromProfile) {
2298
+ const { ensureHarnessInstallation } = await import('../lib/installations/store.js');
2299
+ const { installation } = await ensureHarnessInstallation(agent);
2300
+ version = installation.label;
2267
2301
  }
2302
+ if (!options.quiet)
2303
+ process.stderr.write(chalk.gray(`[agents] account '${spawnAccount.name}' · ${agent}\n`));
2268
2304
  }
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
2305
  }
2282
- }
2283
- else {
2284
- accountEnv = spawnAccount.env;
2306
+ else {
2307
+ accountEnv = spawnAccount.env;
2308
+ }
2285
2309
  }
2286
2310
  }
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).
2311
+ const configuredAccount = launchAccount?.account?.name ?? spawnAccount?.name;
2312
+ // The account and native context were resolved before launch defaults.
2291
2313
  let resumeNative = false;
2292
2314
  let resumeSessionId;
2293
2315
  let forceInteractive = false;
@@ -2300,136 +2322,23 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2300
2322
  console.error(chalk.red('--resume cannot be combined with --loop, --fallback, or --resume-checkpoint (those are separate continuation mechanisms).'));
2301
2323
  process.exit(1);
2302
2324
  }
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
2325
  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
- }
2326
+ const session = resolvedResumeSource;
2327
+ if (!session || !resolvedRecoveryTarget) {
2328
+ throw new Error('Session recovery did not resolve a conversation on this device.');
2329
+ }
2330
+ if (!options.cwd)
2331
+ options.cwd = resolvedRecoveryTarget.mode === 'native'
2332
+ ? resolvedRecoveryTarget.cwd ?? session.cwd
2333
+ : session.cwd;
2334
+ forceInteractive = resolveInteractive({ interactive: options.interactive, headless: options.headless, prompt });
2406
2335
  if (resolvedRecoveryTarget.agent !== agent) {
2407
2336
  console.error(chalk.red(`Session ${session.shortId} belongs to ${resolvedRecoveryTarget.agent}, not ${agent}. ` +
2408
2337
  `Use: agents run auto --resume ${session.id}`));
2409
2338
  process.exit(1);
2410
2339
  }
2411
2340
  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
2341
  if (resolvedRecoveryTarget.mode === 'native') {
2432
- version = session.version;
2433
2342
  resumeNative = true;
2434
2343
  resumeSessionId = session.id;
2435
2344
  // The centralized recovery decision proves the transcript belongs to
@@ -2438,23 +2347,23 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2438
2347
  // differ from the earlier cwd that selected projects/<cwd-key>.
2439
2348
  if (!options.cwd && resolvedRecoveryTarget.cwd)
2440
2349
  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
2350
  if (!options.quiet)
2445
- process.stderr.write(chalk.gray(`Resuming ${agent} ${session.shortId} (native)${version ? ` @${version}` : ''} in ${options.cwd ?? cwd}\n`));
2351
+ process.stderr.write(chalk.gray(`Resuming ${agent} ${session.shortId} (native) in ${options.cwd ?? cwd}\n`));
2446
2352
  }
2447
2353
  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.
2354
+ if (!isInteractiveTerminal() || options.headless) {
2355
+ 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}"`));
2356
+ process.exit(1);
2357
+ }
2358
+ const { confirm } = await import('@inquirer/prompts');
2359
+ const replay = await confirm({ message: `${resolvedRecoveryTarget.reason}. Start a new conversation using this transcript?`, default: false }).catch(() => false);
2360
+ if (!replay)
2361
+ return;
2450
2362
  prompt = buildContinuePrompt(session.id, prompt);
2451
2363
  if (prompt.trim() === `/continue ${session.id}`)
2452
2364
  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
2365
  if (!options.quiet)
2457
- process.stderr.write(chalk.gray(`Resuming ${agent} ${session.shortId} (/continue replay)${version ? ` @${version}` : ''}\n`));
2366
+ process.stderr.write(chalk.gray(`Resuming ${agent} ${session.shortId} (/continue replay)\n`));
2458
2367
  }
2459
2368
  }
2460
2369
  const configuredStrategy = getConfiguredRunStrategy(agent, cwd);
@@ -2511,7 +2420,9 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2511
2420
  // picker's other callers keep the native-only collector.
2512
2421
  const { collectRunCandidatesForRun } = await import('../lib/accounting/account-pool-collect.js');
2513
2422
  bootMark('resolve-version:start');
2514
- const resolved = await resolveRunVersion(agent, strategy, cwd, collectRunCandidatesForRun);
2423
+ const routingModel = options.model ?? resolvedResumeSource?.model ?? workflowModel
2424
+ ?? (options.fallback ? undefined : resolveRunDefaults(agent, resolveVersion(agent, cwd), cwd).model);
2425
+ const resolved = await resolveRunVersion(agent, strategy, cwd, collectRunCandidatesForRun, routingModel);
2515
2426
  bootMark('resolve-version:done');
2516
2427
  if (resolved.exhausted) {
2517
2428
  // Zero healthy accounts splits two ways, and conflating them is what
@@ -2665,17 +2576,6 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2665
2576
  console.error(chalk.red(`agents: ${agent}@${launchTarget} is not runnable and could not be repaired. Try: ${hint}`));
2666
2577
  process.exit(1);
2667
2578
  }
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
2579
  version = healed;
2680
2580
  }
2681
2581
  }
@@ -2741,13 +2641,13 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2741
2641
  // `signInLaunch` means the zero-healthy path already reported this exact
2742
2642
  // account as logged out and named the login command, so re-probing here
2743
2643
  // only prints a second, near-identical warning.
2744
- rotated: !!rotationResult || accountPickerRequested || signInLaunch || spawnAccount?.kind === 'provider',
2644
+ rotated: !!rotationResult || accountPickerRequested || signInLaunch || (launchAccount?.account?.kind ?? spawnAccount?.kind) === 'provider',
2745
2645
  });
2746
2646
  if (preflight) {
2747
2647
  try {
2748
2648
  const { getAccountInfo } = await import('../lib/agents.js');
2749
2649
  const authVersion = accountConfigVersion ?? version;
2750
- const info = await getAccountInfo(agent, authVersion ? getVersionHomePath(agent, authVersion) : undefined);
2650
+ const info = await getAccountInfo(agent, execHome ?? (authVersion ? getVersionHomePath(agent, authVersion) : undefined));
2751
2651
  // Claude authenticates interactively from a per-version setup-token on a
2752
2652
  // keychain-less worker (the shim's .oauth_token fallback), which the
2753
2653
  // native-credential probe above can't see — so don't warn "logged out" when
@@ -2761,8 +2661,8 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2761
2661
  }
2762
2662
  if (!info.signedIn && !authedViaSetupToken) {
2763
2663
  const { addSupported } = await import('../lib/accounts/add.js');
2764
- const hint = spawnAccount?.kind === 'native'
2765
- ? `agents accounts login ${agent}#${spawnAccount.name}`
2664
+ const hint = (launchAccount?.account?.kind ?? spawnAccount?.kind) === 'native'
2665
+ ? `agents accounts login ${agent}#${configuredAccount}`
2766
2666
  : addSupported(agent)
2767
2667
  ? `agents accounts add ${agent} <name>`
2768
2668
  : loginHint(agent);
@@ -2823,7 +2723,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2823
2723
  // `--mode plan` is respected for genuine read-only command runs.
2824
2724
  const stallCmd = headlessPlanStallCommand({
2825
2725
  prompt,
2826
- interactive: options.interactive,
2726
+ interactive: resolveInteractive({ prompt, headless: options.headless, interactive: options.interactive || forceInteractive }),
2827
2727
  mode: resolvedMode,
2828
2728
  modeIsDefault,
2829
2729
  });
@@ -2918,6 +2818,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2918
2818
  : undefined;
2919
2819
  const modelSource = runCmd.getOptionValueSource('model');
2920
2820
  let model = options.model
2821
+ ?? resolvedResumeSource?.model
2921
2822
  ?? (!fromProfile && modelSource === undefined
2922
2823
  ? (workflowModel ?? (options.fallback ? undefined : runDefaults.model))
2923
2824
  : undefined);
@@ -2933,6 +2834,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2933
2834
  const execOptions = {
2934
2835
  agent,
2935
2836
  harnessName: profileName,
2837
+ accountId: launchAccount?.account?.kind === 'legacy-native' ? undefined : (launchAccount?.account?.id ?? spawnAccount?.id),
2936
2838
  version,
2937
2839
  configVersion: accountConfigVersion,
2938
2840
  execHome,