@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
@@ -0,0 +1,500 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, mkdir, open, readdir, realpath, rename, rm, writeFile } from 'node:fs/promises';
4
+ import { basename, dirname, join, resolve, sep } from 'node:path';
5
+ import { canonicalProjectSkillsJson, createCanonicalBundleArchive, declarationDigest, dependencyDigest, effectiveProjectSkills, extractBundleArchive, parseRemoteSkillSelectionResult, projectSkillsDigest, sourceTreeDigest, validateProjectSkillsSnapshot, } from '@playcraft/common/project-skills';
6
+ import { ensureProjectSkillDirectory, readLockedProjectSkillCatalog, readLockedProjectSkillFiles, } from './discovery.js';
7
+ import { writeSharedFile } from '../workspace-runtime/workspaces/shared-write.js';
8
+ const LIMIT = 16 * 1024 * 1024;
9
+ const PROCESS_STARTED_AT = Math.round(Date.now() - process.uptime() * 1000);
10
+ const inside = (root, path) => path === root || path.startsWith(`${root}${sep}`);
11
+ const paths = (root) => ({ declaration: join(root, '.playcraft/skills.json'), lock: join(root, '.playcraft/skills.lock.json') });
12
+ const serialized = (snapshot) => ({ declaration: `${canonicalProjectSkillsJson(snapshot.declaration)}\n`, lock: `${canonicalProjectSkillsJson(snapshot.lock)}\n` });
13
+ const code = (value) => { throw new Error(value); };
14
+ async function safeBytes(path, maximum = LIMIT) {
15
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
16
+ try {
17
+ const stat = await handle.stat();
18
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > maximum)
19
+ code('skill_activation_invalid_file');
20
+ return await handle.readFile();
21
+ }
22
+ finally {
23
+ await handle.close();
24
+ }
25
+ }
26
+ async function currentBytes(root) {
27
+ await ensureProjectSkillDirectory(root, '.playcraft', true);
28
+ const target = paths(root);
29
+ return { declaration: (await safeBytes(target.declaration, 1024 * 1024)).toString('utf8'),
30
+ lock: (await safeBytes(target.lock, 1024 * 1024)).toString('utf8') };
31
+ }
32
+ async function privateDirectory(path, root) {
33
+ const canonicalParent = async (value) => {
34
+ try {
35
+ return await realpath(value);
36
+ }
37
+ catch (error) {
38
+ if (error.code !== 'ENOENT' || dirname(value) === value)
39
+ throw error;
40
+ return join(await canonicalParent(dirname(value)), basename(value));
41
+ }
42
+ };
43
+ const supplied = resolve(path), target = join(await canonicalParent(dirname(supplied)), basename(supplied));
44
+ if (inside(root, target))
45
+ code('skill_activation_state_not_private');
46
+ let current = dirname(target);
47
+ while (true) {
48
+ const stat = await lstat(current).catch((error) => { if (error.code === 'ENOENT')
49
+ return undefined; throw error; });
50
+ if (stat && (!stat.isDirectory() || stat.isSymbolicLink()))
51
+ code('skill_activation_invalid_state');
52
+ if (dirname(current) === current)
53
+ break;
54
+ current = dirname(current);
55
+ }
56
+ await mkdir(target, { recursive: true, mode: 0o700 });
57
+ const stat = await lstat(target);
58
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0)
59
+ code('skill_activation_state_not_private');
60
+ return realpath(target);
61
+ }
62
+ async function writeTransaction(path, transaction) {
63
+ const bytes = canonicalProjectSkillsJson(transaction);
64
+ if (Buffer.byteLength(bytes) > LIMIT)
65
+ code('skill_activation_state_limit');
66
+ const temporary = `${path}.${randomUUID()}.tmp`;
67
+ try {
68
+ await writeFile(temporary, bytes, { flag: 'wx', mode: 0o600 });
69
+ await rename(temporary, path);
70
+ }
71
+ finally {
72
+ await rm(temporary, { force: true });
73
+ }
74
+ }
75
+ function selectionIdentity(selection) {
76
+ return projectSkillsDigest({ declaration: selection.declaration, lock: selection.lock,
77
+ skills: selection.skills.map(({ skillRef, skillVersionId, version, artifactHash }) => ({ skillRef, skillVersionId, version, artifactHash })).sort((a, b) => a.skillRef.localeCompare(b.skillRef)) });
78
+ }
79
+ function storedTransaction(value, root, transactionId) {
80
+ const record = value;
81
+ if (!record || record.version !== 1 || record.transactionId !== transactionId || record.root !== root
82
+ || [record.selectionId, record.selectionHash, record.expectedLockHash, record.expectedDeclarationHash]
83
+ .some(hash => typeof hash !== 'string' || !/^[a-f0-9]{64}$/.test(hash))
84
+ || !['prepared', 'applied'].includes(record.status) || typeof record.before?.declaration !== 'string'
85
+ || typeof record.before?.lock !== 'string' || !Array.isArray(record.selectedKeys))
86
+ code('skill_activation_transaction_conflict');
87
+ const before = validateProjectSkillsSnapshot(record.before.declaration, record.before.lock);
88
+ if (projectSkillsDigest(before.lock) !== record.expectedLockHash || before.lock.declarationHash !== record.expectedDeclarationHash)
89
+ code('skill_activation_transaction_conflict');
90
+ record.after = validateProjectSkillsSnapshot(record.after?.declaration, record.after?.lock);
91
+ if (!record.selectedKeys.length || new Set(record.selectedKeys).size !== record.selectedKeys.length
92
+ || record.selectedKeys.some(key => !record.after.lock.entries.some(entry => entry.entryKey === key)))
93
+ code('skill_activation_transaction_conflict');
94
+ return record;
95
+ }
96
+ function validateTransaction(value, root, selection, options) {
97
+ const record = storedTransaction(value, root, options.transactionId);
98
+ if (record.selectionId !== selection.selectionId || record.selectionHash !== selectionIdentity(selection)
99
+ || record.expectedLockHash !== options.expectedLockHash || record.expectedDeclarationHash !== options.expectedDeclarationHash
100
+ || record.selectedKeys.length !== selection.lock.entries.length)
101
+ code('skill_activation_transaction_conflict');
102
+ return record;
103
+ }
104
+ async function readSelection(root, selection) {
105
+ const relativePath = `.playcraft/skill-selections/${selection.selectionId}`;
106
+ const directory = join(root, relativePath);
107
+ let current = root;
108
+ for (const segment of relativePath.split('/')) {
109
+ current = join(current, segment);
110
+ const stat = await lstat(current);
111
+ if (!stat.isDirectory() || stat.isSymbolicLink())
112
+ code('skill_activation_invalid_selection');
113
+ }
114
+ const saved = parseRemoteSkillSelectionResult(JSON.parse((await safeBytes(join(directory, 'selection.json'))).toString('utf8')));
115
+ if (selectionIdentity(saved) !== selectionIdentity(selection))
116
+ code('skill_activation_selection_conflict');
117
+ const catalog = await readLockedProjectSkillCatalog(directory, join(directory, '.playcraft/skill-artifacts'));
118
+ if (catalog.lockHash !== selection.lockHash || catalog.skills.length !== selection.lock.entries.length)
119
+ code('skill_activation_selection_conflict');
120
+ const archives = new Map();
121
+ for (const entry of selection.lock.entries) {
122
+ const names = [`${entry.revision.artifactHash}.tar.gz`, ...(entry.revision.contentHash ? [`content-${entry.revision.contentHash}.tar.gz`] : [])];
123
+ let archive;
124
+ for (const name of names) {
125
+ try {
126
+ archive = await safeBytes(join(directory, '.playcraft/skill-artifacts', name), 80 * 1024 * 1024);
127
+ break;
128
+ }
129
+ catch (error) {
130
+ if (error.code !== 'ENOENT')
131
+ throw error;
132
+ }
133
+ }
134
+ if (!archive || createHash('sha256').update(archive).digest('hex') !== entry.revision.artifactHash)
135
+ code('skill_activation_artifact_mismatch');
136
+ archives.set(entry.entryKey, archive);
137
+ }
138
+ return archives;
139
+ }
140
+ function contentHash(archive) {
141
+ const extracted = extractBundleArchive(archive);
142
+ if (extracted.issues.length)
143
+ code('skill_activation_artifact_mismatch');
144
+ return sourceTreeDigest(extracted.files.map(file => ({ path: file.path, content: file.data })));
145
+ }
146
+ function merge(before, selection, archives) {
147
+ const after = structuredClone(before);
148
+ const byRef = new Map(after.lock.entries.map(entry => [entry.revision.skillRef, entry]));
149
+ if (byRef.size !== after.lock.entries.length)
150
+ code('skill_activation_revision_conflict');
151
+ const keys = new Set(after.lock.entries.map(entry => entry.entryKey));
152
+ const mapping = new Map();
153
+ for (const selected of selection.lock.entries) {
154
+ const existing = byRef.get(selected.revision.skillRef);
155
+ if (existing) {
156
+ if (existing.revision.kind !== 'release' || selected.revision.kind !== 'release'
157
+ || existing.revision.version !== selected.revision.version || existing.revision.artifactHash !== selected.revision.artifactHash
158
+ || before.lock.lockVersion === 4 && existing.revision.contentHash !== contentHash(archives.get(selected.entryKey)))
159
+ code('skill_activation_revision_conflict');
160
+ const declaration = after.declaration.entries.find(entry => entry.entryKey === existing.entryKey);
161
+ if (declaration.surface !== 'sandbox-agent')
162
+ code('skill_activation_surface_conflict');
163
+ declaration.enabled = true;
164
+ mapping.set(selected.entryKey, existing.entryKey);
165
+ }
166
+ else {
167
+ let key = selected.entryKey;
168
+ if (keys.has(key))
169
+ key = `remote-${createHash('sha256').update(selected.revision.skillRef).digest('hex').slice(0, 32)}`;
170
+ if (keys.has(key))
171
+ code('skill_activation_entry_conflict');
172
+ keys.add(key);
173
+ mapping.set(selected.entryKey, key);
174
+ const revision = structuredClone(selected.revision);
175
+ if (after.lock.lockVersion === 4)
176
+ after.lock.entries.push({ ...structuredClone(selected), entryKey: key,
177
+ revision: { ...revision, contentHash: contentHash(archives.get(selected.entryKey)) } });
178
+ else {
179
+ delete revision.contentHash;
180
+ after.lock.entries.push({ ...structuredClone(selected), entryKey: key, revision });
181
+ }
182
+ if (revision.kind !== 'release')
183
+ code('skill_activation_revision_conflict');
184
+ after.declaration.entries.push({ entryKey: key, source: { kind: 'registry', skillRef: revision.skillRef,
185
+ version: 'version' in revision ? revision.version : code('skill_activation_revision_conflict') }, enabled: true, surface: 'sandbox-agent' });
186
+ }
187
+ }
188
+ for (const selected of selection.lock.entries) {
189
+ const locked = after.lock.entries.find(entry => entry.entryKey === mapping.get(selected.entryKey));
190
+ const mapped = selected.dependencies.map(key => mapping.get(key));
191
+ // Existing exact entries retain prior hard requirements as well as the newly verified closure.
192
+ const previous = before.lock.entries.find(entry => entry.entryKey === locked.entryKey);
193
+ locked.dependencies = [...new Set([...(previous?.dependencies ?? []), ...mapped])].sort();
194
+ }
195
+ for (const entry of after.lock.entries)
196
+ entry.dependencyLockHash = dependencyDigest(after.lock.entries, entry.dependencies);
197
+ after.lock.declarationHash = declarationDigest(after.declaration);
198
+ return { after: validateProjectSkillsSnapshot(after.declaration, after.lock), selectedKeys: [...mapping.values()], mapping };
199
+ }
200
+ async function stageGeneration(root, transactionPath, transaction, selection, selectedArchives) {
201
+ const before = validateProjectSkillsSnapshot(transaction.before.declaration, transaction.before.lock);
202
+ const stage = `${transactionPath}.generation`;
203
+ await mkdir(stage, { recursive: true, mode: 0o700 });
204
+ if ((await lstat(stage)).isSymbolicLink())
205
+ code('skill_activation_invalid_state');
206
+ const cache = await ensureProjectSkillDirectory(stage, '.playcraft/skill-artifacts');
207
+ for (const [name, value] of [['skills.json', transaction.after.declaration], ['skills.lock.json', transaction.after.lock]])
208
+ await writeSharedFile(stage, join(stage, '.playcraft', name), `${canonicalProjectSkillsJson(value)}\n`);
209
+ const selectedByRef = new Map(selection.lock.entries.map(entry => [entry.revision.skillRef, entry]));
210
+ const mainCache = await ensureProjectSkillDirectory(root, '.playcraft/skill-artifacts');
211
+ for (const entry of effectiveProjectSkills(transaction.after.declaration, transaction.after.lock, 'sandbox-agent')) {
212
+ const selected = selectedByRef.get(entry.revision.skillRef);
213
+ let archive;
214
+ if (selected)
215
+ archive = selectedArchives.get(selected.entryKey);
216
+ else {
217
+ const original = before.lock.entries.find(item => item.entryKey === entry.entryKey);
218
+ const files = await readLockedProjectSkillFiles(root, mainCache, original, before, undefined, true);
219
+ try {
220
+ archive = await safeBytes(join(mainCache, `${entry.revision.artifactHash}.tar.gz`), 80 * 1024 * 1024);
221
+ }
222
+ catch (error) {
223
+ if (error.code !== 'ENOENT')
224
+ throw error;
225
+ archive = createCanonicalBundleArchive(files);
226
+ }
227
+ }
228
+ const exact = createHash('sha256').update(archive).digest('hex') === entry.revision.artifactHash;
229
+ if (!exact && (!entry.revision.contentHash || contentHash(archive) !== entry.revision.contentHash))
230
+ code('skill_activation_artifact_mismatch');
231
+ const name = exact ? `${entry.revision.artifactHash}.tar.gz` : `content-${entry.revision.contentHash}.tar.gz`;
232
+ await writeSharedFile(stage, join(cache, name), archive);
233
+ if (selected)
234
+ await writeSharedFile(root, join(mainCache, name), archive);
235
+ }
236
+ const catalog = await readLockedProjectSkillCatalog(stage, cache);
237
+ const lockHash = projectSkillsDigest(transaction.after.lock);
238
+ if (catalog.lockHash !== lockHash)
239
+ code('skill_activation_generation_conflict');
240
+ const destination = await ensureProjectSkillDirectory(root, '.playcraft/skills');
241
+ const generation = join(destination, lockHash), frozen = join(stage, '.playcraft/skills', lockHash);
242
+ const check = async (source, target, relativePath = '') => {
243
+ await ensureProjectSkillDirectory(root, `.playcraft/skills/${lockHash}${relativePath ? `/${relativePath}` : ''}`);
244
+ const expected = await readdir(source, { withFileTypes: true });
245
+ for (const file of expected) {
246
+ const next = relativePath ? `${relativePath}/${file.name}` : file.name;
247
+ if (file.isDirectory())
248
+ await check(join(source, file.name), join(target, file.name), next);
249
+ else {
250
+ if (!file.isFile())
251
+ code('skill_activation_generation_conflict');
252
+ const bytes = await safeBytes(join(source, file.name)), path = join(target, file.name);
253
+ await writeFile(path, bytes, { flag: 'wx', mode: 0o444 }).catch((error) => { if (error.code !== 'EEXIST')
254
+ throw error; });
255
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
256
+ try {
257
+ const stat = await handle.stat();
258
+ if (!stat.isFile() || stat.nlink !== 1 || !bytes.equals(await handle.readFile()))
259
+ code('skill_activation_generation_conflict');
260
+ if ((stat.mode & 0o777) !== 0o444)
261
+ await handle.chmod(0o444);
262
+ }
263
+ finally {
264
+ await handle.close();
265
+ }
266
+ }
267
+ }
268
+ const actual = await readdir(target, { withFileTypes: true });
269
+ if (expected.length !== actual.length || expected.some(file => !actual.some(item => item.name === file.name)))
270
+ code('skill_activation_generation_conflict');
271
+ };
272
+ await check(frozen, generation);
273
+ }
274
+ function ownsBytes(current, transaction) {
275
+ const after = serialized(transaction.after);
276
+ return (current.declaration === transaction.before.declaration || current.declaration === after.declaration)
277
+ && (current.lock === transaction.before.lock || current.lock === after.lock);
278
+ }
279
+ async function takeWriter(path, transactionId, assertCurrent) {
280
+ const create = async () => {
281
+ const handle = await open(path, 'wx', 0o600);
282
+ try {
283
+ await handle.writeFile(JSON.stringify({ version: 1, transactionId, pid: process.pid, startedAt: PROCESS_STARTED_AT }));
284
+ }
285
+ catch (error) {
286
+ await handle.close();
287
+ await rm(path, { force: true });
288
+ throw error;
289
+ }
290
+ return handle;
291
+ };
292
+ try {
293
+ return await create();
294
+ }
295
+ catch (error) {
296
+ if (error.code !== 'EEXIST')
297
+ throw error;
298
+ let bytes, lease;
299
+ try {
300
+ bytes = await safeBytes(path, 1024);
301
+ lease = JSON.parse(bytes.toString('utf8'));
302
+ }
303
+ catch {
304
+ return code('skill_activation_busy');
305
+ }
306
+ if (lease.version !== 1 || lease.transactionId !== transactionId || !Number.isInteger(lease.pid) || !lease.startedAt)
307
+ code('skill_activation_busy');
308
+ let live = true;
309
+ if (lease.pid === process.pid)
310
+ live = lease.startedAt === PROCESS_STARTED_AT;
311
+ else
312
+ try {
313
+ process.kill(lease.pid, 0);
314
+ }
315
+ catch (failure) {
316
+ live = failure.code !== 'ESRCH';
317
+ }
318
+ if (live)
319
+ code('skill_activation_busy');
320
+ await assertCurrent?.();
321
+ if (!(await safeBytes(path, 1024)).equals(bytes))
322
+ code('skill_activation_busy');
323
+ await rm(path);
324
+ return create();
325
+ }
326
+ }
327
+ /** Startup repairs only owned partial writes; it never activates a selection or starts a native request. */
328
+ export async function recoverPreparedSkillActivationTransactions(directory, options) {
329
+ if (!await lstat(options.transactionDirectory).catch((error) => {
330
+ if (error.code === 'ENOENT')
331
+ return undefined;
332
+ throw error;
333
+ }) && !options.orphanTransactionIds?.length)
334
+ return [];
335
+ const root = await realpath(directory), privateRoot = await privateDirectory(options.transactionDirectory, root);
336
+ const files = (await readdir(privateRoot)).filter(name => /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,159}\.json$/.test(name)).sort();
337
+ if (files.length > 1024)
338
+ code('skill_activation_state_limit');
339
+ const results = [];
340
+ for (const file of files) {
341
+ const transactionPath = join(privateRoot, file), value = JSON.parse((await safeBytes(transactionPath)).toString('utf8'));
342
+ if (value.root !== root)
343
+ continue;
344
+ const transaction = storedTransaction(value, root, file.slice(0, -5)), after = serialized(transaction.after);
345
+ let current = await currentBytes(root);
346
+ let status;
347
+ const beforePair = () => current.declaration === transaction.before.declaration && current.lock === transaction.before.lock;
348
+ const afterPair = () => current.declaration === after.declaration && current.lock === after.lock;
349
+ if (!ownsBytes(current, transaction)) {
350
+ // A newer transaction may own this partial pair. Defer validation until every owner was considered.
351
+ status = 'preserved';
352
+ }
353
+ else {
354
+ status = afterPair() ? 'retained' : beforePair() ? 'unchanged' : 'rolled_back';
355
+ const writerPath = join(root, '.playcraft/skills-write.lock');
356
+ const lease = await safeBytes(writerPath, 1024).catch((error) => {
357
+ if (error.code === 'ENOENT')
358
+ return undefined;
359
+ throw error;
360
+ });
361
+ const ownsLease = lease && JSON.parse(lease.toString('utf8')).transactionId === transaction.transactionId;
362
+ // Retired transactions may share a root with a newer writer; only its own lease may be reclaimed.
363
+ if (status === 'rolled_back' || ownsLease) {
364
+ const writer = await takeWriter(writerPath, transaction.transactionId);
365
+ try {
366
+ current = await currentBytes(root);
367
+ if (!ownsBytes(current, transaction))
368
+ status = 'conflict';
369
+ else if (current.declaration === after.declaration && current.lock === after.lock)
370
+ status = 'retained';
371
+ else if (current.declaration === transaction.before.declaration && current.lock === transaction.before.lock)
372
+ status = 'unchanged';
373
+ else
374
+ for (const key of ['declaration', 'lock']) {
375
+ current = await currentBytes(root);
376
+ if (!ownsBytes(current, transaction)) {
377
+ status = 'conflict';
378
+ break;
379
+ }
380
+ if (current[key] !== transaction.before[key])
381
+ await writeSharedFile(root, paths(root)[key], transaction.before[key]);
382
+ }
383
+ }
384
+ finally {
385
+ await writer.close();
386
+ await rm(writerPath, { force: true });
387
+ }
388
+ }
389
+ }
390
+ transaction.recovery = { status, occurredAt: new Date().toISOString() };
391
+ await writeTransaction(transactionPath, transaction);
392
+ results.push({ transactionId: transaction.transactionId, status });
393
+ }
394
+ // An unowned partial pair requires explicit repair; never pass it to native preparation.
395
+ if (results.length || options.orphanTransactionIds?.length) {
396
+ const final = await currentBytes(root);
397
+ try {
398
+ validateProjectSkillsSnapshot(final.declaration, final.lock);
399
+ }
400
+ catch {
401
+ code('skill_activation_recovery_required');
402
+ }
403
+ }
404
+ const writerPath = join(root, '.playcraft/skills-write.lock');
405
+ const remainingLease = await safeBytes(writerPath, 1024).catch((error) => {
406
+ if (error.code === 'ENOENT')
407
+ return undefined;
408
+ throw error;
409
+ });
410
+ if (remainingLease && options.orphanTransactionIds) {
411
+ const lease = JSON.parse(remainingLease.toString('utf8'));
412
+ // Only the managed caller can prove an orphan belongs to an unsent request for this exact root and binding.
413
+ if (!lease.transactionId || !options.orphanTransactionIds.includes(lease.transactionId)
414
+ || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,159}$/.test(lease.transactionId))
415
+ code('skill_activation_busy');
416
+ const writer = await takeWriter(writerPath, lease.transactionId);
417
+ try {
418
+ results.push({ transactionId: lease.transactionId, status: 'unchanged' });
419
+ }
420
+ finally {
421
+ await writer.close();
422
+ await rm(writerPath, { force: true });
423
+ }
424
+ }
425
+ return results;
426
+ }
427
+ /** Called only by an idle managed activation queue while it owns the directory and execution fence. */
428
+ export async function activatePreparedSkillSelection(directory, input, options) {
429
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,159}$/.test(options.transactionId)
430
+ || !/^[a-f0-9]{64}$/.test(options.expectedLockHash) || !/^[a-f0-9]{64}$/.test(options.expectedDeclarationHash))
431
+ code('skill_activation_invalid_request');
432
+ const supplied = resolve(directory), stat = await lstat(supplied);
433
+ if (!stat.isDirectory() || stat.isSymbolicLink())
434
+ code('skill_activation_invalid_root');
435
+ const root = await realpath(supplied), selection = parseRemoteSkillSelectionResult(input);
436
+ const privateRoot = await privateDirectory(options.transactionDirectory, root);
437
+ const transactionPath = join(privateRoot, `${options.transactionId}.json`);
438
+ const selectedArchives = await readSelection(root, selection);
439
+ const writerPath = join(root, '.playcraft/skills-write.lock');
440
+ const writer = await takeWriter(writerPath, options.transactionId, options.assertCurrent);
441
+ try {
442
+ let transaction;
443
+ try {
444
+ transaction = validateTransaction(JSON.parse((await safeBytes(transactionPath)).toString('utf8')), root, selection, options);
445
+ }
446
+ catch (error) {
447
+ if (error.code !== 'ENOENT')
448
+ throw error;
449
+ const before = await currentBytes(root), snapshot = validateProjectSkillsSnapshot(before.declaration, before.lock);
450
+ if (projectSkillsDigest(snapshot.lock) !== options.expectedLockHash || snapshot.lock.declarationHash !== options.expectedDeclarationHash)
451
+ code('skill_activation_revision_conflict');
452
+ const merged = merge(snapshot, selection, selectedArchives);
453
+ transaction = { version: 1, transactionId: options.transactionId, root, selectionId: selection.selectionId,
454
+ selectionHash: selectionIdentity(selection), expectedLockHash: options.expectedLockHash, expectedDeclarationHash: options.expectedDeclarationHash,
455
+ before, after: merged.after, selectedKeys: merged.selectedKeys, status: 'prepared' };
456
+ await writeTransaction(transactionPath, transaction);
457
+ }
458
+ if (!ownsBytes(await currentBytes(root), transaction))
459
+ code('skill_activation_revision_conflict');
460
+ await options.assertCurrent?.();
461
+ await stageGeneration(root, transactionPath, transaction, selection, selectedArchives);
462
+ const after = serialized(transaction.after), target = paths(root);
463
+ try {
464
+ for (const key of ['declaration', 'lock']) {
465
+ await options.assertCurrent?.();
466
+ const current = await currentBytes(root);
467
+ if (!ownsBytes(current, transaction))
468
+ code('skill_activation_revision_conflict');
469
+ if (current[key] !== after[key])
470
+ await writeSharedFile(root, target[key], after[key]);
471
+ }
472
+ const catalog = await readLockedProjectSkillCatalog(root, join(root, '.playcraft/skill-artifacts'));
473
+ if (catalog.lockHash !== projectSkillsDigest(transaction.after.lock))
474
+ code('skill_activation_generation_conflict');
475
+ }
476
+ catch (error) {
477
+ // Ordinary failures restore the original pair only while every observed byte still belongs to this transaction.
478
+ const current = await currentBytes(root).catch(() => undefined);
479
+ if (current && ownsBytes(current, transaction)) {
480
+ for (const key of ['declaration', 'lock']) {
481
+ const observed = await currentBytes(root);
482
+ if (!ownsBytes(observed, transaction))
483
+ break;
484
+ if (observed[key] !== transaction.before[key])
485
+ await writeSharedFile(root, target[key], transaction.before[key]);
486
+ }
487
+ }
488
+ throw error;
489
+ }
490
+ transaction.status = 'applied';
491
+ await writeTransaction(transactionPath, transaction);
492
+ const lockHash = projectSkillsDigest(transaction.after.lock);
493
+ return { lockHash, declarationHash: transaction.after.lock.declarationHash,
494
+ entries: transaction.selectedKeys.map(entryKey => ({ entryKey, lockHash })) };
495
+ }
496
+ finally {
497
+ await writer.close();
498
+ await rm(writerPath, { force: true });
499
+ }
500
+ }
@@ -0,0 +1,117 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { remoteSkillMatchRequestSchema, remoteSkillMatchResultSchema, remoteSkillSelectionRequestSchema, parseRemoteSkillSelectionResult, projectSkillsDigest, remoteSkillActivationRequestSchema, } from '@playcraft/common/project-skills';
3
+ import { skillActivationRegistrationSchema } from '@playcraft/common/cloud-workspace/runtime';
4
+ import { resolveRegistryCredentials } from '../skill-registry/client.js';
5
+ import { businessEndpoint } from '../utils/environment-endpoints.js';
6
+ import { projectSkillsMessage as t } from './messages.js';
7
+ const MAX_ARTIFACT_BYTES = 80 * 1024 * 1024;
8
+ export class RemoteSkillError extends Error {
9
+ code;
10
+ constructor(code, message) {
11
+ super(message);
12
+ this.code = code;
13
+ }
14
+ }
15
+ async function readJson(response, maximum) {
16
+ if (!response.body)
17
+ throw new RemoteSkillError('remote_skills_unavailable', t('remoteUnavailable'));
18
+ const reader = response.body.getReader();
19
+ const chunks = [];
20
+ let length = 0;
21
+ try {
22
+ while (true) {
23
+ const chunk = await reader.read();
24
+ if (chunk.done)
25
+ break;
26
+ length += chunk.value.byteLength;
27
+ if (length > maximum)
28
+ throw new RemoteSkillError('remote_skills_response_too_large', t('remoteTooLarge'));
29
+ chunks.push(chunk.value);
30
+ }
31
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
32
+ }
33
+ finally {
34
+ await reader.cancel().catch(() => undefined);
35
+ }
36
+ }
37
+ /** Prefer the current native turn's delegated loopback capability; never expose controller credentials. */
38
+ export class RemoteSkillClient {
39
+ options;
40
+ transport;
41
+ env;
42
+ constructor(options = {}) {
43
+ this.options = options;
44
+ this.transport = options.fetch ?? fetch;
45
+ this.env = options.env ?? process.env;
46
+ }
47
+ bridge() {
48
+ const endpoint = this.env.PLAYCRAFT_SKILLS_ENDPOINT, token = this.env.PLAYCRAFT_SKILLS_CAPABILITY;
49
+ if (!endpoint && !token)
50
+ return undefined;
51
+ if (!endpoint || endpoint.length > 2048 || !token || !/^[A-Za-z0-9_-]{16,128}$/.test(token))
52
+ throw new RemoteSkillError('remote_skills_bridge_invalid', t('remoteBridgeInvalid'));
53
+ let url;
54
+ try {
55
+ url = new URL(endpoint);
56
+ }
57
+ catch {
58
+ throw new RemoteSkillError('remote_skills_bridge_invalid', t('remoteBridgeInvalid'));
59
+ }
60
+ if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !url.port || url.username || url.password || url.search || url.hash
61
+ || !/^\/[A-Za-z0-9_-]+\/[a-f0-9]{32}\/skill-discovery$/.test(url.pathname))
62
+ throw new RemoteSkillError('remote_skills_bridge_invalid', t('remoteBridgeInvalid'));
63
+ return { endpoint: url, token };
64
+ }
65
+ hasExecutionBridge() { return this.bridge() !== undefined; }
66
+ async request(action, input) {
67
+ const bridge = this.bridge();
68
+ // A failed bridge is never replaced with a broader local login or a different environment.
69
+ const credentials = bridge ? undefined : resolveRegistryCredentials(this.options);
70
+ const endpoint = bridge ? new URL(`${bridge.endpoint.href}/${action}`)
71
+ : businessEndpoint(credentials.url, `/api/project-skills/discovery/${action}`);
72
+ const response = await this.transport(endpoint, { method: 'POST', redirect: 'error',
73
+ signal: AbortSignal.timeout(action === 'artifact' ? 60_000 : 30_000),
74
+ headers: { authorization: `Bearer ${bridge?.token ?? credentials.token}`, 'content-type': 'application/json' },
75
+ body: JSON.stringify(bridge ? { cwd: this.options.cwd ?? process.cwd(), input } : input) });
76
+ if (!response.ok) {
77
+ await response.body?.cancel().catch(() => undefined);
78
+ throw new RemoteSkillError(response.status === 401 || response.status === 403 ? 'remote_skills_access_denied' : 'remote_skills_unavailable', response.status === 401 || response.status === 403 ? t('remoteAccessDenied') : t('remoteUnavailable'));
79
+ }
80
+ return readJson(response, action === 'artifact' ? Math.ceil(MAX_ARTIFACT_BYTES / 3) * 4 + 4096 : 4 * 1024 * 1024);
81
+ }
82
+ async match(input) {
83
+ return remoteSkillMatchResultSchema.parse(await this.request('match', remoteSkillMatchRequestSchema.parse(input)));
84
+ }
85
+ async select(input) {
86
+ const request = remoteSkillSelectionRequestSchema.parse(input);
87
+ const result = parseRemoteSkillSelectionResult(await this.request('selection', request));
88
+ for (const selected of request.skills) {
89
+ if (!result.skills.some(skill => skill.skillRef === selected.skillRef && skill.skillVersionId === selected.skillVersionId
90
+ && skill.version === selected.version && skill.artifactHash === selected.artifactHash))
91
+ throw new RemoteSkillError('remote_skills_selection_changed', t('remoteSelectionChanged'));
92
+ }
93
+ return result;
94
+ }
95
+ /** Local preparation has no native session to continue; a present but invalid bridge remains fatal. */
96
+ async activate(selectionId) {
97
+ if (!this.bridge())
98
+ return undefined;
99
+ return skillActivationRegistrationSchema.parse(await this.request('activate', remoteSkillActivationRequestSchema.parse({ selectionId })));
100
+ }
101
+ async artifact(selection, entry) {
102
+ const expected = selection.lock.entries.find(item => item.entryKey === entry.entryKey);
103
+ if (!expected || projectSkillsDigest(expected.revision) !== projectSkillsDigest(entry.revision))
104
+ throw new RemoteSkillError('remote_skills_selection_changed', t('remoteSelectionChanged'));
105
+ const input = this.bridge() ? { selectionId: selection.selectionId, entryKey: entry.entryKey }
106
+ : { entryKey: entry.entryKey, revision: entry.revision };
107
+ const value = await this.request('artifact', input);
108
+ if (!value || typeof value.content !== 'string' || value.content.length > Math.ceil(MAX_ARTIFACT_BYTES / 3) * 4
109
+ || value.content.length % 4 !== 0 || /[^A-Za-z0-9+/=]/.test(value.content) || value.artifactHash !== entry.revision.artifactHash)
110
+ throw new RemoteSkillError('remote_skills_digest_mismatch', t('remoteDigestMismatch'));
111
+ const archive = Buffer.from(value.content, 'base64');
112
+ if (archive.length > MAX_ARTIFACT_BYTES || archive.toString('base64') !== value.content
113
+ || createHash('sha256').update(archive).digest('hex') !== entry.revision.artifactHash)
114
+ throw new RemoteSkillError('remote_skills_digest_mismatch', t('remoteDigestMismatch'));
115
+ return archive;
116
+ }
117
+ }