@playcraft/cli 0.1.59-beta → 0.1.60-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli-root-help.js +1 -0
- package/dist/commands/references.js +55 -0
- package/dist/index.js +21 -3
- package/dist/references/client.js +69 -0
- package/dist/references/messages.js +57 -0
- package/dist/references/protocol.js +32 -0
- package/dist/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 +28 -1
- package/dist/workspace-runtime/codex/launcher-client.js +34 -24
- package/dist/workspace-runtime/codex/model-broker.js +42 -5
- package/dist/workspace-runtime/codex/remix-references-broker.js +184 -0
- package/dist/workspace-runtime/codex/scheduled-task-tools.js +80 -0
- package/dist/workspace-runtime/serve.js +2 -0
- package/dist/workspace-runtime/server/dispatch.js +1 -0
- package/dist/workspace-runtime/server/http.js +14 -1
- package/dist/workspace-runtime/workspaces/empty-template.js +2 -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/skills.js +3 -1
- package/package.json +3 -3
- package/project-template/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
- package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +0 -2
- package/project-template-v2/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
- package/project-template-v2/.playcraft/skills.lock.json +7 -0
- package/project-templates/empty/AGENTS.md +9 -0
- package/project-templates/phaser/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
- package/project-templates/threejs/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
|
@@ -19,6 +19,7 @@ import { readNativeThreadImage } from './thread-image.js';
|
|
|
19
19
|
import { WORKSPACE_UI_TOOLS, WorkspaceUiTools } from './ui-tools.js';
|
|
20
20
|
import { WORKSPACE_DELIVERY_TOOLS, WorkspaceDeliveryTools, isWorkspaceDeliveryTool } from './delivery-tools.js';
|
|
21
21
|
import { WORKSPACE_UI_INSTRUCTIONS } from './ui-instructions.js';
|
|
22
|
+
import { WORKSPACE_SCHEDULED_TASK_TOOLS, WorkspaceScheduledTaskTools, isWorkspaceScheduledTaskTool } from './scheduled-task-tools.js';
|
|
22
23
|
function withModelShell(config, modelConfigId) {
|
|
23
24
|
if (!modelConfigId || !/^[0-9a-f-]{36}$/i.test(modelConfigId))
|
|
24
25
|
return config;
|
|
@@ -191,9 +192,13 @@ export function createCodexAppServerAdapter(options) {
|
|
|
191
192
|
const coordinatorDirectories = new Map();
|
|
192
193
|
const uiTools = new WorkspaceUiTools(emitCollected, options.uiToolTimeoutMs);
|
|
193
194
|
const deliveryTools = options.deliveryTools ? new WorkspaceDeliveryTools(options.deliveryTools) : undefined;
|
|
195
|
+
const scheduledTaskTools = options.scheduledTaskProposals ? new WorkspaceScheduledTaskTools(options.scheduledTaskProposals, (proposalId, source) => emitCollected(collectedEvent('notification', 'scheduled-task.proposal.changed', {
|
|
196
|
+
proposalId, sessionId: threads.get(source.threadId)?.sessionId, threadId: source.threadId, turnId: source.turnId,
|
|
197
|
+
}))) : undefined;
|
|
194
198
|
const dynamicTools = [
|
|
195
199
|
...(options.workspaceUi ? WORKSPACE_UI_TOOLS : []),
|
|
196
200
|
...(deliveryTools ? WORKSPACE_DELIVERY_TOOLS : []),
|
|
201
|
+
...(scheduledTaskTools ? WORKSPACE_SCHEDULED_TASK_TOOLS : []),
|
|
197
202
|
];
|
|
198
203
|
function emitCollected(event) {
|
|
199
204
|
for (const listener of collected)
|
|
@@ -609,6 +614,26 @@ export function createCodexAppServerAdapter(options) {
|
|
|
609
614
|
}
|
|
610
615
|
if (method === 'item/tool/call') {
|
|
611
616
|
const thread = typeof payload.threadId === 'string' ? threads.get(payload.threadId) : undefined;
|
|
617
|
+
if (scheduledTaskTools && isWorkspaceScheduledTaskTool(payload.tool)) {
|
|
618
|
+
const threadId = String(payload.threadId ?? ''), turnId = String(payload.turnId ?? '');
|
|
619
|
+
const current = () => {
|
|
620
|
+
try {
|
|
621
|
+
return Boolean(thread && threads.get(threadId) === thread && thread.turnActive
|
|
622
|
+
&& thread.lastTurnId === turnId && !childParents.has(threadId)
|
|
623
|
+
&& ['execute', 'plan'].includes(thread.activeMode ?? '') && !options.fixedExecution
|
|
624
|
+
&& !options.trustedExecution?.(thread.sessionId));
|
|
625
|
+
}
|
|
626
|
+
catch {
|
|
627
|
+
return false;
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
setTimeout(() => {
|
|
631
|
+
void scheduledTaskTools.request(id, payload, nativeClient, thread ? {
|
|
632
|
+
bridge: thread.scheduledTaskProposalBridge, threadId, turnId, current,
|
|
633
|
+
} : undefined);
|
|
634
|
+
}, 0);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
612
637
|
if (deliveryTools && isWorkspaceDeliveryTool(payload.tool)) {
|
|
613
638
|
void deliveryTools.request(id, payload, nativeClient, {
|
|
614
639
|
sessionId: thread?.sessionId,
|
|
@@ -734,6 +759,7 @@ export function createCodexAppServerAdapter(options) {
|
|
|
734
759
|
name: 'codex-app-server',
|
|
735
760
|
workspaceUi: options.workspaceUi === true,
|
|
736
761
|
ready: () => readyFlag && !closed,
|
|
762
|
+
...(scheduledTaskTools ? { scheduledTaskProposalSource: (source) => scheduledTaskTools.isActive(source) } : {}),
|
|
737
763
|
executionProcess() {
|
|
738
764
|
if (!live)
|
|
739
765
|
return undefined;
|
|
@@ -927,7 +953,7 @@ export function createCodexAppServerAdapter(options) {
|
|
|
927
953
|
selectedModel ??= thread.resolvedModel ?? model;
|
|
928
954
|
const requestId = rpc.reserveRequest();
|
|
929
955
|
await input.persistReservation({ nativeRequestId: String(requestId), modelConfigId, reasoningEffort, permissionMode });
|
|
930
|
-
activateModel();
|
|
956
|
+
activateModel(execution.sandboxPolicy.type === 'readOnly');
|
|
931
957
|
thread.modelConfigId = modelConfigId;
|
|
932
958
|
thread.reasoningEffort = reasoningEffort;
|
|
933
959
|
thread.permissionMode = permissionMode;
|
|
@@ -941,6 +967,7 @@ export function createCodexAppServerAdapter(options) {
|
|
|
941
967
|
return { outcome: 'uncertain', threadId: input.threadId };
|
|
942
968
|
try {
|
|
943
969
|
thread.turnActive = true;
|
|
970
|
+
thread.scheduledTaskProposalBridge = input.scheduledTaskProposalBridge;
|
|
944
971
|
thread.uiSnapshot = options.workspaceUi && input.uiSnapshot?.sessionId === input.sessionId ? input.uiSnapshot : undefined;
|
|
945
972
|
const started = await rpc.sendReserved(requestId, 'turn/start', { threadId: input.threadId, input: [
|
|
946
973
|
{ type: 'text', text: input.text },
|
|
@@ -33,6 +33,24 @@ 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
|
+
// A new launcher's empty registry cannot confirm an old group's exit.
|
|
42
|
+
for (const signal of ['SIGTERM', 'SIGKILL']) {
|
|
43
|
+
await request(socketPath, { ...identity, action: 'stop', signal });
|
|
44
|
+
const deadline = Date.now() + 3000;
|
|
45
|
+
while (Date.now() < deadline) {
|
|
46
|
+
const status = await request(socketPath, { ...identity, action: 'status' });
|
|
47
|
+
if (status.running === false)
|
|
48
|
+
return;
|
|
49
|
+
await delay(50);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
throw new Error('codex_group_stop_unconfirmed');
|
|
53
|
+
}
|
|
36
54
|
/** The private launcher owns the boundary; development shells never receive its identity. */
|
|
37
55
|
export async function prepareLauncherOrigin(input) {
|
|
38
56
|
try {
|
|
@@ -98,9 +116,12 @@ export async function startLauncherProcess(input) {
|
|
|
98
116
|
try {
|
|
99
117
|
const frame = JSON.parse(line);
|
|
100
118
|
if (!started) {
|
|
119
|
+
if (frame.ok === false)
|
|
120
|
+
throw Object.assign(new Error(frame.error || 'launcher_start_failed'), { launchRejected: true });
|
|
101
121
|
if (!frame.ok || !frame.pid || !frame.instanceId)
|
|
102
122
|
throw new Error(frame.error || 'launcher_start_failed');
|
|
103
123
|
identity = {
|
|
124
|
+
socketPath: input.socketPath,
|
|
104
125
|
processId: input.processId,
|
|
105
126
|
runtimeEpoch: input.runtimeEpoch,
|
|
106
127
|
instanceId: frame.instanceId,
|
|
@@ -146,6 +167,7 @@ export async function startLauncherProcess(input) {
|
|
|
146
167
|
});
|
|
147
168
|
});
|
|
148
169
|
return {
|
|
170
|
+
identity: identity,
|
|
149
171
|
pid,
|
|
150
172
|
stdin,
|
|
151
173
|
stdout,
|
|
@@ -163,31 +185,19 @@ export async function startLauncherProcess(input) {
|
|
|
163
185
|
},
|
|
164
186
|
stop() {
|
|
165
187
|
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
|
-
}
|
|
188
|
+
await stopLauncherProcess(identity);
|
|
189
|
+
let timeout;
|
|
190
|
+
try {
|
|
191
|
+
await Promise.race([transportClosed, new Promise((_resolve, reject) => {
|
|
192
|
+
timeout = setTimeout(() => {
|
|
193
|
+
socket.destroy();
|
|
194
|
+
reject(new Error('launcher_output_drain_unconfirmed'));
|
|
195
|
+
}, 3000);
|
|
196
|
+
})]);
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
clearTimeout(timeout);
|
|
189
200
|
}
|
|
190
|
-
throw new Error('codex_group_stop_unconfirmed');
|
|
191
201
|
})();
|
|
192
202
|
return stopPromise;
|
|
193
203
|
},
|
|
@@ -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);
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { realpath } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, relative } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { PLATFORM_ROUTES, platformEndpoint, WORKSPACE_REMIX_REFERENCE_BODY_MAX_BYTES, WORKSPACE_REMIX_REFERENCES_MANIFEST, workspaceRemixReferenceCandidateSchema, workspaceRemixReferenceExportSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { withDirectoryWrite } from '../server/directory-access.js';
|
|
6
|
+
import { wakeSourceEventUploader } from '../server/source-uploader.js';
|
|
7
|
+
import { workspaceContextFromRoot } from '../workspaces/registry.js';
|
|
8
|
+
import { importWorkspaceRemixReference, listWorkspaceRemixReferences } from '../workspaces/remix-references.js';
|
|
9
|
+
import { shareWorkspaceGit } from '../workspaces/shared-git.js';
|
|
10
|
+
import { batchDirectoryHeld } from '../batch/occupancy.js';
|
|
11
|
+
import { verifyLauncherOrigin } from './launcher-client.js';
|
|
12
|
+
import { localRemixReference, RemixReferenceToolError, remixReferenceToolRequestSchema } from '../../references/protocol.js';
|
|
13
|
+
const ACTIVE = new Set(['dispatched', 'accepted', 'uncertain']);
|
|
14
|
+
const candidatesSchema = z.object({ items: z.array(z.unknown()).max(50),
|
|
15
|
+
total: z.number().int().nonnegative(), page: z.number().int().positive(), pageSize: z.number().int().positive() }).strict();
|
|
16
|
+
const SOURCE_ERRORS = new Set(['remix_reference_unavailable', 'remix_reference_too_large', 'remix_reference_incomplete',
|
|
17
|
+
'remix_reference_unsupported', 'remix_reference_source_changed', 'workspace_access_denied', 'workspace_permission_denied']);
|
|
18
|
+
/** Executes source-reference operations only for the shell and native turn owning this route. */
|
|
19
|
+
export class WorkspaceRemixReferencesBroker {
|
|
20
|
+
state;
|
|
21
|
+
constructor(state) {
|
|
22
|
+
this.state = state;
|
|
23
|
+
}
|
|
24
|
+
operation(context, valid, write = false) {
|
|
25
|
+
const state = this.state;
|
|
26
|
+
const session = state.journal.getSession(context.sessionId);
|
|
27
|
+
const operations = state.journal.listOperations().map(item => item.operation).filter(operation => ['session.turn', 'session.review', 'session.compact'].includes(operation.kind)
|
|
28
|
+
&& ACTIVE.has(operation.status) && operation.generation === state.config.generation && operation.runtimeEpoch === state.runtimeEpoch);
|
|
29
|
+
const operation = operations.length === 1 ? operations[0] : undefined;
|
|
30
|
+
if (!valid() || context.refsBlocked || state.journal.destroyed() || !operation || operation.kind !== 'session.turn' || operation.sessionId !== context.sessionId
|
|
31
|
+
|| operation.generation !== state.config.generation || operation.runtimeEpoch !== state.runtimeEpoch
|
|
32
|
+
|| operation.clientRequestId !== context.clientRequestId || !operation.workspaceId || !operation.directoryName
|
|
33
|
+
|| operation.directoryName === 'root' || !operation.nativeThreadId || !operation.nativeTurnId
|
|
34
|
+
|| !session || session.threadId !== operation.nativeThreadId || session.activeTurnId !== operation.nativeTurnId
|
|
35
|
+
|| session.cwd !== context.cwd || session.workspaceId !== operation.workspaceId
|
|
36
|
+
|| (session.directoryName ?? 'basic') !== operation.directoryName || session.turnBlock === 'uncertain'
|
|
37
|
+
|| !['running', 'waiting_for_user'].includes(session.turnStatus ?? ''))
|
|
38
|
+
throw new RemixReferenceToolError('remix_reference_turn_unavailable');
|
|
39
|
+
if (write) {
|
|
40
|
+
if ((operation.leaseFence ?? 0) < state.acceptedLeaseFence)
|
|
41
|
+
throw new RemixReferenceToolError('remix_reference_turn_unavailable');
|
|
42
|
+
if (context.readOnly || operation.workMode === 'plan' || operation.permissionMode === 'read-only' || session.permissionMode === 'read-only')
|
|
43
|
+
throw new RemixReferenceToolError('remix_reference_readonly');
|
|
44
|
+
if (state.journal.listSessions().some(other => other.sessionId !== context.sessionId
|
|
45
|
+
&& other.workspaceId === operation.workspaceId
|
|
46
|
+
&& ((other.directoryName ?? 'basic') === operation.directoryName || other.directoryName === 'root')
|
|
47
|
+
&& (other.turnBlock === 'uncertain' || ['starting', 'running', 'waiting_for_user'].includes(other.turnStatus ?? ''))))
|
|
48
|
+
throw new RemixReferenceToolError('turn_already_active');
|
|
49
|
+
}
|
|
50
|
+
return operation;
|
|
51
|
+
}
|
|
52
|
+
async current(context, valid, write = false) {
|
|
53
|
+
const operation = this.operation(context, valid, write);
|
|
54
|
+
const scope = await workspaceContextFromRoot(this.state, context.cwd);
|
|
55
|
+
if (scope.workspaceId !== operation.workspaceId || scope.directoryName !== operation.directoryName)
|
|
56
|
+
throw new RemixReferenceToolError('remix_reference_scope_mismatch');
|
|
57
|
+
if (write && await batchDirectoryHeld(this.state, scope.workspaceId, scope.directoryName))
|
|
58
|
+
throw new RemixReferenceToolError('batch_workspace_readonly');
|
|
59
|
+
this.operation(context, valid, write);
|
|
60
|
+
return { ...scope, operation };
|
|
61
|
+
}
|
|
62
|
+
async forward(request, response, context, valid) {
|
|
63
|
+
const reply = (status, value) => {
|
|
64
|
+
response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
65
|
+
response.end(JSON.stringify(value));
|
|
66
|
+
};
|
|
67
|
+
try {
|
|
68
|
+
if (request.socket.remoteAddress !== '127.0.0.1')
|
|
69
|
+
throw new RemixReferenceToolError('remix_reference_turn_unavailable');
|
|
70
|
+
let text = '';
|
|
71
|
+
for await (const chunk of request) {
|
|
72
|
+
text += chunk.toString();
|
|
73
|
+
if (Buffer.byteLength(text) > 16 * 1024)
|
|
74
|
+
throw new RemixReferenceToolError('invalid_remix_reference_tool_request');
|
|
75
|
+
}
|
|
76
|
+
const parsed = remixReferenceToolRequestSchema.safeParse(JSON.parse(text));
|
|
77
|
+
if (!parsed.success)
|
|
78
|
+
throw new RemixReferenceToolError('invalid_remix_reference_tool_request');
|
|
79
|
+
const input = parsed.data;
|
|
80
|
+
const write = input.action === 'import';
|
|
81
|
+
const scope = await this.current(context, valid, write);
|
|
82
|
+
const callerCwd = await realpath(input.cwd);
|
|
83
|
+
const childPath = relative(context.cwd, callerCwd);
|
|
84
|
+
if (isAbsolute(childPath) || childPath === '..' || childPath.startsWith('../') || input.workspaceId !== undefined && input.workspaceId !== scope.workspaceId
|
|
85
|
+
|| input.directoryName !== undefined && input.directoryName !== scope.directoryName)
|
|
86
|
+
throw new RemixReferenceToolError('remix_reference_scope_mismatch');
|
|
87
|
+
const socketPath = process.env.PLAYCRAFT_LAUNCHER_SOCKET;
|
|
88
|
+
if (!socketPath || !context.boundaryId || !request.socket.localPort || !request.socket.remotePort
|
|
89
|
+
|| !await verifyLauncherOrigin({ socketPath, boundaryId: context.boundaryId, runtimeEpoch: this.state.runtimeEpoch,
|
|
90
|
+
serverPort: request.socket.localPort, clientPort: request.socket.remotePort, cwd: context.originCwd ?? callerCwd }))
|
|
91
|
+
throw new RemixReferenceToolError('remix_reference_turn_unavailable');
|
|
92
|
+
await this.current(context, valid, write);
|
|
93
|
+
if (input.action === 'list') {
|
|
94
|
+
const items = await this.state.journal.exclusive(`workspace:${scope.workspaceId}:${scope.directoryName}`, () => listWorkspaceRemixReferences(context.cwd, scope.directoryName));
|
|
95
|
+
this.operation(context, valid);
|
|
96
|
+
return reply(200, { ok: true, workspaceId: scope.workspaceId, directoryName: scope.directoryName,
|
|
97
|
+
items: items.map(reference => localRemixReference(context.cwd, reference)) });
|
|
98
|
+
}
|
|
99
|
+
if (input.action === 'search') {
|
|
100
|
+
const result = candidatesSchema.parse(await this.upstream(scope, { action: 'search', source: input.source,
|
|
101
|
+
...(input.tagCategory === undefined ? {} : { tagCategory: input.tagCategory, tagValues: input.tagValues }),
|
|
102
|
+
...(input.q === undefined ? {} : { q: input.q }), page: input.page, limit: input.limit }, 2 * 1024 * 1024));
|
|
103
|
+
await this.current(context, valid);
|
|
104
|
+
return reply(200, { ok: true, workspaceId: scope.workspaceId, directoryName: scope.directoryName, ...result,
|
|
105
|
+
items: result.items.map(item => workspaceRemixReferenceCandidateSchema.parse(item)) });
|
|
106
|
+
}
|
|
107
|
+
const result = await withDirectoryWrite(this.state, { workspaceId: scope.workspaceId, directoryName: scope.directoryName,
|
|
108
|
+
sessionId: context.sessionId, wait: false }, async () => {
|
|
109
|
+
await this.current(context, valid, true);
|
|
110
|
+
const raw = await this.upstream(scope, { action: 'export', projectUuid: input.projectUuid, branchName: input.branchName }, WORKSPACE_REMIX_REFERENCE_BODY_MAX_BYTES);
|
|
111
|
+
await this.current(context, valid, true);
|
|
112
|
+
const snapshot = workspaceRemixReferenceExportSchema.parse(raw);
|
|
113
|
+
if (snapshot.source.environment !== this.state.config.environment || snapshot.source.projectUuid !== input.projectUuid
|
|
114
|
+
|| snapshot.source.branchName !== input.branchName)
|
|
115
|
+
throw new RemixReferenceToolError('remix_reference_unavailable');
|
|
116
|
+
return this.state.journal.exclusive(`workspace:${scope.workspaceId}:${scope.directoryName}`, async () => {
|
|
117
|
+
await this.current(context, valid, true);
|
|
118
|
+
await shareWorkspaceGit(this.state, context.cwd);
|
|
119
|
+
await this.current(context, valid, true);
|
|
120
|
+
const reference = await importWorkspaceRemixReference(context.cwd, scope.directoryName, snapshot, () => {
|
|
121
|
+
this.operation(context, valid, true);
|
|
122
|
+
});
|
|
123
|
+
this.operation(context, valid, true);
|
|
124
|
+
await this.state.journal.allocateSourceEvent({ runtimeId: this.state.config.runtimeId,
|
|
125
|
+
generation: this.state.config.generation, runtimeEpoch: this.state.runtimeEpoch,
|
|
126
|
+
type: 'workspace.references.imported', occurredAt: new Date().toISOString(),
|
|
127
|
+
sessionId: context.sessionId, workspaceId: scope.workspaceId, operationId: scope.operation.operationId,
|
|
128
|
+
payload: { sessionId: context.sessionId, workspaceId: scope.workspaceId, directoryName: scope.directoryName,
|
|
129
|
+
referenceId: reference.referenceId, path: reference.path, contentHash: reference.contentHash } });
|
|
130
|
+
await this.state.journal.allocateSourceEvent({ runtimeId: this.state.config.runtimeId,
|
|
131
|
+
generation: this.state.config.generation, runtimeEpoch: this.state.runtimeEpoch,
|
|
132
|
+
type: 'files.changed', occurredAt: new Date().toISOString(), sessionId: context.sessionId,
|
|
133
|
+
workspaceId: scope.workspaceId, operationId: scope.operation.operationId,
|
|
134
|
+
payload: { workspaceId: scope.workspaceId, directoryName: scope.directoryName,
|
|
135
|
+
paths: [reference.path, WORKSPACE_REMIX_REFERENCES_MANIFEST], source: 'workspace.references.import',
|
|
136
|
+
operationId: scope.operation.operationId } });
|
|
137
|
+
wakeSourceEventUploader(this.state);
|
|
138
|
+
return reference;
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
return reply(200, { ok: true, workspaceId: scope.workspaceId, directoryName: scope.directoryName,
|
|
142
|
+
reference: localRemixReference(context.cwd, result) });
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (response.headersSent) {
|
|
146
|
+
response.destroy();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const code = error instanceof SyntaxError ? 'invalid_remix_reference_tool_request' : error instanceof RemixReferenceToolError ? error.code
|
|
150
|
+
: error instanceof Error && /^(?:remix_reference_[a-z_]+|directory_busy)$/.test(error.message)
|
|
151
|
+
? error.message : 'remix_reference_tool_failed';
|
|
152
|
+
reply(code === 'invalid_remix_reference_tool_request' ? 400
|
|
153
|
+
: ['turn_already_active', 'batch_workspace_readonly', 'directory_busy'].includes(code) ? 409 : 403, { ok: false, error: { code } });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async upstream(scope, input, limit) {
|
|
157
|
+
const config = this.state.config;
|
|
158
|
+
const response = await fetch(platformEndpoint(config.publicBaseUrl, PLATFORM_ROUTES.remixReferenceTools), {
|
|
159
|
+
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(120_000),
|
|
160
|
+
headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
|
|
161
|
+
body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation, runtimeEpoch: this.state.runtimeEpoch,
|
|
162
|
+
workspaceId: scope.workspaceId, directoryName: scope.directoryName, ...input }),
|
|
163
|
+
});
|
|
164
|
+
const chunks = [];
|
|
165
|
+
let bytes = 0;
|
|
166
|
+
if (!response.body)
|
|
167
|
+
throw new RemixReferenceToolError('remix_reference_unavailable');
|
|
168
|
+
for await (const chunk of response.body) {
|
|
169
|
+
bytes += chunk.byteLength;
|
|
170
|
+
if (bytes > (response.ok ? limit : 8192)) {
|
|
171
|
+
await response.body.cancel().catch(() => undefined);
|
|
172
|
+
throw new RemixReferenceToolError('remix_reference_too_large');
|
|
173
|
+
}
|
|
174
|
+
chunks.push(Buffer.from(chunk));
|
|
175
|
+
}
|
|
176
|
+
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
177
|
+
if (!response.ok) {
|
|
178
|
+
const code = [value?.reason, value?.code, value?.message, value?.error?.reason, value?.error?.code]
|
|
179
|
+
.find(code => typeof code === 'string' && SOURCE_ERRORS.has(code));
|
|
180
|
+
throw new RemixReferenceToolError(code ?? 'remix_reference_unavailable');
|
|
181
|
+
}
|
|
182
|
+
return value;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { scheduledTaskProposalDraftSchema, scheduledTaskProposalDraftJsonSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
3
|
+
import { rejectTool, toolContent } from './tool-response.js';
|
|
4
|
+
export const WORKSPACE_SCHEDULED_TASK_TOOLS = [
|
|
5
|
+
{ type: 'function', name: 'playcraft_get_scheduled_task_context',
|
|
6
|
+
description: 'Read server time, scheduling defaults, and tasks actually related to this conversation. The returned timeZone describes the server clock, not the user’s local timezone; clarify the intended timezone when it is unknown. When several tasks match, ask the user which task to change. This tool never changes a task.',
|
|
7
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
|
|
8
|
+
{ type: 'function', name: 'playcraft_propose_scheduled_task',
|
|
9
|
+
description: 'Prepare a scheduled-task creation or change for the user to review in a persistent confirmation card. Read scheduling context first. This does not create or change a task: only the user can confirm the card. A pending_confirmation result must never be reported as an applied task.',
|
|
10
|
+
inputSchema: { type: 'object', properties: {
|
|
11
|
+
draft: scheduledTaskProposalDraftJsonSchema, supersedesProposalId: { type: 'string', format: 'uuid' },
|
|
12
|
+
}, required: ['draft'], additionalProperties: false } },
|
|
13
|
+
];
|
|
14
|
+
export const SCHEDULED_TASK_TOOL_ERROR_CODES = new Set([
|
|
15
|
+
'unavailable', 'source_unavailable', 'source_pending', 'invalid_input', 'request_conflict', 'request_pending',
|
|
16
|
+
'feature_disabled', 'limit', 'version_conflict', 'schedule_expired', 'invalid_time_zone', 'interval_too_short',
|
|
17
|
+
'target_read_only', 'target_unavailable', 'target_access_denied', 'target_binding_changed', 'session_unavailable',
|
|
18
|
+
'account_unavailable', 'model_unavailable', 'reasoning_effort_unavailable', 'revoked',
|
|
19
|
+
].map(code => `scheduled_task_proposal_${code}`));
|
|
20
|
+
const prepareArguments = z.object({ draft: z.unknown(), supersedesProposalId: z.string().uuid().optional() }).strict();
|
|
21
|
+
const sourceKey = (source) => JSON.stringify([source.threadId, source.turnId, source.toolCallId]);
|
|
22
|
+
/** Only a live root-native call can attest the source of a prepare-only bridge request. */
|
|
23
|
+
export class WorkspaceScheduledTaskTools {
|
|
24
|
+
handler;
|
|
25
|
+
changed;
|
|
26
|
+
active = new Map();
|
|
27
|
+
settled = new Map();
|
|
28
|
+
constructor(handler, changed) {
|
|
29
|
+
this.handler = handler;
|
|
30
|
+
this.changed = changed;
|
|
31
|
+
}
|
|
32
|
+
isActive(source) {
|
|
33
|
+
const call = this.active.get(sourceKey(source));
|
|
34
|
+
return Boolean(call && call.source.bridge === source.bridge && call.current());
|
|
35
|
+
}
|
|
36
|
+
async request(requestId, payload, client, context) {
|
|
37
|
+
if (!context?.bridge || !context.current() || typeof payload.callId !== 'string' || !payload.callId.length
|
|
38
|
+
|| payload.namespace)
|
|
39
|
+
return rejectTool(client, requestId, 'scheduled_task_proposal_source_unavailable');
|
|
40
|
+
const action = payload.tool === 'playcraft_get_scheduled_task_context' ? 'context'
|
|
41
|
+
: payload.tool === 'playcraft_propose_scheduled_task' ? 'prepare' : undefined;
|
|
42
|
+
const parsed = action === 'context' ? z.object({}).strict().safeParse(payload.arguments)
|
|
43
|
+
: prepareArguments.safeParse(payload.arguments);
|
|
44
|
+
if (!action || !parsed.success || (action === 'prepare'
|
|
45
|
+
&& !scheduledTaskProposalDraftSchema.safeParse(parsed.data.draft).success))
|
|
46
|
+
return rejectTool(client, requestId, 'scheduled_task_proposal_invalid_input');
|
|
47
|
+
const source = { bridge: context.bridge, threadId: context.threadId, turnId: context.turnId, toolCallId: payload.callId };
|
|
48
|
+
const key = sourceKey(source), fingerprint = JSON.stringify([action, parsed.data]);
|
|
49
|
+
const prior = this.settled.get(key);
|
|
50
|
+
if (prior) {
|
|
51
|
+
if (prior.fingerprint !== fingerprint)
|
|
52
|
+
return rejectTool(client, requestId, 'scheduled_task_proposal_request_conflict');
|
|
53
|
+
await client.respond(requestId, { success: true, contentItems: toolContent(prior.result) });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (this.active.has(key))
|
|
57
|
+
return rejectTool(client, requestId, 'scheduled_task_proposal_request_pending');
|
|
58
|
+
this.active.set(key, { source, current: context.current });
|
|
59
|
+
try {
|
|
60
|
+
const result = await this.handler(action, source, parsed.data);
|
|
61
|
+
this.settled.set(key, { fingerprint, result });
|
|
62
|
+
if (this.settled.size > 256)
|
|
63
|
+
this.settled.delete(this.settled.keys().next().value);
|
|
64
|
+
if (action === 'prepare' && result && typeof result === 'object'
|
|
65
|
+
&& 'proposalId' in result && typeof result.proposalId === 'string')
|
|
66
|
+
this.changed(result.proposalId, source);
|
|
67
|
+
await client.respond(requestId, { success: true, contentItems: toolContent(result) });
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
await rejectTool(client, requestId, error instanceof Error && SCHEDULED_TASK_TOOL_ERROR_CODES.has(error.message)
|
|
71
|
+
? error.message : 'scheduled_task_proposal_unavailable');
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
this.active.delete(key);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export function isWorkspaceScheduledTaskTool(value) {
|
|
79
|
+
return WORKSPACE_SCHEDULED_TASK_TOOLS.some(tool => tool.name === value);
|
|
80
|
+
}
|
|
@@ -33,6 +33,7 @@ import { observeWorkspaceArtifacts } from './workspaces/artifact-events.js';
|
|
|
33
33
|
import { loadWorkspaceMcpConfig } from './codex/mcp-config.js';
|
|
34
34
|
import { startSourceEventUploader, stopSourceEventUploader } from './server/source-uploader.js';
|
|
35
35
|
import { platformExportTarget, readPlatformExport, startPlatformExport } from './workspaces/platform-export.js';
|
|
36
|
+
import { scheduledTaskProposalHandler } from './workspaces/scheduled-task-proposals.js';
|
|
36
37
|
export async function startWorkspaceRuntime(config, options = {}) {
|
|
37
38
|
process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
|
|
38
39
|
const negotiated = negotiateProtocol(config);
|
|
@@ -412,6 +413,7 @@ function defaultNativeAdapter(state) {
|
|
|
412
413
|
config,
|
|
413
414
|
workspaceUi: true,
|
|
414
415
|
deliveryTools: deliveryToolHandlers(state),
|
|
416
|
+
scheduledTaskProposals: scheduledTaskProposalHandler(state),
|
|
415
417
|
...(multi ? { modelBroker: new WorkspaceModelBroker(state) } : {}),
|
|
416
418
|
...(multi ? { loadMcpConfig: async (cwd) => {
|
|
417
419
|
const { workspaceId } = await workspaceContextFromRoot(state, cwd);
|
|
@@ -497,6 +497,7 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
|
|
|
497
497
|
workMode: selection.data.workMode ?? 'execute',
|
|
498
498
|
skillsLockHash: composer.skillsLockHash,
|
|
499
499
|
uiSnapshot: uiSnapshot.data,
|
|
500
|
+
scheduledTaskProposalBridge: typeof body.scheduledTaskProposalBridge === 'string' ? body.scheduledTaskProposalBridge : undefined,
|
|
500
501
|
simulateLostReceipt: body.simulateLostReceipt === true,
|
|
501
502
|
persistReservation: async ({ nativeRequestId, modelConfigId, reasoningEffort, permissionMode }) => {
|
|
502
503
|
const selectedEffort = reasoningEffort ?? requestedEffort;
|
|
@@ -134,6 +134,7 @@ async function dispatchRuntimeRequest(state, request, response) {
|
|
|
134
134
|
composerContextVersion: 1,
|
|
135
135
|
scheduledTaskIdentityVersion: 1,
|
|
136
136
|
scheduledTaskCancellationVersion: 1,
|
|
137
|
+
...(state.native?.scheduledTaskProposalSource ? { scheduledTaskProposalVersion: 1 } : {}),
|
|
137
138
|
parameterAgentVersion: 1,
|
|
138
139
|
directoryConcurrencyVersion: 1,
|
|
139
140
|
parameterInitializationVersion: 1,
|
|
@@ -812,6 +813,16 @@ async function dispatchRuntimeRequest(state, request, response) {
|
|
|
812
813
|
writeJson(response, 200, operation);
|
|
813
814
|
return;
|
|
814
815
|
}
|
|
816
|
+
if (request.method === 'POST' && url.pathname === '/runtime/scheduled-task-proposals/source') {
|
|
817
|
+
const body = await readJson(request);
|
|
818
|
+
if (!body || Object.keys(body).some(key => !['bridge', 'threadId', 'turnId', 'toolCallId'].includes(key))
|
|
819
|
+
|| !['bridge', 'threadId', 'turnId', 'toolCallId'].every(key => typeof body[key] === 'string' && Boolean(body[key]))) {
|
|
820
|
+
writeJson(response, 400, { valid: false });
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
writeJson(response, 200, { valid: state.native?.scheduledTaskProposalSource?.(body) === true });
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
815
826
|
if (request.method === 'POST' && url.pathname === '/runtime/operations') {
|
|
816
827
|
// Track creation before accepting it, so a new unconnected terminal is known not to have spawned.
|
|
817
828
|
terminalHost(state);
|
|
@@ -977,7 +988,9 @@ async function acceptOperation(state, request, response) {
|
|
|
977
988
|
? { snapshot: parsed.data.remixReferenceSnapshot }
|
|
978
989
|
: parsed.data.kind === 'asset.library.import'
|
|
979
990
|
? { selection: parsed.data.payload, snapshot: parsed.data.libraryAssetImports }
|
|
980
|
-
: parsed.data.
|
|
991
|
+
: parsed.data.kind === 'session.turn' && parsed.data.scheduledTaskProposalBridge
|
|
992
|
+
? { ...asRecord(parsed.data.payload), scheduledTaskProposalBridge: parsed.data.scheduledTaskProposalBridge }
|
|
993
|
+
: parsed.data.payload;
|
|
981
994
|
const accept = async () => {
|
|
982
995
|
const result = await acceptOperationCore(state, parsed.data, {
|
|
983
996
|
leaseFence: incomingFence,
|
|
@@ -6,12 +6,14 @@ import { createHash } from 'node:crypto';
|
|
|
6
6
|
import { canonicalProjectSkillsJson } from '@playcraft/common/project-skills';
|
|
7
7
|
import { syncLocalProjectSkills } from '../../project-skills/local-store.js';
|
|
8
8
|
import { selectOptionalPlatformSkills } from './optional-platform-skills.js';
|
|
9
|
+
import { WORKSPACE_REMIX_REFERENCES_SKILL } from './remix-reference-skill.js';
|
|
9
10
|
export const EMPTY_WORKSPACE_REPOSITORY_SKILLS = [
|
|
10
11
|
'playcraft-skill-recommender',
|
|
11
12
|
'playcraft-image-generation',
|
|
12
13
|
'playcraft-audio-generation',
|
|
13
14
|
'playcraft-image-processing',
|
|
14
15
|
'playcraft-workspace-assets',
|
|
16
|
+
WORKSPACE_REMIX_REFERENCES_SKILL,
|
|
15
17
|
'playcraft-build-optimizer',
|
|
16
18
|
];
|
|
17
19
|
export const EMPTY_WORKSPACE_PLATFORM_SKILLS = ['playcraft-video-understanding', 'playcraft-replica-validation'];
|