@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,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,184 @@
1
+ import { realpath } from 'node:fs/promises';
2
+ import { isAbsolute, relative } from 'node:path';
3
+ import { z } from 'zod';
4
+ import { PLATFORM_ROUTES, platformEndpoint, WORKSPACE_REMIX_REFERENCE_BODY_MAX_BYTES, WORKSPACE_REMIX_REFERENCES_MANIFEST, workspaceRemixReferenceCandidateSchema, workspaceRemixReferenceExportSchema, } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { withDirectoryWrite } from '../server/directory-access.js';
6
+ import { wakeSourceEventUploader } from '../server/source-uploader.js';
7
+ import { workspaceContextFromRoot } from '../workspaces/registry.js';
8
+ import { importWorkspaceRemixReference, listWorkspaceRemixReferences } from '../workspaces/remix-references.js';
9
+ import { shareWorkspaceGit } from '../workspaces/shared-git.js';
10
+ import { batchDirectoryHeld } from '../batch/occupancy.js';
11
+ import { verifyLauncherOrigin } from './launcher-client.js';
12
+ import { localRemixReference, RemixReferenceToolError, remixReferenceToolRequestSchema } from '../../references/protocol.js';
13
+ const ACTIVE = new Set(['dispatched', 'accepted', 'uncertain']);
14
+ const candidatesSchema = z.object({ items: z.array(z.unknown()).max(50),
15
+ total: z.number().int().nonnegative(), page: z.number().int().positive(), pageSize: z.number().int().positive() }).strict();
16
+ const SOURCE_ERRORS = new Set(['remix_reference_unavailable', 'remix_reference_too_large', 'remix_reference_incomplete',
17
+ 'remix_reference_unsupported', 'remix_reference_source_changed', 'workspace_access_denied', 'workspace_permission_denied']);
18
+ /** Executes source-reference operations only for the shell and native turn owning this route. */
19
+ export class WorkspaceRemixReferencesBroker {
20
+ state;
21
+ constructor(state) {
22
+ this.state = state;
23
+ }
24
+ operation(context, valid, write = false) {
25
+ const state = this.state;
26
+ const session = state.journal.getSession(context.sessionId);
27
+ const operations = state.journal.listOperations().map(item => item.operation).filter(operation => ['session.turn', 'session.review', 'session.compact'].includes(operation.kind)
28
+ && ACTIVE.has(operation.status) && operation.generation === state.config.generation && operation.runtimeEpoch === state.runtimeEpoch);
29
+ const operation = operations.length === 1 ? operations[0] : undefined;
30
+ if (!valid() || context.refsBlocked || state.journal.destroyed() || !operation || operation.kind !== 'session.turn' || operation.sessionId !== context.sessionId
31
+ || operation.generation !== state.config.generation || operation.runtimeEpoch !== state.runtimeEpoch
32
+ || operation.clientRequestId !== context.clientRequestId || !operation.workspaceId || !operation.directoryName
33
+ || operation.directoryName === 'root' || !operation.nativeThreadId || !operation.nativeTurnId
34
+ || !session || session.threadId !== operation.nativeThreadId || session.activeTurnId !== operation.nativeTurnId
35
+ || session.cwd !== context.cwd || session.workspaceId !== operation.workspaceId
36
+ || (session.directoryName ?? 'basic') !== operation.directoryName || session.turnBlock === 'uncertain'
37
+ || !['running', 'waiting_for_user'].includes(session.turnStatus ?? ''))
38
+ throw new RemixReferenceToolError('remix_reference_turn_unavailable');
39
+ if (write) {
40
+ if ((operation.leaseFence ?? 0) < state.acceptedLeaseFence)
41
+ throw new RemixReferenceToolError('remix_reference_turn_unavailable');
42
+ if (context.readOnly || operation.workMode === 'plan' || operation.permissionMode === 'read-only' || session.permissionMode === 'read-only')
43
+ throw new RemixReferenceToolError('remix_reference_readonly');
44
+ if (state.journal.listSessions().some(other => other.sessionId !== context.sessionId
45
+ && other.workspaceId === operation.workspaceId
46
+ && ((other.directoryName ?? 'basic') === operation.directoryName || other.directoryName === 'root')
47
+ && (other.turnBlock === 'uncertain' || ['starting', 'running', 'waiting_for_user'].includes(other.turnStatus ?? ''))))
48
+ throw new RemixReferenceToolError('turn_already_active');
49
+ }
50
+ return operation;
51
+ }
52
+ async current(context, valid, write = false) {
53
+ const operation = this.operation(context, valid, write);
54
+ const scope = await workspaceContextFromRoot(this.state, context.cwd);
55
+ if (scope.workspaceId !== operation.workspaceId || scope.directoryName !== operation.directoryName)
56
+ throw new RemixReferenceToolError('remix_reference_scope_mismatch');
57
+ if (write && await batchDirectoryHeld(this.state, scope.workspaceId, scope.directoryName))
58
+ throw new RemixReferenceToolError('batch_workspace_readonly');
59
+ this.operation(context, valid, write);
60
+ return { ...scope, operation };
61
+ }
62
+ async forward(request, response, context, valid) {
63
+ const reply = (status, value) => {
64
+ response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
65
+ response.end(JSON.stringify(value));
66
+ };
67
+ try {
68
+ if (request.socket.remoteAddress !== '127.0.0.1')
69
+ throw new RemixReferenceToolError('remix_reference_turn_unavailable');
70
+ let text = '';
71
+ for await (const chunk of request) {
72
+ text += chunk.toString();
73
+ if (Buffer.byteLength(text) > 16 * 1024)
74
+ throw new RemixReferenceToolError('invalid_remix_reference_tool_request');
75
+ }
76
+ const parsed = remixReferenceToolRequestSchema.safeParse(JSON.parse(text));
77
+ if (!parsed.success)
78
+ throw new RemixReferenceToolError('invalid_remix_reference_tool_request');
79
+ const input = parsed.data;
80
+ const write = input.action === 'import';
81
+ const scope = await this.current(context, valid, write);
82
+ const callerCwd = await realpath(input.cwd);
83
+ const childPath = relative(context.cwd, callerCwd);
84
+ if (isAbsolute(childPath) || childPath === '..' || childPath.startsWith('../') || input.workspaceId !== undefined && input.workspaceId !== scope.workspaceId
85
+ || input.directoryName !== undefined && input.directoryName !== scope.directoryName)
86
+ throw new RemixReferenceToolError('remix_reference_scope_mismatch');
87
+ const socketPath = process.env.PLAYCRAFT_LAUNCHER_SOCKET;
88
+ if (!socketPath || !context.boundaryId || !request.socket.localPort || !request.socket.remotePort
89
+ || !await verifyLauncherOrigin({ socketPath, boundaryId: context.boundaryId, runtimeEpoch: this.state.runtimeEpoch,
90
+ serverPort: request.socket.localPort, clientPort: request.socket.remotePort, cwd: context.originCwd ?? callerCwd }))
91
+ throw new RemixReferenceToolError('remix_reference_turn_unavailable');
92
+ await this.current(context, valid, write);
93
+ if (input.action === 'list') {
94
+ const items = await this.state.journal.exclusive(`workspace:${scope.workspaceId}:${scope.directoryName}`, () => listWorkspaceRemixReferences(context.cwd, scope.directoryName));
95
+ this.operation(context, valid);
96
+ return reply(200, { ok: true, workspaceId: scope.workspaceId, directoryName: scope.directoryName,
97
+ items: items.map(reference => localRemixReference(context.cwd, reference)) });
98
+ }
99
+ if (input.action === 'search') {
100
+ const result = candidatesSchema.parse(await this.upstream(scope, { action: 'search', source: input.source,
101
+ ...(input.tagCategory === undefined ? {} : { tagCategory: input.tagCategory, tagValues: input.tagValues }),
102
+ ...(input.q === undefined ? {} : { q: input.q }), page: input.page, limit: input.limit }, 2 * 1024 * 1024));
103
+ await this.current(context, valid);
104
+ return reply(200, { ok: true, workspaceId: scope.workspaceId, directoryName: scope.directoryName, ...result,
105
+ items: result.items.map(item => workspaceRemixReferenceCandidateSchema.parse(item)) });
106
+ }
107
+ const result = await withDirectoryWrite(this.state, { workspaceId: scope.workspaceId, directoryName: scope.directoryName,
108
+ sessionId: context.sessionId, wait: false }, async () => {
109
+ await this.current(context, valid, true);
110
+ const raw = await this.upstream(scope, { action: 'export', projectUuid: input.projectUuid, branchName: input.branchName }, WORKSPACE_REMIX_REFERENCE_BODY_MAX_BYTES);
111
+ await this.current(context, valid, true);
112
+ const snapshot = workspaceRemixReferenceExportSchema.parse(raw);
113
+ if (snapshot.source.environment !== this.state.config.environment || snapshot.source.projectUuid !== input.projectUuid
114
+ || snapshot.source.branchName !== input.branchName)
115
+ throw new RemixReferenceToolError('remix_reference_unavailable');
116
+ return this.state.journal.exclusive(`workspace:${scope.workspaceId}:${scope.directoryName}`, async () => {
117
+ await this.current(context, valid, true);
118
+ await shareWorkspaceGit(this.state, context.cwd);
119
+ await this.current(context, valid, true);
120
+ const reference = await importWorkspaceRemixReference(context.cwd, scope.directoryName, snapshot, () => {
121
+ this.operation(context, valid, true);
122
+ });
123
+ this.operation(context, valid, true);
124
+ await this.state.journal.allocateSourceEvent({ runtimeId: this.state.config.runtimeId,
125
+ generation: this.state.config.generation, runtimeEpoch: this.state.runtimeEpoch,
126
+ type: 'workspace.references.imported', occurredAt: new Date().toISOString(),
127
+ sessionId: context.sessionId, workspaceId: scope.workspaceId, operationId: scope.operation.operationId,
128
+ payload: { sessionId: context.sessionId, workspaceId: scope.workspaceId, directoryName: scope.directoryName,
129
+ referenceId: reference.referenceId, path: reference.path, contentHash: reference.contentHash } });
130
+ await this.state.journal.allocateSourceEvent({ runtimeId: this.state.config.runtimeId,
131
+ generation: this.state.config.generation, runtimeEpoch: this.state.runtimeEpoch,
132
+ type: 'files.changed', occurredAt: new Date().toISOString(), sessionId: context.sessionId,
133
+ workspaceId: scope.workspaceId, operationId: scope.operation.operationId,
134
+ payload: { workspaceId: scope.workspaceId, directoryName: scope.directoryName,
135
+ paths: [reference.path, WORKSPACE_REMIX_REFERENCES_MANIFEST], source: 'workspace.references.import',
136
+ operationId: scope.operation.operationId } });
137
+ wakeSourceEventUploader(this.state);
138
+ return reference;
139
+ });
140
+ });
141
+ return reply(200, { ok: true, workspaceId: scope.workspaceId, directoryName: scope.directoryName,
142
+ reference: localRemixReference(context.cwd, result) });
143
+ }
144
+ catch (error) {
145
+ if (response.headersSent) {
146
+ response.destroy();
147
+ return;
148
+ }
149
+ const code = error instanceof SyntaxError ? 'invalid_remix_reference_tool_request' : error instanceof RemixReferenceToolError ? error.code
150
+ : error instanceof Error && /^(?:remix_reference_[a-z_]+|directory_busy)$/.test(error.message)
151
+ ? error.message : 'remix_reference_tool_failed';
152
+ reply(code === 'invalid_remix_reference_tool_request' ? 400
153
+ : ['turn_already_active', 'batch_workspace_readonly', 'directory_busy'].includes(code) ? 409 : 403, { ok: false, error: { code } });
154
+ }
155
+ }
156
+ async upstream(scope, input, limit) {
157
+ const config = this.state.config;
158
+ const response = await fetch(platformEndpoint(config.publicBaseUrl, PLATFORM_ROUTES.remixReferenceTools), {
159
+ method: 'POST', redirect: 'error', signal: AbortSignal.timeout(120_000),
160
+ headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
161
+ body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation, runtimeEpoch: this.state.runtimeEpoch,
162
+ workspaceId: scope.workspaceId, directoryName: scope.directoryName, ...input }),
163
+ });
164
+ const chunks = [];
165
+ let bytes = 0;
166
+ if (!response.body)
167
+ throw new RemixReferenceToolError('remix_reference_unavailable');
168
+ for await (const chunk of response.body) {
169
+ bytes += chunk.byteLength;
170
+ if (bytes > (response.ok ? limit : 8192)) {
171
+ await response.body.cancel().catch(() => undefined);
172
+ throw new RemixReferenceToolError('remix_reference_too_large');
173
+ }
174
+ chunks.push(Buffer.from(chunk));
175
+ }
176
+ const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
177
+ if (!response.ok) {
178
+ const code = [value?.reason, value?.code, value?.message, value?.error?.reason, value?.error?.code]
179
+ .find(code => typeof code === 'string' && SOURCE_ERRORS.has(code));
180
+ throw new RemixReferenceToolError(code ?? 'remix_reference_unavailable');
181
+ }
182
+ return value;
183
+ }
184
+ }
@@ -0,0 +1,80 @@
1
+ import { z } from 'zod';
2
+ import { scheduledTaskProposalDraftSchema, scheduledTaskProposalDraftJsonSchema } from '@playcraft/common/cloud-workspace/runtime';
3
+ import { rejectTool, toolContent } from './tool-response.js';
4
+ export const WORKSPACE_SCHEDULED_TASK_TOOLS = [
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.',
7
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
8
+ { type: 'function', name: 'playcraft_propose_scheduled_task',
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.',
10
+ inputSchema: { type: 'object', properties: {
11
+ draft: scheduledTaskProposalDraftJsonSchema, supersedesProposalId: { type: 'string', format: 'uuid' },
12
+ }, required: ['draft'], additionalProperties: false } },
13
+ ];
14
+ export const SCHEDULED_TASK_TOOL_ERROR_CODES = new Set([
15
+ 'unavailable', 'source_unavailable', 'source_pending', 'invalid_input', 'request_conflict', 'request_pending',
16
+ 'feature_disabled', 'limit', 'version_conflict', 'schedule_expired', 'invalid_time_zone', 'interval_too_short',
17
+ 'target_read_only', 'target_unavailable', 'target_access_denied', 'target_binding_changed', 'session_unavailable',
18
+ 'account_unavailable', 'model_unavailable', 'reasoning_effort_unavailable', 'revoked',
19
+ ].map(code => `scheduled_task_proposal_${code}`));
20
+ const prepareArguments = z.object({ draft: z.unknown(), supersedesProposalId: z.string().uuid().optional() }).strict();
21
+ const sourceKey = (source) => JSON.stringify([source.threadId, source.turnId, source.toolCallId]);
22
+ /** Only a live root-native call can attest the source of a prepare-only bridge request. */
23
+ export class WorkspaceScheduledTaskTools {
24
+ handler;
25
+ changed;
26
+ active = new Map();
27
+ settled = new Map();
28
+ constructor(handler, changed) {
29
+ this.handler = handler;
30
+ this.changed = changed;
31
+ }
32
+ isActive(source) {
33
+ const call = this.active.get(sourceKey(source));
34
+ return Boolean(call && call.source.bridge === source.bridge && call.current());
35
+ }
36
+ async request(requestId, payload, client, context) {
37
+ if (!context?.bridge || !context.current() || typeof payload.callId !== 'string' || !payload.callId.length
38
+ || payload.namespace)
39
+ return rejectTool(client, requestId, 'scheduled_task_proposal_source_unavailable');
40
+ const action = payload.tool === 'playcraft_get_scheduled_task_context' ? 'context'
41
+ : payload.tool === 'playcraft_propose_scheduled_task' ? 'prepare' : undefined;
42
+ const parsed = action === 'context' ? z.object({}).strict().safeParse(payload.arguments)
43
+ : prepareArguments.safeParse(payload.arguments);
44
+ if (!action || !parsed.success || (action === 'prepare'
45
+ && !scheduledTaskProposalDraftSchema.safeParse(parsed.data.draft).success))
46
+ return rejectTool(client, requestId, 'scheduled_task_proposal_invalid_input');
47
+ const source = { bridge: context.bridge, threadId: context.threadId, turnId: context.turnId, toolCallId: payload.callId };
48
+ const key = sourceKey(source), fingerprint = JSON.stringify([action, parsed.data]);
49
+ const prior = this.settled.get(key);
50
+ if (prior) {
51
+ if (prior.fingerprint !== fingerprint)
52
+ return rejectTool(client, requestId, 'scheduled_task_proposal_request_conflict');
53
+ await client.respond(requestId, { success: true, contentItems: toolContent(prior.result) });
54
+ return;
55
+ }
56
+ if (this.active.has(key))
57
+ return rejectTool(client, requestId, 'scheduled_task_proposal_request_pending');
58
+ this.active.set(key, { source, current: context.current });
59
+ try {
60
+ const result = await this.handler(action, source, parsed.data);
61
+ this.settled.set(key, { fingerprint, result });
62
+ if (this.settled.size > 256)
63
+ this.settled.delete(this.settled.keys().next().value);
64
+ if (action === 'prepare' && result && typeof result === 'object'
65
+ && 'proposalId' in result && typeof result.proposalId === 'string')
66
+ this.changed(result.proposalId, source);
67
+ await client.respond(requestId, { success: true, contentItems: toolContent(result) });
68
+ }
69
+ catch (error) {
70
+ await rejectTool(client, requestId, error instanceof Error && SCHEDULED_TASK_TOOL_ERROR_CODES.has(error.message)
71
+ ? error.message : 'scheduled_task_proposal_unavailable');
72
+ }
73
+ finally {
74
+ this.active.delete(key);
75
+ }
76
+ }
77
+ }
78
+ export function isWorkspaceScheduledTaskTool(value) {
79
+ return WORKSPACE_SCHEDULED_TASK_TOOLS.some(tool => tool.name === value);
80
+ }
@@ -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
+ }
@@ -33,6 +33,8 @@ import { observeWorkspaceArtifacts } from './workspaces/artifact-events.js';
33
33
  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
+ import { scheduledTaskProposalHandler } from './workspaces/scheduled-task-proposals.js';
37
+ import { proceduralSkillHandler } from './workspaces/procedural-3d/host.js';
36
38
  export async function startWorkspaceRuntime(config, options = {}) {
37
39
  process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
38
40
  const negotiated = negotiateProtocol(config);
@@ -54,7 +56,11 @@ export async function startWorkspaceRuntime(config, options = {}) {
54
56
  const previousEpoch = journal.storedRuntimeEpoch();
55
57
  const previousGroup = journal.executionGroup();
56
58
  const previousIdentityMatches = journal.epochsSnapshot().some((epoch) => epoch.runtime_id === config.runtimeId && epoch.generation === config.generation && epoch.runtime_epoch === previousEpoch);
57
- 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
+ });
58
64
  await recoverInterruptedPreviews(journal);
59
65
  await recoverInterruptedParameterVerifications(journal);
60
66
  await recoverInterruptedDeliveries(config.stateDir);
@@ -412,6 +418,8 @@ function defaultNativeAdapter(state) {
412
418
  config,
413
419
  workspaceUi: true,
414
420
  deliveryTools: deliveryToolHandlers(state),
421
+ scheduledTaskProposals: scheduledTaskProposalHandler(state),
422
+ productionSkill: proceduralSkillHandler(state),
415
423
  ...(multi ? { modelBroker: new WorkspaceModelBroker(state) } : {}),
416
424
  ...(multi ? { loadMcpConfig: async (cwd) => {
417
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
  }