@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.
- package/dist/cli-root-help.js +1 -0
- package/dist/commands/references.js +55 -0
- package/dist/index.js +21 -3
- package/dist/references/client.js +69 -0
- package/dist/references/messages.js +57 -0
- package/dist/references/protocol.js +32 -0
- package/dist/utils/agent-generation-provenance.js +1 -1
- package/dist/workspace-runtime/batch/control.js +3 -3
- package/dist/workspace-runtime/batch/deliver.js +4 -3
- package/dist/workspace-runtime/batch/generate.js +4 -3
- package/dist/workspace-runtime/batch/install-log.js +49 -0
- package/dist/workspace-runtime/batch/policy.js +71 -22
- package/dist/workspace-runtime/batch/prepare.js +29 -13
- package/dist/workspace-runtime/batch/processes.js +86 -14
- package/dist/workspace-runtime/batch/writer-proof.js +2 -2
- package/dist/workspace-runtime/codex/app-server.js +305 -21
- package/dist/workspace-runtime/codex/generation-provenance-broker.js +17 -8
- package/dist/workspace-runtime/codex/launcher-client.js +34 -24
- package/dist/workspace-runtime/codex/loopback.js +2 -0
- package/dist/workspace-runtime/codex/model-broker.js +48 -8
- package/dist/workspace-runtime/codex/remix-references-broker.js +184 -0
- package/dist/workspace-runtime/codex/scheduled-task-tools.js +80 -0
- package/dist/workspace-runtime/serve.js +4 -1
- package/dist/workspace-runtime/server/directory-access.js +1 -1
- package/dist/workspace-runtime/server/dispatch.js +14 -6
- package/dist/workspace-runtime/server/http.js +43 -2
- package/dist/workspace-runtime/server/terminal-host.js +82 -1
- package/dist/workspace-runtime/server/workspace-storage.js +193 -0
- package/dist/workspace-runtime/workspaces/empty-template.js +2 -0
- package/dist/workspace-runtime/workspaces/registry.js +44 -3
- package/dist/workspace-runtime/workspaces/remix-reference-skill.js +126 -0
- package/dist/workspace-runtime/workspaces/scheduled-task-proposals.js +27 -0
- package/dist/workspace-runtime/workspaces/skills.js +3 -1
- package/dist/workspace-runtime/workspaces/storage-snapshot.js +235 -0
- package/package.json +3 -3
- package/project-template/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
- package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +0 -2
- package/project-template-v2/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
- package/project-template-v2/.playcraft/skills.lock.json +7 -0
- package/project-templates/empty/AGENTS.md +9 -0
- package/project-templates/phaser/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
- 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
|
|
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
|
|
187
|
-
const
|
|
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
|
|
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': {
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
47
|
-
|
|
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));
|