@playcraft/cli 0.1.11 → 0.1.13

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.
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, existsSync } from 'fs';
2
- import { join } from 'path';
2
+ import { dirname, join } from 'path';
3
3
  import { homedir } from 'os';
4
4
  import { loadGlobalConfig } from '../config.js';
5
5
  import { DEFAULT_PLAYCRAFT_CLOUD_BASE } from '../commands/login.js';
@@ -18,8 +18,8 @@ function isPatToken(token) {
18
18
  * or from the CLI after `playcraft login`.
19
19
  *
20
20
  * 凭证解析优先级:
21
- * API URL : PLAYCRAFT_API_URL → .playcraft.json → ~/.playcraft/config.json → DEFAULT_PLAYCRAFT_CLOUD_BASE
22
- * Token : PLAYCRAFT_SANDBOX_TOKEN → .playcraft.json token → ~/.playcraft/config.json token (PAT)
21
+ * API URL : PLAYCRAFT_API_URL → sandbox global .playcraft.json → project .playcraft.json → ~/.playcraft/config.json → DEFAULT_PLAYCRAFT_CLOUD_BASE
22
+ * Token : PLAYCRAFT_SANDBOX_TOKEN → sandbox global .playcraft.json → project .playcraft.json → ~/.playcraft/config.json token (PAT)
23
23
  *
24
24
  * 鉴权 header 自动选择:
25
25
  * - 沙箱 JWT (非 PAT) → X-Sandbox-Token: <token>(后端 @SandboxToken 路由的沙箱分支)
@@ -28,7 +28,7 @@ function isPatToken(token) {
28
28
  export class AgentApiClient {
29
29
  apiUrl;
30
30
  token;
31
- isPat;
31
+ credentialFile;
32
32
  playcraftEnv;
33
33
  constructor() {
34
34
  const localConfig = AgentApiClient.loadLocalConfig();
@@ -39,7 +39,7 @@ export class AgentApiClient {
39
39
  globalConfig.backendUrl ||
40
40
  globalConfig.url ||
41
41
  DEFAULT_PLAYCRAFT_CLOUD_BASE).replace(/\/+$/, '');
42
- // Token: 沙箱 token 环境变量 → 本地 .playcraft.json → 全局 login 配置(PAT)
42
+ // Token: 环境变量 → 沙箱全局配置 → 项目配置 → 用户登录配置
43
43
  const token = process.env.PLAYCRAFT_SANDBOX_TOKEN ||
44
44
  localConfig.token ||
45
45
  globalConfig.token ||
@@ -50,14 +50,22 @@ export class AgentApiClient {
50
50
  }
51
51
  this.apiUrl = apiUrl;
52
52
  this.token = token;
53
- this.isPat = isPatToken(token);
53
+ this.credentialFile = localConfig.credentialFile;
54
54
  this.playcraftEnv = (process.env.PLAYCRAFT_ENV || localConfig.env || '').trim();
55
55
  }
56
56
  /** 根据 token 类型构造鉴权 headers */
57
57
  authHeaders() {
58
- const headers = this.isPat
59
- ? { Authorization: `Bearer ${this.token}` }
60
- : { 'X-Sandbox-Token': this.token };
58
+ let token = this.token;
59
+ // Polling commands can outlive a credential: read the atomically refreshed file per request.
60
+ if (this.credentialFile && !process.env.PLAYCRAFT_SANDBOX_TOKEN) {
61
+ try {
62
+ token = JSON.parse(readFileSync(this.credentialFile, 'utf8')).token || token;
63
+ }
64
+ catch { /* Keep the last credential if a local file is temporarily unavailable. */ }
65
+ }
66
+ const headers = isPatToken(token)
67
+ ? { Authorization: `Bearer ${token}` }
68
+ : { 'X-Sandbox-Token': token };
61
69
  if (this.playcraftEnv) {
62
70
  headers['playcraft-env'] = this.playcraftEnv;
63
71
  }
@@ -196,15 +204,22 @@ export class AgentApiClient {
196
204
  * 与全局 login 配置(~/.playcraft/config.json)不同,前者专用于沙箱 token。
197
205
  */
198
206
  static loadLocalConfig() {
207
+ const ancestors = [];
208
+ for (let directory = process.cwd();; directory = dirname(directory)) {
209
+ ancestors.push(join(directory, '.playcraft.json'));
210
+ if (dirname(directory) === directory)
211
+ break;
212
+ }
199
213
  const searchPaths = [
200
- join(process.cwd(), '.playcraft.json'),
214
+ process.env.PLAYCRAFT_SANDBOX_CONFIG_PATH || '/workspaces/.playcraft.json',
215
+ ...ancestors,
201
216
  '/project/.playcraft.json',
202
217
  join(homedir(), '.playcraft.json'),
203
218
  ];
204
219
  for (const p of searchPaths) {
205
220
  if (existsSync(p)) {
206
221
  try {
207
- return JSON.parse(readFileSync(p, 'utf-8'));
222
+ return { ...JSON.parse(readFileSync(p, 'utf-8')), credentialFile: p };
208
223
  }
209
224
  catch {
210
225
  continue;
@@ -1,5 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { randomUUID } from 'node:crypto';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
3
  import { startLauncherProcess } from './launcher-client.js';
4
4
  import { readFile } from 'node:fs/promises';
5
5
  import { join } from 'node:path';
@@ -8,6 +8,8 @@ import { createJsonRpcStdioClient } from './jsonrpc-stdio.js';
8
8
  import { projectNativeThread } from '@playcraft/common/cloud-workspace/runtime';
9
9
  import { collectedEvent, NativePreparationError } from './native-adapter.js';
10
10
  import { exportTranscript } from './transcripts.js';
11
+ import { readProjectSkillsLockFile } from '@playcraft/common/project-skills';
12
+ import { freezeWorkspaceSkills } from '../workspaces/skills.js';
11
13
  function asRecord(value) {
12
14
  return value && typeof value === 'object' && !Array.isArray(value)
13
15
  ? value
@@ -62,6 +64,8 @@ export function createCodexAppServerAdapter(options) {
62
64
  let readyFlag = false;
63
65
  let closed = false;
64
66
  let startPromise;
67
+ let skillsRestartFailed = false;
68
+ let preparationQueue = Promise.resolve();
65
69
  let live;
66
70
  const exitListeners = new Set();
67
71
  const collected = new Set();
@@ -69,6 +73,18 @@ export function createCodexAppServerAdapter(options) {
69
73
  for (const listener of collected)
70
74
  listener(event);
71
75
  }
76
+ async function withPreparation(action) {
77
+ const previous = preparationQueue;
78
+ let release;
79
+ preparationQueue = new Promise(resolve => { release = resolve; });
80
+ await previous;
81
+ try {
82
+ return await action();
83
+ }
84
+ finally {
85
+ release();
86
+ }
87
+ }
72
88
  function transcriptHome() {
73
89
  if (!launcherSocket)
74
90
  return { homeDir };
@@ -82,6 +98,10 @@ export function createCodexAppServerAdapter(options) {
82
98
  };
83
99
  }
84
100
  async function ensureStarted() {
101
+ if (closed)
102
+ throw new Error('codex_not_started');
103
+ if (skillsRestartFailed)
104
+ throw new NativePreparationError('workspace_skills_unavailable');
85
105
  if (readyFlag && rpc)
86
106
  return;
87
107
  startPromise ??= start();
@@ -98,9 +118,100 @@ export function createCodexAppServerAdapter(options) {
98
118
  ? reason : 'model_credential_unavailable');
99
119
  }
100
120
  }
121
+ async function lockedThreadConfig(cwd) {
122
+ const modelConfig = await threadConfig(cwd);
123
+ // Single-workspace legacy runners retain their prior behaviour until explicitly adopted.
124
+ if (await readProjectSkillsLockFile(cwd) === null) {
125
+ if (options.config.workspaceMode === 'multi')
126
+ throw new NativePreparationError('workspace_skills_unavailable');
127
+ return { params: modelConfig, skillsHash: undefined, discoveryHash: undefined };
128
+ }
129
+ try {
130
+ const frozen = await freezeWorkspaceSkills(cwd, join(options.config.stateDir, 'skill-artifacts'), options.loadSkillArtifact ? entry => options.loadSkillArtifact(cwd, entry) : undefined);
131
+ if (!rpc)
132
+ throw new Error('codex_not_started');
133
+ const found = await rpc.request('skills/list', { cwds: [cwd], forceReload: true }, 30_000);
134
+ const listing = found.data.find(item => item.cwd === cwd);
135
+ if (!listing || listing.errors.length)
136
+ throw new Error('workspace_skills_unavailable');
137
+ // Native scanning is used only to disable paths. Availability comes exclusively from the lock.
138
+ const config = { ...('config' in modelConfig ? modelConfig.config : {}),
139
+ 'skills.include_instructions': false,
140
+ 'skills.config': listing.skills.map(skill => ({ path: skill.path, enabled: false })) };
141
+ const developerInstructions = [
142
+ `Available workspace skills are fixed by lock ${frozen.declarationHash}.`,
143
+ 'Use only the following locked skills. Read its SKILL.md when a skill applies, and resolve supporting files relative to that file. Disabled and unlisted skill directories are not available.',
144
+ ...frozen.skills.map(skill => JSON.stringify(skill)),
145
+ ...(frozen.skills.length ? [] : ['(No workspace skills are enabled.)']),
146
+ ].join('\n');
147
+ const discoveryHash = createHash('sha256').update(JSON.stringify(listing.skills.map(skill => skill.path).sort())).digest('hex');
148
+ return { params: { ...modelConfig, config, developerInstructions }, skillsHash: frozen.lockHash, discoveryHash };
149
+ }
150
+ catch {
151
+ throw new NativePreparationError('workspace_skills_unavailable');
152
+ }
153
+ }
154
+ async function restartForSkills(threadId) {
155
+ if ([...threads.values()].some(thread => thread.turnActive) || approvals.size)
156
+ throw new NativePreparationError('skills_reload_pending');
157
+ const previous = live;
158
+ if (previous)
159
+ previous.superseded = true;
160
+ readyFlag = false;
161
+ await previous?.rpc.close();
162
+ await previous?.child.stop();
163
+ if (closed)
164
+ throw new Error('codex_not_started');
165
+ rpc = undefined;
166
+ startPromise = undefined;
167
+ for (const thread of threads.values()) {
168
+ thread.loaded = false;
169
+ thread.needsRecreate = !thread.hasNativeHistory;
170
+ }
171
+ // A controlled idle restart retains the runtime epoch and all durable thread identities.
172
+ try {
173
+ startPromise = start();
174
+ await startPromise;
175
+ if (closed)
176
+ throw new Error('codex_not_started');
177
+ if (!live)
178
+ throw new NativePreparationError('workspace_skills_unavailable');
179
+ await options.onSkillProcessRestart?.({ pid: live.pid, startedAt: live.startedAt });
180
+ if (closed)
181
+ throw new Error('codex_not_started');
182
+ // Other workspaces resume lazily, so a damaged unrelated lock cannot block this workspace.
183
+ const thread = threads.get(threadId);
184
+ if (thread?.hasNativeHistory && thread.cwd)
185
+ await resumePreparedThread(threadId, thread, await lockedThreadConfig(thread.cwd));
186
+ }
187
+ catch {
188
+ readyFlag = false;
189
+ skillsRestartFailed = true;
190
+ if (live) {
191
+ live.superseded = true;
192
+ await live.rpc.close();
193
+ await live.child.stop();
194
+ }
195
+ throw new NativePreparationError('workspace_skills_unavailable');
196
+ }
197
+ }
198
+ async function resumePreparedThread(threadId, thread, prepared) {
199
+ const resumed = await rpc.request('thread/resume', {
200
+ threadId, cwd: thread.cwd, ...prepared.params,
201
+ }, 30_000);
202
+ if (resumed.thread?.id !== threadId)
203
+ throw new NativePreparationError('workspace_skills_unavailable');
204
+ thread.loaded = true;
205
+ thread.skillsHash = prepared.skillsHash;
206
+ thread.discoveryHash = prepared.discoveryHash;
207
+ }
101
208
  async function start() {
209
+ if (closed)
210
+ throw new Error('codex_not_started');
102
211
  await options.modelBroker?.start();
103
212
  const token = options.modelBroker ? 'workspace-broker-managed' : await readToken(options);
213
+ if (closed)
214
+ throw new Error('codex_not_started');
104
215
  const bin = options.command || process.env.PLAYCRAFT_CODEX_BIN || (launcherSocket ? '/usr/local/bin/codex' : 'codex');
105
216
  const { command, prefix } = spawnCommand(bin);
106
217
  const args = [...prefix, ...(options.args ?? ['app-server'])];
@@ -131,6 +242,8 @@ export function createCodexAppServerAdapter(options) {
131
242
  }
132
243
  else {
133
244
  await writePlaycraftCodexConfig({ homeDir, modelProxyUrl: options.config.modelProxyUrl, model });
245
+ if (closed)
246
+ throw new Error('codex_not_started');
134
247
  const local = spawn(command, args, {
135
248
  cwd: options.config.workspaceRoot,
136
249
  env: { ...process.env, ...options.env, CODEX_HOME: homeDir, OPENAI_API_KEY: token, PLAYCRAFT_SKIP_UPDATE_CHECK: '1' },
@@ -147,11 +260,25 @@ export function createCodexAppServerAdapter(options) {
147
260
  },
148
261
  };
149
262
  }
263
+ if (closed) {
264
+ await child.stop();
265
+ throw new Error('codex_not_started');
266
+ }
150
267
  child.stderr?.on('data', (chunk) => {
151
268
  process.stderr.write(chunk);
152
269
  });
153
270
  rpc = createJsonRpcStdioClient(child);
154
271
  rpc.onNotification((method, params) => {
272
+ if (method === 'turn/completed') {
273
+ const record = asRecord(params), turnId = asRecord(record.turn).id;
274
+ const thread = typeof record.threadId === 'string' ? threads.get(record.threadId)
275
+ : [...threads.values()].find(item => item.lastTurnId === turnId);
276
+ if (thread) {
277
+ thread.turnActive = false;
278
+ if (typeof turnId === 'string')
279
+ thread.lastCompletedTurnId = turnId;
280
+ }
281
+ }
155
282
  emitCollected(collectedEvent('notification', method, params));
156
283
  });
157
284
  rpc.onServerRequest((id, method, params) => {
@@ -182,7 +309,8 @@ export function createCodexAppServerAdapter(options) {
182
309
  readyFlag = false;
183
310
  // Retain the old handle until its entire execution group is confirmed stopped.
184
311
  rpc = undefined;
185
- emitCollected(collectedEvent('process_exit', 'process.exited', { pid: current.pid }));
312
+ if (!current.superseded)
313
+ emitCollected(collectedEvent('process_exit', 'process.exited', { pid: current.pid }));
186
314
  }
187
315
  if (!current.superseded && !closed) {
188
316
  for (const listener of exitListeners)
@@ -197,6 +325,8 @@ export function createCodexAppServerAdapter(options) {
197
325
  version: options.config.cliVersion,
198
326
  },
199
327
  });
328
+ if (closed)
329
+ throw new Error('codex_not_started');
200
330
  rpc.notify('initialized', {});
201
331
  readyFlag = true;
202
332
  }
@@ -237,6 +367,7 @@ export function createCodexAppServerAdapter(options) {
237
367
  if (previous)
238
368
  await previous.child.stop();
239
369
  executionEpoch += 1;
370
+ skillsRestartFailed = false;
240
371
  if (closed)
241
372
  throw new Error('codex_not_started');
242
373
  try {
@@ -253,21 +384,38 @@ export function createCodexAppServerAdapter(options) {
253
384
  return { pid: next.pid, startedAt: next.startedAt };
254
385
  },
255
386
  async canContinueThread(input) {
256
- await ensureStarted();
257
- if (!rpc)
258
- return false;
259
- try {
260
- const result = await rpc.request('thread/resume', { threadId: input.threadId, cwd: input.cwd,
261
- ...await threadConfig(input.cwd ?? '') }, 30_000);
262
- const resumedId = result.thread?.id ?? result.threadId;
263
- if (resumedId !== input.threadId)
387
+ return withPreparation(async () => {
388
+ await ensureStarted();
389
+ if (!rpc)
264
390
  return false;
265
- threads.set(input.threadId, { sessionId: input.sessionId });
266
- return true;
267
- }
268
- catch {
269
- return false;
270
- }
391
+ try {
392
+ const prepared = await lockedThreadConfig(input.cwd ?? '');
393
+ const existing = threads.get(input.threadId);
394
+ if (existing && existing.sessionId !== input.sessionId)
395
+ return false;
396
+ if (existing?.needsRecreate)
397
+ return false;
398
+ if (existing?.loaded) {
399
+ if (existing.skillsHash !== prepared.skillsHash || existing.discoveryHash !== prepared.discoveryHash) {
400
+ if (!existing.hasNativeHistory)
401
+ return false;
402
+ await restartForSkills(input.threadId);
403
+ }
404
+ return threads.get(input.threadId)?.sessionId === input.sessionId;
405
+ }
406
+ const result = await rpc.request('thread/resume', { threadId: input.threadId, cwd: input.cwd,
407
+ ...prepared.params }, 30_000);
408
+ const resumedId = result.thread?.id ?? result.threadId;
409
+ if (resumedId !== input.threadId)
410
+ return false;
411
+ threads.set(input.threadId, { sessionId: input.sessionId, cwd: input.cwd, skillsHash: prepared.skillsHash,
412
+ discoveryHash: prepared.discoveryHash, hasNativeHistory: true, loaded: true });
413
+ return true;
414
+ }
415
+ catch {
416
+ return false;
417
+ }
418
+ });
271
419
  },
272
420
  async readThread(input) {
273
421
  try {
@@ -300,44 +448,80 @@ export function createCodexAppServerAdapter(options) {
300
448
  await ensureStarted();
301
449
  },
302
450
  async startThread(input) {
303
- await ensureStarted();
304
- if (!rpc)
305
- throw new Error('codex_not_started');
306
- const result = await rpc.request('thread/start', {
307
- cwd: input.cwd,
308
- model,
309
- ...await threadConfig(input.cwd),
451
+ return withPreparation(async () => {
452
+ await ensureStarted();
453
+ if (!rpc)
454
+ throw new Error('codex_not_started');
455
+ const prepared = await lockedThreadConfig(input.cwd);
456
+ const result = await rpc.request('thread/start', {
457
+ cwd: input.cwd,
458
+ model,
459
+ ...prepared.params,
460
+ });
461
+ const threadId = result.thread?.id;
462
+ if (!threadId)
463
+ throw new Error('native_receipt_lost');
464
+ threads.set(threadId, { sessionId: input.sessionId, cwd: input.cwd, skillsHash: prepared.skillsHash,
465
+ discoveryHash: prepared.discoveryHash, hasNativeHistory: false, loaded: true });
466
+ return { threadId };
310
467
  });
311
- const threadId = result.thread?.id;
312
- if (!threadId)
313
- throw new Error('native_receipt_lost');
314
- threads.set(threadId, { sessionId: input.sessionId });
315
- return { threadId };
316
468
  },
317
469
  async startTurn(input) {
318
- await ensureStarted();
319
- if (!rpc)
320
- throw new Error('codex_not_started');
321
- if (threads.get(input.threadId)?.sessionId !== input.sessionId) {
322
- return { outcome: 'failed', error: 'unknown_thread' };
323
- }
324
- const requestId = rpc.reserveRequest();
325
- await input.persistReservation({ nativeRequestId: String(requestId) });
326
- if (input.simulateLostReceipt)
327
- return { outcome: 'uncertain', threadId: input.threadId };
328
- try {
329
- const started = await rpc.sendReserved(requestId, 'turn/start', { threadId: input.threadId, input: [{ type: 'text', text: input.text }] }, turnTimeoutMs);
330
- const turnId = started.turn?.id;
331
- if (!turnId)
332
- return { outcome: 'uncertain', threadId: input.threadId };
470
+ return withPreparation(async () => {
471
+ await ensureStarted();
472
+ if (!rpc)
473
+ throw new Error('codex_not_started');
474
+ if (threads.get(input.threadId)?.sessionId !== input.sessionId) {
475
+ return { outcome: 'failed', error: 'unknown_thread' };
476
+ }
333
477
  const thread = threads.get(input.threadId);
334
- if (thread)
335
- thread.lastTurnId = turnId;
336
- return { outcome: 'accepted', threadId: input.threadId, turnId };
337
- }
338
- catch {
339
- return { outcome: 'uncertain', threadId: input.threadId };
340
- }
478
+ if (thread.needsRecreate)
479
+ return { outcome: 'failed', error: 'workspace_skills_changed' };
480
+ if (thread.skillsHash && thread.cwd) {
481
+ try {
482
+ const prepared = await lockedThreadConfig(thread.cwd);
483
+ // Codex does not persist an empty thread rollout until its first turn.
484
+ if (!thread.hasNativeHistory) {
485
+ if (prepared.skillsHash !== thread.skillsHash || prepared.discoveryHash !== thread.discoveryHash)
486
+ return { outcome: 'failed', error: 'workspace_skills_changed' };
487
+ }
488
+ else if (prepared.skillsHash !== thread.skillsHash || prepared.discoveryHash !== thread.discoveryHash) {
489
+ await restartForSkills(input.threadId);
490
+ }
491
+ if (!thread.loaded)
492
+ await resumePreparedThread(input.threadId, thread, prepared);
493
+ thread.skillsHash = prepared.skillsHash;
494
+ thread.discoveryHash = prepared.discoveryHash;
495
+ }
496
+ catch (error) {
497
+ return { outcome: 'failed', error: error instanceof NativePreparationError ? error.message : 'workspace_skills_unavailable' };
498
+ }
499
+ }
500
+ const requestId = rpc.reserveRequest();
501
+ await input.persistReservation({ nativeRequestId: String(requestId) });
502
+ if (input.simulateLostReceipt)
503
+ return { outcome: 'uncertain', threadId: input.threadId };
504
+ try {
505
+ thread.turnActive = true;
506
+ const started = await rpc.sendReserved(requestId, 'turn/start', { threadId: input.threadId, input: [
507
+ { type: 'text', text: input.text },
508
+ ...(input.images ?? []).map((path) => ({ type: 'localImage', path })),
509
+ ] }, turnTimeoutMs);
510
+ const turnId = started.turn?.id;
511
+ if (!turnId)
512
+ return { outcome: 'uncertain', threadId: input.threadId };
513
+ const acceptedThread = threads.get(input.threadId);
514
+ if (acceptedThread) {
515
+ acceptedThread.lastTurnId = turnId;
516
+ acceptedThread.hasNativeHistory = true;
517
+ acceptedThread.turnActive = acceptedThread.lastCompletedTurnId !== turnId;
518
+ }
519
+ return { outcome: 'accepted', threadId: input.threadId, turnId };
520
+ }
521
+ catch {
522
+ return { outcome: 'uncertain', threadId: input.threadId };
523
+ }
524
+ });
341
525
  },
342
526
  async interrupt(input) {
343
527
  await ensureStarted();
@@ -1,3 +1,4 @@
1
+ import { closeCliCredentials } from './workspaces/cli-credentials.js';
1
2
  import { randomUUID } from 'node:crypto';
2
3
  import { mkdir } from 'node:fs/promises';
3
4
  import { createServer } from 'node:http';
@@ -16,6 +17,9 @@ import { WorkspaceGitCredentialBroker } from './workspaces/git-credential-broker
16
17
  import { WorkspaceModelBroker } from './codex/model-broker.js';
17
18
  import { recoverInterruptedPreviews } from './previews/publish.js';
18
19
  import { recoverInterruptedInstalls } from './workspaces/dependencies.js';
20
+ import { loadWorkspaceSkillArtifact } from './workspaces/skill-artifact-loader.js';
21
+ import { workspaceRoot } from './workspaces/registry.js';
22
+ import { basename } from 'node:path';
19
23
  export async function startWorkspaceRuntime(config, options = {}) {
20
24
  process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
21
25
  const negotiated = negotiateProtocol(config);
@@ -116,6 +120,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
116
120
  state,
117
121
  url: `http://${formatHost(config.listen.host)}:${port}`,
118
122
  async close() {
123
+ await closeCliCredentials(state);
119
124
  await gitCredentials?.close();
120
125
  disposeDesktopHost(state);
121
126
  disposeTerminalHost(state);
@@ -175,6 +180,13 @@ function defaultNativeAdapter(state) {
175
180
  || (!process.env.PLAYCRAFT_NATIVE_ADAPTER && managed)) {
176
181
  return createCodexAppServerAdapter({ config,
177
182
  ...(managed ? { modelBroker: new WorkspaceModelBroker(state) } : {}),
183
+ loadSkillArtifact: async (cwd, entry) => {
184
+ const id = basename(cwd);
185
+ if (await workspaceRoot(state, id) !== cwd)
186
+ throw new Error('workspace_skills_unavailable');
187
+ return loadWorkspaceSkillArtifact(state, id, entry);
188
+ },
189
+ onSkillProcessRestart: process => state.journal.noteExecutionGroup(process),
178
190
  });
179
191
  }
180
192
  return undefined;
@@ -2,6 +2,7 @@ import { connect as connectTcp } from 'node:net';
2
2
  import { WebSocket, WebSocketServer } from 'ws';
3
3
  import { desktopHealthSchema, desktopResourceName, runtimeError, } from '@playcraft/common/cloud-workspace/runtime';
4
4
  import { controlTokenMatches, extractControlToken } from './auth.js';
5
+ import { createRfbWatchGate } from './rfb-watch-gate.js';
5
6
  /**
6
7
  * A takeover holds the desktop for this long, whether or not a page is watching, and ends on its
7
8
  * own unless the user renews it.
@@ -190,6 +191,7 @@ export class DesktopHost {
190
191
  }
191
192
  else if (this.upstream) {
192
193
  const upstream = new WebSocket(`ws://127.0.0.1:${this.upstream.port}/`);
194
+ const watch = createRfbWatchGate();
193
195
  viewer.upstream = upstream;
194
196
  viewer.detach = () => upstream.close();
195
197
  upstream.on('error', () => {
@@ -204,12 +206,34 @@ export class DesktopHost {
204
206
  return;
205
207
  }
206
208
  const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data);
209
+ watch.observeServer(chunk);
210
+ if (watch.failed) {
211
+ ws.close(1008, 'desktop_protocol_error');
212
+ upstream.close();
213
+ return;
214
+ }
207
215
  send(chunk, isBinary);
208
216
  });
209
217
  upstream.on('close', () => {
210
218
  if (ws.readyState === WebSocket.OPEN)
211
219
  ws.close();
212
220
  });
221
+ ws.on('message', (data, isBinary) => {
222
+ if (this.generation !== generation)
223
+ return;
224
+ const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data);
225
+ if (viewer.upstream?.readyState !== WebSocket.OPEN)
226
+ return;
227
+ const outgoing = watch.clientBytes(chunk, this.holder === 'human');
228
+ if (watch.failed) {
229
+ ws.close(1008, 'desktop_protocol_error');
230
+ upstream.close();
231
+ return;
232
+ }
233
+ for (const message of outgoing) {
234
+ viewer.upstream.send(message, { binary: isBinary });
235
+ }
236
+ });
213
237
  }
214
238
  this.viewers.add(viewer);
215
239
  let closed = false;
@@ -223,12 +247,6 @@ export class DesktopHost {
223
247
  };
224
248
  ws.on('close', close);
225
249
  ws.on('error', close);
226
- ws.on('message', (data, isBinary) => {
227
- if (this.holder !== 'human' || this.generation !== generation)
228
- return;
229
- if (viewer.upstream?.readyState === WebSocket.OPEN)
230
- viewer.upstream.send(data, { binary: isBinary });
231
- });
232
250
  }
233
251
  rejectPlainConnect(response, url) {
234
252
  if (this.currentStatus() !== 'ready') {