Add Windows support, Claude Fable 5 - #276
Open
cafepromenade wants to merge 572 commits into
Open
Conversation
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 标签与资源限制隔离测试资源,在容器内部完成探测,并把身份校验清理纳入最终结果。新增恢复日志和变异验证,防止共享主机误用或暴露无关资源。
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.
|
Claude Fable 5 did all the work on this PR — billions of tokens went into it. |
|
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. 🫡 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/headlessfor server-side screen reconstruction, giving Windows the samecross-restart persistence tmux provides elsewhere.
What is in it
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 filebuild.bat/build-installer.bat/download-dependencies.batbootstrap a fresh machine unattended, with a preflight that names the actual blocker instead of relaying npm's errorfs.renameis not atomic on Windows — it fails withEPERMwhenever 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 testDocker 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.ymlbuilds and runs thenodetermservice, with namedvolumes so sessions, settings and pairings survive a container replacement.
caddyservice behind atlscompose profile —off by default, so a plain
docker compose upworks with no certificatesetup. A required-variable bug that broke that default
upis fixed here.password-only.
quickstart — the quickstart is the path least-experienced users take, which
makes it the one most worth getting right.
scripts/test-docker-host.mjs, a public-quickstarttest, 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
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.
(
schemaVersionas well asversion, atermslist, companion documents withno substitutions) instead of rejecting all of them.
Verification — read this before merging
Honest state, because some of it is genuinely unverified:
commits are tests, including real-Windows and packaged-acceptance harnesses.
packages and publishes. So a green run means "it built", never "it passed".
a4e3b13d, 179 files) is an explicit checkpoint: it preservedin-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.
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 connecthandles, 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:
MoveFileExfails withEPERMwhenever the destinationis 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.
Date.now(). Neither is a globaldimension: 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 nodeso aPOSIX 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 ashebang — 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
preplugin now blanks theshebang 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-contractassertedexpect(version).toBe('0.4.0'), which can be greenfor 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-contractaimed a mutation at a workflow line that no longerexists — the checked-out-commit proof moved to an inline shell test and
assert-targetwas repurposed for tags. A mutation aimed at a missing stringthrows 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: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:
monaco-editorunresolvablenode_modulesheld 0.52.2 while the lock pins 0.56.0. A freshnpm cidoes not reproduce it/bin/sh,/bin/mkdir, AF_UNIX binds)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.3is published, non-draft, latest, targeting the exact built commit. Verified by downloadrather than assertion: the Setup returns HTTP 200 at exactly its built size, the updater feed serves
this release's
RELEASES, andcheck:wiredpasses 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.jsonoverrides Monaco's DOMPurify tothis project's own safe release. A Dependabot bump wrote the vulnerable nested pin back into the
lockfile, so a clean
npm cireinstated it and Monaco would load it in preference. The dependencycontract 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.
armClosestDirectorycalledsetHealthy(true)onestatement 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.
planReapfilters on prefix andactivity 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
Vite's transform cannot parse the
#!shebang every executable script underscripts/carries.Proven by bisection; a byte-identical copy without the shebang loads. 36 tests recovered.
curl: the fixture bin was passed through theenvironment 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
codexand asserting the remote argumentsanyway. That file also dropped from 17.9 s to 5.3 s once the real curl stopped timing out.
script's hard exit turned a missing app-server or a locked-down
$HOMEinto 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.