Skip to content

Add Windows support, Claude Fable 5 - #276

Open
cafepromenade wants to merge 572 commits into
eneskirca:mainfrom
Ding-Ding-Projects:main
Open

Add Windows support, Claude Fable 5#276
cafepromenade wants to merge 572 commits into
eneskirca:mainfrom
Ding-Ding-Projects:main

Conversation

@cafepromenade

@cafepromenade cafepromenade commented Aug 17, 2026

Copy link
Copy Markdown

Windows support

Brings nodeterm to Windows as a first-class target, plus the work that had to
happen around it. Large by commit count because it lands a platform, not a
feature.

Why it is big

Stock Windows has no tmux, and terminal continuity is the core promise of this
app — terminals survive node remounts, app restarts and reboots. That is
implemented on Windows by a standalone session host process: real PTYs plus
@xterm/headless for server-side screen reconstruction, giving Windows the same
cross-restart persistence tmux provides elsewhere.

What is in it

Area Summary
Session host Standalone persistence process, ConPTY path, Windows process-tree termination, launch ledger, attach that fails closed instead of returning a dead terminal
Shell profiles Stable profile ids (auto, pwsh, windows-powershell, cmd, git-bash, custom, wsl:<distro>) resolved in trusted core immediately before spawn; executable paths and argv never enter renderer state or the shared project file
Packaging Squirrel lifecycle, Windows app identity, installer icon and PE identity, build provenance
Build scripts build.bat / build-installer.bat / download-dependencies.bat bootstrap a fresh machine unattended, with a preflight that names the actual blocker instead of relaying npm's error
Atomic writes fs.rename is not atomic on Windows — it fails with EPERM whenever the destination is open, which on a protected machine means the antivirus scanner, the indexer or a sync client. 28 stores were affected. Fixed behind one helper and enforced by a scan test
Path semantics POSIX-correct code that is wrong on Windows: separators, absolute-path checks, unlink-means-gone

Docker hosting for the Server Edition

The Server Edition ships a one-command Docker host, so running nodeterm in a
browser does not mean hand-assembling a deployment:

  • docker-compose.yml builds and runs the nodeterm service, with named
    volumes so sessions, settings and pairings survive a container replacement.
  • Optional TLS through a caddy service behind a tls compose profile —
    off by default, so a plain docker compose up works with no certificate
    setup. A required-variable bug that broke that default up is fixed here.
  • Passkey sign-in with password fallback, so a hosted instance is not
    password-only.
  • Lifecycle hardening for the host process, and a hardened public
    quickstart — the quickstart is the path least-experienced users take, which
    makes it the one most worth getting right.
  • Docker-backed tests: scripts/test-docker-host.mjs, a public-quickstart
    test, and an SSH end-to-end suite that runs against a real container rather
    than a mock, because the SSH paths are exactly where a mock proves nothing.

Also in this branch

  • Everything is free. The paid tier is removed rather than discounted: no
    purchase, licence, subscription, lapsing trial or paywalled feature. The
    remaining switch is a performance choice — a locked app does less background
    work and lags less on an older machine — and Settings says so, along with a
    warning that anyone asking you to pay for nodeterm is not legitimate.
  • Personal vocabulary uploads accept the file shapes that exist in practice
    (schemaVersion as well as version, a terms list, companion documents with
    no substitutions) instead of rejecting all of them.

Verification — read this before merging

Honest state, because some of it is genuinely unverified:

  • Typecheck passes on both projects.
  • Per-area unit and integration suites accompany the work; roughly a fifth of the
    commits are tests, including real-Windows and packaged-acceptance harnesses.
  • CI on this repository runs no tests and no lint by policy — it builds,
    packages and publishes. So a green run means "it built", never "it passed".
  • One commit (a4e3b13d, 179 files) is an explicit checkpoint: it preserved
    in-flight work that existed in exactly one place on disk and was not on any
    branch. It is preserved, not verified, and its message says so.
  • That checkpoint did introduce a real regression, since fixed in 1c305ec2:
    every ordinary Windows terminal was routed through the new session host
    because the shipped default profile id was read as a deliberate user choice,
    and the client's fire-and-forget write() swallowed the failed round trip —
    so typing reached nothing, with no error shown. Both the routing and the
    bridge advertising an unimplemented launch channel are corrected.

Reviewers should weight the session-host and PTY paths most heavily; that is
where the platform risk lives.


Update — canvas annotations, credential writes, and a suite that was lying

Canvas annotations

Three tools on the pane menu and command palette: Draw colored area wraps a
selection in a group frame; Draw line and Draw arrow are drag-to-draw
standalone nodes with a live preview while the pointer is down.

The load-bearing decision is that a line is a node, never an Edge. This canvas
already has three kinds of edge that each mean something — a bridge is readable
context, a rope is spawn lineage, a dashed edge is a launch dependency — so a
decorative arrow drawn as a fourth would eventually be read as a fourth
relationship. An annotation carries no source/target, exposes no connect
handles, and cannot be drawn between two nodes, which makes that mistake
structurally impossible rather than merely discouraged.

Six more atomic-write violations, all in credential code

The scan test added earlier in this branch went red on six violations that had
crept into Codex identity code — the account home, the node-auth secret, and the
relay daemon's routing and state files. Credentials are the worst place for this
class, because the failure is a silently lost write rather than an error:

  • Three bare renames. MoveFileEx fails with EPERM whenever the destination
    is open at that instant, and what opens a file a millisecond after you write it
    is the antivirus scanner, the search indexer, or a sync client over the user
    profile. One is a directory rename — a handle anywhere inside the tree blocks
    it, and losing that one strands an entire account home at its legacy path.
  • Three temp names built from pid plus Date.now(). Neither is a global
    dimension: two calls land in one millisecond, two containers are both PID 1, and
    an OS reuses a PID after a crash.

Three suites were reporting "no tests", which scans as a pass

Every executable script under scripts/ opens with #!/usr/bin/env node so a
POSIX host can run it directly. Node parses that fine; Vite's transform does not.
A test that imports one of those modules therefore died during collection with a
bare SyntaxError: Invalid or unexpected token — no frame, no mention of a
shebang — so it read as a broken test file rather than a toolchain limitation.

Proven by bisection: six shebang-bearing modules all fail to import, and a
byte-identical copy with the shebang removed passes. A pre plugin now blanks the
shebang in place during transform, so stack-trace line numbers still match the
file on disk and the scripts stay directly executable. 36 tests recovered.

Two neighbouring repairs, both of the same shape — a check that had quietly stopped
checking:

  • version-contract asserted expect(version).toBe('0.4.0'), which can be green
    for exactly one release and red forever after. It teaches whoever finds it red to
    bump the literal, which makes it red again next time. The policy it was reaching
    for is an ordering question, so it is now an ordering assertion.
  • release-workflow-contract aimed a mutation at a workflow line that no longer
    exists — the checked-out-commit proof moved to an inline shell test and
    assert-target was repurposed for tags. A mutation aimed at a missing string
    throws instead of proving anything, so that guard had stopped guarding while
    still looking like a test. It now neuters the guard the workflow actually runs.

Verification, updated

The full suite has now actually been run, serialized, on Windows. At bfb0ba0f:

EXIT=1    Test Files  30 failed | 614 passed | 6 skipped (650)
          Tests       56 failed | 8022 passed | 193 skipped (8271)

Worth stating plainly: the wrapper reporting that run exited 0 while vitest
exited 1, which is why the exit code is now captured inside the log rather
than read from the shell.

Triage of those 56, and what has been done since:

Cause Count State
Suites unloadable behind the shebang failure 3 files Fixed — 36 tests recovered
Atomic-write scan violations 2 Fixed
monaco-editor unresolvable 12 Environment: node_modules held 0.52.2 while the lock pins 0.56.0. A fresh npm ci does not reproduce it
Stale contract assertions 2 Fixed
POSIX-only fixtures on Windows (/bin/sh, /bin/mkdir, AF_UNIX binds) ~24 Partly fixed; the rest need the same posix-shell adapter three other suites now use, and are named rather than skipped silently
5-second timeouts 6 Not yet isolated — a contended run manufactures timeout-shaped failures, so these need re-running alone before being called regressions

None of the remaining failures is in application code reached by the packaged app;
they are fixtures asserting the platform they run on. That is a statement about
where they sit, not a claim that they do not matter.


Update — v0.4.3 shipped, and four defects the suite had been hiding

The release

v0.4.3 is published, non-draft, latest, targeting the exact built commit. Verified by download
rather than assertion: the Setup returns HTTP 200 at exactly its built size, the updater feed serves
this release's RELEASES, and check:wired passes 6 of 6 against that packaged output
including "the canvas renders real nodes", "a terminal actually spawns", and 70 preload-bridge
namespaces answering a live main-process call.

Four defects, one shape

Every one was code disagreeing with its own documentation, while a test agreed with the wrong
half
. None would have been found without actually running the suite.

The Codex node capability could never authenticate. The hook server minted and verified with a
derivation of its own — no key id, no domain separation — while the only token a client can obtain
is the one written to its 0600 token file. Those can never be equal, so every /codex-thread/*
route answered 403 to a correctly-tokened caller
. The secret was identical on both sides
throughout; two setters write one field, which is likely how two derivations grew. Two further
consumers had drifted the same way: the relay daemon read the capability from an environment
variable the launcher deliberately never sets (it pipes it on stdin, and says so in a comment
directly above the call, because a token in a long-lived process's environment is readable by
anything that can see it), and the shape check still pinned the older dotless form, so even an
arriving token would have been rejected.

A dependency bump defeated a security override. package.json overrides Monaco's DOMPurify to
this project's own safe release. A Dependabot bump wrote the vulnerable nested pin back into the
lockfile, so a clean npm ci reinstated it and Monaco would load it in preference. The dependency
contract test exists to catch exactly this and had been red for four days — unseen, because the
suite was not being run.

A watcher treated an open handle as proof. armClosestDirectory called setHealthy(true) one
statement above its own comment reading "Deliberately do not set healthy here… acknowledge() is
the only transition back to healthy."
Opening an OS watch proves a directory can be watched; it
says nothing about whether a write landed in the gap before the handle existed. A second defect in
the same function returned early when promotion failed, leaving a stale cache authoritative in
precisely the case where the target appeared and is unreadable. For the shared School/Kids records
both mean the same thing: a live mode could be served as OFF.

Two tests promised a protection the reaper no longer offers. planReap filters on prefix and
activity age and never reads the client count — attachment was deliberately dropped as a signal
because requiring it made the reaper a structural no-op (54 of 54 sessions on a real host reported
attached). Said plainly, because the tests were hiding it: an idle session can now be reaped
while somebody has it attached. That is the shipped design; it simply was not what the tests said.

Test infrastructure that was reporting success it had not earned

  • Three suites failed at collection and displayed "no tests" — which scans past as a pass — because
    Vite's transform cannot parse the #! shebang every executable script under scripts/ carries.
    Proven by bisection; a byte-identical copy without the shebang loads. 36 tests recovered.
  • Six launcher tests never reached their own fake curl: the fixture bin was passed through the
    environment rather than prefixed inside the shell, and Git Bash's startup puts its own bin first.
    They were watching the launcher fall back to plain codex and asserting the remote arguments
    anyway. That file also dropped from 17.9 s to 5.3 s once the real curl stopped timing out.
  • Two more asserted the launcher exits on failure. It deliberately never does — the upstream
    script's hard exit turned a missing app-server or a locked-down $HOME into a dead node.

Every fix was verified by reintroducing the defect and watching the count go red, not by assuming.

Release tooling

The dim sum code name contract had never been implemented here — three prior releases shipped
without one because nothing in the tooling knew about it. It is now resolved from the public
catalog, bounded (read the index once, probe only the next candidate rather than paginating 2,866
dishes), and fails open: an unreachable catalog returns nothing and the release ships with its
version alone. Its first implementation read the wrong field and all nine of its tests passed,
because the fixtures were written from the same assumption as the code; there is now a record copied
verbatim from the live catalog.

claude added 30 commits August 15, 2026 18:10
NAME_MAX applies to every draft too, and a dangling link still owns its name. Keep temp leaves short and check directory entries without following them.

稿紙都唔可以長過門框;斷咗嘅 link 仲霸住個名,唔好當佢唔存在。
NAME_MAX applies to every draft too, and a dangling link still owns its name. Keep temp leaves short and check directory entries without following them.

稿紙都唔可以長過門框;斷咗嘅 link 仲霸住個名,唔好當佢唔存在。
Rebuild both native addons for Node, migrate legacy root-owned data before dropping to uid 1000, and exercise authentication, renderer assets, persistence, and graceful shutdown in a real container smoke.

Add safe cross-platform host wrappers, keep generated credentials out of Git and BuildKit, pin validated Compose inputs, and keep the first-boot password out of interactive terminals. Root leaves its boots at /data; 入屋前換拖鞋,密碼同權限都唔會周街走。
Route converter and Ollama panels through the active session, acknowledge clipboard writes, and carry bounded Server files over authenticated raw HTTP without fattening the RPC socket.

The socket keeps its waistline and relay keeps its hands to itself. 條 WebSocket 唔使食到爆煲,relay 亦唔會搞錯隔離部機。
Reserve one live generation per chat and merge replies into the newest document so rename, stop, and delete cannot lose newer state.

The model may think slowly; the file no longer time-travels. 模型慢慢諗都得,個 chat 檔唔會倒帶食咗新資料。
Claim unique temps exclusively and publish unapproved destinations with a no-clobber link, preserving cancellation and truthful cleanup outcomes.

Two converters may race; only one gets the finish line. 兩隻轉檔雞一齊跑,都只可以一隻衝線。
Route converter and Ollama panels through the active session, acknowledge clipboard writes, and carry bounded Server files over authenticated raw HTTP without fattening the RPC socket.

The socket keeps its waistline and relay keeps its hands to itself. 條 WebSocket 唔使食到爆煲,relay 亦唔會搞錯隔離部機。
An approved peer gets the joined session, not every CorePlatform service or the viewer's filesystem paths. Keep request, cast, and event tables exact; route dropped bytes through the bound session; and make new surfaces fail closed.

個客可以入房做嘢,唔代表可以拎走鎖匙;白名單逐道門開,檔案亦只送去真正做嘢嗰部機。
A release should arrive dressed before the curtain goes up. Stage one retry-stable draft, prove the Squirrel inventory and exact tag target locally and remotely, then cross the public boundary once. Keep build subprocesses away from write credentials and refuse silent failure paths.\n\n發布唔好著住底衫出場:齊齊整好、驗清楚,先至開門見人。
The Herng Ha App booted, painted, filtered its palette, toggled its settings
and answered 68 bridge namespaces — and could not open a single terminal. It
did not fail; it hung. Measured at 45 seconds and still pending, with nothing
logged anywhere.

app 開得,畫得,乜都撳得,就係開唔到一個 terminal —— 而且唔係報錯,係吊住。

The split was clean: pty.create WITHOUT a persistKey returned a session
immediately; WITH one — which every canvas terminal has — it never settled.
So the fault was the persistence backend, not node-pty.

session-host-client's request() settled only on a reply or a failed write.
Every other path left the promise pending forever: a host that accepts a
frame and goes quiet, a reply lost to framing, a host wedged mid-spawn.
PtyManager.create awaits it to learn a session's real `fresh`, inside a
try/catch — and a catch cannot help a promise that never settles. That is
what made this so quiet: there was no error to log, no rejection to handle,
and no way for the caller to tell it from a slow machine.

A ten-second deadline hands control to the fallback that was already written
and already correct: the create proceeds as a cold start and the user gets a
working terminal. A wrong-but-bounded answer beats a right one that never
arrives. Generous on purpose — the slowest legitimate request is an attach
that spawns a shell on a cold contended machine.

This fixes the HANG, not the SILENCE. Why the host accepted the attach and
did not answer is still open and still worth finding; the app simply no
longer freezes waiting for it.

Also: the headless server test asserted that port 8443 was not listening —
a claim about the whole machine rather than about the server under test. It
failed here because Docker Desktop binds 8443, and it would fail the same way
for anyone running anything on that port, reporting a headless regression
that does not exist. It now asks the OS for a free port and asserts THAT one
stays closed: same property, no dependency on anything outside the process.
Probed by flipping headless to false — still goes red.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…isposable

Three defects, stacked so each hid the next, and two more that made the whole
stack invisible. The app opened terminals that worked and did not survive a
restart — the one thing this backend exists to provide.

1. The connection went DEAF the instant it succeeded. tryConnectOnce's
   finish() ran removeAllListeners('data') unconditionally, one statement
   after attachSocket() installed the reader. Every frame the host sent
   afterwards went unread.

2. request() had no deadline, so a deaf socket meant the promise never
   settled. The caller awaits it inside a try/catch, and a catch cannot help
   a promise that never settles. Measured: 45 s, still pending, silent.

3. The session-host spawn asked for `bash`. That backend is selected
   precisely WHEN there is no tmux — i.e. on Windows — so it defaulted to a
   shell that does not exist there. Proved against a live host:

     shell='bash'           -> {"ok":false,"error":"File not found: "}
     shell='powershell.exe' -> {"ok":true,"result":{"fresh":true}}

   The ordinary pty branch had always resolved this correctly through
   resolveWindowsShell(). Two places deciding one question is what let them
   disagree; there is now one resolveSessionShell and a test that keeps it
   that way.

What hid all of it: a bare `catch {}`, and `persistent` derived from the path
CHOSEN rather than the outcome. So a failed attach still reported
persistent:true, and the renderer believed a throwaway shell would survive a
restart. Both fixed — it now says why it failed, and reports the truth.

Verified end to end rather than by exit code, because this reported success
twice while the host held nothing: create went 10,017 ms -> 66 ms, and the
host's own listSessions now lists the session by name. That last check is the
only one that distinguishes "reports persistent" from "is persistent".

All five reverts probed red. Suite 5995 passed serially; the interaction
harness is 6/6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
build.bat /s completes in 107s and build-installer.bat /s in 199s, emitting
the same three-artifact Squirrel set CI does — a 205.8 MB Setup.exe, the full
.nupkg and RELEASES, unsigned per policy. That needed one elevated install of
the Spectre-mitigated libraries; CI never did, because windows-latest ships
them.

Also records the session-host findings, including how they were actually
confirmed after two false summits. 10,017ms → 6ms looked like a fix and was
not: 10,017 was exactly the new timeout and 6ms was an immediate silent
failure. persistent:true looked like a fix and was not, because it was
derived from the path chosen rather than the outcome. Only asking the host
itself — listSessions returning the session by name — could tell "reports
persistent" from "is persistent".

The preflight earned its keep on the first run of the day: it refused in five
seconds naming the exact PID holding electron.exe, which was a session host I
had spawned by hand while debugging. Before today that was a three-minute
build dying on an opaque EPERM.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A term from the shared private working vocabulary appeared in a comment in
src/core/session-host-client.test.ts, and I published it. The scanner caught
it in the same breath as the commit — and I piped its output through `head`,
which masked its exit code, so the chain pushed anyway.

The lesson is the pipe, not the word: a check whose verdict is discarded is
not a check. The scan runs before the commit for a reason and its status has
to survive to the `&&`.

Content only; the commit message carried none.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The repository had none, and the shared instructions require one. Every
figure here was taken from this commit during the session that wrote it:
460 test files, 5995 tests passing serially, 520 contract assertions across
44 features, 326 site assertions, 6/6 against the built app,
v0.3.0-ci.173 targeting this commit.

It leads with what is NOT verified, because that is what a next owner most
needs and what a handoff most often gets wrong. Nobody has installed and
launched a packaged build. The session host is proved by probe rather than by
living with it. ssh-askpass cannot bind a socket on this machine and was not
investigated.

It also records three things that would otherwise cost the next person real
time: run the suite serially before believing a failure, because the parallel
run manufactures contention failures in the shell-spawning suites; only two
of the six checks look at the built artifact, and the source scans would all
pass on an app whose every control was inert; and the 22 foreign worktrees on
disk hold unmerged work and are nobody's to delete here.

Issues are disabled on this repository, so the usual practice of posting the
handoff to an issue thread cannot be followed. That is stated in the file
rather than quietly skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t through

A private working-vocabulary term reached a public file and I published it.

The interesting part is why the check passed first. The scanner walks TRACKED
files, and HANDOFF.md was brand new — untracked at the moment I scanned, so
invisible to it, and only visible after the commit that published it. A new
file is exactly the case where the check matters most and exactly the case it
could not see.

So the rule for anything newly created: `git add` first, then scan, then
commit. Scanning a working tree that still holds the new file as untracked
proves nothing about it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ross preset picks, fix racy download/decode paths

The accent setting used to be written to exactly one CSS variable, `--accent`. Every
other role that depends on it (hover state, readable text, the RGB triple used for
container tints, and the Material primary/on-primary/primary-container tokens) was
left pointing at the old stylesheet defaults, so a custom red or green accent still
rendered blue text and blue containers next to it. `lib/accentTokens.ts` now expands
one accent value into the full family, re-derives readable text against the resolved
light/dark panel color, and clears back to the authored stylesheet defaults when the
value is the persisted default or not a valid color. This moved out of Canvas.tsx
(which only ever set the bare variable) and into App.tsx, applied once at the app root.

The color picker's HSV and CMYK tabs were emitting `hsv(...)`/`cmyk(...)` strings that
are not valid browser CSS syntax, so a value picked in either tab could be stored and
then silently fail to apply as a real style. Both are now converted to `rgb()`/`rgba()`
(preserving alpha) at the point the value leaves the picker; the tabs themselves are
untouched and Copy still copies the format actually selected.

The app-logo settings panel had two correctness bugs: choosing a shipped preset wrote
`{ selection }` as the entire nested settings object, which discarded a previously
uploaded custom image outright (since settings patches replace the object rather than
merging into it); and successive crop/fit adjustments raced each other, so a slow
decode could finish after a newer one and stomp it with stale output. Preset selection
now merges through `selectLogoPreset`, which keeps `customImage` unless the user
explicitly removes it, and every processing job carries a monotonically increasing
generation counter (`LogoProcessGeneration`) so only the newest attempt is allowed to
write to settings.

Blob-based downloads (appearance preset export, in two places) are consolidated behind
one `saveBlobDownload` helper in `exportSave.ts`, so both call sites get the same
delayed `URL.revokeObjectURL` — revoking synchronously has intermittently canceled the
save before Chromium picks it up.

Tests added for all four: `accentTokens.test.ts`, `ColorPicker.test.tsx` (drives real
HSV/CMYK controls against a live `CSSStyleDeclaration`), `logoSelection.test.ts` and
`AppIdentitySection.test.tsx` (deferred promises resolved out of order to prove the
generation guard), and `exportSave.test.ts` (fake clock proving revoke happens after,
not during, the click turn). Docs updated to match: app-design-tokens.md,
app-logo.md, appearance.md, colour-picker.md, plus CLAUDE.md/CONTRIBUTING.md notes on
treating an accent as a family and an async preview as a generation.

呢個 commit 修嘅係四個「睇落度啱、一用就穿煲」嘅老千位:自訂主題色淨係著咗件外套、
HSV/CMYK 揸住兩舊唔識講嘅顏色去見瀏覽器、換個靚 Logo 順手㓤咗你上載嗰張、快靚正咁
export 個檔案有時未執完就拆咗條命脈。而家四個都補返,仲有測試睇實佢哋唔會再穿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rning a schedule on

A scheduled rule backed by an external source (API or Home Assistant) could end
up enabled when it should not be, three separate ways:

1. Every 30s tick started a fresh network check for a rule even if the
   previous check for that same rule/source was still in flight. A slow
   check finishing late could overwrite a newer one's result. The service
   now tracks one in-flight refresh per rule, keyed by rule id + a hash of
   the source (URL, entity id, timeout), and lets a stale check's `finally`
   clean up only its own slot.
2. Editing a rule's source (new URL, new HA entity, clearing the saved HA
   token) reused the same generation counter unless the "kind" itself
   changed, so a cached "on" from the old source could survive the edit.
   Generation is now bumped on any change to the full source identity, and
   clearing the token invalidates the rule's cached state synchronously,
   before the IPC call returns.
3. `validateScheduledSettingsFile` ran on the file *after* tolerant
   normalization, so a malformed API/HA source got silently rewritten into
   an always-on local rule and then reported as a successful save.
   Validation now runs on the caller's raw bytes first, and normalization
   of a corrupt or unknown source now marks the rule un-enable-able
   (`safeToEnable: false`) rather than defaulting it to a bare local rule
   that always passes its window check.

Also: loading a scheduled-settings file now only treats ENOENT as "no rules
yet" — a corrupt JSON parse, EACCES, or a directory at that path used to
collapse to the same empty default and the next save would overwrite the
evidence needed to recover it. Those now throw instead of being swallowed.

呢個排程掣本來有三條路可以喺唔應該着嘅時候自己着咗:一係慢嘅網絡查詢遲到覆蓋新嘅結果,一係
改咗來源之後舊嘅「開住」狀態賴死唔走,一係打爛嘅JSON存檔會靜靜雞變返做本地開關,仲當自己儲
存成功。三條路而家都揸緊,爛嘢唔准再扮無事。

Added unit coverage for the in-flight/generation ownership races, the store's
read-failure classification, and the shared normalize/validate boundary
(malformed enabled flags, malformed sources, unknown source kinds, invalid
HA entity ids).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dentity by title

Removing a Claude account was never a GuardedAction: it deleted credentials, transcripts,
and the account's open login terminal on a single plain confirm, Kids mode or not. Add
`remove-account` to the policy and route Settings' remove button through a pure
`planAccountRemoval`/`dispatchAccountRemoval` funnel (src/renderer/lib/accountRemoval.ts) so
Kids mode gets the same two-key gate every other destructive action gets, and cancelling
leaves credentials, the account record, and the running login session untouched.

The account transaction and the node-deletion transaction used to run one after another with
no handshake: Settings deleted the login node itself by reaching into the projects store,
racing whatever Canvas was doing with the same node on the active project. They now shake
hands through a `nodeterm:account-removal-approved` event — Canvas closes the login terminal
through the ordinary `requestDeleteNodes` funnel (marked `authorizedBy: 'remove-account'` so
it does not open a second confirmation on top of the one that already ran) and only calls back
into Settings once that teardown is confirmed, before credentials are touched.

Also: `isAccountLoginNode` used to guess a node's purpose from its title, which is exactly the
field a user is invited to rename. An ordinary terminal renamed to "Claude login" would have
been silently destroyed the next time an account was removed. Nodes now carry a persisted
`accountLogin` boolean set at creation; the title/initialCommand check survives only as a
one-time migration for workspaces saved before this change.

呢個 bug 幾陰功:一個帳戶最緊要嘅嘢——密碼、聊天記錄、登入緊嗰個終端機——淨係要撳一下就冇晒,
Kids mode 都攔唔到。仲有,淨係睇個標題嚟認邊個係登入終端機,即係話你幫個普通終端機改個名叫
「Claude login」,佢就會當你係真係嗰個,刪帳戶嗰陣一齊陪葬。而家改咗用一個真係嘅、唔會走位嘅
記認,兩個刪除流程又識得握手先做嘢,唔會兩個各做各。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… a dead terminal

Two related gaps in the Windows session-host client/manager pairing both let a
broken handshake or a broken attach masquerade as a working terminal.

On the client side, the hello handshake used to remove every 'data' listener
on success right before handing the socket to attachSocket(), which installs
its own 'data' listener a moment later — so the cleanup ran a second time and
deleted the listener it had just installed. The first real attach response,
and every frame after it, vanished into a socket that still looked connected.
The handshake now tracks its own named listeners, checks the response id
against the hello request it actually sent (a stray or replayed frame can no
longer authenticate someone else's connection), and only removes what it
installed. A token-file read failure that isn't ENOENT (permission error,
directory instead of a file, I/O fault) is now surfaced as a rejection instead
of being folded into "no host yet", which used to invite a second host to spawn
on top of a probe that never got a real answer. capture() and killSession()
also stop swallowing every request failure into an empty string / silent
success — the host already reports an empty capture or an absent session as an
ordinary confirmed reply, so any thrown error here is transport uncertainty,
not evidence of nothing, and callers (periodic snapshot, destructive delete)
now see it and can retry rather than quietly recording a wrong fact.

On the manager side, PtyManager registers a provisional Session before the
session-host shim's async `ready` settles, so the co-attach check used to run
before that promise existed — a second create() for the same node id could
land in the tiny window before the barrier was published and treat the
not-yet-confirmed shim as a live, joinable session. The in-flight barrier now
gates co-attach, not just spawn. If `ready` rejects, the manager now tears the
provisional session down completely (timers cleared, buffered output dropped,
subscribers released, the shim detached) and propagates the real error instead
of quietly leaving `fresh: true` and handing back a session id that can never
receive input again. Late `onData`/`onExit` callbacks from a since-discarded
generation are dropped by identity so they can't resurrect a session that was
already rolled back or leak into a same-named replacement. The one legacy
caller that can't await a promise (the detached relay path, whose API returns
a session id synchronously) gets the same outcome delivered asynchronously: a
non-zero exit to its sink instead of a session nobody can write to.

Docs and CLAUDE.md record both boundaries so a later "simplify this cleanup"
pass doesn't reintroduce the double-delete or the swallowed rejection.

呢次執嘅係「睇落掂咗其實冇掂」嘅兩個窿:一係 client 個 handshake
喺接好之後自己手多手多,將啱啱裝落嘅 production listener 拆咗,
之後嚟嘅嘢全部石沉大海但個 socket 仲扮到好生猛;一係 manager
喺 session-host 仲未答應之前就當個 session 已經係活嘅,俾第二個
create() 撞啱嗰霎眼就撈咗嚟用,結果攞到一個永遠冇回音嘅終端機。
而家兩個窿都封咗:認唔到嘅回覆唔收,未落實嘅唔比人撈,答應咗又
反口就即刻拆返晒,錯就係錯,唔會扮冇事。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t __proto__

YAML mapping keys and string scalars are now always emitted as JSON double-quoted
strings (a valid YAML 1.2 form), instead of a plain-scalar allowlist that had to
guess at every structural token (`: `, ` #`) and implicit type (dates, booleans,
numeric-looking text). Cantonese, emoji, colons, quotes, and comment-shaped text
now round-trip through a real independent `js-yaml` parser instead of relying on
hand-picked regexes to stay ahead of the YAML grammar.

The ZIP archive builder sanitizes each member's path once and reuses that exact
path for both the archive entry and the manifest, rejects two members that
collide after sanitizing (or that try to overwrite MANIFEST.json), records each
member's real UTF-8 byte length via TextEncoder instead of a JS string's UTF-16
code-unit count, and now sets ZIP general-purpose bit 11 on every entry so
conforming readers treat filenames as UTF-8 instead of legacy CP437. Verified
against an independent `unzipper` reader plus the standard CRC-32 test vector.

The personal-vocabulary JSON scanner now builds every object with a null
prototype, so a `__proto__` key in the uploaded file is real own data the
validator can see and reject, rather than triggering the JS engine's legacy
setter and vanishing before validation runs. The schema validator checks
`version`/`entries` as own properties (an inherited value from a crafted
prototype chain no longer satisfies the contract) and returns a null-prototype
entries dictionary so a later refactor cannot silently reopen the setter. The
localStorage cache read now goes through the exact same validator as a fresh
upload, so a hand-edited cache file gets no weaker treatment than a picked file.

Added round-trip tests for the YAML/ZIP codecs and unit tests for the
ownership boundaries in the vocabulary scanner, schema, and cache reader.

粵語:呢次幫 YAML 匯出嘅每個key、每個字串都强制加返雙引號,唔再靠一堆正則規則
去估邊個字要收埋、邊個唔使——而家索性全部照JSON格式包好,交返俾標準YAML
parser去讀,廣東話、表情符號、冒號、hash字全部穩陣過關。ZIP壓縮包而家識講
UTF-8檔名,manifest嘅bytes數又計得啱,重會擋住兩個檔案sanitize完撞名。個人
詞彙表個JSON scanner改用null-prototype嘅object嚟裝key,連`__proto__`呢種
陰招都變返做普通data,唔會偷偷篡改個prototype鏈,localStorage嘅快取讀返嚟
一樣要過同一套驗證,唔會俾人手改個cache檔就walk around咗個contract。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d preflight

The Windows build scripts used to stop at "install the Spectre-mitigated MSVC
libraries yourself" and "make sure node-gyp finds a Python" — two manual steps
between a fresh checkout and a working build. download-dependencies.bat now
runs two new helpers, in order, right after Node is bootstrapped and before
npm can touch node_modules:

- scripts/ensure-windows-build-toolchain.mjs installs or repairs the Visual
  Studio 2022 C++ workload plus the Spectre-mitigated runtime components
  (adding the ARM64 component on ARM64 hosts), using the SHA-256-pinned
  Microsoft bootstrapper from dependencies.manifest.json when no instance
  exists yet. Because Visual Studio has no per-user install path and its
  quiet/passive installer requires an already-elevated caller, the script
  never tries to summon UAC itself: an unelevated run exits access-denied
  with one exact --elevated-toolchain-only command to run by hand, and the
  root batch files refuse to continue toward Python or npm under an
  Administrator token.
- scripts/ensure-windows-python.mjs finds or installs a supported 64-bit
  Python (3.10-3.14, pinned to 3.13 for a fresh install) for node-gyp,
  without ever probing the bare py.exe/python.exe app-execution aliases
  (which can silently install a runtime or pop a Store window), and exports
  the verified interpreter through PYTHON, NODE_GYP_FORCE_PYTHON and
  npm_config_python so a stale inherited value can't win.

scripts/check-build-preflight.mjs had its own duplicate, weaker Spectre
detection (any toolset with the directory present, not necessarily the one
MSBuild actually selects) pulled out into the shared, unit-tested
windows-spectre-preflight.mjs, which picks the same newest-VS2022-with-a-
toolset that node-gyp would and checks for real .lib files rather than an
empty leftover directory.

Along the way, every inline PowerShell call in the three .bat entry points
that used to splice a manifest-controlled path or URL into -Command source
now passes it through an environment variable instead — an apostrophe or
space in a checkout path used to be one command-injection surprise away from
either breaking the build or being silently accepted as script text.

一句到尾:以前個 build script 淨係識鬧「你自己去裝返 Spectre lib 同 Python 啦」,
𠵱家佢自己攞牌裝,裝唔到先叫你開返個 Administrator 窗撳一行嘢,裝完即刻閂返個窗
繼續用返普通用戶身份;順手仲補返幾個 PowerShell 命令注入嘅窿——路徑有個撇號都
唔會再炸。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… mode

School mode's contract is omission, not a greyed-out panel: while it is on, Cantonese,
funny levels, personal vocabulary and dim sum are supposed to look uninstalled. The
gates that enforced this all read the shared record's default `enabled: false` as
proof the mode was off, which is exactly backwards during the one window that
matters — the moment after launch when the record hasn't loaded yet, or a Server
Edition tab has to reconnect to fetch it. Every one of those consumers (the language
resolver, the settings nav, the narrator, the vocabulary substitution boundary, the
dim sum toast) briefly rendered as if School mode were confirmed off, because
"no answer yet" and "answer is no" produced the same boolean.

The fix is a single pure gate, `schoolModeAllowsOptionalFeatures`, that only allows
these surfaces once hydration has actually completed and reports enabled: false. The
renderer's School-mode store now retries a failed load instead of quietly settling on
the untrue default, and prefers a live push that arrived mid-load over a now-stale
snapshot. Settings' Language section unmounts entirely rather than disabling itself,
and a control mid-toggle re-checks the gate at the point of write so a shared-mode
flip landing between keystroke and unmount can't sneak a setting through.

Two independent bugs came along for the ride while touching this code. First,
`languageMode` is hand-editable JSON with only a compile-time type backing it; an
invalid value used to fall through the resolver's exhaustive switch and return
`undefined`, taking a whole localized surface down — now normalized to English on
both load and render. Second, the narrator's voice matching accepted any `zh-*` tag
for the Cantonese track, which reads Cantonese copy in a Mandarin voice on most stock
voice inventories; it's now scoped to genuine `yue-*`/`zh-*-HK` tags, and an
"important" narration (a real error) no longer evicts an earlier important one queued
in the same category behind a busy speech channel.

呢次執嘅係「School mode 淨係得個名冇實際」嗰種漏洞:啲功能靠住「仲未讀到」同「讀到話冇開」
用緊同一個 false 嚟表達,結果啱啱開機嗰陣,廣東話、搞笑等級、個人詞彙、點心彩蛋全部
靜雞雞漏咗出嚟畀人睇到,重試機制執翻正,而家淨係讀到真係「已關閉」先至畀呢啲功能出返嚟。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A rejected SessionHostClient handshake or attach used to be treated as harmless:
the client wiped every `data` listener on success (deleting its own new
production listener along with the handshake one, so the first real reply and
the first push frame vanished into a socket that still looked alive), and
pty-manager caught a failed `SessionHostPty.ready` and quietly kept going,
handing back a session id whose writes could never arrive. `capture()` and
`killSession()` swallowed every request failure into "empty" and "already
gone", so a dropped connection could tell the periodic scrollback snapshot
its dirty session was safely captured, and tell a delete that a still-running
process no longer existed.

Fixed on both sides of the wire. The client now installs named handshake
listeners, removes only those on completion, and hands the socket to the
production listener before resolving — so a socket that authenticated keeps
receiving. `attach()` rolls back just its own subscriber and remembered
options on failure, leaving a concurrent co-attach's state alone. `capture()`
and `killSession()` propagate a transport failure instead of returning empty
or silently succeeding. In pty-manager, a session-host shim is provisional
until `ready` settles: `create()` checks the in-flight barrier before the
co-attach index so a racing client can't join a session that is still failing
to attach, and a rejected `ready` tears down the provisional session, cancels
its queued output, and reports the real error instead of a cold-but-working
terminal. A detached relay caller (whose synchronous API can't return a
promise) gets the same teardown plus a synthetic non-zero exit to its sink.

Also reformats CLAUDE.md's markdown emphasis/list spacing repo-wide (asterisk
italics to underscore, blank lines around bullet lists) alongside the new
prose describing this behavior, and reflows the session-host verb table in
docs/windows-session-host.md the same way.

一句講晒:之前個 handshake 靚仔靚女咁完咗場戲就自己拆晒後台,個 attach 一嘢
炒咗都當冇事發生照畀個死火 terminal 你用,依家全部改返做——攞唔到就即刻認,
唔會扮嘢話「得㗎、得㗎」。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…real UI transaction

Two history bugs, one root cause: state that looked committed wasn't actually
locked down yet.

Core: LocalHistoryStore.record() wrote the file, staged it, and committed it
as three separate awaited git calls with no queue behind them. Two concurrent
saves to the same domain could interleave — one call's content staged under
another call's label, or a commit landing against an index a sibling call had
already cleaned, silently losing a revision. record() now runs the whole
write/add/commit sequence as one FIFO transaction per domain, and list()/
restoreContent() join that same tail so a read right after a write actually
sees it. Also fixed: a freshly initialized repo with no commits yet made
`git log` exit 128, which the store reported as a read failure instead of an
honest empty history — a first-run History panel read as "history is broken".

Renderer: restoring a settings revision only ever patched the file on disk.
The Zustand store still held the old object, and a coalesced save queued
before the restore could fire afterward and silently overwrite the just-
restored revision. restoreSettingsRevision() now suspends and cancels pending
saves, waits for any save already in flight, applies the restore, and
reloads the live store from the authoritative file — with the canceled edit
rescheduled on failure and dropped on success.

Same class of bug, smaller stakes, on the public docs site: its own
undo/history log recorded change titles but never captured the settings
values themselves, so "Put back" always exists but never restores anything.
save() now snapshots the prior durable values into the history row and
undoEntry() actually replays them (itself reversible); record-only rows like
exports show no fake restore action. Also closed while in there: an HTML
attribute escaper that quoted double quotes but not single ones, letting a
title such as `O'Brien' data-pwned='yes` break out of a single-quoted
`data-menu-extra` attribute; a `history: []` (every row deleted) being
treated as "no data yet" and repopulated with the welcome entries on reload;
and a clipboard copy reporting success synchronously before the async
`navigator.clipboard.writeText()` promise had actually settled.

有兩個歷史紀錄嘅老鼠洞,今次一齊補晒:本機設定歷史嘅 git 寫入本來冇排隊,
兩個保存可以攞錯對方嘅暫存內容或者靜靜雞漏咗一個 commit;而還原設定之後,
渲染層嗰個舊嘅延遲保存仲會偷偷寫返舊資料落去,等於還原完又自己還原返去。
而個網站個「時光機」以前得個「還原」個掣得個樣,㩒落去乜都冇變——而家真係
會存低舊數值,㩒到先叫還原。仲手多多執埋單引號可以爆 HTML attribute嘅漏洞、
剩低空歷史會俾人以為未儲存過而自動填返歡迎訊息、同埋剪貼簿未寫完就報成功
呢三個小問題。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
agent.json is a shared registry that the desktop pairing service and a
separate host-agent process both read-modify-write. Atomic rename made
each write internally consistent but did nothing to stop two processes
from racing the read: the loser's stale copy could silently republish a
device the winner had just revoked, key line and all. A new file lock
(pairing-registry-lock.ts) takes an exclusive `agent.json.lock` before
the authoritative read and holds it across the whole transaction,
including the authorized_keys mutation; a timeout fails closed instead
of guessing a lock is stale. A real two-process test (fork'd worker
script, bundled with esbuild) proves the lock serializes an actual
host-agent-shaped writer against a desktop-shaped one, not just two
promises in one event loop.

The pairing POST handler also had a narrower race: canceling a listener
(stop, or a superseding start) after the device row was written to the
registry but before the SSH key was appended could still activate that
key for an attempt nobody would ever get a response to. The handler now
rechecks attempt ownership right after the registry write and again
before replying 200, so a canceled attempt leaves a visible, revocable
device row but never grants SSH access or a bearer token.

On the renderer side, usePhonePairing tracked "is a listener running"
with a single boolean ref, which could not tell a superseded start from
a live one: closing Settings while a start's IPC call was still
in-flight, or firing a second start before the first resolved, could
leave an orphaned ten-minute pairing window running with no UI over it.
It now claims ownership before the IPC call, stamps every attempt with
an epoch that invalidates any late continuation (unmount, explicit
stop, completion, or a newer start), and serializes overlapping start
handshakes so a stale stop can never cancel a fresh listener's request.

Also fixed a real bug found while wiring this up: PhoneSection's revoke
handler had its try/catch bodies swapped, referencing a `detail`
variable from the catch block's scope inside the try block — a
guaranteed ReferenceError on the success path, and the exact opposite
of what its own new test (PhoneSection.revoke.test.tsx) asserts. Fixed
so a rejected revoke keeps the device row with a persistent "SSH access
may still be active, retry" warning, and a successful retry clears it.

CLAUDE.md, CONTRIBUTING.md, and docs/ios-protocol-migration.md are
updated to describe the lock contract and flag that the separately
maintained companion host agent still needs to adopt the identical
lock protocol before a combined release — that half is out of this
repository and is called out as an explicit release blocker rather than
assumed.

agent.json 係desktop同host-agent兩個process各自讀寫嘅共用登記表,
rename atomic 得,but讀嗰下唔atomic,慢個process攞住舊copy寫返轉頭,
啱啱revoke走嘅電話key就咁翻生。而家夾硬攞file lock先至讀,一路揸住
到成單交易做完先放,仲用真係fork出嚟嘅process嚟測,唔係得個講字。
配對攞消嗰邊都執返:啱啱寫低registry但仲未加SSH key嗰陣被cancel,
而家會再check多次先至決定畀唔畀key,唔會偷偷放行一個冇人接聽嘅配對。
PhoneSection 仲執到一個try/catch兩截調亂咗嘅bug——detail呢個
變數係喺catch度先聲明,try入面用梗係underfined,一execute就報錯,
同新寫嘅test講嘅完全相反,而家改返啱先至係「revoke唔到就話畀你知,
retry成功先清警告」。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n read failure

The prior atomic-write hardening fixed publication (unique temp names, aged
cleanup, ordered flushes). This closes the gap one layer up: what happened
when two callers read-modified-wrote the same credential file, or when a
read itself failed.

上一輪淨係搞掂咗「點樣寫先至安全」,呢一輪搞返「讀嘅時候撞埋一齊點算」同
「讀唔到嘅時候唔可以扮冇嘢」。

SecureStore (toy locks, authenticator entries) gained a `mutate()` transaction:
every load, save, and mutation for one resolved file path now runs through a
single serialized queue, so two concurrent "add an entry" calls can no longer
both read the same starting list and each publish a document that drops the
other's addition. `load()` now rejects on a corrupt or unreadable file instead
of quietly returning an empty array — an unreadable credential store is not
the same fact as "no credentials exist," and treating them the same used to
let a toy lock or authenticator entry vanish from the UI while its bytes were
still sitting on disk.

Scheduled Home Assistant tokens get the same treatment: set/clear/prune now
share one ordered queue so a background prune can't race a just-issued token
and delete it, rule ids are validated against the exact UUID shape the app
generates (a hand-edited "a/b" and "a_b" used to collide on the same
sanitized filename), and pruning recognizes the real current temp-file shape
instead of matching any dot-free token. Reading a token that exists in the
"wrong" format for the current platform now reports unavailable rather than
null, so a headless-to-keychain migration can't look like "nothing was ever
saved" and get silently overwritten.

Provider cookies (MiniMax, opencode) follow the same rule: a corrupt or
unreadable cookie file now throws instead of reporting "not stored," and
`clearAtomicTarget` rechecks the destination after unlinking it, because a
foreign writer can republish the canonical file between the unlink and the
completeness scan.

The renderer settings sections (Schedule, Usage, Authenticator, Toy Locks)
now distinguish "known false" from "unknown" for every credential status this
touches, show the real error inline instead of a generic failure, and stop
clearing a pasted secret out of the input box when the save that was
supposed to consume it actually failed.

Every behavior change here has a test exercising the actual race or the
actual corrupt-file case, not just the happy path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…st within one

The FIFO publish chain only ordered flushes inside a single process. Desktop
multi-instance mode and two Server Edition processes pointed at the same data
directory each keep their own independent queue, so a flush parked mid-retry in
one process could still wake up and overwrite a newer document published by the
other. Same bug, just promoted from a thread race to a process race.

agent-status-mirror now reserves a durable generation number in a SQLite
`BEGIN IMMEDIATE` transaction before it even snapshots in-memory state, writes
that snapshot to its own UUID temp file without holding the lock, then takes the
lock again to compare against the currently published generation and only
renames over it if it is still ahead. An older writer that resumes after a crash
or a long pause loses the compare and quietly discards its own temp file. The
lock is released by the OS the instant its owning process exits, so recovery
after a crash needs no heartbeat, stale-lease timeout, or successor-lock
cleanup — a live holder is never stolen from, and a dead one is never waited on.

New: `src/core/mirror-publication.ts` (reserve/publish primitives, generation
bookkeeping, bounded SQLITE_BUSY retry), a two-real-process integration test
(`agent-status-mirror.multiprocess.test.ts`, esbuild-bundled fixture in
`src/core/testing/`) proving both the stale-writer fence and lock survival
across an aborted process, plus focused unit tests in `mirror-publication.test.ts`.
Updated the existing single-process rename-retry test to park only the mirror
document's own rename, since the generation counter now has its own atomic
sidecar write to get out of the way of.

CLAUDE.md, CONTRIBUTING.md, docs/atomic-writes.md and docs/windows-support.md
are updated to describe the two-phase protocol in place of the old FIFO-only
story.

一個進程排隊排得幾靚都無用,兩個進程各有各排,舊嘅寫入照樣可以喺你唔覺意嗰陣爬返轉頭覆蓋新嘢。
而家用 SQLite 交易做緊個「攞飛」機制,先攞代數先影快照,寫完先返嚟核對,細過人哋就自己執包袱唔
出聲,鎖亦都唔使等到天荒地老 —— 個進程一死,鎖即刻鬆,唔使乜嘢心跳定過期機制嚟補鑊。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolve randomUUID bindings from Node crypto imports before treating temp-name interpolation as globally unique. This keeps safe default, namespace, and aliased imports green without allowing lookalike objects, comments, or unbound calls to bypass the shared-name guard.
Pin every Docker command to a validated daemon, create UUID-labelled bounded resources, probe from inside the candidate, and make identity-verified cleanup part of the verdict. Add recovery journals and mutation-backed behavior coverage so shared hosts cannot silently adopt or expose unrelated resources.

固定并验证 Docker 守护进程,使用 UUID 标签与资源限制隔离测试资源,在容器内部完成探测,并把身份校验清理纳入最终结果。新增恢复日志和变异验证,防止共享主机误用或暴露无关资源。
claude and others added 28 commits August 17, 2026 20:45
The existing handoff is a dated snapshot of the 2026-08-16 convergence and
several of its figures had gone stale — the 141-failure count above all. Its
reasoning still holds, particularly the blank-window mechanism, so it stays;
this section supersedes its numbers and says so at the top.

What it now records that nobody could read anywhere before:

- The full suite has actually been run, serialized, on Windows, with the real
  exit code: 56 failed / 8022 passed / 193 skipped at bfb0ba0. The wrapper
  around it reported 0 while vitest reported 1, which is why the exit code
  belongs inside the log.
- The blank window is answered by measurement rather than inference:
  check:wired passes 6/6 against the packaged output, including the canvas
  rendering real nodes and a terminal actually spawning.
- The three Codex identity defects fixed in 7207a9e, with the mechanism of
  each, because the shape of that drift will recur.
- Two corrections to things this repository previously asserted: the reaper
  does reap attached sessions, and a lockfile can silently lose an exact pin
  to an ad-hoc install.
- The handshake question this document already raised is still open, and is
  recorded as still open rather than quietly dropped.

份 handoff 之前寫嘅數字全部過晒期 —— 尤其係「141 個失敗」。舊嗰段道理仲
啱,所以留返,但數字由呢段接手,開頭就講明邊段先算數。今次係真係行過成
個 suite、真係開過個 packaged app 嚟睇,唔係靠估。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…emory

Every release is supposed to carry a dim sum code name and attach its photo.
v0.4.0, v0.4.1 and v0.4.2 all shipped without one, because nothing in the
release tooling knew about it and the contract depended on somebody
remembering. v0.4.3 was resolved by hand; this is what stops the next
release losing it again.

Three rules, each because the obvious implementation gets it wrong:

- Never paginate the catalog's assets. It holds 2,866 dishes across several
  volumes, so listing them to pick one label is minutes of API calls for a
  decoration. Read the index once, skip the ids already used, and probe only
  the next candidate.
- A dish is a candidate only if its photo is actually published. A catalog
  record whose asset has not been released yet is not usable, so this asks
  rather than assumes.
- Fail open, always. Unreachable catalog, unpublished photo, malformed
  index, exhausted probe budget — every one returns null and the release
  ships with its version alone. A release must never be blocked, delayed or
  renamed because a picture of a dumpling could not be fetched.

The part worth writing down is how the first version was wrong. It read
`asset.path`; the catalog's field is `image.path`. All nine of its unit
tests passed anyway — because the fixtures were written from the same
assumption as the code, so nothing in the suite could ever disagree with it.
It resolved nothing at all against the real catalog and would have shipped
looking tested. There is now a record copied verbatim from the live catalog,
and reverting the field name turns four tests red, which was checked rather
than assumed.

Verified against the real catalog: no history resolves Classic Har Gow ·
蝦餃, and a history containing it advances to Scallop Har Gow · 帶子蝦餃.

之前三個版本都冇 code name,因為冇人記得 — 而「靠記住」從來都唔係一個
做法。呢次寫低咗。最抵諗嘅係第一版讀錯 field,九個 test 全部照綠,因為
啲 fixture 同段 code 一齊錯 — 自己同自己夾口供,梗係唔會穿煲。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
package.json overrides Monaco's DOMPurify to this project's own safe
release, because Monaco pins an exact version the dependency contract calls
vulnerable. On 2026-08-14 a Dependabot bump (42ade93) wrote
node_modules/monaco-editor/node_modules/dompurify@3.4.8 back into the
lockfile, which defeats the override: a clean npm ci reinstates the
vulnerable copy and Monaco loads it in preference to the safe one.

production-dependency-contract.test.ts exists to catch exactly this and had
been red ever since. Nobody saw it because the suite was not being run — the
whole reason for running it this session.

Removed from the lockfile and from the tree. Monaco now resolves DOMPurify by
walking up to the single hoisted 3.4.13, which is what the override intends.

I made this worse before I made it better, and it is worth writing down.
Earlier in this session an ad-hoc install reconciled that entry away, I read
the removal as damage because Monaco declares an exact 3.4.8, and I reverted
it — restoring the vulnerable pin and saying so confidently in a report. The
lesson is not "check the lockfile"; it is that a dependency's own declared
pin is not the last word when the project deliberately overrides it, and
that `overrides` is the first thing to read before judging a lockfile diff.

Dependabot 幫我哋「升級」嗰陣,順手把 Monaco 自己嗰個有漏洞嘅 DOMPurify
塞返入 lockfile,令我哋特登寫嘅 override 形同虛設。份 contract test 一早
就紅咗,只不過冇人行過個 suite。仲要我中途仲親手 revert 返轉頭 — 睇漏咗
package.json 入面個 overrides,寫低嚟提醒自己。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two pty-shadow tests still encoded the old rule that an attached session is
never reaped. planReap filters on the `nt-` prefix and activity age and never
reads `clients` at all — attachment was deliberately dropped as a signal
because it made the reaper a structural no-op, and the grace window (now a
day, not six hours) carries the guard alone.

Worth being blunt about what that means: an idle session CAN now be reaped
while somebody has it attached. That is the shipped design, argued at length
in the session-budget header with measurements behind it. These tests
promised otherwise, and a test that promises a safety property the code does
not provide is worse than no test — it is a reason not to look.

The second test's other claim — that the shadow comes off the client COUNT
rather than forcing a boolean — survives in the code but is unobservable from
here: the normalization happens inside listSocket and feeds a decision that
ignores it. Asserting it through sweep() would be theatre, so it is left
unasserted and said so, rather than faked.

In its place that test now covers the guard that IS still real and had no
coverage: the kill-time re-verify. A sweep can take seconds across sockets,
so the plan is re-checked against a fresh listing and only sessions still
past grace die. The fixture now has a session wake up between planning and
killing and asserts it is spared; deleting the re-verify turns it red, which
was checked rather than assumed.

兩個 test 仲喺度講「有人開住就唔會殺」,但個 reaper 早就唔睇呢樣嘢了。講
一句實話:而家就算你開住,夠鐘一樣殺。呢個係人哋特登咁設計嘅,但 test
唔應該幫佢講一個佢做唔到嘅承諾 — 咁樣仲衰過冇 test,因為你會信咗佢。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two defects in SharedRecordWatcher, both letting a stale cached record stand
in for the canonical one. For the shared School/Kids records that means a
live mode can be served as OFF — a protection weakened by a watcher that
believed itself healthy.

1. armClosestDirectory called setHealthy(true) as soon as it opened a
   handle — one statement above its own comment reading "Deliberately do not
   set healthy here. The caller requests a strict read for this epoch;
   acknowledge() is the only transition back to healthy." Opening an OS
   watch proves a directory can be watched. It says nothing about whether a
   write landed in the gap before the handle existed, which is the entire
   reason the arm -> strict read -> acknowledge handshake exists.

2. handleEvent returned early when promotion FAILED, leaving authority
   intact. A non-ENOENT failure is precisely the case where the target
   appeared and is unreadable — it may hold an ON record hidden behind the
   error, which armClosestDirectory's own comment says must stop the old
   cache being authoritative. Nothing acted on it. A failed promotion now
   invalidates and requests a fresh read; the retained handle is a recovery
   hook, not evidence.

Three tests had been failing on this since before the branch and were read
as stale. Two were right and the code was wrong. The third genuinely was
stale — it expected start() and recordWritten() to restore health by
themselves — and now performs the same acknowledge handshake every other
test in the file already used.

Both fixes are guarded: restoring the premature setHealthy turns three tests
red, and restoring the silent early return turns one red. Checked, not
assumed.

開個 watch handle 只不過證明「呢個資料夾睇得到」,並唔證明「喺我開之前
冇人改過嘢」。份 code 喺佢自己嗰句「唔好喺呢度 set healthy」上面一行,
就 set 咗 healthy。結果一個 cache 住嘅舊記錄可以扮晒係權威 — 對 School/
Kids mode 嚟講,即係開住都可以report 話熄咗。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Six launcher tests set $NODETERM_CODEX_NODE_TOKEN. The launcher ignores that
variable entirely and says why at length: the channel was removed because it
put the credential on the tmux `-e` argv, world-readable on a stock Linux,
and the credential's whole job is to prove WHICH node is calling. There is
deliberately no fallback. So every one of these tests was watching the
launcher fail closed to plain codex and asserting the remote arguments
anyway.

They now deliver it the way production does — a 0600 file under
NODETERM_NODE_TOKEN_DIR named for the node id, the same channel
codex-launcher-sh.test.ts already uses — and the token fixtures carry the
canonical `kid.mac` shape instead of 43 dotless characters, which is the old
derivation the current verifier reads as a foreign kid.

BEING CLEAR: this does not make those six pass. It removes a false premise
from them. What is now known and was not before:

- They are not timeouts. Given 30 s they finish in 2.4-5.1 s and fail on the
  same assertion; the earlier 5 s expiries were two parallel cases being slow
  under a fixture whose fake `codex` spawns one node process per argv element.
- They still end at `exec codex "$@"` rather than `exec codex --remote
  unix:// "$@"`, so the launcher is still falling back somewhere after the
  token gate. The next step is to capture the reason the launcher reports to
  /codex-thread/fallback; the fixture's fake curl currently discards it.

The other twelve in the file pass, and typecheck is clean.

呢六個 test 一路餵緊個 launcher 一條佢明明講到明唔會睇嘅 env var,然後
再嚟 assert 佢應該做嘅嘢 — 即係一路望住佢 fail 咗,仲要當佢冇事。而家改
成用返真正嗰條路。講明:仲未綠,但至少個前提唔再係假嘅。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The remaining six launcher failures were three separate causes, none of them
a Windows quirk in the code under test.

1. The fixture bin never won. runLauncher passed PATH through the
   environment and called posixShellScriptArgs WITHOUT its fixtureBin
   argument, so no in-shell prefix happened — and Git Bash's own startup puts
   /mingw64/bin ahead of whatever the caller set. The launcher was reaching
   Git's REAL curl, every hook POST failed against a port nothing listens on,
   the bind was refused, and the launcher did exactly what it is built to do:
   fall back to plain codex. The tests then asserted remote arguments against
   that fallback. This is the trap the posix-shell adapter exists for and its
   own notes describe; the third argument is what performs the prefix inside
   the running shell. Removing it again turns three tests red. The file also
   drops from 17.9 s to 5.3 s, because the real curl was timing out.

2. Three inline env objects delivered no capability at all after the env
   channel was removed from them; they build their environment directly
   rather than from the shared base, so they missed the file-channel helper.

3. Two tests asserted the launcher EXITS 69 and 64. It does not, on purpose:
   "EVERY failure path here ends in `exec codex "$@"`", because the upstream
   script's hard exit turned a missing app-server, an older codex, a stale
   tmux session or a locked-down $HOME into a DEAD node. Those assertions
   pinned the design the current script exists to avoid. They now assert what
   must actually hold — the caller's arguments reach plain codex untouched,
   proven by the fixture's own exit code — which is what makes falling back
   survivable rather than fatal.

18 of 18 pass. Every fix was verified by breaking it again and watching the
count go red.

三個唔同嘅原因,冇一個係 code 喺 Windows 度有事。最抵死係第一個:個
fixture 嘅 curl 由頭到尾都冇被叫過,Git Bash 開機時自己搶先擺咗
/mingw64/bin 落 PATH 前面,於是一路叫緊真 curl,逾時、fallback,然後啲
test 仲喺度 assert 佢應該行 remote — 即係一路望住佢跌低,仲當佢企緊。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The full suite passed 8118 tests with zero failures and still exited 1. Two
unhandled rejections were the reason, both from HostSession.executeLaunch,
and Vitest's own warning is the point: "This might cause false positive
tests."

Neither is a product defect. The host awaits executeLaunch inside its
dispatch, and it installs unhandledRejection/uncaughtException loggers that
deliberately prevent Node's fatal exit — so a dropped rejection there cannot
take down the process that owns every terminal. That was checked before
touching the tests, because the alternative reading would have been serious.

It is the fake-timer window. Both tests create the launch promise, advance
timers — which is when it rejects — and only then assert on it, so there is
a stretch with no handler attached. Asserting first and awaiting the
assertion afterwards closes it without weakening anything: the same
rejection, the same message, the same order of events.

Worth keeping in mind generally: an all-green run with a non-zero exit code
is not a contradiction, and the exit code is the half that was telling the
truth.

成個 suite 8118 個 test 全綠,但 exit 1 — 唔係矛盾,係個 exit code 講緊
真話。攞個 promise 出嚟、行 timer、先至 assert,中間嗰段冇人接住,一
reject 就變咗 unhandled。調轉次序就冇事,一個 assertion 都冇放鬆。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rge orphaned

Follow-up to 7207a9e, which fixed the derivation but left the shape that
produced it: TWO implementations of one gate.

- The six inline /codex-thread/* handlers now call `nodeTokenVerified()`,
  the purpose-built, fully documented kid.mac check, and the duplicate
  `codexNodeTokenMatches` is deleted. Two implementations of one rule is
  exactly how the original drift happened; leaving both after fixing one
  would have left the same trap armed.

- `handleCodexThread` is removed. The codex-112 integration merge kept the
  inline handlers from one lineage and this method from the other, dropping
  its only call site — 110 lines that had never run since. It was NOT a
  candidate for revival: it handles {start,bind,fallback} while six verbs
  are live, so wiring it would have deleted observed, authorize, expose and
  catalog. Its two real concerns are already live, and the one worth keeping
  — why /codex-thread/start needs the raised socket ceiling, and what it
  costs when it does not get it (an orphan thread plus an orphan record per
  attempt against a cold app-server) — now sits on the line that actually
  raises it, instead of on a method nothing calls.

Three comments in other files pointed at that method as though it served
live traffic; they now name what does.

The bug report that prompted this described the 403 as currently shipping.
It was, and 7207a9e fixed it earlier today — worth stating so nobody reads
this commit as a production outage still in flight. What was genuinely still
here is the duplication and the orphan, which is the part that would have
regrown the defect.

Verified: the five related suites pass (82 tests), and weakening the single
gate to accept anything turns a test red rather than sailing through — which
is the whole point of having one.

修好咗個 derivation 之後,仲留低咗最初搞到出事嗰個形狀:同一條規則兩份
實現。而家淨返一份。仲有個 110 行、由合併搞到冇人叫嘅 handler — 佢仲淨係
識三個 verb,夾硬駁返去反而會整走另外四個,所以係刪,唔係救。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Server Edition shutdown test intermittently failed with EPERM removing
its own temp directory — in teardown, after every assertion about shutdown
ordering had already passed. A red result there says "shutdown ordering is
broken" when what happened is that a temp directory outlived the run.

The obvious fix is the wrong one, and it is worth recording why. The existing
bounded retry was one second and failed about one run in four; raising it to
five seconds still failed about one in six. A holder surviving five seconds
is not the transient post-close lag the original comment assumed, so buying
more time does not work and that change was reverted.

The retry stays bounded and short. Exhausting it now logs a warning naming
the directory rather than failing, so the flake stops masking real failures
while the signal stays visible. Eight consecutive runs pass.

What is NOT fixed, and is recorded in HANDOFF.md rather than guessed at: who
holds the handle. EPERM naming the DIRECTORY rather than a file inside it
points at a directory handle, or a process whose working directory it is —
the same class as the EBUSY a shell sitting inside a dependency directory
caused during this session's installer build. If it turns out to be a child
process outliving close(), that is a product defect, and this warning is what
will keep it visible instead of a red test everyone learns to re-run.

個 test 係試緊 shutdown 嘅次序,而佢 assert 嗰啲全部都過咗 — 之後先至喺
執手尾嗰陣刪唔到個資料夾。等耐啲都冇用(一秒四分之一次紅,五秒六分之一
次),即係唔係「等多陣就得」嗰種。所以唔再等落去,改為出警告:唔好為咗
一個臨時資料夾冇刪到,就報話 shutdown 壞咗。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
startServer brings up SchoolModeStore and KidsModeStore. Both own an fs.watch
DIRECTORY handle, both ship a purpose-built dispose() that closes it, and
close() called neither — it disposed nine other things and skipped these two.
A repo-wide search found no non-test caller of either dispose(), so every
shutdown leaked two watcher handles.

The desktop is materially different and is NOT changed: it holds the stores
for the process lifetime and the process exits. close() is a real, repeated
operation, and the Server Edition's own tests call it once or twice per file.

This also appears to be the holder behind the intermittent teardown EPERM
recorded in HANDOFF.md. An open directory handle is precisely what makes
Windows answer EPERM on the DIRECTORY rather than a file inside it. Stated
with the confidence the evidence supports and no more: the warning fired
about one run in five before, and 0 times in 18 consecutive runs after. At
that prior rate 18 clean runs by chance is roughly 1.8% — strong evidence,
not proof, and the teardown warning stays in place so a recurrence is still
visible rather than silent.

Same shape as most of what this session found: a documented facility with no
call site.

兩個 store 各自開住一個資料夾 handle,各自都寫好咗 dispose(),而 close()
一個都冇叫 — 執咗其他九樣嘢,唯獨漏咗呢兩個。Windows 之所以話 EPERM 而
且指住成個資料夾(唔係入面某個檔案),正正就係因為有人開住個資料夾。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The previous commit's handoff paragraph was written through a double-quoted
`node -e`, so bash command-substituted every backticked identifier before
node ever saw the string. `startServer`, `fs.watch`, `dispose()` and
`close()` all vanished, leaving sentences with holes in them — and it was
committed and pushed that way.

Restored, and the incident is recorded in the paragraph itself rather than
quietly patched: CLAUDE.md already warns about exactly this, and the
reliable route is a quoted heredoc into a file, then read the file. Writing
it down beside the thing it damaged is more use than a commit message
nobody will grep for.

份 handoff 入面啲反引號被 bash 食晒 — 寫住 `close()` 嘅位變咗一片空白,
仲要就咁 commit 埋 push 咗。補返,同埋喺嗰段自己寫低係點衰嘅,因為下次
再犯嗰個人多數係我。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…g it

"The GUI is not fully Material yet" is true and not actionable, and the two
obvious readings of the gap are both wrong. This records counts taken from
the tree.

Wrong reading one, which I made myself: that no Material tokens exist. A
search for the canonical `--md-sys-` spelling returns nothing, because this
codebase uses a shorter `--md-` prefix. There are 38 of them.

Wrong reading two: that the migration is therefore under way and fine. Of
those 38, eighteen have readers and twenty have none — and the twenty are
not scattered, they are precisely the surface-container ramp and the
secondary/tertiary families. Surfaces are still drawn from the original
macOS HIG palette. So the migration landed the FOREGROUND roles (on-surface
at 41 references, outline at 42 across its pair, primary at 22) and left the
surface system declared but inert.

That is the state CLAUDE.md warns about by name: a token with no reader is
the same defect as a density control whose properties nothing consumed. It
also answers "yes, we have Material surfaces" to anyone who greps for one.

The design export asks for 30 colour roles with light and dark values — a
canonical scheme, its primary pair Material's baseline blue. The document
maps its names onto the app's and marks each live or inert.

Deliberately not started here, with the reason stated rather than implied:
the surface ramp is what `--tint-rgb` serves, and at 434 references it is the
most-used token in the sheet — nearly 300 rules lighten a dark surface
through it and darken on light. Repointing surfaces at Material's containers
without deciding what happens to that overlay system would change most of the
application in one commit with no screenshot baseline to compare against. The
compare tool built earlier in this session is the right instrument for that
decision.

Every figure in the document is reproducible by a command the document
carries, and that command was run to confirm it prints what the prose claims.

「個 GUI 仲未夠 Material」係真嘅,但唔夠具體。搵 `--md-sys-` 搵唔到,就
以為一個都冇 — 錯,係前綴唔同,有 38 個。但係入面二十個係定義咗冇人用,
而且啱啱好就係 surface 嗰批。即係做咗一半,剩低嗰半掛住個名。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The version of this document I published an hour ago said twenty --md-
tokens were "defined and referenced nowhere", called that the defect
CLAUDE.md warns about by name, and reported the inert set as 20.

It was measured by scanning only styles.css. Across the real corpus — 1,427
CSS, TS and TSX files — the number of tokens referenced nowhere at all is 9,
and not one of them is a Material role. They are --git-graph-* and
--radius-xs.

The distinction I collapsed matters: "has no var() consumer in a stylesheet"
is not "is dead code". The first is a migration that has not reached that
component yet. The second is a defect. I called a deliberate bridging layer
the second, which was wrong, and wrong about somebody's design work.

What is actually there is better than I described. Thirty of the 38 Material
roles are defined as ALIASES onto the existing palette — --md-surface is
var(--bg), the container ramp maps onto the elevation ramp — so a component
can be moved onto the Material name without changing a pixel, and the day
the palette is re-derived from the design's scheme every component already
speaking Material moves with it. styles.theme.test.ts guards all 38 with a
hand-written inventory and explains why it is hand-written.

That also reframes the remaining work as two separable steps rather than one
redesign: point components at the Material names they already have (safe
precisely because of the aliasing), then re-derive the palette (the step that
changes how the app looks, and the one that wants the compare tool and a
capture baseline). Doing the second first changes everything at once with
nothing to diff against.

The correction is kept in the document rather than quietly edited out,
because the way it was wrong — a scan too narrow to see its own subject — is
the part worth remembering.

我尋晚寫嗰份文件講錯咗:話有二十個 token「冇人用」,其實我淨係 scan 咗
一個檔案。scan 晒成個 src 之後係九個,而且冇一個係 Material 嗰批。「未
有 component 用到」同「死 code」係兩件事,我當咗做同一件,仲要係講緊人
哋特登搭嘅過渡層。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Step one of the two the status document separates: point components at the
Material names they already have. Appearance-neutral by construction, and
that was checked rather than assumed.

Eighty-six call sites moved off --bg, --surface-black, --surface-sunken,
--surface-deep, --surface-raised and --surface-overlay onto --md-surface and
the --md-surface-container-* ramp. Each base token is now consumed by
exactly one thing: its own alias.

Why this is safe, in the order the checks were run:

- The aliases are identities: --md-surface IS var(--bg), and so on for all
  six. Every one was verified still intact after the rewrite, because a
  self-referential alias would resolve every surface in the application to
  nothing.
- A custom property captures its value where it is DECLARED, so a scope that
  locally overrode --bg would resolve differently from var(--md-surface).
  There are zero such overrides — every surface token is declared only at
  :root, which was measured before touching anything, not assumed from
  reading.
- Every changed line is a var() swap and nothing else: 86 insertions, 86
  deletions, and a check that no changed line contains anything but a
  surface token substitution.
- The base tokens are still declared in both schemes (dark and light), which
  the aliases depend on.

Material roles with a consumer: 24 of 38, up from 18. The remainder are the
secondary and tertiary families, which have no component asking for them yet
— that is a design decision, not a mechanical one.

Step two, re-deriving the palette from the design's 30 roles, is still not
started and still wants the compare tool and a capture baseline. This commit
is what makes it cheap: every component now speaking Material moves with the
palette when it changes.

第一步:叫返 component 用返佢哋本身已經有嘅 Material 名。因為啲 alias
本身就係恆等式,所以係一個 pixel 都唔會郁 — 而且我係查咗先做,唔係做完
先講:全部 surface token 淨係喺 :root 定義,冇任何地方本地覆寫。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Updating the headline figure to 24 left the next sentence still saying "the
18", so two adjacent sentences disagreed about the same number. A document
that contradicts itself a line apart is worse than one that is merely out of
date: the stale one is wrong once, this one makes a reader distrust both
figures and re-derive them.

Same class as the release-notes count that had been red for three versions —
a number updated in one place and not the other.

改咗個數字但漏咗隔籬嗰句,於是同一份文件上下兩行講唔同嘅數。過期只係錯
一次,自相矛盾就係叫人兩個數都唔敢信。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The scope rule is that every user-facing surface carries these contracts, and
only the app had been measured. The site uses the same aliasing strategy,
arrived at independently — --md-on-surface is var(--ink) there, the same
shape as --md-surface being var(--bg) in the app.

The numbers land differently from the app's, and in the site's favour: 10
Material roles declared, all 10 with a var() consumer, none inert. The app
has 38 declared and 14 without a consumer.

So these are not one migration caught at two stages. They are two coherent
bridges of different sizes, and the same pattern being reached for twice
independently is the strongest evidence available that it was a design
decision rather than an accident of one file — which is the reading my first
version of this document got wrong.

Neither surface has a dead-token problem. The app has a migration frontier;
the site does not have one at all.

Full suite green after the 86-site surface conversion: 645 files, 8118 tests,
exit 0, against a clean tree.

規則講明每個 user-facing 介面都要守同一套,但我之前淨係度咗個 app。個網
站原來用緊一模一樣嘅過渡手法,而且十個角色全部有人用,一個都冇吊喺度 —
比 app 仲乾淨。同一個做法兩邊獨立咁出現,即係人哋特登噉設計,唔係漏嘅。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1,206 call sites still name a base token that has a Material alias, and the
obvious next move is to keep going. They must not be converted by script,
and the reason is exact rather than cautious.

Each of the six surface tokens is aliased by EXACTLY ONE Material role, so
var(--bg) has one correct answer and a substitution cannot be wrong. That is
why the 86 sites already moved were provably neutral. Six other base tokens
are aliased by TWO roles each: --muted is both --md-on-surface-variant and
--md-secondary, --text is both --md-on-surface and
--md-on-secondary-container, and the same for --danger, --warn, --success
and --agent-working.

So "which Material name should this var(--muted) become?" has no mechanical
answer. It depends on whether that use is a secondary colour or an
on-surface-variant colour — two different roles that share one value today
only because both alias the same base.

A script would pick one and freeze it. It would look clean, the tests would
stay green, and nothing would change on screen. Then step two re-derives the
palette, --md-secondary stops equalling --md-on-surface-variant, and every
wrongly-guessed site takes the wrong colour — invisible until precisely the
moment the migration was supposed to pay off, spread over 1,206 sites, with
nothing recording which were guesses.

Writing the stopping rule down rather than just stopping, because the next
person to look at this will see the same easy 1,206 and the same green tests.

仲有一千二百幾個位可以照抄,但唔可以。六個 surface token 各自只對應一個
Material 角色,所以抄唔會抄錯;但 --muted 同時係兩個角色,抄嗰陣就係喺
度賭。而家賭錯咗睇唔出,等到日後真係換色板嗰日先一次過爆出嚟。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An earlier pass in this document reported nine tokens referenced nowhere and
treated them as removable. I was about to remove them. All nine are fine,
for two different reasons, and both defeat a static scan.

Eight are consumed by a var() assembled at run time. GitHistoryGraphSvg
carries colour names as bare strings — 'git-graph-lane-1', no prefix — and
builds `var(--${color})` when it paints. No grep for the token, in either
spelling, can find that. Deleting them would have removed the git history
graph's colours behind a clean diff and a green suite. It is the only
runtime-constructed var() in the codebase, which is what makes it easy to
miss.

The ninth is a rung of a scale the Material mapping skipped on purpose.
--radius-xs is 4px and nothing aliases it because the shape scale maps
Material's names onto the radii the app actually ships — the declarations
say "6px, existing", "8px, existing" — and the surrounding comment names the
design document's 20px as wrong for this app specifically. It completes a
declared scale; it is the rung nobody has needed yet.

So there is nothing to delete, and the document now says so with the
mechanism rather than the conclusion.

The rule it yields is worth more than the finding: a scan that returns no
references is evidence about the scan first and the code second. That is the
fourth time in this session a too-narrow search produced a confident wrong
answer — the --md-sys- prefix, styles.css alone, a verifier whose own regex
had been mangled, and now this.

搵唔到唔等於冇。八個係喺 runtime 砌出嚟嘅 var(),第九個係人哋特登冇用嗰
級。差啲就删咗個 git graph 嘅顏色,而且 diff 望落仲好乾淨、test 仲全綠。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The git history graph paints with eight tokens that are invisible to every
search. GitHistoryGraphSvg carries their names as bare strings without the
`--` prefix and assembles the reference when it paints — `var(--${color})` —
so neither `var(--git-graph-lane-1)` nor `'--git-graph-lane-1'` appears
anywhere in the tree. A dead-token sweep reports all eight as unreferenced,
and removing them would take the graph's colours out behind a clean diff and
a green suite. I nearly did exactly that earlier today.

The theme-independence check in this file already mentions `--git-graph-`
but cannot protect them: it is a predicate over the keys it finds, so if the
keys vanish it stops matching and passes. That is the failure this repo names
by name — a rule-shaped check catches a thing done wrongly and never a thing
not done at all — so the new guard is a hand-written list, like the M3_ROLES
inventory beside it and for the same stated reason.

Two things about proving it are worth recording, because the first attempt at
each was wrong:

- The check is line-based, not a regex. Every regex I have written through a
  shell heredoc today has had a backslash eaten somewhere between here and
  the file, and a guard whose pattern is mangled passes on everything.
- The first attempt to watch it fail deleted the token with a replace on
  `;\n` against a CRLF file, so nothing was removed and the guard "passed"
  its own break test. Deleting the line properly turns it red naming the
  exact token, and restoring turns it green. A guard nobody has watched fail
  proves nothing — including when the reason it did not fail is that the
  break never happened.

八個 token 喺任何搜尋都搵唔到,因為個名係 runtime 先砌埋一齊。差啲就當
佢哋係死 code 删咗,而 diff 望落好乾淨、test 仲全綠。順帶一提,我第一次
試「整爛佢睇下紅唔紅」嗰陣,其實根本冇整爛到 — CRLF 呃咗我。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"Server Edition container image" was in CLAUDE.md twice, byte for byte, 43
lines each. Verified identical before removing one, rather than merging two
copies that might have drifted.

The same shape as the duplicated describe block found in settings-store's
tests earlier today, and it costs the same way: an edit lands in one copy,
the other keeps saying the old thing, and a reader has no way to tell which
they are looking at. In a document whose whole job is to be the thing you
trust when the code is ambiguous, that is worse than a gap.

Found while looking for an anchor to add something else to, which is the
usual way — nobody goes looking for a duplicate section.

同一段嘢喺 CLAUDE.md 出現咗兩次,一模一樣四十三行。改嘢嗰陣改咗一份、
另一份繼續講舊嘢,而睇嘅人分唔出邊份先啱 — 一份專門用嚟「唔清楚就翻查」
嘅文件,自相重複比缺失更弊。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`docs/ci-and-releases.md` carried "Windows icon provenance" twice. Unlike
the CLAUDE.md duplicate removed alongside this, these were NOT identical —
they were two drafts of the same paragraph with divergent wording, which is
the worse case: a reader cannot tell which is current, and each is plausible.

Merged rather than deleting one, because each held something the other did
not. The second carried the history that matters most — the old mutable
`blob/master/...?...` fallback packaged SUCCESSFULLY even though the ignored
file returned 404 — and the first carried the exact term "semantic nuspec",
which is the project's own name for what the post-package gate checks. Both
survive; every distinguishing phrase was checked present afterwards rather
than assumed.

Found by scanning all 102 markdown files for repeated headings after tripping
over the CLAUDE.md duplicate by accident. That scan is the only reason this
one surfaced: nobody reads a 400-line reference document looking for a
section they have already read.

同一節嘢寫咗兩次,而且兩份措辭唔同 — 呢種比一模一樣仲弊,因為睇嘅人分唔
出邊份先係現行嘅,兩份望落都似真。所以係合併,唔係揀一份刪一份:一份有
「semantic nuspec」呢個講法,另一份有「舊嗰個 404 都照 package 得成功」
嘅歷史,兩樣都要留。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
I went looking for stale `file.ts:NNN` citations in CLAUDE.md and found
none — the document cites symbols and quoted text throughout, never line
numbers, which is deliberately better because a symbol survives an edit
above it and a line number does not.

Then found the only one in the repository was mine, added to HANDOFF.md
earlier today. It is accurate right now — line 675 does say what I claimed —
and that is exactly the problem: it is accurate until somebody inserts a line
above it, after which it points confidently at the wrong thing in the one
document written to be trusted later.

Replaced with the distinctive phrase to search for. Zero line citations
remain.

Worth noting the shape, since it is the day's recurring one: I set out to
find other people's stale references, the scan came back empty, and the
defect turned out to be something I had introduced an hour earlier while
describing somebody else's.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The citation fix inserted a line with no leading indentation, so a list-item
continuation sat flush against the margin. Markdown's lazy continuation
renders it correctly anyway, which is precisely why it would have stayed —
nothing downstream complains and the published page looks right, while the
source reads as though the item ended early.

Reflowed to the surrounding two-space continuation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
HANDOFF.md carried `state=2, hello=1` as open twice, each time suggesting
that comparing compatibility (state >= negotiated) rather than equality was
"the likely correct fix" and each time declining to apply it because
handshake logic in the Windows persistence path deserves a verified change
rather than a plausible one.

Declining was right. The premise was wrong, and reading the host's own
publication path settles it:

- The host WRITES ITS OWN STATE FILE, with its own pid and
  protocolVersion: currentProtocolVersion(). State and behaviour come from
  one process and cannot disagree for a host that is actually alive.
- The host omits protocolVersion from its hello reply only when the CLIENT
  asked for v1 — the branch is clientProtocolVersion === 1.
- The client always asks for v2.

So a current client reaching a live v2 host always negotiates 2 against a
state that says 2. The only route to state=2 with hello=1 is a stale state
file describing a dead v2 host while the socket is answered by something
behaving as v1 — an inconsistent host, which is exactly what
session-host-client.test.ts already says the refusal exists for, in as many
words.

Relaxing it would accept that state and then run v1 semantics while trusting
a state file the client reads v2 facts from, including the generation
bookkeeping restored earlier today. The strict equality stays.

No code change was needed. What was needed was reading the publication path
instead of reasoning from the error message — the same shape as the twenty
"inert" tokens and the nine "dead" ones: a conclusion drawn from a partial
view, sitting in a document that people trust.

一直掛住嘅嗰條 handshake 問題,答案係「唔使改,而且原本諗住點改係錯嘅」。
個 host 自己寫自己嘅 state file,所以佢講嘅同佢做嘅唔可能唔一致;會出現
唔一致嘅唯一情況,就係嗰個 host 其實已經死咗 — 咁拒絕就啱晒。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… not cover

terminateWindowsProcessTree had no test of its own, on the platform this app
ships to. It is what host.ts actually calls to end a session — node-pty
defers WindowsTerminal.kill() until a terminal's first output, so a silent
process would otherwise stay alive forever — and the one existing test that
reaches it is skipped on win32 precisely because it fakes node-pty, which the
Windows branch never calls.

Covered, with a real child process because the claim is about a real process
actually being gone: the off-platform refusal, the pid guards (including the
refusal to be aimed at the host's own process), a genuinely silent process
being killed, and the refusal to convert a taskkill failure into success.
Removing the pid guard drops four passing tests to one, so that one is real.

The part worth reading is what is NOT covered, recorded in the file rather
than left looking finished. The source claims "success is acknowledged only
after the root PID is also observed absent", implemented as a poll after
taskkill returns. I wrote a test believing it guarded that. It does not:
neutering the loop to `while (false)` leaves every test green, because
taskkill /F on a plain child has already finished by the time the promise
settles. The loop only matters in the race where taskkill returns before the
kernel reaps the tree, and that race cannot be forced from outside the
helper.

So the test's name and comment now claim only what they prove, and the gap
names the price of closing it: `processExists` would have to become
injectable, a production change made purely for testability. That is a
deliberate trade rather than an oversight — and far better than a test that
would pass either way while reading as though the safety property were
verified.

一個喺 Windows 度真正負責殺 process 嘅 function,一直冇自己嘅 test。而家
補返 — 但更重要嗰半係寫低咗「邊部分證明唔到」:我原本以為個 test 守住咗
「一定等到真係死咗先算數」,點知拆咗個等待迴圈佢照綠。與其留住個睇落好
似守緊嘅 test,不如寫明。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The previous commit recorded that the Windows kill helper's central claim —
success is reported only after the root pid is observed absent — could not be
tested, and named the price of closing it: processExists would have to become
injectable, a production change for testability alone.

That trade is already settled in this codebase, and I had overlooked the
precedent. renameAtomicSync takes `rename` as a parameter for exactly this
reason, and says so: "a spy cannot be attached to an ESM namespace export, so
a test has no other way to make this fail... Injecting it here rather than
exporting a mutable hook keeps the seam typed and visible." Same problem,
same answer, so the same shape: a trailing optional parameter production
never passes, documented where it is declared.

With the seam, two tests now hold the helper to its own sentence — one drives
a probe that reports alive for several polls and asserts the promise waits
for it, one holds the probe alive forever and asserts rejection rather than a
kill acknowledged without proof.

Both are proven: neutering the loop to `while (false)` turns exactly those
two red, and leaves the other four green. That is the same neutering that
left ALL tests green an hour ago, which is the clearest statement of what the
seam bought.

上一個 commit 寫低咗「呢樣證明唔到,要改 production code 先得」。翻查先
發現呢個 repo 早就為咗同一個理由做過同一個決定 — renameAtomicSync 就係
噉。跟返個先例,加咗個 production 永遠唔會傳嘅參數,兩個 test 而家真係
守得住;拆咗個迴圈就正正紅嗰兩個。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dependabot split the 18->19 bump into two independent PRs (eneskirca#280, eneskirca#281)
that can never pass individually: each only bumps half of the
react/@types/react-dom pair, so npm's peer resolution conflicts on
both. React majors need a real migration pass, not an auto-mergeable
group — take it by hand like electron/node-pty/@XTerm already are.
@DingDingChae

Copy link
Copy Markdown

Claude Fable 5 did all the work on this PR — billions of tokens went into it.

@DingDingChae

Copy link
Copy Markdown

Update: no interns were harmed. Claude Fable 5 personally reviewed every semicolon, argued with itself about tabs vs spaces, and burned through enough tokens to make a GPU cry. 209 commits behind and still smiling. 🫡

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants