@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.
- package/dist/cli-root-help.js +1 -0
- package/dist/commands/references.js +55 -0
- package/dist/index.js +21 -3
- package/dist/project-skills/builtin-import.js +2 -2
- package/dist/references/client.js +69 -0
- package/dist/references/messages.js +57 -0
- package/dist/references/protocol.js +32 -0
- package/dist/skill-registry/messages.js +2 -2
- package/dist/skill-registry/platform-package.js +2 -4
- package/dist/workspace-runtime/batch/control.js +3 -3
- package/dist/workspace-runtime/batch/deliver.js +4 -3
- package/dist/workspace-runtime/batch/generate.js +3 -2
- package/dist/workspace-runtime/batch/install-log.js +49 -0
- package/dist/workspace-runtime/batch/prepare.js +29 -13
- package/dist/workspace-runtime/batch/processes.js +86 -14
- package/dist/workspace-runtime/batch/writer-proof.js +2 -2
- package/dist/workspace-runtime/codex/app-server.js +52 -2
- package/dist/workspace-runtime/codex/config-toml.js +9 -5
- package/dist/workspace-runtime/codex/delivery-tools.js +3 -10
- package/dist/workspace-runtime/codex/launcher-client.js +46 -24
- package/dist/workspace-runtime/codex/model-broker.js +42 -5
- package/dist/workspace-runtime/codex/production-skill-tools.js +95 -0
- package/dist/workspace-runtime/codex/remix-references-broker.js +184 -0
- package/dist/workspace-runtime/codex/scheduled-task-tools.js +80 -0
- package/dist/workspace-runtime/processes/skill-command.js +130 -0
- package/dist/workspace-runtime/serve.js +9 -1
- package/dist/workspace-runtime/server/accept-operation.js +10 -1
- package/dist/workspace-runtime/server/directory-access.js +10 -3
- package/dist/workspace-runtime/server/dispatch.js +40 -1
- package/dist/workspace-runtime/server/http.js +39 -4
- package/dist/workspace-runtime/server/operation-record.js +4 -1
- package/dist/workspace-runtime/workspaces/dependencies.js +35 -10
- package/dist/workspace-runtime/workspaces/developer-command.js +65 -64
- package/dist/workspace-runtime/workspaces/directories.js +4 -1
- package/dist/workspace-runtime/workspaces/empty-template.js +5 -1
- package/dist/workspace-runtime/workspaces/platform-export.js +3 -3
- package/dist/workspace-runtime/workspaces/procedural-3d/candidate.js +129 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/contracts.js +34 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/files.js +83 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/host.js +595 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/render-worker.js +159 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/review-contracts.js +257 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/review-page.js +26 -0
- package/dist/workspace-runtime/workspaces/remix-reference-skill.js +126 -0
- package/dist/workspace-runtime/workspaces/scheduled-task-proposals.js +27 -0
- package/dist/workspace-runtime/workspaces/skill-execution-context.js +69 -0
- package/dist/workspace-runtime/workspaces/skills.js +18 -6
- package/package.json +3 -3
- package/project-template/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
- package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +0 -2
- package/project-template-v2/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
- package/project-template-v2/.playcraft/skills.lock.json +14 -0
- package/project-templates/empty/AGENTS.md +19 -0
- package/project-templates/phaser/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
- package/project-templates/threejs/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
|
@@ -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
|
|
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
|
|
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
|
|
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) => {
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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 {
|
|
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 (
|
|
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
|
-
|
|
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
|
-
//
|
|
81
|
-
|
|
82
|
-
//
|
|
83
|
-
await
|
|
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,
|
|
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
|
|
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'
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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,
|
|
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
|
|
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);
|