@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
@@ -1,8 +1,9 @@
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
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, } from '@playcraft/common/cloud-workspace/runtime';
@@ -411,6 +412,20 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
411
412
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'turn_already_active' }));
412
413
  }
413
414
  const root = await sessionWorkspaceRoot(state, operation.workspaceId, operation.directoryName);
415
+ try {
416
+ const policy = await preflightSkillExecution(state, { ...operation, payload });
417
+ if (policy?.directoryAccessMode === 'exclusive' && operation.directoryAccessMode !== 'exclusive')
418
+ throw new ComposerContextError('requires-exclusive-turn');
419
+ if (operation.skillExecutionPolicyVersion === 1 && (!policy ||
420
+ (parameterTask.data ? 'exclusive' : policy.directoryAccessMode) !== operation.directoryAccessMode))
421
+ throw new ComposerContextError('context_skills_changed');
422
+ }
423
+ catch (error) {
424
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', {
425
+ error: error instanceof Error && /^(?:context_|requires-exclusive-turn)/.test(error.message)
426
+ ? error.message : 'context_skill_unavailable',
427
+ }));
428
+ }
414
429
  let composer;
415
430
  let initializationSkill;
416
431
  try {
@@ -497,6 +512,7 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
497
512
  workMode: selection.data.workMode ?? 'execute',
498
513
  skillsLockHash: composer.skillsLockHash,
499
514
  uiSnapshot: uiSnapshot.data,
515
+ scheduledTaskProposalBridge: typeof body.scheduledTaskProposalBridge === 'string' ? body.scheduledTaskProposalBridge : undefined,
500
516
  simulateLostReceipt: body.simulateLostReceipt === true,
501
517
  persistReservation: async ({ nativeRequestId, modelConfigId, reasoningEffort, permissionMode }) => {
502
518
  const selectedEffort = reasoningEffort ?? requestedEffort;
@@ -857,9 +873,32 @@ async function executeInterrupt(state, scopeKey, operation, payload) {
857
873
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'unknown_session' }));
858
874
  }
859
875
  const expectedTurnId = asRecord(payload).expectedTurnId;
876
+ const cancelTarget = asRecord(payload).cancelQueuedOperationId;
860
877
  if (expectedTurnId !== undefined && (typeof expectedTurnId !== 'string' || !expectedTurnId
861
878
  || binding.activeTurnId !== expectedTurnId))
862
879
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'turn_changed' }));
880
+ // A directory-gated turn has no native turn to interrupt. Cancel only a proven pending
881
+ // claim; an admitted/racing claim still goes through the native interruption contract.
882
+ if (expectedTurnId === undefined && !binding.activeTurnId) {
883
+ const candidates = state.journal.deferredDirectoryOperations().flatMap(pending => {
884
+ const queued = state.journal.getOperationById(pending.operationId);
885
+ return queued?.status === 'queued' && ['session.turn', 'session.review', 'session.compact'].includes(queued.kind)
886
+ && queued.sessionId === sessionId && queued.workspaceId === operation.workspaceId
887
+ && (queued.directoryName ?? 'basic') === (operation.directoryName ?? 'basic')
888
+ && (cancelTarget === undefined || cancelTarget === queued.operationId || cancelTarget === queued.gatewayOperationId)
889
+ && !queued.nativeTurnId && !queued.nativeRequestId ? [{ pending, queued }] : [];
890
+ });
891
+ if (candidates.length === 1) {
892
+ const { pending, queued } = candidates[0];
893
+ if (await cancelPendingDirectoryAccess(state, `operation:${queued.operationId}`)) {
894
+ await persist(state, pending.scopeKey, transitionOperation(queued, 'interrupted', { error: 'cancelled' }));
895
+ await state.journal.deleteDeferredDirectoryOperation(queued.operationId);
896
+ return persist(state, scopeKey, transitionOperation(transitionOperation(operation, 'accepted'), 'completed'));
897
+ }
898
+ }
899
+ }
900
+ if (cancelTarget !== undefined && expectedTurnId === undefined)
901
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'turn_changed' }));
863
902
  const result = await native(state).interrupt({ sessionId, threadId: binding.threadId,
864
903
  ...(typeof expectedTurnId === 'string' ? { expectedTurnId } : {}) });
865
904
  if (result.outcome === 'failed') {
@@ -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';
@@ -134,8 +134,10 @@ async function dispatchRuntimeRequest(state, request, response) {
134
134
  composerContextVersion: 1,
135
135
  scheduledTaskIdentityVersion: 1,
136
136
  scheduledTaskCancellationVersion: 1,
137
+ ...(state.native?.scheduledTaskProposalSource ? { scheduledTaskProposalVersion: 1 } : {}),
137
138
  parameterAgentVersion: 1,
138
139
  directoryConcurrencyVersion: 1,
140
+ skillExecutionPolicyVersion: 1,
139
141
  parameterInitializationVersion: 1,
140
142
  remixReferencesVersion: 1,
141
143
  batchSourceFreezeRevision: 2,
@@ -706,6 +708,20 @@ async function dispatchRuntimeRequest(state, request, response) {
706
708
  }
707
709
  return;
708
710
  }
711
+ if (request.method === 'GET' && url.pathname === '/runtime/skills/snapshot') {
712
+ if (writeDestroyed(state, response))
713
+ return;
714
+ const workspaceId = url.searchParams.get('workspaceId') ?? undefined;
715
+ try {
716
+ writeJson(response, 200, await readWorkspaceSkillSnapshot(await workspaceRoot(state, workspaceId, directoryName)));
717
+ }
718
+ catch (error) {
719
+ if (error instanceof WorkspaceContextError)
720
+ throw error;
721
+ writeJson(response, 409, { error: 'context_skill_unavailable' });
722
+ }
723
+ return;
724
+ }
709
725
  if (request.method === 'GET' && url.pathname === '/runtime/skills') {
710
726
  if (writeDestroyed(state, response))
711
727
  return;
@@ -812,6 +828,16 @@ async function dispatchRuntimeRequest(state, request, response) {
812
828
  writeJson(response, 200, operation);
813
829
  return;
814
830
  }
831
+ if (request.method === 'POST' && url.pathname === '/runtime/scheduled-task-proposals/source') {
832
+ const body = await readJson(request);
833
+ if (!body || Object.keys(body).some(key => !['bridge', 'threadId', 'turnId', 'toolCallId'].includes(key))
834
+ || !['bridge', 'threadId', 'turnId', 'toolCallId'].every(key => typeof body[key] === 'string' && Boolean(body[key]))) {
835
+ writeJson(response, 400, { valid: false });
836
+ return;
837
+ }
838
+ writeJson(response, 200, { valid: state.native?.scheduledTaskProposalSource?.(body) === true });
839
+ return;
840
+ }
815
841
  if (request.method === 'POST' && url.pathname === '/runtime/operations') {
816
842
  // Track creation before accepting it, so a new unconnected terminal is known not to have spawned.
817
843
  terminalHost(state);
@@ -963,8 +989,15 @@ async function acceptOperation(state, request, response) {
963
989
  writeJson(response, 400, { error: 'invalid_workspace_directory' });
964
990
  return;
965
991
  }
966
- if (state.config.workspaceMode === 'multi' && parsed.data.kind === 'runtime.ensure' && parsed.data.workspaceId)
967
- await withDirectoryWrite(state, { workspaceId: parsed.data.workspaceId, directoryName: parsed.data.directoryName, mode: 'exclusive', wait: false }, () => restoreWorkspaceDirectory(state, parsed.data.workspaceId, parsed.data.directoryName));
992
+ if (state.config.workspaceMode === 'multi' && parsed.data.kind === 'runtime.ensure' && parsed.data.workspaceId && parsed.data.directoryName !== 'root') {
993
+ const workspaceId = parsed.data.workspaceId, directoryName = parsed.data.directoryName ?? 'basic';
994
+ const restoring = state.journal.getWorkspace(workspaceId)?.directories?.some(directory => directory.directoryName === directoryName && directory.status === 'reclaimed') ?? false;
995
+ // Opening a resident checkout only refreshes instructions and can share its background install.
996
+ // Rebuilding a reclaimed checkout must still exclude every existing writer.
997
+ await withDirectoryWrite(state, { workspaceId, directoryName, mode: restoring ? 'exclusive' : 'shared', wait: false }, async () => {
998
+ await restoreWorkspaceDirectory(state, workspaceId, directoryName, restoring);
999
+ });
1000
+ }
968
1001
  if (state.config.workspaceMode === 'multi')
969
1002
  await sessionWorkspaceRoot(state, parsed.data.workspaceId, parsed.data.directoryName);
970
1003
  // Shared classification: a new conversation writes no files, so a batch hold must not refuse it.
@@ -977,7 +1010,9 @@ async function acceptOperation(state, request, response) {
977
1010
  ? { snapshot: parsed.data.remixReferenceSnapshot }
978
1011
  : parsed.data.kind === 'asset.library.import'
979
1012
  ? { selection: parsed.data.payload, snapshot: parsed.data.libraryAssetImports }
980
- : parsed.data.payload;
1013
+ : parsed.data.kind === 'session.turn' && parsed.data.scheduledTaskProposalBridge
1014
+ ? { ...asRecord(parsed.data.payload), scheduledTaskProposalBridge: parsed.data.scheduledTaskProposalBridge }
1015
+ : parsed.data.payload;
981
1016
  const accept = async () => {
982
1017
  const result = await acceptOperationCore(state, parsed.data, {
983
1018
  leaseFence: incomingFence,
@@ -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,17 +4,21 @@ 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';
10
+ import { WORKSPACE_REMIX_REFERENCES_SKILL } from './remix-reference-skill.js';
9
11
  export const EMPTY_WORKSPACE_REPOSITORY_SKILLS = [
10
12
  'playcraft-skill-recommender',
11
13
  'playcraft-image-generation',
12
14
  'playcraft-audio-generation',
13
15
  'playcraft-image-processing',
14
16
  'playcraft-workspace-assets',
17
+ WORKSPACE_REMIX_REFERENCES_SKILL,
15
18
  'playcraft-build-optimizer',
16
19
  ];
17
- 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);
18
22
  export const EMPTY_WORKSPACE_DEFAULT_SKILLS = [...EMPTY_WORKSPACE_REPOSITORY_SKILLS, ...EMPTY_WORKSPACE_PLATFORM_SKILLS];
19
23
  /** Only called in a new, unpublished staging directory; existing workspaces are never scaffolded. */
20
24
  export async function scaffoldEmptyWorkspace(root, skillCache, access) {
@@ -84,8 +84,9 @@ async function request(state, path, init) {
84
84
  }
85
85
  /**
86
86
  * Saves the directory (commit + push), then exports that commit for the channels through the
87
- * platform, as the delivery panel's Export does. Each channel repeats its latest export of the
88
- * branch, or uses the defaults without one. A standalone workspace first becomes a project.
87
+ * platform, as the delivery panel's Export does. Each channel uses that panel's fixed HTML or
88
+ * ZIP choice. Other options repeat the channel's latest export of the branch, or stay empty
89
+ * without one. A standalone workspace first becomes a project.
89
90
  */
90
91
  export async function startPlatformExport(state, input) {
91
92
  await promoteStandalone(state, input);
@@ -121,7 +122,6 @@ export async function startPlatformExport(state, input) {
121
122
  branchName: status.branch,
122
123
  commitHash: commit,
123
124
  platforms: input.channels,
124
- ...(input.format ? { format: input.format } : {}),
125
125
  clientRequestId: input.clientRequestId,
126
126
  },
127
127
  });
@@ -0,0 +1,129 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { lstat, readdir, rename, rm } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { ensureSharedDirectory } from '../shared-write.js';
5
+ import { createHostReviewFiles } from './review-page.js';
6
+ import { REVIEW_DIRECTORY, REVIEW_PAGE, reviewProfileHash, validateRenderReceipt, } from './review-contracts.js';
7
+ import { checkedFile, copySnapshot, dependencyHash, digest, relativeFile, snapshotFiles, verifyFiles, } from './files.js';
8
+ export async function freezeCandidate(root, jobRoot, input, lockedHelpers) {
9
+ if ((!input.review && (!input.pagePath || !input.files.includes(input.pagePath))) ||
10
+ !input.sourcePaths.includes('src/createObjectModel.ts') ||
11
+ input.files.includes('object-sculpt-spec.json') ||
12
+ input.sourcePaths.some((path) => !input.files.includes(path)))
13
+ throw new Error('procedural_skill_missing_candidate_inputs');
14
+ const files = await snapshotFiles(join(jobRoot, 'workspace'), input.files);
15
+ if (input.review) {
16
+ if (!lockedHelpers ||
17
+ input.pagePath ||
18
+ !input.files.includes(input.review.factoryPath) ||
19
+ input.files.some((path) => path.toLowerCase().startsWith(`${REVIEW_DIRECTORY}/`)))
20
+ throw new Error('procedural_skill_invalid_review_input');
21
+ for (const file of createHostReviewFiles(input.review, lockedHelpers))
22
+ files.push({ ...file, hash: digest(file.bytes) });
23
+ files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
24
+ if (files.length > 256 ||
25
+ files.reduce((size, file) => size + file.bytes.length, 0) > 64 * 1024 * 1024)
26
+ throw new Error('procedural_skill_size_limit');
27
+ }
28
+ const id = randomUUID();
29
+ await copySnapshot(root, join(jobRoot, 'candidates', id, 'source'), files);
30
+ const spec = await checkedFile(join(jobRoot, 'workspace'), 'object-sculpt-spec.json');
31
+ await copySnapshot(root, join(jobRoot, 'candidates', id), [{ path: 'spec.json', bytes: spec }]);
32
+ const hashes = files.map(({ path, hash }) => ({ path, hash }));
33
+ return {
34
+ id,
35
+ files: hashes,
36
+ sourcePaths: [...new Set(input.sourcePaths)].sort(),
37
+ sourceHash: digest(JSON.stringify(hashes)),
38
+ dependencyHash: await dependencyHash(root),
39
+ pagePath: relativeFile(input.review ? REVIEW_PAGE : input.pagePath),
40
+ views: input.views,
41
+ specHash: digest(spec),
42
+ expectedSpecHash: digest(spec),
43
+ passId: input.passId,
44
+ ...(input.review
45
+ ? {
46
+ hostReview: {
47
+ profile: input.review.profile,
48
+ profileHash: reviewProfileHash(input.review.profile),
49
+ helperHash: digest(lockedHelpers.scene),
50
+ profileHelperHash: digest(lockedHelpers.profile),
51
+ },
52
+ }
53
+ : {}),
54
+ };
55
+ }
56
+ export const candidateWorkspaceFiles = (candidate) => candidate.hostReview
57
+ ? candidate.files.filter((file) => !file.path.startsWith(`${REVIEW_DIRECTORY}/`))
58
+ : candidate.files;
59
+ export async function verifyCandidate(root, jobRoot, candidate) {
60
+ if ((await dependencyHash(root)) !== candidate.dependencyHash)
61
+ throw new Error('procedural_skill_dependencies_changed');
62
+ await verifyFiles(join(jobRoot, 'workspace'), candidateWorkspaceFiles(candidate));
63
+ await verifyFiles(join(jobRoot, 'candidates', candidate.id, 'source'), candidate.files);
64
+ if (digest(await checkedFile(join(jobRoot, 'workspace'), 'object-sculpt-spec.json')) !==
65
+ candidate.expectedSpecHash ||
66
+ digest(await checkedFile(join(jobRoot, 'candidates', candidate.id), 'spec.json')) !==
67
+ candidate.specHash)
68
+ throw new Error('procedural_skill_spec_changed');
69
+ if (!candidate.captures?.length)
70
+ throw new Error('procedural_skill_captures_missing');
71
+ await verifyFiles(jobRoot, candidate.captures);
72
+ if (candidate.hostReview) {
73
+ if (!candidate.renderReceipt)
74
+ throw new Error('procedural_skill_review_receipt_missing');
75
+ const bytes = await checkedFile(jobRoot, candidate.renderReceipt.path);
76
+ if (digest(bytes) !== candidate.renderReceipt.hash)
77
+ throw new Error('procedural_skill_review_receipt_changed');
78
+ validateRenderReceipt(JSON.parse(bytes.toString('utf8')), candidate.hostReview.profile, candidate.views);
79
+ }
80
+ }
81
+ /** Publish a new immutable source directory; never overwrite the agent's current factory. */
82
+ export async function adoptCandidate(root, jobRoot, candidate, assetId) {
83
+ await verifyCandidate(root, jobRoot, candidate);
84
+ if (candidate.review?.decision !== 'accept' ||
85
+ candidate.review.sourceHash !== candidate.sourceHash)
86
+ throw new Error('procedural_skill_review_required');
87
+ if (!/^[a-z][a-z0-9-]{0,63}$/.test(assetId))
88
+ throw new Error('procedural_skill_invalid_asset_id');
89
+ const relative = `src/generated/${assetId}/${candidate.id}`;
90
+ const target = join(root, relative);
91
+ const expected = candidate.files.filter((file) => candidate.sourcePaths.includes(file.path));
92
+ await ensureSharedDirectory(root, dirname(target));
93
+ const existing = await lstat(target).catch((error) => {
94
+ if (error.code === 'ENOENT')
95
+ return undefined;
96
+ throw error;
97
+ });
98
+ if (existing) {
99
+ if (!existing.isDirectory() || existing.isSymbolicLink())
100
+ throw new Error('procedural_skill_adoption_conflict');
101
+ const expectedPaths = new Set(expected.map((file) => file.path));
102
+ const checkTree = async (directory = '') => {
103
+ for (const item of await readdir(join(target, directory), { withFileTypes: true })) {
104
+ const path = directory ? `${directory}/${item.name}` : item.name;
105
+ if (item.isDirectory() && expected.some((file) => file.path.startsWith(`${path}/`)))
106
+ await checkTree(path);
107
+ else if (!item.isFile() || !expectedPaths.has(path))
108
+ throw new Error('procedural_skill_adoption_conflict');
109
+ }
110
+ };
111
+ await checkTree();
112
+ await verifyFiles(target, expected);
113
+ return { path: relative, sourceHash: candidate.sourceHash };
114
+ }
115
+ const staging = `${target}.${randomUUID()}.pending`;
116
+ try {
117
+ const files = await Promise.all(expected.map(async (file) => ({
118
+ path: file.path,
119
+ bytes: await checkedFile(join(jobRoot, 'candidates', candidate.id, 'source'), file.path),
120
+ })));
121
+ await copySnapshot(root, staging, files);
122
+ await verifyFiles(staging, expected);
123
+ await rename(staging, target);
124
+ return { path: relative, sourceHash: candidate.sourceHash };
125
+ }
126
+ finally {
127
+ await rm(staging, { recursive: true, force: true });
128
+ }
129
+ }