castle-web-cli 0.4.172 → 0.4.174

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 (67) hide show
  1. package/dist/agent-prompts.d.ts +8 -2
  2. package/dist/agent-prompts.js +19 -11
  3. package/dist/agent.js +37 -13
  4. package/dist/assetCompression.d.ts +3 -0
  5. package/dist/assetCompression.js +30 -0
  6. package/dist/castleJson.d.ts +1 -1
  7. package/dist/castleJson.js +2 -2
  8. package/dist/imports.js +2 -2
  9. package/dist/init.js +4 -4
  10. package/dist/native/loop.js +9 -0
  11. package/dist/native/openrouter.d.ts +2 -0
  12. package/dist/native/openrouter.js +10 -1
  13. package/dist/native/types.d.ts +1 -0
  14. package/dist/serve.js +2 -0
  15. package/dist/serveSecurity.d.ts +2 -0
  16. package/dist/serveSecurity.js +17 -3
  17. package/dist/shell/assets/{index-Ws0WrCbi.js → index-DF1yMXPS.js} +4 -4
  18. package/dist/shell/index.html +1 -1
  19. package/kits/base/castle.json +1 -1
  20. package/kits/multiplayer-2d/castle.json +2 -2
  21. package/kits/multiplayer-2d/code/server/sceneBodies.js +1 -1
  22. package/kits/multiplayer-2d/code/server/world.js +2 -2
  23. package/kits/multiplayer-2d/code/systems/multiplayer.js +12 -0
  24. package/kits/multiplayer-2d/fonts.generated.js +1 -1
  25. package/kits/multiplayer-2d/package-lock.json +1 -1
  26. package/kits/multiplayer-3d/CLAUDE.md +5 -4
  27. package/kits/multiplayer-3d/castle.json +3 -3
  28. package/kits/multiplayer-3d/code/client/avatars.js +47 -18
  29. package/kits/multiplayer-3d/code/client/nameTags.js +16 -35
  30. package/kits/multiplayer-3d/code/server/sceneBodies.js +1 -1
  31. package/kits/multiplayer-3d/code/systems/multiplayer.js +14 -1
  32. package/kits/multiplayer-3d/fonts.generated.js +1 -1
  33. package/kits/multiplayer-3d/package-lock.json +1 -1
  34. package/kits/physics-2d/CLAUDE.md +7 -6
  35. package/kits/physics-2d/behaviors/Collider.jsx +7 -1
  36. package/kits/physics-2d/behaviors/RigidBody.jsx +6 -3
  37. package/kits/physics-2d/castle.json +2 -2
  38. package/kits/physics-2d/editors/SceneEditor.jsx +1 -0
  39. package/kits/physics-2d/editors/SingleEditor.jsx +4 -4
  40. package/kits/physics-2d/editors/{StyleEditor.jsx → ThemeEditor.jsx} +8 -8
  41. package/kits/physics-2d/editors/deckFont.js +2 -2
  42. package/kits/physics-2d/editors/{styleEditor.module.css → themeEditor.module.css} +2 -2
  43. package/kits/physics-2d/engine/avatarArt.js +108 -14
  44. package/kits/physics-2d/engine/files.js +2 -2
  45. package/kits/physics-2d/engine/physics/matterBridge.js +2 -2
  46. package/kits/physics-2d/engine/scene.js +1 -1
  47. package/kits/physics-2d/fonts.generated.js +1 -1
  48. package/kits/physics-2d/package-lock.json +1 -1
  49. package/kits/physics-3d/behaviors/Body.jsx +10 -1
  50. package/kits/physics-3d/behaviors/Door.jsx +5 -0
  51. package/kits/physics-3d/behaviors/Lookable.jsx +3 -0
  52. package/kits/physics-3d/behaviors/Model.jsx +4 -0
  53. package/kits/physics-3d/behaviors/Pickup.jsx +4 -0
  54. package/kits/physics-3d/behaviors/Player.jsx +7 -0
  55. package/kits/physics-3d/behaviors/Shape.jsx +13 -0
  56. package/kits/physics-3d/castle.json +1 -1
  57. package/kits/physics-3d/engine3d/editor3dInspector.jsx +1 -0
  58. package/kits/physics-3d/fonts.generated.js +1 -1
  59. package/kits/physics-3d/package-lock.json +1 -1
  60. package/kits/real-time/castle.json +1 -1
  61. package/kits/real-time/code/client/connection.js +11 -2
  62. package/kits/real-time/package-lock.json +1 -1
  63. package/kits/turn-based/castle.json +1 -1
  64. package/kits/turn-based/fonts.generated.js +1 -1
  65. package/kits/turn-based/package-lock.json +1 -1
  66. package/package.json +3 -1
  67. /package/kits/physics-2d/editors/{styleTheme.js → theme.js} +0 -0
@@ -75,7 +75,11 @@ export declare function renderPlanDigest(plan: RouterPlanOpts, tasks: PromptTask
75
75
  export declare function planOpenQuestionLines(fileText: string): string[];
76
76
  export declare function renderTaskPlanSlice(fileText: string, item: string | undefined, finishedWork?: PlanFinishedWork[]): string;
77
77
  export declare const NO_DECK_DOCS = "This deck has no CLAUDE.md / AGENTS.md quick reference. There is no documentation to read -- do not search for one (CLAUDE.md, README, **/*.md, etc.).";
78
- export declare function buildRouterPrompt(opts: {
78
+ export interface RouterPromptParts {
79
+ system: string;
80
+ user: string;
81
+ }
82
+ export interface RouterPromptOpts {
79
83
  deckLabel: string;
80
84
  quickReference?: string;
81
85
  deckTree?: string;
@@ -85,7 +89,9 @@ export declare function buildRouterPrompt(opts: {
85
89
  plan?: RouterPlanOpts;
86
90
  playtest?: boolean;
87
91
  instruction: string;
88
- }): string;
92
+ }
93
+ export declare function buildRouterPromptParts(opts: RouterPromptOpts): RouterPromptParts;
94
+ export declare function buildRouterPrompt(opts: RouterPromptOpts): string;
89
95
  export declare function userTurnInstruction(opts: {
90
96
  messages: string[];
91
97
  interruptedDraft?: string;
@@ -820,7 +820,7 @@ function renderTasks(tasks, showItems) {
820
820
  // not included" and burns its first turns hunting for CLAUDE.md. Exported so
821
821
  // the platform block (platformDoc.ts) can keep the sentence after itself.
822
822
  export const NO_DECK_DOCS = "This deck has no CLAUDE.md / AGENTS.md quick reference. There is no documentation to read -- do not search for one (CLAUDE.md, README, **/*.md, etc.).";
823
- export function buildRouterPrompt(opts) {
823
+ export function buildRouterPromptParts(opts) {
824
824
  // Making the ABSENCE explicit, not just omitting the section, matters: a
825
825
  // router on a bare/greenfield deck otherwise has no way to tell "no docs
826
826
  // exist" from "the docs section just wasn't included in this prompt", and
@@ -842,17 +842,19 @@ export function buildRouterPrompt(opts) {
842
842
  const plan = opts.plan
843
843
  ? `\n\n== plan ==\n${renderPlanDigest(opts.plan, opts.tasks)}`
844
844
  : "";
845
- // Section order is stable-to-volatile for prompt caching: rules + deck
846
- // identity never change within a serve, the transcript is append-only, and
847
- // the churning state (file tree, task board, instruction) rides at the tail
848
- // so a task creating a file doesn't invalidate the cached transcript prefix.
849
- return `${routerRules(opts.plan !== undefined, opts.playtest === true)}
845
+ // Two parts, split for prompt caching. `system` holds what is the same from
846
+ // one turn to the next within a serve -- the rules, the deck identity, the
847
+ // file tree and (smith) the deck source -- and each backend sends it as
848
+ // system text, which is the prefix the provider caches turn after turn. A
849
+ // file edit invalidates it; a chat turn does not. `user` holds what changes
850
+ // every turn: the transcript, the plan, the board and this instruction.
851
+ return {
852
+ system: `${routerRules(opts.plan !== undefined, opts.playtest === true)}
850
853
 
851
854
  == deck ==
852
- ${opts.deckLabel}${quickReference}
853
-
854
- == conversation so far ==
855
- ${renderTranscript(opts.messages)}${deckFiles}${deckSource}${plan}
855
+ ${opts.deckLabel}${quickReference}${deckFiles}${deckSource}`,
856
+ user: `== conversation so far ==
857
+ ${renderTranscript(opts.messages)}${plan}
856
858
 
857
859
  == background tasks ==
858
860
  ${renderTasks(opts.tasks, opts.plan !== undefined)}
@@ -860,7 +862,13 @@ ${renderTasks(opts.tasks, opts.plan !== undefined)}
860
862
  == now ==
861
863
  ${opts.instruction}
862
864
 
863
- Reply now, as "you" in the conversation. Plain reply text only -- no role prefix.`;
865
+ Reply now, as "you" in the conversation. Plain reply text only -- no role prefix.`,
866
+ };
867
+ }
868
+ // The router prompt as one string, system part first. What the goldens pin.
869
+ export function buildRouterPrompt(opts) {
870
+ const parts = buildRouterPromptParts(opts);
871
+ return `${parts.system}\n\n${parts.user}`;
864
872
  }
865
873
  export function userTurnInstruction(opts) {
866
874
  const parts = [];
package/dist/agent.js CHANGED
@@ -27,7 +27,7 @@ import { WebSocketServer } from 'ws';
27
27
  import { rawDataToString } from './rawData.js';
28
28
  import { atomicWriteFileSync } from './atomicFile.js';
29
29
  import { AGENT_ATTACHMENT_PREFIX, AGENT_PLAYTEST_PREFIX, PLAN_FILE } from './localPaths.js';
30
- import { applyPlanOps, buildRouterPrompt, buildTaskPrompt, parsePlanOps, planOpenQuestionLines, truncateToBytes, userTurnInstruction, CLAUDE_TASK_SYSTEM_REMINDER, } from './agent-prompts.js';
30
+ import { applyPlanOps, buildRouterPromptParts, buildTaskPrompt, parsePlanOps, planOpenQuestionLines, truncateToBytes, userTurnInstruction, CLAUDE_TASK_SYSTEM_REMINDER, } from './agent-prompts.js';
31
31
  import { readCastleJson } from './castleJson.js';
32
32
  import { checkOpenrouterKey, checkOpenrouterModel, openrouterCatalogEntry, primeOpenrouterCatalog, } from './openrouter-catalog.js';
33
33
  import { classifyProviderError, failureCopy, setReaderTimeZone, } from './agent-failures.js';
@@ -501,6 +501,17 @@ function claudeSettingsArg(auth) {
501
501
  ...(auth?.mode === 'user-key' ? { apiKeyHelper: anthropicKeyHelperCommand() } : {}),
502
502
  });
503
503
  }
504
+ // The claude CLI puts every CLAUDE.md it finds, and a git-status note, into the
505
+ // first user message of each process, ahead of the prompt. A router turn is a
506
+ // new process, so that text (the kit guide: ~23k tokens on a kit deck) is sent
507
+ // uncached on every call of every turn. The router already carries the deck's
508
+ // quick reference and file tree in its cached system part, and can read a
509
+ // guide on demand; tasks keep the CLI's own loading. Measured on a 30k-token
510
+ // router prompt: a second turn goes from 40k tokens written to 4.6k.
511
+ const ROUTER_CLAUDE_ENV = {
512
+ CLAUDE_CODE_DISABLE_CLAUDE_MDS: '1',
513
+ CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS: '1',
514
+ };
504
515
  function buildAgentInvocation(backend, role, prompt, claudeModel,
505
516
  // Already resolved for this role by the caller (router turns pass
506
517
  // settings.routerOpenrouterModel, task spawns settings.tasksOpenrouterModel).
@@ -512,7 +523,14 @@ metering,
512
523
  // The task this spawn belongs to, so its playtest frames land under
513
524
  // tasks/<id>/playtest/ and count against that task's call budget. Absent for
514
525
  // router turns, which get no playtest tool at all.
515
- taskId) {
526
+ taskId,
527
+ // The stable part of a router prompt. claude takes it as appended system
528
+ // text, which its own cache breakpoints cover; cursor has no such flag and
529
+ // gets it ahead of the prompt.
530
+ systemPrompt) {
531
+ const systemText = [systemPrompt, role === 'task' ? CLAUDE_TASK_SYSTEM_REMINDER : undefined]
532
+ .filter((text) => Boolean(text))
533
+ .join('\n\n');
516
534
  if (backend === 'claude') {
517
535
  const viaOpenrouter = claudeModel === 'openrouter';
518
536
  const orAuth = viaOpenrouter ? resolveOpenrouterAuth() : null;
@@ -547,15 +565,18 @@ taskId) {
547
565
  claudeSettingsArg(anAuth),
548
566
  '--strict-mcp-config',
549
567
  ...(role === 'task' ? [mcpConfigArg(metering.deckDir, taskId)] : []),
550
- ...(role === 'task' ? ['--append-system-prompt', CLAUDE_TASK_SYSTEM_REMINDER] : []),
568
+ ...(systemText ? ['--append-system-prompt', systemText] : []),
551
569
  prompt,
552
570
  ],
553
- env: withCustomHeaders(viaOpenrouter ? envForOpenrouterSpawn(orAuth) : envForClaudeSpawn(anAuth), meteringHeaders({
554
- deckDir: metering.deckDir,
555
- sessionId: metering.sessionId,
556
- route: viaOpenrouter ? 'openrouter' : 'anthropic',
557
- direct,
558
- })),
571
+ env: {
572
+ ...withCustomHeaders(viaOpenrouter ? envForOpenrouterSpawn(orAuth) : envForClaudeSpawn(anAuth), meteringHeaders({
573
+ deckDir: metering.deckDir,
574
+ sessionId: metering.sessionId,
575
+ route: viaOpenrouter ? 'openrouter' : 'anthropic',
576
+ direct,
577
+ })),
578
+ ...(role === 'router' ? ROUTER_CLAUDE_ENV : {}),
579
+ },
559
580
  };
560
581
  }
561
582
  return {
@@ -569,7 +590,7 @@ taskId) {
569
590
  '--model',
570
591
  CURSOR_MODEL,
571
592
  ...(role === 'router' ? ['--mode', 'ask'] : ['--force']),
572
- prompt,
593
+ systemPrompt ? `${systemPrompt}\n\n${prompt}` : prompt,
573
594
  ],
574
595
  env: envForAgentSpawn(backend),
575
596
  };
@@ -1852,6 +1873,7 @@ async function runAgentSmith(opts) {
1852
1873
  // "" (auto) becomes undefined so no provider.order is sent.
1853
1874
  providerTier: opts.openrouterTuning?.providerTier || undefined,
1854
1875
  prompt: opts.prompt,
1876
+ system: opts.system,
1855
1877
  systemReminder: opts.systemReminder,
1856
1878
  attachments: opts.attachments,
1857
1879
  timeoutMs: opts.timeoutMs,
@@ -2169,6 +2191,7 @@ async function runAgentTurn(opts) {
2169
2191
  }),
2170
2192
  model: opts.openrouterModel,
2171
2193
  prompt: opts.prompt,
2194
+ system: opts.systemPrompt,
2172
2195
  // Mirrors claude's --append-system-prompt for tasks (the native loop
2173
2196
  // appends it to its own system framing).
2174
2197
  systemReminder: opts.role === 'task' ? CLAUDE_TASK_SYSTEM_REMINDER : undefined,
@@ -2185,7 +2208,7 @@ async function runAgentTurn(opts) {
2185
2208
  onSpawn: opts.onSpawn,
2186
2209
  }));
2187
2210
  }
2188
- const invocation = buildAgentInvocation(opts.backend, opts.role, opts.prompt, opts.claudeModel, opts.openrouterModel, { sessionId, deckDir: opts.cwd }, opts.taskId);
2211
+ const invocation = buildAgentInvocation(opts.backend, opts.role, opts.prompt, opts.claudeModel, opts.openrouterModel, { sessionId, deckDir: opts.cwd }, opts.taskId, opts.systemPrompt);
2189
2212
  const startedMs = Date.now();
2190
2213
  const run = runAgentCli({
2191
2214
  cwd: opts.cwd,
@@ -3273,7 +3296,7 @@ function routerTurnPrompt(ctx, instruction, selfMessageId, plan) {
3273
3296
  // ROUTER_DECK_CONTENTS_BUDGET's comment (the router prompt is already the
3274
3297
  // largest one this serve builds).
3275
3298
  const isSmith = ctx.backend() === 'smith';
3276
- return buildRouterPrompt({
3299
+ return buildRouterPromptParts({
3277
3300
  deckLabel: ctx.deckLabel,
3278
3301
  quickReference: quickReferenceFor(ctx.deckDir, ctx.quickReference, ctx.backend()),
3279
3302
  deckTree: buildDeckTree(ctx.deckDir, isSmith
@@ -3409,7 +3432,8 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
3409
3432
  void runAgentTurn({
3410
3433
  backend,
3411
3434
  role: 'router',
3412
- prompt,
3435
+ prompt: prompt.user,
3436
+ systemPrompt: prompt.system,
3413
3437
  claudeModel: ctx.claudeModel(),
3414
3438
  openrouterModel: ctx.openrouterModel(),
3415
3439
  openrouterTuning: ctx.openrouterTuning(),
@@ -0,0 +1,3 @@
1
+ import type { Connect } from 'vite';
2
+ /** Compress assets, never the token-bearing shell bootstrap or control API. */
3
+ export declare function createAssetCompression(): Connect.NextHandleFunction;
@@ -0,0 +1,30 @@
1
+ import compression from 'compression';
2
+ /** Compress assets, never the token-bearing shell bootstrap or control API. */
3
+ export function createAssetCompression() {
4
+ // compression supports plain Node HTTP/Connect; its DefinitelyTyped signature
5
+ // unnecessarily requires Express request/response extensions.
6
+ return compression({
7
+ threshold: 1024,
8
+ filter(req, res) {
9
+ const requestPath = (req.url || '/').split('?')[0];
10
+ if ((req.method !== 'GET' && req.method !== 'HEAD') ||
11
+ requestPath === '/' ||
12
+ (requestPath.startsWith('/__castle/') &&
13
+ !requestPath.startsWith('/__castle/ide/assets/')) ||
14
+ req.headers.range ||
15
+ res.hasHeader('Content-Range')) {
16
+ return false;
17
+ }
18
+ // Conditional responses must retain the representation's Vary header,
19
+ // but must never start a compressor or acquire a body.
20
+ if (res.statusCode === 304) {
21
+ if (res.getHeader('Vary') !== '*')
22
+ res.appendHeader('Vary', 'Accept-Encoding');
23
+ return false;
24
+ }
25
+ return (res.statusCode === 200 &&
26
+ !String(res.getHeader('Content-Type')).startsWith('text/event-stream') &&
27
+ compression.filter(req, res));
28
+ },
29
+ });
30
+ }
@@ -1,4 +1,4 @@
1
- export declare const STARTER_STYLE_FILES: string[];
1
+ export declare const STARTER_THEME_FILES: string[];
2
2
  export declare const IMPORTS_DIR = "imports";
3
3
  export interface DeckImport {
4
4
  deckId?: string;
@@ -5,7 +5,7 @@ import picomatch from 'picomatch';
5
5
  // module with no imports of its own, because both the import machinery and the
6
6
  // fork overlay need it and importing it from either of those would close a
7
7
  // cycle.
8
- // Root-level style files a kit ships as a deck's starting point, seeded into a
8
+ // Root-level theme files a kit ships as a deck's starting point, seeded into a
9
9
  // deck at scaffold (init.ts `writeStarterTheme`) and when an import newly brings
10
10
  // them (imports.ts `seedKitTheme`).
11
11
  //
@@ -18,7 +18,7 @@ import picomatch from 'picomatch';
18
18
  // `theme.style` names the deck's font, `fonts.generated.js` is what carries that
19
19
  // face's bytes into the bundle, and a deck given the first without the second
20
20
  // renders in the fallback while claiming otherwise.
21
- export const STARTER_STYLE_FILES = ['theme.style', 'fonts.generated.js'];
21
+ export const STARTER_THEME_FILES = ['theme.style', 'fonts.generated.js'];
22
22
  export const IMPORTS_DIR = 'imports';
23
23
  export const DEFAULT_STARTER_SCENE = 'scenes/main.scene';
24
24
  export const DEFAULT_SERVER_TICK_RATE = 20;
package/dist/imports.js CHANGED
@@ -6,7 +6,7 @@ import { runTar } from './save-deck.js';
6
6
  import { normalizeDeckPackageJson } from './normalize.js';
7
7
  import { getKitsDir } from './localPaths.js';
8
8
  import { writePlatformDocs } from './platformDoc.js';
9
- import { ensureVisibleGlob, IMPORTS_DIR, STARTER_STYLE_FILES, readCastleJson as tryReadCastleJson, readCastleJsonOrThrow as readCastleJson, } from './castleJson.js';
9
+ import { ensureVisibleGlob, IMPORTS_DIR, STARTER_THEME_FILES, readCastleJson as tryReadCastleJson, readCastleJsonOrThrow as readCastleJson, } from './castleJson.js';
10
10
  // Adding another deck as a dependency (`castle-web add-import`; removing one is a
11
11
  // later command). Deliberately NOT `get-deck`: that one
12
12
  // replaces THIS deck's own source from the server (and carries guards for the
@@ -614,7 +614,7 @@ function seedKitTheme(deckDir, alias, log) {
614
614
  if (typeof main !== 'string' || !main.trim())
615
615
  return;
616
616
  const seeded = [];
617
- for (const name of STARTER_STYLE_FILES) {
617
+ for (const name of STARTER_THEME_FILES) {
618
618
  const src = path.join(importDir, name);
619
619
  const dest = path.join(deckDir, name);
620
620
  if (!fs.existsSync(src) || fs.existsSync(dest))
package/dist/init.js CHANGED
@@ -2,7 +2,7 @@ import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { CLAUDE_MD_PLATFORM_SECTION } from './platformDoc.js';
4
4
  import { installDeps } from './install.js';
5
- import { deckMainFile, deckStarterScene, STARTER_STYLE_FILES } from './castleJson.js';
5
+ import { deckMainFile, deckStarterScene, STARTER_THEME_FILES } from './castleJson.js';
6
6
  import { addImportTo, IMPORTS_DIR, lockImportTree, restoreMissingImports, syncImportDependencies, } from './imports.js';
7
7
  import { discardParentSource, fetchParentSource, unpackParentSource, writeRemixIdentity, } from './remix.js';
8
8
  import { getCliEntryPath, getKitsDir, getSdkPackagePath, toPosixPath } from './localPaths.js';
@@ -309,7 +309,7 @@ export function writeStarterScene(projectDir, kitDir, alias) {
309
309
  fs.mkdirSync(path.join(projectDir, 'scenes'), { recursive: true });
310
310
  writeJsonFile(path.join(projectDir, 'scenes', 'main.scene'), rewrite(scene));
311
311
  }
312
- // A kit may ship starter style files (deck-level styling: the color palette,
312
+ // A kit may ship starter theme files (deck-level theme: the color palette,
313
313
  // the deck font). Like the starter scene, they are deck content: the engine
314
314
  // reads them from the DECK root, and they are the creator's to edit -- so they
315
315
  // get seeded once at scaffold time rather than living read-only in the import.
@@ -317,11 +317,11 @@ export function writeStarterScene(projectDir, kitDir, alias) {
317
317
  // kit file paths, so no ref rewriting). Exported for the same two scaffold paths
318
318
  // as writeStarterScene.
319
319
  //
320
- // The set is STARTER_STYLE_FILES (castleJson.ts), which says why these two
320
+ // The set is STARTER_THEME_FILES (castleJson.ts), which says why these two
321
321
  // travel together: a deck given the record without the generated module renders
322
322
  // in the fallback while claiming otherwise.
323
323
  export function writeStarterTheme(projectDir, kitDir) {
324
- for (const name of STARTER_STYLE_FILES) {
324
+ for (const name of STARTER_THEME_FILES) {
325
325
  const src = path.join(kitDir, name);
326
326
  const dest = path.join(projectDir, name);
327
327
  if (!fs.existsSync(src) || fs.existsSync(dest))
@@ -319,6 +319,8 @@ const ROLE_FRAMING = {
319
319
  };
320
320
  function buildSystemMessage(opts) {
321
321
  const parts = [ROLE_FRAMING[opts.role]];
322
+ if (opts.system)
323
+ parts.push(opts.system);
322
324
  if (opts.systemReminder)
323
325
  parts.push(opts.systemReminder);
324
326
  return parts.join("\n\n");
@@ -700,10 +702,17 @@ async function runLoop(opts, toolSchemas, log) {
700
702
  };
701
703
  }
702
704
  usedAnyTool = true;
705
+ // The reasoning goes back with the turn it belongs to. Anthropic treats an
706
+ // assistant turn returned without its thinking as a changed prefix and
707
+ // recomputes every message after it, so without this no call in the loop
708
+ // reads the cache (measured: 0 cached tokens per call, 30k written each).
703
709
  messages.push({
704
710
  role: "assistant",
705
711
  content: streamResult.message.content || null,
706
712
  tool_calls: toolCalls,
713
+ ...(streamResult.message.reasoning_details
714
+ ? { reasoning_details: streamResult.message.reasoning_details }
715
+ : {}),
707
716
  });
708
717
  const toolResults = await runToolCalls(toolCalls, opts.role, toolCtx, playtestFrames, toolLabels, imageLabels, log, opts.onActivity);
709
718
  messages.push(...toolResults);
@@ -25,11 +25,13 @@ export interface ORMessage {
25
25
  content?: string | null | ORContentPart[];
26
26
  tool_calls?: ORToolCall[];
27
27
  tool_call_id?: string;
28
+ reasoning_details?: unknown[];
28
29
  }
29
30
  export interface ORAssistantMessage {
30
31
  role: "assistant";
31
32
  content: string;
32
33
  tool_calls?: ORToolCall[];
34
+ reasoning_details?: unknown[];
33
35
  }
34
36
  export type ORReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
35
37
  export type ORRoutingMode = "balanced" | "nitro" | "exacto" | "floor";
@@ -344,6 +344,7 @@ export async function streamChatCompletion(opts) {
344
344
  return { message: null, crashed: false, error: "could not run openrouter: empty response body" };
345
345
  }
346
346
  let content = "";
347
+ const reasoningDetails = [];
347
348
  // Observed live (2026-07): some providers' MID-stream usage passthrough is
348
349
  // junk (constant cache_read=128 / cache_created=0 on every event) while the
349
350
  // FINAL usage chunk -- the one OpenRouter emits at/after finish_reason --
@@ -398,6 +399,9 @@ export async function streamChatCompletion(opts) {
398
399
  if (typeof delta.reasoning === "string" && delta.reasoning) {
399
400
  opts.onThinking?.(delta.reasoning);
400
401
  }
402
+ if (Array.isArray(delta.reasoning_details)) {
403
+ reasoningDetails.push(...delta.reasoning_details);
404
+ }
401
405
  const toolCallDeltas = delta.tool_calls;
402
406
  if (Array.isArray(toolCallDeltas)) {
403
407
  for (const tc of toolCallDeltas) {
@@ -461,7 +465,12 @@ export async function streamChatCompletion(opts) {
461
465
  };
462
466
  }
463
467
  return {
464
- message: { role: "assistant", content, tool_calls: finalizeToolCalls(pendingToolCalls) },
468
+ message: {
469
+ role: "assistant",
470
+ content,
471
+ tool_calls: finalizeToolCalls(pendingToolCalls),
472
+ ...(reasoningDetails.length ? { reasoning_details: reasoningDetails } : {}),
473
+ },
465
474
  usage: finalUsage ?? usage,
466
475
  reasoningTokens: finalReasoningTokens ?? reasoningTokens,
467
476
  crashed: false,
@@ -23,6 +23,7 @@ export interface NativeRunOpts {
23
23
  routing?: ORRoutingMode;
24
24
  providerTier?: string;
25
25
  prompt: string;
26
+ system?: string;
26
27
  systemReminder?: string;
27
28
  attachments?: string[];
28
29
  playtest?: NativePlaytestOpts;
package/dist/serve.js CHANGED
@@ -19,6 +19,7 @@ import { graphql as castleGraphql } from './api.js';
19
19
  import { executeCommand } from './castle-host/host.js';
20
20
  import { ServeSecurity } from './serveSecurity.js';
21
21
  import { isSameOriginUpgrade } from './wsOrigin.js';
22
+ import { createAssetCompression } from './assetCompression.js';
22
23
  import { resolveServerConfig } from './castleJson.js';
23
24
  import { startDevSessionServer, } from './devSessionServer.js';
24
25
  // The log is a tail, not an archive: append, and once it passes the cap drop
@@ -315,6 +316,7 @@ window.__castleShellOrigin=(function(){
315
316
  },
316
317
  },
317
318
  configureServer(server) {
319
+ server.middlewares.use(createAssetCompression());
318
320
  server.middlewares.use((req, res, next) => {
319
321
  const reqPath = (req.url || '/').split('?')[0];
320
322
  const trustedSurface = req.headers['x-castle-sandbox-surface'];
@@ -18,6 +18,7 @@ export declare class ServeSecurity {
18
18
  private readonly contentProxyToken;
19
19
  private readonly signingKey;
20
20
  private readonly serveNonce;
21
+ private contentGrantExpiresAtSeconds;
21
22
  private readonly pathKey;
22
23
  private readonly tickets;
23
24
  private localContentPorts;
@@ -50,6 +51,7 @@ export declare class ServeSecurity {
50
51
  editOrigin: string;
51
52
  playOrigin: string;
52
53
  };
54
+ private contentGrantExpiry;
53
55
  /**
54
56
  * Origin a browser should use for a local content role.
55
57
  *
@@ -6,6 +6,7 @@ const CROCKFORD = '0123456789abcdefghjkmnpqrstvwxyz';
6
6
  const CONTENT_LABEL_RE = /^1([ep])([0-9a-z]{1,4})-([a-z0-9]{14})-([0-9a-z]{1,8})-([0-9a-hjkmnp-tv-z]{13})-([0-9a-hjkmnp-tv-z]{20})$/;
7
7
  const MAX_GRANT_SECONDS = 12 * 60 * 60;
8
8
  const GRANT_CLOCK_SKEW_SECONDS = 60;
9
+ const MIN_CONTENT_GRANT_REMAINING_SECONDS = 11 * 60 * 60;
9
10
  const BROWSER_GRANT_SECONDS = 3 * 60;
10
11
  export const MOBILE_HOST_PROTOCOL = '2';
11
12
  // A signature covers the request path and nothing else, so every path it can
@@ -79,6 +80,7 @@ export class ServeSecurity {
79
80
  contentProxyToken = randomBytes(32).toString('base64url');
80
81
  signingKey = process.env.CASTLE_AGENT_TOKEN || randomBytes(32).toString('base64url');
81
82
  serveNonce = base32Lower(randomBytes(8));
83
+ contentGrantExpiresAtSeconds = 0;
82
84
  pathKey = randomBytes(32).toString('base64url');
83
85
  tickets = new Map();
84
86
  localContentPorts;
@@ -284,9 +286,7 @@ window.fetch=function(input,init){
284
286
  : cloud
285
287
  ? 'sandboxes.castlexyz.com'
286
288
  : null;
287
- // Leave room for small positive clock skew at the control plane. Ghost still
288
- // enforces the hard 12-hour maximum when it resolves the capability.
289
- const expiresAtSeconds = Math.floor(Date.now() / 1000) + MAX_GRANT_SECONDS - GRANT_CLOCK_SKEW_SECONDS;
289
+ const expiresAtSeconds = cloud && contentDomain ? this.contentGrantExpiry() : 0;
290
290
  const originFor = (role) => {
291
291
  // Safari on macOS 15 asks DNS to resolve `*.localhost`, where it fails.
292
292
  // Dedicated loopback ports are still distinct browser origins and need no DNS.
@@ -297,6 +297,20 @@ window.fetch=function(input,init){
297
297
  };
298
298
  return { editOrigin: originFor('edit'), playOrigin: originFor('play') };
299
299
  }
300
+ contentGrantExpiry() {
301
+ const nowSeconds = Math.floor(Date.now() / 1000);
302
+ const latestExpiry = nowSeconds + MAX_GRANT_SECONDS - GRANT_CLOCK_SKEW_SECONDS;
303
+ // Expiry is part of the hostname: reuse it so reopening the shell can reuse
304
+ // the browser's module cache. Until open shells support automatic refresh,
305
+ // keep at least 11 hours for every new shell (about an hour of cache reuse).
306
+ // Already-open shells keep their old, still-valid grants until expiry.
307
+ // Also remint after a backwards clock jump rather than exceed Ghost's cap.
308
+ if (this.contentGrantExpiresAtSeconds - nowSeconds < MIN_CONTENT_GRANT_REMAINING_SECONDS ||
309
+ this.contentGrantExpiresAtSeconds > latestExpiry) {
310
+ this.contentGrantExpiresAtSeconds = latestExpiry;
311
+ }
312
+ return this.contentGrantExpiresAtSeconds;
313
+ }
300
314
  /**
301
315
  * Origin a browser should use for a local content role.
302
316
  *