castle-web-cli 0.4.78 → 0.4.79

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 (46) hide show
  1. package/dist/agent-prompts.d.ts +4 -1
  2. package/dist/agent-prompts.js +28 -7
  3. package/dist/agent.d.ts +7 -2
  4. package/dist/agent.js +655 -51
  5. package/dist/native/loop.d.ts +2 -0
  6. package/dist/native/loop.js +698 -0
  7. package/dist/native/openrouter.d.ts +55 -0
  8. package/dist/native/openrouter.js +354 -0
  9. package/dist/native/playtest-browser.d.ts +34 -0
  10. package/dist/native/playtest-browser.js +354 -0
  11. package/dist/native/playtest-executor.d.ts +3 -0
  12. package/dist/native/playtest-executor.js +156 -0
  13. package/dist/native/playtest.d.ts +131 -0
  14. package/dist/native/playtest.js +314 -0
  15. package/dist/native/tools.d.ts +38 -0
  16. package/dist/native/tools.js +630 -0
  17. package/dist/native/types.d.ts +40 -0
  18. package/dist/native/types.js +41 -0
  19. package/dist/serve.js +12 -0
  20. package/dist/shell/assets/{index-yGdKhgfZ.js → index-CNT3KxJb.js} +37 -37
  21. package/dist/shell/assets/{index-WE24qX3d.css → index-RZrw5gQ2.css} +1 -1
  22. package/dist/shell/index.html +2 -2
  23. package/kits/basic-2d/CLAUDE.md +29 -3
  24. package/kits/basic-2d/behaviors/Layout.jsx +10 -0
  25. package/kits/basic-2d/behaviors/Sprite.jsx +1 -1
  26. package/kits/basic-2d/blueprints/cauldron.scene +22 -0
  27. package/kits/basic-2d/castle.json +5 -7
  28. package/kits/basic-2d/docs/pxart-format.md +4 -3
  29. package/kits/basic-2d/drawings/cauldron.pxart +113 -0
  30. package/kits/basic-2d/editors/BlueprintLibrary.jsx +247 -0
  31. package/kits/basic-2d/editors/PlayOnly.jsx +1 -0
  32. package/kits/basic-2d/editors/SceneEditor.jsx +399 -411
  33. package/kits/basic-2d/editors/SelectionOverlay.jsx +125 -63
  34. package/kits/basic-2d/editors/SingleEditor.jsx +11 -2
  35. package/kits/basic-2d/editors/editorHistory.js +8 -2
  36. package/kits/basic-2d/editors/inspectorSheet.js +5 -19
  37. package/kits/basic-2d/engine/ScenePlayer.jsx +2 -2
  38. package/kits/basic-2d/engine/blueprint.js +423 -0
  39. package/kits/basic-2d/engine/files.js +1 -1
  40. package/kits/basic-2d/engine/scene.js +29 -29
  41. package/kits/basic-2d/engine/ui.jsx +160 -21
  42. package/kits/basic-2d/engine/ui.module.css +155 -13
  43. package/kits/basic-2d/pnpm-workspace.yaml +3 -0
  44. package/kits/basic-2d/scenes/main.scene +3 -13
  45. package/package.json +2 -1
  46. package/kits/basic-2d/drawings/pig.pxart +0 -26
package/dist/agent.js CHANGED
@@ -22,26 +22,129 @@ import { nanoid } from "nanoid";
22
22
  import { WebSocketServer } from "ws";
23
23
  import { rawDataToString } from "./ide.js";
24
24
  import { buildRouterPrompt, buildTaskPrompt, userTurnInstruction, CLAUDE_TASK_SYSTEM_REMINDER, } from "./agent-prompts.js";
25
+ import { runAgentNative } from "./native/loop.js";
26
+ import { createPlaytestBrowserManager } from "./native/playtest-browser.js";
27
+ import { createPlaywrightPlaytestExecutor } from "./native/playtest-executor.js";
25
28
  export const AGENT_WS_PATH = "/__castle/agent";
26
29
  export const AGENT_ATTACHMENT_PREFIX = "/__castle/agent/attachments/";
30
+ // Playtest frame PNGs (tasks/<id>/playtest/<file>.png), served for the
31
+ // finished-task card's thumbnails -- see makePlaytestFrameHandler.
32
+ export const AGENT_PLAYTEST_PREFIX = "/__castle/agent/playtest/";
27
33
  const DEFAULT_SETTINGS = {
28
34
  router: "claude",
29
35
  tasks: "claude",
30
- claudeModel: "opus",
36
+ routerClaudeModel: "opus",
37
+ // Tasks default one tier down: task agents run long unattended builds, so
38
+ // sonnet's cost/speed wins by default; the conductor stays on opus.
39
+ tasksClaudeModel: "sonnet",
40
+ // Free-form -- change to any OpenRouter slug.
41
+ routerOpenrouterModel: "google/gemini-3.5-flash",
42
+ tasksOpenrouterModel: "google/gemini-3.5-flash",
31
43
  };
32
44
  function normalizeBackend(value) {
33
- return value === "cursor" || value === "claude" ? value : null;
45
+ return value === "cursor" || value === "claude" || value === "smith"
46
+ ? value
47
+ : null;
48
+ }
49
+ // Settings written before the smith backend existed used "openrouter" as a
50
+ // BACKEND value meaning "claude CLI routed at OpenRouter" -- exactly what
51
+ // backend "claude" + that role's claude model "openrouter" means now. Used
52
+ // only when loading persisted settings (normalizeBackend rejects it live) so
53
+ // old decks keep their behavior across the upgrade.
54
+ function migrateStoredBackend(value) {
55
+ if (value === "openrouter")
56
+ return "claude";
57
+ return normalizeBackend(value);
34
58
  }
35
59
  function normalizeClaudeModel(value) {
36
- return value === "sonnet" || value === "opus" || value === "fable"
60
+ return value === "sonnet" ||
61
+ value === "opus" ||
62
+ value === "fable" ||
63
+ value === "openrouter"
37
64
  ? value
38
65
  : null;
39
66
  }
40
- // Build the headless CLI invocation for a backend/role. Cursor's router runs
41
- // in read-only ask mode; claude runs permission-mode auto for both roles (NOT
42
- // plan mode -- that makes it emit plan tool calls) at medium effort.
43
- function buildAgentInvocation(backend, role, prompt, claudeModel) {
67
+ // Free-form, so validation is just "non-empty, not absurdly long" (guards
68
+ // against a stray huge paste landing in settings.json / the CLI argv).
69
+ const OPENROUTER_MODEL_MAX_LEN = 200;
70
+ function normalizeOpenrouterModel(value) {
71
+ if (typeof value !== "string")
72
+ return null;
73
+ const trimmed = value.trim();
74
+ return trimmed && trimmed.length <= OPENROUTER_MODEL_MAX_LEN ? trimmed : null;
75
+ }
76
+ // OpenRouter's Anthropic-compatible endpoint (confirmed current, 2026: it
77
+ // accepts the standard Anthropic Messages API shape -- text/tool-use/
78
+ // extended-thinking -- for ANY OpenRouter model slug, not just Anthropic
79
+ // ones). Using it directly means claude CLI's OWN stream-json + tool loop
80
+ // talks to OpenRouter with zero translation layer -- no proxy needed.
81
+ const OPENROUTER_BASE_URL = "https://openrouter.ai/api";
82
+ // Env for a claude CLI spawn routed at OpenRouter (claudeModel "openrouter",
83
+ // Path A). Two things make this deterministic regardless of the user's own
84
+ // Anthropic auth (verified live against the real `claude` binary while
85
+ // building this):
86
+ // - ANTHROPIC_AUTH_TOKEN (not ANTHROPIC_API_KEY) takes precedence over a
87
+ // saved claude.ai/Console login IMMEDIATELY, with no interactive approval
88
+ // prompt -- ANTHROPIC_API_KEY needs a one-time approval in interactive
89
+ // mode, which a headless `-p` run can never satisfy.
90
+ // - ANTHROPIC_API_KEY is explicitly deleted so it's never ALSO sent (Claude
91
+ // Code warns "both a token and an API key are set" otherwise).
92
+ // CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 is required for a reason that
93
+ // only showed up under live testing: without it, --model is validated
94
+ // against claude CLI's own built-in model catalog client-side -- an unknown
95
+ // slug like "openai/gpt-5.1" 404s with a synthetic "model_not_found" and
96
+ // NEVER reaches the network. Setting it makes the CLI accept any --model
97
+ // string and actually send the request upstream.
98
+ function envForOpenrouterSpawn() {
99
+ const env = { ...process.env };
100
+ delete env.ANTHROPIC_API_KEY;
101
+ env.ANTHROPIC_BASE_URL = OPENROUTER_BASE_URL;
102
+ env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
103
+ const val = openrouterApiKey();
104
+ if (val)
105
+ env.ANTHROPIC_AUTH_TOKEN = val;
106
+ else
107
+ delete env.ANTHROPIC_AUTH_TOKEN;
108
+ return env;
109
+ }
110
+ // The one OpenRouter credential, shared by BOTH OpenRouter paths: smith's
111
+ // native loop passes it as runAgentNative's apiKey (Bearer header), and the
112
+ // claude-via-OpenRouter spawn sends it as ANTHROPIC_AUTH_TOKEN (see
113
+ // envForOpenrouterSpawn). Castle-provided key file first, env fallback --
114
+ // same sourcing order as envForAgentSpawn.
115
+ const OPENROUTER_KEY_NAME = "OPENROUTER_API_KEY";
116
+ function openrouterApiKey() {
117
+ return castleKeys()[OPENROUTER_KEY_NAME] ?? process.env[OPENROUTER_KEY_NAME] ?? "";
118
+ }
119
+ // Build the headless CLI invocation for a spawning backend/role (smith never
120
+ // comes through here -- it has no CLI process; see runAgentSmith). Cursor's
121
+ // router runs in read-only ask mode; claude runs permission-mode auto for
122
+ // both roles (NOT plan mode -- that makes it emit plan tool calls) at medium
123
+ // effort. claudeModel "openrouter" keeps the SAME claude binary/flags and
124
+ // swaps only the model slug and env (see envForOpenrouterSpawn) -- plus one
125
+ // extra permission grant, see OPENROUTER_ALLOWED_TOOLS below.
126
+ // claude CLI 2.1.198 SILENTLY resolves `--permission-mode auto` to "default"
127
+ // whenever --model is not in its built-in catalog -- which is every free-form
128
+ // OpenRouter slug (verified live: `--model sonnet` inits with
129
+ // permissionMode "auto"; `--model anthropic/claude-sonnet-4.5` inits with
130
+ // "default" under identical argv/env; the gateway env vars are irrelevant).
131
+ // Headless (-p) default mode then denies every prompt-requiring tool with
132
+ // "you haven't granted it yet", so OR-routed tasks could never edit files
133
+ // (incident: task UmI8O3VR looped ~60 denials, then exited 0 having done
134
+ // nothing). This explicit tool allowlist restores auto's effective headless
135
+ // capability -- edits plus unrestricted Bash, network commands included
136
+ // (acceptEdits was rejected: it survives the downgrade but still denies
137
+ // network Bash like curl, which plain auto allows). Deliberately NOT passed
138
+ // to plain-claude spawns, where auto still resolves correctly.
139
+ // Single `=` token because --allowedTools is variadic and would otherwise
140
+ // swallow the trailing prompt positional.
141
+ const OPENROUTER_ALLOWED_TOOLS = "--allowedTools=Edit,Write,NotebookEdit,Bash";
142
+ function buildAgentInvocation(backend, role, prompt, claudeModel,
143
+ // Already resolved for this role by the caller (router turns pass
144
+ // settings.routerOpenrouterModel, task spawns settings.tasksOpenrouterModel).
145
+ openrouterModel) {
44
146
  if (backend === "claude") {
147
+ const viaOpenrouter = claudeModel === "openrouter";
45
148
  return {
46
149
  command: "claude",
47
150
  args: [
@@ -52,10 +155,18 @@ function buildAgentInvocation(backend, role, prompt, claudeModel) {
52
155
  "--include-partial-messages",
53
156
  "--permission-mode",
54
157
  "auto",
158
+ ...(viaOpenrouter ? [OPENROUTER_ALLOWED_TOOLS] : []),
55
159
  "--model",
56
- claudeModel,
160
+ viaOpenrouter ? openrouterModel : claudeModel,
57
161
  "--effort",
58
162
  "medium",
163
+ // Newer claude models default thinking display to "omitted" (empty
164
+ // thinking_delta text, signature only); "summarized" restores actual
165
+ // summary text so the shell's expandable thinking transcript has
166
+ // content. Slight time-to-first-text cost (the API streams the
167
+ // summary before prose). Undocumented in --help but honored.
168
+ "--thinking-display",
169
+ "summarized",
59
170
  // Keep runs independent of the machine's user config: no user plugins
60
171
  // (LSP servers etc.), no user MCP servers. CLAUDE.md auto-discovery
61
172
  // and OAuth still work.
@@ -67,6 +178,7 @@ function buildAgentInvocation(backend, role, prompt, claudeModel) {
67
178
  : []),
68
179
  prompt,
69
180
  ],
181
+ env: viaOpenrouter ? envForOpenrouterSpawn() : envForAgentSpawn(backend),
70
182
  };
71
183
  }
72
184
  return {
@@ -82,8 +194,12 @@ function buildAgentInvocation(backend, role, prompt, claudeModel) {
82
194
  ...(role === "router" ? ["--mode", "ask"] : ["--force"]),
83
195
  prompt,
84
196
  ],
197
+ env: envForAgentSpawn(backend),
85
198
  };
86
199
  }
200
+ function parserForBackend(backend) {
201
+ return backend === "cursor" ? "cursor" : "claude";
202
+ }
87
203
  const ROUTER_TIMEOUT_MS = 3 * 60_000;
88
204
  const TASK_TIMEOUT_MS = 30 * 60_000;
89
205
  const MAX_TASK_ATTEMPTS = 3;
@@ -299,6 +415,22 @@ function humanizeAskBlocks(text) {
299
415
  }
300
416
  });
301
417
  }
418
+ // Monotonic source of smith pseudo-pids (see AgentRunHandle.pid).
419
+ let smithRunSeq = 0;
420
+ function makeSmithRunHandle(controller) {
421
+ smithRunSeq += 1;
422
+ return {
423
+ pid: -smithRunSeq,
424
+ // Reads as "still running" for symmetry, but nothing consumes it for
425
+ // smith runs: the registry already excludes them by pid sign.
426
+ exitCode: null,
427
+ spawnfile: "castle-smith",
428
+ kill: () => {
429
+ controller.abort();
430
+ return true;
431
+ },
432
+ };
433
+ }
302
434
  function baseName(p) {
303
435
  const parts = p.split(/[\\/]/).filter(Boolean);
304
436
  return parts[parts.length - 1] || p;
@@ -381,6 +513,9 @@ function castleKeys() {
381
513
  return {};
382
514
  }
383
515
  }
516
+ // Keys for the SPAWNING backends' env injection (envForAgentSpawn). Smith is
517
+ // absent by design: it never spawns a CLI -- its OpenRouter key flows through
518
+ // openrouterApiKey() into runAgentNative's Authorization header instead.
384
519
  const BACKEND_KEY_ENV = {
385
520
  claude: "ANTHROPIC_API_KEY",
386
521
  cursor: "CURSOR_API_KEY",
@@ -432,9 +567,22 @@ const DECK_TREE_MAX_ENTRIES = 200;
432
567
  // from the snapshot entirely. Summarizing the overflow as "(+N more .pxart)"
433
568
  // keeps every directory visible and turns the count itself into signal.
434
569
  const DECK_TREE_PER_DIR = 15;
570
+ // Smith-only slimmed caps for the tree section on a turn where
571
+ // buildDeckContents (below) already inlined the deck's actual source -- the
572
+ // tree's job shrinks from "the listing" to "orientation for whatever isn't
573
+ // inlined" (art/binaries, and any overflow buildDeckContents had to list
574
+ // instead of inline), so it doesn't need the full 200-entry/15-per-dir detail
575
+ // the CLI backends still get (they have no contents section to lean on).
576
+ const DECK_TREE_SLIM_MAX_ENTRIES = 60;
577
+ const DECK_TREE_SLIM_PER_DIR = 8;
435
578
  // Shallow orientation snapshot for router/task prompts. Best-effort by design:
436
- // filesystem hiccups should cost context, not fail an agent turn.
437
- function buildDeckTree(deckDir) {
579
+ // filesystem hiccups should cost context, not fail an agent turn. `caps` lets
580
+ // a smith turn ask for the slimmed version above once deck contents are
581
+ // already inlined; omitted (the CLI backends) keeps today's byte-identical
582
+ // output.
583
+ function buildDeckTree(deckDir, caps) {
584
+ const maxEntries = caps?.maxEntries ?? DECK_TREE_MAX_ENTRIES;
585
+ const perDir = caps?.perDir ?? DECK_TREE_PER_DIR;
438
586
  const lines = [];
439
587
  const walk = (dir, prefix, depth) => {
440
588
  let entries;
@@ -450,8 +598,8 @@ function buildDeckTree(deckDir) {
450
598
  return a.name.localeCompare(b.name);
451
599
  });
452
600
  const visible = entries.filter((e) => !DECK_TREE_EXCLUDE.has(e.name));
453
- for (const entry of visible.slice(0, DECK_TREE_PER_DIR)) {
454
- if (lines.length >= DECK_TREE_MAX_ENTRIES) {
601
+ for (const entry of visible.slice(0, perDir)) {
602
+ if (lines.length >= maxEntries) {
455
603
  lines.push(`${prefix}...`);
456
604
  return;
457
605
  }
@@ -461,8 +609,8 @@ function buildDeckTree(deckDir) {
461
609
  walk(path.join(dir, entry.name), prefix + " ", depth + 1);
462
610
  }
463
611
  }
464
- const rest = visible.slice(DECK_TREE_PER_DIR);
465
- if (rest.length > 0 && lines.length < DECK_TREE_MAX_ENTRIES) {
612
+ const rest = visible.slice(perDir);
613
+ if (rest.length > 0 && lines.length < maxEntries) {
466
614
  // Name the overflow's extension when it's uniform ("+214 more .pxart"),
467
615
  // since that tells the reader what kind of files dominate the directory.
468
616
  const exts = new Set(rest.map((e) => (e.isDirectory() ? "/" : path.extname(e.name))));
@@ -474,7 +622,145 @@ function buildDeckTree(deckDir) {
474
622
  walk(deckDir, "", 0);
475
623
  return lines.join("\n");
476
624
  }
477
- function readQuickReference(deckDir) {
625
+ // Text source extensions buildDeckContents will inline. Deliberately an
626
+ // allowlist (not "everything readable") -- decks are small web projects, so
627
+ // this covers what an agent actually hand-edits. SVG is included: it's a text
628
+ // format read_file already treats as source (view_image explicitly excludes
629
+ // it for the same reason). .scene is included too, and deliberately not
630
+ // filtered out despite looking like data: scenes/*.scene and
631
+ // blueprints/*.scene are JSON (actors, behaviors, layout), and they are
632
+ // exactly what a router/task agent orients on first when it opens a kit deck
633
+ // -- leaving them out just forces the hand-read this inlining exists to
634
+ // avoid (castle.json needs no separate entry -- it's already covered by
635
+ // `.json`). .pxart is deliberately NOT added even though it's also
636
+ // JSON-ish text: pixel-art drawings are bulky per-pixel grid data, not
637
+ // orientation material an agent reads to understand the deck, and they're
638
+ // meant to be produced by a tool/command, not hand-typed -- inlining them
639
+ // would just burn budget for content the agent isn't meant to
640
+ // read-then-copy-edit. True binary/art formats (images, audio, fonts) and
641
+ // anything else outside this list are skipped for the more obvious reason
642
+ // that they're unreadable as text.
643
+ const DECK_CONTENTS_TEXT_EXTS = new Set([
644
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts",
645
+ ".json", ".css", ".html", ".md", ".txt", ".svg", ".yml", ".yaml", ".scene",
646
+ ]);
647
+ // Generated/lockfiles that happen to match the extension allowlist but are
648
+ // never hand-edited -- inlining a lockfile would just spend budget other
649
+ // files need for zero benefit (nobody reads a lockfile to plan an edit).
650
+ const DECK_CONTENTS_SKIP_NAMES = new Set([
651
+ "package-lock.json",
652
+ "pnpm-lock.yaml",
653
+ "yarn.lock",
654
+ ]);
655
+ // Per-file cap: keeps one unusually large source file (a generated bundle
656
+ // accidentally left in the tree, a huge scene) from eating the whole budget
657
+ // by itself. 16KB comfortably covers real hand-written deck files (kit engine
658
+ // modules run a few KB each) while still being small next to the total budget
659
+ // below.
660
+ const DECK_CONTENTS_PER_FILE_CAP = 16 * 1024;
661
+ // Safety valve against a pathological tree (mirrors tools.ts's
662
+ // MAX_WALK_FILES) -- decks are modest-sized web projects, this is not a
663
+ // normal limit.
664
+ const DECK_CONTENTS_MAX_WALK_FILES = 5_000;
665
+ // Smith-only total budgets (bytes), passed by the call sites below. The
666
+ // router's prompt is already the largest (rules + full transcript + the
667
+ // whole task board), so it gets a tighter ceiling than a task's -- a task
668
+ // prompt is comparatively lean and can afford to carry more of the deck.
669
+ const TASK_DECK_CONTENTS_BUDGET = 80 * 1024;
670
+ const ROUTER_DECK_CONTENTS_BUDGET = 40 * 1024;
671
+ // Full-content inlining for smith prompts (buildTaskPrompt/buildRouterPrompt's
672
+ // `deckContents`): unlike buildDeckTree (shallow, name-only, same for every
673
+ // backend), this walks the ENTIRE file set the tree covers -- no depth limit
674
+ // -- because the point is to hand smith the deck's actual code, not just its
675
+ // shape. Only smith needs this: cursor/claude read files with their own tools
676
+ // turn over turn, but smith's native loop pays a full round-trip (a tool call
677
+ // + its result re-entering context on every later iteration) for every read,
678
+ // and Castle decks are tiny enough that this rarely costs more than one
679
+ // inlining would anyway -- so front-loading it removes those round trips
680
+ // entirely for the common small/greenfield deck.
681
+ //
682
+ // `budget` (bytes) is the caller's total ceiling (tasks get more room than
683
+ // the router -- see the call sites in runTaskAgentIn / routerTurnPrompt).
684
+ // Degrades gracefully under both caps: a file over DECK_CONTENTS_PER_FILE_CAP,
685
+ // or one that would push the running total over `budget`, is listed by name
686
+ // instead of inlined (deterministic order -- once the budget is spent every
687
+ // later file lists too, it never back-fills a smaller one out of order).
688
+ function buildDeckContents(deckDir, budget) {
689
+ const files = [];
690
+ const walk = (dir) => {
691
+ if (files.length >= DECK_CONTENTS_MAX_WALK_FILES)
692
+ return;
693
+ let entries;
694
+ try {
695
+ entries = fs.readdirSync(dir, { withFileTypes: true });
696
+ }
697
+ catch {
698
+ return;
699
+ }
700
+ entries.sort((a, b) => a.name.localeCompare(b.name));
701
+ for (const entry of entries) {
702
+ if (DECK_TREE_EXCLUDE.has(entry.name))
703
+ continue;
704
+ const abs = path.join(dir, entry.name);
705
+ if (entry.isDirectory()) {
706
+ walk(abs);
707
+ }
708
+ else if (entry.isFile()) {
709
+ files.push(abs);
710
+ if (files.length >= DECK_CONTENTS_MAX_WALK_FILES)
711
+ return;
712
+ }
713
+ }
714
+ };
715
+ walk(deckDir);
716
+ const inlined = [];
717
+ const listed = [];
718
+ let used = 0;
719
+ for (const abs of files) {
720
+ const rel = path.relative(deckDir, abs).split(path.sep).join("/");
721
+ if (DECK_CONTENTS_SKIP_NAMES.has(path.basename(rel)))
722
+ continue;
723
+ if (!DECK_CONTENTS_TEXT_EXTS.has(path.extname(rel).toLowerCase()))
724
+ continue;
725
+ let stat;
726
+ try {
727
+ stat = fs.statSync(abs);
728
+ }
729
+ catch {
730
+ continue;
731
+ }
732
+ if (stat.size > DECK_CONTENTS_PER_FILE_CAP || used + stat.size > budget) {
733
+ listed.push(`${rel} (${stat.size}b, not inlined -- use read tool)`);
734
+ continue;
735
+ }
736
+ let content;
737
+ try {
738
+ content = fs.readFileSync(abs, "utf8");
739
+ }
740
+ catch {
741
+ listed.push(`${rel} (not inlined -- use read tool)`);
742
+ continue;
743
+ }
744
+ if (content.includes("\u0000")) {
745
+ // Looks binary despite the extension allowlist (e.g. a mislabeled
746
+ // asset) -- skip it exactly like read_file's own NUL sniff would.
747
+ listed.push(`${rel} (not inlined -- use read tool)`);
748
+ continue;
749
+ }
750
+ inlined.push(`--- ${rel} ---\n${content}`);
751
+ used += stat.size;
752
+ }
753
+ const parts = [];
754
+ if (inlined.length > 0)
755
+ parts.push(inlined.join("\n\n"));
756
+ if (listed.length > 0) {
757
+ parts.push(`(not inlined -- binary/generated, over the per-file cap, or past the total budget; read these yourself if you need them):\n${listed.join("\n")}`);
758
+ }
759
+ return parts.join("\n\n");
760
+ }
761
+ const DEFAULT_WELCOME_MESSAGE = "Welcome to an early test of Castle's new engine! You're starting with a blank deck, without the official art or scene editors, but if HTML and JavaScript can do it then I can help you build it.\n\nDo you already know what you want to make, or do you want to figure it out together?";
762
+ function readClaudeSection(deckDir, section) {
763
+ const headingRe = new RegExp(`^## ${section}\\s*$`, "im");
478
764
  for (const name of ["CLAUDE.md", "AGENTS.md"]) {
479
765
  let raw = "";
480
766
  try {
@@ -483,7 +769,7 @@ function readQuickReference(deckDir) {
483
769
  catch {
484
770
  continue;
485
771
  }
486
- const heading = /^## Quick reference\s*$/im.exec(raw);
772
+ const heading = headingRe.exec(raw);
487
773
  if (!heading || heading.index === undefined)
488
774
  continue;
489
775
  const bodyStart = raw.indexOf("\n", heading.index);
@@ -495,6 +781,12 @@ function readQuickReference(deckDir) {
495
781
  }
496
782
  return "";
497
783
  }
784
+ function readQuickReference(deckDir) {
785
+ return readClaudeSection(deckDir, "Quick reference");
786
+ }
787
+ function readWelcomeMessage(deckDir) {
788
+ return readClaudeSection(deckDir, "Welcome message");
789
+ }
498
790
  const TOUCHED_FILE_LIMIT = 10;
499
791
  function collectStrings(value, out) {
500
792
  if (typeof value === "string") {
@@ -656,14 +948,18 @@ function formatTokenCount(value) {
656
948
  return `${(value / 1000).toFixed(1)}k`;
657
949
  return String(value);
658
950
  }
659
- function logRouterUsage(backend, usage) {
951
+ // One usage line per finished agent run, labeled per role/backend so the
952
+ // serve's stderr is a running cost ledger for BOTH roles: `label` is
953
+ // "router" or "task <id>". Task runs log once per attempt (each attempt is
954
+ // its own CLI process / native run with its own usage).
955
+ function logAgentUsage(label, backend, usage) {
660
956
  if (!usage)
661
957
  return;
662
958
  const input = formatTokenCount(usage.input_tokens);
663
959
  const read = formatTokenCount(usage.cache_read_input_tokens);
664
960
  const created = formatTokenCount(usage.cache_creation_input_tokens);
665
961
  const output = formatTokenCount(usage.output_tokens);
666
- console.error(`[agent usage] router ${backend}: input=${input} cache_read=${read} cache_created=${created} output=${output}`);
962
+ console.error(`[agent usage] ${label} ${backend}: input=${input} cache_read=${read} cache_created=${created} output=${output}`);
667
963
  }
668
964
  // Build the per-run stdout event handler over a shared mutable parser state.
669
965
  // Splitting the cursor + claude stream decoding out of runAgentCli keeps each
@@ -811,7 +1107,7 @@ function runAgentCli(opts) {
811
1107
  return new Promise((resolve) => {
812
1108
  const child = spawn(opts.command, opts.args, {
813
1109
  cwd: opts.cwd,
814
- env: envForAgentSpawn(opts.parser),
1110
+ env: opts.env,
815
1111
  stdio: ["ignore", "pipe", "pipe"],
816
1112
  });
817
1113
  opts.children.add(child);
@@ -891,6 +1187,123 @@ function runAgentCli(opts) {
891
1187
  });
892
1188
  });
893
1189
  }
1190
+ // One smith (native castle agent) run, adapted to runAgentCli's contract so
1191
+ // every caller downstream of runAgentTurn is backend-agnostic:
1192
+ // - NativeRunResult.text -> finalText; error/usage/filesTouched/crashed pass
1193
+ // through by name. `ok` is derived as !error && !crashed -- there is no
1194
+ // process exit code; those two fields are the whole story.
1195
+ // - Cancellation: one AbortController per run, registered in the same
1196
+ // `children` set the CLI runs use, via a handle whose kill() aborts it
1197
+ // (see AgentRunHandle). Interrupts (killRouterChildren), task halts
1198
+ // (haltTask), and serve shutdown all just work. An aborted run resolves
1199
+ // with error "agent run stopped" and NO crashed flag -- same downstream
1200
+ // behavior as a SIGKILLed CLI child (router: the epoch check routes to
1201
+ // settleInterruptedTurn; tasks: stopRequested returns before the retry
1202
+ // check).
1203
+ // - Timeout is enforced INSIDE runAgentNative (same "agent run timed out"
1204
+ // error string runAgentCli produces, so classifyRouterFailure's buckets
1205
+ // hold); connect failures surface as "could not run openrouter: ..."
1206
+ // matching the "could not run" spawn bucket.
1207
+ // - logPath appends structured JSONL run events to the SAME files the CLI
1208
+ // backends stream their raw log into (tasks/<id>/log.jsonl,
1209
+ // .castle/agent/router-log.jsonl) -- see createRunLogger in native/loop.ts
1210
+ // for the line shapes.
1211
+ // - attachments (router turns): deck-relative image paths that become
1212
+ // image_url content parts in the run's initial user message -- the native
1213
+ // tool set can't read image bytes (read_file rejects binaries), unlike the
1214
+ // CLI agents which re-read attachment files themselves.
1215
+ async function runAgentSmith(opts) {
1216
+ const controller = new AbortController();
1217
+ const handle = makeSmithRunHandle(controller);
1218
+ opts.children.add(handle);
1219
+ // The pseudo-pid lands in task.pid so haltTask can find this run's handle
1220
+ // in the shared children set, exactly like a CLI child's real pid.
1221
+ opts.onSpawn?.(handle.pid);
1222
+ try {
1223
+ const result = await runAgentNative({
1224
+ cwd: opts.cwd,
1225
+ role: opts.role,
1226
+ model: opts.model,
1227
+ apiKey: openrouterApiKey(),
1228
+ prompt: opts.prompt,
1229
+ systemReminder: opts.systemReminder,
1230
+ attachments: opts.attachments,
1231
+ timeoutMs: opts.timeoutMs,
1232
+ logPath: opts.logPath,
1233
+ playtest: opts.playtest,
1234
+ restart: opts.restart,
1235
+ signal: controller.signal,
1236
+ onDelta: opts.onDelta,
1237
+ onActivity: opts.onActivity,
1238
+ onThinking: opts.onThinking,
1239
+ });
1240
+ return {
1241
+ ok: !result.error && !result.crashed,
1242
+ finalText: result.text,
1243
+ error: result.error,
1244
+ usage: result.usage,
1245
+ filesTouched: result.filesTouched,
1246
+ playtestFrames: result.playtestFrames,
1247
+ crashed: result.crashed,
1248
+ };
1249
+ }
1250
+ catch (err) {
1251
+ // runAgentNative resolves (never rejects) by contract; this is pure
1252
+ // belt-and-braces so an unexpected throw degrades like a spawn failure
1253
+ // instead of rejecting through startTask's catch.
1254
+ return {
1255
+ ok: false,
1256
+ finalText: "",
1257
+ error: `could not run openrouter: ${err instanceof Error ? err.message : String(err)}`,
1258
+ };
1259
+ }
1260
+ finally {
1261
+ opts.children.delete(handle);
1262
+ }
1263
+ }
1264
+ // The one backend dispatch point for running an agent turn: smith runs
1265
+ // in-process (runAgentSmith -> runAgentNative); cursor/claude spawn a CLI
1266
+ // (buildAgentInvocation -> runAgentCli). Everything downstream consumes the
1267
+ // same CliRunResult contract either way.
1268
+ function runAgentTurn(opts) {
1269
+ if (opts.backend === "smith") {
1270
+ return runAgentSmith({
1271
+ cwd: opts.cwd,
1272
+ role: opts.role,
1273
+ model: opts.openrouterModel,
1274
+ prompt: opts.prompt,
1275
+ // Mirrors claude's --append-system-prompt for tasks (the native loop
1276
+ // appends it to its own system framing).
1277
+ systemReminder: opts.role === "task" ? CLAUDE_TASK_SYSTEM_REMINDER : undefined,
1278
+ attachments: opts.attachments,
1279
+ timeoutMs: opts.timeoutMs,
1280
+ logPath: opts.logPath,
1281
+ playtest: opts.playtest,
1282
+ restart: opts.restart,
1283
+ children: opts.children,
1284
+ onDelta: opts.onDelta,
1285
+ onActivity: opts.onActivity,
1286
+ onThinking: opts.onThinking,
1287
+ onSpawn: opts.onSpawn,
1288
+ });
1289
+ }
1290
+ const invocation = buildAgentInvocation(opts.backend, opts.role, opts.prompt, opts.claudeModel, opts.openrouterModel);
1291
+ return runAgentCli({
1292
+ cwd: opts.cwd,
1293
+ command: invocation.command,
1294
+ args: invocation.args,
1295
+ parser: parserForBackend(opts.backend),
1296
+ env: invocation.env,
1297
+ timeoutMs: opts.timeoutMs,
1298
+ logPath: opts.logPath,
1299
+ children: opts.children,
1300
+ onDelta: opts.onDelta,
1301
+ onActivity: opts.onActivity,
1302
+ onThinking: opts.onThinking,
1303
+ onSpawn: opts.onSpawn,
1304
+ labelUnknownTools: opts.labelUnknownTools,
1305
+ });
1306
+ }
894
1307
  // -- task store ---------------------------------------------------------------
895
1308
  function persistTaskFile(tasksDir, task) {
896
1309
  fs.writeFileSync(path.join(tasksDir, task.id, "task.json"), JSON.stringify(task, null, 2) + "\n");
@@ -996,6 +1409,8 @@ function depsSummaryFor(tasks, task) {
996
1409
  const parts = [`- "${dep.title}" finished ${dep.status}`];
997
1410
  if (dep.files && dep.files.length > 0)
998
1411
  parts.push(` files it touched: ${dep.files.join(", ")}`);
1412
+ if (dep.suspectNoChanges)
1413
+ parts.push(" caution: it finished without touching any tracked files (bash side effects aren't tracked) -- verify its work actually landed before building on it");
999
1414
  // The agent's own closing prose is the real handoff -- names it created,
1000
1415
  // what it wired, what it left undone. The notes file is player-facing
1001
1416
  // and deliberately stripped of that detail.
@@ -1037,6 +1452,13 @@ async function waitBeforeTaskRetry(attempt, stopRequested, taskId) {
1037
1452
  async function runTaskAgentIn(ctx, task) {
1038
1453
  const dir = path.join(ctx.tasksDir, task.id);
1039
1454
  const relDir = path.relative(ctx.deckDir, dir);
1455
+ // Deck source inlining is smith-only (see buildDeckContents): cursor/claude
1456
+ // read files themselves turn over turn, so computing and sending this
1457
+ // would be pure prompt bloat for them with no offsetting benefit.
1458
+ const isSmith = ctx.backend === "smith";
1459
+ const deckContents = isSmith
1460
+ ? buildDeckContents(ctx.deckDir, TASK_DECK_CONTENTS_BUDGET)
1461
+ : undefined;
1040
1462
  const taskPrompt = buildTaskPrompt({
1041
1463
  deckLabel: ctx.deckLabel,
1042
1464
  taskId: task.id,
@@ -1046,7 +1468,11 @@ async function runTaskAgentIn(ctx, task) {
1046
1468
  notesPath: path.join(relDir, "notes.md"),
1047
1469
  depsSummary: ctx.depsSummary,
1048
1470
  backend: ctx.backend,
1049
- deckTree: buildDeckTree(ctx.deckDir),
1471
+ // Slimmed once contents are inlined -- see DECK_TREE_SLIM_* above.
1472
+ deckTree: buildDeckTree(ctx.deckDir, isSmith
1473
+ ? { maxEntries: DECK_TREE_SLIM_MAX_ENTRIES, perDir: DECK_TREE_SLIM_PER_DIR }
1474
+ : undefined),
1475
+ deckContents,
1050
1476
  quickReference: ctx.quickReference,
1051
1477
  siblings: ctx.siblings,
1052
1478
  });
@@ -1056,7 +1482,14 @@ async function runTaskAgentIn(ctx, task) {
1056
1482
  // can't-verify-live-play loop. A single message + the autonomy reminder
1057
1483
  // appended to the system prompt (CLAUDE_TASK_SYSTEM_REMINDER) is enough to
1058
1484
  // get the work done; the agent stops when the code is in, the user verifies.
1059
- const invocation = buildAgentInvocation(ctx.backend, "task", taskPrompt, ctx.claudeModel);
1485
+ // Pre-warm the playtest browser download as soon as a smith task starts
1486
+ // (fire-and-forget; single-flight shared with any real playtest call), so
1487
+ // the ~250MB one-time install usually completes during the agent's early
1488
+ // read/plan turns instead of stalling its first playtest. Smith-only:
1489
+ // playtest is a native-loop tool, so CLI-backend tasks would download a
1490
+ // browser they can never use.
1491
+ if (ctx.backend === "smith")
1492
+ ctx.playtest?.prewarm?.();
1060
1493
  let result = { ok: false, finalText: "", error: "not run" };
1061
1494
  let lineBuf = "";
1062
1495
  // ```signal blocks span multiple lines and must NOT show in the live feed:
@@ -1095,13 +1528,19 @@ async function runTaskAgentIn(ctx, task) {
1095
1528
  };
1096
1529
  for (let attempt = 1; attempt <= MAX_TASK_ATTEMPTS; attempt++) {
1097
1530
  await staggerTaskSpawn();
1098
- result = await runAgentCli({
1531
+ result = await runAgentTurn({
1532
+ backend: ctx.backend,
1533
+ role: "task",
1534
+ prompt: taskPrompt,
1535
+ claudeModel: ctx.claudeModel,
1536
+ openrouterModel: ctx.openrouterModel,
1099
1537
  cwd: ctx.deckDir,
1100
- command: invocation.command,
1101
- args: invocation.args,
1102
- parser: ctx.backend,
1103
1538
  timeoutMs: TASK_TIMEOUT_MS,
1104
1539
  logPath: path.join(dir, "log.jsonl"),
1540
+ playtest: ctx.playtest
1541
+ ? { executor: ctx.playtest.executor, serveUrl: ctx.playtest.serveUrl, framesDir: path.join(dir, "playtest") }
1542
+ : undefined,
1543
+ restart: ctx.restart,
1105
1544
  children: ctx.children,
1106
1545
  onSpawn: (pid) => {
1107
1546
  task.pid = pid;
@@ -1113,6 +1552,7 @@ async function runTaskAgentIn(ctx, task) {
1113
1552
  ctx.onFeed(`[${activity}]`);
1114
1553
  },
1115
1554
  });
1555
+ logAgentUsage(`task ${task.id}`, ctx.backend, result.usage);
1116
1556
  if (ctx.stopRequested.has(task.id))
1117
1557
  return result;
1118
1558
  if (!result.crashed)
@@ -1146,8 +1586,11 @@ function startTask(ctx, task) {
1146
1586
  children: ctx.children,
1147
1587
  backend: ctx.backend(),
1148
1588
  claudeModel: ctx.claudeModel(),
1589
+ openrouterModel: ctx.openrouterModel(),
1149
1590
  stopRequested: ctx.stopRequested,
1150
1591
  quickReference: ctx.quickReference,
1592
+ playtest: ctx.playtest,
1593
+ restart: ctx.restart,
1151
1594
  depsSummary: depsSummaryFor(ctx.tasks, task),
1152
1595
  // Same visibility rule as the router's board (hide acked+finished rows),
1153
1596
  // minus this task itself. Snapshot at start -- consistent with the deck
@@ -1196,6 +1639,11 @@ function startTask(ctx, task) {
1196
1639
  if (result.ok && !wasStopped)
1197
1640
  task.progress = 100;
1198
1641
  task.files = result.filesTouched ?? [];
1642
+ task.playtestFrames = result.playtestFrames ?? [];
1643
+ // Flag, don't fail: see the TaskRecord.suspectNoChanges comment.
1644
+ if (task.status === "done" && task.files.length === 0) {
1645
+ task.suspectNoChanges = true;
1646
+ }
1199
1647
  task.finishedAt = nowIso();
1200
1648
  task.resultSummary = wasStopped
1201
1649
  ? "stopped by the router"
@@ -1338,6 +1786,9 @@ function createTaskStore(opts) {
1338
1786
  quickReference: opts.quickReference,
1339
1787
  backend: opts.backend,
1340
1788
  claudeModel: opts.claudeModel,
1789
+ openrouterModel: opts.openrouterModel,
1790
+ playtest: opts.playtest,
1791
+ restart: opts.restart,
1341
1792
  onStarted: opts.onStarted,
1342
1793
  onFinished: opts.onFinished,
1343
1794
  onRetry: opts.onRetry,
@@ -1535,6 +1986,7 @@ function asPromptTask(task) {
1535
1986
  files: task.files,
1536
1987
  error: task.status === "failed" ? firstErrorLine(task.resultSummary) : undefined,
1537
1988
  blockedBy: task.status === "blocked" ? task.blockedBy : undefined,
1989
+ suspectNoChanges: task.suspectNoChanges,
1538
1990
  };
1539
1991
  }
1540
1992
  function asClientTask(task) {
@@ -1552,6 +2004,8 @@ function asClientTask(task) {
1552
2004
  phase: task.phase,
1553
2005
  acknowledged: task.acknowledged,
1554
2006
  rejected: task.rejected,
2007
+ suspectNoChanges: task.suspectNoChanges,
2008
+ playtestFrames: (task.playtestFrames ?? []).map((rel) => `${AGENT_PLAYTEST_PREFIX}${task.id}/${path.basename(rel)}`),
1555
2009
  };
1556
2010
  }
1557
2011
  function createTaskFeeds(broadcast) {
@@ -1571,7 +2025,26 @@ function createTaskFeeds(broadcast) {
1571
2025
  }
1572
2026
  return { map, push };
1573
2027
  }
1574
- function createMessageLog(messagesPath, broadcast) {
2028
+ function createMessageThinking(broadcast) {
2029
+ const map = new Map();
2030
+ function append(id, delta) {
2031
+ const now = Date.now();
2032
+ const entry = map.get(id);
2033
+ if (entry) {
2034
+ entry.text += delta;
2035
+ entry.lastAt = now;
2036
+ }
2037
+ else {
2038
+ map.set(id, { text: delta, firstAt: now, lastAt: now });
2039
+ }
2040
+ broadcast({ type: "message-thinking-delta", id, delta });
2041
+ }
2042
+ function snapshot() {
2043
+ return Object.fromEntries([...map].map(([id, e]) => [id, { text: e.text, ms: e.lastAt - e.firstAt }]));
2044
+ }
2045
+ return { append, snapshot };
2046
+ }
2047
+ function createMessageLog(messagesPath, broadcast, welcomeMessage) {
1575
2048
  const loaded = readJsonFile(messagesPath) ?? [];
1576
2049
  const messages = loaded
1577
2050
  .filter((m) => m.text.trim() !== "" || m.role === "user")
@@ -1579,6 +2052,16 @@ function createMessageLog(messagesPath, broadcast) {
1579
2052
  function persist() {
1580
2053
  fs.writeFileSync(messagesPath, JSON.stringify(messages, null, 2) + "\n");
1581
2054
  }
2055
+ if (messages.length === 0) {
2056
+ messages.push({
2057
+ id: nanoid(8),
2058
+ role: "assistant",
2059
+ text: welcomeMessage,
2060
+ at: nowIso(),
2061
+ status: "done",
2062
+ });
2063
+ persist();
2064
+ }
1582
2065
  function add(message) {
1583
2066
  messages.push(message);
1584
2067
  persist();
@@ -1623,6 +2106,27 @@ function makeAttachmentHandler(attachmentsDir) {
1623
2106
  return true;
1624
2107
  };
1625
2108
  }
2109
+ // Serve playtest frame PNGs saved under tasks/<id>/playtest/ -- path is
2110
+ // AGENT_PLAYTEST_PREFIX + taskId + "/" + filename; both segments are reduced
2111
+ // to path.basename before joining, so neither can escape tasksDir.
2112
+ function makePlaytestFrameHandler(tasksDir) {
2113
+ return (_req, res, reqPath) => {
2114
+ if (!reqPath.startsWith(AGENT_PLAYTEST_PREFIX))
2115
+ return false;
2116
+ const rest = reqPath.slice(AGENT_PLAYTEST_PREFIX.length);
2117
+ const slash = rest.indexOf("/");
2118
+ const taskId = slash >= 0 ? path.basename(rest.slice(0, slash)) : "";
2119
+ const name = slash >= 0 ? path.basename(rest.slice(slash + 1)) : "";
2120
+ const filePath = path.join(tasksDir, taskId, "playtest", name);
2121
+ if (!taskId || !name || !name.endsWith(".png") || !fs.existsSync(filePath)) {
2122
+ res.writeHead(404).end();
2123
+ return true;
2124
+ }
2125
+ res.writeHead(200, { "content-type": "image/png", "cache-control": "no-store" });
2126
+ fs.createReadStream(filePath).pipe(res);
2127
+ return true;
2128
+ };
2129
+ }
1626
2130
  // Classify from the error strings runAgentCli actually produces (see its
1627
2131
  // child.on("error"), timeout, and close handlers).
1628
2132
  function classifyRouterFailure(error) {
@@ -1656,10 +2160,19 @@ function routerFailureCopy(opts) {
1656
2160
  // context + transcript replay (minus log lines and the in-flight reply) +
1657
2161
  // the live board + this turn's instruction.
1658
2162
  function routerTurnPrompt(ctx, instruction, selfMessageId) {
2163
+ // Smith-only, smaller budget than a task's -- see TASK_DECK_CONTENTS_BUDGET/
2164
+ // ROUTER_DECK_CONTENTS_BUDGET's comment (the router prompt is already the
2165
+ // largest one this serve builds).
2166
+ const isSmith = ctx.backend() === "smith";
1659
2167
  return buildRouterPrompt({
1660
2168
  deckLabel: ctx.deckLabel,
1661
2169
  quickReference: ctx.quickReference,
1662
- deckTree: buildDeckTree(ctx.deckDir),
2170
+ deckTree: buildDeckTree(ctx.deckDir, isSmith
2171
+ ? { maxEntries: DECK_TREE_SLIM_MAX_ENTRIES, perDir: DECK_TREE_SLIM_PER_DIR }
2172
+ : undefined),
2173
+ deckContents: isSmith
2174
+ ? buildDeckContents(ctx.deckDir, ROUTER_DECK_CONTENTS_BUDGET)
2175
+ : undefined,
1663
2176
  messages: ctx.log.messages
1664
2177
  .filter((m) => m.role !== "log" &&
1665
2178
  m.id !== selfMessageId &&
@@ -1759,7 +2272,10 @@ function spawnCompletedTaskFences(ctx, message, midStream, raw) {
1759
2272
  }
1760
2273
  // One router turn: stream a reply message, then spawn the directives it
1761
2274
  // emitted (unless a newer user message superseded this turn).
1762
- function runRouterTurnIn(ctx, instruction) {
2275
+ // `attachments` = deck-relative image paths from the drained sends; only
2276
+ // smith consumes them (image content blocks), the CLI backends read the
2277
+ // files themselves via the paths already named in the instruction text.
2278
+ function runRouterTurnIn(ctx, instruction, attachments = []) {
1763
2279
  const epoch = ctx.currentEpoch();
1764
2280
  const message = {
1765
2281
  id: nanoid(8),
@@ -1783,13 +2299,17 @@ function runRouterTurnIn(ctx, instruction) {
1783
2299
  ctx.broadcast({ type: "message-activity", id: message.id, activity: "Thinking" });
1784
2300
  const prompt = routerTurnPrompt(ctx, instruction, message.id);
1785
2301
  const backend = ctx.backend();
1786
- const invocation = buildAgentInvocation(backend, "router", prompt, ctx.claudeModel());
1787
- void runAgentCli({
2302
+ void runAgentTurn({
2303
+ backend,
2304
+ role: "router",
2305
+ prompt,
2306
+ claudeModel: ctx.claudeModel(),
2307
+ openrouterModel: ctx.openrouterModel(),
2308
+ attachments,
1788
2309
  cwd: ctx.deckDir,
1789
- command: invocation.command,
1790
- args: invocation.args,
1791
- parser: backend,
1792
2310
  timeoutMs: ROUTER_TIMEOUT_MS,
2311
+ // CLI backends append raw stream-json; smith appends structured run
2312
+ // events (see createRunLogger in native/loop.ts). Same file either way.
1793
2313
  logPath: path.join(ctx.agentDir, "router-log.jsonl"),
1794
2314
  children: ctx.children,
1795
2315
  labelUnknownTools: true,
@@ -1812,9 +2332,12 @@ function runRouterTurnIn(ctx, instruction) {
1812
2332
  lastActivity = activity;
1813
2333
  ctx.broadcast({ type: "message-activity", id: message.id, activity });
1814
2334
  },
2335
+ onThinking: (delta) => {
2336
+ ctx.messageThinking.append(message.id, delta);
2337
+ },
1815
2338
  })
1816
2339
  .then((result) => {
1817
- logRouterUsage(backend, result.usage);
2340
+ logAgentUsage("router", backend, result.usage);
1818
2341
  // Signals the finally -> onSettled(retryable): the turn failed cleanly
1819
2342
  // enough (transient, nothing salvaged) that the queue may re-run it.
1820
2343
  let retryable = false;
@@ -1940,10 +2463,19 @@ function applyAgentSettings(incoming, ctx) {
1940
2463
  changes.push(`${key} agent -> ${value}`);
1941
2464
  }
1942
2465
  }
1943
- const model = normalizeClaudeModel(incoming.claudeModel);
1944
- if (model && model !== settings.claudeModel) {
1945
- settings.claudeModel = model;
1946
- changes.push(`claude model -> ${model}`);
2466
+ for (const key of ["routerClaudeModel", "tasksClaudeModel"]) {
2467
+ const value = normalizeClaudeModel(incoming[key]);
2468
+ if (value && value !== settings[key]) {
2469
+ settings[key] = value;
2470
+ changes.push(`${key === "routerClaudeModel" ? "conductor" : "tasks"} claude model -> ${value}`);
2471
+ }
2472
+ }
2473
+ for (const key of ["routerOpenrouterModel", "tasksOpenrouterModel"]) {
2474
+ const value = normalizeOpenrouterModel(incoming[key]);
2475
+ if (value && value !== settings[key]) {
2476
+ settings[key] = value;
2477
+ changes.push(`${key === "routerOpenrouterModel" ? "conductor" : "tasks"} openrouter model -> ${value}`);
2478
+ }
1947
2479
  }
1948
2480
  if (changes.length === 0)
1949
2481
  return;
@@ -1980,7 +2512,11 @@ function startChildRegistry(registryPath, groups) {
1980
2512
  const live = [];
1981
2513
  for (const group of groups) {
1982
2514
  for (const child of group) {
1983
- if (typeof child.pid === "number" && child.exitCode === null) {
2515
+ // pid > 0 excludes smith runs (negative pseudo-pids, no OS process
2516
+ // for the orphan sweep to kill -- see AgentRunHandle).
2517
+ if (typeof child.pid === "number" &&
2518
+ child.pid > 0 &&
2519
+ child.exitCode === null) {
1984
2520
  live.push({ pid: child.pid, command: child.spawnfile });
1985
2521
  }
1986
2522
  }
@@ -2086,8 +2622,9 @@ function killRouterChildren(ctx) {
2086
2622
  }
2087
2623
  return drafts.join("\n\n");
2088
2624
  }
2089
- function startRouterTurn(ctx, instruction) {
2625
+ function startRouterTurn(ctx, instruction, attachments = []) {
2090
2626
  ctx.state.lastInstruction = instruction;
2627
+ ctx.state.lastAttachments = attachments;
2091
2628
  runRouterTurnIn({
2092
2629
  deckDir: ctx.deckDir,
2093
2630
  deckLabel: ctx.deckLabel,
@@ -2097,12 +2634,14 @@ function startRouterTurn(ctx, instruction) {
2097
2634
  log: ctx.log,
2098
2635
  broadcast: ctx.broadcast,
2099
2636
  taskStore: ctx.taskStore,
2637
+ messageThinking: ctx.messageThinking,
2100
2638
  currentEpoch: () => ctx.state.userEpoch,
2101
2639
  backend: () => ctx.settings.router,
2102
- claudeModel: () => ctx.settings.claudeModel,
2640
+ claudeModel: () => ctx.settings.routerClaudeModel,
2641
+ openrouterModel: () => ctx.settings.routerOpenrouterModel,
2103
2642
  canAutoRetry: () => !ctx.state.autoRetryUsed && ctx.state.pendingSends.length === 0,
2104
2643
  onSettled: (retryable) => onRouterQueueSettled(ctx, retryable),
2105
- }, instruction);
2644
+ }, instruction, attachments);
2106
2645
  }
2107
2646
  // Commit each drained send to the message log (skipping ones already logged
2108
2647
  // -- see PendingSend.logged -- so a re-carried send doesn't double its user
@@ -2157,7 +2696,7 @@ function maybeStartRouterQueueTurn(ctx) {
2157
2696
  messages: texts,
2158
2697
  interruptedDraft: draft || undefined,
2159
2698
  attachments: attachmentPaths,
2160
- }));
2699
+ }), attachmentPaths);
2161
2700
  }
2162
2701
  // The turn settled: clear the busy flag, broadcast it, then flush anything
2163
2702
  // that queued mid-turn (a clean end and an interrupt take the same path). A
@@ -2174,7 +2713,7 @@ function onRouterQueueSettled(ctx, retryable) {
2174
2713
  state.autoRetryUsed = true;
2175
2714
  state.routerRunning = true;
2176
2715
  broadcastQueueState(ctx);
2177
- startRouterTurn(ctx, state.lastInstruction);
2716
+ startRouterTurn(ctx, state.lastInstruction, state.lastAttachments);
2178
2717
  return;
2179
2718
  }
2180
2719
  broadcastQueueState(ctx);
@@ -2282,6 +2821,7 @@ function createRouterQueue(deps) {
2282
2821
  autoRetryUsed: false,
2283
2822
  autoFoldUsed: false,
2284
2823
  lastInstruction: "",
2824
+ lastAttachments: [],
2285
2825
  },
2286
2826
  };
2287
2827
  // Restart recovery: re-enqueue sends that never reached the message log,
@@ -2300,11 +2840,32 @@ function createRouterQueue(deps) {
2300
2840
  export function createAgentServer(opts) {
2301
2841
  const { deckDir, deckLabel } = opts;
2302
2842
  const quickReference = readQuickReference(deckDir);
2843
+ const welcomeMessage = readWelcomeMessage(deckDir) || DEFAULT_WELCOME_MESSAGE;
2303
2844
  const agentDir = path.join(deckDir, ".castle", "agent");
2304
2845
  const tasksDir = path.join(agentDir, "tasks");
2305
2846
  const attachmentsDir = path.join(agentDir, "attachments");
2306
2847
  const messagesPath = path.join(agentDir, "messages.json");
2307
2848
  fs.mkdirSync(tasksDir, { recursive: true });
2849
+ // ONE warm Chromium for the serve's entire lifetime, shared by every
2850
+ // playtest call (each call still gets its own fresh browser context +
2851
+ // page -- see playtest-executor.ts). Lazily launched on first use; never
2852
+ // touched at all if no task ever calls playtest.
2853
+ const playtestBrowserManager = createPlaytestBrowserManager();
2854
+ const playtest = opts.port
2855
+ ? {
2856
+ executor: createPlaywrightPlaytestExecutor(playtestBrowserManager),
2857
+ // `localhost`, NOT 127.0.0.1: a serve started without --host leaves
2858
+ // Vite on its default host, which binds ONLY [::1] on macOS --
2859
+ // 127.0.0.1 never answers there, and playtest would fail every call
2860
+ // with a navigation error. `localhost` resolves correctly under
2861
+ // every binding mode (default ::1, --host 0.0.0.0, explicit IPs).
2862
+ serveUrl: `http://localhost:${opts.port}`,
2863
+ // Kicks off the one-time Chromium download in the background at
2864
+ // smith-task start (see runTaskAgentIn) -- single-flight with any
2865
+ // real playtest call, silent on every failure path.
2866
+ prewarm: () => playtestBrowserManager.prewarm(),
2867
+ }
2868
+ : undefined;
2308
2869
  const taskChildren = new Set();
2309
2870
  const routerChildren = new Set();
2310
2871
  const clients = new Set();
@@ -2323,21 +2884,54 @@ export function createAgentServer(opts) {
2323
2884
  socket.send(payload);
2324
2885
  }
2325
2886
  }
2326
- const log = createMessageLog(messagesPath, broadcast);
2887
+ const log = createMessageLog(messagesPath, broadcast, welcomeMessage);
2327
2888
  const messages = log.messages;
2328
2889
  const addLog = (text) => log.addLog(text);
2329
2890
  // Which CLI backs the router and the task agents -- independently
2330
2891
  // switchable from the settings popover, persisted next to the chat state.
2331
2892
  const settingsPath = path.join(agentDir, "settings.json");
2893
+ // Loosely typed on purpose: the file may carry legacy values the current
2894
+ // types no longer admit (backend "openrouter", single openrouterModel /
2895
+ // claudeModel) -- every field goes through a normalizer/migrator below.
2332
2896
  const storedSettings = readJsonFile(settingsPath);
2897
+ // Three legacy shapes migrate on load, chained oldest-first (rewritten in
2898
+ // the new shape on the next settings change):
2899
+ // - a single `openrouterModel` (pre per-role split) seeds BOTH per-role
2900
+ // slug fields;
2901
+ // - a single shared `claudeModel` (pre per-role split) seeds BOTH per-role
2902
+ // claude-model fields;
2903
+ // - backend value "openrouter" ON A ROLE (pre-smith: it meant that role
2904
+ // ran claude-via-OpenRouter) becomes backend "claude" + THAT role's
2905
+ // claude model "openrouter" -- exactly that behavior today, and now
2906
+ // per-role: one legacy openrouter role no longer drags the other role's
2907
+ // model along.
2908
+ const legacyOpenrouterModel = normalizeOpenrouterModel(storedSettings?.openrouterModel);
2909
+ const legacySharedClaudeModel = normalizeClaudeModel(storedSettings?.claudeModel);
2910
+ const roleClaudeModel = (role) => {
2911
+ // A stored per-role value wins; a legacy openrouter BACKEND on this role
2912
+ // forces "openrouter" (those two never coexist in one file -- per-role
2913
+ // fields postdate the openrouter backend's removal); else the legacy
2914
+ // shared claudeModel, else the default.
2915
+ return (normalizeClaudeModel(storedSettings?.[`${role}ClaudeModel`]) ??
2916
+ (storedSettings?.[role] === "openrouter"
2917
+ ? "openrouter"
2918
+ : (legacySharedClaudeModel ?? DEFAULT_SETTINGS[`${role}ClaudeModel`])));
2919
+ };
2333
2920
  const settings = {
2334
- router: normalizeBackend(storedSettings?.router) ?? DEFAULT_SETTINGS.router,
2335
- tasks: normalizeBackend(storedSettings?.tasks) ?? DEFAULT_SETTINGS.tasks,
2336
- claudeModel: normalizeClaudeModel(storedSettings?.claudeModel) ??
2337
- DEFAULT_SETTINGS.claudeModel,
2921
+ router: migrateStoredBackend(storedSettings?.router) ?? DEFAULT_SETTINGS.router,
2922
+ tasks: migrateStoredBackend(storedSettings?.tasks) ?? DEFAULT_SETTINGS.tasks,
2923
+ routerClaudeModel: roleClaudeModel("router"),
2924
+ tasksClaudeModel: roleClaudeModel("tasks"),
2925
+ routerOpenrouterModel: normalizeOpenrouterModel(storedSettings?.routerOpenrouterModel) ??
2926
+ legacyOpenrouterModel ??
2927
+ DEFAULT_SETTINGS.routerOpenrouterModel,
2928
+ tasksOpenrouterModel: normalizeOpenrouterModel(storedSettings?.tasksOpenrouterModel) ??
2929
+ legacyOpenrouterModel ??
2930
+ DEFAULT_SETTINGS.tasksOpenrouterModel,
2338
2931
  };
2339
2932
  const applySettings = (incoming) => applyAgentSettings(incoming, { settings, settingsPath, broadcast });
2340
2933
  const taskFeeds = createTaskFeeds(broadcast);
2934
+ const messageThinking = createMessageThinking(broadcast);
2341
2935
  const taskStore = createTaskStore({
2342
2936
  deckDir,
2343
2937
  deckLabel,
@@ -2345,7 +2939,10 @@ export function createAgentServer(opts) {
2345
2939
  children: taskChildren,
2346
2940
  quickReference,
2347
2941
  backend: () => settings.tasks,
2348
- claudeModel: () => settings.claudeModel,
2942
+ openrouterModel: () => settings.tasksOpenrouterModel,
2943
+ claudeModel: () => settings.tasksClaudeModel,
2944
+ playtest,
2945
+ restart: opts.restart,
2349
2946
  // Task lifecycle stays on the board only -- log lines for it were spam.
2350
2947
  onUpdate: (task) => broadcast({ type: "task-update", task: asClientTask(task) }),
2351
2948
  onStarted: () => undefined,
@@ -2365,6 +2962,7 @@ export function createAgentServer(opts) {
2365
2962
  log,
2366
2963
  broadcast,
2367
2964
  taskStore,
2965
+ messageThinking,
2368
2966
  messages,
2369
2967
  settings,
2370
2968
  });
@@ -2394,6 +2992,7 @@ export function createAgentServer(opts) {
2394
2992
  tasks: taskStore.sorted().map(asClientTask),
2395
2993
  settings,
2396
2994
  feeds: Object.fromEntries(taskFeeds.map),
2995
+ thinking: messageThinking.snapshot(),
2397
2996
  running: routerQueue.isRunning(),
2398
2997
  queued: routerQueue.queuedSnippets(),
2399
2998
  };
@@ -2438,7 +3037,11 @@ export function createAgentServer(opts) {
2438
3037
  wss.handleUpgrade(req, socket, head, (ws) => attachClient(ws));
2439
3038
  return true;
2440
3039
  }
2441
- const handleHttpRequest = makeAttachmentHandler(attachmentsDir);
3040
+ const handleAttachment = makeAttachmentHandler(attachmentsDir);
3041
+ const handlePlaytestFrame = makePlaytestFrameHandler(tasksDir);
3042
+ function handleHttpRequest(req, res, reqPath) {
3043
+ return handleAttachment(req, res, reqPath) || handlePlaytestFrame(req, res, reqPath);
3044
+ }
2442
3045
  function shutdown() {
2443
3046
  taskStore.shutdown();
2444
3047
  for (const child of [...taskChildren, ...routerChildren]) {
@@ -2451,6 +3054,7 @@ export function createAgentServer(opts) {
2451
3054
  }
2452
3055
  stopChildRegistry();
2453
3056
  wss.close();
3057
+ void playtestBrowserManager.shutdown();
2454
3058
  }
2455
3059
  return { handleUpgrade, handleHttpRequest, shutdown };
2456
3060
  }