@playcraft/cli 0.1.60-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 (32) hide show
  1. package/dist/project-skills/builtin-import.js +2 -2
  2. package/dist/skill-registry/messages.js +2 -2
  3. package/dist/skill-registry/platform-package.js +2 -4
  4. package/dist/workspace-runtime/codex/app-server.js +24 -1
  5. package/dist/workspace-runtime/codex/config-toml.js +9 -5
  6. package/dist/workspace-runtime/codex/delivery-tools.js +3 -10
  7. package/dist/workspace-runtime/codex/launcher-client.js +25 -13
  8. package/dist/workspace-runtime/codex/production-skill-tools.js +95 -0
  9. package/dist/workspace-runtime/processes/skill-command.js +130 -0
  10. package/dist/workspace-runtime/serve.js +7 -1
  11. package/dist/workspace-runtime/server/accept-operation.js +10 -1
  12. package/dist/workspace-runtime/server/directory-access.js +10 -3
  13. package/dist/workspace-runtime/server/dispatch.js +39 -1
  14. package/dist/workspace-runtime/server/http.js +25 -3
  15. package/dist/workspace-runtime/server/operation-record.js +4 -1
  16. package/dist/workspace-runtime/workspaces/dependencies.js +35 -10
  17. package/dist/workspace-runtime/workspaces/developer-command.js +65 -64
  18. package/dist/workspace-runtime/workspaces/directories.js +4 -1
  19. package/dist/workspace-runtime/workspaces/empty-template.js +3 -1
  20. package/dist/workspace-runtime/workspaces/platform-export.js +3 -3
  21. package/dist/workspace-runtime/workspaces/procedural-3d/candidate.js +129 -0
  22. package/dist/workspace-runtime/workspaces/procedural-3d/contracts.js +34 -0
  23. package/dist/workspace-runtime/workspaces/procedural-3d/files.js +83 -0
  24. package/dist/workspace-runtime/workspaces/procedural-3d/host.js +595 -0
  25. package/dist/workspace-runtime/workspaces/procedural-3d/render-worker.js +159 -0
  26. package/dist/workspace-runtime/workspaces/procedural-3d/review-contracts.js +257 -0
  27. package/dist/workspace-runtime/workspaces/procedural-3d/review-page.js +26 -0
  28. package/dist/workspace-runtime/workspaces/skill-execution-context.js +69 -0
  29. package/dist/workspace-runtime/workspaces/skills.js +15 -5
  30. package/package.json +3 -3
  31. package/project-template-v2/.playcraft/skills.lock.json +7 -0
  32. package/project-templates/empty/AGENTS.md +10 -0
@@ -2,9 +2,9 @@ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
3
  import { basename, join } from 'node:path';
4
4
  import { canonicalProjectSkillsJson, declarationDigest, dependencyDigest, extractBundleArchive, readProjectSkillSource, readProjectSkillsLockFile, validateProjectSkillsSnapshot, PROJECT_SKILLS_DECLARATION_PATH, PROJECT_SKILLS_LOCK_PATH, } from '@playcraft/common/project-skills';
5
- import { LEGACY_VIDEO_SOURCE_HASHES } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { LEGACY_VIDEO_SOURCE_HASHES, WORKSPACE_BUILTIN_SKILL_NAMES } from '@playcraft/common/cloud-workspace/runtime';
6
6
  import { projectSkillsCache } from './local-store.js';
7
- export const IMPORTABLE_BUILTIN_SKILLS = ['playcraft-video-understanding', 'playcraft-replica-validation'];
7
+ export const IMPORTABLE_BUILTIN_SKILLS = WORKSPACE_BUILTIN_SKILL_NAMES;
8
8
  export const platformSkillRef = (name) => `remix/${name}`;
9
9
  const hash = (value) => createHash('sha256').update(canonicalProjectSkillsJson(value)).digest('hex');
10
10
  async function safeDirectory(root, path) {
@@ -49,7 +49,7 @@ const messages = {
49
49
  platform: 'Register native platform skill bundles',
50
50
  platformPush: 'Register a native platform draft from a directory or archive without publishing',
51
51
  evaluationTemplate: 'Optional evaluation template identifier for the draft',
52
- invalidPlatformBundle: 'Expected an unchanged native platform video skill bundle with an aligned manifest and SKILL.md.',
52
+ invalidPlatformBundle: 'Expected a registered native platform skill bundle with an aligned manifest and SKILL.md.',
53
53
  evaluation: 'Configure and run real skill evaluations',
54
54
  evaluationTemplates: 'List evaluation runtime templates',
55
55
  createTemplate: 'Create an evaluation template draft from a file',
@@ -129,7 +129,7 @@ const messages = {
129
129
  platform: '登记原生平台技能包',
130
130
  platformPush: '从目录或压缩包登记原生平台草稿,不执行发布',
131
131
  evaluationTemplate: '草稿可选使用的评估模板标识',
132
- invalidPlatformBundle: '需要原生平台视频技能包,且清单与 SKILL.md 信息一致;不自动改写清单。',
132
+ invalidPlatformBundle: '需要已登记的原生平台技能包,且清单与 SKILL.md 信息一致;不自动改写清单。',
133
133
  evaluation: '配置并执行真实技能评估',
134
134
  evaluationTemplates: '列出评估运行模板',
135
135
  createTemplate: '根据文件创建评估模板草稿',
@@ -1,12 +1,10 @@
1
1
  import { constants, promises as fs } from 'node:fs';
2
2
  import { extractBundleArchive } from '@playcraft/common/project-skills';
3
+ import { WORKSPACE_BUILTIN_SKILL_REFS } from '@playcraft/common/cloud-workspace/runtime';
3
4
  import yaml from 'js-yaml';
4
5
  import { MAX_BUNDLE_BYTES, packSkillDirectory, parseSkillReference, sha256, } from './package.js';
5
6
  import { message, RegistryError } from './messages.js';
6
- const NATIVE_REFS = new Set([
7
- 'remix/playcraft-video-understanding',
8
- 'remix/playcraft-replica-validation',
9
- ]);
7
+ const NATIVE_REFS = new Set(WORKSPACE_BUILTIN_SKILL_REFS);
10
8
  /** Preserve native manifest bytes; the server remains the authoritative bundle validator. */
11
9
  export function inspectNativePlatformArchive(archive) {
12
10
  const invalid = () => {
@@ -20,6 +20,7 @@ import { WORKSPACE_UI_TOOLS, WorkspaceUiTools } from './ui-tools.js';
20
20
  import { WORKSPACE_DELIVERY_TOOLS, WorkspaceDeliveryTools, isWorkspaceDeliveryTool } from './delivery-tools.js';
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
+ import { PROCEDURAL_3D_TOOL, ProductionSkillTools } from './production-skill-tools.js';
23
24
  function withModelShell(config, modelConfigId) {
24
25
  if (!modelConfigId || !/^[0-9a-f-]{36}$/i.test(modelConfigId))
25
26
  return config;
@@ -195,10 +196,12 @@ export function createCodexAppServerAdapter(options) {
195
196
  const scheduledTaskTools = options.scheduledTaskProposals ? new WorkspaceScheduledTaskTools(options.scheduledTaskProposals, (proposalId, source) => emitCollected(collectedEvent('notification', 'scheduled-task.proposal.changed', {
196
197
  proposalId, sessionId: threads.get(source.threadId)?.sessionId, threadId: source.threadId, turnId: source.turnId,
197
198
  }))) : undefined;
199
+ const productionSkill = options.productionSkill ? new ProductionSkillTools(options.productionSkill) : undefined;
198
200
  const dynamicTools = [
199
201
  ...(options.workspaceUi ? WORKSPACE_UI_TOOLS : []),
200
202
  ...(deliveryTools ? WORKSPACE_DELIVERY_TOOLS : []),
201
203
  ...(scheduledTaskTools ? WORKSPACE_SCHEDULED_TASK_TOOLS : []),
204
+ ...(productionSkill ? [PROCEDURAL_3D_TOOL] : []),
202
205
  ];
203
206
  function emitCollected(event) {
204
207
  for (const listener of collected)
@@ -599,7 +602,13 @@ export function createCodexAppServerAdapter(options) {
599
602
  approvals.delete(key);
600
603
  }
601
604
  }
602
- emitCollected(collectedEvent('notification', method, payload));
605
+ if (method === 'turn/completed' && typeof payload.threadId === 'string'
606
+ && productionSkill?.hasActive(payload.threadId, String(asRecord(payload.turn).id))) {
607
+ // Keep the operation's directory lease until the tool's writer has stopped.
608
+ void productionSkill.cancel(payload.threadId, String(asRecord(payload.turn).id)).then(() => emitCollected(collectedEvent('notification', method, payload)));
609
+ }
610
+ else
611
+ emitCollected(collectedEvent('notification', method, payload));
603
612
  });
604
613
  rpc.onServerRequest((id, method, params) => {
605
614
  const payload = asRecord(params);
@@ -634,6 +643,16 @@ export function createCodexAppServerAdapter(options) {
634
643
  }, 0);
635
644
  return;
636
645
  }
646
+ if (productionSkill && payload.tool === PROCEDURAL_3D_TOOL.name) {
647
+ const trusted = thread?.turnActive && thread.cwd && thread.activeMode === 'execute'
648
+ && thread.permissionMode !== 'read-only' && thread.readOnlyConfirmed !== true
649
+ && payload.turnId === thread.lastTurnId && typeof payload.threadId === 'string' && typeof payload.turnId === 'string';
650
+ void productionSkill.request(id, payload, nativeClient, trusted ? {
651
+ sessionId: thread.sessionId, root: thread.cwd,
652
+ nativeThreadId: payload.threadId, nativeTurnId: payload.turnId,
653
+ } : undefined);
654
+ return;
655
+ }
637
656
  if (deliveryTools && isWorkspaceDeliveryTool(payload.tool)) {
638
657
  void deliveryTools.request(id, payload, nativeClient, {
639
658
  sessionId: thread?.sessionId,
@@ -678,6 +697,7 @@ export function createCodexAppServerAdapter(options) {
678
697
  if (live === current) {
679
698
  readyFlag = false;
680
699
  void uiTools.cancel(undefined, 'ui_runtime_disconnected');
700
+ void productionSkill?.cancel();
681
701
  approvals.clear();
682
702
  // Retain the old handle until its entire execution group is confirmed stopped.
683
703
  rpc = undefined;
@@ -781,6 +801,7 @@ export function createCodexAppServerAdapter(options) {
781
801
  previous.superseded = true;
782
802
  readyFlag = false;
783
803
  await uiTools.cancel(undefined, 'ui_runtime_disconnected');
804
+ await productionSkill?.cancel();
784
805
  startPromise = undefined;
785
806
  threads.clear();
786
807
  approvals.clear();
@@ -1267,6 +1288,7 @@ export function createCodexAppServerAdapter(options) {
1267
1288
  return { outcome: 'failed', error: 'turn_changed' };
1268
1289
  try {
1269
1290
  await uiTools.cancel(input.threadId);
1291
+ await productionSkill?.cancel(input.threadId, turnId);
1270
1292
  await rpc.request('turn/interrupt', { threadId: input.threadId, turnId });
1271
1293
  const cwd = threads.get(input.threadId)?.cwd;
1272
1294
  if (cwd && (input.expectedTurnId === undefined || threads.get(input.threadId)?.lastTurnId === turnId))
@@ -1327,6 +1349,7 @@ export function createCodexAppServerAdapter(options) {
1327
1349
  closed = true;
1328
1350
  readyFlag = false;
1329
1351
  await uiTools.cancel(undefined, 'ui_runtime_disconnected');
1352
+ await productionSkill?.cancel();
1330
1353
  approvals.clear();
1331
1354
  if (live)
1332
1355
  live.superseded = true;
@@ -1,6 +1,6 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
- import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { chmod, mkdir, writeFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
  const exec = promisify(execFile);
@@ -77,13 +77,17 @@ export async function writePortableCodexCatalog(input) {
77
77
  }
78
78
  await mkdir(input.directory, { recursive: true });
79
79
  const path = join(input.directory, 'portable-models.json');
80
- // private
81
- await writeFile(path, body, { encoding: 'utf8', mode: 0o644 });
82
- // private
83
- await writeFile(join(input.directory, 'portable-models.version.json'), JSON.stringify({
80
+ // shared: public model metadata crosses from the control user to the development user.
81
+ // Explicit permissions also repair existing files and override a private process umask.
82
+ await writeFile(path, body, { encoding: 'utf8', mode: 0o644 }); // shared
83
+ await chmod(path, 0o644);
84
+ const versionPath = join(input.directory, 'portable-models.version.json');
85
+ await writeFile(// shared
86
+ versionPath, JSON.stringify({
84
87
  version,
85
88
  sha256: createHash('sha256').update(body).digest('hex'),
86
89
  }), { encoding: 'utf8', mode: 0o644 });
90
+ await chmod(versionPath, 0o644);
87
91
  return path;
88
92
  }
89
93
  export async function writePlaycraftCodexConfig(input) {
@@ -1,11 +1,11 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { DELIVERY_CHANNELS, deliveryFormatSchema, } from '@playcraft/common/cloud-workspace/runtime';
2
+ import { DELIVERY_CHANNELS, } from '@playcraft/common/cloud-workspace/runtime';
3
3
  import { rejectTool, toolContent } from './tool-response.js';
4
4
  export const WORKSPACE_DELIVERY_TOOLS = [
5
5
  {
6
6
  type: 'function',
7
7
  name: 'playcraft_build_delivery',
8
- description: 'Export channel packages through the PlayCraft platform. This first saves the workspace (commit and push), then exports that commit; each channel reuses the options of its latest export on this branch, or the defaults when it has none. A workspace without a project first gets one, with a private repository, as its first save would. If the result is pending at workspace.save, finish the current turn without polling so the save can run, then continue the export in a later turn. Once build ids are returned, poll playcraft_get_delivery for status, checks and failure details.',
8
+ description: 'Export channel packages through the PlayCraft platform. This first saves the workspace (commit and push), then exports that commit. Each channel uses the same fixed HTML or ZIP choice as the Export button for that channel and the project engine. Other options reuse the channel\'s latest export on this branch, or the defaults when it has none. A workspace without a project first gets one, with a private repository, as its first save would. If the result is pending at workspace.save, finish the current turn without polling so the save can run, then continue the export in a later turn. Once build ids are returned, poll playcraft_get_delivery for status, checks and failure details.',
9
9
  inputSchema: {
10
10
  type: 'object',
11
11
  properties: {
@@ -16,7 +16,6 @@ export const WORKSPACE_DELIVERY_TOOLS = [
16
16
  maxItems: 8,
17
17
  uniqueItems: true,
18
18
  },
19
- format: { type: 'string', enum: ['html', 'zip'] },
20
19
  },
21
20
  additionalProperties: false,
22
21
  },
@@ -71,7 +70,7 @@ export class WorkspaceDeliveryTools {
71
70
  await this.reject(client, requestId, 'permission_denied');
72
71
  return;
73
72
  }
74
- const unknown = Object.keys(input).filter(key => key !== 'channels' && key !== 'format');
73
+ const unknown = Object.keys(input).filter(key => key !== 'channels');
75
74
  if (unknown.length) {
76
75
  await this.reject(client, requestId, 'invalid_delivery_tool_arguments');
77
76
  return;
@@ -83,11 +82,6 @@ export class WorkspaceDeliveryTools {
83
82
  await this.reject(client, requestId, 'invalid_delivery_tool_arguments');
84
83
  return;
85
84
  }
86
- const format = input.format === undefined ? undefined : deliveryFormatSchema.safeParse(input.format);
87
- if (format && !format.success) {
88
- await this.reject(client, requestId, 'invalid_delivery_tool_arguments');
89
- return;
90
- }
91
85
  try {
92
86
  const result = await this.handlers.build({
93
87
  ...context,
@@ -95,7 +89,6 @@ export class WorkspaceDeliveryTools {
95
89
  ? payload.callId
96
90
  : randomUUID(),
97
91
  channels: channels,
98
- ...(format && format.success ? { format: format.data } : {}),
99
92
  });
100
93
  await this.respond(client, requestId, result);
101
94
  }
@@ -38,18 +38,7 @@ export async function stopLauncherProcess(input) {
38
38
  if (!input.instanceId)
39
39
  throw new Error('launcher_start_unconfirmed');
40
40
  const { socketPath, ...identity } = input;
41
- // A new launcher's empty registry cannot confirm an old group's exit.
42
- for (const signal of ['SIGTERM', 'SIGKILL']) {
43
- await request(socketPath, { ...identity, action: 'stop', signal });
44
- const deadline = Date.now() + 3000;
45
- while (Date.now() < deadline) {
46
- const status = await request(socketPath, { ...identity, action: 'status' });
47
- if (status.running === false)
48
- return;
49
- await delay(50);
50
- }
51
- }
52
- throw new Error('codex_group_stop_unconfirmed');
41
+ await stopLauncherExecution(socketPath, identity);
53
42
  }
54
43
  /** The private launcher owns the boundary; development shells never receive its identity. */
55
44
  export async function prepareLauncherOrigin(input) {
@@ -69,6 +58,24 @@ export async function verifyLauncherOrigin(input) {
69
58
  return false;
70
59
  }
71
60
  }
61
+ /** Recovered writers need a receipt from the same launcher, not just an absent process id. */
62
+ export async function stopLauncherExecution(socketPath, identity) {
63
+ if (!identity || typeof identity.processId !== 'string' || !identity.processId ||
64
+ typeof identity.instanceId !== 'string' || !identity.instanceId ||
65
+ !Number.isSafeInteger(identity.runtimeEpoch) || identity.runtimeEpoch < 0)
66
+ throw new Error('launcher_execution_identity_invalid');
67
+ for (const signal of ['SIGTERM', 'SIGKILL']) {
68
+ await request(socketPath, { ...identity, action: 'stop', signal });
69
+ const deadline = Date.now() + 3000;
70
+ while (Date.now() < deadline) {
71
+ const status = await request(socketPath, { ...identity, action: 'status' });
72
+ if (status.running === false)
73
+ return;
74
+ await delay(50);
75
+ }
76
+ }
77
+ throw new Error('codex_group_stop_unconfirmed');
78
+ }
72
79
  export async function startLauncherProcess(input) {
73
80
  const socket = createConnection(input.socketPath);
74
81
  const stdout = new PassThrough();
@@ -167,8 +174,13 @@ export async function startLauncherProcess(input) {
167
174
  });
168
175
  });
169
176
  return {
170
- identity: identity,
177
+ identity,
171
178
  pid,
179
+ executionIdentity: {
180
+ processId: identity.processId,
181
+ runtimeEpoch: identity.runtimeEpoch,
182
+ instanceId: identity.instanceId,
183
+ },
172
184
  stdin,
173
185
  stdout,
174
186
  stderr,
@@ -0,0 +1,95 @@
1
+ import { rejectTool, toolContent } from './tool-response.js';
2
+ export const PROCEDURAL_3D_TOOL = {
3
+ type: 'function',
4
+ name: 'playcraft_procedural_3d',
5
+ description: 'Run the enabled, explicitly selected procedural 3D skill using its locked tools. Read that skill for action inputs. prepare freezes a local reference; adapter actions operate in the returned run directory; capture freezes and renders a candidate; review records observations against that candidate; adopt copies only the reviewed bytes. Calls require an exclusive execution turn. A requires_exclusive_turn result requires selecting the skill in a new turn.',
6
+ inputSchema: {
7
+ type: 'object',
8
+ properties: {
9
+ action: {
10
+ type: 'string',
11
+ enum: [
12
+ 'describe',
13
+ 'preflight',
14
+ 'prepare',
15
+ 'status',
16
+ 'recover',
17
+ 'next',
18
+ 'run-step',
19
+ 'request-correction',
20
+ 'capture',
21
+ 'review',
22
+ 'adopt',
23
+ ],
24
+ },
25
+ runId: { type: 'string' },
26
+ expectedRevision: { type: 'integer', minimum: 0 },
27
+ input: { type: 'object' },
28
+ },
29
+ required: ['action'],
30
+ additionalProperties: false,
31
+ },
32
+ };
33
+ /** Only the host may attach a validated workspace-relative diagnostic report. */
34
+ export class ProductionSkillFailure extends Error {
35
+ reportPath;
36
+ constructor(reason, reportPath) {
37
+ super(reason);
38
+ this.reportPath = reportPath;
39
+ }
40
+ }
41
+ /** Native turn identity is supplied by the app-server adapter, never by tool arguments. */
42
+ export class ProductionSkillTools {
43
+ handler;
44
+ active = new Map();
45
+ constructor(handler) {
46
+ this.handler = handler;
47
+ }
48
+ hasActive(threadId, turnId) {
49
+ return [...this.active.values()].some((item) => (!threadId || item.context.nativeThreadId === threadId) &&
50
+ (!turnId || item.context.nativeTurnId === turnId));
51
+ }
52
+ async request(id, payload, client, context) {
53
+ if (!context)
54
+ return rejectTool(client, id, 'procedural_skill_inactive_turn');
55
+ const key = `${context.nativeThreadId}:${context.nativeTurnId}`;
56
+ if (this.active.has(key))
57
+ return rejectTool(client, id, 'procedural_skill_busy');
58
+ const abort = new AbortController();
59
+ let finish;
60
+ const done = new Promise((resolve) => {
61
+ finish = resolve;
62
+ });
63
+ this.active.set(key, { context, abort, done });
64
+ try {
65
+ const result = await this.handler(payload.arguments, context, abort.signal);
66
+ await client.respond(id, { success: true, contentItems: toolContent(result) });
67
+ }
68
+ catch (error) {
69
+ const reason = error instanceof Error && /^[a-z][a-z0-9_:\-]{0,160}$/.test(error.message)
70
+ ? error.message
71
+ : 'procedural_skill_failed';
72
+ if (error instanceof ProductionSkillFailure) {
73
+ await client
74
+ .respond(id, {
75
+ success: false,
76
+ contentItems: toolContent({ status: 'failed', reason, reportPath: error.reportPath }),
77
+ })
78
+ .catch(() => undefined);
79
+ return;
80
+ }
81
+ await rejectTool(client, id, reason);
82
+ }
83
+ finally {
84
+ this.active.delete(key);
85
+ finish();
86
+ }
87
+ }
88
+ async cancel(threadId, turnId) {
89
+ const pending = [...this.active.values()].filter((item) => (!threadId || item.context.nativeThreadId === threadId) &&
90
+ (!turnId || item.context.nativeTurnId === turnId));
91
+ for (const item of pending)
92
+ item.abort.abort();
93
+ await Promise.allSettled(pending.map((item) => item.done));
94
+ }
95
+ }
@@ -0,0 +1,130 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { startLauncherProcess } from '../codex/launcher-client.js';
4
+ import { launcherSocket } from '../workspaces/developer-command.js';
5
+ import { holdDirectoryAccess } from '../server/directory-access.js';
6
+ import { stopBackgroundWriter } from './managed-writes.js';
7
+ /** Structured process invocation under the caller's exclusive directory owner. */
8
+ export async function runSkillCommand(state, input) {
9
+ if (input.signal.aborted)
10
+ throw new Error('procedural_skill_cancelled');
11
+ const writeId = `skill-${randomUUID()}`;
12
+ const release = await holdDirectoryAccess(state, input.scope, `managed:${writeId}`, 'exclusive');
13
+ const record = {
14
+ writeId,
15
+ workspaceId: state.config.workspaceMode === 'multi'
16
+ ? `${input.scope.workspaceId}:${input.scope.directoryName ?? 'basic'}`
17
+ : 'basic',
18
+ source: 'agent',
19
+ directoryAccessMode: 'exclusive',
20
+ startedAt: new Date().toISOString(),
21
+ };
22
+ let child;
23
+ let stopped = true;
24
+ let timeout = false;
25
+ let oversized = false;
26
+ let timer;
27
+ const abort = () => {
28
+ void child?.stop().catch(() => undefined);
29
+ };
30
+ try {
31
+ await state.journal.putManagedWrite(record);
32
+ if (input.signal.aborted)
33
+ throw new Error('procedural_skill_cancelled');
34
+ const socketPath = launcherSocket(state);
35
+ if (socketPath) {
36
+ stopped = false;
37
+ child = await startLauncherProcess({
38
+ socketPath,
39
+ processId: writeId,
40
+ runtimeEpoch: state.runtimeEpoch,
41
+ // Development outputs must remain readable by the workspace control group even when
42
+ // the private launcher starts with umask 077. Keep arguments literal and this mask local.
43
+ argv: ['/usr/bin/bash', '-c', 'umask 007; exec "$@"', 'playcraft-skill', ...input.argv],
44
+ cwd: input.cwd,
45
+ env: { PYTHONDONTWRITEBYTECODE: '1' },
46
+ });
47
+ }
48
+ else {
49
+ const local = spawn(input.argv[0], input.argv.slice(1), {
50
+ cwd: input.cwd,
51
+ detached: true,
52
+ stdio: ['pipe', 'pipe', 'pipe'],
53
+ env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' },
54
+ });
55
+ stopped = !local.pid;
56
+ child = {
57
+ pid: local.pid,
58
+ stdin: local.stdin,
59
+ stdout: local.stdout,
60
+ stderr: local.stderr,
61
+ onExit(listener) {
62
+ local.once('error', () => listener(null));
63
+ local.once('close', listener);
64
+ },
65
+ async stop() {
66
+ if (local.pid && !(await stopBackgroundWriter(local.pid)))
67
+ throw new Error('procedural_skill_stop_unconfirmed');
68
+ },
69
+ };
70
+ }
71
+ // Attach exit listeners before asynchronous persistence: short commands can finish immediately.
72
+ const exit = new Promise((resolve) => child.onExit(resolve));
73
+ let stdout = '', stderr = '';
74
+ const collect = (stream, chunk) => {
75
+ if (stream === 'stdout')
76
+ stdout += chunk.toString();
77
+ else
78
+ stderr += chunk.toString();
79
+ if (stdout.length > 4 * 1024 * 1024 || stderr.length > 1024 * 1024) {
80
+ oversized = true;
81
+ abort();
82
+ }
83
+ };
84
+ child.stdout?.on('data', (chunk) => collect('stdout', chunk));
85
+ child.stderr?.on('data', (chunk) => collect('stderr', chunk));
86
+ child.stdin?.on('error', () => undefined);
87
+ await state.journal.putManagedWrite({ ...record, pid: child.pid });
88
+ input.signal.addEventListener('abort', abort, { once: true });
89
+ timer = setTimeout(() => {
90
+ timeout = true;
91
+ abort();
92
+ }, input.timeoutMs ?? 120_000);
93
+ if (input.signal.aborted)
94
+ abort();
95
+ else
96
+ child.stdin?.end(JSON.stringify(input.input));
97
+ const code = await exit;
98
+ await child.stop();
99
+ stopped = true;
100
+ if (input.signal.aborted)
101
+ throw new Error('procedural_skill_cancelled');
102
+ if (timeout)
103
+ throw new Error('procedural_skill_timeout');
104
+ if (oversized)
105
+ throw new Error('procedural_skill_output_limit');
106
+ return { stdout, stderr, code };
107
+ }
108
+ finally {
109
+ if (timer)
110
+ clearTimeout(timer);
111
+ input.signal.removeEventListener('abort', abort);
112
+ if (child && !stopped) {
113
+ try {
114
+ await child.stop();
115
+ stopped = true;
116
+ }
117
+ catch {
118
+ /* retain the writer claim */
119
+ }
120
+ }
121
+ if (stopped) {
122
+ await state.journal.putManagedWrite({
123
+ ...record,
124
+ pid: child?.pid,
125
+ stoppedAt: new Date().toISOString(),
126
+ });
127
+ release();
128
+ }
129
+ }
130
+ }
@@ -34,6 +34,7 @@ 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 { proceduralSkillHandler } from './workspaces/procedural-3d/host.js';
37
38
  export async function startWorkspaceRuntime(config, options = {}) {
38
39
  process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
39
40
  const negotiated = negotiateProtocol(config);
@@ -55,7 +56,11 @@ export async function startWorkspaceRuntime(config, options = {}) {
55
56
  const previousEpoch = journal.storedRuntimeEpoch();
56
57
  const previousGroup = journal.executionGroup();
57
58
  const previousIdentityMatches = journal.epochsSnapshot().some((epoch) => epoch.runtime_id === config.runtimeId && epoch.generation === config.generation && epoch.runtime_epoch === previousEpoch);
58
- await recoverInterruptedInstalls(journal);
59
+ await recoverInterruptedInstalls(journal, {
60
+ runtimeEpoch: config.runtimeEpoch, previousRuntimeEpoch: previousEpoch, identityMatches: previousIdentityMatches,
61
+ ...(config.workspaceMode === 'multi' && process.env.PLAYCRAFT_LAUNCHER_SOCKET
62
+ ? { launcherSocket: process.env.PLAYCRAFT_LAUNCHER_SOCKET } : {}),
63
+ });
59
64
  await recoverInterruptedPreviews(journal);
60
65
  await recoverInterruptedParameterVerifications(journal);
61
66
  await recoverInterruptedDeliveries(config.stateDir);
@@ -414,6 +419,7 @@ function defaultNativeAdapter(state) {
414
419
  workspaceUi: true,
415
420
  deliveryTools: deliveryToolHandlers(state),
416
421
  scheduledTaskProposals: scheduledTaskProposalHandler(state),
422
+ productionSkill: proceduralSkillHandler(state),
417
423
  ...(multi ? { modelBroker: new WorkspaceModelBroker(state) } : {}),
418
424
  ...(multi ? { loadMcpConfig: async (cwd) => {
419
425
  const { workspaceId } = await workspaceContextFromRoot(state, cwd);
@@ -1,6 +1,7 @@
1
1
  import { assertLeaseFence, hashOperationPayload, isLostThreadError, operationScopeKey, resolveIdempotentAccept, runtimeError, takesWorkspaceDirectory, isTurnControl, } from '@playcraft/common/cloud-workspace/runtime';
2
2
  import { canExecuteKind, executeOperation } from './dispatch.js';
3
3
  import { buildOperationRecord } from './operation-record.js';
4
+ import { preflightSkillExecution } from '../workspaces/skill-execution-context.js';
4
5
  /**
5
6
  * Shared operation acceptance: lease fence, idempotency, capability, record creation and
6
7
  * execution. HTTP supplies its transport/session checks through `validate`; in-sandbox Agent
@@ -62,7 +63,15 @@ export async function acceptOperationCore(state, parsed, options) {
62
63
  if (validation)
63
64
  return { rejected: true, refusal: validation };
64
65
  const executable = canExecuteKind(state, parsed.kind);
65
- const operation = buildOperationRecord(state, parsed, payloadHash, options.leaseFence, executable);
66
+ let policy;
67
+ try {
68
+ policy = await preflightSkillExecution(state, parsed);
69
+ }
70
+ catch (error) {
71
+ return rejected(409, { error: error instanceof Error && /^(?:context_|invalid_composer_context|untrusted_runtime_field)/.test(error.message)
72
+ ? error.message : 'context_skill_unavailable' });
73
+ }
74
+ const operation = buildOperationRecord(state, parsed, payloadHash, options.leaseFence, executable, policy);
66
75
  if (!executable) {
67
76
  const saved = await state.journal.putOperation(scopeKey, operation);
68
77
  return rejected(409, runtimeError('capability_unavailable', state.journal.destroyed() ? 'runtime_destroyed' : state.nativeError ?? 'adapter_not_started', { operationId: saved.operationId }));
@@ -152,7 +152,7 @@ async function gateFor(state) {
152
152
  gate.restore({ ...terminal, owner: `terminal:${terminal.terminalId}`, mode: 'shared' });
153
153
  for (const writer of state.journal.listManagedWrites())
154
154
  if (!writer.stoppedAt) {
155
- gate.restore({ ...managedDirectoryScope(state, writer.workspaceId), owner: `managed:${writer.writeId}`, mode: 'shared' });
155
+ gate.restore({ ...managedDirectoryScope(state, writer.workspaceId), owner: `managed:${writer.writeId}`, mode: writer.directoryAccessMode ?? 'shared' });
156
156
  }
157
157
  const waiting = [];
158
158
  for (const barrier of state.journal.directoryBarriers()) {
@@ -237,6 +237,11 @@ export async function withDirectoryOwner(state, scope, owner, action) {
237
237
  throw new Error('directory_owner_not_held');
238
238
  return context.run({ state, claim: { ...scope, owner, mode: 'exclusive' } }, action);
239
239
  }
240
+ /** Verify the existing claim without acquiring or upgrading a lease from a tool callback. */
241
+ export async function assertOperationDirectoryAccess(state, operation, mode) {
242
+ if (!(await gateFor(state)).permits(`operation:${operation.operationId}`, operation, mode))
243
+ throw new Error('requires-exclusive-turn');
244
+ }
240
245
  export async function withDirectoryWrite(state, scope, action) {
241
246
  const inherited = context.getStore();
242
247
  const gate = await gateFor(state);
@@ -314,9 +319,11 @@ export async function withOperationDirectoryAccess(state, operation, action, def
314
319
  const current = state.journal.getOperationById(operation.operationId);
315
320
  if (!current || TERMINAL.has(current.status))
316
321
  return;
317
- await state.journal.putOperation(deferred.scopeKey, transitionOperation(current, 'uncertain', {
318
- error: error instanceof Error && error.message === 'directory_access_cancelled' ? 'cancelled' : 'native_receipt_lost',
322
+ const cancelled = current.status === 'queued' && error instanceof Error && error.message === 'directory_access_cancelled';
323
+ await state.journal.putOperation(deferred.scopeKey, transitionOperation(current, cancelled ? 'interrupted' : 'uncertain', {
324
+ error: cancelled ? 'cancelled' : 'native_receipt_lost',
319
325
  }));
326
+ await state.journal.deleteDeferredDirectoryOperation(operation.operationId);
320
327
  }).catch(() => undefined);
321
328
  return state.journal.getOperationById(operation.operationId) ?? operation;
322
329
  }