@playcraft/cli 0.1.65 → 0.1.67

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 (102) hide show
  1. package/dist/assets/client.js +4 -2
  2. package/dist/assets/messages.js +2 -0
  3. package/dist/audio-source/messages.js +35 -0
  4. package/dist/commands/assets.js +1 -1
  5. package/dist/commands/audio-source.js +109 -0
  6. package/dist/commands/audio.js +11 -4
  7. package/dist/commands/build.js +4 -2
  8. package/dist/commands/tools-context.js +46 -0
  9. package/dist/commands/tools.js +2 -0
  10. package/dist/model-adaptation/project.js +1 -1
  11. package/dist/project-skills/builtin-import.js +69 -22
  12. package/dist/project-skills/commands.js +11 -2
  13. package/dist/project-skills/lifecycle.js +1 -1
  14. package/dist/project-skills/local-store.js +90 -31
  15. package/dist/project-skills/messages.js +16 -0
  16. package/dist/project-skills/migrate-lock.js +166 -0
  17. package/dist/project-skills/remote-cache.js +36 -5
  18. package/dist/utils/agent-api-client.js +18 -1
  19. package/dist/utils/agent-generation-provenance.js +8 -2
  20. package/dist/utils/audio-export.js +249 -0
  21. package/dist/utils/audio-processor.js +22 -99
  22. package/dist/utils/execution-context-bridge.js +28 -0
  23. package/dist/workspace-runtime/batch/control.js +13 -2
  24. package/dist/workspace-runtime/batch/generate.js +43 -2
  25. package/dist/workspace-runtime/batch/prepare.js +4 -0
  26. package/dist/workspace-runtime/batch/skill-artifacts.js +103 -0
  27. package/dist/workspace-runtime/batch/source-capture.js +6 -8
  28. package/dist/workspace-runtime/codex/app-server.js +26 -2
  29. package/dist/workspace-runtime/codex/execution-context-broker.js +61 -0
  30. package/dist/workspace-runtime/codex/execution-context-tools.js +37 -0
  31. package/dist/workspace-runtime/codex/generation-provenance-broker.js +9 -6
  32. package/dist/workspace-runtime/codex/level-editor-tools.js +18 -0
  33. package/dist/workspace-runtime/codex/model-broker.js +15 -2
  34. package/dist/workspace-runtime/codex/scheduled-task-tools.js +1 -1
  35. package/dist/workspace-runtime/previews/finalize.js +1 -1
  36. package/dist/workspace-runtime/previews/output.js +4 -1
  37. package/dist/workspace-runtime/previews/publish.js +5 -2
  38. package/dist/workspace-runtime/previews/runtime-preview.js +38 -3
  39. package/dist/workspace-runtime/serve.js +6 -0
  40. package/dist/workspace-runtime/server/accept-operation.js +85 -13
  41. package/dist/workspace-runtime/server/audio-assets.js +211 -0
  42. package/dist/workspace-runtime/server/audio-export.js +136 -0
  43. package/dist/workspace-runtime/server/directory-access.js +222 -67
  44. package/dist/workspace-runtime/server/dispatch.js +252 -29
  45. package/dist/workspace-runtime/server/http.js +97 -3
  46. package/dist/workspace-runtime/server/operation-record.js +1 -1
  47. package/dist/workspace-runtime/workspaces/artifact-files.js +10 -3
  48. package/dist/workspace-runtime/workspaces/artifacts.js +47 -6
  49. package/dist/workspace-runtime/workspaces/audio-assets/artifact-package.js +104 -0
  50. package/dist/workspace-runtime/workspaces/audio-assets/binding-usage.js +71 -0
  51. package/dist/workspace-runtime/workspaces/audio-assets/build-inputs.js +76 -0
  52. package/dist/workspace-runtime/workspaces/audio-assets/render-worker.js +117 -0
  53. package/dist/workspace-runtime/workspaces/audio-assets/renderer.js +128 -0
  54. package/dist/workspace-runtime/workspaces/audio-assets/representations.js +73 -0
  55. package/dist/workspace-runtime/workspaces/audio-assets/service.js +327 -0
  56. package/dist/workspace-runtime/workspaces/audio-assets/storage.js +125 -0
  57. package/dist/workspace-runtime/workspaces/build-isolation.js +2 -0
  58. package/dist/workspace-runtime/workspaces/composer-context.js +40 -1
  59. package/dist/workspace-runtime/workspaces/empty-template.js +1 -1
  60. package/dist/workspace-runtime/workspaces/execution-context.js +24 -0
  61. package/dist/workspace-runtime/workspaces/files.js +3 -5
  62. package/dist/workspace-runtime/workspaces/level-editor-agent-store.js +143 -0
  63. package/dist/workspace-runtime/workspaces/level-editor-agent.js +410 -0
  64. package/dist/workspace-runtime/workspaces/level-editor-atomic.js +424 -0
  65. package/dist/workspace-runtime/workspaces/level-editor-extensions.js +140 -0
  66. package/dist/workspace-runtime/workspaces/level-editor-format.js +75 -0
  67. package/dist/workspace-runtime/workspaces/level-editor-history-store.js +250 -0
  68. package/dist/workspace-runtime/workspaces/level-editor-json5.js +132 -0
  69. package/dist/workspace-runtime/workspaces/level-editor-requests.js +724 -0
  70. package/dist/workspace-runtime/workspaces/level-editor.js +454 -0
  71. package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +62 -22
  72. package/dist/workspace-runtime/workspaces/parameter-production.js +1 -1
  73. package/dist/workspace-runtime/workspaces/remix-reference-skill.js +4 -3
  74. package/dist/workspace-runtime/workspaces/skill-artifact-loader.js +19 -5
  75. package/dist/workspace-runtime/workspaces/skills.js +48 -13
  76. package/package.json +6 -4
  77. package/project-template/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
  78. package/project-template/.claude/skills/playcraft-level-editor/SKILL.md +34 -0
  79. package/project-template/.claude/skills/playcraft-level-editor/references/authoring-contract.json +3475 -0
  80. package/project-template/.claude/skills/playcraft-level-editor/references/extensions.md +37 -0
  81. package/project-template/.claude/skills/playcraft-level-editor/references/preview.md +15 -0
  82. package/project-template/.claude/skills/playcraft-level-editor/references/protocol-v1.md +35 -0
  83. package/project-template/vite.config.ts +1 -1
  84. package/project-template-v2/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
  85. package/project-template-v2/.playcraft/skills.lock.json +9 -2
  86. package/project-template-v2/vite.config.ts +1 -1
  87. package/project-templates/empty/AGENTS.md +8 -4
  88. package/project-templates/phaser/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
  89. package/project-templates/phaser/vite.config.ts +1 -1
  90. package/project-templates/playable-template-phaser/vite.config.ts +1 -1
  91. package/project-templates/threejs/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
  92. package/project-templates/threejs/vite.config.ts +1 -1
  93. package/templates/preview-runtime/v1/README.md +12 -0
  94. package/templates/preview-runtime/v1/build-preview.mjs +17 -8
  95. package/templates/preview-runtime/v1/manifest.json +9 -6
  96. package/templates/preview-runtime/v1/release-guard.mjs +1 -1
  97. package/templates/preview-runtime/v1/runtime.d.mts +1 -3
  98. package/templates/preview-runtime/v1/runtime.d.ts +1 -3
  99. package/templates/preview-runtime/v1/runtime.mjs +1142 -257
  100. package/templates/preview-runtime/v1/types/host.d.ts +29 -0
  101. package/templates/preview-runtime/v1/types/index.d.ts +6 -0
  102. package/templates/preview-runtime/v1/types/level-editor.d.ts +123 -0
@@ -57,19 +57,17 @@ export async function readSourceFiles(root) {
57
57
  }
58
58
  return files;
59
59
  }
60
- /**
61
- * Drops paths whose bytes change between two reads. A managed writer that could not be stopped
62
- * does not become part of the frozen input.
63
- */
60
+ /** A changing source must not silently lose files from the captured input. */
64
61
  export async function filterUnstableFiles(root, files) {
65
62
  const again = await readSourceFiles(root);
66
- const stable = new Map();
63
+ if (again.size !== files.size)
64
+ throw new Error('batch_source_changed');
67
65
  for (const [path, file] of files) {
68
66
  const next = again.get(path);
69
- if (next && next.body.equals(file.body) && next.mode === file.mode)
70
- stable.set(path, file);
67
+ if (!next || !next.body.equals(file.body) || next.mode !== file.mode)
68
+ throw new Error('batch_source_changed');
71
69
  }
72
- return stable;
70
+ return files;
73
71
  }
74
72
  /** Independent root commit. The source branch, index and config are never written. */
75
73
  export async function captureIndependentSource(source, destination) {
@@ -21,6 +21,8 @@ import { WORKSPACE_DELIVERY_TOOLS, WorkspaceDeliveryTools, isWorkspaceDeliveryTo
21
21
  import { WORKSPACE_UI_INSTRUCTIONS } from './ui-instructions.js';
22
22
  import { WORKSPACE_SCHEDULED_TASK_TOOLS, WorkspaceScheduledTaskTools, isWorkspaceScheduledTaskTool } from './scheduled-task-tools.js';
23
23
  import { PROCEDURAL_3D_TOOL, ProductionSkillTools } from './production-skill-tools.js';
24
+ import { WORKSPACE_EXECUTION_CONTEXT_TOOL, WorkspaceExecutionContextTools } from './execution-context-tools.js';
25
+ import { WORKSPACE_LEVEL_EDITOR_TOOL, requestLevelEditorTool } from './level-editor-tools.js';
24
26
  function withModelShell(config, modelConfigId) {
25
27
  if (!modelConfigId || !/^[0-9a-f-]{36}$/i.test(modelConfigId))
26
28
  return config;
@@ -32,7 +34,7 @@ const ENGINEERING_BATCH_INSTRUCTIONS = [
32
34
  'When the user requests multiple independent engineering variants, create a real platform batch using playcraft tools create-batch --prompt <the complete request, including constraints established in this conversation> --workspace-id <the trusted batch request workspaceId> --source-directory <the trusted batch request sourceDirectoryName> --client-request-id <the trusted batch request clientRequestId> --json.',
33
35
  'Distinguish independent variants from multiple assets, levels or themes within one engineering project. Use the current session directory supplied by the runtime as the batch source; do not infer it from a preview or another conversation.',
34
36
  'The platform freezes the current source and generates each planned variant in its own Remix directory. Submission authorizes automatic generation without another prompt approval. Keep the source unchanged for this batch; do not replace independent variants with theme switching in the source.',
35
- 'Pass --team-id when the trusted batch request context supplies teamId. Creation means queued; use playcraft tools get-batch --batch-id <the actual batch identifier> for results. On an uncertain creation, reuse the same clientRequestId. Report creation failures without falling back to editing the source.',
37
+ 'Creation means queued; use playcraft tools get-batch --batch-id <the actual batch identifier> for results. On an uncertain creation, reuse the same clientRequestId. Report creation failures without falling back to editing the source.',
36
38
  ].join('\n');
37
39
  const MEMBER_EXECUTION_INSTRUCTIONS = [
38
40
  'You are implementing one assigned variant in an independent Remix directory which the platform has already created.',
@@ -197,11 +199,14 @@ export function createCodexAppServerAdapter(options) {
197
199
  proposalId, sessionId: threads.get(source.threadId)?.sessionId, threadId: source.threadId, turnId: source.turnId,
198
200
  }))) : undefined;
199
201
  const productionSkill = options.productionSkill ? new ProductionSkillTools(options.productionSkill) : undefined;
202
+ const executionContext = options.executionContext ? new WorkspaceExecutionContextTools(options.executionContext) : undefined;
200
203
  const dynamicTools = [
201
204
  ...(options.workspaceUi ? WORKSPACE_UI_TOOLS : []),
202
205
  ...(deliveryTools ? WORKSPACE_DELIVERY_TOOLS : []),
203
206
  ...(scheduledTaskTools ? WORKSPACE_SCHEDULED_TASK_TOOLS : []),
204
207
  ...(productionSkill ? [PROCEDURAL_3D_TOOL] : []),
208
+ ...(executionContext ? [WORKSPACE_EXECUTION_CONTEXT_TOOL] : []),
209
+ ...(options.levelEditorTasks ? [WORKSPACE_LEVEL_EDITOR_TOOL] : []),
205
210
  ];
206
211
  function emitCollected(event) {
207
212
  for (const listener of collected)
@@ -310,7 +315,6 @@ export function createCodexAppServerAdapter(options) {
310
315
  'You coordinate PlayCraft batches. The workspace engineering directories are read-only in this conversation.',
311
316
  `Workspace identity: ${scope}. Engineering source root: ${JSON.stringify(cwd)}. Default source directory: basic.`,
312
317
  'For a request to create multiple independent variants, create a real platform batch using playcraft tools create-batch --prompt <the complete user request> --workspace-id <workspace identity> --source-directory <basic or the explicitly selected remix directory> --client-request-id <the current batch request context identity> --json.',
313
- 'When the batch request context provides teamId, pass it as --team-id; the platform verifies membership before creating project ownership for a standalone workspace.',
314
318
  'The platform plans differences, freezes the selected source and generates independent Remix engineering directories. It automatically starts generation; no browser approval is required. Preserve the source files and source Git history.',
315
319
  'Do not implement a batch as theme switching inside one engineering directory. Do not generate or repair engineering in this coordinator conversation; the platform production tasks own that work.',
316
320
  'If the user asks for status, use playcraft tools get-batch --batch-id <the actual batch identifier>. A successful create-batch response means queued, not completed. Report the actual identifier and current status; never claim generation completed without platform results.',
@@ -633,6 +637,26 @@ export function createCodexAppServerAdapter(options) {
633
637
  }
634
638
  if (method === 'item/tool/call') {
635
639
  const thread = typeof payload.threadId === 'string' ? threads.get(payload.threadId) : undefined;
640
+ if (executionContext && payload.tool === WORKSPACE_EXECUTION_CONTEXT_TOOL.name) {
641
+ const current = () => Boolean(thread && threads.get(String(payload.threadId)) === thread && thread.turnActive
642
+ && payload.turnId === thread.lastTurnId && !childParents.has(String(payload.threadId)));
643
+ setTimeout(() => {
644
+ void executionContext.request(id, payload, nativeClient, thread?.cwd ? {
645
+ cwd: thread.cwd, sessionId: thread.sessionId, current,
646
+ } : undefined);
647
+ }, 0);
648
+ return;
649
+ }
650
+ if (options.levelEditorTasks && payload.tool === WORKSPACE_LEVEL_EDITOR_TOOL.name) {
651
+ const trusted = thread?.turnActive && thread.cwd && thread.activeMode === 'execute' && !childParents.has(String(payload.threadId))
652
+ && !options.fixedExecution && !options.trustedExecution?.(thread.sessionId) && thread.permissionMode !== 'read-only'
653
+ && !thread.readOnlyConfirmed && payload.turnId === thread.lastTurnId && typeof payload.threadId === 'string'
654
+ && typeof payload.turnId === 'string' && typeof payload.callId === 'string' && payload.callId.length > 0;
655
+ void requestLevelEditorTool(options.levelEditorTasks, id, payload, nativeClient, trusted ? {
656
+ sessionId: thread.sessionId, cwd: thread.cwd, threadId: payload.threadId, turnId: payload.turnId, toolCallId: payload.callId,
657
+ } : undefined);
658
+ return;
659
+ }
636
660
  if (scheduledTaskTools && isWorkspaceScheduledTaskTool(payload.tool)) {
637
661
  const threadId = String(payload.threadId ?? ''), turnId = String(payload.turnId ?? '');
638
662
  const current = () => {
@@ -0,0 +1,61 @@
1
+ import { workspaceExecutionContextQuerySchema } from '@playcraft/common/cloud-workspace/runtime';
2
+ import { executionContextToolHandler } from '../workspaces/execution-context.js';
3
+ /** Reads use the existing authenticated per-session broker route, without trusting shell scope fields. */
4
+ export class WorkspaceExecutionContextBroker {
5
+ state;
6
+ constructor(state) {
7
+ this.state = state;
8
+ }
9
+ current(source, valid) {
10
+ const state = this.state, session = state.journal.getSession(source.sessionId);
11
+ const operations = state.journal.listOperations().map(item => item.operation).filter(operation => ['session.turn', 'session.review', 'session.compact'].includes(operation.kind) && operation.sessionId === source.sessionId
12
+ && operation.clientRequestId === source.clientRequestId
13
+ && operation.generation === state.config.generation && operation.runtimeEpoch === state.runtimeEpoch
14
+ && ['dispatched', 'accepted'].includes(operation.status));
15
+ const operation = operations.length === 1 ? operations[0] : undefined;
16
+ return Boolean(valid() && !state.journal.destroyed() && operation?.nativeThreadId && operation.nativeTurnId
17
+ && session?.threadId === operation.nativeThreadId && session?.activeTurnId === operation.nativeTurnId
18
+ && session?.cwd === source.cwd && session.workspaceId === operation.workspaceId
19
+ && (session.directoryName ?? 'basic') === operation.directoryName
20
+ && !session.turnBlock && ['running', 'waiting_for_user'].includes(session.turnStatus ?? ''));
21
+ }
22
+ async forward(request, response, source, valid) {
23
+ const reply = (status, value) => {
24
+ response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
25
+ response.end(JSON.stringify(value));
26
+ };
27
+ try {
28
+ if (request.socket.remoteAddress !== '127.0.0.1' || !this.current(source, valid))
29
+ return reply(403, { code: 'execution_context_access_denied' });
30
+ let text = '';
31
+ for await (const chunk of request) {
32
+ text += chunk.toString();
33
+ if (Buffer.byteLength(text) > 4096)
34
+ return reply(400, { code: 'execution_context_invalid_input' });
35
+ }
36
+ let raw;
37
+ try {
38
+ raw = JSON.parse(text);
39
+ }
40
+ catch {
41
+ return reply(400, { code: 'execution_context_invalid_input' });
42
+ }
43
+ const parsed = workspaceExecutionContextQuerySchema.safeParse(raw);
44
+ if (!parsed.success)
45
+ return reply(400, { code: 'execution_context_invalid_input' });
46
+ if (!this.current(source, valid))
47
+ return reply(403, { code: 'execution_context_access_denied' });
48
+ const result = await executionContextToolHandler(this.state)(parsed.data, source);
49
+ if (!this.current(source, valid))
50
+ return reply(403, { code: 'execution_context_access_denied' });
51
+ reply(200, result);
52
+ }
53
+ catch (error) {
54
+ const denied = error instanceof Error && ['execution_context_access_denied', 'execution_context_scope_unavailable'].includes(error.message);
55
+ if (!response.headersSent)
56
+ reply(denied ? 403 : 503, { code: denied ? 'execution_context_access_denied' : 'execution_context_unavailable' });
57
+ else
58
+ response.destroy();
59
+ }
60
+ }
61
+ }
@@ -0,0 +1,37 @@
1
+ import { workspaceExecutionContextQuerySchema, workspaceExecutionContextReadSchema, } from '@playcraft/common/cloud-workspace/runtime';
2
+ import { rejectTool, toolContent } from './tool-response.js';
3
+ export const WORKSPACE_EXECUTION_CONTEXT_TOOL = {
4
+ type: 'function', name: 'playcraft_get_execution_context',
5
+ description: 'Read selected current user, team, workspace or clock information for this execution. Scope and permissions come from the runtime. Request only needed fields. Omit knownRevision when the corresponding information is no longer visible, including after compaction. Clock reads always sample the current time.',
6
+ inputSchema: { type: 'object', properties: {
7
+ fields: { type: 'array', items: { type: 'string', enum: ['user', 'team', 'workspace', 'clock'] },
8
+ minItems: 1, maxItems: 4, uniqueItems: true },
9
+ knownRevision: { type: 'string', pattern: '^[a-f0-9]{64}$',
10
+ description: 'A revision from a previous read of the same fields, only when those values remain visible.' },
11
+ }, required: ['fields'], additionalProperties: false },
12
+ };
13
+ /** Explicit reads always reach the authority again, including conditional and repeated reads. */
14
+ export class WorkspaceExecutionContextTools {
15
+ handler;
16
+ constructor(handler) {
17
+ this.handler = handler;
18
+ }
19
+ async request(requestId, payload, client, context) {
20
+ if (!context?.current() || payload.namespace || payload.tool !== WORKSPACE_EXECUTION_CONTEXT_TOOL.name)
21
+ return rejectTool(client, requestId, 'execution_context_source_unavailable');
22
+ const parsed = workspaceExecutionContextQuerySchema.safeParse(payload.arguments);
23
+ if (!parsed.success)
24
+ return rejectTool(client, requestId, 'execution_context_invalid_input');
25
+ try {
26
+ const value = workspaceExecutionContextReadSchema.parse(await this.handler(parsed.data, { cwd: context.cwd, sessionId: context.sessionId }));
27
+ if (!context.current())
28
+ return rejectTool(client, requestId, 'execution_context_source_unavailable');
29
+ await client.respond(requestId, { success: true, contentItems: toolContent(value) });
30
+ }
31
+ catch (error) {
32
+ const allowed = new Set(['execution_context_access_denied', 'execution_context_scope_unavailable']);
33
+ await rejectTool(client, requestId, error instanceof Error && allowed.has(error.message)
34
+ ? error.message : 'execution_context_unavailable');
35
+ }
36
+ }
37
+ }
@@ -13,11 +13,11 @@ export class WorkspaceGenerationProvenanceBroker {
13
13
  const root = this.state.native?.executionProcess?.();
14
14
  return socketPath && root ? prepareLauncherOrigin({ socketPath, rootPid: root.pid, runtimeEpoch: this.state.runtimeEpoch }) : undefined;
15
15
  }
16
- operation(context, batch = false) {
16
+ operation(context, scoped = false) {
17
17
  let active = this.state.journal.listOperations().map(item => item.operation).filter(operation => ['session.turn', 'session.review', 'session.compact'].includes(operation.kind)
18
18
  && ['dispatched', 'accepted', 'uncertain'].includes(operation.status)
19
19
  && operation.generation === this.state.config.generation && operation.runtimeEpoch === this.state.runtimeEpoch);
20
- if (batch) {
20
+ if (scoped) {
21
21
  const owned = active.find(operation => operation.sessionId === context.sessionId && operation.clientRequestId === context.clientRequestId);
22
22
  if (!owned)
23
23
  return undefined;
@@ -50,13 +50,16 @@ export class WorkspaceGenerationProvenanceBroker {
50
50
  const parsed = requestSchema.safeParse(JSON.parse(text));
51
51
  if (!parsed.success)
52
52
  return deny();
53
- const batch = parsed.data.toolPath === '/agent/tools/create-batch';
54
- const operation = this.operation(context, batch);
53
+ const audio = parsed.data.toolPath === '/agent/tools/workspace-audio';
54
+ if (audio && context.readOnly)
55
+ return deny();
56
+ const scoped = audio || parsed.data.toolPath === '/agent/tools/create-batch';
57
+ const operation = this.operation(context, scoped);
55
58
  if (!operation || !await verifyLauncherOrigin({ socketPath, boundaryId: context.boundaryId,
56
59
  runtimeEpoch: this.state.runtimeEpoch, serverPort: request.socket.localPort, clientPort: request.socket.remotePort, cwd: context.originCwd ?? context.cwd }))
57
60
  return deny();
58
61
  // The socket lookup may take time. Never attest a turn which ended or changed meanwhile.
59
- if (!valid() || this.operation(context, batch)?.operationId !== operation.operationId)
62
+ if (!valid() || this.operation(context, scoped)?.operationId !== operation.operationId)
60
63
  return deny();
61
64
  const config = this.state.config;
62
65
  const input = agentGenerationProvenanceRequestSchema.parse({ ...parsed.data,
@@ -74,7 +77,7 @@ export class WorkspaceGenerationProvenanceBroker {
74
77
  return deny();
75
78
  }
76
79
  const value = z.object({ proof: z.string().min(1).max(8192) }).strict().parse(await upstream.json());
77
- if (!valid() || this.operation(context, batch)?.operationId !== operation.operationId)
80
+ if (!valid() || this.operation(context, scoped)?.operationId !== operation.operationId)
78
81
  return deny();
79
82
  response.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
80
83
  response.end(JSON.stringify(value));
@@ -0,0 +1,18 @@
1
+ import { levelEditorTaskIntentSchema } from '@playcraft/common/cloud-workspace/runtime';
2
+ import { rejectTool, toolContent } from './tool-response.js';
3
+ export const WORKSPACE_LEVEL_EDITOR_TOOL = { type: 'function', name: 'playcraft_request_level_editor_task',
4
+ description: 'Request creation, evolution or repair of this project’s declarative level editor. This records a request only. Finish this turn normally without doing the same initialization yourself; the platform then runs the locked level-editor skill in a managed follow-up turn in this conversation. A requested result is not a completed editor.',
5
+ inputSchema: { type: 'object', properties: {
6
+ action: { type: 'string', enum: ['initialize', 'evolve', 'repair'] }, goal: { type: 'string', minLength: 1, maxLength: 4000 }, editorId: { type: 'string' },
7
+ }, required: ['action', 'goal'], additionalProperties: false } };
8
+ export async function requestLevelEditorTool(handler, id, payload, client, source) {
9
+ const parsed = levelEditorTaskIntentSchema.safeParse(payload.arguments);
10
+ if (!source || !parsed.success || payload.namespace)
11
+ return rejectTool(client, id, 'level_editor_request_unavailable');
12
+ try {
13
+ await client.respond(id, { success: true, contentItems: toolContent(await handler(source, parsed.data)) });
14
+ }
15
+ catch (error) {
16
+ await rejectTool(client, id, error instanceof Error && /^level_editor_/.test(error.message) ? error.message : 'level_editor_request_failed');
17
+ }
18
+ }
@@ -8,6 +8,7 @@ import { workspaceContextFromRoot } from '../workspaces/registry.js';
8
8
  import { PLATFORM_ROUTES, modelResponsesPath, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
9
9
  import { WorkspaceGenerationProvenanceBroker } from './generation-provenance-broker.js';
10
10
  import { WorkspaceRemixReferencesBroker } from './remix-references-broker.js';
11
+ import { WorkspaceExecutionContextBroker } from './execution-context-broker.js';
11
12
  const credentialSchema = z.object({
12
13
  token: z.string().min(1), expiresAt: z.string().datetime(),
13
14
  configId: z.string().uuid(), model: z.string().min(1).max(128),
@@ -30,10 +31,12 @@ export class WorkspaceModelBroker {
30
31
  sessionRequests = new Map();
31
32
  generation;
32
33
  references;
34
+ executionContext;
33
35
  constructor(state) {
34
36
  this.state = state;
35
37
  this.generation = new WorkspaceGenerationProvenanceBroker(state);
36
38
  this.references = new WorkspaceRemixReferencesBroker(state);
39
+ this.executionContext = new WorkspaceExecutionContextBroker(state);
37
40
  }
38
41
  async start() {
39
42
  if (this.server)
@@ -182,14 +185,15 @@ export class WorkspaceModelBroker {
182
185
  const cancel = () => abort.abort();
183
186
  response.once('close', cancel);
184
187
  try {
185
- const match = /^\/([0-9a-f-]{36}(?:\/[0-9a-f]{32})?)\/(responses|generation-provenance|remix-references)$/.exec(request.url ?? '');
188
+ const match = /^\/([0-9a-f-]{36}(?:\/[0-9a-f]{32})?)\/(responses|generation-provenance|remix-references|execution-context)$/.exec(request.url ?? '');
186
189
  const route = match ? this.routes.get(match[1]) : undefined;
187
190
  const selected = route?.selected;
188
191
  const entry = selected?.entry;
189
192
  const presented = Buffer.from(request.headers.authorization ?? '');
190
193
  const generationRequest = match?.[2] === 'generation-provenance';
194
+ const contextRequest = match?.[2] === 'execution-context';
191
195
  const referencesRequest = match?.[2] === 'remix-references';
192
- const expected = Buffer.from(route ? `Bearer ${generationRequest ? route.generationCapability
196
+ const expected = Buffer.from(route ? `Bearer ${generationRequest || contextRequest ? route.generationCapability
193
197
  : referencesRequest ? route.referencesCapability : route.capability}` : '');
194
198
  if (request.method !== 'POST' || !entry || !expected.length || presented.length !== expected.length
195
199
  || !timingSafeEqual(presented, expected) || !this.valid(entry)) {
@@ -206,6 +210,15 @@ export class WorkspaceModelBroker {
206
210
  await this.generation.forward(request, response, selected.feedback, () => this.valid(entry) && route?.selected === selected);
207
211
  return;
208
212
  }
213
+ if (contextRequest) {
214
+ if (!selected?.feedback) {
215
+ response.writeHead(403);
216
+ response.end();
217
+ return;
218
+ }
219
+ await this.executionContext.forward(request, response, selected.feedback, () => this.valid(entry) && route?.selected === selected);
220
+ return;
221
+ }
209
222
  if (referencesRequest) {
210
223
  if (!selected?.feedback) {
211
224
  response.writeHead(403);
@@ -3,7 +3,7 @@ import { scheduledTaskProposalDraftSchema, scheduledTaskProposalDraftJsonSchema
3
3
  import { rejectTool, toolContent } from './tool-response.js';
4
4
  export const WORKSPACE_SCHEDULED_TASK_TOOLS = [
5
5
  { type: 'function', name: 'playcraft_get_scheduled_task_context',
6
- description: 'Read server time, scheduling defaults, and tasks actually related to this conversation. The returned timeZone describes the server clock, not the user’s local timezone; clarify the intended timezone when it is unknown. When several tasks match, ask the user which task to change. This tool never changes a task.',
6
+ description: 'Read current server time, the resolved workspace or user timezone, scheduling defaults, and tasks actually related to this conversation. Use the returned timeZone for local scheduling unless the user explicitly requests another timezone. When several tasks match, ask the user which task to change. This tool never changes a task.',
7
7
  inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
8
8
  { type: 'function', name: 'playcraft_propose_scheduled_task',
9
9
  description: 'Prepare a scheduled-task creation or change for the user to review in a persistent confirmation card. Read scheduling context first. This does not create or change a task: only the user can confirm the card. A pending_confirmation result must never be reported as an applied task.',
@@ -59,6 +59,6 @@ export async function finalizeWorkspacePlayable(root, built, tool, options = {})
59
59
  throw new Error('preview_source_changed');
60
60
  const current = await readWorkspacePlayable(root).catch(() => undefined);
61
61
  if (!current?.bytes.equals(bytes))
62
- await saveWorkspacePlayableOutput(root, bytes);
62
+ await saveWorkspacePlayableOutput(root, bytes, options);
63
63
  await captureGeneratedArtifact(WORKSPACE_PLAYABLE_OUTPUT_PATH, tool, {}, root, createHash('sha256').update(bytes).digest('hex'), options.source);
64
64
  }
@@ -3,7 +3,7 @@ import { chmod, lstat, mkdir, realpath, rename, rm, writeFile } from 'node:fs/pr
3
3
  import { dirname, join, relative } from 'node:path';
4
4
  import { WORKSPACE_PLAYABLE_OUTPUT_PATH } from '@playcraft/common/cloud-workspace/runtime';
5
5
  /** Persist only validated, self-contained build bytes; unrelated delivery files are preserved. */
6
- export async function saveWorkspacePlayableOutput(root, bytes) {
6
+ export async function saveWorkspacePlayableOutput(root, bytes, options = {}) {
7
7
  const workspace = await realpath(root);
8
8
  let directory = workspace;
9
9
  for (const segment of dirname(WORKSPACE_PLAYABLE_OUTPUT_PATH).split('/')) {
@@ -30,6 +30,9 @@ export async function saveWorkspacePlayableOutput(root, bytes) {
30
30
  return undefined; throw error; });
31
31
  if (stat && (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1))
32
32
  throw new Error('preview_build_failed');
33
+ const signature = stat ? JSON.stringify([stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs]) : undefined;
34
+ if ('expectedSignature' in options && signature !== options.expectedSignature)
35
+ throw new Error('preview_source_changed');
33
36
  await rename(temporary, target);
34
37
  }
35
38
  finally {
@@ -13,6 +13,7 @@ import { classifyPreviewOutput, findExternalLocalReferences } from './single-fil
13
13
  import { finalizeWorkspacePlayable, readWorkspacePlayable, workspacePlayableSignature } from './finalize.js';
14
14
  import { refreshWorkspaceArtifacts } from '../workspaces/artifact-events.js';
15
15
  import { isolateWorkspaceForBuild } from '../workspaces/build-isolation.js';
16
+ import { markAudioBuildInputs, verifyAudioBuildInputs } from '../workspaces/audio-assets/build-inputs.js';
16
17
  import { openScratchToDevelopment } from '../workspaces/directory-access.js';
17
18
  import { injectPreviewDiagnostics } from './diagnostics.js';
18
19
  import { OperationLogStore } from '../workspaces/operation-log.js';
@@ -103,7 +104,7 @@ export async function runPreviewPublish(state, operation, root) {
103
104
  // The build runs as the development identity while this copy belongs to the controller.
104
105
  await openScratchToDevelopment(buildCopy, (await lstat(root)).gid);
105
106
  const project = join(buildCopy, 'project'), staged = join(buildCopy, 'output');
106
- await isolateWorkspaceForBuild(root, project);
107
+ const audioInputs = await isolateWorkspaceForBuild(root, project);
107
108
  if (modelInputs)
108
109
  await verifyWorkspaceModelBuildInputs(project, modelInputs);
109
110
  await buildPreview(state, project, staged, operation.operationId, output, appendLog);
@@ -112,7 +113,9 @@ export async function runPreviewPublish(state, operation, root) {
112
113
  if (modelInputs)
113
114
  await verifyWorkspaceModelBuildInputs(root, modelInputs);
114
115
  const built = await readSingleHtml(staged);
115
- const finalBytes = modelInputs ? markWorkspaceModelBuild(built, modelInputs) : built;
116
+ await verifyAudioBuildInputs(root, audioInputs);
117
+ const audioMarked = markAudioBuildInputs(built, audioInputs);
118
+ const finalBytes = modelInputs ? markWorkspaceModelBuild(audioMarked, modelInputs) : audioMarked;
116
119
  await finalizeWorkspacePlayable(root, finalBytes, 'preview.publish', {
117
120
  expectedSignature: signature, source: { operationId: operation.operationId, ...(operation.sessionId ? { sessionId: operation.sessionId } : {}) },
118
121
  });
@@ -5,6 +5,7 @@ import JSON5 from 'json5';
5
5
  import { z } from 'zod';
6
6
  import { embedWorkspaceRuntimeProfile, workspaceRelativePathSchema, workspaceRuntimePreviewProfileSchema, } from '@playcraft/common/cloud-workspace/runtime';
7
7
  import { readWorkspaceParameters } from '../workspaces/parameters.js';
8
+ import { readWorkspaceLevelEditor, levelEditorDigest } from '../workspaces/level-editor.js';
8
9
  import { revisionScope } from '../workspaces/registry.js';
9
10
  import { PREVIEW_RUNTIME_DIRECTORY, previewRuntimeSdkArtifactSchema, readPreviewRuntimeDistribution, } from '../../preview-runtime/distribution.js';
10
11
  const manifestPath = '.playcraft/workspace.json';
@@ -106,7 +107,21 @@ export async function prepareRuntimePreview(state, operation, root) {
106
107
  if (typeof packageData.scripts?.[config.buildScript] !== 'string')
107
108
  throw new RuntimePreviewBuildError('runtime_preview_script_missing');
108
109
  const scope = revisionScope(state, operation.workspaceId, operation.directoryName);
109
- const read = () => readWorkspaceParameters(root, state.journal.fileRevisions(scope));
110
+ const readLevel = () => readWorkspaceLevelEditor(root, state.journal.fileRevisions(scope), { state, operation });
111
+ const beforeLevel = await readLevel();
112
+ if (JSON5.parse(manifest.toString()).levelEditor && beforeLevel.status !== 'ready')
113
+ throw new RuntimePreviewBuildError('runtime_preview_level_editor_invalid');
114
+ const read = async () => {
115
+ const parameters = await readWorkspaceParameters(root, state.journal.fileRevisions(scope));
116
+ if (parameters.status === 'ready')
117
+ return parameters;
118
+ const level = await readLevel();
119
+ if (level.status !== 'ready')
120
+ return parameters;
121
+ return { status: 'ready', schemaHash: level.schemaHash, sourceFingerprint: level.sourceFingerprint,
122
+ dataHash: levelEditorDigest(JSON.stringify(level.documentHashes)), dataRevision: Math.max(0, ...Object.values(level.revisions)),
123
+ schemaRevision: 0, themeId: 'default', runtimeI18n: undefined };
124
+ };
110
125
  const before = await read();
111
126
  if (before.status !== 'ready' ||
112
127
  !before.schemaHash ||
@@ -170,7 +185,14 @@ export async function prepareRuntimePreview(state, operation, root) {
170
185
  ...(before.runtimeI18n.themeHash ? { runtimeI18nHash: before.runtimeI18n.themeHash } : {}) } : {}),
171
186
  },
172
187
  };
173
- const context = { buildId, ...profileBase };
188
+ const levelBinding = beforeLevel.status === 'ready' && beforeLevel.definition ? {
189
+ workspaceId: operation.workspaceId, directoryName: operation.directoryName ?? 'basic', editorId: beforeLevel.definition.editorId,
190
+ dataContractVersion: beforeLevel.definition.dataContractVersion, executionContractHash: beforeLevel.executionContractHash,
191
+ } : undefined;
192
+ const levelInputs = beforeLevel.status === 'ready' && beforeLevel.definition ? await Promise.all([...new Set([beforeLevel.definitionPath, ...Object.values(beforeLevel.definition.documents).flatMap(item => [item.path, item.schema])])]
193
+ .map(async (path) => ({ path, sha256: digest(await insideFile(root, path)) }))) : undefined;
194
+ const context = { buildId, ...profileBase, ...(levelBinding ? { levelEditor: { binding: levelBinding, inputs: levelInputs,
195
+ schemaHash: beforeLevel.schemaHash, dataHash: before.dataHash } } : {}) };
174
196
  // Remove stale outputs before the script runs; a stale report cannot satisfy this build's identity.
175
197
  for (const path of [config.outputPath, config.reportPath]) {
176
198
  await rm(await isolatedPath(root, path), { force: true });
@@ -183,6 +205,10 @@ export async function prepareRuntimePreview(state, operation, root) {
183
205
  : `pnpm run ${shellQuote(config.buildScript)}`}`,
184
206
  async finish() {
185
207
  const after = await read();
208
+ const afterLevel = await readLevel();
209
+ if (levelBinding && (afterLevel.status !== 'ready' || afterLevel.executionContractHash !== beforeLevel.executionContractHash ||
210
+ JSON.stringify(afterLevel.documentHashes) !== JSON.stringify(beforeLevel.documentHashes)))
211
+ throw new RuntimePreviewBuildError('runtime_preview_level_editor_changed');
186
212
  if (after.status !== 'ready' ||
187
213
  after.sourceFingerprint !== before.sourceFingerprint ||
188
214
  after.dataHash !== before.dataHash ||
@@ -243,8 +269,17 @@ export async function prepareRuntimePreview(state, operation, root) {
243
269
  ...profileBase,
244
270
  buildDigest: report.data.htmlDigest,
245
271
  });
272
+ let html = embedWorkspaceRuntimeProfile(bytes.toString(), profile);
273
+ if (levelBinding) {
274
+ if (!/<\/head>/i.test(html))
275
+ throw new RuntimePreviewBuildError('runtime_preview_html_invalid');
276
+ if (/id=["']playcraft-level-editor-profile["']/.test(html))
277
+ throw new RuntimePreviewBuildError('runtime_preview_duplicate_level_profile');
278
+ const stamp = `<script id="playcraft-level-editor-profile" type="application/json">${JSON.stringify({ protocolVersion: 1, binding: levelBinding }).replaceAll('<', '\\u003c')}</script>`;
279
+ html = html.replace(/<\/head>/i, `${stamp}</head>`);
280
+ }
246
281
  return {
247
- bytes: Buffer.from(embedWorkspaceRuntimeProfile(bytes.toString(), profile)),
282
+ bytes: Buffer.from(html),
248
283
  profile,
249
284
  };
250
285
  },
@@ -34,6 +34,8 @@ import { loadWorkspaceMcpConfig } from './codex/mcp-config.js';
34
34
  import { startSourceEventUploader, stopSourceEventUploader } from './server/source-uploader.js';
35
35
  import { platformExportTarget, readPlatformExport, startPlatformExport } from './workspaces/platform-export.js';
36
36
  import { scheduledTaskProposalHandler } from './workspaces/scheduled-task-proposals.js';
37
+ import { executionContextToolHandler } from './workspaces/execution-context.js';
38
+ import { registerLevelEditorRequest, startLevelEditorRequestQueue, stopLevelEditorRequestQueue } from './workspaces/level-editor-requests.js';
37
39
  import { proceduralSkillHandler } from './workspaces/procedural-3d/host.js';
38
40
  import { recoverModelPreviewOperations } from './workspaces/procedural-3d/model-preview-recovery.js';
39
41
  import { recoverModelPreparationOperations, stopModelPreparations } from './workspaces/model-preparation.js';
@@ -313,11 +315,13 @@ export async function startWorkspaceRuntime(config, options = {}) {
313
315
  socket.destroy();
314
316
  });
315
317
  const port = await listenOn(server, config.listen.host, config.listen.port);
318
+ startLevelEditorRequestQueue(state);
316
319
  return {
317
320
  config: state.config,
318
321
  state,
319
322
  url: `http://${formatHost(config.listen.host)}:${port}`,
320
323
  async close() {
324
+ stopLevelEditorRequestQueue(state);
321
325
  const serverClosed = closeServer(server);
322
326
  await stopParameterVerifications(state);
323
327
  await stopModelPreparations(state);
@@ -429,7 +433,9 @@ function defaultNativeAdapter(state) {
429
433
  workspaceUi: true,
430
434
  deliveryTools: deliveryToolHandlers(state),
431
435
  scheduledTaskProposals: scheduledTaskProposalHandler(state),
436
+ executionContext: executionContextToolHandler(state),
432
437
  productionSkill: proceduralSkillHandler(state),
438
+ levelEditorTasks: (source, intent) => registerLevelEditorRequest(state, source, intent),
433
439
  ...(multi ? { modelBroker: new WorkspaceModelBroker(state) } : {}),
434
440
  ...(multi ? { loadMcpConfig: async (cwd) => {
435
441
  const { workspaceId } = await workspaceContextFromRoot(state, cwd);