Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
d27ffdd
rolldown's unlisted wasi binding, and the [cause] chains errors were …
ThyFriendlyFox Aug 11, 2026
b1133b6
The listener leak behind vite's port storm, and canc twice to kill a …
ThyFriendlyFox Aug 11, 2026
dfe85ba
Brief: the vite restart loop's trigger, identified by stack capture
ThyFriendlyFox Aug 11, 2026
adb7de3
Brief: the measurement trap that invalidated iteration 2, and the hal…
ThyFriendlyFox Aug 11, 2026
21eda19
Brief: it is an unlink storm, not an add — every file reported deleted
ThyFriendlyFox Aug 11, 2026
6feb04e
ROOT CAUSE: the project sits at / and chokidar deletes the world
ThyFriendlyFox Aug 11, 2026
7254ab6
Brief: scope the root-path fix, and the gate that was missing
ThyFriendlyFox Aug 11, 2026
dd733d4
Correct the brief: option B cannot work, and option A's exact call sites
ThyFriendlyFox Aug 11, 2026
d27265e
A project no longer lives at "/", and vite's restart storm ends
ThyFriendlyFox Aug 11, 2026
d2cd024
`import` is a legal method name, and vite's ModuleRunner uses it
ThyFriendlyFox Aug 11, 2026
1741d03
The pinned ESM divergence is the blocker: svelte needs live bindings
ThyFriendlyFox Aug 11, 2026
7c1a256
Named imports are live bindings now, with a parse-failure escape hatch
ThyFriendlyFox Aug 12, 2026
e307844
Live bindings made fail-safe, and a response body that arrived as its…
ThyFriendlyFox Aug 12, 2026
6328744
A Uint8Array body is bytes, not its own decimal listing
ThyFriendlyFox Aug 12, 2026
e45bb6e
A URL in an error message turned svelte's compiler half-off
ThyFriendlyFox Aug 12, 2026
7f885ed
The dependency scan was never broken twice
ThyFriendlyFox Aug 12, 2026
c1e5b16
lightningcss on wasm, and a file: URL that is the same URL with one s…
ThyFriendlyFox Aug 12, 2026
3a47bf3
A gate for the root nobody was watching
ThyFriendlyFox Aug 12, 2026
7dfec54
An error that says what went wrong
ThyFriendlyFox Aug 12, 2026
213226a
A server nobody could reach by name
ThyFriendlyFox Aug 12, 2026
1acede5
Dev servers in general, and seven files that were nobody's
ThyFriendlyFox Aug 12, 2026
dbd5d2d
The alias stops standing on a real directory
ThyFriendlyFox Aug 12, 2026
efa0dfe
Reconciled: the whole suite, against the code that ships
ThyFriendlyFox Aug 12, 2026
1232471
The watcher, exercised rather than inferred
ThyFriendlyFox Aug 12, 2026
a7230fd
Tailwind 3 works, and saying so is the useful half
ThyFriendlyFox Aug 12, 2026
4b02ca7
The harness count follows the harnesses
ThyFriendlyFox Aug 12, 2026
cba4f1e
The brief says what is true now, in 159 lines instead of 438
ThyFriendlyFox Aug 12, 2026
0873ca0
Tapping the project's name goes back to the picker
ThyFriendlyFox Aug 12, 2026
755a1c1
The gate for leaving a project with something running
ThyFriendlyFox Aug 12, 2026
9ca871c
Reconciled at HEAD, because the engine moved after the last green
ThyFriendlyFox Aug 12, 2026
8d57278
Write down the approach not taken, and why
ThyFriendlyFox Aug 12, 2026
0e30f30
The Files container's docs gain the way out
ThyFriendlyFox Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions .claude/npm-dev-brief.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# Goal: `npm run dev` works for Node dev servers on iOS

## MET — SvelteKit, on the simulator, curled from the Mac

VITE v7.3.6 ready in 19343 ms started once, no restart loop
curl http://localhost:5173/ HTTP 200, 33105 bytes, the rendered app
node_modules/.vite/deps 20 dependencies pre-bundled

The watcher was exercised rather than inferred, because silence is also what a
dead watcher produces. Appending a line to `src/routes/+page.svelte` in the
running workspace: ~3s later the curl returned 33133 bytes with the marker in
it, the terminal printed exactly one line —
`[vite] (ssr) page reload src/routes/+page.svelte` — and reverting put it back.
One reload, naming the file that changed. That is the original bug's inverse.

Not a SvelteKit special case, which this brief asks for explicitly. Green on the
same engine: `reactdev` (vite serves .tsx with types erased and JSX compiled),
`hmr` (an edit pushed down a real WebSocket), `firstrun` (`npm create vite`
scaffolds, installs and serves, end to end), `vite`, `npmrun`, `tscwatch`.

Whole suite at the shipping code: **141 assertions passed, 0 failed**, plus the
five harnesses `verify.sh` declares investigations and does not count.

### The fixes, in the order they were found

Each verified on the simulator, each with its evidence in its commit message.

1. **The restart storm** — the project sat at `/`; see the root cause below. A
program now starts at a named root, so the path it watches has a real parent
and a real basename.
2. **`import` as a method name** — `obj.import(...)` was read as an import.
3. **ESM named imports were a snapshot, not a live binding**, so a module that
exports `let x` and assigns it later stayed frozen at `undefined`.
4. **`Request`/`Response` bodies from a Uint8Array** went through `String(body)`
and arrived as comma-separated character codes, so every SSR response was
digits (`6328744`).
5. **Svelte's compiler ran half in dev mode** (`e45bb6e`): the live-binding
rewrite skipped `dev` because `svelte.dev` inside a thrown error's URL read as
a parameter, and it mis-rewrote shorthand properties written one per line.
6. **lightningcss on wasm, and `file:/x` is the same URL as `file:///x`**
(`c1e5b16`).
7. **`error.stack` had no `Name: message` header** (`7dfec54`), so every tool
logging a stack printed frames and no reason.
8. **`listen()` with no host bound IPv4-only `0.0.0.0`** (`213226a`) where node
binds dual-stack `::`, so a program could not reach its own server by name.
9. **The `/project` alias shadowed a real directory of that name** (`dbd5d2d`).

## Still open

- **Tailwind 4 cannot run here; tailwind 3 can, and does.** Version 3 has no
native half — scanner and compiler are both JavaScript — and
`verify/tailwind` pins it: content scanning, plain utilities, a `hover:`
variant, a `md:` breakpoint reaching `@media`, an arbitrary `w-[37px]`, and an
unused class correctly absent. A user who wants tailwind on a phone today
wants 3.

Version 4 is a platform wall, not our bug. `@tailwindcss/oxide`'s wasi build
declares SHARED memory and JSC answers `WebAssembly.Module doesn't parse at
byte 398: shared memory is not enabled`. Measured, so nobody re-derives it:
`new WebAssembly.Memory({shared: true})` succeeds, but a module whose memory
section carries the shared flag does not parse. The gate is
`JSC::Options::useSharedArrayBuffer`, RESTRICTED in Apple's build —
`JSC_useSharedArrayBuffer=true` is accepted as a name (a bogus name beside it
is rejected, so the parse happened) and `JSC_dumpOptions=1` still prints
`useSharedArrayBuffer=false (default: false)`. This bounds every napi-rs wasi
binding built with threads. `tailwindcss()` is therefore kept out of
`local__test-2`'s `vite.config.ts`, and the project runs.

- **An error the ENGINE throws still has no header when read through `.stack`.**
JSC materialises `stack` during construction, on its own errors as much as
ours, so nothing catches the moment an engine error is born. Errors JavaScript
constructs are covered (`verify/stackshape`); console and util.inspect rebuild
the header for the rest.

There IS one more approach, designed and deliberately not attempted, so nobody
has to rediscover it or start it blind. `stack` is writable, and the engine
already rewrites every ESM module — so `catch (e) {` could become
`catch (e) { __mouseFixStack(e);`, giving an engine-thrown error its header at
the moment code catches it. That is exactly where SvelteKit caught the
TypeError this loop chased. Why it was not done: it is a fifth rewrite in
`transpileESM`, and two of the four already there shipped bugs found only by a
real bundle failing (`svelte.dev` inside a string, shorthand across newlines).
It would cover only transpiled ESM, not the CJS vite bundles its own chokidar
into. And it needs its own gate for `catch` with no binding, a rethrown `e`,
and a `catch` inside a template or a comment. Worth doing on a fresh context
with room to gate it properly; not worth starting at the end of one.

## The target, concretely

In the simulator workspace `local__test-2` (a SvelteKit project created by
`npx sv create`), `npm run dev` must start **once** — no restart loop; not print
`Failed to run dependency scan`; print its URL; and answer
`curl http://localhost:<port>/` **from the Mac** with the app's HTML.

The fix belongs in the ENGINE (`swift/`). Do not "fix" it by editing the user's
project or `node_modules`. Temporary instrumentation of `node_modules` is
allowed for diagnosis and MUST be reverted before the iteration ends. It must be
general — React/Vite and any other node dev server.

## Root cause of the storm — measured, do not re-derive

A project rooted at `/` breaks file watching, spectacularly. chokidar records a
directory under its parent: `_getWatchedDir(dirname(dir)).add(basename(dir))`.
For `dir === "/"`, POSIX gives dirname `/` and basename `""` — node agrees, our
path module is right — so it files an EMPTY-NAMED child inside the root's own
record. The next `_handleRead('/')` holds the real entries and never contains
`""`, so the diff removes it, `_remove('/', '')` resolves back to the root, and
the whole tracked tree is torn down: an `unlink` for every file in the project,
`vite.config.ts` included. Vite restarts, builds a fresh watcher, and does it
again about three seconds later. Captured in one run as:

REMOVE dir=/ item= currentSize=14 prevN=3

The dependency-scan failure was downstream of that, never a second bug: the scan
is asynchronous, and vite reports `ERR_CLOSED_SERVER` when the plugin container
closes under it — `[plugin: vite:dep-scan] The server is being restarted or
closed. Request is outdated`. Fix the restarts and the scan completes.

No other platform hits this, because no real project lives at the filesystem
root. Gated now by `verify/watchroot`, which drives the real shell because the
fix lives in the launch path; `verify/chokidar` watches a SUBDIRECTORY, which is
why it was green throughout.

**A named root was the only option, and the alternative is proven impossible.**
Making `fs.realpathSync('/')` answer a named alias cannot work: chokidar uses
`dir` — the path as passed in — for dirname/basename bookkeeping, and consults
realpath only for its symlink check. Do not attempt it.

## Ruled out by device probes — do not repeat these

- esbuild core works, and its plugin callbacks over the pipe work.
- `fs.appendFileSync` creates a missing file correctly.
- A raw `fs.watch` on the config plus `fs.watch('.')` reported **zero** events
while the config was read and while a temp file was written and deleted under
`node_modules/.vite-temp`. "Reading the config fires an event" is FALSE.
- The event was never a leaked initial-scan `add`; it was `unlink`, and the
cause is the empty basename above.
- Directory reading was never at fault: `readdir` and `readdirp` both returned
all 18 entries with working `isFile()`.

## Measuring on the device — read before trusting a number

**The dev server keeps running.** After `npm run dev` the terminal is owned by
the vite PROGRAM: anything typed afterwards goes to vite as KEYSTROKES, not to
msh. Several measurements were contaminated this way, showing output from a
previous run because the new command never started. Confirm the prompt reads
`~ $` (kill with canc TWICE) and that the command echoed before trusting a
result.

**Re-run a red gate before believing it.** Three conclusions in this loop were
wrong the first time and right after a re-run: "the dependency scan is a second
bug"; "devserver is a regression I caused" (a flake — it slept a fixed six
seconds for vite to boot, and now polls); and "event-sequences is pre-existing"
(asserted on a machine where five hung `verify_bin` processes were still
running). A flake and a regression read identically.

## Verification rule

Verify on the booted simulator (iPhone 16 Pro,
`58A6B442-292A-4610-9DE8-500E7E8EBC74`) EVERY iteration: build, install, launch,
drive the terminal, screenshot, and curl the port from the Mac. A green build is
not evidence.

Everything in AGENTS.md applies: gate what you fix, no leftover diagnostics,
docs move with behavior, commit at each boundary with the evidence in the
message.

## Stop condition — met

On the simulator, `npm run dev` in `local__test-2` starts once, prints its URL,
does not print `Failed to run dependency scan`, and a curl from the Mac returns
the app's HTML. Gates green, `swift/` committed. Branch:
`fix/rolldown-wasi-and-causes`.
19 changes: 13 additions & 6 deletions STATUS.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
# STATUS.md — the verified state of Mouse

Last reconciled: 2026-07-31, at commit 953dd98 (end of the phase-G run,
27ed75b..953dd98, 184 commits). This file is the one place that says where
every phase stands, with the evidence named. When a phase's state changes,
this file moves with it — same rule as every other doc here.
Last reconciled: 2026-08-12, at commit 755a1c1 (the `npm run dev` run,
d27ffdd..755a1c1), by running the whole suite rather than trusting the last
green one: **143 assertions passed, 0 failed, 0 build-broken**, plus the five
harnesses `verify.sh` declares investigations and does not count — `breadth`,
`glob`, `reachable`, `shapes`, `vitestrun`. Re-run at HEAD rather than left at
the earlier green, because `TerminalSession` changed after it. Before that,
2026-07-31 at 953dd98 (end of the phase-G run, 27ed75b..953dd98, 184 commits).

This file is the one place that says where every phase stands, with the evidence
named. When a phase's state changes, this file moves with it — same rule as
every other doc here.

The letter phases are subsystems, not a sequence. Build order was
dependency-driven: T → A → F → G, with D falling out of G. compile.md
Expand All @@ -16,8 +23,8 @@ numbers some of the same ground differently; the mapping is noted per row.
| **T** — terminal screen | VT100/xterm grid, ANSI parser, TUI hosting, keyboard encoding | **Done** | `swift/Mouse/TerminalScreen.swift`, `TerminalWidth.swift`, `TerminalPrograms.swift`. Gated: screen corpus, pyte cross-check, wide-char/alt-screen/DECCKM/bracketed-paste harnesses (`verify/tty`, `altscreen`, `widetui`, `inkwide`) |
| **A** — shell language | msh's POSIX-subset grammar: control flow, `$()`, functions, redirects | **Done** | `swift/Mouse/ShellLanguage.swift`. Gated against real `/bin/sh` on the 25-script corpus (`verify/shell`) |
| **F** — package manager | Real npm registry, semver, integrity, lockfiles; `npm run`/`create`/`npx`; native-binary → wasm-build substitution | **Done** | `swift/Mouse/PackageManager.swift`. Gated: semver corpus vs pnpm, `verify/pkg`, `firstrun` (scaffold→install→dev end to end) |
| **G** — the Node layer | Node 22-surface runtime on JavaScriptCore: modules, streams, fs, net/http/1.1+2, crypto, workers, child processes, vm, WASI | **Done** (this run) | `swift/Mouse/NodeEngine.swift` + `NodeSockets`/`NodeWatch`/`NodeKeys`/`NodeScrypt`/`NodeBrotli`/`NodeDNS`. ~88-gate suite green at 953dd98 (`verify/`), fixtures byte-identical to real node v22 |
| **D** — web toolchain | tsc, bundlers, dev servers | **Largely done**, as a byproduct of G | tsc + `tsc --watch`, webpack, esbuild-wasm, vite dev (HMR) and vite build (rollup-wasm) all gated (`verify/esbuild`, `devserver`, `hmr`, `firstrun`). Remaining piece is the Preview surface (phase C) |
| **G** — the Node layer | Node 22-surface runtime on JavaScriptCore: modules, streams, fs, net/http/1.1+2, crypto, workers, child processes, vm, WASI | **Done** (this run) | `swift/Mouse/NodeEngine.swift` + `NodeSockets`/`NodeWatch`/`NodeKeys`/`NodeScrypt`/`NodeBrotli`/`NodeDNS`. 148-harness suite (`verify/`), fixtures byte-identical to real node v22. Added since: `lightningcss` (the native CSS transformer, substituted with its authors' wasm build), `watchroot` (a watcher rooted at the project root, which is where the restart storm lived), `stackshape` (error.stack carrying its message, the way V8 writes it), `tailwind` (version 3 compiles here; version 4 cannot, and the wall is Apple's) |
| **D** — web toolchain | tsc, bundlers, dev servers | **Largely done**, as a byproduct of G | tsc + `tsc --watch`, webpack, esbuild-wasm, vite dev (HMR) and vite build (rollup-wasm) all gated (`verify/esbuild`, `devserver`, `hmr`, `firstrun`). A real SvelteKit project runs `npm run dev` on the device: starts once, pre-bundles its 20 dependencies, and answers a curl from the Mac with HTTP 200 and the rendered 33 kB page. Remaining piece is the Preview surface (phase C) |
| **B** — WebView JIT | Move JS/wasm execution into WKWebView for JIT speed | **Not started — optional** | Measured: everything runs interpreted; the JIT buys speed, not capability (system.md:2094). No longer a prerequisite for anything |
| **C** — Preview container | In-app viewing surface for what dev servers serve; LAN hosting | **Not started** | The server half works (vite serves clients outside the app — gated in `verify/devserver`); no in-app viewer exists |
| **E** — wasm runtime processes | Real processes: `$PATH`, executable bits, `ps`/`kill`/`&`, pipes between programs; other languages (Python first) as wasm32-wasi artifacts | **Runtime half done** | `pkg install python` downloads the official CPython wasm32-wasi build, hash-checks it, unpacks it (zip reader written here — iOS has no `unzip`) and `python hello.py` runs CPython 3.14.6. `swift/Mouse/Runtimes.swift` + mounts in `NodeEngine`. Gated: `verify/python`, `verify/pkgpython`. Written up in system.md §5b. Missing: `$PATH`, executable bits, background jobs (`&` is still refused by name in the lexer) |
Expand Down
12 changes: 12 additions & 0 deletions swift/Mouse/CarouselDeck.swift
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,18 @@ final class CarouselDeck {
ringTerminal = session
return session
}

/// Go back to the project picker. Everything the ring was holding belonged to the project it
/// is leaving: the terminal (and whatever it was running), the open file, the workspace
/// itself. Dropping the session without stopping it would strand a running dev server on its
/// port with no screen able to reach it.
@MainActor
func leaveWorkspace() {
ringTerminal?.stopForProjectChange()
ringTerminal = nil
openFilePath = nil
workspace = nil
}
/// Extra divider height while a gap-label lesson needs room for its word. Animated model
/// state (not derived at render time) so divider growth and the compensating lane re-fit
/// share one transaction — the stack's total height never wavers. Set by the view layer.
Expand Down
Loading
Loading