@playcraft/cli 0.1.70 → 0.1.72

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 (43) hide show
  1. package/README.md +20 -1
  2. package/dist/cli-root-help.js +1 -0
  3. package/dist/commands/references.js +3 -0
  4. package/dist/commands/skills.js +68 -4
  5. package/dist/commands/tools-batch.js +2 -0
  6. package/dist/project-skills/discovery.js +248 -0
  7. package/dist/project-skills/messages.js +80 -0
  8. package/dist/project-skills/remote-activation.js +500 -0
  9. package/dist/project-skills/remote-client.js +117 -0
  10. package/dist/project-skills/remote-commands.js +87 -0
  11. package/dist/project-skills/remote-prepare.js +223 -0
  12. package/dist/project-skills/search-command.js +47 -0
  13. package/dist/project-skills/search.js +96 -0
  14. package/dist/recording/project-digest.js +1 -1
  15. package/dist/references/messages.js +2 -0
  16. package/dist/references/protocol.js +39 -12
  17. package/dist/workspace-runtime/batch/freeze.js +94 -24
  18. package/dist/workspace-runtime/batch/records.js +12 -6
  19. package/dist/workspace-runtime/batch/routes.js +31 -11
  20. package/dist/workspace-runtime/batch/source-candidate.js +29 -16
  21. package/dist/workspace-runtime/batch/source-capture.js +251 -20
  22. package/dist/workspace-runtime/codex/app-server.js +30 -1
  23. package/dist/workspace-runtime/codex/model-broker.js +93 -6
  24. package/dist/workspace-runtime/codex/remix-references-broker.js +208 -67
  25. package/dist/workspace-runtime/codex/skill-discovery-broker.js +174 -0
  26. package/dist/workspace-runtime/serve.js +5 -0
  27. package/dist/workspace-runtime/server/directory-access.js +10 -0
  28. package/dist/workspace-runtime/server/dispatch.js +121 -28
  29. package/dist/workspace-runtime/server/http.js +345 -117
  30. package/dist/workspace-runtime/server/operation-record.js +3 -1
  31. package/dist/workspace-runtime/workspaces/composer-context.js +92 -41
  32. package/dist/workspace-runtime/workspaces/files.js +59 -23
  33. package/dist/workspace-runtime/workspaces/remix-references.js +162 -53
  34. package/dist/workspace-runtime/workspaces/skill-activation-recovery.js +54 -0
  35. package/dist/workspace-runtime/workspaces/skill-activation-requests.js +512 -0
  36. package/dist/workspace-runtime/workspaces/skills.js +13 -176
  37. package/dist/workspace-runtime/workspaces/starter-template.js +74 -0
  38. package/package.json +4 -3
  39. package/project-template/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
  40. package/project-template-v2/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
  41. package/project-templates/empty/gitignore +1 -0
  42. package/project-templates/phaser/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
  43. package/project-templates/threejs/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
@@ -0,0 +1,174 @@
1
+ import { realpath } from 'node:fs/promises';
2
+ import { isAbsolute, relative } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { z } from 'zod';
5
+ import { remoteSkillMatchRequestSchema, remoteSkillMatchResultSchema, remoteSkillSelectionRequestSchema, remoteSkillArtifactRequestSchema, remoteSkillActivationRequestSchema, parseRemoteSkillSelectionResult, } from '@playcraft/common/project-skills';
6
+ import { platformEndpoint, skillActivationRegistrationSchema } from '@playcraft/common/cloud-workspace/runtime';
7
+ import { workspaceContextFromRoot } from '../workspaces/registry.js';
8
+ import { loadWorkspaceSkillArtifact } from '../workspaces/skill-artifact-loader.js';
9
+ import { verifyLauncherOrigin } from './launcher-client.js';
10
+ import { registerSkillActivationRequest } from '../workspaces/skill-activation-requests.js';
11
+ const requestSchema = z.object({ cwd: z.string().min(1).max(4096), input: z.unknown() }).strict();
12
+ const MAX_METADATA_BYTES = 4 * 1024 * 1024;
13
+ const SELECTION_TTL_MS = 15 * 60_000;
14
+ /** Resolves candidates with control identity; shell capability never authorizes changing the active lock. */
15
+ export class WorkspaceSkillDiscoveryBroker {
16
+ state;
17
+ selections = new Map();
18
+ constructor(state) {
19
+ this.state = state;
20
+ }
21
+ operation(context, valid) {
22
+ const state = this.state, session = state.journal.getSession(context.sessionId);
23
+ const operations = state.journal.listOperations().map(item => item.operation).filter(operation => operation.kind === 'session.turn' && operation.sessionId === context.sessionId && operation.clientRequestId === context.clientRequestId
24
+ && operation.generation === state.config.generation && operation.runtimeEpoch === state.runtimeEpoch
25
+ && ['dispatched', 'accepted'].includes(operation.status));
26
+ const operation = operations.length === 1 ? operations[0] : undefined;
27
+ if (!valid() || context.refsBlocked || state.journal.destroyed() || !operation?.workspaceId || !operation.directoryName
28
+ || operation.directoryName === 'root' || !operation.nativeThreadId || !operation.nativeTurnId
29
+ || !session || session.threadId !== operation.nativeThreadId || session.activeTurnId !== operation.nativeTurnId
30
+ || session.cwd !== context.cwd || session.workspaceId !== operation.workspaceId
31
+ || (session.directoryName ?? 'basic') !== operation.directoryName || session.turnBlock
32
+ || !['running', 'waiting_for_user'].includes(session.turnStatus ?? ''))
33
+ throw new Error('skill_discovery_turn_unavailable');
34
+ return operation;
35
+ }
36
+ async current(context, valid) {
37
+ const operation = this.operation(context, valid);
38
+ const scope = await workspaceContextFromRoot(this.state, context.cwd);
39
+ if (scope.workspaceId !== operation.workspaceId || scope.directoryName !== operation.directoryName)
40
+ throw new Error('skill_discovery_scope_mismatch');
41
+ this.operation(context, valid);
42
+ return scope;
43
+ }
44
+ selectionKey(context, selectionId) {
45
+ return JSON.stringify([this.state.config.runtimeId, this.state.config.generation, this.state.runtimeEpoch,
46
+ context.cwd, context.sessionId, context.clientRequestId, selectionId]);
47
+ }
48
+ async forward(request, response, context, valid, action) {
49
+ const reply = (status, value) => {
50
+ response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
51
+ response.end(JSON.stringify(value));
52
+ };
53
+ try {
54
+ if (request.socket.remoteAddress !== '127.0.0.1')
55
+ throw new Error('skill_discovery_turn_unavailable');
56
+ let text = '';
57
+ for await (const chunk of request) {
58
+ text += chunk.toString();
59
+ if (Buffer.byteLength(text) > 32 * 1024)
60
+ throw new Error('skill_discovery_invalid_request');
61
+ }
62
+ const parsed = requestSchema.safeParse(JSON.parse(text));
63
+ if (!parsed.success)
64
+ throw new Error('skill_discovery_invalid_request');
65
+ const scope = await this.current(context, valid);
66
+ const callerCwd = await realpath(parsed.data.cwd);
67
+ const childPath = relative(context.cwd, callerCwd);
68
+ if (isAbsolute(childPath) || childPath === '..' || childPath.startsWith('../'))
69
+ throw new Error('skill_discovery_scope_mismatch');
70
+ const socketPath = process.env.PLAYCRAFT_LAUNCHER_SOCKET;
71
+ if (!socketPath || !context.boundaryId || !request.socket.localPort || !request.socket.remotePort
72
+ || !await verifyLauncherOrigin({ socketPath, boundaryId: context.boundaryId, runtimeEpoch: this.state.runtimeEpoch,
73
+ serverPort: request.socket.localPort, clientPort: request.socket.remotePort, cwd: context.originCwd ?? callerCwd }))
74
+ throw new Error('skill_discovery_turn_unavailable');
75
+ await this.current(context, valid);
76
+ if (action === 'activate') {
77
+ const input = remoteSkillActivationRequestSchema.safeParse(parsed.data.input);
78
+ if (!input.success)
79
+ throw new Error('skill_discovery_invalid_request');
80
+ const operation = this.operation(context, valid);
81
+ if (context.readOnly || operation.workMode === 'plan' || operation.permissionMode === 'read-only'
82
+ || this.state.journal.getSession(context.sessionId)?.permissionMode === 'read-only')
83
+ throw new Error('skill_discovery_readonly');
84
+ const selected = this.selections.get(this.selectionKey(context, input.data.selectionId));
85
+ if (!selected || selected.expiresAt <= Date.now())
86
+ throw new Error('skill_discovery_selection_unavailable');
87
+ const result = await registerSkillActivationRequest(this.state, {
88
+ cwd: context.cwd, sessionId: context.sessionId, clientRequestId: context.clientRequestId, requestedSkillRefs: selected.requestedSkillRefs,
89
+ }, selected.selection);
90
+ await this.current(context, valid);
91
+ return reply(200, skillActivationRegistrationSchema.parse(result));
92
+ }
93
+ if (action === 'artifact') {
94
+ const input = remoteSkillArtifactRequestSchema.safeParse(parsed.data.input);
95
+ if (!input.success)
96
+ throw new Error('skill_discovery_invalid_request');
97
+ const selection = this.selections.get(this.selectionKey(context, input.data.selectionId));
98
+ const entry = selection && selection.expiresAt > Date.now() ? selection.entries.find(item => item.entryKey === input.data.entryKey) : undefined;
99
+ if (!entry)
100
+ throw new Error('skill_discovery_selection_unavailable');
101
+ const archive = await loadWorkspaceSkillArtifact(this.state, scope.workspaceId, entry, scope.directoryName);
102
+ await this.current(context, valid);
103
+ selection.expiresAt = Date.now() + SELECTION_TTL_MS;
104
+ return reply(200, { content: archive.toString('base64'), artifactHash: createHash('sha256').update(archive).digest('hex') });
105
+ }
106
+ const query = action === 'match' ? remoteSkillMatchRequestSchema.safeParse(parsed.data.input)
107
+ : remoteSkillSelectionRequestSchema.safeParse(parsed.data.input);
108
+ if (!query.success)
109
+ throw new Error('skill_discovery_invalid_request');
110
+ const raw = await this.upstream(scope, action, query.data);
111
+ await this.current(context, valid);
112
+ if (action === 'match')
113
+ return reply(200, remoteSkillMatchResultSchema.parse(raw));
114
+ const value = parseRemoteSkillSelectionResult(raw);
115
+ const selection = remoteSkillSelectionRequestSchema.parse(query.data);
116
+ for (const requested of selection.skills) {
117
+ if (!value.lock.entries.some(entry => entry.revision.kind === 'release' && entry.revision.skillRef === requested.skillRef
118
+ && entry.revision.version === requested.version && entry.revision.artifactHash === requested.artifactHash)
119
+ || !value.skills.some(item => item.skillVersionId === requested.skillVersionId && item.skillRef === requested.skillRef
120
+ && item.version === requested.version && item.artifactHash === requested.artifactHash))
121
+ throw new Error('skill_discovery_invalid_response');
122
+ }
123
+ for (const [key, cached] of this.selections)
124
+ if (cached.expiresAt <= Date.now())
125
+ this.selections.delete(key);
126
+ if (this.selections.size >= 64)
127
+ this.selections.delete(this.selections.keys().next().value);
128
+ this.selections.set(this.selectionKey(context, value.selectionId), { expiresAt: Date.now() + SELECTION_TTL_MS, entries: value.lock.entries,
129
+ selection: value, requestedSkillRefs: selection.skills.map(skill => skill.skillRef) });
130
+ return reply(200, value);
131
+ }
132
+ catch (error) {
133
+ if (response.headersSent) {
134
+ response.destroy();
135
+ return;
136
+ }
137
+ const code = error instanceof SyntaxError ? 'skill_discovery_invalid_request'
138
+ : error instanceof Error && /^(?:skill_discovery_|workspace_skills_)[a-z_]+$/.test(error.message)
139
+ ? error.message : 'skill_discovery_unavailable';
140
+ reply(code === 'skill_discovery_invalid_request' ? 400 : code === 'skill_discovery_unavailable' ? 503 : 403, { error: { code } });
141
+ }
142
+ }
143
+ async upstream(scope, action, input) {
144
+ const config = this.state.config;
145
+ const response = await fetch(platformEndpoint(config.publicBaseUrl, `/runtime/internal/skill-discovery/${action}`), {
146
+ method: 'POST', redirect: 'error', signal: AbortSignal.timeout(45_000),
147
+ headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
148
+ body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation, runtimeEpoch: this.state.runtimeEpoch,
149
+ workspaceId: scope.workspaceId, directoryName: scope.directoryName, input }),
150
+ });
151
+ if (!response.ok || !response.body) {
152
+ await response.body?.cancel().catch(() => undefined);
153
+ throw new Error('skill_discovery_unavailable');
154
+ }
155
+ const chunks = [];
156
+ let bytes = 0;
157
+ const reader = response.body.getReader();
158
+ try {
159
+ while (true) {
160
+ const chunk = await reader.read();
161
+ if (chunk.done)
162
+ break;
163
+ bytes += chunk.value.byteLength;
164
+ if (bytes > MAX_METADATA_BYTES)
165
+ throw new Error('skill_discovery_invalid_response');
166
+ chunks.push(Buffer.from(chunk.value));
167
+ }
168
+ }
169
+ finally {
170
+ await reader.cancel().catch(() => undefined);
171
+ }
172
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
173
+ }
174
+ }
@@ -37,6 +37,8 @@ import { platformExportTarget, readPlatformExport, startPlatformExport } from '.
37
37
  import { scheduledTaskProposalHandler } from './workspaces/scheduled-task-proposals.js';
38
38
  import { executionContextToolHandler } from './workspaces/execution-context.js';
39
39
  import { registerLevelEditorRequest, startLevelEditorRequestQueue, stopLevelEditorRequestQueue } from './workspaces/level-editor-requests.js';
40
+ import { startSkillActivationRequestQueue, stopSkillActivationRequestQueue } from './workspaces/skill-activation-requests.js';
41
+ import { recoverInterruptedSkillActivations } from './workspaces/skill-activation-recovery.js';
40
42
  import { proceduralSkillHandler } from './workspaces/procedural-3d/host.js';
41
43
  import { recoverModelPreviewOperations } from './workspaces/procedural-3d/model-preview-recovery.js';
42
44
  import { recoverModelPreparationOperations, stopModelPreparations } from './workspaces/model-preparation.js';
@@ -83,6 +85,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
83
85
  executionQueue: Promise.resolve(),
84
86
  instanceToken: randomUUID(),
85
87
  };
88
+ await recoverInterruptedSkillActivations(state);
86
89
  const recoveredImports = new Map();
87
90
  await recoverModelPreviewOperations(state, { identityMatches: previousIdentityMatches,
88
91
  runtimeEpoch: config.runtimeEpoch, previousRuntimeEpoch: previousEpoch,
@@ -348,12 +351,14 @@ export async function startWorkspaceRuntime(config, options = {}) {
348
351
  });
349
352
  const port = await listenOn(server, config.listen.host, config.listen.port);
350
353
  startLevelEditorRequestQueue(state);
354
+ startSkillActivationRequestQueue(state);
351
355
  return {
352
356
  config: state.config,
353
357
  state,
354
358
  url: `http://${formatHost(config.listen.host)}:${port}`,
355
359
  async close() {
356
360
  stopLevelEditorRequestQueue(state);
361
+ await stopSkillActivationRequestQueue(state);
357
362
  const serverClosed = closeServer(server);
358
363
  await stopParameterVerifications(state);
359
364
  await stopModelPreparations(state);
@@ -1,6 +1,7 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { isTurnControl, takesWorkspaceDirectory, takesExecutionSlot, transitionOperation, } from '@playcraft/common/cloud-workspace/runtime';
4
+ import { batchDirectoryHeld } from '../batch/occupancy.js';
4
5
  import { listMembers, readSnapshot } from '../batch/records.js';
5
6
  const context = new AsyncLocalStorage();
6
7
  const gates = new WeakMap();
@@ -481,6 +482,15 @@ export function releaseBatchDirectory(state, member) {
481
482
  if (!member.holding && member.writersStopped)
482
483
  instances.get(state)?.release(`run:${member.runId}`);
483
484
  }
485
+ /**
486
+ * A lease rejection names its cause so callers can tell a running batch member apart from ordinary
487
+ * directory contention: the former is a production lock, not a failure. A damaged member record
488
+ * must not replace the rejection with a failed response.
489
+ */
490
+ export async function directoryBusyCode(state, workspaceId, directoryName = 'basic') {
491
+ const held = await batchDirectoryHeld(state, workspaceId, directoryName).catch(() => false);
492
+ return held ? 'batch_workspace_readonly' : 'directory_busy';
493
+ }
484
494
  export async function withDirectoryFreeze(state, scope, action, quiesce, options) {
485
495
  const pending = freezing.get(state) ??
486
496
  new Map();
@@ -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)