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.
- package/dist/agent-prompts.d.ts +4 -1
- package/dist/agent-prompts.js +28 -7
- package/dist/agent.d.ts +7 -2
- package/dist/agent.js +655 -51
- package/dist/native/loop.d.ts +2 -0
- package/dist/native/loop.js +698 -0
- package/dist/native/openrouter.d.ts +55 -0
- package/dist/native/openrouter.js +354 -0
- package/dist/native/playtest-browser.d.ts +34 -0
- package/dist/native/playtest-browser.js +354 -0
- package/dist/native/playtest-executor.d.ts +3 -0
- package/dist/native/playtest-executor.js +156 -0
- package/dist/native/playtest.d.ts +131 -0
- package/dist/native/playtest.js +314 -0
- package/dist/native/tools.d.ts +38 -0
- package/dist/native/tools.js +630 -0
- package/dist/native/types.d.ts +40 -0
- package/dist/native/types.js +41 -0
- package/dist/serve.js +12 -0
- package/dist/shell/assets/{index-yGdKhgfZ.js → index-CNT3KxJb.js} +37 -37
- package/dist/shell/assets/{index-WE24qX3d.css → index-RZrw5gQ2.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/kits/basic-2d/CLAUDE.md +29 -3
- package/kits/basic-2d/behaviors/Layout.jsx +10 -0
- package/kits/basic-2d/behaviors/Sprite.jsx +1 -1
- package/kits/basic-2d/blueprints/cauldron.scene +22 -0
- package/kits/basic-2d/castle.json +5 -7
- package/kits/basic-2d/docs/pxart-format.md +4 -3
- package/kits/basic-2d/drawings/cauldron.pxart +113 -0
- package/kits/basic-2d/editors/BlueprintLibrary.jsx +247 -0
- package/kits/basic-2d/editors/PlayOnly.jsx +1 -0
- package/kits/basic-2d/editors/SceneEditor.jsx +399 -411
- package/kits/basic-2d/editors/SelectionOverlay.jsx +125 -63
- package/kits/basic-2d/editors/SingleEditor.jsx +11 -2
- package/kits/basic-2d/editors/editorHistory.js +8 -2
- package/kits/basic-2d/editors/inspectorSheet.js +5 -19
- package/kits/basic-2d/engine/ScenePlayer.jsx +2 -2
- package/kits/basic-2d/engine/blueprint.js +423 -0
- package/kits/basic-2d/engine/files.js +1 -1
- package/kits/basic-2d/engine/scene.js +29 -29
- package/kits/basic-2d/engine/ui.jsx +160 -21
- package/kits/basic-2d/engine/ui.module.css +155 -13
- package/kits/basic-2d/pnpm-workspace.yaml +3 -0
- package/kits/basic-2d/scenes/main.scene +3 -13
- package/package.json +2 -1
- 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
|
-
|
|
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"
|
|
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" ||
|
|
60
|
+
return value === "sonnet" ||
|
|
61
|
+
value === "opus" ||
|
|
62
|
+
value === "fable" ||
|
|
63
|
+
value === "openrouter"
|
|
37
64
|
? value
|
|
38
65
|
: null;
|
|
39
66
|
}
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
function
|
|
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
|
-
|
|
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,
|
|
454
|
-
if (lines.length >=
|
|
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(
|
|
465
|
-
if (rest.length > 0 && lines.length <
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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]
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1787
|
-
|
|
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
|
-
|
|
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
|
|
1944
|
-
|
|
1945
|
-
settings
|
|
1946
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
2335
|
-
tasks:
|
|
2336
|
-
|
|
2337
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|