@playcraft/cli 0.1.60-beta → 0.1.62-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 (40) hide show
  1. package/dist/commands/build.js +16 -7
  2. package/dist/project-skills/builtin-import.js +2 -2
  3. package/dist/skill-registry/messages.js +2 -2
  4. package/dist/skill-registry/platform-package.js +2 -4
  5. package/dist/utils/agent-api-client.js +22 -0
  6. package/dist/workspace-runtime/codex/app-server.js +24 -1
  7. package/dist/workspace-runtime/codex/config-toml.js +9 -5
  8. package/dist/workspace-runtime/codex/delivery-tools.js +3 -10
  9. package/dist/workspace-runtime/codex/launcher-client.js +25 -13
  10. package/dist/workspace-runtime/codex/model-broker.js +13 -3
  11. package/dist/workspace-runtime/codex/production-skill-tools.js +95 -0
  12. package/dist/workspace-runtime/processes/skill-command.js +130 -0
  13. package/dist/workspace-runtime/serve.js +12 -1
  14. package/dist/workspace-runtime/server/accept-operation.js +10 -1
  15. package/dist/workspace-runtime/server/directory-access.js +11 -4
  16. package/dist/workspace-runtime/server/dispatch.js +70 -3
  17. package/dist/workspace-runtime/server/http.js +61 -3
  18. package/dist/workspace-runtime/server/operation-record.js +4 -1
  19. package/dist/workspace-runtime/workspaces/dependencies.js +35 -10
  20. package/dist/workspace-runtime/workspaces/developer-command.js +65 -64
  21. package/dist/workspace-runtime/workspaces/directories.js +4 -1
  22. package/dist/workspace-runtime/workspaces/empty-template.js +3 -1
  23. package/dist/workspace-runtime/workspaces/platform-export.js +3 -3
  24. package/dist/workspace-runtime/workspaces/procedural-3d/candidate.js +129 -0
  25. package/dist/workspace-runtime/workspaces/procedural-3d/contracts.js +34 -0
  26. package/dist/workspace-runtime/workspaces/procedural-3d/files.js +83 -0
  27. package/dist/workspace-runtime/workspaces/procedural-3d/host.js +595 -0
  28. package/dist/workspace-runtime/workspaces/procedural-3d/model-preview-build.js +184 -0
  29. package/dist/workspace-runtime/workspaces/procedural-3d/model-preview-recovery.js +77 -0
  30. package/dist/workspace-runtime/workspaces/procedural-3d/model-preview-store.js +170 -0
  31. package/dist/workspace-runtime/workspaces/procedural-3d/model-preview-worker.js +85 -0
  32. package/dist/workspace-runtime/workspaces/procedural-3d/read-candidates.js +219 -0
  33. package/dist/workspace-runtime/workspaces/procedural-3d/render-worker.js +159 -0
  34. package/dist/workspace-runtime/workspaces/procedural-3d/review-contracts.js +257 -0
  35. package/dist/workspace-runtime/workspaces/procedural-3d/review-page.js +26 -0
  36. package/dist/workspace-runtime/workspaces/skill-execution-context.js +69 -0
  37. package/dist/workspace-runtime/workspaces/skills.js +15 -5
  38. package/package.json +4 -3
  39. package/project-template-v2/.playcraft/skills.lock.json +7 -0
  40. package/project-templates/empty/AGENTS.md +11 -1
@@ -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 }));
@@ -8,7 +8,7 @@ const instances = new WeakMap();
8
8
  const executing = new WeakMap();
9
9
  const freezing = new WeakMap();
10
10
  const TERMINAL = new Set(['completed', 'failed', 'interrupted']);
11
- const EXCLUSIVE_OPERATIONS = new Set(['preview.publish', 'delivery.build', 'directory.create',
11
+ const EXCLUSIVE_OPERATIONS = new Set(['preview.publish', 'delivery.build', 'model.preview', 'directory.create',
12
12
  'directory.rename', 'directory.delete', 'workspace.references.import', 'skills.update', 'parameter.verify',
13
13
  'checkpoint.create', 'checkpoint.restore']);
14
14
  export function managedDirectoryScope(state, value) {
@@ -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
  }
@@ -1,11 +1,12 @@
1
1
  import { withObservedFile } from '../workspaces/revisions.js';
2
2
  import { revisionScope, sessionWorkspaceRoot, workspaceRoot, workspaceSessions } from '../workspaces/registry.js';
3
+ import { preflightSkillExecution } from '../workspaces/skill-execution-context.js';
3
4
  import { abandonTurnChanges, beginTurnChanges, finishTurnChanges } from '../workspaces/turn-changes.js';
4
5
  import { shareWorkspaceGit } from '../workspaces/shared-git.js';
5
- import { holdDirectoryAccess, withOperationDirectoryAccess } from './directory-access.js';
6
+ import { cancelPendingDirectoryAccess, holdDirectoryAccess, withOperationDirectoryAccess } from './directory-access.js';
6
7
  import { join } from 'node:path';
7
8
  import { z } from 'zod';
8
- import { fileWritePayloadSchema, fileBinaryWritePayloadSchema, directoryCreatePayloadSchema, directoryRenamePayloadSchema, directoryDeletePayloadSchema, directoryMutationReceiptSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, gitPullPayloadSchema, gitMergeAbortPayloadSchema, parameterPatchPayloadSchema, parameterAgentTaskRequestSchema, parameterAgentVerifyPayloadSchema, productionSessionAttemptId, reasoningEffortSchema, workspacePermissionModeSchema, workspaceComposerSelectionSchema, workspaceReviewPayloadSchema, workspaceCompactPayloadSchema, workspaceSteerPayloadSchema, workspaceApprovalResponseSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, recoverUncertainOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, workspaceRemixReferenceImportPayloadSchema, deliveryBuildPayloadSchema, deliveryCancelPayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
9
+ import { fileWritePayloadSchema, fileBinaryWritePayloadSchema, directoryCreatePayloadSchema, directoryRenamePayloadSchema, directoryDeletePayloadSchema, directoryMutationReceiptSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, gitPullPayloadSchema, gitMergeAbortPayloadSchema, parameterPatchPayloadSchema, parameterAgentTaskRequestSchema, parameterAgentVerifyPayloadSchema, productionSessionAttemptId, reasoningEffortSchema, workspacePermissionModeSchema, workspaceComposerSelectionSchema, workspaceReviewPayloadSchema, workspaceCompactPayloadSchema, workspaceSteerPayloadSchema, workspaceApprovalResponseSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, recoverUncertainOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, workspaceRemixReferenceImportPayloadSchema, deliveryBuildPayloadSchema, deliveryCancelPayloadSchema, workspaceModelPreviewBuildSchema, workspaceModelPreviewCancelSchema, } from '@playcraft/common/cloud-workspace/runtime';
9
10
  import { closeCliCredentials, ensureCliCredentials } from '../workspaces/cli-credentials.js';
10
11
  import { artifactFormat } from '../workspaces/artifact-files.js';
11
12
  import { refreshWorkspaceArtifacts } from '../workspaces/artifact-events.js';
@@ -42,6 +43,7 @@ import { observeWorkspaceDirectories } from '../workspaces/directory-revisions.j
42
43
  import { publishAssetTransfer } from '../workspaces/asset-transfer-publish.js';
43
44
  import { AssetTransferError } from '../workspaces/asset-transfer-storage.js';
44
45
  import { runDeliveryBuild, cancelDeliveryBuild, trackDeliveryBuild, DeliveryBuildError } from '../workspaces/delivery-build.js';
46
+ import { runModelPreviewBuild, cancelModelPreview, modelPreviewError } from '../workspaces/procedural-3d/model-preview-build.js';
45
47
  const CONVERSATION_KINDS = new Set([
46
48
  'session.create',
47
49
  'session.turn',
@@ -76,6 +78,8 @@ const LOCAL_KINDS = new Set([
76
78
  'preview.publish',
77
79
  'delivery.build',
78
80
  'delivery.cancel',
81
+ 'model.preview',
82
+ 'model.preview.cancel',
79
83
  'skills.update',
80
84
  ]);
81
85
  const parameterVerifications = new WeakMap();
@@ -119,7 +123,7 @@ export async function executeOperation(state, scopeKey, operation, payload, reco
119
123
  return executeScopedOperation(state, scopeKey, operation, payload, recovery);
120
124
  }, { operationId: operation.operationId, scopeKey, payload, ...(recovery ? { recovery } : {}) });
121
125
  };
122
- if (['runtime.destroy', 'session.interrupt', 'session.approval.respond', 'session.steer', 'preview.publish', 'delivery.build', 'delivery.cancel'].includes(operation.kind)) {
126
+ if (['runtime.destroy', 'session.interrupt', 'session.approval.respond', 'session.steer', 'preview.publish', 'delivery.build', 'delivery.cancel', 'model.preview', 'model.preview.cancel'].includes(operation.kind)) {
123
127
  return run();
124
128
  }
125
129
  return state.journal.exclusive(operation.kind.startsWith('session.') && operation.sessionId
@@ -201,6 +205,9 @@ async function executeScopedOperation(state, scopeKey, operation, payload, recov
201
205
  if (operation.kind === 'delivery.cancel') {
202
206
  return executeDeliveryCancel(state, scopeKey, current, payload);
203
207
  }
208
+ if (operation.kind === 'model.preview' || operation.kind === 'model.preview.cancel') {
209
+ return executeModelPreview(state, scopeKey, current, payload);
210
+ }
204
211
  if (!state.native?.ready()) {
205
212
  return persist(state, scopeKey, transitionOperation(current, 'failed', { error: 'capability_unavailable' }));
206
213
  }
@@ -411,6 +418,20 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
411
418
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'turn_already_active' }));
412
419
  }
413
420
  const root = await sessionWorkspaceRoot(state, operation.workspaceId, operation.directoryName);
421
+ try {
422
+ const policy = await preflightSkillExecution(state, { ...operation, payload });
423
+ if (policy?.directoryAccessMode === 'exclusive' && operation.directoryAccessMode !== 'exclusive')
424
+ throw new ComposerContextError('requires-exclusive-turn');
425
+ if (operation.skillExecutionPolicyVersion === 1 && (!policy ||
426
+ (parameterTask.data ? 'exclusive' : policy.directoryAccessMode) !== operation.directoryAccessMode))
427
+ throw new ComposerContextError('context_skills_changed');
428
+ }
429
+ catch (error) {
430
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', {
431
+ error: error instanceof Error && /^(?:context_|requires-exclusive-turn)/.test(error.message)
432
+ ? error.message : 'context_skill_unavailable',
433
+ }));
434
+ }
414
435
  let composer;
415
436
  let initializationSkill;
416
437
  try {
@@ -858,9 +879,32 @@ async function executeInterrupt(state, scopeKey, operation, payload) {
858
879
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'unknown_session' }));
859
880
  }
860
881
  const expectedTurnId = asRecord(payload).expectedTurnId;
882
+ const cancelTarget = asRecord(payload).cancelQueuedOperationId;
861
883
  if (expectedTurnId !== undefined && (typeof expectedTurnId !== 'string' || !expectedTurnId
862
884
  || binding.activeTurnId !== expectedTurnId))
863
885
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'turn_changed' }));
886
+ // A directory-gated turn has no native turn to interrupt. Cancel only a proven pending
887
+ // claim; an admitted/racing claim still goes through the native interruption contract.
888
+ if (expectedTurnId === undefined && !binding.activeTurnId) {
889
+ const candidates = state.journal.deferredDirectoryOperations().flatMap(pending => {
890
+ const queued = state.journal.getOperationById(pending.operationId);
891
+ return queued?.status === 'queued' && ['session.turn', 'session.review', 'session.compact'].includes(queued.kind)
892
+ && queued.sessionId === sessionId && queued.workspaceId === operation.workspaceId
893
+ && (queued.directoryName ?? 'basic') === (operation.directoryName ?? 'basic')
894
+ && (cancelTarget === undefined || cancelTarget === queued.operationId || cancelTarget === queued.gatewayOperationId)
895
+ && !queued.nativeTurnId && !queued.nativeRequestId ? [{ pending, queued }] : [];
896
+ });
897
+ if (candidates.length === 1) {
898
+ const { pending, queued } = candidates[0];
899
+ if (await cancelPendingDirectoryAccess(state, `operation:${queued.operationId}`)) {
900
+ await persist(state, pending.scopeKey, transitionOperation(queued, 'interrupted', { error: 'cancelled' }));
901
+ await state.journal.deleteDeferredDirectoryOperation(queued.operationId);
902
+ return persist(state, scopeKey, transitionOperation(transitionOperation(operation, 'accepted'), 'completed'));
903
+ }
904
+ }
905
+ }
906
+ if (cancelTarget !== undefined && expectedTurnId === undefined)
907
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'turn_changed' }));
864
908
  const result = await native(state).interrupt({ sessionId, threadId: binding.threadId,
865
909
  ...(typeof expectedTurnId === 'string' ? { expectedTurnId } : {}) });
866
910
  if (result.outcome === 'failed') {
@@ -1466,6 +1510,29 @@ async function executePreviewPublish(state, scopeKey, operation) {
1466
1510
  }).catch(() => undefined));
1467
1511
  return accepted;
1468
1512
  }
1513
+ async function executeModelPreview(state, scopeKey, operation, payload) {
1514
+ if (!operation.workspaceId || operation.directoryName === 'root')
1515
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'model_preview_scope_invalid' }));
1516
+ const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
1517
+ if (operation.kind === 'model.preview.cancel') {
1518
+ try {
1519
+ const input = workspaceModelPreviewCancelSchema.parse(payload);
1520
+ await cancelModelPreview(state, operation.workspaceId, operation.directoryName ?? 'basic', input.previewId);
1521
+ return persist(state, scopeKey, transitionOperation(accepted, 'completed'));
1522
+ }
1523
+ catch (error) {
1524
+ return persist(state, scopeKey, transitionOperation(accepted, 'failed', { error: modelPreviewError(error) }));
1525
+ }
1526
+ }
1527
+ const parsed = workspaceModelPreviewBuildSchema.safeParse(payload);
1528
+ if (!parsed.success)
1529
+ return persist(state, scopeKey, transitionOperation(accepted, 'failed', { error: 'model_preview_invalid_request' }));
1530
+ trackPreviewPublish(state, runModelPreviewBuild(state, accepted, parsed.data).then(async () => { await persist(state, scopeKey, transitionOperation(accepted, 'completed')); }, async (error) => {
1531
+ const code = modelPreviewError(error);
1532
+ await persist(state, scopeKey, transitionOperation(accepted, code === 'model_preview_process_unknown' ? 'uncertain' : code === 'model_preview_cancelled' ? 'interrupted' : 'failed', { error: code }));
1533
+ }).catch(() => undefined));
1534
+ return accepted;
1535
+ }
1469
1536
  async function executeDeliveryBuild(state, scopeKey, operation, payload) {
1470
1537
  const parsed = deliveryBuildPayloadSchema.safeParse(payload);
1471
1538
  if (!parsed.success) {
@@ -22,7 +22,7 @@ import { validateDeliveryArtifact, DeliveryValidationError } from '../workspaces
22
22
  import { DeliveryStore } from '../workspaces/delivery-store.js';
23
23
  import { importWorkspaceLibraryAttachment, listWorkspaceLibraryAttachments, listWorkspaceLibraryImportReceipts, readLibraryAttachmentUpload } from '../workspaces/library-attachments.js';
24
24
  import { beginWorkspaceDirectoryAttachment, uploadWorkspaceDirectoryFile, completeWorkspaceDirectoryAttachment, cancelWorkspaceDirectoryAttachment } from '../workspaces/directory-attachments.js';
25
- import { readWorkspaceSkills } from '../workspaces/skills.js';
25
+ import { readWorkspaceSkills, readWorkspaceSkillSnapshot } from '../workspaces/skills.js';
26
26
  import { listWorkspaceRemixReferences, RemixReferenceError } from '../workspaces/remix-references.js';
27
27
  import { loadWorkspaceSkillArtifact } from '../workspaces/skill-artifact-loader.js';
28
28
  import { join } from 'node:path';
@@ -47,6 +47,8 @@ import { restoreNativeSession, SessionRestoreError } from './session-restore.js'
47
47
  import { handleAssetTransferRequest } from './asset-transfers.js';
48
48
  import { readWorktreeSource, registerChatWorktree } from '../workspaces/worktrees.js';
49
49
  import { withDirectoryWrite } from './directory-access.js';
50
+ import { readProceduralModelCandidates } from '../workspaces/procedural-3d/read-candidates.js';
51
+ import { ModelPreviewStore } from '../workspaces/procedural-3d/model-preview-store.js';
50
52
  export async function handleRuntimeRequest(state, request, response) {
51
53
  const url = new URL(request.url ?? '/', 'http://127.0.0.1');
52
54
  const workspaceId = url.searchParams.get('workspaceId');
@@ -137,6 +139,7 @@ async function dispatchRuntimeRequest(state, request, response) {
137
139
  ...(state.native?.scheduledTaskProposalSource ? { scheduledTaskProposalVersion: 1 } : {}),
138
140
  parameterAgentVersion: 1,
139
141
  directoryConcurrencyVersion: 1,
142
+ skillExecutionPolicyVersion: 1,
140
143
  parameterInitializationVersion: 1,
141
144
  remixReferencesVersion: 1,
142
145
  batchSourceFreezeRevision: 2,
@@ -530,6 +533,40 @@ async function dispatchRuntimeRequest(state, request, response) {
530
533
  writeJson(response, 200, deliveryBuildDetailSchema.parse({ record, ...(report ? { report } : {}) }));
531
534
  return;
532
535
  }
536
+ if (['GET', 'HEAD'].includes(request.method ?? '') && url.pathname === '/runtime/model-preview-content') {
537
+ if (writeDestroyed(state, response))
538
+ return;
539
+ const workspaceId = url.searchParams.get('workspaceId');
540
+ if (!workspaceId || directoryName === 'root') {
541
+ writeJson(response, 400, { error: 'invalid_workspace' });
542
+ return;
543
+ }
544
+ const root = await workspaceRoot(state, workspaceId, directoryName);
545
+ try {
546
+ const output = await new ModelPreviewStore(state.config.stateDir, root).content(url.searchParams.get('previewId') ?? '');
547
+ response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-length': output.bytes.length,
548
+ 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff', 'x-content-sha256': output.record.contentHash,
549
+ 'content-security-policy': "sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; connect-src 'none'",
550
+ 'referrer-policy': 'no-referrer' });
551
+ response.end(request.method === 'HEAD' ? undefined : output.bytes);
552
+ }
553
+ catch {
554
+ writeJson(response, 404, { error: 'model_preview_unavailable' });
555
+ }
556
+ return;
557
+ }
558
+ if (request.method === 'GET' && url.pathname === '/runtime/model-candidates') {
559
+ if (writeDestroyed(state, response))
560
+ return;
561
+ const workspaceId = url.searchParams.get('workspaceId');
562
+ if (!workspaceId || directoryName === 'root') {
563
+ writeJson(response, 400, { error: 'invalid_workspace' });
564
+ return;
565
+ }
566
+ const root = await workspaceRoot(state, workspaceId, directoryName);
567
+ writeJson(response, 200, await readProceduralModelCandidates(root, state.config.stateDir));
568
+ return;
569
+ }
533
570
  if (request.method === 'GET' && url.pathname === '/runtime/artifacts') {
534
571
  if (writeDestroyed(state, response))
535
572
  return;
@@ -707,6 +744,20 @@ async function dispatchRuntimeRequest(state, request, response) {
707
744
  }
708
745
  return;
709
746
  }
747
+ if (request.method === 'GET' && url.pathname === '/runtime/skills/snapshot') {
748
+ if (writeDestroyed(state, response))
749
+ return;
750
+ const workspaceId = url.searchParams.get('workspaceId') ?? undefined;
751
+ try {
752
+ writeJson(response, 200, await readWorkspaceSkillSnapshot(await workspaceRoot(state, workspaceId, directoryName)));
753
+ }
754
+ catch (error) {
755
+ if (error instanceof WorkspaceContextError)
756
+ throw error;
757
+ writeJson(response, 409, { error: 'context_skill_unavailable' });
758
+ }
759
+ return;
760
+ }
710
761
  if (request.method === 'GET' && url.pathname === '/runtime/skills') {
711
762
  if (writeDestroyed(state, response))
712
763
  return;
@@ -974,8 +1025,15 @@ async function acceptOperation(state, request, response) {
974
1025
  writeJson(response, 400, { error: 'invalid_workspace_directory' });
975
1026
  return;
976
1027
  }
977
- if (state.config.workspaceMode === 'multi' && parsed.data.kind === 'runtime.ensure' && parsed.data.workspaceId)
978
- await withDirectoryWrite(state, { workspaceId: parsed.data.workspaceId, directoryName: parsed.data.directoryName, mode: 'exclusive', wait: false }, () => restoreWorkspaceDirectory(state, parsed.data.workspaceId, parsed.data.directoryName));
1028
+ if (state.config.workspaceMode === 'multi' && parsed.data.kind === 'runtime.ensure' && parsed.data.workspaceId && parsed.data.directoryName !== 'root') {
1029
+ const workspaceId = parsed.data.workspaceId, directoryName = parsed.data.directoryName ?? 'basic';
1030
+ const restoring = state.journal.getWorkspace(workspaceId)?.directories?.some(directory => directory.directoryName === directoryName && directory.status === 'reclaimed') ?? false;
1031
+ // Opening a resident checkout only refreshes instructions and can share its background install.
1032
+ // Rebuilding a reclaimed checkout must still exclude every existing writer.
1033
+ await withDirectoryWrite(state, { workspaceId, directoryName, mode: restoring ? 'exclusive' : 'shared', wait: false }, async () => {
1034
+ await restoreWorkspaceDirectory(state, workspaceId, directoryName, restoring);
1035
+ });
1036
+ }
979
1037
  if (state.config.workspaceMode === 'multi')
980
1038
  await sessionWorkspaceRoot(state, parsed.data.workspaceId, parsed.data.directoryName);
981
1039
  // Shared classification: a new conversation writes no files, so a batch hold must not refuse it.
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { workspaceComposerSelectionSchema } from '@playcraft/common/cloud-workspace/runtime';
2
3
  /** One construction point for operation records so HTTP and in-sandbox callers cannot drift. */
3
- export function buildOperationRecord(state, parsed, payloadHash, leaseFence, executable = true) {
4
+ export function buildOperationRecord(state, parsed, payloadHash, leaseFence, executable = true, policy) {
4
5
  const now = new Date().toISOString();
5
6
  const payload = parsed.payload && typeof parsed.payload === 'object' && !Array.isArray(parsed.payload)
6
7
  ? parsed.payload
@@ -8,6 +9,8 @@ export function buildOperationRecord(state, parsed, payloadHash, leaseFence, exe
8
9
  return {
9
10
  operationId: randomUUID(),
10
11
  ...(parsed.gatewayOperationId ? { gatewayOperationId: parsed.gatewayOperationId } : {}),
12
+ ...(policy ? { skillExecutionPolicyVersion: policy.skillExecutionPolicyVersion, directoryAccessMode: policy.directoryAccessMode,
13
+ ...workspaceComposerSelectionSchema.parse({ context: payload.context, workMode: payload.workMode }) } : {}),
11
14
  ...(parsed.kind === 'session.turn' && payload.parameterTask !== undefined ? { directoryAccessMode: 'exclusive' } : {}),
12
15
  environment: state.config.environment,
13
16
  userId: state.config.userId,
@@ -5,6 +5,7 @@ import { setTimeout as delay } from 'node:timers/promises';
5
5
  import { join } from 'node:path';
6
6
  import { launcherSocket, runDeveloperCommand } from './developer-command.js';
7
7
  import { adoptDirectoryAccess, holdDirectoryAccess, managedDirectoryScope } from '../server/directory-access.js';
8
+ import { stopLauncherExecution } from '../codex/launcher-client.js';
8
9
  /** Long enough for a cold store on a game template; a stuck registry must not hold the write lease. */
9
10
  const INSTALL_TIMEOUT_MS = 10 * 60_000;
10
11
  const LOG_TAIL_LINES = 50;
@@ -96,7 +97,7 @@ export async function ensureWorkspaceDependencies(state, workspaceId, root) {
96
97
  * A lost controller makes the install result unknown. Keep its writer claim until a supervisor
97
98
  * confirms that the process group stopped; a closed connection alone cannot prove that.
98
99
  */
99
- export async function recoverInterruptedInstalls(journal) {
100
+ export async function recoverInterruptedInstalls(journal, recovery) {
100
101
  const now = new Date().toISOString();
101
102
  for (const record of journal.listWorkspaceDependencies()) {
102
103
  if (record.status !== 'installing')
@@ -108,6 +109,26 @@ export async function recoverInterruptedInstalls(journal) {
108
109
  finishedAt: now,
109
110
  });
110
111
  }
112
+ for (const writer of journal.listManagedWrites()) {
113
+ if (writer.stoppedAt || writer.source !== 'background' || writer.executionMode === 'local' || !writer.writeId.startsWith(WRITE_PREFIX))
114
+ continue;
115
+ // As with terminals, the trusted supervisor advances an epoch only after stop-all is confirmed.
116
+ const stoppedEpoch = recovery?.launcherSocket && recovery.identityMatches && recovery.previousRuntimeEpoch !== undefined &&
117
+ recovery.runtimeEpoch > recovery.previousRuntimeEpoch &&
118
+ (writer.runtimeEpoch ?? recovery.previousRuntimeEpoch) < recovery.runtimeEpoch;
119
+ if (!stoppedEpoch) {
120
+ if (!recovery?.identityMatches || !recovery.launcherSocket || !writer.launcher ||
121
+ writer.launcher.processId !== writer.writeId || writer.launcher.runtimeEpoch !== writer.runtimeEpoch)
122
+ continue;
123
+ try {
124
+ await stopLauncherExecution(recovery.launcherSocket, writer.launcher);
125
+ }
126
+ catch {
127
+ continue;
128
+ }
129
+ }
130
+ await journal.putManagedWrite({ ...writer, stoppedAt: now });
131
+ }
111
132
  }
112
133
  async function installOnce(state, workspaceId, root, writeId) {
113
134
  const startedAt = new Date().toISOString();
@@ -150,7 +171,9 @@ async function installOnce(state, workspaceId, root, writeId) {
150
171
  }
151
172
  await excludeFromGit(root);
152
173
  // A checkpoint waits for this write: node_modules is half written until the install ends.
153
- await state.journal.putManagedWrite({ writeId, workspaceId, source: 'background', startedAt });
174
+ const writer = { writeId, workspaceId, source: 'background', startedAt, runtimeEpoch: state.runtimeEpoch,
175
+ executionMode: launcherSocket(state) ? 'launcher' : 'local' };
176
+ await state.journal.putManagedWrite(writer);
154
177
  await state.journal.putWorkspaceDependencies({
155
178
  workspaceId,
156
179
  status: 'installing',
@@ -163,7 +186,7 @@ async function installOnce(state, workspaceId, root, writeId) {
163
186
  const shared = !launcherSocket(state) || (await waitUntilShared(root));
164
187
  if (shared) {
165
188
  processMayRun = true;
166
- outcome = await runInstall(state, workspaceId, root);
189
+ outcome = await runInstall(state, root, writer);
167
190
  processStopped = outcome.code !== undefined;
168
191
  }
169
192
  else
@@ -172,10 +195,7 @@ async function installOnce(state, workspaceId, root, writeId) {
172
195
  finally {
173
196
  if (!processMayRun || processStopped)
174
197
  await state.journal.putManagedWrite({
175
- writeId,
176
- workspaceId,
177
- source: 'background',
178
- startedAt,
198
+ ...(state.journal.getManagedWrite(writeId) ?? writer),
179
199
  stoppedAt: new Date().toISOString(),
180
200
  });
181
201
  }
@@ -203,22 +223,27 @@ async function installOnce(state, workspaceId, root, writeId) {
203
223
  * Runs as the development user through the launcher, so the packages belong to the same user as
204
224
  * Codex and its own later installs. Without a launcher (local runs and tests) it runs directly.
205
225
  */
206
- async function runInstall(state, workspaceId, root) {
226
+ async function runInstall(state, root, writer) {
207
227
  // Tests replace the install; a real registry is never reached from them.
208
228
  const planned = process.env.PLAYCRAFT_DEPENDENCY_INSTALL_COMMAND || dependencyInstallCommand(state);
209
229
  const logDir = join(state.config.stateDir, 'dependency-logs');
210
230
  // private
211
231
  await mkdir(logDir, { recursive: true, mode: 0o700 });
212
- const logPath = join(logDir, `${workspaceId}.log`);
232
+ const logPath = join(logDir, `${writer.workspaceId}.log`);
213
233
  // private
214
234
  await writeFile(logPath, `$ ${planned}\n`, { mode: 0o600 });
215
235
  let log = '';
216
236
  let pending = Promise.resolve();
217
237
  const outcome = await runDeveloperCommand(state, {
218
- processId: `${WRITE_PREFIX}${workspaceId}`,
238
+ processId: writer.writeId,
219
239
  cwd: root,
220
240
  command: planned,
221
241
  timeoutMs: INSTALL_TIMEOUT_MS,
242
+ onStarted: async (identity) => {
243
+ const current = state.journal.getManagedWrite(writer.writeId) ?? writer;
244
+ if (!current.stoppedAt)
245
+ await state.journal.putManagedWrite({ ...current, ...identity });
246
+ },
222
247
  onOutput: (chunk) => {
223
248
  const text = String(chunk);
224
249
  log = (log + text).slice(-64 * 1024);
@@ -13,97 +13,98 @@ export function launcherSocket(state) {
13
13
  export async function runDeveloperCommand(state, input) {
14
14
  if (input.signal?.aborted)
15
15
  return { code: null, timedOut: false, canceled: true };
16
- const command = `{ ${input.command}; } </dev/null 2>&1`;
16
+ const deadline = Date.now() + input.timeoutMs;
17
+ const command = `{ umask 007; ${input.command}; } </dev/null 2>&1`;
18
+ const gate = input.onStarted ? 'IFS= read -r playcraft_start && [ "$playcraft_start" = start ] && ' : '';
19
+ const args = ['-c', `umask 007; ${gate}exec "$BASH" -lc "$1"`, 'playcraft-command', command];
17
20
  const socketPath = launcherSocket(state);
18
21
  const launched = socketPath
19
22
  ? await startLauncherProcess({
20
23
  socketPath,
21
24
  processId: input.processId,
22
25
  runtimeEpoch: state.runtimeEpoch,
23
- argv: ['/usr/bin/bash', '-lc', command],
26
+ argv: ['/usr/bin/bash', ...args],
24
27
  cwd: input.cwd,
25
28
  env: {},
26
29
  })
27
30
  : undefined;
28
- launched?.stdin?.end();
29
31
  const local = launched
30
32
  ? undefined
31
- : spawn('bash', ['-lc', command], {
33
+ : spawn('bash', args, {
32
34
  cwd: input.cwd,
33
35
  detached: true,
34
- stdio: ['ignore', 'pipe', 'pipe'],
36
+ stdio: ['pipe', 'pipe', 'pipe'],
35
37
  });
36
38
  const stdout = launched?.stdout ?? local.stdout;
37
39
  const stderr = launched?.stderr ?? local.stderr;
40
+ local?.stdin.on('error', () => undefined);
38
41
  stdout.on('data', input.onOutput);
39
42
  stderr.on('data', input.onOutput);
40
- let timedOut = false;
41
- let canceled = false;
42
- let killTimer;
43
- const abort = () => {
44
- if (canceled || timedOut)
45
- return;
46
- canceled = true;
47
- if (launched) {
48
- void launched.stop().catch(() => undefined);
49
- return;
43
+ const exited = new Promise(resolve => {
44
+ const finish = (code) => resolve({ kind: 'exit', code });
45
+ if (launched)
46
+ launched.onExit(finish);
47
+ else {
48
+ local.once('error', () => finish(null));
49
+ local.once('close', finish);
50
50
  }
51
- if (!local?.pid)
52
- return;
51
+ });
52
+ let stopping;
53
+ const stop = () => stopping ??= (async () => {
53
54
  try {
54
- process.kill(-local.pid, 'SIGTERM');
55
+ if (launched) {
56
+ await launched.stop();
57
+ return true;
58
+ }
59
+ return !local?.pid || await stopBackgroundWriter(local.pid);
55
60
  }
56
61
  catch {
57
- /* already gone */
62
+ return false;
58
63
  }
59
- killTimer = setTimeout(() => {
60
- try {
61
- process.kill(-local.pid, 'SIGKILL');
62
- }
63
- catch {
64
- /* already gone */
65
- }
66
- }, 5_000);
67
- killTimer.unref?.();
68
- };
64
+ })();
65
+ let timedOut = false;
66
+ let canceled = false;
67
+ let interrupt;
68
+ const interrupted = new Promise(resolve => {
69
+ interrupt = () => { resolve({ kind: 'interrupted' }); void stop(); };
70
+ });
71
+ const abort = () => { canceled = true; interrupt(); };
72
+ const timeout = () => { timedOut = true; interrupt(); };
69
73
  input.signal?.addEventListener('abort', abort, { once: true });
70
- const code = await new Promise((resolve) => {
71
- const timer = setTimeout(() => {
72
- timedOut = true;
73
- if (launched)
74
- void launched.stop().catch(() => undefined);
75
- else if (local?.pid) {
76
- try {
77
- process.kill(-local.pid, 'SIGKILL');
78
- }
79
- catch {
80
- /* already gone */
81
- }
82
- }
83
- }, input.timeoutMs);
84
- const finish = (value) => {
85
- clearTimeout(timer);
86
- if (killTimer)
87
- clearTimeout(killTimer);
88
- input.signal?.removeEventListener('abort', abort);
89
- resolve(value);
90
- };
91
- if (launched)
92
- launched.onExit(finish);
93
- else {
94
- local.once('error', () => finish(null));
95
- local.once('close', (value) => finish(value));
96
- }
74
+ const timer = setTimeout(timeout, Math.max(0, deadline - Date.now()));
75
+ if (input.signal?.aborted)
76
+ abort();
77
+ if (Date.now() >= deadline)
78
+ timeout();
79
+ const outcome = async (code) => ({
80
+ code: await stop() ? code : undefined, timedOut, ...(canceled ? { canceled: true } : {}),
97
81
  });
98
- let stopped = !launched && !local?.pid;
99
82
  try {
100
- if (launched) {
101
- await launched.stop();
102
- stopped = true;
83
+ if (canceled || timedOut)
84
+ return await outcome(null);
85
+ if (input.onStarted) {
86
+ if (launched && !launched.executionIdentity)
87
+ throw new Error('launcher_execution_identity_unavailable');
88
+ const persisted = Promise.resolve().then(() => input.onStarted({ pid: launched?.pid ?? local?.pid,
89
+ ...(launched ? { launcher: launched.executionIdentity } : {}) })).then(() => ({ kind: 'persisted' }));
90
+ const ready = await Promise.race([persisted, interrupted, exited]);
91
+ if (ready.kind !== 'persisted')
92
+ return await outcome(ready.kind === 'exit' ? ready.code : null);
103
93
  }
104
- else if (local?.pid)
105
- stopped = await stopBackgroundWriter(local.pid);
94
+ // A late persistence result must never reopen a canceled or timed-out command gate.
95
+ if (canceled || timedOut)
96
+ return await outcome(null);
97
+ (launched?.stdin ?? local.stdin)?.end(input.onStarted ? 'start\n' : undefined);
98
+ const result = await Promise.race([exited, interrupted]);
99
+ return await outcome(result.kind === 'exit' ? result.code : null);
100
+ }
101
+ catch (error) {
102
+ // The gate never opened. Retain the journal claim if this stop cannot be confirmed.
103
+ await stop();
104
+ throw error;
105
+ }
106
+ finally {
107
+ clearTimeout(timer);
108
+ input.signal?.removeEventListener('abort', abort);
106
109
  }
107
- catch { /* Transport loss cannot prove the process group has stopped. */ }
108
- return { code: stopped ? code : undefined, timedOut, ...(canceled ? { canceled: true } : {}) };
109
110
  }
@@ -94,7 +94,7 @@ export async function registerWorkspaceDirectory(state, input, leaseFence, sourc
94
94
  });
95
95
  }
96
96
  /** A reclaimed checkout recovers its existing remote branch; no new variant or moving basic head is used. */
97
- export async function restoreWorkspaceDirectory(state, workspaceId, directoryName = 'basic') {
97
+ export async function restoreWorkspaceDirectory(state, workspaceId, directoryName = 'basic', allowReclaimedRestore = true) {
98
98
  if (directoryName === 'root')
99
99
  return;
100
100
  await state.journal.exclusive(`directory:${workspaceId}:${directoryName}`, async () => {
@@ -106,6 +106,9 @@ export async function restoreWorkspaceDirectory(state, workspaceId, directoryNam
106
106
  await ensureWorkspaceArtifactInstructions(await workspaceRoot(state, workspaceId, directoryName));
107
107
  return;
108
108
  }
109
+ // Recheck under the journal lock: a resident-directory caller holds only shared access.
110
+ if (!allowReclaimedRestore)
111
+ throw new Error('directory_busy');
109
112
  if (binding.targetKind === 'standalone' || !state.gitCredentials)
110
113
  throw new WorkspaceContextError('workspace_recovery_required');
111
114
  const preparation = await state.gitCredentials.prepareDirectory(workspaceId, directoryName);
@@ -4,6 +4,7 @@ import { resolveCliPackageRoot } from '../../scaffold/compose.js';
4
4
  import { importBuiltinProjectSkills } from '../../project-skills/builtin-import.js';
5
5
  import { createHash } from 'node:crypto';
6
6
  import { canonicalProjectSkillsJson } from '@playcraft/common/project-skills';
7
+ import { WORKSPACE_BUILTIN_SKILLS } from '@playcraft/common/cloud-workspace/runtime';
7
8
  import { syncLocalProjectSkills } from '../../project-skills/local-store.js';
8
9
  import { selectOptionalPlatformSkills } from './optional-platform-skills.js';
9
10
  import { WORKSPACE_REMIX_REFERENCES_SKILL } from './remix-reference-skill.js';
@@ -16,7 +17,8 @@ export const EMPTY_WORKSPACE_REPOSITORY_SKILLS = [
16
17
  WORKSPACE_REMIX_REFERENCES_SKILL,
17
18
  'playcraft-build-optimizer',
18
19
  ];
19
- export const EMPTY_WORKSPACE_PLATFORM_SKILLS = ['playcraft-video-understanding', 'playcraft-replica-validation'];
20
+ export const EMPTY_WORKSPACE_PLATFORM_SKILLS = WORKSPACE_BUILTIN_SKILLS
21
+ .filter(skill => skill.defaultOnInitialize).map(skill => skill.name);
20
22
  export const EMPTY_WORKSPACE_DEFAULT_SKILLS = [...EMPTY_WORKSPACE_REPOSITORY_SKILLS, ...EMPTY_WORKSPACE_PLATFORM_SKILLS];
21
23
  /** Only called in a new, unpublished staging directory; existing workspaces are never scaffolded. */
22
24
  export async function scaffoldEmptyWorkspace(root, skillCache, access) {