@phnx-labs/agents-cli 1.22.115 → 1.22.117

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 (91) hide show
  1. package/CHANGELOG.md +161 -14
  2. package/README.md +1 -1
  3. package/dist/commands/browser.js +11 -0
  4. package/dist/commands/exec.d.ts +18 -0
  5. package/dist/commands/exec.js +263 -152
  6. package/dist/commands/feed.js +65 -14
  7. package/dist/commands/sessions-picker.d.ts +11 -0
  8. package/dist/commands/sessions-picker.js +88 -7
  9. package/dist/commands/sessions.d.ts +21 -2
  10. package/dist/commands/sessions.js +157 -3
  11. package/dist/commands/setup-secrets.d.ts +2 -2
  12. package/dist/commands/setup-term.d.ts +25 -0
  13. package/dist/commands/setup-term.js +71 -0
  14. package/dist/commands/setup.d.ts +1 -1
  15. package/dist/commands/setup.js +12 -4
  16. package/dist/commands/ssh.js +1 -69
  17. package/dist/lib/accounting/rotate.d.ts +63 -1
  18. package/dist/lib/accounting/rotate.js +56 -0
  19. package/dist/lib/accounts/add.js +2 -2
  20. package/dist/lib/accounts/slots.js +32 -2
  21. package/dist/lib/answer-router.d.ts +11 -2
  22. package/dist/lib/answer-router.js +26 -2
  23. package/dist/lib/auth-mint.d.ts +5 -4
  24. package/dist/lib/auth-mint.js +4 -3
  25. package/dist/lib/browser/drivers/arc.d.ts +1 -1
  26. package/dist/lib/browser/service.d.ts +10 -0
  27. package/dist/lib/browser/service.js +208 -36
  28. package/dist/lib/browser/types.d.ts +18 -0
  29. package/dist/lib/config-keys.d.ts +1 -1
  30. package/dist/lib/config-keys.js +5 -0
  31. package/dist/lib/device-config.js +61 -0
  32. package/dist/lib/devices/doctor-findings.js +2 -6
  33. package/dist/lib/feed/answer.d.ts +153 -4
  34. package/dist/lib/feed/answer.js +716 -105
  35. package/dist/lib/feed/feed.d.ts +61 -1
  36. package/dist/lib/feed/feed.js +226 -14
  37. package/dist/lib/feed/hub-server.d.ts +58 -3
  38. package/dist/lib/feed/hub-server.js +306 -54
  39. package/dist/lib/feed/pr-status.d.ts +8 -0
  40. package/dist/lib/feed/pr-status.js +9 -1
  41. package/dist/lib/feed-outcome.d.ts +1 -1
  42. package/dist/lib/feed-outcome.js +9 -2
  43. package/dist/lib/feed-policy.js +9 -3
  44. package/dist/lib/fleet/auth-sync.d.ts +2 -55
  45. package/dist/lib/fleet/auth-sync.js +2 -89
  46. package/dist/lib/harness-auth-capabilities.js +7 -2
  47. package/dist/lib/hosts/dispatch.d.ts +20 -1
  48. package/dist/lib/hosts/dispatch.js +52 -30
  49. package/dist/lib/hosts/remote-cmd.d.ts +21 -0
  50. package/dist/lib/hosts/remote-cmd.js +27 -2
  51. package/dist/lib/mailbox.d.ts +12 -0
  52. package/dist/lib/mailbox.js +16 -2
  53. package/dist/lib/menubar/snapshot.d.ts +51 -0
  54. package/dist/lib/menubar/snapshot.js +42 -3
  55. package/dist/lib/open-url.js +2 -2
  56. package/dist/lib/placement.d.ts +2 -0
  57. package/dist/lib/placement.js +5 -0
  58. package/dist/lib/projects.d.ts +23 -0
  59. package/dist/lib/projects.js +78 -0
  60. package/dist/lib/secrets-cli.d.ts +3 -3
  61. package/dist/lib/secrets-cli.js +1 -1
  62. package/dist/lib/session/active.d.ts +1 -0
  63. package/dist/lib/session/active.js +8 -0
  64. package/dist/lib/session/db.d.ts +67 -3
  65. package/dist/lib/session/db.js +381 -126
  66. package/dist/lib/session/prompt.d.ts +23 -7
  67. package/dist/lib/session/prompt.js +46 -8
  68. package/dist/lib/session/remote/remote-list.d.ts +20 -0
  69. package/dist/lib/session/remote/remote-list.js +22 -6
  70. package/dist/lib/session/remote/watch.d.ts +12 -0
  71. package/dist/lib/session/remote/watch.js +9 -0
  72. package/dist/lib/session/remote-preview-cache.d.ts +29 -0
  73. package/dist/lib/session/remote-preview-cache.js +373 -0
  74. package/dist/lib/session/tail.d.ts +50 -0
  75. package/dist/lib/session/tail.js +219 -0
  76. package/dist/lib/setup-tool-install.js +2 -1
  77. package/dist/lib/setup-tool-status.d.ts +1 -1
  78. package/dist/lib/setup-tool-status.js +7 -1
  79. package/dist/lib/signin-badge.d.ts +19 -4
  80. package/dist/lib/signin-badge.js +29 -11
  81. package/dist/lib/term-driver.d.ts +24 -0
  82. package/dist/lib/term-driver.js +36 -0
  83. package/dist/lib/terminal/index.d.ts +1 -1
  84. package/dist/lib/terminal/index.js +1 -1
  85. package/dist/lib/terminal/inject.d.ts +38 -0
  86. package/dist/lib/terminal/inject.js +55 -9
  87. package/dist/lib/terminal/transport.d.ts +15 -5
  88. package/dist/lib/terminal/transport.js +61 -11
  89. package/package.json +1 -1
  90. package/dist/lib/fleet/remote-login.d.ts +0 -170
  91. package/dist/lib/fleet/remote-login.js +0 -568
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `agents setup term` — install the standalone `term` CLI if missing (PHNX-4092).
3
+ *
4
+ * The PTY engine lives in `@phnx-labs/term-cli` (extracted PHNX-4091);
5
+ * agents-cli never rebundles it. The setup-token mint behind `agents accounts
6
+ * add` / `agents accounts login` (auth-mint.ts → term-driver.ts) spawns `term`
7
+ * on demand and fails loud when it is absent, so onboarding installs it here
8
+ * like every other standalone tool.
9
+ *
10
+ * Unlike browser/computer/secrets there is nothing to configure — no profile,
11
+ * no OS permission, no migrate step. A missing binary is a routine install
12
+ * (`agents clis install term` via the system `clis/term.yaml`, then a pinned
13
+ * `npm i -g`, both handled by installSetupTool), and once it is on PATH the
14
+ * tool is ready.
15
+ */
16
+ import chalk from 'chalk';
17
+ import { isPromptCancelled } from './utils.js';
18
+ import { openSetupTerminal } from './setup-terminal.js';
19
+ import { resolveTermBin } from '../lib/term-client.js';
20
+ import { installSetupTool } from '../lib/setup-tool-install.js';
21
+ import { refreshToolSetup } from '../lib/setup-tool-status.js';
22
+ const INSTALL_HINT = 'agents clis install term # or: npm i -g @phnx-labs/term-cli';
23
+ /** True when the standalone `term` executable resolves ($TERM_BIN or PATH). */
24
+ export function isTermCliInstalled() {
25
+ return resolveTermBin() !== null;
26
+ }
27
+ /**
28
+ * Install the standalone `term` CLI if missing. Returns whether `term` is on
29
+ * PATH afterwards. There is no further onboarding — presence is readiness.
30
+ */
31
+ export async function runTermWizard() {
32
+ if (isTermCliInstalled()) {
33
+ console.log(chalk.green('The standalone `term` CLI is installed.'));
34
+ return true;
35
+ }
36
+ if (await installSetupTool('term')) {
37
+ console.log(chalk.green('Installed the standalone `term` CLI.'));
38
+ return true;
39
+ }
40
+ console.log(chalk.yellow('The standalone `term` CLI is not installed.'));
41
+ console.log(chalk.gray('Install it, then re-run `agents setup term`:'));
42
+ console.log(chalk.cyan(` ${INSTALL_HINT}`));
43
+ return false;
44
+ }
45
+ /** Register `agents setup term` under the parent `setup` command. */
46
+ export function registerSetupTermCommand(setupCmd) {
47
+ setupCmd
48
+ .command('term')
49
+ .description('Install the standalone `term` CLI (the PTY engine `agents accounts add`/`login` spawn) if missing.')
50
+ .option('--install-only', 'Install the standalone term CLI (identical to the wizard; term needs no further setup)')
51
+ .option('--terminal [backend]', 'Open interactive setup in a detected or selected terminal')
52
+ .action(async (options) => {
53
+ try {
54
+ if (options.terminal !== undefined) {
55
+ await openSetupTerminal('term', options.terminal, options.installOnly);
56
+ return;
57
+ }
58
+ if (!(await runTermWizard()))
59
+ process.exitCode = 1;
60
+ await refreshToolSetup('term');
61
+ }
62
+ catch (err) {
63
+ if (isPromptCancelled(err)) {
64
+ console.log(chalk.yellow('\nCancelled'));
65
+ return;
66
+ }
67
+ console.error(chalk.red(err.message));
68
+ process.exitCode = 1;
69
+ }
70
+ });
71
+ }
@@ -31,7 +31,7 @@ export declare function ensureInitialized(program: Command): Promise<void>;
31
31
  * wizard. Never throws — a cancel or an optional wizard's error just skips the
32
32
  * rest and lets core setup complete.
33
33
  */
34
- type SetupPhase = 'browser' | 'computer' | 'secrets' | 'accounts' | 'fleet' | 'watchdog' | 'preferences';
34
+ type SetupPhase = 'browser' | 'computer' | 'secrets' | 'term' | 'accounts' | 'fleet' | 'watchdog' | 'preferences';
35
35
  type SetupStatusState = 'ready' | 'missing' | 'n/a';
36
36
  interface SetupStatusRow {
37
37
  phase: 'core' | SetupPhase;
@@ -22,6 +22,7 @@ import { registerSetupBrowserCommand, runBrowserWizard } from './setup-browser.j
22
22
  import { registerSetupComputerCommand, runComputerWizard } from './setup-computer.js';
23
23
  import { registerSetupMineCommand } from './setup-mine.js';
24
24
  import { registerSetupSecretsCommand } from './setup-secrets.js';
25
+ import { registerSetupTermCommand, runTermWizard, isTermCliInstalled } from './setup-term.js';
25
26
  import { registerSetupFleetCommand } from './setup-fleet.js';
26
27
  import { registerSetupAccountsCommand, runAccountsSetupWizard } from './setup-accounts.js';
27
28
  import { registerSetupWatchdogCommand, runWatchdogSetupWizard } from './setup-watchdog.js';
@@ -278,6 +279,7 @@ export async function getSetupStatus() {
278
279
  const devices = await loadDevices();
279
280
  const coreReady = isGitRepo(getAgentsDir());
280
281
  const secretsReady = fs.existsSync(setupSecretsPrefsPath());
282
+ const termReady = isTermCliInstalled();
281
283
  const minted = hasMintedSetupToken();
282
284
  const watchdogEnabled = getConfigValue('watchdog.enabled').value === true;
283
285
  const interactiveHost = getConfigValue('interactive.host').value;
@@ -287,6 +289,7 @@ export async function getSetupStatus() {
287
289
  { phase: 'browser', state: browserReady ? 'ready' : 'missing', detail: browserReady ? `profile ${browserProfile.name}` : browserProfile ? `profile ${browserProfile.name} cannot launch here` : installedBrowsers.length ? 'no default profile' : 'no supported browser found' },
288
290
  { phase: 'computer', state: computerState, detail: computerState === 'ready' ? 'helper trusted' : computerState === 'n/a' ? 'macOS local setup only' : 'helper not running or not trusted' },
289
291
  { phase: 'secrets', state: secretsReady ? 'ready' : 'missing', detail: secretsReady ? 'defaults chosen' : 'defaults not chosen' },
292
+ { phase: 'term', state: termReady ? 'ready' : 'missing', detail: termReady ? 'installed' : 'not installed (accounts add/login spawn it)' },
290
293
  { phase: 'accounts', state: minted.ready ? 'ready' : 'missing', detail: minted.detail },
291
294
  { phase: 'fleet', state: Object.keys(devices).length ? 'ready' : 'missing', detail: Object.keys(devices).length ? `${Object.keys(devices).length} device${Object.keys(devices).length === 1 ? '' : 's'} registered` : 'no devices registered' },
292
295
  { phase: 'watchdog', state: watchdogEnabled ? 'ready' : 'missing', detail: watchdogEnabled ? 'enabled on this device' : 'disabled on this device' },
@@ -308,6 +311,8 @@ async function runSetupPhase(phase) {
308
311
  await runComputerWizard();
309
312
  else if (phase === 'secrets')
310
313
  await import('./setup-secrets.js').then((m) => m.runSecretsSetupWizard());
314
+ else if (phase === 'term')
315
+ await runTermWizard();
311
316
  else if (phase === 'accounts')
312
317
  await runAccountsSetupWizard();
313
318
  else if (phase === 'fleet')
@@ -366,11 +371,12 @@ export function registerSetupCommand(program) {
366
371
  .description('Set up agents-cli, or re-open the capability onboarding hub.')
367
372
  .option('-f, --force', 'Re-run setup even if ~/.agents/.system/ already exists (use with caution)')
368
373
  .option('--no-system-repo', 'Skip cloning the system repo (you must populate ~/.agents/.system/ yourself)');
369
- // Capability subcommands: `agents setup browser|computer|mine|secrets|accounts|fleet|alias|beta`.
374
+ // Capability subcommands: `agents setup browser|computer|term|mine|secrets|accounts|fleet|alias|beta`.
370
375
  // Artifact publishing is no longer set up here — it lives in the standalone
371
376
  // `artifacts` CLI (`artifacts share setup`/`join`, PHNX-3992).
372
377
  registerSetupBrowserCommand(setupCmd);
373
378
  registerSetupComputerCommand(setupCmd);
379
+ registerSetupTermCommand(setupCmd);
374
380
  registerSetupMineCommand(setupCmd);
375
381
  registerSetupSecretsCommand(setupCmd);
376
382
  registerSetupAccountsCommand(setupCmd);
@@ -399,15 +405,15 @@ export function registerSetupCommand(program) {
399
405
  `,
400
406
  });
401
407
  setupCmd.command('status')
402
- .description('Show setup readiness for core, browser, computer, secrets, accounts, fleet, watchdog, and preferences.')
408
+ .description('Show setup readiness for core, browser, computer, secrets, term, accounts, fleet, watchdog, and preferences.')
403
409
  .option('--json', 'print machine-readable JSON')
404
- .option('--tool <name>', 'Read cached standalone tool status: browser, computer, secrets, or all')
410
+ .option('--tool <name>', 'Read cached standalone tool status: browser, computer, secrets, term, or all')
405
411
  .option('--refresh', 'Explicitly refresh only the selected standalone tool health checks')
406
412
  .action(async (options) => {
407
413
  if (options.tool || options.refresh) {
408
414
  const selected = options.tool ?? 'all';
409
415
  if (selected !== 'all' && !SETUP_TOOLS.includes(selected))
410
- throw new Error('Unknown tool. Choose browser, computer, secrets, or all.');
416
+ throw new Error('Unknown tool. Choose browser, computer, secrets, term, or all.');
411
417
  const rows = options.refresh
412
418
  ? await refreshToolSetup(selected)
413
419
  : getCachedToolSetup().filter((row) => selected === 'all' || row.tool === selected);
@@ -438,6 +444,7 @@ export function registerSetupCommand(program) {
438
444
  agents setup browser
439
445
  agents setup computer
440
446
  agents setup secrets
447
+ agents setup term
441
448
  agents setup accounts
442
449
  agents setup fleet
443
450
  agents setup watchdog
@@ -455,6 +462,7 @@ export function registerSetupCommand(program) {
455
462
  agents setup browser # detect a browser + create the default profile
456
463
  agents setup computer # install the signed macOS helper + grant permissions
457
464
  agents setup secrets # choose secrets backend/policy defaults + import
465
+ agents setup term # install the standalone term CLI (PTY engine agents accounts add/login spawn)
458
466
  agents setup accounts # mint a Claude setup-token for unattended usage/probe
459
467
  agents setup fleet # discover Tailscale devices + configure SSH access
460
468
  agents setup watchdog # choose which devices run the daemon watchdog pass
@@ -58,7 +58,6 @@ import { crabboxList, crabboxFind, crabboxSshArgv } from '../lib/crabbox/cli.js'
58
58
  import { boxAddress, boxStatus, fmtIdleShort, fmtExpiresShort, registerLeaseCommand } from './lease.js';
59
59
  import { registerSnapshotCommand } from './snapshot.js';
60
60
  import { authCellColor, formatCheckedAge, isDeadVerdict, readAuthHealthCache, summarizeHostAuth, summarizeVerdicts, verdictColor, verdictLabel, writeFleetAuthRows, } from '../lib/auth-health.js';
61
- import { runFleetLogin } from '../lib/fleet/remote-login.js';
62
61
  import { getConfigValue, listConfig, setConfigValue, unsetConfigValue, configKeySpec, autoPoolMode, configuredDeviceRole, listConfiguredDeviceRoles, setConfiguredDeviceRole, } from '../lib/device-config.js';
63
62
  import { filterAutoPool, listWorkerDevices } from '../lib/devices/pool.js';
64
63
  import { registerCommandGroups, setHelpSections } from '../lib/help.js';
@@ -1055,46 +1054,6 @@ function renderAuthMatrix(results, opts) {
1055
1054
  }
1056
1055
  return lines;
1057
1056
  }
1058
- /** Render the final `fleet login` result summary in the house auth-matrix style. */
1059
- function renderLoginMatrix(results) {
1060
- const lines = [chalk.bold('Fleet login')];
1061
- if (results.length === 0) {
1062
- lines.push(chalk.gray(' Nothing pending — every requested account is already logged in.'));
1063
- return lines;
1064
- }
1065
- const nameW = Math.max(6, ...results.map((r) => `${r.agent}@${r.device}`.length));
1066
- for (const r of results) {
1067
- const who = `${r.agent}@${r.device}`.padEnd(nameW);
1068
- let badge;
1069
- switch (r.state) {
1070
- case 'authorized':
1071
- badge = chalk.green('authorized');
1072
- break;
1073
- case 'ready':
1074
- badge = chalk.cyan('code ready');
1075
- break;
1076
- case 'driving':
1077
- badge = chalk.yellow('driving');
1078
- break;
1079
- case 'skipped':
1080
- badge = chalk.gray('not remotable');
1081
- break;
1082
- case 'error':
1083
- badge = chalk.red('error');
1084
- break;
1085
- default:
1086
- badge = chalk.gray(r.state);
1087
- break;
1088
- }
1089
- const note = r.detail ? chalk.dim(` ${r.detail}`) : (r.reason && !r.remotable ? chalk.dim(` ${r.reason}`) : '');
1090
- lines.push(` ${who} ${badge}${note}`);
1091
- }
1092
- const auth = results.filter((r) => r.state === 'authorized').length;
1093
- const remotable = results.filter((r) => r.remotable).length;
1094
- lines.push('');
1095
- lines.push(chalk.gray(` ${auth}/${remotable} remotable logins authorized`));
1096
- return lines;
1097
- }
1098
1057
  /** Register the `agents devices` command tree (also aliased as `fleet`). */
1099
1058
  function registerDevicesCommands(program) {
1100
1059
  const devicesCmd = program
@@ -1141,7 +1100,7 @@ function registerDevicesCommands(program) {
1141
1100
  { title: 'Inspect', names: ['list', 'show', 'status', 'ping', 'harnesses', 'accounts', 'snapshot'] },
1142
1101
  { title: 'Disposable devices', names: ['lease'] },
1143
1102
  { title: 'Configure a device', names: ['config', 'describe', 'render'] },
1144
- { title: 'Fleet operations', names: ['update', 'run', 'login', 'capture', 'apply', 'worktrees'] },
1103
+ { title: 'Fleet operations', names: ['update', 'run', 'capture', 'apply', 'worktrees'] },
1145
1104
  ]);
1146
1105
  devicesCmd
1147
1106
  .command('sync')
@@ -2008,33 +1967,6 @@ agents devices accounts --json # machine-readable, per-host account gr
2008
1967
  Collapses the installs that share one account (e.g. five claude versions on one
2009
1968
  email) into a single row. Use \`agents devices harnesses\` for the per-install view.`,
2010
1969
  });
2011
- devicesCmd
2012
- .command('login')
2013
- .description('Log agent CLIs into fleet boxes over SSH: drive each box\'s device-code OAuth, scrape the URL + code, and surface every pending login in one local browser page. Default drives all codes at once; --interactive walks one box at a time (codes requested just-in-time so they don\'t expire).')
2014
- .option('--agents <csv>', 'only these agents (comma-separated); default: every agent with a device-code flow')
2015
- .option('--devices <csv>', 'only these devices (comma-separated); default: every online box')
2016
- .option('--all', 'target every device-code pair regardless of cached login state (cold cache / forced re-login)')
2017
- .option('--interactive', 'guided one-box-at-a-time wizard (codes requested just-in-time)')
2018
- .option('--json', 'output the final result matrix as JSON')
2019
- .action(async (opts) => {
2020
- const csv = (s) => (s ? s.split(',').map((x) => x.trim()).filter(Boolean) : undefined);
2021
- const results = await runFleetLogin({
2022
- agents: csv(opts.agents),
2023
- devices: csv(opts.devices),
2024
- all: opts.all,
2025
- interactive: opts.interactive,
2026
- json: opts.json,
2027
- });
2028
- if (opts.json) {
2029
- console.log(JSON.stringify(results, null, 2));
2030
- }
2031
- else {
2032
- for (const line of renderLoginMatrix(results))
2033
- console.log(line);
2034
- }
2035
- if (results.some((r) => r.remotable && r.state !== 'authorized'))
2036
- process.exitCode = 1;
2037
- });
2038
1970
  devicesCmd
2039
1971
  .command('show <name>')
2040
1972
  .description('Show the full profile for one device.')
@@ -4,7 +4,7 @@
4
4
  * Detects which installed versions have expired credentials and rotates
5
5
  * authentication tokens so users maintain active sessions across version switches.
6
6
  */
7
- import type { AgentId, RunStrategy } from '../types.js';
7
+ import { type AgentId, type RunStrategy } from '../types.js';
8
8
  import type { FallbackEntry } from '../exec.js';
9
9
  import { PROJECTION_HORIZON_MIN, capacityWeight } from './capacity.js';
10
10
  import { type AccountInfo, type CredentialPresence } from '../agents.js';
@@ -549,6 +549,68 @@ export declare const DEFAULT_ROTATION_FAILOVER_LIMIT = 3;
549
549
  * completely unchanged.
550
550
  */
551
551
  export declare function rotationFailoverChain(rotation: RotateResult | null, pickedVersion: string, limit?: number): FallbackEntry[];
552
+ /**
553
+ * Whether a run SHAPE may take the preflight alternate-harness handoff
554
+ * ({@link preflightFallbackHandoff}) — the sibling of
555
+ * {@link shouldArmRotationFailover}, and load-bearing for the same reason.
556
+ *
557
+ * The handoff consumes the entry it uses from the `--fallback` spec, and the
558
+ * canonical `--fallback` validation runs LATER in the run command: it rejects a
559
+ * chain on an interactive, `--acp`, `--loop`, or `--resume-checkpoint` run, and
560
+ * requires a prompt. Handing off first on one of those shapes would empty the
561
+ * spec, leave that guard nothing to reject, and let the run proceed as the
562
+ * alternate harness — an interactive Codex launch from
563
+ * `agents run claude --interactive --fallback codex`, which the CLI refuses
564
+ * today. Gating the shape here keeps the original validation intact: an
565
+ * ineligible shape never switches, so the spec survives and still fails there
566
+ * with its own message.
567
+ *
568
+ * `resume` and `workflowScoped` are excluded for their own reasons: a resume is
569
+ * bound to the session's OWN harness, and a workflow's tool/MCP scoping is
570
+ * claude-only, so switching harness would silently drop the declared sandbox —
571
+ * the fail-open `runWithFallback` only warns about.
572
+ *
573
+ * Pure so the matrix is unit-testable without invoking the run command.
574
+ */
575
+ export interface PreflightHandoffContext {
576
+ hasPrompt: boolean;
577
+ interactive: boolean;
578
+ acp: boolean;
579
+ loop: boolean;
580
+ resumeCheckpoint: boolean;
581
+ resume: boolean;
582
+ workflowScoped: boolean;
583
+ }
584
+ export declare function preflightHandoffEligible(ctx: PreflightHandoffContext): boolean;
585
+ /**
586
+ * The permitted alternate harness to launch when EVERY account of the primary
587
+ * agent is already exhausted at PREFLIGHT (PHNX-3999 F19).
588
+ *
589
+ * `runWithFallback` only reaches a `--fallback` entry after the primary has
590
+ * actually RUN and hit a rate limit. When the primary's accounts are throttled
591
+ * before the run starts, version resolution reports `exhausted` and the run used
592
+ * to exit — so the alternate a user configured for exactly this case never fired.
593
+ * This resolves the first usable alternate from the same `--fallback` spec, and
594
+ * returns the remainder of the spec so the alternate becomes the primary and is
595
+ * not also listed as its own fallback.
596
+ *
597
+ * Deliberately NOT a second parser: an entry this cannot resolve exactly — a
598
+ * typo, an alias the canonical parse would fuzzy-match, the primary itself —
599
+ * makes the WHOLE spec yield `null`, so the run reaches the one canonical
600
+ * `--fallback` parse and fails (or corrects) there with the message it already
601
+ * has. `--fallback typoo,codex` must never silently launch codex while dropping
602
+ * `typoo`.
603
+ *
604
+ * Returns `null` when there is no spec, when any entry is not an exact agent id
605
+ * or names the primary, or when the primary is only sign-in-recoverable rather
606
+ * than throttled — a logged-out account is fixed by logging in, not by silently
607
+ * running a different harness.
608
+ */
609
+ export declare function preflightFallbackHandoff(spec: string | undefined, primary: AgentId, exhausted: RotateCandidate[]): {
610
+ agent: AgentId;
611
+ version?: string;
612
+ remainingSpec?: string;
613
+ } | null;
552
614
  /**
553
615
  * Whether a run is eligible to have a mid-run rate-limit failover chain armed
554
616
  * (issue #348). Failover injects synthesized `FallbackEntry`s into the same
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import * as fs from 'fs';
8
8
  import * as path from 'path';
9
+ import { isAgentId } from '../types.js';
9
10
  import { PROJECTION_HORIZON_MIN, capacityWeight } from './capacity.js';
10
11
  import { accountDisplayLabel, getAccountInfo, credentialPresence, ALL_AGENT_IDS, } from '../agents.js';
11
12
  import { readMeta, getHelpersDir } from '../state.js';
@@ -1206,6 +1207,61 @@ export function rotationFailoverChain(rotation, pickedVersion, limit = DEFAULT_R
1206
1207
  }
1207
1208
  return chain;
1208
1209
  }
1210
+ export function preflightHandoffEligible(ctx) {
1211
+ return (ctx.hasPrompt &&
1212
+ !ctx.interactive &&
1213
+ !ctx.acp &&
1214
+ !ctx.loop &&
1215
+ !ctx.resumeCheckpoint &&
1216
+ !ctx.resume &&
1217
+ !ctx.workflowScoped);
1218
+ }
1219
+ /**
1220
+ * The permitted alternate harness to launch when EVERY account of the primary
1221
+ * agent is already exhausted at PREFLIGHT (PHNX-3999 F19).
1222
+ *
1223
+ * `runWithFallback` only reaches a `--fallback` entry after the primary has
1224
+ * actually RUN and hit a rate limit. When the primary's accounts are throttled
1225
+ * before the run starts, version resolution reports `exhausted` and the run used
1226
+ * to exit — so the alternate a user configured for exactly this case never fired.
1227
+ * This resolves the first usable alternate from the same `--fallback` spec, and
1228
+ * returns the remainder of the spec so the alternate becomes the primary and is
1229
+ * not also listed as its own fallback.
1230
+ *
1231
+ * Deliberately NOT a second parser: an entry this cannot resolve exactly — a
1232
+ * typo, an alias the canonical parse would fuzzy-match, the primary itself —
1233
+ * makes the WHOLE spec yield `null`, so the run reaches the one canonical
1234
+ * `--fallback` parse and fails (or corrects) there with the message it already
1235
+ * has. `--fallback typoo,codex` must never silently launch codex while dropping
1236
+ * `typoo`.
1237
+ *
1238
+ * Returns `null` when there is no spec, when any entry is not an exact agent id
1239
+ * or names the primary, or when the primary is only sign-in-recoverable rather
1240
+ * than throttled — a logged-out account is fixed by logging in, not by silently
1241
+ * running a different harness.
1242
+ */
1243
+ export function preflightFallbackHandoff(spec, primary, exhausted) {
1244
+ if (!spec)
1245
+ return null;
1246
+ if (exhausted.length === 0)
1247
+ return null;
1248
+ if (signInRecoverableCandidates(exhausted).length > 0)
1249
+ return null;
1250
+ const entries = spec.split(',').map((e) => e.trim()).filter(Boolean);
1251
+ if (entries.length === 0)
1252
+ return null;
1253
+ const parsed = entries.map((entry) => entry.split('@'));
1254
+ // Every entry has to be exactly resolvable, or this defers entirely.
1255
+ if (parsed.some(([name]) => !isAgentId(name) || name === primary))
1256
+ return null;
1257
+ const [name, version] = parsed[0];
1258
+ const remaining = entries.slice(1);
1259
+ return {
1260
+ agent: name,
1261
+ version: version || undefined,
1262
+ remainingSpec: remaining.length > 0 ? remaining.join(',') : undefined,
1263
+ };
1264
+ }
1209
1265
  export function shouldArmRotationFailover(ctx) {
1210
1266
  return (ctx.hasRotation &&
1211
1267
  ctx.hasVersion &&
@@ -89,11 +89,11 @@ export function workerProvisioningHint(agent) {
89
89
  parts.push(env === 'provider' ? 'a provider API key collected by add (--api-key)' : `the ${env} collected by add (--api-key)`);
90
90
  }
91
91
  else if (kind.startsWith('per-device')) {
92
- parts.push(`per-device login on each worker (--per-device / agents fleet login ${agent})`);
92
+ parts.push(`per-device login on each worker (--per-device; run \`agents run ${agent} --device <box>\` there and complete its native login)`);
93
93
  }
94
94
  }
95
95
  if (parts.length === 0)
96
- return `${agent} has no portable credential — it logs in per box (agents fleet login ${agent})`;
96
+ return `${agent} has no portable credential — it logs in per box (run \`agents run ${agent} --device <box>\` on the worker and complete its native login)`;
97
97
  return parts.join(', or ');
98
98
  }
99
99
  /**
@@ -13,7 +13,8 @@
13
13
  */
14
14
  import * as fs from 'node:fs';
15
15
  import * as path from 'node:path';
16
- import { agentConfigDirName } from '../agents.js';
16
+ import { AGENTS, agentConfigDirName } from '../agents.js';
17
+ import { createLink } from '../platform/links.js';
17
18
  import { harnessAuth, harnessWorkerIsPerDevice } from '../harness-auth-capabilities.js';
18
19
  import { installSessionTrackerHookSync } from '../hooks/install.js';
19
20
  import { getGlobalDefault, getVersionHomePath, listInstalledVersions } from '../installations/store.js';
@@ -64,7 +65,36 @@ function projectResources(harness, version, destHome, fromHome) {
64
65
  if (!writer)
65
66
  continue;
66
67
  if (kind === 'rules') {
67
- writer.write({ version, versionHome: destHome, selection: { preset: 'default' }, cwd });
68
+ // Symlink to the version home's rules instead of composing a duplicate.
69
+ // Harnesses whose shim pins a separate config dir (e.g. CLAUDE_CONFIG_DIR
70
+ // → account slot) cause the harness to load rules from both the version
71
+ // home (~/.claude/) and the slot, doubling ~5 K tokens per session.
72
+ // A symlink gives both paths the same inode so the harness can dedup.
73
+ const cap = AGENTS[harness].capabilities.rules;
74
+ if (typeof cap !== 'object')
75
+ continue;
76
+ const srcFile = path.join(fromHome, agentConfigDirName(harness), cap.file);
77
+ if (!fs.existsSync(srcFile)) {
78
+ writer.write({ version, versionHome: destHome, selection: { preset: 'default' }, cwd });
79
+ continue;
80
+ }
81
+ const destFile = path.join(destHome, agentConfigDirName(harness), cap.file);
82
+ fs.mkdirSync(path.dirname(destFile), { recursive: true });
83
+ try {
84
+ const st = fs.lstatSync(destFile);
85
+ if (st.isSymbolicLink() || st.isFile())
86
+ fs.unlinkSync(destFile);
87
+ }
88
+ catch { /* did not exist */ }
89
+ try {
90
+ createLink(srcFile, destFile);
91
+ }
92
+ catch (err) {
93
+ if (err.code === 'EEXIST') {
94
+ continue;
95
+ }
96
+ throw err;
97
+ }
68
98
  continue;
69
99
  }
70
100
  const names = getDetector(kind, harness)?.list({ version, versionHome: fromHome, cwd }) ?? [];
@@ -10,7 +10,7 @@
10
10
  * This module picks the delivery mechanism from (open feed block × session
11
11
  * liveness × runtime rail). Pure — unit-testable without a live terminal.
12
12
  */
13
- import type { OpenBlock, BlockOption } from './feed/feed.js';
13
+ import { type OpenBlock, type BlockOption } from './feed/feed.js';
14
14
  import type { ActiveSession } from './session/active.js';
15
15
  import type { InjectTarget } from './terminal/index.js';
16
16
  export type AnswerRouteKind = 'mailbox' | 'tmux' | 'iterm' | 'resume' | 'refuse';
@@ -67,7 +67,16 @@ export declare function keystrokesForAnswer(answer: string, options?: Array<Pick
67
67
  };
68
68
  /** True when the session is waiting on user input (parked on a question/plan). */
69
69
  export declare function isParkedOnInput(session: ActiveSession | null | undefined): boolean;
70
- /** True when an open feed block still needs an answer. */
70
+ /**
71
+ * True when an open feed block still needs an answer.
72
+ *
73
+ * Openness comes from {@link deriveBlockState}, not from `block.answer` being
74
+ * truthy: a PENDING claim records the answer while deliberately leaving the
75
+ * block `open` (feed.ts `recordAnswer`'s two-phase mode), so reading the field
76
+ * directly would call a claimed-but-undelivered block "closed" — and a retry of
77
+ * a stranded claim would then re-route a parked headless agent to the mailbox it
78
+ * will never drain (PHNX-3999).
79
+ */
71
80
  export declare function isOpenQuestionBlock(block: OpenBlock | null | undefined): boolean;
72
81
  /**
73
82
  * Pick the delivery mechanism for one answer.
@@ -1,3 +1,16 @@
1
+ /**
2
+ * Parked-agent answer router (RUSH-1474).
3
+ *
4
+ * `agents message` historically only enqueued into the mailbox (delivered at the
5
+ * next PreToolUse). That steers a *running* agent between tool calls but never
6
+ * unblocks a *parked* agent:
7
+ * - interactive TUI open on AskUserQuestion (needs keystrokes, not context)
8
+ * - headless run waiting on input (no next tool call; needs resume)
9
+ *
10
+ * This module picks the delivery mechanism from (open feed block × session
11
+ * liveness × runtime rail). Pure — unit-testable without a live terminal.
12
+ */
13
+ import { deriveBlockState } from './feed/feed.js';
1
14
  import { addressabilityRecoveryHint } from './terminal/resolve.js';
2
15
  import { injectTargetFromReplyRail } from './session/inject.js';
3
16
  /** The Escape keystroke as its raw control byte — what a tmux/iterm rail reads as a real Escape. */
@@ -64,11 +77,22 @@ export function isParkedOnInput(session) {
64
77
  }
65
78
  return false;
66
79
  }
67
- /** True when an open feed block still needs an answer. */
80
+ /**
81
+ * True when an open feed block still needs an answer.
82
+ *
83
+ * Openness comes from {@link deriveBlockState}, not from `block.answer` being
84
+ * truthy: a PENDING claim records the answer while deliberately leaving the
85
+ * block `open` (feed.ts `recordAnswer`'s two-phase mode), so reading the field
86
+ * directly would call a claimed-but-undelivered block "closed" — and a retry of
87
+ * a stranded claim would then re-route a parked headless agent to the mailbox it
88
+ * will never drain (PHNX-3999).
89
+ */
68
90
  export function isOpenQuestionBlock(block) {
69
91
  if (!block)
70
92
  return false;
71
- if (block.answer || block.parkedAt || block.continuedAt || block.defaultedAt)
93
+ if (block.parkedAt || block.continuedAt || block.defaultedAt)
94
+ return false;
95
+ if (deriveBlockState(block) !== 'open')
72
96
  return false;
73
97
  return (block.questions?.length ?? 0) > 0;
74
98
  }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * First-class setup-token mint + seed (PHNX-2364).
3
3
  *
4
- * Closes the mint-auth manual recipe: drive `claude setup-token` through the
5
- * same injectable term driver `agents fleet login` uses, capture a well-formed
4
+ * Closes the mint-auth manual recipe: drive `claude setup-token` through an
5
+ * injectable term driver (`lib/term-driver.ts`), capture a well-formed
6
6
  * `sk-ant-oat01-…` token (the #1767 ANSI-banner guard), and seed BOTH:
7
7
  *
8
8
  * 1. a named provider account (`agents accounts add` shape, policy never)
@@ -16,7 +16,7 @@
16
16
  import type { AgentId, Meta } from './types.js';
17
17
  import { type CredentialAccount } from './account-registry.js';
18
18
  import { type StorableCredentialKind } from './secrets-policy.js';
19
- import { type DriveOptions, type TermDriver } from './fleet/remote-login.js';
19
+ import { type DriveOptions, type TermDriver } from './term-driver.js';
20
20
  /** Well-formed Claude setup-token as it appears inside a TTY blob. */
21
21
  export declare const CLAUDE_SETUP_TOKEN_CAPTURE_RE: RegExp;
22
22
  export interface MintFlow {
@@ -37,7 +37,8 @@ export interface MintFlow {
37
37
  * key, COLLECTED via `--api-key` or a prompt by `accounts add` (never derived
38
38
  * from OAuth). The api-key entries describe collection only — they have no
39
39
  * mint argv, and `mintAndSeed` refuses them with the `accounts add` pointer.
40
- * Native device-code login (kimi/antigravity) stays on `agents fleet login`.
40
+ * A token-less harness (kimi/antigravity) has no portable credential and logs
41
+ * in per box (run it there and complete its native login).
41
42
  * The apiKeyEnv values are pinned to HARNESS_AUTH's `api-key:<ENV>` worker
42
43
  * kinds by auth-mint.test.ts — keep them in lockstep.
43
44
  */
@@ -5,7 +5,7 @@ import { bundleBackendSync, isSecretsClientError, bundleExists, bundleExistsSync
5
5
  import { assertStorableCredentialKind, reservedStoreName, } from './secrets-policy.js';
6
6
  import { getBinaryPath, getGlobalDefault, getVersionHomePath, listInstalledVersions } from './installations/versions.js';
7
7
  import { shellQuote } from './ssh-exec.js';
8
- import { defaultTermDriver, } from './fleet/remote-login.js';
8
+ import { defaultTermDriver, } from './term-driver.js';
9
9
  import { showUrl } from './open-url.js';
10
10
  import { loadDevices } from './devices/registry.js';
11
11
  import { isSelfHost } from './devices/self-host.js';
@@ -21,7 +21,8 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
21
21
  * key, COLLECTED via `--api-key` or a prompt by `accounts add` (never derived
22
22
  * from OAuth). The api-key entries describe collection only — they have no
23
23
  * mint argv, and `mintAndSeed` refuses them with the `accounts add` pointer.
24
- * Native device-code login (kimi/antigravity) stays on `agents fleet login`.
24
+ * A token-less harness (kimi/antigravity) has no portable credential and logs
25
+ * in per box (run it there and complete its native login).
25
26
  * The apiKeyEnv values are pinned to HARNESS_AUTH's `api-key:<ENV>` worker
26
27
  * kinds by auth-mint.test.ts — keep them in lockstep.
27
28
  */
@@ -63,7 +64,7 @@ export function unmintableMessage(harness) {
63
64
  return [
64
65
  `Cannot mint a setup-token for '${harness}'.`,
65
66
  `Interactive setup-token mint is implemented for: ${listMintableHarnesses().join(', ')}.`,
66
- `Device-code native login is \`agents fleet login --agent ${harness}\`.`,
67
+ `A token-less harness like '${harness}' logs in per box — run it on that device and complete its native login.`,
67
68
  `API-key / pasted-token accounts use \`agents accounts add <name> --provider <provider> --auth api-key\`.`,
68
69
  ].join(' ');
69
70
  }
@@ -7,7 +7,7 @@ interface ArcNativeTab extends ArcNativeTabRef {
7
7
  url: string;
8
8
  title: string;
9
9
  }
10
- interface ArcEnumeratedSpace {
10
+ export interface ArcEnumeratedSpace {
11
11
  windowId: string;
12
12
  spaceId: string;
13
13
  spaceTitle: string;
@@ -731,6 +731,16 @@ export declare class BrowserService {
731
731
  * async/promise evaluation fails honestly with an AppleScript error.
732
732
  */
733
733
  private evaluateArcNative;
734
+ /**
735
+ * Resolve a task's owned Arc tabs against one shared snapshot of every
736
+ * window/space/tab, rather than asking Arc per tab as `listArcTaskTabs` does.
737
+ *
738
+ * Same ownership rule as the live path — `requireArcTask`, then the recorded
739
+ * ref must be present in its original window and Space. The snapshot changes
740
+ * how often Arc is asked, never what counts as owned. Actions keep the live
741
+ * path, because a snapshot can go stale before it is acted on.
742
+ */
743
+ private listArcTaskTabsFromSnapshot;
734
744
  private listArcTaskTabs;
735
745
  /**
736
746
  * Close native Arc tabs owned by a task. Only closes tabs the task created