@playcraft/cli 0.1.25 → 0.1.27

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.
@@ -10,6 +10,9 @@ import { projectNativeThread } from '@playcraft/common/cloud-workspace/runtime';
10
10
  import { collectedEvent, NativePreparationError } from './native-adapter.js';
11
11
  import { readProjectSkillsLockFile } from '@playcraft/common/project-skills';
12
12
  import { freezeWorkspaceSkills } from '../workspaces/skills.js';
13
+ function codexReasoningEffort(effort) {
14
+ return effort === 'max' ? 'xhigh' : effort;
15
+ }
13
16
  function asRecord(value) {
14
17
  return value && typeof value === 'object' && !Array.isArray(value)
15
18
  ? value
@@ -85,24 +88,25 @@ export function createCodexAppServerAdapter(options) {
85
88
  startPromise ??= start();
86
89
  await startPromise;
87
90
  }
88
- async function threadConfig(cwd, modelConfigId, sessionId) {
91
+ async function threadConfig(cwd, modelConfigId, sessionId, reasoningEffort) {
89
92
  try {
90
- return await options.modelBroker?.prepareThreadConfig(cwd, modelConfigId, sessionId)
93
+ return await options.modelBroker?.prepareThreadConfig(cwd, modelConfigId, sessionId, reasoningEffort)
91
94
  ?? { ...options.fixedThreadConfig, modelConfigId: undefined, activate: () => { } };
92
95
  }
93
96
  catch (error) {
94
97
  const reason = error instanceof Error ? error.message : '';
95
98
  throw new NativePreparationError(['model_config_required',
96
- 'model_credential_scope_mismatch', 'model_config_denied', 'workspace_not_registered'].includes(reason)
99
+ 'model_credential_scope_mismatch', 'model_config_denied', 'reasoning_effort_denied', 'reasoning_effort_unsupported', 'workspace_not_registered'].includes(reason)
97
100
  ? reason : 'model_credential_unavailable');
98
101
  }
99
102
  }
100
- async function lockedThreadConfig(cwd, selectedConfigId, sessionId) {
101
- const { modelConfigId, activate, ...providedModelConfig } = await threadConfig(cwd, selectedConfigId, sessionId);
103
+ async function lockedThreadConfig(cwd, selectedConfigId, sessionId, reasoningEffort) {
104
+ const { modelConfigId, activate, ...providedModelConfig } = await threadConfig(cwd, selectedConfigId, sessionId, reasoningEffort);
102
105
  // Both local and managed threads must use the verified portable catalog.
103
106
  const modelConfig = { ...providedModelConfig, config: {
104
107
  ...('config' in providedModelConfig ? asRecord(providedModelConfig.config) : {}),
105
108
  ...portableCodexConfig(catalogDirectory ? join(catalogDirectory, 'portable-models.json') : undefined),
109
+ ...(reasoningEffort ? { model_reasoning_effort: codexReasoningEffort(reasoningEffort) } : {}),
106
110
  } };
107
111
  const scope = relative(options.config.workspaceRoot, cwd);
108
112
  const orchestration = options.config.workspaceMode === 'multi' && /^[0-9a-f-]{36}$/i.test(scope);
@@ -175,7 +179,7 @@ export function createCodexAppServerAdapter(options) {
175
179
  throw new NativePreparationError('workspace_skills_unavailable');
176
180
  }
177
181
  }
178
- async function restartForSkills(threadId, selectedConfigId) {
182
+ async function restartForSkills(threadId, selectedConfigId, reasoningEffort) {
179
183
  if ([...threads.values()].some(thread => thread.turnActive) || approvals.size)
180
184
  throw new NativePreparationError('skills_reload_pending');
181
185
  const previous = live;
@@ -206,7 +210,7 @@ export function createCodexAppServerAdapter(options) {
206
210
  // Other workspaces resume lazily, so a damaged unrelated lock cannot block this workspace.
207
211
  const thread = threads.get(threadId);
208
212
  if (thread?.hasNativeHistory && thread.cwd)
209
- await resumePreparedThread(threadId, thread, await lockedThreadConfig(thread.cwd, selectedConfigId ?? thread.modelConfigId, thread.sessionId));
213
+ await resumePreparedThread(threadId, thread, await lockedThreadConfig(thread.cwd, selectedConfigId ?? thread.modelConfigId, thread.sessionId, reasoningEffort));
210
214
  }
211
215
  catch {
212
216
  readyFlag = false;
@@ -452,7 +456,7 @@ export function createCodexAppServerAdapter(options) {
452
456
  await ensureStarted();
453
457
  if (!rpc)
454
458
  throw new Error('codex_not_started');
455
- const prepared = await lockedThreadConfig(input.cwd, input.modelConfigId, input.sessionId);
459
+ const prepared = await lockedThreadConfig(input.cwd, input.modelConfigId, input.sessionId, input.reasoningEffort);
456
460
  const result = await rpc.request('thread/start', {
457
461
  cwd: input.cwd,
458
462
  model,
@@ -463,8 +467,8 @@ export function createCodexAppServerAdapter(options) {
463
467
  throw new Error('native_receipt_lost');
464
468
  prepared.activate();
465
469
  threads.set(threadId, { sessionId: input.sessionId, cwd: input.cwd, skillsHash: prepared.skillsHash,
466
- discoveryHash: prepared.discoveryHash, modelConfigId: prepared.modelConfigId, hasNativeHistory: false, loaded: true });
467
- return { threadId, modelConfigId: prepared.modelConfigId };
470
+ discoveryHash: prepared.discoveryHash, modelConfigId: prepared.modelConfigId, reasoningEffort: input.reasoningEffort, hasNativeHistory: false, loaded: true });
471
+ return { threadId, modelConfigId: prepared.modelConfigId, reasoningEffort: input.reasoningEffort };
468
472
  });
469
473
  },
470
474
  async resumeThread(input) {
@@ -472,7 +476,7 @@ export function createCodexAppServerAdapter(options) {
472
476
  await ensureStarted();
473
477
  if (!rpc)
474
478
  throw new Error('codex_not_started');
475
- const prepared = await lockedThreadConfig(input.cwd, input.modelConfigId, input.sessionId);
479
+ const prepared = await lockedThreadConfig(input.cwd, input.modelConfigId, input.sessionId, input.reasoningEffort);
476
480
  let resumedId;
477
481
  try {
478
482
  const resumed = await rpc.request('thread/resume', {
@@ -487,8 +491,8 @@ export function createCodexAppServerAdapter(options) {
487
491
  return undefined;
488
492
  prepared.activate();
489
493
  threads.set(input.threadId, { sessionId: input.sessionId, cwd: input.cwd, skillsHash: prepared.skillsHash,
490
- discoveryHash: prepared.discoveryHash, modelConfigId: prepared.modelConfigId, hasNativeHistory: true, loaded: true });
491
- return { modelConfigId: prepared.modelConfigId };
494
+ discoveryHash: prepared.discoveryHash, modelConfigId: prepared.modelConfigId, reasoningEffort: input.reasoningEffort, hasNativeHistory: true, loaded: true });
495
+ return { modelConfigId: prepared.modelConfigId, reasoningEffort: input.reasoningEffort };
492
496
  });
493
497
  },
494
498
  async startTurn(input) {
@@ -506,11 +510,13 @@ export function createCodexAppServerAdapter(options) {
506
510
  return { outcome: 'failed', error: 'workspace_skills_changed' };
507
511
  let selectedModel;
508
512
  let modelConfigId = thread.modelConfigId;
513
+ const modelChanged = input.modelConfigId !== undefined && input.modelConfigId !== thread.modelConfigId;
514
+ const reasoningEffort = input.reasoningEffort ?? (modelChanged ? undefined : thread.reasoningEffort);
509
515
  let activateModel = () => { };
510
516
  let execution;
511
517
  if (thread.cwd) {
512
518
  try {
513
- const prepared = await lockedThreadConfig(thread.cwd, input.modelConfigId ?? thread.modelConfigId, input.sessionId);
519
+ const prepared = await lockedThreadConfig(thread.cwd, input.modelConfigId ?? thread.modelConfigId, input.sessionId, reasoningEffort);
514
520
  selectedModel = 'model' in prepared.params ? prepared.params.model : undefined;
515
521
  modelConfigId = prepared.modelConfigId;
516
522
  activateModel = prepared.activate;
@@ -521,7 +527,7 @@ export function createCodexAppServerAdapter(options) {
521
527
  return { outcome: 'failed', error: 'workspace_skills_changed' };
522
528
  }
523
529
  else if (thread.skillsHash && (prepared.skillsHash !== thread.skillsHash || prepared.discoveryHash !== thread.discoveryHash)) {
524
- await restartForSkills(input.threadId, prepared.modelConfigId);
530
+ await restartForSkills(input.threadId, prepared.modelConfigId, reasoningEffort);
525
531
  }
526
532
  if (!thread.loaded)
527
533
  await resumePreparedThread(input.threadId, thread, prepared);
@@ -533,9 +539,10 @@ export function createCodexAppServerAdapter(options) {
533
539
  }
534
540
  }
535
541
  const requestId = rpc.reserveRequest();
536
- await input.persistReservation({ nativeRequestId: String(requestId), modelConfigId });
542
+ await input.persistReservation({ nativeRequestId: String(requestId), modelConfigId, reasoningEffort });
537
543
  activateModel();
538
544
  thread.modelConfigId = modelConfigId;
545
+ thread.reasoningEffort = reasoningEffort;
539
546
  if (input.simulateLostReceipt)
540
547
  return { outcome: 'uncertain', threadId: input.threadId };
541
548
  try {
@@ -543,7 +550,9 @@ export function createCodexAppServerAdapter(options) {
543
550
  const started = await rpc.sendReserved(requestId, 'turn/start', { threadId: input.threadId, input: [
544
551
  { type: 'text', text: input.text },
545
552
  ...(input.images ?? []).map((path) => ({ type: 'localImage', path })),
546
- ], ...(selectedModel ? { model: selectedModel } : {}), ...execution }, turnTimeoutMs);
553
+ ], ...(selectedModel ? { model: selectedModel } : {}),
554
+ ...(reasoningEffort ? { effort: codexReasoningEffort(reasoningEffort) } : modelChanged ? { effort: null } : {}),
555
+ ...execution }, turnTimeoutMs);
547
556
  const turnId = started.turn?.id;
548
557
  if (!turnId)
549
558
  return { outcome: 'uncertain', threadId: input.threadId };
@@ -554,7 +563,7 @@ export function createCodexAppServerAdapter(options) {
554
563
  acceptedThread.modelConfigId = modelConfigId;
555
564
  acceptedThread.turnActive = acceptedThread.lastCompletedTurnId !== turnId;
556
565
  }
557
- return { outcome: 'accepted', threadId: input.threadId, turnId, modelConfigId };
566
+ return { outcome: 'accepted', threadId: input.threadId, turnId, modelConfigId, reasoningEffort };
558
567
  }
559
568
  catch {
560
569
  return { outcome: 'uncertain', threadId: input.threadId };
@@ -47,8 +47,6 @@ export function portableCodexCatalog(raw) {
47
47
  multi_agent_version: 'v1',
48
48
  supports_search_tool: false,
49
49
  experimental_supported_tools: [],
50
- default_reasoning_level: null,
51
- supported_reasoning_levels: [],
52
50
  };
53
51
  });
54
52
  return { ...catalog, models };
@@ -9,6 +9,7 @@ import { PLATFORM_ROUTES, modelResponsesPath, platformEndpoint } from '@playcraf
9
9
  const credentialSchema = z.object({
10
10
  token: z.string().min(1), expiresAt: z.string().datetime(),
11
11
  configId: z.string().uuid(), model: z.string().min(1).max(128),
12
+ supportsReasoning: z.boolean().optional(),
12
13
  claims: z.object({
13
14
  environment: z.string(), userId: z.string(), workspaceId: z.string(), runtimeId: z.string(),
14
15
  generation: z.number().int(), runtimeEpoch: z.number().int(),
@@ -43,12 +44,12 @@ export class WorkspaceModelBroker {
43
44
  throw new Error('model_broker_unavailable');
44
45
  this.baseUrl = `http://127.0.0.1:${address.port}`;
45
46
  }
46
- async threadConfig(cwd, modelConfigId, sessionId) {
47
- const { activate, ...settings } = await this.prepareThreadConfig(cwd, modelConfigId, sessionId);
47
+ async threadConfig(cwd, modelConfigId, sessionId, reasoningEffort) {
48
+ const { activate, ...settings } = await this.prepareThreadConfig(cwd, modelConfigId, sessionId, reasoningEffort);
48
49
  activate();
49
50
  return settings;
50
51
  }
51
- async prepareThreadConfig(cwd, modelConfigId, sessionId) {
52
+ async prepareThreadConfig(cwd, modelConfigId, sessionId, reasoningEffort) {
52
53
  if (!this.server)
53
54
  throw new Error('workspace_not_registered');
54
55
  const { workspaceId } = await workspaceContextFromRoot(this.state, cwd);
@@ -57,10 +58,11 @@ export class WorkspaceModelBroker {
57
58
  const routeKey = sessionId ? `${workspaceId}/${createHash('sha256').update(sessionId).digest('hex').slice(0, 32)}` : workspaceId;
58
59
  let route = this.routes.get(routeKey);
59
60
  const selected = modelConfigId ?? (route?.epoch === this.state.runtimeEpoch ? route.selected?.entry.modelConfigId : undefined);
60
- const cacheKey = `${workspaceId}:${selected ?? 'default'}`;
61
- let entry = this.entries.get(cacheKey) ?? [...this.entries.values()].find(item => item.workspaceId === workspaceId && item.epoch === this.state.runtimeEpoch && item.modelConfigId === selected);
61
+ const cacheKey = `${workspaceId}:${selected ?? 'default'}:${reasoningEffort ?? 'default'}`;
62
+ let entry = this.entries.get(cacheKey) ?? [...this.entries.values()].find(item => item.workspaceId === workspaceId && item.epoch === this.state.runtimeEpoch && item.modelConfigId === selected
63
+ && item.reasoningEffort === reasoningEffort);
62
64
  if (!entry || entry.epoch !== this.state.runtimeEpoch) {
63
- entry = { workspaceId, epoch: this.state.runtimeEpoch, cacheKey, modelConfigId: selected };
65
+ entry = { workspaceId, epoch: this.state.runtimeEpoch, cacheKey, modelConfigId: selected, reasoningEffort };
64
66
  this.entries.set(cacheKey, entry);
65
67
  }
66
68
  // Resolve current model metadata and permission at every native turn boundary.
@@ -113,11 +115,12 @@ export class WorkspaceModelBroker {
113
115
  headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
114
116
  body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation,
115
117
  runtimeEpoch: entry.epoch, workspaceId: entry.workspaceId,
116
- ...(entry.modelConfigId ? { modelConfigId: entry.modelConfigId } : {}) }),
118
+ ...(entry.modelConfigId ? { modelConfigId: entry.modelConfigId } : {}),
119
+ ...(entry.reasoningEffort ? { reasoningEffort: entry.reasoningEffort } : {}) }),
117
120
  });
118
121
  if (!response.ok) {
119
122
  const error = await response.json().catch(() => ({}));
120
- throw new Error(['model_config_required', 'model_config_denied'].includes(error.reason ?? '') ? error.reason : 'model_credential_unavailable');
123
+ throw new Error(['model_config_required', 'model_config_denied', 'reasoning_effort_denied', 'reasoning_effort_unsupported'].includes(error.reason ?? '') ? error.reason : 'model_credential_unavailable');
121
124
  }
122
125
  const credential = credentialSchema.parse(await response.json());
123
126
  const claims = credential.claims;
@@ -176,6 +179,22 @@ export class WorkspaceModelBroker {
176
179
  }
177
180
  // The browser selects a configuration, never an arbitrary billable upstream model.
178
181
  body.model = selected.model;
182
+ if (credential.supportsReasoning === false) {
183
+ const reasoning = body.reasoning;
184
+ if (reasoning && typeof reasoning === 'object' && !Array.isArray(reasoning)) {
185
+ delete reasoning.effort;
186
+ }
187
+ }
188
+ else if (entry.reasoningEffort) {
189
+ const reasoning = body.reasoning;
190
+ const modelName = selected.model.split('/').pop() ?? selected.model;
191
+ const effort = entry.reasoningEffort === 'max' && /^o\d|gpt-[5-9]/i.test(modelName)
192
+ ? 'xhigh' : entry.reasoningEffort;
193
+ body.reasoning = {
194
+ ...(reasoning && typeof reasoning === 'object' && !Array.isArray(reasoning) ? reasoning : {}),
195
+ effort,
196
+ };
197
+ }
179
198
  const expires = setTimeout(cancel, Math.max(0, Date.parse(credential.expiresAt) - Date.now()));
180
199
  try {
181
200
  const upstream = await fetch(platformEndpoint(this.state.config.publicBaseUrl, modelResponsesPath(credential.configId)), {
@@ -2,7 +2,7 @@ import { withObservedFile } from '../workspaces/revisions.js';
2
2
  import { revisionScope, sessionWorkspaceRoot, workspaceRoot, workspaceSessions } from '../workspaces/registry.js';
3
3
  import { join } from 'node:path';
4
4
  import { z } from 'zod';
5
- import { PLATFORM_ROUTES, platformEndpoint, fileWritePayloadSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, parameterPatchPayloadSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { PLATFORM_ROUTES, platformEndpoint, fileWritePayloadSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, parameterPatchPayloadSchema, reasoningEffortSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
6
6
  import { closeCliCredentials } from '../workspaces/cli-credentials.js';
7
7
  import { artifactFormat } from '../workspaces/artifact-files.js';
8
8
  import { refreshWorkspaceArtifacts } from '../workspaces/artifact-events.js';
@@ -163,12 +163,16 @@ async function executeCreate(state, scopeKey, operation, payload) {
163
163
  const selected = z.string().uuid().optional().safeParse(asRecord(payload).modelConfigId);
164
164
  if (!selected.success)
165
165
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'model_config_denied' }));
166
+ const effort = reasoningEffortSchema.optional().safeParse(asRecord(payload).reasoningEffort);
167
+ if (!effort.success)
168
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'reasoning_effort_denied' }));
166
169
  let started;
167
170
  try {
168
171
  started = await native(state).startThread({
169
172
  sessionId,
170
173
  cwd: await sessionWorkspaceRoot(state, operation.workspaceId, operation.directoryName),
171
174
  modelConfigId: selected.data,
175
+ reasoningEffort: effort.data,
172
176
  });
173
177
  }
174
178
  catch (error) {
@@ -188,11 +192,13 @@ async function executeCreate(state, scopeKey, operation, payload) {
188
192
  cwd: await sessionWorkspaceRoot(state, operation.workspaceId, operation.directoryName),
189
193
  recoverability: 'native',
190
194
  modelConfigId: started.modelConfigId,
195
+ reasoningEffort: started.reasoningEffort ?? effort.data,
191
196
  });
192
197
  const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted', {
193
198
  nativeRequestId: `thread/start:${sessionId}`,
194
199
  nativeThreadId: started.threadId,
195
200
  modelConfigId: started.modelConfigId,
201
+ reasoningEffort: started.reasoningEffort ?? effort.data,
196
202
  }));
197
203
  await recordNativeEvents(state, accepted, [
198
204
  { type: 'session.created', payload: { sessionId, threadId: started.threadId }, occurredAt: new Date().toISOString() },
@@ -205,9 +211,14 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
205
211
  const selected = z.string().uuid().optional().safeParse(body.modelConfigId);
206
212
  if (!selected.success)
207
213
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'model_config_denied' }));
214
+ const effort = reasoningEffortSchema.optional().safeParse(body.reasoningEffort);
215
+ if (!effort.success)
216
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'reasoning_effort_denied' }));
208
217
  let found = state.journal.getSession(sessionId);
218
+ const modelChanged = selected.data !== undefined && selected.data !== (found?.modelConfigId ?? recovery?.modelConfigId);
219
+ const requestedEffort = effort.data ?? (modelChanged ? undefined : recovery?.reasoningEffort ?? found?.reasoningEffort);
209
220
  if (recovery && found?.recoverability !== 'native') {
210
- const rebound = await rebindSession(state, operation, found, selected.data ?? recovery.modelConfigId);
221
+ const rebound = await rebindSession(state, operation, found, selected.data ?? recovery.modelConfigId, requestedEffort);
211
222
  if (typeof rebound === 'string') {
212
223
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: rebound }));
213
224
  }
@@ -246,16 +257,19 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
246
257
  images: attachments.filter((file) => file.mimeType.startsWith('image/')).map((file) => file.path),
247
258
  clientRequestId: operation.clientRequestId,
248
259
  modelConfigId: selected.data ?? binding.modelConfigId,
260
+ reasoningEffort: requestedEffort,
249
261
  simulateLostReceipt: body.simulateLostReceipt === true,
250
- persistReservation: async ({ nativeRequestId, modelConfigId }) => {
262
+ persistReservation: async ({ nativeRequestId, modelConfigId, reasoningEffort }) => {
263
+ const selectedEffort = reasoningEffort ?? requestedEffort;
251
264
  await persist(state, scopeKey, {
252
265
  ...operation,
253
266
  nativeRequestId,
254
267
  nativeThreadId: binding.threadId,
255
268
  modelConfigId,
269
+ reasoningEffort: selectedEffort,
256
270
  updatedAt: new Date().toISOString(),
257
271
  });
258
- await state.journal.putSession({ ...binding, modelConfigId, turnStatus: 'starting' });
272
+ await state.journal.putSession({ ...binding, modelConfigId, reasoningEffort: selectedEffort, turnStatus: 'starting' });
259
273
  },
260
274
  });
261
275
  const reserved = state.journal.getOperation(scopeKey) ?? operation;
@@ -323,7 +337,7 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
323
337
  * carry the earlier conversation until a turn starts on it.
324
338
  * Returns an error code when the thread cannot be prepared.
325
339
  */
326
- async function rebindSession(state, operation, binding, modelConfigId) {
340
+ async function rebindSession(state, operation, binding, modelConfigId, reasoningEffort) {
327
341
  const sessionId = required(operation.sessionId, 'sessionId');
328
342
  const adapter = native(state);
329
343
  const cwd = await sessionWorkspaceRoot(state, operation.workspaceId, operation.directoryName);
@@ -335,6 +349,7 @@ async function rebindSession(state, operation, binding, modelConfigId) {
335
349
  threadId: binding.threadId,
336
350
  cwd,
337
351
  modelConfigId: modelConfigId ?? binding.modelConfigId,
352
+ reasoningEffort,
338
353
  });
339
354
  if (resumed) {
340
355
  const next = {
@@ -342,6 +357,7 @@ async function rebindSession(state, operation, binding, modelConfigId) {
342
357
  cwd,
343
358
  recoverability: 'native',
344
359
  modelConfigId: resumed.modelConfigId ?? binding.modelConfigId,
360
+ reasoningEffort: resumed.reasoningEffort ?? reasoningEffort,
345
361
  turnStatus: undefined,
346
362
  turnBlock: undefined,
347
363
  activeTurnId: undefined,
@@ -351,7 +367,8 @@ async function rebindSession(state, operation, binding, modelConfigId) {
351
367
  return next;
352
368
  }
353
369
  }
354
- const started = await adapter.startThread({ sessionId, cwd, modelConfigId: modelConfigId ?? binding?.modelConfigId });
370
+ const started = await adapter.startThread({ sessionId, cwd, modelConfigId: modelConfigId ?? binding?.modelConfigId,
371
+ reasoningEffort });
355
372
  const next = {
356
373
  sessionId,
357
374
  workspaceId: operation.workspaceId,
@@ -360,6 +377,7 @@ async function rebindSession(state, operation, binding, modelConfigId) {
360
377
  cwd,
361
378
  recoverability: 'native',
362
379
  modelConfigId: started.modelConfigId,
380
+ reasoningEffort: started.reasoningEffort ?? reasoningEffort,
363
381
  recoveryContextPending: true,
364
382
  };
365
383
  await state.journal.putSession(next);
@@ -563,6 +581,15 @@ async function executeGitCommit(state, scopeKey, operation, payload) {
563
581
  * and the Git gateway never lets a workspace write its project's main branch, so those two only
564
582
  * commit.
565
583
  */
584
+ /**
585
+ * Whether a save pushes after it commits. A workspace bound to a project (its main branch or a
586
+ * Remix branch) pushes the way the Git panel does; the credential decides whether the remote
587
+ * accepts it. A standalone workspace has no remote, so its saves stay local; its project
588
+ * directories have their own branch.
589
+ */
590
+ export function savePushesBranch(targetKind, directoryName) {
591
+ return targetKind === 'main' || targetKind === 'remix' || Boolean(directoryName && directoryName !== 'basic');
592
+ }
566
593
  async function executeWorkspaceSave(state, scopeKey, operation, payload) {
567
594
  const parsed = workspaceSavePayloadSchema.safeParse(payload ?? {});
568
595
  if (!parsed.success) {
@@ -572,7 +599,7 @@ async function executeWorkspaceSave(state, scopeKey, operation, payload) {
572
599
  try {
573
600
  const saved = await saveWorkspaceGit(await workspaceRoot(state, operation.workspaceId, operation.directoryName), {
574
601
  message: parsed.data.trigger === 'auto' ? 'Autosave workspace' : 'Save workspace',
575
- push: binding?.targetKind === 'remix' || Boolean(operation.directoryName && operation.directoryName !== 'basic'),
602
+ push: savePushesBranch(binding?.targetKind, operation.directoryName),
576
603
  boundBranch: boundBranch(state, operation),
577
604
  });
578
605
  const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
@@ -12,6 +12,7 @@ import { readDependencies } from '../workspaces/dependencies.js';
12
12
  import { desktopHost, desktopRoute } from './desktop-host.js';
13
13
  import { ensureCliCredentials } from '../workspaces/cli-credentials.js';
14
14
  import { readAttachmentUpload, uploadWorkspaceAttachment, uploadWorkspaceVideoAttachment } from '../workspaces/attachments.js';
15
+ import { serveWorkspaceAttachment } from '../workspaces/attachment-content.js';
15
16
  import { readWorkspaceSkills } from '../workspaces/skills.js';
16
17
  import { loadWorkspaceSkillArtifact } from '../workspaces/skill-artifact-loader.js';
17
18
  import { join } from 'node:path';
@@ -190,6 +191,13 @@ export async function handleRuntimeRequest(state, request, response) {
190
191
  }
191
192
  return;
192
193
  }
194
+ if (['GET', 'HEAD'].includes(request.method ?? '') && url.pathname === '/runtime/attachment-content') {
195
+ if (writeDestroyed(state, response))
196
+ return;
197
+ const root = await workspaceRoot(state, url.searchParams.get('workspaceId') ?? '', directoryName);
198
+ await serveWorkspaceAttachment(root, url.searchParams.get('attachmentId') ?? '', request, response);
199
+ return;
200
+ }
193
201
  if (request.method === 'GET' && url.pathname === '/runtime/artifacts') {
194
202
  if (writeDestroyed(state, response))
195
203
  return;
@@ -0,0 +1,111 @@
1
+ import { constants } from 'node:fs';
2
+ import { open } from 'node:fs/promises';
3
+ import { extname } from 'node:path';
4
+ import { pipeline } from 'node:stream/promises';
5
+ import { attachmentIdsSchema, attachmentUploadSchema, WORKSPACE_ATTACHMENT_MAX_BYTES, WORKSPACE_USER_UPLOADS_DIRECTORY, WORKSPACE_VIDEO_ATTACHMENT_MAX_BYTES, } from '@playcraft/common/cloud-workspace/runtime';
6
+ import { artifactMime, artifactPath } from './artifact-files.js';
7
+ const additionalMediaTypes = {
8
+ '.aac': 'audio/aac', '.opus': 'audio/ogg', '.oga': 'audio/ogg', '.aif': 'audio/aiff', '.aiff': 'audio/aiff',
9
+ '.bmp': 'image/bmp', '.ico': 'image/vnd.microsoft.icon', '.heic': 'image/heic', '.heif': 'image/heif',
10
+ '.tif': 'image/tiff', '.tiff': 'image/tiff',
11
+ };
12
+ const detectedImageTypes = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
13
+ function attachmentRange(header, size) {
14
+ if (!header)
15
+ return undefined;
16
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
17
+ if (!match || (!match[1] && !match[2]) || !size)
18
+ return null;
19
+ const first = match[1] ? Number(match[1]) : undefined;
20
+ const last = match[2] ? Number(match[2]) : undefined;
21
+ if ((first !== undefined && !Number.isSafeInteger(first)) || (last !== undefined && !Number.isSafeInteger(last)))
22
+ return null;
23
+ const start = first ?? Math.max(0, size - (last ?? 0));
24
+ const end = first === undefined ? size - 1 : Math.min(last ?? size - 1, size - 1);
25
+ return start >= size || start > end ? null : { start, end };
26
+ }
27
+ async function openAttachment(root, id) {
28
+ const metadataPath = await artifactPath(root, `${WORKSPACE_USER_UPLOADS_DIRECTORY}/.metadata/${id}.json`);
29
+ const metadataFile = await open(metadataPath, constants.O_RDONLY | constants.O_NOFOLLOW);
30
+ let metadata;
31
+ try {
32
+ const stat = await metadataFile.stat();
33
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > 8192)
34
+ throw new Error('attachment_unavailable');
35
+ metadata = JSON.parse(await metadataFile.readFile('utf8'));
36
+ if (metadata.id !== id || !attachmentUploadSchema.shape.name.safeParse(metadata.name).success)
37
+ throw new Error('attachment_unavailable');
38
+ }
39
+ finally {
40
+ await metadataFile.close();
41
+ }
42
+ const path = await artifactPath(root, `${WORKSPACE_USER_UPLOADS_DIRECTORY}/${id}/${metadata.name}`);
43
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
44
+ try {
45
+ const stat = await handle.stat();
46
+ const mimeType = detectedImageTypes.has(metadata.mimeType) ? metadata.mimeType
47
+ : additionalMediaTypes[extname(metadata.name).toLowerCase()] ?? artifactMime(metadata.name);
48
+ const limit = mimeType.startsWith('video/') ? WORKSPACE_VIDEO_ATTACHMENT_MAX_BYTES : WORKSPACE_ATTACHMENT_MAX_BYTES;
49
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size !== metadata.size || stat.size > limit)
50
+ throw new Error('attachment_unavailable');
51
+ return { handle, name: metadata.name, mimeType, size: stat.size };
52
+ }
53
+ catch (error) {
54
+ await handle.close();
55
+ throw error;
56
+ }
57
+ }
58
+ /** Serve only registered references; no caller-supplied file paths reach the filesystem. */
59
+ export async function serveWorkspaceAttachment(root, attachmentId, request, response) {
60
+ if (!attachmentIdsSchema.safeParse([attachmentId]).success) {
61
+ response.writeHead(400, { 'content-type': 'application/json', 'cache-control': 'no-store' });
62
+ response.end(JSON.stringify({ error: 'invalid_attachment' }));
63
+ return;
64
+ }
65
+ let content;
66
+ try {
67
+ content = await openAttachment(root, attachmentId);
68
+ }
69
+ catch {
70
+ response.writeHead(404, { 'content-type': 'application/json', 'cache-control': 'no-store' });
71
+ response.end(JSON.stringify({ error: 'attachment_not_found' }));
72
+ return;
73
+ }
74
+ try {
75
+ const range = attachmentRange(request.headers.range, content.size);
76
+ const headers = {
77
+ 'content-type': content.mimeType,
78
+ 'cache-control': 'private, no-store',
79
+ 'x-content-type-options': 'nosniff',
80
+ 'content-security-policy': "sandbox; default-src 'none'; style-src 'unsafe-inline'",
81
+ 'referrer-policy': 'no-referrer',
82
+ 'accept-ranges': 'bytes',
83
+ };
84
+ if (!/^(?:image|video|audio|model)\//.test(content.mimeType) || content.mimeType === 'image/svg+xml')
85
+ headers['content-disposition'] = `attachment; filename*=UTF-8''${encodeURIComponent(content.name).replace(/'/g, '%27')}`;
86
+ if (range === null) {
87
+ response.writeHead(416, { ...headers, 'content-range': `bytes */${content.size}`, 'content-length': '0' });
88
+ response.end();
89
+ return;
90
+ }
91
+ if (range)
92
+ headers['content-range'] = `bytes ${range.start}-${range.end}/${content.size}`;
93
+ headers['content-length'] = String(range ? range.end - range.start + 1 : content.size);
94
+ response.writeHead(range ? 206 : 200, headers);
95
+ if (request.method === 'HEAD' || !content.size) {
96
+ response.end();
97
+ return;
98
+ }
99
+ await pipeline(content.handle.createReadStream({
100
+ start: range?.start ?? 0,
101
+ end: range?.end ?? content.size - 1,
102
+ autoClose: false,
103
+ }), response);
104
+ }
105
+ catch (error) {
106
+ response.destroy(error instanceof Error ? error : undefined);
107
+ }
108
+ finally {
109
+ await content.handle.close();
110
+ }
111
+ }
@@ -189,6 +189,16 @@ async function assertBoundBranch(rootReal, boundBranch) {
189
189
  * A push the remote refused for this ref is a definite outcome, unlike a lost response. Porcelain
190
190
  * output marks the ref with "!" and says whether the remote has commits this checkout lacks.
191
191
  */
192
+ /**
193
+ * The gateway or the host refused the push over HTTP before any ref changed: no write access, the
194
+ * gateway's writes switched off, or no such repository. Like a protected-branch refusal, nothing
195
+ * was pushed and the commit is kept.
196
+ */
197
+ function httpRefusal(stderr) {
198
+ return /returned error: (?:401|403|404)\b|Authentication failed|Access denied|could not read Username|repository '[^']*' not found/i.test(stderr)
199
+ ? 'git_push_rejected'
200
+ : undefined;
201
+ }
192
202
  function pushRejection(stdout) {
193
203
  const line = stdout.split('\n').find((item) => item.startsWith('!\t'));
194
204
  if (!line)
@@ -235,7 +245,7 @@ export async function pushWorkspaceGit(root, boundBranch) {
235
245
  catch { /* A lost response does not prove the remote rejected the update. */ }
236
246
  if (pushed?.code === 0)
237
247
  return;
238
- const rejected = pushed ? pushRejection(pushed.stdout) : undefined;
248
+ const rejected = pushed ? pushRejection(pushed.stdout) ?? httpRefusal(pushed.stderr) : undefined;
239
249
  if (rejected)
240
250
  throw new GitWorkspaceError(rejected, rejected);
241
251
  try {
@@ -282,6 +292,42 @@ export async function saveWorkspaceGit(root, input) {
282
292
  await recordPushedHead(rootReal);
283
293
  return { ...(commit ? { commit } : {}), pushed: true, withheld };
284
294
  }
295
+ /**
296
+ * Connects a standalone checkout to the repository it was just given, keeping all its history and
297
+ * uncommitted work. The new repository holds one empty commit on `branch`; it becomes the base of an
298
+ * unborn checkout, or is merged into existing history, so the next push is a plain fast-forward.
299
+ * Credentials come from the workspace's configured helper and never enter the remote URL.
300
+ * Safe to run again after a failure part way.
301
+ */
302
+ export async function attachWorkspaceRemote(root, repositoryUrl, branch) {
303
+ const rootReal = await ensureWorkspaceGit(root);
304
+ const origin = await gitAllowFail(rootReal, ['remote', 'get-url', 'origin']);
305
+ if (origin.code !== 0)
306
+ await git(rootReal, ['remote', 'add', 'origin', repositoryUrl]);
307
+ else if (origin.stdout.trim() !== repositoryUrl)
308
+ throw new GitWorkspaceError('remote_not_configured', 'origin_mismatch');
309
+ const remoteRef = `refs/remotes/origin/${branch}`;
310
+ const fetched = await gitAllowFail(rootReal, ['fetch', '--no-tags', 'origin', `+refs/heads/${branch}:${remoteRef}`], 90000);
311
+ if (fetched.code !== 0)
312
+ throw new GitWorkspaceError(httpRefusal(fetched.stderr) ?? 'git_command_failed', 'git_fetch_failed');
313
+ const current = (await gitAllowFail(rootReal, ['symbolic-ref', '--quiet', '--short', 'HEAD'])).stdout.trim();
314
+ const born = (await gitAllowFail(rootReal, ['rev-parse', '-q', '--verify', 'HEAD^{commit}'])).code === 0;
315
+ if (current !== branch) {
316
+ await git(rootReal, born ? ['branch', '-M', branch] : ['symbolic-ref', 'HEAD', `refs/heads/${branch}`]);
317
+ }
318
+ if (!born) {
319
+ // Nothing committed yet: start the branch at the remote commit; the files stay as changes.
320
+ await git(rootReal, ['update-ref', `refs/heads/${branch}`, remoteRef]);
321
+ }
322
+ else if ((await gitAllowFail(rootReal, ['merge-base', '--is-ancestor', remoteRef, 'HEAD'])).code !== 0) {
323
+ const merged = await gitAllowFail(rootReal, [...(await commitIdentity(rootReal)), 'merge', '--allow-unrelated-histories', '--no-edit', remoteRef], 60000);
324
+ if (merged.code !== 0) {
325
+ await gitAllowFail(rootReal, ['merge', '--abort']);
326
+ throw new GitWorkspaceError('git_command_failed', merged.stderr.trim() || 'git_merge_failed');
327
+ }
328
+ }
329
+ await git(rootReal, ['branch', `--set-upstream-to=origin/${branch}`, branch]);
330
+ }
285
331
  async function unstage(rootReal, paths) {
286
332
  if (paths.length === 0)
287
333
  return;
@@ -1,5 +1,5 @@
1
1
  import { execFile } from 'node:child_process';
2
- import { lstat, mkdir, realpath } from 'node:fs/promises';
2
+ import { lstat, mkdir, realpath, rm, writeFile } from 'node:fs/promises';
3
3
  import { join, relative } from 'node:path';
4
4
  import { promisify } from 'node:util';
5
5
  import { isLocalWorkspaceGitAuthMode, sameWorkspaceRegistration, workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
@@ -9,6 +9,7 @@ import { scheduleDependencyInstall } from './dependencies.js';
9
9
  import { ensureWorkspaceSkills, readWorkspaceSkills } from './skills.js';
10
10
  import { loadWorkspaceSkillArtifact, workspacePlatformSkillAccess } from './skill-artifact-loader.js';
11
11
  import { writeWorkspaceCliContext } from './cli-context.js';
12
+ import { attachWorkspaceRemote } from './git.js';
12
13
  export { WorkspaceContextError } from './context-error.js';
13
14
  const exec = promisify(execFile);
14
15
  export function revisionScope(state, workspaceId, directoryName = 'basic') {
@@ -106,8 +107,11 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
106
107
  const existing = state.journal.getWorkspace(binding.workspaceId);
107
108
  const preservedLocalBasic = binding.targetKind !== 'standalone' && binding.preserveLocalBasic === true;
108
109
  const localUpgrade = preservedLocalBasic && binding.targetKind === 'main' && existing?.targetKind === 'standalone';
109
- if (existing && !sameWorkspaceRegistration(existing, binding) && !localUpgrade)
110
+ // A standalone workspace promoted to a project's main keeps its checkout and gains that remote.
111
+ const remoteUpgrade = !preservedLocalBasic && binding.targetKind === 'main' && existing?.targetKind === 'standalone';
112
+ if (existing && !sameWorkspaceRegistration(existing, binding) && !localUpgrade && !remoteUpgrade)
110
113
  throw new WorkspaceContextError('workspace_binding_conflict');
114
+ const attachMarker = join(state.config.stateDir, 'workspace-remote-attach', `${binding.workspaceId}.json`);
111
115
  const directories = binding.directories ?? existing?.directories;
112
116
  if (directories?.some(directory => directory.directoryName === 'basic' && directory.status === 'reclaimed')) {
113
117
  // Registration publishes trusted identity. Only an explicit basic ensure restores the checkout.
@@ -142,6 +146,28 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
142
146
  GIT_CONFIG_GLOBAL: '/dev/null',
143
147
  },
144
148
  })).stdout.trim();
149
+ // Unfinished if it failed part way: the marker outlives the journal entry written below.
150
+ if (binding.targetKind === 'main' && !preservedLocalBasic && (remoteUpgrade || await lstat(attachMarker).then(() => true, () => false))) {
151
+ if (preparation?.mode !== 'clone' || !state.gitCredentials)
152
+ throw new WorkspaceContextError('workspace_git_gateway_unavailable');
153
+ await mkdir(join(state.config.stateDir, 'workspace-remote-attach'), { recursive: true, mode: 0o700 });
154
+ await writeFile(attachMarker, JSON.stringify({ repositoryUrl: binding.repositoryUrl, branchName: binding.branchName }), { mode: 0o600 });
155
+ // The credential helper only serves a workspace the journal knows to be bound.
156
+ await state.journal.putWorkspace({ ...binding, directories });
157
+ try {
158
+ await state.gitCredentials.configure(binding, preparation);
159
+ }
160
+ catch {
161
+ throw new WorkspaceContextError('workspace_git_gateway_unavailable');
162
+ }
163
+ try {
164
+ await attachWorkspaceRemote(root, binding.repositoryUrl, binding.branchName);
165
+ }
166
+ catch {
167
+ throw new WorkspaceContextError('workspace_remote_attach_failed');
168
+ }
169
+ await rm(attachMarker, { force: true });
170
+ }
145
171
  try {
146
172
  let checkoutMatches;
147
173
  if (binding.targetKind === 'standalone' || preservedLocalBasic) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -50,8 +50,8 @@
50
50
  "@gltf-transform/core": "^4.3.0",
51
51
  "@gltf-transform/extensions": "^4.3.0",
52
52
  "@gltf-transform/functions": "^4.3.0",
53
- "@playcraft/build": "^0.1.25",
54
- "@playcraft/common": "^0.1.25",
53
+ "@playcraft/build": "^0.1.27",
54
+ "@playcraft/common": "^0.1.27",
55
55
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
56
56
  "chokidar": "^4.0.3",
57
57
  "commander": "^13.1.0",