@playcraft/cli 0.1.28 → 0.1.30

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.
@@ -0,0 +1,46 @@
1
+ import { NativeRpcResponseError } from './jsonrpc-stdio.js';
2
+ /** The compact receipt is empty. A separately observed turn supplies its durable identity. */
3
+ export async function requestNativeCompaction(input) {
4
+ let turnId;
5
+ let rejectStarted = () => { };
6
+ let unsubscribe = () => { };
7
+ let timer;
8
+ const started = new Promise((resolve, reject) => {
9
+ rejectStarted = reject;
10
+ timer = setTimeout(() => reject(new Error('compact_start_timeout')), input.timeoutMs);
11
+ unsubscribe = input.onCollected(event => {
12
+ if (event.kind === 'process_exit') {
13
+ reject(new Error('compact_process_exited'));
14
+ return;
15
+ }
16
+ if (event.method !== 'turn/started')
17
+ return;
18
+ const params = record(event.params), turn = record(params.turn);
19
+ if (params.threadId !== input.threadId || typeof turn.id !== 'string' || !turn.id || turn.id === input.previousTurnId)
20
+ return;
21
+ turnId = turn.id;
22
+ resolve(turn.id);
23
+ });
24
+ });
25
+ try {
26
+ // Subscribe before sending: Codex may emit the new turn before acknowledging this request.
27
+ await Promise.all([started, input.client.sendReserved(input.requestId, 'thread/compact/start', {
28
+ threadId: input.threadId,
29
+ }, input.timeoutMs)]);
30
+ return { outcome: 'accepted', threadId: input.threadId, turnId: turnId };
31
+ }
32
+ catch (error) {
33
+ if (error instanceof NativeRpcResponseError && !turnId)
34
+ return { outcome: 'failed', error: 'compact_start_rejected' };
35
+ return { outcome: 'uncertain', threadId: input.threadId, ...(turnId ? { turnId } : {}) };
36
+ }
37
+ finally {
38
+ if (timer)
39
+ clearTimeout(timer);
40
+ unsubscribe();
41
+ rejectStarted(new Error('compact_request_finished'));
42
+ }
43
+ }
44
+ function record(value) {
45
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
46
+ }
@@ -1,4 +1,12 @@
1
1
  import { EventEmitter } from 'node:events';
2
+ export class NativeRpcResponseError extends Error {
3
+ code;
4
+ constructor(message, code) {
5
+ super(message);
6
+ this.code = code;
7
+ this.name = 'NativeRpcResponseError';
8
+ }
9
+ }
2
10
  export function createJsonRpcStdioClient(child) {
3
11
  const stdin = child.stdin;
4
12
  const stdout = child.stdout;
@@ -10,6 +18,14 @@ export function createJsonRpcStdioClient(child) {
10
18
  let turnStarts = 0;
11
19
  let buffer = '';
12
20
  let closed = false;
21
+ const onTransportError = () => {
22
+ for (const [id, waiter] of pending) {
23
+ clearTimeout(waiter.timer);
24
+ waiter.reject(new Error('jsonrpc_closed'));
25
+ pending.delete(id);
26
+ }
27
+ };
28
+ stdin.on('error', onTransportError);
13
29
  const onData = (chunk) => {
14
30
  buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
15
31
  let newline = buffer.indexOf('\n');
@@ -31,14 +47,14 @@ export function createJsonRpcStdioClient(child) {
31
47
  catch {
32
48
  return;
33
49
  }
34
- if (message.id !== undefined && pending.has(Number(message.id))) {
50
+ if (!message.method && message.id !== undefined && pending.has(Number(message.id))) {
35
51
  const waiter = pending.get(Number(message.id));
36
52
  if (!waiter)
37
53
  return;
38
54
  pending.delete(Number(message.id));
39
55
  clearTimeout(waiter.timer);
40
56
  if (message.error) {
41
- waiter.reject(new Error(message.error.message || 'jsonrpc_error'));
57
+ waiter.reject(new NativeRpcResponseError(message.error.message || 'jsonrpc_error', message.error.code));
42
58
  return;
43
59
  }
44
60
  waiter.resolve(message.result);
@@ -53,7 +69,7 @@ export function createJsonRpcStdioClient(child) {
53
69
  }
54
70
  }
55
71
  const send = (payload) => {
56
- if (closed)
72
+ if (closed || stdin.destroyed || stdin.writableEnded)
57
73
  throw new Error('jsonrpc_closed');
58
74
  stdin.write(`${JSON.stringify(payload)}\n`);
59
75
  };
@@ -104,7 +120,13 @@ export function createJsonRpcStdioClient(child) {
104
120
  return () => emitter.off('serverRequest', listener);
105
121
  },
106
122
  respond(id, result) {
107
- send({ id, result });
123
+ return new Promise((resolve, reject) => {
124
+ if (closed || stdin.destroyed || stdin.writableEnded) {
125
+ reject(new Error('jsonrpc_closed'));
126
+ return;
127
+ }
128
+ stdin.write(`${JSON.stringify({ id, result })}\n`, (error) => error ? reject(error) : resolve());
129
+ });
108
130
  },
109
131
  async close() {
110
132
  if (closed)
@@ -66,7 +66,7 @@ export function createLoopbackNativeAdapter() {
66
66
  if (input.text.includes('[approval]')) {
67
67
  const approvalId = `approval_${input.clientRequestId}`;
68
68
  approvals.add(JSON.stringify([input.sessionId, approvalId]));
69
- emit(collectedEvent('approval_request', 'item/tool/requestUserInput', {
69
+ emit(collectedEvent('approval_request', 'item/commandExecution/requestApproval', {
70
70
  threadId: input.threadId,
71
71
  turnId,
72
72
  approvalId,
@@ -98,6 +98,9 @@ export function createLoopbackNativeAdapter() {
98
98
  },
99
99
  async respondApproval(input) {
100
100
  const key = JSON.stringify([input.sessionId, input.approvalId]);
101
+ const accepted = input.response?.kind === 'decision' ? input.response.accepted : input.response ? undefined : input.accepted;
102
+ if (typeof accepted !== 'boolean')
103
+ return { outcome: 'failed', approvalId: input.approvalId };
101
104
  if (!approvals.has(key))
102
105
  return { outcome: 'failed', approvalId: input.approvalId };
103
106
  approvals.delete(key);
@@ -105,7 +108,7 @@ export function createLoopbackNativeAdapter() {
105
108
  queueMicrotask(() => {
106
109
  emit(collectedEvent('notification', 'approval.resolved', {
107
110
  approvalId: input.approvalId,
108
- accepted: input.accepted,
111
+ accepted,
109
112
  threadId: thread?.[0],
110
113
  turnId: thread?.[1].turnId,
111
114
  }));
@@ -0,0 +1,39 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { platformEndpoint, PLATFORM_ROUTES, workspaceMcpConfigSchema, } from '@playcraft/common/cloud-workspace/runtime';
3
+ import { NativePreparationError } from './native-adapter.js';
4
+ function canonical(value) {
5
+ if (Array.isArray(value))
6
+ return value.map(canonical);
7
+ if (value && typeof value === 'object')
8
+ return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right))
9
+ .map(([key, entry]) => [key, canonical(entry)]));
10
+ return value;
11
+ }
12
+ /** MCP (Model Context Protocol, 模型上下文协议) settings are scoped to one native thread. */
13
+ export function nativeMcpConfig(value) {
14
+ const parsed = workspaceMcpConfigSchema.safeParse(value);
15
+ if (!parsed.success)
16
+ throw new NativePreparationError('workspace_mcp_unavailable');
17
+ return {
18
+ config: { mcp_servers: parsed.data.mcpServers },
19
+ hash: createHash('sha256').update(JSON.stringify(canonical(parsed.data))).digest('hex'),
20
+ };
21
+ }
22
+ /** Resolve current owner settings at every turn boundary without writing secrets to a checkout. */
23
+ export async function loadWorkspaceMcpConfig(state, workspaceId) {
24
+ const config = state.config;
25
+ const runtimeEpoch = state.runtimeEpoch;
26
+ try {
27
+ const response = await fetch(platformEndpoint(config.publicBaseUrl, PLATFORM_ROUTES.workspaceMcp), {
28
+ method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10_000),
29
+ headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
30
+ body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation, runtimeEpoch, workspaceId }),
31
+ });
32
+ if (!response.ok || state.journal.destroyed() || state.runtimeEpoch !== runtimeEpoch)
33
+ throw new Error('workspace_mcp_unavailable');
34
+ return workspaceMcpConfigSchema.parse(await response.json());
35
+ }
36
+ catch {
37
+ throw new NativePreparationError('workspace_mcp_unavailable');
38
+ }
39
+ }
@@ -49,7 +49,7 @@ export class WorkspaceModelBroker {
49
49
  activate();
50
50
  return settings;
51
51
  }
52
- async prepareThreadConfig(cwd, modelConfigId, sessionId, reasoningEffort) {
52
+ async prepareThreadConfig(cwd, modelConfigId, sessionId, reasoningEffort, clientRequestId) {
53
53
  if (!this.server)
54
54
  throw new Error('workspace_not_registered');
55
55
  const { workspaceId } = await workspaceContextFromRoot(this.state, cwd);
@@ -76,7 +76,8 @@ export class WorkspaceModelBroker {
76
76
  activate: () => {
77
77
  if (!this.valid(entry) || this.routes.get(routeKey) !== route)
78
78
  throw new Error('model_access_denied');
79
- route.selected = { entry, model: credential.model };
79
+ route.selected = { entry, model: credential.model,
80
+ ...(sessionId && clientRequestId ? { feedback: { sessionId, clientRequestId } } : {}) };
80
81
  },
81
82
  model: credential.model,
82
83
  modelConfigId: credential.configId,
@@ -199,7 +200,11 @@ export class WorkspaceModelBroker {
199
200
  try {
200
201
  const upstream = await fetch(platformEndpoint(this.state.config.publicBaseUrl, modelResponsesPath(credential.configId)), {
201
202
  method: 'POST', redirect: 'error', signal: abort.signal,
202
- headers: { authorization: `Bearer ${credential.token}`, 'content-type': 'application/json' },
203
+ headers: { authorization: `Bearer ${credential.token}`, 'content-type': 'application/json',
204
+ ...(selected?.feedback ? {
205
+ 'x-playcraft-cloud-session-id': selected.feedback.sessionId,
206
+ 'x-playcraft-client-request-id': selected.feedback.clientRequestId,
207
+ } : {}) },
203
208
  body: JSON.stringify(body),
204
209
  });
205
210
  if (upstream.status === 401 || upstream.status === 403)
@@ -0,0 +1,70 @@
1
+ export const NATIVE_APPROVALS_REVIEWER = 'user';
2
+ /** Fixed production/coordinator boundaries may only become narrower. */
3
+ export function executionPolicy(cwd, mode, fixed) {
4
+ const directory = fixed?.cwd ?? cwd;
5
+ if (mode === 'read-only')
6
+ return { cwd: directory, approvalPolicy: 'never', sandboxPolicy: { type: 'readOnly', networkAccess: false } };
7
+ if (mode === 'auto')
8
+ return fixed ?? { cwd, approvalPolicy: 'never', sandboxPolicy: { type: 'dangerFullAccess' } };
9
+ if (fixed?.sandboxPolicy.type === 'readOnly')
10
+ return { ...fixed, sandboxPolicy: { type: 'readOnly', networkAccess: false } };
11
+ return { cwd: directory, approvalPolicy: fixed ? 'never' : 'on-request', sandboxPolicy: {
12
+ type: 'workspaceWrite', writableRoots: fixed?.sandboxPolicy.type === 'workspaceWrite' ? fixed.sandboxPolicy.writableRoots : [directory],
13
+ networkAccess: false, excludeTmpdirEnvVar: true, excludeSlashTmp: true,
14
+ } };
15
+ }
16
+ export function threadExecutionParams(execution) {
17
+ const sandbox = execution.sandboxPolicy.type === 'readOnly' ? 'read-only'
18
+ : execution.sandboxPolicy.type === 'workspaceWrite' ? 'workspace-write' : 'danger-full-access';
19
+ const policy = execution.sandboxPolicy;
20
+ return { cwd: execution.cwd, approvalPolicy: execution.approvalPolicy, approvalsReviewer: NATIVE_APPROVALS_REVIEWER, sandbox,
21
+ config: policy.type === 'workspaceWrite' ? {
22
+ 'sandbox_workspace_write.writable_roots': policy.writableRoots,
23
+ 'sandbox_workspace_write.network_access': policy.networkAccess,
24
+ 'sandbox_workspace_write.exclude_tmpdir_env_var': policy.excludeTmpdirEnvVar,
25
+ 'sandbox_workspace_write.exclude_slash_tmp': policy.excludeSlashTmp,
26
+ } : {} };
27
+ }
28
+ export const APPROVAL_METHODS = new Set([
29
+ 'item/commandExecution/requestApproval', 'item/fileChange/requestApproval',
30
+ 'item/permissions/requestApproval', 'item/tool/requestUserInput',
31
+ ]);
32
+ function record(value) {
33
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
34
+ }
35
+ /** Returns only the native request's permissions; a browser cannot supply new paths or network access. */
36
+ export function nativeApprovalResponse(method, request, response, canEscalate) {
37
+ if (method === 'item/commandExecution/requestApproval' || method === 'item/fileChange/requestApproval') {
38
+ if (response.kind !== 'decision' || (response.accepted && !canEscalate))
39
+ return undefined;
40
+ const decision = response.accepted ? 'accept' : Array.isArray(request.availableDecisions)
41
+ && !request.availableDecisions.includes('decline') && request.availableDecisions.includes('cancel') ? 'cancel' : 'decline';
42
+ if (Array.isArray(request.availableDecisions) && !request.availableDecisions.includes(decision))
43
+ return undefined;
44
+ return { decision };
45
+ }
46
+ if (method === 'item/permissions/requestApproval') {
47
+ if (response.kind !== 'permissions' || (response.accepted && !canEscalate))
48
+ return undefined;
49
+ const requested = record(request.permissions);
50
+ if (!request.permissions || Object.keys(requested).some(key => !['network', 'fileSystem'].includes(key)))
51
+ return undefined;
52
+ return { permissions: response.accepted ? Object.fromEntries(Object.entries(requested).filter(([, value]) => value != null)) : {}, scope: response.scope };
53
+ }
54
+ if (method !== 'item/tool/requestUserInput' || response.kind !== 'user-input' || !Array.isArray(request.questions) || !request.questions.length)
55
+ return undefined;
56
+ const questions = request.questions.map(record);
57
+ const ids = questions.map(question => question.id);
58
+ if (ids.some(id => typeof id !== 'string' || !id) || new Set(ids).size !== ids.length
59
+ || Object.keys(response.answers).length !== ids.length || Object.keys(response.answers).some(id => !ids.includes(id)))
60
+ return undefined;
61
+ for (const question of questions) {
62
+ const answers = response.answers[question.id]?.answers;
63
+ if (!answers || answers.length !== 1 || !answers[0]?.trim())
64
+ return undefined;
65
+ const options = Array.isArray(question.options) ? question.options.map(record) : [];
66
+ if (options.length && question.isOther !== true && !options.some(option => option.label === answers[0]))
67
+ return undefined;
68
+ }
69
+ return { answers: response.answers };
70
+ }
@@ -0,0 +1,15 @@
1
+ export function workModeExecution(execution, workMode) {
2
+ return workMode === 'plan' ? {
3
+ ...execution, approvalPolicy: 'never', sandboxPolicy: { type: 'readOnly', networkAccess: false },
4
+ } : execution;
5
+ }
6
+ export function nativeCollaborationMode(model, effort, workMode) {
7
+ return {
8
+ mode: workMode === 'plan' ? 'plan' : 'default',
9
+ settings: {
10
+ model,
11
+ reasoning_effort: effort === 'max' ? 'xhigh' : effort ?? null,
12
+ developer_instructions: null,
13
+ },
14
+ };
15
+ }
@@ -132,13 +132,13 @@ function remember(database, childId, parentId, owner, metadata, confirmed = true
132
132
  return true;
133
133
  }
134
134
  function ownsOperation(session, rootThreadId, found) {
135
- return Boolean(found && found.operation.kind === 'session.turn' && found.operation.sessionId === session.sessionId &&
135
+ return Boolean(found && ['session.turn', 'session.review', 'session.compact'].includes(found.operation.kind) && found.operation.sessionId === session.sessionId &&
136
136
  (found.operation.workspaceId === undefined || found.operation.workspaceId === session.workspaceId) && found.operation.nativeThreadId === rootThreadId);
137
137
  }
138
138
  function readOperation(database, column, value, threadId) {
139
139
  if (!value)
140
140
  return undefined;
141
- const row = database.prepare(`SELECT scope_key, operation_json FROM operation_intents WHERE ${column} = ?${threadId ? ' AND thread_id = ?' : ''}`)
141
+ const row = database.prepare(`SELECT scope_key, operation_json FROM operation_intents WHERE ${column} = ?${threadId ? ' AND thread_id = ?' : ''}${column === 'turn_id' ? " AND json_extract(operation_json, '$.kind') IN ('session.turn', 'session.review', 'session.compact')" : ''}`)
142
142
  .get(...(threadId ? [value, threadId] : [value]));
143
143
  if (!row)
144
144
  return undefined;
@@ -329,13 +329,13 @@ function quarantineReservedTurns(database) {
329
329
  if (!parsed.success)
330
330
  continue;
331
331
  const operation = parsed.data;
332
- if (operation.kind !== 'session.turn' || operation.status !== 'dispatched')
332
+ if (!['session.turn', 'session.review', 'session.compact', 'session.steer'].includes(operation.kind) || operation.status !== 'dispatched')
333
333
  continue;
334
- if (!operation.nativeRequestId || operation.nativeTurnId)
334
+ if (!operation.nativeRequestId || (operation.kind !== 'session.steer' && operation.nativeTurnId))
335
335
  continue;
336
336
  const uncertain = transitionOperation(operation, 'uncertain', { error: 'native_receipt_lost' });
337
337
  putOperation(database, row.scope_key, uncertain);
338
- if (operation.sessionId) {
338
+ if (operation.sessionId && operation.kind !== 'session.steer') {
339
339
  const session = readSession(database, operation.sessionId);
340
340
  if (session) {
341
341
  upsertRecord(database, 'sessions', operation.sessionId, { ...session, turnBlock: 'uncertain', turnStatus: 'starting' });
@@ -512,8 +512,9 @@ function convertInboxRow(database, ingress, input) {
512
512
  const params = input.params ?? JSON.parse(row.payload_json || '{}');
513
513
  const payload = linkedPayload(params, input.session, input.subAgent);
514
514
  const nativeApprovalId = optionalString(payload.approvalId) || optionalString(payload.itemId) || input.itemId;
515
- const childApproval = input.subAgent && nativeApprovalId && input.method?.includes('requestApproval');
516
- if (input.subAgent && nativeApprovalId && (childApproval || input.method === 'approval.resolved')) {
515
+ const approvalRequest = input.method?.includes('requestApproval') || input.method === 'item/tool/requestUserInput';
516
+ const childApproval = input.subAgent && nativeApprovalId && approvalRequest;
517
+ if (input.subAgent && nativeApprovalId && (childApproval || input.method === 'approval.resolved' || input.method === 'serverRequest/resolved')) {
517
518
  payload.approvalId = `subagent_${createHash('sha256').update(JSON.stringify([input.subAgent.threadId, nativeApprovalId])).digest('hex')}`;
518
519
  }
519
520
  const type = input.kind === 'process_exit' ? 'process.exited' : input.method || input.kind;
@@ -544,16 +545,44 @@ function convertInboxRow(database, ingress, input) {
544
545
  approvalId: payload.approvalId,
545
546
  sessionId: input.operation.operation.sessionId,
546
547
  runtimeEpoch: input.runtimeEpoch,
547
- nativeRequestId: `approval:${nativeApprovalId}`,
548
+ nativeRequestId: optionalString(payload.requestId) ?? `approval:${nativeApprovalId}`,
548
549
  nativeApprovalId,
549
550
  nativeThreadId: input.subAgent.threadId,
551
+ nativeTurnId: input.turnId,
552
+ rootThreadId: input.subAgent.rootThreadId,
553
+ rootTurnId: input.operation.operation.nativeTurnId,
554
+ method: input.method,
555
+ request: payload,
550
556
  status: 'pending',
551
557
  });
552
558
  }
559
+ settleNativeApprovals(database, input, payload);
553
560
  if (!input.subAgent)
554
561
  applyNativeToState(database, { ...input, operation: resolveOperation(database, input, payload) }, payload);
555
562
  return event;
556
563
  }
564
+ /** An ended native request must never reappear as a pending approval after reconnect. */
565
+ function settleNativeApprovals(database, input, payload) {
566
+ const resolved = input.method === 'approval.resolved' || input.method === 'serverRequest/resolved';
567
+ const terminal = Boolean(terminalTurn(input.method ?? '', payload));
568
+ if (!resolved && !terminal && input.kind !== 'process_exit')
569
+ return;
570
+ const rows = database.prepare("SELECT record_key, body_json FROM records WHERE bucket = 'approvals'").all();
571
+ for (const row of rows) {
572
+ const approval = asRecord(JSON.parse(row.body_json));
573
+ if (approval.status !== 'pending')
574
+ continue;
575
+ const matchesThread = approval.nativeThreadId === input.threadId;
576
+ const matchesRequest = (payload.approvalId && approval.approvalId === payload.approvalId)
577
+ || (payload.requestId !== undefined && String(approval.nativeRequestId) === String(payload.requestId));
578
+ const endedParent = terminal && approval.rootThreadId === input.threadId && approval.rootTurnId === input.turnId;
579
+ if (input.kind !== 'process_exit' && !endedParent && (!matchesThread || (resolved ? !matchesRequest
580
+ : input.turnId && approval.nativeTurnId && approval.nativeTurnId !== input.turnId)))
581
+ continue;
582
+ upsertRecord(database, 'approvals', row.record_key, { ...approval,
583
+ status: input.method === 'approval.resolved' ? payload.accepted === false ? 'rejected' : 'approved' : 'expired' });
584
+ }
585
+ }
557
586
  function applyRecordedTurn(database, threadId, turnId) {
558
587
  const rows = database
559
588
  .prepare(`SELECT type, payload_json, thread_id, session_id FROM source_events
@@ -561,7 +590,7 @@ function applyRecordedTurn(database, threadId, turnId) {
561
590
  ORDER BY source_sequence`)
562
591
  .all(threadId, turnId, `%"${turnId}"%`);
563
592
  for (const row of rows) {
564
- if (!row.type.includes('requestApproval') && !row.type.includes('requestUserInput') && !row.type.includes('completed') && !row.type.includes('interrupted') && row.type !== 'turn.failed' && row.type !== 'approval.resolved') {
593
+ if (!row.type.includes('requestApproval') && !row.type.includes('requestUserInput') && !row.type.includes('completed') && !row.type.includes('interrupted') && row.type !== 'turn.failed' && row.type !== 'approval.resolved' && row.type !== 'serverRequest/resolved') {
565
594
  continue;
566
595
  }
567
596
  const payload = asRecord(JSON.parse(row.payload_json));
@@ -586,14 +615,19 @@ function resolveOperation(database, input, payload) {
586
615
  }
587
616
  function applyNativeToState(database, input, payload) {
588
617
  const method = input.method ?? '';
618
+ settleNativeApprovals(database, input, payload);
589
619
  const session = input.session;
590
620
  if (session?.sessionId) {
591
621
  const next = { ...session };
592
622
  const approval = method.includes('requestApproval') || method.includes('requestUserInput');
593
623
  if (approval)
594
624
  next.turnStatus = moveTurn(String(session.turnStatus ?? 'running'), 'waiting_for_user');
595
- else if (method === 'approval.resolved')
596
- next.turnStatus = moveTurn(String(session.turnStatus ?? 'waiting_for_user'), 'running');
625
+ else if (method === 'approval.resolved' || method === 'serverRequest/resolved') {
626
+ const waiting = database.prepare("SELECT 1 FROM records WHERE bucket = 'approvals' AND json_extract(body_json, '$.sessionId') = ? AND json_extract(body_json, '$.status') = 'pending' AND json_extract(body_json, '$.nativeThreadId') = ? LIMIT 1")
627
+ .get(session.sessionId, session.threadId);
628
+ if (!waiting)
629
+ next.turnStatus = moveTurn(String(session.turnStatus ?? 'waiting_for_user'), 'running');
630
+ }
597
631
  else if (method.includes('delta') || method === 'turn/started' || method === 'turn.started') {
598
632
  const current = String(session.turnStatus ?? 'starting');
599
633
  if (current !== 'waiting_for_user')
@@ -618,7 +652,12 @@ function applyNativeToState(database, input, payload) {
618
652
  approvalId,
619
653
  sessionId: operation.sessionId,
620
654
  runtimeEpoch: operation.runtimeEpoch ?? 0,
621
- nativeRequestId: operation.nativeRequestId ?? `approval:${approvalId}`,
655
+ nativeRequestId: optionalString(payload.requestId) ?? `approval:${approvalId}`,
656
+ nativeThreadId: input.threadId,
657
+ nativeTurnId: input.turnId,
658
+ nativeApprovalId: approvalId,
659
+ method,
660
+ request: payload,
622
661
  status: 'pending',
623
662
  });
624
663
  operation = operationRecordSchema.parse({
@@ -902,7 +941,7 @@ function sessionByThread(database, threadId) {
902
941
  }
903
942
  function operationByTurn(database, turnId, threadId) {
904
943
  const row = database
905
- .prepare(`SELECT scope_key, operation_json FROM operation_intents WHERE turn_id = ?${threadId ? ' AND thread_id = ?' : ''}`)
944
+ .prepare(`SELECT scope_key, operation_json FROM operation_intents WHERE turn_id = ?${threadId ? ' AND thread_id = ?' : ''} AND json_extract(operation_json, '$.kind') IN ('session.turn', 'session.review', 'session.compact')`)
906
945
  .get(...(threadId ? [turnId, threadId] : [turnId]));
907
946
  if (!row)
908
947
  return undefined;
@@ -6,6 +6,7 @@ import { ingestCollected, publishSourceEvents, recordNativeEvents } from '../ser
6
6
  import { verifyProductionLease } from './storage.js';
7
7
  import { processBirth, stopProductionGroup } from './processes.js';
8
8
  import { openDirectoryToDevelopment } from '../workspaces/directory-access.js';
9
+ import { loadWorkspaceMcpConfig } from '../codex/mcp-config.js';
9
10
  export async function runProductionNative(state, input, root, record, track) {
10
11
  if (!input.model)
11
12
  throw new Error('production_model_configuration_required');
@@ -25,6 +26,7 @@ export async function runProductionNative(state, input, root, record, track) {
25
26
  homeDir: process.env.PLAYCRAFT_LAUNCHER_SOCKET
26
27
  ? `/var/lib/playcraft/codex/production-${input.task.attemptId}` : join(state.config.stateDir, 'production-native', input.task.attemptId),
27
28
  model: input.model.model, processGroup: true,
29
+ loadMcpConfig: () => loadWorkspaceMcpConfig(state, input.task.workspaceId),
28
30
  fixedExecution: { cwd: root, approvalPolicy: 'never', sandboxPolicy: { type: 'workspaceWrite', writableRoots: [root],
29
31
  networkAccess: true, excludeTmpdirEnvVar: true, excludeSlashTmp: true } },
30
32
  fixedThreadConfig: { model: input.model.model, modelProvider: 'playcraft_production', config: {
@@ -22,3 +22,14 @@ git diff --cached --quiet || git -c core.hooksPath=/dev/null commit -m "Save gen
22
22
  git -c protocol.version=0 -c core.hooksPath=/dev/null -c remote.origin.url="$PRODUCTION_REPOSITORY" -c remote.origin.pushurl="$PRODUCTION_REPOSITORY" push --set-upstream origin HEAD:"refs/heads/$PRODUCTION_BRANCH"
23
23
  git rev-parse HEAD`;
24
24
  }
25
+ /** Commits generated engineering locally inside the sandbox without pushing a branch to remote origin. */
26
+ export function productionLocalCommitCommand() {
27
+ return `set -eu
28
+ git -c core.hooksPath=/dev/null -c core.fsmonitor=false add -A .
29
+ if [ -n "$(git ls-files -- .playcraft.json playcraft.config.json ':(glob)**/.playcraft.json' ':(glob)**/playcraft.config.json')" ]; then
30
+ printf 'production_git_private_path\\n' >&2
31
+ exit 1
32
+ fi
33
+ git diff --cached --quiet || git -c core.hooksPath=/dev/null commit -m "Save generated engineering"
34
+ git rev-parse HEAD`;
35
+ }
@@ -10,7 +10,7 @@ import { registerWorkspaceDirectory } from '../workspaces/directories.js';
10
10
  import { workspaceContainerRoot, workspaceRoot, workspaceSessions } from '../workspaces/registry.js';
11
11
  import { excludeFromGit, settleDependencyInstalls } from '../workspaces/dependencies.js';
12
12
  import { resolveProductionDependencies, verifyProductionDependencyLocks } from './dependencies.js';
13
- import { productionSaveCommand } from './save-command.js';
13
+ import { productionLocalCommitCommand } from './save-command.js';
14
14
  import { productionOutputStore, verifyProductionLease } from './storage.js';
15
15
  import { runProductionCommand } from './processes.js';
16
16
  import { runProductionNative } from './native.js';
@@ -105,11 +105,9 @@ export async function executeProductionStage(state, input, processRecord, track)
105
105
  return { generated: true };
106
106
  }
107
107
  if (stage === 'save') {
108
- if (!input.git)
109
- throw new Error('production_git_scope_required');
110
108
  const binding = state.journal.getWorkspace(workspaceId);
111
109
  const branch = input.directoryName === 'basic' ? binding.branchName : binding.directories?.find(value => value.directoryName === input.directoryName)?.branchName;
112
- if (input.git.branchName !== branch || await git(root, 'symbolic-ref', '--short', 'HEAD') !== branch)
110
+ if ((input.git && input.git.branchName !== branch) || await git(root, 'symbolic-ref', '--short', 'HEAD') !== branch)
113
111
  throw new Error('production_git_binding_mismatch');
114
112
  await excludeFromGit(root);
115
113
  await excludeFromGit(root, '/.env*');
@@ -117,15 +115,8 @@ export async function executeProductionStage(state, input, processRecord, track)
117
115
  await excludeFromGit(root, '/.playcraft-preview-out/');
118
116
  await git(root, 'config', 'user.name', 'PlayCraft');
119
117
  await git(root, 'config', 'user.email', 'playcraft@localhost');
120
- const env = { PRODUCTION_REPOSITORY: input.git.repositoryUrl, PRODUCTION_BRANCH: input.git.branchName,
121
- GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' };
122
- if (input.git.username && input.git.password)
123
- Object.assign(env, { GIT_CONFIG_COUNT: '1',
124
- GIT_CONFIG_KEY_0: `http.${input.git.repositoryUrl}.extraHeader`,
125
- GIT_CONFIG_VALUE_0: `Authorization: Basic ${Buffer.from(`${input.git.username}:${input.git.password}`).toString('base64')}` });
126
- if (typeof input.payload.expectedParent === 'string')
127
- env.PRODUCTION_EXPECTED_PARENT = input.payload.expectedParent;
128
- await run(root, productionSaveCommand(), env, true);
118
+ const env = { GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' };
119
+ await run(root, productionLocalCommitCommand(), env, true);
129
120
  const commitHash = await git(root, 'rev-parse', 'HEAD');
130
121
  const snapshot = await freezeBatchCommit({ source: { workspaceId, root }, commitHash, snapshotId: `${input.task.attemptId}-save` });
131
122
  return { snapshot, saved: { attemptId: input.task.attemptId, resultVersionId: resultId, workspaceId, sourceDigest: snapshot.checksum, commitHash, pushedCommitHash: commitHash } };
@@ -146,8 +137,17 @@ export async function executeProductionStage(state, input, processRecord, track)
146
137
  if (!snapshot || snapshot.commitHash !== saved.commitHash || snapshot.checksum !== saved.sourceDigest)
147
138
  throw new Error('production_saved_snapshot_mismatch');
148
139
  const destination = join(root, '.playcraft-temp', `production-${input.task.attemptId}`);
140
+ const removeDestination = async () => {
141
+ try {
142
+ await rm(destination, { recursive: true, force: true });
143
+ }
144
+ catch {
145
+ await run(destination, 'chmod -R u+rwX,g+rwX .').catch(() => undefined);
146
+ await rm(destination, { recursive: true, force: true });
147
+ }
148
+ };
149
149
  await excludeFromGit(root, '/.playcraft-temp/');
150
- await rm(destination, { recursive: true, force: true });
150
+ await removeDestination();
151
151
  try {
152
152
  await checkoutBatchCommit({ source: { workspaceId, root }, frozen: snapshot, destination });
153
153
  await openDirectoryToDevelopment(destination, (await lstat(state.config.workspaceRoot)).gid);
@@ -174,7 +174,7 @@ export async function executeProductionStage(state, input, processRecord, track)
174
174
  sourceDigest: snapshot.checksum, commitHash: snapshot.commitHash, buildId: `${input.task.attemptId}-build`, succeeded: true } };
175
175
  }
176
176
  finally {
177
- await rm(destination, { recursive: true, force: true });
177
+ await removeDestination();
178
178
  }
179
179
  }
180
180
  if (stage === 'preview') {
@@ -190,7 +190,7 @@ export async function executeProductionStage(state, input, processRecord, track)
190
190
  try {
191
191
  const browser = process.env.PLAYCRAFT_PRODUCTION_PREVIEW_COMMAND ?? 'chromium --headless --no-sandbox --disable-gpu --virtual-time-budget=1000 --dump-dom';
192
192
  const opened = await run(root, `${browser} ${quote(`file://${previewPath}`)}`);
193
- if (!/<html[\s>]/i.test(opened))
193
+ if (!/<\/?html[\s>]/i.test(opened))
194
194
  throw new Error('production_preview_not_opened');
195
195
  return { opened: true, outputDigest: built.outputDigest };
196
196
  }
@@ -20,6 +20,7 @@ import { recoverInterruptedInstalls } from './workspaces/dependencies.js';
20
20
  import { loadWorkspaceSkillArtifact } from './workspaces/skill-artifact-loader.js';
21
21
  import { workspaceContextFromRoot } from './workspaces/registry.js';
22
22
  import { observeWorkspaceArtifacts } from './workspaces/artifact-events.js';
23
+ import { loadWorkspaceMcpConfig } from './codex/mcp-config.js';
23
24
  export async function startWorkspaceRuntime(config, options = {}) {
24
25
  process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
25
26
  const negotiated = negotiateProtocol(config);
@@ -191,6 +192,10 @@ function defaultNativeAdapter(state) {
191
192
  return createCodexAppServerAdapter({
192
193
  config,
193
194
  ...(multi ? { modelBroker: new WorkspaceModelBroker(state) } : {}),
195
+ ...(multi ? { loadMcpConfig: async (cwd) => {
196
+ const { workspaceId } = await workspaceContextFromRoot(state, cwd);
197
+ return loadWorkspaceMcpConfig(state, workspaceId);
198
+ } } : {}),
194
199
  loadSkillArtifact: async (cwd, entry) => {
195
200
  const { workspaceId, directoryName } = await workspaceContextFromRoot(state, cwd);
196
201
  return loadWorkspaceSkillArtifact(state, workspaceId, entry, directoryName === 'root' ? 'basic' : directoryName);