@playcraft/cli 0.1.58-beta → 0.1.60-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 (42) hide show
  1. package/dist/cli-root-help.js +1 -0
  2. package/dist/commands/references.js +55 -0
  3. package/dist/index.js +21 -3
  4. package/dist/references/client.js +69 -0
  5. package/dist/references/messages.js +57 -0
  6. package/dist/references/protocol.js +32 -0
  7. package/dist/utils/agent-generation-provenance.js +1 -1
  8. package/dist/workspace-runtime/batch/control.js +3 -3
  9. package/dist/workspace-runtime/batch/deliver.js +4 -3
  10. package/dist/workspace-runtime/batch/generate.js +4 -3
  11. package/dist/workspace-runtime/batch/install-log.js +49 -0
  12. package/dist/workspace-runtime/batch/policy.js +71 -22
  13. package/dist/workspace-runtime/batch/prepare.js +29 -13
  14. package/dist/workspace-runtime/batch/processes.js +86 -14
  15. package/dist/workspace-runtime/batch/writer-proof.js +2 -2
  16. package/dist/workspace-runtime/codex/app-server.js +305 -21
  17. package/dist/workspace-runtime/codex/generation-provenance-broker.js +17 -8
  18. package/dist/workspace-runtime/codex/launcher-client.js +34 -24
  19. package/dist/workspace-runtime/codex/loopback.js +2 -0
  20. package/dist/workspace-runtime/codex/model-broker.js +48 -8
  21. package/dist/workspace-runtime/codex/remix-references-broker.js +184 -0
  22. package/dist/workspace-runtime/codex/scheduled-task-tools.js +80 -0
  23. package/dist/workspace-runtime/serve.js +4 -1
  24. package/dist/workspace-runtime/server/directory-access.js +1 -1
  25. package/dist/workspace-runtime/server/dispatch.js +14 -6
  26. package/dist/workspace-runtime/server/http.js +43 -2
  27. package/dist/workspace-runtime/server/terminal-host.js +82 -1
  28. package/dist/workspace-runtime/server/workspace-storage.js +193 -0
  29. package/dist/workspace-runtime/workspaces/empty-template.js +2 -0
  30. package/dist/workspace-runtime/workspaces/registry.js +44 -3
  31. package/dist/workspace-runtime/workspaces/remix-reference-skill.js +126 -0
  32. package/dist/workspace-runtime/workspaces/scheduled-task-proposals.js +27 -0
  33. package/dist/workspace-runtime/workspaces/skills.js +3 -1
  34. package/dist/workspace-runtime/workspaces/storage-snapshot.js +235 -0
  35. package/package.json +3 -3
  36. package/project-template/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
  37. package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +0 -2
  38. package/project-template-v2/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
  39. package/project-template-v2/.playcraft/skills.lock.json +7 -0
  40. package/project-templates/empty/AGENTS.md +9 -0
  41. package/project-templates/phaser/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
  42. package/project-templates/threejs/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
@@ -19,6 +19,7 @@ import { readNativeThreadImage } from './thread-image.js';
19
19
  import { WORKSPACE_UI_TOOLS, WorkspaceUiTools } from './ui-tools.js';
20
20
  import { WORKSPACE_DELIVERY_TOOLS, WorkspaceDeliveryTools, isWorkspaceDeliveryTool } from './delivery-tools.js';
21
21
  import { WORKSPACE_UI_INSTRUCTIONS } from './ui-instructions.js';
22
+ import { WORKSPACE_SCHEDULED_TASK_TOOLS, WorkspaceScheduledTaskTools, isWorkspaceScheduledTaskTool } from './scheduled-task-tools.js';
22
23
  function withModelShell(config, modelConfigId) {
23
24
  if (!modelConfigId || !/^[0-9a-f-]{36}$/i.test(modelConfigId))
24
25
  return config;
@@ -32,6 +33,10 @@ const ENGINEERING_BATCH_INSTRUCTIONS = [
32
33
  '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
34
  '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
35
  ].join('\n');
36
+ const MEMBER_EXECUTION_INSTRUCTIONS = [
37
+ 'You are implementing one assigned variant in an independent Remix directory which the platform has already created.',
38
+ '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.',
39
+ ].join('\n');
35
40
  function codexReasoningEffort(effort) {
36
41
  return effort === 'max' ? 'xhigh' : effort;
37
42
  }
@@ -40,11 +45,65 @@ function asRecord(value) {
40
45
  ? value
41
46
  : {};
42
47
  }
48
+ function nativeContextHash(params) {
49
+ return createHash('sha256').update(JSON.stringify(params.developerInstructions ?? '')).digest('hex');
50
+ }
43
51
  function isReadOnlySnapshot(value) {
44
52
  const settings = asRecord(value);
45
53
  const policy = asRecord(settings.sandboxPolicy ?? settings.sandbox);
46
54
  return settings.approvalPolicy === 'never' && policy.type === 'readOnly' && policy.networkAccess === false;
47
55
  }
56
+ const DEFAULT_PREDICTION_TIMEOUT_MS = 15_000;
57
+ const PREDICTION_INSTRUCTIONS = [
58
+ "You predict the user's most likely next message in this coding session.",
59
+ 'Use only the conversation below. Do not answer it, continue the work, or call tools.',
60
+ "Write in the user's language as short imperative requests, at most 80 characters each.",
61
+ 'Return JSON only, with no markdown fences, exactly in this shape:',
62
+ '{"suggestions":["...","..."]}',
63
+ 'Provide 1 to 3 suggestions ordered by likelihood, without explanations.',
64
+ ].join('\n');
65
+ const PREDICTION_OUTPUT_SCHEMA = {
66
+ type: 'object',
67
+ additionalProperties: false,
68
+ required: ['suggestions'],
69
+ properties: {
70
+ suggestions: {
71
+ type: 'array',
72
+ minItems: 1,
73
+ maxItems: 3,
74
+ items: { type: 'string', minLength: 1, maxLength: 160 },
75
+ },
76
+ },
77
+ };
78
+ /** Parses the structured prediction turn without trusting markdown fences or extra prose. */
79
+ export function parsePredictionSuggestions(text) {
80
+ const cleaned = text.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim();
81
+ const start = cleaned.indexOf('{');
82
+ const end = cleaned.lastIndexOf('}');
83
+ if (start < 0 || end <= start)
84
+ return [];
85
+ let parsed;
86
+ try {
87
+ parsed = JSON.parse(cleaned.slice(start, end + 1));
88
+ }
89
+ catch {
90
+ return [];
91
+ }
92
+ const candidates = asRecord(parsed).suggestions;
93
+ if (!Array.isArray(candidates))
94
+ return [];
95
+ return candidates
96
+ .filter((value) => typeof value === 'string')
97
+ .map(value => value.trim())
98
+ .filter(Boolean)
99
+ .slice(0, 3);
100
+ }
101
+ function predictionPrompt(items) {
102
+ const recent = items.slice(-8)
103
+ .map(item => `${item.role.toUpperCase()}: ${item.text.slice(0, 4000)}`)
104
+ .join('\n\n');
105
+ return `${PREDICTION_INSTRUCTIONS}\n\nConversation (oldest first):\n${recent || '(no earlier messages)'}`;
106
+ }
48
107
  function waitForChildExit(child, processGroup = false) {
49
108
  if ((child.exitCode !== null || child.signalCode !== null) && child.stdout?.destroyed && child.stderr?.destroyed)
50
109
  return Promise.resolve();
@@ -89,6 +148,34 @@ export function createCodexAppServerAdapter(options) {
89
148
  const threads = new Map();
90
149
  const approvals = new Map();
91
150
  const childParents = new Map();
151
+ const predictions = new Map();
152
+ const predictionByParent = new Map();
153
+ const predictionTimeoutMs = options.predictionTimeoutMs ?? DEFAULT_PREDICTION_TIMEOUT_MS;
154
+ function settlePrediction(threadId, error) {
155
+ const prediction = predictions.get(threadId);
156
+ if (!prediction || prediction.settled)
157
+ return;
158
+ prediction.settled = true;
159
+ clearTimeout(prediction.timer);
160
+ predictions.delete(threadId);
161
+ if (predictionByParent.get(prediction.parentThreadId) === threadId)
162
+ predictionByParent.delete(prediction.parentThreadId);
163
+ const suggestions = error ? [] : parsePredictionSuggestions(prediction.chunks.join(''));
164
+ prediction.resolve(suggestions.length
165
+ ? { outcome: 'ok', suggestions }
166
+ : { outcome: 'failed', error: error ?? 'prediction_empty' });
167
+ }
168
+ async function cancelPredictionForThread(parentThreadId) {
169
+ const threadId = predictionByParent.get(parentThreadId);
170
+ const prediction = threadId ? predictions.get(threadId) : undefined;
171
+ if (!threadId || !prediction || !rpc)
172
+ return;
173
+ const turnId = prediction.turnId;
174
+ settlePrediction(threadId, 'prediction_cancelled');
175
+ if (turnId)
176
+ await rpc.request('turn/interrupt', { threadId, turnId }, 10_000).catch(() => undefined);
177
+ await rpc.request('thread/unsubscribe', { threadId }, 10_000).catch(() => undefined);
178
+ }
92
179
  let child;
93
180
  let rpc;
94
181
  let readyFlag = false;
@@ -105,9 +192,13 @@ export function createCodexAppServerAdapter(options) {
105
192
  const coordinatorDirectories = new Map();
106
193
  const uiTools = new WorkspaceUiTools(emitCollected, options.uiToolTimeoutMs);
107
194
  const deliveryTools = options.deliveryTools ? new WorkspaceDeliveryTools(options.deliveryTools) : undefined;
195
+ const scheduledTaskTools = options.scheduledTaskProposals ? new WorkspaceScheduledTaskTools(options.scheduledTaskProposals, (proposalId, source) => emitCollected(collectedEvent('notification', 'scheduled-task.proposal.changed', {
196
+ proposalId, sessionId: threads.get(source.threadId)?.sessionId, threadId: source.threadId, turnId: source.turnId,
197
+ }))) : undefined;
108
198
  const dynamicTools = [
109
199
  ...(options.workspaceUi ? WORKSPACE_UI_TOOLS : []),
110
200
  ...(deliveryTools ? WORKSPACE_DELIVERY_TOOLS : []),
201
+ ...(scheduledTaskTools ? WORKSPACE_SCHEDULED_TASK_TOOLS : []),
111
202
  ];
112
203
  function emitCollected(event) {
113
204
  for (const listener of collected)
@@ -153,9 +244,9 @@ export function createCodexAppServerAdapter(options) {
153
244
  startPromise ??= start();
154
245
  await startPromise;
155
246
  }
156
- async function threadConfig(cwd, modelConfigId, sessionId, reasoningEffort, clientRequestId) {
247
+ async function threadConfig(cwd, modelConfigId, sessionId, reasoningEffort, clientRequestId, originCwd) {
157
248
  try {
158
- return await options.modelBroker?.prepareThreadConfig(cwd, modelConfigId, sessionId, reasoningEffort, clientRequestId)
249
+ return await options.modelBroker?.prepareThreadConfig(cwd, modelConfigId, sessionId, reasoningEffort, clientRequestId, originCwd)
159
250
  ?? { ...options.fixedThreadConfig, modelConfigId: undefined, activate: () => { } };
160
251
  }
161
252
  catch (error) {
@@ -166,7 +257,20 @@ export function createCodexAppServerAdapter(options) {
166
257
  }
167
258
  }
168
259
  async function lockedThreadConfig(cwd, selectedConfigId, sessionId, reasoningEffort, clientRequestId, permissionMode = 'auto') {
169
- const { modelConfigId, activate, ...providedModelConfig } = await threadConfig(cwd, selectedConfigId, sessionId, reasoningEffort, clientRequestId);
260
+ const scope = relative(options.config.workspaceRoot, cwd);
261
+ const orchestration = options.config.workspaceMode === 'multi' && /^[0-9a-f-]{36}$/i.test(scope);
262
+ let coordinatorDirectory;
263
+ if (orchestration) {
264
+ const key = JSON.stringify([cwd, sessionId]);
265
+ coordinatorDirectory = coordinatorDirectories.get(key);
266
+ if (!coordinatorDirectory) {
267
+ coordinatorDirectory = await mkdtemp(join(tmpdir(), 'playcraft-batch-coordinator-'));
268
+ // The sandbox launches native commands as its development identity, not the controller.
269
+ await chmod(coordinatorDirectory, 0o777);
270
+ coordinatorDirectories.set(key, coordinatorDirectory);
271
+ }
272
+ }
273
+ const { modelConfigId, activate, ...providedModelConfig } = await threadConfig(cwd, selectedConfigId, sessionId, reasoningEffort, clientRequestId, coordinatorDirectory);
170
274
  let mcp;
171
275
  if (options.loadMcpConfig) {
172
276
  try {
@@ -183,19 +287,11 @@ export function createCodexAppServerAdapter(options) {
183
287
  ...mcp?.config,
184
288
  ...(reasoningEffort ? { model_reasoning_effort: codexReasoningEffort(reasoningEffort) } : {}),
185
289
  } };
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'
290
+ const unattended = Boolean(options.fixedExecution || (sessionId && options.trustedExecution?.(sessionId)));
291
+ const engineeringBatch = !unattended && options.config.workspaceMode === 'multi'
189
292
  && /^[0-9a-f-]{36}\/(?:basic|remix-[a-z0-9][a-z0-9-]{3,63})$/i.test(scope);
190
293
  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
- }
294
+ const directory = coordinatorDirectory;
199
295
  const execution = executionPolicy(directory, permissionMode, { cwd: directory, approvalPolicy: 'never', sandboxPolicy: {
200
296
  type: 'workspaceWrite', writableRoots: [directory], networkAccess: true,
201
297
  excludeTmpdirEnvVar: true, excludeSlashTmp: true,
@@ -205,7 +301,8 @@ export function createCodexAppServerAdapter(options) {
205
301
  'sandbox_workspace_write.network_access': true,
206
302
  'sandbox_workspace_write.exclude_tmpdir_env_var': true,
207
303
  'sandbox_workspace_write.exclude_slash_tmp': true,
208
- 'shell_environment_policy.set': { PLAYCRAFT_TOOL_REQUEST_DIR: directory, PLAYCRAFT_SKIP_UPDATE_CHECK: '1', TMPDIR: directory }, };
304
+ 'shell_environment_policy.set': { ...asRecord(asRecord(modelConfig.config)['shell_environment_policy.set']),
305
+ PLAYCRAFT_TOOL_REQUEST_DIR: directory, PLAYCRAFT_SKIP_UPDATE_CHECK: '1', TMPDIR: directory }, };
209
306
  const developerInstructions = [
210
307
  'You coordinate PlayCraft batches. The workspace engineering directories are read-only in this conversation.',
211
308
  `Workspace identity: ${scope}. Engineering source root: ${JSON.stringify(cwd)}. Default source directory: basic.`,
@@ -228,7 +325,7 @@ export function createCodexAppServerAdapter(options) {
228
325
  const execution = resolveSessionExecution(cwd, permissionMode, sessionId ? options.trustedExecution?.(sessionId) : undefined, options.fixedExecution);
229
326
  const selected = threadExecutionParams(execution);
230
327
  return { params: { ...modelConfig, ...selected, config: withModelShell({ ...modelConfig.config, ...selected.config }, modelConfigId),
231
- ...(options.workspaceUi && !options.fixedExecution ? { developerInstructions: WORKSPACE_UI_INSTRUCTIONS } : {}) },
328
+ ...((unattended || options.workspaceUi) ? { developerInstructions: unattended ? MEMBER_EXECUTION_INSTRUCTIONS : WORKSPACE_UI_INSTRUCTIONS } : {}) },
232
329
  execution, modelConfigId, activate, skillsHash: undefined, discoveryHash: undefined, mcpHash: mcp?.hash };
233
330
  }
234
331
  try {
@@ -249,7 +346,8 @@ export function createCodexAppServerAdapter(options) {
249
346
  ...frozen.skills.map(skill => JSON.stringify(skill)),
250
347
  ...(frozen.skills.length ? [] : ['(No workspace skills are enabled.)']),
251
348
  ...(engineeringBatch ? [ENGINEERING_BATCH_INSTRUCTIONS] : []),
252
- ...(options.workspaceUi && !options.fixedExecution ? [WORKSPACE_UI_INSTRUCTIONS] : []),
349
+ ...(unattended ? [MEMBER_EXECUTION_INSTRUCTIONS] : []),
350
+ ...(options.workspaceUi && !unattended ? [WORKSPACE_UI_INSTRUCTIONS] : []),
253
351
  ].join('\n');
254
352
  const discoveryHash = createHash('sha256').update(JSON.stringify(listing.skills.map(skill => skill.path).sort())).digest('hex');
255
353
  const execution = resolveSessionExecution(cwd, permissionMode, sessionId ? options.trustedExecution?.(sessionId) : undefined, options.fixedExecution);
@@ -312,6 +410,7 @@ export function createCodexAppServerAdapter(options) {
312
410
  async function resumePreparedThread(threadId, thread, prepared) {
313
411
  const resumed = await rpc.request('thread/resume', {
314
412
  threadId, ...prepared.params,
413
+ ...(dynamicTools.length ? { dynamicTools } : {}),
315
414
  }, 30_000);
316
415
  if (resumed.thread?.id !== threadId)
317
416
  throw new NativePreparationError('workspace_skills_unavailable');
@@ -320,6 +419,7 @@ export function createCodexAppServerAdapter(options) {
320
419
  thread.skillsHash = prepared.skillsHash;
321
420
  thread.discoveryHash = prepared.discoveryHash;
322
421
  thread.mcpHash = prepared.mcpHash;
422
+ thread.contextHash = nativeContextHash(prepared.params);
323
423
  }
324
424
  async function start() {
325
425
  if (closed)
@@ -412,6 +512,30 @@ export function createCodexAppServerAdapter(options) {
412
512
  const nativeClient = rpc;
413
513
  rpc.onNotification((method, params) => {
414
514
  const payload = asRecord(params);
515
+ const predictionThreadId = typeof payload.threadId === 'string' ? payload.threadId : undefined;
516
+ const prediction = predictionThreadId ? predictions.get(predictionThreadId) : undefined;
517
+ if (prediction && predictionThreadId) {
518
+ if (method === 'turn/started') {
519
+ const turnId = asRecord(payload.turn).id;
520
+ if (typeof turnId === 'string')
521
+ prediction.turnId = turnId;
522
+ }
523
+ else if (method === 'item/agentMessage/delta' && typeof payload.delta === 'string') {
524
+ prediction.chunks.push(payload.delta);
525
+ }
526
+ else if (method === 'item/completed') {
527
+ const message = asRecord(payload.item);
528
+ if (message.type === 'agentMessage' && typeof message.text === 'string' && !prediction.chunks.length)
529
+ prediction.chunks.push(message.text);
530
+ }
531
+ else if (method === 'turn/completed') {
532
+ settlePrediction(predictionThreadId);
533
+ }
534
+ else if (method === 'error') {
535
+ settlePrediction(predictionThreadId, 'prediction_failed');
536
+ }
537
+ return;
538
+ }
415
539
  if (method === 'turn/started' && typeof payload.threadId === 'string') {
416
540
  const thread = threads.get(payload.threadId), turnId = asRecord(payload.turn).id;
417
541
  if (thread?.turnActive && typeof turnId === 'string')
@@ -479,8 +603,37 @@ export function createCodexAppServerAdapter(options) {
479
603
  });
480
604
  rpc.onServerRequest((id, method, params) => {
481
605
  const payload = asRecord(params);
606
+ const prediction = typeof payload.threadId === 'string' ? predictions.get(payload.threadId) : undefined;
607
+ if (prediction) {
608
+ const threadId = String(payload.threadId);
609
+ const turnId = prediction.turnId;
610
+ settlePrediction(threadId, 'prediction_tool_request');
611
+ if (turnId)
612
+ void nativeClient.request('turn/interrupt', { threadId, turnId }, 10_000).catch(() => undefined);
613
+ return;
614
+ }
482
615
  if (method === 'item/tool/call') {
483
616
  const thread = typeof payload.threadId === 'string' ? threads.get(payload.threadId) : undefined;
617
+ if (scheduledTaskTools && isWorkspaceScheduledTaskTool(payload.tool)) {
618
+ const threadId = String(payload.threadId ?? ''), turnId = String(payload.turnId ?? '');
619
+ const current = () => {
620
+ try {
621
+ return Boolean(thread && threads.get(threadId) === thread && thread.turnActive
622
+ && thread.lastTurnId === turnId && !childParents.has(threadId)
623
+ && ['execute', 'plan'].includes(thread.activeMode ?? '') && !options.fixedExecution
624
+ && !options.trustedExecution?.(thread.sessionId));
625
+ }
626
+ catch {
627
+ return false;
628
+ }
629
+ };
630
+ setTimeout(() => {
631
+ void scheduledTaskTools.request(id, payload, nativeClient, thread ? {
632
+ bridge: thread.scheduledTaskProposalBridge, threadId, turnId, current,
633
+ } : undefined);
634
+ }, 0);
635
+ return;
636
+ }
484
637
  if (deliveryTools && isWorkspaceDeliveryTool(payload.tool)) {
485
638
  void deliveryTools.request(id, payload, nativeClient, {
486
639
  sessionId: thread?.sessionId,
@@ -606,6 +759,7 @@ export function createCodexAppServerAdapter(options) {
606
759
  name: 'codex-app-server',
607
760
  workspaceUi: options.workspaceUi === true,
608
761
  ready: () => readyFlag && !closed,
762
+ ...(scheduledTaskTools ? { scheduledTaskProposalSource: (source) => scheduledTaskTools.isActive(source) } : {}),
609
763
  executionProcess() {
610
764
  if (!live)
611
765
  return undefined;
@@ -702,7 +856,7 @@ export function createCodexAppServerAdapter(options) {
702
856
  throw new Error('native_receipt_lost');
703
857
  prepared.activate();
704
858
  threads.set(threadId, { sessionId: input.sessionId, cwd: input.cwd, skillsHash: prepared.skillsHash,
705
- 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 });
859
+ 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 });
706
860
  threads.get(threadId).readOnlyConfirmed = isReadOnlySnapshot(result);
707
861
  return { threadId, modelConfigId: prepared.modelConfigId, reasoningEffort: input.reasoningEffort, permissionMode: input.permissionMode ?? 'auto' };
708
862
  });
@@ -731,7 +885,7 @@ export function createCodexAppServerAdapter(options) {
731
885
  return undefined;
732
886
  prepared.activate();
733
887
  threads.set(input.threadId, { sessionId: input.sessionId, cwd: input.cwd, skillsHash: prepared.skillsHash,
734
- 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 });
888
+ 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 });
735
889
  threads.get(input.threadId).readOnlyConfirmed = readOnlyConfirmed;
736
890
  return { modelConfigId: prepared.modelConfigId, reasoningEffort: input.reasoningEffort, permissionMode: input.permissionMode ?? 'auto' };
737
891
  });
@@ -741,6 +895,8 @@ export function createCodexAppServerAdapter(options) {
741
895
  await ensureStarted();
742
896
  if (!rpc)
743
897
  throw new Error('codex_not_started');
898
+ // A real turn always wins over a speculative prediction for the same thread.
899
+ await cancelPredictionForThread(input.threadId);
744
900
  if (threads.get(input.threadId)?.sessionId !== input.sessionId) {
745
901
  return { outcome: 'failed', error: 'unknown_thread' };
746
902
  }
@@ -777,6 +933,12 @@ export function createCodexAppServerAdapter(options) {
777
933
  else if (mcpChanged || (thread.skillsHash && (prepared.skillsHash !== thread.skillsHash || prepared.discoveryHash !== thread.discoveryHash))) {
778
934
  await restartForSkills(input.threadId, prepared.modelConfigId, reasoningEffort, mcpChanged ? 'mcp' : 'skills', mcpChanged ? prepared : undefined);
779
935
  }
936
+ if (thread.loaded && thread.contextHash !== nativeContextHash(prepared.params)) {
937
+ if (!thread.hasNativeHistory)
938
+ return { outcome: 'failed', error: 'unknown_thread' };
939
+ await rpc.request('thread/unsubscribe', { threadId: input.threadId }, 30_000);
940
+ thread.loaded = false;
941
+ }
780
942
  if (!thread.loaded)
781
943
  await resumePreparedThread(input.threadId, thread, prepared);
782
944
  thread.skillsHash = prepared.skillsHash;
@@ -791,7 +953,7 @@ export function createCodexAppServerAdapter(options) {
791
953
  selectedModel ??= thread.resolvedModel ?? model;
792
954
  const requestId = rpc.reserveRequest();
793
955
  await input.persistReservation({ nativeRequestId: String(requestId), modelConfigId, reasoningEffort, permissionMode });
794
- activateModel();
956
+ activateModel(execution.sandboxPolicy.type === 'readOnly');
795
957
  thread.modelConfigId = modelConfigId;
796
958
  thread.reasoningEffort = reasoningEffort;
797
959
  thread.permissionMode = permissionMode;
@@ -805,6 +967,7 @@ export function createCodexAppServerAdapter(options) {
805
967
  return { outcome: 'uncertain', threadId: input.threadId };
806
968
  try {
807
969
  thread.turnActive = true;
970
+ thread.scheduledTaskProposalBridge = input.scheduledTaskProposalBridge;
808
971
  thread.uiSnapshot = options.workspaceUi && input.uiSnapshot?.sessionId === input.sessionId ? input.uiSnapshot : undefined;
809
972
  const started = await rpc.sendReserved(requestId, 'turn/start', { threadId: input.threadId, input: [
810
973
  { type: 'text', text: input.text },
@@ -832,6 +995,122 @@ export function createCodexAppServerAdapter(options) {
832
995
  }
833
996
  });
834
997
  },
998
+ async startPrediction(input) {
999
+ await ensureStarted();
1000
+ if (!rpc)
1001
+ return { outcome: 'failed', error: 'prediction_unavailable' };
1002
+ const reader = rpc;
1003
+ const parent = threads.get(input.threadId);
1004
+ if (!parent || parent.sessionId !== input.sessionId)
1005
+ return { outcome: 'failed', error: 'unknown_thread' };
1006
+ if (parent.turnActive)
1007
+ return { outcome: 'failed', error: 'turn_already_active' };
1008
+ if (predictionByParent.has(input.threadId))
1009
+ await cancelPredictionForThread(input.threadId);
1010
+ let items = [];
1011
+ try {
1012
+ // Recent turns only: full thread/read hydration is deprecated for paginated threads
1013
+ // and grows with the whole session. The summary view still carries user/agent text.
1014
+ const page = await reader.request('thread/turns/list', { threadId: input.threadId, limit: 4 }, 15_000);
1015
+ for (const turn of [...(page.data ?? [])].reverse()) {
1016
+ for (const rawItem of turn.items ?? []) {
1017
+ const item = asRecord(rawItem);
1018
+ if (item.type === 'userMessage') {
1019
+ const text = (Array.isArray(item.content) ? item.content : [])
1020
+ .map(part => asRecord(part))
1021
+ .filter(part => part.type === 'text' && typeof part.text === 'string')
1022
+ .map(part => String(part.text)).join('\n').trim();
1023
+ if (text)
1024
+ items.push({ role: 'user', text });
1025
+ }
1026
+ else if (item.type === 'agentMessage' && typeof item.text === 'string' && item.text.trim()) {
1027
+ items.push({ role: 'assistant', text: item.text });
1028
+ }
1029
+ }
1030
+ }
1031
+ items = items.slice(-8);
1032
+ }
1033
+ catch {
1034
+ // Older builds without paginated turn listing: fall back to the whole thread.
1035
+ try {
1036
+ const read = await reader.request('thread/read', { threadId: input.threadId, includeTurns: true }, 15_000);
1037
+ items = projectNativeThread(input.threadId, read.thread ?? read)?.items ?? [];
1038
+ }
1039
+ catch {
1040
+ // An empty transcript still lets the model propose generic next steps.
1041
+ }
1042
+ }
1043
+ let forkId;
1044
+ try {
1045
+ const forked = await withPreparation(async () => {
1046
+ await ensureStarted();
1047
+ if (!rpc)
1048
+ throw new Error('codex_not_started');
1049
+ // Prediction forks cannot combine with config overrides; they inherit the
1050
+ // parent thread's model provider, i.e. the workspace broker route.
1051
+ return await rpc.request('thread/fork', {
1052
+ threadId: input.threadId,
1053
+ ephemeral: true,
1054
+ excludeTurns: true,
1055
+ threadSource: 'composer_predictions',
1056
+ experimentalPredictionMode: true,
1057
+ }, 30_000);
1058
+ });
1059
+ forkId = forked.thread?.id;
1060
+ }
1061
+ catch {
1062
+ return { outcome: 'failed', error: 'prediction_unavailable' };
1063
+ }
1064
+ if (!forkId)
1065
+ return { outcome: 'failed', error: 'prediction_unavailable' };
1066
+ const predictionThreadId = forkId;
1067
+ const client = rpc;
1068
+ if (!client)
1069
+ return { outcome: 'failed', error: 'prediction_unavailable' };
1070
+ childParents.set(predictionThreadId, input.threadId);
1071
+ const result = new Promise((resolve) => {
1072
+ const timer = setTimeout(() => {
1073
+ const pending = predictions.get(predictionThreadId);
1074
+ if (pending?.turnId)
1075
+ void client.request('turn/interrupt', {
1076
+ threadId: predictionThreadId, turnId: pending.turnId,
1077
+ }, 10_000).catch(() => undefined);
1078
+ settlePrediction(predictionThreadId, 'prediction_timeout');
1079
+ }, predictionTimeoutMs);
1080
+ timer.unref?.();
1081
+ predictions.set(predictionThreadId, {
1082
+ parentThreadId: input.threadId, chunks: [], settled: false, resolve, timer,
1083
+ });
1084
+ predictionByParent.set(input.threadId, predictionThreadId);
1085
+ });
1086
+ try {
1087
+ const started = await client.request('turn/start', {
1088
+ threadId: predictionThreadId,
1089
+ input: [{ type: 'text', text: predictionPrompt(items) }],
1090
+ outputSchema: PREDICTION_OUTPUT_SCHEMA,
1091
+ turnTrigger: 'prediction',
1092
+ clientUserMessageId: `prediction_${randomUUID()}`,
1093
+ approvalPolicy: 'never',
1094
+ sandboxPolicy: { type: 'readOnly', networkAccess: false },
1095
+ }, 30_000);
1096
+ const pending = predictions.get(predictionThreadId);
1097
+ if (pending && started.turn?.id)
1098
+ pending.turnId = started.turn.id;
1099
+ }
1100
+ catch {
1101
+ settlePrediction(predictionThreadId, 'prediction_unavailable');
1102
+ }
1103
+ try {
1104
+ return await result;
1105
+ }
1106
+ finally {
1107
+ predictions.delete(predictionThreadId);
1108
+ if (predictionByParent.get(input.threadId) === predictionThreadId)
1109
+ predictionByParent.delete(input.threadId);
1110
+ childParents.delete(predictionThreadId);
1111
+ await client.request('thread/unsubscribe', { threadId: predictionThreadId }, 10_000).catch(() => undefined);
1112
+ }
1113
+ },
835
1114
  async startReview(input) {
836
1115
  return withPreparation(async () => {
837
1116
  await ensureStarted();
@@ -973,6 +1252,7 @@ export function createCodexAppServerAdapter(options) {
973
1252
  },
974
1253
  async interrupt(input) {
975
1254
  await ensureStarted();
1255
+ await cancelPredictionForThread(input.threadId);
976
1256
  const thread = threads.get(input.threadId);
977
1257
  // Nothing is sent when no turn of this session runs here, so that answer is definite.
978
1258
  if (thread?.sessionId !== input.sessionId || !thread.turnActive)
@@ -983,11 +1263,13 @@ export function createCodexAppServerAdapter(options) {
983
1263
  // A turn that is still starting has no id yet; the last one is already over.
984
1264
  if (!turnId || turnId === thread.lastCompletedTurnId)
985
1265
  return { outcome: 'uncertain' };
1266
+ if (input.expectedTurnId !== undefined && input.expectedTurnId !== turnId)
1267
+ return { outcome: 'failed', error: 'turn_changed' };
986
1268
  try {
987
1269
  await uiTools.cancel(input.threadId);
988
1270
  await rpc.request('turn/interrupt', { threadId: input.threadId, turnId });
989
1271
  const cwd = threads.get(input.threadId)?.cwd;
990
- if (cwd)
1272
+ if (cwd && (input.expectedTurnId === undefined || threads.get(input.threadId)?.lastTurnId === turnId))
991
1273
  options.modelBroker?.cancelSession(cwd, input.sessionId);
992
1274
  }
993
1275
  catch {
@@ -1040,6 +1322,8 @@ export function createCodexAppServerAdapter(options) {
1040
1322
  },
1041
1323
  close() {
1042
1324
  closing ??= (async () => {
1325
+ for (const threadId of [...predictions.keys()])
1326
+ settlePrediction(threadId, 'prediction_unavailable');
1043
1327
  closed = true;
1044
1328
  readyFlag = false;
1045
1329
  await uiTools.cancel(undefined, 'ui_runtime_disconnected');
@@ -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));