@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
@@ -348,7 +348,7 @@ export function createCodexAppServerAdapter(options) {
348
348
  'skills.include_instructions': false,
349
349
  'skills.config': listing.skills.map(skill => ({ path: skill.path, enabled: false })) };
350
350
  const developerInstructions = [
351
- `Available workspace skills are fixed by lock ${frozen.declarationHash}.`,
351
+ `Available workspace skills are fixed by lock ${frozen.lockHash}.`,
352
352
  'Use only the following locked skills. Read its SKILL.md when a skill applies, and resolve supporting files relative to that file. Disabled and unlisted skill directories are not available.',
353
353
  ...frozen.skills.map(skill => JSON.stringify(skill)),
354
354
  ...(frozen.skills.length ? [] : ['(No workspace skills are enabled.)']),
@@ -414,6 +414,26 @@ export function createCodexAppServerAdapter(options) {
414
414
  throw new NativePreparationError(unavailable);
415
415
  }
416
416
  }
417
+ async function injectPreparedSkillContext(threadId, prepared) {
418
+ const instructions = asRecord(prepared.params).developerInstructions;
419
+ if (!prepared.skillsHash || typeof instructions !== 'string')
420
+ return;
421
+ if (threads.get(threadId)?.turnActive || [...approvals.values()].some(pending => owningThreadId(pending.threadId) === threadId))
422
+ throw new NativePreparationError('skills_reload_pending');
423
+ // Resume configuration does not replace developer messages already in native history.
424
+ // Keep built-in collaboration policies and update only the platform-owned workspace context.
425
+ try {
426
+ await rpc.request('thread/inject_items', { threadId, items: [{ type: 'message', role: 'developer', content: [{
427
+ type: 'input_text', text: [
428
+ `Current platform workspace skill lock: ${prepared.skillsHash}. This replaces only earlier platform workspace skill lock and allowlist declarations. Keep all other instructions and permissions in force.`,
429
+ instructions,
430
+ ].join('\n\n'),
431
+ }] }] }, 30_000);
432
+ }
433
+ catch {
434
+ throw new NativePreparationError('workspace_skills_unavailable');
435
+ }
436
+ }
417
437
  async function resumePreparedThread(threadId, thread, prepared) {
418
438
  const resumed = await rpc.request('thread/resume', {
419
439
  threadId, ...prepared.params,
@@ -421,6 +441,7 @@ export function createCodexAppServerAdapter(options) {
421
441
  }, 30_000);
422
442
  if (resumed.thread?.id !== threadId)
423
443
  throw new NativePreparationError('workspace_skills_unavailable');
444
+ await injectPreparedSkillContext(threadId, prepared);
424
445
  thread.loaded = true;
425
446
  thread.readOnlyConfirmed = isReadOnlySnapshot(resumed);
426
447
  thread.skillsHash = prepared.skillsHash;
@@ -938,6 +959,7 @@ export function createCodexAppServerAdapter(options) {
938
959
  }
939
960
  if (resumedId !== input.threadId)
940
961
  return undefined;
962
+ await injectPreparedSkillContext(input.threadId, prepared);
941
963
  prepared.activate();
942
964
  threads.set(input.threadId, { sessionId: input.sessionId, cwd: input.cwd, skillsHash: prepared.skillsHash,
943
965
  discoveryHash: prepared.discoveryHash, mcpHash: prepared.mcpHash, contextHash: nativeContextHash(prepared.params), resolvedModel: 'model' in prepared.params ? prepared.params.model : model, modelConfigId: prepared.modelConfigId, reasoningEffort: input.reasoningEffort, permissionMode: input.permissionMode ?? 'auto', canEscalate: sessionCanEscalate(Boolean(options.trustedExecution?.(input.sessionId)), prepared.execution, Boolean(options.fixedExecution)), hasNativeHistory: true, loaded: true });
@@ -1024,6 +1046,8 @@ export function createCodexAppServerAdapter(options) {
1024
1046
  thread.turnActive = true;
1025
1047
  thread.scheduledTaskProposalBridge = input.scheduledTaskProposalBridge;
1026
1048
  thread.uiSnapshot = options.workspaceUi && input.uiSnapshot?.sessionId === input.sessionId ? input.uiSnapshot : undefined;
1049
+ if (thread.cwd)
1050
+ options.modelBroker?.markCodexDispatched?.(thread.cwd, input.sessionId, input.clientRequestId);
1027
1051
  const started = await rpc.sendReserved(requestId, 'turn/start', { threadId: input.threadId, input: [
1028
1052
  { type: 'text', text: input.text },
1029
1053
  ...(input.images ?? []).map((path) => ({ type: 'localImage', path })),
@@ -1,14 +1,15 @@
1
1
  import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
2
  import { createServer } from 'node:http';
3
- import { Readable } from 'node:stream';
3
+ import { Readable, Transform } from 'node:stream';
4
4
  import { pipeline } from 'node:stream/promises';
5
5
  import { basename, dirname } from 'node:path';
6
6
  import { z } from 'zod';
7
7
  import { workspaceContextFromRoot } from '../workspaces/registry.js';
8
- import { PLATFORM_ROUTES, modelResponsesPath, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
8
+ import { PLATFORM_ROUTES, modelResponsesPath, platformEndpoint, workspaceExecutionTraceOriginSchema } from '@playcraft/common/cloud-workspace/runtime';
9
9
  import { WorkspaceGenerationProvenanceBroker } from './generation-provenance-broker.js';
10
10
  import { WorkspaceRemixReferencesBroker } from './remix-references-broker.js';
11
11
  import { WorkspaceExecutionContextBroker } from './execution-context-broker.js';
12
+ import { WorkspaceSkillDiscoveryBroker } from './skill-discovery-broker.js';
12
13
  const credentialSchema = z.object({
13
14
  token: z.string().min(1), expiresAt: z.string().datetime(),
14
15
  configId: z.string().uuid(), model: z.string().min(1).max(128),
@@ -16,6 +17,7 @@ const credentialSchema = z.object({
16
17
  claims: z.object({
17
18
  environment: z.string(), userId: z.string(), workspaceId: z.string(), runtimeId: z.string(),
18
19
  generation: z.number().int(), runtimeEpoch: z.number().int(),
20
+ traceOrigin: z.custom(value => workspaceExecutionTraceOriginSchema.safeParse(value).success).optional(),
19
21
  }),
20
22
  });
21
23
  const MAX_MODEL_REQUEST_BYTES = 128 * 1024 * 1024;
@@ -32,11 +34,13 @@ export class WorkspaceModelBroker {
32
34
  generation;
33
35
  references;
34
36
  executionContext;
37
+ skills;
35
38
  constructor(state) {
36
39
  this.state = state;
37
40
  this.generation = new WorkspaceGenerationProvenanceBroker(state);
38
41
  this.references = new WorkspaceRemixReferencesBroker(state);
39
42
  this.executionContext = new WorkspaceExecutionContextBroker(state);
43
+ this.skills = new WorkspaceSkillDiscoveryBroker(state);
40
44
  }
41
45
  async start() {
42
46
  if (this.server)
@@ -81,7 +85,8 @@ export class WorkspaceModelBroker {
81
85
  const boundaryId = sessionId && clientRequestId ? await this.generation.prepare() : undefined;
82
86
  if (!route || route.epoch !== this.state.runtimeEpoch) {
83
87
  route = { workspaceId, epoch: this.state.runtimeEpoch, capability: randomBytes(32).toString('base64url'),
84
- generationCapability: randomBytes(32).toString('base64url'), referencesCapability: randomBytes(32).toString('base64url') };
88
+ generationCapability: randomBytes(32).toString('base64url'), referencesCapability: randomBytes(32).toString('base64url'),
89
+ skillsCapability: randomBytes(32).toString('base64url') };
85
90
  this.routes.set(routeKey, route);
86
91
  }
87
92
  // Each session pins its prepared model; shared credential renewal cannot change an active turn.
@@ -123,6 +128,8 @@ export class WorkspaceModelBroker {
123
128
  PLAYCRAFT_AGENT_GENERATION_CAPABILITY: route.generationCapability,
124
129
  PLAYCRAFT_REMIX_REFERENCES_ENDPOINT: `${this.baseUrl}/${routeKey}/remix-references`,
125
130
  PLAYCRAFT_REMIX_REFERENCES_CAPABILITY: route.referencesCapability,
131
+ PLAYCRAFT_SKILLS_ENDPOINT: `${this.baseUrl}/${routeKey}/skill-discovery`,
132
+ PLAYCRAFT_SKILLS_CAPABILITY: route.skillsCapability,
126
133
  } } : {}),
127
134
  'model_providers.playcraft_workspace': {
128
135
  name: 'PlayCraft Workspace', base_url: `${this.baseUrl}/${routeKey}`,
@@ -137,6 +144,16 @@ export class WorkspaceModelBroker {
137
144
  && this.state.runtimeEpoch === entry.epoch && this.state.journal.getWorkspace(entry.workspaceId)
138
145
  && this.entries.get(entry.cacheKey) === entry);
139
146
  }
147
+ /** Called immediately before the native turn/start write, within this broker's process clock. */
148
+ markCodexDispatched(cwd, sessionId, clientRequestId) {
149
+ const workspaceId = /^(basic|remix-)/.test(basename(cwd)) ? basename(dirname(cwd)) : basename(cwd);
150
+ const route = this.routes.get(`${workspaceId}/${createHash('sha256').update(sessionId).digest('hex').slice(0, 32)}`);
151
+ const entry = route?.selected?.entry;
152
+ if (!entry || !this.valid(entry) || entry.sessionId !== sessionId || entry.clientRequestId !== clientRequestId)
153
+ return;
154
+ entry.codexDispatchedAt = new Date().toISOString();
155
+ entry.codexDispatchMark = performance.now();
156
+ }
140
157
  async credential(entry, refresh = false) {
141
158
  if (!this.valid(entry))
142
159
  throw new Error('model_access_denied');
@@ -179,13 +196,16 @@ export class WorkspaceModelBroker {
179
196
  return credential;
180
197
  }
181
198
  async forward(request, response) {
199
+ const receivedMark = performance.now();
200
+ const receivedAt = new Date().toISOString();
201
+ const elapsed = (start) => Math.max(0, Math.round(performance.now() - start));
182
202
  const abort = new AbortController();
183
203
  this.requests.add(abort);
184
204
  let activeRoute;
185
205
  const cancel = () => abort.abort();
186
206
  response.once('close', cancel);
187
207
  try {
188
- const match = /^\/([0-9a-f-]{36}(?:\/[0-9a-f]{32})?)\/(responses|generation-provenance|remix-references|execution-context)$/.exec(request.url ?? '');
208
+ const match = /^\/([0-9a-f-]{36}(?:\/[0-9a-f]{32})?)\/(responses|generation-provenance|remix-references|execution-context|skill-discovery\/(?:match|selection|artifact|activate))$/.exec(request.url ?? '');
189
209
  const route = match ? this.routes.get(match[1]) : undefined;
190
210
  const selected = route?.selected;
191
211
  const entry = selected?.entry;
@@ -193,8 +213,9 @@ export class WorkspaceModelBroker {
193
213
  const generationRequest = match?.[2] === 'generation-provenance';
194
214
  const contextRequest = match?.[2] === 'execution-context';
195
215
  const referencesRequest = match?.[2] === 'remix-references';
216
+ const skillsRequest = match?.[2]?.startsWith('skill-discovery/');
196
217
  const expected = Buffer.from(route ? `Bearer ${generationRequest || contextRequest ? route.generationCapability
197
- : referencesRequest ? route.referencesCapability : route.capability}` : '');
218
+ : referencesRequest ? route.referencesCapability : skillsRequest ? route.skillsCapability : route.capability}` : '');
198
219
  if (request.method !== 'POST' || !entry || !expected.length || presented.length !== expected.length
199
220
  || !timingSafeEqual(presented, expected) || !this.valid(entry)) {
200
221
  response.writeHead(403);
@@ -228,6 +249,16 @@ export class WorkspaceModelBroker {
228
249
  await this.references.forward(request, response, selected.feedback, () => this.valid(entry) && route?.selected === selected);
229
250
  return;
230
251
  }
252
+ if (skillsRequest) {
253
+ if (!selected?.feedback) {
254
+ response.writeHead(403);
255
+ response.end();
256
+ return;
257
+ }
258
+ const action = match[2].slice('skill-discovery/'.length);
259
+ await this.skills.forward(request, response, selected.feedback, () => this.valid(entry) && route?.selected === selected, action);
260
+ return;
261
+ }
231
262
  const rejectOversizedBody = () => {
232
263
  response.writeHead(413, { 'content-type': 'application/json', 'cache-control': 'no-store' });
233
264
  response.end(JSON.stringify({ error: { message: 'model_request_too_large',
@@ -242,7 +273,10 @@ export class WorkspaceModelBroker {
242
273
  const active = this.sessionRequests.get(route) ?? new Set();
243
274
  active.add(abort);
244
275
  this.sessionRequests.set(route, active);
276
+ const credentialMark = performance.now();
245
277
  const credential = await this.credential(entry);
278
+ const credentialWaitMs = elapsed(credentialMark);
279
+ const requestReadMark = performance.now();
246
280
  const chunks = [];
247
281
  let size = 0;
248
282
  for await (const chunk of request) {
@@ -253,6 +287,7 @@ export class WorkspaceModelBroker {
253
287
  }
254
288
  chunks.push(Buffer.from(chunk));
255
289
  }
290
+ const requestReadMs = elapsed(requestReadMark);
256
291
  if (!this.valid(entry))
257
292
  throw new Error('model_access_denied');
258
293
  const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
@@ -280,16 +315,37 @@ export class WorkspaceModelBroker {
280
315
  };
281
316
  }
282
317
  const expires = setTimeout(cancel, Math.max(0, Date.parse(credential.expiresAt) - Date.now()));
318
+ const dispatchedAt = new Date().toISOString();
319
+ const dispatchedMark = performance.now();
320
+ const beforeDispatchMs = elapsed(receivedMark);
321
+ const candidateOrigin = credential.claims.traceOrigin;
322
+ const origin = candidateOrigin?.sessionId === entry.sessionId && candidateOrigin?.clientRequestId === entry.clientRequestId
323
+ ? candidateOrigin : undefined;
324
+ const broker = { receivedAt, dispatchedAt,
325
+ credentialWaitMs, requestReadMs, beforeDispatchMs };
326
+ let attemptId;
327
+ let outcome = 'error';
283
328
  try {
284
329
  const upstream = await fetch(platformEndpoint(this.state.config.publicBaseUrl, modelResponsesPath(credential.configId)), {
285
330
  method: 'POST', redirect: 'error', signal: abort.signal,
286
331
  headers: { authorization: `Bearer ${credential.token}`, 'content-type': 'application/json',
332
+ ...(origin ? { 'x-playcraft-model-journey': JSON.stringify({ version: 1,
333
+ runtimeOperationId: origin.runtimeOperationId, sessionId: origin.sessionId, clientRequestId: origin.clientRequestId,
334
+ ...(entry.codexDispatchedAt ? { codexDispatchedAt: entry.codexDispatchedAt } : {}),
335
+ brokerReceivedAt: receivedAt, brokerDispatchedAt: dispatchedAt, credentialWaitMs, requestReadMs, beforeDispatchMs,
336
+ ...(entry.codexDispatchMark === undefined ? {} : { codexDispatchToBrokerMs: Math.max(0, Math.round(receivedMark - entry.codexDispatchMark)) }),
337
+ }) } : {}),
287
338
  ...(selected?.feedback ? {
288
339
  'x-playcraft-cloud-session-id': selected.feedback.sessionId,
289
340
  'x-playcraft-client-request-id': selected.feedback.clientRequestId,
290
341
  } : {}) },
291
342
  body: JSON.stringify(body),
292
343
  });
344
+ broker.responseHeadersAt = new Date().toISOString();
345
+ broker.proxyResponseHeadersMs = elapsed(dispatchedMark);
346
+ const reference = upstream.headers.get('x-model-attempt-id');
347
+ if (reference && z.string().uuid().safeParse(reference).success)
348
+ attemptId = reference;
293
349
  if (upstream.status === 401 || upstream.status === 403)
294
350
  entry.credential = undefined;
295
351
  const admissionReason = upstream.headers.get('x-model-admission-reason')?.trim();
@@ -305,12 +361,33 @@ export class WorkspaceModelBroker {
305
361
  ...(retryAfter ? { 'retry-after': retryAfter } : {}),
306
362
  });
307
363
  if (upstream.body)
308
- await pipeline(Readable.fromWeb(upstream.body), response);
364
+ await pipeline(Readable.fromWeb(upstream.body), new Transform({
365
+ transform(chunk, _encoding, callback) {
366
+ if (chunk.length && broker.firstBodyByteAt === undefined) {
367
+ broker.firstBodyByteAt = new Date().toISOString();
368
+ broker.proxyFirstBodyByteMs = elapsed(dispatchedMark);
369
+ }
370
+ callback(null, chunk);
371
+ },
372
+ }), response);
309
373
  else
310
374
  response.end();
375
+ outcome = 'completed';
376
+ }
377
+ catch (error) {
378
+ outcome = abort.signal.aborted ? 'interrupted' : 'error';
379
+ throw error;
311
380
  }
312
381
  finally {
313
382
  clearTimeout(expires);
383
+ broker.completedAt = new Date().toISOString();
384
+ broker.proxyResponseDurationMs = elapsed(dispatchedMark);
385
+ if (origin && attemptId) {
386
+ // Telemetry is sent after transfer and cannot delay or change the Codex model response.
387
+ void this.reportObservation(credential.token, { version: 1, attemptId,
388
+ runtimeOperationId: origin.runtimeOperationId, sessionId: origin.sessionId,
389
+ clientRequestId: origin.clientRequestId, broker, outcome });
390
+ }
314
391
  }
315
392
  }
316
393
  catch {
@@ -332,6 +409,16 @@ export class WorkspaceModelBroker {
332
409
  }
333
410
  }
334
411
  }
412
+ async reportObservation(token, input) {
413
+ try {
414
+ const response = await fetch(platformEndpoint(this.state.config.publicBaseUrl, '/api/llm-proxy/runtime-observations'), {
415
+ method: 'POST', redirect: 'error', signal: AbortSignal.timeout(3_000),
416
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify(input),
417
+ });
418
+ await response.body?.cancel();
419
+ }
420
+ catch { /* Missing observations remain unknown; never replay a model request for telemetry. */ }
421
+ }
335
422
  cancelSession(cwd, sessionId) {
336
423
  const routeKey = `${/^(basic|remix-)/.test(basename(cwd)) ? basename(dirname(cwd)) : basename(cwd)}/${createHash('sha256').update(sessionId).digest('hex').slice(0, 32)}`;
337
424
  const route = this.routes.get(routeKey);
@@ -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);