castle-web-cli 0.4.173 → 0.4.175

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.
@@ -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;
@@ -35,6 +35,7 @@ Hard rules:
35
35
  - IMPORTS ARE YOURS, and they are the one exception to the rule above. Run \`castle-web list-decks --kind kits\` (also \`mine\` and \`saved\`) to see what this deck can import, and \`castle-web add-import <deckIdOrUrl>\` to add one -- both directly, not through a task. Do NOT claim you can list or import anything until you have actually run these; do not describe imports you have not looked at. \`list-decks\` prints one deck per line starting with the id \`add-import\` takes, and marks the ones this deck already has. A pasted castle.xyz deck link works in place of an id. Everything else about a deck's files still goes to task agents.
36
36
  - Imported files are READ-ONLY, which is what to say when the user asks whether they can change something a kit or another import gave them, or why an edit to one did not stick: the deck takes its own version of that one file -- a fork, stored beside the import and used in its place -- and that version is what runs. Forking is how their own data and code changes override an import's, and a task agent does it; the tradeoff, worth saying plainly, is that a file done this way stops picking up changes when the import itself is updated, so it stays to the few files the change actually needs. Often it does not come to a fork at all, which is the better thing to say first: a kit picks up the deck's own behaviors and systems, so new logic is usually just a new file in the deck.
37
37
  - You are the fast lane: get to your final reply as quickly as possible. When the user reports something broken, do NOT dig into the code to diagnose it first -- spawn a task whose job is to investigate AND fix it. Only read deck files when your reply itself needs them (answering a question about the deck, grounding a claim -- never make things up); never read as pre-work before spawning a task, and never read files just to learn conventions already covered by the Quick reference.${playtest ? `\n${PLAYTEST_ROUTER_RULE}` : ""}
38
+ - When you point the user at a file, a line, or a tunable value, write it as a link they can tap: \`[label](game.js)\` for a file, \`[label](game.js#L108)\` for a line, \`[label](game.js#params)\` for a file's tunable values and \`[label](game.js#params:enemySpeed)\` for ONE of them (the key exactly as it is written in that file's \`PARAMS\` object -- \`player.speed\` for a nested one). The label is your own words for the thing; the path must be exact and must be a file this deck actually has, or it renders as plain text. A bare \`game.js#108\` in a sentence links too. Cite a line number ONLY when you have read that file with numbered lines -- any deck contents in this prompt carry none, so never count them yourself; link the file or its params instead when you are unsure.
38
39
  - Launch a SET of small steps the user tests one by one -- a pipeline, never one big task they wait on, never untestable fragments. One interacting mechanic = one task (paddle + ball + bricks = one playable core, not three). First step = the smallest genuinely playable thing; later steps build it out. Match breadth to ambition ("basic" = a few steps; "go wild" = many). You're optimizing the user's taste and feedback -- more small testable steps = more points where they steer it into something theirs.
39
40
  - The whole goal: every piece of work TESTABLE in actual gameplay ASAP. Start every task as early as possible and run them in PARALLEL. Do NOT break tasks down by which files they touch, and never add \`after:\` just to avoid two tasks editing the same file -- tasks make surgical edits and overlap fine. The only real dependency between tasks is INFORMATION: a task is blocked only when it needs a fact it does not yet have.
40
41
  - Dissolve those information-dependencies with a CONTRACT decided up front, instead of serializing tasks. Before spawning, pin the shared names the tasks will agree on -- scene file names, behavior names, drawing file names, tags -- and give each parallel task the exact names it must CREATE or REFERENCE. Pinned names must be EXACT filenames, extension included -- \`drawings/ship.sprite\` in a kit deck (the kit's CLAUDE.md names the format), \`drawings/ship.svg\` in a bare one. A bare deck has no kit to define a default art format or loader, so its contract must also state the format and loading approach every consumer will use. Then they all run at once and converge to a working game with NO follow-up wiring task: the scene task places actors referencing behaviors and drawings by their agreed names before those files even exist; the behavior tasks create those behavior names; the drawing tasks create those drawing files. (Art: pin an exact drawing filename like \`drawings/ship.sprite\` up front -> the scene task points an actor at it while another task draws it, in parallel; no place-it-later task.) Fall back to a separate waiting wire-in task (\`after:\` the maker, on the least possible) ONLY when a task truly needs another's actual produced output, not just its agreed name. Working out this breakdown -- what to parallelize, which names to pin up front -- is the core of your job and differs per game.
@@ -820,7 +821,7 @@ function renderTasks(tasks, showItems) {
820
821
  // not included" and burns its first turns hunting for CLAUDE.md. Exported so
821
822
  // the platform block (platformDoc.ts) can keep the sentence after itself.
822
823
  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) {
824
+ export function buildRouterPromptParts(opts) {
824
825
  // Making the ABSENCE explicit, not just omitting the section, matters: a
825
826
  // router on a bare/greenfield deck otherwise has no way to tell "no docs
826
827
  // exist" from "the docs section just wasn't included in this prompt", and
@@ -842,17 +843,19 @@ export function buildRouterPrompt(opts) {
842
843
  const plan = opts.plan
843
844
  ? `\n\n== plan ==\n${renderPlanDigest(opts.plan, opts.tasks)}`
844
845
  : "";
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)}
846
+ // Two parts, split for prompt caching. `system` holds what is the same from
847
+ // one turn to the next within a serve -- the rules, the deck identity, the
848
+ // file tree and (smith) the deck source -- and each backend sends it as
849
+ // system text, which is the prefix the provider caches turn after turn. A
850
+ // file edit invalidates it; a chat turn does not. `user` holds what changes
851
+ // every turn: the transcript, the plan, the board and this instruction.
852
+ return {
853
+ system: `${routerRules(opts.plan !== undefined, opts.playtest === true)}
850
854
 
851
855
  == deck ==
852
- ${opts.deckLabel}${quickReference}
853
-
854
- == conversation so far ==
855
- ${renderTranscript(opts.messages)}${deckFiles}${deckSource}${plan}
856
+ ${opts.deckLabel}${quickReference}${deckFiles}${deckSource}`,
857
+ user: `== conversation so far ==
858
+ ${renderTranscript(opts.messages)}${plan}
856
859
 
857
860
  == background tasks ==
858
861
  ${renderTasks(opts.tasks, opts.plan !== undefined)}
@@ -860,7 +863,13 @@ ${renderTasks(opts.tasks, opts.plan !== undefined)}
860
863
  == now ==
861
864
  ${opts.instruction}
862
865
 
863
- Reply now, as "you" in the conversation. Plain reply text only -- no role prefix.`;
866
+ Reply now, as "you" in the conversation. Plain reply text only -- no role prefix.`,
867
+ };
868
+ }
869
+ // The router prompt as one string, system part first. What the goldens pin.
870
+ export function buildRouterPrompt(opts) {
871
+ const parts = buildRouterPromptParts(opts);
872
+ return `${parts.system}\n\n${parts.user}`;
864
873
  }
865
874
  export function userTurnInstruction(opts) {
866
875
  const parts = [];
@@ -984,7 +993,7 @@ phase: wiring the paddle to touch
984
993
 
985
994
  - \`avatar\` -- what you're doing RIGHT NOW, exactly one of: \`thinking\` (figuring out the plan), \`reading\` (looking through files), \`building\` (writing game logic), \`painting\` (making or editing art/visuals), \`playing\` (trying it in the game).
986
995
  - \`phase\` -- a SHORT, plain-language line for this moment ("wiring the paddle to touch", "drawing the flag"). No file or code names. Update \`avatar\` and \`phase\` together each time your stage changes -- a few times across the task, not every turn. (You may also include \`progress: NN\` here, but the progress file above is the primary progress channel.)
987
- - Before finishing, write ${opts.notesPath}: a tiny test guide for the PLAYER -- AT MOST 2-3 bullets (markdown \`- \` lines), each ONE short phrase: what to try and what should happen. NO code, NO API or "integration contract" detail, and no tour of how you built it -- that part is for you, not them. But DO name a file or a value where the user could act on it: a number they might want changed is worth naming, and worth naming the way they would find it ("enemy speed -- \`spawnRate\` in game.js"). Occasionally one bullet may run a little longer, but default to terse. Mention a blocker only if you hit one. The user reads this verbatim to check your work.${wrapUp}
996
+ - Before finishing, write ${opts.notesPath}: a tiny test guide for the PLAYER -- AT MOST 2-3 bullets (markdown \`- \` lines), each ONE short phrase: what to try and what should happen. NO code, NO API or "integration contract" detail, and no tour of how you built it -- that part is for you, not them. But DO name a file or a value where the user could act on it: a number they might want changed is worth naming, and worth naming the way they would find it, as a link they can tap: "enemy speed -- [spawnRate](game.js#params:spawnRate)" when it is an entry in that file's \`PARAMS\` object, "[the spawn loop](game.js#L42)" for a place in the code (a line number only from a numbered read of the file). Occasionally one bullet may run a little longer, but default to terse. Mention a blocker only if you hit one. The user reads this verbatim to check your work.${wrapUp}
988
997
  - If you are truly blocked, write the blocker to the notes file and stop rather than guessing wildly.
989
998
  - Never touch files under .castle/ other than ${opts.plan ? "those three paths" : "those two paths"}. (The version store lives there too, but you never edit it by hand -- \`castle-web save-version\` writes it for you.)`;
990
999
  }
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(),
package/dist/ide.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import * as http from 'http';
2
2
  import { Duplex } from 'stream';
3
3
  import { COVER_FILE } from './localPaths.js';
4
+ /** Cache only Vite's content-fingerprinted executable, style, and font output. */
5
+ export declare function shellAssetCacheControl(asset: string): string;
4
6
  export declare const IDE_ASSET_PREFIX = "/__castle/ide/";
5
7
  export declare const PTY_WS_PATH = "/__castle/pty";
6
8
  export declare const VENDOR_PREFIX = "/__castle/vendor/";
package/dist/ide.js CHANGED
@@ -51,6 +51,13 @@ const SHELL_MIME = {
51
51
  '.ttf': 'font/ttf',
52
52
  '.map': 'application/json; charset=utf-8',
53
53
  };
54
+ const IMMUTABLE_SHELL_ASSET = /^assets\/[^/\\]+-[A-Za-z0-9_-]{8}\.(?:css|js|woff2?|ttf)$/;
55
+ /** Cache only Vite's content-fingerprinted executable, style, and font output. */
56
+ export function shellAssetCacheControl(asset) {
57
+ return IMMUTABLE_SHELL_ASSET.test(asset)
58
+ ? 'public, max-age=31536000, immutable'
59
+ : 'no-store';
60
+ }
54
61
  // Does the deck serve this root-level file itself? Vite serves both the deck
55
62
  // root and its `public/` dir at `/`, so either location counts.
56
63
  function deckHasFile(deckDir, name) {
@@ -97,7 +104,10 @@ function serveShellFile(res, asset) {
97
104
  }
98
105
  res.writeHead(200, {
99
106
  'content-type': SHELL_MIME[path.extname(filePath)] ?? 'application/octet-stream',
100
- 'cache-control': 'no-store',
107
+ // These built assets are platform-global bytes. The shell HTML and all
108
+ // unversioned files stay no-store because only the Vite hash makes a URL
109
+ // safe to retain across CLI updates.
110
+ 'cache-control': shellAssetCacheControl(asset),
101
111
  });
102
112
  fs.createReadStream(filePath).pipe(res);
103
113
  return true;
@@ -366,6 +376,49 @@ function filterImportedFiles(deckDir, imported) {
366
376
  }
367
377
  return out;
368
378
  }
379
+ // The deck's files as the Files panel sees them. Each deck curates its own: the
380
+ // importing deck's visible/hidden paths name its own dirs (a kit's name scenes/,
381
+ // drawings/ ...), so applying them to imports would hide every import by
382
+ // omission -- an import is instead filtered by the config of the deck it came
383
+ // from, which is the deck that knows which of its files are worth showing.
384
+ function curatedDeckFiles(deckDir) {
385
+ const listed = listDeckFiles(deckDir);
386
+ const own = listed.filter((f) => !isImportPath(f));
387
+ return [
388
+ ...filterOwnFiles(own, readEditorConfig(deckDir)),
389
+ ...filterImportedFiles(deckDir, listed.filter(isImportPath)),
390
+ ].sort((a, b) => a.localeCompare(b));
391
+ }
392
+ // The source extensions a `PARAMS` object can be read out of -- the same ones
393
+ // the shell parses as JS/TS. Not a kit's vocabulary: `export const PARAMS` is a
394
+ // JavaScript declaration, not a file type somebody declared.
395
+ const PARAMS_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx']);
396
+ // Deliberately looser than the shell's parser, which also accepts a `const
397
+ // PARAMS` that is never exported. A prefilter that were TIGHTER than the parser
398
+ // would drop files the panel can actually edit; the shell re-parses each
399
+ // candidate and discards the ones that only look like a match.
400
+ const PARAMS_DECLARATION = /\bconst\s+PARAMS\b/;
401
+ // Which of the deck's files declare params. Scanning here rather than in the
402
+ // browser because a physics-2d deck carries a hundred-plus files under
403
+ // `imports/`, and reading each one over HTTP to open a panel is the wrong cost.
404
+ function filesDeclaringParams(deckDir) {
405
+ const out = [];
406
+ for (const rel of curatedDeckFiles(deckDir)) {
407
+ if (!PARAMS_EXTS.has(path.extname(rel).toLowerCase()))
408
+ continue;
409
+ const resolved = resolveDeckPath(deckDir, rel);
410
+ if (!resolved.ok)
411
+ continue;
412
+ try {
413
+ if (PARAMS_DECLARATION.test(fs.readFileSync(resolved.abs, 'utf8')))
414
+ out.push(rel);
415
+ }
416
+ catch {
417
+ /* raced a delete, or not readable -- it simply has no params to offer */
418
+ }
419
+ }
420
+ return out;
421
+ }
369
422
  // Read a raw (binary) request body, refusing anything over `limit`. Uploads are
370
423
  // arbitrary device files, so the cap is enforced as bytes arrive rather than
371
424
  // after buffering the whole thing.
@@ -859,21 +912,12 @@ function handleFilesApi(deckDir, req, res, reqPath, restart, invalidate) {
859
912
  // toggle) -- still minus the always-ignored dirs (node_modules/.castle/...),
860
913
  // just without the deck's visible/hidden path curation.
861
914
  const url = new URL(req.url ?? '/', 'http://localhost');
862
- const listed = listDeckFiles(deckDir);
863
- let files = listed;
864
- if (url.searchParams.get('all') !== '1') {
865
- // Each deck curates its own files. The importing deck's visible/hidden
866
- // paths name its own dirs (a kit's name scenes/, drawings/ ...), so
867
- // applying them to imports would hide every import by omission -- an
868
- // import is instead filtered by the config of the deck it came from, which
869
- // is the deck that knows which of its files are worth showing.
870
- const own = listed.filter((f) => !isImportPath(f));
871
- files = [
872
- ...filterOwnFiles(own, readEditorConfig(deckDir)),
873
- ...filterImportedFiles(deckDir, listed.filter(isImportPath)),
874
- ].sort((a, b) => a.localeCompare(b));
875
- }
876
- sendJson(res, 200, { files });
915
+ const all = url.searchParams.get('all') === '1';
916
+ sendJson(res, 200, { files: all ? listDeckFiles(deckDir) : curatedDeckFiles(deckDir) });
917
+ return true;
918
+ }
919
+ if (action === 'params') {
920
+ sendJson(res, 200, { files: filesDeclaringParams(deckDir) });
877
921
  return true;
878
922
  }
879
923
  if (action === 'read') {
@@ -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;
@@ -53,7 +53,7 @@ export function everyDeckBullets(deckDir) {
53
53
  "- Do not reload the deck after an edit. Nothing auto-reloads: the person applies your changes when they are ready, from the Play panel's restart button or a panel's own reload control. `npm run restart` reloads every open panel at once and breaks their flow — only when asked.",
54
54
  "- The deck can define its own editors: declare a file type in `castle.json` (`editor.fileTypes`: ext, label, icon, and an `editors/` module) and the Files panel opens that type in it — whole recipe under `## Editors` in the deck's guide (its kit's `CLAUDE.md` under `imports/`). Editors under `imports/` are read-only; add or fork, don't edit.",
55
55
  '- An editor is for content FILES the creator keeps coming back to (catalogs, dialogue, level tables) that nothing already opens. One-off or generated data stays code.',
56
- "- **Put the values someone would tune while playtesting in a `PARAMS` object**: `export const PARAMS = { jumpHeight: 4.2, coyoteTime: 0.08 };` near the top of the file that uses them. Every `.js` / `.jsx` / `.ts` / `.tsx` file has a Params view beside its Code view that renders those values as fields -- a number scrubs, a boolean toggles, a `'#rrggbb'` string opens a color picker -- and writes the edit back into the file. That is how a creator tunes a game on a phone without putting a cursor inside an expression. Nested one level deep (`player: { speed: 5 }`) renders as a group; a value that is an expression (`speed: BASE * 2`) still shows, read-only.",
56
+ "- **Put the values someone would tune while playtesting in a `PARAMS` object**: `export const PARAMS = { jumpHeight: 4.2, coyoteTime: 0.08 };` near the top of the file that uses them. The Parameters panel lists every file in the deck that has one and renders their values as fields -- a number scrubs, a boolean toggles, a `'#rrggbb'` string opens a color picker -- and writes the edit back into the file. It edits every file's params in one place, so nobody has to remember which file a setting lives in, and a file that has params says so above its code with a way in. That is how a creator tunes a game on a phone without putting a cursor inside an expression. Nested one level deep (`player: { speed: 5 }`) renders as a group; a value that is an expression (`speed: BASE * 2`) still shows, read-only.",
57
57
  '- **This is for the handful of numbers someone would actually reach for between playtests, not every constant in the file.** A `PARAMS` with sixty keys is a wall nobody can scan, which is how a creator stops opening the pane at all. A magic number that only ever has one right value is not a param.',
58
58
  "- **Read them through the object where they are used** -- `PARAMS.jumpHeight`, not `const { jumpHeight } = PARAMS` at module load. A destructured copy is taken once when the module loads and can never track a later change, so keeping the read at the use site is what leaves a tuned value able to reach the running deck.",
59
59
  MODE_BULLET,
package/dist/serve.js CHANGED
@@ -266,6 +266,7 @@ const EDIT_CONTENT_READ_ENDPOINTS = new Set([
266
266
  '/__castle/files/read',
267
267
  '/__castle/files/info',
268
268
  '/__castle/files/list',
269
+ '/__castle/files/params',
269
270
  '/__castle/files/forks',
270
271
  ]);
271
272
  export function isAllowedContentFilesRequest(role, method, reqPath) {
@@ -596,6 +597,15 @@ function lockExistingImports(projectDir) {
596
597
  }
597
598
  }
598
599
  }
600
+ function importPinsFingerprint(projectDir) {
601
+ try {
602
+ const config = JSON.parse(fs.readFileSync(path.join(projectDir, 'castle.json'), 'utf8'));
603
+ return JSON.stringify(config.imports ?? {});
604
+ }
605
+ catch {
606
+ return null;
607
+ }
608
+ }
599
609
  export async function serve(dir, options = {}) {
600
610
  const projectDir = path.resolve(dir);
601
611
  if (!fs.existsSync(projectDir)) {
@@ -642,11 +652,18 @@ export async function serve(dir, options = {}) {
642
652
  //
643
653
  // Also skipped when CASTLE_NO_AUTO_UPDATE is set (local kit development:
644
654
  // keep hand-synced imports/ instead of pulling published kits).
655
+ let importsChangedAtStartup = false;
645
656
  if (!existingServe(projectDir)) {
657
+ const importsBeforeUpdate = importPinsFingerprint(projectDir);
646
658
  // A failure to check must never keep the deck from opening.
647
- await autoUpdateImports(projectDir).catch((e) => {
659
+ const updated = await autoUpdateImports(projectDir).catch((e) => {
648
660
  console.warn(`auto-update: skipped -- ${e instanceof Error ? e.message : String(e)}`);
661
+ return [];
649
662
  });
663
+ // Comparing pins as well as the returned list preserves the invalidation
664
+ // if a later step throws after one or more imported trees were replaced.
665
+ importsChangedAtStartup =
666
+ updated.length > 0 || importsBeforeUpdate !== importPinsFingerprint(projectDir);
650
667
  }
651
668
  // Start pulling the headless browser now, in the background.
652
669
  //
@@ -815,6 +832,13 @@ export async function serve(dir, options = {}) {
815
832
  playtestHolder.run = (req) => agentServer.runPlaytestFor(req);
816
833
  const vite = await createViteServer({
817
834
  root: projectDir,
835
+ // Vite's cached optimizer metadata keys package/lock/config inputs, not the
836
+ // source of an imported deck. A serve-start auto-update can therefore add
837
+ // bare imports while leaving Vite convinced its old pre-bundle is current.
838
+ // Force only that startup's cold optimization: unchanged serves keep the
839
+ // warm cache, while the first page after replaced import source never has
840
+ // to recover from an outdated optimized-dependency response (HMR is off).
841
+ ...(importsChangedAtStartup ? { optimizeDeps: { force: true } } : {}),
818
842
  plugins: [
819
843
  castlePlugin(security, ideServer, agentServer, options.host),
820
844
  forksOverlayPlugin(),