@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.
- package/dist/utils/agent-generation-provenance.js +1 -1
- package/dist/workspace-runtime/batch/generate.js +1 -1
- package/dist/workspace-runtime/batch/policy.js +71 -22
- package/dist/workspace-runtime/codex/app-server.js +277 -20
- package/dist/workspace-runtime/codex/generation-provenance-broker.js +17 -8
- package/dist/workspace-runtime/codex/loopback.js +2 -0
- package/dist/workspace-runtime/codex/model-broker.js +7 -4
- package/dist/workspace-runtime/serve.js +2 -1
- package/dist/workspace-runtime/server/directory-access.js +1 -1
- package/dist/workspace-runtime/server/dispatch.js +13 -6
- package/dist/workspace-runtime/server/http.js +29 -1
- 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/registry.js +44 -3
- package/dist/workspace-runtime/workspaces/storage-snapshot.js +235 -0
- package/package.json +3 -3
|
@@ -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
|
-
|
|
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,
|
|
40
|
+
map.set(sessionId, binding);
|
|
40
41
|
memory.set(state, map);
|
|
41
42
|
}
|
|
42
|
-
|
|
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
|
-
|
|
49
|
-
if (!
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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
|
|
187
|
-
const
|
|
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
|
|
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': {
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
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
|
-
|
|
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));
|