@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
@@ -4,20 +4,47 @@ import { lstat, mkdir, open, readFile, realpath, rename, rm, writeFile } from 'n
4
4
  import { dirname, isAbsolute, join, relative } from 'node:path';
5
5
  import { gzip, gunzip } from 'node:zlib';
6
6
  import { promisify } from 'node:util';
7
- import { WORKSPACE_REMIX_REFERENCES_MANIFEST } from '@playcraft/common/cloud-workspace/runtime';
7
+ import { WORKSPACE_REMIX_REFERENCES_MANIFEST, starterTemplateTreeHashInput, remixReferenceContentHashInput, workspaceStarterTemplateSnapshotSchema, } from '@playcraft/common/cloud-workspace/runtime';
8
8
  import { excludeFromGit } from '../workspaces/dependencies.js';
9
- import { batchReferenceEnvExamples, batchReferenceMetadata } from '../workspaces/remix-references.js';
9
+ import { batchReferenceEnvExamples, batchReferenceMetadata, } from '../workspaces/remix-references.js';
10
10
  import { gitRead, gitWrite } from './git.js';
11
11
  const compress = promisify(gzip);
12
12
  const decompress = promisify(gunzip);
13
- const privateNames = new Set(['.git', 'node_modules', '.ssh', '.aws', '.azure', '.gnupg', '.codex', '.npm', '.pnpm-store',
14
- '.config', '.npmrc', '.yarnrc', '.yarnrc.yml', '.netrc', '.git-credentials', 'credentials.json', 'credentials', 'secrets',
15
- 'id_rsa', 'id_ed25519', 'settings.local.json', '.playcraft.json', 'playcraft.config.json', '.playcraft-temp', '.playcraft-preview-out']);
13
+ const privateNames = new Set([
14
+ '.git',
15
+ 'node_modules',
16
+ '.ssh',
17
+ '.aws',
18
+ '.azure',
19
+ '.gnupg',
20
+ '.codex',
21
+ '.npm',
22
+ '.pnpm-store',
23
+ '.config',
24
+ '.npmrc',
25
+ '.yarnrc',
26
+ '.yarnrc.yml',
27
+ '.netrc',
28
+ '.git-credentials',
29
+ 'credentials.json',
30
+ 'credentials',
31
+ 'secrets',
32
+ 'id_rsa',
33
+ 'id_ed25519',
34
+ 'settings.local.json',
35
+ '.playcraft.json',
36
+ 'playcraft.config.json',
37
+ '.playcraft-temp',
38
+ '.playcraft-preview-out',
39
+ ]);
16
40
  function included(path, referenceExamples) {
17
41
  const parts = path.split('/');
18
- return !parts.some((name, index) => privateNames.has(name) || (/^\.env(?:$|\.)/.test(name) &&
19
- !(index === parts.length - 1 && referenceExamples.has(path))) || /\.(?:pem|key|p12|pfx)$/.test(name))
20
- && !path.startsWith('.playcraft/skills/');
42
+ return (!parts.some((name, index) => privateNames.has(name) ||
43
+ (/^\.env(?:$|\.)/.test(name) &&
44
+ !(index === parts.length - 1 && referenceExamples.has(path))) ||
45
+ /\.(?:pem|key|p12|pfx)$/.test(name)) &&
46
+ !path.startsWith('.playcraft/skills/') &&
47
+ !path.startsWith('.playcraft/skill-selections/'));
21
48
  }
22
49
  /** Git keeps only the executable bit, so the digest must not depend on umask or group-write bits. */
23
50
  function snapshotMode(mode) {
@@ -30,7 +57,13 @@ function inside(root, path) {
30
57
  export function digestFiles(files) {
31
58
  const hash = createHash('sha256');
32
59
  for (const [path, file] of files)
33
- hash.update(path).update('\0').update(String(file.mode)).update('\0').update(file.body).update('\0');
60
+ hash
61
+ .update(path)
62
+ .update('\0')
63
+ .update(String(file.mode))
64
+ .update('\0')
65
+ .update(file.body)
66
+ .update('\0');
34
67
  return hash.digest('hex');
35
68
  }
36
69
  /** Files Git will accept. Credentials, caches and node_modules are left out. */
@@ -38,13 +71,15 @@ export async function readSourceFiles(root) {
38
71
  const source = await realpath(root);
39
72
  const referenceExamples = await batchReferenceEnvExamples(source);
40
73
  const referenceRecords = await batchReferenceMetadata(source);
41
- const paths = new Set((await gitRead(source, ['ls-files', '--cached', '--others', '--exclude-standard', '-z'])).split('\0').filter(Boolean));
74
+ const paths = new Set((await gitRead(source, ['ls-files', '--cached', '--others', '--exclude-standard', '-z']))
75
+ .split('\0')
76
+ .filter(Boolean));
42
77
  const files = new Map();
43
78
  for (const path of [...paths].sort()) {
44
79
  if (!included(path, referenceExamples))
45
80
  continue;
46
81
  const full = join(source, path);
47
- const stat = await lstat(full).catch(error => {
82
+ const stat = await lstat(full).catch((error) => {
48
83
  if (error.code === 'ENOENT')
49
84
  return undefined;
50
85
  throw error;
@@ -73,8 +108,10 @@ export async function readSourceFiles(root) {
73
108
  // A damaged reference may make an otherwise allowed example fail verification. That
74
109
  // is a reference-integrity error, not evidence that a protected path was withheld.
75
110
  const isExample = /^\.env\.(?:example|sample|template)$/i.test(path.split('/').pop() ?? '');
76
- if (paths.has(path) && !included(path, referenceExamples) &&
77
- (!isExample || !included(path, new Set([path]))) && (await lstat(join(source, path)).catch(() => undefined))?.isFile()) {
111
+ if (paths.has(path) &&
112
+ !included(path, referenceExamples) &&
113
+ (!isExample || !included(path, new Set([path]))) &&
114
+ (await lstat(join(source, path)).catch(() => undefined))?.isFile()) {
78
115
  withheld.push(record.referenceId);
79
116
  break;
80
117
  }
@@ -83,10 +120,15 @@ export async function readSourceFiles(root) {
83
120
  delete manifest.batchPolicyWithheldReferenceIds;
84
121
  if (withheld.length)
85
122
  manifest.batchPolicyWithheldReferenceIds = withheld;
86
- files.set(WORKSPACE_REMIX_REFERENCES_MANIFEST, { ...manifestFile, body: Buffer.from(JSON.stringify(manifest)) });
123
+ files.set(WORKSPACE_REMIX_REFERENCES_MANIFEST, {
124
+ ...manifestFile,
125
+ body: Buffer.from(JSON.stringify(manifest)),
126
+ });
87
127
  }
88
128
  }
89
- catch { /* Preserve malformed legacy metadata without letting it block an unrelated batch. */ }
129
+ catch {
130
+ /* Preserve malformed legacy metadata without letting it block an unrelated batch. */
131
+ }
90
132
  }
91
133
  return files;
92
134
  }
@@ -102,8 +144,121 @@ export async function filterUnstableFiles(root, files) {
102
144
  }
103
145
  return files;
104
146
  }
147
+ const scaffoldFiles = new Set([
148
+ 'AGENTS.md',
149
+ '.gitignore',
150
+ '.playcraft/skills.json',
151
+ '.playcraft/skills.lock.json',
152
+ '.playcraft/workspace-context.json',
153
+ '.playcraft/workspace.json',
154
+ WORKSPACE_REMIX_REFERENCES_MANIFEST,
155
+ ]);
156
+ const scaffoldDirectories = [
157
+ '.agents/skills/',
158
+ '.playcraft/skills/',
159
+ '.playcraft/skill-artifacts/',
160
+ '.playcraft/skill-migrations/',
161
+ '.playcraft/skill-selections/',
162
+ 'references/remixes/',
163
+ ];
164
+ const ignoredDirectories = [
165
+ 'node_modules/',
166
+ 'dist/',
167
+ 'dist-all/',
168
+ 'coverage/',
169
+ '.playcraft-preview-out/',
170
+ '.playcraft-temp/',
171
+ '.tmp/',
172
+ '.temp/',
173
+ '.git/',
174
+ '.vscode/',
175
+ '.idea/',
176
+ ];
177
+ function scaffoldPath(path) {
178
+ return scaffoldFiles.has(path) || scaffoldDirectories.some((prefix) => path.startsWith(prefix));
179
+ }
180
+ async function inspectSource(root, files) {
181
+ // Inspect ignored paths too: a user's .gitignore may otherwise hide a real project.
182
+ const ignored = (await gitRead(root, [
183
+ 'ls-files',
184
+ '--others',
185
+ '--ignored',
186
+ '--exclude-standard',
187
+ '--directory',
188
+ '-z',
189
+ ]))
190
+ .split('\0')
191
+ .filter(Boolean);
192
+ const safeIgnored = ignored.every((path) => scaffoldPath(path) ||
193
+ ignoredDirectories.some((prefix) => path === prefix || path.startsWith(prefix)) ||
194
+ !included(path, new Set()) ||
195
+ /(?:^|\/)(?:\.DS_Store|Thumbs\.db)$/.test(path) ||
196
+ /\.(?:log|tmp)$/.test(path));
197
+ return {
198
+ empty: [...files.keys()].every(scaffoldPath),
199
+ safeIgnored,
200
+ ignoredDigest: createHash('sha256').update(JSON.stringify(ignored.sort())).digest('hex'),
201
+ };
202
+ }
203
+ /** Merge only the trusted engine files. Workspace instructions, skills, references and configuration remain authoritative. */
204
+ export function mergeStarterTemplate(files, template) {
205
+ if ('error' in template)
206
+ throw new Error(template.error);
207
+ const result = workspaceStarterTemplateSnapshotSchema.safeParse(template.snapshot);
208
+ if (!result.success)
209
+ throw new Error('starter_template_invalid');
210
+ const snapshot = result.data;
211
+ if (createHash('sha256').update(remixReferenceContentHashInput(snapshot.files)).digest('hex') !==
212
+ snapshot.contentHash)
213
+ throw new Error('starter_template_invalid');
214
+ if (createHash('sha256').update(starterTemplateTreeHashInput(snapshot.files)).digest('hex') !==
215
+ template.treeHash)
216
+ throw new Error('starter_template_invalid');
217
+ const sourcePaths = new Set([...files.keys()].map((path) => path.toLowerCase()));
218
+ const added = [];
219
+ for (const file of snapshot.files) {
220
+ const { path } = file;
221
+ const mode = file.mode;
222
+ if (mode !== '100644' && mode !== '100755')
223
+ throw new Error('starter_template_invalid');
224
+ const body = Buffer.from(file.content, 'base64');
225
+ if (body.toString('base64') !== file.content ||
226
+ body.length !== file.size ||
227
+ createHash('sha256').update(body).digest('hex') !== file.sha256 ||
228
+ body
229
+ .subarray(0, 128)
230
+ .toString('utf8')
231
+ .startsWith('version https://git-lfs.github.com/spec/v1'))
232
+ throw new Error('starter_template_invalid');
233
+ const parts = path.split('/');
234
+ if (path === 'AGENTS.md' ||
235
+ parts[0] === '.claude' ||
236
+ parts[0] === '.agents' ||
237
+ parts[0] === '.playcraft' ||
238
+ parts[0] === 'references' ||
239
+ !included(path, new Set()))
240
+ continue;
241
+ if (path === '.gitignore' && files.has(path)) {
242
+ files.set(path, {
243
+ body: Buffer.concat([files.get(path).body, Buffer.from('\n'), body]),
244
+ mode: 0o644,
245
+ });
246
+ continue;
247
+ }
248
+ if (sourcePaths.has(path.toLowerCase()) ||
249
+ [...sourcePaths].some((existing) => existing.startsWith(`${path.toLowerCase()}/`) ||
250
+ path.toLowerCase().startsWith(`${existing}/`)))
251
+ throw new Error('starter_template_path_conflict');
252
+ files.set(path, { body, mode: mode === '100755' ? 0o755 : 0o644 });
253
+ sourcePaths.add(path.toLowerCase());
254
+ added.push(path);
255
+ }
256
+ if (!added.length)
257
+ throw new Error('starter_template_invalid');
258
+ return added;
259
+ }
105
260
  /** Independent root commit. The source branch, index and config are never written. */
106
- export async function captureIndependentSource(source, destination) {
261
+ export async function captureIndependentSource(source, destination, starterTemplate, identity) {
107
262
  const root = await realpath(source);
108
263
  if (await lstat(destination).catch(() => undefined))
109
264
  throw new Error('batch_source_destination_exists');
@@ -111,6 +266,56 @@ export async function captureIndependentSource(source, destination) {
111
266
  let files = await readSourceFiles(root);
112
267
  files = await filterUnstableFiles(root, files);
113
268
  const contentDigest = digestFiles(files);
269
+ const inspection = starterTemplate ? await inspectSource(root, files) : undefined;
270
+ const probePath = `${destination}.template-probe`;
271
+ const probe = identity && inspection
272
+ ? JSON.stringify({
273
+ version: 1,
274
+ identity,
275
+ root,
276
+ head: headBefore,
277
+ contentDigest,
278
+ ignoredDigest: inspection.ignoredDigest,
279
+ })
280
+ : undefined;
281
+ const existingProbe = await readFile(probePath, 'utf8').catch((error) => {
282
+ if (error.code === 'ENOENT')
283
+ return undefined;
284
+ throw error;
285
+ });
286
+ if (identity && starterTemplate && 'snapshot' in starterTemplate && !existingProbe)
287
+ throw new Error('batch_source_candidate_lost');
288
+ if (existingProbe && (!probe || existingProbe !== probe))
289
+ throw new Error('batch_source_changed');
290
+ if (inspection && !inspection.safeIgnored)
291
+ throw new Error('batch_source_ignored_files');
292
+ if (starterTemplate && inspection?.empty && 'error' in starterTemplate && probe) {
293
+ if (headBefore !== (await gitRead(root, ['rev-parse', '--verify', 'HEAD']).catch(() => '')) ||
294
+ digestFiles(await filterUnstableFiles(root, await readSourceFiles(root))) !== contentDigest ||
295
+ (await inspectSource(root, files)).ignoredDigest !== inspection.ignoredDigest)
296
+ throw new Error('batch_source_changed');
297
+ if (!existingProbe) {
298
+ // private
299
+ await mkdir(dirname(probePath), { recursive: true, mode: 0o700 });
300
+ // private
301
+ const handle = await open(probePath, 'wx', 0o600);
302
+ try {
303
+ await handle.writeFile(probe);
304
+ await handle.sync();
305
+ }
306
+ finally {
307
+ await handle.close();
308
+ }
309
+ const directory = await open(dirname(probePath), 'r');
310
+ try {
311
+ await directory.sync();
312
+ }
313
+ finally {
314
+ await directory.close();
315
+ }
316
+ }
317
+ }
318
+ const templatePaths = starterTemplate && inspection?.empty ? mergeStarterTemplate(files, starterTemplate) : [];
114
319
  const staging = join(dirname(destination), `.batch-source-${randomUUID()}`);
115
320
  try {
116
321
  await mkdir(staging, { recursive: true });
@@ -122,16 +327,35 @@ export async function captureIndependentSource(source, destination) {
122
327
  await mkdir(dirname(full), { recursive: true });
123
328
  await writeFile(full, file.body, { mode: file.mode, flag: 'wx' });
124
329
  }
125
- if (headBefore !== await gitRead(root, ['rev-parse', '--verify', 'HEAD']).catch(() => ''))
330
+ if (headBefore !== (await gitRead(root, ['rev-parse', '--verify', 'HEAD']).catch(() => '')))
126
331
  throw new Error('batch_source_changed');
127
332
  const after = await filterUnstableFiles(root, await readSourceFiles(root));
128
333
  if (digestFiles(after) !== contentDigest)
129
334
  throw new Error('batch_source_changed');
335
+ if (inspection) {
336
+ const last = await inspectSource(root, after);
337
+ if (last.empty !== inspection.empty || last.ignoredDigest !== inspection.ignoredDigest)
338
+ throw new Error('batch_source_changed');
339
+ }
130
340
  await excludeFromGit(staging);
131
341
  await excludeFromGit(staging, '/.env*');
132
- for (const entry of ['/.playcraft.json', '/playcraft.config.json', '/.playcraft/skills/', '/.playcraft-temp/'])
342
+ for (const entry of [
343
+ '/.playcraft.json',
344
+ '/playcraft.config.json',
345
+ '/.playcraft/skills/',
346
+ '/.playcraft-temp/',
347
+ ])
133
348
  await excludeFromGit(staging, entry);
134
349
  await gitWrite(staging, ['add', '-A', '.']);
350
+ // The template owns its source files even when its ignore rules exclude them.
351
+ for (let i = 0; i < templatePaths.length; i += 100)
352
+ await gitWrite(staging, [
353
+ '--literal-pathspecs',
354
+ 'add',
355
+ '--force',
356
+ '--',
357
+ ...templatePaths.slice(i, i + 100),
358
+ ]);
135
359
  // Imported reference files can be tracked in the source despite .gitignore. Preserve only
136
360
  // their filtered staging paths, without changing ignore handling for other source files.
137
361
  for (const path of ['.playcraft/remix-references.json', 'references/remixes']) {
@@ -151,7 +375,11 @@ export async function archiveSource(root, meta) {
151
375
  const bundle = join(dirname(root), `.bundle-${randomUUID()}`);
152
376
  try {
153
377
  await gitWrite(root, ['bundle', 'create', bundle, 'HEAD']);
154
- const body = JSON.stringify({ version: 1, ...meta, bundle: (await readFile(bundle)).toString('base64') });
378
+ const body = JSON.stringify({
379
+ version: 1,
380
+ ...meta,
381
+ bundle: (await readFile(bundle)).toString('base64'),
382
+ });
155
383
  return await compress(Buffer.from(body));
156
384
  }
157
385
  finally {
@@ -160,7 +388,10 @@ export async function archiveSource(root, meta) {
160
388
  }
161
389
  export async function restoreArchive(body, destination, expectedDigest) {
162
390
  const archive = JSON.parse((await decompress(body, { maxOutputLength: 512 * 1024 * 1024 })).toString());
163
- if (archive.version !== 1 || archive.digest !== expectedDigest || !archive.commitHash || !archive.bundle)
391
+ if (archive.version !== 1 ||
392
+ archive.digest !== expectedDigest ||
393
+ !archive.commitHash ||
394
+ !archive.bundle)
164
395
  throw new Error('batch_snapshot_mismatch');
165
396
  const temporary = join(dirname(destination), `.bundle-${randomUUID()}`);
166
397
  await mkdir(temporary, { recursive: true });
@@ -31,6 +31,9 @@ function withModelShell(config, modelConfigId) {
31
31
  return { ...config, 'shell_environment_policy.set': { ...existing, PLAYCRAFT_MODEL_CONFIG_ID: modelConfigId } };
32
32
  }
33
33
  const ENGINEERING_BATCH_INSTRUCTIONS = [
34
+ 'For a single replica, work directly in the current repository and preserve the user-provided references. Do not create a batch for a single replica.',
35
+ 'If the repository is empty, initialize it using the default starter template before implementing the replica; preserve existing workspace metadata and files. If the template is unavailable, report the failure instead of substituting another template.',
36
+ 'A repository is empty only when it has no user project files, including ignored files; workspace metadata, skills, references and attachments do not count as a project. For an empty repository, run playcraft references prepare-starter-template --json and merge the returned staged template into the current repository. Preserve existing files and workspace configuration, merge .gitignore without replacing it, preserve executable permissions, and report conflicts or preparation failures instead of overwriting files or creating a batch. Once finished, remove only the exact staging directory returned by the command.',
34
37
  'When the user requests multiple independent engineering variants, create a real platform batch using playcraft tools create-batch --prompt <the complete request, including constraints established in this conversation> --workspace-id <the trusted batch request workspaceId> --source-directory <the trusted batch request sourceDirectoryName> --client-request-id <the trusted batch request clientRequestId> --json.',
35
38
  'Distinguish independent variants from multiple assets, levels or themes within one engineering project. Use the current session directory supplied by the runtime as the batch source; do not infer it from a preview or another conversation.',
36
39
  'The platform freezes the current source and generates each planned variant in its own Remix directory. Submission authorizes automatic generation without another prompt approval. Keep the source unchanged for this batch; do not replace independent variants with theme switching in the source.',
@@ -314,6 +317,7 @@ export function createCodexAppServerAdapter(options) {
314
317
  const developerInstructions = [
315
318
  'You coordinate PlayCraft batches. The workspace engineering directories are read-only in this conversation.',
316
319
  `Workspace identity: ${scope}. Engineering source root: ${JSON.stringify(cwd)}. Default source directory: basic.`,
320
+ 'For a single replica, direct the user to the engineering conversation for the intended repository; do not create a batch or edit an engineering directory from this coordinator.',
317
321
  'For a request to create multiple independent variants, create a real platform batch using playcraft tools create-batch --prompt <the complete user request> --workspace-id <workspace identity> --source-directory <basic or the explicitly selected remix directory> --client-request-id <the current batch request context identity> --json.',
318
322
  'The platform plans differences, freezes the selected source and generates independent Remix engineering directories. It automatically starts generation; no browser approval is required. Preserve the source files and source Git history.',
319
323
  'Do not implement a batch as theme switching inside one engineering directory. Do not generate or repair engineering in this coordinator conversation; the platform production tasks own that work.',
@@ -348,7 +352,7 @@ export function createCodexAppServerAdapter(options) {
348
352
  'skills.include_instructions': false,
349
353
  'skills.config': listing.skills.map(skill => ({ path: skill.path, enabled: false })) };
350
354
  const developerInstructions = [
351
- `Available workspace skills are fixed by lock ${frozen.declarationHash}.`,
355
+ `Available workspace skills are fixed by lock ${frozen.lockHash}.`,
352
356
  '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
357
  ...frozen.skills.map(skill => JSON.stringify(skill)),
354
358
  ...(frozen.skills.length ? [] : ['(No workspace skills are enabled.)']),
@@ -414,6 +418,27 @@ export function createCodexAppServerAdapter(options) {
414
418
  throw new NativePreparationError(unavailable);
415
419
  }
416
420
  }
421
+ async function injectPreparedSkillContext(threadId, prepared) {
422
+ const instructions = asRecord(prepared.params).developerInstructions;
423
+ if (typeof instructions !== 'string')
424
+ return;
425
+ if (threads.get(threadId)?.turnActive || [...approvals.values()].some(pending => owningThreadId(pending.threadId) === threadId))
426
+ throw new NativePreparationError('skills_reload_pending');
427
+ // Resume configuration does not replace developer messages already in native history.
428
+ // Keep built-in collaboration policies and update only the platform-owned workspace context.
429
+ try {
430
+ await rpc.request('thread/inject_items', { threadId, items: [{ type: 'message', role: 'developer', content: [{
431
+ type: 'input_text', text: [
432
+ ...(prepared.skillsHash ? [`Current platform workspace skill lock: ${prepared.skillsHash}. This replaces earlier platform workspace skill lock and allowlist declarations.`] : []),
433
+ 'This replaces earlier platform batch routing instructions and platform starter-template instructions. Keep all other instructions and permissions in force.',
434
+ instructions,
435
+ ].join('\n\n'),
436
+ }] }] }, 30_000);
437
+ }
438
+ catch {
439
+ throw new NativePreparationError('workspace_skills_unavailable');
440
+ }
441
+ }
417
442
  async function resumePreparedThread(threadId, thread, prepared) {
418
443
  const resumed = await rpc.request('thread/resume', {
419
444
  threadId, ...prepared.params,
@@ -421,6 +446,7 @@ export function createCodexAppServerAdapter(options) {
421
446
  }, 30_000);
422
447
  if (resumed.thread?.id !== threadId)
423
448
  throw new NativePreparationError('workspace_skills_unavailable');
449
+ await injectPreparedSkillContext(threadId, prepared);
424
450
  thread.loaded = true;
425
451
  thread.readOnlyConfirmed = isReadOnlySnapshot(resumed);
426
452
  thread.skillsHash = prepared.skillsHash;
@@ -938,6 +964,7 @@ export function createCodexAppServerAdapter(options) {
938
964
  }
939
965
  if (resumedId !== input.threadId)
940
966
  return undefined;
967
+ await injectPreparedSkillContext(input.threadId, prepared);
941
968
  prepared.activate();
942
969
  threads.set(input.threadId, { sessionId: input.sessionId, cwd: input.cwd, skillsHash: prepared.skillsHash,
943
970
  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 +1051,8 @@ export function createCodexAppServerAdapter(options) {
1024
1051
  thread.turnActive = true;
1025
1052
  thread.scheduledTaskProposalBridge = input.scheduledTaskProposalBridge;
1026
1053
  thread.uiSnapshot = options.workspaceUi && input.uiSnapshot?.sessionId === input.sessionId ? input.uiSnapshot : undefined;
1054
+ if (thread.cwd)
1055
+ options.modelBroker?.markCodexDispatched?.(thread.cwd, input.sessionId, input.clientRequestId);
1027
1056
  const started = await rpc.sendReserved(requestId, 'turn/start', { threadId: input.threadId, input: [
1028
1057
  { type: 'text', text: input.text },
1029
1058
  ...(input.images ?? []).map((path) => ({ type: 'localImage', path })),