@playcraft/cli 0.1.58-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.
@@ -13,7 +13,7 @@ export async function agentGenerationProvenanceHeaders(path, body, requestId, cl
13
13
  return {};
14
14
  }
15
15
  const assetCreate = normalized === '/workspace-assets' && payload?.action === 'create';
16
- if (!isWorkspaceGenerationPath(normalized) && !assetCreate)
16
+ if (!isWorkspaceGenerationPath(normalized) && !assetCreate && normalized !== '/create-batch')
17
17
  return {};
18
18
  const endpoint = process.env.PLAYCRAFT_AGENT_GENERATION_ENDPOINT;
19
19
  const capability = process.env.PLAYCRAFT_AGENT_GENERATION_CAPABILITY;
@@ -86,7 +86,7 @@ export async function generateBatchMember(state, body) {
86
86
  // private
87
87
  await mkdir(tempDir, { recursive: true, mode: 0o700 });
88
88
  const policy = memberExecutionPolicy(root, tempDir);
89
- await bindMemberPolicy(state, sessionId, policy);
89
+ await bindMemberPolicy(state, sessionId, policy, { runId: command.runId, ownerFence: command.ownerFence });
90
90
  const modelConfigId = command.model && /^[0-9a-f-]{36}$/i.test(command.model) ? command.model : undefined;
91
91
  const turnPayload = { text: command.prompt, permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {}) };
92
92
  const turnKey = operation(state, { kind: 'session.turn', clientRequestId: `batch-turn:${command.runId}`, sessionId, workspaceId: command.workspaceId, directoryName: command.directoryName, payload: turnPayload });
@@ -1,4 +1,4 @@
1
- import { readFileSync, mkdirSync, writeFileSync, renameSync } from 'node:fs';
1
+ import { readFileSync, readdirSync, mkdirSync, writeFileSync, renameSync } from 'node:fs';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { dirname, join } from 'node:path';
4
4
  const memory = new WeakMap();
@@ -29,38 +29,87 @@ function parsePolicy(raw) {
29
29
  return undefined;
30
30
  return value;
31
31
  }
32
- export async function bindMemberPolicy(state, sessionId, policy) {
32
+ export async function bindMemberPolicy(state, sessionId, policy, owner) {
33
33
  const path = policyPath(state, sessionId);
34
34
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
35
35
  const temporary = `${path}.${randomUUID()}.tmp`;
36
- writeFileSync(temporary, JSON.stringify(policy), { mode: 0o600 });
36
+ const binding = { ...owner, policy };
37
+ writeFileSync(temporary, JSON.stringify(binding), { mode: 0o600 });
37
38
  renameSync(temporary, path);
38
39
  const map = memory.get(state) ?? new Map();
39
- map.set(sessionId, policy);
40
+ map.set(sessionId, binding);
40
41
  memory.set(state, map);
41
42
  }
42
- /** Synchronous: the shared session adapter reapplies this on every thread start and resume. */
43
+ function readBinding(raw) {
44
+ const value = JSON.parse(raw);
45
+ if (!('policy' in value)) {
46
+ const policy = parsePolicy(raw);
47
+ return policy ? { policy } : undefined;
48
+ }
49
+ const policy = parsePolicy(JSON.stringify(value.policy));
50
+ return policy && typeof value.runId === 'string' && /^[0-9a-f-]{36}$/i.test(value.runId)
51
+ && Number.isInteger(value.ownerFence) && value.ownerFence >= 0 ? { ...value, policy } : undefined;
52
+ }
53
+ function memberRecord(state, runId, sessionId) {
54
+ if (typeof runId !== 'string' || !/^[0-9a-f-]{36}$/i.test(runId))
55
+ throw new Error('member_policy_record_invalid');
56
+ const record = JSON.parse(readFileSync(join(state.config.stateDir, 'batch-members', `${runId}.json`), 'utf8'));
57
+ if (record.runId !== runId || record.sessionId !== sessionId || !Number.isInteger(record.ownerFence)
58
+ || typeof record.holding !== 'boolean' || typeof record.writersStopped !== 'boolean')
59
+ throw new Error('member_policy_record_invalid');
60
+ return record;
61
+ }
62
+ function sessionMembers(state, sessionId) {
63
+ return readdirSync(join(state.config.stateDir, 'batch-members')).filter(name => name.endsWith('.json'))
64
+ .map(name => JSON.parse(readFileSync(join(state.config.stateDir, 'batch-members', name), 'utf8')))
65
+ .filter(record => record.sessionId === sessionId)
66
+ .map(record => memberRecord(state, record.runId, sessionId));
67
+ }
68
+ /** Re-read the owning Run on every lookup; a cached policy is never proof of an active member. */
43
69
  export function trustedMemberPolicy(state, sessionId) {
44
- const cached = memory.get(state)?.get(sessionId);
45
- if (cached)
46
- return cached;
47
70
  try {
48
- const parsed = parsePolicy(readFileSync(policyPath(state, sessionId), 'utf8'));
49
- if (!parsed) {
50
- return {
51
- cwd: state.config.workspaceRoot,
52
- approvalPolicy: 'never',
53
- sandboxPolicy: { type: 'readOnly', networkAccess: false },
54
- };
71
+ let binding = memory.get(state)?.get(sessionId);
72
+ if (!binding) {
73
+ let raw;
74
+ try {
75
+ raw = readFileSync(policyPath(state, sessionId), 'utf8');
76
+ }
77
+ catch (error) {
78
+ if (error.code === 'ENOENT')
79
+ return undefined;
80
+ throw error;
81
+ }
82
+ binding = readBinding(raw);
83
+ if (!binding)
84
+ throw new Error('member_policy_invalid');
85
+ const map = memory.get(state) ?? new Map();
86
+ map.set(sessionId, binding);
87
+ memory.set(state, map);
55
88
  }
56
- const map = memory.get(state) ?? new Map();
57
- map.set(sessionId, parsed);
58
- memory.set(state, map);
59
- return parsed;
60
- }
61
- catch (error) {
62
- if (error.code === 'ENOENT')
89
+ if (binding.runId) {
90
+ const record = memberRecord(state, binding.runId, sessionId);
91
+ if (record.holding || !record.writersStopped) {
92
+ if (record.ownerFence !== binding.ownerFence)
93
+ throw new Error('member_policy_owner_mismatch');
94
+ return binding.policy;
95
+ }
96
+ if (sessionMembers(state, sessionId).some(member => member.holding || !member.writersStopped))
97
+ throw new Error('member_policy_owner_mismatch');
98
+ return undefined;
99
+ }
100
+ // Profiles written before Run ownership was recorded still use durable member facts.
101
+ const records = sessionMembers(state, sessionId);
102
+ if (!records.length)
103
+ throw new Error('member_policy_record_missing');
104
+ const active = records.filter(record => record.holding || !record.writersStopped);
105
+ if (!active.length)
63
106
  return undefined;
107
+ if (active.length !== 1 || binding.policy.sandboxPolicy.type !== 'workspaceWrite'
108
+ || !binding.policy.sandboxPolicy.writableRoots.includes(join(state.config.stateDir, 'batch-temp', active[0].runId)))
109
+ throw new Error('member_policy_owner_mismatch');
110
+ return binding.policy;
111
+ }
112
+ catch {
64
113
  return {
65
114
  cwd: state.config.workspaceRoot,
66
115
  approvalPolicy: 'never',
@@ -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)
@@ -412,6 +507,30 @@ export function createCodexAppServerAdapter(options) {
412
507
  const nativeClient = rpc;
413
508
  rpc.onNotification((method, params) => {
414
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
+ }
415
534
  if (method === 'turn/started' && typeof payload.threadId === 'string') {
416
535
  const thread = threads.get(payload.threadId), turnId = asRecord(payload.turn).id;
417
536
  if (thread?.turnActive && typeof turnId === 'string')
@@ -479,6 +598,15 @@ export function createCodexAppServerAdapter(options) {
479
598
  });
480
599
  rpc.onServerRequest((id, method, params) => {
481
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
+ }
482
610
  if (method === 'item/tool/call') {
483
611
  const thread = typeof payload.threadId === 'string' ? threads.get(payload.threadId) : undefined;
484
612
  if (deliveryTools && isWorkspaceDeliveryTool(payload.tool)) {
@@ -702,7 +830,7 @@ export function createCodexAppServerAdapter(options) {
702
830
  throw new Error('native_receipt_lost');
703
831
  prepared.activate();
704
832
  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 });
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 });
706
834
  threads.get(threadId).readOnlyConfirmed = isReadOnlySnapshot(result);
707
835
  return { threadId, modelConfigId: prepared.modelConfigId, reasoningEffort: input.reasoningEffort, permissionMode: input.permissionMode ?? 'auto' };
708
836
  });
@@ -731,7 +859,7 @@ export function createCodexAppServerAdapter(options) {
731
859
  return undefined;
732
860
  prepared.activate();
733
861
  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 });
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 });
735
863
  threads.get(input.threadId).readOnlyConfirmed = readOnlyConfirmed;
736
864
  return { modelConfigId: prepared.modelConfigId, reasoningEffort: input.reasoningEffort, permissionMode: input.permissionMode ?? 'auto' };
737
865
  });
@@ -741,6 +869,8 @@ export function createCodexAppServerAdapter(options) {
741
869
  await ensureStarted();
742
870
  if (!rpc)
743
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);
744
874
  if (threads.get(input.threadId)?.sessionId !== input.sessionId) {
745
875
  return { outcome: 'failed', error: 'unknown_thread' };
746
876
  }
@@ -777,6 +907,12 @@ export function createCodexAppServerAdapter(options) {
777
907
  else if (mcpChanged || (thread.skillsHash && (prepared.skillsHash !== thread.skillsHash || prepared.discoveryHash !== thread.discoveryHash))) {
778
908
  await restartForSkills(input.threadId, prepared.modelConfigId, reasoningEffort, mcpChanged ? 'mcp' : 'skills', mcpChanged ? prepared : undefined);
779
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
+ }
780
916
  if (!thread.loaded)
781
917
  await resumePreparedThread(input.threadId, thread, prepared);
782
918
  thread.skillsHash = prepared.skillsHash;
@@ -832,6 +968,122 @@ export function createCodexAppServerAdapter(options) {
832
968
  }
833
969
  });
834
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
+ },
835
1087
  async startReview(input) {
836
1088
  return withPreparation(async () => {
837
1089
  await ensureStarted();
@@ -973,6 +1225,7 @@ export function createCodexAppServerAdapter(options) {
973
1225
  },
974
1226
  async interrupt(input) {
975
1227
  await ensureStarted();
1228
+ await cancelPredictionForThread(input.threadId);
976
1229
  const thread = threads.get(input.threadId);
977
1230
  // Nothing is sent when no turn of this session runs here, so that answer is definite.
978
1231
  if (thread?.sessionId !== input.sessionId || !thread.turnActive)
@@ -983,11 +1236,13 @@ export function createCodexAppServerAdapter(options) {
983
1236
  // A turn that is still starting has no id yet; the last one is already over.
984
1237
  if (!turnId || turnId === thread.lastCompletedTurnId)
985
1238
  return { outcome: 'uncertain' };
1239
+ if (input.expectedTurnId !== undefined && input.expectedTurnId !== turnId)
1240
+ return { outcome: 'failed', error: 'turn_changed' };
986
1241
  try {
987
1242
  await uiTools.cancel(input.threadId);
988
1243
  await rpc.request('turn/interrupt', { threadId: input.threadId, turnId });
989
1244
  const cwd = threads.get(input.threadId)?.cwd;
990
- if (cwd)
1245
+ if (cwd && (input.expectedTurnId === undefined || threads.get(input.threadId)?.lastTurnId === turnId))
991
1246
  options.modelBroker?.cancelSession(cwd, input.sessionId);
992
1247
  }
993
1248
  catch {
@@ -1040,6 +1295,8 @@ export function createCodexAppServerAdapter(options) {
1040
1295
  },
1041
1296
  close() {
1042
1297
  closing ??= (async () => {
1298
+ for (const threadId of [...predictions.keys()])
1299
+ settlePrediction(threadId, 'prediction_unavailable');
1043
1300
  closed = true;
1044
1301
  readyFlag = false;
1045
1302
  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));