@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.
Files changed (33) 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/references/client.js +69 -0
  5. package/dist/references/messages.js +57 -0
  6. package/dist/references/protocol.js +32 -0
  7. package/dist/workspace-runtime/batch/control.js +3 -3
  8. package/dist/workspace-runtime/batch/deliver.js +4 -3
  9. package/dist/workspace-runtime/batch/generate.js +3 -2
  10. package/dist/workspace-runtime/batch/install-log.js +49 -0
  11. package/dist/workspace-runtime/batch/prepare.js +29 -13
  12. package/dist/workspace-runtime/batch/processes.js +86 -14
  13. package/dist/workspace-runtime/batch/writer-proof.js +2 -2
  14. package/dist/workspace-runtime/codex/app-server.js +28 -1
  15. package/dist/workspace-runtime/codex/launcher-client.js +34 -24
  16. package/dist/workspace-runtime/codex/model-broker.js +42 -5
  17. package/dist/workspace-runtime/codex/remix-references-broker.js +184 -0
  18. package/dist/workspace-runtime/codex/scheduled-task-tools.js +80 -0
  19. package/dist/workspace-runtime/serve.js +2 -0
  20. package/dist/workspace-runtime/server/dispatch.js +1 -0
  21. package/dist/workspace-runtime/server/http.js +14 -1
  22. package/dist/workspace-runtime/workspaces/empty-template.js +2 -0
  23. package/dist/workspace-runtime/workspaces/remix-reference-skill.js +126 -0
  24. package/dist/workspace-runtime/workspaces/scheduled-task-proposals.js +27 -0
  25. package/dist/workspace-runtime/workspaces/skills.js +3 -1
  26. package/package.json +3 -3
  27. package/project-template/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
  28. package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +0 -2
  29. package/project-template-v2/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
  30. package/project-template-v2/.playcraft/skills.lock.json +7 -0
  31. package/project-templates/empty/AGENTS.md +9 -0
  32. package/project-templates/phaser/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
  33. 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
- // 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
- }
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, ...(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);
@@ -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.payload;
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'];