clearotron 0.3.0-beta.2 → 0.3.0-beta.4

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 (40) hide show
  1. package/INSTALL.md +32 -6
  2. package/bin/connect.mjs +68 -25
  3. package/bin/disconnect.mjs +16 -11
  4. package/bin/onboard.mjs +65 -6
  5. package/bin/start.mjs +7 -1
  6. package/build-info.json +2 -2
  7. package/driver/CHANGELOG.md +38 -0
  8. package/driver/driver.config.mjs +5 -0
  9. package/driver/engine/anthropic-agent.mjs +34 -17
  10. package/driver/gateway.mjs +76 -15
  11. package/driver/package.json +1 -1
  12. package/driver/portal-service.mjs +1 -1
  13. package/driver/profile-service.mjs +31 -3
  14. package/driver/publish/index.mjs +12 -9
  15. package/driver/publish/knockout.mjs +4 -2
  16. package/driver/publish/office-record-links.mjs +56 -21
  17. package/driver/recipe-service.mjs +14 -5
  18. package/driver/record-origins.mjs +14 -0
  19. package/driver/suite-census.json +34 -28
  20. package/mcp-server/CHANGELOG.md +8 -0
  21. package/mcp-server/lib/driver.mjs +2 -0
  22. package/mcp-server/lib/knockout.mjs +2 -2
  23. package/mcp-server/lib/options.mjs +9 -2
  24. package/mcp-server/package.json +1 -1
  25. package/package.json +1 -1
  26. package/portal-ui/dist/assets/{index-CsCuPshD.css → index-Cv-E_agg.css} +199 -86
  27. package/portal-ui/dist/assets/{index-CcFjgM78.js → index-DWYCsOCJ.js} +514 -380
  28. package/portal-ui/dist/index.html +2 -2
  29. package/portal-ui/package.json +1 -1
  30. package/providers/clarivate/src/core.js +5 -0
  31. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  32. package/providers/oauth-mcp-bridge/package.json +1 -1
  33. package/scripts/e2e-unread-terminals.mjs +1 -1
  34. package/scripts/e2e.mjs +114 -9
  35. package/scripts/revisit-render-check.mjs +22 -4
  36. package/scripts/travelling-predicates.mjs +1 -1
  37. package/shared/connect-clients.mjs +282 -321
  38. package/shared/names-in-force.mjs +1 -0
  39. package/shared/stdio-connect.mjs +63 -0
  40. package/shared/store-in-repo.mjs +50 -2
package/INSTALL.md CHANGED
@@ -491,6 +491,25 @@ matches, the neutral Generic default applies.
491
491
  - **Your real customers live outside the repo.** Point `CLEAROTRON_CUSTOMERS_DIR` at your own private
492
492
  config store and the engine loads *those* accounts instead. **Same engine, different config path** —
493
493
  the code carries no customer identities.
494
+
495
+ Two things go with it, and both are refusals rather than preferences:
496
+
497
+ - **`PROFILE_REPO_ROOT` moves too.** The customer directory has to sit inside the repository that
498
+ variable names, because editing a profile is a commit. Point one somewhere new and leave the other
499
+ behind and the portal and the profile service both refuse to start, naming both variables.
500
+ - **That repository needs a `user.name` and a `user.email` of its own.** Saves are committed under
501
+ the name of whoever asked for them, but git also records who *made* the commit, and it will not
502
+ commit at all without one. A service account usually has no global git identity, so a store created
503
+ by hand needs its own:
504
+
505
+ ```bash
506
+ git init -b main /srv/clearotron-store
507
+ git -C /srv/clearotron-store config user.name "clearotron local install"
508
+ git -C /srv/clearotron-store config user.email "clearotron@example.com"
509
+ ```
510
+
511
+ `clearotron start` does this for the store it creates. A store you make yourself does not get it,
512
+ and the symptom is the first save failing at a commit rather than anything about profiles.
494
513
  - **Run data is external too.** Published reports, audits, and per-run state go to the archive pool at
495
514
  `CLEAROTRON_REPORTS_DIR`. Nothing customer-specific is committed to the repository.
496
515
 
@@ -903,12 +922,19 @@ their own integration work.
903
922
  **Two shapes, and the line between them is what your assistant can do — not where it runs.** Ruling
904
923
  2026-09-03, on the vendor's own documented behaviour:
905
924
 
906
- 1. **An assistant that can launch a local process** — Claude Code, Claude Desktop's local config,
907
- Codex CLI, an agent that runs commands. It spawns the server over stdio. No address, no key, no
908
- network, no ingress. `npx clearotron start` prints the one line to paste, and `npx clearotron
909
- connect` hands it over per assistant.
910
- 2. **Everything else, wherever it appears to run** — Cowork, ChatGPT, claude.ai, Perplexity, the mobile
911
- apps. These need **a publicly reachable HTTPS address**, plus a key. Always.
925
+ 1. **Clearotron is installed on the machine the assistant runs on** — Claude's desktop app, Claude Code,
926
+ Codex, the ChatGPT desktop app, an agent that runs commands. The assistant spawns the server over
927
+ stdio. No address, no key, no network, no ingress. `npx clearotron start` prints the one line to
928
+ paste, and `npx clearotron connect --where here` hands it over per assistant.
929
+ 2. **Clearotron is running somewhere else** — a server, a cloud machine, anywhere the assistant is
930
+ not. These need **a publicly reachable HTTPS address**, plus a key made for the person connecting.
931
+ Always. Claude (app, web, Cowork and mobile), ChatGPT on the web, Perplexity and other agents only
932
+ ever connect this way; Claude Code and Codex can connect either way. `npx clearotron connect
933
+ --where elsewhere` makes the key and prints the steps for the assistant you name.
934
+
935
+ Without `--where`, `connect` asks when both answers are possible, and `--client <name>` on its own keeps
936
+ the answer that assistant had before: a key for Claude, the local line for Claude Code and Codex.
937
+ `npx clearotron disconnect` takes the same `--where`.
912
938
 
913
939
  **A loopback address is never an answer for shape 2, and that is not about your network.** A remote MCP
914
940
  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
@@ -97,7 +97,7 @@ import { processTable } from "../shared/process-table.mjs"; // — /proc is no
97
97
  import { programsFromAnotherCheckout } from "../shared/checkout-move.mjs";
98
98
  import { entrypointOf } from "../driver/systemd/install-census.mjs"; // one ExecStart parser
99
99
  import { overlayReport, renderOverlayReport } from "../shared/doctrine-overlay.mjs"; // — the doctor reports the overlay
100
- import { whereSavesGo } from "../shared/store-in-repo.mjs"; // — doctor says where a portal save goes once it is committed
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
102
  import { probeEngineTurn, probeFailureText, PROBE_MODEL, PROBE_TIMEOUT_SEC, engineEnvKeys } from "../driver/engine/probe.mjs";
103
103
  import { runRequiredNames, missingRequirements, REGISTER_ENV, ENGINE_ENV } from "../driver/run-requirements.mjs"; // the order-time gate's own question, asked here rather than restated
@@ -1799,6 +1799,41 @@ export async function runCheck() {
1799
1799
  }
1800
1800
  }
1801
1801
 
1802
+ // — SAVED SEARCHES, JUDGED BY THE RULE THE PORTAL APPLIES WHEN IT STARTS. The portal switches them off,
1803
+ // and answers every saved-search route "not found", when the store is unset or sits outside the
1804
+ // repository its saves are committed in. Its boot log said so and nothing an operator runs did, so an
1805
+ // install whose Custom searches could never load passed this command. Same resolver, same containment
1806
+ // check, read from the services' environment for the reason the roster lines give; when that could not
1807
+ // be read, the lines above already said so and nothing is judged here. A store that is configured but
1808
+ // holds a file that cannot be read fails every company's saved searches, in the portal and the
1809
+ // connector alike, so that is read here too.
1810
+ if (!hosted || serviceKnown) {
1811
+ const recipesSet = hosted ? effectiveForService("CLEAROTRON_RECIPES_DIR") : effective("CLEAROTRON_RECIPES_DIR");
1812
+ const recipesDir = recipesSet?.v || null;
1813
+ if (!recipesDir) {
1814
+ info("saved searches are off: CLEAROTRON_RECIPES_DIR is not set, so Custom searches in the portal and the "
1815
+ + "connector offer none. Name a directory inside a git repository to switch them on");
1816
+ } 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
+ const resolved = resolveStoreRepoRoot({ names: ["RECIPE_REPO_ROOT", "PROFILE_REPO_ROOT"], fallback: REPO, env: storeEnv });
1821
+ const reach = storeInRepo(recipesDir, resolved.root);
1822
+ if (!reach.ok) {
1823
+ warn(`saved searches are OFF: ${storeOutsideRepoMessage({ storeVar: "CLEAROTRON_RECIPES_DIR", storeDir: reach.store, repoVar: "RECIPE_REPO_ROOT", repoRoot: reach.repo })} `
1824
+ + `The repository came from ${resolved.from}. Until this is fixed the portal answers every saved-search request as not found`);
1825
+ } else {
1826
+ const { loadRecipes } = await import("../driver/search-policy.mjs");
1827
+ let unreadable = null;
1828
+ try { loadRecipes({ dir: recipesDir, force: true }); } catch (e) { unreadable = String(e?.message ?? e).split("\n")[0]; }
1829
+ if (unreadable) {
1830
+ warn(`saved searches cannot be read from ${recipesDir}: ${unreadable}. Every company's saved searches fail `
1831
+ + "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}`);
1833
+ }
1834
+ }
1835
+ }
1836
+
1802
1837
  // — WHICH DOCTRINE FILES THIS INSTALL OVERRIDES, AND WHETHER OURS HAVE MOVED UNDER THEM.
1803
1838
  //
1804
1839
  // The report itself shipped in and worked, reachable only as `npm run doctrine-report` — a name
@@ -1846,7 +1881,21 @@ export async function runCheck() {
1846
1881
  }
1847
1882
 
1848
1883
  say("\n Register provider");
1849
- const prov = effective("CLEAROTRON_DATABASE");
1884
+ // WHAT THE SERVICES WILL SEARCH, READ AS THE ORDER-TIME CHECK BELOW READS IT. This section asked the
1885
+ // shell and this command's .env, so in a fresh terminal on a hosted box it told a working install that
1886
+ // no register was selected, one screen above "nothing a search is refused for is missing from the units'
1887
+ // environment": one install, two answers, and the fix it named was already set. effectiveForService is
1888
+ // the reader that check asks, so the two lines cannot disagree; where it cannot read the units, this
1889
+ // says it could not look rather than reporting the register absent.
1890
+ // Only a HOSTED box has a second environment to read; with no units, the services are started from
1891
+ // this command's own file, and `effective` has always read and labelled exactly that.
1892
+ const serviceValue = hosted ? effectiveForService : effective;
1893
+ const prov = serviceKnown ? serviceValue("CLEAROTRON_DATABASE") : null;
1894
+ if (!serviceKnown) {
1895
+ info(`the units are installed but their environment could not be read (${unitEnv?.why ?? "no reason given"}) — `
1896
+ + "which register the services search, and whether its keys are set, is not judged here: a failure to look, not a finding");
1897
+ }
1898
+ else
1850
1899
  // `blocking`, not `warn` and not `problem`. The exit status is a CONTRACT — an absence reports and
1851
1900
  // exits 0, a misconfiguration exits 1, and onboard-wizard.test.mjs holds it — so this cannot become a
1852
1901
  // `problem` however much it stops the reader: an install that has not chosen a register yet is
@@ -1865,7 +1914,7 @@ export async function runCheck() {
1865
1914
  else {
1866
1915
  ok(`${spec.id} — ${spec.label} (${prov.from})`);
1867
1916
  for (const k of spec.credentials) {
1868
- const c = effective(k);
1917
+ const c = serviceValue(k);
1869
1918
  // issue 1871 — SET, not WORKING, and the line now says which. An operator reads a tick as "this
1870
1919
  // works"; this one is equally true of a valid key, an expired key, a key scoped to the wrong
1871
1920
  // account and forty characters of nonsense. --probe-providers is what settles it.
@@ -1876,7 +1925,7 @@ export async function runCheck() {
1876
1925
  // and never as a problem, but never silently either: the reader has to know which offices this
1877
1926
  // box will not reach before they read a report that says nothing was found there.
1878
1927
  for (const k of spec.optionalCredentials ?? []) {
1879
- const c = effective(k);
1928
+ const c = serviceValue(k);
1880
1929
  if (c) ok(`${k} present (${c.from}) — presence only; add --probe-providers to prove it retrieves`);
1881
1930
  else info(`${k} is NOT set — ${spec.id} will run without it and DISCLOSE the offices it cannot reach as deferred coverage. Set it to search them.`);
1882
1931
  }
@@ -1884,8 +1933,10 @@ export async function runCheck() {
1884
1933
  }
1885
1934
 
1886
1935
  say("\n Research provider");
1887
- const px = effective("PERPLEXITY_API_KEY");
1888
- if (px) ok(`PERPLEXITY_API_KEY present (${px.from}) presence only; add --probe-providers to prove it answers`);
1936
+ // The same reader as the register, for the same reason: this is a key the services use.
1937
+ const px = serviceKnown ? serviceValue("PERPLEXITY_API_KEY") : null;
1938
+ if (!serviceKnown) info("the units' environment could not be read, so whether the services hold PERPLEXITY_API_KEY is not judged here — a failure to look, not a finding");
1939
+ else if (px) ok(`PERPLEXITY_API_KEY present (${px.from}) — presence only; add --probe-providers to prove it answers`);
1889
1940
  else info("PERPLEXITY_API_KEY is not set — the three clearance searches carry the common-law grid and cannot switch it off, so a clearance stops before it starts, and names the missing key; a Knockout search still runs and discloses the half it skipped");
1890
1941
 
1891
1942
  // ── — THE LANES A PRODUCT DECLARES IT NEEDS, BEFORE A REPORT NAMES THEM ──
@@ -3644,6 +3695,14 @@ try {
3644
3695
  candidate["CLEAROTRON_CUSTOMERS_DIR"] = join(cfg, "profiles");
3645
3696
  candidate.PROFILE_REPO_ROOT = cfg; // no alias row — this name is current
3646
3697
  for (const k of ["CLEAROTRON_CUSTOMERS_DIR", "PROFILE_REPO_ROOT"]) ok(`${k}=${candidate[k]}`);
3698
+ // A REPOSITORY ALREADY THERE IS ASKED NOW, while the operator is still here, whether it can record a
3699
+ // save. `clearotron start` gives a store it creates an identity of its own; one made by hand has none
3700
+ // unless somebody set it, and on a machine with no global identity the first company created in the
3701
+ // portal is refused. Said here, with the command, rather than discovered on that first company.
3702
+ if (existsSync(join(cfgAbs, ".git"))) {
3703
+ const cannot = storeCommitRefusal(cfgAbs);
3704
+ if (cannot) warn(`${cannot.message}. Until then, creating a company in the portal is refused.`);
3705
+ }
3647
3706
 
3648
3707
  // CLEAROTRON_INSTRUCTIONS_DIR IS DELIBERATELY NOT WRITTEN (found in review).
3649
3708
  //
package/bin/start.mjs CHANGED
@@ -109,7 +109,7 @@ async function runTables() {
109
109
  return { registers: PROVIDERS, engines: ENGINE_BINARIES, defaultEngine: RUN_DEFAULT_ENGINE };
110
110
  }
111
111
  import { spawn, spawnSync, execFileSync } from "node:child_process";
112
- import { storeInRepo, storeOutsideRepoMessage } from "../shared/store-in-repo.mjs"; //
112
+ import { storeInRepo, storeOutsideRepoMessage, storeCommitRefusal } from "../shared/store-in-repo.mjs"; //
113
113
  import { stdioConnectOffer } from "../shared/stdio-connect.mjs";
114
114
  import { mergeEnvFile } from "../shared/env-file-merge.mjs";
115
115
  import { mcpOriginFor } from "../shared/lane-address.mjs"; // — one author for the origin
@@ -1252,6 +1252,12 @@ if (isMain) {
1252
1252
  // search is what will fail.
1253
1253
  err(` WARNING: could not initialise the saved-search store at ${paths.configStore} (${String(e?.message ?? e)}). Searches will list and run; SAVING one will fail until this is a git repository.`);
1254
1254
  }
1255
+ } else {
1256
+ // AN ADOPTED STORE IS ASKED WHETHER IT CAN RECORD A SAVE, HERE, not at the first save. A repository
1257
+ // made by hand has no identity unless somebody gave it one, and on a machine with no global identity
1258
+ // the first company created in the portal is then refused. The one created above sets its own.
1259
+ const cannot = storeCommitRefusal(paths.configStore);
1260
+ if (cannot) err(` WARNING: ${cannot.message}. Until then, creating a company or saving a search is refused.`);
1255
1261
  }
1256
1262
 
1257
1263
  // ── THE DEMO'S OWN STORE, WITH ITS COMPANY IN IT ────────────────────────────────────────────────
package/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "f24d87c471bd3073f3a56dbf0edff39d1a640175",
3
- "version": "0.3.0-beta.2"
2
+ "commit": "0fb023f5822c9458f9c63bf993876c3a3a8053e3",
3
+ "version": "0.3.0-beta.4"
4
4
  }
@@ -1,5 +1,43 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.3.0-beta.4
4
+
5
+ ### Minor Changes
6
+
7
+ - 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.
8
+
9
+ New: Claude Code and Codex can connect to an installation running elsewhere, and the ChatGPT desktop app to one on the same machine.
10
+
11
+ Fixed: Codex was told to paste a settings block into a terminal. Its steps now name the file the block goes in.
12
+
13
+ For operators: `clearotron connect` and `clearotron disconnect` take `--where here` or `--where elsewhere`. Assistant names used before, such as `cowork`, still work.
14
+
15
+ ### Patch Changes
16
+
17
+ - 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.
18
+
19
+ ## 0.3.0-beta.3
20
+
21
+ ### Patch Changes
22
+
23
+ - 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.
24
+
25
+ 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.
26
+ - 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.
27
+
28
+ The token totals `clearotron tokens` reports for runs with a stopped stage now include that output.
29
+ - 0ff42d1: Fixed: `clearotron doctor` now says when saved searches are switched off and why, and when a saved search file cannot be read.
30
+
31
+ An assistant asking for saved searches is told when they could not be read, instead of being told there are none.
32
+ - a782aad: Fixed: `clearotron doctor` reports the register and the research key the background services will use, read from the file they read. Run from a new terminal, it used to say no register was selected on an install whose searches were running.
33
+ - 0ff42d1: Fixed: Global config appears in the account menu only for people who can open it. Someone managing one organisation used to be offered it, and the page then said it was not available.
34
+
35
+ Clearances and People no longer ask for installation-wide data that a manager of one organisation cannot see.
36
+ - a782aad: Fixed: A clearance or knockout searched through Compumark now links each register record to the trade mark office's own page for it. Those records used to show an internal reference nobody could open.
37
+
38
+ The offices linked are the United States, the European Union, the United Kingdom, Canada, Australia, Switzerland, France, Norway, Sweden and WIPO. A record from any other office is cited by its number, and the report says why once, under the findings.
39
+ - a782aad: Fixed: Saved searches work on a fresh install and in the demo, for every company, Generic included. They used to fail to load for every company, with a message saying to try again shortly.
40
+
3
41
  ## 0.3.0-beta.2
4
42
 
5
43
  ### Minor Changes
@@ -1110,6 +1110,11 @@ export const PROVIDERS = {
1110
1110
  office: row?.office ?? null,
1111
1111
  application_date: row?.application_date ?? null,
1112
1112
  registration_date: row?.registration_date ?? null,
1113
+ // The office's own numbers: publish addresses the filing at the office's page from these, the
1114
+ // way it does for this register's clearance findings. Compumark states no filing route or IR
1115
+ // number of its own, so a Madrid designation is addressed as the national filing it is listed as.
1116
+ application_number: row?.application_number ?? null,
1117
+ registration_number: row?.registration_number ?? null,
1113
1118
  // hasPublicRecordUrl:false on this provider — there is no per-record page to link, and a
1114
1119
  // fabricated one would be worse than none.
1115
1120
  record_url: null,
@@ -467,19 +467,32 @@ export const anthropicAgentEngine = {
467
467
  let rateLimitEvent = null; // a 429 session-cap rejection rides a `rate_limit_event` (status:"rejected" + resetsAt)
468
468
  // Streamed-usage accumulator: what the turn PROVABLY moved, observed from the stream itself, so a
469
469
  // killed turn is never journalled as usage:null when millions of tokens moved (the "137 + usage:null
470
- // ⇒ mislabelled transient/lane-wedge" class). Per completed API call the `assistant` event's usage is
471
- // authoritative (summed); an in-flight call contributes its `message_start` usage + the latest
472
- // `message_delta` output count until its own assistant event supersedes them (no double count).
473
- const streamTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
474
- let pendingStart = null, pendingDeltaOut = 0;
470
+ // ⇒ mislabelled transient/lane-wedge" class).
471
+ //
472
+ // KEYED BY MESSAGE, AND EACH FIELD IS THE LARGEST READING SEEN FOR THAT MESSAGE. One API call's usage
473
+ // reaches the stream several times: on `message_start`, on every `assistant` event (one per content
474
+ // block), and on its `message_delta`, whose output count is the call's own running total. Summing the
475
+ // assistant events counted a call's input and cache once per block and its output at whatever it
476
+ // was when the block went out, and dropping the delta lost the rest: a turn killed after 75 calls
477
+ // journalled 810 output tokens. Which event carries the final count need not be known here, and the
478
+ // order is inferred rather than recorded, so every reading is kept and the largest wins. A delta names
479
+ // no message, so it belongs to the one its last `message_start` opened; an assistant event with no id
480
+ // and no open message is a call of its own, as it always was.
481
+ const perMessage = new Map();
482
+ let openMessage = null, anonMessages = 0;
483
+ const foldUsage = (key, u) => {
484
+ if (!u) return;
485
+ const was = perMessage.get(key);
486
+ perMessage.set(key, was
487
+ ? { input: Math.max(was.input, u.input), output: Math.max(was.output, u.output),
488
+ cacheRead: Math.max(was.cacheRead, u.cacheRead), cacheWrite: Math.max(was.cacheWrite, u.cacheWrite) }
489
+ : { input: u.input, output: u.output, cacheRead: u.cacheRead, cacheWrite: u.cacheWrite });
490
+ };
475
491
  const streamedUsage = () => {
476
- const p = mapUsage(pendingStart) ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
477
- const u = {
478
- input: streamTotals.input + p.input,
479
- output: streamTotals.output + p.output + pendingDeltaOut,
480
- cacheRead: streamTotals.cacheRead + p.cacheRead,
481
- cacheWrite: streamTotals.cacheWrite + p.cacheWrite,
482
- };
492
+ const u = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
493
+ for (const m of perMessage.values()) {
494
+ u.input += m.input; u.output += m.output; u.cacheRead += m.cacheRead; u.cacheWrite += m.cacheWrite;
495
+ }
483
496
  u.total = u.input + u.output + u.cacheRead + u.cacheWrite;
484
497
  return u.total > 0 ? u : null; // zero observed movement stays null — a never-admitted turn must keep classifying as a lane wedge
485
498
  };
@@ -703,9 +716,9 @@ export const anthropicAgentEngine = {
703
716
  periodStart = now; periodChunk = chunkSeq;
704
717
  }
705
718
  syncOpenAsk();
706
- const u = mapUsage(ev.message?.usage);
707
- if (u) { streamTotals.input += u.input; streamTotals.output += u.output; streamTotals.cacheRead += u.cacheRead; streamTotals.cacheWrite += u.cacheWrite; }
708
- pendingStart = null; pendingDeltaOut = 0; // this call's usage is now authoritative — drop its partials
719
+ // This block's usage, folded into its own call: the largest reading wins, so a block's early count
720
+ // never replaces the delta's running total, and a second block of the same call adds nothing twice.
721
+ foldUsage(ev.message?.id ?? openMessage ?? `__assistant_${++anonMessages}`, mapUsage(ev.message?.usage));
709
722
  progress();
710
723
  }
711
724
  else if (ev.type === "user") {
@@ -737,8 +750,12 @@ export const anthropicAgentEngine = {
737
750
  }
738
751
  else if (ev.type === "stream_event") {
739
752
  const t = ev.event?.type;
740
- if (t === "message_start") { pendingStart = ev.event.message?.usage ?? null; progress(); }
741
- else if (t === "message_delta") { const o = Number(ev.event?.usage?.output_tokens); if (o > 0) pendingDeltaOut = o; progress(); }
753
+ if (t === "message_start") {
754
+ openMessage = ev.event.message?.id ?? `__start_${++anonMessages}`;
755
+ foldUsage(openMessage, mapUsage(ev.event.message?.usage));
756
+ progress();
757
+ }
758
+ else if (t === "message_delta") { foldUsage(openMessage ?? "__delta_without_start", mapUsage(ev.event?.usage)); progress(); }
742
759
  else if (t === "content_block_delta" || t === "content_block_start") progress();
743
760
  // THINKING GAUGE (partials): the earliest tells. Any one is sufficient; belt-and-braces so a
744
761
  // display-mode or CLI-version change cannot silently blind the gauge.