@pix3/cli 1.6.0 → 1.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +788 -754
  3. package/dist/index.js +208 -203
  4. package/dist/runtime-types.json +1 -1
  5. package/dist/smoke/prebuilt/{chunk-J2SJVMDO.mjs → chunk-CBFYKDS7.mjs} +1 -1
  6. package/dist/smoke/prebuilt/chunk-VJHJ5BCO.mjs +239 -0
  7. package/dist/smoke/prebuilt/runtime.mjs +1 -1
  8. package/dist/smoke/prebuilt/smoke-worker.mjs +1 -1
  9. package/dist/smoke/prebuilt/tree-defaults.mjs +1 -1
  10. package/dist/validate/prebuilt/bundle-main.js +13 -13
  11. package/dist/validate/prebuilt/chunk-CFQFEI52.js +239 -0
  12. package/dist/validate/prebuilt/runtime.js +1 -1
  13. package/kit/files/.claude/skills/pix3-nodes/SKILL.md +210 -210
  14. package/kit/files/.claude/skills/pix3-nodes/reference.md +39 -24
  15. package/kit/files/.claude/skills/pix3-scene-format/SKILL.md +238 -235
  16. package/kit/files/.claude/skills/pix3-scene-format/reference.md +43 -28
  17. package/kit/files/.claude/skills/pix3-scripts/SKILL.md +285 -281
  18. package/kit/files/.claude/skills/pix3-scripts/reference.md +69 -52
  19. package/kit/files/.claude/skills/pix3-verify/SKILL.md +298 -256
  20. package/kit/files/.claude/skills/pix3-verify/reference.md +21 -21
  21. package/kit/files/AGENTS.md +203 -203
  22. package/kit/files/CLAUDE.md +1 -1
  23. package/kit/kit.json +2 -2
  24. package/package.json +54 -54
  25. package/templates/empty-2d/files/README.md +20 -20
  26. package/templates/empty-2d/files/scenes/main.pix3scene +68 -68
  27. package/templates/empty-2d/template.yaml +7 -7
  28. package/templates/empty-3d/files/README.md +20 -20
  29. package/templates/empty-3d/files/scenes/main.pix3scene +108 -108
  30. package/templates/empty-3d/template.yaml +7 -7
  31. package/templates/idea-blank/files/README.md +22 -22
  32. package/templates/idea-blank/files/scenes/main.pix3scene +37 -37
  33. package/templates/idea-blank/template.yaml +10 -10
  34. package/templates/minigame-2d/files/README.md +44 -44
  35. package/templates/minigame-2d/files/design/tests/reachability.json +1 -1
  36. package/templates/minigame-2d/files/design/tests/routines/mute-music.json +44 -44
  37. package/templates/minigame-2d/files/scenes/main.pix3scene +84 -84
  38. package/templates/minigame-2d/files/scenes/menu.pix3scene +118 -118
  39. package/templates/minigame-2d/files/scenes/ui/settings-window.pix3scene +138 -138
  40. package/templates/minigame-2d/files/scripts/GameFlow.ts +133 -133
  41. package/templates/minigame-2d/files/scripts/MenuFlow.ts +217 -217
  42. package/templates/minigame-2d/files/scripts/SettingsWindow.ts +198 -198
  43. package/templates/minigame-2d/template.yaml +10 -10
  44. package/templates/playable-2d/files/README.md +30 -30
  45. package/templates/playable-2d/files/design/recipe.md +83 -83
  46. package/templates/playable-2d/files/design/tests/reachability.json +4 -4
  47. package/templates/playable-2d/files/design/tests/routines/intro-to-cta.json +32 -32
  48. package/templates/playable-2d/files/scenes/main.pix3scene +111 -111
  49. package/templates/playable-2d/files/scenes/ui/end-screen.pix3scene +91 -91
  50. package/templates/playable-2d/files/scenes/ui/intro.pix3scene +65 -65
  51. package/templates/playable-2d/files/scripts/GameFlow.ts +256 -256
  52. package/templates/playable-2d/template.yaml +9 -9
  53. package/templates/playable-3d/files/README.md +32 -32
  54. package/templates/playable-3d/files/design/recipe.md +127 -127
  55. package/templates/playable-3d/files/design/tests/reachability.json +4 -4
  56. package/templates/playable-3d/files/design/tests/routines/intro-to-cta.json +32 -32
  57. package/templates/playable-3d/files/scenes/main.pix3scene +230 -230
  58. package/templates/playable-3d/files/scenes/ui/end-screen.pix3scene +91 -91
  59. package/templates/playable-3d/files/scenes/ui/intro.pix3scene +65 -65
  60. package/templates/playable-3d/files/scripts/GameFlow.ts +309 -309
  61. package/templates/playable-3d/files/scripts/GameRules.ts +290 -290
  62. package/templates/playable-3d/files/scripts/ScoreHud.ts +113 -113
  63. package/templates/playable-3d/template.yaml +9 -9
  64. package/templates/recipe-arena-2d/files/README.md +48 -48
  65. package/templates/recipe-arena-2d/files/design/recipe.md +127 -127
  66. package/templates/recipe-arena-2d/files/scenes/main.pix3scene +292 -292
  67. package/templates/recipe-arena-2d/files/scenes/menu.pix3scene +100 -100
  68. package/templates/recipe-arena-2d/files/scenes/prefabs/hazard.pix3scene +42 -42
  69. package/templates/recipe-arena-2d/files/scenes/prefabs/pickup.pix3scene +45 -45
  70. package/templates/recipe-arena-2d/files/scenes/ui/result.pix3scene +85 -85
  71. package/templates/recipe-arena-2d/files/scripts/GameRules.ts +368 -368
  72. package/templates/recipe-arena-2d/files/scripts/MenuFlow.ts +106 -106
  73. package/templates/recipe-arena-2d/files/scripts/PlayerController.ts +151 -151
  74. package/templates/recipe-arena-2d/files/scripts/ScoreHud.ts +94 -94
  75. package/templates/recipe-arena-2d/files/scripts/Spawner.ts +214 -214
  76. package/templates/recipe-arena-2d/files/scripts/TouchRules.ts +174 -174
  77. package/templates/recipe-arena-2d/template.yaml +10 -10
  78. package/templates/recipe-blank-2d/files/README.md +61 -61
  79. package/templates/recipe-blank-2d/files/design/recipe.md +148 -148
  80. package/templates/recipe-blank-2d/files/design/tests/reachability.json +4 -4
  81. package/templates/recipe-blank-2d/files/design/tests/routines/restart.json +27 -27
  82. package/templates/recipe-blank-2d/files/scenes/main.pix3scene +224 -224
  83. package/templates/recipe-blank-2d/files/scenes/ui/result.pix3scene +85 -85
  84. package/templates/recipe-blank-2d/files/scripts/GameRules.ts +350 -350
  85. package/templates/recipe-blank-2d/files/scripts/ScoreHud.ts +110 -110
  86. package/templates/recipe-blank-2d/template.yaml +10 -10
  87. package/templates/recipe-bouncer-2d/files/README.md +81 -81
  88. package/templates/recipe-bouncer-2d/files/design/recipe.md +146 -146
  89. package/templates/recipe-bouncer-2d/files/scenes/main.pix3scene +615 -615
  90. package/templates/recipe-bouncer-2d/files/scenes/menu.pix3scene +191 -191
  91. package/templates/recipe-bouncer-2d/files/scenes/ui/result.pix3scene +87 -87
  92. package/templates/recipe-bouncer-2d/files/scripts/BallBody.ts +298 -298
  93. package/templates/recipe-bouncer-2d/files/scripts/GameRules.ts +377 -377
  94. package/templates/recipe-bouncer-2d/files/scripts/MenuFlow.ts +109 -109
  95. package/templates/recipe-bouncer-2d/files/scripts/PaddleController.ts +112 -112
  96. package/templates/recipe-bouncer-2d/files/scripts/ScoreHud.ts +94 -94
  97. package/templates/recipe-bouncer-2d/files/scripts/TouchRules.ts +263 -263
  98. package/templates/recipe-bouncer-2d/files/scripts/ball-collision.ts +307 -307
  99. package/templates/recipe-bouncer-2d/template.yaml +10 -10
  100. package/templates/recipe-grid-3d/files/README.md +22 -22
  101. package/templates/recipe-grid-3d/files/design/recipe.md +110 -110
  102. package/templates/recipe-grid-3d/files/scenes/main.pix3scene +169 -169
  103. package/templates/recipe-grid-3d/files/scenes/menu.pix3scene +100 -100
  104. package/templates/recipe-grid-3d/files/scenes/ui/result.pix3scene +105 -105
  105. package/templates/recipe-grid-3d/files/scripts/GridBoard.ts +319 -319
  106. package/templates/recipe-grid-3d/files/scripts/GridRules.ts +278 -278
  107. package/templates/recipe-grid-3d/files/scripts/MenuFlow.ts +106 -106
  108. package/templates/recipe-grid-3d/files/scripts/ScoreHud.ts +94 -94
  109. package/templates/recipe-grid-3d/template.yaml +10 -10
  110. package/templates/recipe-tapper-2d/files/README.md +39 -39
  111. package/templates/recipe-tapper-2d/files/design/recipe.md +132 -132
  112. package/templates/recipe-tapper-2d/files/design/tests/reachability.json +4 -4
  113. package/templates/recipe-tapper-2d/files/design/tests/routines/terminal-retry.json +23 -23
  114. package/templates/recipe-tapper-2d/files/scenes/main.pix3scene +269 -269
  115. package/templates/recipe-tapper-2d/files/scenes/menu.pix3scene +100 -100
  116. package/templates/recipe-tapper-2d/files/scenes/prefabs/hazard.pix3scene +42 -42
  117. package/templates/recipe-tapper-2d/files/scenes/prefabs/target.pix3scene +45 -45
  118. package/templates/recipe-tapper-2d/files/scenes/ui/result.pix3scene +85 -85
  119. package/templates/recipe-tapper-2d/files/scripts/GameRules.ts +368 -368
  120. package/templates/recipe-tapper-2d/files/scripts/MenuFlow.ts +106 -106
  121. package/templates/recipe-tapper-2d/files/scripts/ScoreHud.ts +94 -94
  122. package/templates/recipe-tapper-2d/files/scripts/Spawner.ts +214 -214
  123. package/templates/recipe-tapper-2d/files/scripts/TouchRules.ts +192 -192
  124. package/templates/recipe-tapper-2d/template.yaml +10 -10
  125. package/dist/smoke/prebuilt/chunk-OJXQXIIQ.mjs +0 -239
  126. package/dist/validate/prebuilt/chunk-BS2OZM7U.js +0 -239
package/README.md CHANGED
@@ -1,754 +1,788 @@
1
- # @pix3/cli
2
-
3
- Command line for Pix3:
4
-
5
- ```text
6
- pix3 new [<recipe> [dir]] [--name <n>] create a project from a recipe/template
7
- pix3 mcp --workspace [--project <dir>] stdio MCP server for your agent, through `pix3 serve`
8
- pix3 mcp [--project <dir>] [--agent <name>] phase-0 prototype: stdio MCP + FSA link server
9
- pix3 setup [claude|codex] print how to register the MCP server
10
- pix3 serve [--project <dir>] [--port <n>] [--new-token]
11
- serve a project folder to a Pix3 editor
12
- pix3 validate [paths…] [--json] strict scene check
13
- pix3 check [--json] [--no-hydrate] [--offline] [--project <dir>]
14
- validate + tsc over the scripts + merge-log + versions
15
- pix3 smoke [scene] [--changed|--all] [--frames N] [--timeout S] [--json] [--project <dir>]
16
- run the game headless in Node, report what threw
17
- pix3 tree [scene] [--depth N] [--types A,B] [--props] [--json] [--project <dir>]
18
- scene outline, one line per node; no scene = overview
19
- pix3 kit [--update] [--project <dir>] install / update the agent kit in a project
20
- pix3 read <path> print a file and ack its bytes' sha256
21
- pix3 ack <path> --sha256 <hash> ack a version you read (hash of raw bytes)
22
- pix3 sfx <preset|"text"> [--out <f.wav>] [--seed <n>] [--json]
23
- synthesize a sound effect to WAV, offline
24
- ```
25
-
26
- `new`, `kit`, `mcp`, `serve`, `read`/`ack` load neither TypeScript nor the kit generator: the kit
27
- and the runtime types are prebuilt into the package (`kit/`, `dist/runtime-types.json`, at
28
- `prepack`).
29
- `tree` reads YAML only; `smoke` and `tree --props` load the runtime from a bundle prebuilt at
30
- `prepack` (`dist/smoke/prebuilt/`, like validate's), so none of this slows `new` / `mcp` / `serve`.
31
-
32
- `read` / `ack` append `{ path, sha256, at }` to `.pix3/ack.json`. The editor, merging the next
33
- version of that file you write, lifts the protection of the manual edits that version contained
34
- and removes the ack (one-shot); see `docs/pix3-specification.md` → "Co-authoring mode".
35
-
36
- ## `pix3 check` — everything an agent should run after a batch of edits
37
-
38
- `pix3 check` = `pix3 validate` (both levels) + a TypeScript type-check of the project's scripts +
39
- the newest `.pix3/merge-log.jsonl` entries + a version check. Exit 0 = no errors (warnings
40
- allowed), 1 = at least one error, 2 = could not run. Diagnostics are one list, validate's plus:
41
-
42
- | Code | Severity | When |
43
- | --- | --- | --- |
44
- | `E_TYPE` | error | a tsc diagnostic (`message` starts with `TS<code>:`; `file`, `line`) |
45
- | `E_TYPECHECK_UNAVAILABLE` | error | TypeScript could not be found or installed (`fix` = the command to run) |
46
- | `W_RUNTIME_VERSION_MISMATCH` | warning | own `tsconfig.json`, and `node_modules/@pix3/runtime` is not this CLI's version |
47
- | `W_RUNTIME_NOT_INSTALLED` | warning | own `tsconfig.json`, and no `node_modules/@pix3/runtime` |
48
- | `W_KIT_OUTDATED` | warning | `metadata.agentKit.version` is not this CLI's version (`pix3 kit --update`) |
49
-
50
- `--json`:
51
-
52
- ```text
53
- {
54
- "ok": true, "projectRoot": "…", "errorCount": 0, "warningCount": 2,
55
- "level2": { "state": "ran", "filesHydrated": 5, "filesSkipped": 0 },
56
- "files": [{ "file": "scenes/main.pix3scene", "sha256": "<hex of the raw bytes>" }],
57
- "diagnostics": [{ "severity", "code", "file", "line?", "nodeId?", "path?", "message", "fix?" }],
58
- "notes": [],
59
- "typecheck": { "ok": true, "errors": 0, "tsconfig": ".pix3/tsconfig.check.json", "mode": "pix3-types",
60
- "files": 5, "typescript": { "version": "5.8.3", "source": "cache" } },
61
- "mergeLog": [{ "at", "file", "event", "…": "the newest 10 lines of .pix3/merge-log.jsonl" }],
62
- "kit": { "version": "1.6.0", "cliVersion": "1.6.0", "upToDate": true },
63
- "timingsMs": { "validate": 190, "typecheck": 1180, "total": 1380 }
64
- }
65
- ```
66
-
67
- `files` holds every scene validated and every script type-checked, hashed over the **raw bytes** —
68
- the hashes `expect` (barrier tools) and `pix3 ack --sha256` take. `typecheck.errors` counts the
69
- `E_TYPE` / `E_TYPECHECK_UNAVAILABLE` entries of `diagnostics`.
70
-
71
- **Which tsconfig.** A project with its **own** root `tsconfig.json` (a Vite project, or one the
72
- editor's *build from templates* turned into one) is checked with it, as is, against its own
73
- `node_modules` (`mode: "project"`); nothing is written into it. Otherwise (`mode: "pix3-types"`)
74
- `check` uses `.pix3/tsconfig.check.json` against the bundled types in `.pix3/types/`, and first
75
- (re)writes both when they are missing or from another CLI build.
76
-
77
- **Where TypeScript comes from** — it is not a dependency of `@pix3/cli`: (1) the project's own
78
- `node_modules/typescript` (or an ancestor's); (2) the CLI's sibling install
79
- (`import.meta.resolve('typescript')`, e.g. the monorepo); (3) `~/.pix3/typescript/5.8.3/`,
80
- installed there once with `npm install --prefix ~/.pix3/typescript/5.8.3 typescript@5.8.3 …`
81
- (printed before it runs). `--offline` never installs: it fails with `E_TYPECHECK_UNAVAILABLE` and
82
- the command. `PIX3_TYPESCRIPT=<package dir>` overrides the search. Loaded with a dynamic `import()`.
83
-
84
- Measured (recipe-tapper-2d, 5 scripts, packed CLI installed outside the repo, fresh `HOME`):
85
- first `check` 2.1 s including the TypeScript install, then 1.45 s (validate ~0.2 s, tsc ~1.2 s).
86
-
87
- ### SVG sprites
88
-
89
- Every texture in Pix3 (editor viewport, play mode, the single-file export) loads as bytes → `Blob`
90
- → object URL → three's `TextureLoader` → `<img>`, so an `.svg` renders exactly when a browser
91
- `<img>` decodes it, at the size the `<img>` reports. Level 1 checks every `.svg` a scene
92
- references (once per file per scene, at its first reference):
93
-
94
- | Code | Severity | When |
95
- | --- | --- | --- |
96
- | `E_SVG_INVALID` | error | no `<svg>` root, or the root lacks `xmlns="http://www.w3.org/2000/svg"` (the browser refuses to decode it) |
97
- | `E_SVG_NO_SIZE` | error | no absolute `width`/`height` and no `viewBox` (renders into a cropped 300x150 box), or one dimension without a `viewBox` |
98
- | `W_SVG_VIEWBOX_ONLY` | warning | a `viewBox` but no absolute `width`/`height` (`%` and `style=` sizes count as none): Chrome gives it 300x150, art letterboxed |
99
- | `W_SVG_EXTERNAL_REF` | warning | an `href`, CSS `url()` or `@import` to anything but `#fragment` / `data:` — never loads in SVG-as-image mode |
100
-
101
- Measured in Chromium 129 through the runtime's own `ResourceManager` + `AssetLoader` + `Sprite2D`:
102
- a sized SVG auto-sizes the sprite to its `width`×`height`; a viewBox-only one to 300×150; one
103
- without `xmlns`, or any SVG whose Blob type is not `image/svg+xml`, fails to load (browsers do
104
- not sniff SVG).
105
-
106
- ## `pix3 sfx` — sound effects without the editor
107
-
108
- ```text
109
- pix3 sfx coin # -> audio/coin.wav in the project root
110
- pix3 sfx "short high coin pickup" --out audio/pickup.wav --seed 7
111
- pix3 sfx explosion --json # { path, res, durationMs, preset, seed, modifiers, bytes, peak, params }
112
- ```
113
-
114
- Presets: `coin`, `jump`, `hit`, `explosion`, `powerup`, `click`. Free text picks the preset whose
115
- keyword appears first (`boom`, `pickup`, `button`, `hurt`, …) and applies modifier words
116
- (`high`/`low` pitch ×1.4/×0.7, `short`/`long` time ×0.65/×1.6, `soft`). `--seed <n>` renders a
117
- deterministic variation (pitch ±15 %, times ±20 %, duty/vibrato/cutoff nudged); without it the
118
- preset as tuned. Output is 44.1 kHz 16-bit mono PCM WAV — `AssetLoader.loadAudio` decodes it with
119
- `decodeAudioData` like any `.ogg`/`.mp3`, and the export embeds it as `audio/wav`. `res` in the
120
- JSON is the `res://` path when the file lands inside a project. Exit 2 on a bad argument or a
121
- description that names no preset.
122
-
123
- The synth (`src/sfx/synth.ts`, no dependencies) is one jsfxr-style voice: square / saw / sine /
124
- triangle / noise, exponential pitch slide, pitch jump, vibrato, ADSR, resonant low-pass, DC
125
- blocker, peak normalisation. It does not use the editor's `@txt2sfx/*` packages: their only
126
- renderer drives an `OfflineAudioContext` the caller supplies, i.e. a native Web Audio module
127
- (`node-web-audio-api`) in Node. Each preset renders in well under 10 ms; the whole command runs in
128
- ~0.12 s; files are 2–67 KiB (click 24 ms … explosion 772 ms).
129
-
130
- ## `pix3 smoke` — run the game headless
131
-
132
- `pix3 smoke [scene]` runs the game in Node — no browser, no editor, no `pix3 serve` — for
133
- `--frames N` fixed steps of 1/60 s (default 120) and reports everything that went wrong. It is the
134
- behavioural check after `pix3 check` when no editor is connected; with the live channel,
135
- `game_run` is the stronger one (it renders).
136
-
137
- What runs: the project's scripts compiled with esbuild (as `validate` level 2 does, but bare
138
- imports are bundled for real from the project's `node_modules` — Rapier, three addons — and only
139
- what resolves nowhere becomes an empty module, reported as `W_SMOKE_STUBBED_IMPORT`), the scene
140
- loaded from disk by the real `SceneLoader`, a real `SceneRunner` in manual time. Frame 0 is loading
141
- and starting the scene (`onAttach`/`onStart`, then 128 event-loop turns so spawn chains land);
142
- frame N is the N-th step. The run happens in a worker thread, so a script stuck in a loop is
143
- stopped by `--timeout` (default 20 s).
144
-
145
- Stubbed: rendering (a null renderer; post-processing never loads), audio (no Web Audio in Node),
146
- input (no events), network (`scene.network` is null). Textures resolve empty, glTF models and
147
- Spine skeletons referenced by nodes are not built; images a script loads through three's
148
- `TextureLoader` "load" as blank 1×1 pictures (so preloaders finish). A small browser shim provides
149
- `window` / `document` / canvas / `localStorage` / `matchMedia` / `requestAnimationFrame`; any other
150
- browser property a script reads is recorded, and an error right after such a read is
151
- `E_SMOKE_DOM` with the read attached (`domAccess`).
152
-
153
- Scene: the argument (`res://`, project-relative or a path) runs that one scene. With no argument
154
- several run, one after another from one bundle, a line each (`src/smoke/select-scenes.ts`):
155
-
156
- 1. In a git work tree with uncommitted changes (staged, unstaged or untracked): the **top-level**
157
- scenes those changes reach — the scene itself, a prefab / `scenes/ui/` overlay it instances, a
158
- script exporting a class it attaches as `user:X`, a `res://` file it names. `--changed` forces
159
- this (exit 2 without git or without changes).
160
- 2. Otherwise — no git, nothing changed, a changed scene/script that reaches no top-level scene (a
161
- helper module, an unused prefab), or a changed `pix3project.yaml` — **every** top-level scene
162
- (not instanced by another, not under `prefabs/` / `ui/`), `scenes/main.pix3scene` first (the
163
- editor's startup scene, where the game lives), then `defaultExportScenePath`, then by path.
164
- `--all` forces this.
165
-
166
- It never picks `defaultExportScenePath` alone any more: in every recipe that is the menu, input is
167
- empty, PLAY is never pressed, and a game whose `onStart` throws used to smoke green.
168
-
169
- | Code | |
170
- | --- | --- |
171
- | `E_SMOKE_SCRIPT` | A script hook threw (`onAttach`/`onStart`/`onUpdate`…); the engine disabled that component |
172
- | `E_SMOKE_DOM` | …and it was a browser API the shim does not provide (`domAccess` names it) |
173
- | `E_SMOKE_TICK` / `E_SMOKE_COMMAND` | The engine tick / a game command handler threw |
174
- | `E_SMOKE_CONSOLE_ERROR` | `console.error` (not the engine re-reporting one of the above) |
175
- | `E_SMOKE_UNHANDLED` | An unhandled promise rejection, or a timer callback that threw |
176
- | `E_SMOKE_LOAD` | The scene failed to load (missing prefab, invalid scene) |
177
- | `E_SMOKE_SCRIPT_COMPILE` / `E_SMOKE_SCRIPT_IMPORT` | Scripts do not compile / throw at module top level |
178
- | `W_SMOKE_MISSING_RESOURCE` | A `res://` file does not exist (the node loads without it) |
179
- | `W_SMOKE_PENDING_COMPONENT` | A component type is not registered — it never runs |
180
- | `W_SMOKE_CONSOLE_WARN`, `W_SMOKE_LOADER`, `W_SMOKE_STUBBED_IMPORT`, `W_SMOKE_STOPPED` | `console.warn`, loader warnings, an import replaced by `{}`, the game stopped itself |
181
-
182
- Exit 0 = no errors (warnings allowed), 1 = errors, 2 = could not run (with several scenes, the
183
- worst run decides): `E_SMOKE_NO_PROJECT`,
184
- `E_SMOKE_NO_SCENE`, `E_SMOKE_BUNDLE`, `E_SMOKE_UNSUPPORTED` (no esbuild), `E_SMOKE_TIMEOUT`,
185
- `E_SMOKE_CRASH`. `--json` prints `{ ok, scene, frames, framesRequested, firstFrameOk, errors:
186
- [{ code, frame, script?, nodeId?, nodeName?, phase?, message, stack?, domAccess? }], warnings,
187
- nodes: { start, end }, timingsMs: { compile, load, firstFrame, step: { total, mean, p95, max },
188
- total }, scripts, domMissing, notes, game, logs }` — `game` is the `registerGameDebug` provider's
189
- `snapshot()` at the end (when the game registers one), `logs` the first `console.log` lines,
190
- frame-stamped. Stacks are source-mapped to the project's files. With no scene argument `--json`
191
- prints `{ ok, selection: "changed" | "all", reason, changed?, runs: [<that report, per scene>] }`.
192
-
193
- A green smoke run means nothing threw for N frames without input. It does not mean the game
194
- plays: nothing was tapped, nothing was drawn.
195
-
196
- ## `pix3 tree` — find your way around a scene
197
-
198
- `pix3 tree <scene>` prints one line per node, indented by depth, from the YAML alone (no loader,
199
- no project code — instant):
200
-
201
- ```text
202
- scenes/main.pix3scene — 12 nodes
203
- Group2D#game-root "Game Root" size=1080x1920 layout=stretch/stretch components=[user:GameRules, user:TouchRules]
204
- CanvasLayer2D#hud "HUD" size=1080x1920 layout=stretch/stretch components=[user:ScoreHud]
205
- Label2D#score-label "Score Label" text="SCORE 0" pos=(-340,850) layout=left/top
206
- Group2D#result-overlay "Result Overlay" ↳ instance res://scenes/ui/result.pix3scene (1 override) hidden
207
- ```
208
-
209
- `--depth N` stops N levels below the roots (cut subtrees end in `… +K below`); `--types A,B` keeps
210
- nodes of those types (or carrying those components; `instance` = prefab instances) with their
211
- ancestors as `·` context lines; `--props` adds, under each node, the properties that differ from
212
- the node type's defaults (read from a bare instance of the runtime class, through the disk-format
213
- table — the one step that loads the runtime bundle); `--json` gives the same as nested
214
- `{ id, type, name, depth, position?, size?, layout?, hidden?, text?, groups?, components,
215
- instance?: { path, rootType?, rootName?, overrides }, props?, children }`. Override count =
216
- instance-root properties + every `overrides.byLocalId.*.properties` key.
217
-
218
- `pix3 tree` with no scene is the project overview: manifest facts, the `user:` scripts, and every
219
- scene/prefab/overlay with its node count, node types, components and instances (entry scene
220
- marked `*`). Exit 0, 1 when a scene does not parse, 2 for bad arguments or a missing file.
221
-
222
- ## `pix3 kit` — the agent kit
223
-
224
- `pix3 kit` installs the agent kit into an existing project (one the editor created, or after a CLI
225
- upgrade); `pix3 new` runs the same step. What lands in the project:
226
-
227
- | Path | What |
228
- | --- | --- |
229
- | `AGENTS.md` | Root rules (read by Codex, Cursor, …). If the project already has its own, it is kept and the kit goes to `AGENTS.pix3.md` (the command prints the line to add). |
230
- | `CLAUDE.md` | `@AGENTS.md` (plus `@AGENTS.pix3.md` in that case). A `CLAUDE.md` of the project's own is never touched — the command prints the line to add. |
231
- | `.claude/skills/pix3-{scene-format,nodes,scripts,verify}/SKILL.md` + `reference.md` | Skills loaded on demand; the `reference.md` files are generated from `docs/` and the runtime's registry |
232
- | `.mcp.json` | The pinned `pix3 mcp --workspace` entry; other servers kept |
233
- | `.gitignore` | `.pix3/` appended when not covered; existing content kept |
234
- | `tsconfig.json`, `.pix3/tsconfig.check.json`, `.pix3/types/` | Only without a `tsconfig.json` of the project's own (see below) |
235
- | `pix3project.yaml` | `metadata.agentKit: { version, files }` (everything else as written) |
236
- | `.pix3/kit-manifest.json` | sha256 of every file the kit wrote |
237
-
238
- Without `--update`, existing files are never replaced (missing ones are written; an unchanged file
239
- from an older kit is reported `outdated`). `--update` replaces every kit file whose bytes still
240
- match the hash in `.pix3/kit-manifest.json` and **skips** (and reports) every file edited since.
241
- `.pix3/` is gitignored, so on a fresh clone the manifest is gone and every differing kit file counts
242
- as edited.
243
-
244
- **Generated, not copied.** `scripts/build-kit.mjs` (run at `prepack`; a repo checkout regenerates
245
- `kit/` automatically whenever an input changed) expands the templates in `kit-src/`: hand-written
246
- prose plus `{{include:<repo path>#<heading>}}` / `{{include:<repo path>@<paragraph>}}` directives
247
- over `docs/pix3-specification.md`, `docs/node-types-reference.md`, `docs/nodes-and-systems.md`,
248
- `src/services/agent/agent-skills/engine-api-map.md` and this README, and `{{generated:…}}` blocks
249
- computed from code (the `core:` component table from the runtime's registry, the MCP tool list,
250
- the barrier error codes). Syntax: `src/kit/generate.ts`. `src/kit.spec.ts` builds the kit and fails
251
- on drift: an unresolved directive, a `pix3` command or flag not in the usage text, a tool name
252
- outside the 14, a diagnostic code no command emits, a node type the loader does not know, a
253
- property in the nodes skill's tables that the disk-format descriptor
254
- (`packages/pix3-runtime/src/core/scene-disk-format.ts`) does not accept, a `core:` component that
255
- does not exist.
256
-
257
- ### Script types (`.pix3/types/`)
258
-
259
- `scripts/build-runtime-types.mjs` (at `prepack`; rebuilt on demand in a checkout when the runtime
260
- sources change) runs `tsc -p packages/pix3-runtime/tsconfig.types.json` (declarations of what
261
- `src/index.ts` reaches — no specs, samples or `testing/`) into `runtime-types/@pix3/runtime/`, and
262
- copies `@types/three` (without its `node_modules`) to `runtime-types/@types/three/` — the runtime's
263
- public types extend three.js. The tarball carries that tree packed into one file,
264
- `dist/runtime-types.json` (see "Package layout" below); the installed CLI expands it on first use. `lit` (re-exported `property`/`state` decorators), `postprocessing`
265
- and the Spine runtime are not shipped: they are only reached from inside declaration files, which
266
- `skipLibCheck` leaves alone (they type as `any`). In a project without its own `tsconfig.json`:
267
-
268
- ```text
269
- .pix3/types/@pix3/runtime/** the runtime's .d.ts (+ package.json with the version)
270
- .pix3/types/@types/three/** three.js types
271
- .pix3/types/version.json which CLI build wrote them
272
- .pix3/tsconfig.check.json paths → ./types/…, strict, noEmit, skipLibCheck, lib ES2022 + DOM,
273
- include ../scripts/**/*.ts and ../src/scripts/**/*.ts
274
- tsconfig.json { "extends": "./.pix3/tsconfig.check.json" } — so an IDE sees them
275
- ```
276
-
277
- ## `pix3 serve` — workspace server
278
-
279
- Serves one project folder to the Pix3 editor over **one loopback port**, without File System
280
- Access. Built for a project that lives on a remote machine (VS Code Remote SSH): run `pix3 serve`
281
- there, forward the port, and connect the editor on your computer with the address and token.
282
-
283
- - Binds `127.0.0.1` only. `--port N`: exactly N; if it is busy the command fails (no silent
284
- switch). Without `--port`: the first free port of 8490–8499.
285
- - Project root: `--project`, else the nearest ancestor of the cwd with `pix3project.yaml`.
286
- A folder without `pix3project.yaml` is refused.
287
- - **One server per root.** A second `pix3 serve` on the same root prints the running server's
288
- address and exits 0 (`--new-token` then rotates the token of the running server).
289
- - Stops on Ctrl+C / SIGTERM. stdout is for humans; the event log goes to stderr.
290
-
291
- Protocol version: **`WORKSPACE_PROTOCOL = 1`** (`src/protocol.ts`), reported as `protocol` in
292
- `hello` and `/ws/status`. It is independent of the CLI's lockstep product version.
293
-
294
- ### Pairing and server state
295
-
296
- - On the first run the server mints a **token**: `p3ws_` + base64url of 32 random bytes. It is
297
- printed **once** and never stored in plaintext. `pix3 serve --new-token` issues a new one; the
298
- old token stops working at once, live sockets authenticated with it are closed.
299
- - State lives in `<root>/.pix3/` (mode 0700, with a `.gitignore` of `*`):
300
- - `workspace.json` (mode 0600):
301
-
302
- ```json
303
- {
304
- "version": 1,
305
- "workspaceId": "<uuid, minted once per root>",
306
- "root": "<canonical absolute root>",
307
- "token": { "sha256": "<hex sha256 of the token>", "issuedAt": "<ISO time>" },
308
- "server": {
309
- "pid": 1234,
310
- "port": 8490,
311
- "serverSession": "<uuid>",
312
- "control": "<secret>",
313
- "startedAt": "<ISO time>"
314
- }
315
- }
316
- ```
317
-
318
- `server` is `null` while no server runs. **Revoke = delete this file**: a running server
319
- notices within ~2 s, closes every socket and refuses the old token.
320
- If `root` differs from the folder's canonical path (the project was copied), the server
321
- mints a new `workspaceId` and token instead of trusting the copied file.
322
- - `serve.lock` — pid of the server owning the root.
323
- - `tmp/` — staging for atomic writes.
324
- - `link/` — challenge files of the FSA link server.
325
- - **Server-private** = `.pix3` itself, `.pix3/workspace.json`, `.pix3/serve.lock`, `.pix3/tmp/**`
326
- and `.pix3/link/**` (names compared case-insensitively): unreachable through every route
327
- (`403 reserved_path`) and never listed.
328
- - **Everything else under `.pix3/`** is the editor's co-authoring bookkeeping
329
- (`protected.json`, `merge-log.jsonl`, `recovery/**`, `ack.json`, …) and is readable and
330
- writable like any other path. It is **never part of the revision set**: it does not move
331
- `revision` and produces no `change` events — with one exception, `.pix3/ack.json` (see
332
- `ChangeEvent` below). `/ws/manifest` lists it (see there).
333
-
334
- ### Common rules (every HTTP route)
335
-
336
- - **Host**: `localhost`, `127.0.0.1` or `[::1]`, **with any port or none** (a forwarded local
337
- port may differ from the server's). Anything else → `403 forbidden_host`.
338
- - **Origin**: absent (a local process) or one of `https://editor.pix3.dev`,
339
- `http://localhost:8123`, `http://127.0.0.1:8123`. Anything else → `403 forbidden_origin`,
340
- without CORS headers. Allowed origins get `Access-Control-Allow-Origin: <origin>`,
341
- `Vary: Origin`, and `Access-Control-Expose-Headers: ETag, Content-Range, Content-Length,
342
- Accept-Ranges, X-Mutation-Replayed`.
343
- - **Preflight** `OPTIONS` (no auth): `204`, `Access-Control-Allow-Methods: GET, HEAD, PUT,
344
- POST, OPTIONS`, `Access-Control-Allow-Headers: Authorization, Content-Type, If-Match,
345
- If-None-Match, X-Mutation-Id, Range`, `Access-Control-Max-Age: 600`.
346
- - **Auth**: `Authorization: Bearer <token>` on every route. Missing/wrong/revoked →
347
- `401 unauthorized` with `WWW-Authenticate: Bearer`. After **10 failures within 60 s**, every
348
- attempt (valid token or not) gets `429 rate_limited` with `Retry-After: <s>` and
349
- `retryAfter` in the body, until the window moves on.
350
- - **Errors** are JSON: `{ "error": "<code>", "message": "<text>", ...extra }`.
351
- - **Paths** are POSIX, relative to the root, case preserved: `scenes/main.pix3scene`. Refused
352
- with `400 bad_path`: empty, absolute (`/…`), backslashes, a drive prefix (`C:`), NUL, empty
353
- segments (leading/trailing/double `/`), `.` or `..` segments. A server-private path (see
354
- above) → `403 reserved_path`.
355
- `?path=` is percent-decoded **exactly once** (a literal `%2e%2e` after that one decoding is a
356
- file name, not `..`); JSON bodies are not decoded. **No operation passes through a symlink**:
357
- a symlink anywhere on the path, parents of a path being created included, →
358
- `403 symlink`. A file where a parent directory is expected → `409 not_a_directory`.
359
-
360
- ### Revision set, manifest and `revision`
361
-
362
- The **revision set** is what an export ships (the walk of `ProjectBuildService`): every file under
363
- the root except inside directories named `node_modules`, `.git`, `.yalc`, `.vscode`, `.idea`,
364
- `dist`, `build`, `out`, `coverage`, `.cache` (at any depth) and `.pix3/` at the root. A *file*
365
- named like one of those is included. Symlinks are neither listed nor followed.
366
-
367
- `revision` = lowercase hex sha256 of the UTF-8 text formed by the lines `<path>:<sha256>` of every
368
- **file** of the set, sorted by JavaScript string comparison (UTF-16 code units), joined with `\n`,
369
- no trailing newline. Directories do not contribute. (Empty set → sha256 of the empty string.)
370
-
371
- ### Routes
372
-
373
- #### `GET /ws/manifest`
374
-
375
- A **full scan** of the disk (hashes cached by inode + size + mtime), so it also reconciles
376
- anything the watcher missed — differences it finds are broadcast as a `change` frame first.
377
-
378
- ```json
379
- {
380
- "workspaceId": "…",
381
- "serverSession": "…",
382
- "revision": "<hex>",
383
- "seq": 12,
384
- "files": [
385
- { "path": "scenes", "kind": "dir", "size": 0, "mtime": 1790373745915 },
386
- { "path": "scenes/main.pix3scene", "kind": "file", "size": 9, "mtime": 1790373745915, "sha256": "<hex>" }
387
- ]
388
- }
389
- ```
390
-
391
- `files` is sorted by path; `mtime` is integer epoch milliseconds; `sha256` is present on every
392
- file and absent on directories. Not an atomic snapshot of a folder others keep writing to.
393
-
394
- `files` is the revision set **plus** the non-private part of `.pix3/` (`.pix3` itself as a `dir`,
395
- then e.g. `.pix3/protected.json`, `.pix3/recovery/…`), so a client can list and find its
396
- bookkeeping the same way it would in a local folder. `revision` is computed from the revision set
397
- only: a client recomputing it from `files` must leave out every path under `.pix3/`.
398
-
399
- #### `GET /ws/file?path=<p>` (and `HEAD`)
400
-
401
- Raw bytes. Headers: `Content-Type` (by extension; unknown → `application/octet-stream`),
402
- `Content-Length`, `ETag: "<sha256 of the bytes>"`, `Accept-Ranges: bytes`,
403
- `Cache-Control: private, no-cache`, `X-Content-Type-Options: nosniff`.
404
-
405
- - `If-None-Match: "<sha256>"` (list, `W/` and `*` accepted) → `304` with the `ETag`, no body.
406
- - `Range: bytes=a-b` | `bytes=a-` | `bytes=-n` (one range) → `206` with
407
- `Content-Range: bytes a-b/size`; unsatisfiable → `416` with `Content-Range: bytes */size`.
408
- Several ranges or an unparsable header → the whole file, `200`.
409
- - A directory → `400 not_a_file`; missing → `404 not_found`.
410
-
411
- #### `PUT /ws/file?path=<p>`
412
-
413
- Body = the new bytes (≤ 1 GiB). Written to `.pix3/tmp/`, then renamed over the target (atomic on
414
- one filesystem; the file mode of a replaced file is kept). Missing parent directories are created.
415
-
416
- - `If-Match: "<baseHash>"` — optional. Current content hash ≠ base →
417
- `409 { "error": "base_mismatch", "currentHash": "<hex>" | null, … }` and nothing is written.
418
- `If-Match: *` = the file must exist.
419
- - `If-None-Match: *` — create only; existing file → `409 { "error": "exists", "currentHash" }`.
420
- - The target is a directory → `409 not_a_file`.
421
- - `X-Mutation-Id` — see *Mutation journal*.
422
-
423
- → `200 { "path": "…", "sha256": "<hex>", "size": 10, "mtime": 1790373745915, "seq": 13 }`
424
-
425
- The check and the rename are serialised with the server's other writes, but they are **not** a
426
- compare-and-swap against a process writing the folder directly (the agent): that race remains.
427
-
428
- #### `POST /ws/mkdir` — `{ "path": "<p>" }`
429
-
430
- Recursive. → `200 { "path", "created": true | false, "seq" }` (`false` = already a directory);
431
- a file in the way → `409 exists`.
432
-
433
- #### `POST /ws/delete` — `{ "path": "<p>", "recursive"?: boolean }`
434
-
435
- → `200 { "path", "kind": "file" | "dir", "seq" }`. Missing → `404 not_found`; non-empty
436
- directory without `recursive: true` → `409 not_empty`.
437
-
438
- #### `POST /ws/move` — `{ "from": "<p>", "to": "<p>", "overwrite"?: boolean }`
439
-
440
- Creates missing parents of `to`. → `200 { "from", "to", "kind": "file" | "dir", "sha256"?, "seq" }`
441
- (`sha256` for files). `to` exists → `409 exists` (unless `overwrite: true` and both are files);
442
- `to` equal to or inside `from` → `400 bad_move`; `from` missing → `404 not_found`.
443
-
444
- #### `POST /ws/hash` — `{ "paths": ["<p>", …] }` (≤ 20 000)
445
-
446
- Hashes of the disk **now** (for the sync barrier). → `200 { "hashes": { "<p>": "<hex>" | null }, "seq" }`;
447
- `null` = missing or not a file. Any invalid path fails the whole request (`400 bad_path`,
448
- `403 reserved_path`, `403 symlink`).
449
-
450
- #### `GET /ws/status`
451
-
452
- → `200 { "workspaceId", "serverSession", "protocol", "cliVersion", "root", "pid", "port",
453
- "revision", "seq", "leased" }`. Accepts the bearer token **or** `X-Pix3-Control: <server.control>`
454
- from `workspace.json` (how another local `pix3` process confirms a live server is the one the file
455
- describes; beyond this route and `GET /ws/revision`, the control secret grants only the agent
456
- lane below — never the file API).
457
-
458
- #### `GET /ws/revision`
459
-
460
- → `200 { "revision", "seq", "serverSession" }` — no scan, the table as it is. Bearer token or
461
- `X-Pix3-Control`.
462
-
463
- ### Mutation journal (`X-Mutation-Id`)
464
-
465
- `PUT /ws/file`, `/ws/mkdir`, `/ws/delete`, `/ws/move` accept `X-Mutation-Id: <1–128 chars of
466
- [A-Za-z0-9_.:-]>`. Within one `serverSession`, the first request with an id is applied and its
467
- answer recorded (success **and** failure). A retry with the same id — also while the first is
468
- still in flight — is **not applied again**: it gets the recorded status and body plus
469
- `X-Mutation-Replayed: true` (a PUT retry's body is drained and discarded). The same id on a
470
- different request (other route, path or conditions) → `422 mutation_id_reused`. The journal keeps
471
- the last 2 000 ids and is lost on restart: after a new `serverSession`, the outcome of an
472
- unanswered mutation is unknown and must be checked against the disk, not retried blindly.
473
-
474
- ### `seq`
475
-
476
- One counter per `serverSession`, starting at 0. Every change of the file table takes the next
477
- value: an external change batch (sent as a `change` frame) or one of the server's own mutations
478
- (returned in its response, **not** sent as a frame). So `seq` is monotonic but **not dense** on the
479
- event stream — a gap is a write made through the API, not a lost event. Nothing is replayed: a
480
- client that reconnects gets the current `revision`/`seq` in `hello` and must re-scan
481
- (`/ws/manifest`) if its revision differs.
482
-
483
- ### WebSocket `/ws/events`
484
-
485
- Same Host/Origin rules as HTTP (checked at the upgrade; refused → `403` before the handshake).
486
- **The token is never in the URL.** All frames are JSON text objects; binary frames close the
487
- socket (`1003`).
488
-
489
- **Client → server**
490
-
491
- | Frame | Meaning |
492
- | --- | --- |
493
- | `{ "type": "auth", "token": "<token>" }` | Must be the **first** frame, within **5 s**. |
494
- | `{ "type": "pong" }` | Answer to `ping`. |
495
- | `{ "type": "ping" }` | Server answers `{ "type": "pong" }`. |
496
- | `{ "type": "lease", "action": "acquire", "leaseId"?: "<id>" }` | Take the free lease; with the current `leaseId`, resume it (during the grace period, or from a new socket while the old one is still open). |
497
- | `{ "type": "lease", "action": "takeover" }` | Take the lease from whoever holds it. |
498
- | `{ "type": "lease", "action": "release" }` | Give it up. |
499
- | `{ "type": "call-result", "id": "<call id>", "result": { "content": [Block, …], "isError"?: true, "_meta"?: {…} } }` | Answer a `call` (lease holder only). `Block` = `{ "type": "text", "text" }` or `{ "type": "image", "data": "<base64, no data: prefix>", "mimeType": "image/png" }`. `_meta.pix3 = { playRevision, stale }` on observing tools (see the agent lane). |
500
-
501
- **Server → client**
502
-
503
- | Frame | When |
504
- | --- | --- |
505
- | `{ "type": "hello", "workspaceId", "serverSession", "protocol": 1, "cliVersion", "revision", "seq", "root", "projectId": string \| null, "projectName", "lease": "held" \| "free", "leaseGraceMs": 10000, "agentPresence": { "attached", "agent" } }` | Right after a valid `auth`. `root` is the server's absolute path, `projectName` is `metadata.projectName` (else the folder name), `projectId` is `metadata.projectId`, `leaseGraceMs` is the lease grace (below), `agentPresence` the current agent presence (next row). |
506
- | `{ "type": "agent-presence", "attached": boolean, "agent": { "name": string \| null, "verified": false } \| null }` | Whenever the agent presence changes: a `pix3 mcp --workspace` process announced itself, left, or went silent for 30 s (see `POST /ws/agent/presence`). `agent` is the most recently heard process; its name is self-declared. The editor keeps its background loops running while `attached` (keepalive). |
507
- | `{ "type": "change", "seq", "revision", "events": [ChangeEvent, …] }` | External changes, debounced ~100 ms (at most ~1 s under a steady stream). `revision` is the revision after the batch. |
508
- | `{ "type": "ping" }` | Every 10 s. A socket silent (no frame at all) for 30 s is terminated. |
509
- | `{ "type": "lease", "state": "granted", "leaseId", "resumed": boolean }` | Lease granted (`resumed: true` = same lease after a reconnect). |
510
- | `{ "type": "lease", "state": "busy", "inGrace": boolean }` | Someone else holds it (`inGrace`: its holder is disconnected but may come back). |
511
- | `{ "type": "lease", "state": "lost", "reason": "taken_over" \| "expired" \| "revoked" \| "resumed_elsewhere", "leaseId" }` | Sent to the holder that lost it. `resumed_elsewhere`: its `leaseId` was presented on another socket (then this one closes with `4409`). |
512
- | `{ "type": "lease", "state": "released" }` | Answer to `release`. |
513
- | `{ "type": "call", "id", "name", "input", "agent"? }` | An MCP tool call for the lease holder. `agent = { name, session, verified: false }` on agent-lane calls: `name` is what the `pix3 mcp` process calls itself (its MCP client's `clientInfo.name`, else `--agent` / `PIX3_AGENT`), `session` a random id per `pix3 mcp` process. Nothing verifies either. |
514
- | `{ "type": "error", "error": "<code>", "message", "id"? }` | `unauthorized`, `auth_timeout`, `rate_limited`, `revoked` (then the socket closes), or `bad_frame`, `unknown_frame`, `not_lease_holder`, `bad_result`, `unknown_call`. |
515
-
516
- `ChangeEvent`:
517
-
518
- ```json
519
- { "op": "create" | "modify" | "delete" | "rename", "path": "<p>", "kind": "file" | "dir", "sha256"?: "<hex>", "from"?: "<p>" }
520
- ```
521
-
522
- `sha256` on file `create`/`modify`/`rename`; `from` only on `rename` (a delete + create of the same
523
- content in one batch, when unambiguous). Events are sorted by `path`. A file touched without a
524
- content change produces no event. Changes made **through this API** never appear as events.
525
- Nothing under `.pix3/` produces events, except an outside change of `.pix3/ack.json` (what
526
- `pix3 read` / `pix3 ack` write): it comes as its own frame with one `create` / `modify` /
527
- `delete` event for that path, and the frame's `revision` is unchanged. Clients may also poll it.
528
- Events are hints, not a guarantee of seeing every write — the barrier re-checks with
529
- `/ws/manifest` or `/ws/hash`.
530
-
531
- **Close codes**: `4401` unauthorized / auth timeout / revoked, `4409` lease resumed on another
532
- socket, `4429` rate limited, `1003` binary frame, `1001` server shutting down.
533
-
534
- **Lease.** One holder at a time; it is the window that edits and answers MCP calls. When the
535
- holder's socket closes, the lease is kept for a **10 s grace**: the same window reconnecting
536
- sends `acquire` with its `leaseId` and gets `resumed: true` (calls it had been handed are sent
537
- again); anyone else gets `busy` with `inGrace: true`. After the grace, the lease is free and
538
- pending calls fail. The server does **not** announce that expiry: a client answered
539
- `busy {inGrace: true}` should send `acquire` again after `leaseGraceMs` (the editor adds 500 ms,
540
- and falls back to 11 s for a server that omits the field), repeating until `granted` or a
541
- `busy {inGrace: false}`. The editor keeps its `leaseId` per workspace in `sessionStorage`, so a
542
- reloaded tab resumes its own lease; another tab never shares it.
543
-
544
- **The `leaseId` is the holder's secret**: an `acquire` presenting the current holder's `leaseId`
545
- from a **different socket while the holder's socket is still open** is the same tab after a
546
- reload whose new page connected before the old page's socket closed. The lease moves to the new
547
- socket at once — `granted {resumed: true}` there, `lost {reason: "resumed_elsewhere"}` on the old
548
- socket, which is then closed with `4409` (no grace starts); calls the old socket had not answered
549
- are sent again on the new one, same ids. Any other `leaseId` (or none) still gets
550
- `busy {inGrace: false}`. Because of that rule the editor presents a stored `leaseId` only when the
551
- page is a reload (`PerformanceNavigationTiming.type === "reload"`) or the page that stored it
552
- marked it on `pagehide` — the copy of `sessionStorage` a duplicated tab inherits from a live tab
553
- is dropped, and that tab acquires without an id. Against an older server (no move on a matching
554
- id), an editor answered `busy {inGrace: false}` while presenting its own `leaseId` asks again
555
- every 1 s for up to 15 s. On `takeover`, the old holder
556
- gets `lost/taken_over` and calls it had not
557
- answered go to the new holder. On `release`, pending calls fail. The HTTP routes do not check the
558
- lease (v1): a window without it is expected to stay read-only.
559
-
560
- **Calls.** `WorkspaceServer.enqueueCall(name, input, timeoutMs = 60 000, extra?) →
561
- Promise<ToolCallResult>` (in process; the agent lane below is its HTTP face). With no lease holder
562
- it resolves at once with an error result (`isError: true`, `relayFailure: 'no_editor'`); otherwise
563
- the call is delivered to the holder (`extra` becomes extra fields of the `call` frame) and the
564
- promise settles with its `call-result`, or an error result on timeout (`relayFailure: 'timeout'`)
565
- or lease loss (`'cancelled'`). A reconnecting holder that resumes its lease gets the calls it had
566
- been handed again, with the same ids — the editor answers a repeated id once.
567
-
568
- ### Agent lane — `/ws/agent/*`
569
-
570
- The routes a local `pix3 mcp --workspace` process drives. **Auth: `X-Pix3-Control: <server.control>`
571
- only** (from `.pix3/workspace.json`, 0600 — a process of the same user; the plan's trust model).
572
- The browser's bearer token is refused (`401 unauthorized`), and so is any request carrying an
573
- `Origin` (`403 forbidden_origin`): browsers never call this lane.
574
-
575
- | Route | Answer |
576
- | --- | --- |
577
- | `GET /ws/agent/status` | `/ws/status` fields plus `holder: "connected" \| "grace" \| null`, `projectName`, `projectId`, `agentPresence: { attached, agent }`. |
578
- | `POST /ws/agent/presence` `{ agent: { name, session }, leaving? }` | Heartbeat of a `pix3 mcp --workspace` process: `pix3 mcp` sends it on start, once its MCP client has introduced itself, every 10 s, and with `leaving: true` on shutdown. One presence per `session`; it expires 30 s after its last heartbeat (an agent-lane `call` also counts as one). Answers `{ agentPresence }`; every change goes out as an `agent-presence` frame. |
579
- | `GET /ws/agent/tools` | `{ tools: [{name, description, inputSchema}], serverSession }` — the window's own tool definitions for the v1 allowlist (it answers an internal `tools_manifest` call within 5 s). `409 no_editor` without a window. |
580
- | `POST /ws/agent/call` `{ name, input, timeoutMs?, agent? }` | Parks the call for the lease holder and answers when it replies: `200 { result }` (the window's result as is, error results included). `timeoutMs` is clamped to 1–120 s (default 120 s). `409 no_editor` at once when no window holds the lease (message: open `<root>` in Pix3 — File → Connect to Workspace…); `504 no_editor_reply` when it does not answer in time; `409 lease_lost` when the lease ended under the call (on a takeover the call goes to the new holder instead); `429 too_many_calls`. `agent` → the `call` frame's `agent`. |
581
- | `POST /ws/agent/hash` `{ paths }` | Same as `POST /ws/hash`. |
582
- | `POST /ws/agent/expect` `{ expect: { path: sha256 } }` | The agent's expectations against the disk **now**: `{ matchesAgent, differing: [{ path, diskHash \| null, agentHash, recovery, mergeLog? }], hashes, seq }`. `recovery` = the wire path of a file under `.pix3/recovery/<encodeURIComponent(path)>/` whose bytes hash to `agentHash` (every journaled version of that path is hashed), else `null` — a copy is named only when it exists. `mergeLog: true` when `.pix3/merge-log.jsonl` has a line for that path whose `mergedHash`/`hash` is the disk hash, or one no older than the file's mtime minus 5 s — the editor wrote those bytes. |
583
- | `GET /ws/agent/changes?since=<seq>` | Rescans the whole revision set first (the watcher may have missed a write; differences go out as a `change` frame), then `{ since, seq, revision, paths, complete, entries }`: `paths` = distinct revision-set **files** changed after `since` — external writes and writes through the file API alike (`.pix3/` never, directories never); `entries` = one `{ seq, path, origin }` per recorded change, `origin` = `external` (seen by the watcher or this rescan) or `editor` (a mutation through the file API, i.e. the editor window). The server keeps the last 5 000 file changes; `complete: false` = the ring no longer reaches back to `since`, so an empty list proves nothing. |
584
-
585
- ## `pix3 mcp --workspace` — the live channel
586
-
587
- A stdio MCP server the agent starts from its config (`.mcp.json`, `pix3 setup`). It has no port
588
- of its own: it finds the running `pix3 serve` of the project (`--project`, else the nearest
589
- ancestor of the cwd with `pix3project.yaml`) through `.pix3/workspace.json` and confirms it with
590
- `GET /ws/agent/status` + the control secret. When none runs, every tool answers
591
- `no_workspace_server` and stderr says ``Workspace server is not running. Run `pix3 serve` in
592
- <root>``; the next call looks again, so the agent never restarts its MCP server.
593
-
594
- **Presence.** While it runs, the process announces itself to that server
595
- (`POST /ws/agent/presence`, every 10 s and on start/stop; failures are silent and the next beat
596
- rediscovers a restarted server). That is what tells the editor an agent is attached, so it keeps
597
- the game, the viewport, file polling and reconnects running in a hidden or unfocused tab instead
598
- of pausing them for battery (`AgentKeepaliveService`; the user can switch this off in Settings →
599
- General). `PIX3_PRESENCE_HEARTBEAT_MS` overrides the cadence (tests).
600
-
601
- **Tools (v1, exactly):** `project_status`, `play_start`, `play_stop`, `play_restart`,
602
- `play_status`, `game_run`, `game_input`, `game_observe`, `read_errors`, `read_logs`,
603
- `viewport_screenshot` (the PNG comes back as an MCP image block), `generate_asset`,
604
- `generate_sfx`, `get_selection`. No scene-mutating tool: the agent edits files. Schemas are the
605
- window's (`GET /ws/agent/tools`), with a static fallback so `tools/list` works before a window
606
- connects (a `notifications/tools/list_changed` follows once a window's schemas are known).
607
- `play_start` / `play_restart` / `game_run` also take `expect: { path: sha256 }` — the sha256 of the
608
- raw bytes of every file the agent wrote.
609
-
610
- **The sync barrier** (plan §5 D) runs before `play_start`, `play_restart` and `game_run`:
611
-
612
- 1. **Agent's expectations.** `expect` is checked against the disk (`/ws/agent/expect`) before
613
- anything syncs. Any mismatch → `disk_differs_from_agent` with, per file: merged by the editor
614
- (`mergeLog: true` — re-read it), or overwritten — with `recovery: ".pix3/recovery/…"` only when
615
- a copy with exactly those bytes exists, else "no copy exists — write the file again". Nothing
616
- starts. Without `expect` the answer says `agentExpectations: "none"`.
617
- 2. **Editor = disk.** The window's internal `sync_barrier` holds autosave (edits accumulate and are
618
- saved after the run), stops play, runs `syncNow()` (waits for the stabilisation window and the
619
- script build; re-reads `pix3project.yaml` when the disk holds another version) and returns
620
- `{loaded: {path: sha256}, errors}` — every open scene/prefab (any path), every source the last
621
- script build read (the entry scripts **and every module the bundle pulled in**, e.g. `src/**`;
622
- the hash recorded when the build read it, so nothing is re-read) and `pix3project.yaml`. An open
623
- scene the editor holds no disk version of is a `load_failed` error, never silently left out. The
624
- MCP process gives this call 100 s. These hashes (plus `expect`) are compared with `/ws/agent/hash` at that moment;
625
- a mismatch retries the editor's sync for up to ~5 s. Then: loader/compiler errors →
626
- `load_failed` (`{file, line, message, kind}`); a file that stays unreadable →
627
- `pending_external`; hashes that never agree → `sync_timeout` with the differing paths. The
628
- window then starts the game (`game_run` starts play itself; `play_restart` of a stopped game is
629
- a start) and answers only once the game is actually running: up to 30 s, as soon as it runs,
630
- failing fast (`load_failed` with the play-mode error) when play mode stops instead; `startupMs`
631
- says how long the start took (always present in the answer: `null` means no start happened —
632
- `game_run` found the game already running — never "0 ms"). The MCP process gives the tool call 120 s (the server's cap). The
633
- hold is released (`sync_release`) after the run.
634
- 3. **After the run** (for `game_run` when it finished; for `play_start` / `play_restart` right
635
- after the start was acknowledged) the verified files are hashed again and
636
- `/ws/agent/changes?since=<seq of the verification>` is read: `changedDuringRun` and
637
- `editorWroteDuringRun`.
638
-
639
- The answer of a barrier tool:
640
-
641
- ```json
642
- {
643
- "revision": { "<path>": "<sha256 of the verified version>" },
644
- "startupMs": 8123,
645
- "matchesAgent": true,
646
- "matchesDisk": true,
647
- "changedDuringRun": [],
648
- "editorWroteDuringRun": [],
649
- "editorChangedSinceAgentWrite": [],
650
- "result": { "…": "the editor tool's own result" }
651
- }
652
- ```
653
-
654
- `startupMs` is always present: the milliseconds from the start request until the game was
655
- running, or `null` when no start happened (the game was already running, so there was nothing to
656
- measure). `matchesAgent` is `null` (with `agentExpectations: "none"`) without `expect`; `matchesDisk` is
657
- whether every verified hash still matched the disk at the final check; `changedDuringRun` is what
658
- the barrier did not verify: every file changed meanwhile by someone other than the editor, plus
659
- every verified file whose hash moved between the two checks with no editor write on record
660
- (`changeLogIncomplete: true` when the server's ring did not reach back); `editorWroteDuringRun` is
661
- every file whose only changes since the verification are the editor's own writes through the file
662
- API (e.g. a run report under `design/tests/reports/`) — known to the server, so not a
663
- detection. Both list files only, never directories; `editorChangedSinceAgentWrite` is
664
- the `expect` paths the merge log says the editor wrote after the check. **A green answer without
665
- marks means: at start disk = agent's expectations = verified version; at the final check the
666
- verified hashes match disk; detected changes are listed.** It does not mean the disk did not change
667
- during the run: `v1 → v2 → v1` between the two checks is invisible, and the game reads resources
668
- lazily.
669
-
670
- **Observing tools** (`play_status`, `game_input`, `game_observe`, `viewport_screenshot`,
671
- `read_errors`, `read_logs`) neither stop nor sync: `{ revision, stale, result }`, where `revision` is
672
- what the running game was verified against (`null` when play was not started through the barrier)
673
- and `stale: true` when the editor saw an external change during play or a `revision` path's disk
674
- hash moved since. Other tools (`project_status`, `play_stop`, `get_selection`, `generate_*`) answer
675
- with the editor's result as is. `project_status` without a window answers
676
- `{ connected: false, server, editor: null, message }` (not an error).
677
-
678
- **Error codes** (every error is `isError: true` with `{ "error": "<code>", "message", … }`):
679
-
680
- | Code | When |
681
- | --- | --- |
682
- | `disk_differs_from_agent` | Step 1: the disk does not hold the `expect` versions (`differing[]` with `recovery`, `mergeLog`, `hint`). |
683
- | `sync_timeout` | Step 2: the editor's loaded hashes did not match the disk within ~5 s (`differing[]` with `loadedHash` / `agentHash` / `diskHash`). |
684
- | `load_failed` | Step 2: the loader or the script compiler failed on the current files, or an open scene has no verified version (`errors[]`). At the start: the game was not running within 30 s, or play mode stopped (`result` carries it, with `startupMs`). |
685
- | `pending_external` | Step 2: a file stays unreadable (partial / invalid write); the editor keeps its last good version. |
686
- | `no_editor` | No window holds the lease, it did not answer (`reason: "no_reply"`), or it lost the lease mid-call (`reason: "lease_lost"`). |
687
- | `permission_denied` | `generate_*`: the human denied, or did not answer within 60 s. |
688
- | `no_workspace_server` | No `pix3 serve` runs for the project root. |
689
-
690
- Anything else the editor refuses (`unknown_tool`, `agent_disabled` when the human switched the
691
- channel off) is passed through as the editor wrote it.
692
-
693
- **Generation permission.** The first `generate_asset` / `generate_sfx` of a connection (server
694
- session + lease + `pix3 mcp` process) opens a prompt in the editor naming the process and what it
695
- calls itself (unverified); the call waits up to 60 s. Allowed → 20 generations, then it asks again.
696
- In memory only: a reconnect, a new `pix3 serve` run, a lease takeover or a new `pix3 mcp` process
697
- resets it; the status-bar Agent pill has a revoke button and switches the channel off.
698
-
699
- ## MCP configuration and `pix3 setup`
700
-
701
- `pix3 new` and `pix3 kit` write the project's `.mcp.json` (Claude Code's project format), **pinned
702
- to the CLI version that wrote it** — never a bare `@pix3/cli`:
703
-
704
- ```json
705
- { "mcpServers": { "pix3": { "command": "npx", "args": ["-y", "@pix3/cli@<X.Y.Z>", "mcp", "--workspace"] } } }
706
- ```
707
-
708
- `pix3 setup [claude|codex]` prints (never runs) the registration for a project without one:
709
- `claude mcp add pix3 -- npx -y @pix3/cli@<X.Y.Z> mcp --workspace` (run in the project folder), and
710
- for Codex a `~/.codex/config.toml` block — `[mcp_servers.pix3]` with `command`, `args` (plus
711
- `--project <root>`, since that config is global) and `tool_timeout_sec = 180`. The Codex keys are
712
- the ones `tools/pix3-agent-bridge` passes to `codex exec -c mcp_servers.pix3.*`; the file form is
713
- best-effort, check `codex mcp --help` of your Codex version.
714
-
715
- **Dev mode.** From a checkout of the pix3 repo (the CLI runs from `packages/pix3-cli/src/`), or
716
- with `PIX3_CLI_DEV=1`, both write `node <repo>/packages/pix3-cli/src/index.ts mcp --workspace`
717
- instead, so the channel can be tried before that version is on npm. `PIX3_CLI_DEV=0` forces the
718
- pinned form.
719
-
720
- ## Package layout and publishing
721
-
722
- The published package has **no runtime `dependencies`**, so a cold `npx -y @pix3/cli@X.Y.Z …`
723
- fetches one tarball and installs nothing else (measured in
724
- `.plans/measurements/external-agent-phase0-cold-start.md`). `prepack` builds:
725
-
726
- ```text
727
- dist/index.js the bin: src/index.ts + yaml + @modelcontextprotocol/sdk (zod, ajv, …) + ws,
728
- one minified ESM file (scripts/build-bin.mjs); lazy commands stay lazy
729
- dist/validate/prebuilt/ validate + @pix3/runtime + three (scripts/build-validate.mjs)
730
- dist/smoke/prebuilt/ smoke worker + tree defaults + @pix3/runtime + three (scripts/build-smoke.mjs)
731
- dist/runtime-types.json runtime-types/ packed into one file (scripts/build-runtime-types.mjs):
732
- ~1 100 small .d.ts cost npm over a second to unpack on every npx run
733
- kit/ the agent kit (scripts/build-kit.mjs)
734
- templates/ copy of src/templates/projects (scripts/copy-templates.mjs; removed at postpack)
735
- ```
736
-
737
- Left out of the bin on purpose: Node built-ins; `esbuild` (an `optionalDependency` — level 2 of
738
- `validate`, `smoke` and `check` resolve it from the bin with `import.meta.resolve`, and degrade
739
- with a note when it is absent) and `typescript` (fetched on demand by `check`); `ws`'s optional
740
- native `bufferutil` / `utf-8-validate`; and the checkout-only modules (`validate/bundle.ts`,
741
- `smoke/bundle.ts`, the kit generator), which become a throwing stub. Every file the CLI reads from
742
- its own package is addressed from the package root (`src/package-root.ts`), never relative to the
743
- current module — in the bundle every module's `import.meta.url` is the bin's. The sources (`node
744
- src/index.ts`, the specs) never use a prebuilt bundle, even when an old `dist/` exists.
745
- `src/bin-bundle.spec.ts` builds the bin into a temp package layout and checks `--version` against
746
- `package.json` (and that against the lockstep root version), the pinned `@pix3/cli@X.Y.Z` launch,
747
- and that nothing but built-ins and the optional externals is imported at run time.
748
-
749
- Publishing is `.github/workflows/publish-packages.yml` (npm Trusted Publishing / OIDC, no token):
750
- a `runtime-vX.Y.Z` tag publishes `@pix3/runtime` and `@pix3/cli` together, `cli-vX.Y.Z` the CLI
751
- alone, or run the workflow manually. The job runs `npm ci` at the repo root (the build needs the
752
- runtime sources, the root TypeScript and esbuild), checks the lockstep version against the root
753
- and the tag, type-checks, runs the CLI specs and `npm publish`es. Try the tarball locally with
754
- `npm pack -w packages/pix3-cli` at the repo root, then `npx -y --package ./pix3-cli-X.Y.Z.tgz pix3 …`.
1
+ # @pix3/cli
2
+
3
+ Command line for Pix3:
4
+
5
+ ```text
6
+ pix3 new [<recipe> [dir]] [--name <n>] create a project from a recipe/template
7
+ pix3 mcp --workspace [--project <dir>] stdio MCP server for your agent, through `pix3 serve`
8
+ pix3 mcp [--project <dir>] [--agent <name>] phase-0 prototype: stdio MCP + FSA link server
9
+ pix3 setup [claude|codex] print how to register the MCP server
10
+ pix3 serve [--project <dir>] [--port <n>] [--new-token]
11
+ serve a project folder to a Pix3 editor
12
+ pix3 validate [paths…] [--json] strict scene check
13
+ pix3 check [--json] [--no-hydrate] [--offline] [--project <dir>]
14
+ validate + tsc over the scripts + merge-log + versions
15
+ pix3 smoke [scene] [--changed|--all] [--frames N] [--timeout S] [--json] [--project <dir>]
16
+ run the game headless in Node, report what threw
17
+ pix3 tree [scene] [--depth N] [--types A,B] [--props] [--json] [--project <dir>]
18
+ scene outline, one line per node; no scene = overview
19
+ pix3 kit [--update] [--project <dir>] install / update the agent kit in a project
20
+ pix3 read <path> print a file and ack its bytes' sha256
21
+ pix3 ack <path> --sha256 <hash> ack a version you read (hash of raw bytes)
22
+ pix3 sfx <preset|"text"> [--out <f.wav>] [--seed <n>] [--json]
23
+ synthesize a sound effect to WAV, offline
24
+ ```
25
+
26
+ `new`, `kit`, `mcp`, `serve`, `read`/`ack` load neither TypeScript nor the kit generator: the kit
27
+ and the runtime types are prebuilt into the package (`kit/`, `dist/runtime-types.json`, at
28
+ `prepack`).
29
+ `tree` reads YAML only; `smoke` and `tree --props` load the runtime from a bundle prebuilt at
30
+ `prepack` (`dist/smoke/prebuilt/`, like validate's), so none of this slows `new` / `mcp` / `serve`.
31
+
32
+ `read` / `ack` append `{ path, sha256, at }` to `.pix3/ack.json`. The editor, merging the next
33
+ version of that file you write, lifts the protection of the manual edits that version contained
34
+ and removes the ack (one-shot); see `docs/pix3-specification.md` → "Co-authoring mode".
35
+
36
+ ## `pix3 check` — everything an agent should run after a batch of edits
37
+
38
+ `pix3 check` = `pix3 validate` (both levels) + a TypeScript type-check of the project's scripts +
39
+ the newest `.pix3/merge-log.jsonl` entries + a version check. Exit 0 = no errors (warnings
40
+ allowed), 1 = at least one error, 2 = could not run. Diagnostics are one list, validate's plus:
41
+
42
+ | Code | Severity | When |
43
+ | --- | --- | --- |
44
+ | `E_TYPE` | error | a tsc diagnostic (`message` starts with `TS<code>:`; `file`, `line`) |
45
+ | `E_TYPECHECK_UNAVAILABLE` | error | TypeScript could not be found or installed (`fix` = the command to run) |
46
+ | `W_RUNTIME_VERSION_MISMATCH` | warning | own `tsconfig.json`, and `node_modules/@pix3/runtime` is not this CLI's version |
47
+ | `W_RUNTIME_NOT_INSTALLED` | warning | own `tsconfig.json`, and no `node_modules/@pix3/runtime` |
48
+ | `W_KIT_OUTDATED` | warning | `metadata.agentKit.version` is not this CLI's version (`pix3 kit --update`) |
49
+
50
+ `--json`:
51
+
52
+ ```text
53
+ {
54
+ "ok": true, "projectRoot": "…", "errorCount": 0, "warningCount": 2,
55
+ "level2": { "state": "ran", "filesHydrated": 5, "filesSkipped": 0, "filesSkippedForScripts?": 0 },
56
+ "files": [{ "file": "scenes/main.pix3scene", "sha256": "<hex of the raw bytes>" }],
57
+ "diagnostics": [{ "severity", "code", "file", "line?", "nodeId?", "path?", "message", "fix?" }],
58
+ "notes": [],
59
+ "typecheck": { "ok": true, "errors": 0, "tsconfig": ".pix3/tsconfig.check.json", "mode": "pix3-types",
60
+ "files": 5, "typescript": { "version": "5.8.3", "source": "cache" } },
61
+ "mergeLog": [{ "at", "file", "event", "…": "the newest 10 lines of .pix3/merge-log.jsonl" }],
62
+ "kit": { "version": "1.6.0", "cliVersion": "1.6.0", "upToDate": true },
63
+ "timingsMs": { "validate": 190, "typecheck": 1180, "total": 1380 }
64
+ }
65
+ ```
66
+
67
+ `files` holds every scene validated and every script type-checked, hashed over the **raw bytes** —
68
+ the hashes `expect` (barrier tools) and `pix3 ack --sha256` take. `typecheck.errors` counts the
69
+ `E_TYPE` / `E_TYPECHECK_UNAVAILABLE` entries of `diagnostics`.
70
+
71
+ **A compile error hides level 2.** When the scripts do not compile (`E_SCRIPT_COMPILE`), level 2
72
+ skips every scene with `user:` components — their `E_UNKNOWN_CONFIG_KEY` / `E_PROPERTY_TYPE` /
73
+ component checks are *missing*, not passed. The report says so: `level2.filesSkippedForScripts`,
74
+ a `SKIPPED: N scene(s) …` note, and the human summary line reads `level 2 hydrated 0 file(s), N
75
+ SKIPPED (scripts do not compile — user: components unchecked)`. Fix the compile error and run again.
76
+
77
+ **Merge-log notes.** An `ack-unknown` entry (a `pix3 read` / `pix3 ack` of bytes the editor never
78
+ recorded) prints as a `note:` only while its hash is the file's *current* version; one about a
79
+ version the disk no longer holds is history and is not printed (it stays in `mergeLog` of `--json`).
80
+
81
+ **Which tsconfig.** A project with its **own** root `tsconfig.json` (a Vite project, or one the
82
+ editor's *build from templates* turned into one) is checked with it, as is, against its own
83
+ `node_modules` (`mode: "project"`); nothing is written into it. Otherwise (`mode: "pix3-types"`)
84
+ `check` uses `.pix3/tsconfig.check.json` against the bundled types in `.pix3/types/`, and first
85
+ (re)writes both when they are missing or from another CLI build.
86
+
87
+ **Where TypeScript comes from** — it is not a dependency of `@pix3/cli`: (1) the project's own
88
+ `node_modules/typescript` (or an ancestor's); (2) the CLI's sibling install
89
+ (`import.meta.resolve('typescript')`, e.g. the monorepo); (3) `~/.pix3/typescript/5.8.3/`,
90
+ installed there once with `npm install --prefix ~/.pix3/typescript/5.8.3 typescript@5.8.3 …`
91
+ (printed before it runs). `--offline` never installs: it fails with `E_TYPECHECK_UNAVAILABLE` and
92
+ the command. `PIX3_TYPESCRIPT=<package dir>` overrides the search. Loaded with a dynamic `import()`.
93
+
94
+ Measured (recipe-tapper-2d, 5 scripts, packed CLI installed outside the repo, fresh `HOME`):
95
+ first `check` 2.1 s including the TypeScript install, then 1.45 s (validate ~0.2 s, tsc ~1.2 s).
96
+
97
+ ### SVG sprites
98
+
99
+ Every texture in Pix3 (editor viewport, play mode, the single-file export) loads as bytes → `Blob`
100
+ → object URL → three's `TextureLoader` → `<img>`, so an `.svg` renders exactly when a browser
101
+ `<img>` decodes it, at the size the `<img>` reports. Level 1 checks every `.svg` a scene
102
+ references (once per file per scene, at its first reference):
103
+
104
+ | Code | Severity | When |
105
+ | --- | --- | --- |
106
+ | `E_SVG_INVALID` | error | no `<svg>` root, or the root lacks `xmlns="http://www.w3.org/2000/svg"` (the browser refuses to decode it) |
107
+ | `E_SVG_NO_SIZE` | error | no absolute `width`/`height` and no `viewBox` (renders into a cropped 300x150 box), or one dimension without a `viewBox` |
108
+ | `W_SVG_VIEWBOX_ONLY` | warning | a `viewBox` but no absolute `width`/`height` (`%` and `style=` sizes count as none): Chrome gives it 300x150, art letterboxed |
109
+ | `W_SVG_EXTERNAL_REF` | warning | an `href`, CSS `url()` or `@import` to anything but `#fragment` / `data:` — never loads in SVG-as-image mode |
110
+
111
+ Measured in Chromium 129 through the runtime's own `ResourceManager` + `AssetLoader` + `Sprite2D`:
112
+ a sized SVG auto-sizes the sprite to its `width`×`height`; a viewBox-only one to 300×150; one
113
+ without `xmlns`, or any SVG whose Blob type is not `image/svg+xml`, fails to load (browsers do
114
+ not sniff SVG).
115
+
116
+ ## `pix3 sfx` — sound effects without the editor
117
+
118
+ ```text
119
+ pix3 sfx coin # -> audio/coin.wav in the project root
120
+ pix3 sfx "short high coin pickup" --out audio/pickup.wav --seed 7
121
+ pix3 sfx explosion --json # { path, res, durationMs, preset, seed, modifiers, bytes, peak, params }
122
+ ```
123
+
124
+ Presets: `coin`, `jump`, `hit`, `explosion`, `powerup`, `click`. Free text picks the preset whose
125
+ keyword appears first (`boom`, `pickup`, `button`, `hurt`, …) and applies modifier words
126
+ (`high`/`low` pitch ×1.4/×0.7, `short`/`long` time ×0.65/×1.6, `soft`). `--seed <n>` renders a
127
+ deterministic variation (pitch ±15 %, times ±20 %, duty/vibrato/cutoff nudged); without it the
128
+ preset as tuned. Output is 44.1 kHz 16-bit mono PCM WAV — `AssetLoader.loadAudio` decodes it with
129
+ `decodeAudioData` like any `.ogg`/`.mp3`, and the export embeds it as `audio/wav`. `res` in the
130
+ JSON is the `res://` path when the file lands inside a project. Exit 2 on a bad argument or a
131
+ description that names no preset.
132
+
133
+ The synth (`src/sfx/synth.ts`, no dependencies) is one jsfxr-style voice: square / saw / sine /
134
+ triangle / noise, exponential pitch slide, pitch jump, vibrato, ADSR, resonant low-pass, DC
135
+ blocker, peak normalisation. It does not use the editor's `@txt2sfx/*` packages: their only
136
+ renderer drives an `OfflineAudioContext` the caller supplies, i.e. a native Web Audio module
137
+ (`node-web-audio-api`) in Node. Each preset renders in well under 10 ms; the whole command runs in
138
+ ~0.12 s; files are 2–67 KiB (click 24 ms … explosion 772 ms).
139
+
140
+ ## `pix3 smoke` — run the game headless
141
+
142
+ `pix3 smoke [scene]` runs the game in Node — no browser, no editor, no `pix3 serve` — for
143
+ `--frames N` fixed steps of 1/60 s (default 120) and reports everything that went wrong. It is the
144
+ behavioural check after `pix3 check` when no editor is connected; with the live channel,
145
+ `game_run` is the stronger one (it renders).
146
+
147
+ What runs: the project's scripts compiled with esbuild (as `validate` level 2 does, but bare
148
+ imports are bundled for real from the project's `node_modules` — Rapier, three addons — and only
149
+ what resolves nowhere becomes an empty module, reported as `W_SMOKE_STUBBED_IMPORT`), the scene
150
+ loaded from disk by the real `SceneLoader`, a real `SceneRunner` in manual time. Frame 0 is loading
151
+ and starting the scene (`onAttach`/`onStart`, then 128 event-loop turns so spawn chains land);
152
+ frame N is the N-th step. The run happens in a worker thread, so a script stuck in a loop is
153
+ stopped by `--timeout` (default 20 s).
154
+
155
+ Stubbed: rendering (a null renderer; post-processing never loads), audio (no Web Audio in Node),
156
+ input (no events), network (`scene.network` is null). Textures resolve empty, glTF models and
157
+ Spine skeletons referenced by nodes are not built; images a script loads through three's
158
+ `TextureLoader` "load" as blank 1×1 pictures (so preloaders finish). A small browser shim provides
159
+ `window` / `document` / canvas / `localStorage` / `matchMedia` / `requestAnimationFrame`; any other
160
+ browser property a script reads is recorded, and an error right after such a read is
161
+ `E_SMOKE_DOM` with the read attached (`domAccess`).
162
+
163
+ Scene: the argument (`res://`, project-relative or a path) runs that one scene. With no argument
164
+ several run, one after another from one bundle, a line each (`src/smoke/select-scenes.ts`):
165
+
166
+ 1. In a git work tree with uncommitted changes (staged, unstaged or untracked): the **top-level**
167
+ scenes those changes reach — the scene itself, a prefab / `scenes/ui/` overlay it instances, a
168
+ script exporting a class it attaches as `user:X`, a `res://` file it names. `--changed` forces
169
+ this (exit 2 without git or without changes). Changes under `node_modules/`, `.yalc/` (a linked
170
+ package copy), `.git/`, `.pix3/`, `dist/`, `.vite/`, `.cache/` are not project changes and are
171
+ ignored, and a `*.spec.ts` / `*.test.ts` next to the scripts is not game code — a consumer
172
+ project's `.yalc/@pix3/runtime/**` and its unit tests used to read as hundreds of changed
173
+ scripts that reach no scene and widen every run to everything.
174
+ 2. Otherwise — no git, nothing changed, a changed scene/script that reaches no top-level scene (a
175
+ helper module, an unused prefab), or a changed `pix3project.yaml` — **every** top-level scene
176
+ (not instanced by another, not under `prefabs/` / `ui/`), `scenes/main.pix3scene` first (the
177
+ editor's startup scene, where the game lives), then `defaultExportScenePath`, then by path.
178
+ `--all` forces this.
179
+
180
+ It never picks `defaultExportScenePath` alone any more: in every recipe that is the menu, input is
181
+ empty, PLAY is never pressed, and a game whose `onStart` throws used to smoke green.
182
+
183
+ | Code | |
184
+ | --- | --- |
185
+ | `E_SMOKE_SCRIPT` | A script hook threw (`onAttach`/`onStart`/`onUpdate`…); the engine disabled that component |
186
+ | `E_SMOKE_DOM` | …and it was a browser API the shim does not provide (`domAccess` names it) |
187
+ | `E_SMOKE_TICK` / `E_SMOKE_COMMAND` | The engine tick / a game command handler threw |
188
+ | `E_SMOKE_CONSOLE_ERROR` | `console.error` (not the engine re-reporting one of the above) |
189
+ | `E_SMOKE_UNHANDLED` | An unhandled promise rejection, or a timer callback that threw |
190
+ | `E_SMOKE_LOAD` | The scene failed to load (missing prefab, invalid scene) |
191
+ | `E_SMOKE_SCRIPT_COMPILE` / `E_SMOKE_SCRIPT_IMPORT` | Scripts do not compile / throw at module top level |
192
+ | `W_SMOKE_MISSING_RESOURCE` | A `res://` file does not exist (the node loads without it) |
193
+ | `W_SMOKE_PENDING_COMPONENT` | A component type is not registered — it never runs |
194
+ | `W_SMOKE_CONSOLE_WARN`, `W_SMOKE_LOADER`, `W_SMOKE_STUBBED_IMPORT`, `W_SMOKE_STOPPED` | `console.warn`, loader warnings, an import replaced by `{}`, the game stopped itself |
195
+
196
+ Exit 0 = no errors (warnings allowed), 1 = errors, 2 = could not run (with several scenes, the
197
+ worst run decides): `E_SMOKE_NO_PROJECT`,
198
+ `E_SMOKE_NO_SCENE`, `E_SMOKE_BUNDLE`, `E_SMOKE_UNSUPPORTED` (no esbuild), `E_SMOKE_TIMEOUT`,
199
+ `E_SMOKE_CRASH`. The human report prints the `game` snapshot whole — on one line when it is short, pretty-printed
200
+ and indented otherwise, cut only past 4 000 characters (`--json` always carries it whole).
201
+ `--json` prints `{ ok, scene, frames, framesRequested, firstFrameOk, errors:
202
+ [{ code, frame, script?, nodeId?, nodeName?, phase?, message, stack?, domAccess? }], warnings,
203
+ nodes: { start, end }, timingsMs: { compile, load, firstFrame, step: { total, mean, p95, max },
204
+ total }, scripts, domMissing, notes, game, logs }` — `game` is the `registerGameDebug` provider's
205
+ `snapshot()` at the end (when the game registers one), `logs` the first `console.log` lines,
206
+ frame-stamped. Stacks are source-mapped to the project's files. With no scene argument `--json`
207
+ prints `{ ok, selection: "changed" | "all", reason, changed?, runs: [<that report, per scene>] }`.
208
+
209
+ A green smoke run means nothing threw for N frames without input. It does not mean the game
210
+ plays: nothing was tapped, nothing was drawn.
211
+
212
+ ## `pix3 tree` — find your way around a scene
213
+
214
+ `pix3 tree <scene>` prints one line per node, indented by depth, from the YAML alone (no loader,
215
+ no project code — instant):
216
+
217
+ ```text
218
+ scenes/main.pix3scene — 12 nodes
219
+ Group2D#game-root "Game Root" size=1080x1920 layout=stretch/stretch components=[user:GameRules, user:TouchRules]
220
+ CanvasLayer2D#hud "HUD" size=1080x1920 layout=stretch/stretch components=[user:ScoreHud]
221
+ Label2D#score-label "Score Label" text="SCORE 0" pos=(-340,850) layout=left/top
222
+ Group2D#result-overlay "Result Overlay" ↳ instance res://scenes/ui/result.pix3scene (1 property) hidden
223
+ ```
224
+
225
+ `--depth N` stops N levels below the roots (cut subtrees end in `… +K below`); `--types A,B` keeps
226
+ nodes of those types (or carrying those components; `instance` = prefab instances) with their
227
+ ancestors as `·` context lines; `--props` adds, under each node, the properties that differ from
228
+ the node type's defaults (read from a bare instance of the runtime class, through the disk-format
229
+ table — the one step that loads the runtime bundle); `--json` gives the same as nested
230
+ `{ id, type, name, depth, position?, size?, layout?, hidden?, text?, groups?, components,
231
+ instance?: { path, rootType?, rootName?, overrides, properties }, props?, children }`. On an instance
232
+ `overrides` counts every `overrides.byLocalId.*.properties` key (edits to nodes inside the prefab)
233
+ and `properties` the instance node's own `properties` keys (applied to the prefab root); the line
234
+ prints `(2 overrides, 1 property)`, or `(no overrides)` when both are 0.
235
+
236
+ `pix3 tree` with no scene is the project overview: manifest facts, the `user:` scripts, and every
237
+ scene/prefab/overlay with its node count, node types, components and instances (entry scene
238
+ marked `*`). Exit 0, 1 when a scene does not parse, 2 for bad arguments or a missing file.
239
+
240
+ ## `pix3 kit` — the agent kit
241
+
242
+ `pix3 kit` installs the agent kit into an existing project (one the editor created, or after a CLI
243
+ upgrade); `pix3 new` runs the same step. What lands in the project:
244
+
245
+ | Path | What |
246
+ | --- | --- |
247
+ | `AGENTS.md` | Root rules (read by Codex, Cursor, …). If the project already has its own, it is kept and the kit goes to `AGENTS.pix3.md` (the command prints the line to add). |
248
+ | `CLAUDE.md` | `@AGENTS.md` (plus `@AGENTS.pix3.md` in that case). A `CLAUDE.md` of the project's own is never touched — the command prints the line to add. |
249
+ | `.claude/skills/pix3-{scene-format,nodes,scripts,verify}/SKILL.md` + `reference.md` | Skills loaded on demand; the `reference.md` files are generated from `docs/` and the runtime's registry |
250
+ | `.mcp.json` | The pinned `pix3 mcp --workspace` entry; other servers kept |
251
+ | `.gitignore` | `.pix3/` appended when not covered; existing content kept |
252
+ | `tsconfig.json`, `.pix3/tsconfig.check.json`, `.pix3/types/` | Only without a `tsconfig.json` of the project's own (see below) |
253
+ | `pix3project.yaml` | `metadata.agentKit: { version, files }` (everything else as written) |
254
+ | `.pix3/kit-manifest.json` | sha256 of every file the kit wrote |
255
+
256
+ Without `--update`, existing files are never replaced (missing ones are written; an unchanged file
257
+ from an older kit is reported `outdated`). `--update` replaces every kit file whose bytes still
258
+ match the hash in `.pix3/kit-manifest.json` and **skips** (and reports) every file edited since.
259
+ `.pix3/` is gitignored, so on a fresh clone the manifest is gone and every differing kit file counts
260
+ as edited.
261
+
262
+ **Generated, not copied.** `scripts/build-kit.mjs` (run at `prepack`; a repo checkout regenerates
263
+ `kit/` automatically whenever an input changed) expands the templates in `kit-src/`: hand-written
264
+ prose plus `{{include:<repo path>#<heading>}}` / `{{include:<repo path>@<paragraph>}}` directives
265
+ over `docs/pix3-specification.md`, `docs/node-types-reference.md`, `docs/nodes-and-systems.md`,
266
+ `src/services/agent/agent-skills/engine-api-map.md` and this README, and `{{generated:…}}` blocks
267
+ computed from code (the `core:` component table from the runtime's registry, the MCP tool list,
268
+ the barrier error codes). Syntax: `src/kit/generate.ts`. `src/kit.spec.ts` builds the kit and fails
269
+ on drift: an unresolved directive, a `pix3` command or flag not in the usage text, a tool name
270
+ outside the 14, a diagnostic code no command emits, a node type the loader does not know, a
271
+ property in the nodes skill's tables that the disk-format descriptor
272
+ (`packages/pix3-runtime/src/core/scene-disk-format.ts`) does not accept, a `core:` component that
273
+ does not exist.
274
+
275
+ ### Script types (`.pix3/types/`)
276
+
277
+ `scripts/build-runtime-types.mjs` (at `prepack`; rebuilt on demand in a checkout when the runtime
278
+ sources change) runs `tsc -p packages/pix3-runtime/tsconfig.types.json` (declarations of what
279
+ `src/index.ts` reaches — no specs, samples or `testing/`) into `runtime-types/@pix3/runtime/`, and
280
+ copies `@types/three` (without its `node_modules`) to `runtime-types/@types/three/` — the runtime's
281
+ public types extend three.js. The tarball carries that tree packed into one file,
282
+ `dist/runtime-types.json` (see "Package layout" below); the installed CLI expands it on first use. `lit` (re-exported `property`/`state` decorators), `postprocessing`
283
+ and the Spine runtime are not shipped: they are only reached from inside declaration files, which
284
+ `skipLibCheck` leaves alone (they type as `any`). In a project without its own `tsconfig.json`:
285
+
286
+ ```text
287
+ .pix3/types/@pix3/runtime/** the runtime's .d.ts (+ package.json with the version)
288
+ .pix3/types/@types/three/** three.js types
289
+ .pix3/types/version.json which CLI build wrote them
290
+ .pix3/tsconfig.check.json paths → ./types/…, strict, noEmit, skipLibCheck, lib ES2022 + DOM,
291
+ include ../scripts/**/*.ts and ../src/scripts/**/*.ts
292
+ tsconfig.json { "extends": "./.pix3/tsconfig.check.json" } — so an IDE sees them
293
+ ```
294
+
295
+ ## `pix3 serve` — workspace server
296
+
297
+ Serves one project folder to the Pix3 editor over **one loopback port**, without File System
298
+ Access. Built for a project that lives on a remote machine (VS Code Remote SSH): run `pix3 serve`
299
+ there, forward the port, and connect the editor on your computer with the address and token.
300
+
301
+ - Binds `127.0.0.1` only. `--port N`: exactly N; if it is busy the command fails (no silent
302
+ switch). Without `--port`: the first free port of 8490–8499.
303
+ - Project root: `--project`, else the nearest ancestor of the cwd with `pix3project.yaml`.
304
+ A folder without `pix3project.yaml` is refused.
305
+ - **One server per root.** A second `pix3 serve` on the same root prints the running server's
306
+ address and exits 0 (`--new-token` then rotates the token of the running server).
307
+ - Stops on Ctrl+C / SIGTERM. stdout is for humans; the event log goes to stderr.
308
+
309
+ Protocol version: **`WORKSPACE_PROTOCOL = 1`** (`src/protocol.ts`), reported as `protocol` in
310
+ `hello` and `/ws/status`. It is independent of the CLI's lockstep product version.
311
+
312
+ ### Pairing and server state
313
+
314
+ - On the first run the server mints a **token**: `p3ws_` + base64url of 32 random bytes. It is
315
+ printed **once** and never stored in plaintext. `pix3 serve --new-token` issues a new one; the
316
+ old token stops working at once, live sockets authenticated with it are closed.
317
+ - State lives in `<root>/.pix3/` (mode 0700, with a `.gitignore` of `*`):
318
+ - `workspace.json` (mode 0600):
319
+
320
+ ```json
321
+ {
322
+ "version": 1,
323
+ "workspaceId": "<uuid, minted once per root>",
324
+ "root": "<canonical absolute root>",
325
+ "token": { "sha256": "<hex sha256 of the token>", "issuedAt": "<ISO time>" },
326
+ "server": {
327
+ "pid": 1234,
328
+ "port": 8490,
329
+ "serverSession": "<uuid>",
330
+ "control": "<secret>",
331
+ "startedAt": "<ISO time>"
332
+ }
333
+ }
334
+ ```
335
+
336
+ `server` is `null` while no server runs. **Revoke = delete this file**: a running server
337
+ notices within ~2 s, closes every socket and refuses the old token.
338
+ If `root` differs from the folder's canonical path (the project was copied), the server
339
+ mints a new `workspaceId` and token instead of trusting the copied file.
340
+ - `serve.lock` — pid of the server owning the root.
341
+ - `tmp/` — staging for atomic writes.
342
+ - `link/` — challenge files of the FSA link server.
343
+ - **Server-private** = `.pix3` itself, `.pix3/workspace.json`, `.pix3/serve.lock`, `.pix3/tmp/**`
344
+ and `.pix3/link/**` (names compared case-insensitively): unreachable through every route
345
+ (`403 reserved_path`) and never listed.
346
+ - **Everything else under `.pix3/`** is the editor's co-authoring bookkeeping
347
+ (`protected.json`, `merge-log.jsonl`, `recovery/**`, `ack.json`, …) and is readable and
348
+ writable like any other path. It is **never part of the revision set**: it does not move
349
+ `revision` and produces no `change` events — with one exception, `.pix3/ack.json` (see
350
+ `ChangeEvent` below). `/ws/manifest` lists it (see there).
351
+
352
+ ### Common rules (every HTTP route)
353
+
354
+ - **Host**: `localhost`, `127.0.0.1` or `[::1]`, **with any port or none** (a forwarded local
355
+ port may differ from the server's). Anything else → `403 forbidden_host`.
356
+ - **Origin**: absent (a local process) or one of `https://editor.pix3.dev`,
357
+ `http://localhost:8123`, `http://127.0.0.1:8123`. Anything else → `403 forbidden_origin`,
358
+ without CORS headers. Allowed origins get `Access-Control-Allow-Origin: <origin>`,
359
+ `Vary: Origin`, and `Access-Control-Expose-Headers: ETag, Content-Range, Content-Length,
360
+ Accept-Ranges, X-Mutation-Replayed`.
361
+ - **Preflight** `OPTIONS` (no auth): `204`, `Access-Control-Allow-Methods: GET, HEAD, PUT,
362
+ POST, OPTIONS`, `Access-Control-Allow-Headers: Authorization, Content-Type, If-Match,
363
+ If-None-Match, X-Mutation-Id, Range`, `Access-Control-Max-Age: 600`.
364
+ - **Auth**: `Authorization: Bearer <token>` on every route. Missing/wrong/revoked →
365
+ `401 unauthorized` with `WWW-Authenticate: Bearer`. After **10 failures within 60 s**, every
366
+ attempt (valid token or not) gets `429 rate_limited` with `Retry-After: <s>` and
367
+ `retryAfter` in the body, until the window moves on.
368
+ - **Errors** are JSON: `{ "error": "<code>", "message": "<text>", ...extra }`.
369
+ - **Paths** are POSIX, relative to the root, case preserved: `scenes/main.pix3scene`. Refused
370
+ with `400 bad_path`: empty, absolute (`/…`), backslashes, a drive prefix (`C:`), NUL, empty
371
+ segments (leading/trailing/double `/`), `.` or `..` segments, a `:` in a segment (an NTFS
372
+ stream: `name::$DATA`), a segment ending in `.` or a space (Win32 strips them). On Windows an
373
+ 8.3 short name (`WORKSP~1.JSO`) is refused too — only an entry's own long name is accepted. A server-private path (see
374
+ above) → `403 reserved_path`.
375
+ `?path=` is percent-decoded **exactly once** (a literal `%2e%2e` after that one decoding is a
376
+ file name, not `..`); JSON bodies are not decoded. **No operation passes through a symlink**:
377
+ a symlink anywhere on the path, parents of a path being created included, →
378
+ `403 symlink`. A file where a parent directory is expected → `409 not_a_directory`.
379
+
380
+ ### Revision set, manifest and `revision`
381
+
382
+ The **revision set** is what an export ships (the walk of `ProjectBuildService`): every file under
383
+ the root except inside directories named `node_modules`, `.git`, `.yalc`, `.vscode`, `.idea`,
384
+ `dist`, `build`, `out`, `coverage`, `.cache` (at any depth) and `.pix3/` at the root. A *file*
385
+ named like one of those is included. Symlinks are neither listed nor followed.
386
+
387
+ `revision` = lowercase hex sha256 of the UTF-8 text formed by the lines `<path>:<sha256>` of every
388
+ **file** of the set, sorted by JavaScript string comparison (UTF-16 code units), joined with `\n`,
389
+ no trailing newline. Directories do not contribute. (Empty set → sha256 of the empty string.)
390
+
391
+ ### Routes
392
+
393
+ #### `GET /ws/manifest`
394
+
395
+ A **full scan** of the disk (hashes cached by inode + size + mtime), so it also reconciles
396
+ anything the watcher missed — differences it finds are broadcast as a `change` frame first.
397
+
398
+ ```json
399
+ {
400
+ "workspaceId": "…",
401
+ "serverSession": "…",
402
+ "revision": "<hex>",
403
+ "seq": 12,
404
+ "files": [
405
+ { "path": "scenes", "kind": "dir", "size": 0, "mtime": 1790373745915 },
406
+ { "path": "scenes/main.pix3scene", "kind": "file", "size": 9, "mtime": 1790373745915, "sha256": "<hex>" }
407
+ ]
408
+ }
409
+ ```
410
+
411
+ `files` is sorted by path; `mtime` is integer epoch milliseconds; `sha256` is present on every
412
+ file and absent on directories. Not an atomic snapshot of a folder others keep writing to.
413
+
414
+ `files` is the revision set **plus** the non-private part of `.pix3/` (`.pix3` itself as a `dir`,
415
+ then e.g. `.pix3/protected.json`, `.pix3/recovery/…`), so a client can list and find its
416
+ bookkeeping the same way it would in a local folder. `revision` is computed from the revision set
417
+ only: a client recomputing it from `files` must leave out every path under `.pix3/`.
418
+
419
+ #### `GET /ws/file?path=<p>` (and `HEAD`)
420
+
421
+ Raw bytes. Headers: `Content-Type` (by extension; unknown → `application/octet-stream`),
422
+ `Content-Length`, `ETag: "<sha256 of the bytes>"`, `Accept-Ranges: bytes`,
423
+ `Cache-Control: private, no-cache`, `X-Content-Type-Options: nosniff`.
424
+
425
+ - `If-None-Match: "<sha256>"` (list, `W/` and `*` accepted) → `304` with the `ETag`, no body.
426
+ - `Range: bytes=a-b` | `bytes=a-` | `bytes=-n` (one range) → `206` with
427
+ `Content-Range: bytes a-b/size`; unsatisfiable → `416` with `Content-Range: bytes */size`.
428
+ Several ranges or an unparsable header → the whole file, `200`.
429
+ - A directory → `400 not_a_file`; missing → `404 not_found`.
430
+
431
+ #### `PUT /ws/file?path=<p>`
432
+
433
+ Body = the new bytes (≤ 1 GiB). Written to `.pix3/tmp/`, then renamed over the target (atomic on
434
+ one filesystem; the file mode of a replaced file is kept). Missing parent directories are created.
435
+
436
+ - `If-Match: "<baseHash>"` — optional. Current content hash ≠ base →
437
+ `409 { "error": "base_mismatch", "currentHash": "<hex>" | null, … }` and nothing is written.
438
+ `If-Match: *` = the file must exist.
439
+ - `If-None-Match: *` — create only; existing file → `409 { "error": "exists", "currentHash" }`.
440
+ - The target is a directory → `409 not_a_file`.
441
+ - `X-Mutation-Id` — see *Mutation journal*.
442
+
443
+ → `200 { "path": "…", "sha256": "<hex>", "size": 10, "mtime": 1790373745915, "seq": 13 }`
444
+
445
+ The check and the rename are serialised with the server's other writes, but they are **not** a
446
+ compare-and-swap against a process writing the folder directly (the agent): that race remains.
447
+
448
+ #### `POST /ws/mkdir` — `{ "path": "<p>" }`
449
+
450
+ Recursive. → `200 { "path", "created": true | false, "seq" }` (`false` = already a directory);
451
+ a file in the way → `409 exists`.
452
+
453
+ #### `POST /ws/delete` — `{ "path": "<p>", "recursive"?: boolean }`
454
+
455
+ → `200 { "path", "kind": "file" | "dir", "seq" }`. Missing → `404 not_found`; non-empty
456
+ directory without `recursive: true` → `409 not_empty`.
457
+
458
+ #### `POST /ws/move` — `{ "from": "<p>", "to": "<p>", "overwrite"?: boolean }`
459
+
460
+ Creates missing parents of `to`. → `200 { "from", "to", "kind": "file" | "dir", "sha256"?, "seq" }`
461
+ (`sha256` for files). `to` exists → `409 exists` (unless `overwrite: true` and both are files);
462
+ `to` equal to or inside `from` → `400 bad_move`; `from` missing → `404 not_found`.
463
+
464
+ #### `POST /ws/hash` — `{ "paths": ["<p>", …] }` (≤ 20 000)
465
+
466
+ Hashes of the disk **now** (for the sync barrier). → `200 { "hashes": { "<p>": "<hex>" | null }, "seq" }`;
467
+ `null` = missing or not a file. Any invalid path fails the whole request (`400 bad_path`,
468
+ `403 reserved_path`, `403 symlink`).
469
+
470
+ #### `GET /ws/status`
471
+
472
+ → `200 { "workspaceId", "serverSession", "protocol", "cliVersion", "root", "pid", "port",
473
+ "revision", "seq", "leased" }`. Accepts the bearer token **or** `X-Pix3-Control: <server.control>`
474
+ from `workspace.json` (how another local `pix3` process confirms a live server is the one the file
475
+ describes; beyond this route and `GET /ws/revision`, the control secret grants only the agent
476
+ lane below — never the file API).
477
+
478
+ #### `GET /ws/revision`
479
+
480
+ → `200 { "revision", "seq", "serverSession" }` — no scan, the table as it is. Bearer token or
481
+ `X-Pix3-Control`.
482
+
483
+ ### Mutation journal (`X-Mutation-Id`)
484
+
485
+ `PUT /ws/file`, `/ws/mkdir`, `/ws/delete`, `/ws/move` accept `X-Mutation-Id: <1–128 chars of
486
+ [A-Za-z0-9_.:-]>`. Within one `serverSession`, the first request with an id is applied and its
487
+ answer recorded (success **and** failure). A retry with the same id — also while the first is
488
+ still in flight — is **not applied again**: it gets the recorded status and body plus
489
+ `X-Mutation-Replayed: true` (a PUT retry's body is drained and discarded). The same id on a
490
+ different request (other route, path or conditions) → `422 mutation_id_reused`. The journal keeps
491
+ the last 2 000 ids and is lost on restart: after a new `serverSession`, the outcome of an
492
+ unanswered mutation is unknown and must be checked against the disk, not retried blindly.
493
+
494
+ ### `seq`
495
+
496
+ One counter per `serverSession`, starting at 0. Every change of the file table takes the next
497
+ value: an external change batch (sent as a `change` frame) or one of the server's own mutations
498
+ (returned in its response, **not** sent as a frame). So `seq` is monotonic but **not dense** on the
499
+ event stream — a gap is a write made through the API, not a lost event. Nothing is replayed: a
500
+ client that reconnects gets the current `revision`/`seq` in `hello` and must re-scan
501
+ (`/ws/manifest`) if its revision differs.
502
+
503
+ ### WebSocket `/ws/events`
504
+
505
+ Same Host/Origin rules as HTTP (checked at the upgrade; refused → `403` before the handshake).
506
+ **The token is never in the URL.** All frames are JSON text objects; binary frames close the
507
+ socket (`1003`).
508
+
509
+ **Client → server**
510
+
511
+ | Frame | Meaning |
512
+ | --- | --- |
513
+ | `{ "type": "auth", "token": "<token>" }` | Must be the **first** frame, within **5 s**. |
514
+ | `{ "type": "pong" }` | Answer to `ping`. |
515
+ | `{ "type": "ping" }` | Server answers `{ "type": "pong" }`. |
516
+ | `{ "type": "lease", "action": "acquire", "leaseId"?: "<id>" }` | Take the free lease; with the current `leaseId`, resume it (during the grace period, or from a new socket while the old one is still open). |
517
+ | `{ "type": "lease", "action": "takeover" }` | Take the lease from whoever holds it. |
518
+ | `{ "type": "lease", "action": "release" }` | Give it up. |
519
+ | `{ "type": "call-result", "id": "<call id>", "result": { "content": [Block, …], "isError"?: true, "_meta"?: {…} } }` | Answer a `call` (lease holder only). `Block` = `{ "type": "text", "text" }` or `{ "type": "image", "data": "<base64, no data: prefix>", "mimeType": "image/png" }`. `_meta.pix3 = { playRevision, stale }` on observing tools (see the agent lane). |
520
+
521
+ **Server → client**
522
+
523
+ | Frame | When |
524
+ | --- | --- |
525
+ | `{ "type": "hello", "workspaceId", "serverSession", "protocol": 1, "cliVersion", "revision", "seq", "root", "projectId": string \| null, "projectName", "lease": "held" \| "free", "leaseGraceMs": 10000, "agentPresence": { "attached", "agent" } }` | Right after a valid `auth`. `root` is the server's absolute path, `projectName` is `metadata.projectName` (else the folder name), `projectId` is `metadata.projectId`, `leaseGraceMs` is the lease grace (below), `agentPresence` the current agent presence (next row). |
526
+ | `{ "type": "agent-presence", "attached": boolean, "agent": { "name": string \| null, "verified": false } \| null }` | Whenever the agent presence changes: a `pix3 mcp --workspace` process announced itself, left, or went silent for 30 s (see `POST /ws/agent/presence`). `agent` is the most recently heard process; its name is self-declared. The editor keeps its background loops running while `attached` (keepalive). |
527
+ | `{ "type": "change", "seq", "revision", "events": [ChangeEvent, …] }` | External changes, debounced ~100 ms (at most ~1 s under a steady stream). `revision` is the revision after the batch. |
528
+ | `{ "type": "ping" }` | Every 10 s. A socket silent (no frame at all) for 30 s is terminated. |
529
+ | `{ "type": "lease", "state": "granted", "leaseId", "resumed": boolean }` | Lease granted (`resumed: true` = same lease after a reconnect). |
530
+ | `{ "type": "lease", "state": "busy", "inGrace": boolean }` | Someone else holds it (`inGrace`: its holder is disconnected but may come back). |
531
+ | `{ "type": "lease", "state": "lost", "reason": "taken_over" \| "expired" \| "revoked" \| "resumed_elsewhere", "leaseId" }` | Sent to the holder that lost it. `resumed_elsewhere`: its `leaseId` was presented on another socket (then this one closes with `4409`). |
532
+ | `{ "type": "lease", "state": "released" }` | Answer to `release`. |
533
+ | `{ "type": "call", "id", "name", "input", "agent"? }` | An MCP tool call for the lease holder. `agent = { name, session, verified: false }` on agent-lane calls: `name` is what the `pix3 mcp` process calls itself (its MCP client's `clientInfo.name`, else `--agent` / `PIX3_AGENT`), `session` a random id per `pix3 mcp` process. Nothing verifies either. |
534
+ | `{ "type": "error", "error": "<code>", "message", "id"? }` | `unauthorized`, `auth_timeout`, `rate_limited`, `revoked` (then the socket closes), or `bad_frame`, `unknown_frame`, `not_lease_holder`, `bad_result`, `unknown_call`. |
535
+
536
+ `ChangeEvent`:
537
+
538
+ ```json
539
+ { "op": "create" | "modify" | "delete" | "rename", "path": "<p>", "kind": "file" | "dir", "sha256"?: "<hex>", "from"?: "<p>" }
540
+ ```
541
+
542
+ `sha256` on file `create`/`modify`/`rename`; `from` only on `rename` (a delete + create of the same
543
+ content in one batch, when unambiguous). Events are sorted by `path`. A file touched without a
544
+ content change produces no event. Changes made **through this API** never appear as events.
545
+ Nothing under `.pix3/` produces events, except an outside change of `.pix3/ack.json` (what
546
+ `pix3 read` / `pix3 ack` write): it comes as its own frame with one `create` / `modify` /
547
+ `delete` event for that path, and the frame's `revision` is unchanged. Clients may also poll it.
548
+ Events are hints, not a guarantee of seeing every write — the barrier re-checks with
549
+ `/ws/manifest` or `/ws/hash`.
550
+
551
+ **Close codes**: `4401` unauthorized / auth timeout / revoked, `4409` lease resumed on another
552
+ socket, `4429` rate limited, `1003` binary frame, `1001` server shutting down.
553
+
554
+ **Lease.** One holder at a time; it is the window that edits and answers MCP calls. When the
555
+ holder's socket closes, the lease is kept for a **10 s grace**: the same window reconnecting
556
+ sends `acquire` with its `leaseId` and gets `resumed: true` (calls it had been handed are sent
557
+ again); anyone else gets `busy` with `inGrace: true`. After the grace, the lease is free and
558
+ pending calls fail. The server does **not** announce that expiry: a client answered
559
+ `busy {inGrace: true}` should send `acquire` again after `leaseGraceMs` (the editor adds 500 ms,
560
+ and falls back to 11 s for a server that omits the field), repeating until `granted` or a
561
+ `busy {inGrace: false}`. The editor keeps its `leaseId` per workspace in `sessionStorage`, so a
562
+ reloaded tab resumes its own lease; another tab never shares it.
563
+
564
+ **The `leaseId` is the holder's secret**: an `acquire` presenting the current holder's `leaseId`
565
+ from a **different socket while the holder's socket is still open** is the same tab after a
566
+ reload whose new page connected before the old page's socket closed. The lease moves to the new
567
+ socket at once — `granted {resumed: true}` there, `lost {reason: "resumed_elsewhere"}` on the old
568
+ socket, which is then closed with `4409` (no grace starts); calls the old socket had not answered
569
+ are sent again on the new one, same ids. Any other `leaseId` (or none) still gets
570
+ `busy {inGrace: false}`. Because of that rule the editor presents a stored `leaseId` only when the
571
+ page is a reload (`PerformanceNavigationTiming.type === "reload"`) or the page that stored it
572
+ marked it on `pagehide` — the copy of `sessionStorage` a duplicated tab inherits from a live tab
573
+ is dropped, and that tab acquires without an id. Against an older server (no move on a matching
574
+ id), an editor answered `busy {inGrace: false}` while presenting its own `leaseId` asks again
575
+ every 1 s for up to 15 s. On `takeover`, the old holder
576
+ gets `lost/taken_over` and calls it had not
577
+ answered go to the new holder. On `release`, pending calls fail. The HTTP routes do not check the
578
+ lease (v1): a window without it is expected to stay read-only.
579
+
580
+ **Calls.** `WorkspaceServer.enqueueCall(name, input, timeoutMs = 60 000, extra?) →
581
+ Promise<ToolCallResult>` (in process; the agent lane below is its HTTP face). With no lease holder
582
+ it resolves at once with an error result (`isError: true`, `relayFailure: 'no_editor'`); otherwise
583
+ the call is delivered to the holder (`extra` becomes extra fields of the `call` frame) and the
584
+ promise settles with its `call-result`, or an error result on timeout (`relayFailure: 'timeout'`)
585
+ or lease loss (`'cancelled'`). A reconnecting holder that resumes its lease gets the calls it had
586
+ been handed again, with the same ids — the editor answers a repeated id once.
587
+
588
+ ### Agent lane — `/ws/agent/*`
589
+
590
+ The routes a local `pix3 mcp --workspace` process drives. **Auth: `X-Pix3-Control: <server.control>`
591
+ only** (from `.pix3/workspace.json`, 0600 — a process of the same user; the plan's trust model).
592
+ The browser's bearer token is refused (`401 unauthorized`), and so is any request carrying an
593
+ `Origin` (`403 forbidden_origin`): browsers never call this lane.
594
+
595
+ | Route | Answer |
596
+ | --- | --- |
597
+ | `GET /ws/agent/status` | `/ws/status` fields plus `holder: "connected" \| "grace" \| null`, `projectName`, `projectId`, `agentPresence: { attached, agent }`. |
598
+ | `POST /ws/agent/presence` `{ agent: { name, session }, leaving? }` | Heartbeat of a `pix3 mcp --workspace` process: `pix3 mcp` sends it on start, once its MCP client has introduced itself, every 10 s, and with `leaving: true` on shutdown. One presence per `session`; it expires 30 s after its last heartbeat (an agent-lane `call` also counts as one). Answers `{ agentPresence }`; every change goes out as an `agent-presence` frame. |
599
+ | `GET /ws/agent/tools` | `{ tools: [{name, description, inputSchema}], serverSession }` — the window's own tool definitions for the v1 allowlist (it answers an internal `tools_manifest` call within 5 s). `409 no_editor` without a window. |
600
+ | `POST /ws/agent/call` `{ name, input, timeoutMs?, agent? }` | Parks the call for the lease holder and answers when it replies: `200 { result }` (the window's result as is, error results included). `timeoutMs` is clamped to 1–120 s (default 120 s). `409 no_editor` at once when no window holds the lease (message: open `<root>` in Pix3 — File → Connect to Workspace…); `504 no_editor_reply` when it does not answer in time; `409 lease_lost` when the lease ended under the call (on a takeover the call goes to the new holder instead); `429 too_many_calls`. `agent` → the `call` frame's `agent`. |
601
+ | `POST /ws/agent/hash` `{ paths }` | Same as `POST /ws/hash`. |
602
+ | `POST /ws/agent/expect` `{ expect: { path: sha256 } }` | The agent's expectations against the disk **now**: `{ matchesAgent, differing: [{ path, diskHash \| null, agentHash, recovery, mergeLog? }], hashes, seq }`. `recovery` = the wire path of a file under `.pix3/recovery/<encodeURIComponent(path)>/` whose bytes hash to `agentHash` (every journaled version of that path is hashed), else `null` — a copy is named only when it exists. `mergeLog: true` when `.pix3/merge-log.jsonl` has a line for that path whose `mergedHash`/`hash` is the disk hash, or one no older than the file's mtime minus 5 s — the editor wrote those bytes. |
603
+ | `GET /ws/agent/changes?since=<seq>` | Rescans the whole revision set first (the watcher may have missed a write; differences go out as a `change` frame), then `{ since, seq, revision, paths, complete, entries }`: `paths` = distinct revision-set **files** changed after `since` — external writes and writes through the file API alike (`.pix3/` never, directories never); `entries` = one `{ seq, path, origin }` per recorded change, `origin` = `external` (seen by the watcher or this rescan) or `editor` (a mutation through the file API, i.e. the editor window). The server keeps the last 5 000 file changes; `complete: false` = the ring no longer reaches back to `since`, so an empty list proves nothing. |
604
+
605
+ ## `pix3 mcp --workspace` — the live channel
606
+
607
+ A stdio MCP server the agent starts from its config (`.mcp.json`, `pix3 setup`). It has no port
608
+ of its own: it finds the running `pix3 serve` of the project (`--project`, else the nearest
609
+ ancestor of the cwd with `pix3project.yaml`) through `.pix3/workspace.json` and confirms it with
610
+ `GET /ws/agent/status` + the control secret. When none runs, every tool answers
611
+ `no_workspace_server` and stderr says ``Workspace server is not running. Run `pix3 serve` in
612
+ <root>``; the next call looks again, so the agent never restarts its MCP server.
613
+
614
+ **Presence.** While it runs, the process announces itself to that server
615
+ (`POST /ws/agent/presence`, every 10 s and on start/stop; failures are silent and the next beat
616
+ rediscovers a restarted server). That is what tells the editor an agent is attached, so it keeps
617
+ the game, the viewport, file polling and reconnects running in a hidden or unfocused tab instead
618
+ of pausing them for battery (`AgentKeepaliveService`; the user can switch this off in Settings →
619
+ General). `PIX3_PRESENCE_HEARTBEAT_MS` overrides the cadence (tests).
620
+
621
+ **Tools (v1, exactly):** `project_status`, `play_start`, `play_stop`, `play_restart`,
622
+ `play_status`, `game_run`, `game_input`, `game_observe`, `read_errors`, `read_logs`,
623
+ `viewport_screenshot` (the PNG comes back as an MCP image block), `generate_asset`,
624
+ `generate_sfx`, `get_selection`. No scene-mutating tool: the agent edits files. Schemas are the
625
+ window's (`GET /ws/agent/tools`), with a static fallback so `tools/list` works before a window
626
+ connects (a `notifications/tools/list_changed` follows once a window's schemas are known).
627
+ `play_start` / `play_restart` / `game_run` also take `expect: { path: sha256 }` — the sha256 of the
628
+ raw bytes of every file the agent wrote.
629
+
630
+ **The sync barrier** (plan §5 D) runs before `play_start`, `play_restart` and `game_run`:
631
+
632
+ 1. **Agent's expectations.** `expect` is checked against the disk (`/ws/agent/expect`) before
633
+ anything syncs. Any mismatch → `disk_differs_from_agent` with, per file: merged by the editor
634
+ (`mergeLog: true` — re-read it), or overwritten — with `recovery: ".pix3/recovery/…"` only when
635
+ a copy with exactly those bytes exists, else "no copy exists — write the file again". Nothing
636
+ starts. Without `expect` the answer says `agentExpectations: "none"`.
637
+ 2. **Editor = disk.** The window's internal `sync_barrier` holds autosave (edits accumulate and are
638
+ saved after the run), stops play, runs `syncNow()` (waits for the stabilisation window and the
639
+ script build; re-reads `pix3project.yaml` when the disk holds another version) and returns
640
+ `{loaded: {path: sha256}, errors}` — every open scene/prefab (any path), every source the last
641
+ script build read (the entry scripts **and every module the bundle pulled in**, e.g. `src/**`;
642
+ the hash recorded when the build read it, so nothing is re-read) and `pix3project.yaml`. An open
643
+ scene the editor holds no disk version of is a `load_failed` error, never silently left out. The
644
+ MCP process gives this call 100 s. These hashes (plus `expect`) are compared with `/ws/agent/hash` at that moment;
645
+ a mismatch retries the editor's sync for up to ~5 s. Then: loader/compiler errors →
646
+ `load_failed` (`{file, line, message, kind}`); a file that stays unreadable →
647
+ `pending_external`; hashes that never agree → `sync_timeout` with the differing paths — except
648
+ when every difference is an `expect` path whose disk version moved on *after* step 1 while the
649
+ editor already holds what the disk holds (`loadedHash === diskHash !== agentHash`): that is the
650
+ agent's expectation being stale (the editor merged the write with a human edit, a human saved,
651
+ another agent wrote), retrying cannot bring the old bytes back, and it answers
652
+ `expectation_stale` at once. The
653
+ window then starts the game (`game_run` starts play itself; `play_restart` of a stopped game is
654
+ a start) and answers only once the game is actually running: up to 30 s, as soon as it runs,
655
+ failing fast (`load_failed` with the play-mode error) when play mode stops instead; `startupMs`
656
+ says how long the start took (always present in the answer: `null` means no start happened —
657
+ `game_run` found the game already running — never "0 ms"). The MCP process gives the tool call 120 s (the server's cap). The
658
+ hold is released (`sync_release`) after the run.
659
+ 3. **After the run** (for `game_run` when it finished; for `play_start` / `play_restart` right
660
+ after the start was acknowledged) the verified files are hashed again and
661
+ `/ws/agent/changes?since=<seq of the verification>` is read: `changedDuringRun` and
662
+ `editorWroteDuringRun`.
663
+
664
+ The answer of a barrier tool:
665
+
666
+ ```json
667
+ {
668
+ "revision": { "files": 91, "digest": "<sha256 of the whole map>",
669
+ "changed": { "<path>": "<sha256 of the verified version>" }, "removed?": ["<path>"] },
670
+ "startupMs": 8123,
671
+ "matchesAgent": true,
672
+ "matchesDisk": true,
673
+ "changedDuringRun": [],
674
+ "editorWroteDuringRun": [],
675
+ "editorChangedSinceAgentWrite": [],
676
+ "result": { "…": "the editor tool's own result" }
677
+ }
678
+ ```
679
+
680
+ `revision` is **compact by default**: the verified map (every open scene, every source the build
681
+ read, the manifest — ~90 entries on a real game) used to open every answer in full. Now `files` is
682
+ its size, `digest` a sha256 over its sorted `path\0hash` lines (equal digests = equal maps), and
683
+ `changed` holds only the entries that are new or different since the **previous answer of this
684
+ `pix3 mcp` process** (the whole map on the first answer; `removed` names paths that dropped out).
685
+ Pass `fullRevision: true` to any barrier or observing tool for the plain `{ "<path>": "<sha256>" }`
686
+ map, or read `editor.playRevision` from `project_status` while the game plays. `startupMs` is
687
+ always present: the milliseconds from the start request until the game was running, or `null`
688
+ when no start happened (the game was already running, so there was nothing to measure). `matchesAgent` is `null` (with `agentExpectations: "none"`) without `expect`; `matchesDisk` is
689
+ whether every verified hash still matched the disk at the final check; `changedDuringRun` is what
690
+ the barrier did not verify: every file changed meanwhile by someone other than the editor, plus
691
+ every verified file whose hash moved between the two checks with no editor write on record
692
+ (`changeLogIncomplete: true` when the server's ring did not reach back); `editorWroteDuringRun` is
693
+ every file whose only changes since the verification are the editor's own writes through the file
694
+ API (e.g. a run report under `design/tests/reports/`) — known to the server, so not a
695
+ detection. Both list files only, never directories; `editorChangedSinceAgentWrite` is
696
+ the `expect` paths the merge log says the editor wrote after the check. **A green answer without
697
+ marks means: at start disk = agent's expectations = verified version; at the final check the
698
+ verified hashes match disk; detected changes are listed.** It does not mean the disk did not change
699
+ during the run: `v1 → v2 → v1` between the two checks is invisible, and the game reads resources
700
+ lazily.
701
+
702
+ **Observing tools** (`play_status`, `game_input`, `game_observe`, `viewport_screenshot`,
703
+ `read_errors`, `read_logs`) neither stop nor sync: `{ revision, stale, result }`, where `revision` is
704
+ what the running game was verified against (compact, as above; `null` when play was not started
705
+ through the barrier) and `stale: true` when the editor saw an external change during play or a
706
+ `revision` path's disk hash moved since. `read_errors` takes `since` (epoch ms) like `read_logs`;
707
+ every entry carries its `at`. Other tools (`project_status`, `play_stop`, `get_selection`, `generate_*`) answer
708
+ with the editor's result as is. `project_status` without a window answers
709
+ `{ connected: false, server, editor: null, message }` (not an error).
710
+
711
+ **Error codes** (every error is `isError: true` with `{ "error": "<code>", "message", … }`):
712
+
713
+ | Code | When |
714
+ | --- | --- |
715
+ | `disk_differs_from_agent` | Step 1: the disk does not hold the `expect` versions (`differing[]` with `recovery`, `mergeLog`, `hint`). |
716
+ | `expectation_stale` | Step 2, at once: the disk moved past an `expect` version after step 1 and the editor already matches the disk (`differing[]` with `diskHash` / `agentHash` / `loadedHash`, plus `mergeLog` / `recovery` / `hint` as in step 1). Somebody wrote a newer version — re-read it, re-run `pix3 check --json`, pass the new hashes. Not editor lag. |
717
+ | `sync_timeout` | Step 2: the editor's loaded hashes did not match the disk within ~5 s (`differing[]` with `loadedHash` / `agentHash` / `diskHash`) — the editor is still catching up with a write. |
718
+ | `load_failed` | Step 2: the loader or the script compiler failed on the current files, or an open scene has no verified version (`errors[]`). At the start: the game was not running within 30 s, or play mode stopped (`result` carries it, with `startupMs`). |
719
+ | `pending_external` | Step 2: a file stays unreadable (partial / invalid write); the editor keeps its last good version. |
720
+ | `no_editor` | No window holds the lease, it did not answer (`reason: "no_reply"`), or it lost the lease mid-call (`reason: "lease_lost"`). |
721
+ | `permission_denied` | `generate_*`: the human denied, or did not answer within 60 s. |
722
+ | `no_workspace_server` | No `pix3 serve` runs for the project root. |
723
+
724
+ Anything else the editor refuses (`unknown_tool`, `agent_disabled` when the human switched the
725
+ channel off) is passed through as the editor wrote it.
726
+
727
+ **Generation permission.** The first `generate_asset` / `generate_sfx` of a connection (server
728
+ session + lease + `pix3 mcp` process) opens a prompt in the editor naming the process and what it
729
+ calls itself (unverified); the call waits up to 60 s. Allowed → 20 generations, then it asks again.
730
+ In memory only: a reconnect, a new `pix3 serve` run, a lease takeover or a new `pix3 mcp` process
731
+ resets it; the status-bar Agent pill has a revoke button and switches the channel off.
732
+
733
+ ## MCP configuration and `pix3 setup`
734
+
735
+ `pix3 new` and `pix3 kit` write the project's `.mcp.json` (Claude Code's project format), **pinned
736
+ to the CLI version that wrote it** — never a bare `@pix3/cli`:
737
+
738
+ ```json
739
+ { "mcpServers": { "pix3": { "command": "npx", "args": ["-y", "@pix3/cli@<X.Y.Z>", "mcp", "--workspace"] } } }
740
+ ```
741
+
742
+ `pix3 setup [claude|codex]` prints (never runs) the registration for a project without one:
743
+ `claude mcp add pix3 -- npx -y @pix3/cli@<X.Y.Z> mcp --workspace` (run in the project folder), and
744
+ for Codex a `~/.codex/config.toml` block — `[mcp_servers.pix3]` with `command`, `args` (plus
745
+ `--project <root>`, since that config is global) and `tool_timeout_sec = 180`. The Codex keys are
746
+ the ones `tools/pix3-agent-bridge` passes to `codex exec -c mcp_servers.pix3.*`; the file form is
747
+ best-effort, check `codex mcp --help` of your Codex version.
748
+
749
+ **Dev mode.** From a checkout of the pix3 repo (the CLI runs from `packages/pix3-cli/src/`), or
750
+ with `PIX3_CLI_DEV=1`, both write `node <repo>/packages/pix3-cli/src/index.ts mcp --workspace`
751
+ instead, so the channel can be tried before that version is on npm. `PIX3_CLI_DEV=0` forces the
752
+ pinned form.
753
+
754
+ ## Package layout and publishing
755
+
756
+ The published package has **no runtime `dependencies`**, so a cold `npx -y @pix3/cli@X.Y.Z …`
757
+ fetches one tarball and installs nothing else (measured in
758
+ `.plans/measurements/external-agent-phase0-cold-start.md`). `prepack` builds:
759
+
760
+ ```text
761
+ dist/index.js the bin: src/index.ts + yaml + @modelcontextprotocol/sdk (zod, ajv, …) + ws,
762
+ one minified ESM file (scripts/build-bin.mjs); lazy commands stay lazy
763
+ dist/validate/prebuilt/ validate + @pix3/runtime + three (scripts/build-validate.mjs)
764
+ dist/smoke/prebuilt/ smoke worker + tree defaults + @pix3/runtime + three (scripts/build-smoke.mjs)
765
+ dist/runtime-types.json runtime-types/ packed into one file (scripts/build-runtime-types.mjs):
766
+ ~1 100 small .d.ts cost npm over a second to unpack on every npx run
767
+ kit/ the agent kit (scripts/build-kit.mjs)
768
+ templates/ copy of src/templates/projects (scripts/copy-templates.mjs; removed at postpack)
769
+ ```
770
+
771
+ Left out of the bin on purpose: Node built-ins; `esbuild` (an `optionalDependency` — level 2 of
772
+ `validate`, `smoke` and `check` resolve it from the bin with `import.meta.resolve`, and degrade
773
+ with a note when it is absent) and `typescript` (fetched on demand by `check`); `ws`'s optional
774
+ native `bufferutil` / `utf-8-validate`; and the checkout-only modules (`validate/bundle.ts`,
775
+ `smoke/bundle.ts`, the kit generator), which become a throwing stub. Every file the CLI reads from
776
+ its own package is addressed from the package root (`src/package-root.ts`), never relative to the
777
+ current module — in the bundle every module's `import.meta.url` is the bin's. The sources (`node
778
+ src/index.ts`, the specs) never use a prebuilt bundle, even when an old `dist/` exists.
779
+ `src/bin-bundle.spec.ts` builds the bin into a temp package layout and checks `--version` against
780
+ `package.json` (and that against the lockstep root version), the pinned `@pix3/cli@X.Y.Z` launch,
781
+ and that nothing but built-ins and the optional externals is imported at run time.
782
+
783
+ Publishing is `.github/workflows/publish-packages.yml` (npm Trusted Publishing / OIDC, no token):
784
+ a `runtime-vX.Y.Z` tag publishes `@pix3/runtime` and `@pix3/cli` together, `cli-vX.Y.Z` the CLI
785
+ alone, or run the workflow manually. The job runs `npm ci` at the repo root (the build needs the
786
+ runtime sources, the root TypeScript and esbuild), checks the lockstep version against the root
787
+ and the tag, type-checks, runs the CLI specs and `npm publish`es. Try the tarball locally with
788
+ `npm pack -w packages/pix3-cli` at the repo root, then `npx -y --package ./pix3-cli-X.Y.Z.tgz pix3 …`.