clearotron 0.3.0-beta.3 → 0.3.0-beta.5

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.
package/INSTALL.md CHANGED
@@ -749,6 +749,8 @@ Use the demo to see what this system produces. Use `npx clearotron start` to run
749
749
 
750
750
  - Generates `PORTAL_SECRET` and `TRADEMARK_MCP_TOKEN_SECRET` and **appends** them to `~/.config/clearotron/.env` at
751
751
  mode 600. Append, never rewrite: that file also holds the credentials `npx clearotron install` collected.
752
+ It also records where saved searches are kept, `CLEAROTRON_RECIPES_DIR` and `RECIPE_REPO_ROOT`, so
753
+ `clearotron doctor` and a connected assistant read the same saved searches as the portal.
752
754
  - Creates `~/trademark/` — `pool/`, `workspace/`, `queue/`, `outbox/`, `locks/`, an empty grants file,
753
755
  and a small git repository for saved searches. Same base directory `npx clearotron install` uses, so whichever
754
756
  of the two you ran first, the other finds the same install. Move it with `npx clearotron start --base <dir>`.
@@ -922,12 +924,19 @@ their own integration work.
922
924
  **Two shapes, and the line between them is what your assistant can do — not where it runs.** Ruling
923
925
  2026-09-03, on the vendor's own documented behaviour:
924
926
 
925
- 1. **An assistant that can launch a local process** — Claude Code, Claude Desktop's local config,
926
- Codex CLI, an agent that runs commands. It spawns the server over stdio. No address, no key, no
927
- network, no ingress. `npx clearotron start` prints the one line to paste, and `npx clearotron
928
- connect` hands it over per assistant.
929
- 2. **Everything else, wherever it appears to run** — Cowork, ChatGPT, claude.ai, Perplexity, the mobile
930
- apps. These need **a publicly reachable HTTPS address**, plus a key. Always.
927
+ 1. **Clearotron is installed on the machine the assistant runs on** — Claude's desktop app, Claude Code,
928
+ Codex, the ChatGPT desktop app, an agent that runs commands. The assistant spawns the server over
929
+ stdio. No address, no key, no network, no ingress. `npx clearotron start` prints the one line to
930
+ paste, and `npx clearotron connect --where here` hands it over per assistant.
931
+ 2. **Clearotron is running somewhere else** — a server, a cloud machine, anywhere the assistant is
932
+ not. These need **a publicly reachable HTTPS address**, plus a key made for the person connecting.
933
+ Always. Claude (app, web, Cowork and mobile), ChatGPT on the web, Perplexity and other agents only
934
+ ever connect this way; Claude Code and Codex can connect either way. `npx clearotron connect
935
+ --where elsewhere` makes the key and prints the steps for the assistant you name.
936
+
937
+ Without `--where`, `connect` asks when both answers are possible, and `--client <name>` on its own keeps
938
+ the answer that assistant had before: a key for Claude, the local line for Claude Code and Codex.
939
+ `npx clearotron disconnect` takes the same `--where`.
931
940
 
932
941
  **A loopback address is never an answer for shape 2, and that is not about your network.** A remote MCP
933
942
  connector is reached **from the vendor's cloud**, never from the reader's device. Anthropic's own help
package/bin/connect.mjs CHANGED
@@ -45,7 +45,7 @@ import { homedir, userInfo } from "node:os";
45
45
  import { fileURLToPath } from "node:url";
46
46
  import { execFileSync } from "node:child_process";
47
47
  import { createServer } from "node:net";
48
- import { CONNECT_CLIENTS, clientById, whatItNeeds } from "../shared/connect-clients.mjs";
48
+ import { CONNECT_CLIENTS, WHERE_FLAG, clientById, leadRouteFor, plainStep, whatItNeeds } from "../shared/connect-clients.mjs";
49
49
  import { stdioConnectFor, STDIO_SHAPES } from "../shared/stdio-connect.mjs";
50
50
  import { defaultDenylistPath, clientDoorAddress, clientDoorPort, clientDoorState, enablePlan, applyEnablePlan, describeChange, recordConnectKey, CLIENT_DOOR_UNIT } from "../shared/client-door.mjs";
51
51
  import { mintToken, tokenId, resolvePerson, loadGrants } from "../shared/scope.mjs";
@@ -555,17 +555,12 @@ async function render(offer, have, { dryRun, running, allowMove = false }) {
555
555
  return 1;
556
556
  }
557
557
 
558
- if (offer.route === "disk" || (offer.route === "either" && offer.command)) {
559
- // A COMMAND AND A CONFIG BLOCK ARE NOT THE SAME INSTRUCTION, and saying "run this" over a TOML
560
- // block is how a reader pastes four lines into a shell. The shape says which it is.
561
- const s = offer.stdio;
562
- say(s?.kind === "config"
563
- ? ` Add this to ${s.where}:`
564
- : " Run this once, on this machine:");
558
+ if (offer.route === "disk") {
559
+ // THE ROW'S OWN STEPS, and nothing written here. A command and a settings block are not the same
560
+ // instruction — saying "run this" over a TOML block is how a reader pastes four lines into a shell
561
+ // and the steps already say which each one is and where it goes.
562
+ printSteps(offer, null);
565
563
  say("");
566
- for (const line of String(offer.command).split("\n")) say(` ${line}`);
567
- say("");
568
- if (s?.after) { say(` ${s.after}`); say(""); }
569
564
  say(` ${offer.note}`);
570
565
  return 0;
571
566
  }
@@ -621,22 +616,40 @@ async function render(offer, have, { dryRun, running, allowMove = false }) {
621
616
  }
622
617
 
623
618
  say(` Address: ${offer.address}`);
624
- if (key) say(` Key: ${key}`); // printed once, stored nowhere
625
- // ── AND WHERE TO PUT THEM ( F35) ───────────────────────────────────────
626
- //
627
- // The owner was left with two strings and no destination: *"I don't know how to connect it in Claude
628
- // Cowork with those details."* The steps were DEFINED IN THE PRODUCT the whole time — `withSteps`
629
- // computes them for every offer, interpolating this install's own address and operator — and this
630
- // verb simply never printed them. Nothing new is authored here; a second set of instructions written
631
- // at the CLI would drift from the page's, which is the defect connect-clients-are-data exists against.
619
+ // Printed once, stored nowhere — and only here when no step hands it over. A step whose copy carries
620
+ // the key prints it inside the line the reader pastes, which is the one place it is needed.
621
+ const keyInSteps = (offer.steps ?? []).some((s) => s.copy?.kind === "secret");
622
+ if (key && !keyInSteps) say(` Key: ${key}`);
623
+ printSteps(offer, key);
624
+ say("");
625
+ say(` ${offer.note}`);
626
+ return 0;
627
+ }
628
+
629
+ /**
630
+ * The offer's own steps, numbered, with each copy printed under the step that hands it over.
631
+ *
632
+ * ── AND WHERE TO PUT THEM ( — F35) ───────────────────────────────────────
633
+ *
634
+ * The owner was left with two strings and no destination: *"I don't know how to connect it in Claude
635
+ * Cowork with those details."* The steps were DEFINED IN THE PRODUCT the whole time and this verb simply
636
+ * never printed them. Nothing is authored here; a second set of instructions written at the CLI would
637
+ * drift from the page's, which is the defect connect-clients-are-data exists against. A secret copy is
638
+ * printed with the key this press minted put in its slot — the page does the same substitution into the
639
+ * clipboard — and with no key (a dry run) the step prints and its copy does not.
640
+ */
641
+ function printSteps(offer, key) {
632
642
  if (offer.steps?.length) {
633
643
  say("");
634
644
  say(` In ${offer.client?.name ?? "your assistant"}:`);
635
- offer.steps.forEach((step, n) => say(` ${n + 1}. ${step}`));
645
+ offer.steps.forEach((step, n) => {
646
+ say(` ${n + 1}. ${plainStep(step.text)}`);
647
+ const c = step.copy;
648
+ const text = !c ? null : c.kind === "secret" ? (key ? c.template.split(c.slot).join(key) : null) : c.text;
649
+ if (text) { say(""); for (const line of text.split("\n")) say(` ${line}`); say(""); }
650
+ if (step.hint) say(` ${plainStep(step.hint)}`);
651
+ });
636
652
  }
637
- say("");
638
- say(` ${offer.note}`);
639
- return 0;
640
653
  }
641
654
 
642
655
  async function main() {
@@ -654,6 +667,9 @@ async function main() {
654
667
  say(" not about you.");
655
668
  say("");
656
669
  say(" --client <name> skip the question (see --list for the names)");
670
+ say(" --where here your assistant runs on this machine: it starts the software itself");
671
+ say(" --where elsewhere your assistant runs somewhere else and reaches this install over the");
672
+ say(" internet, with a key made for you now");
657
673
  say(" --list the assistants this build knows");
658
674
  say(" --dry-run say what would change, change nothing");
659
675
  say(" --allow-checkout-move");
@@ -663,7 +679,7 @@ async function main() {
663
679
  say("");
664
680
  return 0;
665
681
  }
666
- const known = new Set(["--client", "--list", "--dry-run", "--allow-checkout-move", "--help", "-h"]);
682
+ const known = new Set(["--client", "--where", "--list", "--dry-run", "--allow-checkout-move", "--help", "-h"]);
667
683
  const unknown = argv.filter((a) => a.startsWith("--") && !known.has(a));
668
684
  if (unknown.length) {
669
685
  console.error(`connect: unrecognised flag(s): ${unknown.join(", ")}`);
@@ -671,6 +687,12 @@ async function main() {
671
687
  process.exit(2);
672
688
  }
673
689
  const dryRun = argv.includes("--dry-run");
690
+ const w = argv.indexOf("--where");
691
+ if (w >= 0 && !Object.hasOwn(WHERE_FLAG, argv[w + 1] ?? "")) {
692
+ console.error(`connect: --where takes one of: ${Object.keys(WHERE_FLAG).join(", ")}`);
693
+ process.exit(2);
694
+ }
695
+ let route = w >= 0 ? WHERE_FLAG[argv[w + 1]] : null;
674
696
  // F40 — reason about the RUNNING product from the units' own environment, not this CLI's env file.
675
697
  const running = runningEnv();
676
698
  const have = deploymentHas(running.env);
@@ -712,9 +734,30 @@ async function main() {
712
734
  chosen = CONNECT_CLIENTS[Number(answer) - 1] ?? clientById(answer);
713
735
  } finally { rl.close(); }
714
736
  if (!chosen) { console.error("connect: not one of the listed assistants."); process.exit(2); }
737
+ // THE SECOND QUESTION, ASKED ONLY WHEN IT HAS TWO ANSWERS — the page's own, in the terminal's words.
738
+ // Where only one route is served here, asking would offer a choice whose other half cannot work.
739
+ const both = ["disk", "public-http"].filter((r) => whatItNeeds(chosen, have, r)?.served);
740
+ if (!route && both.length > 1) {
741
+ say("");
742
+ say(" Where does it run?");
743
+ say("");
744
+ say(" 1) On this machine — it starts the software itself, nothing to open up");
745
+ say(" 2) Somewhere else — it reaches this install over the internet, with a key made for you now");
746
+ say("");
747
+ const rl2 = createInterface({ input: stdin, output: stdout });
748
+ try {
749
+ const answer = (await rl2.question(" 1-2: ")).trim();
750
+ route = answer === "1" ? "disk" : answer === "2" ? "public-http" : null;
751
+ } finally { rl2.close(); }
752
+ if (!route) { console.error("connect: answer 1 or 2."); process.exit(2); }
753
+ }
754
+ route ??= both[0] ?? chosen.lead;
715
755
  }
716
756
 
717
- return await render(whatItNeeds(chosen, have), have, { dryRun, running, allowMove: argv.includes("--allow-checkout-move") });
757
+ // NAMED WITHOUT --where: the route this id had before every row took both, so a scripted
758
+ // `--client cowork` still mints a key and `--client codex` still prints a settings block.
759
+ route ??= leadRouteFor(argv[i + 1]);
760
+ return await render(whatItNeeds(chosen, have, route), have, { dryRun, running, allowMove: argv.includes("--allow-checkout-move") });
718
761
  }
719
762
 
720
763
  // THE DISPATCH RUNS ONLY WHEN THIS FILE IS THE COMMAND. Without the guard, importing
@@ -57,7 +57,7 @@ import { stdin, stdout } from "node:process";
57
57
  import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from "node:fs";
58
58
  import { join, dirname } from "node:path";
59
59
  import { homedir, userInfo } from "node:os";
60
- import { CONNECT_CLIENTS, clientById } from "../shared/connect-clients.mjs";
60
+ import { CONNECT_CLIENTS, WHERE_FLAG, clientById, leadRouteFor } from "../shared/connect-clients.mjs";
61
61
  import { defaultDenylistPath, disablePlan, revokeEveryonePlan, applyDisablePlan, describeClosure, recordedKeysFor, removeRecordedKeys } from "../shared/client-door.mjs";
62
62
  import { loadGrants } from "../shared/scope.mjs";
63
63
  import { envFrom } from "../shared/env-aliases.mjs";
@@ -83,6 +83,8 @@ async function main() {
83
83
  say(" `clearotron grant` manages that.");
84
84
  say("");
85
85
  say(" --client <name> which assistant you connected (see --list for the names)");
86
+ say(" --where here|elsewhere");
87
+ say(" how you connected it: on this machine, or over the internet with a key");
86
88
  say(" --list the assistants this build knows");
87
89
  say(" --everyone the admin act: revoke EVERY issued key on this install. It says");
88
90
  say(" how many keys and how many people that is before doing it.");
@@ -90,7 +92,7 @@ async function main() {
90
92
  say("");
91
93
  return 0;
92
94
  }
93
- const known = new Set(["--client", "--list", "--dry-run", "--everyone", "--help", "-h"]);
95
+ const known = new Set(["--client", "--where", "--list", "--dry-run", "--everyone", "--help", "-h"]);
94
96
  const unknown = argv.filter((a) => a.startsWith("--") && !known.has(a));
95
97
  if (unknown.length) {
96
98
  console.error(`disconnect: unrecognised flag(s): ${unknown.join(", ")}`);
@@ -137,18 +139,21 @@ async function main() {
137
139
  say(` ${chosen.name}`);
138
140
  say("");
139
141
 
140
- // The row's own property decides the side. `accepts: "stdio"` never touched this install; "either"
141
- // only opens the door when its stdio route was missing, and on THIS box (the one disconnect runs on)
142
- // the stdio route resolves, so its connect handed over a command too.
143
- if (chosen.accepts === "stdio" || chosen.accepts === "either") {
142
+ // THE ROUTE DECIDES THE SIDE, not the assistant: every row takes both now. Connected on this machine,
143
+ // it never touched this install; connected over the internet, it holds a key and the key is what goes.
144
+ // Unnamed, the route is the one `connect` gives the same id unnamed, so the two verbs pair up.
145
+ const w = argv.indexOf("--where");
146
+ if (w >= 0 && !Object.hasOwn(WHERE_FLAG, argv[w + 1] ?? "")) {
147
+ console.error(`disconnect: --where takes one of: ${Object.keys(WHERE_FLAG).join(", ")}`);
148
+ process.exit(2);
149
+ }
150
+ const route = w >= 0 ? WHERE_FLAG[argv[w + 1]] : (i >= 0 ? leadRouteFor(argv[i + 1]) : chosen.lead);
151
+ if (route === "disk") {
144
152
  say(" Connecting this assistant changed nothing on this install — it runs the software itself,");
145
153
  say(" from the configuration you added on its side. To disconnect it, remove that entry in the");
146
154
  say(" assistant's own settings.");
147
- if (chosen.accepts === "either") {
148
- say("");
149
- say(" If you connected it by address instead, the door and key are shared — disconnect the");
150
- say(" assistant you named when the door was opened, and the closure covers this one too.");
151
- }
155
+ say("");
156
+ say(" If you connected it over the internet instead, run this again with --where elsewhere.");
152
157
  return 0;
153
158
  }
154
159
 
package/bin/onboard.mjs CHANGED
@@ -99,7 +99,15 @@ import { entrypointOf } from "../driver/systemd/install-census.mjs"; //
99
99
  import { overlayReport, renderOverlayReport } from "../shared/doctrine-overlay.mjs"; // — the doctor reports the overlay
100
100
  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
101
101
  import { engineInventory, engineMode, ENGINE_MODES } from "../driver/config-inventory.mjs"; //
102
- import { probeEngineTurn, probeFailureText, PROBE_MODEL, PROBE_TIMEOUT_SEC, engineEnvKeys } from "../driver/engine/probe.mjs";
102
+ import { probeEngineTurn, probeFailureText, PROBE_TIMEOUT_SEC, engineEnvKeys } from "../driver/engine/probe.mjs";
103
+
104
+ // THE PROVING SENTENCES NAME NO MODEL. They printed the driver's tier word, which is an Anthropic model's
105
+ // name, on both engines, so a codex user was told setup was about to spend on a model family they do not
106
+ // use. "Its cheapest model" is true of either engine and brands neither.
107
+ export const probingLine = (engineId) =>
108
+ `Probing ${engineId} with one turn on its cheapest model (this SPENDS; ${PROBE_TIMEOUT_SEC}s ceiling)…`;
109
+ export const proveQuestion = ({ engineId, lane }) =>
110
+ `Prove ${engineId} on the ${lane} lane now with one turn on its cheapest model (a few tokens, ${PROBE_TIMEOUT_SEC}s ceiling)?`;
103
111
  import { runRequiredNames, missingRequirements, REGISTER_ENV, ENGINE_ENV } from "../driver/run-requirements.mjs"; // the order-time gate's own question, asked here rather than restated
104
112
  import { pinEnv, envFrom } from "../shared/env-aliases.mjs";
105
113
  import { isEntrypoint } from "../shared/is-entrypoint.mjs"; // — one entry-point test, all spellings
@@ -1206,7 +1214,7 @@ export async function runCheck() {
1206
1214
  // deliberate — a second reader would drift from this one exactly as the composer and the checker did
1207
1215
  // in F41, and the drift is invisible because both sides keep passing their own arms.
1208
1216
  const unitDir = join(homedir(), ".config", "systemd", "user");
1209
- const { BACKGROUND_UNITS } = await import(pathToFileURL(join(REPO, "bin", "start.mjs")).href);
1217
+ const { BACKGROUND_UNITS, startPaths } = await import(pathToFileURL(join(REPO, "bin", "start.mjs")).href);
1210
1218
  const hosted = BACKGROUND_UNITS.some((u) => existsSync(join(unitDir, u)));
1211
1219
  const unitEnv = hosted
1212
1220
  ? unitEnvironment({
@@ -1440,7 +1448,7 @@ export async function runCheck() {
1440
1448
  } else if (!bin.executable || bin.relative) {
1441
1449
  info("not probed — there is no usable binary to probe. Fix the line above first.");
1442
1450
  } else {
1443
- say(`\n Probing ${engineId} with one ${PROBE_MODEL}-tier turn (this SPENDS; ${PROBE_TIMEOUT_SEC}s ceiling)…`);
1451
+ say(`\n ${probingLine(engineId)}`);
1444
1452
  // The engine as a RUN would see it: environment first, .env behind it. Only the engine-selection
1445
1453
  // keys — the probe must bill exactly the way this box bills and moves no other variable.
1446
1454
  const probeEnv = { ...process.env };
@@ -1628,6 +1636,13 @@ export async function runCheck() {
1628
1636
  // because a reader debugging `clearotron run` by hand IS this process.
1629
1637
  info(`this command's own process ${where} — a CLI is not started by the units' EnvironmentFile, so `
1630
1638
  + "that differs by design and is not what a run uses");
1639
+ } else if (!hosted && svcCustomers?.from === serviceEnvLabel) {
1640
+ // NAMED IN THE ENV FILE ALONE, ON AN INSTALL WITH NO UNITS. `clearotron start` loads that file and
1641
+ // its services inherit the store; this command does not load it, so its own process fell back to
1642
+ // the bundled roster and printed that as the answer, one line below a ✓ for the same variable.
1643
+ ok(`the services resolve profiles from ${svcCustomers.v} (${svcCustomers.from})`);
1644
+ info(`this command's own process ${where} — it does not load ${serviceEnvFile}, so that differs by `
1645
+ + `design and is not what \`${invoke("start")}\` hands the services`);
1631
1646
  } else {
1632
1647
  if (r.situation === "overlay" && !r.findings.length) ok(`${where} — the configured store`);
1633
1648
  else if (r.situation === "bundled-fallback") info(`${where} — THE BUNDLED DEMO ROSTER, because CLEAROTRON_CUSTOMERS_DIR is unset. Legitimate on a generic-defaults install; a fallback either way, and it is what a misconfigured deployment also looks like`);
@@ -1638,7 +1653,8 @@ export async function runCheck() {
1638
1653
  info(`the units are installed but their environment could not be read (${unitEnv?.why ?? "no reason given"}) — `
1639
1654
  + "what the services resolve is not judged here, and the line above is this process's own answer");
1640
1655
  info("what a RUN used is its own `profile-store` journal line — this command reports what the "
1641
- + (hosted ? "units' file says, which the running services read at their own start" : "environment you are typing in says"));
1656
+ + (hosted ? "units' file says, which the running services read at their own start"
1657
+ : `environment you are typing in and your environment file say, which \`${invoke("start")}\` reads at its own start`));
1642
1658
  // ── AND WHO IS ACTUALLY IN IT ─────────────────────────────────────────
1643
1659
  //
1644
1660
  // The line above names the STORE. An operator who has just configured one wants to know their
@@ -1807,16 +1823,32 @@ export async function runCheck() {
1807
1823
  // be read, the lines above already said so and nothing is judged here. A store that is configured but
1808
1824
  // holds a file that cannot be read fails every company's saved searches, in the portal and the
1809
1825
  // connector alike, so that is read here too.
1826
+ //
1827
+ // WITH NO UNITS, THE SERVICES ARE `clearotron start`'s CHILDREN, and it hands every one of them a store
1828
+ // whether or not the env file names it. So "off" is only ever true of a hosted box. This read the env
1829
+ // file alone, and on a local install started before `start` wrote the store there, it told a portal
1830
+ // that was listing saved searches that they were off.
1810
1831
  if (!hosted || serviceKnown) {
1811
- const recipesSet = hosted ? effectiveForService("CLEAROTRON_RECIPES_DIR") : effective("CLEAROTRON_RECIPES_DIR");
1832
+ // Layered as effectiveForService layers it this command's environment over the services' file — so
1833
+ // the repository root is read from the same place the store directory is.
1834
+ let storeEnv = { ...(serviceFileEnv ?? {}), ...process.env };
1835
+ let recipesSet = effectiveForService("CLEAROTRON_RECIPES_DIR");
1836
+ let handedBy = "";
1837
+ if (!hosted && !recipesSet?.v) {
1838
+ const handed = startPaths({ env: storeEnv });
1839
+ recipesSet = { v: handed.recipes, from: "clearotron start", name: "CLEAROTRON_RECIPES_DIR" };
1840
+ storeEnv = { ...storeEnv, RECIPE_REPO_ROOT: handed.configStore };
1841
+ handedBy = ` — where \`${invoke("start")}\` puts them`;
1842
+ }
1812
1843
  const recipesDir = recipesSet?.v || null;
1813
1844
  if (!recipesDir) {
1814
1845
  info("saved searches are off: CLEAROTRON_RECIPES_DIR is not set, so Custom searches in the portal and the "
1815
1846
  + "connector offer none. Name a directory inside a git repository to switch them on");
1847
+ } else if (handedBy && !existsSync(join(storeEnv.RECIPE_REPO_ROOT, ".git"))) {
1848
+ // NOT CREATED YET IS NOT UNREADABLE. The store does not exist until the first start makes it, and
1849
+ // reading it now would report a missing directory as a broken one.
1850
+ info(`saved searches switch on at the first \`${invoke("start")}\`, which creates their store in ${recipesDir}`);
1816
1851
  } else {
1817
- // Layered as effectiveForService layers it — this command's environment over the services' file — so
1818
- // the repository root is read from the same place the store directory above was.
1819
- const storeEnv = { ...(serviceFileEnv ?? {}), ...process.env };
1820
1852
  const resolved = resolveStoreRepoRoot({ names: ["RECIPE_REPO_ROOT", "PROFILE_REPO_ROOT"], fallback: REPO, env: storeEnv });
1821
1853
  const reach = storeInRepo(recipesDir, resolved.root);
1822
1854
  if (!reach.ok) {
@@ -1829,7 +1861,7 @@ export async function runCheck() {
1829
1861
  if (unreadable) {
1830
1862
  warn(`saved searches cannot be read from ${recipesDir}: ${unreadable}. Every company's saved searches fail `
1831
1863
  + "to load, in the portal and the connector, until that file is fixed");
1832
- } else ok(`saved searches are read from ${recipesDir}, and saves are committed in ${reach.repo}`);
1864
+ } else ok(`saved searches are read from ${recipesDir}${handedBy}, and saves are committed in ${reach.repo}`);
1833
1865
  }
1834
1866
  }
1835
1867
  }
@@ -3281,7 +3313,7 @@ try {
3281
3313
  // skipping it costs an hour later.
3282
3314
  info("A file that exists is not an engine that works — so setup tries one before writing anything.");
3283
3315
  info("It takes a few seconds here. Skipped, a broken engine surfaces an hour into a real search.");
3284
- if (!await confirm(`Prove ${pick.id} on the ${authPick.id} lane now with one ${PROBE_MODEL}-tier turn (a few tokens, ${PROBE_TIMEOUT_SEC}s ceiling)?`, true)) {
3316
+ if (!await confirm(proveQuestion({ engineId: pick.id, lane: authPick.id }), true)) {
3285
3317
  info("Not proven, so not written. Pick again — the last row configures no engine at all.");
3286
3318
  continue;
3287
3319
  }
package/bin/start.mjs CHANGED
@@ -355,6 +355,50 @@ export function defaultGrantsPath({ env = process.env, demo = false } = {}) {
355
355
  return installPaths(join(homedir(), demo ? "trademark-demo" : "trademark")).grants;
356
356
  }
357
357
 
358
+ /**
359
+ * The paths `clearotron start` hands its services: the install's layout under `base`, with whatever the
360
+ * environment already names winning over the layout's default, as `start` has always applied it.
361
+ *
362
+ * EXPORTED FOR THE ONE OTHER READER THAT MUST AGREE WITH IT. On an install with no background units,
363
+ * `clearotron doctor` has no unit file to read the services' environment from: the services are this
364
+ * command's children and get the saved-search store from here. An install started before the store was
365
+ * written to the env file has no line there to read, so doctor asks this function for it, and the
366
+ * answer it prints and the one the services were given have one author.
367
+ *
368
+ * NOT IN A DEMO. Nothing the environment says about an install is the demo's: with the reader's settings
369
+ * in force, 0.3.0-beta.1's demo seeded its example reports into their real archive.
370
+ */
371
+ export function startPaths({ env = process.env, base = join(homedir(), "trademark"), demo = false } = {}) {
372
+ const paths = installPaths(base);
373
+ if (demo) return paths;
374
+ for (const [k, name] of [["pool", "CLEAROTRON_REPORTS_DIR"], ["workspace", "CLEAROTRON_WORK_DIR"], ["queue", "CLEAROTRON_QUEUE_DIR"],
375
+ ["outbox", "CLEAROTRON_OUTBOX_DIR"], ["locks", "CLEAROTRON_RUN_LOCK_DIR"], ["grants", "CLEAROTRON_ACCESS_FILE"],
376
+ ["recipes", "CLEAROTRON_RECIPES_DIR"]]) if (env[name]) paths[k] = env[name];
377
+ if (env.RECIPE_REPO_ROOT) paths.configStore = env.RECIPE_REPO_ROOT;
378
+ if (env.PORTAL_AUDIT) paths.audit = env.PORTAL_AUDIT;
379
+ return paths;
380
+ }
381
+
382
+ /**
383
+ * The saved-search store, as the env file must record it for every reader OUTSIDE this command's tree.
384
+ *
385
+ * The services are handed the store by `childEnv`. A connector an assistant launches, `clearotron doctor`
386
+ * and every other hand-run command are not this command's children: they read the env file, and the
387
+ * file never named the store. So on a local install the portal listed a company's saved searches, the
388
+ * connected assistant was told there were none, and doctor said saved searches were off. Written
389
+ * add-only beside the secrets, as `npx clearotron install` writes the reports and work directories: a
390
+ * line an operator wrote wins.
391
+ */
392
+ export function storesForOtherReaders(paths) {
393
+ return { CLEAROTRON_RECIPES_DIR: paths.recipes, RECIPE_REPO_ROOT: paths.configStore };
394
+ }
395
+
396
+ // Written above each line, so a reader who moves the install sees that these two move with it.
397
+ const STORE_NOTES = {
398
+ CLEAROTRON_RECIPES_DIR: "Where saved searches are kept. `clearotron start` created it; edit both lines if you move the install.",
399
+ RECIPE_REPO_ROOT: "The git repository saved searches are committed in, which holds the directory above.",
400
+ };
401
+
358
402
  /** Everything this install keeps on disk, under one base directory. */
359
403
  export function installPaths(base) {
360
404
  return {
@@ -846,20 +890,11 @@ if (isMain) {
846
890
  const DEMO = argv.includes("--demo");
847
891
  // The same base `npm run setup` writes under, so whichever of the two a reader ran first, the other
848
892
  // finds the same install rather than a second one beside it.
849
- const paths = installPaths(flag("--base", join(homedir(), DEMO ? "trademark-demo" : "trademark")));
850
893
  // Whatever the environment already says wins over the base-derived default, for every path — a reader
851
- // who ran `npm run setup` has these in .env already and this must not move their data.
852
- //
853
- // NOT IN A DEMO. Nothing the environment says about an install is the demo's: with the reader's
854
- // settings in force, 0.3.0-beta.1's demo seeded its example reports into their real archive. Not read,
855
- // rather than deleted from the environment, so a real start cannot be reached by this branch at all.
856
- if (!DEMO) {
857
- for (const [k, name] of [["pool", "CLEAROTRON_REPORTS_DIR"], ["workspace", "CLEAROTRON_WORK_DIR"], ["queue", "CLEAROTRON_QUEUE_DIR"],
858
- ["outbox", "CLEAROTRON_OUTBOX_DIR"], ["locks", "CLEAROTRON_RUN_LOCK_DIR"], ["grants", "CLEAROTRON_ACCESS_FILE"],
859
- ["recipes", "CLEAROTRON_RECIPES_DIR"]]) if (process.env[name]) paths[k] = process.env[name];
860
- if (process.env.RECIPE_REPO_ROOT) paths.configStore = process.env.RECIPE_REPO_ROOT;
861
- if (process.env.PORTAL_AUDIT) paths.audit = process.env.PORTAL_AUDIT;
862
- }
894
+ // who ran `npm run setup` has these in .env already and this must not move their data. Not in a demo,
895
+ // which `startPaths` says why. One author, because `doctor` asks the same function what the services
896
+ // were handed.
897
+ const paths = startPaths({ env: process.env, base: flag("--base", join(homedir(), DEMO ? "trademark-demo" : "trademark")), demo: DEMO });
863
898
  // ── THIS INSTALL'S FIRST START, read before this start writes either file that answers it ────────────
864
899
  //
865
900
  // The grants file and the config store's repository are both written further down, on every start
@@ -1117,14 +1152,19 @@ if (isMain) {
1117
1152
  const portalSecret = secretFor("PORTAL_SECRET");
1118
1153
  const tokenSecret = secretFor("TRADEMARK_MCP_TOKEN_SECRET");
1119
1154
  if (!process.env.PORTAL_LOCAL_USER) generated.PORTAL_LOCAL_USER = user;
1155
+ const stores = DEMO ? {} : storesForOtherReaders(paths);
1120
1156
 
1121
1157
  // A DEMO WRITES NO SECRETS AND NO ADDRESS. They are generated per run and live in memory only, which
1122
1158
  // is the same posture the ops key already has here — and it is what makes "removing the demo is one
1123
1159
  // directory" true rather than nearly true.
1124
- if (Object.keys(generated).length && !DEMO) {
1160
+ //
1161
+ // THE STORES ARE WRITTEN EVEN WHEN NO SECRET IS. An install whose secrets are already in the file is
1162
+ // exactly the install whose connector and doctor could not see its saved searches, and a gate on the
1163
+ // secrets alone would never reach it.
1164
+ if ((Object.keys(generated).length || Object.keys(stores).length) && !DEMO) {
1125
1165
  let existing = "";
1126
1166
  try { existing = readFileSync(ENV_PATH, "utf8"); } catch (e) { if (e.code !== "ENOENT") fatal(`${ENV_PATH} exists but could not be read (${e.code}).`); }
1127
- const merged = mergeEnvFile(existing, generated);
1167
+ const merged = mergeEnvFile(existing, { ...generated, ...stores }, { notes: STORE_NOTES });
1128
1168
  if (merged.added.length) {
1129
1169
  // ONE WRITER FOR EVERY FILE THAT HOLDS CREDENTIALS, and it creates the directory. This site had its
1130
1170
  // own copy of the write and did not learn what the wizard's copy learned when `.env` moved under
@@ -1135,7 +1175,7 @@ if (isMain) {
1135
1175
  } catch (e) {
1136
1176
  fatal(`could not write ${ENV_PATH} (${String(e?.message ?? e)}).`);
1137
1177
  }
1138
- // NAMES only. This file is where the credentials are.
1178
+ // NAMES only. Some of what this file holds is credentials.
1139
1179
  say(` wrote ${merged.added.join(", ")} to ${ENV_PATH} (mode 600)`);
1140
1180
  }
1141
1181
  }
package/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "5c5342ab541f8240732467dfc8bbf3c065ef13f6",
3
- "version": "0.3.0-beta.3"
2
+ "commit": "a9ac3366ee814b9faf6a5d23fe7d0c8f3a5c0d3f",
3
+ "version": "0.3.0-beta.5"
4
4
  }
@@ -1,5 +1,30 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.3.0-beta.5
4
+
5
+ ### Patch Changes
6
+
7
+ - 5f7e295: Fixed: A Clearotron install signed in to OpenAI's `codex` with a subscription keeps working after codex refreshes its login. Before, every search after the first refresh failed within seconds until you signed in again.
8
+ - 2878809: Fixed: When the company store cannot record a new company, the New company page now says why and what fixes it, instead of "Try again shortly". Someone who does not run the installation is told to ask whoever does.
9
+ - e7cd9e1: Fixed: On an install run with `clearotron start`, a connected assistant now lists the same saved searches as the portal, including Generic's. `clearotron doctor` no longer says working saved searches are off, and it names one place profiles come from.
10
+ - e7cd9e1: Fixed: When setup offers to try your engine, it no longer names an Anthropic model to someone who chose OpenAI's `codex`.
11
+
12
+ ## 0.3.0-beta.4
13
+
14
+ ### Minor Changes
15
+
16
+ - a9f5375: New: Use your own AI asks first where Clearotron is running, then shows the steps for your app beside the list. The same five apps are offered either way.
17
+
18
+ New: Claude Code and Codex can connect to an installation running elsewhere, and the ChatGPT desktop app to one on the same machine.
19
+
20
+ Fixed: Codex was told to paste a settings block into a terminal. Its steps now name the file the block goes in.
21
+
22
+ For operators: `clearotron connect` and `clearotron disconnect` take `--where here` or `--where elsewhere`. Assistant names used before, such as `cowork`, still work.
23
+
24
+ ### Patch Changes
25
+
26
+ - a9f5375: New: the company switcher in the sidebar ends with `+ New company`. Making a company is now one click from every screen, whether or not a company is selected.
27
+
3
28
  ## 0.3.0-beta.3
4
29
 
5
30
  ### Patch Changes
@@ -7,9 +32,9 @@
7
32
  - a782aad: Fixed: Creating a company is refused, with nothing left behind, when the configuration store cannot record it. The company used to be created anyway, with no record of who made it or when, and its organisation was given access to it.
8
33
 
9
34
  A store with no git identity is the usual cause on a new machine, and the refusal names the command that fixes it. Setup and `clearotron start` now check a store they adopt for this straight away.
10
- - d1ef225: Fixed: A stage stopped at its time limit now records the output it actually produced. It used to record a small fraction, so a stage that was working read as one that had stalled.
35
+ - d1ef225: Fixed: A search step stopped at its time limit now records the output it actually produced. It used to record a small fraction, so a step that was working read as one that had stalled.
11
36
 
12
- The token totals `clearotron tokens` reports for runs with a stopped stage now include that output.
37
+ The token totals `clearotron tokens` reports for runs with a stopped step now include that output.
13
38
  - 0ff42d1: Fixed: `clearotron doctor` now says when saved searches are switched off and why, and when a saved search file cannot be read.
14
39
 
15
40
  An assistant asking for saved searches is told when they could not be read, instead of being told there are none.
@@ -63,11 +88,11 @@
63
88
 
64
89
  Fixed: A search now records any of your default territories that the engine cannot search, in the record of that search. A mistyped default no longer narrows a search silently.
65
90
  - eacfce4: Fixed: An assistant connected to a local install now sees the saved searches the portal shows, and can plan a run from one of them. Before, outside the demo, only the portal was told where saved searches are kept, so an assistant was told the install had none.
66
- - 34cc1c7: For operators: A health check that could not look now fails instead of reporting success. Two halves of the unit check can go quiet. One goes quiet when the installation does not say which installation it is; the other when the walk over the unit files does not finish. Both used to note that they had not run and then pass.
91
+ - 34cc1c7: For operators: A deployment health check that could not finish now fails instead of reporting success. Two parts of the service check could skip themselves. One skipped when the installation did not say which installation it is; the other when the scan of the service files did not finish. Both used to note that they had not run and then pass.
67
92
 
68
93
  For operators: Set `CLEAROTRON_BOX` to `prod` or `test`. Those are the only two values the check accepts; anything else, including any other name, reads as unnamed and fails. The failure names the setting and says what went unchecked.
69
94
 
70
- For operators: The half that goes quiet is the one that notices a service that has stopped and stayed stopped. The other half lists what is running, so it cannot see something that is no longer there. While that half is suppressed, a service can disappear without the check saying anything.
95
+ For operators: The part that could skip itself is the one that notices a service that has stopped and stayed stopped. The other part lists what is running, so it cannot see something that is no longer there. While the first part is skipped, a service can disappear without the check saying anything.
71
96
  - b45a5cf: Fixed: `clearotron doctor` now reports everything a search would be refused for, checked against the environment the search will run in. So an installation it passes is one that can run a search.
72
97
 
73
98
  Fixed: A token set in the installation's own configuration file now reaches the engine check. A headless server set up the documented way proves its engine instead of reporting it signed out.
@@ -112,7 +137,7 @@
112
137
  Fixed: When a feature is switched off on your installation, the screen says so and what to change. It used to suggest trying again shortly.
113
138
 
114
139
  Fixed: The link to the risk-framework guide opens in a new tab and goes straight to the section on writing your own. It also appears on the Company profile screen.
115
- - ad088d6: Fixed: The audit workbook's "What was searched" sheet is now in plain words. Its Result and Note columns carry the search log's own notes. Engine vocabulary could reach them: a receipt "deferred", a full web address, a bare HTTP code. Those words are now replaced as the workbook is built, a republished report included. A search recorded as not run still reads as not searched. Search terms and names stay exactly as written, because they record what was searched.
140
+ - ad088d6: Fixed: The audit workbook's "What was searched" sheet is now in plain words. Its Result and Note columns carry the search log's own notes. Internal terms could reach them: a status such as "deferred", a full web address, a bare HTTP code. Those words are now replaced as the workbook is built, a republished report included. A search recorded as not run still reads as not searched. Search terms and names stay exactly as written, because they record what was searched.
116
141
  - ff16a3b: For operators: The deployment health check now reports whether the component that updates an installation is itself up to date. It was the one part of a deployment the check could not identify. An installation kept current by an out-of-date updater could report healthy while serving stale code.
117
142
 
118
143
  For operators: An updater that cannot be identified is now reported as a failure rather than passed over. A copy old enough to predate this reporting writes nothing at all. That silence is the case worth knowing about, so it is treated as a finding.
@@ -142,7 +167,7 @@
142
167
 
143
168
  The product now says company throughout. It used to say brand owner, account, client and customer for the same thing. The firm running the installation is named separately, in the top bar.
144
169
 
145
- Companies created through the settings page were saved without a risk framework. Their matters were then rated under the house default, with nothing on screen saying so. Every company created now carries one, and says which.
170
+ Companies created through the settings page were saved without a risk framework. Their matters were then rated under the default risk framework, with nothing on screen saying so. Every company created now carries one, and says which.
146
171
  - f75266d: New: The setup wizard now asks for the organisation's name after the sign-in address, and the person who installs starts with access to everything.
147
172
 
148
173
  `clearotron grant add` sets a person's two permissions with `--run` and `--manage`; with neither, the person can look and start nothing.
@@ -164,18 +189,18 @@
164
189
 
165
190
  Getting the deck's shape wrong used to fail quietly. The profile screen showed the framework's title and your band colours, and silently omitted the box saying what the bands mean. The new command answers that question directly, using the screen's own read of the deck.
166
191
 
167
- Fixed: a framework served from the product's own files, while you have a configuration store set, now says so.
192
+ Fixed: When you have a configuration store set and a risk framework comes from the product's own files instead, the product now says so.
168
193
 
169
194
  Your store is looked in first, and the product's files answer when it is silent. The product ships decks under names you may also have chosen. So a deck that went missing from your store was replaced by ours rather than reported absent. Same band words, different rubric, nothing raised anywhere. The profile screen now writes one line naming what happened, and the new command reports it.
170
195
 
171
- Fixed: the house triage ladder's profile page explains its bands again.
196
+ Fixed: The built-in triage framework's profile page explains its bands again.
172
197
 
173
198
  Its band sections stated their meanings as plain paragraphs, which the screen does not read. Every company without a framework of its own saw band colours and no explanation. The wording is unchanged.
174
- - cfb9a9f: Fixed: `clearotron doctor` now says which companies your portal's trigger key can start.
199
+ - cfb9a9f: Fixed: `clearotron doctor` now lists the companies your portal's key may start runs for.
175
200
 
176
201
  The key carries a list of the companies it may start runs for. A company added after the key was minted is outside it. Doctor reported the key's expiry and never its coverage. So the one command whose job is to tell you what a machine is configured for said nothing about it.
177
202
 
178
- It reads the roster the services read, not the one a command-line process resolves, and it says which. Those two can disagree, and when they do the difference is the whole answer.
203
+ It reads the company list the background services read, not the one a command run in a terminal would find, and it says which. The two can differ, and when they do, that difference is the answer.
179
204
 
180
205
  The line is a note, not a failure. Your portal takes a fresh credential at the start of every call, so a company outside the key is not normally refused. It is refused when the portal cannot take a fresh one, and the line says so and gives you the command to widen the key.
181
206
  - ba2899b: Fixed: On reports searched through Signa, each register finding now links to the office's own page for that record, where the office publishes one. Singapore publishes no such page, so its registrations are cited by number, and the report says why. A number that an office's page cannot take is cited the same way.