clearotron 0.3.0-beta.5 → 0.3.0-beta.6

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/.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.
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
@@ -300,6 +300,22 @@ export function resolvePorts(env = {}) {
300
300
  client: one("CLIENT_MCP_HTTP_PORT", clientDoorPort({})) };
301
301
  }
302
302
 
303
+ /**
304
+ * WHAT TO DO WHEN THE PAGE THAT OPENS IS NOT OURS. Printed under every "Open" line.
305
+ *
306
+ * A port can be free where this runs and taken where the browser runs: on WSL, a Windows-side listener
307
+ * (VS Code's Remote-SSH forwarding is the one measured, 2026-09-11) answers 127.0.0.1 before WSL does. The
308
+ * doors bind cleanly, the in-use detection has nothing to see, and the browser shows somebody else's page
309
+ * with nothing on this screen saying so. `--port` already moves all three doors; the reader has to be told
310
+ * about it at the moment the address is handed over, which is here.
311
+ */
312
+ export function foreignPageHint(verb) {
313
+ return [
314
+ "If the page that opens is not this install's sign-in, another program on this machine holds that",
315
+ `port from outside this environment. Run \`${invoke(verb)} --port 28802\` (or any free number) instead.`,
316
+ ];
317
+ }
318
+
303
319
  /**
304
320
  * Apply `--port <n>` to the three doors.
305
321
  *
@@ -1370,8 +1386,12 @@ if (isMain) {
1370
1386
  // SEEDED FROM A COPY, for the reason the player publishes from one: republishing writes a receipt
1371
1387
  // into the run directory it reads, and `demo/` is tracked. This is the path a reader actually takes
1372
1388
  // — `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");
1389
+ const { publishSource, seedDemoRuns } = await import("../driver/demo-container.mjs");
1374
1390
  const seed = await seedPool({ pool: paths.pool, examplesDir: publishSource(join(REPO, "demo"), { repoRoot: REPO }), republish: republishRun });
1391
+ // AND AS RUNS, so the assistant this demo's connect line wires has them to list, brief and open. Under
1392
+ // the demo's own workspace only: nothing of it reaches an install started afterwards.
1393
+ const runs = seedDemoRuns({ workspace: paths.workspace, examplesDir: join(REPO, "demo") });
1394
+ if (runs.seeded.length) say(` runs ${runs.seeded.length} sample run(s) your assistant can list, brief and open`);
1375
1395
  // WHAT WAS ALREADY THERE IS SAID TOO. This branch used to run only when the pool
1376
1396
  // was empty; it now tops a stale pool up to the package's set, so "seeded 1" on an upgrade is a fact
1377
1397
  // about what was MISSING and says nothing on its own about how many are now listed.
@@ -1879,6 +1899,7 @@ if (isMain) {
1879
1899
  }
1880
1900
  say("");
1881
1901
  say(` Open: ${envs.url}`);
1902
+ for (const line of foreignPageHint(DEMO ? "demo" : "start")) say(` ${line}`);
1882
1903
  say(" This SURVIVES the terminal — close the window, the product keeps running.");
1883
1904
  say(` Stop it: ${invoke("stop")} (stops and removes the units; issued connect keys survive — \`${invoke("disconnect")}\` revokes those)`);
1884
1905
  say(` Is it up? ${invoke("status")}`);
@@ -2112,6 +2133,7 @@ if (isMain) {
2112
2133
 
2113
2134
  say("");
2114
2135
  say(` Open ${envs.url}`);
2136
+ for (const line of foreignPageHint(DEMO ? "demo" : "start")) say(` ${line}`);
2115
2137
  say("");
2116
2138
  // ── TWO DOORS, TWO AUDIENCES, BOTH NAMED ( — F26) ─────────────────────────
2117
2139
  //
@@ -2209,7 +2231,9 @@ if (isMain) {
2209
2231
  //
2210
2232
  // The string comes from the ONE composer, not from a literal here: three surfaces state this route
2211
2233
  // and a line of instruction with more than one author drifts silently.
2212
- const connect = stdioConnectOffer({ workDir: process.env.CLEAROTRON_WORK_DIR || null });
2234
+ // THE WORKSPACE AND POOL THE SERVICES WERE HANDED, not this process's environment: a demo reads no env
2235
+ // file, so its own line named no workspace and the connector fell back to the real install's.
2236
+ const connect = stdioConnectOffer({ workDir: paths.workspace, reportsDir: paths.pool });
2213
2237
  say(" Connect your assistant to this install — one line, no address and no sign-in:");
2214
2238
  say("");
2215
2239
  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": "d96db92fd1989500c9b107df8e7f7a8b7f715500",
3
+ "version": "0.3.0-beta.6"
4
4
  }
@@ -291,6 +291,7 @@ because the rule is about what PRODUCT CODE reads, not about what a run reads.
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,22 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.3.0-beta.6
4
+
5
+ ### Patch Changes
6
+
7
+ - 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.
8
+ - 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`.
9
+ - 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.
10
+ - f133f7d: Fixed: A passphrase pasted with a space or line break at either end now signs in, instead of being refused as wrong.
11
+ - 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.
12
+ - a81279f: Fixed: The local sign-in page no longer invites the browser to fill in a saved password from another install.
13
+ - 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.
14
+ - 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.
15
+ - 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.
16
+ - 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.
17
+ - a81279f: Fixed: The sign-in page's reset line now runs for a demo started with npx, and resets that demo's own passphrase.
18
+ - a81279f: New: `clearotron update` now updates an npm-installed copy itself, and a beta install moves on to the release once it is published.
19
+
3
20
  ## 0.3.0-beta.5
4
21
 
5
22
  ### 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 } 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,31 @@ 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 left alone, so a visitor's second start changes nothing.
93
+ */
94
+ export function seedDemoRuns({ workspace, examplesDir }) {
95
+ const seeded = [], already = [];
96
+ for (const name of demoChildren(examplesDir)) {
97
+ const run = join(examplesDir, name, "run");
98
+ let s;
99
+ try { s = JSON.parse(readFileSync(join(run, "status.json"), "utf8")); } catch { continue; }
100
+ if (!s?.slug || !s?.codename || !s?.date) continue;
101
+ const dir = join(workspace, `workspace-${s.agent || "clawdi"}`, "studio", "prelim-search", s.slug, `${s.date}-${s.codename}`);
102
+ if (existsSync(join(dir, "status.json"))) { already.push(s.runId); continue; }
103
+ mkdirSync(dirname(dir), { recursive: true });
104
+ cpSync(run, dir, { recursive: true });
105
+ seeded.push(s.runId);
106
+ }
107
+ return { seeded, already };
108
+ }
@@ -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 : [];
@@ -1511,6 +1511,12 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1511
1511
  // Guarded on `fail`: a turn whose finished, validated artifact was just accepted (the exit-1 rescue above)
1512
1512
  // stays accepted — the NEXT stage's first turn hits the same cap at 0 tokens spent and postpones cleanly.
1513
1513
  if (turn.signals?.rateLimited && fail) fail = "rate_limited";
1514
+ // AN ENGINE WHOSE SIGN-IN EXPIRED IS NAMED, not left as `nonzero_exit_1`. codex fails its refresh in
1515
+ // seconds with a bare exit code while `codex login status` still reports the user signed in, so the
1516
+ // one thing that fixes it has to be in the failure itself. AFTER the rate limit, which wins when both
1517
+ // appear: a postponed run resumes on its own, and a signed-out one cannot. The sentence rides after
1518
+ // the colon, as `model_mismatch:` carries its detail, so no run-level classifier needs a new token.
1519
+ else if (turn.signals?.signedOut && fail) fail = `engine_signed_out: ${turn.signals.signedOut}`;
1514
1520
  // A6 (addendum 2026-07-30): stop_reason max_tokens with ZERO usable output is a DETECTED FAULT with a
1515
1521
  // name — never a silent paid retry. The turn ran to its output-token ceiling and the artifact never
1516
1522
  // landed (a content fail on a "successful" turn), or the turn itself died at the ceiling (transport
@@ -2002,6 +2008,12 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
2002
2008
  note(`[${name}] model mismatch is deterministic — breaking the ladder (a retry re-buys the same wrong model)`);
2003
2009
  break;
2004
2010
  }
2011
+ // A sign-in that could not be refreshed refuses every attempt the same way: the next one sends the
2012
+ // same credential and gets the same 401, seconds apart. Break, and let the stage fail with it named.
2013
+ if (fail.startsWith("engine_signed_out:")) {
2014
+ note(`[${name}] the engine's sign-in could not be refreshed — breaking the ladder (a retry re-sends the same credential)`);
2015
+ break;
2016
+ }
2005
2017
  // D3: overload (529 / status_overloaded) — stop the ladder here: an in-ladder re-attempt hammers an
2006
2018
  // API that just said it is overloaded, seconds apart, on the SAME model. Breaking hands the failure
2007
2019
  // to the existing machinery: the chain cascades models (FALLBACK_ELIGIBLE) and an exhausted chain
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.0-beta.5",
5
+ "version": "0.3.0-beta.6",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
8
8
  "engines": {
@@ -45,7 +45,7 @@
45
45
  // need the prefix — one side of a pair is enough to separate the pair.
46
46
  import { randomBytes, scryptSync, timingSafeEqual, createHmac } from "node:crypto";
47
47
  import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync } from "node:fs";
48
- import { basename, dirname, join } from "node:path";
48
+ import { basename, dirname, join, relative, isAbsolute, sep } from "node:path";
49
49
  import { homedir } from "node:os";
50
50
  import { envPrefix } from "../shared/os-advice.mjs";
51
51
 
@@ -187,6 +187,30 @@ export function passphraseResetCommand({ prefix = "", credentialPath = null, env
187
187
  return `${prefix.slice(0, at)}${envPrefix("PORTAL_LOCAL_CREDENTIAL", path)}${prefix.slice(at)}clearotron passphrase --reset`;
188
188
  }
189
189
 
190
+ /**
191
+ * The recovery line the SIGN-IN PAGE shows: one that resets the credential this portal reads, and that
192
+ * names no path on this machine.
193
+ *
194
+ * The page is read by somebody who is not signed in, so `passphraseResetCommand`'s line, with its
195
+ * `cd <install> && ` and its absolute `--base`, would show the server's home directory and account to
196
+ * anyone who loads it. So the install is named from `$HOME`, which POSIX shells and PowerShell both
197
+ * expand, and a demo run from npx's cache is reached as `npx clearotron@<version>`, which runs from any
198
+ * directory. The bare `clearotron passphrase --reset` this replaced reset the shared default, not a
199
+ * demo's own credential, and did not run at all where `clearotron` is not on the PATH (measured
200
+ * 2026-09-11 on a published beta).
201
+ */
202
+ export function signInResetCommand({ credentialPath = null, home = null, npxVersion = null } = {}) {
203
+ const verb = `${npxVersion ? `npx clearotron@${npxVersion}` : "clearotron"} passphrase --reset`;
204
+ if (!credentialPath || basename(credentialPath) !== INSTALL_CREDENTIAL_FILE) return verb;
205
+ const h = home ?? homedir();
206
+ const dir = dirname(credentialPath);
207
+ if (dir === defaultInstallBase({ home: h })) return verb;
208
+ const rel = relative(h, dir);
209
+ if (!rel || rel.startsWith("..") || isAbsolute(rel)) return `${verb} --base <this install's folder>`;
210
+ const at = `$HOME/${rel.split(sep).join("/")}`;
211
+ return `${verb} --base ${/\s/.test(at) ? `"${at}"` : at}`;
212
+ }
213
+
190
214
  /**
191
215
  * The name an install's own credential has inside the install's directory: the shared default's name,
192
216
  * so the two are one kind of file in two places.
@@ -287,7 +311,8 @@ export function establishCredential({ path, email, passphrase = null }) {
287
311
  // 18 bytes → 24 base64url characters, no padding and no ambiguous punctuation: readable off a
288
312
  // terminal, typeable into a browser, and 144 bits of entropy so a generated one is never the weak
289
313
  // half of this design.
290
- const secret = generated ? newPassphrase() : String(passphrase);
314
+ // Trimmed for the reason checkPassphrase trims: surrounding whitespace is never part of a passphrase.
315
+ const secret = generated ? newPassphrase() : String(passphrase).trim();
291
316
  if (!secret) throw new Error("establishCredential: an empty passphrase is not a credential");
292
317
  const salt = randomBytes(16).toString("base64url");
293
318
  const rec = {
@@ -319,7 +344,14 @@ export function establishCredential({ path, email, passphrase = null }) {
319
344
  */
320
345
  export function checkPassphrase(record, passphrase) {
321
346
  if (!record || typeof record.salt !== "string" || typeof record.hash !== "string") return false;
322
- if (typeof passphrase !== "string" || !passphrase) return false;
347
+ if (typeof passphrase !== "string") return false;
348
+ // SURROUNDING WHITESPACE IS NOT PART OF A PASSPHRASE. A value copied from a terminal or a chat carries a
349
+ // trailing space or line break, and the digest of `pass ` is not the digest of `pass`, so a correct
350
+ // passphrase was refused as wrong (measured 2026-09-11 on a published beta). Every passphrase is minted
351
+ // trimmed (establishCredential, below), and a generated one has no whitespace at all, so trimming here
352
+ // refuses nothing that matched before. Whitespace INSIDE a passphrase is kept.
353
+ passphrase = passphrase.trim();
354
+ if (!passphrase) return false;
323
355
  if (record.algo !== ALGO) return false;
324
356
  let got;
325
357
  try { got = derive(passphrase, record.salt); } catch { return false; }