clearotron 0.3.2-beta.7 → 0.3.2-beta.9

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 (272) hide show
  1. package/.env.example +58 -26
  2. package/CONTRIBUTING.md +8 -8
  3. package/INSTALL.md +148 -81
  4. package/README.md +3 -3
  5. package/SECURITY.md +3 -3
  6. package/bin/brandowner.mjs +3 -3
  7. package/bin/framework-preflight.mjs +1 -1
  8. package/bin/onboard.mjs +637 -216
  9. package/bin/start.mjs +151 -27
  10. package/bin/update.mjs +58 -11
  11. package/build-info.json +2 -2
  12. package/docs/DELIVERY.md +2 -1
  13. package/docs/INTAKE.md +1 -1
  14. package/docs/ONBOARDING.md +1 -1
  15. package/docs/architecture/03-run-lifecycle.md +6 -6
  16. package/docs/architecture/04-configuration-reference.md +32 -14
  17. package/docs/architecture/05-config-governance.md +23 -8
  18. package/docs/architecture/05-customer-profiles.md +2 -2
  19. package/docs/architecture/06-operations-runbook.md +3 -3
  20. package/docs/architecture/08-development-guide.md +6 -6
  21. package/docs/configuration.md +5 -5
  22. package/docs/decisions/0003-credential-model.md +1 -1
  23. package/docs/writing-standard.md +4 -0
  24. package/driver/CHANGELOG.md +124 -0
  25. package/driver/README.md +3 -3
  26. package/driver/band-size.mjs +59 -0
  27. package/driver/binding-layers.mjs +1 -1
  28. package/driver/citation-census.json +3 -3
  29. package/driver/{prelim-variants-record.mjs → clearance-variants-record.mjs} +24 -24
  30. package/driver/common-law-receipts.mjs +2 -2
  31. package/driver/company-bundle.mjs +3 -3
  32. package/driver/compose-read.mjs +8 -14
  33. package/driver/config-inventory.mjs +112 -9
  34. package/driver/consumption-ledger.mjs +2 -2
  35. package/driver/contract-arm2-baseline.json +2 -5
  36. package/driver/contract-dictation-registry.mjs +19 -19
  37. package/driver/contract-e3-backlog.mjs +43 -43
  38. package/driver/contract-e3-baseline.json +14 -14
  39. package/driver/contract-vocabulary.mjs +68 -27
  40. package/driver/deliver-trigger.sh +16 -16
  41. package/driver/demo-container.mjs +3 -3
  42. package/driver/dev-portal.mjs +3 -3
  43. package/driver/disposition-call.mjs +1 -1
  44. package/driver/door-gates.mjs +41 -7
  45. package/driver/doubt-ledger.mjs +2 -2
  46. package/driver/drainer-identity.mjs +34 -8
  47. package/driver/driver.config.mjs +367 -104
  48. package/driver/engine/CONTRACT.md +10 -3
  49. package/driver/engine/README.md +2 -2
  50. package/driver/engine/anthropic-agent.mjs +77 -21
  51. package/driver/engine/auth.mjs +129 -10
  52. package/driver/engine/jx-turn.mjs +7 -6
  53. package/driver/engine/mcp/README.md +1 -1
  54. package/driver/engine/mcp/dispositions-server.mjs +3 -3
  55. package/driver/engine/mcp/gather-config.mjs +9 -9
  56. package/driver/engine/mcp/perplexity-server.mjs +2 -2
  57. package/driver/engine/mcp/recording-server.mjs +18 -5
  58. package/driver/engine/openai-agent.mjs +4 -2
  59. package/driver/engine/probe.mjs +110 -23
  60. package/driver/enqueue-schema.mjs +6 -2
  61. package/driver/findings-model.mjs +6 -3
  62. package/driver/flag-snapshot.mjs +34 -8
  63. package/driver/form-neighbourhood.mjs +54 -7
  64. package/driver/framework.mjs +4 -4
  65. package/driver/gateway.mjs +36 -24
  66. package/driver/jx-lanes.mjs +23 -4
  67. package/driver/jx-units.mjs +7 -4
  68. package/driver/jx.mjs +34 -4
  69. package/driver/knockout-review-record.mjs +56 -4
  70. package/driver/known-conflicts.mjs +1 -1
  71. package/driver/matter-frame-record.mjs +90 -1
  72. package/driver/named-band.mjs +1 -1
  73. package/driver/ordinary-words.mjs +51 -0
  74. package/driver/outbox-backoff.mjs +31 -16
  75. package/driver/package.json +1 -1
  76. package/driver/partial-payload-baseline.json +2 -2
  77. package/driver/phase0.mjs +3 -3
  78. package/driver/pipeline-knockout.mjs +5 -5
  79. package/driver/pipeline.mjs +396 -81
  80. package/driver/placement-form.mjs +77 -1
  81. package/driver/placement-model.mjs +1 -1
  82. package/driver/portal-config-view.mjs +30 -1
  83. package/driver/portal-report.mjs +107 -6
  84. package/driver/portal-service.mjs +80 -14
  85. package/driver/portal-upstream.mjs +1 -1
  86. package/driver/predelivery-lint.mjs +12 -2
  87. package/driver/preserve-merge.mjs +3 -3
  88. package/driver/product-rows.mjs +2 -2
  89. package/driver/products.mjs +1 -1
  90. package/driver/profiles/README.md +3 -3
  91. package/driver/profiles/demo-brand-owner.json +2 -2
  92. package/driver/profiles.mjs +55 -17
  93. package/driver/progress.mjs +18 -8
  94. package/driver/provider-usage.mjs +8 -8
  95. package/driver/publish/index.mjs +154 -8
  96. package/driver/publish/knockout.mjs +39 -5
  97. package/driver/publish/pool-admin.mjs +1 -1
  98. package/driver/publish/publish-inputs.mjs +18 -2
  99. package/driver/publish/render-knockout.mjs +184 -31
  100. package/driver/publish/render.mjs +323 -93
  101. package/driver/publish/report-data.mjs +4 -1
  102. package/driver/publish/report-topbar.mjs +58 -0
  103. package/driver/publish/search-depth.mjs +133 -4
  104. package/driver/publish/templates/report.css +78 -4
  105. package/driver/publish/xlsx.mjs +20 -1
  106. package/driver/queue-order.mjs +2 -2
  107. package/driver/recording-agreement.mjs +1 -1
  108. package/driver/reference-score.mjs +1 -1
  109. package/driver/register-availability.mjs +2 -2
  110. package/driver/register-count.mjs +50 -5
  111. package/driver/register-coverage.mjs +161 -1
  112. package/driver/register-digest-record.mjs +236 -11
  113. package/driver/register-grant-vocabulary.mjs +1 -1
  114. package/driver/register-plan.mjs +189 -2
  115. package/driver/registry-fidelity.mjs +3 -3
  116. package/driver/repair-composers.mjs +1 -1
  117. package/driver/repair-contract.mjs +1 -1
  118. package/driver/replay-archive.mjs +6 -6
  119. package/driver/report-overview-record.mjs +2 -2
  120. package/driver/result-noun-fields.mjs +2 -2
  121. package/driver/run-economics.mjs +41 -10
  122. package/driver/run-requirements.mjs +173 -9
  123. package/driver/runner.mjs +5 -5
  124. package/driver/scope-facts.mjs +20 -5
  125. package/driver/scope-ledger.mjs +5 -5
  126. package/driver/search-policy.mjs +22 -12
  127. package/driver/skills/README.md +15 -15
  128. package/driver/skills/blind-frame/SKILL.md +2 -2
  129. package/driver/skills/case-law-citation/SKILL.md +4 -4
  130. package/driver/skills/case-law-citation/sources/eurlex.md +1 -1
  131. package/driver/skills/{prelim-common-law → clearance-common-law}/SKILL.md +22 -22
  132. package/driver/skills/{prelim-common-law → clearance-common-law}/perplexity-prompts.md +1 -1
  133. package/driver/skills/{prelim-register → clearance-register}/SKILL.md +10 -10
  134. package/driver/skills/{prelim-register → clearance-register}/digest.md +2 -2
  135. package/driver/skills/{prelim-register → clearance-register}/providers/README.md +1 -1
  136. package/driver/skills/{prelim-register → clearance-register}/providers/clarivate.md +37 -35
  137. package/driver/skills/{prelim-register → clearance-register}/providers/corsearch.md +20 -11
  138. package/driver/skills/{prelim-register → clearance-register}/providers/signa.md +5 -5
  139. package/driver/skills/{prelim-register → clearance-register}/register-recipes.md +3 -3
  140. package/driver/skills/{prelim-register → clearance-register}/status-rules.md +2 -2
  141. package/driver/skills/{prelim-register → clearance-register}/stealth-filer-indicators.md +1 -1
  142. package/driver/skills/{prelim-register → clearance-register}/unit.md +2 -2
  143. package/driver/skills/{prelim-search → clearance-search}/SKILL.md +31 -31
  144. package/driver/skills/{prelim-search → clearance-search}/delivery-contract.md +1 -1
  145. package/driver/skills/{prelim-search → clearance-search}/phase2-execution.md +18 -18
  146. package/driver/skills/{prelim-search → clearance-search}/synthesis-rules.md +7 -7
  147. package/driver/skills/{prelim-variants → clearance-variants}/SKILL.md +18 -18
  148. package/driver/skills/{prelim-variants → clearance-variants}/transliteration-scripts.md +5 -5
  149. package/driver/skills/frame-diff/SKILL.md +1 -1
  150. package/driver/skills/knockout-assess/SKILL.md +10 -7
  151. package/driver/skills/matter-frame/SKILL.md +3 -3
  152. package/driver/skills/narrative-refutation/SKILL.md +9 -9
  153. package/driver/skills/placement-inquiry/SKILL.md +5 -5
  154. package/driver/stage-context.mjs +1 -1
  155. package/driver/stages-knockout.mjs +4 -4
  156. package/driver/stages.mjs +65 -61
  157. package/driver/status-snapshot.mjs +2 -2
  158. package/driver/suite-census.json +340 -136
  159. package/driver/surface-exit-verdict.mjs +58 -0
  160. package/driver/systemd/README.md +9 -6
  161. package/driver/systemd/clearotron-worker.service +1 -1
  162. package/driver/terminal-clamp.mjs +109 -1
  163. package/driver/tokens.mjs +169 -3
  164. package/driver/unit-environment.mjs +42 -15
  165. package/driver/unit-inventory.mjs +19 -2
  166. package/driver/usage-ledger.mjs +1 -1
  167. package/driver/variant-manifest-model.mjs +4 -4
  168. package/driver/verify-knockout.mjs +27 -0
  169. package/driver/verify.mjs +94 -6
  170. package/driver/whatif-queue.mjs +1 -1
  171. package/driver/wordlists/en.txt +63906 -0
  172. package/mcp-server/CHANGELOG.md +8 -0
  173. package/mcp-server/README.md +1 -1
  174. package/mcp-server/lib/README.md +1 -1
  175. package/mcp-server/lib/options.mjs +8 -7
  176. package/mcp-server/lib/plan.mjs +18 -2
  177. package/mcp-server/lib/runs.mjs +1 -1
  178. package/mcp-server/lib/usage.mjs +3 -3
  179. package/mcp-server/lib/whatif.mjs +1 -1
  180. package/mcp-server/package.json +1 -1
  181. package/mcp-server/server.mjs +18 -1
  182. package/package.json +12 -11
  183. package/portal-ui/dist/assets/{index-CVOIvdhc.css → index-CtvwLCti.css} +207 -3
  184. package/portal-ui/dist/assets/{index-5UyqAyNM.js → index-EVaSo5-g.js} +1580 -527
  185. package/portal-ui/dist/index.html +2 -2
  186. package/portal-ui/package.json +1 -1
  187. package/providers/README.md +1 -1
  188. package/providers/_shared/enumerate.mjs +6 -6
  189. package/providers/_shared/execute-plan.mjs +3 -3
  190. package/providers/_shared/ledger.mjs +119 -5
  191. package/providers/_shared/provider-text.mjs +2 -2
  192. package/providers/_shared/screen.mjs +2 -2
  193. package/providers/_shared/script-form.mjs +3 -3
  194. package/providers/_shared/territory-codes.mjs +23 -3
  195. package/providers/clarivate/README.md +1 -1
  196. package/providers/clarivate/src/capabilities.js +12 -12
  197. package/providers/clarivate/src/core.js +37 -43
  198. package/providers/corsearch/README.md +1 -1
  199. package/providers/corsearch/src/capabilities.js +5 -5
  200. package/providers/corsearch/src/core.js +3 -3
  201. package/providers/jx/README.md +2 -1
  202. package/providers/jx/src/turn-envelope.mjs +8 -3
  203. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  204. package/providers/oauth-mcp-bridge/package.json +1 -1
  205. package/providers/perplexity/src/core.js +1 -1
  206. package/providers/signa/README.md +1 -1
  207. package/providers/signa/src/capabilities.js +42 -49
  208. package/providers/signa/src/core.js +106 -29
  209. package/providers/uspto-local/README.md +1 -1
  210. package/providers/uspto-local/src/sync.js +1 -1
  211. package/scripts/README.md +1 -0
  212. package/scripts/ask-ai-render-check.mjs +127 -1
  213. package/scripts/authority-boundary-probe.mjs +8 -6
  214. package/scripts/backfill-started-at.mjs +2 -2
  215. package/scripts/census-merge-driver.mjs +33 -2
  216. package/scripts/citation-anchor-report.mjs +181 -0
  217. package/scripts/dead-names.mjs +1 -1
  218. package/scripts/deprecate-below.mjs +66 -8
  219. package/scripts/drain-preflight.mjs +1 -1
  220. package/scripts/e2e.mjs +174 -0
  221. package/scripts/env-audit.mjs +39 -6
  222. package/scripts/env-classify.mjs +20 -2
  223. package/scripts/freeze-example-run.mjs +61 -18
  224. package/scripts/generated-files-are-current.mjs +69 -4
  225. package/scripts/live-surface-check.mjs +124 -41
  226. package/scripts/markdown-link-check.mjs +1 -1
  227. package/scripts/merge-shape-check.mjs +242 -0
  228. package/scripts/mint-names-in-force.mjs +5 -5
  229. package/scripts/mint-offered-territories.mjs +72 -0
  230. package/scripts/mint-public-residue.mjs +2 -2
  231. package/scripts/mint-reference-strip-backlog.mjs +2 -2
  232. package/scripts/mint-suite-census.mjs +75 -2
  233. package/scripts/mint-writing-standard-backlog.mjs +2 -2
  234. package/scripts/purge-runs.mjs +7 -7
  235. package/scripts/reconcile-runs.mjs +2 -2
  236. package/scripts/release-approve-parked.mjs +20 -2
  237. package/scripts/release-await-cut.mjs +120 -1
  238. package/scripts/release-note-required.mjs +76 -8
  239. package/scripts/report-header-render-check.mjs +164 -0
  240. package/scripts/settings-render-check.mjs +75 -2
  241. package/scripts/test-full.mjs +96 -3
  242. package/scripts/test-run.mjs +10 -0
  243. package/shared/brand.mjs +27 -0
  244. package/shared/connect-clients.mjs +39 -11
  245. package/shared/deployment-box.mjs +7 -2
  246. package/shared/driver-dir.mjs +1 -1
  247. package/shared/env-aliases.mjs +1 -1
  248. package/shared/identifier-scan.mjs +65 -9
  249. package/shared/identifier-sentinels.mjs +22 -0
  250. package/shared/names-in-force.mjs +4 -2
  251. package/shared/offered-territories.json +738 -0
  252. package/shared/pre-rename-spellings.mjs +53 -0
  253. package/shared/reference-guard-classes.mjs +40 -2
  254. package/shared/stdio-connect.mjs +39 -4
  255. package/shared/tree-commit.mjs +48 -0
  256. /package/driver/skills/{prelim-register → clearance-register}/providers/euipo.md +0 -0
  257. /package/driver/skills/{prelim-register → clearance-register}/providers/free-tier.md +0 -0
  258. /package/driver/skills/{prelim-register → clearance-register}/providers/uspto-local.md +0 -0
  259. /package/driver/skills/{prelim-search → clearance-search}/field-doctrine-pharma.md +0 -0
  260. /package/driver/skills/{prelim-search → clearance-search}/firm-wide-reasoning.md +0 -0
  261. /package/driver/skills/{prelim-search → clearance-search}/report-prose.md +0 -0
  262. /package/driver/skills/{prelim-search → clearance-search}/risk-framework-demo.manifest.json +0 -0
  263. /package/driver/skills/{prelim-search → clearance-search}/risk-framework-demo.md +0 -0
  264. /package/driver/skills/{prelim-search → clearance-search}/risk-framework-triage.manifest.json +0 -0
  265. /package/driver/skills/{prelim-search → clearance-search}/risk-framework-triage.md +0 -0
  266. /package/driver/skills/{prelim-search → clearance-search}/risk-framework.manifest.json +0 -0
  267. /package/driver/skills/{prelim-search → clearance-search}/risk-framework.md +0 -0
  268. /package/driver/skills/{prelim-search → clearance-search}/template-formatting.md +0 -0
  269. /package/driver/skills/{prelim-search → clearance-search}/templates/email/generic.md +0 -0
  270. /package/driver/skills/{prelim-search → clearance-search}/templates/search-request-form.html +0 -0
  271. /package/driver/skills/{prelim-search → clearance-search}/worked-examples-demo.md +0 -0
  272. /package/driver/skills/{prelim-search → clearance-search}/worked-examples.md +0 -0
package/bin/onboard.mjs CHANGED
@@ -97,8 +97,9 @@ import {
97
97
  // does nothing at import time, so this is inert — the ONE sharp edge it carries (a module-level
98
98
  // REGISTER_PROVIDER frozen at first import) is the one `preflightCandidate` below already cache-busts
99
99
  // around, and it is cache-busted whether or not this static import happened first.
100
- import { config, ENGINE_BINARIES, DEFAULT_ENGINE_ID, RESEARCH_PROVIDERS, SERP_PROVIDERS } from "../driver/driver.config.mjs";
101
- import { resolveAuthMode } from "../driver/engine/auth.mjs";
100
+ import { config, ENGINE_BINARIES, DEFAULT_ENGINE_ID, RESEARCH_PROVIDERS, SERP_PROVIDERS, resolveEngineProgram, ON_A_WINDOWS_DRIVE,
101
+ enginesFolder, engineInstallArgs, engineInstallCommand } from "../driver/driver.config.mjs";
102
+ import { resolveAuthMode, CLOUD_SWITCH, CLOUD_SETTINGS, CLOUD_SECRETS, cloudsSwitchedOn } from "../driver/engine/auth.mjs";
102
103
  import { isInsideCheckout } from "../shared/inside-checkout.mjs"; // — one copy of the rule, and it is testable
103
104
  import { packagedBuild as sharedPackagedBuild } from "../shared/packaged-build.mjs"; // — one reader of build-info.json, reachable from the driver
104
105
  import { processTable } from "../shared/process-table.mjs"; // — /proc is not the only box
@@ -107,7 +108,8 @@ import { entrypointOf } from "../driver/systemd/install-census.mjs"; //
107
108
  import { overlayReport, renderOverlayReport } from "../shared/doctrine-overlay.mjs"; // — the doctor reports the overlay
108
109
  import { whereSavesGo, storeCommitRefusal, storeInRepo, storeOutsideRepoMessage, resolveStoreRepoRoot } from "../shared/store-in-repo.mjs"; // — doctor says where a portal save goes once it is committed, and why saved searches are off
109
110
  import { engineInventory, engineMode, ENGINE_MODES } from "../driver/config-inventory.mjs"; //
110
- import { probeEngineTurn, probeFailureText, PROBE_TIMEOUT_SEC, engineEnvKeys } from "../driver/engine/probe.mjs";
111
+ import { probeEngineTurn, probeFailureText, PROBE_TIMEOUT_SEC, engineEnvKeys, namingProgram } from "../driver/engine/probe.mjs";
112
+ import { probeCliVersion } from "../driver/engine/cli-version.mjs"; // the engine question reads each program's version the way a run records it
111
113
 
112
114
  // THE PROVING SENTENCES NAME NO MODEL. They printed the driver's tier word, which is an Anthropic model's
113
115
  // name, on both engines, so a codex user was told setup was about to spend on a model family they do not
@@ -116,7 +118,116 @@ export const probingLine = (engineId) =>
116
118
  `Probing ${engineId} with one turn on its cheapest model (this SPENDS; ${PROBE_TIMEOUT_SEC}s ceiling)…`;
117
119
  export const proveQuestion = ({ engineId, lane }) =>
118
120
  `Prove ${engineId} on the ${lane} lane now with one turn on its cheapest model (a few tokens, ${PROBE_TIMEOUT_SEC}s ceiling)?`;
119
- import { runRequiredNames, missingRequirements, REGISTER_ENV, ENGINE_ENV } from "../driver/run-requirements.mjs"; // the order-time gate's own question, asked here rather than restated
121
+
122
+ // HOW CLAUDE IS PAID FOR, IN THE OWNER'S WORDS (2026-09-14): one question, three answers, the third a cloud
123
+ // account. Codex keeps the question it had: a cloud account bills Claude only, and the resolver refuses
124
+ // `cloud` on Codex, so offering it there would offer an answer that cannot run.
125
+ export const CLAUDE_PAY_QUESTION = "How is Claude paid for on this machine?";
126
+ const CLAUDE_PAY_ANSWERS = Object.freeze([
127
+ { id: "subscription", label: "A Claude subscription (Pro, Max or Team): you sign in once" },
128
+ { id: "api-key", label: "An Anthropic API key: pay per use, paste the key" },
129
+ { id: "cloud", label: "Through your Google, Microsoft or Amazon cloud account: pay per use on that cloud's bill" },
130
+ ]);
131
+ // SETUP'S FIRST QUESTION, IN THE OWNER'S WORDS (2026-09-15). It asked which program does the reasoning,
132
+ // which is how this code thinks of an engine and not how a reader choosing one does.
133
+ export const ENGINE_QUESTION = "Which AI should run your searches?";
134
+ /**
135
+ * What setup says under the engine question's rows, about the question after it. Every answer
136
+ * `payQuestion` offers, for any engine, is named here, and the cloud answer as Claude's alone, so on a
137
+ * machine that has only Codex it still reads true. The chooser prints it after the rows and before the
138
+ * prompt, so it is the last thing read before answering.
139
+ */
140
+ export const PAY_PREAMBLE = Object.freeze([
141
+ " Next, setup asks how you pay for it: a subscription you sign in with, an API key,",
142
+ " or, for Claude, your own cloud account.",
143
+ ]);
144
+ /**
145
+ * What setup says when no engine is chosen, by the menu's last row or by any route out of the engine step
146
+ * (sayNoEngine): what still works, what does not, and what to do about it, in one sentence.
147
+ */
148
+ export const NO_AI_CHOSEN = "No AI chosen. The demo works without one; a real search needs one, so run setup again when you're ready.";
149
+ /**
150
+ * The screen setup's chooser prints for one question: the question, one numbered line per answer with the
151
+ * default marked, and any lines that belong under the answers (`after`). A function of its arguments alone,
152
+ * so the screen a reader sees can be asserted whole.
153
+ */
154
+ export function menuScreen(question, options, def = 0, after = []) {
155
+ return ["", ` ${question}`,
156
+ ...options.map((o, i) => ` ${i + 1}) ${o.label}${i === def ? " (default)" : ""}`),
157
+ ...(after.length ? ["", ...after] : [])];
158
+ }
159
+ /** The pay question setup asks for an engine, and its answers; each answer's id is a billing word. */
160
+ export function payQuestion({ engineId, eng, bin }) {
161
+ if (engineId === "anthropic-agent") return { question: CLAUDE_PAY_QUESTION, answers: CLAUDE_PAY_ANSWERS };
162
+ return {
163
+ question: `How is ${eng.product ?? engineId} paid for on this machine?`,
164
+ answers: [
165
+ { id: "subscription", label: `Subscription — ${namingProgram(eng.subscriptionHow, eng, bin)}` },
166
+ { id: "api-key", label: `API key — metered per token, from ${eng.apiKeyEnv}` },
167
+ ],
168
+ };
169
+ }
170
+
171
+ // THE THREE CLOUDS, what each is called where a reader sees it, and the least setup asks for each. The rest
172
+ // is a sign-in the machine already has (gcloud's, Azure's, AWS's), which the program finds by itself. Every
173
+ // name asked for is on auth.mjs's CLOUD_SETTINGS, so the proof turn and doctor carry it.
174
+ export const CLOUD_CHOICES = Object.freeze([
175
+ { id: "vertex", label: "Google Cloud (Vertex AI)", account: "your Google Cloud account (Vertex AI)",
176
+ note: "It uses the Google sign-in on this machine: gcloud's, or the service-account key GOOGLE_APPLICATION_CREDENTIALS names.",
177
+ asks: [
178
+ { env: "ANTHROPIC_VERTEX_PROJECT_ID", q: "Google Cloud project id:" },
179
+ { env: "CLOUD_ML_REGION", q: "Region your Claude quota is in:", def: "global" },
180
+ ] },
181
+ { id: "foundry", label: "Microsoft Azure (Foundry)", account: "your Microsoft Azure account (Foundry)",
182
+ note: "Foundry calls each model by the name of its deployment, so give the names you deployed them under.",
183
+ asks: [
184
+ { env: "ANTHROPIC_FOUNDRY_RESOURCE", q: "Foundry resource name:" },
185
+ { env: "ANTHROPIC_FOUNDRY_API_KEY", q: "Its key:", secret: true, skippable: true, skipped: "No key: the Azure sign-in on this machine is used." },
186
+ // SKIPPING IS SAFE ONLY UNDER ONE CONDITION, and the line says which. With no pin the program asks
187
+ // Foundry for a deployment named after the model, which resolves only if the reader deployed it under
188
+ // exactly that name; otherwise every turn of that tier is refused (the proof turn catches it).
189
+ { env: "ANTHROPIC_DEFAULT_OPUS_MODEL", q: "Your Opus deployment name:", skippable: true,
190
+ skipped: "Not set: the program asks for a deployment named after the model, which works only if you deployed it under that name." },
191
+ { env: "ANTHROPIC_DEFAULT_SONNET_MODEL", q: "Your Sonnet deployment name:", skippable: true,
192
+ skipped: "Not set: the program asks for a deployment named after the model, which works only if you deployed it under that name." },
193
+ { env: "ANTHROPIC_DEFAULT_HAIKU_MODEL", q: "Your Haiku deployment name:", skippable: true,
194
+ skipped: "Not set: the program asks for a deployment named after the model, which works only if you deployed it under that name." },
195
+ ] },
196
+ // AMAZON IS OFFERED AND MARKED, because nobody has run Claude through a Bedrock account with it yet. The
197
+ // mark is on the menu row only: doctor's account wording (`account`) names the account, not our testing.
198
+ { id: "bedrock", label: "Amazon Bedrock (not yet tested)", account: "your Amazon Bedrock account",
199
+ note: "It uses the AWS credentials on this machine: a profile, an instance role or the standard AWS variables.",
200
+ asks: [
201
+ { env: "AWS_REGION", q: "AWS region your Claude models are enabled in:" },
202
+ ] },
203
+ ]);
204
+
205
+ /**
206
+ * The settings a cloud answer writes, as a run reads them: the billing word, that cloud's switch, and each
207
+ * answer given. A blank answer writes nothing, so the program falls back to what the machine has. Pure, so a
208
+ * test can drive it through the resolver.
209
+ */
210
+ export function cloudSettings(cloud, answers = {}) {
211
+ const out = { CLEAROTRON_AI_BILLING: "cloud", [CLOUD_SWITCH[cloud]]: "1" };
212
+ for (const [k, v] of Object.entries(answers)) {
213
+ const t = String(v ?? "").trim();
214
+ if (t) out[k] = t;
215
+ }
216
+ return out;
217
+ }
218
+
219
+ /** A cloud setting as setup shows it after writing it: a secret (auth.mjs's CLOUD_SECRETS) as set, never its value. */
220
+ export const shownSetting = (k, v) => `${k}=${CLOUD_SECRETS.includes(k) ? "…" : v}`;
221
+
222
+ /** Whose bill a cloud billing mode charges, as doctor says it. */
223
+ export const cloudAccount = (cloud) =>
224
+ cloud === "gateway" ? "the gateway at ANTHROPIC_BASE_URL" : (CLOUD_CHOICES.find((c) => c.id === cloud)?.account ?? `the ${cloud} account`);
225
+
226
+ /** What a completed probe turn says served it, as the program reported; null when it named nothing. */
227
+ export const servedLine = (v) => (v?.served || v?.provider)
228
+ ? `served by ${v.served ?? "a model the program did not name"}${v.provider ? `; the program names its provider "${v.provider}"` : ""}.`
229
+ : null;
230
+ import { runRequiredNames, missingRequirements, billingRefusalWords, REGISTER_ENV, ENGINE_ENV } from "../driver/run-requirements.mjs"; // the order-time gate's own question, asked here rather than restated
120
231
  import { pinEnv, envFrom } from "../shared/env-aliases.mjs";
121
232
  import { isEntrypoint } from "../shared/is-entrypoint.mjs"; // — one entry-point test, all spellings
122
233
  // one synopsis reader for every verb that prints one.
@@ -679,44 +790,125 @@ export async function askSignIn(io, { localAccount = "user", existing = null } =
679
790
  * Was `resolveClaudeBin`. The body never had anything claude-specific in it; the NAME was the last place
680
791
  * this file still assumed one engine, and a name that lies is how the second adapter stayed invisible.
681
792
  */
682
- export function resolveEngineBin(bin, { env = process.env, wsl = null, onWindowsDrive = null } = {}) {
683
- // The predicate, not just the pattern, because /mnt/c cannot be created on a Linux runner without
684
- // root — so an arm that could only supply a PATH could never drive the skip, and the branch would
685
- // ship asserted by nobody. `ON_A_WINDOWS_DRIVE` is held to real paths by its own arm.
686
- const onDrive = onWindowsDrive ?? ((x) => ON_A_WINDOWS_DRIVE.test(x));
687
- // driver/engine/anthropic-agent.mjs — `CLEAROTRON_CLAUDE_PATH || "claude"`; openai-agent.mjs — the same
688
- // shape on `CLEAROTRON_CODEX_PATH || "codex"`. A RELATIVE path is the trap for BOTH: stage subprocesses are
689
- // spawned with cwd set to the RUN DIRECTORY (driver/engine/common.mjs resolveSpawnCwd, shared by the
690
- // two adapters), so a relative binary resolves against a directory that did not exist at setup time.
691
- const underWsl = wsl ?? isWsl({ env });
692
- if (bin.includes("/")) {
693
- const abs = resolve(bin);
694
- // A PATH SOMEBODY TYPED IS NOT OVERRULED, only reported. The reader stated this one, and silently
695
- // resolving somewhere else would be the launcher moving a door off a port that was asked for.
696
- return { path: abs, executable: isExec(abs), relative: !isAbsolute(bin), windowsShim: underWsl && onDrive(abs), skipped: [] };
697
- }
698
- // ── UNDER WSL, THE WINDOWS PATH IS APPENDED TO THIS ONE ────────────────────────────────────────
699
- //
700
- // So `claude` on a fresh WSL2 Ubuntu resolves to /mnt/c/…/claude — the WINDOWS shim — before any
701
- // Linux install is reached, and it is executable by every test this makes. It then fails the proof
702
- // turn as "not signed in", because the credential it is looking for is the Linux one, and a reader
703
- // is sent to fix a sign-in that was never the problem. Reported from a real WSL2 attempt.
793
+ export function resolveEngineBin(bin, { env = process.env, wsl = null, onWindowsDrive = null, engine = null, enginesDir = undefined } = {}) {
794
+ // A VIEW OVER THE DRIVER'S ONE RESOLVER (driver.config.mjs resolveEngineProgram), which every other
795
+ // reader asks too: the run door, the inventory the portal reads, and both adapters. This used to be a
796
+ // second PATH walk of its own, and it was the only one that knew to pass over a Windows copy under WSL;
797
+ // that skip now lives in the one resolver, so the run door and this command cannot disagree about it.
704
798
  //
705
- // SKIPPED, AND NAMED. Passing over a candidate silently would leave the reader with "no binary
706
- // found" on a machine where `which claude` prints one, so the skips travel back for the caller to
707
- // say out loud. A missing Linux install then reports as missing, which is the true answer.
708
- const skipped = [];
709
- for (const dir of (env.PATH || "").split(delimiter).filter(Boolean)) {
710
- const p = join(dir, bin);
711
- if (!isExec(p)) continue;
712
- if (underWsl && onDrive(p)) { skipped.push(p); continue; }
713
- return { path: p, executable: true, relative: false, windowsShim: false, skipped };
799
+ // `bin` is the candidate the wizard wants checked, a path or a bare name, and it is asked under the
800
+ // engine's own setting, so "the setting names this" and "this is what the engine would find" are one
801
+ // question. The engine's fallback word is the default, so `claude` asks PATH and then the copy
802
+ // Clearotron installed. `wsl` and `onWindowsDrive` stay injectable: /mnt/c cannot be created on a
803
+ // Linux runner without root, so an arm that could only supply a PATH could never drive the skip.
804
+ const id = engine ?? Object.keys(ENGINE_BINARIES).find((k) => ENGINE_BINARIES[k].fallback === bin) ?? DEFAULT_ENGINE_ID;
805
+ const spec = ENGINE_BINARIES[id];
806
+ const r = resolveEngineProgram(id, { env: { ...env, [spec.env]: bin }, wsl, onWindowsDrive, enginesDir });
807
+ const found = { source: r.source, version: r.version, rejected: r.rejected, skipped: r.skipped, windowsShim: r.windowsShim };
808
+ // A RELATIVE path is the trap for both adapters: stage subprocesses run with cwd set to the RUN
809
+ // DIRECTORY (driver/engine/common.mjs resolveSpawnCwd), so it resolves against a directory that did not
810
+ // exist at setup time. Reported with its absolute form from here, which is what the wizard then uses.
811
+ if (r.relative) { const abs = resolve(bin); return { path: abs, executable: isExec(abs), relative: true, ...found }; }
812
+ // A PATH SOMEBODY TYPED IS NOT OVERRULED, only reported: the resolver never falls through from it.
813
+ if (!r.resolved && r.explicit && bin.includes("/")) return { path: resolve(bin), executable: false, relative: false, ...found };
814
+ return { path: r.resolved, executable: Boolean(r.resolved), relative: false, ...found };
815
+ }
816
+
817
+ /** A path on a Windows drive as WSL mounts it: the driver's one definition. */
818
+ export { ON_A_WINDOWS_DRIVE };
819
+
820
+ /**
821
+ * An engine instruction rewritten to name the copy Clearotron installed, which is not on PATH. One copy of
822
+ * it, in engine/probe.mjs, because the probe's sign-in advice names that copy too; re-exported here.
823
+ */
824
+ export { namingProgram };
825
+
826
+ /**
827
+ * What setup says after a failed proof turn about the step it cannot take for the reader, by how the turn
828
+ * is paid for (`billing`, the billing word). On a subscription that is the sign-in, naming the copy that runs,
829
+ * and the route for a machine with no browser; `captureToken` says whether that route's token is offered for
830
+ * pasting. Under an API key or a cloud account there is nothing to sign in to, and the verdict above names
831
+ * what to check, so it says how to change what the turn ran on instead. A sign-in the subscription uses
832
+ * would not be used under either, so no token is offered there.
833
+ */
834
+ export function signInHandOff(eng, bin, billing) {
835
+ if (billing === "cloud") return { lines: ["if you fixed the cloud's sign-in on this machine, answer yes below; to change the answers "
836
+ + "above, answer no and pick the engine again."], captureToken: false };
837
+ if (billing === "api-key") return { lines: ["to give a different key, answer no below and pick the engine again."], captureToken: false };
838
+ const lines = [`if it is signed out: ${namingProgram(eng.signIn, eng, bin)}, then answer yes below.`];
839
+ // Codex's headless sign-in runs HERE, so it names the copy that runs here. Claude's token can be made on
840
+ // any machine, so its command keeps the bare word, and this machine's copy is named beside it.
841
+ if (eng.headless) {
842
+ lines.push(`on a machine you cannot complete a sign-in on: run \`${eng.headless.tokenEnv ? eng.headless.cmd : namingProgram(eng.headless.cmd, eng, bin)}\``
843
+ + `${eng.headless.tokenEnv ? ` (from any machine you can sign in on${bin?.source === "installed" ? `; on this one the program is ${bin.path}` : ""})` : " here"}.`);
714
844
  }
715
- return { path: null, executable: false, relative: false, windowsShim: false, skipped };
845
+ return { lines, captureToken: Boolean(eng.headless?.tokenEnv) };
846
+ }
847
+
848
+ /**
849
+ * What setup's proof turn is handed: the environment it runs in, with the program setting pinned to the
850
+ * absolute path of the copy found, so the turn runs exactly that copy; and that copy as setup found it, so
851
+ * the probe's advice names it as what it is. Pinned by path, the copy setup installed reads to the probe as
852
+ * a path the reader set, and a failed turn told the reader to run a bare `claude` or `codex login`, which
853
+ * that copy does not answer to, above lines naming the copy itself.
854
+ *
855
+ * THE CLOUD SETTINGS IN THE SETTINGS FILE RIDE WITH IT, as a run reads them. `settings` is what the file a
856
+ * search reads holds now (settingsInForce, below). The turn was built from the shell and the answers alone, so
857
+ * on an Amazon machine whose keys live only in that file it ran without them and failed while searches
858
+ * worked. Only the names on auth.mjs's CLOUD_SETTINGS are taken, in a run's order: a name the shell holds wins
859
+ * over the file, even when it is empty, because that is what the loader does (shared/env-local.mjs,
860
+ * loadEnvLocal); an answer given in setup wins over both, because it is written over the file.
861
+ */
862
+ export function proofTurn({ engineId, eng, bin, authEnv = {}, env = process.env, settings = {} }) {
863
+ const fromFile = Object.fromEntries(CLOUD_SETTINGS.filter((k) => settings[k] !== undefined).map((k) => [k, settings[k]]));
864
+ return { env: { ...fromFile, ...env, CLEAROTRON_AI: engineId, [eng.env]: bin.path, ...authEnv },
865
+ program: { source: bin.source ?? null, path: bin.path } };
866
+ }
867
+
868
+ /**
869
+ * What the settings file a search reads holds now: the file the loader resolves (activeEnvPath), which is
870
+ * the one at the old location on an install configured before the move, and not always the one setup writes.
871
+ *
872
+ * THE PROOF TURN READS THIS, NOT THE FILE SETUP WRITES. It read `ENV_PATH`, so on an install still configured
873
+ * at the old location the keys every search and doctor were using never reached setup's test turn. The write
874
+ * stays on `ENV_PATH` (see READ_ENV_PATH), and it carries only that file's lines: on such an install the file
875
+ * setup writes starts without the old one's, which a run then reads instead. That is the write's behaviour,
876
+ * not this read's. `repoRoot` and `home` are here so a test can drive an install at the old location without
877
+ * touching this checkout or the home it runs in.
878
+ */
879
+ export function settingsInForce({ repoRoot = REPO, home = homedir() } = {}) {
880
+ return readEnvFile(activeEnvPath({ repoRoot, home }), { home });
716
881
  }
717
882
 
718
- /** A path on a Windows drive as WSL mounts it. */
719
- export const ON_A_WINDOWS_DRIVE = /^\/mnt\/[a-z]\//i;
883
+ /**
884
+ * What setup writes into an engine's program setting for the copy it just proved: the absolute path of a
885
+ * copy found on PATH or given by path, because a service's PATH is not the shell's, and for the copy
886
+ * Clearotron installed the engine's own fallback word, which means exactly what unset means.
887
+ *
888
+ * WRITTEN, NOT LEFT OUT. The installed copy's path must never be written: it would become the explicit
889
+ * setting, and a copy the reader installs on this machine later would never be used. Leaving the setting
890
+ * out of the file is not enough either. Setup never reads the file it rewrites (it is on the NO_DOTFILE
891
+ * list), and composeEnvBody keeps every setting it did not collect, so a path an earlier setup wrote would
892
+ * survive the rewrite, and the run door, which never overrules a named path, would refuse every run on a
893
+ * program that has since gone. The fallback word replaces it.
894
+ */
895
+ export function engineProgramSetting(eng, bin) {
896
+ return bin?.source === "installed" ? eng.fallback : bin.path;
897
+ }
898
+
899
+ /**
900
+ * Why no copy of an engine's program can run, in one clause, for a wizard line that would otherwise
901
+ * report an absence: each copy found and refused with its reason, the setting that names a program that
902
+ * is not there, or that there is none. The vendor's placeholder is a copy that IS installed and cannot
903
+ * run, and its fix is a reinstall, not the vendor install this step offers next.
904
+ */
905
+ export function unusableEngineWords(eng, bin, setting = "") {
906
+ const set = String(setting ?? "").trim();
907
+ const named = set && set !== eng.fallback ? `${eng.env}="${set}"` : "";
908
+ if (bin?.rejected?.length) return `${named ? `${named} → ` : ""}${bin.rejected.map((x) => `${x.path} is ${x.why}`).join("; ")}`;
909
+ if (named) return `${named} names nothing on PATH that can run`;
910
+ return `no \`${eng.fallback}\` on PATH, and Clearotron has not installed one`;
911
+ }
720
912
 
721
913
  /** Whether this is a Linux running under Windows: the one answer, from shared/wsl.mjs. */
722
914
  export { isWsl };
@@ -757,40 +949,212 @@ export function platformEngineRefusal({ platform = process.platform } = {}) {
757
949
  * On the platform the run door refuses, the standard advice is a loop: install a CLI the reader may
758
950
  * already have, then restart a service — neither of which can change the answer, because the refusal
759
951
  * is about the platform rather than the program. Naming WSL2 is the only instruction that ends it.
952
+ *
953
+ * ELSEWHERE IT NAMES SETUP, WHICH DOES THE WORK NOW. This used to say to install the CLI by hand with
954
+ * `npm install -g`, then run `claude` to sign in, and to restart the engine service so it re-read its PATH.
955
+ * Setup offers to install the program itself, into a folder that is not on PATH, so the bare `claude` it
956
+ * named was a command the reader's shell does not have; and a run finds that copy without re-reading PATH.
957
+ * What a restart is still for is the settings setup writes: a running service reads them when it starts,
958
+ * and the portal reports what the engine saw then. No sign-in command is named here: no copy can run in
959
+ * demo mode, so none can be named, and setup names the one that signs in the copy it proves. `command` is
960
+ * the setup command as the reader can type it from here.
760
961
  */
761
- export function leaveDemoAdvice(engSpec, { platform = process.platform } = {}) {
962
+ export function leaveDemoAdvice(engSpec, { platform = process.platform, command = reachableCommand("install"),
963
+ startCommand = reachableCommand("start") } = {}) {
762
964
  if (platform === "win32") {
763
965
  return [`To leave demo on Windows: run the product under WSL2, or in the devcontainer. Installing `
764
966
  + `${engSpec.vendor}'s CLI natively will not change this — the run door refuses on the platform, `
765
967
  + "not on the program."];
766
968
  }
969
+ // BACKGROUND SERVICES ARE THE EXCEPTION to "restart them": they read `~/.env`, which setup does not write,
970
+ // and `start --background` only adds lines to it (see programDisagreement below, which says the same).
767
971
  return [
768
- `To leave demo: install ${engSpec.vendor}'s CLI (\`${engSpec.fallback}\`)`
769
- + `${engSpec.install ? ` with \`${engSpec.install}\`` : ""}, then ${engSpec.signIn}.`,
770
- "Restart any running engine service afterwards so it re-reads its PATH: the portal reports what the "
771
- + "engine saw when it last started, and it will not notice a new install until then.",
972
+ `To leave demo: run \`${command}\`. It offers to install ${engSpec.vendor}'s CLI if this machine has none, `
973
+ + "asks how it is paid for, and proves it with one turn; if the CLI is not signed in, it names the command that signs it in.",
974
+ "If Clearotron's services are already running, restart them afterwards: they read the settings setup writes "
975
+ + "when they start, and the portal reports what the engine saw when it last started. Background services "
976
+ + `read \`~/.env\` instead, which setup does not write, and \`${startCommand} --background\` adds to it only the `
977
+ + "lines it lacks, so change there any setting it already has.",
772
978
  ];
773
979
  }
774
980
 
981
+ /**
982
+ * What doctor says when the engine's capture and this machine disagree about whether the engine's program
983
+ * can be found. `capture` and `live` are the comparison's words, "found" or "not found" (flag-snapshot.mjs,
984
+ * postureDisagreement); `command` is the setup command as the reader can type it from here; `hosted` says
985
+ * whether the services are systemd units, `setting` names the engine's program setting, and `file` is the
986
+ * settings file the services read (doctor's serviceEnvFile).
987
+ *
988
+ * THE CAPTURE IS WRITTEN WHEN THE SERVICES START and at no other time: by `clearotron start`, whose children
989
+ * they are, and by the worker unit's ExecStartPost. So the sentence opens with what they recorded then, and
990
+ * "not found" means a restart makes them look again.
991
+ *
992
+ * A PROGRAM THIS MACHINE FINDS AND THEY STILL DO NOT is not on the PATH they run with, and what gets it to
993
+ * them depends on which file they read. Without units they are the children of `clearotron start`, which reads
994
+ * Clearotron's settings file when it starts, so setup is the remedy: it writes the full path of the program
995
+ * this shell finds there, or, when this shell finds none, offers to install a copy, which is found without
996
+ * PATH (resolveEngineProgram: the path setting, then PATH, then that copy). It does NOT install over a program
997
+ * it finds, so the install is not promised on its own. Units read `~/.env` instead, which setup does not
998
+ * write and `clearotron start --background` only adds names to, so there the remedy is the setting in that file.
999
+ *
1000
+ * This used to tell the reader to restart the engine service so it re-read its PATH, or to install the CLI
1001
+ * where the service could see it: words from before setup installed the program, given for both directions,
1002
+ * and one of them is a machine the services found the program on.
1003
+ */
1004
+ export function programDisagreement({ capture, live }, { command = reachableCommand("install"), hosted = false,
1005
+ setting = null, file = null } = {}) {
1006
+ const head = `When the services last started they recorded the engine program as ${capture}; this machine `
1007
+ + `reads it as ${live}. A NEW search will refuse while that is true.`;
1008
+ if (capture === "not found") {
1009
+ const remedy = hosted
1010
+ ? `set ${setting ?? "the engine's program setting"} to its full path in ${file ?? "the file they read"}, `
1011
+ + "which they read when they start, then restart them."
1012
+ : `run \`${command}\`: it writes the full path of the program this shell finds into Clearotron's settings, `
1013
+ + "or offers to install a copy found without PATH if this shell finds none. Then restart them.";
1014
+ return `${head} Restart them so they look again. If they still cannot find it, it is not on the PATH they `
1015
+ + `run with: ${remedy}`;
1016
+ }
1017
+ return `${head} If the program was removed, run \`${command}\` to install it again (the copy setup installs is `
1018
+ + "found without PATH), then restart the services so they look again.";
1019
+ }
1020
+
1021
+ /**
1022
+ * What an engine's install takes on disk, and how to take it back: the line setup's install offer says
1023
+ * right after it names the folder, and before it asks. A reader deciding whether to let a program onto
1024
+ * their machine is owed its size and its way off, and neither was said. The size is the registry's
1025
+ * measured figure (driver.config.mjs, `installMB`), kept beside the package it measures rather than in
1026
+ * this file. The removal is the folder, because the install is an npm project inside it and puts nothing
1027
+ * on PATH.
1028
+ */
1029
+ export function installSizeLine(eng) {
1030
+ return `${Number.isFinite(eng?.installMB) ? `It takes about ${eng.installMB} MB. ` : ""}To remove it, delete that folder.`;
1031
+ }
1032
+
775
1033
  /**
776
1034
  * The engine menu, built from the driver's registry so the wizard cannot offer an adapter that does not
777
1035
  * exist — or hide one that does. Same guarantee the register-provider list has.
778
1036
  *
779
1037
  * The last row is deliberately NOT an engine, and that is what makes the refusal above workable: a
780
- * reader whose CLI is signed out, or who only wants `npm run example`, has a stated route through setup
781
- * that does not end in a `.env` naming an engine nobody proved. `id: null` is that row; anything
1038
+ * reader whose CLI is signed out, or who only wants the demo, has a stated route through setup that does
1039
+ * not end in a `.env` naming an engine nobody proved. `id: null` is that row, "None for now"; what still
1040
+ * works without an engine is said after that choice (sayNoEngine), not crammed into the row. Anything
782
1041
  * asserting this list against the adapter registry must drop it first.
1042
+ *
1043
+ * EACH ROW NAMES THE AI AND ITS MAKER AND SAYS WHAT SETUP FOUND, in the words the owner approved on
1044
+ * 2026-09-15 (foundWords). `found` is engineMenuState's answer; without it the rows carry the names alone.
783
1045
  */
784
- export function engineOptions() {
1046
+ export function engineOptions(found = {}) {
1047
+ // The AI and its MAKER, not `label`: the labels are mechanism sentences (`claude -p`, `codex exec`) and
1048
+ // the menu is the first question a lawyer reads. The mechanism still appears in the confirmation lines
1049
+ // after a choice, where it belongs.
1050
+ const rows = Object.entries(ENGINE_BINARIES).map(([id, s]) => ({ id, name: `${s.product}, by ${s.vendor}`, state: foundWords(s, found[id]) }));
1051
+ const width = Math.max(...rows.map((r) => r.name.length));
785
1052
  return [
786
- // The VENDOR and plain words, not `label` — the labels are mechanism sentences (`claude -p`,
787
- // `codex exec`) and the menu is the first question a lawyer reads. The
788
- // mechanism still appears in the confirmation lines after a choice, where it belongs.
789
- ...Object.entries(ENGINE_BINARIES).map(([id, s]) => ({ id, label: `${s.vendor} — uses its \`${s.fallback}\` program on this machine` })),
790
- { id: null, label: "Neither yet — configure no engine (`npm run example` needs none; a real run will refuse)" },
1053
+ ...rows.map(({ id, name, state }) => ({ id, label: state ? `${name.padEnd(width)} ${state}` : name })),
1054
+ { id: null, label: "None for now" },
791
1055
  ];
792
1056
  }
793
1057
 
1058
+ // A COPY REFUSED AS THE VENDOR'S PLACEHOLDER, told apart by the resolver's own reason for refusing it
1059
+ // (driver.config.mjs engineCandidate). setup-asks-which-ai-runs-your-searches.test.mjs creates a real
1060
+ // placeholder and reads the row, so a reworded reason turns that test red instead of this row wrong.
1061
+ const isPlaceholder = (x) => /^the placeholder /.test(String(x?.why ?? ""));
1062
+
1063
+ /** An engine's program setting when it names a program; "" when it is unset or the default word. */
1064
+ function namedSetting(eng, setting) {
1065
+ const set = String(setting ?? "").trim();
1066
+ return set && set !== eng.fallback ? set : "";
1067
+ }
1068
+
1069
+ /**
1070
+ * Why no copy of an engine's program can run, as one of three kinds, or null when nothing was refused and
1071
+ * no setting names a program. The menu row and the line after a pick both ask this, so they cannot
1072
+ * disagree. THE PLACEHOLDER COMES FIRST, even where a setting names it: that copy is there, so "isn't
1073
+ * there" would be false, and what mends it is a working copy.
1074
+ */
1075
+ function programProblem(bin, named) {
1076
+ const placeholder = bin?.rejected?.find(isPlaceholder);
1077
+ if (placeholder) return { kind: "incomplete", path: placeholder.path };
1078
+ if (named || bin?.relative) return { kind: "setting" };
1079
+ if (bin?.rejected?.length) return { kind: "refused", path: bin.rejected[0].path };
1080
+ return null;
1081
+ }
1082
+
1083
+ /**
1084
+ * What setup found of one engine's program, as its menu row says it; "" when nothing was looked for. A copy
1085
+ * that cannot run is a problem, not an absence, and installing another is not always the fix, so the row
1086
+ * says which problem and that choosing it shows the fix. Setup offers an install only where the engine has
1087
+ * a package to install.
1088
+ */
1089
+ export function foundWords(eng, bin) {
1090
+ if (!bin) return "";
1091
+ if (bin.executable && !bin.relative) return bin.version ? `found on this computer (version ${bin.version})` : "found on this computer";
1092
+ const p = programProblem(bin, bin.explicit);
1093
+ if (p?.kind === "incomplete") return `problem: the copy of ${eng.product} here is incomplete and won't run — choose it to see the fix`;
1094
+ if (p?.kind === "setting") return `problem: this computer is set to use a copy of ${eng.product} that isn't there — choose it to see the fix`;
1095
+ if (p?.kind === "refused") return `problem: the copy of ${eng.product} here won't run — choose it to see the fix`;
1096
+ return eng.package ? "not on this computer — setup can install it" : "not on this computer";
1097
+ }
1098
+
1099
+ /**
1100
+ * What setup says once an engine whose copy cannot run is chosen, in the words approved with its row. The
1101
+ * path in the setting case is the one the setting names, as it names it. A copy refused for another reason,
1102
+ * or nothing found and nothing named, has no approved sentence and keeps unusableEngineWords' clause.
1103
+ * NO SETTING IS NAMED HERE: the one line that names it is ownCopyLine, said if the install is declined.
1104
+ */
1105
+ export function cannotRunLine(eng, bin, setting = "") {
1106
+ const set = namedSetting(eng, setting);
1107
+ const p = programProblem(bin, set);
1108
+ if (p?.kind === "incomplete") return `The copy of ${eng.product} at ${p.path} is incomplete: its installation stopped before the program was added. Setup can install a working copy.`;
1109
+ if (p?.kind === "setting") return `This computer is set to use ${eng.product} at ${set}, and nothing there can run. Setup can install ${eng.product} and use that instead.`;
1110
+ return `${unusableEngineWords(eng, bin, setting)}.`;
1111
+ }
1112
+
1113
+ /** What setup says when its install offer is declined with a setting in force: how to use one's own copy. */
1114
+ export function ownCopyLine(eng) {
1115
+ return `To use your own copy of ${eng.product} instead, change ${eng.env} to its full path, or give that path at the next question.`;
1116
+ }
1117
+
1118
+ /**
1119
+ * What this machine has of each engine's program, for the engine question: resolved the way a run
1120
+ * resolves it (the explicit path setting, then PATH, then the copy setup installed), and the version of
1121
+ * whatever was found. The copy setup installed says its version in its own package.json, and so does one
1122
+ * npm put on PATH; anything else is asked with `--version`, the same short, time-limited call a run makes
1123
+ * to record the tool that served it (driver/engine/cli-version.mjs). That call starts no session and
1124
+ * needs no network; one that fails or times out leaves the version null, and the row then says "found
1125
+ * on this computer". `readVersion` is injectable so a test can drive the unreadable branch.
1126
+ *
1127
+ * SHORTER THAN A RUN'S CALL, AND ONLY A VERSION IS SHOWN. The question waits on these calls, one program
1128
+ * after another, before it prints, and a run's five-second limit let two programs that hang hold the
1129
+ * screen for ten; two seconds is a judgement, ample for these programs to print a version and short
1130
+ * enough to wait on. The answers are kept apart from the run's own record of the tool, so a call cut
1131
+ * short here is not what a run reads. And a program that answers in prose, which a run records as said,
1132
+ * put its whole first line in the menu row; the row shows a version only when the answer is shaped like
1133
+ * one, and otherwise says "found on this computer".
1134
+ *
1135
+ * A SETTING IN FORCE IS ASKED THE WAY THE LINE AFTER A PICK ASKS IT (namedSetting): trimmed, and the
1136
+ * engine's default word counts as unset, as the resolver counts it. A setting of spaces alone made the
1137
+ * row say the setting named a copy that isn't there while the resolver and that line treated it as unset.
1138
+ */
1139
+ const MENU_VERSION_TIMEOUT_MS = 2000;
1140
+ const MENU_VERSIONS = new Map();
1141
+ const menuVersion = (p) => probeCliVersion(p, { timeoutMs: MENU_VERSION_TIMEOUT_MS, cache: MENU_VERSIONS }).version;
1142
+ export function engineMenuState({ env = process.env, enginesDir = undefined, readVersion = menuVersion } = {}) {
1143
+ const out = {};
1144
+ for (const [id, e] of Object.entries(ENGINE_BINARIES)) {
1145
+ const set = env[e.env];
1146
+ const bin = resolveEngineBin(set || e.fallback, { env, engine: id, enginesDir });
1147
+ const usable = bin.executable && !bin.relative;
1148
+ let version = bin.version;
1149
+ if (usable && !version) {
1150
+ try { version = readVersion(bin.path) ?? null; } catch { version = null; }
1151
+ if (!/^\d+\.\d+/.test(String(version ?? ""))) version = null;
1152
+ }
1153
+ out[id] = { ...bin, version, explicit: Boolean(namedSetting(e, set)) };
1154
+ }
1155
+ return out;
1156
+ }
1157
+
794
1158
  /**
795
1159
  * What `<repo>/.env` would give a run, read THROUGH THE ENGINE'S OWN LOADER.
796
1160
  *
@@ -1333,6 +1697,15 @@ export async function runCheck() {
1333
1697
  }
1334
1698
  return null;
1335
1699
  };
1700
+ // WHAT THE SERVICES THEMSELVES READ, for the two judgements that decide whether a search runs: how they
1701
+ // pay, and "Will a search run?". On a machine with units, that is their file and nothing else: they run
1702
+ // with CLEAROTRON_NO_ENV_FILE=1, so doctor's shell never reaches them. Layered as effectiveForService
1703
+ // layers it, a cloud switch this shell exported for its own use was judged the services' own, and doctor
1704
+ // reported "a search is refused" on services that ran; a billing word here asked them for a key they did
1705
+ // not need. With no units, the services are the children of `clearotron start` and inherit its shell, so
1706
+ // effectiveForService is the true answer there.
1707
+ const servicesRead = (k) => (!hosted ? effectiveForService(k)
1708
+ : serviceFileEnv && present(serviceFileEnv[k]) ? { v: serviceFileEnv[k], from: serviceEnvLabel, name: k } : null);
1336
1709
 
1337
1710
  say("\n Engine");
1338
1711
  // This block used to read CLEAROTRON_CLAUDE_PATH unconditionally, under the heading "Engine binary", on a
@@ -1356,9 +1729,17 @@ export async function runCheck() {
1356
1729
  // — through `effective`, so the engine binary is found under whichever spelling is set. Read
1357
1730
  // literally, this reported a configured binary as missing on any install the wizard had written.
1358
1731
  const binEff = effective(engSpec.env, [engSpec.env]);
1359
- const binSet = !!binEff;
1732
+ // The engine's own fallback word is the default spelled out (resolveEngineProgram), so a setting of
1733
+ // `claude` is not a configuration that can be wrong: it asks PATH, then the copy Clearotron installed.
1734
+ const binSet = !!binEff && String(binEff.v).trim() !== engSpec.fallback;
1360
1735
  const binSetting = binEff?.v || engSpec.fallback;
1361
- const bin = resolveEngineBin(binSetting);
1736
+ const bin = resolveEngineBin(binSetting, { engine: engineId });
1737
+ // WHICH COPY, AND ITS VERSION, because the machine's own install and the one Clearotron installed
1738
+ // are both legitimate and behave differently: the first updates itself, the second moves with
1739
+ // `clearotron update`. The version comes from the copy's own package.json when npm installed it;
1740
+ // doctor spawns nothing to ask (a vendor's own native installer leaves no package.json to read).
1741
+ const copyWords = (b) => `${b.source === "installed" ? "the copy Clearotron installed"
1742
+ : b.source === "explicit" ? `set in ${engSpec.env}` : "on PATH"}${b.version ? `, version ${b.version}` : ", version not read"}`;
1362
1743
  // ── NATIVE WINDOWS IS ANSWERED HERE, BEFORE ANY PATH IS RESOLVED OR REPORTED ──────────────────
1363
1744
  //
1364
1745
  // `resolveEngineBin` tests a candidate with `accessSync(X_OK)` and `isFile()`. Windows has no
@@ -1380,17 +1761,22 @@ export async function runCheck() {
1380
1761
  // and needs no engine, which is why four reports published on that same Windows box.
1381
1762
  const platformRefusal = platformEngineRefusal();
1382
1763
  if (platformRefusal) problem(platformRefusal);
1383
- else if (bin.executable && !bin.relative) ok(`${bin.path}`);
1764
+ else if (bin.executable && !bin.relative) ok(`${bin.path} — ${copyWords(bin)}`);
1765
+ // A copy that is there and cannot run is a broken install, not an absence: the vendor's placeholder
1766
+ // left by an install that skipped its step, most often. Named with the reason and the fix.
1767
+ else if (!binSet && bin.rejected?.length) problem(`no usable \`${engSpec.fallback}\`: ${bin.rejected.map((x) => `${x.path} is ${x.why}`).join("; ")}`);
1384
1768
  // The FACT only. It used to carry "install it for a real run (`npm run example` needs no engine)",
1385
1769
  // which is the absence framing was filed about — and it now says half of what the MODE line
1386
1770
  // below says, in worse words. One statement of a state, in the place that states states.
1387
- else if (!binSet) info(`no \`${engSpec.fallback}\` on PATH`);
1771
+ else if (!binSet) info(`no \`${engSpec.fallback}\` on PATH, and Clearotron has not installed one`);
1388
1772
  // Relative is reported BEFORE not-executable. A relative path that also does not resolve from the
1389
1773
  // current directory would otherwise be reported as a missing file, sending the reader to check the
1390
1774
  // file — and the file is usually fine. The relativity is the defect.
1391
1775
  else if (bin.relative) problem(`${engSpec.env}="${binSetting}" is RELATIVE — stage subprocesses run with cwd set to the run directory, so it will not resolve there. Use an absolute path (${bin.path} from here)`);
1392
1776
  else if (!bin.path) problem(`${engSpec.env}="${binSetting}" resolves to nothing on PATH`);
1393
- else problem(`${engSpec.env}="${binSetting}" → ${bin.path} is not an executable file`);
1777
+ // The resolver's reason, not a fixed phrase: the vendor's placeholder IS an executable file, and "not an
1778
+ // executable file" sent the reader to check a permission that was fine.
1779
+ else problem(`${engSpec.env}="${binSetting}" → ${bin.path} is ${bin.rejected?.[0]?.why ?? "not an executable file"}`);
1394
1780
  // SAID AFTER THE CHAIN ABOVE, AND OUTSIDE IT. This block is one if/else-if ladder, so a statement
1395
1781
  // placed between two of its clauses re-parents every clause below onto the new `if` — measured:
1396
1782
  // it made doctor report an executable mock binary as "not an executable file", because the ladder's
@@ -1440,8 +1826,11 @@ export async function runCheck() {
1440
1826
  // platform rather than the binary. Naming WSL2 is the only instruction that ends this state.
1441
1827
  for (const line of leaveDemoAdvice(engSpec)) info(line);
1442
1828
  } else {
1829
+ // THE COMMAND THE READER CAN TYPE FROM HERE, as the demo line above names its own. A bare
1830
+ // `clearotron` is not on PATH for an install run from npx, and now that the engine program comes
1831
+ // with the install this is the line most installs see first.
1443
1832
  info("The engine program is installed. Whether it is signed in cannot be read from disk: run "
1444
- + "clearotron doctor --probe-engine to find out.");
1833
+ + `\`${reachableCommand("doctor --probe-engine")}\` to find out.`);
1445
1834
  }
1446
1835
 
1447
1836
  // AND WHETHER THE ENGINE AGREES, which is a different question from the one above and the reason an
@@ -1461,12 +1850,7 @@ export async function runCheck() {
1461
1850
  // NULL IS NOT AGREEMENT and neither is an empty pool — a box with no capture has nothing to
1462
1851
  // disagree with, and saying so beats printing a clean bill nobody measured.
1463
1852
  const clash = (rows ?? []).find((r) => r.what === "engine program");
1464
- if (clash) {
1465
- problem(`The engine that last ran and this machine disagree about the engine program: the last run `
1466
- + `recorded it as ${clash.capture}, this machine reads it as ${clash.live}. A NEW search will `
1467
- + `refuse while that is true. Restart the engine service so it re-reads its PATH, or install the `
1468
- + `CLI where the service can see it.`);
1469
- }
1853
+ if (clash) problem(programDisagreement(clash, { hosted, setting: engSpec?.env, file: serviceEnvFile }));
1470
1854
  } catch (e) {
1471
1855
  // WHAT ACTUALLY REACHES THIS CATCH, established by driving it rather than by reading it.
1472
1856
  //
@@ -1494,16 +1878,49 @@ export async function runCheck() {
1494
1878
  // here as the problem it is: that .env cannot run a stage, and finding out at `--check` is the
1495
1879
  // entire point of the command.
1496
1880
  try {
1497
- // Same construction as the probe env below: the environment as a RUN would see it, with the .env's
1498
- // values overlaid for exactly the keys this answer depends on and no others.
1881
+ // The same construction as the probe env below, from the same list: the environment as a RUN would
1882
+ // see it, with the .env's values overlaid for the keys an engine spawn is made of. The cloud's names
1883
+ // are on it, because with the billing word read from the file and a cloud's switch left to the shell,
1884
+ // a cloud file that runs would read as refused.
1499
1885
  const envForResolve = { ...process.env };
1500
- for (const k of [engSpec.authEnv, engSpec.apiKeyEnv]) {
1501
- const e = k ? effective(k) : null;
1886
+ for (const k of engineEnvKeys()) {
1887
+ const e = effective(k);
1502
1888
  if (e) envForResolve[k] = e.v;
1503
1889
  }
1504
- const auth = resolveAuthMode({ engineName: engineId, env: envForResolve });
1505
- if (auth.mode === "unknown") info(`billing: no policy for ${engineId} — this engine declares no sign-in modes`);
1506
- else ok(`billing: ${auth.mode}${auth.apiBilled ? ` — charged per token against ${engSpec.apiKeyEnv}` : " — charged to the signed-in subscription, not per token"}`);
1890
+ const billingOf = (eng, env) => {
1891
+ try {
1892
+ const auth = resolveAuthMode({ engineName: eng, env });
1893
+ if (auth.mode === "unknown") return { say: info, text: `billing: no policy for ${eng} — this engine declares no sign-in modes` };
1894
+ if (auth.mode === "cloud") return { say: ok, text: `billing: cloud — charged per use ${auth.cloud === "gateway" ? "through" : "to"} ${cloudAccount(auth.cloud)}` };
1895
+ return { say: ok, text: `billing: ${auth.mode}${auth.apiBilled ? ` — charged per token against ${ENGINE_BINARIES[eng]?.apiKeyEnv ?? engSpec.apiKeyEnv}` : " — charged to the signed-in subscription, not per token"}` };
1896
+ // A word that is not a billing mode is quoted by the run door, and a key pasted there by mistake is
1897
+ // that word: said by name here, as the order-time reason says it.
1898
+ } catch (e) { return { say: problem, text: billingRefusalWords(String(e?.message ?? e)) }; }
1899
+ };
1900
+ const here = billingOf(engineId, envForResolve);
1901
+ here.say(here.text);
1902
+ // AND AS THE SERVICES READ IT, when this machine runs them and that reading differs. The line above is
1903
+ // this command's configuration; the services read their own file, and a start never replaces a line
1904
+ // in it. So a machine whose services pay through a cloud account printed "billing: subscription" here,
1905
+ // two sections above "Will a search run?" reading the services' file. Both are said when they differ.
1906
+ // Read as the services read it (servicesRead), never through this shell.
1907
+ //
1908
+ // A CAUTION ONLY WHEN THIS COMMAND SAYS OTHERWISE. With no billing word in this command's configuration,
1909
+ // the line above is the default and contradicts nothing, and a working api-key or cloud install whose
1910
+ // billing lives in the services' file alone was cautioned on every run from a bare shell. Then it is
1911
+ // information. Never a problem: the services' refusal, if there is one, is reported under that section.
1912
+ if (hosted && serviceKnown) {
1913
+ const envForService = {};
1914
+ for (const k of engineEnvKeys()) {
1915
+ const e = servicesRead(k);
1916
+ if (e) envForService[k] = e.v;
1917
+ }
1918
+ const svc = billingOf(String(envForService.CLEAROTRON_AI ?? DEFAULT_ENGINE_ID).trim().toLowerCase(), envForService);
1919
+ if (svc.text !== here.text) {
1920
+ if (effective(engSpec?.authEnv ?? "CLEAROTRON_AI_BILLING")) warn(`the services read how they pay from ${serviceEnvLabel}, and it says otherwise — ${svc.text}`);
1921
+ else info(`the services pay as ${serviceEnvLabel} says — ${svc.text}`);
1922
+ }
1923
+ }
1507
1924
  } catch (e) {
1508
1925
  problem(String(e?.message ?? e));
1509
1926
  }
@@ -1541,6 +1958,8 @@ export async function runCheck() {
1541
1958
  const v = await probeEngineTurn({ env: probeEnv });
1542
1959
  if (v.ok) {
1543
1960
  ok(`${engineId} completed a turn — binary, credential and model access all work`);
1961
+ const served = servedLine(v);
1962
+ if (served) info(served);
1544
1963
  // The ONLY route to READY. Everything else in this block reads the filesystem, and a signed-out
1545
1964
  // CLI passes every filesystem test there is — which is why this line is here and not above.
1546
1965
  ok("MODE: engine ready — proven by the turn just spent, not inferred from a file being executable.");
@@ -2076,12 +2495,28 @@ export async function runCheck() {
2076
2495
  if (!serviceKnown) {
2077
2496
  info("the units' environment could not be read, so what a search would be refused for is NOT checked here — a failure to look is not a clean result");
2078
2497
  } else {
2079
- const tables = { registers: PROVIDERS, engines: ENGINE_BINARIES, defaultEngine: DEFAULT_ENGINE_ID };
2080
- // Two passes: which credentials a run needs depends on the register and the engine it names.
2498
+ const tables = { registers: PROVIDERS, engines: ENGINE_BINARIES, defaultEngine: DEFAULT_ENGINE_ID, resolveEngine: resolveEngineProgram };
2499
+ // UNTIL NOTHING NEW IS NAMED: which names a run needs depends on values read in the pass before. The
2500
+ // register and the engine name their credentials, their program and the billing word; a billing word of
2501
+ // `cloud` names the cloud switches and the gateway address; a switch found becomes the blocking row, and
2502
+ // the cloud's other settings are named only where the view already holds them. Two passes stopped before
2503
+ // the billing word was read, so a machine that pays through a cloud account was checked as a subscription
2504
+ // one and its missing switch went unreported. It stops at the first pass that finds no new value: the names
2505
+ // asked for depend only on the values found, so the next pass would ask for the names this one just read.
2081
2506
  const view = {};
2082
- const fill = (names) => { for (const n of names) { const e = effectiveForService(n); if (e) view[n] = e.v; } };
2507
+ // Read as the services read it (servicesRead): a value in this shell never reaches a unit.
2508
+ const fill = (names) => { for (const n of names) { const e = servicesRead(n); if (e) view[n] = e.v; } };
2083
2509
  fill([REGISTER_ENV, ENGINE_ENV]);
2084
- fill(runRequiredNames(view, tables));
2510
+ let before;
2511
+ do { before = Object.keys(view).length; fill(runRequiredNames(view, tables)); } while (Object.keys(view).length > before);
2512
+ // AND THE PATH THE SERVICES SEARCH, so the engine's program is looked for where a run looks for it.
2513
+ // The view held the settings and no PATH, so a `claude` on the services' PATH, with no path setting and
2514
+ // no copy installed by setup, was reported as a search refused while the run found it and ran. On a
2515
+ // machine with units that PATH is the units' own (every unit sets it, with `%h` for the home, which the
2516
+ // unit reader expands); never this shell's, which the units do not inherit. With no units, the
2517
+ // services are the children of `clearotron start`, which inherit the PATH of the shell it runs in.
2518
+ const servicesPath = hosted ? unitValue(unitEnv, "PATH").value : process.env.PATH;
2519
+ if (servicesPath) view.PATH = servicesPath;
2085
2520
  const { atOrder } = missingRequirements(view, tables);
2086
2521
  if (atOrder.length) {
2087
2522
  blocking(`a search is refused until ${atOrder.length === 1 ? "this is" : "these are"} set in ${serviceEnvLabel}: ${atOrder.map((r) => r.name).join(", ")}`);
@@ -3223,12 +3658,10 @@ const askValue = async (q, { def = "", secret = false, skippable = false, skippe
3223
3658
  * land here, and two copies would drift the moment one of them was reworded.
3224
3659
  */
3225
3660
  const sayNoEngine = () => {
3226
- info("No engine configured, and nothing engine-related will be written.");
3227
- info(`\`${invoke("demo")}\` needs none. A real run refuses at its own door until one is set — re-run setup then.`);
3661
+ info(NO_AI_CHOSEN);
3228
3662
  };
3229
- const choose = async (q, options, def = 0) => {
3230
- say(`\n ${q}`);
3231
- options.forEach((o, i) => say(` ${i + 1}) ${o.label}${i === def ? " (default)" : ""}`));
3663
+ const choose = async (q, options, def = 0, after = []) => {
3664
+ for (const line of menuScreen(q, options, def, after)) say(line);
3232
3665
  for (;;) {
3233
3666
  const a = await askRaw(` 1-${options.length} [${def + 1}] `);
3234
3667
  const n = a === "" ? def + 1 : Number(a);
@@ -3260,10 +3693,11 @@ try {
3260
3693
  mark: bracketAsciiCells(), columns: process.stdout.columns }));
3261
3694
  say("");
3262
3695
  say(` ${style.bold("Before you start")} — what this setup can take, so nothing here surprises you:`);
3263
- // `vendor`, not `label`: the labels are engineer sentences carrying flag names, and a question a
3264
- // lawyer reads may not (the first rule).
3265
- say(` · Which AI runs the searches (${Object.values(ENGINE_BINARIES).map((e) => e.vendor).join(" or ")}),`);
3266
- say(" and how it bills — the subscription you already sign in with, or an API key.");
3696
+ // `product`, not `label`: the labels are engineer sentences carrying flag names, and a question a
3697
+ // lawyer reads may not (the first rule). The AI by the name the engine question gives it.
3698
+ say(` · Which AI runs the searches (${Object.values(ENGINE_BINARIES).map((e) => e.product).join(" or ")}),`);
3699
+ say(" and how it is paid for: a subscription you sign in with, an API key, or, for Claude,");
3700
+ say(" your own cloud account.");
3267
3701
  say(" · Your trademark register vendor's credential, if you have one (a register can be chosen later).");
3268
3702
  for (const table of [RESEARCH_PROVIDERS, SERP_PROVIDERS]) {
3269
3703
  for (const a of Object.values(table)) {
@@ -3287,26 +3721,24 @@ try {
3287
3721
  // This step used to resolve `claude` and nothing else, then write CLEAROTRON_AI=anthropic-agent five
3288
3722
  // steps later without ever asking. The driver has shipped a second adapter the whole time.
3289
3723
  say("\n Engine");
3290
- say(" The reasoning stages run as headless turns of a coding CLI. The choice is INSTALL-WIDE: one");
3291
- say(" engine serves every stage of every run on this box, so it is not a per-job setting.");
3724
+ say(" Clearotron runs each step of a search as a short, unattended session of an AI. One AI serves");
3725
+ say(" every search on this computer, so it is not chosen per search.");
3292
3726
  engine: for (;;) {
3293
3727
  // ── THE STATE FIRST, THE QUESTIONS OFF IT ──────────────────────────────
3294
3728
  //
3295
3729
  // The wizard used to ask which engine and how it bills, and only then discover the box could not
3296
3730
  // complete a sign-in — headless over SSH, the owner's own dead end. What is detectable is said
3297
- // before anything is asked: the binary, and the credentials already in the environment. Sign-in
3298
- // state itself is deliberately NOT guessed — the proof turn is the only honest answer to it, and
3299
- // a guessed "signed in" that the turn then contradicts costs more than no claim.
3300
- say("\n What this box already has:");
3301
- for (const [id, e] of Object.entries(ENGINE_BINARIES)) {
3302
- const b = resolveEngineBin(process.env[e.env] || e.fallback);
3303
- const creds = [e.apiKeyEnv, e.headless?.tokenEnv].filter((n) => n && present(process.env[n]));
3304
- say(` ${e.vendor}: ${b.executable ? `CLI found (${b.path})` : "no CLI on PATH"}${creds.length ? ` · ${creds.join(" and ")} already set` : ""}`);
3305
- }
3306
- say(" Choosing an engine also chooses how it bills — the subscription you sign in with, or an");
3307
- say(" API key. That question comes right after this one.");
3308
-
3309
- const pick = await choose("Which engine runs the reasoning stages?", engineOptions(), 0);
3731
+ // before anything is asked, ON THE QUESTION'S OWN ROWS: each says what setup found of that program
3732
+ // (foundWords). A block above the question used to say it a second time, with each program's path,
3733
+ // where it was found and any API key already set; the path is said once a program is chosen, and a
3734
+ // key already set is said at the pay question, where it makes the key the default and is either
3735
+ // adopted or named as unused. A sign-in token already set, which that block named and never adopted,
3736
+ // is no longer said before the proof turn, which is still handed it with the rest of the shell
3737
+ // (proofTurn). Sign-in state itself is deliberately NOT guessed — the proof turn is the
3738
+ // only honest answer to it, and a guessed "signed in" that the turn then contradicts costs more than
3739
+ // no claim. Resolved once per pass, so a reader who mends something and comes back sees it mended.
3740
+ const found = engineMenuState();
3741
+ const pick = await choose(ENGINE_QUESTION, engineOptions(found), 0, PAY_PREAMBLE);
3310
3742
  if (!pick.id) { sayNoEngine(); break; }
3311
3743
  const eng = ENGINE_BINARIES[pick.id];
3312
3744
 
@@ -3330,108 +3762,63 @@ try {
3330
3762
  continue;
3331
3763
  }
3332
3764
 
3333
- let bin = resolveEngineBin(process.env[eng.env] || eng.fallback);
3765
+ let bin = resolveEngineBin(process.env[eng.env] || eng.fallback, { engine: pick.id });
3334
3766
  // Under WSL the Windows build on the appended PATH has been passed over. Said before the install
3335
3767
  // offer below, because otherwise a reader whose Linux install is genuinely missing is asked to
3336
3768
  // install a binary their own `which` already prints — and would decline for the wrong reason.
3337
3769
  const shimNote = windowsShimNote(bin.skipped, eng.fallback);
3338
3770
  if (shimNote) info(shimNote);
3339
- if (!(bin.executable && !bin.relative) && eng.install) {
3340
- // ── — INSTALLING IT IS ONE COMMAND, AND WE USED TO STOP AT A SENTENCE ───────────────────
3771
+ if (!(bin.executable && !bin.relative) && eng.package) {
3772
+ // ── SETUP INSTALLS THE ONE PROGRAM THIS ENGINE RUNS ─────────────────────────────────────────────
3773
+ //
3774
+ // Signing in is a browser round-trip nobody here can perform for someone. Installing the program
3775
+ // is not, and the whole sequence (install, hand off to the vendor's own login, prove it with a
3776
+ // turn) is work this file already does either side of the gap.
3341
3777
  //
3342
- // Signing in is a browser round-trip nobody here can perform for someone. Installing the binary
3343
- // is not, and the whole sequence — install, hand off to the vendor's own login, prove it with a
3344
- // turn — is work this file already does either side of the gap.
3778
+ // NOTHING IS BUNDLED INTO THE PACKAGE. Installing every engine for everyone, before anyone has
3779
+ // chosen one, downloaded both programs, and on npm 10 every platform's binaries too. So the program
3780
+ // is installed here, for the engine just picked and this platform only, into the engines folder
3781
+ // Clearotron owns under the reader's home (driver.config.mjs enginesFolder). That folder needs no
3782
+ // root, so there is one route and no prefix to probe, and it is not on PATH, so a copy this machine
3783
+ // installs itself later is still found first.
3345
3784
  //
3346
- // THE COMMAND IS SHOWN IN FULL AND THE DEFAULT IS NO. It runs as this user, it installs
3347
- // PROPRIETARY THIRD-PARTY SOFTWARE governed by that vendor's terms rather than by this
3348
- // repository's licence (README §Licence, INSTALL §1), and it is the one thing setup does that
3349
- // reaches outside this checkout. A reader has to be able to read it before answering, which is
3350
- // also why the command in ENGINE_BINARIES is an npm install rather than the vendor's
3351
- // `curl … | bash` — a piped remote script cannot be read before it runs.
3352
- warn(`no \`${eng.fallback}\` binary on PATH.`);
3785
+ // THE COMMAND IS SHOWN IN FULL AND THE DEFAULT IS NO. It runs as this user and installs
3786
+ // THIRD-PARTY SOFTWARE governed by that vendor's terms rather than by this repository's licence
3787
+ // (README §Licence, INSTALL §1), so a reader has to be able to read it before answering. It is an
3788
+ // npm install, never the vendor's `curl … | bash`, because a piped remote script cannot be read
3789
+ // before it runs, and it is spawned as argv, never through a shell.
3790
+ warn(cannotRunLine(eng, bin, process.env[eng.env]));
3353
3791
  say(` ${eng.label}`);
3354
3792
  say("");
3355
- say(` ${eng.vendor}'s CLI is proprietary third-party software. Installing it accepts ${eng.vendor}'s`);
3793
+ say(` ${eng.vendor}'s CLI is ${eng.licence}. Installing it accepts ${eng.vendor}'s`);
3356
3794
  say(" terms, not this product's licence, and this product redistributes no part of it.");
3795
+ const dir = enginesFolder();
3796
+ say(` It goes into ${dir}, for this user and this platform only, and not`);
3797
+ say(" onto PATH, so a copy this machine installs itself later is used first.");
3798
+ say(` ${installSizeLine(eng)}`);
3357
3799
  say("");
3358
- // ── WHICH ROUTE CAN WORK ON THIS BOX, MEASURED FIRST ─────────────────
3359
- //
3360
- // `npm install -g` on a root-owned prefix cannot work as this user; offering only it, then
3361
- // mis-reporting its failure, was the owner's dead end. The prefix is probed by ACCESS, and when
3362
- // it needs root the vendor's own no-root installer is NAMED — never run: the stance in
3363
- // ENGINE_BINARIES holds, a piped remote script is not a command this product executes for
3364
- // someone. The reader runs it by their own hand, and the loop below re-checks rather than
3365
- // dead-ending at a path prompt for a file that does not exist.
3366
- const npmPrefix = (() => {
3367
- const q = spawnSync("npm", ["prefix", "-g"], { encoding: "utf8" });
3368
- return !q.error && q.status === 0 ? String(q.stdout).trim() : null;
3369
- })();
3370
- const prefixWritable = (() => {
3371
- if (!npmPrefix) return null; // could not ask npm — not a verdict either way
3372
- try { accessSync(join(npmPrefix, "lib"), constants.W_OK); return true; } catch { /* fall through */ }
3373
- try { accessSync(npmPrefix, constants.W_OK); return true; } catch { return false; }
3374
- })();
3375
- if (prefixWritable === false) {
3376
- say(` npm's global prefix here is ${npmPrefix}, and this user cannot write to it — the npm`);
3377
- say(" route needs root on this box, so it is not offered first.");
3378
- if (eng.installNoRoot) {
3379
- say(` ${eng.vendor}'s no-root installer lands in ${eng.installNoRoot.lands} and needs no sudo:`);
3380
- say("");
3381
- say(` ${eng.installNoRoot.cmd}`);
3382
- say("");
3383
- say(" Run it YOURSELF in another terminal — it is the vendor's script, and this setup will");
3384
- say(" not pipe a remote script into a shell for you. Come back and continue here.");
3385
- } else {
3386
- say(" The no-root way is to point npm at a prefix you own, then install:");
3387
- say("");
3388
- say(` npm config set prefix ~/.local && ${eng.install}`);
3389
- say("");
3390
- say(" (~/.local/bin must be on PATH.) Run that yourself in another terminal, then continue.");
3391
- }
3392
- if (await confirm("Done (or already installed elsewhere)? Check this box again", true)) {
3393
- bin = resolveEngineBin(process.env[eng.env] || eng.fallback);
3394
- if (!(bin.executable && !bin.relative)) {
3395
- const home = process.env.HOME || homedir();
3396
- const local = join(home, ".local", "bin", eng.fallback);
3397
- if (isExec(local)) { ok(`found it at ${local} — not on this shell's PATH yet`); bin = resolveEngineBin(local); }
3398
- else info("still not found — the path prompt below takes the absolute location if it landed somewhere else.");
3399
- } else ok(`installed: ${bin.path}`);
3400
- }
3401
- } else if (await confirm(`Run \`${eng.install}\` now?`, false)) {
3402
- say(` $ ${eng.install}`);
3403
- const [cmd, ...args] = eng.install.split(" ");
3404
- const r = spawnSync(cmd, args, { stdio: "inherit" });
3405
- // THE EXIT CODE IS NOT THE ANSWER, and this is the issue's own rule. A package manager that
3406
- // exits 0 having installed to a prefix outside this shell's PATH has succeeded at its job and
3407
- // left us exactly where we started; one that exits non-zero may still have left a usable
3408
- // binary. So what decides is the same resolution the ENGINE resolves with, and after that, a
3409
- // turn.
3800
+ if (await confirm(`Run \`${engineInstallCommand(eng, dir)}\` now?`, false)) {
3801
+ say(` $ ${engineInstallCommand(eng, dir)}`);
3802
+ const r = spawnSync("npm", engineInstallArgs(eng, dir), { stdio: "inherit" });
3803
+ // THE EXIT CODE IS NOT THE ANSWER, and this is the issue's own rule. An install that exits 0 may
3804
+ // have left the vendor's placeholder (npm told to skip install scripts), and one that exits
3805
+ // non-zero may still have left a usable program. So what decides is the same resolution the
3806
+ // ENGINE resolves with, and after that, a turn.
3410
3807
  if (r.error) problem(`could not run it: ${r.error.message}`);
3411
3808
  else if (r.status !== 0) warn(`that command exited ${r.status ?? "on a signal"} — checking anyway, since its exit code is not what settles this.`);
3412
- bin = resolveEngineBin(process.env[eng.env] || eng.fallback);
3413
- if (!(bin.executable && !bin.relative)) {
3414
- // The common ending: npm's global prefix is not on this shell's PATH. Naming the path it
3415
- // would be at is the difference between a dead end and one more answer.
3416
- const prefix = (() => {
3417
- const q = spawnSync("npm", ["prefix", "-g"], { encoding: "utf8" });
3418
- return q.status === 0 ? String(q.stdout).trim() : null;
3419
- })();
3420
- const guess = prefix ? join(prefix, "bin", eng.fallback) : null;
3421
- if (guess && isExec(guess)) {
3422
- ok(`installed, but not on this shell's PATH — found it at ${guess}`);
3423
- bin = resolveEngineBin(guess);
3424
- } else {
3425
- warn(`no \`${eng.fallback}\` on PATH after that command.`);
3426
- if (prefix) info(`npm installs global binaries under ${join(prefix, "bin")} — add that to PATH, or give the absolute path below.`);
3427
- // The other route, re-offered rather than a dead end: what happened is
3428
- // reported above; what to do next must not be only a path prompt at a file that never landed.
3429
- if (eng.installNoRoot) info(`the vendor's no-root installer is \`${eng.installNoRoot.cmd}\` — run it yourself in another terminal (lands in ${eng.installNoRoot.lands}), then give the path below or re-run setup.`);
3430
- }
3431
- } else {
3432
- ok(`installed: ${bin.path}`);
3433
- }
3434
- }
3809
+ bin = resolveEngineBin(eng.fallback, { engine: pick.id });
3810
+ if (bin.executable && !bin.relative) { if (bin.source === "installed") ok(`installed: ${bin.path}`); }
3811
+ else warn(cannotRunLine(eng, bin, ""));
3812
+ // RESOLVED WITH THE ENGINE'S DEFAULT WORD, NOT THE SETTING IN FORCE. The line above the offer promised
3813
+ // to install the program "and use that instead", and it is said only when a setting names a copy that
3814
+ // cannot run. A setting that names a program never falls through to the copy setup installed, so
3815
+ // resolving with it here found nothing, whatever npm had put in place, and setup asked for a path.
3816
+ // The default word is what a run reads once the proof turn passes (engineProgramSetting writes it for
3817
+ // the copy setup installed), and it replaces the setting in the file setup writes. So what is found
3818
+ // here is what a run will use: the machine's own copy on PATH if it has one, and otherwise the copy
3819
+ // just installed, and "installed" is said only of the latter. A failure is said without the
3820
+ // setting's name, which only the declined branch below says.
3821
+ } else if (namedSetting(eng, process.env[eng.env])) info(ownCopyLine(eng));
3435
3822
  }
3436
3823
  if (!(bin.executable && !bin.relative)) {
3437
3824
  warn(`no usable \`${eng.fallback}\` binary.`);
@@ -3455,11 +3842,18 @@ try {
3455
3842
  continue;
3456
3843
  }
3457
3844
  const p = await askValue("Absolute path:");
3458
- bin = resolveEngineBin(p);
3845
+ bin = resolveEngineBin(p, { engine: pick.id });
3459
3846
  if (bin.relative) warn("that path is relative. Stage subprocesses run with cwd set to the run directory, so it will not resolve — using the absolute form.");
3460
- if (!bin.executable) { problem(`${bin.path ?? resolve(p)} is not an executable file.`); continue; }
3847
+ if (!bin.executable) { problem(`${bin.path ?? resolve(p)} is ${bin.rejected?.[0]?.why ?? "not an executable file"}.`); continue; }
3848
+ }
3849
+ ok(`found ${bin.path}${bin.source === "installed" ? `, the copy Clearotron installed${bin.version ? ` (${bin.version})` : ""}` : ""}`);
3850
+ // THE TERMS SENTENCE TRAVELS WITH THE PROGRAM, NOT WITH THE INSTALL OFFER. It was said only when this
3851
+ // step offered to install the CLI, and a copy Clearotron installed on an earlier run skips that offer, so it is
3852
+ // said here too. Using it, rather than installing it, is what accepts the vendor's terms.
3853
+ if (bin.source === "installed") {
3854
+ say(` ${eng.vendor}'s CLI is ${eng.licence}. Using it accepts ${eng.vendor}'s`);
3855
+ say(" terms, not this product's licence, and this product redistributes no part of it.");
3461
3856
  }
3462
- ok(`found ${bin.path}`);
3463
3857
 
3464
3858
  // ── item 5 — HOW THIS BOX PAYS, asked BEFORE the proof ────────────────────────────────────
3465
3859
  //
@@ -3479,11 +3873,16 @@ try {
3479
3873
  // adopting OPENAI_API_KEY instead would write a .env that `auth.mjs` refuses — the same defect this
3480
3874
  // item exists to remove, wearing the other engine.
3481
3875
  const ambientKeyPresent = present(process.env[eng.apiKeyEnv]);
3482
- const authPick = await choose(`How does this box pay for ${pick.id}?`, [
3483
- { id: "subscription", label: `Subscription — ${eng.subscriptionHow}` },
3484
- { id: "api-key", label: `API key — metered per token, from ${eng.apiKeyEnv}` },
3485
- ], ambientKeyPresent ? 1 : 0);
3876
+ // A cloud switched on in this shell is the reader's own setup saying how Claude is paid, so it makes the
3877
+ // cloud answer the default, as a key in the environment makes the key answer the default.
3878
+ const ambientClouds = pick.id === "anthropic-agent" ? cloudsSwitchedOn(process.env) : [];
3879
+ const ambientCloud = ambientClouds.length === 1 ? CLOUD_CHOICES.findIndex((c) => c.id === ambientClouds[0]) : -1;
3880
+ const pay = payQuestion({ engineId: pick.id, eng, bin });
3881
+ const authPick = await choose(pay.question, pay.answers, ambientCloud >= 0 ? 2 : ambientKeyPresent ? 1 : 0);
3486
3882
  let apiKey = null;
3883
+ // A cloud answer's settings: the billing word, the cloud's switch and each answer given. They are the
3884
+ // probe's environment below and, once it passes, lines in the .env, so a run bills the account proved.
3885
+ let cloudEnv = null;
3487
3886
  if (authPick.id === "api-key") {
3488
3887
  if (ambientKeyPresent) {
3489
3888
  apiKey = process.env[eng.apiKeyEnv];
@@ -3491,13 +3890,26 @@ try {
3491
3890
  } else {
3492
3891
  apiKey = await askValue(`${eng.apiKeyEnv}:`, { secret: true });
3493
3892
  }
3494
- } else if (ambientKeyPresent) {
3893
+ } else if (authPick.id === "cloud") {
3894
+ const cloud = await choose("Which cloud account pays?", CLOUD_CHOICES, Math.max(ambientCloud, 0));
3895
+ info(cloud.note);
3896
+ const answers = {};
3897
+ for (const a of cloud.asks) {
3898
+ // A value already in this shell is the default, except a secret: that is adopted and named, and never
3899
+ // shown in a prompt.
3900
+ const have = process.env[a.env];
3901
+ if (a.secret && present(have)) { answers[a.env] = have; info(`${a.env} is already in your environment — adopting it.`); continue; }
3902
+ answers[a.env] = await askValue(a.q, { def: present(have) ? have : (a.def ?? ""), secret: a.secret === true, skippable: a.skippable === true, skipped: a.skipped ?? null });
3903
+ }
3904
+ cloudEnv = cloudSettings(cloud.id, answers);
3905
+ }
3906
+ if (authPick.id !== "api-key" && ambientKeyPresent) {
3495
3907
  // Not a warning: it is the resolved behaviour, stated once, because the opposite guess is the
3496
- // expensive one. The adapter strips the key under subscription, so the probe below really does
3497
- // exercise the subscription and the key sitting in the environment changes nothing.
3498
- info(`${eng.apiKeyEnv} is set in your environment and will NOT be used — subscription mode strips it from every stage.`);
3908
+ // expensive one. The adapter strips the key under subscription and under cloud, so the probe below
3909
+ // really does exercise that lane and the key sitting in the environment changes nothing.
3910
+ info(`${eng.apiKeyEnv} is set in your environment and will NOT be used — ${authPick.id} mode strips it from every stage.`);
3499
3911
  }
3500
- const authEnv = { [eng.authEnv]: authPick.id, ...(apiKey ? { [eng.apiKeyEnv]: apiKey } : {}) };
3912
+ const authEnv = cloudEnv ?? { [eng.authEnv]: authPick.id, ...(apiKey ? { [eng.apiKeyEnv]: apiKey } : {}) };
3501
3913
 
3502
3914
  // THE PROOF. An executable file is not a working engine: a signed-out CLI, an expired credential, an
3503
3915
  // unreachable tier and a spent quota all pass every check above and surface as a stage failure after
@@ -3520,17 +3932,27 @@ try {
3520
3932
  }
3521
3933
  for (;;) {
3522
3934
  say(" Running one turn…");
3523
- const v = await probeEngineTurn({ env: { ...process.env, CLEAROTRON_AI: pick.id, [eng.env]: bin.path, ...authEnv } });
3935
+ // Read on every try, so a setting the reader fixes in the file between tries is the one the next turn uses.
3936
+ const v = await probeEngineTurn(proofTurn({ engineId: pick.id, eng, bin, authEnv, settings: settingsInForce() }));
3524
3937
  if (v.ok) {
3525
3938
  ok(`${pick.id} completed a turn on the ${authPick.id} lane — binary, credential, billing mode and model access all work.`);
3939
+ const served = servedLine(v);
3940
+ if (served) info(served);
3526
3941
  candidate.CLEAROTRON_AI = pick.id;
3527
- candidate[eng.env] = bin.path;
3942
+ // ALWAYS WRITTEN, and for the copy Clearotron installed it is the engine's default word: see
3943
+ // engineProgramSetting for why leaving it out was not enough.
3944
+ candidate[eng.env] = engineProgramSetting(eng, bin);
3528
3945
  candidate[eng.authEnv] = authPick.id;
3529
3946
  if (apiKey) candidate[eng.apiKeyEnv] = apiKey;
3947
+ // A cloud answer writes the settings the turn above ran on, every one, so a run bills that account.
3948
+ const cloudLines = Object.entries(cloudEnv ?? {}).filter(([k]) => k !== eng.authEnv);
3949
+ for (const [k, val] of cloudLines) candidate[k] = val;
3530
3950
  info(`CLEAROTRON_AI=${pick.id}`);
3531
3951
  info(`${eng.authEnv}=${authPick.id} — the lane the turn above actually ran on.`);
3532
3952
  if (apiKey) info(`${eng.apiKeyEnv}=… — adopted, so a run bills the way you just proved.`);
3533
- info(`${eng.env}=${bin.path} — the absolute form, because a service's PATH is not your shell's.`);
3953
+ for (const [k, val] of cloudLines) info(shownSetting(k, val));
3954
+ if (bin.source === "installed") info(`${eng.env}=${eng.fallback} — the engine's default: this machine's own \`${eng.fallback}\` once it has one, and the copy Clearotron installed until then.`);
3955
+ else info(`${eng.env}=${bin.path} — the absolute form, because a service's PATH is not your shell's.`);
3534
3956
  break engine;
3535
3957
  }
3536
3958
  problem(probeFailureText(v));
@@ -3551,8 +3973,10 @@ try {
3551
3973
  // — THE HAND-OFF. Signing in is the one step of this sequence nobody here can perform for
3552
3974
  // someone, so the wizard names the command, waits, and re-probes rather than ending at a
3553
3975
  // description of what is wrong. The text comes from ENGINE_BINARIES so the two adapters cannot
3554
- // drift into one set of instructions.
3555
- info(`if it is signed out: ${eng.signIn}, then answer yes below.`);
3976
+ // drift into one set of instructions. BY HOW THE TURN IS PAID FOR (signInHandOff): the sign-in is a
3977
+ // subscription's step, and on a cloud or under a key the verdict above has named what to check.
3978
+ const handOff = signInHandOff(eng, bin, authPick.id);
3979
+ for (const line of handOff.lines) info(line);
3556
3980
  // ── THE HEADLESS ENDING ────────────────────────────────────────────────
3557
3981
  //
3558
3982
  // On a box with no browser the interactive sign-in cannot complete, and the documented route had
@@ -3562,18 +3986,15 @@ try {
3562
3986
  // stream layout is not ours to guess at, and a paste works whatever it prints where. Codex's
3563
3987
  // headless ending writes its own auth file and there is nothing to capture — the command is
3564
3988
  // named, and the re-probe is the proof either way.
3565
- if (eng.headless) {
3566
- info(`on a box with no browser: run \`${eng.headless.cmd}\`${eng.headless.tokenEnv ? " (from any machine you can sign in on)" : " here"}.`);
3567
- if (eng.headless.tokenEnv) {
3568
- // A TOKEN PASTED AT THE YES/NO IS THE ANSWER (confirmOrKey): taken, never shown, not asked for twice.
3569
- const answer = await confirmOrKey(`Did that give you a token to paste? Capture it into ${eng.headless.tokenEnv} now`, false, { what: "token" });
3570
- const tok = answer.value ?? (answer.yes
3571
- ? await askValue(`${eng.headless.tokenEnv}:`, { secret: true, skippable: true, skipped: "Nothing captured." }) : null);
3572
- if (tok !== null) {
3573
- candidate[eng.headless.tokenEnv] = tok;
3574
- authEnv[eng.headless.tokenEnv] = tok; // the re-probe below must prove the lane WITH it
3575
- info(`${eng.headless.tokenEnv} captured — the turn below proves it before anything is written.`);
3576
- }
3989
+ if (handOff.captureToken) {
3990
+ // A TOKEN PASTED AT THE YES/NO IS THE ANSWER (confirmOrKey): taken, never shown, not asked for twice.
3991
+ const answer = await confirmOrKey(`Did that give you a token to paste? Capture it into ${eng.headless.tokenEnv} now`, false, { what: "token" });
3992
+ const tok = answer.value ?? (answer.yes
3993
+ ? await askValue(`${eng.headless.tokenEnv}:`, { secret: true, skippable: true, skipped: "Nothing captured." }) : null);
3994
+ if (tok !== null) {
3995
+ candidate[eng.headless.tokenEnv] = tok;
3996
+ authEnv[eng.headless.tokenEnv] = tok; // the re-probe below must prove the sign-in WITH it
3997
+ info(`${eng.headless.tokenEnv} captured — the turn below proves it before anything is written.`);
3577
3998
  }
3578
3999
  }
3579
4000
  // — found in review, and the same trap closed one prompt over. The default