@pix3/cli 1.6.0

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 (155) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +754 -0
  3. package/dist/index.js +494 -0
  4. package/dist/runtime-types.json +1 -0
  5. package/dist/smoke/prebuilt/build-7MIXIMSZ.mjs +948 -0
  6. package/dist/smoke/prebuilt/chunk-J2SJVMDO.mjs +2 -0
  7. package/dist/smoke/prebuilt/chunk-OJXQXIIQ.mjs +239 -0
  8. package/dist/smoke/prebuilt/chunk-P2CBRXPJ.mjs +4050 -0
  9. package/dist/smoke/prebuilt/runtime.mjs +2 -0
  10. package/dist/smoke/prebuilt/smoke-worker.mjs +3 -0
  11. package/dist/smoke/prebuilt/three.mjs +2 -0
  12. package/dist/smoke/prebuilt/tree-defaults.mjs +2 -0
  13. package/dist/validate/prebuilt/build-AUGKXER3.js +948 -0
  14. package/dist/validate/prebuilt/bundle-main.js +24 -0
  15. package/dist/validate/prebuilt/chunk-6ACT6FNV.js +4050 -0
  16. package/dist/validate/prebuilt/chunk-BS2OZM7U.js +239 -0
  17. package/dist/validate/prebuilt/runtime.js +2 -0
  18. package/dist/validate/prebuilt/three.js +2 -0
  19. package/kit/files/.claude/skills/pix3-nodes/SKILL.md +210 -0
  20. package/kit/files/.claude/skills/pix3-nodes/reference.md +1331 -0
  21. package/kit/files/.claude/skills/pix3-scene-format/SKILL.md +235 -0
  22. package/kit/files/.claude/skills/pix3-scene-format/reference.md +176 -0
  23. package/kit/files/.claude/skills/pix3-scripts/SKILL.md +281 -0
  24. package/kit/files/.claude/skills/pix3-scripts/reference.md +638 -0
  25. package/kit/files/.claude/skills/pix3-verify/SKILL.md +386 -0
  26. package/kit/files/.claude/skills/pix3-verify/reference.md +21 -0
  27. package/kit/files/AGENTS.md +203 -0
  28. package/kit/files/CLAUDE.md +1 -0
  29. package/kit/kit.json +24 -0
  30. package/package.json +54 -0
  31. package/templates/empty-2d/files/README.md +20 -0
  32. package/templates/empty-2d/files/scenes/main.pix3scene +68 -0
  33. package/templates/empty-2d/files/sprites/pix3-logo.png +0 -0
  34. package/templates/empty-2d/template.yaml +7 -0
  35. package/templates/empty-3d/files/README.md +20 -0
  36. package/templates/empty-3d/files/scenes/main.pix3scene +108 -0
  37. package/templates/empty-3d/files/sprites/pix3-logo.png +0 -0
  38. package/templates/empty-3d/template.yaml +7 -0
  39. package/templates/idea-blank/files/README.md +22 -0
  40. package/templates/idea-blank/files/scenes/main.pix3scene +37 -0
  41. package/templates/idea-blank/template.yaml +10 -0
  42. package/templates/minigame-2d/files/README.md +44 -0
  43. package/templates/minigame-2d/files/design/tests/reachability.json +1 -0
  44. package/templates/minigame-2d/files/design/tests/routines/mute-music.json +44 -0
  45. package/templates/minigame-2d/files/scenes/main.pix3scene +84 -0
  46. package/templates/minigame-2d/files/scenes/menu.pix3scene +118 -0
  47. package/templates/minigame-2d/files/scenes/ui/settings-window.pix3scene +138 -0
  48. package/templates/minigame-2d/files/scripts/GameFlow.ts +133 -0
  49. package/templates/minigame-2d/files/scripts/MenuFlow.ts +217 -0
  50. package/templates/minigame-2d/files/scripts/SettingsWindow.ts +198 -0
  51. package/templates/minigame-2d/template.yaml +10 -0
  52. package/templates/playable-2d/files/README.md +30 -0
  53. package/templates/playable-2d/files/design/recipe.md +83 -0
  54. package/templates/playable-2d/files/design/tests/reachability.json +4 -0
  55. package/templates/playable-2d/files/design/tests/routines/intro-to-cta.json +32 -0
  56. package/templates/playable-2d/files/scenes/main.pix3scene +111 -0
  57. package/templates/playable-2d/files/scenes/ui/end-screen.pix3scene +91 -0
  58. package/templates/playable-2d/files/scenes/ui/intro.pix3scene +65 -0
  59. package/templates/playable-2d/files/scripts/CtaButton.ts +55 -0
  60. package/templates/playable-2d/files/scripts/GameFlow.ts +256 -0
  61. package/templates/playable-2d/files/sprites/pix3-logo.png +0 -0
  62. package/templates/playable-2d/template.yaml +9 -0
  63. package/templates/playable-3d/files/README.md +32 -0
  64. package/templates/playable-3d/files/design/recipe.md +127 -0
  65. package/templates/playable-3d/files/design/tests/reachability.json +4 -0
  66. package/templates/playable-3d/files/design/tests/routines/intro-to-cta.json +32 -0
  67. package/templates/playable-3d/files/scenes/main.pix3scene +230 -0
  68. package/templates/playable-3d/files/scenes/ui/end-screen.pix3scene +91 -0
  69. package/templates/playable-3d/files/scenes/ui/intro.pix3scene +65 -0
  70. package/templates/playable-3d/files/scripts/CtaButton.ts +55 -0
  71. package/templates/playable-3d/files/scripts/GameFlow.ts +309 -0
  72. package/templates/playable-3d/files/scripts/GameRules.ts +290 -0
  73. package/templates/playable-3d/files/scripts/ScoreHud.ts +113 -0
  74. package/templates/playable-3d/template.yaml +9 -0
  75. package/templates/recipe-arena-2d/files/README.md +48 -0
  76. package/templates/recipe-arena-2d/files/design/recipe.md +127 -0
  77. package/templates/recipe-arena-2d/files/design/tests/reachability.json +4 -0
  78. package/templates/recipe-arena-2d/files/design/tests/routines/terminal-retry.json +23 -0
  79. package/templates/recipe-arena-2d/files/scenes/main.pix3scene +292 -0
  80. package/templates/recipe-arena-2d/files/scenes/menu.pix3scene +100 -0
  81. package/templates/recipe-arena-2d/files/scenes/prefabs/hazard.pix3scene +42 -0
  82. package/templates/recipe-arena-2d/files/scenes/prefabs/pickup.pix3scene +45 -0
  83. package/templates/recipe-arena-2d/files/scenes/ui/result.pix3scene +85 -0
  84. package/templates/recipe-arena-2d/files/scripts/GameRules.ts +368 -0
  85. package/templates/recipe-arena-2d/files/scripts/MenuFlow.ts +106 -0
  86. package/templates/recipe-arena-2d/files/scripts/PlayerController.ts +151 -0
  87. package/templates/recipe-arena-2d/files/scripts/ScoreHud.ts +94 -0
  88. package/templates/recipe-arena-2d/files/scripts/Spawner.ts +214 -0
  89. package/templates/recipe-arena-2d/files/scripts/TouchRules.ts +174 -0
  90. package/templates/recipe-arena-2d/files/sprites/ph-avatar.png +0 -0
  91. package/templates/recipe-arena-2d/files/sprites/ph-hazard.png +0 -0
  92. package/templates/recipe-arena-2d/files/sprites/ph-pickup.png +0 -0
  93. package/templates/recipe-arena-2d/template.yaml +10 -0
  94. package/templates/recipe-blank-2d/files/README.md +61 -0
  95. package/templates/recipe-blank-2d/files/design/recipe.md +148 -0
  96. package/templates/recipe-blank-2d/files/design/tests/reachability.json +4 -0
  97. package/templates/recipe-blank-2d/files/design/tests/routines/restart.json +27 -0
  98. package/templates/recipe-blank-2d/files/design/tests/routines/terminal-retry.json +23 -0
  99. package/templates/recipe-blank-2d/files/scenes/main.pix3scene +224 -0
  100. package/templates/recipe-blank-2d/files/scenes/ui/result.pix3scene +85 -0
  101. package/templates/recipe-blank-2d/files/scripts/GameRules.ts +350 -0
  102. package/templates/recipe-blank-2d/files/scripts/ScoreHud.ts +110 -0
  103. package/templates/recipe-blank-2d/files/sprites/ph-bg.png +0 -0
  104. package/templates/recipe-blank-2d/files/sprites/ph-capsule.png +0 -0
  105. package/templates/recipe-blank-2d/files/sprites/ph-circle.png +0 -0
  106. package/templates/recipe-blank-2d/files/sprites/ph-orb.png +0 -0
  107. package/templates/recipe-blank-2d/files/sprites/ph-ring.png +0 -0
  108. package/templates/recipe-blank-2d/files/sprites/ph-star.png +0 -0
  109. package/templates/recipe-blank-2d/template.yaml +10 -0
  110. package/templates/recipe-bouncer-2d/files/README.md +81 -0
  111. package/templates/recipe-bouncer-2d/files/design/recipe.md +146 -0
  112. package/templates/recipe-bouncer-2d/files/design/tests/reachability.json +4 -0
  113. package/templates/recipe-bouncer-2d/files/design/tests/routines/terminal-retry.json +23 -0
  114. package/templates/recipe-bouncer-2d/files/scenes/main.pix3scene +615 -0
  115. package/templates/recipe-bouncer-2d/files/scenes/menu.pix3scene +191 -0
  116. package/templates/recipe-bouncer-2d/files/scenes/ui/result.pix3scene +87 -0
  117. package/templates/recipe-bouncer-2d/files/scripts/BallBody.ts +298 -0
  118. package/templates/recipe-bouncer-2d/files/scripts/GameRules.ts +377 -0
  119. package/templates/recipe-bouncer-2d/files/scripts/MenuFlow.ts +109 -0
  120. package/templates/recipe-bouncer-2d/files/scripts/PaddleController.ts +112 -0
  121. package/templates/recipe-bouncer-2d/files/scripts/ScoreHud.ts +94 -0
  122. package/templates/recipe-bouncer-2d/files/scripts/TouchRules.ts +263 -0
  123. package/templates/recipe-bouncer-2d/files/scripts/ball-collision.ts +307 -0
  124. package/templates/recipe-bouncer-2d/files/sprites/ph-ball.png +0 -0
  125. package/templates/recipe-bouncer-2d/files/sprites/ph-bg.png +0 -0
  126. package/templates/recipe-bouncer-2d/files/sprites/ph-bumper.png +0 -0
  127. package/templates/recipe-bouncer-2d/files/sprites/ph-paddle.png +0 -0
  128. package/templates/recipe-bouncer-2d/template.yaml +10 -0
  129. package/templates/recipe-grid-3d/files/README.md +22 -0
  130. package/templates/recipe-grid-3d/files/design/recipe.md +110 -0
  131. package/templates/recipe-grid-3d/files/scenes/main.pix3scene +169 -0
  132. package/templates/recipe-grid-3d/files/scenes/menu.pix3scene +100 -0
  133. package/templates/recipe-grid-3d/files/scenes/ui/result.pix3scene +105 -0
  134. package/templates/recipe-grid-3d/files/scripts/GridBoard.ts +319 -0
  135. package/templates/recipe-grid-3d/files/scripts/GridRules.ts +278 -0
  136. package/templates/recipe-grid-3d/files/scripts/MenuFlow.ts +106 -0
  137. package/templates/recipe-grid-3d/files/scripts/ScoreHud.ts +94 -0
  138. package/templates/recipe-grid-3d/template.yaml +10 -0
  139. package/templates/recipe-tapper-2d/files/README.md +39 -0
  140. package/templates/recipe-tapper-2d/files/design/recipe.md +132 -0
  141. package/templates/recipe-tapper-2d/files/design/tests/reachability.json +4 -0
  142. package/templates/recipe-tapper-2d/files/design/tests/routines/terminal-retry.json +23 -0
  143. package/templates/recipe-tapper-2d/files/scenes/main.pix3scene +269 -0
  144. package/templates/recipe-tapper-2d/files/scenes/menu.pix3scene +100 -0
  145. package/templates/recipe-tapper-2d/files/scenes/prefabs/hazard.pix3scene +42 -0
  146. package/templates/recipe-tapper-2d/files/scenes/prefabs/target.pix3scene +45 -0
  147. package/templates/recipe-tapper-2d/files/scenes/ui/result.pix3scene +85 -0
  148. package/templates/recipe-tapper-2d/files/scripts/GameRules.ts +368 -0
  149. package/templates/recipe-tapper-2d/files/scripts/MenuFlow.ts +106 -0
  150. package/templates/recipe-tapper-2d/files/scripts/ScoreHud.ts +94 -0
  151. package/templates/recipe-tapper-2d/files/scripts/Spawner.ts +214 -0
  152. package/templates/recipe-tapper-2d/files/scripts/TouchRules.ts +192 -0
  153. package/templates/recipe-tapper-2d/files/sprites/ph-hazard.png +0 -0
  154. package/templates/recipe-tapper-2d/files/sprites/ph-target.png +0 -0
  155. package/templates/recipe-tapper-2d/template.yaml +10 -0
package/README.md ADDED
@@ -0,0 +1,754 @@
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 …`.