@playcraft/cli 0.1.59-beta → 0.1.61-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/cli-root-help.js +1 -0
  2. package/dist/commands/references.js +55 -0
  3. package/dist/index.js +21 -3
  4. package/dist/project-skills/builtin-import.js +2 -2
  5. package/dist/references/client.js +69 -0
  6. package/dist/references/messages.js +57 -0
  7. package/dist/references/protocol.js +32 -0
  8. package/dist/skill-registry/messages.js +2 -2
  9. package/dist/skill-registry/platform-package.js +2 -4
  10. package/dist/workspace-runtime/batch/control.js +3 -3
  11. package/dist/workspace-runtime/batch/deliver.js +4 -3
  12. package/dist/workspace-runtime/batch/generate.js +3 -2
  13. package/dist/workspace-runtime/batch/install-log.js +49 -0
  14. package/dist/workspace-runtime/batch/prepare.js +29 -13
  15. package/dist/workspace-runtime/batch/processes.js +86 -14
  16. package/dist/workspace-runtime/batch/writer-proof.js +2 -2
  17. package/dist/workspace-runtime/codex/app-server.js +52 -2
  18. package/dist/workspace-runtime/codex/config-toml.js +9 -5
  19. package/dist/workspace-runtime/codex/delivery-tools.js +3 -10
  20. package/dist/workspace-runtime/codex/launcher-client.js +46 -24
  21. package/dist/workspace-runtime/codex/model-broker.js +42 -5
  22. package/dist/workspace-runtime/codex/production-skill-tools.js +95 -0
  23. package/dist/workspace-runtime/codex/remix-references-broker.js +184 -0
  24. package/dist/workspace-runtime/codex/scheduled-task-tools.js +80 -0
  25. package/dist/workspace-runtime/processes/skill-command.js +130 -0
  26. package/dist/workspace-runtime/serve.js +9 -1
  27. package/dist/workspace-runtime/server/accept-operation.js +10 -1
  28. package/dist/workspace-runtime/server/directory-access.js +10 -3
  29. package/dist/workspace-runtime/server/dispatch.js +40 -1
  30. package/dist/workspace-runtime/server/http.js +39 -4
  31. package/dist/workspace-runtime/server/operation-record.js +4 -1
  32. package/dist/workspace-runtime/workspaces/dependencies.js +35 -10
  33. package/dist/workspace-runtime/workspaces/developer-command.js +65 -64
  34. package/dist/workspace-runtime/workspaces/directories.js +4 -1
  35. package/dist/workspace-runtime/workspaces/empty-template.js +5 -1
  36. package/dist/workspace-runtime/workspaces/platform-export.js +3 -3
  37. package/dist/workspace-runtime/workspaces/procedural-3d/candidate.js +129 -0
  38. package/dist/workspace-runtime/workspaces/procedural-3d/contracts.js +34 -0
  39. package/dist/workspace-runtime/workspaces/procedural-3d/files.js +83 -0
  40. package/dist/workspace-runtime/workspaces/procedural-3d/host.js +595 -0
  41. package/dist/workspace-runtime/workspaces/procedural-3d/render-worker.js +159 -0
  42. package/dist/workspace-runtime/workspaces/procedural-3d/review-contracts.js +257 -0
  43. package/dist/workspace-runtime/workspaces/procedural-3d/review-page.js +26 -0
  44. package/dist/workspace-runtime/workspaces/remix-reference-skill.js +126 -0
  45. package/dist/workspace-runtime/workspaces/scheduled-task-proposals.js +27 -0
  46. package/dist/workspace-runtime/workspaces/skill-execution-context.js +69 -0
  47. package/dist/workspace-runtime/workspaces/skills.js +18 -6
  48. package/package.json +3 -3
  49. package/project-template/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
  50. package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +0 -2
  51. package/project-template-v2/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
  52. package/project-template-v2/.playcraft/skills.lock.json +14 -0
  53. package/project-templates/empty/AGENTS.md +19 -0
  54. package/project-templates/phaser/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
  55. package/project-templates/threejs/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
@@ -2,6 +2,8 @@ import { execFile, spawn } from 'node:child_process';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { promisify } from 'node:util';
4
4
  import { setTimeout as delay } from 'node:timers/promises';
5
+ import { startLauncherProcess, stopLauncherProcess } from '../codex/launcher-client.js';
6
+ import { launcherSocket } from '../workspaces/developer-command.js';
5
7
  export async function processBirth(pid) {
6
8
  if (process.platform !== 'linux') {
7
9
  return promisify(execFile)('ps', ['-p', String(pid), '-o', 'lstart='], { timeout: 2000 }).then(value => value.stdout.trim() || undefined, () => undefined);
@@ -23,12 +25,27 @@ export function groupExited(pid) {
23
25
  return error.code === 'ESRCH';
24
26
  }
25
27
  }
28
+ /** A missing local process cannot replace the launcher's proof for a development-user group. */
29
+ export function processHasExited(record) {
30
+ return record.stopped || (!record.launcher && groupExited(record.pid));
31
+ }
26
32
  /**
27
33
  * Stops one run's process group. A mismatched birth means the pid was reused, which is not proof
28
34
  * that the original writers exited, so the caller must not report writersStopped.
29
35
  */
30
36
  export async function stopProcessGroup(record) {
31
- if (record.stopped || groupExited(record.pid))
37
+ if (record.stopped)
38
+ return true;
39
+ if (record.launcher) {
40
+ try {
41
+ await stopLauncherProcess(record.launcher);
42
+ return true;
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ }
48
+ if (groupExited(record.pid))
32
49
  return true;
33
50
  if (record.birth && await processBirth(record.pid) !== record.birth)
34
51
  return false;
@@ -46,27 +63,82 @@ export async function stopProcessGroup(record) {
46
63
  }
47
64
  return groupExited(record.pid);
48
65
  }
49
- export async function runProcessGroup(cwd, command, role, onStart) {
50
- const child = spawn('bash', ['-lc', command], {
66
+ export async function runProcessGroup(cwd, command, role, onStart, options) {
67
+ const socketPath = options && launcherSocket(options.state);
68
+ if (options && options.state.config.workspaceMode === 'multi' &&
69
+ options.state.config.environment !== 'local' && !socketPath)
70
+ throw new Error('development_launcher_unavailable');
71
+ let launched;
72
+ let tracked;
73
+ if (socketPath) {
74
+ // Persist uncertainty before sending start: a lost acknowledgement is not a rejected launch.
75
+ tracked = { pid: 0, role, stopped: false, launcher: {
76
+ socketPath, processId: options.processId, runtimeEpoch: options.state.runtimeEpoch, instanceId: '',
77
+ } };
78
+ await onStart(tracked);
79
+ try {
80
+ launched = await startLauncherProcess({
81
+ socketPath, processId: options.processId, runtimeEpoch: options.state.runtimeEpoch,
82
+ argv: ['/usr/bin/bash', '-lc', `{ ${command}; } </dev/null 2>&1`], cwd, env: {},
83
+ });
84
+ }
85
+ catch (error) {
86
+ if (error.launchRejected) {
87
+ tracked.stopped = true;
88
+ await onStart(tracked);
89
+ }
90
+ throw error;
91
+ }
92
+ }
93
+ launched?.stdin?.end();
94
+ const child = launched ? undefined : spawn('bash', ['-lc', command], {
51
95
  cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'],
52
96
  env: { PATH: process.env.PATH, HOME: cwd, GIT_TERMINAL_PROMPT: '0' },
53
97
  });
54
- const pid = child.pid;
98
+ const pid = launched?.pid ?? child?.pid;
55
99
  if (!pid)
56
100
  throw new Error('batch_process_not_started');
57
101
  let output = '';
58
- const collect = (chunk) => { output = (output + String(chunk)).slice(-64 * 1024); };
59
- child.stdout?.on('data', collect);
60
- child.stderr?.on('data', collect);
102
+ const collect = (chunk) => {
103
+ output = (output + String(chunk)).slice(-64 * 1024);
104
+ options?.onOutput?.(chunk);
105
+ };
106
+ (launched?.stdout ?? child?.stdout)?.on('data', collect);
107
+ (launched?.stderr ?? child?.stderr)?.on('data', collect);
61
108
  // Subscribe before any await. A short command can exit during processBirth or onStart,
62
109
  // and a late listener never sees that close event.
63
110
  const finished = new Promise((resolve) => {
64
- child.once('error', () => resolve(null));
65
- child.once('close', resolve);
111
+ if (launched)
112
+ launched.onExit(code => resolve(code ?? null));
113
+ else {
114
+ child.once('error', () => resolve(null));
115
+ child.once('close', resolve);
116
+ }
66
117
  });
67
- const tracked = { pid, birth: await processBirth(pid), stopped: false, role };
68
- await onStart(tracked);
69
- const code = await finished;
70
- tracked.stopped = groupExited(pid);
71
- return { code, output, process: tracked };
118
+ tracked = { pid, birth: await processBirth(pid), stopped: false, role,
119
+ ...(launched ? { launcher: launched.identity } : {}) };
120
+ try {
121
+ await onStart(tracked);
122
+ const code = await finished;
123
+ if (launched) {
124
+ try {
125
+ await launched.stop();
126
+ tracked.stopped = true;
127
+ }
128
+ catch (error) {
129
+ collect(`\n[install] ${error instanceof Error ? error.message : String(error)}\n`);
130
+ // Keep the directory occupied until the launcher confirms stop.
131
+ }
132
+ }
133
+ else
134
+ tracked.stopped = groupExited(pid);
135
+ return { code, output, process: tracked };
136
+ }
137
+ catch (error) {
138
+ if (launched)
139
+ await launched.stop().catch(() => undefined);
140
+ else
141
+ await stopProcessGroup(tracked);
142
+ throw error;
143
+ }
72
144
  }
@@ -1,12 +1,12 @@
1
1
  import { setTimeout as delay } from 'node:timers/promises';
2
- import { groupExited, stopProcessGroup } from './processes.js';
2
+ import { processHasExited, stopProcessGroup } from './processes.js';
3
3
  /** An interrupt acknowledgement is not proof that the native turn and its writers have stopped. */
4
4
  export async function stopMemberWriters(state, record) {
5
5
  if (!record)
6
6
  return true;
7
7
  let stopped = true;
8
8
  for (const process of record.processes) {
9
- if (process.stopped || groupExited(process.pid))
9
+ if (processHasExited(process))
10
10
  process.stopped = true;
11
11
  else
12
12
  process.stopped = await stopProcessGroup(process);
@@ -19,6 +19,8 @@ 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';
23
+ import { PROCEDURAL_3D_TOOL, ProductionSkillTools } from './production-skill-tools.js';
22
24
  function withModelShell(config, modelConfigId) {
23
25
  if (!modelConfigId || !/^[0-9a-f-]{36}$/i.test(modelConfigId))
24
26
  return config;
@@ -191,9 +193,15 @@ export function createCodexAppServerAdapter(options) {
191
193
  const coordinatorDirectories = new Map();
192
194
  const uiTools = new WorkspaceUiTools(emitCollected, options.uiToolTimeoutMs);
193
195
  const deliveryTools = options.deliveryTools ? new WorkspaceDeliveryTools(options.deliveryTools) : undefined;
196
+ const scheduledTaskTools = options.scheduledTaskProposals ? new WorkspaceScheduledTaskTools(options.scheduledTaskProposals, (proposalId, source) => emitCollected(collectedEvent('notification', 'scheduled-task.proposal.changed', {
197
+ proposalId, sessionId: threads.get(source.threadId)?.sessionId, threadId: source.threadId, turnId: source.turnId,
198
+ }))) : undefined;
199
+ const productionSkill = options.productionSkill ? new ProductionSkillTools(options.productionSkill) : undefined;
194
200
  const dynamicTools = [
195
201
  ...(options.workspaceUi ? WORKSPACE_UI_TOOLS : []),
196
202
  ...(deliveryTools ? WORKSPACE_DELIVERY_TOOLS : []),
203
+ ...(scheduledTaskTools ? WORKSPACE_SCHEDULED_TASK_TOOLS : []),
204
+ ...(productionSkill ? [PROCEDURAL_3D_TOOL] : []),
197
205
  ];
198
206
  function emitCollected(event) {
199
207
  for (const listener of collected)
@@ -594,7 +602,13 @@ export function createCodexAppServerAdapter(options) {
594
602
  approvals.delete(key);
595
603
  }
596
604
  }
597
- emitCollected(collectedEvent('notification', method, payload));
605
+ if (method === 'turn/completed' && typeof payload.threadId === 'string'
606
+ && productionSkill?.hasActive(payload.threadId, String(asRecord(payload.turn).id))) {
607
+ // Keep the operation's directory lease until the tool's writer has stopped.
608
+ void productionSkill.cancel(payload.threadId, String(asRecord(payload.turn).id)).then(() => emitCollected(collectedEvent('notification', method, payload)));
609
+ }
610
+ else
611
+ emitCollected(collectedEvent('notification', method, payload));
598
612
  });
599
613
  rpc.onServerRequest((id, method, params) => {
600
614
  const payload = asRecord(params);
@@ -609,6 +623,36 @@ export function createCodexAppServerAdapter(options) {
609
623
  }
610
624
  if (method === 'item/tool/call') {
611
625
  const thread = typeof payload.threadId === 'string' ? threads.get(payload.threadId) : undefined;
626
+ if (scheduledTaskTools && isWorkspaceScheduledTaskTool(payload.tool)) {
627
+ const threadId = String(payload.threadId ?? ''), turnId = String(payload.turnId ?? '');
628
+ const current = () => {
629
+ try {
630
+ return Boolean(thread && threads.get(threadId) === thread && thread.turnActive
631
+ && thread.lastTurnId === turnId && !childParents.has(threadId)
632
+ && ['execute', 'plan'].includes(thread.activeMode ?? '') && !options.fixedExecution
633
+ && !options.trustedExecution?.(thread.sessionId));
634
+ }
635
+ catch {
636
+ return false;
637
+ }
638
+ };
639
+ setTimeout(() => {
640
+ void scheduledTaskTools.request(id, payload, nativeClient, thread ? {
641
+ bridge: thread.scheduledTaskProposalBridge, threadId, turnId, current,
642
+ } : undefined);
643
+ }, 0);
644
+ return;
645
+ }
646
+ if (productionSkill && payload.tool === PROCEDURAL_3D_TOOL.name) {
647
+ const trusted = thread?.turnActive && thread.cwd && thread.activeMode === 'execute'
648
+ && thread.permissionMode !== 'read-only' && thread.readOnlyConfirmed !== true
649
+ && payload.turnId === thread.lastTurnId && typeof payload.threadId === 'string' && typeof payload.turnId === 'string';
650
+ void productionSkill.request(id, payload, nativeClient, trusted ? {
651
+ sessionId: thread.sessionId, root: thread.cwd,
652
+ nativeThreadId: payload.threadId, nativeTurnId: payload.turnId,
653
+ } : undefined);
654
+ return;
655
+ }
612
656
  if (deliveryTools && isWorkspaceDeliveryTool(payload.tool)) {
613
657
  void deliveryTools.request(id, payload, nativeClient, {
614
658
  sessionId: thread?.sessionId,
@@ -653,6 +697,7 @@ export function createCodexAppServerAdapter(options) {
653
697
  if (live === current) {
654
698
  readyFlag = false;
655
699
  void uiTools.cancel(undefined, 'ui_runtime_disconnected');
700
+ void productionSkill?.cancel();
656
701
  approvals.clear();
657
702
  // Retain the old handle until its entire execution group is confirmed stopped.
658
703
  rpc = undefined;
@@ -734,6 +779,7 @@ export function createCodexAppServerAdapter(options) {
734
779
  name: 'codex-app-server',
735
780
  workspaceUi: options.workspaceUi === true,
736
781
  ready: () => readyFlag && !closed,
782
+ ...(scheduledTaskTools ? { scheduledTaskProposalSource: (source) => scheduledTaskTools.isActive(source) } : {}),
737
783
  executionProcess() {
738
784
  if (!live)
739
785
  return undefined;
@@ -755,6 +801,7 @@ export function createCodexAppServerAdapter(options) {
755
801
  previous.superseded = true;
756
802
  readyFlag = false;
757
803
  await uiTools.cancel(undefined, 'ui_runtime_disconnected');
804
+ await productionSkill?.cancel();
758
805
  startPromise = undefined;
759
806
  threads.clear();
760
807
  approvals.clear();
@@ -927,7 +974,7 @@ export function createCodexAppServerAdapter(options) {
927
974
  selectedModel ??= thread.resolvedModel ?? model;
928
975
  const requestId = rpc.reserveRequest();
929
976
  await input.persistReservation({ nativeRequestId: String(requestId), modelConfigId, reasoningEffort, permissionMode });
930
- activateModel();
977
+ activateModel(execution.sandboxPolicy.type === 'readOnly');
931
978
  thread.modelConfigId = modelConfigId;
932
979
  thread.reasoningEffort = reasoningEffort;
933
980
  thread.permissionMode = permissionMode;
@@ -941,6 +988,7 @@ export function createCodexAppServerAdapter(options) {
941
988
  return { outcome: 'uncertain', threadId: input.threadId };
942
989
  try {
943
990
  thread.turnActive = true;
991
+ thread.scheduledTaskProposalBridge = input.scheduledTaskProposalBridge;
944
992
  thread.uiSnapshot = options.workspaceUi && input.uiSnapshot?.sessionId === input.sessionId ? input.uiSnapshot : undefined;
945
993
  const started = await rpc.sendReserved(requestId, 'turn/start', { threadId: input.threadId, input: [
946
994
  { type: 'text', text: input.text },
@@ -1240,6 +1288,7 @@ export function createCodexAppServerAdapter(options) {
1240
1288
  return { outcome: 'failed', error: 'turn_changed' };
1241
1289
  try {
1242
1290
  await uiTools.cancel(input.threadId);
1291
+ await productionSkill?.cancel(input.threadId, turnId);
1243
1292
  await rpc.request('turn/interrupt', { threadId: input.threadId, turnId });
1244
1293
  const cwd = threads.get(input.threadId)?.cwd;
1245
1294
  if (cwd && (input.expectedTurnId === undefined || threads.get(input.threadId)?.lastTurnId === turnId))
@@ -1300,6 +1349,7 @@ export function createCodexAppServerAdapter(options) {
1300
1349
  closed = true;
1301
1350
  readyFlag = false;
1302
1351
  await uiTools.cancel(undefined, 'ui_runtime_disconnected');
1352
+ await productionSkill?.cancel();
1303
1353
  approvals.clear();
1304
1354
  if (live)
1305
1355
  live.superseded = true;
@@ -1,6 +1,6 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
- import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { chmod, mkdir, writeFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
  const exec = promisify(execFile);
@@ -77,13 +77,17 @@ export async function writePortableCodexCatalog(input) {
77
77
  }
78
78
  await mkdir(input.directory, { recursive: true });
79
79
  const path = join(input.directory, 'portable-models.json');
80
- // private
81
- await writeFile(path, body, { encoding: 'utf8', mode: 0o644 });
82
- // private
83
- await writeFile(join(input.directory, 'portable-models.version.json'), JSON.stringify({
80
+ // shared: public model metadata crosses from the control user to the development user.
81
+ // Explicit permissions also repair existing files and override a private process umask.
82
+ await writeFile(path, body, { encoding: 'utf8', mode: 0o644 }); // shared
83
+ await chmod(path, 0o644);
84
+ const versionPath = join(input.directory, 'portable-models.version.json');
85
+ await writeFile(// shared
86
+ versionPath, JSON.stringify({
84
87
  version,
85
88
  sha256: createHash('sha256').update(body).digest('hex'),
86
89
  }), { encoding: 'utf8', mode: 0o644 });
90
+ await chmod(versionPath, 0o644);
87
91
  return path;
88
92
  }
89
93
  export async function writePlaycraftCodexConfig(input) {
@@ -1,11 +1,11 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { DELIVERY_CHANNELS, deliveryFormatSchema, } from '@playcraft/common/cloud-workspace/runtime';
2
+ import { DELIVERY_CHANNELS, } from '@playcraft/common/cloud-workspace/runtime';
3
3
  import { rejectTool, toolContent } from './tool-response.js';
4
4
  export const WORKSPACE_DELIVERY_TOOLS = [
5
5
  {
6
6
  type: 'function',
7
7
  name: 'playcraft_build_delivery',
8
- description: 'Export channel packages through the PlayCraft platform. This first saves the workspace (commit and push), then exports that commit; each channel reuses the options of its latest export on this branch, or the defaults when it has none. A workspace without a project first gets one, with a private repository, as its first save would. If the result is pending at workspace.save, finish the current turn without polling so the save can run, then continue the export in a later turn. Once build ids are returned, poll playcraft_get_delivery for status, checks and failure details.',
8
+ description: 'Export channel packages through the PlayCraft platform. This first saves the workspace (commit and push), then exports that commit. Each channel uses the same fixed HTML or ZIP choice as the Export button for that channel and the project engine. Other options reuse the channel\'s latest export on this branch, or the defaults when it has none. A workspace without a project first gets one, with a private repository, as its first save would. If the result is pending at workspace.save, finish the current turn without polling so the save can run, then continue the export in a later turn. Once build ids are returned, poll playcraft_get_delivery for status, checks and failure details.',
9
9
  inputSchema: {
10
10
  type: 'object',
11
11
  properties: {
@@ -16,7 +16,6 @@ export const WORKSPACE_DELIVERY_TOOLS = [
16
16
  maxItems: 8,
17
17
  uniqueItems: true,
18
18
  },
19
- format: { type: 'string', enum: ['html', 'zip'] },
20
19
  },
21
20
  additionalProperties: false,
22
21
  },
@@ -71,7 +70,7 @@ export class WorkspaceDeliveryTools {
71
70
  await this.reject(client, requestId, 'permission_denied');
72
71
  return;
73
72
  }
74
- const unknown = Object.keys(input).filter(key => key !== 'channels' && key !== 'format');
73
+ const unknown = Object.keys(input).filter(key => key !== 'channels');
75
74
  if (unknown.length) {
76
75
  await this.reject(client, requestId, 'invalid_delivery_tool_arguments');
77
76
  return;
@@ -83,11 +82,6 @@ export class WorkspaceDeliveryTools {
83
82
  await this.reject(client, requestId, 'invalid_delivery_tool_arguments');
84
83
  return;
85
84
  }
86
- const format = input.format === undefined ? undefined : deliveryFormatSchema.safeParse(input.format);
87
- if (format && !format.success) {
88
- await this.reject(client, requestId, 'invalid_delivery_tool_arguments');
89
- return;
90
- }
91
85
  try {
92
86
  const result = await this.handlers.build({
93
87
  ...context,
@@ -95,7 +89,6 @@ export class WorkspaceDeliveryTools {
95
89
  ? payload.callId
96
90
  : randomUUID(),
97
91
  channels: channels,
98
- ...(format && format.success ? { format: format.data } : {}),
99
92
  });
100
93
  await this.respond(client, requestId, result);
101
94
  }
@@ -33,6 +33,13 @@ function request(socketPath, message) {
33
33
  });
34
34
  });
35
35
  }
36
+ /** A persisted identity lets the controller stop development-user writers after a request ends. */
37
+ export async function stopLauncherProcess(input) {
38
+ if (!input.instanceId)
39
+ throw new Error('launcher_start_unconfirmed');
40
+ const { socketPath, ...identity } = input;
41
+ await stopLauncherExecution(socketPath, identity);
42
+ }
36
43
  /** The private launcher owns the boundary; development shells never receive its identity. */
37
44
  export async function prepareLauncherOrigin(input) {
38
45
  try {
@@ -51,6 +58,24 @@ export async function verifyLauncherOrigin(input) {
51
58
  return false;
52
59
  }
53
60
  }
61
+ /** Recovered writers need a receipt from the same launcher, not just an absent process id. */
62
+ export async function stopLauncherExecution(socketPath, identity) {
63
+ if (!identity || typeof identity.processId !== 'string' || !identity.processId ||
64
+ typeof identity.instanceId !== 'string' || !identity.instanceId ||
65
+ !Number.isSafeInteger(identity.runtimeEpoch) || identity.runtimeEpoch < 0)
66
+ throw new Error('launcher_execution_identity_invalid');
67
+ for (const signal of ['SIGTERM', 'SIGKILL']) {
68
+ await request(socketPath, { ...identity, action: 'stop', signal });
69
+ const deadline = Date.now() + 3000;
70
+ while (Date.now() < deadline) {
71
+ const status = await request(socketPath, { ...identity, action: 'status' });
72
+ if (status.running === false)
73
+ return;
74
+ await delay(50);
75
+ }
76
+ }
77
+ throw new Error('codex_group_stop_unconfirmed');
78
+ }
54
79
  export async function startLauncherProcess(input) {
55
80
  const socket = createConnection(input.socketPath);
56
81
  const stdout = new PassThrough();
@@ -98,9 +123,12 @@ export async function startLauncherProcess(input) {
98
123
  try {
99
124
  const frame = JSON.parse(line);
100
125
  if (!started) {
126
+ if (frame.ok === false)
127
+ throw Object.assign(new Error(frame.error || 'launcher_start_failed'), { launchRejected: true });
101
128
  if (!frame.ok || !frame.pid || !frame.instanceId)
102
129
  throw new Error(frame.error || 'launcher_start_failed');
103
130
  identity = {
131
+ socketPath: input.socketPath,
104
132
  processId: input.processId,
105
133
  runtimeEpoch: input.runtimeEpoch,
106
134
  instanceId: frame.instanceId,
@@ -146,7 +174,13 @@ export async function startLauncherProcess(input) {
146
174
  });
147
175
  });
148
176
  return {
177
+ identity,
149
178
  pid,
179
+ executionIdentity: {
180
+ processId: identity.processId,
181
+ runtimeEpoch: identity.runtimeEpoch,
182
+ instanceId: identity.instanceId,
183
+ },
150
184
  stdin,
151
185
  stdout,
152
186
  stderr,
@@ -163,31 +197,19 @@ export async function startLauncherProcess(input) {
163
197
  },
164
198
  stop() {
165
199
  stopPromise ??= (async () => {
166
- // A new launcher's empty registry cannot confirm an old group's exit.
167
- for (const signal of ['SIGTERM', 'SIGKILL']) {
168
- await request(input.socketPath, { ...identity, action: 'stop', signal });
169
- const deadline = Date.now() + 3000;
170
- while (Date.now() < deadline) {
171
- const status = await request(input.socketPath, { ...identity, action: 'status' });
172
- if (status.running === false) {
173
- let timeout;
174
- try {
175
- await Promise.race([transportClosed, new Promise((_resolve, reject) => {
176
- timeout = setTimeout(() => {
177
- socket.destroy();
178
- reject(new Error('launcher_output_drain_unconfirmed'));
179
- }, 3000);
180
- })]);
181
- }
182
- finally {
183
- clearTimeout(timeout);
184
- }
185
- return;
186
- }
187
- await delay(50);
188
- }
200
+ await stopLauncherProcess(identity);
201
+ let timeout;
202
+ try {
203
+ await Promise.race([transportClosed, new Promise((_resolve, reject) => {
204
+ timeout = setTimeout(() => {
205
+ socket.destroy();
206
+ reject(new Error('launcher_output_drain_unconfirmed'));
207
+ }, 3000);
208
+ })]);
209
+ }
210
+ finally {
211
+ clearTimeout(timeout);
189
212
  }
190
- throw new Error('codex_group_stop_unconfirmed');
191
213
  })();
192
214
  return stopPromise;
193
215
  },
@@ -7,6 +7,7 @@ import { z } from 'zod';
7
7
  import { workspaceContextFromRoot } from '../workspaces/registry.js';
8
8
  import { PLATFORM_ROUTES, modelResponsesPath, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
9
9
  import { WorkspaceGenerationProvenanceBroker } from './generation-provenance-broker.js';
10
+ import { WorkspaceRemixReferencesBroker } from './remix-references-broker.js';
10
11
  const credentialSchema = z.object({
11
12
  token: z.string().min(1), expiresAt: z.string().datetime(),
12
13
  configId: z.string().uuid(), model: z.string().min(1).max(128),
@@ -27,9 +28,11 @@ export class WorkspaceModelBroker {
27
28
  requests = new Set();
28
29
  sessionRequests = new Map();
29
30
  generation;
31
+ references;
30
32
  constructor(state) {
31
33
  this.state = state;
32
34
  this.generation = new WorkspaceGenerationProvenanceBroker(state);
35
+ this.references = new WorkspaceRemixReferencesBroker(state);
33
36
  }
34
37
  async start() {
35
38
  if (this.server)
@@ -74,16 +77,37 @@ export class WorkspaceModelBroker {
74
77
  const boundaryId = sessionId && clientRequestId ? await this.generation.prepare() : undefined;
75
78
  if (!route || route.epoch !== this.state.runtimeEpoch) {
76
79
  route = { workspaceId, epoch: this.state.runtimeEpoch, capability: randomBytes(32).toString('base64url'),
77
- generationCapability: randomBytes(32).toString('base64url') };
80
+ generationCapability: randomBytes(32).toString('base64url'), referencesCapability: randomBytes(32).toString('base64url') };
78
81
  this.routes.set(routeKey, route);
79
82
  }
80
83
  // Each session pins its prepared model; shared credential renewal cannot change an active turn.
81
84
  return {
82
- activate: () => {
85
+ activate: (readOnly = false) => {
83
86
  if (!this.valid(entry) || this.routes.get(routeKey) !== route)
84
87
  throw new Error('model_access_denied');
88
+ const priorFeedback = route.selected?.feedback;
89
+ let refsBlocked = Boolean(entry.refsBlocked || priorFeedback?.refsBlocked && priorFeedback.clientRequestId === clientRequestId);
90
+ if (sessionId && clientRequestId) {
91
+ const overlaps = (this.state.journal.listOperations?.() ?? []).some(({ operation }) => ['session.turn', 'session.review', 'session.compact'].includes(operation.kind)
92
+ && ['dispatched', 'accepted', 'uncertain'].includes(operation.status)
93
+ && operation.generation === this.state.config.generation && operation.runtimeEpoch === this.state.runtimeEpoch
94
+ && (operation.sessionId !== sessionId || operation.clientRequestId !== clientRequestId));
95
+ if (overlaps) {
96
+ refsBlocked = true;
97
+ // A completed overlapping turn may leave shells alive. Only a later turn gets a new safe boundary.
98
+ for (const prepared of this.entries.values()) {
99
+ if (prepared.epoch === this.state.runtimeEpoch)
100
+ prepared.refsBlocked = true;
101
+ }
102
+ for (const existing of this.routes.values()) {
103
+ if (existing.epoch === this.state.runtimeEpoch && existing.selected?.feedback)
104
+ existing.selected.feedback.refsBlocked = true;
105
+ }
106
+ }
107
+ }
85
108
  route.selected = { entry, model: credential.model,
86
- ...(sessionId && clientRequestId ? { feedback: { sessionId, clientRequestId, cwd, boundaryId, ...(originCwd ? { originCwd } : {}) } } : {}) };
109
+ ...(sessionId && clientRequestId ? { feedback: { sessionId, clientRequestId, cwd, boundaryId, readOnly, refsBlocked,
110
+ ...(originCwd ? { originCwd } : {}) } } : {}) };
87
111
  },
88
112
  model: credential.model,
89
113
  modelConfigId: credential.configId,
@@ -92,6 +116,8 @@ export class WorkspaceModelBroker {
92
116
  ...(sessionId ? { 'shell_environment_policy.set': {
93
117
  PLAYCRAFT_AGENT_GENERATION_ENDPOINT: `${this.baseUrl}/${routeKey}/generation-provenance`,
94
118
  PLAYCRAFT_AGENT_GENERATION_CAPABILITY: route.generationCapability,
119
+ PLAYCRAFT_REMIX_REFERENCES_ENDPOINT: `${this.baseUrl}/${routeKey}/remix-references`,
120
+ PLAYCRAFT_REMIX_REFERENCES_CAPABILITY: route.referencesCapability,
95
121
  } } : {}),
96
122
  'model_providers.playcraft_workspace': {
97
123
  name: 'PlayCraft Workspace', base_url: `${this.baseUrl}/${routeKey}`,
@@ -154,13 +180,15 @@ export class WorkspaceModelBroker {
154
180
  const cancel = () => abort.abort();
155
181
  response.once('close', cancel);
156
182
  try {
157
- const match = /^\/([0-9a-f-]{36}(?:\/[0-9a-f]{32})?)\/(responses|generation-provenance)$/.exec(request.url ?? '');
183
+ const match = /^\/([0-9a-f-]{36}(?:\/[0-9a-f]{32})?)\/(responses|generation-provenance|remix-references)$/.exec(request.url ?? '');
158
184
  const route = match ? this.routes.get(match[1]) : undefined;
159
185
  const selected = route?.selected;
160
186
  const entry = selected?.entry;
161
187
  const presented = Buffer.from(request.headers.authorization ?? '');
162
188
  const generationRequest = match?.[2] === 'generation-provenance';
163
- const expected = Buffer.from(route ? `Bearer ${generationRequest ? route.generationCapability : route.capability}` : '');
189
+ const referencesRequest = match?.[2] === 'remix-references';
190
+ const expected = Buffer.from(route ? `Bearer ${generationRequest ? route.generationCapability
191
+ : referencesRequest ? route.referencesCapability : route.capability}` : '');
164
192
  if (request.method !== 'POST' || !entry || !expected.length || presented.length !== expected.length
165
193
  || !timingSafeEqual(presented, expected) || !this.valid(entry)) {
166
194
  response.writeHead(403);
@@ -176,6 +204,15 @@ export class WorkspaceModelBroker {
176
204
  await this.generation.forward(request, response, selected.feedback, () => this.valid(entry) && route?.selected === selected);
177
205
  return;
178
206
  }
207
+ if (referencesRequest) {
208
+ if (!selected?.feedback) {
209
+ response.writeHead(403);
210
+ response.end();
211
+ return;
212
+ }
213
+ await this.references.forward(request, response, selected.feedback, () => this.valid(entry) && route?.selected === selected);
214
+ return;
215
+ }
179
216
  activeRoute = route;
180
217
  const active = this.sessionRequests.get(route) ?? new Set();
181
218
  active.add(abort);