@playcraft/cli 0.1.69 → 0.1.71

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 (34) hide show
  1. package/README.md +20 -1
  2. package/dist/commands/skills.js +68 -4
  3. package/dist/project-skills/discovery.js +248 -0
  4. package/dist/project-skills/messages.js +80 -0
  5. package/dist/project-skills/remote-activation.js +500 -0
  6. package/dist/project-skills/remote-client.js +117 -0
  7. package/dist/project-skills/remote-commands.js +87 -0
  8. package/dist/project-skills/remote-prepare.js +223 -0
  9. package/dist/project-skills/search-command.js +47 -0
  10. package/dist/project-skills/search.js +96 -0
  11. package/dist/recording/project-digest.js +1 -1
  12. package/dist/workspace-runtime/batch/content-changes.js +3 -0
  13. package/dist/workspace-runtime/batch/generate.js +4 -1
  14. package/dist/workspace-runtime/batch/prepare.js +5 -1
  15. package/dist/workspace-runtime/batch/source-capture.js +43 -4
  16. package/dist/workspace-runtime/codex/app-server.js +25 -1
  17. package/dist/workspace-runtime/codex/model-broker.js +93 -6
  18. package/dist/workspace-runtime/codex/skill-discovery-broker.js +174 -0
  19. package/dist/workspace-runtime/serve.js +5 -0
  20. package/dist/workspace-runtime/server/dispatch.js +121 -28
  21. package/dist/workspace-runtime/server/http.js +15 -0
  22. package/dist/workspace-runtime/server/operation-record.js +3 -1
  23. package/dist/workspace-runtime/verify-artifact.js +2 -1
  24. package/dist/workspace-runtime/workspaces/files.js +20 -14
  25. package/dist/workspace-runtime/workspaces/remix-references.js +119 -2
  26. package/dist/workspace-runtime/workspaces/skill-activation-recovery.js +54 -0
  27. package/dist/workspace-runtime/workspaces/skill-activation-requests.js +512 -0
  28. package/dist/workspace-runtime/workspaces/skills.js +13 -176
  29. package/package.json +3 -3
  30. package/project-template/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
  31. package/project-template-v2/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
  32. package/project-templates/empty/gitignore +1 -0
  33. package/project-templates/phaser/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
  34. package/project-templates/threejs/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
@@ -9,7 +9,7 @@ import { remoteAttachPending } from '../workspaces/remote-attach-marker.js';
9
9
  import { cancelPendingDirectoryAccess, holdDirectoryAccess, withOperationDirectoryAccess, } from './directory-access.js';
10
10
  import { join } from 'node:path';
11
11
  import { z } from 'zod';
12
- import { fileWritePayloadSchema, fileBinaryWritePayloadSchema, directoryCreatePayloadSchema, directoryRenamePayloadSchema, directoryDeletePayloadSchema, directoryMutationReceiptSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, gitPullPayloadSchema, gitMergeAbortPayloadSchema, hashOperationPayload, parameterPatchPayloadSchema, parameterAgentTaskRequestSchema, parameterAgentVerifyPayloadSchema, levelEditorTaskRequestSchema, levelEditorVerifyPayloadSchema, levelEditorTransactionRequestSchema, productionSessionAttemptId, reasoningEffortSchema, workspacePermissionModeSchema, workspaceComposerSelectionSchema, workspaceReviewPayloadSchema, workspaceCompactPayloadSchema, workspaceSteerPayloadSchema, workspaceApprovalResponseSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, recoverUncertainOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, workspaceRemixReferenceImportPayloadSchema, workspaceModelPreviewBuildSchema, workspaceModelPreviewCancelSchema, isSoundDocumentPath, } from '@playcraft/common/cloud-workspace/runtime';
12
+ import { fileWritePayloadSchema, fileBinaryWritePayloadSchema, directoryCreatePayloadSchema, directoryRenamePayloadSchema, directoryDeletePayloadSchema, directoryMutationReceiptSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, gitPullPayloadSchema, gitMergeAbortPayloadSchema, hashOperationPayload, parameterPatchPayloadSchema, parameterAgentTaskRequestSchema, parameterAgentVerifyPayloadSchema, levelEditorTaskRequestSchema, skillActivationPayloadSchema, levelEditorVerifyPayloadSchema, levelEditorTransactionRequestSchema, productionSessionAttemptId, reasoningEffortSchema, workspacePermissionModeSchema, workspaceComposerSelectionSchema, workspaceReviewPayloadSchema, workspaceCompactPayloadSchema, workspaceSteerPayloadSchema, workspaceApprovalResponseSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, recoverUncertainOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, workspaceRemixReferenceImportPayloadSchema, workspaceModelPreviewBuildSchema, workspaceModelPreviewCancelSchema, isSoundDocumentPath, } from '@playcraft/common/cloud-workspace/runtime';
13
13
  import { closeCliCredentials, ensureCliCredentials } from '../workspaces/cli-credentials.js';
14
14
  import { artifactFormat } from '../workspaces/artifact-files.js';
15
15
  import { refreshWorkspaceArtifacts } from '../workspaces/artifact-events.js';
@@ -52,6 +52,7 @@ import { prepareLevelEditorTask, finishLevelEditorTask, verifyLevelEditorTask, }
52
52
  import { readWorkspaceLevelEditor, transactWorkspaceLevelEditor, reconcileWorkspaceLevelEditorTransaction, } from '../workspaces/level-editor.js';
53
53
  import { readLevelEditorHistory } from '../workspaces/level-editor-history-store.js';
54
54
  import { wakeLevelEditorRequests, ensureLevelEditorTaskPipeline, retryLevelEditorRequest, assertLevelEditorRetryInput, } from '../workspaces/level-editor-requests.js';
55
+ import { activateSkillSelectionForTurn, cancelSkillActivationPreparation, skillActivationRuntimeClosing, waitForSkillActivationIdle } from '../workspaces/skill-activation-requests.js';
55
56
  const CONVERSATION_KINDS = new Set([
56
57
  'session.create',
57
58
  'session.turn',
@@ -94,6 +95,7 @@ const LOCAL_KINDS = new Set([
94
95
  ]);
95
96
  const parameterVerifications = new WeakMap();
96
97
  const parameterVerificationClosing = new WeakSet();
98
+ const nativePreparations = new WeakMap();
97
99
  export async function stopParameterVerifications(state) {
98
100
  parameterVerificationClosing.add(state);
99
101
  const tasks = parameterVerifications.get(state);
@@ -146,9 +148,30 @@ export async function executeOperation(state, scopeKey, operation, payload, reco
146
148
  if (['runtime.destroy', 'session.interrupt', 'session.approval.respond', 'session.steer', 'preview.publish', 'model.preview', 'model.preview.cancel', 'model.prepare'].includes(operation.kind)) {
147
149
  return run();
148
150
  }
149
- return state.journal.exclusive(operation.kind.startsWith('session.') && operation.sessionId
151
+ const execute = () => state.journal.exclusive(operation.kind.startsWith('session.') && operation.sessionId
150
152
  ? `session-execution:${operation.sessionId}`
151
153
  : `workspace:${revisionScope(state, operation.workspaceId, operation.directoryName) ?? 'single'}`, run);
154
+ if (!['session.turn', 'session.review', 'session.compact'].includes(operation.kind))
155
+ return execute();
156
+ const pending = nativePreparations.get(state) ?? new Map();
157
+ nativePreparations.set(state, pending);
158
+ const entry = { scopeKey, operation, held: false, activation: operation.kind === 'session.turn'
159
+ && skillActivationPayloadSchema.safeParse(asRecord(payload).skillActivation).success };
160
+ pending.set(operation.operationId, entry);
161
+ try {
162
+ return await state.journal.exclusive('native-preparation', async () => {
163
+ entry.held = true;
164
+ const live = state.journal.getOperation(scopeKey) ?? operation;
165
+ if (['completed', 'failed', 'interrupted'].includes(live.status))
166
+ return live;
167
+ if (skillActivationRuntimeClosing(state))
168
+ return persist(state, scopeKey, transitionOperation(live, 'interrupted', { error: 'runtime_stopping' }));
169
+ return execute();
170
+ });
171
+ }
172
+ finally {
173
+ pending.delete(operation.operationId);
174
+ }
152
175
  }
153
176
  async function executeScopedOperation(state, scopeKey, operation, payload, recovery) {
154
177
  let current = await persist(state, scopeKey, transitionOperation(operation, 'dispatched'));
@@ -440,16 +463,39 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
440
463
  const levelEditorTask = levelEditorTaskRequestSchema.optional().safeParse(body.levelEditorTask);
441
464
  if (!levelEditorTask.success || levelEditorTask.data && (parameterTask.data || levelEditorTask.data.taskId !== operation.clientRequestId))
442
465
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_level_editor_task' }));
443
- if (parameterTask.data || levelEditorTask.data) {
466
+ const skillActivation = skillActivationPayloadSchema.optional().safeParse(body.skillActivation);
467
+ if (!skillActivation.success || skillActivation.data && (parameterTask.data || levelEditorTask.data
468
+ || skillActivation.data.requestId !== operation.clientRequestId || skillActivation.data.sourceSessionId !== sessionId))
469
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'skill_activation_request_invalid' }));
470
+ if (parameterTask.data || levelEditorTask.data || skillActivation.data) {
444
471
  if (operation.directoryName === 'root')
445
472
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_workspace_directory' }));
446
473
  if ((selection.data.workMode ?? 'execute') !== 'execute' || requestedMode === 'read-only')
447
- return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: levelEditorTask.data ? 'level_editor_task_requires_execute' : 'parameter_task_readonly' }));
474
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: skillActivation.data ? 'skill_activation_requires_execute' : levelEditorTask.data ? 'level_editor_task_requires_execute' : 'parameter_task_readonly' }));
448
475
  if (workspaceSessions(state, operation.workspaceId, operation.directoryName).some(sessionTurnBusy))
449
476
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'turn_already_active' }));
450
477
  if (await batchDirectoryHeld(state, operation.workspaceId, operation.directoryName))
451
478
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'batch_workspace_readonly' }));
452
479
  }
480
+ let activatedSkillSelection;
481
+ if (skillActivation.data) {
482
+ try {
483
+ if (!found || operation.directoryAccessMode !== 'exclusive' || selection.data.context?.skills.length)
484
+ throw new Error('skill_activation_request_invalid');
485
+ // Repair an interrupted activation pair before session recovery can read or freeze that pair.
486
+ await waitForSkillActivationIdle(state, operation, skillActivation.data);
487
+ activatedSkillSelection = await activateSkillSelectionForTurn(state, operation, skillActivation.data, await sessionWorkspaceRoot(state, operation.workspaceId, operation.directoryName));
488
+ }
489
+ catch (error) {
490
+ const live = state.journal.getOperation(scopeKey);
491
+ if (live && ['failed', 'interrupted'].includes(live.status))
492
+ return live;
493
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', {
494
+ error: error instanceof Error && /^(?:skill_activation_|skills_revision_conflict|workspace_skills_)/.test(error.message)
495
+ ? error.message : 'skill_activation_failed',
496
+ }));
497
+ }
498
+ }
453
499
  const modelChanged = selected.data !== undefined && selected.data !== (found?.modelConfigId ?? recovery?.modelConfigId);
454
500
  const requestedEffort = effort.data ?? (modelChanged ? undefined : recovery?.reasoningEffort ?? found?.reasoningEffort);
455
501
  if (recovery && found?.recoverability !== 'native') {
@@ -476,11 +522,11 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
476
522
  }
477
523
  const root = await sessionWorkspaceRoot(state, operation.workspaceId, operation.directoryName);
478
524
  try {
479
- const policy = await preflightSkillExecution(state, { ...operation, payload });
525
+ const policy = skillActivation.data ? undefined : await preflightSkillExecution(state, { ...operation, payload });
480
526
  if (policy?.directoryAccessMode === 'exclusive' && operation.directoryAccessMode !== 'exclusive')
481
527
  throw new ComposerContextError('requires-exclusive-turn');
482
- if (operation.skillExecutionPolicyVersion === 1 && (!policy ||
483
- (parameterTask.data || levelEditorTask.data ? 'exclusive' : policy.directoryAccessMode) !== operation.directoryAccessMode))
528
+ if (!skillActivation.data && operation.skillExecutionPolicyVersion === 1 && (!policy ||
529
+ (parameterTask.data || levelEditorTask.data || skillActivation.data ? 'exclusive' : policy.directoryAccessMode) !== operation.directoryAccessMode))
484
530
  throw new ComposerContextError('context_skills_changed');
485
531
  }
486
532
  catch (error) {
@@ -490,9 +536,14 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
490
536
  }));
491
537
  }
492
538
  let composer;
539
+ let skillActivationInstructions = '';
493
540
  let initializationSkill;
494
541
  try {
495
542
  let turnContext = selection.data.context;
543
+ if (activatedSkillSelection) {
544
+ turnContext = { files: turnContext?.files ?? [], skills: activatedSkillSelection.entries };
545
+ skillActivationInstructions = activatedSkillSelection.instructions;
546
+ }
496
547
  if (parameterTask.data?.action === 'initialize' || levelEditorTask.data) {
497
548
  const selectedSkill = await ensureParameterInitializationSkill(root, levelEditorTask.data ? 'playcraft-level-editor' : 'playcraft-runtime-parameters', levelEditorTask.data ? { preserveExisting: true } : {});
498
549
  initializationSkill = { ...selectedSkill,
@@ -512,11 +563,16 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
512
563
  });
513
564
  }
514
565
  catch (error) {
566
+ const live = state.journal.getOperation(scopeKey);
567
+ if (skillActivation.data && live && ['failed', 'interrupted'].includes(live.status))
568
+ return live;
515
569
  return persist(state, scopeKey, transitionOperation(operation, 'failed', {
516
- error: error instanceof ComposerContextError ? error.message : parameterTask.data?.action === 'initialize' || levelEditorTask.data
517
- ? error instanceof Error && /^(?:workspace_skills_|skills_revision_conflict$)/.test(error.message)
518
- ? error.message : levelEditorTask.data ? 'level_editor_skill_unavailable' : parameterAgentError(error)
519
- : 'invalid_composer_context',
570
+ error: skillActivation.data ? error instanceof Error && /^(?:skill_activation_|skills_revision_conflict|workspace_skills_)/.test(error.message)
571
+ ? error.message : 'skill_activation_failed'
572
+ : error instanceof ComposerContextError ? error.message : parameterTask.data?.action === 'initialize' || levelEditorTask.data
573
+ ? error instanceof Error && /^(?:workspace_skills_|skills_revision_conflict$)/.test(error.message)
574
+ ? error.message : levelEditorTask.data ? 'level_editor_skill_unavailable' : parameterAgentError(error)
575
+ : 'invalid_composer_context',
520
576
  }));
521
577
  }
522
578
  let attachments;
@@ -567,6 +623,9 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
567
623
  }
568
624
  }
569
625
  // Taken before the native turn exists, so nothing the agent writes can precede it.
626
+ const beforeStart = state.journal.getOperation(scopeKey);
627
+ if (skillActivation.data && beforeStart && ['failed', 'interrupted'].includes(beforeStart.status))
628
+ return beforeStart;
570
629
  await beginTurnChanges(state, sessionId, {
571
630
  root, workspaceId: operation.workspaceId, directoryName: binding.directoryName ?? 'basic',
572
631
  });
@@ -575,7 +634,7 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
575
634
  const startTurn = () => native(state).startTurn({
576
635
  sessionId,
577
636
  threadId: binding.threadId,
578
- text: composer.append(batchContext + context + String(body.text ?? '') + attachmentText, [engineeringBatchContext, parameterInstructions].filter(Boolean).join('\n\n')),
637
+ text: composer.append(batchContext + context + String(body.text ?? '') + attachmentText, [engineeringBatchContext, parameterInstructions, skillActivationInstructions].filter(Boolean).join('\n\n')),
579
638
  images: attachments.filter((file) => ['image/png', 'image/jpeg', 'image/gif', 'image/webp'].includes(file.mimeType)).map((file) => file.path),
580
639
  clientRequestId: operation.clientRequestId,
581
640
  modelConfigId: selected.data ?? binding.modelConfigId,
@@ -587,22 +646,30 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
587
646
  scheduledTaskProposalBridge: typeof body.scheduledTaskProposalBridge === 'string' ? body.scheduledTaskProposalBridge : undefined,
588
647
  simulateLostReceipt: body.simulateLostReceipt === true,
589
648
  persistReservation: async ({ nativeRequestId, modelConfigId, reasoningEffort, permissionMode }) => {
590
- const selectedEffort = reasoningEffort ?? requestedEffort;
591
- const selectedMode = permissionMode ?? requestedMode;
592
- await persist(state, scopeKey, {
593
- ...operation,
594
- nativeRequestId,
595
- nativeThreadId: binding.threadId,
596
- modelConfigId,
597
- reasoningEffort: selectedEffort,
598
- permissionMode: selectedMode,
599
- workMode: selection.data.workMode ?? 'execute',
600
- context: composer.context,
601
- userText: selection.data.workMode !== undefined || composer.context !== undefined || parameterTask.data || levelEditorTask.data
602
- ? String(body.userText ?? body.text ?? '') : undefined,
603
- updatedAt: new Date().toISOString(),
649
+ await state.journal.exclusive(`native-reservation:${operation.operationId}`, async () => {
650
+ const live = state.journal.getOperation(scopeKey) ?? operation;
651
+ if (live.status !== 'dispatched' || skillActivationRuntimeClosing(state) || state.journal.destroyed())
652
+ throw new NativePreparationError(skillActivation.data ? 'skill_activation_cancelled' : 'native_preparation_cancelled');
653
+ if ((live.leaseFence ?? 0) < state.acceptedLeaseFence || live.generation !== state.config.generation || live.runtimeEpoch !== state.runtimeEpoch)
654
+ throw new NativePreparationError('stale_lease');
655
+ const selectedEffort = reasoningEffort ?? requestedEffort;
656
+ const selectedMode = permissionMode ?? requestedMode;
657
+ await persist(state, scopeKey, {
658
+ ...live,
659
+ nativeRequestId,
660
+ nativeThreadId: binding.threadId,
661
+ modelConfigId,
662
+ reasoningEffort: selectedEffort,
663
+ permissionMode: selectedMode,
664
+ workMode: selection.data.workMode ?? 'execute',
665
+ context: composer.context,
666
+ ...(activatedSkillSelection ? { skillExecutionPolicyVersion: 1 } : {}),
667
+ userText: selection.data.workMode !== undefined || composer.context !== undefined || parameterTask.data || levelEditorTask.data
668
+ ? String(body.userText ?? body.text ?? '') : undefined,
669
+ updatedAt: new Date().toISOString(),
670
+ });
671
+ await state.journal.putSession({ ...binding, modelConfigId, reasoningEffort: selectedEffort, permissionMode: selectedMode, turnStatus: 'starting' });
604
672
  });
605
- await state.journal.putSession({ ...binding, modelConfigId, reasoningEffort: selectedEffort, permissionMode: selectedMode, turnStatus: 'starting' });
606
673
  },
607
674
  });
608
675
  result = await startTurn();
@@ -619,9 +686,12 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
619
686
  }
620
687
  }
621
688
  catch (error) {
622
- if (!parameterTask.data && !levelEditorTask.data)
689
+ if (!parameterTask.data && !levelEditorTask.data && !skillActivation.data)
623
690
  throw error;
624
691
  abandonTurnChanges(state, sessionId);
692
+ const live = state.journal.getOperation(scopeKey);
693
+ if (live && ['completed', 'failed', 'interrupted'].includes(live.status))
694
+ return live;
625
695
  if (error instanceof NativePreparationError) {
626
696
  await state.journal.putSession({ ...(state.journal.getSession(sessionId) ?? binding), turnStatus: undefined, turnBlock: undefined });
627
697
  return persist(state, scopeKey, transitionOperation(state.journal.getOperation(scopeKey) ?? operation, 'failed', { error: error.message }));
@@ -630,6 +700,10 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
630
700
  return persist(state, scopeKey, transitionOperation(state.journal.getOperation(scopeKey) ?? operation, 'uncertain', { error: 'native_receipt_lost' }));
631
701
  }
632
702
  const reserved = state.journal.getOperation(scopeKey) ?? operation;
703
+ if (['completed', 'failed', 'interrupted'].includes(reserved.status)) {
704
+ abandonTurnChanges(state, sessionId);
705
+ return reserved;
706
+ }
633
707
  if (result.outcome === 'uncertain' || result.outcome === 'failed' || !result.turnId) {
634
708
  abandonTurnChanges(state, sessionId);
635
709
  const failed = result.outcome === 'failed';
@@ -952,6 +1026,25 @@ async function executeInterrupt(state, scopeKey, operation, payload) {
952
1026
  // A directory-gated turn has no native turn to interrupt. Cancel only a proven pending
953
1027
  // claim; an admitted/racing claim still goes through the native interruption contract.
954
1028
  if (expectedTurnId === undefined && !binding.activeTurnId) {
1029
+ const preparations = [...(nativePreparations.get(state)?.values() ?? [])].filter(item => item.operation.sessionId === sessionId && item.operation.workspaceId === operation.workspaceId
1030
+ && (item.operation.directoryName ?? 'basic') === (operation.directoryName ?? 'basic')
1031
+ && (!item.held || item.activation)
1032
+ && (cancelTarget === undefined || cancelTarget === item.operation.operationId || cancelTarget === item.operation.gatewayOperationId)
1033
+ && !state.journal.getOperationById(item.operation.operationId)?.nativeRequestId);
1034
+ if (preparations.length === 1) {
1035
+ const preparation = preparations[0];
1036
+ const cancelled = await state.journal.exclusive(`native-reservation:${preparation.operation.operationId}`, async () => {
1037
+ const live = state.journal.getOperationById(preparation.operation.operationId) ?? preparation.operation;
1038
+ if (!['queued', 'dispatched'].includes(live.status) || live.nativeTurnId || live.nativeRequestId)
1039
+ return false;
1040
+ await persist(state, preparation.scopeKey, transitionOperation(live, 'interrupted', { error: 'cancelled' }));
1041
+ cancelSkillActivationPreparation(state, live.operationId);
1042
+ return true;
1043
+ });
1044
+ if (cancelled) {
1045
+ return persist(state, scopeKey, transitionOperation(transitionOperation(operation, 'accepted'), 'completed'));
1046
+ }
1047
+ }
955
1048
  const candidates = state.journal.deferredDirectoryOperations().flatMap(pending => {
956
1049
  const queued = state.journal.getOperationById(pending.operationId);
957
1050
  return queued?.status === 'queued' && ['session.turn', 'session.review', 'session.compact'].includes(queued.kind)
@@ -15,6 +15,7 @@ import { listParameterAgentTasks } from '../workspaces/parameter-agent.js';
15
15
  import { readWorkspaceLevelEditor } from '../workspaces/level-editor.js';
16
16
  import { listLevelEditorTasks } from '../workspaces/level-editor-agent.js';
17
17
  import { listLevelEditorRequests } from '../workspaces/level-editor-requests.js';
18
+ import { listSkillActivationRequests } from '../workspaces/skill-activation-requests.js';
18
19
  import { readDependencies } from '../workspaces/dependencies.js';
19
20
  import { permissionDriftFromError } from '../workspaces/workspace-shares.js';
20
21
  import { desktopHost, desktopRoute } from './desktop-host.js';
@@ -191,6 +192,7 @@ async function dispatchRuntimeRequest(state, request, response) {
191
192
  parameterAgentVersion: 1,
192
193
  directoryConcurrencyVersion: 1,
193
194
  skillExecutionPolicyVersion: 1,
195
+ skillActivationVersion: 1,
194
196
  parameterInitializationVersion: 1,
195
197
  levelEditorVersion: 1,
196
198
  levelEditorTaskVersion: 1,
@@ -201,6 +203,7 @@ async function dispatchRuntimeRequest(state, request, response) {
201
203
  remixReferencesVersion: 1,
202
204
  batchSourceFreezeRevision: 2,
203
205
  batchSourcePublicationVersion: 1,
206
+ batchReferenceContextVersion: 1,
204
207
  ...(state.native?.workspaceUi ? { workspaceUiVersion: 1 } : {}),
205
208
  ...(state.native?.startReview && state.native?.steer ? { turnControlVersion: 1 } : {}),
206
209
  ...(state.native?.startCompact ? { compactionVersion: 1 } : {}),
@@ -880,6 +883,18 @@ async function dispatchRuntimeRequest(state, request, response) {
880
883
  writeJson(response, 200, { tasks: await listLevelEditorTasks(state, workspaceId, directoryName, await workspaceRoot(state, workspaceId, directoryName)) });
881
884
  return;
882
885
  }
886
+ if (request.method === 'GET' && url.pathname === '/runtime/skill-activation-requests') {
887
+ if (writeDestroyed(state, response))
888
+ return;
889
+ if (directoryName === 'root') {
890
+ writeJson(response, 400, { error: 'invalid_workspace_directory' });
891
+ return;
892
+ }
893
+ const workspaceId = url.searchParams.get('workspaceId') ?? undefined;
894
+ await workspaceRoot(state, workspaceId, directoryName);
895
+ writeJson(response, 200, { requests: await listSkillActivationRequests(state, workspaceId, directoryName) });
896
+ return;
897
+ }
883
898
  if (request.method === 'GET' && url.pathname === '/runtime/level-editor/requests') {
884
899
  if (writeDestroyed(state, response))
885
900
  return;
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { workspaceComposerSelectionSchema } from '@playcraft/common/cloud-workspace/runtime';
2
+ import { skillActivationPayloadSchema, workspaceComposerSelectionSchema } from '@playcraft/common/cloud-workspace/runtime';
3
3
  /** One construction point for operation records so HTTP and in-sandbox callers cannot drift. */
4
4
  export function buildOperationRecord(state, parsed, payloadHash, leaseFence, executable = true, policy) {
5
5
  const now = new Date().toISOString();
@@ -12,6 +12,8 @@ export function buildOperationRecord(state, parsed, payloadHash, leaseFence, exe
12
12
  ...(policy ? { skillExecutionPolicyVersion: policy.skillExecutionPolicyVersion, directoryAccessMode: policy.directoryAccessMode,
13
13
  ...workspaceComposerSelectionSchema.parse({ context: payload.context, workMode: payload.workMode }) } : {}),
14
14
  ...(parsed.kind === 'session.turn' && (payload.parameterTask !== undefined || payload.levelEditorTask !== undefined) ? { directoryAccessMode: 'exclusive' } : {}),
15
+ ...(parsed.kind === 'session.turn' && skillActivationPayloadSchema.safeParse(payload.skillActivation).success
16
+ ? { directoryAccessMode: 'exclusive', skillExecutionPolicyVersion: 1 } : {}),
15
17
  environment: state.config.environment,
16
18
  userId: state.config.userId,
17
19
  clientRequestId: parsed.clientRequestId,
@@ -48,6 +48,7 @@ export async function verifyRuntimeArtifact(expectedVersion) {
48
48
  assert.equal(identity.runtimeId, config.runtimeId);
49
49
  assert.equal(identity.batchSourceFreezeRevision, 2, 'built runtime lacks the required batch source freeze implementation');
50
50
  assert.equal(identity.batchSourcePublicationVersion, 1, 'built runtime lacks trusted batch source publication');
51
+ assert.equal(identity.batchReferenceContextVersion, 1, 'built runtime lacks batch reference context support');
51
52
  }
52
53
  finally {
53
54
  await runtime?.close();
@@ -56,5 +57,5 @@ export async function verifyRuntimeArtifact(expectedVersion) {
56
57
  }
57
58
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
58
59
  await verifyRuntimeArtifact(process.argv[2] ?? '');
59
- console.log('Runtime artifact verified: batchSourceFreezeRevision=2, batchSourcePublicationVersion=1');
60
+ console.log('Runtime artifact verified: batchSourceFreezeRevision=2, batchSourcePublicationVersion=1, batchReferenceContextVersion=1');
60
61
  }
@@ -22,7 +22,7 @@ export async function resolveWorkspaceFile(root, relativePath) {
22
22
  const parsed = workspaceRelativePathSchema.safeParse(relativePath);
23
23
  if (!parsed.success)
24
24
  throw new InvalidWorkspacePathError(relativePath);
25
- const rootReal = await ensureWorkspaceRoot(root);
25
+ const rootReal = await realpath(root);
26
26
  const target = resolve(rootReal, ...parsed.data.split('/'));
27
27
  assertInsideRoot(rootReal, target);
28
28
  try {
@@ -126,19 +126,25 @@ function isPermissionDenied(error) {
126
126
  return ['EACCES', 'EPERM'].includes(String(error?.code));
127
127
  }
128
128
  async function assertParentInsideRoot(rootReal, parent) {
129
- if (parent === rootReal)
130
- return;
131
- try {
132
- const parentReal = await realpath(parent);
133
- assertInsideRoot(rootReal, parentReal);
134
- }
135
- catch (error) {
136
- if (error.code !== 'ENOENT')
137
- throw error;
138
- // private
139
- await mkdir(parent, { recursive: true, mode: 0o700 });
140
- const parentReal = await realpath(parent);
141
- assertInsideRoot(rootReal, parentReal);
129
+ while (parent !== rootReal) {
130
+ try {
131
+ const parentReal = await realpath(parent);
132
+ assertInsideRoot(rootReal, parentReal);
133
+ return;
134
+ }
135
+ catch (error) {
136
+ if (error.code !== 'ENOENT')
137
+ throw error;
138
+ const entry = await lstat(parent).catch((error) => {
139
+ if (error.code === 'ENOENT')
140
+ return undefined;
141
+ throw error;
142
+ });
143
+ if (entry?.isSymbolicLink())
144
+ throw new InvalidWorkspacePathError(parent);
145
+ // Resolve the nearest existing ancestor without creating anything during a read.
146
+ parent = dirname(parent);
147
+ }
142
148
  }
143
149
  }
144
150
  function assertInsideRoot(rootReal, target) {
@@ -115,12 +115,18 @@ function validateRecord(record, directoryName) {
115
115
  originalFiles.reduce((sum, file) => sum + file.size, 0) > WORKSPACE_REMIX_REFERENCE_MAX_BYTES)
116
116
  throw new RemixReferenceError('remix_reference_manifest_invalid');
117
117
  }
118
- async function verifyReference(root, record, directoryName) {
118
+ async function verifyReferenceMetadata(root, record, directoryName, alternateDirectory) {
119
119
  validateRecord(record, directoryName);
120
120
  const target = await safeDirectory(root, record.path);
121
121
  const receipt = JSON.parse((await readFileSafely(target, RECEIPT, MANIFEST_MAX_BYTES)).toString('utf8'));
122
- if (JSON.stringify(workspaceRemixReferenceSchema.parse(receipt)) !== JSON.stringify(record))
122
+ const parsedReceipt = workspaceRemixReferenceSchema.parse(receipt);
123
+ if (JSON.stringify(parsedReceipt) !== JSON.stringify(record)
124
+ && (!alternateDirectory || JSON.stringify(parsedReceipt) !== JSON.stringify({ ...record, directoryName: alternateDirectory })))
123
125
  throw new RemixReferenceError('remix_reference_changed');
126
+ return target;
127
+ }
128
+ async function verifyReference(root, record, directoryName, alternateDirectory) {
129
+ const target = await verifyReferenceMetadata(root, record, directoryName, alternateDirectory);
124
130
  if ((await readFileSafely(target, ATTRIBUTES, 256)).toString('utf8') !== SNAPSHOT_ATTRIBUTES)
125
131
  throw new RemixReferenceError('remix_reference_changed');
126
132
  const expected = new Set([RECEIPT, ATTRIBUTES, ...record.files.map(file => file.path)]);
@@ -144,6 +150,117 @@ async function verifyReference(root, record, directoryName) {
144
150
  throw new RemixReferenceError('remix_reference_changed');
145
151
  }
146
152
  }
153
+ /** Metadata-verified records identify files excluded by the batch source safety policy. */
154
+ export async function batchReferenceMetadata(root) {
155
+ let items;
156
+ try {
157
+ items = await readManifest(root);
158
+ }
159
+ catch {
160
+ return [];
161
+ }
162
+ const verified = [];
163
+ for (const record of items) {
164
+ try {
165
+ await verifyReferenceMetadata(root, record, record.directoryName);
166
+ verified.push(record);
167
+ }
168
+ catch { /* An unrelated invalid reference cannot block source capture. */ }
169
+ }
170
+ return verified;
171
+ }
172
+ /** Only verified imported snapshots can retain credential-free .env examples in a batch freeze. */
173
+ export async function batchReferenceEnvExamples(root) {
174
+ const examples = new Set();
175
+ let items;
176
+ try {
177
+ items = await readManifest(root);
178
+ }
179
+ catch {
180
+ return examples;
181
+ }
182
+ for (const record of items) {
183
+ const files = record.files.filter(file => /^\.env\.(?:example|sample|template)$/i.test(file.path.split('/').pop() ?? '')
184
+ && !isCredentialFile(file.sourcePath));
185
+ if (!files.length)
186
+ continue;
187
+ try {
188
+ await verifyReference(root, record, record.directoryName);
189
+ for (const file of files)
190
+ examples.add(`${record.path}/${file.path}`);
191
+ }
192
+ catch { /* An unselected damaged reference must not stop freezing the project. */ }
193
+ }
194
+ return examples;
195
+ }
196
+ /** Rebind frozen source references inside a member checkout; content and source identities remain immutable. */
197
+ export async function rebindBatchRemixReferences(root, sourceDirectory, memberDirectory, selected) {
198
+ workspaceDirectoryNameSchema.parse(sourceDirectory);
199
+ workspaceDirectoryNameSchema.parse(memberDirectory);
200
+ const rootReal = await realpath(root);
201
+ let items;
202
+ try {
203
+ items = await readManifest(rootReal);
204
+ }
205
+ catch (error) {
206
+ if (!selected.length)
207
+ return;
208
+ throw error;
209
+ }
210
+ for (const reference of selected) {
211
+ const record = items.find(item => item.referenceId === reference.referenceId);
212
+ if (reference.directoryName !== sourceDirectory || !record || record.contentHash !== reference.snapshotHash)
213
+ throw new RemixReferenceError('context_remix_reference_changed');
214
+ }
215
+ if (selected.length) {
216
+ const manifest = JSON.parse((await readFileSafely(rootReal, WORKSPACE_REMIX_REFERENCES_MANIFEST, MANIFEST_MAX_BYTES)).toString('utf8'));
217
+ if (Array.isArray(manifest.batchPolicyWithheldReferenceIds) && selected.some(reference => manifest.batchPolicyWithheldReferenceIds.includes(reference.referenceId)))
218
+ throw new RemixReferenceError('batch_reference_files_withheld');
219
+ }
220
+ // Selected references must be complete. Other records need only valid metadata to be rebound;
221
+ // their contents remain subject to the normal checks when the user later lists or selects them.
222
+ const rebound = [];
223
+ const writable = [];
224
+ for (const record of items) {
225
+ const isSelected = selected.some(reference => reference.referenceId === record.referenceId);
226
+ try {
227
+ if (record.directoryName !== sourceDirectory && record.directoryName !== memberDirectory)
228
+ throw new RemixReferenceError('remix_reference_manifest_invalid');
229
+ const alternate = record.directoryName === sourceDirectory ? memberDirectory : sourceDirectory;
230
+ if (isSelected)
231
+ await verifyReference(rootReal, record, record.directoryName, alternate);
232
+ else
233
+ await verifyReferenceMetadata(rootReal, record, record.directoryName, alternate);
234
+ const next = { ...record, directoryName: memberDirectory };
235
+ rebound.push(next);
236
+ writable.push(next);
237
+ }
238
+ catch (error) {
239
+ if (isSelected)
240
+ throw error;
241
+ rebound.push(record);
242
+ }
243
+ }
244
+ if (!items.length)
245
+ return;
246
+ const metadata = await safeDirectory(rootReal, '.playcraft');
247
+ const atomicWrite = async (destination, body) => {
248
+ const temporary = join(metadata, `.batch-reference-${randomUUID()}.tmp`);
249
+ try {
250
+ // shared
251
+ await writeFile(temporary, body, { flag: 'wx', mode: 0o644 });
252
+ await rename(temporary, destination);
253
+ }
254
+ finally {
255
+ await rm(temporary, { force: true });
256
+ }
257
+ };
258
+ for (const record of writable) {
259
+ const target = await safeDirectory(rootReal, record.path);
260
+ await atomicWrite(join(target, RECEIPT), JSON.stringify(record));
261
+ }
262
+ await atomicWrite(join(metadata, 'remix-references.json'), JSON.stringify({ version: 1, items: rebound }));
263
+ }
147
264
  /** Reads revalidate the snapshot, so changed/deleted files cannot silently remain attached to a turn. */
148
265
  export async function listWorkspaceRemixReferences(root, directoryName = 'basic') {
149
266
  workspaceDirectoryNameSchema.parse(directoryName);
@@ -0,0 +1,54 @@
1
+ import { join } from 'node:path';
2
+ import { lstat } from 'node:fs/promises';
3
+ import { skillActivationBindingHash, transitionOperation } from '@playcraft/common/cloud-workspace/runtime';
4
+ import { recoverPreparedSkillActivationTransactions } from '../../project-skills/remote-activation.js';
5
+ import { workspaceRoot } from './registry.js';
6
+ import { listSkillActivationRequests, reconcileSkillActivationRequestReceipts } from './skill-activation-requests.js';
7
+ /** Runs before native startup: restore owned pairs and settle only provably unsent continuations. */
8
+ export async function recoverInterruptedSkillActivations(state) {
9
+ if (state.config.workspaceMode !== 'multi' || !await lstat(join(state.config.stateDir, 'skill-activation-requests'))
10
+ .catch((error) => { if (error.code === 'ENOENT')
11
+ return undefined; throw error; }))
12
+ return;
13
+ const transactionDirectory = join(state.config.stateDir, 'skill-activation-requests/transactions');
14
+ const requests = await listSkillActivationRequests(state);
15
+ const unsentContinuations = (request) => {
16
+ const source = state.journal.getOperationById(request.sourceOperationId);
17
+ if (!source || source.kind !== 'session.turn' || source.sessionId !== request.sourceSessionId
18
+ || source.environment !== state.config.environment || source.userId !== state.config.userId
19
+ || source.workspaceId !== request.workspaceId || (source.directoryName ?? 'basic') !== request.directoryName
20
+ || source.generation !== request.generation || source.runtimeEpoch !== request.runtimeEpoch
21
+ || source.nativeThreadId !== request.sourceThreadId || source.nativeTurnId !== request.sourceTurnId)
22
+ return [];
23
+ return state.journal.listOperations().filter(({ operation }) => operation.kind === 'session.turn' && operation.clientRequestId === request.requestId
24
+ && operation.environment === state.config.environment && operation.userId === state.config.userId
25
+ && operation.sessionId === request.sourceSessionId && operation.workspaceId === request.workspaceId
26
+ && (operation.directoryName ?? 'basic') === request.directoryName && operation.generation === request.generation
27
+ && operation.runtimeEpoch === request.runtimeEpoch && operation.status === 'dispatched'
28
+ && !operation.nativeRequestId && !operation.nativeTurnId);
29
+ };
30
+ for (const workspaceId of new Set(requests.map(request => request.workspaceId))) {
31
+ const binding = state.journal.getWorkspace(workspaceId);
32
+ if (binding) {
33
+ for (const directoryName of new Set(['basic', ...(binding.directories ?? []).map(directory => directory.directoryName)])) {
34
+ const root = await workspaceRoot(state, binding.workspaceId, directoryName).catch(error => {
35
+ if (error instanceof Error && error.message === 'workspace_not_prepared')
36
+ return undefined;
37
+ throw error;
38
+ });
39
+ if (root)
40
+ await recoverPreparedSkillActivationTransactions(root, { transactionDirectory,
41
+ orphanTransactionIds: requests.filter(request => request.workspaceId === workspaceId && request.directoryName === directoryName
42
+ && request.registrationHash === skillActivationBindingHash(binding, directoryName) && unsentContinuations(request).length > 0)
43
+ .map(request => request.requestId) });
44
+ }
45
+ }
46
+ }
47
+ for (const request of requests) {
48
+ for (const { scopeKey, operation } of unsentContinuations(request)) {
49
+ // Reservation is persisted before native send. Without it this interrupted preparation never sent a turn.
50
+ await state.journal.putOperation(scopeKey, transitionOperation(operation, 'failed', { error: 'skill_activation_interrupted' }));
51
+ }
52
+ }
53
+ await reconcileSkillActivationRequestReceipts(state);
54
+ }