@playcraft/cli 0.1.59-beta → 0.1.61-beta

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 (55) hide show
  1. package/dist/cli-root-help.js +1 -0
  2. package/dist/commands/references.js +55 -0
  3. package/dist/index.js +21 -3
  4. package/dist/project-skills/builtin-import.js +2 -2
  5. package/dist/references/client.js +69 -0
  6. package/dist/references/messages.js +57 -0
  7. package/dist/references/protocol.js +32 -0
  8. package/dist/skill-registry/messages.js +2 -2
  9. package/dist/skill-registry/platform-package.js +2 -4
  10. package/dist/workspace-runtime/batch/control.js +3 -3
  11. package/dist/workspace-runtime/batch/deliver.js +4 -3
  12. package/dist/workspace-runtime/batch/generate.js +3 -2
  13. package/dist/workspace-runtime/batch/install-log.js +49 -0
  14. package/dist/workspace-runtime/batch/prepare.js +29 -13
  15. package/dist/workspace-runtime/batch/processes.js +86 -14
  16. package/dist/workspace-runtime/batch/writer-proof.js +2 -2
  17. package/dist/workspace-runtime/codex/app-server.js +52 -2
  18. package/dist/workspace-runtime/codex/config-toml.js +9 -5
  19. package/dist/workspace-runtime/codex/delivery-tools.js +3 -10
  20. package/dist/workspace-runtime/codex/launcher-client.js +46 -24
  21. package/dist/workspace-runtime/codex/model-broker.js +42 -5
  22. package/dist/workspace-runtime/codex/production-skill-tools.js +95 -0
  23. package/dist/workspace-runtime/codex/remix-references-broker.js +184 -0
  24. package/dist/workspace-runtime/codex/scheduled-task-tools.js +80 -0
  25. package/dist/workspace-runtime/processes/skill-command.js +130 -0
  26. package/dist/workspace-runtime/serve.js +9 -1
  27. package/dist/workspace-runtime/server/accept-operation.js +10 -1
  28. package/dist/workspace-runtime/server/directory-access.js +10 -3
  29. package/dist/workspace-runtime/server/dispatch.js +40 -1
  30. package/dist/workspace-runtime/server/http.js +39 -4
  31. package/dist/workspace-runtime/server/operation-record.js +4 -1
  32. package/dist/workspace-runtime/workspaces/dependencies.js +35 -10
  33. package/dist/workspace-runtime/workspaces/developer-command.js +65 -64
  34. package/dist/workspace-runtime/workspaces/directories.js +4 -1
  35. package/dist/workspace-runtime/workspaces/empty-template.js +5 -1
  36. package/dist/workspace-runtime/workspaces/platform-export.js +3 -3
  37. package/dist/workspace-runtime/workspaces/procedural-3d/candidate.js +129 -0
  38. package/dist/workspace-runtime/workspaces/procedural-3d/contracts.js +34 -0
  39. package/dist/workspace-runtime/workspaces/procedural-3d/files.js +83 -0
  40. package/dist/workspace-runtime/workspaces/procedural-3d/host.js +595 -0
  41. package/dist/workspace-runtime/workspaces/procedural-3d/render-worker.js +159 -0
  42. package/dist/workspace-runtime/workspaces/procedural-3d/review-contracts.js +257 -0
  43. package/dist/workspace-runtime/workspaces/procedural-3d/review-page.js +26 -0
  44. package/dist/workspace-runtime/workspaces/remix-reference-skill.js +126 -0
  45. package/dist/workspace-runtime/workspaces/scheduled-task-proposals.js +27 -0
  46. package/dist/workspace-runtime/workspaces/skill-execution-context.js +69 -0
  47. package/dist/workspace-runtime/workspaces/skills.js +18 -6
  48. package/package.json +3 -3
  49. package/project-template/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
  50. package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +0 -2
  51. package/project-template-v2/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
  52. package/project-template-v2/.playcraft/skills.lock.json +14 -0
  53. package/project-templates/empty/AGENTS.md +19 -0
  54. package/project-templates/phaser/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
  55. package/project-templates/threejs/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
@@ -0,0 +1,159 @@
1
+ import { createRequire } from 'node:module';
2
+ import { dirname, join } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
5
+ import { chromium } from 'playwright-core';
6
+ import sharp from 'sharp';
7
+ import { digest } from './files.js';
8
+ import { canonicalJson, reviewProfileHash, validateRenderState, } from './review-contracts.js';
9
+ /** Launched as the development user; Vite and Chromium die with the managed process group. */
10
+ async function main() {
11
+ let raw = '';
12
+ for await (const chunk of process.stdin) {
13
+ raw += chunk;
14
+ if (raw.length > 64 * 1024)
15
+ throw new Error('render_input_limit');
16
+ }
17
+ const input = JSON.parse(raw);
18
+ if (input.reviewProfile &&
19
+ reviewProfileHash(input.reviewProfile.profile) !== input.reviewProfile.hash)
20
+ throw new Error('render_profile_mismatch');
21
+ const project = createRequire(join(input.projectRoot, 'package.json'));
22
+ const vite = await import(pathToFileURL(project.resolve('vite')).href);
23
+ const three = join(dirname(project.resolve('three')), 'three.module.js');
24
+ const threeVersion = JSON.parse(await readFile(join(dirname(three), '../package.json'), 'utf8')).version;
25
+ const server = await vite.createServer({
26
+ configFile: false,
27
+ root: input.sourceRoot,
28
+ logLevel: 'silent',
29
+ resolve: { alias: [{ find: /^three$/, replacement: three }] },
30
+ server: {
31
+ host: '127.0.0.1',
32
+ port: 0,
33
+ strictPort: false,
34
+ fs: { strict: true, allow: [input.sourceRoot, dirname(three)] },
35
+ },
36
+ });
37
+ let browser;
38
+ try {
39
+ await server.listen();
40
+ const address = server.httpServer.address();
41
+ const origin = `http://127.0.0.1:${address.port}`;
42
+ browser = await chromium.launch({
43
+ executablePath: process.env.PLAYCRAFT_CHROMIUM_PATH || '/usr/bin/chromium',
44
+ headless: true,
45
+ args: [
46
+ '--disable-dev-shm-usage',
47
+ '--enable-unsafe-swiftshader',
48
+ '--use-gl=angle',
49
+ '--use-angle=swiftshader',
50
+ ],
51
+ });
52
+ const context = await browser.newContext({
53
+ viewport: { width: 768, height: 768 },
54
+ deviceScaleFactor: 1,
55
+ serviceWorkers: 'block',
56
+ });
57
+ const errors = [];
58
+ await context.route('**/*', (route) => {
59
+ const url = route.request().url();
60
+ if (url === `${origin}/favicon.ico`)
61
+ return route.fulfill({ status: 204 });
62
+ return url.startsWith(`${origin}/`) || /^(data|blob):/.test(url)
63
+ ? route.continue()
64
+ : route.abort('blockedbyclient');
65
+ });
66
+ const page = await context.newPage();
67
+ page.on('pageerror', (error) => errors.push(error.message));
68
+ page.on('console', (message) => {
69
+ if (message.type() === 'error')
70
+ errors.push(message.text());
71
+ });
72
+ page.on('requestfailed', (request) => errors.push(`${request.url()}: ${request.failure()?.errorText}`));
73
+ await page.goto(`${origin}/${input.pagePath.split('/').map(encodeURIComponent).join('/')}`, {
74
+ waitUntil: 'networkidle',
75
+ timeout: 30_000,
76
+ });
77
+ await page.waitForFunction('Boolean(window.__PLAYCRAFT_SCULPT__?.ready)', undefined, {
78
+ timeout: 30_000,
79
+ });
80
+ await page.evaluate('Promise.resolve(window.__PLAYCRAFT_SCULPT__.ready)');
81
+ const renderer = await page.evaluate(`(() => {const c=document.querySelector('canvas'); const g=c?.getContext('webgl2')||c?.getContext('webgl'); if(!g) throw new Error('webgl_unavailable'); const e=g.getExtension('WEBGL_debug_renderer_info'); return {renderer:g.getParameter(e?e.UNMASKED_RENDERER_WEBGL:g.RENDERER),vendor:g.getParameter(e?e.UNMASKED_VENDOR_WEBGL:g.VENDOR)};})()`);
82
+ await mkdir(input.outputRoot, { recursive: true });
83
+ const captures = [];
84
+ const states = {};
85
+ const captureState = async (view, stripped = false) => {
86
+ if (!input.reviewProfile)
87
+ return;
88
+ states[view] = validateRenderState(await page.evaluate('window.__PLAYCRAFT_SCULPT__.getRenderState()'), input.reviewProfile.profile, view, stripped);
89
+ };
90
+ for (const view of input.views) {
91
+ await page.evaluate(`Promise.resolve(window.__PLAYCRAFT_SCULPT__.setView(${JSON.stringify(view)})).then(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))))`);
92
+ await page.screenshot({
93
+ path: join(input.outputRoot, `${view}.png`),
94
+ animations: 'disabled',
95
+ });
96
+ await captureState(view);
97
+ captures.push({ view, file: `${view}.png` });
98
+ }
99
+ await page.evaluate(`Promise.resolve(window.__PLAYCRAFT_SCULPT__.setView('hero')).then(()=>window.__PLAYCRAFT_SCULPT__.setMapStripped(true)).then(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))))`);
100
+ await page.screenshot({
101
+ path: join(input.outputRoot, 'map-stripped.png'),
102
+ animations: 'disabled',
103
+ });
104
+ captures.push({ view: 'map-stripped', file: 'map-stripped.png' });
105
+ await captureState('map-stripped', true);
106
+ const reference = await sharp(input.referencePath)
107
+ .resize(768, 768, { fit: 'contain', background: '#e9e9e9' })
108
+ .png()
109
+ .toBuffer();
110
+ const hero = await sharp(join(input.outputRoot, `${input.views.includes('hero') ? 'hero' : input.views[0]}.png`))
111
+ .resize(768, 768)
112
+ .png()
113
+ .toBuffer();
114
+ await sharp({ create: { width: 1536, height: 768, channels: 3, background: '#e9e9e9' } })
115
+ .composite([
116
+ { input: reference, left: 0, top: 0 },
117
+ { input: hero, left: 768, top: 0 },
118
+ ])
119
+ .png()
120
+ .toFile(join(input.outputRoot, 'comparison.png'));
121
+ captures.push({ view: 'comparison', file: 'comparison.png' });
122
+ let renderReceipt;
123
+ if (input.reviewProfile) {
124
+ const bytes = canonicalJson({
125
+ schemaVersion: 1,
126
+ reviewProfileHash: input.reviewProfile.hash,
127
+ profile: input.reviewProfile.profile,
128
+ views: states,
129
+ });
130
+ await writeFile(join(input.outputRoot, 'render-state.json'), bytes, { flag: 'wx' });
131
+ renderReceipt = { file: 'render-state.json', hash: digest(bytes) };
132
+ }
133
+ await page.evaluate('window.__PLAYCRAFT_SCULPT__.dispose?.()');
134
+ if (errors.length)
135
+ throw new Error(`render_errors: ${errors.join('\n').slice(0, 4000)}`);
136
+ return {
137
+ captures,
138
+ renderer,
139
+ browser: browser.version(),
140
+ node: process.version,
141
+ three: threeVersion,
142
+ vite: vite.version,
143
+ viewport: { width: 768, height: 768, deviceScaleFactor: 1 },
144
+ ready: true,
145
+ ...(renderReceipt ? { renderReceipt } : {}),
146
+ errors,
147
+ };
148
+ }
149
+ finally {
150
+ await browser?.close();
151
+ await server.close();
152
+ }
153
+ }
154
+ main()
155
+ .then((result) => process.stdout.write(JSON.stringify({ ok: true, result })))
156
+ .catch((error) => {
157
+ process.stderr.write(String(error));
158
+ process.exitCode = 1;
159
+ });
@@ -0,0 +1,257 @@
1
+ import { z } from 'zod';
2
+ import { digest, relativeFile } from './files.js';
3
+ export const REVIEW_DIRECTORY = '__playcraft-review';
4
+ export const REVIEW_PAGE = `${REVIEW_DIRECTORY}/index.html`;
5
+ export const REVIEW_VIEWS = ['front', 'right', 'back', 'left', 'hero'];
6
+ const finite = z.number().finite();
7
+ const coordinate = finite.min(-100000).max(100000);
8
+ const vector = z.tuple([coordinate, coordinate, coordinate]);
9
+ const projectedVector = z.tuple([finite, finite, finite]);
10
+ const matrix = z.array(finite).length(16);
11
+ export const reviewProfileSchema = z
12
+ .object({
13
+ schemaVersion: z.literal(1),
14
+ fovDegrees: finite.min(1).max(120),
15
+ near: finite.min(0.0001).max(1000),
16
+ far: finite.max(100000),
17
+ target: vector,
18
+ views: z
19
+ .object({ front: vector, right: vector, back: vector, left: vector, hero: vector })
20
+ .strict(),
21
+ background: z
22
+ .string()
23
+ .regex(/^#[a-fA-F0-9]{6}$/)
24
+ .transform((value) => value.toLowerCase()),
25
+ keyLightPosition: vector,
26
+ referenceCameraStatus: z.literal('estimated'),
27
+ })
28
+ .strict()
29
+ .superRefine((profile, context) => {
30
+ if (profile.far < profile.near + 0.0001)
31
+ context.addIssue({ code: z.ZodIssueCode.custom, message: 'invalid camera range' });
32
+ for (const position of Object.values(profile.views)) {
33
+ const distance = Math.hypot(...position.map((value, index) => value - profile.target[index]));
34
+ if (distance <= profile.near || distance >= profile.far)
35
+ context.addIssue({
36
+ code: z.ZodIssueCode.custom,
37
+ message: 'invalid camera target distance',
38
+ });
39
+ }
40
+ });
41
+ /** A bounded canonical JSON (JavaScript Object Notation, JavaScript对象表示法) encoding. */
42
+ export function canonicalJson(value) {
43
+ let nodes = 0;
44
+ const normalize = (input, depth = 0) => {
45
+ if (++nodes > 4096 || depth > 12)
46
+ throw new Error('procedural_skill_invalid_review_json');
47
+ if (input === null || typeof input === 'string' || typeof input === 'boolean')
48
+ return input;
49
+ if (typeof input === 'number' && Number.isFinite(input))
50
+ return input;
51
+ if (Array.isArray(input))
52
+ return input.map((item) => normalize(item, depth + 1));
53
+ if (input && typeof input === 'object' && Object.getPrototypeOf(input) === Object.prototype)
54
+ return Object.fromEntries(Object.keys(input)
55
+ .sort()
56
+ .map((key) => [key, normalize(input[key], depth + 1)]));
57
+ throw new Error('procedural_skill_invalid_review_json');
58
+ };
59
+ const encoded = JSON.stringify(normalize(value));
60
+ if (Buffer.byteLength(encoded) > 64 * 1024)
61
+ throw new Error('procedural_skill_invalid_review_json');
62
+ return encoded;
63
+ }
64
+ export const reviewProfileHash = (profile) => digest(canonicalJson(profile));
65
+ export const captureV2Schema = z
66
+ .object({
67
+ files: z.array(z.string()).min(1).max(252),
68
+ sourcePaths: z.array(z.string()).min(1).max(128),
69
+ views: z.array(z.enum(REVIEW_VIEWS)).min(3).max(5).default(['front', 'right', 'hero']),
70
+ review: z
71
+ .object({
72
+ factoryPath: z.string(),
73
+ exportName: z.string().regex(/^[A-Za-z_$][A-Za-z0-9_$]{0,79}$/),
74
+ parameters: z.record(z.unknown()).optional(),
75
+ profile: reviewProfileSchema,
76
+ })
77
+ .strict(),
78
+ })
79
+ .strict()
80
+ .superRefine((input, context) => {
81
+ const invalid = () => context.addIssue({ code: z.ZodIssueCode.custom, message: 'invalid controlled review input' });
82
+ if (new Set(input.views).size !== input.views.length || !input.views.includes('hero'))
83
+ invalid();
84
+ if (!input.sourcePaths.includes(input.review.factoryPath) ||
85
+ !/\.(?:[cm]?[jt]sx?)$/.test(input.review.factoryPath))
86
+ invalid();
87
+ if (input.files.some((path) => path.toLowerCase() === REVIEW_DIRECTORY ||
88
+ path.toLowerCase().startsWith(`${REVIEW_DIRECTORY}/`)))
89
+ invalid();
90
+ try {
91
+ relativeFile(input.review.factoryPath);
92
+ if (input.review.parameters &&
93
+ Buffer.byteLength(canonicalJson(input.review.parameters)) > 16 * 1024)
94
+ invalid();
95
+ }
96
+ catch {
97
+ invalid();
98
+ }
99
+ });
100
+ const renderStateSchema = z
101
+ .object({
102
+ schemaVersion: z.literal(1),
103
+ view: z.enum(REVIEW_VIEWS),
104
+ mapStripped: z.boolean(),
105
+ profile: reviewProfileSchema,
106
+ viewport: z
107
+ .object({ width: z.literal(768), height: z.literal(768), deviceScaleFactor: z.literal(1) })
108
+ .strict(),
109
+ camera: z
110
+ .object({
111
+ position: vector,
112
+ target: vector,
113
+ fovDegrees: finite,
114
+ near: finite,
115
+ far: finite,
116
+ projectionMatrix: matrix,
117
+ matrixWorld: matrix,
118
+ matrixWorldInverse: matrix,
119
+ })
120
+ .strict(),
121
+ modelBounds: z.object({ min: vector, max: vector }).strict(),
122
+ projectedBounds: z
123
+ .object({ min: projectedVector, max: projectedVector, conservative: z.literal(true) })
124
+ .strict()
125
+ .nullable(),
126
+ framing: z.enum(['contained', 'bounds-overflow', 'unknown']),
127
+ })
128
+ .strict();
129
+ const close = (a, b) => Math.abs(a - b) <= 1e-7 * Math.max(1, Math.abs(a), Math.abs(b));
130
+ const same = (a, b) => a.length === b.length && a.every((value, index) => close(value, b[index]));
131
+ const cross = (a, b) => [
132
+ a[1] * b[2] - a[2] * b[1],
133
+ a[2] * b[0] - a[0] * b[2],
134
+ a[0] * b[1] - a[1] * b[0],
135
+ ];
136
+ const norm = (a) => {
137
+ const length = Math.hypot(...a);
138
+ return a.map((value) => value / length);
139
+ };
140
+ const dot = (a, b) => a.reduce((sum, value, index) => sum + value * b[index], 0);
141
+ /** Independently check the actual helper state against the fixed camera, never fit model bounds. */
142
+ export function validateRenderState(value, profile, view, stripped = false) {
143
+ const state = renderStateSchema.parse(value);
144
+ const expectedView = stripped ? 'hero' : view;
145
+ const position = profile.views[expectedView];
146
+ const fail = () => {
147
+ throw new Error('procedural_skill_review_state_mismatch');
148
+ };
149
+ if (!position ||
150
+ state.view !== expectedView ||
151
+ state.mapStripped !== stripped ||
152
+ canonicalJson(state.profile) !== canonicalJson(profile) ||
153
+ !same(state.camera.position, position) ||
154
+ !same(state.camera.target, profile.target) ||
155
+ !close(state.camera.fovDegrees, profile.fovDegrees) ||
156
+ !close(state.camera.near, profile.near) ||
157
+ !close(state.camera.far, profile.far))
158
+ fail();
159
+ let zAxis = norm(position.map((value, index) => value - profile.target[index]));
160
+ if (Math.hypot(...cross([0, 1, 0], zAxis)) === 0) {
161
+ zAxis[2] += 0.0001;
162
+ zAxis = norm(zAxis);
163
+ }
164
+ const xAxis = norm(cross([0, 1, 0], zAxis)), yAxis = cross(zAxis, xAxis);
165
+ const world = [...xAxis, 0, ...yAxis, 0, ...zAxis, 0, ...position, 1];
166
+ const inverse = [
167
+ xAxis[0],
168
+ yAxis[0],
169
+ zAxis[0],
170
+ 0,
171
+ xAxis[1],
172
+ yAxis[1],
173
+ zAxis[1],
174
+ 0,
175
+ xAxis[2],
176
+ yAxis[2],
177
+ zAxis[2],
178
+ 0,
179
+ -dot(xAxis, position),
180
+ -dot(yAxis, position),
181
+ -dot(zAxis, position),
182
+ 1,
183
+ ];
184
+ const scale = 1 / Math.tan((profile.fovDegrees * Math.PI) / 360);
185
+ const projection = [
186
+ scale,
187
+ 0,
188
+ 0,
189
+ 0,
190
+ 0,
191
+ scale,
192
+ 0,
193
+ 0,
194
+ 0,
195
+ 0,
196
+ -(profile.far + profile.near) / (profile.far - profile.near),
197
+ -1,
198
+ 0,
199
+ 0,
200
+ (-2 * profile.far * profile.near) / (profile.far - profile.near),
201
+ 0,
202
+ ];
203
+ if (!same(state.camera.matrixWorld, world) ||
204
+ !same(state.camera.matrixWorldInverse, inverse) ||
205
+ !same(state.camera.projectionMatrix, projection))
206
+ fail();
207
+ if (state.modelBounds.min.some((entry, index) => entry > state.modelBounds.max[index]))
208
+ fail();
209
+ const multiply = (m, p) => [0, 1, 2, 3].map((row) => p.reduce((sum, entry, column) => sum + m[column * 4 + row] * entry, 0));
210
+ const points = [];
211
+ for (const x of [state.modelBounds.min[0], state.modelBounds.max[0]])
212
+ for (const y of [state.modelBounds.min[1], state.modelBounds.max[1]])
213
+ for (const z of [state.modelBounds.min[2], state.modelBounds.max[2]]) {
214
+ const clip = multiply(projection, multiply(inverse, [x, y, z, 1]));
215
+ if (!clip.every(Number.isFinite) || clip[3] <= 1e-9) {
216
+ if (state.projectedBounds !== null || state.framing !== 'unknown')
217
+ fail();
218
+ return state;
219
+ }
220
+ points.push(clip.slice(0, 3).map((entry) => entry / clip[3]));
221
+ }
222
+ const min = [0, 1, 2].map((axis) => Math.min(...points.map((point) => point[axis])));
223
+ const max = [0, 1, 2].map((axis) => Math.max(...points.map((point) => point[axis])));
224
+ const framing = min.every((value) => value >= -1) && max.every((value) => value <= 1)
225
+ ? 'contained'
226
+ : 'bounds-overflow';
227
+ if (!state.projectedBounds ||
228
+ !same(state.projectedBounds.min, min) ||
229
+ !same(state.projectedBounds.max, max) ||
230
+ state.framing !== framing)
231
+ fail();
232
+ return state;
233
+ }
234
+ export function validateRenderReceipt(value, profile, views) {
235
+ const receipt = z
236
+ .object({
237
+ schemaVersion: z.literal(1),
238
+ reviewProfileHash: z.string().regex(/^[a-f0-9]{64}$/),
239
+ profile: reviewProfileSchema,
240
+ views: z.record(z.unknown()),
241
+ })
242
+ .strict()
243
+ .parse(value);
244
+ const expected = [...views, 'map-stripped'];
245
+ if (receipt.reviewProfileHash !== reviewProfileHash(profile) ||
246
+ canonicalJson(receipt.profile) !== canonicalJson(profile) ||
247
+ Object.keys(receipt.views).length !== expected.length ||
248
+ expected.some((view) => !Object.hasOwn(receipt.views, view)))
249
+ throw new Error('procedural_skill_review_receipt_mismatch');
250
+ return {
251
+ ...receipt,
252
+ views: Object.fromEntries(expected.map((view) => [
253
+ view,
254
+ validateRenderState(receipt.views[view], profile, view, view === 'map-stripped'),
255
+ ])),
256
+ };
257
+ }
@@ -0,0 +1,26 @@
1
+ import { canonicalJson, REVIEW_DIRECTORY, REVIEW_PAGE, } from './review-contracts.js';
2
+ const literal = (value) => JSON.stringify(value)
3
+ .replace(/</g, '\\u003c')
4
+ .replace(/\u2028/g, '\\u2028')
5
+ .replace(/\u2029/g, '\\u2029');
6
+ /** Only locked helpers and declarative factory data enter the host-owned review page. */
7
+ export function createHostReviewFiles(review, helpers) {
8
+ const factory = `/${review.factoryPath.split('/').map(encodeURIComponent).join('/')}`;
9
+ const profile = canonicalJson(review.profile);
10
+ const page = `<!doctype html><html><head><meta charset="utf-8"><base href="/"><style>html,body{margin:0;width:768px;height:768px;overflow:hidden}</style></head><body><script type="module">
11
+ import { createReviewScene } from '/${REVIEW_DIRECTORY}/review-scene.mjs';
12
+ import * as factory from ${literal(factory)};
13
+ const createModel = factory[${literal(review.exportName)}];
14
+ if (typeof createModel !== 'function') throw new Error('review_factory_missing');
15
+ const parameters = JSON.parse(${literal(canonicalJson(review.parameters ?? {}))});
16
+ const profile = JSON.parse(${literal(profile)});
17
+ const api = createReviewScene(() => createModel(parameters), { profile });
18
+ Object.defineProperty(window, '__PLAYCRAFT_SCULPT__', { value: api, writable: false, configurable: false });
19
+ </script></body></html>`;
20
+ return [
21
+ { path: REVIEW_PAGE, bytes: Buffer.from(page) },
22
+ { path: `${REVIEW_DIRECTORY}/review-scene.mjs`, bytes: helpers.scene },
23
+ { path: `${REVIEW_DIRECTORY}/review-profile.mjs`, bytes: helpers.profile },
24
+ { path: `${REVIEW_DIRECTORY}/profile.json`, bytes: Buffer.from(profile) },
25
+ ];
26
+ }
@@ -0,0 +1,126 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { basename, join } from 'node:path';
4
+ import { buildLocalProjectSkillsSnapshot, canonicalProjectSkillsJson, createCanonicalBundleArchive, declarationDigest, PROJECT_SKILL_SOURCE_ROOTS, readProjectSkillSource, readProjectSkillsLockFile, sourceTreeDigest, validateProjectSkillsSnapshot, } from '@playcraft/common/project-skills';
5
+ import { resolveCliPackageRoot } from '../../scaffold/compose.js';
6
+ import { ensureSharedDirectory, writeSharedFile } from './shared-write.js';
7
+ export const WORKSPACE_REMIX_REFERENCES_SKILL = 'playcraft-remix-references';
8
+ const ORIGINAL_BUNDLED_GUIDE = 'dbc89eb34ca8bc6a25c92d1cd83f6dcc4d8baddacaa7cb92aa8e5aad6986e8f6';
9
+ /** Adds the bundled guide or upgrades its unchanged original revision, preserving user selection. */
10
+ export async function ensureWorkspaceRemixReferenceSkill(root, cache) {
11
+ const management = await ensureSharedDirectory(root, join(root, '.playcraft'));
12
+ const lease = join(management, 'skills-write.lock');
13
+ // private: the write lease is controller coordination state, not workspace content.
14
+ await writeFile(lease, `${process.pid}\n`, { flag: 'wx', mode: 0o600 });
15
+ let ownedSource;
16
+ let replacedSource;
17
+ let committed = false;
18
+ try {
19
+ const declarationPath = join(management, 'skills.json');
20
+ const stat = await lstat(declarationPath);
21
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024)
22
+ throw new Error('workspace_skills_invalid');
23
+ const original = await readFile(declarationPath);
24
+ const previous = validateProjectSkillsSnapshot(original.toString('utf8'), await readProjectSkillsLockFile(root));
25
+ const name = WORKSPACE_REMIX_REFERENCES_SKILL;
26
+ const source = `.agents/skills/${name}`;
27
+ const matches = previous.declaration.entries.filter(entry => entry.entryKey === name || basename(entry.source.kind === 'repository'
28
+ ? entry.source.root : entry.source.skillRef) === name);
29
+ const existing = matches[0];
30
+ let oldContent;
31
+ if (existing) {
32
+ if (matches.length !== 1 || existing.source.kind !== 'repository' || existing.source.root !== source
33
+ || existing.projectFiles?.length || previous.lock.entries.some(entry => entry.dependencies.includes(existing.entryKey)))
34
+ return;
35
+ const locked = previous.lock.entries.find(entry => entry.entryKey === existing.entryKey);
36
+ if (locked.revision.kind !== 'source' || locked.dependencies.length)
37
+ return;
38
+ const current = await readProjectSkillSource(root, existing);
39
+ if (current.files.length !== 1 || current.files[0]?.path !== 'SKILL.md')
40
+ return;
41
+ oldContent = Buffer.from(current.files[0].content);
42
+ const archive = createCanonicalBundleArchive([{ path: 'SKILL.md', data: oldContent }]);
43
+ if (createHash('sha256').update(oldContent).digest('hex') !== ORIGINAL_BUNDLED_GUIDE
44
+ || current.sourceTreeHash !== locked.sourceTreeHash
45
+ || createHash('sha256').update(archive).digest('hex') !== locked.revision.artifactHash)
46
+ return;
47
+ }
48
+ else {
49
+ if (previous.declaration.entries.length >= 512 || previous.lock.entries.some(entry => basename(entry.revision.skillRef) === name))
50
+ return;
51
+ for (const sourceRoot of PROJECT_SKILL_SOURCE_ROOTS) {
52
+ const found = await lstat(join(root, sourceRoot, name)).catch(error => {
53
+ if (error.code === 'ENOENT')
54
+ return undefined;
55
+ throw error;
56
+ });
57
+ if (found)
58
+ return;
59
+ }
60
+ }
61
+ const entryKey = existing?.entryKey ?? `repo-${createHash('sha256').update(source).digest('hex').slice(0, 24)}`;
62
+ if (!existing && previous.declaration.entries.some(entry => entry.entryKey === entryKey))
63
+ return;
64
+ const content = await readFile(join(resolveCliPackageRoot(), 'project-template/.claude/skills', name, 'SKILL.md'));
65
+ const files = [{ path: 'SKILL.md', content }];
66
+ const addition = buildLocalProjectSkillsSnapshot({
67
+ declaration: { schemaVersion: 1, entries: [existing ?? { entryKey, source: { kind: 'repository', root: source }, enabled: true, surface: 'sandbox-agent' }] },
68
+ discovered: [], sources: [{ entryKey, root: source, files, sourceTreeHash: sourceTreeDigest(files) }], issues: [],
69
+ }, existing ? previous.lock : undefined);
70
+ const next = structuredClone(previous);
71
+ if (existing) {
72
+ next.declaration.entries[next.declaration.entries.findIndex(entry => entry.entryKey === entryKey)] = addition.declaration.entries[0];
73
+ next.lock.entries[next.lock.entries.findIndex(entry => entry.entryKey === entryKey)] = addition.lock.entries[0];
74
+ }
75
+ else {
76
+ next.declaration.entries.push(...addition.declaration.entries);
77
+ next.lock.entries.push(...addition.lock.entries);
78
+ }
79
+ next.lock.declarationHash = declarationDigest(next.declaration);
80
+ validateProjectSkillsSnapshot(next.declaration, next.lock);
81
+ // private: `cache` is the controller state dir, not the shared workspace tree.
82
+ await mkdir(cache, { recursive: true, mode: 0o700 });
83
+ for (const artifact of addition.artifacts) {
84
+ const target = join(cache, `${artifact.artifactHash}.tar.gz`);
85
+ // private
86
+ await writeFile(target, artifact.archive, { flag: 'wx', mode: 0o600 }).catch(async (error) => {
87
+ if (error.code !== 'EEXIST' || !(await readFile(target)).equals(artifact.archive))
88
+ throw error;
89
+ });
90
+ await writeSharedFile(root, join(management, 'skill-artifacts', `${artifact.artifactHash}.tar.gz`), artifact.archive);
91
+ }
92
+ await ensureSharedDirectory(root, join(root, '.agents/skills'));
93
+ const destination = join(root, source);
94
+ if (oldContent) {
95
+ const current = await readProjectSkillSource(root, existing);
96
+ if (current.sourceTreeHash !== previous.lock.entries.find(entry => entry.entryKey === entryKey).sourceTreeHash)
97
+ return;
98
+ replacedSource = { path: join(destination, 'SKILL.md'), content: oldContent };
99
+ }
100
+ else {
101
+ await mkdir(destination);
102
+ ownedSource = destination;
103
+ }
104
+ await writeSharedFile(root, join(destination, 'SKILL.md'), content);
105
+ await writeSharedFile(root, declarationPath, `${canonicalProjectSkillsJson(next.declaration)}\n`);
106
+ try {
107
+ await writeSharedFile(root, join(management, 'skills.lock.json'), `${canonicalProjectSkillsJson(next.lock)}\n`);
108
+ }
109
+ catch (error) {
110
+ await writeSharedFile(root, declarationPath, original);
111
+ throw error;
112
+ }
113
+ committed = true;
114
+ }
115
+ finally {
116
+ try {
117
+ if (replacedSource && !committed)
118
+ await writeSharedFile(root, replacedSource.path, replacedSource.content);
119
+ if (ownedSource && !committed)
120
+ await rm(ownedSource, { recursive: true, force: true });
121
+ }
122
+ finally {
123
+ await rm(lease, { force: true });
124
+ }
125
+ }
126
+ }
@@ -0,0 +1,27 @@
1
+ import { platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
2
+ import { SCHEDULED_TASK_TOOL_ERROR_CODES } from '../codex/scheduled-task-tools.js';
3
+ /** This credential can only prepare a card; browser confirmation is a separate authority. */
4
+ export function scheduledTaskProposalHandler(state) {
5
+ return async (action, source, args) => {
6
+ const deadline = Date.now() + 3_000;
7
+ for (;;) {
8
+ const response = await fetch(platformEndpoint(state.config.publicBaseUrl, `/runtime/internal/scheduled-task-proposals/${action}`), {
9
+ method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15_000),
10
+ headers: { authorization: `Bearer ${state.config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': state.config.environment },
11
+ body: JSON.stringify({ runtimeId: state.config.runtimeId, generation: state.config.generation,
12
+ runtimeEpoch: state.runtimeEpoch, ...source, arguments: args }),
13
+ });
14
+ const result = await response.json().catch(() => undefined);
15
+ if (!response.ok) {
16
+ const code = typeof result?.code === 'string' ? result.code : result?.message;
17
+ if (response.status === 409 && code === 'scheduled_task_proposal_source_pending' && Date.now() < deadline
18
+ && state.native?.scheduledTaskProposalSource?.(source)) {
19
+ await new Promise(resolve => setTimeout(resolve, 50));
20
+ continue;
21
+ }
22
+ throw new Error(typeof code === 'string' && SCHEDULED_TASK_TOOL_ERROR_CODES.has(code) ? code : 'scheduled_task_proposal_unavailable');
23
+ }
24
+ return result;
25
+ }
26
+ };
27
+ }
@@ -0,0 +1,69 @@
1
+ import { realpath } from 'node:fs/promises';
2
+ import { PROCEDURAL_3D_SKILL_REF, resolveWorkspaceSkillExecutionPolicy, workspaceComposerSelectionSchema, } from '@playcraft/common/cloud-workspace/runtime';
3
+ import { assertOperationDirectoryAccess } from '../server/directory-access.js';
4
+ import { sessionWorkspaceRoot } from './registry.js';
5
+ import { readWorkspaceSkillSnapshot } from './skills.js';
6
+ /** Read-only preflight. It must precede the directory gate and cannot install or freeze files. */
7
+ export async function preflightSkillExecution(state, request) {
8
+ if (request.kind !== 'session.turn')
9
+ return undefined;
10
+ const body = request.payload && typeof request.payload === 'object'
11
+ ? request.payload
12
+ : {};
13
+ if ('directoryAccessMode' in body || 'skillExecutionPolicyVersion' in body)
14
+ throw new Error('untrusted_runtime_field');
15
+ const selection = workspaceComposerSelectionSchema.safeParse({
16
+ context: body.context,
17
+ workMode: body.workMode,
18
+ });
19
+ if (!selection.success)
20
+ throw new Error('invalid_composer_context');
21
+ if (!selection.data.context?.skills.length)
22
+ return undefined;
23
+ if (request.directoryName === 'root')
24
+ throw new Error('context_directory_mismatch');
25
+ const root = await sessionWorkspaceRoot(state, request.workspaceId, request.directoryName);
26
+ return resolveWorkspaceSkillExecutionPolicy(await readWorkspaceSkillSnapshot(root), selection.data.context.skills);
27
+ }
28
+ /** Native tool callers supply the actual thread/turn binding, never shell-provided identity. */
29
+ export async function resolveActiveSkillExecutionContext(state, input) {
30
+ const skillRef = input.skillRef ?? PROCEDURAL_3D_SKILL_REF;
31
+ if (state.journal.destroyed())
32
+ throw new Error('skill_execution_identity_mismatch');
33
+ if (skillRef !== PROCEDURAL_3D_SKILL_REF)
34
+ throw new Error('context_skill_unavailable');
35
+ const session = state.journal.getSession(input.sessionId);
36
+ const matches = state.journal
37
+ .listOperations()
38
+ .map((item) => item.operation)
39
+ .filter((operation) => operation.kind === 'session.turn' &&
40
+ ['dispatched', 'accepted'].includes(operation.status) &&
41
+ operation.sessionId === input.sessionId &&
42
+ operation.nativeThreadId === input.nativeThreadId &&
43
+ operation.nativeTurnId === input.nativeTurnId &&
44
+ operation.generation === state.config.generation &&
45
+ operation.runtimeEpoch === state.runtimeEpoch &&
46
+ operation.userId === state.config.userId &&
47
+ operation.environment === state.config.environment);
48
+ const operation = matches.length === 1 ? matches[0] : undefined;
49
+ if (!operation ||
50
+ !session ||
51
+ session.threadId !== input.nativeThreadId ||
52
+ (session.activeTurnId !== undefined && session.activeTurnId !== input.nativeTurnId) ||
53
+ session.workspaceId !== operation.workspaceId ||
54
+ (session.directoryName ?? 'basic') !== (operation.directoryName ?? 'basic'))
55
+ throw new Error('skill_execution_identity_mismatch');
56
+ if (operation.directoryAccessMode !== 'exclusive' || operation.skillExecutionPolicyVersion !== 1)
57
+ throw new Error('requires-exclusive-turn');
58
+ if (operation.workMode === 'plan' || operation.permissionMode === 'read-only')
59
+ throw new Error('skill_execution_readonly');
60
+ await assertOperationDirectoryAccess(state, operation, 'exclusive');
61
+ const root = await sessionWorkspaceRoot(state, operation.workspaceId, operation.directoryName);
62
+ if ((await realpath(root)) !== (await realpath(input.root)))
63
+ throw new Error('skill_execution_identity_mismatch');
64
+ const policy = resolveWorkspaceSkillExecutionPolicy(await readWorkspaceSkillSnapshot(root), operation.context?.skills ?? []);
65
+ const entry = policy.entries.find((item) => item.revision.skillRef === skillRef);
66
+ if (!entry || policy.directoryAccessMode !== 'exclusive')
67
+ throw new Error('requires-exclusive-turn');
68
+ return { operation, entry, lockHash: policy.lockHash, declarationHash: policy.declarationHash };
69
+ }