@phnx-labs/agents-cli 1.22.115 → 1.22.116

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 (88) hide show
  1. package/CHANGELOG.md +149 -14
  2. package/README.md +1 -1
  3. package/dist/commands/browser.js +11 -0
  4. package/dist/commands/exec.js +212 -141
  5. package/dist/commands/feed.js +65 -14
  6. package/dist/commands/sessions-picker.d.ts +11 -0
  7. package/dist/commands/sessions-picker.js +88 -7
  8. package/dist/commands/sessions.d.ts +21 -2
  9. package/dist/commands/sessions.js +157 -3
  10. package/dist/commands/setup-secrets.d.ts +2 -2
  11. package/dist/commands/setup-term.d.ts +24 -0
  12. package/dist/commands/setup-term.js +70 -0
  13. package/dist/commands/setup.d.ts +1 -1
  14. package/dist/commands/setup.js +12 -4
  15. package/dist/commands/ssh.js +1 -69
  16. package/dist/lib/accounting/rotate.d.ts +63 -1
  17. package/dist/lib/accounting/rotate.js +56 -0
  18. package/dist/lib/accounts/add.js +2 -2
  19. package/dist/lib/accounts/slots.js +32 -2
  20. package/dist/lib/answer-router.d.ts +11 -2
  21. package/dist/lib/answer-router.js +26 -2
  22. package/dist/lib/auth-mint.d.ts +5 -4
  23. package/dist/lib/auth-mint.js +4 -3
  24. package/dist/lib/browser/drivers/arc.d.ts +1 -1
  25. package/dist/lib/browser/service.d.ts +10 -0
  26. package/dist/lib/browser/service.js +208 -36
  27. package/dist/lib/browser/types.d.ts +18 -0
  28. package/dist/lib/config-keys.d.ts +1 -1
  29. package/dist/lib/config-keys.js +5 -0
  30. package/dist/lib/device-config.js +61 -0
  31. package/dist/lib/devices/doctor-findings.js +2 -6
  32. package/dist/lib/feed/answer.d.ts +153 -4
  33. package/dist/lib/feed/answer.js +716 -105
  34. package/dist/lib/feed/feed.d.ts +61 -1
  35. package/dist/lib/feed/feed.js +226 -14
  36. package/dist/lib/feed/hub-server.d.ts +58 -3
  37. package/dist/lib/feed/hub-server.js +306 -54
  38. package/dist/lib/feed/pr-status.d.ts +8 -0
  39. package/dist/lib/feed/pr-status.js +9 -1
  40. package/dist/lib/feed-outcome.d.ts +1 -1
  41. package/dist/lib/feed-outcome.js +9 -2
  42. package/dist/lib/feed-policy.js +9 -3
  43. package/dist/lib/fleet/auth-sync.d.ts +2 -55
  44. package/dist/lib/fleet/auth-sync.js +2 -89
  45. package/dist/lib/harness-auth-capabilities.js +7 -2
  46. package/dist/lib/hosts/dispatch.d.ts +20 -1
  47. package/dist/lib/hosts/dispatch.js +52 -30
  48. package/dist/lib/hosts/remote-cmd.d.ts +21 -0
  49. package/dist/lib/hosts/remote-cmd.js +26 -2
  50. package/dist/lib/mailbox.d.ts +12 -0
  51. package/dist/lib/mailbox.js +16 -2
  52. package/dist/lib/menubar/snapshot.d.ts +51 -0
  53. package/dist/lib/menubar/snapshot.js +42 -3
  54. package/dist/lib/open-url.js +2 -2
  55. package/dist/lib/projects.d.ts +23 -0
  56. package/dist/lib/projects.js +78 -0
  57. package/dist/lib/secrets-cli.d.ts +3 -3
  58. package/dist/lib/secrets-cli.js +1 -1
  59. package/dist/lib/session/active.d.ts +1 -0
  60. package/dist/lib/session/active.js +8 -0
  61. package/dist/lib/session/db.d.ts +67 -3
  62. package/dist/lib/session/db.js +381 -126
  63. package/dist/lib/session/prompt.d.ts +23 -7
  64. package/dist/lib/session/prompt.js +46 -8
  65. package/dist/lib/session/remote/remote-list.d.ts +20 -0
  66. package/dist/lib/session/remote/remote-list.js +22 -6
  67. package/dist/lib/session/remote/watch.d.ts +12 -0
  68. package/dist/lib/session/remote/watch.js +9 -0
  69. package/dist/lib/session/remote-preview-cache.d.ts +29 -0
  70. package/dist/lib/session/remote-preview-cache.js +373 -0
  71. package/dist/lib/session/tail.d.ts +50 -0
  72. package/dist/lib/session/tail.js +219 -0
  73. package/dist/lib/setup-tool-install.js +2 -1
  74. package/dist/lib/setup-tool-status.d.ts +1 -1
  75. package/dist/lib/setup-tool-status.js +6 -1
  76. package/dist/lib/signin-badge.d.ts +19 -4
  77. package/dist/lib/signin-badge.js +29 -11
  78. package/dist/lib/term-driver.d.ts +24 -0
  79. package/dist/lib/term-driver.js +36 -0
  80. package/dist/lib/terminal/index.d.ts +1 -1
  81. package/dist/lib/terminal/index.js +1 -1
  82. package/dist/lib/terminal/inject.d.ts +38 -0
  83. package/dist/lib/terminal/inject.js +55 -9
  84. package/dist/lib/terminal/transport.d.ts +15 -5
  85. package/dist/lib/terminal/transport.js +61 -11
  86. package/package.json +1 -1
  87. package/dist/lib/fleet/remote-login.d.ts +0 -170
  88. package/dist/lib/fleet/remote-login.js +0 -568
@@ -2045,7 +2045,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2045
2045
  const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, implicitModeFor, headlessPlanStallCommand, nativeResume, resolveInteractive, inferredInteractiveWithoutTty }, { ALL_AGENT_IDS, ACCOUNT_INSPECTION_AGENT_IDS, agentLabel, supportsAccountInspection }, { profileExists, readProfile, resolveProfileForRun },
2046
2046
  // Engine DATA ops resolve through the standalone `secrets` process client
2047
2047
  // (PHNX-3989); the agents-owned `bundle@host` policy helpers stay in agents-cli.
2048
- { readAndResolveBundleEnv, describeBundle, remoteResolveEnv }, { assertRemoteBundleFlagsUnsupported, splitBundleRef, resolveSecretsContextForRun }, { resolveHostSshTarget }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, rotationFailoverChain, shouldArmRotationFailover, RUN_STRATEGIES, collectHarnessCandidates, pickHarnessWeighted, classifyHarnessCandidates, formatHarnessPickBanner, formatNoHealthyHarnessError, formatNoHealthyAccountError, formatNoVerifiedUsageError, signInRecoverableCandidates }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias, ensureAgentRunnable }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents, pruneStaleWorkflowSubagents, ensureSubagentDispatchTool }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports, capableAgents }, { shareRuntimeEnv },] = await Promise.all([
2048
+ { readAndResolveBundleEnv, describeBundle, remoteResolveEnv }, { assertRemoteBundleFlagsUnsupported, splitBundleRef, resolveSecretsContextForRun }, { resolveHostSshTarget }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, rotationFailoverChain, DEFAULT_ROTATION_FAILOVER_LIMIT, shouldArmRotationFailover, preflightFallbackHandoff, preflightHandoffEligible, RUN_STRATEGIES, collectHarnessCandidates, pickHarnessWeighted, classifyHarnessCandidates, formatHarnessPickBanner, formatNoHealthyHarnessError, formatNoHealthyAccountError, formatNoVerifiedUsageError, signInRecoverableCandidates }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias, ensureAgentRunnable }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents, pruneStaleWorkflowSubagents, ensureSubagentDispatchTool }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports, capableAgents }, { shareRuntimeEnv },] = await Promise.all([
2049
2049
  import('../lib/exec.js'),
2050
2050
  import('../lib/agents.js'),
2051
2051
  import('../lib/profiles.js'),
@@ -2590,6 +2590,21 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2590
2590
  // synthesize a same-agent fallback chain from the other healthy accounts
2591
2591
  // (issue #348). Stays null unless a non-pinned strategy actually rotated.
2592
2592
  let rotationResult = null;
2593
+ /**
2594
+ * The PRIMARY harness's configured run-default mode/effort, carried across a
2595
+ * preflight alternate-harness handoff (PHNX-3999 F19).
2596
+ *
2597
+ * Run defaults are resolved after the harness is known, so without this a
2598
+ * handoff would silently adopt the ALTERNATE's configured defaults — a
2599
+ * primary configured `mode: plan` becoming the alternate's `mode: skip`
2600
+ * escalates a read-only intent into an unattended writable run. The
2601
+ * canonical mid-run cascade keeps the primary's resolved mode for the same
2602
+ * reason (`runWithFallback` forwards `options.mode` and only re-derives the
2603
+ * mode that was never configured at all). An IMPLICIT mode is deliberately
2604
+ * not carried: it is the harness's own safe default, so the alternate's
2605
+ * applies (identical to `modeWasImplicit ? implicitModeFor(agent)`).
2606
+ */
2607
+ let handoffRunDefaults;
2593
2608
  // Precomputed launchable-signed-in verdict for the ACTUAL launched
2594
2609
  // candidate, fed to the pre-launch `run.launch` event so it need not
2595
2610
  // re-probe. Sourced per resolution branch from the candidate that WON, not
@@ -2622,155 +2637,202 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2622
2637
  // account and account rotation silently stops (the gh-monitor heal bug).
2623
2638
  // `pinned` still resolves so a logged-out default can yield to a signed-in
2624
2639
  // sibling on this device (PHNX-2685); an explicit @version pin is unchanged.
2625
- if (!accountPickerRequested && !configuredAccount && (!version || strategy !== 'pinned' || options.balanced || explicitStrategy)) {
2626
- if (version) {
2627
- process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} ignored: version ${version} is pinned\n`));
2628
- }
2629
- else if (fromProfile) {
2630
- process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} ignored: custom harness pins its own version/auth\n`));
2631
- }
2632
- else {
2633
- try {
2634
- // Account-centric candidate list: native version-home logins PLUS
2635
- // provider accounts (setup-token / API-key) that can auth this agent,
2636
- // so `--strategy balanced` spreads across ALL accounts, not just the
2637
- // ones sitting in a version home (RUSH-3182). Run-path only — the
2638
- // picker's other callers keep the native-only collector.
2639
- const { collectRunCandidatesForRun } = await import('../lib/accounting/account-pool-collect.js');
2640
- bootMark('resolve-version:start');
2641
- const routingModel = options.model ?? resolvedResumeSource?.model ?? workflowModel
2642
- ?? (options.fallback ? undefined : resolveRunDefaults(agent, resolveVersion(agent, cwd), cwd).model);
2643
- const resolved = await resolveRunVersion(agent, strategy, cwd, collectRunCandidatesForRun, routingModel);
2644
- bootMark('resolve-version:done');
2645
- if (resolved.exhausted) {
2646
- // Zero healthy accounts splits two ways, and conflating them is what
2647
- // stranded a logged-out harness with no way in at all (RUSH-2334):
2648
- //
2649
- // - THROTTLED (rate_limited / out_of_credits) -> fail loud (RUSH-2132).
2650
- // The old behavior warned "found no usable version; falling back to
2651
- // defaults" and launched the pinned default anyway — the exact move
2652
- // that loops a rotate into an exhausted account. Only a window reset
2653
- // clears it. The message text is a contract the Factory watchdog
2654
- // tail-detects; do not reword it.
2655
- // - NEEDS A SIGN-IN (signed_out / revoked) -> launching IS the fix,
2656
- // because the harness's own TUI is the login surface. So on a TTY we
2657
- // carry the user into that login instead of erroring. Exiting here
2658
- // made `agents run <agent>`, `agents run <agent>#`, and `agents use`
2659
- // all dead-end with no reachable way to authenticate.
2660
- const recoverable = signInRecoverableCandidates(resolved.exhausted);
2661
- const { signInLaunchDecision } = await import('./run-account-picker.js');
2662
- const decision = signInLaunchDecision({
2663
- recoverable: recoverable.length,
2664
- tty: isInteractiveTerminal(),
2665
- json: options.json === true,
2666
- });
2667
- if (decision === 'launch') {
2668
- const { pickSignInLaunchVersion } = await import('./run-account-picker.js');
2669
- const signInVersion = await pickSignInLaunchVersion(agent, recoverable, !!options.quiet);
2670
- // A cancelled prompt launches nothing — same contract as the
2671
- // trailing-# account picker above.
2672
- if (!signInVersion)
2673
- return;
2674
- version = signInVersion;
2675
- // We just told the user this account is logged out and why we're
2676
- // launching it, so suppress the downstream login preflight — it
2677
- // would print a second, near-identical "looks logged out" warning.
2678
- signInLaunch = true;
2640
+ // Bounded handoff loop: each iteration resolves an account for `agent`, and
2641
+ // the only `continue` is the preflight exhaustion handoff below, which
2642
+ // consumes one entry of the `--fallback` spec before retrying as that
2643
+ // alternate. The spec is finite, so this terminates (PHNX-3999 F19).
2644
+ preflight: for (;;) {
2645
+ if (!accountPickerRequested && !configuredAccount && (!version || strategy !== 'pinned' || options.balanced || explicitStrategy)) {
2646
+ if (version) {
2647
+ process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} ignored: version ${version} is pinned\n`));
2648
+ }
2649
+ else if (fromProfile) {
2650
+ process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} ignored: custom harness pins its own version/auth\n`));
2651
+ }
2652
+ else {
2653
+ try {
2654
+ // Account-centric candidate list: native version-home logins PLUS
2655
+ // provider accounts (setup-token / API-key) that can auth this agent,
2656
+ // so `--strategy balanced` spreads across ALL accounts, not just the
2657
+ // ones sitting in a version home (RUSH-3182). Run-path only — the
2658
+ // picker's other callers keep the native-only collector.
2659
+ const { collectRunCandidatesForRun } = await import('../lib/accounting/account-pool-collect.js');
2660
+ bootMark('resolve-version:start');
2661
+ const routingModel = options.model ?? resolvedResumeSource?.model ?? workflowModel
2662
+ ?? (options.fallback ? undefined : resolveRunDefaults(agent, resolveVersion(agent, cwd), cwd).model);
2663
+ const resolved = await resolveRunVersion(agent, strategy, cwd, collectRunCandidatesForRun, routingModel);
2664
+ bootMark('resolve-version:done');
2665
+ if (resolved.exhausted) {
2666
+ // Zero healthy accounts splits two ways, and conflating them is what
2667
+ // stranded a logged-out harness with no way in at all (RUSH-2334):
2668
+ //
2669
+ // - THROTTLED (rate_limited / out_of_credits) -> fail loud (RUSH-2132).
2670
+ // The old behavior warned "found no usable version; falling back to
2671
+ // defaults" and launched the pinned default anyway — the exact move
2672
+ // that loops a rotate into an exhausted account. Only a window reset
2673
+ // clears it. The message text is a contract the Factory watchdog
2674
+ // tail-detects; do not reword it.
2675
+ // - NEEDS A SIGN-IN (signed_out / revoked) -> launching IS the fix,
2676
+ // because the harness's own TUI is the login surface. So on a TTY we
2677
+ // carry the user into that login instead of erroring. Exiting here
2678
+ // made `agents run <agent>`, `agents run <agent>#`, and `agents use`
2679
+ // all dead-end with no reachable way to authenticate.
2680
+ const recoverable = signInRecoverableCandidates(resolved.exhausted);
2681
+ const { signInLaunchDecision } = await import('./run-account-picker.js');
2682
+ const decision = signInLaunchDecision({
2683
+ recoverable: recoverable.length,
2684
+ tty: isInteractiveTerminal(),
2685
+ json: options.json === true,
2686
+ });
2687
+ if (decision === 'launch') {
2688
+ const { pickSignInLaunchVersion } = await import('./run-account-picker.js');
2689
+ const signInVersion = await pickSignInLaunchVersion(agent, recoverable, !!options.quiet);
2690
+ // A cancelled prompt launches nothing — same contract as the
2691
+ // trailing-# account picker above.
2692
+ if (!signInVersion)
2693
+ return;
2694
+ version = signInVersion;
2695
+ // We just told the user this account is logged out and why we're
2696
+ // launching it, so suppress the downstream login preflight — it
2697
+ // would print a second, near-identical "looks logged out" warning.
2698
+ signInLaunch = true;
2699
+ }
2700
+ else {
2701
+ // Every account of this harness is throttled. A configured
2702
+ // alternate exists for exactly this case, so hand off to it here
2703
+ // rather than exiting — `runWithFallback` would only reach it
2704
+ // after a run that can no longer start (PHNX-3999 F19). The
2705
+ // alternate becomes the primary and is dropped from the spec, so
2706
+ // the chain that remains never lists the agent now running.
2707
+ //
2708
+ // ONLY for a run shape `--fallback` is valid on. The handoff
2709
+ // consumes the entry it uses, and the canonical --fallback
2710
+ // validation runs later (it rejects interactive/--acp/--loop/
2711
+ // --resume-checkpoint chains and requires a prompt) — so
2712
+ // switching first on an ineligible shape would empty the spec,
2713
+ // leave that guard nothing to reject, and start the alternate
2714
+ // harness on a run the CLI refuses today. An ineligible shape
2715
+ // never switches, so the spec survives and still fails there
2716
+ // with its own message. See preflightHandoffEligible.
2717
+ const handoff = preflightHandoffEligible({
2718
+ hasPrompt: prompt !== undefined,
2719
+ interactive: options.interactive === true,
2720
+ acp: options.acp === true,
2721
+ loop: options.loop === true,
2722
+ resumeCheckpoint: !!options.resumeCheckpoint,
2723
+ resume: !!options.resume,
2724
+ workflowScoped: !!workflowToolsRestrict || !!workflowMcpConfigPath,
2725
+ })
2726
+ ? preflightFallbackHandoff(options.fallback, agent, resolved.exhausted)
2727
+ : null;
2728
+ if (handoff) {
2729
+ process.stderr.write(chalk.yellow(`[agents] every ${agent} account is exhausted — handing off to ${handoff.agent}${handoff.version ? `@${handoff.version}` : ''}\n`));
2730
+ // Snapshot the PRIMARY's configured defaults BEFORE the switch —
2731
+ // this is the operator's intent for this run, not the alternate's.
2732
+ handoffRunDefaults ??= fromProfile ? undefined : resolveRunDefaults(agent, version ?? resolveVersion(agent, cwd), cwd);
2733
+ agent = handoff.agent;
2734
+ // Alias-resolve against the NEW harness's installs, exactly as
2735
+ // the canonical --fallback parse does for its own entries.
2736
+ version = resolveVersionAlias(agent, handoff.version);
2737
+ options.fallback = handoff.remainingSpec;
2738
+ rotationResult = null;
2739
+ continue preflight;
2740
+ }
2741
+ console.error(chalk.red(formatNoHealthyAccountError(agent, strategy, resolved.exhausted)));
2742
+ if (recoverable.length > 0) {
2743
+ // Off a TTY nobody can complete a login, so we still exit — but
2744
+ // name the actual fix rather than only offering --strategy pinned,
2745
+ // which would just pin the same unauthenticated account.
2746
+ const { loginHint } = await import('../lib/signin-badge.js');
2747
+ console.error(chalk.gray(`To sign in: ${loginHint(agent)} — or run \`agents run ${agent}\` from a terminal.`));
2748
+ }
2749
+ process.exit(1);
2750
+ }
2679
2751
  }
2680
- else {
2681
- console.error(chalk.red(formatNoHealthyAccountError(agent, strategy, resolved.exhausted)));
2682
- if (recoverable.length > 0) {
2683
- // Off a TTY nobody can complete a login, so we still exit — but
2684
- // name the actual fix rather than only offering --strategy pinned,
2685
- // which would just pin the same unauthenticated account.
2686
- const { loginHint } = await import('../lib/signin-badge.js');
2687
- console.error(chalk.gray(`To sign in: ${loginHint(agent)} — or run \`agents run ${agent}\` from a terminal.`));
2752
+ else if (resolved.noVerifiedUsage) {
2753
+ // Entirely stale usage (PHNX-2526): every eligible account carries
2754
+ // a stale-but-present usage number and none is verified, so the
2755
+ // route would be a guess. NEVER auto-pick it. Interactive: show the
2756
+ // account picker so a human chooses with the (stale) numbers in
2757
+ // view. Unattended: fail loud with NO_VERIFIED_USAGE. The stale
2758
+ // pool survives ONLY as `resolved.rotation.healthy` for bounded
2759
+ // post-rejection failover, never as the initial pick.
2760
+ const { noVerifiedUsageDecision, pickRunAccountCandidate } = await import('./run-account-picker.js');
2761
+ const decision = noVerifiedUsageDecision({
2762
+ tty: isInteractiveTerminal(),
2763
+ json: options.json === true,
2764
+ headless: options.headless === true,
2765
+ });
2766
+ if (decision === 'picker') {
2767
+ const selected = await pickRunAccountCandidate(agent);
2768
+ // A cancelled picker launches nothing — same contract as the
2769
+ // trailing-# account picker and the sign-in launch above.
2770
+ if (!selected)
2771
+ return;
2772
+ version = selected.version;
2773
+ // Source the run.launch verdict from the account the user ACTUALLY
2774
+ // picked — the picker may deliberately return a logged-out one
2775
+ // (RUSH-2334), so it can differ from rotationResult.picked.
2776
+ launchSignedIn = selected.signedIn;
2777
+ launchEmail = selected.email;
2778
+ // Keep the rotation so mid-run failover can still cascade across
2779
+ // the other (stale) healthy accounts after a real rejection.
2780
+ rotationResult = resolved.rotation;
2781
+ if (!options.quiet) {
2782
+ const identity = selected.accountLabel || 'signed-in account';
2783
+ process.stderr.write(chalk.gray(`[agents] no fresh usage for any ${agent} account — you picked ${identity} · ${agent}@${selected.version}\n`));
2784
+ }
2785
+ }
2786
+ else {
2787
+ console.error(chalk.red(formatNoVerifiedUsageError(agent, strategy, resolved.rotation?.healthy ?? [])));
2788
+ process.exit(1);
2688
2789
  }
2689
- process.exit(1);
2690
2790
  }
2691
- }
2692
- else if (resolved.noVerifiedUsage) {
2693
- // Entirely stale usage (PHNX-2526): every eligible account carries
2694
- // a stale-but-present usage number and none is verified, so the
2695
- // route would be a guess. NEVER auto-pick it. Interactive: show the
2696
- // account picker so a human chooses with the (stale) numbers in
2697
- // view. Unattended: fail loud with NO_VERIFIED_USAGE. The stale
2698
- // pool survives ONLY as `resolved.rotation.healthy` for bounded
2699
- // post-rejection failover, never as the initial pick.
2700
- const { noVerifiedUsageDecision, pickRunAccountCandidate } = await import('./run-account-picker.js');
2701
- const decision = noVerifiedUsageDecision({
2702
- tty: isInteractiveTerminal(),
2703
- json: options.json === true,
2704
- headless: options.headless === true,
2705
- });
2706
- if (decision === 'picker') {
2707
- const selected = await pickRunAccountCandidate(agent);
2708
- // A cancelled picker launches nothing — same contract as the
2709
- // trailing-# account picker and the sign-in launch above.
2710
- if (!selected)
2711
- return;
2712
- version = selected.version;
2713
- // Source the run.launch verdict from the account the user ACTUALLY
2714
- // picked — the picker may deliberately return a logged-out one
2715
- // (RUSH-2334), so it can differ from rotationResult.picked.
2716
- launchSignedIn = selected.signedIn;
2717
- launchEmail = selected.email;
2718
- // Keep the rotation so mid-run failover can still cascade across
2719
- // the other (stale) healthy accounts after a real rejection.
2791
+ else if (resolved.version) {
2792
+ version = resolved.version;
2720
2793
  rotationResult = resolved.rotation;
2721
- if (!options.quiet) {
2722
- const identity = selected.accountLabel || 'signed-in account';
2723
- process.stderr.write(chalk.gray(`[agents] no fresh usage for any ${agent} account — you picked ${identity} · ${agent}@${selected.version}\n`));
2794
+ // The auto-pick already computed the launchable-signed-in verdict for
2795
+ // this exact version via the same gate — reuse it for run.launch.
2796
+ if (resolved.rotation) {
2797
+ launchSignedIn = resolved.rotation.picked.signedIn;
2798
+ launchEmail = resolved.rotation.picked.email;
2724
2799
  }
2725
- }
2726
- else {
2727
- console.error(chalk.red(formatNoVerifiedUsageError(agent, strategy, resolved.rotation?.healthy ?? [])));
2728
- process.exit(1);
2729
- }
2730
- }
2731
- else if (resolved.version) {
2732
- version = resolved.version;
2733
- rotationResult = resolved.rotation;
2734
- // The auto-pick already computed the launchable-signed-in verdict for
2735
- // this exact version via the same gate — reuse it for run.launch.
2736
- if (resolved.rotation) {
2737
- launchSignedIn = resolved.rotation.picked.signedIn;
2738
- launchEmail = resolved.rotation.picked.email;
2739
- }
2740
- // A balanced/available pick of a PROVIDER account (setup-token /
2741
- // API-key) carries `providerAccount`. Resolve its env through the
2742
- // same `resolveSpawnAccount` path an explicit `--account` uses, so
2743
- // exec injects the credential; a native pick has no providerAccount
2744
- // and runs from its own version home unchanged (RUSH-3182).
2745
- const pickedProviderAccount = resolved.rotation?.picked.providerAccount;
2746
- if (pickedProviderAccount) {
2747
- try {
2748
- const picked = resolveSpawnAccount(pickedProviderAccount, agent, resolved.version, readMeta(), { useDefault: false });
2749
- if (picked?.kind === 'provider')
2750
- accountEnv = picked.env;
2800
+ // A balanced/available pick of a PROVIDER account (setup-token /
2801
+ // API-key) carries `providerAccount`. Resolve its env through the
2802
+ // same `resolveSpawnAccount` path an explicit `--account` uses, so
2803
+ // exec injects the credential; a native pick has no providerAccount
2804
+ // and runs from its own version home unchanged (RUSH-3182).
2805
+ const pickedProviderAccount = resolved.rotation?.picked.providerAccount;
2806
+ if (pickedProviderAccount) {
2807
+ try {
2808
+ const picked = resolveSpawnAccount(pickedProviderAccount, agent, resolved.version, readMeta(), { useDefault: false });
2809
+ if (picked?.kind === 'provider')
2810
+ accountEnv = picked.env;
2811
+ }
2812
+ catch (err) {
2813
+ console.error(chalk.red(err.message));
2814
+ process.exit(1);
2815
+ }
2751
2816
  }
2752
- catch (err) {
2753
- console.error(chalk.red(err.message));
2754
- process.exit(1);
2817
+ if (resolved.rotation && !options.quiet) {
2818
+ const banner = formatRotationBanner(resolved.rotation, strategy);
2819
+ process.stderr.write(chalk.gray(banner + '\n'));
2755
2820
  }
2756
2821
  }
2757
- if (resolved.rotation && !options.quiet) {
2758
- const banner = formatRotationBanner(resolved.rotation, strategy);
2759
- process.stderr.write(chalk.gray(banner + '\n'));
2822
+ else if (!options.quiet) {
2823
+ // No installed version at all (not "accounts exhausted" — that
2824
+ // fails loud above): keep the pre-existing default resolution.
2825
+ process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} found no usable ${agent} version; falling back to defaults\n`));
2760
2826
  }
2761
2827
  }
2762
- else if (!options.quiet) {
2763
- // No installed version at all (not "accounts exhausted" — that
2764
- // fails loud above): keep the pre-existing default resolution.
2765
- process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} found no usable ${agent} version; falling back to defaults\n`));
2766
- }
2767
- }
2768
- catch (err) {
2769
- if (!options.quiet) {
2770
- process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} skipped: ${err.message}\n`));
2828
+ catch (err) {
2829
+ if (!options.quiet) {
2830
+ process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} skipped: ${err.message}\n`));
2831
+ }
2771
2832
  }
2772
2833
  }
2773
2834
  }
2835
+ break;
2774
2836
  }
2775
2837
  // Self-heal the launch target. A gutted install (JS wrapper present,
2776
2838
  // native binary missing — a partial/raced npm extraction of the optional
@@ -2899,9 +2961,12 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2899
2961
  // alias for 'skip' (rewritten downstream by normalizeMode in exec.ts).
2900
2962
  let mode = options.mode;
2901
2963
  const modeSource = runCmd.getOptionValueSource('mode');
2902
- const modeFromRunDefault = modeSource === 'default' && !!runDefaults.mode;
2964
+ // A configured mode carried across a preflight handoff wins over the
2965
+ // alternate harness's own configured default (see handoffRunDefaults).
2966
+ const configuredMode = handoffRunDefaults?.mode ?? runDefaults.mode;
2967
+ const modeFromRunDefault = modeSource === 'default' && !!configuredMode;
2903
2968
  if (modeFromRunDefault) {
2904
- mode = runDefaults.mode;
2969
+ mode = configuredMode;
2905
2970
  }
2906
2971
  if (!['plan', 'edit', 'auto', 'skip', 'full'].includes(mode)) {
2907
2972
  console.error(chalk.red(`Invalid mode: ${mode}. Use plan, edit, auto, or skip ('full' accepted as alias for skip).`));
@@ -2952,7 +3017,8 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2952
3017
  process.exit(1);
2953
3018
  }
2954
3019
  const effortSource = runCmd.getOptionValueSource('effort');
2955
- const effort = (effortSource === 'default' && runDefaults.effort ? runDefaults.effort : options.effort);
3020
+ const configuredEffort = handoffRunDefaults?.effort ?? runDefaults.effort;
3021
+ const effort = (effortSource === 'default' && configuredEffort ? configuredEffort : options.effort);
2956
3022
  if (!['low', 'medium', 'high', 'xhigh', 'max', 'auto'].includes(effort)) {
2957
3023
  console.error(chalk.red(`Invalid effort: ${effort}. Use 'low', 'medium', 'high', 'xhigh', 'max', or 'auto'`));
2958
3024
  process.exit(1);
@@ -3190,7 +3256,12 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
3190
3256
  loop: !!options.loop,
3191
3257
  resumeCheckpoint: !!options.resumeCheckpoint,
3192
3258
  })) {
3193
- const failover = rotationFailoverChain(rotationResult, version);
3259
+ // With an explicit alternate harness configured, "use the alternate once
3260
+ // the primary's accounts are exhausted" is the policy — so EVERY healthy
3261
+ // same-agent account has to be tried first, not the default cap of three
3262
+ // (PHNX-3999 F19). Without one, the cap stands: it bounds how long a
3263
+ // rate-limited run keeps retrying itself.
3264
+ const failover = rotationFailoverChain(rotationResult, version, fallback.length > 0 ? (rotationResult.healthy.length || DEFAULT_ROTATION_FAILOVER_LIMIT) : undefined);
3194
3265
  if (failover.length > 0) {
3195
3266
  fallback.unshift(...failover);
3196
3267
  if (!options.quiet) {
@@ -1,5 +1,5 @@
1
1
  import chalk from 'chalk';
2
- import { ensureFeedPublishHook, listAskStats, listBlocks, recordNotified, buildDeclaredBlock, publishBlock, } from '../lib/feed/feed.js';
2
+ import { ensureFeedPublishHook, listAskStats, listBlocks, recordNotified, buildDeclaredBlock, deriveBlockState, publishBlock, } from '../lib/feed/feed.js';
3
3
  import { ensureActivityLogHook, readRecentActivity, formatActivityLine, formatProgressUpdate, mergeActivityEvents, parseActivityPayload, } from '../lib/feed/activity.js';
4
4
  import { projectKeyFromCwd } from '../lib/project-key.js';
5
5
  import { postFeedStatus } from '../lib/feed-post.js';
@@ -16,7 +16,7 @@ import { gatherRemoteAgentsJson } from '../lib/remote-agents-json.js';
16
16
  import { loadPolicy, applyPolicyToBlock, isPhoneUrgent } from '../lib/feed-policy.js';
17
17
  import { notifyUrgentBlock } from '../lib/notify.js';
18
18
  import { registerFeedWatchCommand } from './feed-watch.js';
19
- import { claimAndRouteAttentionAnswer, forwardFeedAnswer } from '../lib/feed/answer.js';
19
+ import { AnswerError, answerOwnerIsLocal, checkAnswerDelivery, claimAndRouteAttentionAnswer, forwardFeedAnswer, parseAttentionKey, } from '../lib/feed/answer.js';
20
20
  import { gcMailbox } from '../lib/mailbox-gc.js';
21
21
  import { isValidMailboxId } from '../lib/mailbox.js';
22
22
  import { getActiveSessions } from '../lib/session/active.js';
@@ -171,7 +171,10 @@ function renderBlock(b, localHost, indent = '') {
171
171
  const cost = b.costOfDelay ? chalk.gray(`cost:${b.costOfDelay}`) : '';
172
172
  const rank = b.delayRank ? chalk.gray(`rank:${Math.round(b.delayRank.score)}`) : '';
173
173
  // Shared fleet-comms glyphs: ▲ open ask, ✓ answered (see comms-render GLYPH).
174
- const marker = b.answer
174
+ // A PENDING claim carries an answer record while the block is still open, so
175
+ // the delivered glyph is driven by the canonical state, not by `b.answer`
176
+ // being set — a claimed-but-undelivered ask must keep reading as needs-you.
177
+ const marker = deriveBlockState(b) !== 'open'
175
178
  ? chalk.green(GLYPH.delivered)
176
179
  : b.kind === 'control'
177
180
  ? chalk.red('!')
@@ -197,7 +200,9 @@ function renderBlock(b, localHost, indent = '') {
197
200
  if (b.answer) {
198
201
  const verified = b.answer.verified ? chalk.green(GLYPH.delivered) : chalk.yellow('?');
199
202
  const who = b.answer.answeredFrom + (b.answer.answeredBy ? ` (${b.answer.answeredBy})` : '');
200
- console.log(`${indent} ${chalk.green('answered')} by ${who} ${verified}`);
203
+ const pending = deriveBlockState(b) === 'open';
204
+ const label = pending ? chalk.yellow('claimed (delivery unconfirmed)') : chalk.green('answered');
205
+ console.log(`${indent} ${label} by ${who} ${verified}`);
201
206
  }
202
207
  if (b.parkedAt) {
203
208
  console.log(`${indent} ${chalk.red('hard-parked')} ${relTime(b.parkedAt)}`);
@@ -314,6 +319,23 @@ export async function loadSessionMetasForFeedEnrichment(load) {
314
319
  throw err;
315
320
  }
316
321
  }
322
+ /**
323
+ * One human line per answer outcome. The three states the operator has to tell
324
+ * apart are a real receipt, a hand-off no rail can confirm, and a refusal —
325
+ * never collapsed into a single "Delivered" (PHNX-3999).
326
+ */
327
+ function renderAnswerResult(result) {
328
+ switch (result.status) {
329
+ case 'failed':
330
+ return `Not delivered: ${result.reason ?? 'unknown failure'} — answering again is safe.`;
331
+ case 'unknown':
332
+ return `Delivery unconfirmed: ${result.reason ?? 'no rail reported a receipt.'} Run \`agents feed answer ${result.attentionKey} --check\` rather than resending.`;
333
+ case 'already_answered':
334
+ return `Already answered — ${result.receipt?.status ?? 'claimed'}${result.receipt ? ` as ${result.receipt.msgId}` : ''} at ${result.receipt?.at ?? result.attempt}.`;
335
+ default:
336
+ return `Delivered ${result.receipt?.msgId} (${result.receipt?.status}).`;
337
+ }
338
+ }
317
339
  export function registerFeedCommand(program) {
318
340
  const feed = program
319
341
  .command('feed')
@@ -334,23 +356,52 @@ export function registerFeedCommand(program) {
334
356
  .option('--choice <choice-id>', 'Stable choice id from the attention item')
335
357
  .option('--text <answer>', 'Free-text answer')
336
358
  .option('--as <operator>', 'Verified operator id for high-consequence answers')
359
+ .option('--check', 'Read-only: report this item\'s delivery state without claiming, routing or resending')
360
+ .option('--attempt <at>', 'With --check: the attempt timestamp being checked, so a newer one is reported as such')
337
361
  .option('--json', 'Emit the delivery result as JSON')
338
362
  .action(async (attentionKey, opts, invoked) => {
363
+ const wantsJson = Boolean(opts.json || invoked.parent?.opts()?.json);
339
364
  try {
340
- const ownerHost = attentionKey.slice(0, attentionKey.indexOf('/'));
341
- const result = ownerHost && ownerHost !== machineId()
342
- ? await forwardFeedAnswer({ host: ownerHost, attentionKey, choiceId: opts.choice, text: opts.text, operatorId: opts.as })
343
- : await claimAndRouteAttentionAnswer({
344
- attentionKey, choiceId: opts.choice, text: opts.text,
345
- operator: { id: opts.as, verified: Boolean(opts.as), label: opts.as },
346
- });
347
- if (opts.json || invoked.parent?.opts()?.json)
365
+ // The key's own host decides the rail; `answerOwnerIsLocal` normalizes
366
+ // both sides so a `.local` suffix is not read as a different machine.
367
+ const { host: ownerHost } = parseAttentionKey(attentionKey);
368
+ const local = answerOwnerIsLocal(ownerHost);
369
+ if (opts.check && (opts.choice != null || opts.text != null)) {
370
+ throw new AnswerError('--check is read-only; it takes no --choice or --text.', 'empty_answer');
371
+ }
372
+ if (opts.attempt != null && !opts.check) {
373
+ throw new AnswerError('--attempt only applies to --check.', 'empty_answer');
374
+ }
375
+ const result = opts.check
376
+ ? (local
377
+ ? checkAnswerDelivery(attentionKey, undefined, opts.attempt)
378
+ : await forwardFeedAnswer({ host: ownerHost, attentionKey, check: true, attempt: opts.attempt, operatorId: opts.as }))
379
+ : local
380
+ ? await claimAndRouteAttentionAnswer({
381
+ attentionKey, choiceId: opts.choice, text: opts.text,
382
+ operator: { id: opts.as, verified: Boolean(opts.as), label: opts.as },
383
+ })
384
+ : await forwardFeedAnswer({ host: ownerHost, attentionKey, choiceId: opts.choice, text: opts.text, operatorId: opts.as });
385
+ if (wantsJson)
348
386
  console.log(JSON.stringify(result));
349
387
  else
350
- console.log(result.status === 'delivered' ? `Delivered ${result.receipt.msgId}.` : `Already answered (${result.receipt.at}).`);
388
+ console.log(renderAnswerResult(result));
351
389
  }
352
390
  catch (error) {
353
- invoked.error(`error: ${error instanceof Error ? error.message : String(error)}`);
391
+ // A failure is a first-class outcome for the operator UI, not a parse
392
+ // error: `--json` still gets a result object it can branch on, with a
393
+ // non-zero exit so a scripted caller sees the failure too.
394
+ const message = error instanceof Error ? error.message : String(error);
395
+ if (wantsJson) {
396
+ const failure = {
397
+ status: 'failed', delivery: 'failed', resolved: false, reason: message,
398
+ code: error instanceof AnswerError ? error.code : 'rail_failed', attentionKey,
399
+ };
400
+ console.log(JSON.stringify(failure));
401
+ process.exitCode = 1;
402
+ return;
403
+ }
404
+ invoked.error(`error: ${message}`);
354
405
  }
355
406
  });
356
407
  feed
@@ -153,6 +153,17 @@ export interface SessionPreviewDigest {
153
153
  firstError?: string;
154
154
  toolHistogram: ReturnType<typeof toolHistogram>;
155
155
  test: ReturnType<typeof detectTestResult>;
156
+ /**
157
+ * True when this digest was built WITHOUT parsing the transcript (PHNX-3999):
158
+ * the file exceeded {@link PREVIEW_DIGEST_MAX_PARSE_BYTES} and no cached
159
+ * digest existed yet. `firstUser`/`lastAssistant` fall back to the already-
160
+ * indexed `SessionMeta.firstUserMessage`/`lastUserMessage` (cheap, no parse)
161
+ * rather than being silently empty; every event-derived field (toolCalls,
162
+ * changedFiles, artifacts, etc.) stays at its zero-value default because it
163
+ * genuinely was not computed, not because nothing happened.
164
+ */
165
+ partial?: boolean;
166
+ partialReason?: string;
156
167
  }
157
168
  /** Fold a harness-normalized event stream into the stable preview data model. */
158
169
  export declare function buildSessionPreviewDigest(events: SessionEvent[], session: SessionMeta): SessionPreviewDigest;