@pix3/cli 1.6.4 → 2.0.0-alpha.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 (168) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +722 -788
  3. package/dist/index.js +557 -421
  4. package/dist/runtime-types.json +1 -1
  5. package/dist/smoke/prebuilt/{build-7MIXIMSZ.mjs → build-7REMSA2S.mjs} +52 -52
  6. package/dist/smoke/prebuilt/chunk-4DGG2BKR.mjs +2 -0
  7. package/dist/smoke/prebuilt/chunk-YLBMUJ2H.mjs +238 -0
  8. package/dist/smoke/prebuilt/runtime.mjs +1 -1
  9. package/dist/smoke/prebuilt/smoke-worker.mjs +211 -2
  10. package/dist/smoke/prebuilt/tree-defaults.mjs +1 -1
  11. package/dist/validate/prebuilt/{build-AUGKXER3.js → build-ADF5EEE2.js} +52 -52
  12. package/dist/validate/prebuilt/bundle-main.js +223 -18
  13. package/dist/validate/prebuilt/chunk-VIHIF4G2.js +238 -0
  14. package/dist/validate/prebuilt/runtime.js +1 -1
  15. package/kit/files/.claude/skills/pix3-editor/SKILL.md +200 -0
  16. package/kit/files/.claude/skills/pix3-nodes/SKILL.md +13 -15
  17. package/kit/files/.claude/skills/pix3-nodes/reference.md +77 -71
  18. package/kit/files/.claude/skills/pix3-scene-format/SKILL.md +38 -41
  19. package/kit/files/.claude/skills/pix3-scene-format/pix3anim.md +185 -0
  20. package/kit/files/.claude/skills/pix3-scene-format/project-files.md +244 -0
  21. package/kit/files/.claude/skills/pix3-scene-format/reference.md +28 -16
  22. package/kit/files/.claude/skills/pix3-scripts/SKILL.md +34 -24
  23. package/kit/files/.claude/skills/pix3-scripts/reference.md +68 -141
  24. package/kit/files/.claude/skills/pix3-verify/SKILL.md +65 -326
  25. package/kit/files/.claude/skills/pix3-verify/reference.md +2 -14
  26. package/kit/files/AGENTS.md +76 -84
  27. package/kit/files/design/tests/bots/pix3-test-bot.d.ts +89 -0
  28. package/kit/kit.json +8 -5
  29. package/package.json +62 -54
  30. package/templates/2d/files/scenes/main.pix3scene +31 -0
  31. package/templates/2d/template.yaml +11 -0
  32. package/templates/3d/files/scenes/main.pix3scene +50 -0
  33. package/templates/3d/template.yaml +11 -0
  34. package/templates/base/files/README.md +39 -0
  35. package/templates/base/files/gitignore +11 -0
  36. package/templates/base/files/index.html +23 -0
  37. package/templates/base/files/package.json +28 -0
  38. package/templates/base/files/src/main.ts +3 -0
  39. package/templates/base/files/tsconfig.json +17 -0
  40. package/templates/base/files/vite.config.ts +10 -0
  41. package/dist/smoke/prebuilt/chunk-CBFYKDS7.mjs +0 -2
  42. package/dist/smoke/prebuilt/chunk-VJHJ5BCO.mjs +0 -239
  43. package/dist/validate/prebuilt/chunk-CFQFEI52.js +0 -239
  44. package/templates/empty-2d/files/README.md +0 -20
  45. package/templates/empty-2d/files/scenes/main.pix3scene +0 -68
  46. package/templates/empty-2d/files/sprites/pix3-logo.png +0 -0
  47. package/templates/empty-2d/template.yaml +0 -7
  48. package/templates/empty-3d/files/README.md +0 -20
  49. package/templates/empty-3d/files/scenes/main.pix3scene +0 -108
  50. package/templates/empty-3d/files/sprites/pix3-logo.png +0 -0
  51. package/templates/empty-3d/template.yaml +0 -7
  52. package/templates/idea-blank/files/README.md +0 -22
  53. package/templates/idea-blank/files/scenes/main.pix3scene +0 -37
  54. package/templates/idea-blank/template.yaml +0 -10
  55. package/templates/minigame-2d/files/README.md +0 -44
  56. package/templates/minigame-2d/files/design/tests/reachability.json +0 -1
  57. package/templates/minigame-2d/files/design/tests/routines/mute-music.json +0 -44
  58. package/templates/minigame-2d/files/scenes/main.pix3scene +0 -84
  59. package/templates/minigame-2d/files/scenes/menu.pix3scene +0 -118
  60. package/templates/minigame-2d/files/scenes/ui/settings-window.pix3scene +0 -138
  61. package/templates/minigame-2d/files/scripts/GameFlow.ts +0 -133
  62. package/templates/minigame-2d/files/scripts/MenuFlow.ts +0 -217
  63. package/templates/minigame-2d/files/scripts/SettingsWindow.ts +0 -198
  64. package/templates/minigame-2d/template.yaml +0 -10
  65. package/templates/playable-2d/files/README.md +0 -30
  66. package/templates/playable-2d/files/design/recipe.md +0 -83
  67. package/templates/playable-2d/files/design/tests/reachability.json +0 -4
  68. package/templates/playable-2d/files/design/tests/routines/intro-to-cta.json +0 -32
  69. package/templates/playable-2d/files/scenes/main.pix3scene +0 -111
  70. package/templates/playable-2d/files/scenes/ui/end-screen.pix3scene +0 -91
  71. package/templates/playable-2d/files/scenes/ui/intro.pix3scene +0 -65
  72. package/templates/playable-2d/files/scripts/CtaButton.ts +0 -55
  73. package/templates/playable-2d/files/scripts/GameFlow.ts +0 -256
  74. package/templates/playable-2d/files/sprites/pix3-logo.png +0 -0
  75. package/templates/playable-2d/template.yaml +0 -9
  76. package/templates/playable-3d/files/README.md +0 -32
  77. package/templates/playable-3d/files/design/recipe.md +0 -127
  78. package/templates/playable-3d/files/design/tests/reachability.json +0 -4
  79. package/templates/playable-3d/files/design/tests/routines/intro-to-cta.json +0 -32
  80. package/templates/playable-3d/files/scenes/main.pix3scene +0 -230
  81. package/templates/playable-3d/files/scenes/ui/end-screen.pix3scene +0 -91
  82. package/templates/playable-3d/files/scenes/ui/intro.pix3scene +0 -65
  83. package/templates/playable-3d/files/scripts/CtaButton.ts +0 -55
  84. package/templates/playable-3d/files/scripts/GameFlow.ts +0 -309
  85. package/templates/playable-3d/files/scripts/GameRules.ts +0 -290
  86. package/templates/playable-3d/files/scripts/ScoreHud.ts +0 -113
  87. package/templates/playable-3d/template.yaml +0 -9
  88. package/templates/recipe-arena-2d/files/README.md +0 -48
  89. package/templates/recipe-arena-2d/files/design/recipe.md +0 -127
  90. package/templates/recipe-arena-2d/files/design/tests/reachability.json +0 -4
  91. package/templates/recipe-arena-2d/files/design/tests/routines/terminal-retry.json +0 -23
  92. package/templates/recipe-arena-2d/files/scenes/main.pix3scene +0 -292
  93. package/templates/recipe-arena-2d/files/scenes/menu.pix3scene +0 -100
  94. package/templates/recipe-arena-2d/files/scenes/prefabs/hazard.pix3scene +0 -42
  95. package/templates/recipe-arena-2d/files/scenes/prefabs/pickup.pix3scene +0 -45
  96. package/templates/recipe-arena-2d/files/scenes/ui/result.pix3scene +0 -85
  97. package/templates/recipe-arena-2d/files/scripts/GameRules.ts +0 -368
  98. package/templates/recipe-arena-2d/files/scripts/MenuFlow.ts +0 -106
  99. package/templates/recipe-arena-2d/files/scripts/PlayerController.ts +0 -151
  100. package/templates/recipe-arena-2d/files/scripts/ScoreHud.ts +0 -94
  101. package/templates/recipe-arena-2d/files/scripts/Spawner.ts +0 -214
  102. package/templates/recipe-arena-2d/files/scripts/TouchRules.ts +0 -174
  103. package/templates/recipe-arena-2d/files/sprites/ph-avatar.png +0 -0
  104. package/templates/recipe-arena-2d/files/sprites/ph-hazard.png +0 -0
  105. package/templates/recipe-arena-2d/files/sprites/ph-pickup.png +0 -0
  106. package/templates/recipe-arena-2d/template.yaml +0 -10
  107. package/templates/recipe-blank-2d/files/README.md +0 -61
  108. package/templates/recipe-blank-2d/files/design/recipe.md +0 -148
  109. package/templates/recipe-blank-2d/files/design/tests/reachability.json +0 -4
  110. package/templates/recipe-blank-2d/files/design/tests/routines/restart.json +0 -27
  111. package/templates/recipe-blank-2d/files/design/tests/routines/terminal-retry.json +0 -23
  112. package/templates/recipe-blank-2d/files/scenes/main.pix3scene +0 -224
  113. package/templates/recipe-blank-2d/files/scenes/ui/result.pix3scene +0 -85
  114. package/templates/recipe-blank-2d/files/scripts/GameRules.ts +0 -350
  115. package/templates/recipe-blank-2d/files/scripts/ScoreHud.ts +0 -110
  116. package/templates/recipe-blank-2d/files/sprites/ph-bg.png +0 -0
  117. package/templates/recipe-blank-2d/files/sprites/ph-capsule.png +0 -0
  118. package/templates/recipe-blank-2d/files/sprites/ph-circle.png +0 -0
  119. package/templates/recipe-blank-2d/files/sprites/ph-orb.png +0 -0
  120. package/templates/recipe-blank-2d/files/sprites/ph-ring.png +0 -0
  121. package/templates/recipe-blank-2d/files/sprites/ph-star.png +0 -0
  122. package/templates/recipe-blank-2d/template.yaml +0 -10
  123. package/templates/recipe-bouncer-2d/files/README.md +0 -81
  124. package/templates/recipe-bouncer-2d/files/design/recipe.md +0 -146
  125. package/templates/recipe-bouncer-2d/files/design/tests/reachability.json +0 -4
  126. package/templates/recipe-bouncer-2d/files/design/tests/routines/terminal-retry.json +0 -23
  127. package/templates/recipe-bouncer-2d/files/scenes/main.pix3scene +0 -615
  128. package/templates/recipe-bouncer-2d/files/scenes/menu.pix3scene +0 -191
  129. package/templates/recipe-bouncer-2d/files/scenes/ui/result.pix3scene +0 -87
  130. package/templates/recipe-bouncer-2d/files/scripts/BallBody.ts +0 -298
  131. package/templates/recipe-bouncer-2d/files/scripts/GameRules.ts +0 -377
  132. package/templates/recipe-bouncer-2d/files/scripts/MenuFlow.ts +0 -109
  133. package/templates/recipe-bouncer-2d/files/scripts/PaddleController.ts +0 -112
  134. package/templates/recipe-bouncer-2d/files/scripts/ScoreHud.ts +0 -94
  135. package/templates/recipe-bouncer-2d/files/scripts/TouchRules.ts +0 -263
  136. package/templates/recipe-bouncer-2d/files/scripts/ball-collision.ts +0 -307
  137. package/templates/recipe-bouncer-2d/files/sprites/ph-ball.png +0 -0
  138. package/templates/recipe-bouncer-2d/files/sprites/ph-bg.png +0 -0
  139. package/templates/recipe-bouncer-2d/files/sprites/ph-bumper.png +0 -0
  140. package/templates/recipe-bouncer-2d/files/sprites/ph-paddle.png +0 -0
  141. package/templates/recipe-bouncer-2d/template.yaml +0 -10
  142. package/templates/recipe-grid-3d/files/README.md +0 -22
  143. package/templates/recipe-grid-3d/files/design/recipe.md +0 -110
  144. package/templates/recipe-grid-3d/files/scenes/main.pix3scene +0 -169
  145. package/templates/recipe-grid-3d/files/scenes/menu.pix3scene +0 -100
  146. package/templates/recipe-grid-3d/files/scenes/ui/result.pix3scene +0 -105
  147. package/templates/recipe-grid-3d/files/scripts/GridBoard.ts +0 -319
  148. package/templates/recipe-grid-3d/files/scripts/GridRules.ts +0 -278
  149. package/templates/recipe-grid-3d/files/scripts/MenuFlow.ts +0 -106
  150. package/templates/recipe-grid-3d/files/scripts/ScoreHud.ts +0 -94
  151. package/templates/recipe-grid-3d/template.yaml +0 -10
  152. package/templates/recipe-tapper-2d/files/README.md +0 -39
  153. package/templates/recipe-tapper-2d/files/design/recipe.md +0 -132
  154. package/templates/recipe-tapper-2d/files/design/tests/reachability.json +0 -4
  155. package/templates/recipe-tapper-2d/files/design/tests/routines/terminal-retry.json +0 -23
  156. package/templates/recipe-tapper-2d/files/scenes/main.pix3scene +0 -269
  157. package/templates/recipe-tapper-2d/files/scenes/menu.pix3scene +0 -100
  158. package/templates/recipe-tapper-2d/files/scenes/prefabs/hazard.pix3scene +0 -42
  159. package/templates/recipe-tapper-2d/files/scenes/prefabs/target.pix3scene +0 -45
  160. package/templates/recipe-tapper-2d/files/scenes/ui/result.pix3scene +0 -85
  161. package/templates/recipe-tapper-2d/files/scripts/GameRules.ts +0 -368
  162. package/templates/recipe-tapper-2d/files/scripts/MenuFlow.ts +0 -106
  163. package/templates/recipe-tapper-2d/files/scripts/ScoreHud.ts +0 -94
  164. package/templates/recipe-tapper-2d/files/scripts/Spawner.ts +0 -214
  165. package/templates/recipe-tapper-2d/files/scripts/TouchRules.ts +0 -192
  166. package/templates/recipe-tapper-2d/files/sprites/ph-hazard.png +0 -0
  167. package/templates/recipe-tapper-2d/files/sprites/ph-target.png +0 -0
  168. package/templates/recipe-tapper-2d/template.yaml +0 -10
package/README.md CHANGED
@@ -1,788 +1,722 @@
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 …`.
1
+ # @pix3/cli
2
+
3
+ Command line for Pix3:
4
+
5
+ ```text
6
+ pix3 new [<2d|3d> [dir]] [--name <n>] create an empty 2D or 3D project (npm create pix3)
7
+ pix3 editor [--project <dir>] [--stop] [--stop-chrome] [--chrome-only] [--no-chrome] [--port <n>] [--cdp-port <n>] [--headless]
8
+ find/start the dev server, open the editor in Chrome (CDP behind the token proxy)
9
+ pix3 agent-setup [claude|codex] [--repair] [--cdp-port <n>] [--project <dir>]
10
+ write the project's chrome-devtools-mcp config
11
+ pix3 validate [paths…] [--json] strict scene check
12
+ pix3 check [--json] [--no-hydrate] [--offline] [--no-sync] [--project <dir>]
13
+ validate + tsc over the scripts + versions
14
+ pix3 smoke [scene] [--changed|--all] [--frames N] [--timeout S] [--json] [--project <dir>]
15
+ run the game headless in Node, report what threw
16
+ pix3 tree [scene] [--depth N] [--types A,B] [--props] [--json] [--project <dir>]
17
+ scene outline, one line per node; no scene = overview
18
+ pix3 kit [--update] [--migrate] [--project <dir>]
19
+ install / update the agent kit; --migrate: a 1.x kit → 2.x
20
+ pix3 character-compile <spec> [--dry-run] [--force] [--json] [--project <dir>]
21
+ a 2D character (.pix3anim + prefab) from frame PNGs
22
+ pix3 sfx <preset|"text"> [--out <f.wav>] [--seed <n>] [--json]
23
+ synthesize a sound effect to WAV, offline
24
+ pix3 gap "<what was missing>" [--kind …] [--list] record what the agent had to work around
25
+ pix3 help [command] this overview, or one command's usage
26
+ ```
27
+
28
+ `pix3 <command> --help` (or `-h`, or `pix3 help <command>`) prints that command's own usage —
29
+ every text lives in `src/usage.ts` (`USAGE`, `COMMAND_USAGE`), and `bin-bundle.spec` holds every
30
+ command the entry dispatches to an entry there, answered by the bundled bin. `validate --help`
31
+ adds its codes, `check --help` the ones it adds.
32
+
33
+ A usage error exits **2**: an unknown option, an option without its value, a bad argument, or no
34
+ `pix3project.yaml` above the folder — with the message (and, for a bad argument, the command's
35
+ usage) on stderr. That holds for `editor`, `agent-setup` and `gap` as for the checks; `kit`
36
+ refuses an unknown option rather than ignoring it (`pix3 kit --updat` is exit 2, not a plain
37
+ install).
38
+
39
+ `pix3 new` is what `npm create pix3` runs: the `base` layer of `packages/create-pix3/templates/`
40
+ (package.json, vite.config.ts with `pix3()`, index.html, src/main.ts, tsconfig.json, gitignore,
41
+ README.md) plus the `2d` or `3d` layer (one empty entry scene), `{{PROJECT_NAME}}` /
42
+ `{{PACKAGE_NAME}}` / `{{PIX3_VERSION}}` substituted, `pix3project.yaml` generated, then the agent
43
+ kit. Nothing in it plays a game. Record: `.plans/templates.md`.
44
+
45
+ Two things a first run trips on: **2.0.0-alpha packages are not published yet** — a new
46
+ project's `npm install` fails until they are; the starter's README says how to link a pix3-core
47
+ checkout meanwhile (`node_modules` → the checkout's, `node <checkout>/packages/cli/src/index.ts`
48
+ for `pix3`). And **the dev port is Vite's**, 5173 only when free: `pix3 editor`, the kit and every
49
+ harness read `.pix3/dev.json` (`editorUrl`, `publicEditorUrl`), never a fixed port.
50
+
51
+ `new`, `kit`, `editor` and `agent-setup` load neither TypeScript nor the kit generator: the kit
52
+ and the runtime types are prebuilt into the package (`kit/`, `dist/runtime-types.json`, at
53
+ `prepack`).
54
+ `tree` reads YAML only; `smoke` and `tree --props` load the runtime from a bundle prebuilt at
55
+ `prepack` (`dist/smoke/prebuilt/`, like validate's), so none of this slows `new` / `editor`.
56
+
57
+ Coding agents drive the open editor tab through Chrome DevTools MCP (`chrome-devtools-mcp`): the
58
+ tab registers its `pix3_*` tools on the page (`packages/editor-core/src/host/bridge-tools.ts`),
59
+ `pix3 editor` puts that tab in a Chrome whose DevTools protocol is reachable only through its
60
+ token proxy, `pix3 agent-setup` points the agent's MCP config at the proxy with the token. There is no Pix3 MCP server (plan §D.2); the agent-facing procedure is
61
+ the kit's `pix3-editor` skill.
62
+
63
+ ## `pix3 check` — everything an agent should run after a batch of edits
64
+
65
+ `pix3 check` = `pix3 validate` (both levels) + a TypeScript type-check of the project's scripts +
66
+ a version check. Exit 0 = no errors (warnings
67
+ allowed), 1 = at least one error, 2 = could not run. Files are the truth only once the editor has
68
+ written them: when `.pix3/dev.json` names a live dev server, `check` first asks it to flush the
69
+ editor's unsaved scenes (`POST /__pix3/api/flush`, up to 15 s; `--no-sync` skips it, a dead
70
+ server is ignored). Diagnostics are one list, validate's plus:
71
+
72
+ | Code | Severity | When |
73
+ | --- | --- | --- |
74
+ | `E_TYPE` | error | a tsc diagnostic (`message` starts with `TS<code>:`; `file`, `line`) |
75
+ | `E_TYPECHECK_UNAVAILABLE` | error | TypeScript could not be found or installed (`fix` = the command to run) |
76
+ | `E_EDITOR_UNSYNCED` | error | `.pix3/dev.json` points at a live dev server whose editor did not flush its unsaved scenes within 15 s (`--no-sync` reads the disk as it is) |
77
+ | `E_RUNTIME_VERSION` | error | the version gate: the project resolves a `node_modules/@pix3/runtime` that is not the runtime this CLI validates (and smoke-runs) with — run the project's own CLI (`npx pix3 …`) or install the matching runtime |
78
+ | `W_PIX3_VERSION_MISMATCH` | warning | an installed `@pix3/cli`, `@pix3/vite-plugin` or `@pix3/editor-core` is not this CLI's version (lockstep packages) |
79
+ | `W_RUNTIME_NOT_INSTALLED` | warning | own `tsconfig.json`, and no `node_modules/@pix3/runtime` |
80
+ | `W_KIT_OUTDATED` | warning | `metadata.agentKit.version` is not this CLI's version (`pix3 kit --update`) |
81
+ | `W_EDITOR_HMR_API` | warning | `import.meta.hot` in a module the editor runs |
82
+ | `W_EDITOR_CSS_IMPORT` | warning | a stylesheet import (not `?inline`/`?raw`/`?url`) in a module the editor runs |
83
+ | `W_EDITOR_DYNAMIC_IMPORT` | warning | an `import()` whose argument is not one plain string, in a module the editor runs |
84
+
85
+ "A module the editor runs" = the editor's script chain (plugin contract B): `scripts/**`,
86
+ `src/scripts/**`, `design/tests/bots/**` (`.ts`, minus specs/tests/`.d.ts`) and every local module
87
+ they import, transitively; `message` names the root that reaches it. Each of the three makes Vite
88
+ put `/@vite/client` on the editor page, and then the game's `full-reload` reloads the editor too.
89
+ The scan is lexical (`src/check/editor-chain.ts`), bare packages are not followed.
90
+
91
+ `--json`:
92
+
93
+ ```text
94
+ {
95
+ "ok": true, "projectRoot": "…", "errorCount": 0, "warningCount": 2,
96
+ "level2": { "state": "ran", "filesHydrated": 5, "filesSkipped": 0, "filesSkippedForScripts?": 0 },
97
+ "files": [{ "file": "scenes/main.pix3scene", "sha256": "<hex of the raw bytes>" }],
98
+ "diagnostics": [{ "severity", "code", "file", "line?", "nodeId?", "path?", "message", "fix?" }],
99
+ "notes": [],
100
+ "typecheck": { "ok": true, "errors": 0, "tsconfig": ".pix3/tsconfig.check.json", "mode": "pix3-types",
101
+ "files": 5, "typescript": { "version": "5.8.3", "source": "cache" } },
102
+ "kit": { "version": "1.6.0", "cliVersion": "1.6.0", "upToDate": true },
103
+ "timingsMs": { "validate": 190, "typecheck": 1180, "total": 1380 }
104
+ }
105
+ ```
106
+
107
+ `files` holds every scene validated and every script type-checked, hashed over the **raw bytes** —
108
+ the hashes `pix3_sync`'s `expect` takes. `typecheck.errors` counts the
109
+ `E_TYPE` / `E_TYPECHECK_UNAVAILABLE` entries of `diagnostics`.
110
+
111
+ **A compile error hides level 2.** When the scripts do not compile (`E_SCRIPT_COMPILE`), level 2
112
+ skips every scene with `user:` components — their `E_UNKNOWN_CONFIG_KEY` / `E_PROPERTY_TYPE` /
113
+ component checks are *missing*, not passed. The report says so: `level2.filesSkippedForScripts`,
114
+ a `SKIPPED: N scene(s) …` note, and the human summary line reads `0 scene file(s) loaded as the game loads
115
+ them, N NOT loaded (scripts do not compile — user: components unchecked)`. Fix the compile error and run again.
116
+
117
+ **Which tsconfig.** A project with its **own** root `tsconfig.json` (a Vite project — every
118
+ `npm create pix3` starter) is checked with it, as is, against its own
119
+ `node_modules` (`mode: "project"`); nothing is written into it. Otherwise (`mode: "pix3-types"`)
120
+ `check` uses `.pix3/tsconfig.check.json` against the bundled types in `.pix3/types/`, and first
121
+ (re)writes both when they are missing or from another CLI build.
122
+
123
+ **Where TypeScript comes from** — it is not a dependency of `@pix3/cli`: (1) the project's own
124
+ `node_modules/typescript` (or an ancestor's); (2) the CLI's sibling install
125
+ (`import.meta.resolve('typescript')`, e.g. the monorepo); (3) `~/.pix3/typescript/5.8.3/`,
126
+ installed there once with `npm install --prefix ~/.pix3/typescript/5.8.3 typescript@5.8.3 …`
127
+ (printed before it runs). `--offline` never installs: it fails with `E_TYPECHECK_UNAVAILABLE` and
128
+ the command. `PIX3_TYPESCRIPT=<package dir>` overrides the search. Loaded with a dynamic `import()`.
129
+
130
+ Measured (the 1.x recipe-tapper-2d, 5 scripts, packed CLI installed outside the repo, fresh `HOME`):
131
+ first `check` 2.1 s including the TypeScript install, then 1.45 s (validate ~0.2 s, tsc ~1.2 s).
132
+
133
+ ### SVG sprites
134
+
135
+ Every texture in Pix3 (editor viewport, play mode, the single-file export) loads as bytes → `Blob`
136
+ → object URL → three's `TextureLoader` → `<img>`, so an `.svg` renders exactly when a browser
137
+ `<img>` decodes it, at the size the `<img>` reports. Level 1 checks every `.svg` a scene
138
+ references (once per file per scene, at its first reference):
139
+
140
+ | Code | Severity | When |
141
+ | --- | --- | --- |
142
+ | `E_SVG_INVALID` | error | no `<svg>` root, or the root lacks `xmlns="http://www.w3.org/2000/svg"` (the browser refuses to decode it) |
143
+ | `E_SVG_NO_SIZE` | error | no absolute `width`/`height` and no `viewBox` (renders into a cropped 300x150 box), or one dimension without a `viewBox` |
144
+ | `W_SVG_VIEWBOX_ONLY` | warning | a `viewBox` but no absolute `width`/`height` (`%` and `style=` sizes count as none): Chrome gives it 300x150, art letterboxed |
145
+ | `W_SVG_EXTERNAL_REF` | warning | an `href`, CSS `url()` or `@import` to anything but `#fragment` / `data:` — never loads in SVG-as-image mode |
146
+
147
+ Measured in Chromium 129 through the runtime's own `ResourceManager` + `AssetLoader` + `Sprite2D`:
148
+ a sized SVG auto-sizes the sprite to its `width`×`height`; a viewBox-only one to 300×150; one
149
+ without `xmlns`, or any SVG whose Blob type is not `image/svg+xml`, fails to load (browsers do
150
+ not sniff SVG).
151
+
152
+ ### `.pix3anim` frames, locale tables and keys
153
+
154
+ Level 1 also follows what a scene reaches through another file. Every `.pix3anim` (all of them on
155
+ a whole-project run, else those the validated scenes name) must be a JSON object, and every image
156
+ it names — each frame's `texturePath`, the spritesheet's top-level one — must exist; an `.svg`
157
+ frame gets the sprite rules above (`E_SVG_*`), reported on the `.pix3anim` with the frame's path.
158
+ An `AnimatedSprite3D` names its frames itself (`frames:` — `res://` paths, bare paths or
159
+ `{ url }`): each must exist (`E_MISSING_RESOURCE`, the SVG rules on an `.svg`), and an entry that
160
+ is not a texture is `E_PROPERTY_TYPE` (a blank frame).
161
+ The locales are the ones the plugin ships: the `localization:` block of `pix3project.yaml`, else
162
+ every `locales/*.json` (`en` the default when there is one). Severity is what the player would
163
+ see: the default or fallback locale's table not loading means every `labelKey` shows its key
164
+ (error); another locale's means its texts fall back (warning). A key is looked up the way
165
+ `LocalizationService` does — the default table, then the fallback's, `""` counting as none.
166
+
167
+ | Code | Severity | When |
168
+ | --- | --- | --- |
169
+ | `E_ANIM_JSON` | error | a `.pix3anim` is not JSON, not an object, or its `clips` are not a list |
170
+ | `E_MISSING_FRAME` | error | a frame image or the spritesheet a `.pix3anim` names does not exist (case hint as for `E_MISSING_RESOURCE`) |
171
+ | `E_LOCALE_MISSING` / `W_LOCALE_MISSING` | error / warning | a declared locale has no `locales/<id>.json` (default or fallback / another); on `pix3project.yaml` at its line |
172
+ | `E_LOCALE_JSON` / `W_LOCALE_JSON` | error / warning | the table is not JSON, not an object, or `strings` / `sprites` is not a map |
173
+ | `E_LOCALE_VALUE` / `W_LOCALE_VALUE` | error / warning | a `strings` / `sprites` value is not a string (the runtime drops it; nested keys are the usual cause) |
174
+ | `E_MISSING_LOCALE_SPRITE` | error | an image a table's `sprites` names does not exist — in any locale: the table wins over a node's own texture, so its keyed nodes draw nothing in that locale; an `.svg` gets `E_SVG_*`, on the table at `sprites.<key>` |
175
+ | `E_LOCALE_KEY` | error | a `labelKey` (on a node or an instance) with no text — the node shows the key |
176
+ | `E_LOCALE_SCRIPT_KEY` | error | a key a script passes as a string literal to `tr` / `setTextKey` / `trPlural` with no text — the game shows the key (`trPlural`: neither `<key>.other` nor `<key>`, its last two steps). Every `.ts`/`.js` of the project but specs, tests and declarations (`*.spec.ts`, `*.test.ts`, `*.d.ts` — the project-script rule's exclusions, `PROJECT_SCRIPT_EXCLUDE_PATTERN`); the same scan as the editor's Localization Scan (`scanScriptLocalizationKeys` in the runtime). A computed key — a variable, an interpolated template — is skipped |
177
+ | `E_LOCALE_SPRITE_KEY` / `W_LOCALE_SPRITE_KEY` | error / warning | a `Sprite2D` `textureKey`, a `Button2D` `stateTextureKeys.<state>` (`texture<State>Key` on an instance) or a script's `trSprite('…')` with no `sprites` entry: an error for a `Sprite2D` with no `texture` of its own (it draws nothing), a warning otherwise (its own texture in every locale; a button keeps its skin; `trSprite` returns null) |
178
+
179
+ The tables, their images and the scripts' keys are checked on whole-project runs (as
180
+ `W_UNUSED_ASSET`); the keys a scene sets on every run. Nothing is said about keys when the project
181
+ has no tables or the default one does not load (that is its own error).
182
+
183
+ ### Autoloads
184
+
185
+ `pix3project.yaml` `autoloads:` is checked on every run, against the rule every host builds them
186
+ by (the runtime's `normalizeAutoloads` / `autoloadComponentType`; the player, the editor's play
187
+ mode and `pix3 smoke` run them): each entry needs a `singleton` and a `scriptPath`, the file must
188
+ be a script entry under `scripts/` or `src/scripts/` exporting a `Script` class named like the
189
+ file. A disabled entry is not checked.
190
+
191
+ | Code | Severity | When |
192
+ | --- | --- | --- |
193
+ | `E_AUTOLOAD` | error | an autoload that would never run: no `singleton`/`scriptPath`, a repeated singleton, a script that is missing, outside the script folders, not an entry, or exporting no class named like the file (level 1, by text); at level 2, the class named like the file is not a `Script` |
194
+
195
+ ## `pix3 sfx` — sound effects without the editor
196
+
197
+ ```text
198
+ pix3 sfx coin # -> audio/coin.wav in the project root
199
+ pix3 sfx "short high coin pickup" --out audio/pickup.wav --seed 7
200
+ pix3 sfx explosion --json # { path, res, durationMs, preset, seed, modifiers, bytes, peak, params }
201
+ ```
202
+
203
+ Presets: `coin`, `jump`, `hit`, `explosion`, `powerup`, `click`. Free text picks the preset whose
204
+ keyword appears first (`boom`, `pickup`, `button`, `hurt`, …) and applies modifier words
205
+ (`high`/`low` pitch ×1.4/×0.7, `short`/`long` time ×0.65/×1.6, `soft`). `--seed <n>` renders a
206
+ deterministic variation (pitch ±15 %, times ±20 %, duty/vibrato/cutoff nudged); without it the
207
+ preset as tuned. Output is 44.1 kHz 16-bit mono PCM WAV — `AssetLoader.loadAudio` decodes it with
208
+ `decodeAudioData` like any `.ogg`/`.mp3`, and the export embeds it as `audio/wav`. `res` in the
209
+ JSON is the `res://` path when the file lands inside a project. Exit 2 on a bad argument or a
210
+ description that names no preset.
211
+
212
+ The synth (`src/sfx/synth.ts`, no dependencies) is one jsfxr-style voice: square / saw / sine /
213
+ triangle / noise, exponential pitch slide, pitch jump, vibrato, ADSR, resonant low-pass, DC
214
+ blocker, peak normalisation. It does not use the editor's `@txt2sfx/*` packages: their only
215
+ renderer drives an `OfflineAudioContext` the caller supplies, i.e. a native Web Audio module
216
+ (`node-web-audio-api`) in Node. Each preset renders in well under 10 ms; the whole command runs in
217
+ ~0.12 s; files are 2–67 KiB (click 24 ms … explosion 772 ms).
218
+
219
+ ## `pix3 character-compile` — a 2D character from frame PNGs
220
+
221
+ ```text
222
+ pix3 character-compile art/goblin.yaml # writes into the project around the cwd
223
+ pix3 character-compile art/goblin.yaml --dry-run # the plan, nothing written
224
+ pix3 character-compile art/goblin.yaml --json # { ok, name, slug, animationPath, prefabPath, clips, files, warnings }
225
+ ```
226
+
227
+ The 1.x Store compiler (`character2d`, `pix3: src/services/library/character-compiler.ts`) as a
228
+ command. A spec (YAML or JSON; frame paths relative to the spec file) groups PNG frames into
229
+ `variant × state` clips:
230
+
231
+ ```yaml
232
+ name: Goblin # display name, prefab file name
233
+ slug: goblin # sprite folder (default: from name)
234
+ anchor: { x: 0.5, y: 0.9 } # frame anchor, y from the top: the feet land on the node position
235
+ defaultVariant: sword # the pair the prefab starts on (default: the first clip's)
236
+ defaultState: idle
237
+ clips:
238
+ - { variant: sword, state: idle, fps: 10, frames: [sword/idle_1.png, sword/idle_2.png] }
239
+ - { variant: sword, state: attack, fps: 15, sequence: sword/attack } # sword/attack_<n>.png, numeric order
240
+ - { state: die, sequence: die/die } # no variant: clip "die"
241
+ ```
242
+
243
+ Output, in the managed-sprite-folder layout:
244
+ `sprites/<slug>/<slug>.pix3anim` (clips `<variant>.<state>`, every frame with `texturePath`,
245
+ `anchor`, `sourceSize`), the frames copied beside it as `<variant>_<state>_<nnnn>.png`, and
246
+ `scenes/prefabs/<Name>.pix3scene` — an `AnimatedSprite2D` root (`sizeMode: native`) carrying
247
+ `core:CharacterVisual2D` (`variant`, `state`, `separator`); game code calls
248
+ `playState('attack', { restart: true })` / `setVariant('bow')`. `spriteDirectory` /
249
+ `prefabDirectory` / `separator` override the defaults. Defaults are proposals and print as
250
+ warnings (fps 12; `loop: false` for `attack`, `die`, `death`, `hit`, `hurt`); a sequence with gaps
251
+ warns, one with a duplicate number fails. Writes are all or nothing: a target that exists with
252
+ other bytes is refused (exit 1, nothing written) unless `--force`; identical bytes are `current`.
253
+ Frames must be PNG (the size comes from the header; the Node-runnable CLI does not load the
254
+ engine's image reader). Exit 0 = written or current, 1 = refused, 2 = usage / no project.
255
+
256
+ `src/character/character.spec.ts` holds the output to the runtime (`normalizeAnimationResource` of
257
+ the written `.pix3anim` is the identity; the compiler's types are assignable to
258
+ `AnimationResource`), to the managed folder's frame naming (`<clip>_<nnnn>.png`), and to
259
+ `pix3 validate` on a starter that instances the prefab; `character.headless.spec.ts` boots the
260
+ prefab through the real loader and drives idle → attack → restart → die → variant switch. The
261
+ format itself: the kit's `.claude/skills/pix3-scene-format/pix3anim.md`.
262
+
263
+ ## `pix3 smoke` — run the game headless
264
+
265
+ `pix3 smoke [scene]` runs the game in Node — no browser, no editor, no dev server — for
266
+ `--frames N` fixed steps of 1/60 s (default 120) and reports everything that went wrong. It is the
267
+ behavioural check after `pix3 check` when no editor is connected; with the editor open,
268
+ `pix3_game_run` is the stronger one (it renders).
269
+
270
+ What runs: the project's scripts compiled with esbuild (as `validate` level 2 does, but bare
271
+ imports are bundled for real from the project's `node_modules` — Rapier, three addons — and only
272
+ what resolves nowhere becomes an empty module, reported as `W_SMOKE_STUBBED_IMPORT`), the scene
273
+ loaded from disk by the real `SceneLoader`, a real `SceneRunner` in manual time. Frame 0 is loading
274
+ and starting the scene (`onAttach`/`onStart`, then 128 event-loop turns so spawn chains land);
275
+ frame N is the N-th step. The run happens in a worker thread, so a script stuck in a loop is
276
+ stopped by `--timeout` (default 20 s).
277
+
278
+ Stubbed: rendering (a null renderer; post-processing never loads), audio (no Web Audio in Node),
279
+ input (no events), network (`scene.network` is null). Textures resolve empty, glTF models and
280
+ Spine skeletons referenced by nodes are not built; images a script loads through three's
281
+ `TextureLoader` "load" as blank 1×1 pictures (so preloaders finish). A small browser shim provides
282
+ `window` / `document` / canvas / `localStorage` / `matchMedia` / `requestAnimationFrame`; any other
283
+ browser property a script reads is recorded, and an error right after such a read is
284
+ `E_SMOKE_DOM` with the read attached (`domAccess`).
285
+
286
+ Scene: the argument (`res://`, project-relative or a path) runs that one scene. With no argument
287
+ several run, one after another from one bundle, a line each (`src/smoke/select-scenes.ts`):
288
+
289
+ 1. In a git work tree with uncommitted changes (staged, unstaged or untracked): the **top-level**
290
+ scenes those changes reach — the scene itself, a prefab / `scenes/ui/` overlay it instances, a
291
+ script exporting a class it attaches as `user:X`, a `res://` file it names. `--changed` forces
292
+ this (exit 2 without git or without changes). Changes under `node_modules/`, `.yalc/` (a linked
293
+ package copy), `.git/`, `.pix3/`, `dist/`, `.vite/`, `.cache/` are not project changes and are
294
+ ignored, and a `*.spec.ts` / `*.test.ts` next to the scripts is not game code — a consumer
295
+ project's `.yalc/@pix3/runtime/**` and its unit tests used to read as hundreds of changed
296
+ scripts that reach no scene and widen every run to everything.
297
+ 2. Otherwise — no git, nothing changed, a changed scene/script that reaches no top-level scene (a
298
+ helper module, an unused prefab), or a changed `pix3project.yaml` — **every** top-level scene
299
+ (not instanced by another, not under `prefabs/` / `ui/`, not a `.prefab` — those are only
300
+ instanced, and a whole-project `pix3 validate` checks them as prefabs),
301
+ `scenes/main.pix3scene` first (where the starters and the 1.x recipes put the game), then
302
+ `defaultExportScenePath`, then by path.
303
+ `--all` forces this.
304
+
305
+ It never picks `defaultExportScenePath` alone any more: in every 1.x recipe that is the menu, input is
306
+ empty, PLAY is never pressed, and a game whose `onStart` throws used to smoke green.
307
+
308
+ | Code | |
309
+ | --- | --- |
310
+ | `E_SMOKE_SCRIPT` | A script hook threw (`onAttach`/`onStart`/`onUpdate`…); the engine disabled that component |
311
+ | `E_SMOKE_DOM` | …and it was a browser API the shim does not provide (`domAccess` names it) |
312
+ | `E_SMOKE_TICK` / `E_SMOKE_COMMAND` | The engine tick / a game command handler threw |
313
+ | `E_SMOKE_CONSOLE_ERROR` | `console.error` (not the engine re-reporting one of the above) |
314
+ | `E_SMOKE_UNHANDLED` | An unhandled promise rejection, or a timer callback that threw |
315
+ | `E_SMOKE_LOAD` | The scene failed to load (missing prefab, invalid scene) |
316
+ | `E_SMOKE_SCRIPT_COMPILE` / `E_SMOKE_SCRIPT_IMPORT` | Scripts do not compile / throw at module top level |
317
+ | `W_SMOKE_MISSING_RESOURCE` | A `res://` file does not exist (the node loads without it) |
318
+ | `W_SMOKE_PENDING_COMPONENT` | A component type is not registered — it never runs |
319
+ | `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 |
320
+
321
+ Exit 0 = no errors (warnings allowed), 1 = errors, 2 = could not run (with several scenes, the
322
+ worst run decides): `E_SMOKE_NO_PROJECT`,
323
+ `E_SMOKE_NO_SCENE`, `E_SMOKE_BUNDLE`, `E_SMOKE_UNSUPPORTED` (no esbuild), `E_SMOKE_TIMEOUT`,
324
+ `E_SMOKE_CRASH`, `E_EDITOR_UNSYNCED` (a live editor named by `.pix3/dev.json` did not flush its
325
+ unsaved scenes first; `--no-sync` reads the disk as it is), `E_RUNTIME_VERSION` (the version gate:
326
+ the project installs another `@pix3/runtime` than the one bundled into this CLI, so a run would
327
+ test an engine the game does not ship). The human report prints the `game` snapshot whole — on one line when it is short, pretty-printed
328
+ and indented otherwise, cut only past 4 000 characters (`--json` always carries it whole).
329
+ `--json` prints `{ ok, scene, frames, framesRequested, firstFrameOk, errors:
330
+ [{ code, frame, script?, nodeId?, nodeName?, phase?, message, stack?, domAccess? }], warnings,
331
+ nodes: { start, end }, timingsMs: { compile, load, firstFrame, step: { total, mean, p95, max },
332
+ total }, scripts, domMissing, notes, game, logs }` — `game` is the `registerGameDebug` provider's
333
+ `snapshot()` at the end (when the game registers one), `logs` the first `console.log` lines,
334
+ frame-stamped. Stacks are source-mapped to the project's files. With no scene argument `--json`
335
+ prints `{ ok, selection: "changed" | "all", reason, changed?, runs: [<that report, per scene>] }`.
336
+
337
+ A green smoke run means nothing threw for N frames without input. It does not mean the game
338
+ plays: nothing was tapped, nothing was drawn.
339
+
340
+ ## `pix3 tree` — find your way around a scene
341
+
342
+ `pix3 tree <scene>` prints one line per node, indented by depth, from the YAML alone (no loader,
343
+ no project code — instant):
344
+
345
+ ```text
346
+ scenes/main.pix3scene — 12 nodes
347
+ Group2D#game-root "Game Root" size=1080x1920 layout=stretch/stretch components=[user:GameRules, user:TouchRules]
348
+ CanvasLayer2D#hud "HUD" size=1080x1920 layout=stretch/stretch components=[user:ScoreHud]
349
+ Label2D#score-label "Score Label" text="SCORE 0" pos=(-340,850) layout=left/top
350
+ Group2D#result-overlay "Result Overlay" ↳ instance res://scenes/ui/result.pix3scene (1 property) hidden
351
+ ```
352
+
353
+ `--depth N` stops N levels below the roots (cut subtrees end in `… +K below`); `--types A,B` keeps
354
+ nodes of those types (or carrying those components; `instance` = prefab instances) with their
355
+ ancestors as `·` context lines; `--props` adds, under each node, the properties that differ from
356
+ the node type's defaults (read from a bare instance of the runtime class, through the disk-format
357
+ table — the one step that loads the runtime bundle); `--json` gives the same as nested
358
+ `{ id, type, name, depth, position?, size?, layout?, margins?, resolved?, hidden?, text?, groups?,
359
+ components, instance?: { path, rootType?, rootName?, overrides, properties }, props?, children }`.
360
+ On an instance
361
+ `overrides` counts every `overrides.byLocalId.*.properties` key (edits to nodes inside the prefab)
362
+ and `properties` the instance node's own `properties` keys (applied to the prefab root); the line
363
+ prints `(2 overrides, 1 property)`, or `(no overrides)` when both are 0.
364
+
365
+ A node anchored in the margin form (W21: `layout: { enabled, horizontalAlign, verticalAlign,
366
+ left?, right?, top?, bottom? }`, every side the alignment keeps written) holds `0` in the file for
367
+ the position component of an anchored axis and no `width`/`height` for a stretched one — the
368
+ margins are the rect. The line shows the margins beside the layout and, as `pos` / `size`, the
369
+ rect they give at the design size (the parent's size; `viewportBaseSize` for a root), the way
370
+ `Node2D`'s layout pass computes it; `?` where the file does not say enough (a parent or the node
371
+ without a size in the file):
372
+
373
+ ```text
374
+ Group2D#hud size=1080x1920 layout=stretch/stretch(left=0,right=0,top=0,bottom=0)
375
+ Group2D#bar pos=(0,-920) size=1000x40 layout=stretch/bottom(left=40,right=40,bottom=20)
376
+ Group2D#knob pos=(480,5) size=20x20 layout=right/none(right=10)
377
+ Sprite2D#unsized pos=(?,70) layout=left/none(left=10)
378
+ ```
379
+
380
+ `--json` keeps `position` / `size` as the file has them and adds `margins` and
381
+ `resolved: { position: [x, y], size: [w, h] }` (`null` for `?`). A pre-W21 anchor (no margins in
382
+ the file) prints its file position as before.
383
+
384
+ `pix3 tree` with no scene is the project overview: manifest facts, the `user:` scripts, and every
385
+ scene/prefab/overlay with its node count, node types, components and instances (entry scene
386
+ marked `*`). Exit 0, 1 when a scene does not parse, 2 for bad arguments or a missing file.
387
+
388
+ ## `pix3 kit` — the agent kit
389
+
390
+ `pix3 kit` installs the agent kit into an existing project (one made without it, or after a CLI
391
+ upgrade); `pix3 new` runs the same step. What lands in the project:
392
+
393
+ | Path | What |
394
+ | --- | --- |
395
+ | `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). |
396
+ | `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. |
397
+ | `.claude/skills/pix3-{scene-format,nodes,scripts,verify,editor}/SKILL.md` + `reference.md` | Skills loaded on demand; the `reference.md` files are generated from `docs/` and the runtime's registry; `pix3-scene-format/pix3anim.md` is the `.pix3anim` format |
398
+ | `design/tests/bots/pix3-test-bot.d.ts` | Global types for bot policies (`Pix3TestBot`, `BotPolicy`, …): a policy is `export default { name, tick(bot) { … } } satisfies BotPolicy`, and the starter's `tsconfig.json` includes `design/tests`, so `pix3 check` type-checks it. The editor writes no project files; this is where the 1.x editor-written declaration went |
399
+ | `.gitignore` | `.pix3/*` with `!.pix3/kit-manifest.json` and `!.pix3/template.json` appended when no line ignores `.pix3` yet; existing content kept |
400
+ | `tsconfig.json`, `.pix3/tsconfig.check.json`, `.pix3/types/` | Only without a `tsconfig.json` of the project's own (see below) |
401
+ | `pix3project.yaml` | `metadata.agentKit: { version, files }` (everything else as written) |
402
+ | `.pix3/kit-manifest.json` | sha256 of every file the kit wrote |
403
+
404
+ Without `--update`, existing files are never replaced (missing ones are written; an unchanged file
405
+ from an older kit is reported `outdated`). `--update` replaces every kit file whose bytes still
406
+ match the hash in `.pix3/kit-manifest.json` and **skips** (and reports) every file edited since.
407
+ The starters ignore `.pix3/*` but not `.pix3/kit-manifest.json` (nor `.pix3/template.json`), so a
408
+ clone keeps the manifest. A project whose `.gitignore` ignores all of `.pix3/` (an older starter, or by
409
+ hand) loses it on a fresh clone, and every differing kit file then counts as
410
+ edited — replace that line with the three of the starter.
411
+
412
+ **Generated, not copied.** `scripts/build-kit.mjs` (run at `prepack`; a repo checkout regenerates
413
+ `kit/` automatically whenever an input changed) expands the templates in `kit-src/`: hand-written
414
+ prose plus `{{include:<repo path>#<heading>}}` / `{{include:<repo path>@<paragraph>}}` directives
415
+ over `docs/pix3-specification.md`, `docs/node-types-reference.md`, `docs/nodes-and-systems.md`,
416
+ `packages/cli/kit-includes/engine-api-map.md` and this README, and `{{generated:…}}` blocks
417
+ computed from code (the `core:` component table from the runtime's registry). Syntax:
418
+ `src/kit/generate.ts`. `src/kit.spec.ts` builds the kit and fails on drift: an unresolved
419
+ directive, a `pix3` command or flag not in the usage text, an `npm run` script the starter does
420
+ not have, a `pix3_*` tool, parameter or sync reason the bridge (`bridge-tools.ts`) does not have, a
421
+ retired 1.x in-editor tool, a verdict phrase `GameTestService` does not produce, a diagnostic code
422
+ no command emits, a node type the loader does not know, a property in the nodes skill's tables
423
+ that the disk-format descriptor (`packages/runtime/src/core/scene-disk-format.ts`) does not
424
+ accept, a `core:` component that does not exist, and a `.pix3anim` reference (`pix3anim.md`)
425
+ whose interfaces, fields, optionality, types or "Omitted →" defaults differ from
426
+ `packages/runtime/src/core/AnimationResource.ts` and `normalizeAnimationResource`, and bot-policy
427
+ types missing a member of `Pix3TestBot` / `BotPolicy` / `BotNodeView`
428
+ (`packages/editor-core/src/services/game-test/game-bots.ts`).
429
+
430
+ ### `pix3 kit --migrate` — a 1.x project's kit to 2.x
431
+
432
+ Plan §A.4 step 2. It migrates **the kit only** (`src/kit/migrate.ts`), and reports every change:
433
+
434
+ | What | How |
435
+ | --- | --- |
436
+ | `.mcp.json` | the 1.x `pix3 mcp --workspace` server (`npx -y @pix3/cli@1.x mcp …`, or the dev `node …/packages/cli/src/index.ts mcp`) is removed; other servers stay; a file left empty is deleted |
437
+ | Retired kit files | files in `.pix3/kit-manifest.json` that the 2.x kit no longer ships: deleted when unchanged, kept and reported when edited |
438
+ | `pix3project.yaml` | `metadata.pix3Hybrid` (the 1.x cloud link) removed, its value printed; `metadata.agentKit` rewritten |
439
+ | The kit | installed with `--update` semantics: unchanged 1.x kit files replaced, edited ones kept; an edited one that still mentions `pix3 mcp` / `pix3 serve` / `pix3 read` / the live channel / the in-editor tools is flagged `STILL 1.x` and the 2.x text written to `.pix3/kit-migrate/<path>` for a manual merge |
440
+
441
+ Never touched: anything outside the project (`~/.codex/config.toml` — the report says to delete a
442
+ 1.x `[mcp_servers.pix3]` table), a project `.codex/config.toml` (reported when it runs `pix3 mcp`),
443
+ the project's own `AGENTS.md` / `CLAUDE.md` (an edited copy of the 1.x kit's `AGENTS.md` is
444
+ reported), scenes, scripts, assets, `package.json`, `vite.config.*`. The 2.x agent config is
445
+ `pix3 agent-setup`'s (the report says to run it). Moving a 1.x project onto Vite (`pix3()` in
446
+ `vite.config`, plan §A.4 step 3) is optional and by hand; the report notes a project without
447
+ `@pix3/vite-plugin`. A second run finds nothing to migrate. Spec: `src/kit/migrate.spec.ts`
448
+ (a 1.x-shaped starter, edits kept, idempotence, a copy of `../DeepCore` when present).
449
+
450
+ ### Script types (`.pix3/types/`)
451
+
452
+ `scripts/build-runtime-types.mjs` (at `prepack`; rebuilt on demand in a checkout when the runtime
453
+ sources change) runs `tsc -p packages/runtime/tsconfig.types.json` (declarations of what
454
+ `src/index.ts` reaches — no specs, samples or `testing/`) into `runtime-types/@pix3/runtime/`, and
455
+ copies `@types/three` (without its `node_modules`) to `runtime-types/@types/three/` — the runtime's
456
+ public types extend three.js. The tarball carries that tree packed into one file,
457
+ `dist/runtime-types.json` (see "Package layout" below); the installed CLI expands it on first use. `lit` (re-exported `property`/`state` decorators), `postprocessing`
458
+ and the Spine runtime are not shipped: they are only reached from inside declaration files, which
459
+ `skipLibCheck` leaves alone (they type as `any`). In a project without its own `tsconfig.json`:
460
+
461
+ ```text
462
+ .pix3/types/@pix3/runtime/** the runtime's .d.ts (+ package.json with the version)
463
+ .pix3/types/@types/three/** three.js types
464
+ .pix3/types/version.json which CLI build wrote them
465
+ .pix3/tsconfig.check.json paths → ./types/…, strict, noEmit, skipLibCheck, lib ES2022 + DOM,
466
+ include ../scripts/**/*.ts and ../src/scripts/**/*.ts
467
+ tsconfig.json { "extends": "./.pix3/tsconfig.check.json" } — so an IDE sees them
468
+ ```
469
+
470
+ ## `pix3 gap` — what the agent had to work around
471
+
472
+ Plan §G.3 «Чего не хватает?», §G.4. `pix3 gap "<what was missing>" --kind
473
+ capability|tool|node|doc|other [--detail <text>] [--context <file or task>] [--agent <name>]`
474
+ appends one line to the project's `.pix3/gaps.jsonl`:
475
+
476
+ ```json
477
+ {"ts":"2026-10-10T12:00:00.000Z","agent":"claude-code","kind":"node","summary":"no 9-slice panel node","detail":"built from 9 Sprite2D","context":"scenes/ui/shop.pix3scene"}
478
+ ```
479
+
480
+ `agent` comes from `--agent`, else the environment (`CLAUDECODE=1` → `claude-code`, a `CODEX_*`
481
+ variable → `codex`), else it is left out; the summary is one line of ≤200 characters. One
482
+ `O_APPEND` write per record, so two agents never interleave. `pix3 gap --list [--json]` prints
483
+ them. The kit's `AGENTS.md` tells the agent to record a gap only after it worked around
484
+ something Pix3 lacks — not its own mistakes or the game's features. A CLI command rather than a
485
+ bridge tool: it needs no editor, and on a Remote SSH host it runs where the project is. `.pix3/`
486
+ is gitignored in the starters: the file stays with the checkout.
487
+
488
+ ## `pix3 editor` — the editor in Chrome, for the agent
489
+
490
+ Plan §D.3 / §D.4 / §D.5. Idempotent; run it from the project (or `--project <dir>`):
491
+
492
+ 1. **Dev server.** `.pix3/dev.json` (written by `@pix3/vite-plugin` while Vite listens: `url`,
493
+ `editorUrl`, `port`, `pid`) is probed with `GET <url>__pix3/api/hello`. A live server is
494
+ reused; a stale record is ignored and `node_modules/vite/bin/vite.js` is started **detached**
495
+ (`process.execPath`, `cwd` = the project, output in `.pix3/dev.log`), and the command waits
496
+ up to 15 s for the new `dev.json`. `--port <n>` passes `--port --strictPort` to Vite.
497
+ `--stop` sends SIGTERM to the recorded pid (and drops a record whose process is gone).
498
+ 2. **Chrome, owned.** `PIX3_CHROME` names the binary (a wrapper script works if it `exec`s
499
+ Chrome: the pipe is fds 3 and 4), else the platform's usual place (`google-chrome` /
500
+ `chromium` on the PATH, `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`,
501
+ `%ProgramFiles%\Google\Chrome\Application\chrome.exe`). `pix3 editor` starts a **detached
502
+ Chrome owner** (`pix3 __chrome-owner`, log in `~/.pix3/chrome-owner.log`) and returns once its
503
+ proxy answers; the owner launches Chrome with `--app=<editorUrl>
504
+ --user-data-dir=~/.pix3/chrome --remote-debugging-pipe` — **no debugging port** — no first run,
505
+ no default-browser check, background throttling off (`--disable-background-timer-throttling
506
+ --disable-renderer-backgrounding --disable-backgrounding-occluded-windows`). The owner lives
507
+ exactly as long as Chrome: Chrome gone → the owner closes every client, removes
508
+ `~/.pix3/chrome.json` and exits; the owner gone (killed) → the pipe closes and Chrome exits
509
+ with it. `--stop-chrome` asks the owner to close Chrome. Then, launched or reused, the editor
510
+ tab is checked in the proxy's `/json/list` and, when it is not there, opened through the proxy
511
+ (`Target.createTarget`: a new window, a tab in a headless Chrome) and checked again — a binary
512
+ that ignores `--app` (`PIX3_CHROME` pointing at a `chrome-headless-shell`) still gets its tab,
513
+ and `pix3 editor` fails rather than print success without one. A second project gets its tab
514
+ the same way: the profile is never launched a second time (a headless one would start a
515
+ second Chrome that nobody owns and `--stop-chrome` would not end). `--headless` opens the URL
516
+ as a plain tab with `--headless=new` (headless Chrome ignores `--app`). `--no-chrome` stops after the server;
517
+ under `SSH_CONNECTION` Chrome is not launched — see [Remote SSH](#remote-ssh--vs-code-on-a-remote-host-chrome-on-your-machine).
518
+ 3. **The token proxy (9333).** The owner serves `ws://127.0.0.1:9333/pix3` (the browser target;
519
+ `…/pix3/page/<targetId>` for one page) and `GET /json/version`, `/json/list` (`/json`) on
520
+ loopback. Every request needs `Authorization: Bearer <token>`, the token of
521
+ `~/.pix3/cdp-token` (32 random bytes, base64url, mode 0600, created once by whichever command
522
+ needs it first; delete the file to rotate, then `agent-setup --repair` and a new Chrome);
523
+ a request with an `Origin` (any web page) or a non-loopback `Host` is refused (403), a
524
+ missing or wrong token is 401 — a WebSocket is refused before the upgrade. Several clients
525
+ share the pipe (chrome-devtools-mcp, a harness, `pix3 editor` itself): each gets its own root
526
+ session (`Target.attachToBrowserTarget`, or `attachToTarget {flatten}` for a page), message
527
+ ids are remapped per client, events go only to the client owning their session, another
528
+ client's session is "not found", a client that leaves is detached with its child sessions.
529
+ 4. **The port.** The check sends `X-Pix3-Challenge` and **no token**: a proxy answering with the
530
+ proof that it knows our token (`X-Pix3-Proof`, the HMAC of [Remote SSH](#remote-ssh--vs-code-on-a-remote-host-chrome-on-your-machine))
531
+ = ours → reused (only then is it asked, with the token, for its tabs; when the editor tab is
532
+ open there, nothing is launched). A proxy without that proof (another user's, another
533
+ `PIX3_HOME`, a squatter), a foreign DevTools endpoint or any other listener — none of which
534
+ ever sees the token — → the next of 9334–9339, with a line per skipped port, the chosen one
535
+ recorded in `~/.pix3/chrome.json`, and the reminder to run `pix3 agent-setup --repair` (a
536
+ running Codex / Claude Code session keeps the old endpoint: new thread). A plain DevTools port
537
+ with a `/__pix3/` page (or on the recorded port) is the P1 launch (open port, no token): it
538
+ holds the profile, so `pix3 editor` stops and says to close that Chrome. When the port is the
539
+ usual one but the project's `.mcp.json` / `.codex/config.toml` entry is not the current launch
540
+ (an older one — `--wsHeaders` with the token on its command line, `--browserUrl`), or the
541
+ `~/.pix3/cdp-mcp.json` it names holds another port or token, it says to run
542
+ `pix3 agent-setup --repair`. `--cdp-port <n>` moves the preferred port
543
+ (test harnesses). `PIX3_HOME` relocates `~/.pix3`.
544
+
545
+ Keepalive is the editor's: every bridge call keeps its loops running for 60 s, agent play until
546
+ it stops (`AgentKeepaliveService`); the Chrome flags above keep Chrome from throttling the tab.
547
+
548
+ ## `pix3 agent-setup` — chrome-devtools-mcp for Codex and Claude Code
549
+
550
+ Plan §D.6. Writes project-level config, idempotently:
551
+
552
+ - Claude Code — `.mcp.json`, `mcpServers["pix3-browser"]`, other servers kept;
553
+ - Codex — `.codex/config.toml`, the `[mcp_servers.pix3-browser]` table (`startup_timeout_sec =
554
+ 20`, `tool_timeout_sec = 300`: `pix3_game_run` outlives the 60 s default), replaced as a whole,
555
+ every other byte of the file kept.
556
+
557
+ The entry is `npx -y chrome-devtools-mcp@1.10.1 --categoryExperimentalThirdParty=true
558
+ --experimentalVision=true --config=<~/.pix3/cdp-mcp.json>` (`cmd /c npx …` on Windows;
559
+ `experimentalVision` enables `click_at {x, y}`, the agent's input at the coordinates
560
+ `pix3_scene` returns as `screen`). `~/.pix3/cdp-mcp.json` (mode 0600, written by this command,
561
+ rewritten whenever the port or the token moves) holds `wsEndpoint` = `ws://127.0.0.1:<port>/pix3`
562
+ and `wsHeaders` = `{"Authorization":"Bearer <token>"}`: 1.10.1's `--config` reads a JSON object
563
+ with the flags' own options and coercions (`build/src/config/mcp-options.js`; `wsEndpoint` /
564
+ `wsHeaders` in `build/src/config/browser-options.js`, `wsHeaders` parsed as a JSON object); with
565
+ `wsEndpoint` puppeteer connects to that WebSocket only, sending the headers on the upgrade, and
566
+ makes no `/json/*` request. So **the token is in no project file and on no command line** (every
567
+ user of a box can read every process's command line). The version is **pinned**:
568
+ the third-party tool category is experimental and may move in a minor release, so it changes
569
+ only after the S4 transport run is repeated (`CHROME_DEVTOOLS_MCP_VERSION` in
570
+ `src/agent-setup/config.ts`); the inline fallback (`window.__PIX3_DEBUG__.call`) works on any
571
+ version. The port is `--cdp-port`, else what `~/.pix3/chrome.json` recorded, else 9333; the token
572
+ is `~/.pix3/cdp-token` (created here if `pix3 editor` has not run yet). `--remote` is the same
573
+ shape for an agent on a Remote SSH host, with `~/.pix3/remote-cdp.json` (see Remote SSH below).
574
+
575
+ The project files name a path in this machine's home, so the starters' `.gitignore` keeps
576
+ listing `.mcp.json` and `.codex/config.toml`. An entry that already matches is `up to date`; one
577
+ that differs (another version or config file, the P1 `--browserUrl` launch or the earlier
578
+ `--wsEndpoint` + `--wsHeaders` launch that carried the token, each named as such) is reported as
579
+ drift and exit 1 — `--repair` rewrites it after copying the file to `<file>.bak` (the `.bak` of a
580
+ `--wsHeaders` entry still holds the token: delete it); that is the migration of a P1 or an early
581
+ P2 project.
582
+ Nothing is done to a sandbox: Codex must be allowed to reach `127.0.0.1` once when it asks.
583
+ The alternative for every project at once is printed (`claude mcp add --scope user …`, or the
584
+ table in `~/.codex/config.toml`).
585
+
586
+ **The proxy (plan §D.5).** Chrome opens no debugging port; the only way in is the proxy with the
587
+ token, so another local user, a web page or a process that cannot read `~/.pix3/cdp-token`
588
+ cannot drive the editor's Chrome (checked: `../pix3-core-spikes/editor-e2e/gate-p2.mjs`). Same-user
589
+ processes can read the token file — the boundary is the user account, as for any file in the
590
+ home directory. Chrome on macOS / Windows is untested (`.plans/agent-bridge.md` debt).
591
+
592
+ ## Remote SSH — VS Code on a remote host, Chrome on your machine
593
+
594
+ Plan §E.3. You work in VS Code connected to a Linux box over **Remote SSH**: the project, Vite and
595
+ the coding agent (Claude Code, Codex) run there; the browser runs on your machine. The editor
596
+ reaches your browser through VS Code's port forward; the agent reaches that browser through an SSH
597
+ `RemoteForward` of the CDP token proxy, back onto the remote host's loopback.
598
+
599
+ 1. **On the remote host** (VS Code's terminal, in the project): `npm run editor`. Under
600
+ `SSH_CONNECTION` it starts (or reuses) the dev server, launches no Chrome, and prints what to
601
+ do on your machine — with the one remote port this host uses for the forward (9333, or
602
+ `--cdp-port <n>`, which it stores):
603
+
604
+ ```text
605
+ On your machine, once — ~/.ssh/config, under the Host you connect to, then reconnect:
606
+ RemoteForward 127.0.0.1:9333 127.0.0.1:9333
607
+ ExitOnForwardFailure yes
608
+ On your machine, each session — VS Code forwards port 5173 (its Ports tab shows the local address):
609
+ npx @pix3/cli@<version> editor --chrome-only --url http://localhost:5173/__pix3/ --remote-port 9333
610
+ ```
611
+
612
+ VS Code forwards the dev server's port by itself (it sees the URL in the terminal; the Ports
613
+ tab lists it). When the local port is not the same (5173 was taken on your machine), use the
614
+ local address in `--url`.
615
+ 2. **On your machine, once:** add the two lines to `~/.ssh/config` under that `Host`, close the
616
+ remote window and connect again. The first port is the one on the remote host — one port,
617
+ configured on both sides, never "the next free one": if another user of that host holds it,
618
+ the forward must fail visibly (`pix3 editor` there says the port is held; pick another with
619
+ `--cdp-port <n>` there, the same `<n>` in `RemoteForward` and in `--remote-port`); the second
620
+ is your proxy (9333 unless `pix3 editor` said it had to use another). `ExitOnForwardFailure
621
+ yes` is not optional: without it a connection whose forward failed — because someone else on
622
+ the remote host took the port — comes up anyway, and the agent would hand its token to
623
+ whoever holds the port.
624
+ 3. **On your machine, each session:** the `npx @pix3/cli … editor --chrome-only --url …` line.
625
+ No project needed there: it checks that the URL answers as a Pix3 editor, starts Chrome behind
626
+ the token proxy (as `pix3 editor` always does), opens the editor, and records `--remote-port`
627
+ (default 9333) in `~/.pix3/cdp-forward-ports.json`: the proxy proves its token for the ports
628
+ listed there and for its own, nothing else. It then prints one line
629
+ that copies its token to the remote host (`--ssh <host>` fills the host in):
630
+
631
+ ```text
632
+ ssh <host> 'umask 077 && mkdir -p ~/.pix3 && cat > ~/.pix3/remote-cdp-token' < ~/.pix3/cdp-token
633
+ ```
634
+
635
+ Run it once (again only after the token changes). The token goes from your token file through
636
+ the SSH channel into a 0600 file on the remote host: it is never printed, typed, pasted or put
637
+ on a command line.
638
+ 4. **On the remote host, once:** `npx pix3 agent-setup --remote`, then a new agent thread. It
639
+ asks the configured port (the one `pix3 editor` stored, or `--cdp-port <n>`) for **proof**
640
+ that it knows the token (`X-Pix3-Challenge` + `X-Pix3-Proof-Scope: forward` → `X-Pix3-Proof`,
641
+ an HMAC the proxy computes over the challenge **and the port the client dialled**; the token
642
+ itself is never sent to a port that has not proven it), writes `~/.pix3/remote-cdp.json`
643
+ (0600: the endpoint `ws://127.0.0.1:<port>/pix3` and the token, read by chrome-devtools-mcp's
644
+ `--config`), and the project's `.mcp.json` / `.codex/config.toml` entry `npx -y
645
+ chrome-devtools-mcp@1.10.1 … --config=<that file>`, which holds no secret. Something on the
646
+ port that cannot give the proof gets nothing written (exit 1: a squatter waiting for the
647
+ token, or your proxy not told the port — `--remote-port`); a port nothing answers on yet is
648
+ written with a warning. Bound to the port, a proof cannot be relayed: a squatter on 9333 that
649
+ passes the challenge to your real forward on 9334 gets an answer for 9334, which the agent's
650
+ side rejects.
651
+ `npm run editor` on the remote host says whether the forward is live (`CDP forward: live on
652
+ 127.0.0.1:9333`).
653
+
654
+ The agent finds its tab by `.pix3/dev.json`: `publicEditorUrl` is the address your browser
655
+ reached the dev server at — learnt from the editor tab's `Origin` when it differs from the
656
+ server's own (a port forward that is not 1:1), or set with `PIX3_PUBLIC_URL=http://localhost:5174`
657
+ in the environment of the dev server; absent, `editorUrl` is the address.
658
+
659
+ Why it is safe on a shared host: the remote host's loopback is reachable by every user of it, and
660
+ so is the forwarded port — that is what the token is for (no token → 401). On the remote host the
661
+ token lives only in two 0600 files of your home; not in the project, not in any process's command
662
+ line (readable by every user of a host), not in any output. What the token does not protect: a
663
+ process of your own account on the remote host can read it (the same boundary as on your
664
+ machine), and the dev server itself (`/__pix3/api/*` reads and writes the project) answers any
665
+ local user of the remote host, as every Vite dev server does. To rotate: delete
666
+ `~/.pix3/cdp-token` on your machine, `pix3 editor --stop-chrome`, run the `--chrome-only` line and
667
+ the copy line again, then `npx pix3 agent-setup --remote` and a new thread. Untested: a Windows
668
+ or macOS machine on the human side (the `type … | ssh` line is cmd's and PowerShell's).
669
+
670
+ ## The editor bridge — what the tab exposes
671
+
672
+ `packages/editor-core/src/host/bridge-tools.ts` is the tool table (names, descriptions, JSON
673
+ Schemas), `debug-bridge.ts` the one implementation behind two transports: the page answers
674
+ chrome-devtools-mcp's `devtoolstooldiscovery` with the `pix3` group
675
+ (`list_3p_developer_tools` / `execute_3p_developer_tool {toolName, params: "<JSON>"}`), and
676
+ `window.__PIX3_DEBUG__.call(name, params)` is the same call from `evaluate_script`
677
+ (`waitForStableDom: false`). Tools: `pix3_status`, `pix3_sync` (`{expect?, timeoutMs?}` →
678
+ flush, rescan, barrier), `pix3_scene` (`{path?, maxDepth?, nodeId?, find?}`), `pix3_play`
679
+ (`{action: start|stop|restart|pause|status, scenePath?, force?}` — the designer's session is
680
+ refused with `not_owner`, `force` included), `pix3_game_run` (`GameTestService.run`),
681
+ `pix3_screenshot` (`{target: game|viewport}` prepares the view; the picture is
682
+ `take_screenshot`), `pix3_errors` (`{since?, clear?}`). Params are validated on both paths;
683
+ every refusal is `{ok:false, reason, detail}`. `pix3 check`, `pix3 smoke` and `vite build`
684
+ flush the editor through `POST /__pix3/api/flush` (the CLI side is `src/editor-sync.ts`),
685
+ without the bridge. The kit's `pix3-editor` skill is held to the table by `src/kit.spec.ts`.
686
+
687
+ ## Package layout and publishing
688
+
689
+ The published package has **no runtime `dependencies`**, so a cold `npx -y @pix3/cli@X.Y.Z …`
690
+ fetches one tarball and installs nothing else (measured in
691
+ `.plans/measurements/external-agent-phase0-cold-start.md`). `prepack` builds:
692
+
693
+ ```text
694
+ dist/index.js the bin: src/index.ts + yaml, one minified ESM file
695
+ (scripts/build-bin.mjs); lazy commands stay lazy
696
+ dist/validate/prebuilt/ validate + @pix3/runtime + three (scripts/build-validate.mjs)
697
+ dist/smoke/prebuilt/ smoke worker + tree defaults + @pix3/runtime + three (scripts/build-smoke.mjs)
698
+ dist/runtime-types.json runtime-types/ packed into one file (scripts/build-runtime-types.mjs):
699
+ ~1 100 small .d.ts cost npm over a second to unpack on every npx run
700
+ kit/ the agent kit (scripts/build-kit.mjs)
701
+ templates/ copy of packages/create-pix3/templates (scripts/copy-templates.mjs; removed at postpack)
702
+ ```
703
+
704
+ Left out of the bin on purpose: Node built-ins; `esbuild` (an `optionalDependency` — level 2 of
705
+ `validate`, `smoke` and `check` resolve it from the bin with `import.meta.resolve`, and degrade
706
+ with a note when it is absent) and `typescript` (fetched on demand by `check`); and the
707
+ checkout-only modules (`validate/bundle.ts`,
708
+ `smoke/bundle.ts`, the kit generator), which become a throwing stub. Every file the CLI reads from
709
+ its own package is addressed from the package root (`src/package-root.ts`), never relative to the
710
+ current module — in the bundle every module's `import.meta.url` is the bin's. The sources (`node
711
+ src/index.ts`, the specs) never use a prebuilt bundle, even when an old `dist/` exists.
712
+ `src/bin-bundle.spec.ts` builds the bin into a temp package layout and checks `--version` against
713
+ `package.json` (and that against the lockstep root version), the pinned `chrome-devtools-mcp`
714
+ launch `agent-setup` writes, and that nothing but built-ins and the optional externals is
715
+ imported at run time.
716
+
717
+ Publishing is `.github/workflows/publish-packages.yml` (npm Trusted Publishing / OIDC, no token):
718
+ a `runtime-vX.Y.Z` tag publishes `@pix3/runtime` and `@pix3/cli` together, `cli-vX.Y.Z` the CLI
719
+ alone, or run the workflow manually. The job runs `npm ci` at the repo root (the build needs the
720
+ runtime sources, the root TypeScript and esbuild), checks the lockstep version against the root
721
+ and the tag, type-checks, runs the CLI specs and `npm publish`es. Try the tarball locally with
722
+ `npm pack -w packages/cli` at the repo root, then `npx -y --package ./pix3-cli-X.Y.Z.tgz pix3 …`.