clearotron 0.3.0-beta.5 → 0.3.0-beta.7

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 (42) hide show
  1. package/.env.example +13 -3
  2. package/INSTALL.md +11 -0
  3. package/bin/connect.mjs +3 -1
  4. package/bin/onboard.mjs +38 -18
  5. package/bin/start.mjs +60 -2
  6. package/bin/update.mjs +38 -6
  7. package/build-info.json +2 -2
  8. package/demo/full-country-search/run/status.json +1 -1
  9. package/demo/global-preliminary-search/run/status.json +1 -1
  10. package/demo/knockout-search/run/email-body.md +1 -1
  11. package/demo/knockout-search/run/status.json +2 -2
  12. package/demo/multi-country-focus-search/run/status.json +1 -1
  13. package/docs/architecture/04-configuration-reference.md +2 -1
  14. package/driver/CHANGELOG.md +25 -0
  15. package/driver/contract-e3-backlog.mjs +1 -1
  16. package/driver/demo-container.mjs +65 -2
  17. package/driver/engine/openai-agent.mjs +8 -0
  18. package/driver/enqueue-schema.mjs +3 -1
  19. package/driver/gateway.mjs +12 -0
  20. package/driver/package.json +1 -1
  21. package/driver/portal-local-auth.mjs +35 -3
  22. package/driver/portal-service.mjs +80 -18
  23. package/driver/portal-upstream.mjs +11 -1
  24. package/driver/search-policy.mjs +7 -2
  25. package/driver/suite-census.json +63 -27
  26. package/mcp-server/CHANGELOG.md +8 -0
  27. package/mcp-server/CONNECT.md +3 -2
  28. package/mcp-server/lib/options.mjs +9 -4
  29. package/mcp-server/package.json +1 -1
  30. package/package.json +1 -1
  31. package/portal-ui/package.json +1 -1
  32. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  33. package/providers/oauth-mcp-bridge/package.json +1 -1
  34. package/scripts/e2e.mjs +84 -12
  35. package/scripts/env-classify.mjs +3 -0
  36. package/shared/brand.mjs +7 -3
  37. package/shared/connect-clients.mjs +8 -0
  38. package/shared/names-in-force.mjs +1 -0
  39. package/shared/permanent-install.mjs +203 -0
  40. package/shared/stdio-connect.mjs +32 -14
  41. package/shared/verb-shim.mjs +10 -1
  42. package/shared/wsl.mjs +23 -0
package/.env.example CHANGED
@@ -399,6 +399,15 @@ CLEAROTRON_DEMO=
399
399
  # effect: deployment
400
400
  # CLEAROTRON_INVOKED_AS=
401
401
 
402
+ # Marks an install that has already moved itself out of npm's npx cache. `npx clearotron install` first
403
+ # installs the same version under ~/.local and then runs that copy's install, with this set to 1, so the
404
+ # copy does not try to move itself a second time. Unset, an install running from npx's cache moves.
405
+ # Set by bin/onboard.mjs on the one process it starts, never by an operator, so the row is left
406
+ # commented. It is listed for the reason CLEAROTRON_DEMO is: the catalogue is about what an operator can
407
+ # FIND. Read by bin/onboard.mjs.
408
+ # effect: deployment
409
+ # CLEAROTRON_RELOCATED=
410
+
402
411
  # How many client what-ifs the runner drains at once (default 1). A what-if re-runs ONE stage of a run
403
412
  # that already exists, in a sandbox, at a client's request — the owner opened it to clients on
404
413
  # 2026-08-27 and ruled SPEND controls out with it ("ignore the call spend"). This is not one.
@@ -552,8 +561,9 @@ CLEAROTRON_DEMO_PROFILES=
552
561
  # Your organisation's name. `npm run setup` asks for it (and, on a local install, not for a sign-in
553
562
  # address) and writes it here, quoted, so a name holding a `#` survives. The first `clearotron start` files it as the first
554
563
  # organisation in the grants file (CLEAROTRON_ACCESS_FILE) when that file holds none; from then on the
555
- # grants file holds the name, renaming is an edit there, and this is not read. Unset or empty, no
556
- # organisation is invented. `clearotron start --organisation <name>` supplies it for one start, and a
557
- # demo never reads it. Read by bin/start.mjs.
564
+ # grants file holds the name and renaming is an edit there. The portal also reads it, to name the
565
+ # organisation on its top bar and on its sign-in refusal page. Unset or empty, no organisation is
566
+ # invented. `clearotron start --organisation <name>` supplies it for one start, and a demo never reads
567
+ # it. Read by bin/start.mjs and shared/brand.mjs.
558
568
  # effect: deployment
559
569
  CLEAROTRON_ORGANISATION_NAME=
package/INSTALL.md CHANGED
@@ -64,6 +64,15 @@ run is [mcp-server/CONNECT.md](mcp-server/CONNECT.md), and why something is the
64
64
  npx clearotron install
65
65
  ```
66
66
 
67
+ Run from `npx`, the install first installs Clearotron under `~/.local`, as `npm install -g --prefix
68
+ ~/.local` would, and finishes from there. That way the `clearotron` command and your assistant's connection
69
+ do not point into npm's temporary cache, which npm replaces on an update and deletes when it cleans up.
70
+ Later, the `update` command updates that copy in place.
71
+
72
+ On WSL, a program on the Windows side can hold a port that WSL reports as free, and the browser reaches
73
+ it first. VS Code's Remote-SSH port forwarding is the common case. If the page that opens is not this
74
+ install's sign-in, run the same command again with `--port 28802`, or any free number.
75
+
67
76
  A *hosted* deployment needs Linux for one further thing, the systemd outbox trigger —
68
77
  [driver/systemd/README.md](driver/systemd/README.md).
69
78
  - **A reasoning CLI on your `PATH`, signed in.** This is the prerequisite people miss. Every stage runs
@@ -754,6 +763,8 @@ Use the demo to see what this system produces. Use `npx clearotron start` to run
754
763
  - Creates `~/trademark/` — `pool/`, `workspace/`, `queue/`, `outbox/`, `locks/`, an empty grants file,
755
764
  and a small git repository for saved searches. Same base directory `npx clearotron install` uses, so whichever
756
765
  of the two you ran first, the other finds the same install. Move it with `npx clearotron start --base <dir>`.
766
+ That does not move anything the env file already names: the saved-search lines above, and the data
767
+ directories `npx clearotron install` wrote, keep pointing at the old place until you edit them.
757
768
  - Mints your sign-in passphrase and **prints it once**. Write it down. It is stored as a scrypt digest in
758
769
  `~/trademark/portal-local-credential.json`, nothing can read it back, and no later start reprints it. To
759
770
  get a new one, run `clearotron passphrase --reset`. An install that has been signing in with
package/bin/connect.mjs CHANGED
@@ -47,6 +47,7 @@ import { execFileSync } from "node:child_process";
47
47
  import { createServer } from "node:net";
48
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
+ import { isWsl } from "../shared/wsl.mjs";
50
51
  import { defaultDenylistPath, clientDoorAddress, clientDoorPort, clientDoorState, enablePlan, applyEnablePlan, describeChange, recordConnectKey, CLIENT_DOOR_UNIT } from "../shared/client-door.mjs";
51
52
  import { mintToken, tokenId, resolvePerson, loadGrants } from "../shared/scope.mjs";
52
53
  import { envFrom } from "../shared/env-aliases.mjs";
@@ -205,7 +206,8 @@ function deploymentHas(env = process.env) {
205
206
  return {
206
207
  // EVERY SHAPE, RESOLVED ONCE. A row picks its own; nothing here knows a client's name.
207
208
  stdioRoutes: Object.fromEntries(Object.keys(STDIO_SHAPES).map((shape) =>
208
- [shape, stdioConnectFor(shape, { workDir: env.CLEAROTRON_WORK_DIR || null })])),
209
+ [shape, stdioConnectFor(shape, { workDir: env.CLEAROTRON_WORK_DIR || null, reportsDir: env.CLEAROTRON_REPORTS_DIR || null })])),
210
+ wsl: isWsl({ env }),
209
211
  // WHERE THE DOOR BINDS — not an address handed to any assistant. It is the loopback address the
210
212
  // unit listens on, and `enablePlan` needs it to write the unit. It used to be passed to the
211
213
  // resolver as `localAddress` and served to Cowork as somewhere to connect, which is the false
package/bin/onboard.mjs CHANGED
@@ -68,6 +68,8 @@ import { nodeFloorVerdict } from "../shared/node-floor.mjs"; // — the floor
68
68
  import { invocationForm } from "../shared/invocation.mjs"; // — and WHY that form
69
69
  import { standFrom } from "../shared/invocation.mjs"; // is this tree one npm replaces?
70
70
  import { installShim } from "../shared/verb-shim.mjs"; // — the verb goes on PATH
71
+ import { relocationPlan } from "../shared/permanent-install.mjs"; // — and the program out of npx's cache
72
+ import { isWsl } from "../shared/wsl.mjs"; // — one answer to "is this WSL", shared with the connect lines
71
73
  import { styleFor, banner } from "../shared/tty-style.mjs"; // — weight where the meaning is
72
74
  import { bracketAsciiCells, BRAND } from "../shared/brand.mjs"; // F18 — the mark, from the geometry the SVG already uses
73
75
  // THE REFUSALS ABOUT THE SIGN-IN ADDRESS ITSELF, shared with `bin/start.mjs`. Two copies would be a
@@ -703,23 +705,8 @@ export function resolveEngineBin(bin, { env = process.env, wsl = null, onWindows
703
705
  /** A path on a Windows drive as WSL mounts it. */
704
706
  export const ON_A_WINDOWS_DRIVE = /^\/mnt\/[a-z]\//i;
705
707
 
706
- /**
707
- * Whether this is a Linux running under Windows.
708
- *
709
- * BOTH SIGNALS INJECTABLE, for the reason `platformEngineRefusal` gives: the readers this protects
710
- * are the ones who cannot run this suite to find out, so a Linux runner has to be able to drive both
711
- * answers rather than read the source and agree with it.
712
- *
713
- * A READ THAT FAILS ANSWERS "NOT WSL", and that is the direction that changes nothing: it leaves the
714
- * resolution exactly as it was before this existed. Claiming WSL on a could-not-read would start
715
- * refusing candidates under /mnt on an ordinary Linux box with an ordinary mount.
716
- */
717
- export function isWsl({ env = process.env, procVersion = null } = {}) {
718
- if (String(env.WSL_DISTRO_NAME ?? "").trim()) return true;
719
- if (String(env.WSL_INTEROP ?? "").trim()) return true;
720
- const v = procVersion ?? (() => { try { return readFileSync("/proc/version", "utf8"); } catch { return ""; } })();
721
- return /microsoft|wsl/i.test(v);
722
- }
708
+ /** Whether this is a Linux running under Windows: the one answer, from shared/wsl.mjs. */
709
+ export { isWsl };
723
710
 
724
711
  /**
725
712
  * What to say about candidates passed over because they sit on a Windows drive — or `null` when none
@@ -1860,7 +1847,7 @@ export async function runCheck() {
1860
1847
  try { loadRecipes({ dir: recipesDir, force: true }); } catch (e) { unreadable = String(e?.message ?? e).split("\n")[0]; }
1861
1848
  if (unreadable) {
1862
1849
  warn(`saved searches cannot be read from ${recipesDir}: ${unreadable}. Every company's saved searches fail `
1863
- + "to load, in the portal and the connector, until that file is fixed");
1850
+ + "to load, in the portal and the connector, until it is fixed");
1864
1851
  } else ok(`saved searches are read from ${recipesDir}${handedBy}, and saves are committed in ${reach.repo}`);
1865
1852
  }
1866
1853
  }
@@ -2933,6 +2920,39 @@ if (!input.isTTY) {
2933
2920
  process.exit(2);
2934
2921
  }
2935
2922
 
2923
+ // ── OUT OF NPX'S CACHE, BEFORE ANYTHING IS WRITTEN ─────────────────────────────────────────────────────
2924
+ //
2925
+ // Run from npx, this program lives in npm's cache, and everything below would be wired to a directory npm
2926
+ // deletes: the launcher, and the connect line an assistant is registered with. So the install first puts
2927
+ // this same version somewhere permanent (shared/permanent-install.mjs) and runs itself from there. Nothing
2928
+ // has been written yet, so a failure here costs nothing, and it stops rather than carrying on: an install
2929
+ // finished from the cache is the defect, not a fallback.
2930
+ const move = relocationPlan();
2931
+ if (move && !move.skip && process.env.CLEAROTRON_RELOCATED !== "1") {
2932
+ say(`\n This is running from npm's temporary npx cache. Installing clearotron ${move.version} to ${move.prefix}`);
2933
+ say(" first, so the launcher and your assistants keep working after npm cleans that cache or you update.\n");
2934
+ // The npm that launched this, when npm says which: no second npm is guessed at.
2935
+ const npmCli = process.env.npm_execpath;
2936
+ const r = npmCli && existsSync(npmCli)
2937
+ ? spawnSync(process.execPath, [npmCli, ...move.npmArgs], { stdio: "inherit" })
2938
+ : spawnSync("npm", move.npmArgs, { stdio: "inherit" });
2939
+ if (r.status !== 0 || !existsSync(move.entry)) {
2940
+ console.error(`\n Could not install clearotron to ${move.prefix}${r.error ? ` (${r.error.message})` : ""}. Nothing was installed, and nothing of yours was changed.`);
2941
+ console.error(` Run \`npm install --global --prefix ${move.prefix} clearotron@${move.version}\`, then \`${join(move.prefix, "bin", "clearotron")} install\`.\n`);
2942
+ process.exit(1);
2943
+ }
2944
+ // THE REST OF THE INSTALL RUNS FROM THE PERMANENT COPY, with npm's marks of an npx arrival taken off, so
2945
+ // it prints the commands of the install it now is.
2946
+ const env = { ...process.env, CLEAROTRON_RELOCATED: "1" };
2947
+ for (const k of ["npm_command", "npm_lifecycle_event", "npm_execpath"]) delete env[k];
2948
+ const moved = spawnSync(process.execPath, [move.entry, "install", ...process.argv.slice(2)], { stdio: "inherit", env });
2949
+ process.exit(moved.status ?? 1);
2950
+ }
2951
+ if (move?.skip) {
2952
+ console.error(`\n Note: this is running from npm's temporary npx cache and cannot be moved out of it here (${move.skip}).`);
2953
+ console.error(" It will stop working when npm cleans that cache. `npm install -g clearotron` installs it permanently.\n");
2954
+ }
2955
+
2936
2956
  // A credential typed at a prompt is echoed by the terminal and then sits in scrollback, in tmux history,
2937
2957
  // in whatever the reader pastes into a bug report. So the echo is muted while a secret is being typed:
2938
2958
  // the output stream readline writes through drops everything while `muted` is set.
package/bin/start.mjs CHANGED
@@ -111,6 +111,7 @@ async function runTables() {
111
111
  import { spawn, spawnSync, execFileSync } from "node:child_process";
112
112
  import { storeInRepo, storeOutsideRepoMessage, storeCommitRefusal } from "../shared/store-in-repo.mjs"; //
113
113
  import { stdioConnectOffer } from "../shared/stdio-connect.mjs";
114
+ import { demoProgramPlan } from "../shared/permanent-install.mjs"; // — a demo from npx keeps its own copy
114
115
  import { mergeEnvFile } from "../shared/env-file-merge.mjs";
115
116
  import { mcpOriginFor } from "../shared/lane-address.mjs"; // — one author for the origin
116
117
  import { SERVER_INSTALL_SET, unitsToRestartOnRefresh, unitHealthVerdict } from "../shared/server-units.mjs"; // — one authority, two callers
@@ -300,6 +301,22 @@ export function resolvePorts(env = {}) {
300
301
  client: one("CLIENT_MCP_HTTP_PORT", clientDoorPort({})) };
301
302
  }
302
303
 
304
+ /**
305
+ * WHAT TO DO WHEN THE PAGE THAT OPENS IS NOT OURS. Printed under every "Open" line.
306
+ *
307
+ * A port can be free where this runs and taken where the browser runs: on WSL, a Windows-side listener
308
+ * (VS Code's Remote-SSH forwarding is the one measured, 2026-09-11) answers 127.0.0.1 before WSL does. The
309
+ * doors bind cleanly, the in-use detection has nothing to see, and the browser shows somebody else's page
310
+ * with nothing on this screen saying so. `--port` already moves all three doors; the reader has to be told
311
+ * about it at the moment the address is handed over, which is here.
312
+ */
313
+ export function foreignPageHint(verb) {
314
+ return [
315
+ "If the page that opens is not this install's sign-in, another program on this machine holds that",
316
+ `port from outside this environment. Run \`${invoke(verb)} --port 28802\` (or any free number) instead.`,
317
+ ];
318
+ }
319
+
303
320
  /**
304
321
  * Apply `--port <n>` to the three doors.
305
322
  *
@@ -740,6 +757,10 @@ export function childEnv({ ports, paths, user, portalSecret, tokenSecret, opsTok
740
757
  // already wrong for a process that is not the portal.
741
758
  ...(demo ? {
742
759
  CLEAROTRON_DEMO: "1",
760
+ // A DEMO NAMES NO ORGANISATION. The portal reads this to name the one running the install, on its
761
+ // top bar and its sign-in refusal page, and an exported value is the reader's real organisation.
762
+ // The portal's alone: no door names an organisation, and the doors stay as a live install's.
763
+ CLEAROTRON_ORGANISATION_NAME: "",
743
764
  // AND THE SIGN-IN CREDENTIAL LIVES IN THE DEMO'S OWN BASE. Without this it defaults to
744
765
  // ~/.cordillera/portal-local-credential.json — shared with every install on the box — and the
745
766
  // demo then inherits a digest minted for somebody else's address: the portal prints "the
@@ -1370,8 +1391,13 @@ if (isMain) {
1370
1391
  // SEEDED FROM A COPY, for the reason the player publishes from one: republishing writes a receipt
1371
1392
  // into the run directory it reads, and `demo/` is tracked. This is the path a reader actually takes
1372
1393
  // — `clearotron demo` hands over to this — so fixing the player alone left the defect where it was.
1373
- const { publishSource } = await import("../driver/demo-container.mjs");
1394
+ const { publishSource, seedDemoRuns } = await import("../driver/demo-container.mjs");
1374
1395
  const seed = await seedPool({ pool: paths.pool, examplesDir: publishSource(join(REPO, "demo"), { repoRoot: REPO }), republish: republishRun });
1396
+ // AND AS RUNS, so the assistant this demo's connect line wires has them to list, brief and open. Under
1397
+ // the demo's own workspace only: nothing of it reaches an install started afterwards. Their report
1398
+ // links are stamped with this portal's address, the one the Open line prints.
1399
+ const runs = seedDemoRuns({ workspace: paths.workspace, examplesDir: join(REPO, "demo"), portalOrigin: `http://${HOST}:${ports.portal}` });
1400
+ if (runs.seeded.length) say(` runs ${runs.seeded.length} sample run(s) your assistant can list, brief and open`);
1375
1401
  // WHAT WAS ALREADY THERE IS SAID TOO. This branch used to run only when the pool
1376
1402
  // was empty; it now tops a stale pool up to the package's set, so "seeded 1" on an upgrade is a fact
1377
1403
  // about what was MISSING and says nothing on its own about how many are now listed.
@@ -1431,6 +1457,33 @@ if (isMain) {
1431
1457
  // the file the verb resets is the file the portal reads. A demo keeps its own, by layout (childEnv).
1432
1458
  const { installCredential } = await import("../driver/portal-local-auth.mjs");
1433
1459
  const signIn = DEMO ? null : installCredential({ base: paths.base, env: process.env, firstStart: firstStartOfThisInstall });
1460
+ // ── A DEMO RUN FROM NPX KEEPS ITS OWN COPY OF THE PROGRAM ───────────────────────────────────────────
1461
+ //
1462
+ // Its connect line launched the connector from npm's cache, which npm deletes when it cleans up, so an
1463
+ // assistant registered with it lost the demo without a word (measured on a published beta, 2026-09-11).
1464
+ // The same version goes into `<base>/program` (shared/permanent-install.mjs), BEFORE the services start
1465
+ // so the portal's connect rows name it too, and only when it is not there already. It never stops the
1466
+ // demo: if npm fails, the demo runs as before and says the line will not survive a cache clean.
1467
+ let demoProgramRoot = null;
1468
+ if (DEMO) {
1469
+ const plan = demoProgramPlan({ base: paths.base });
1470
+ if (plan?.current) demoProgramRoot = plan.root;
1471
+ else if (plan && !plan.skip) {
1472
+ say(` program copying clearotron ${plan.version} into ${plan.prefix}, so your assistant's connection survives npm cleaning its cache`);
1473
+ // The npm that launched this, when npm says which: no second npm is guessed at.
1474
+ const npmCli = process.env.npm_execpath;
1475
+ const r = npmCli && existsSync(npmCli)
1476
+ ? spawnSync(process.execPath, [npmCli, ...plan.npmArgs], { stdio: ["ignore", "ignore", "pipe"], encoding: "utf8", timeout: 180_000 })
1477
+ : spawnSync("npm", plan.npmArgs, { stdio: ["ignore", "ignore", "pipe"], encoding: "utf8", timeout: 180_000 });
1478
+ if (r.status === 0 && existsSync(join(plan.root, "mcp-server", "server.mjs"))) demoProgramRoot = plan.root;
1479
+ else {
1480
+ const why = r.error ? r.error.message : String(r.stderr ?? "").trim().split("\n").pop() || `npm exited ${r.status}`;
1481
+ say(` program could not be copied (${why}). The connect line below runs from npm's temporary cache,`);
1482
+ say(" so it stops working when npm cleans that cache; start the demo again to retry.");
1483
+ }
1484
+ }
1485
+ }
1486
+
1434
1487
  const envs = childEnv({ ports, paths, user, portalSecret, tokenSecret, opsToken,
1435
1488
  localWorker: wantWorker, demo: DEMO, clientFence: declaredFence || null,
1436
1489
  credential: signIn && signIn.source !== "shared" ? signIn.path : null });
@@ -1879,6 +1932,7 @@ if (isMain) {
1879
1932
  }
1880
1933
  say("");
1881
1934
  say(` Open: ${envs.url}`);
1935
+ for (const line of foreignPageHint(DEMO ? "demo" : "start")) say(` ${line}`);
1882
1936
  say(" This SURVIVES the terminal — close the window, the product keeps running.");
1883
1937
  say(` Stop it: ${invoke("stop")} (stops and removes the units; issued connect keys survive — \`${invoke("disconnect")}\` revokes those)`);
1884
1938
  say(` Is it up? ${invoke("status")}`);
@@ -2112,6 +2166,7 @@ if (isMain) {
2112
2166
 
2113
2167
  say("");
2114
2168
  say(` Open ${envs.url}`);
2169
+ for (const line of foreignPageHint(DEMO ? "demo" : "start")) say(` ${line}`);
2115
2170
  say("");
2116
2171
  // ── TWO DOORS, TWO AUDIENCES, BOTH NAMED ( — F26) ─────────────────────────
2117
2172
  //
@@ -2209,7 +2264,10 @@ if (isMain) {
2209
2264
  //
2210
2265
  // The string comes from the ONE composer, not from a literal here: three surfaces state this route
2211
2266
  // and a line of instruction with more than one author drifts silently.
2212
- const connect = stdioConnectOffer({ workDir: process.env.CLEAROTRON_WORK_DIR || null });
2267
+ // THE WORKSPACE AND POOL THE SERVICES WERE HANDED, not this process's environment: a demo reads no env
2268
+ // file, so its own line named no workspace and the connector fell back to the real install's.
2269
+ // A demo run from npx names its own copy of the program, which a cache clean does not remove.
2270
+ const connect = stdioConnectOffer({ workDir: paths.workspace, reportsDir: paths.pool, ...(demoProgramRoot ? { installRoot: demoProgramRoot } : {}) });
2213
2271
  say(" Connect your assistant to this install — one line, no address and no sign-in:");
2214
2272
  say("");
2215
2273
  say(` ${connect.command}`);
package/bin/update.mjs CHANGED
@@ -59,6 +59,8 @@ import { isEntrypoint } from "../shared/is-entrypoint.mjs"; // — one entry-p
59
59
  import { readEnvFile } from "./onboard.mjs";
60
60
  import { invoke, invocationPrefix } from "../shared/invocation.mjs"; // — name a command the reader can actually type
61
61
  import { rebuildIfStale } from "../shared/bundle-rebuild.mjs"; // a pull cannot update an untracked bundle
62
+ import { packagedUpdate } from "../shared/permanent-install.mjs"; // — a packaged install updates at its own prefix
63
+ import { installShim, inspectShim, shimPath } from "../shared/verb-shim.mjs"; // — npm's link replaces the launcher
62
64
 
63
65
  const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
64
66
  const ENV_PATH = envLocalPath({ repoRoot: REPO }); // resolved, never composed: one resolver, so moving this file later is one line
@@ -246,13 +248,22 @@ export async function update(argv = process.argv.slice(2)) {
246
248
  // not succeed` — true, useless, and pointing at the wrong thing entirely. `update` is the verb a
247
249
  // stranger reaches for, so the one install we expect most people to have must not be answered with
248
250
  // a git error about a directory that was never a repository.
251
+ //
252
+ // A PACKAGED INSTALL NOW UPDATES ITSELF, the way it was installed: npm, at the prefix it lives under, on
253
+ // the channel it came from (shared/permanent-install.mjs). It runs AFTER the live-run refusal below,
254
+ // because npm replaces the program's files as surely as `npm ci` does. Only a layout that cannot be
255
+ // named is still refused.
256
+ let packaged = null;
249
257
  if (!isGitCheckout()) {
250
- console.error("\n This install is not a git checkout, so there is nothing to pull.");
251
- console.error(" It was installed from a package rather than cloned, which is the ordinary way.");
252
- console.error("\n Update it the way it was installed:\n");
253
- console.error(" npm install -g clearotron@latest\n");
254
- console.error(" Nothing was touched.");
255
- return 4;
258
+ packaged = packagedUpdate();
259
+ if (!packaged) {
260
+ console.error("\n This install is not a git checkout, so there is nothing to pull.");
261
+ console.error(" It was installed from a package rather than cloned, which is the ordinary way.");
262
+ console.error("\n Update it the way it was installed:\n");
263
+ console.error(" npm install -g clearotron@latest\n");
264
+ console.error(" Nothing was touched.");
265
+ return 4;
266
+ }
256
267
  }
257
268
 
258
269
  // ── AND THE SECOND REFUSAL: NOT OVER A LIVE RUN ──────────────────────────────────────────────
@@ -289,6 +300,27 @@ export async function update(argv = process.argv.slice(2)) {
289
300
  return 4;
290
301
  }
291
302
 
303
+ if (packaged) {
304
+ if (packaged.current) {
305
+ say(`\n This install is ${packaged.installed}, and nothing newer is published (${packaged.tag}: ${packaged.version}). Nothing was touched.\n`);
306
+ return 0;
307
+ }
308
+ if (packaged.unread) say(`\n npm did not say which versions are published, so this follows the ${packaged.tag} channel.`);
309
+ say(`\n Updating this install at ${packaged.prefix} from ${packaged.installed ?? "an unreadable version"} to clearotron@${packaged.spec}.`);
310
+ const rc = runInCheckout("npm", packaged.npmArgs);
311
+ if (rc !== 0) return rc;
312
+ // npm puts its own link back at `<prefix>/bin/clearotron` on every install, over the launcher the
313
+ // install wrote, and that link runs whichever `node` is first on PATH. Put the launcher back, but only
314
+ // over npm's link or our own: anything else there was not ours before this update either.
315
+ const kind = inspectShim(shimPath()).kind;
316
+ if (kind === "npm-link" || kind === "ours" || kind === "ours-other-install") {
317
+ const shim = installShim();
318
+ if (!shim.ok) console.error(`\n The update worked, but the launcher at ${shim.path ?? "~/.local/bin/clearotron"} could not be written back: ${shim.detail}.`);
319
+ }
320
+ say("\n Updated. An assistant starts the new version the next time it launches Clearotron; restart the services for the portal.\n");
321
+ return 0;
322
+ }
323
+
292
324
  say("\n Configuration store is outside the checkout. Updating the product.");
293
325
  for (const e of entries) say(` ${e.name}=${e.value} (${e.from})`);
294
326
  if (!entries.length) {
package/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "a9ac3366ee814b9faf6a5d23fe7d0c8f3a5c0d3f",
3
- "version": "0.3.0-beta.5"
2
+ "commit": "263794e3f7267c8ed0c25d9906efbb1694883674",
3
+ "version": "0.3.0-beta.7"
4
4
  }
@@ -27,7 +27,7 @@
27
27
  "stepTotal": 9,
28
28
  "lastStage": "publish",
29
29
  "verdict": "CONDITIONAL",
30
- "url": "https://test-trademark.cordillera.ch/tmpdemo2014fullcountrysearch-venqori-2026-09-03-sample-capture/report.html",
30
+ "url": "/portal/report/tmpdemo2014fullcountrysearch-venqori-2026-09-03-sample-capture/",
31
31
  "failedStage": null,
32
32
  "reason": null,
33
33
  "deliveredAt": "2026-09-03T18:06:17.281Z",
@@ -27,7 +27,7 @@
27
27
  "stepTotal": 9,
28
28
  "lastStage": "publish",
29
29
  "verdict": "CONDITIONAL",
30
- "url": "https://test-trademark.cordillera.ch/tmpdemo2014globalpreliminarysearch-venqori-2026-09-02-sample-capture/report.html",
30
+ "url": "/portal/report/tmpdemo2014globalpreliminarysearch-venqori-2026-09-02-sample-capture/",
31
31
  "failedStage": null,
32
32
  "reason": null,
33
33
  "deliveredAt": "2026-09-02T17:33:15.625Z",
@@ -1,3 +1,3 @@
1
- <p style="font-family:Calibri,sans-serif;font-size:11pt">Knockout trademark review — DEMO-2014-knockout-search: <b>1 mark</b>, worst band <b style="color:#2f55a4">Low</b>. <a href="https://test-trademark.cordillera.ch/tmpdemo2014knockoutsearch-venqori-2026-09-02-sample-capture/report.html">Open the full report</a> · <a href="https://test-trademark.cordillera.ch/portal/report/tmpdemo2014knockoutsearch-venqori-2026-09-02-sample-capture/audit.xlsx">audit workbook</a>.</p>
1
+ <p style="font-family:Calibri,sans-serif;font-size:11pt">Knockout trademark review — DEMO-2014-knockout-search: <b>1 mark</b>, worst band <b style="color:#2f55a4">Low</b>. <a href="/portal/report/tmpdemo2014knockoutsearch-venqori-2026-09-02-sample-capture/">Open the full report</a> · <a href="/portal/report/tmpdemo2014knockoutsearch-venqori-2026-09-02-sample-capture/audit.xlsx">audit workbook</a>.</p>
2
2
  <ul style="font-family:Calibri,sans-serif;font-size:11pt"><li><b>VENQORI</b>: <b style="color:#2f55a4">Low</b> — Classes 9, 42, 41 — Register filings (classes 9, 42, 41): 0 identical, 0 containing, 0 on close variations.</li></ul>
3
3
  <p style="font-family:Calibri,sans-serif;font-size:11pt"><i>Ratings reflect our common law assessment. Register analysis may adjust ratings in either direction.</i></p>
@@ -33,7 +33,7 @@
33
33
  "stepN": 6,
34
34
  "stepTotal": 6,
35
35
  "verdict": "Low",
36
- "url": "https://test-trademark.cordillera.ch/tmpdemo2014knockoutsearch-venqori-2026-09-02-sample-capture/report.html",
36
+ "url": "/portal/report/tmpdemo2014knockoutsearch-venqori-2026-09-02-sample-capture/",
37
37
  "failedStage": null,
38
38
  "reason": null,
39
39
  "deliveredAt": "2026-09-02T21:46:53.238Z",
@@ -254,7 +254,7 @@
254
254
  "reports": [
255
255
  {
256
256
  "mark": "VENQORI",
257
- "url": "https://test-trademark.cordillera.ch/tmpdemo2014knockoutsearch-venqori-2026-09-02-sample-capture/report.html"
257
+ "url": "/portal/report/tmpdemo2014knockoutsearch-venqori-2026-09-02-sample-capture/"
258
258
  }
259
259
  ],
260
260
  "sendPending": true,
@@ -27,7 +27,7 @@
27
27
  "stepTotal": 9,
28
28
  "lastStage": "publish",
29
29
  "verdict": "CONDITIONAL",
30
- "url": "https://test-trademark.cordillera.ch/tmpdemo2014multicountryfocussearch-venqori-2026-09-02-sample-capture/report.html",
30
+ "url": "/portal/report/tmpdemo2014multicountryfocussearch-venqori-2026-09-02-sample-capture/",
31
31
  "failedStage": null,
32
32
  "reason": null,
33
33
  "deliveredAt": "2026-09-02T19:51:48.801Z",
@@ -285,12 +285,13 @@ because the rule is about what PRODUCT CODE reads, not about what a run reads.
285
285
  | `CLEAROTRON_DEMO` | unset | `1` puts this install in the DEMO posture. **Ordering is real**: the four products are listed and orderable, the form, the plan and the confirmation are the product's own, and the confirmation resolves to a finished report that already exists rather than dispatching — no engine turn, no register call, no queue entry, no run directory (ruling 2026-08-31, superseding the greyed-control ruling of the same day). A product the demo carries no finished report for refuses and names which one. It also re-aims two boot warnings written for an operator of a real deployment at the visitor who is not one, from one place (`driver/demo-posture.mjs`). **Set by `npx clearotron demo`, not by an operator** — it is passed explicitly to the two processes that have a reason to know (the portal and the MCP door; the worker is not told, because a demo never queues anything for it to drain), and those run with `CLEAROTRON_NO_ENV_FILE=1`, so a stray `.env` can neither put a live install into demo mode nor take a demo out of one. Anything but the literal `1` is not a demo. Replaces `PORTAL_DEMO`, which named only one of the processes that has to know. |
286
286
  | `CLEAROTRON_TEST_FIXTURE_PROFILES` | unset | `1` makes the profile loader return the three suite fixtures, which are refused from every roster otherwise. Set by `scripts/test-run.mjs`, never by an operator; an explicit `includeTestFixtures` argument beats it. Effect class `harness`; the full contract is its row in `.env.example`. |
287
287
  | `CLEAROTRON_DEMO_PROFILES` | unset | `1` makes the profile loader return the bundled demo account, which a fresh install does not resolve (ruling 2026-09-08). Set by `clearotron demo`, `start --demo` and the suite runner, never by an operator. The gate is on the bundled layer, so a deployment's own configured store keeps its `demoData` accounts either way. Effect class `harness`; the full contract is its row in `.env.example`. |
288
- | `CLEAROTRON_ORGANISATION_NAME` | unset | Your organisation's name, written quoted by `npx clearotron install`, which asks for it and not for a sign-in address. The first `clearotron start` files it as the first organisation in the grants file (`CLEAROTRON_ACCESS_FILE`) when that file holds none; from then on the grants file holds the name, renaming is an edit there, and this is not read. Unset ⇒ no organisation is invented. `clearotron start --organisation <name>` supplies it for one start, and a demo never reads it. Effect class `deployment`; the full contract is its row in `.env.example`. |
288
+ | `CLEAROTRON_ORGANISATION_NAME` | unset | Your organisation's name, written quoted by `npx clearotron install`, which asks for it and not for a sign-in address. The first `clearotron start` files it as the first organisation in the grants file (`CLEAROTRON_ACCESS_FILE`) when that file holds none; from then on the grants file holds the name and renaming is an edit there. The portal also reads it, to name the organisation on its top bar and on its sign-in refusal page. Unset ⇒ no organisation is invented. `clearotron start --organisation <name>` supplies it for one start, and a demo never reads it. Effect class `deployment`; the full contract is its row in `.env.example`. |
289
289
  | `PORTAL_LOCAL_CREDENTIAL` | `~/.cordillera/portal-local-credential.json` | Where local sign-in keeps its passphrase DIGEST. `clearotron start` points it inside the install's own base directory for a new install, so it mints its own passphrase instead of adopting a digest another install left; an install that has been signing in with the shared file keeps it. `npx clearotron demo` always points it inside the demo's own base directory and mints a new passphrase there on every start, so a demo never inherits a digest minted for another address, and removing the demo stays one `rm -rf`. |
290
290
  | `PORTAL_LOCAL_PASSPHRASE` | unset | **NEVER set this in a file.** An internal one-shot handoff, not an operator control: on a first FOREGROUND start the supervisor mints the passphrase and hands it to the portal it spawns *at the spawn call*, so the closing summary can print the value beside the address rather than sending a first-time reader back into eleven startup log lines for the one value in this product that cannot be read back. It is deliberately absent from the composed child environments, because that composition is what `--background` writes into the units' env file — a passphrase there would be a permanent plaintext copy on disk and the product's own sentence, "it is stored only as a digest", would stop being true. Setting it in any env file recreates exactly that. Lost passphrase: `clearotron passphrase --reset`. |
291
291
  | `PORTAL_URL` | `http://127.0.0.1:18802`, or built from `PORTAL_SERVICE_HOST`/`PORTAL_SERVICE_PORT` | Where the deploy tick's live-surface check expects to reach the portal. |
292
292
  | `PORTAL_OPS_TOKEN_FILE` | `~/.config/systemd/user/trademark-portal.service.d/secrets.conf` | The systemd drop-in the live-surface check reads `PORTAL_OPS_TOKEN` out of. It reads the FILE rather than the environment so a check run by hand sees the same token the service does. |
293
293
  | `CLEAROTRON_INVOKED_AS` | unset (⇒ the verb's own `argv[1]`) | How the reader typed the command, so every command a verb prints for them to type next is spelled the way they type it: `clearotron …` after a global install, `npx clearotron …` otherwise. The dispatcher runs each verb as a process of its own, whose `argv[1]` is always `bin/<verb>.mjs`, so `bin/clearotron.mjs` passes its own `argv[1]` down in this name. **Set by the dispatcher, never by an operator.** Effect class `deployment`; the full contract is its row in `.env.example`. |
294
+ | `CLEAROTRON_RELOCATED` | unset (⇒ an install running from npx's cache moves itself to `~/.local` first) | Marks an install that has already made that move. `npx clearotron install` installs the same version under `~/.local`, then runs that copy's install with this set to `1`, so the copy does not try to move itself again. **Set by the install on the one process it starts, never by an operator.** Effect class `deployment`; the full contract is its row in `.env.example`. |
294
295
  | `CLEAROTRON_REQUIRE_EXPLICIT_PORTS` | unset | `1` makes a service that would listen on a built-in default port refuse to start instead of warning. For a box that runs more than one instance, where one instance's default is another's port on the day that other instance is down. Unset, a service on a default port still says so as it starts. Effect class `deployment`. |
295
296
  | `CLEAROTRON_UPDATER_STAMP` | `_updater-identity.json` beside the update script, in the directory the updater runs from | The full path of the file in which the updater that deploys this box records which copy of itself ran. The updater writes it and deploy health reads it under this one name, so a box that moves the stamp sets it once for both. On a box with no updater unit, setting it says an updater exists elsewhere and is to be judged. Effect class `deployment`. |
296
297
  | `CLEAROTRON_CUT_REF` | `HEAD` | Which ref the cut decision reads the version from. **Read only by the release workflow, never set on a deployment.** The jobs that ask about `main` set it to `origin/main` explicitly, because their checkout is pinned to the run's own ref and `HEAD` there is that ref rather than the branch they are deciding about. A job that asks the wrong subject gets a confident wrong answer. |
@@ -1,5 +1,30 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.3.0-beta.7
4
+
5
+ ### Patch Changes
6
+
7
+ - c5744bf: Fixed: A demo started with npx keeps its assistant connection working after npm cleans its cache.
8
+ - c5744bf: Fixed: An assistant connected to the demo now opens each sample report on the demo's own portal, not on another server.
9
+ - c5744bf: Fixed: The portal names the organisation setup recorded, and the product keeps its own name beside it.
10
+
11
+ ## 0.3.0-beta.6
12
+
13
+ ### Patch Changes
14
+
15
+ - 2ca849d: Fixed: When codex's sign-in can no longer be refreshed, the search stops and says to run `codex login`. It used to retry with a bare exit code.
16
+ - a81279f: Fixed: When the portal address opens someone else's page, for example a port forwarded from outside WSL, Clearotron now says so and points you to `--port`.
17
+ - a81279f: Fixed: The sign-in page clears a session left by another Clearotron on the same address and says so. A refusal that is not about the passphrase now says what it is about.
18
+ - f133f7d: Fixed: A passphrase pasted with a space or line break at either end now signs in, instead of being refused as wrong.
19
+ - 2ca849d: Fixed: A manager who does not run the installation no longer sees server file paths when a new company cannot be recorded or filed.
20
+ - a81279f: Fixed: The local sign-in page no longer invites the browser to fill in a saved password from another install.
21
+ - a81279f: Fixed: `npx clearotron install` now installs Clearotron permanently under `~/.local` before setting up. The `clearotron` command and your assistants' connections keep working after npm cleans its cache.
22
+ - 2ca849d: Fixed: When the saved-search store exists but cannot be read, the connector and `clearotron doctor` now say so, instead of reporting no saved searches.
23
+ - a81279f: Fixed: On WSL, the "on this computer" connect steps now say to run them inside WSL. The Claude Code line registers Clearotron for every project and works in Windows PowerShell.
24
+ - a81279f: New: An assistant connected to `clearotron demo` can now list, brief and open the demo's four sample runs. They stay inside the demo's own folder.
25
+ - a81279f: Fixed: The sign-in page's reset line now runs for a demo started with npx, and resets that demo's own passphrase.
26
+ - a81279f: New: `clearotron update` now updates an npm-installed copy itself, and a beta install moves on to the release once it is published.
27
+
3
28
  ## 0.3.0-beta.5
4
29
 
5
30
  ### Patch Changes
@@ -221,7 +221,7 @@ export const E3_BACKLOG = [
221
221
  where: "driver/skills/prelim-common-law/SKILL.md:192 (restated at driver/skills/prelim-search/synthesis-rules.md:394)",
222
222
  surface: "skill-file",
223
223
  evidence: "A clean PR/connotation row MUST cite its search — add a `**Connotation-search source:** <URL | \"perplexity_research — no result\">` line.",
224
- reparsedBy: "driver/connotation-search.mjs — validators.commonLaw rejects a clean claim with no such line (connotation_search_missing); the hint is re-dictated at driver/gateway.mjs:1824",
224
+ reparsedBy: "driver/connotation-search.mjs — validators.commonLaw rejects a clean claim with no such line (connotation_search_missing); the hint is re-dictated by correctionHint() in gateway.mjs",
225
225
  removedByMove: "NOTHING ON THE #850 PLAN REMOVES THIS",
226
226
  },
227
227
  {
@@ -23,9 +23,9 @@
23
23
  // stage apart. This module is the single answer. `cut/` cannot import it (that directory does not travel
24
24
  // and this one does), so the pack gate restates the disjunction and its own test pins the two together.
25
25
 
26
- import { cpSync, existsSync, mkdtempSync, readdirSync } from "node:fs";
26
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
27
27
  import { tmpdir } from "node:os";
28
- import { join, resolve, sep } from "node:path";
28
+ import { dirname, join, resolve, sep } from "node:path";
29
29
 
30
30
  /** The entry file each lane's publisher reads as its source, in the order a child is probed for one. */
31
31
  export const ENTRY_FILES = Object.freeze(["report.md", "knockout-findings.json"]);
@@ -78,3 +78,66 @@ export function publishSource(dir, { repoRoot, tmp = tmpdir() } = {}) {
78
78
  cpSync(here, copy, { recursive: true });
79
79
  return copy;
80
80
  }
81
+
82
+ /**
83
+ * THE DEMO'S SAMPLE RUNS, WHERE AN ASSISTANT LOOKS FOR RUNS.
84
+ *
85
+ * The demo published its samples as reports and made no run directory, so the connector its own connect
86
+ * line wires listed nothing: an assistant, one of the demo's three faces, had nothing to explore. Each
87
+ * sample's finished `run/` is copied to the layout the connector walks, under the DEMO'S OWN workspace:
88
+ * `<workspace>/workspace-<agent>/studio/prelim-search/<slug>/<date>-<codename>/`. Nothing is written
89
+ * anywhere else, so a real install started afterwards sees none of it (the demo is its own install).
90
+ *
91
+ * Copied, never linked: a connector reading a run may write beside it, and `demo/` is tracked. A run
92
+ * already in place is not copied again, so a visitor's second start changes nothing but its links.
93
+ *
94
+ * ITS REPORT LINKS POINT AT THIS DEMO'S PORTAL. Each sample's `status.json` carried the report URL it was
95
+ * stamped with where it was captured, a test instance's address, and the connector hands a run's `url` to
96
+ * the assistant as-is, so an assistant asked to open a demo report sent the person to that host (measured
97
+ * on a published beta, 2026-09-11). The tracked samples now carry the portal's own route,
98
+ * `/portal/report/<runId>/`, with no host, and each copy here is stamped with `portalOrigin`, the demo
99
+ * portal's address. Stamped on EVERY start, the copies already in place included: `--port` moves the
100
+ * portal, and a copy laid down by an earlier version still carries the old host.
101
+ */
102
+ export function seedDemoRuns({ workspace, examplesDir, portalOrigin = null }) {
103
+ const seeded = [], already = [];
104
+ for (const name of demoChildren(examplesDir)) {
105
+ const run = join(examplesDir, name, "run");
106
+ let s;
107
+ try { s = JSON.parse(readFileSync(join(run, "status.json"), "utf8")); } catch { continue; }
108
+ if (!s?.slug || !s?.codename || !s?.date) continue;
109
+ const dir = join(workspace, `workspace-${s.agent || "clawdi"}`, "studio", "prelim-search", s.slug, `${s.date}-${s.codename}`);
110
+ if (existsSync(join(dir, "status.json"))) already.push(s.runId);
111
+ else {
112
+ mkdirSync(dirname(dir), { recursive: true });
113
+ cpSync(run, dir, { recursive: true });
114
+ seeded.push(s.runId);
115
+ }
116
+ if (portalOrigin) stampReportLinks(join(dir, "status.json"), portalOrigin);
117
+ }
118
+ return { seeded, already };
119
+ }
120
+
121
+ /** The portal route that serves a run's report: the one `scanAccountRuns` hands the portal's own list. */
122
+ export const reportRoute = (runId) => `/portal/report/${encodeURIComponent(runId)}/`;
123
+
124
+ /**
125
+ * Point a seeded run's report links at `origin`. A link already on the portal's route keeps its path; any
126
+ * other (the host an older sample carried) becomes the run's own route. Written only when something moved.
127
+ */
128
+ export function stampReportLinks(statusFile, origin) {
129
+ let s;
130
+ try { s = JSON.parse(readFileSync(statusFile, "utf8")); } catch { return false; }
131
+ if (!s?.runId) return false;
132
+ const base = String(origin).replace(/\/+$/, "");
133
+ const local = (url) => {
134
+ const path = String(url ?? "").replace(/^https?:\/\/[^/]+/, "");
135
+ return `${base}${path.startsWith("/portal/report/") ? path : reportRoute(s.runId)}`;
136
+ };
137
+ const next = { ...s, url: local(s.url) };
138
+ if (Array.isArray(s.reports)) next.reports = s.reports.map((r) => (r && typeof r === "object" ? { ...r, url: local(r.url) } : r));
139
+ const text = `${JSON.stringify(next, null, 2)}\n`;
140
+ if (text === `${JSON.stringify(s, null, 2)}\n`) return false;
141
+ writeFileSync(statusFile, text);
142
+ return true;
143
+ }
@@ -412,6 +412,10 @@ export function readServedModel(codexHome, sinceMs = 0) {
412
412
  // that cannot recur. This alternation is still a list of prose a vendor may change without notice,
413
413
  // which is exactly why `rateLimitBasis: "text-match"` stays on the record.
414
414
  const RATE_LIMIT_RE = /\b429\b|rate.?limit|usage limit|quota|too many requests|insufficient_quota/i;
415
+ // A SIGN-IN THAT CAN NO LONGER BE REFRESHED, in codex's own words (measured on a live install, 2026-09-11).
416
+ // Narrow on purpose: a register provider's 401 or 403 can land in a stage's stderr too, and reading that as
417
+ // the operator's codex login having expired would send them to sign in again for nothing.
418
+ const REFRESH_FAILED_RE = /refresh token (?:has|was) already been used|access token could not be refreshed|failed to refresh token/i;
415
419
 
416
420
  // A retry hint, WHEN THE MESSAGE HAPPENS TO CARRY ONE. Two anchored shapes and nothing clever: an
417
421
  // explicit timestamp, or a relative delay with a unit. Anything else yields undefined and the driver
@@ -484,6 +488,7 @@ function settleTuple({ r, ev, resumeRef }) {
484
488
  // backoff (CLEAROTRON_RATE_LIMIT_DEFAULT_BACKOFF_MS), exactly as the anthropic no-reset 429 path does.
485
489
  const rateLimitText = `${ev.turnFailed || ""}\n${ev.streamError || ""}\n${r.stderr || ""}`;
486
490
  const rateLimited = !killed && RATE_LIMIT_RE.test(rateLimitText);
491
+ const signedOut = !killed && REFRESH_FAILED_RE.test(rateLimitText);
487
492
  const resetsAt = rateLimited ? parseResetHint(rateLimitText) : undefined;
488
493
  return {
489
494
  code: killed ? 137 : (failed ? (r.rawCode || 1) : 0),
@@ -521,6 +526,9 @@ function settleTuple({ r, ev, resumeRef }) {
521
526
  // receives and is not this adapter's call — but nothing downstream can any longer fail to know.
522
527
  mcpRefused: mcpToolGauge(ev).mcpToolCallsRefused > 0 || undefined,
523
528
  rateLimited: rateLimited || undefined,
529
+ // The engine's sign-in could not be refreshed: what the operator runs to fix it. The gateway names the
530
+ // stage's failure with it.
531
+ signedOut: signedOut ? "codex sign-in expired — run `codex login`, then start the search again" : undefined,
524
532
  rateLimitBasis: rateLimited ? "text-match" : undefined,
525
533
  // resetsAtBasis (2026-08-20): same honesty as rateLimitBasis one line up, for the reset
526
534
  // CLOCK rather than the classification. codex states its reset as human prose with NO timezone
@@ -456,7 +456,9 @@ export function validateJob(job, { atClaim = false } = {}) {
456
456
  if (projectKey) consulted.push(`the project ${JSON.stringify(projectKey)}`);
457
457
  // The saved search is a rung above the project. `clarify` means the selector itself is wrong, which
458
458
  // the recipeKey/product checks further down report properly — here it just means no scope to read.
459
- const resolved = resolveSearchPolicy(job, { profile, recipes: loadRecipes({ force: true, proseGuard: recipeProseGuard }) });
459
+ // The store is read only for a job that names a saved search, as every other door reads it: one that
460
+ // cannot be read must not refuse a job that never asked for anything in it.
461
+ const resolved = resolveSearchPolicy(job, { profile, recipes: job.recipeKey ? loadRecipes({ force: true, proseGuard: recipeProseGuard }) : null });
460
462
  if (job.recipeKey) consulted.push(`the saved search ${JSON.stringify(String(job.recipeKey))}`);
461
463
  const scope = resolveEffectiveScope(job, profile, resolved?.clarify ? null : resolved);
462
464
  inherited = Array.isArray(scope?.classes) ? scope.classes : [];