@playcraft/cli 0.1.56-beta → 0.1.57-beta

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.
@@ -6,6 +6,7 @@ import { basename, dirname } from 'node:path';
6
6
  import { z } from 'zod';
7
7
  import { workspaceContextFromRoot } from '../workspaces/registry.js';
8
8
  import { PLATFORM_ROUTES, modelResponsesPath, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
9
+ import { WorkspaceGenerationProvenanceBroker } from './generation-provenance-broker.js';
9
10
  const credentialSchema = z.object({
10
11
  token: z.string().min(1), expiresAt: z.string().datetime(),
11
12
  configId: z.string().uuid(), model: z.string().min(1).max(128),
@@ -25,8 +26,10 @@ export class WorkspaceModelBroker {
25
26
  pending = new Map();
26
27
  requests = new Set();
27
28
  sessionRequests = new Map();
29
+ generation;
28
30
  constructor(state) {
29
31
  this.state = state;
32
+ this.generation = new WorkspaceGenerationProvenanceBroker(state);
30
33
  }
31
34
  async start() {
32
35
  if (this.server)
@@ -67,8 +70,10 @@ export class WorkspaceModelBroker {
67
70
  }
68
71
  // Resolve current model metadata and permission at every native turn boundary.
69
72
  const credential = await this.credential(entry, true);
73
+ const boundaryId = sessionId && clientRequestId ? await this.generation.prepare() : undefined;
70
74
  if (!route || route.epoch !== this.state.runtimeEpoch) {
71
- route = { workspaceId, epoch: this.state.runtimeEpoch, capability: randomBytes(32).toString('base64url') };
75
+ route = { workspaceId, epoch: this.state.runtimeEpoch, capability: randomBytes(32).toString('base64url'),
76
+ generationCapability: randomBytes(32).toString('base64url') };
72
77
  this.routes.set(routeKey, route);
73
78
  }
74
79
  // Each session pins its prepared model; shared credential renewal cannot change an active turn.
@@ -77,12 +82,16 @@ export class WorkspaceModelBroker {
77
82
  if (!this.valid(entry) || this.routes.get(routeKey) !== route)
78
83
  throw new Error('model_access_denied');
79
84
  route.selected = { entry, model: credential.model,
80
- ...(sessionId && clientRequestId ? { feedback: { sessionId, clientRequestId } } : {}) };
85
+ ...(sessionId && clientRequestId ? { feedback: { sessionId, clientRequestId, cwd, boundaryId } } : {}) };
81
86
  },
82
87
  model: credential.model,
83
88
  modelConfigId: credential.configId,
84
89
  modelProvider: 'playcraft_workspace',
85
90
  config: {
91
+ ...(sessionId ? { 'shell_environment_policy.set': {
92
+ PLAYCRAFT_AGENT_GENERATION_ENDPOINT: `${this.baseUrl}/${routeKey}/generation-provenance`,
93
+ PLAYCRAFT_AGENT_GENERATION_CAPABILITY: route.generationCapability,
94
+ } } : {}),
86
95
  'model_providers.playcraft_workspace': {
87
96
  name: 'PlayCraft Workspace', base_url: `${this.baseUrl}/${routeKey}`,
88
97
  wire_api: 'responses', requires_openai_auth: false,
@@ -142,18 +151,28 @@ export class WorkspaceModelBroker {
142
151
  const cancel = () => abort.abort();
143
152
  response.once('close', cancel);
144
153
  try {
145
- const match = /^\/([0-9a-f-]{36}(?:\/[0-9a-f]{32})?)\/responses$/.exec(request.url ?? '');
154
+ const match = /^\/([0-9a-f-]{36}(?:\/[0-9a-f]{32})?)\/(responses|generation-provenance)$/.exec(request.url ?? '');
146
155
  const route = match ? this.routes.get(match[1]) : undefined;
147
156
  const selected = route?.selected;
148
157
  const entry = selected?.entry;
149
158
  const presented = Buffer.from(request.headers.authorization ?? '');
150
- const expected = Buffer.from(route ? `Bearer ${route.capability}` : '');
159
+ const generationRequest = match?.[2] === 'generation-provenance';
160
+ const expected = Buffer.from(route ? `Bearer ${generationRequest ? route.generationCapability : route.capability}` : '');
151
161
  if (request.method !== 'POST' || !entry || !expected.length || presented.length !== expected.length
152
162
  || !timingSafeEqual(presented, expected) || !this.valid(entry)) {
153
163
  response.writeHead(403);
154
164
  response.end();
155
165
  return;
156
166
  }
167
+ if (generationRequest) {
168
+ if (!selected?.feedback) {
169
+ response.writeHead(403);
170
+ response.end();
171
+ return;
172
+ }
173
+ await this.generation.forward(request, response, selected.feedback, () => this.valid(entry) && route?.selected === selected);
174
+ return;
175
+ }
157
176
  activeRoute = route;
158
177
  const active = this.sessionRequests.get(route) ?? new Set();
159
178
  active.add(abort);
@@ -2,7 +2,6 @@ import { closeCliCredentials } from './workspaces/cli-credentials.js';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { mkdir, readdir } from 'node:fs/promises';
4
4
  import { createServer } from 'node:http';
5
- import { createPlatformAdapter } from '@playcraft/build';
6
5
  import { INCOMPATIBLE_EXIT_CODE, negotiateProtocol, transitionOperation, directoryMutationReceiptSchema, productionSessionAttemptId, } from '@playcraft/common/cloud-workspace/runtime';
7
6
  import { RuntimeJournal } from './persistence/journal.js';
8
7
  import { createCodexAppServerAdapter } from './codex/app-server.js';
@@ -32,7 +31,7 @@ import { revisionScope } from './workspaces/registry.js';
32
31
  import { observeWorkspaceArtifacts } from './workspaces/artifact-events.js';
33
32
  import { loadWorkspaceMcpConfig } from './codex/mcp-config.js';
34
33
  import { startSourceEventUploader, stopSourceEventUploader } from './server/source-uploader.js';
35
- import { submitTrustedOperation } from './server/submit-operation.js';
34
+ import { platformExportTarget, readPlatformExport, startPlatformExport } from './workspaces/platform-export.js';
36
35
  export async function startWorkspaceRuntime(config, options = {}) {
37
36
  process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
38
37
  const negotiated = negotiateProtocol(config);
@@ -355,6 +354,7 @@ function closeServer(server) {
355
354
  function formatHost(host) {
356
355
  return host.includes(':') ? `[${host}]` : host;
357
356
  }
357
+ const UUID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
358
358
  function deliveryToolHandlers(state) {
359
359
  const contextFor = async (input) => {
360
360
  if (input.workspaceId)
@@ -368,37 +368,15 @@ function deliveryToolHandlers(state) {
368
368
  const { workspaceId, directoryName } = await contextFor(input);
369
369
  if (directoryName === 'root')
370
370
  throw new Error('invalid_workspace_directory');
371
- const builds = [];
372
- for (const channel of input.channels) {
373
- const operation = await submitTrustedOperation(state, {
374
- kind: 'delivery.build',
375
- workspaceId,
376
- directoryName,
377
- ...(input.sessionId ? { sessionId: input.sessionId } : {}),
378
- clientRequestId: `${input.clientRequestId.slice(0, 80)}:${channel}`,
379
- payload: {
380
- channel,
381
- format: input.format ?? createPlatformAdapter({ platform: channel, format: 'html' }).getDefaultFormat(),
382
- actor: 'agent',
383
- },
384
- });
385
- let record;
386
- for (let attempt = 0; attempt < 40 && !record; attempt++) {
387
- record = await new DeliveryStore(state.config.stateDir, workspaceId).read(operation.operationId);
388
- if (!record)
389
- await new Promise(resolve => setTimeout(resolve, 25));
390
- }
391
- builds.push({
392
- buildId: operation.operationId,
393
- channel,
394
- status: record?.status ?? operation.status,
395
- ...(record?.queuePosition ? { queuePosition: record.queuePosition } : {}),
396
- });
397
- }
398
- return { builds };
371
+ // Exports go through the platform; a standalone workspace becomes a project first.
372
+ return startPlatformExport(state, { ...input, workspaceId, directoryName });
399
373
  },
400
374
  async get(input) {
401
375
  const { workspaceId, directoryName } = await contextFor(input);
376
+ const sandboxBuild = input.buildId !== undefined && UUID.test(input.buildId);
377
+ if (!sandboxBuild && await platformExportTarget(state, { workspaceId, directoryName })) {
378
+ return readPlatformExport(state, { ...input, workspaceId, directoryName });
379
+ }
402
380
  const store = new DeliveryStore(state.config.stateDir, workspaceId);
403
381
  const record = input.buildId
404
382
  ? await store.read(input.buildId)
@@ -15,16 +15,21 @@ const SHARE_WAIT_MS = 60_000;
15
15
  * prompts, and writes plain lines to the log. Package scripts run: the workspace is the user's own
16
16
  * sandbox and the install runs as the development user.
17
17
  */
18
- const INSTALL_COMMAND = 'pnpm install --no-frozen-lockfile --store-dir /var/cache/playcraft/pnpm-store --reporter=append-only --config.confirm-modules-purge=false';
19
- /** Installs whenever there is a package.json; the hash says whether a finished install is current. */
18
+ export function dependencyInstallCommand(state, options = {}) {
19
+ const store = process.env.PLAYCRAFT_PNPM_STORE ?? (process.platform === 'linux' ? '/var/cache/playcraft/pnpm-store' : join(state.config.stateDir, 'pnpm-store'));
20
+ const quotedStore = `'${store.replaceAll("'", "'\\''")}'`;
21
+ return `pnpm install --${options.frozenLockfile ? 'frozen' : 'no-frozen'}-lockfile --store-dir ${quotedStore} --reporter=append-only --config.confirm-modules-purge=false`;
22
+ }
23
+ /** pnpm owns installation; other package managers' lockfiles remain untouched. */
20
24
  export async function planInstall(root) {
21
25
  const manifest = await readOptional(join(root, 'package.json'));
22
26
  if (manifest === undefined)
23
27
  return { kind: 'skip' };
24
- const lock = (await readOptional(join(root, 'pnpm-lock.yaml'))) ?? '';
28
+ const lock = await readOptional(join(root, 'pnpm-lock.yaml'));
25
29
  return {
26
30
  kind: 'install',
27
- lockHash: createHash('sha256').update(manifest).update('\0').update(lock).digest('hex'),
31
+ lockHash: createHash('sha256').update(manifest).update('\0').update(lock ?? '').digest('hex'),
32
+ hasLockfile: lock !== undefined,
28
33
  };
29
34
  }
30
35
  const running = new WeakMap();
@@ -108,7 +113,16 @@ export async function recoverInterruptedInstalls(journal) {
108
113
  }
109
114
  }
110
115
  async function installOnce(state, workspaceId, root) {
111
- const plan = await planInstall(root);
116
+ let plan;
117
+ try {
118
+ plan = await planInstall(root);
119
+ }
120
+ catch {
121
+ await state.journal.putWorkspaceDependencies({
122
+ workspaceId, status: 'failed', error: 'dependency_inputs_unreadable', finishedAt: new Date().toISOString(),
123
+ });
124
+ return;
125
+ }
112
126
  const current = state.journal.getWorkspaceDependencies(workspaceId);
113
127
  if (plan.kind === 'skip') {
114
128
  if (current?.status !== 'none') {
@@ -174,9 +188,7 @@ async function installOnce(state, workspaceId, root) {
174
188
  */
175
189
  async function runInstall(state, workspaceId, root) {
176
190
  // Tests replace the install; a real registry is never reached from them.
177
- const store = process.env.PLAYCRAFT_PNPM_STORE ?? (process.platform === 'linux' ? '/var/cache/playcraft/pnpm-store' : join(state.config.stateDir, 'pnpm-store'));
178
- const quotedStore = `'${store.replaceAll("'", "'\\''")}'`;
179
- const planned = process.env.PLAYCRAFT_DEPENDENCY_INSTALL_COMMAND || INSTALL_COMMAND.replace('/var/cache/playcraft/pnpm-store', quotedStore);
191
+ const planned = process.env.PLAYCRAFT_DEPENDENCY_INSTALL_COMMAND || dependencyInstallCommand(state);
180
192
  const logDir = join(state.config.stateDir, 'dependency-logs');
181
193
  await mkdir(logDir, { recursive: true, mode: 0o700 });
182
194
  const logPath = join(logDir, `${workspaceId}.log`);
@@ -265,8 +277,10 @@ async function readOptional(path) {
265
277
  try {
266
278
  return await readFile(path, 'utf8');
267
279
  }
268
- catch {
269
- return undefined;
280
+ catch (error) {
281
+ if (error.code === 'ENOENT')
282
+ return undefined;
283
+ throw error;
270
284
  }
271
285
  }
272
286
  async function isDirectory(path) {
@@ -0,0 +1,185 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { z } from 'zod';
6
+ import { buildStageAt } from '@playcraft/common/build-progress';
7
+ import { PLATFORM_ROUTES, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
8
+ import { submitTrustedOperation } from '../server/submit-operation.js';
9
+ import { businessEndpoint } from '../../utils/environment-endpoints.js';
10
+ import { ensureCliCredentials } from './cli-credentials.js';
11
+ import { readWorkspaceGitStatus } from './git.js';
12
+ import { sessionWorkspaceRoot } from './registry.js';
13
+ const run = promisify(execFile);
14
+ const REQUEST_TIMEOUT_MS = 60_000;
15
+ /** Validation findings beyond this are summarized by count; the Agent fixes the first ones first. */
16
+ const MAX_FINDINGS = 30;
17
+ /** A tool error the Agent sees as its reason; `code` is stable, the detail explains it. */
18
+ export class PlatformExportError extends Error {
19
+ code;
20
+ constructor(code, detail) {
21
+ super(detail ? `${code}: ${detail}` : code);
22
+ this.code = code;
23
+ }
24
+ }
25
+ /** The project and branch a workspace directory exports, or undefined when it has no project. */
26
+ export async function platformExportTarget(state, scope) {
27
+ const binding = state.journal.getWorkspace(scope.workspaceId);
28
+ if (!binding?.projectId)
29
+ return undefined;
30
+ const root = await sessionWorkspaceRoot(state, scope.workspaceId, scope.directoryName);
31
+ return { projectId: binding.projectId, root };
32
+ }
33
+ /**
34
+ * A standalone workspace has no remote to export from. The platform gives it a project (as its
35
+ * first save does) and registers it again; the registration reaches this runtime before the reply.
36
+ */
37
+ async function promoteStandalone(state, scope) {
38
+ if (state.journal.getWorkspace(scope.workspaceId)?.targetKind !== 'standalone' || scope.directoryName !== 'basic')
39
+ return;
40
+ const config = state.config;
41
+ const response = await fetch(platformEndpoint(config.publicBaseUrl, PLATFORM_ROUTES.workspacePromotions), {
42
+ method: 'POST',
43
+ redirect: 'error',
44
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
45
+ headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
46
+ body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation, runtimeEpoch: state.runtimeEpoch,
47
+ workspaceId: scope.workspaceId }),
48
+ });
49
+ if (!response.ok) {
50
+ const body = await response.json().catch(() => undefined);
51
+ throw new PlatformExportError('workspace_promotion_failed', typeof body?.reason === 'string' ? body.reason : String(response.status));
52
+ }
53
+ await response.body?.cancel().catch(() => undefined);
54
+ }
55
+ const credentialSchema = z.object({ apiUrl: z.string().url(), token: z.string().min(1) });
56
+ async function request(state, path, init) {
57
+ await ensureCliCredentials(state).catch(() => { throw new PlatformExportError('cli_credentials_unavailable'); });
58
+ const credential = credentialSchema.parse(JSON.parse(await readFile(join(state.config.workspaceRoot, '.playcraft.json'), 'utf8')));
59
+ const response = await fetch(businessEndpoint(credential.apiUrl, path), {
60
+ method: init.method,
61
+ redirect: 'error',
62
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
63
+ headers: {
64
+ 'X-Sandbox-Token': credential.token,
65
+ 'playcraft-env': state.config.environment,
66
+ ...(init.body !== undefined ? { 'content-type': 'application/json' } : {}),
67
+ },
68
+ ...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
69
+ });
70
+ const text = await response.text();
71
+ let body;
72
+ try {
73
+ body = JSON.parse(text);
74
+ }
75
+ catch {
76
+ body = undefined;
77
+ }
78
+ const error = body && typeof body === 'object' && 'error' in body ? body.error : undefined;
79
+ if (!response.ok || (typeof error === 'string' && !('id' in body))) {
80
+ const message = body && typeof body === 'object' && 'message' in body ? body.message : error;
81
+ throw new PlatformExportError('platform_export_rejected', `${response.status} ${typeof message === 'string' ? message : Array.isArray(message) ? message.join('; ') : text.slice(0, 300)}`);
82
+ }
83
+ return body;
84
+ }
85
+ /**
86
+ * Saves the directory (commit + push), then exports that commit for the channels through the
87
+ * platform, as the delivery panel's Export does. Each channel repeats its latest export of the
88
+ * branch, or uses the defaults without one. A standalone workspace first becomes a project.
89
+ */
90
+ export async function startPlatformExport(state, input) {
91
+ await promoteStandalone(state, input);
92
+ const target = await platformExportTarget(state, input);
93
+ if (!target)
94
+ throw new PlatformExportError('platform_export_requires_project');
95
+ const saved = await submitTrustedOperation(state, {
96
+ kind: 'workspace.save',
97
+ workspaceId: input.workspaceId,
98
+ directoryName: input.directoryName,
99
+ ...(input.sessionId ? { sessionId: input.sessionId } : {}),
100
+ clientRequestId: `${input.clientRequestId.slice(0, 80)}:export-save`,
101
+ payload: {},
102
+ });
103
+ if (saved.status !== 'completed')
104
+ throw new PlatformExportError('workspace_save_failed', saved.error);
105
+ const status = await readWorkspaceGitStatus(target.root);
106
+ // A save skips the push during a merge; with an upstream, `ahead` says the push did not land.
107
+ if (status.merging || (status.hasUpstream && status.ahead > 0))
108
+ throw new PlatformExportError('workspace_not_pushed');
109
+ if (status.branch === 'HEAD')
110
+ throw new PlatformExportError('workspace_detached_head');
111
+ const commit = (await run('git', ['-C', target.root, 'rev-parse', 'HEAD'])).stdout.trim();
112
+ const result = await request(state, '/api/builds/agent-exports', {
113
+ method: 'POST',
114
+ body: {
115
+ projectId: target.projectId,
116
+ branchName: status.branch,
117
+ commitHash: commit,
118
+ platforms: input.channels,
119
+ ...(input.format ? { format: input.format } : {}),
120
+ clientRequestId: input.clientRequestId,
121
+ },
122
+ });
123
+ return {
124
+ commit: commit.slice(0, 7),
125
+ branch: status.branch,
126
+ builds: result.builds.map((build) => ({
127
+ buildId: build.id,
128
+ channel: build.platform,
129
+ status: build.status,
130
+ ...(build.format ? { format: build.format } : {}),
131
+ ...(build.error ? { error: build.error } : {}),
132
+ ...(result.options?.[build.platform] ? { options: result.options[build.platform] } : {}),
133
+ })),
134
+ };
135
+ }
136
+ /** One platform export as the Agent reads it: status, the recorded failure and the checks. */
137
+ export async function readPlatformExport(state, input) {
138
+ const target = await platformExportTarget(state, input);
139
+ if (!target)
140
+ throw new PlatformExportError('platform_export_requires_project');
141
+ let buildId = input.buildId;
142
+ if (!buildId) {
143
+ const { branch } = await readWorkspaceGitStatus(target.root);
144
+ const query = new URLSearchParams({ projectId: target.projectId, branchName: branch, limit: '1',
145
+ ...(input.channel ? { platform: input.channel } : {}) });
146
+ const list = await request(state, `/api/agent/tools/list-builds?${query}`, { method: 'GET' });
147
+ buildId = list.items[0]?.id;
148
+ if (!buildId)
149
+ throw new PlatformExportError('delivery_build_not_found');
150
+ }
151
+ const build = await request(state, `/api/agent/tools/get-build?${new URLSearchParams({ id: buildId })}`, { method: 'GET' });
152
+ const running = build.status === 'queued' || build.status === 'running';
153
+ const findings = (build.validationWarnings ?? []).map((warning) => ({
154
+ code: warning.code,
155
+ severity: warning.severity,
156
+ message: warning.message,
157
+ ...(typeof warning.details?.status === 'string' ? { status: warning.details.status } : {}),
158
+ ...(typeof warning.details?.recommendation === 'string' ? { recommendation: warning.details.recommendation } : {}),
159
+ ...(typeof warning.details?.file === 'string' ? { file: warning.details.file } : {}),
160
+ ...(typeof warning.details?.line === 'number' ? { line: warning.details.line } : {}),
161
+ }));
162
+ return {
163
+ build: {
164
+ buildId: build.id,
165
+ channel: build.platform,
166
+ format: build.format,
167
+ status: build.status,
168
+ ...(running ? { stage: buildStageAt(build.progress).stage } : {}),
169
+ ...(build.commitHash ? { commit: build.commitHash } : {}),
170
+ ...(build.error ? { error: build.error } : {}),
171
+ ...(build.previewUrl ? { previewUrl: build.previewUrl } : {}),
172
+ ...(build.downloadUrl ? { downloadUrl: build.downloadUrl } : {}),
173
+ ...(typeof build.outputSize === 'number' ? { sizeBytes: build.outputSize } : {}),
174
+ ...(typeof build.sizeLimit === 'number' ? { sizeLimitBytes: build.sizeLimit } : {}),
175
+ },
176
+ ...(build.artifacts?.failure ? { failure: build.artifacts.failure } : {}),
177
+ ...(!running && build.validationPassed !== null && build.validationPassed !== undefined ? {
178
+ validation: {
179
+ passed: build.validationPassed,
180
+ findings: findings.slice(0, MAX_FINDINGS),
181
+ ...(findings.length > MAX_FINDINGS ? { omitted: findings.length - MAX_FINDINGS } : {}),
182
+ },
183
+ } : {}),
184
+ };
185
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.56-beta",
3
+ "version": "0.1.57-beta",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -52,8 +52,8 @@
52
52
  "@gltf-transform/core": "^4.3.0",
53
53
  "@gltf-transform/extensions": "^4.3.0",
54
54
  "@gltf-transform/functions": "^4.3.0",
55
- "@playcraft/build": "0.1.56-beta",
56
- "@playcraft/common": "0.1.56-beta",
55
+ "@playcraft/build": "0.1.57-beta",
56
+ "@playcraft/common": "0.1.57-beta",
57
57
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
58
58
  "chokidar": "^4.0.3",
59
59
  "commander": "^13.1.0",
@@ -10,8 +10,8 @@
10
10
  },
11
11
  {
12
12
  "skillRef": "platform/playcraft-asset-management",
13
- "version": "1.0.0",
14
- "artifactHash": "27209dba24f6e1851dc83682fbd24165894c802794c797cac32d8871f740cf14",
13
+ "version": "1.1.0",
14
+ "artifactHash": "0b5408792edfeb7b852817bd76893b95738a0e161407f0b48c4b50161797ba3c",
15
15
  "surface": "portal-agent",
16
16
  "mountPath": ".playcraft/skills/platform__playcraft-asset-management"
17
17
  },
@@ -108,8 +108,8 @@
108
108
  },
109
109
  {
110
110
  "skillRef": "remix/playcraft-audio-generation",
111
- "version": "1.0.0",
112
- "artifactHash": "8d1b035b289545ca8fd6c247b9417bf34bddf5a26b31e114c35d001aa3a9ebf0",
111
+ "version": "1.1.1",
112
+ "artifactHash": "27661b1b52e7981a0b9cf5531f535fcf10928ea290348fcb469e35ad2082b437",
113
113
  "surface": "sandbox-agent",
114
114
  "mountPath": ".playcraft/skills/remix__playcraft-audio-generation"
115
115
  },
@@ -122,15 +122,15 @@
122
122
  },
123
123
  {
124
124
  "skillRef": "remix/playcraft-image-generation",
125
- "version": "1.0.0",
126
- "artifactHash": "91bec2422c750d9d47d5be2796ec5aaffb48de561f2c2d8b92112f37d576f489",
125
+ "version": "1.1.2",
126
+ "artifactHash": "a006c96700f8dbfe4c34a31d0e1646620ca6148d319e59e7466d627fea1ca65e",
127
127
  "surface": "sandbox-agent",
128
128
  "mountPath": ".playcraft/skills/remix__playcraft-image-generation"
129
129
  },
130
130
  {
131
131
  "skillRef": "remix/playcraft-image-processing",
132
- "version": "1.0.0",
133
- "artifactHash": "d845c067bed103c3d1d3f145b1cb8246ffbd6a9ae91efcfbce2eae456819212d",
132
+ "version": "1.1.1",
133
+ "artifactHash": "903f4efc14e2bb57403856edeca43146904ec64811cfd107b6ec76bb451842fd",
134
134
  "surface": "sandbox-agent",
135
135
  "mountPath": ".playcraft/skills/remix__playcraft-image-processing"
136
136
  },
@@ -147,6 +147,13 @@
147
147
  "artifactHash": "e2a59c61ef5b094a821fbbab3b0f65f906bfc0dfda6f3d8c0b33a6f460dd0963",
148
148
  "surface": "sandbox-agent",
149
149
  "mountPath": ".playcraft/skills/remix__playcraft-remix-workflow"
150
+ },
151
+ {
152
+ "skillRef": "remix/playcraft-workspace-assets",
153
+ "version": "1.0.0",
154
+ "artifactHash": "f098949b043a229e561d92a34c3a65b44ffbd83d1758ef30ac09d2d0d5ea2570",
155
+ "surface": "sandbox-agent",
156
+ "mountPath": ".playcraft/skills/remix__playcraft-workspace-assets"
150
157
  }
151
158
  ]
152
159
  }
@@ -22,6 +22,10 @@ Prepare all required images before changing visible objects, call `assertCurrent
22
22
 
23
23
  `ResourceLoadError` is exported from `runtime.mjs`. Live adapters let this error reach the runtime, which retains the last known installation and reports a recoverable, field-level resource failure. Do not catch an arbitrary update exception and claim successful fallback. During initial `createGame`, catch only this load error to start with an embedded default image or empty background; the runtime still reports the saved image as unapplied. A later patch of that field retries it; unrelated parameter updates can continue. `resourceErrors` and file-save receipts are independent. Save logical paths and let the original production asset registry include the file when building delivery output.
24
24
 
25
+ The endpoint advertises `canRefreshImages: true` for explicit same-path content refresh. The host sends registered live-image fields and their expected file digests through a separate resource sequence; raw parameters and draft revisions remain unchanged. In `apply`, honor `context.fields` even when a logical path equals its previous value: request that field through `loadImage` and install the returned image after `assertCurrent()`. A local path-equality cache must not hide this explicit update. The runtime prepares new images without blocking unrelated live parameters, checks the expected content digest, rebases against the latest permitted configuration and records installed digests only after the real render acknowledgement. A matching installed digest is a no-op; a failed or superseded preparation leaves the old image available and can be retried. Older endpoints without this optional capability require the existing rebuild route.
26
+
27
+ Content generations are independent per field. If an opening image and board image are being prepared together, a newer board-only refresh cancels only the old board read; the still-current opening image must finish and apply while preserving the newer board. Each application uses only the prepared fields that remain current and the latest permitted configuration. A preparation failure in a still-current batch retains the old images and marks all its unapplied fields as recoverable, so an explicit retry cannot silently forget a prepared sibling.
28
+
25
29
  ## Preview build
26
30
 
27
31
  The engineering project must install a compatible Vite as a development dependency. Set a separate package script:
@@ -4,14 +4,14 @@
4
4
  "protocolVersion": 1,
5
5
  "files": {
6
6
  "build-preview.mjs": "2bd512675d829cc07ceddcb58365a6f74d36ff93690f8d72f4bbaa1d45511c91",
7
- "README.md": "f104440b3acb23adb88bde0dff23840fa170a14f477d1d7338575eca75e73571",
7
+ "README.md": "3e35ca2ce6eab84ba8688b400aee8ee5e69458466a13f00e459fd630575d9cbd",
8
8
  "release-guard.mjs": "0c33d1559c68844f3760c3e754ee36ca70dc27151ef6f7ad62f29f95239679cc",
9
9
  "runtime.d.mts": "a366552166a4a93103306997058951416740eaf6d6f3d0a34854386b4f1e02f5",
10
10
  "runtime.d.ts": "a366552166a4a93103306997058951416740eaf6d6f3d0a34854386b4f1e02f5",
11
- "runtime.mjs": "25b33218ff32dfeaaad7bcd69647cbf662fa4312d49b62724a2104fd27d9b001",
12
- "types/adapters.d.ts": "29b5c18d4576ab00eca44875b5c4eae2fc0e0ed6ab5595dbbaf6ba9b9fd8beed",
13
- "types/preview.d.ts": "19ea55ed6faf4faa4f4fc62a2d70309d7ba917fc165c2c149856798e1b4197f8",
14
- "types/protocol.d.ts": "716e83802336c4e7377abc1b433c77045ed59997a45646fe2fdbe55cb82c5e62",
15
- "types/runtime.d.ts": "efcd429b24d75b06afb3f05ec3f8134efc381ede0dd59cb43ec5770e2559c77b"
11
+ "runtime.mjs": "700c6cb0859ef4b4390d7a1dae41f7e8fa7be7c8dad90b5b48a7e7ee5f90a6a1",
12
+ "types/adapters.d.ts": "f5e70c4bb9043de1c590eada14266f613286360c809faf26680b0bbbd1cc7b2c",
13
+ "types/preview.d.ts": "db2e7eb1fa3406ca53fc7c3a4352da061c05af714ead978111ab5118de58db32",
14
+ "types/protocol.d.ts": "b298d1d35d6e261bbf6a0500f9029f38cad1532bb46475566180b3b92833c507",
15
+ "types/runtime.d.ts": "f52d08d7b53ff84394381f9d1741a624142f75fac468abf9a5029e98fe172ca4"
16
16
  }
17
17
  }