@playcraft/cli 0.1.57-beta → 0.1.59-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.
Files changed (89) hide show
  1. package/dist/commands/workspace.js +43 -0
  2. package/dist/utils/agent-generation-provenance.js +1 -1
  3. package/dist/workspace/messages.js +10 -0
  4. package/dist/workspace-runtime/batch/deliver.js +140 -80
  5. package/dist/workspace-runtime/batch/freeze.js +18 -6
  6. package/dist/workspace-runtime/batch/generate.js +68 -64
  7. package/dist/workspace-runtime/batch/policy.js +71 -22
  8. package/dist/workspace-runtime/batch/prepare.js +108 -93
  9. package/dist/workspace-runtime/batch/records.js +4 -0
  10. package/dist/workspace-runtime/batch/routes.js +10 -2
  11. package/dist/workspace-runtime/batch/source-candidate.js +2 -0
  12. package/dist/workspace-runtime/batch/source-capture.js +1 -0
  13. package/dist/workspace-runtime/batch/source-failure.js +2 -0
  14. package/dist/workspace-runtime/batch/source-publish.js +3 -0
  15. package/dist/workspace-runtime/batch/writer-proof.js +37 -0
  16. package/dist/workspace-runtime/codex/app-server.js +278 -20
  17. package/dist/workspace-runtime/codex/config-toml.js +4 -0
  18. package/dist/workspace-runtime/codex/delivery-tools.js +1 -1
  19. package/dist/workspace-runtime/codex/generation-provenance-broker.js +17 -8
  20. package/dist/workspace-runtime/codex/loopback.js +2 -0
  21. package/dist/workspace-runtime/codex/model-broker.js +7 -4
  22. package/dist/workspace-runtime/persistence/journal.js +34 -0
  23. package/dist/workspace-runtime/persistence/sqlite-worker.js +68 -6
  24. package/dist/workspace-runtime/previews/output.js +2 -0
  25. package/dist/workspace-runtime/previews/publish.js +20 -5
  26. package/dist/workspace-runtime/previews/runtime-preview.js +4 -0
  27. package/dist/workspace-runtime/processes/managed-writes.js +47 -5
  28. package/dist/workspace-runtime/serve.js +13 -3
  29. package/dist/workspace-runtime/server/accept-operation.js +22 -2
  30. package/dist/workspace-runtime/server/directory-access.js +414 -0
  31. package/dist/workspace-runtime/server/dispatch.js +74 -29
  32. package/dist/workspace-runtime/server/endpoints.js +1 -0
  33. package/dist/workspace-runtime/server/http.js +80 -10
  34. package/dist/workspace-runtime/server/operation-record.js +2 -0
  35. package/dist/workspace-runtime/server/terminal-host.js +124 -8
  36. package/dist/workspace-runtime/server/workspace-storage.js +193 -0
  37. package/dist/workspace-runtime/server/workspace-transfer.js +3 -1
  38. package/dist/workspace-runtime/workspaces/append-only-log.js +3 -0
  39. package/dist/workspace-runtime/workspaces/artifact-events.js +5 -2
  40. package/dist/workspace-runtime/workspaces/artifact-files.js +14 -8
  41. package/dist/workspace-runtime/workspaces/artifact-receipts.js +7 -25
  42. package/dist/workspace-runtime/workspaces/artifacts.js +4 -1
  43. package/dist/workspace-runtime/workspaces/asset-library-import.js +9 -4
  44. package/dist/workspace-runtime/workspaces/asset-publication-access.js +7 -9
  45. package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +3 -0
  46. package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +17 -2
  47. package/dist/workspace-runtime/workspaces/attachments.js +9 -0
  48. package/dist/workspace-runtime/workspaces/batch-commit.js +5 -3
  49. package/dist/workspace-runtime/workspaces/binary-write.js +12 -7
  50. package/dist/workspace-runtime/workspaces/cli-context.js +2 -0
  51. package/dist/workspace-runtime/workspaces/cli-credentials.js +1 -0
  52. package/dist/workspace-runtime/workspaces/delivery-build.js +30 -11
  53. package/dist/workspace-runtime/workspaces/delivery-store.js +2 -0
  54. package/dist/workspace-runtime/workspaces/dependencies.js +90 -70
  55. package/dist/workspace-runtime/workspaces/developer-command.js +12 -1
  56. package/dist/workspace-runtime/workspaces/directory-access.js +13 -18
  57. package/dist/workspace-runtime/workspaces/directory-attachments.js +8 -0
  58. package/dist/workspace-runtime/workspaces/directory-create.js +18 -5
  59. package/dist/workspace-runtime/workspaces/directory-maintenance.js +46 -35
  60. package/dist/workspace-runtime/workspaces/directory-mutations.js +8 -3
  61. package/dist/workspace-runtime/workspaces/files.js +4 -0
  62. package/dist/workspace-runtime/workspaces/git-credential-broker.js +3 -0
  63. package/dist/workspace-runtime/workspaces/git-direct-store.js +2 -0
  64. package/dist/workspace-runtime/workspaces/json5-edit.js +36 -0
  65. package/dist/workspace-runtime/workspaces/library-attachments.js +4 -0
  66. package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +2 -0
  67. package/dist/workspace-runtime/workspaces/parameter-agent-store.js +2 -0
  68. package/dist/workspace-runtime/workspaces/parameter-agent.js +40 -11
  69. package/dist/workspace-runtime/workspaces/parameter-assets.js +1 -0
  70. package/dist/workspace-runtime/workspaces/parameter-initialization-skill-transaction.js +141 -0
  71. package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +329 -0
  72. package/dist/workspace-runtime/workspaces/parameter-initialization.js +63 -0
  73. package/dist/workspace-runtime/workspaces/parameter-production.js +124 -0
  74. package/dist/workspace-runtime/workspaces/parameters.js +95 -3
  75. package/dist/workspace-runtime/workspaces/platform-export.js +5 -0
  76. package/dist/workspace-runtime/workspaces/prepare.js +4 -0
  77. package/dist/workspace-runtime/workspaces/registry.js +55 -3
  78. package/dist/workspace-runtime/workspaces/remix-references.js +5 -0
  79. package/dist/workspace-runtime/workspaces/shared-write.js +187 -0
  80. package/dist/workspace-runtime/workspaces/skills.js +14 -15
  81. package/dist/workspace-runtime/workspaces/storage-snapshot.js +235 -0
  82. package/dist/workspace-runtime/workspaces/transfer.js +4 -1
  83. package/dist/workspace-runtime/workspaces/workspace-shares.js +235 -0
  84. package/dist/workspace-runtime/workspaces/worktree-attachments.js +2 -0
  85. package/dist/workspace-runtime/workspaces/worktree-checkout.js +11 -6
  86. package/package.json +5 -4
  87. package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +32 -0
  88. package/project-template/.claude/skills/playcraft-runtime-parameters/references/platform-protocol.md +21 -0
  89. package/project-template/.claude/skills/playcraft-runtime-parameters/references/preview-integration.md +57 -0
@@ -32,6 +32,10 @@ const ENGINEERING_BATCH_INSTRUCTIONS = [
32
32
  '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.',
33
33
  'Pass --team-id when the trusted batch request context supplies teamId. Creation means queued; use playcraft tools get-batch --batch-id <the actual batch identifier> for results. On an uncertain creation, reuse the same clientRequestId. Report creation failures without falling back to editing the source.',
34
34
  ].join('\n');
35
+ const MEMBER_EXECUTION_INSTRUCTIONS = [
36
+ 'You are implementing one assigned variant in an independent Remix directory which the platform has already created.',
37
+ 'Apply the assigned changes directly to the current working directory. The originating source is a separate directory; source freezing, variant planning and directory creation have already been handled by the platform.',
38
+ ].join('\n');
35
39
  function codexReasoningEffort(effort) {
36
40
  return effort === 'max' ? 'xhigh' : effort;
37
41
  }
@@ -40,11 +44,65 @@ function asRecord(value) {
40
44
  ? value
41
45
  : {};
42
46
  }
47
+ function nativeContextHash(params) {
48
+ return createHash('sha256').update(JSON.stringify(params.developerInstructions ?? '')).digest('hex');
49
+ }
43
50
  function isReadOnlySnapshot(value) {
44
51
  const settings = asRecord(value);
45
52
  const policy = asRecord(settings.sandboxPolicy ?? settings.sandbox);
46
53
  return settings.approvalPolicy === 'never' && policy.type === 'readOnly' && policy.networkAccess === false;
47
54
  }
55
+ const DEFAULT_PREDICTION_TIMEOUT_MS = 15_000;
56
+ const PREDICTION_INSTRUCTIONS = [
57
+ "You predict the user's most likely next message in this coding session.",
58
+ 'Use only the conversation below. Do not answer it, continue the work, or call tools.',
59
+ "Write in the user's language as short imperative requests, at most 80 characters each.",
60
+ 'Return JSON only, with no markdown fences, exactly in this shape:',
61
+ '{"suggestions":["...","..."]}',
62
+ 'Provide 1 to 3 suggestions ordered by likelihood, without explanations.',
63
+ ].join('\n');
64
+ const PREDICTION_OUTPUT_SCHEMA = {
65
+ type: 'object',
66
+ additionalProperties: false,
67
+ required: ['suggestions'],
68
+ properties: {
69
+ suggestions: {
70
+ type: 'array',
71
+ minItems: 1,
72
+ maxItems: 3,
73
+ items: { type: 'string', minLength: 1, maxLength: 160 },
74
+ },
75
+ },
76
+ };
77
+ /** Parses the structured prediction turn without trusting markdown fences or extra prose. */
78
+ export function parsePredictionSuggestions(text) {
79
+ const cleaned = text.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim();
80
+ const start = cleaned.indexOf('{');
81
+ const end = cleaned.lastIndexOf('}');
82
+ if (start < 0 || end <= start)
83
+ return [];
84
+ let parsed;
85
+ try {
86
+ parsed = JSON.parse(cleaned.slice(start, end + 1));
87
+ }
88
+ catch {
89
+ return [];
90
+ }
91
+ const candidates = asRecord(parsed).suggestions;
92
+ if (!Array.isArray(candidates))
93
+ return [];
94
+ return candidates
95
+ .filter((value) => typeof value === 'string')
96
+ .map(value => value.trim())
97
+ .filter(Boolean)
98
+ .slice(0, 3);
99
+ }
100
+ function predictionPrompt(items) {
101
+ const recent = items.slice(-8)
102
+ .map(item => `${item.role.toUpperCase()}: ${item.text.slice(0, 4000)}`)
103
+ .join('\n\n');
104
+ return `${PREDICTION_INSTRUCTIONS}\n\nConversation (oldest first):\n${recent || '(no earlier messages)'}`;
105
+ }
48
106
  function waitForChildExit(child, processGroup = false) {
49
107
  if ((child.exitCode !== null || child.signalCode !== null) && child.stdout?.destroyed && child.stderr?.destroyed)
50
108
  return Promise.resolve();
@@ -89,6 +147,34 @@ export function createCodexAppServerAdapter(options) {
89
147
  const threads = new Map();
90
148
  const approvals = new Map();
91
149
  const childParents = new Map();
150
+ const predictions = new Map();
151
+ const predictionByParent = new Map();
152
+ const predictionTimeoutMs = options.predictionTimeoutMs ?? DEFAULT_PREDICTION_TIMEOUT_MS;
153
+ function settlePrediction(threadId, error) {
154
+ const prediction = predictions.get(threadId);
155
+ if (!prediction || prediction.settled)
156
+ return;
157
+ prediction.settled = true;
158
+ clearTimeout(prediction.timer);
159
+ predictions.delete(threadId);
160
+ if (predictionByParent.get(prediction.parentThreadId) === threadId)
161
+ predictionByParent.delete(prediction.parentThreadId);
162
+ const suggestions = error ? [] : parsePredictionSuggestions(prediction.chunks.join(''));
163
+ prediction.resolve(suggestions.length
164
+ ? { outcome: 'ok', suggestions }
165
+ : { outcome: 'failed', error: error ?? 'prediction_empty' });
166
+ }
167
+ async function cancelPredictionForThread(parentThreadId) {
168
+ const threadId = predictionByParent.get(parentThreadId);
169
+ const prediction = threadId ? predictions.get(threadId) : undefined;
170
+ if (!threadId || !prediction || !rpc)
171
+ return;
172
+ const turnId = prediction.turnId;
173
+ settlePrediction(threadId, 'prediction_cancelled');
174
+ if (turnId)
175
+ await rpc.request('turn/interrupt', { threadId, turnId }, 10_000).catch(() => undefined);
176
+ await rpc.request('thread/unsubscribe', { threadId }, 10_000).catch(() => undefined);
177
+ }
92
178
  let child;
93
179
  let rpc;
94
180
  let readyFlag = false;
@@ -153,9 +239,9 @@ export function createCodexAppServerAdapter(options) {
153
239
  startPromise ??= start();
154
240
  await startPromise;
155
241
  }
156
- async function threadConfig(cwd, modelConfigId, sessionId, reasoningEffort, clientRequestId) {
242
+ async function threadConfig(cwd, modelConfigId, sessionId, reasoningEffort, clientRequestId, originCwd) {
157
243
  try {
158
- return await options.modelBroker?.prepareThreadConfig(cwd, modelConfigId, sessionId, reasoningEffort, clientRequestId)
244
+ return await options.modelBroker?.prepareThreadConfig(cwd, modelConfigId, sessionId, reasoningEffort, clientRequestId, originCwd)
159
245
  ?? { ...options.fixedThreadConfig, modelConfigId: undefined, activate: () => { } };
160
246
  }
161
247
  catch (error) {
@@ -166,7 +252,20 @@ export function createCodexAppServerAdapter(options) {
166
252
  }
167
253
  }
168
254
  async function lockedThreadConfig(cwd, selectedConfigId, sessionId, reasoningEffort, clientRequestId, permissionMode = 'auto') {
169
- const { modelConfigId, activate, ...providedModelConfig } = await threadConfig(cwd, selectedConfigId, sessionId, reasoningEffort, clientRequestId);
255
+ const scope = relative(options.config.workspaceRoot, cwd);
256
+ const orchestration = options.config.workspaceMode === 'multi' && /^[0-9a-f-]{36}$/i.test(scope);
257
+ let coordinatorDirectory;
258
+ if (orchestration) {
259
+ const key = JSON.stringify([cwd, sessionId]);
260
+ coordinatorDirectory = coordinatorDirectories.get(key);
261
+ if (!coordinatorDirectory) {
262
+ coordinatorDirectory = await mkdtemp(join(tmpdir(), 'playcraft-batch-coordinator-'));
263
+ // The sandbox launches native commands as its development identity, not the controller.
264
+ await chmod(coordinatorDirectory, 0o777);
265
+ coordinatorDirectories.set(key, coordinatorDirectory);
266
+ }
267
+ }
268
+ const { modelConfigId, activate, ...providedModelConfig } = await threadConfig(cwd, selectedConfigId, sessionId, reasoningEffort, clientRequestId, coordinatorDirectory);
170
269
  let mcp;
171
270
  if (options.loadMcpConfig) {
172
271
  try {
@@ -183,19 +282,11 @@ export function createCodexAppServerAdapter(options) {
183
282
  ...mcp?.config,
184
283
  ...(reasoningEffort ? { model_reasoning_effort: codexReasoningEffort(reasoningEffort) } : {}),
185
284
  } };
186
- const scope = relative(options.config.workspaceRoot, cwd);
187
- const orchestration = options.config.workspaceMode === 'multi' && /^[0-9a-f-]{36}$/i.test(scope);
188
- const engineeringBatch = !options.fixedExecution && options.config.workspaceMode === 'multi'
285
+ const unattended = Boolean(options.fixedExecution || (sessionId && options.trustedExecution?.(sessionId)));
286
+ const engineeringBatch = !unattended && options.config.workspaceMode === 'multi'
189
287
  && /^[0-9a-f-]{36}\/(?:basic|remix-[a-z0-9][a-z0-9-]{3,63})$/i.test(scope);
190
288
  if (orchestration) {
191
- const key = JSON.stringify([cwd, sessionId]);
192
- let directory = coordinatorDirectories.get(key);
193
- if (!directory) {
194
- directory = await mkdtemp(join(tmpdir(), 'playcraft-batch-coordinator-'));
195
- // The sandbox launches native commands as its development identity, not the controller.
196
- await chmod(directory, 0o777);
197
- coordinatorDirectories.set(key, directory);
198
- }
289
+ const directory = coordinatorDirectory;
199
290
  const execution = executionPolicy(directory, permissionMode, { cwd: directory, approvalPolicy: 'never', sandboxPolicy: {
200
291
  type: 'workspaceWrite', writableRoots: [directory], networkAccess: true,
201
292
  excludeTmpdirEnvVar: true, excludeSlashTmp: true,
@@ -205,7 +296,8 @@ export function createCodexAppServerAdapter(options) {
205
296
  'sandbox_workspace_write.network_access': true,
206
297
  'sandbox_workspace_write.exclude_tmpdir_env_var': true,
207
298
  'sandbox_workspace_write.exclude_slash_tmp': true,
208
- 'shell_environment_policy.set': { PLAYCRAFT_TOOL_REQUEST_DIR: directory, PLAYCRAFT_SKIP_UPDATE_CHECK: '1', TMPDIR: directory }, };
299
+ 'shell_environment_policy.set': { ...asRecord(asRecord(modelConfig.config)['shell_environment_policy.set']),
300
+ PLAYCRAFT_TOOL_REQUEST_DIR: directory, PLAYCRAFT_SKIP_UPDATE_CHECK: '1', TMPDIR: directory }, };
209
301
  const developerInstructions = [
210
302
  'You coordinate PlayCraft batches. The workspace engineering directories are read-only in this conversation.',
211
303
  `Workspace identity: ${scope}. Engineering source root: ${JSON.stringify(cwd)}. Default source directory: basic.`,
@@ -228,7 +320,7 @@ export function createCodexAppServerAdapter(options) {
228
320
  const execution = resolveSessionExecution(cwd, permissionMode, sessionId ? options.trustedExecution?.(sessionId) : undefined, options.fixedExecution);
229
321
  const selected = threadExecutionParams(execution);
230
322
  return { params: { ...modelConfig, ...selected, config: withModelShell({ ...modelConfig.config, ...selected.config }, modelConfigId),
231
- ...(options.workspaceUi && !options.fixedExecution ? { developerInstructions: WORKSPACE_UI_INSTRUCTIONS } : {}) },
323
+ ...((unattended || options.workspaceUi) ? { developerInstructions: unattended ? MEMBER_EXECUTION_INSTRUCTIONS : WORKSPACE_UI_INSTRUCTIONS } : {}) },
232
324
  execution, modelConfigId, activate, skillsHash: undefined, discoveryHash: undefined, mcpHash: mcp?.hash };
233
325
  }
234
326
  try {
@@ -249,7 +341,8 @@ export function createCodexAppServerAdapter(options) {
249
341
  ...frozen.skills.map(skill => JSON.stringify(skill)),
250
342
  ...(frozen.skills.length ? [] : ['(No workspace skills are enabled.)']),
251
343
  ...(engineeringBatch ? [ENGINEERING_BATCH_INSTRUCTIONS] : []),
252
- ...(options.workspaceUi && !options.fixedExecution ? [WORKSPACE_UI_INSTRUCTIONS] : []),
344
+ ...(unattended ? [MEMBER_EXECUTION_INSTRUCTIONS] : []),
345
+ ...(options.workspaceUi && !unattended ? [WORKSPACE_UI_INSTRUCTIONS] : []),
253
346
  ].join('\n');
254
347
  const discoveryHash = createHash('sha256').update(JSON.stringify(listing.skills.map(skill => skill.path).sort())).digest('hex');
255
348
  const execution = resolveSessionExecution(cwd, permissionMode, sessionId ? options.trustedExecution?.(sessionId) : undefined, options.fixedExecution);
@@ -312,6 +405,7 @@ export function createCodexAppServerAdapter(options) {
312
405
  async function resumePreparedThread(threadId, thread, prepared) {
313
406
  const resumed = await rpc.request('thread/resume', {
314
407
  threadId, ...prepared.params,
408
+ ...(dynamicTools.length ? { dynamicTools } : {}),
315
409
  }, 30_000);
316
410
  if (resumed.thread?.id !== threadId)
317
411
  throw new NativePreparationError('workspace_skills_unavailable');
@@ -320,6 +414,7 @@ export function createCodexAppServerAdapter(options) {
320
414
  thread.skillsHash = prepared.skillsHash;
321
415
  thread.discoveryHash = prepared.discoveryHash;
322
416
  thread.mcpHash = prepared.mcpHash;
417
+ thread.contextHash = nativeContextHash(prepared.params);
323
418
  }
324
419
  async function start() {
325
420
  if (closed)
@@ -372,6 +467,7 @@ export function createCodexAppServerAdapter(options) {
372
467
  }
373
468
  else {
374
469
  // Local execution owns its fresh profile; managed launcher profiles are prepared for the development identity by the image.
470
+ // private
375
471
  await mkdir(homeDir, { recursive: true, mode: 0o700 });
376
472
  if (closed)
377
473
  throw new Error('codex_not_started');
@@ -411,6 +507,30 @@ export function createCodexAppServerAdapter(options) {
411
507
  const nativeClient = rpc;
412
508
  rpc.onNotification((method, params) => {
413
509
  const payload = asRecord(params);
510
+ const predictionThreadId = typeof payload.threadId === 'string' ? payload.threadId : undefined;
511
+ const prediction = predictionThreadId ? predictions.get(predictionThreadId) : undefined;
512
+ if (prediction && predictionThreadId) {
513
+ if (method === 'turn/started') {
514
+ const turnId = asRecord(payload.turn).id;
515
+ if (typeof turnId === 'string')
516
+ prediction.turnId = turnId;
517
+ }
518
+ else if (method === 'item/agentMessage/delta' && typeof payload.delta === 'string') {
519
+ prediction.chunks.push(payload.delta);
520
+ }
521
+ else if (method === 'item/completed') {
522
+ const message = asRecord(payload.item);
523
+ if (message.type === 'agentMessage' && typeof message.text === 'string' && !prediction.chunks.length)
524
+ prediction.chunks.push(message.text);
525
+ }
526
+ else if (method === 'turn/completed') {
527
+ settlePrediction(predictionThreadId);
528
+ }
529
+ else if (method === 'error') {
530
+ settlePrediction(predictionThreadId, 'prediction_failed');
531
+ }
532
+ return;
533
+ }
414
534
  if (method === 'turn/started' && typeof payload.threadId === 'string') {
415
535
  const thread = threads.get(payload.threadId), turnId = asRecord(payload.turn).id;
416
536
  if (thread?.turnActive && typeof turnId === 'string')
@@ -478,6 +598,15 @@ export function createCodexAppServerAdapter(options) {
478
598
  });
479
599
  rpc.onServerRequest((id, method, params) => {
480
600
  const payload = asRecord(params);
601
+ const prediction = typeof payload.threadId === 'string' ? predictions.get(payload.threadId) : undefined;
602
+ if (prediction) {
603
+ const threadId = String(payload.threadId);
604
+ const turnId = prediction.turnId;
605
+ settlePrediction(threadId, 'prediction_tool_request');
606
+ if (turnId)
607
+ void nativeClient.request('turn/interrupt', { threadId, turnId }, 10_000).catch(() => undefined);
608
+ return;
609
+ }
481
610
  if (method === 'item/tool/call') {
482
611
  const thread = typeof payload.threadId === 'string' ? threads.get(payload.threadId) : undefined;
483
612
  if (deliveryTools && isWorkspaceDeliveryTool(payload.tool)) {
@@ -701,7 +830,7 @@ export function createCodexAppServerAdapter(options) {
701
830
  throw new Error('native_receipt_lost');
702
831
  prepared.activate();
703
832
  threads.set(threadId, { sessionId: input.sessionId, cwd: input.cwd, skillsHash: prepared.skillsHash,
704
- discoveryHash: prepared.discoveryHash, mcpHash: prepared.mcpHash, 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: false, loaded: true });
833
+ 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: false, loaded: true });
705
834
  threads.get(threadId).readOnlyConfirmed = isReadOnlySnapshot(result);
706
835
  return { threadId, modelConfigId: prepared.modelConfigId, reasoningEffort: input.reasoningEffort, permissionMode: input.permissionMode ?? 'auto' };
707
836
  });
@@ -730,7 +859,7 @@ export function createCodexAppServerAdapter(options) {
730
859
  return undefined;
731
860
  prepared.activate();
732
861
  threads.set(input.threadId, { sessionId: input.sessionId, cwd: input.cwd, skillsHash: prepared.skillsHash,
733
- discoveryHash: prepared.discoveryHash, mcpHash: prepared.mcpHash, 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 });
862
+ 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 });
734
863
  threads.get(input.threadId).readOnlyConfirmed = readOnlyConfirmed;
735
864
  return { modelConfigId: prepared.modelConfigId, reasoningEffort: input.reasoningEffort, permissionMode: input.permissionMode ?? 'auto' };
736
865
  });
@@ -740,6 +869,8 @@ export function createCodexAppServerAdapter(options) {
740
869
  await ensureStarted();
741
870
  if (!rpc)
742
871
  throw new Error('codex_not_started');
872
+ // A real turn always wins over a speculative prediction for the same thread.
873
+ await cancelPredictionForThread(input.threadId);
743
874
  if (threads.get(input.threadId)?.sessionId !== input.sessionId) {
744
875
  return { outcome: 'failed', error: 'unknown_thread' };
745
876
  }
@@ -776,6 +907,12 @@ export function createCodexAppServerAdapter(options) {
776
907
  else if (mcpChanged || (thread.skillsHash && (prepared.skillsHash !== thread.skillsHash || prepared.discoveryHash !== thread.discoveryHash))) {
777
908
  await restartForSkills(input.threadId, prepared.modelConfigId, reasoningEffort, mcpChanged ? 'mcp' : 'skills', mcpChanged ? prepared : undefined);
778
909
  }
910
+ if (thread.loaded && thread.contextHash !== nativeContextHash(prepared.params)) {
911
+ if (!thread.hasNativeHistory)
912
+ return { outcome: 'failed', error: 'unknown_thread' };
913
+ await rpc.request('thread/unsubscribe', { threadId: input.threadId }, 30_000);
914
+ thread.loaded = false;
915
+ }
779
916
  if (!thread.loaded)
780
917
  await resumePreparedThread(input.threadId, thread, prepared);
781
918
  thread.skillsHash = prepared.skillsHash;
@@ -831,6 +968,122 @@ export function createCodexAppServerAdapter(options) {
831
968
  }
832
969
  });
833
970
  },
971
+ async startPrediction(input) {
972
+ await ensureStarted();
973
+ if (!rpc)
974
+ return { outcome: 'failed', error: 'prediction_unavailable' };
975
+ const reader = rpc;
976
+ const parent = threads.get(input.threadId);
977
+ if (!parent || parent.sessionId !== input.sessionId)
978
+ return { outcome: 'failed', error: 'unknown_thread' };
979
+ if (parent.turnActive)
980
+ return { outcome: 'failed', error: 'turn_already_active' };
981
+ if (predictionByParent.has(input.threadId))
982
+ await cancelPredictionForThread(input.threadId);
983
+ let items = [];
984
+ try {
985
+ // Recent turns only: full thread/read hydration is deprecated for paginated threads
986
+ // and grows with the whole session. The summary view still carries user/agent text.
987
+ const page = await reader.request('thread/turns/list', { threadId: input.threadId, limit: 4 }, 15_000);
988
+ for (const turn of [...(page.data ?? [])].reverse()) {
989
+ for (const rawItem of turn.items ?? []) {
990
+ const item = asRecord(rawItem);
991
+ if (item.type === 'userMessage') {
992
+ const text = (Array.isArray(item.content) ? item.content : [])
993
+ .map(part => asRecord(part))
994
+ .filter(part => part.type === 'text' && typeof part.text === 'string')
995
+ .map(part => String(part.text)).join('\n').trim();
996
+ if (text)
997
+ items.push({ role: 'user', text });
998
+ }
999
+ else if (item.type === 'agentMessage' && typeof item.text === 'string' && item.text.trim()) {
1000
+ items.push({ role: 'assistant', text: item.text });
1001
+ }
1002
+ }
1003
+ }
1004
+ items = items.slice(-8);
1005
+ }
1006
+ catch {
1007
+ // Older builds without paginated turn listing: fall back to the whole thread.
1008
+ try {
1009
+ const read = await reader.request('thread/read', { threadId: input.threadId, includeTurns: true }, 15_000);
1010
+ items = projectNativeThread(input.threadId, read.thread ?? read)?.items ?? [];
1011
+ }
1012
+ catch {
1013
+ // An empty transcript still lets the model propose generic next steps.
1014
+ }
1015
+ }
1016
+ let forkId;
1017
+ try {
1018
+ const forked = await withPreparation(async () => {
1019
+ await ensureStarted();
1020
+ if (!rpc)
1021
+ throw new Error('codex_not_started');
1022
+ // Prediction forks cannot combine with config overrides; they inherit the
1023
+ // parent thread's model provider, i.e. the workspace broker route.
1024
+ return await rpc.request('thread/fork', {
1025
+ threadId: input.threadId,
1026
+ ephemeral: true,
1027
+ excludeTurns: true,
1028
+ threadSource: 'composer_predictions',
1029
+ experimentalPredictionMode: true,
1030
+ }, 30_000);
1031
+ });
1032
+ forkId = forked.thread?.id;
1033
+ }
1034
+ catch {
1035
+ return { outcome: 'failed', error: 'prediction_unavailable' };
1036
+ }
1037
+ if (!forkId)
1038
+ return { outcome: 'failed', error: 'prediction_unavailable' };
1039
+ const predictionThreadId = forkId;
1040
+ const client = rpc;
1041
+ if (!client)
1042
+ return { outcome: 'failed', error: 'prediction_unavailable' };
1043
+ childParents.set(predictionThreadId, input.threadId);
1044
+ const result = new Promise((resolve) => {
1045
+ const timer = setTimeout(() => {
1046
+ const pending = predictions.get(predictionThreadId);
1047
+ if (pending?.turnId)
1048
+ void client.request('turn/interrupt', {
1049
+ threadId: predictionThreadId, turnId: pending.turnId,
1050
+ }, 10_000).catch(() => undefined);
1051
+ settlePrediction(predictionThreadId, 'prediction_timeout');
1052
+ }, predictionTimeoutMs);
1053
+ timer.unref?.();
1054
+ predictions.set(predictionThreadId, {
1055
+ parentThreadId: input.threadId, chunks: [], settled: false, resolve, timer,
1056
+ });
1057
+ predictionByParent.set(input.threadId, predictionThreadId);
1058
+ });
1059
+ try {
1060
+ const started = await client.request('turn/start', {
1061
+ threadId: predictionThreadId,
1062
+ input: [{ type: 'text', text: predictionPrompt(items) }],
1063
+ outputSchema: PREDICTION_OUTPUT_SCHEMA,
1064
+ turnTrigger: 'prediction',
1065
+ clientUserMessageId: `prediction_${randomUUID()}`,
1066
+ approvalPolicy: 'never',
1067
+ sandboxPolicy: { type: 'readOnly', networkAccess: false },
1068
+ }, 30_000);
1069
+ const pending = predictions.get(predictionThreadId);
1070
+ if (pending && started.turn?.id)
1071
+ pending.turnId = started.turn.id;
1072
+ }
1073
+ catch {
1074
+ settlePrediction(predictionThreadId, 'prediction_unavailable');
1075
+ }
1076
+ try {
1077
+ return await result;
1078
+ }
1079
+ finally {
1080
+ predictions.delete(predictionThreadId);
1081
+ if (predictionByParent.get(input.threadId) === predictionThreadId)
1082
+ predictionByParent.delete(input.threadId);
1083
+ childParents.delete(predictionThreadId);
1084
+ await client.request('thread/unsubscribe', { threadId: predictionThreadId }, 10_000).catch(() => undefined);
1085
+ }
1086
+ },
834
1087
  async startReview(input) {
835
1088
  return withPreparation(async () => {
836
1089
  await ensureStarted();
@@ -972,6 +1225,7 @@ export function createCodexAppServerAdapter(options) {
972
1225
  },
973
1226
  async interrupt(input) {
974
1227
  await ensureStarted();
1228
+ await cancelPredictionForThread(input.threadId);
975
1229
  const thread = threads.get(input.threadId);
976
1230
  // Nothing is sent when no turn of this session runs here, so that answer is definite.
977
1231
  if (thread?.sessionId !== input.sessionId || !thread.turnActive)
@@ -982,11 +1236,13 @@ export function createCodexAppServerAdapter(options) {
982
1236
  // A turn that is still starting has no id yet; the last one is already over.
983
1237
  if (!turnId || turnId === thread.lastCompletedTurnId)
984
1238
  return { outcome: 'uncertain' };
1239
+ if (input.expectedTurnId !== undefined && input.expectedTurnId !== turnId)
1240
+ return { outcome: 'failed', error: 'turn_changed' };
985
1241
  try {
986
1242
  await uiTools.cancel(input.threadId);
987
1243
  await rpc.request('turn/interrupt', { threadId: input.threadId, turnId });
988
1244
  const cwd = threads.get(input.threadId)?.cwd;
989
- if (cwd)
1245
+ if (cwd && (input.expectedTurnId === undefined || threads.get(input.threadId)?.lastTurnId === turnId))
990
1246
  options.modelBroker?.cancelSession(cwd, input.sessionId);
991
1247
  }
992
1248
  catch {
@@ -1039,6 +1295,8 @@ export function createCodexAppServerAdapter(options) {
1039
1295
  },
1040
1296
  close() {
1041
1297
  closing ??= (async () => {
1298
+ for (const threadId of [...predictions.keys()])
1299
+ settlePrediction(threadId, 'prediction_unavailable');
1042
1300
  closed = true;
1043
1301
  readyFlag = false;
1044
1302
  await uiTools.cancel(undefined, 'ui_runtime_disconnected');
@@ -77,7 +77,9 @@ export async function writePortableCodexCatalog(input) {
77
77
  }
78
78
  await mkdir(input.directory, { recursive: true });
79
79
  const path = join(input.directory, 'portable-models.json');
80
+ // private
80
81
  await writeFile(path, body, { encoding: 'utf8', mode: 0o644 });
82
+ // private
81
83
  await writeFile(join(input.directory, 'portable-models.version.json'), JSON.stringify({
82
84
  version,
83
85
  sha256: createHash('sha256').update(body).digest('hex'),
@@ -85,6 +87,7 @@ export async function writePortableCodexCatalog(input) {
85
87
  return path;
86
88
  }
87
89
  export async function writePlaycraftCodexConfig(input) {
90
+ // private
88
91
  await mkdir(input.homeDir, { recursive: true, mode: 0o700 });
89
92
  const path = join(input.homeDir, 'config.toml');
90
93
  const body = [
@@ -102,6 +105,7 @@ export async function writePlaycraftCodexConfig(input) {
102
105
  'requires_openai_auth = false',
103
106
  '',
104
107
  ].join('\n');
108
+ // private
105
109
  await writeFile(path, body, { encoding: 'utf8', mode: 0o600 });
106
110
  return path;
107
111
  }
@@ -5,7 +5,7 @@ export const WORKSPACE_DELIVERY_TOOLS = [
5
5
  {
6
6
  type: 'function',
7
7
  name: 'playcraft_build_delivery',
8
- description: 'Export channel packages through the PlayCraft platform. This first saves the workspace (commit and push), then exports that commit; each channel reuses the options of its latest export on this branch, or the defaults when it has none. A workspace without a project first gets one, with a private repository, as its first save would. Returns build ids immediately; poll playcraft_get_delivery for status, checks and failure details.',
8
+ description: 'Export channel packages through the PlayCraft platform. This first saves the workspace (commit and push), then exports that commit; each channel reuses the options of its latest export on this branch, or the defaults when it has none. A workspace without a project first gets one, with a private repository, as its first save would. If the result is pending at workspace.save, finish the current turn without polling so the save can run, then continue the export in a later turn. Once build ids are returned, poll playcraft_get_delivery for status, checks and failure details.',
9
9
  inputSchema: {
10
10
  type: 'object',
11
11
  properties: {
@@ -13,10 +13,16 @@ export class WorkspaceGenerationProvenanceBroker {
13
13
  const root = this.state.native?.executionProcess?.();
14
14
  return socketPath && root ? prepareLauncherOrigin({ socketPath, rootPid: root.pid, runtimeEpoch: this.state.runtimeEpoch }) : undefined;
15
15
  }
16
- operation(context) {
17
- const active = this.state.journal.listOperations().map(item => item.operation).filter(operation => ['session.turn', 'session.review', 'session.compact'].includes(operation.kind)
16
+ operation(context, batch = false) {
17
+ let active = this.state.journal.listOperations().map(item => item.operation).filter(operation => ['session.turn', 'session.review', 'session.compact'].includes(operation.kind)
18
18
  && ['dispatched', 'accepted', 'uncertain'].includes(operation.status)
19
19
  && operation.generation === this.state.config.generation && operation.runtimeEpoch === this.state.runtimeEpoch);
20
+ if (batch) {
21
+ const owned = active.find(operation => operation.sessionId === context.sessionId && operation.clientRequestId === context.clientRequestId);
22
+ if (!owned)
23
+ return undefined;
24
+ active = active.filter(operation => operation.workspaceId === owned.workspaceId && operation.directoryName === owned.directoryName);
25
+ }
20
26
  if (active.length !== 1)
21
27
  return undefined;
22
28
  const operation = active[0];
@@ -32,8 +38,7 @@ export class WorkspaceGenerationProvenanceBroker {
32
38
  const socketPath = process.env.PLAYCRAFT_LAUNCHER_SOCKET;
33
39
  const deny = () => { response.writeHead(403, { 'cache-control': 'no-store' }); response.end(); };
34
40
  try {
35
- const operation = this.operation(context);
36
- if (!valid() || !socketPath || !context.boundaryId || !operation || request.socket.remoteAddress !== '127.0.0.1'
41
+ if (!valid() || !socketPath || !context.boundaryId || request.socket.remoteAddress !== '127.0.0.1'
37
42
  || !request.socket.localPort || !request.socket.remotePort)
38
43
  return deny();
39
44
  let text = '';
@@ -43,11 +48,15 @@ export class WorkspaceGenerationProvenanceBroker {
43
48
  return deny();
44
49
  }
45
50
  const parsed = requestSchema.safeParse(JSON.parse(text));
46
- if (!parsed.success || !await verifyLauncherOrigin({ socketPath, boundaryId: context.boundaryId,
47
- runtimeEpoch: this.state.runtimeEpoch, serverPort: request.socket.localPort, clientPort: request.socket.remotePort, cwd: context.cwd }))
51
+ if (!parsed.success)
52
+ return deny();
53
+ const batch = parsed.data.toolPath === '/agent/tools/create-batch';
54
+ const operation = this.operation(context, batch);
55
+ if (!operation || !await verifyLauncherOrigin({ socketPath, boundaryId: context.boundaryId,
56
+ runtimeEpoch: this.state.runtimeEpoch, serverPort: request.socket.localPort, clientPort: request.socket.remotePort, cwd: context.originCwd ?? context.cwd }))
48
57
  return deny();
49
58
  // The socket lookup may take time. Never attest a turn which ended or changed meanwhile.
50
- if (!valid() || this.operation(context)?.operationId !== operation.operationId)
59
+ if (!valid() || this.operation(context, batch)?.operationId !== operation.operationId)
51
60
  return deny();
52
61
  const config = this.state.config;
53
62
  const input = agentGenerationProvenanceRequestSchema.parse({ ...parsed.data,
@@ -65,7 +74,7 @@ export class WorkspaceGenerationProvenanceBroker {
65
74
  return deny();
66
75
  }
67
76
  const value = z.object({ proof: z.string().min(1).max(8192) }).strict().parse(await upstream.json());
68
- if (!valid() || this.operation(context)?.operationId !== operation.operationId)
77
+ if (!valid() || this.operation(context, batch)?.operationId !== operation.operationId)
69
78
  return deny();
70
79
  response.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
71
80
  response.end(JSON.stringify(value));
@@ -87,6 +87,8 @@ export function createLoopbackNativeAdapter() {
87
87
  if (!thread || thread.sessionId !== input.sessionId)
88
88
  return { outcome: 'uncertain' };
89
89
  const turnId = thread.turnId;
90
+ if (input.expectedTurnId !== undefined && input.expectedTurnId !== turnId)
91
+ return { outcome: 'failed', error: 'turn_changed' };
90
92
  queueMicrotask(() => {
91
93
  emit(collectedEvent('notification', 'turn/completed', {
92
94
  threadId: input.threadId,
@@ -52,7 +52,7 @@ export class WorkspaceModelBroker {
52
52
  activate();
53
53
  return settings;
54
54
  }
55
- async prepareThreadConfig(cwd, modelConfigId, sessionId, reasoningEffort, clientRequestId) {
55
+ async prepareThreadConfig(cwd, modelConfigId, sessionId, reasoningEffort, clientRequestId, originCwd) {
56
56
  if (!this.server)
57
57
  throw new Error('workspace_not_registered');
58
58
  const { workspaceId } = await workspaceContextFromRoot(this.state, cwd);
@@ -61,11 +61,12 @@ export class WorkspaceModelBroker {
61
61
  const routeKey = sessionId ? `${workspaceId}/${createHash('sha256').update(sessionId).digest('hex').slice(0, 32)}` : workspaceId;
62
62
  let route = this.routes.get(routeKey);
63
63
  const selected = modelConfigId ?? (route?.epoch === this.state.runtimeEpoch ? route.selected?.entry.modelConfigId : undefined);
64
- const cacheKey = `${workspaceId}:${selected ?? 'default'}:${reasoningEffort ?? 'default'}`;
64
+ const cacheKey = `${workspaceId}:${selected ?? 'default'}:${reasoningEffort ?? 'default'}:${sessionId ?? ''}:${clientRequestId ?? ''}`;
65
65
  let entry = this.entries.get(cacheKey) ?? [...this.entries.values()].find(item => item.workspaceId === workspaceId && item.epoch === this.state.runtimeEpoch && item.modelConfigId === selected
66
+ && item.sessionId === sessionId && item.clientRequestId === clientRequestId
66
67
  && item.reasoningEffort === reasoningEffort);
67
68
  if (!entry || entry.epoch !== this.state.runtimeEpoch) {
68
- entry = { workspaceId, epoch: this.state.runtimeEpoch, cacheKey, modelConfigId: selected, reasoningEffort };
69
+ entry = { workspaceId, epoch: this.state.runtimeEpoch, cacheKey, modelConfigId: selected, reasoningEffort, sessionId, clientRequestId };
69
70
  this.entries.set(cacheKey, entry);
70
71
  }
71
72
  // Resolve current model metadata and permission at every native turn boundary.
@@ -82,7 +83,7 @@ export class WorkspaceModelBroker {
82
83
  if (!this.valid(entry) || this.routes.get(routeKey) !== route)
83
84
  throw new Error('model_access_denied');
84
85
  route.selected = { entry, model: credential.model,
85
- ...(sessionId && clientRequestId ? { feedback: { sessionId, clientRequestId, cwd, boundaryId } } : {}) };
86
+ ...(sessionId && clientRequestId ? { feedback: { sessionId, clientRequestId, cwd, boundaryId, ...(originCwd ? { originCwd } : {}) } } : {}) };
86
87
  },
87
88
  model: credential.model,
88
89
  modelConfigId: credential.configId,
@@ -125,6 +126,8 @@ export class WorkspaceModelBroker {
125
126
  headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
126
127
  body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation,
127
128
  runtimeEpoch: entry.epoch, workspaceId: entry.workspaceId,
129
+ ...(entry.sessionId ? { sessionId: entry.sessionId } : {}),
130
+ ...(entry.clientRequestId ? { clientRequestId: entry.clientRequestId } : {}),
128
131
  ...(entry.modelConfigId ? { modelConfigId: entry.modelConfigId } : {}),
129
132
  ...(entry.reasoningEffort ? { reasoningEffort: entry.reasoningEffort } : {}) }),
130
133
  });