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
@@ -0,0 +1,630 @@
1
+ // Tool schemas + executors for the native agent loop (native/loop.ts). Role
2
+ // scoping mirrors today's CLI trust levels (agent.ts's buildAgentInvocation):
3
+ // the router is read-only (read_file/list_files/grep/view_image), task agents
4
+ // get everything including bash at full-shell trust (matches the existing
5
+ // `--force` level -- ratified, not revisited here; sandboxing is a later,
6
+ // separate concern for end users).
7
+ //
8
+ // Every call goes through `executeTool`, which is the ratified
9
+ // policy-checkable seam: role scoping is enforced there (not just by
10
+ // omitting a tool's schema from what the model is offered -- a model can
11
+ // still hallucinate a call to a tool it wasn't given), and an optional
12
+ // `checkPolicy` hook on ToolExecContext lets a future caller reject
13
+ // individual calls (e.g. "don't write to a sibling-claimed drawing file")
14
+ // without touching the executors themselves. No policy exists yet --
15
+ // `checkPolicy` defaults to allow-everything.
16
+ import { spawn } from "child_process";
17
+ import * as fs from "fs";
18
+ import * as path from "path";
19
+ import picomatch from "picomatch";
20
+ import { PLAYTEST_TOOL_DESCRIPTION, PLAYTEST_TOOL_PARAMETERS, runPlaytest, } from "./playtest.js";
21
+ function err(message) {
22
+ return { ok: false, output: `Error: ${message}` };
23
+ }
24
+ // Mirrors PROGRESS_FILE_RE / the .castle/ exclusion in agent.ts's
25
+ // normalizeTouchedPath, so a native task's filesTouched reads the same as a
26
+ // CLI task's once wired together.
27
+ const PROGRESS_FILE_RE = /\.castle\/agent\/tasks\/[^/]+\/progress$/;
28
+ function isTrackedTouch(rel) {
29
+ if (rel.startsWith(".castle/"))
30
+ return false;
31
+ if (PROGRESS_FILE_RE.test(rel))
32
+ return false;
33
+ return true;
34
+ }
35
+ // Mirrors DECK_TREE_EXCLUDE in agent.ts.
36
+ const IGNORED_DIRS = new Set(["node_modules", ".castle", ".git", "dist", ".DS_Store"]);
37
+ const MAX_WALK_FILES = 20_000;
38
+ function baseName(p) {
39
+ const parts = p.split(/[\\/]/).filter(Boolean);
40
+ return parts[parts.length - 1] || p;
41
+ }
42
+ // Resolves a tool-supplied path against the deck dir and rejects any escape
43
+ // (absolute paths outside it, `..` traversal). Returns both the absolute path
44
+ // and a deck-root-relative path (forward-slashed, for display and for
45
+ // filesTouched entries).
46
+ function resolveInDeck(deckDir, rawPath) {
47
+ if (typeof rawPath !== "string" || rawPath.trim() === "")
48
+ return null;
49
+ const abs = path.resolve(deckDir, rawPath);
50
+ const rel = path.relative(deckDir, abs);
51
+ if (rel.startsWith("..") || path.isAbsolute(rel))
52
+ return null;
53
+ return { abs, rel: rel.split(path.sep).join("/") };
54
+ }
55
+ // Sorted recursive file walk (deterministic order matters for grep/list_files
56
+ // results and for tests), skipping IGNORED_DIRS at any depth. Capped as a
57
+ // safety valve against pathological trees, not as a normal limit -- decks are
58
+ // modest-sized web projects.
59
+ function walkFiles(root) {
60
+ const out = [];
61
+ const walk = (dir) => {
62
+ if (out.length >= MAX_WALK_FILES)
63
+ return;
64
+ let entries;
65
+ try {
66
+ entries = fs.readdirSync(dir, { withFileTypes: true });
67
+ }
68
+ catch {
69
+ return;
70
+ }
71
+ entries.sort((a, b) => a.name.localeCompare(b.name));
72
+ for (const entry of entries) {
73
+ if (IGNORED_DIRS.has(entry.name))
74
+ continue;
75
+ const abs = path.join(dir, entry.name);
76
+ if (entry.isDirectory()) {
77
+ walk(abs);
78
+ }
79
+ else if (entry.isFile()) {
80
+ out.push(abs);
81
+ if (out.length >= MAX_WALK_FILES)
82
+ return;
83
+ }
84
+ }
85
+ };
86
+ walk(root);
87
+ return out;
88
+ }
89
+ // -- read_file ----------------------------------------------------------------
90
+ const READ_RAW_SIZE_CAP = 2 * 1024 * 1024; // 2MB
91
+ const READ_DEFAULT_LINE_LIMIT = 2000;
92
+ // Extensions read_file refuses outright as binary: dumping decoded-as-utf8
93
+ // garbage into the context helps nobody and burns tokens. Ratified decision
94
+ // (2026-07): images reach the model as image content parts -- user
95
+ // attachments ride the initial message, and any deck image is viewable via
96
+ // the view_image tool -- so the error redirects there instead of returning
97
+ // raw bytes. Grep's \0 sniff below catches binaries this extension list
98
+ // misses.
99
+ const BINARY_READ_EXTS = new Set([
100
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico",
101
+ ".mp3", ".wav", ".ogg", ".mp4", ".webm", ".mov",
102
+ ".woff", ".woff2", ".ttf", ".otf", ".zip", ".tgz", ".gz", ".wasm",
103
+ ]);
104
+ function readFileRun(args, ctx) {
105
+ const resolved = resolveInDeck(ctx.deckDir, args.path);
106
+ if (!resolved)
107
+ return err(`path escapes the deck directory: ${String(args.path)}`);
108
+ let stat;
109
+ try {
110
+ stat = fs.statSync(resolved.abs);
111
+ }
112
+ catch {
113
+ return err(`no such file: ${resolved.rel}`);
114
+ }
115
+ if (!stat.isFile())
116
+ return err(`not a file: ${resolved.rel}`);
117
+ const ext = path.extname(resolved.rel).toLowerCase();
118
+ if (BINARY_READ_EXTS.has(ext)) {
119
+ return err(`${resolved.rel} is a binary/image file -- it cannot be read as text. Use the view_image tool to look at image files.`);
120
+ }
121
+ const offset = Number.isInteger(args.offset) && args.offset > 0 ? args.offset : 1;
122
+ const limit = Number.isInteger(args.limit) && args.limit > 0 ? args.limit : undefined;
123
+ if (stat.size > READ_RAW_SIZE_CAP && offset === 1 && limit === undefined) {
124
+ return err(`${resolved.rel} is too large to read in full (> ${READ_RAW_SIZE_CAP} bytes) -- pass offset/limit to read a slice.`);
125
+ }
126
+ let content;
127
+ try {
128
+ content = fs.readFileSync(resolved.abs, "utf8");
129
+ }
130
+ catch (e) {
131
+ return err(`could not read ${resolved.rel}: ${e instanceof Error ? e.message : String(e)}`);
132
+ }
133
+ if (content.includes("\u0000")) {
134
+ return err(`${resolved.rel} looks binary (contains NUL bytes) -- it cannot be read as text.`);
135
+ }
136
+ const lines = content.split("\n");
137
+ const effectiveLimit = limit ?? READ_DEFAULT_LINE_LIMIT;
138
+ const slice = lines.slice(offset - 1, offset - 1 + effectiveLimit);
139
+ const numbered = slice.map((l, i) => `${offset + i}\t${l}`).join("\n");
140
+ const truncated = offset - 1 + effectiveLimit < lines.length;
141
+ return {
142
+ ok: true,
143
+ output: numbered + (truncated ? `\n... (${lines.length - (offset - 1 + effectiveLimit)} more lines -- use offset/limit to continue)` : ""),
144
+ };
145
+ }
146
+ // -- view_image -----------------------------------------------------------------
147
+ // Raster formats vision models accept as data URIs. SVG is deliberately
148
+ // absent: it's a text format the model can read (and reason about) via
149
+ // read_file, and providers don't rasterize it.
150
+ const VIEW_IMAGE_MIME = {
151
+ ".png": "image/png",
152
+ ".jpg": "image/jpeg",
153
+ ".jpeg": "image/jpeg",
154
+ ".gif": "image/gif",
155
+ ".webp": "image/webp",
156
+ };
157
+ // Pre-base64 cap. Matches the serve's own attachment ingest ceiling closely
158
+ // enough that anything a user could paste is viewable; deck art beyond this
159
+ // is almost certainly not something a model needs at full resolution.
160
+ const VIEW_IMAGE_SIZE_CAP = 4 * 1024 * 1024;
161
+ // Delivery note (the wrinkle this tool is built around): in the OpenAI
162
+ // chat-completions shape, role:"tool" messages accept only TEXT content
163
+ // reliably -- image blocks inside tool results are not consistently
164
+ // supported across the providers OpenRouter fronts. The robust pattern
165
+ // (used by every major harness) is: the tool result SAYS the image follows,
166
+ // and the loop appends a synthetic role:"user" message carrying the actual
167
+ // image_url block immediately after the batch's tool results. That is what
168
+ // `imageDataUrl` implements -- see runToolCalls in loop.ts.
169
+ //
170
+ // Path confinement is the same resolveInDeck as every file tool; note that
171
+ // user attachments live at .castle/agent/attachments/ INSIDE the deck, so
172
+ // they are reachable here (the .castle/ exclusions elsewhere apply to
173
+ // filesTouched tracking and tree walks, never to reads).
174
+ function viewImageRun(args, ctx) {
175
+ const resolved = resolveInDeck(ctx.deckDir, args.path);
176
+ if (!resolved)
177
+ return err(`path escapes the deck directory: ${String(args.path)}`);
178
+ const mime = VIEW_IMAGE_MIME[path.extname(resolved.rel).toLowerCase()];
179
+ if (!mime) {
180
+ return err(`${resolved.rel} is not a viewable image -- view_image supports png/jpg/jpeg/gif/webp. SVG and other text formats can be read with read_file.`);
181
+ }
182
+ let stat;
183
+ try {
184
+ stat = fs.statSync(resolved.abs);
185
+ }
186
+ catch {
187
+ return err(`no such file: ${resolved.rel}`);
188
+ }
189
+ if (!stat.isFile())
190
+ return err(`not a file: ${resolved.rel}`);
191
+ if (stat.size > VIEW_IMAGE_SIZE_CAP) {
192
+ return err(`${resolved.rel} is too large to view (${(stat.size / (1024 * 1024)).toFixed(1)}MB > ${VIEW_IMAGE_SIZE_CAP / (1024 * 1024)}MB).`);
193
+ }
194
+ let data;
195
+ try {
196
+ data = fs.readFileSync(resolved.abs);
197
+ }
198
+ catch (e) {
199
+ return err(`could not read ${resolved.rel}: ${e instanceof Error ? e.message : String(e)}`);
200
+ }
201
+ return {
202
+ ok: true,
203
+ output: `Viewing ${resolved.rel} (${mime}, ${(stat.size / 1024).toFixed(1)}KB) -- the image follows in the next message.`,
204
+ images: [{ label: `view_image ${resolved.rel}`, dataUrl: `data:${mime};base64,${data.toString("base64")}` }],
205
+ };
206
+ }
207
+ // -- write_file -----------------------------------------------------------------
208
+ function writeFileRun(args, ctx) {
209
+ const resolved = resolveInDeck(ctx.deckDir, args.path);
210
+ if (!resolved)
211
+ return err(`path escapes the deck directory: ${String(args.path)}`);
212
+ if (typeof args.content !== "string")
213
+ return err("write_file requires a `content` string.");
214
+ try {
215
+ fs.mkdirSync(path.dirname(resolved.abs), { recursive: true });
216
+ fs.writeFileSync(resolved.abs, args.content, "utf8");
217
+ }
218
+ catch (e) {
219
+ return err(`could not write ${resolved.rel}: ${e instanceof Error ? e.message : String(e)}`);
220
+ }
221
+ const filesTouched = isTrackedTouch(resolved.rel) ? [resolved.rel] : [];
222
+ return {
223
+ ok: true,
224
+ output: `Wrote ${resolved.rel} (${Buffer.byteLength(args.content, "utf8")} bytes).`,
225
+ filesTouched,
226
+ };
227
+ }
228
+ // -- edit_file ------------------------------------------------------------------
229
+ function countOccurrences(haystack, needle) {
230
+ return needle === "" ? 0 : haystack.split(needle).length - 1;
231
+ }
232
+ function editFileRun(args, ctx) {
233
+ const resolved = resolveInDeck(ctx.deckDir, args.path);
234
+ if (!resolved)
235
+ return err(`path escapes the deck directory: ${String(args.path)}`);
236
+ if (typeof args.old_string !== "string" || typeof args.new_string !== "string") {
237
+ return err("edit_file requires `old_string` and `new_string` strings.");
238
+ }
239
+ if (args.old_string === "")
240
+ return err("old_string must not be empty.");
241
+ if (args.old_string === args.new_string) {
242
+ return err("old_string and new_string are identical -- nothing to change.");
243
+ }
244
+ let content;
245
+ try {
246
+ content = fs.readFileSync(resolved.abs, "utf8");
247
+ }
248
+ catch {
249
+ return err(`no such file: ${resolved.rel}`);
250
+ }
251
+ const count = countOccurrences(content, args.old_string);
252
+ if (count === 0)
253
+ return err(`old_string not found in ${resolved.rel}.`);
254
+ const replaceAll = args.replace_all === true;
255
+ if (count > 1 && !replaceAll) {
256
+ return err(`old_string appears ${count} times in ${resolved.rel} -- it must uniquely identify one location. Include more surrounding context, or pass replace_all: true to replace every occurrence.`);
257
+ }
258
+ const next = replaceAll
259
+ ? content.split(args.old_string).join(args.new_string)
260
+ : content.replace(args.old_string, args.new_string);
261
+ try {
262
+ fs.writeFileSync(resolved.abs, next, "utf8");
263
+ }
264
+ catch (e) {
265
+ return err(`could not write ${resolved.rel}: ${e instanceof Error ? e.message : String(e)}`);
266
+ }
267
+ const filesTouched = isTrackedTouch(resolved.rel) ? [resolved.rel] : [];
268
+ return {
269
+ ok: true,
270
+ output: `Edited ${resolved.rel}${replaceAll ? ` (${count} replacements)` : ""}.`,
271
+ filesTouched,
272
+ };
273
+ }
274
+ // -- list_files -----------------------------------------------------------------
275
+ const LIST_FILES_MAX_RESULTS = 500;
276
+ function listFilesRun(args, ctx) {
277
+ const startPath = typeof args.path === "string" && args.path ? args.path : ".";
278
+ const resolved = resolveInDeck(ctx.deckDir, startPath);
279
+ if (!resolved)
280
+ return err(`path escapes the deck directory: ${startPath}`);
281
+ if (!fs.existsSync(resolved.abs))
282
+ return err(`no such directory: ${resolved.rel || "."}`);
283
+ const pattern = typeof args.pattern === "string" && args.pattern ? args.pattern : "**/*";
284
+ let isMatch;
285
+ try {
286
+ isMatch = picomatch(pattern, { dot: false });
287
+ }
288
+ catch (e) {
289
+ return err(`invalid glob pattern: ${e instanceof Error ? e.message : String(e)}`);
290
+ }
291
+ const files = walkFiles(resolved.abs);
292
+ const matches = [];
293
+ for (const abs of files) {
294
+ const relFromStart = path.relative(resolved.abs, abs).split(path.sep).join("/");
295
+ if (!isMatch(relFromStart))
296
+ continue;
297
+ matches.push(path.relative(ctx.deckDir, abs).split(path.sep).join("/"));
298
+ if (matches.length >= LIST_FILES_MAX_RESULTS)
299
+ break;
300
+ }
301
+ if (matches.length === 0)
302
+ return { ok: true, output: "(no files matched)" };
303
+ const truncated = matches.length >= LIST_FILES_MAX_RESULTS;
304
+ return {
305
+ ok: true,
306
+ output: matches.join("\n") + (truncated ? `\n... (capped at ${LIST_FILES_MAX_RESULTS} results)` : ""),
307
+ };
308
+ }
309
+ // -- grep -------------------------------------------------------------------
310
+ const GREP_MAX_RESULTS = 200;
311
+ function grepRun(args, ctx) {
312
+ if (typeof args.pattern !== "string" || args.pattern === "") {
313
+ return err("grep requires a non-empty `pattern` string.");
314
+ }
315
+ let re;
316
+ try {
317
+ re = new RegExp(args.pattern, args.case_insensitive === true ? "i" : "");
318
+ }
319
+ catch (e) {
320
+ return err(`invalid regular expression: ${e instanceof Error ? e.message : String(e)}`);
321
+ }
322
+ const startPath = typeof args.path === "string" && args.path ? args.path : ".";
323
+ const resolved = resolveInDeck(ctx.deckDir, startPath);
324
+ if (!resolved)
325
+ return err(`path escapes the deck directory: ${startPath}`);
326
+ if (!fs.existsSync(resolved.abs))
327
+ return err(`no such directory: ${resolved.rel || "."}`);
328
+ let globMatch = null;
329
+ if (typeof args.glob === "string" && args.glob) {
330
+ try {
331
+ globMatch = picomatch(args.glob, { dot: false });
332
+ }
333
+ catch (e) {
334
+ return err(`invalid glob pattern: ${e instanceof Error ? e.message : String(e)}`);
335
+ }
336
+ }
337
+ const files = walkFiles(resolved.abs);
338
+ const results = [];
339
+ outer: for (const abs of files) {
340
+ const relFromDeck = path.relative(ctx.deckDir, abs).split(path.sep).join("/");
341
+ if (globMatch && !globMatch(relFromDeck))
342
+ continue;
343
+ let content;
344
+ try {
345
+ content = fs.readFileSync(abs, "utf8");
346
+ }
347
+ catch {
348
+ continue;
349
+ }
350
+ if (content.includes("\u0000"))
351
+ continue; // looks binary -- skip
352
+ const lines = content.split("\n");
353
+ for (let i = 0; i < lines.length; i++) {
354
+ if (re.test(lines[i])) {
355
+ results.push(`${relFromDeck}:${i + 1}:${lines[i]}`);
356
+ if (results.length >= GREP_MAX_RESULTS)
357
+ break outer;
358
+ }
359
+ }
360
+ }
361
+ if (results.length === 0)
362
+ return { ok: true, output: "(no matches)" };
363
+ const truncated = results.length >= GREP_MAX_RESULTS;
364
+ return {
365
+ ok: true,
366
+ output: results.join("\n") + (truncated ? `\n... (capped at ${GREP_MAX_RESULTS} matches)` : ""),
367
+ };
368
+ }
369
+ // -- bash -------------------------------------------------------------------
370
+ // Full shell, trusted -- matches today's --force trust level for task agents
371
+ // (ratified; not revisited here). cwd is always the deck dir; per-call
372
+ // timeout defaults to 2 minutes and can be raised up to 10 minutes by the
373
+ // model for known-slow commands (npm install, etc.), never higher.
374
+ const BASH_DEFAULT_TIMEOUT_MS = 2 * 60_000;
375
+ const BASH_MAX_TIMEOUT_MS = 10 * 60_000;
376
+ // Success and failure get different caps: every tool result stays in context
377
+ // and gets re-read on every later iteration, so a clean exit only needs
378
+ // enough output to confirm it worked (npm install's dependency tree, a build's
379
+ // asset list) -- 4KB is generous for that. A NONZERO exit is a different
380
+ // case entirely: stderr/stdout there is how the model debugs, so it keeps the
381
+ // old, much larger cap rather than risk truncating away the actual error.
382
+ const BASH_OUTPUT_CAP_OK = 4_000;
383
+ const BASH_OUTPUT_CAP_ERROR = 20_000;
384
+ function bashRun(args, ctx) {
385
+ if (typeof args.command !== "string" || args.command.trim() === "") {
386
+ return Promise.resolve(err("bash requires a non-empty `command` string."));
387
+ }
388
+ const command = args.command;
389
+ const requested = Number.isInteger(args.timeout_ms) ? args.timeout_ms : undefined;
390
+ const timeoutMs = requested && requested > 0 ? Math.min(requested, BASH_MAX_TIMEOUT_MS) : BASH_DEFAULT_TIMEOUT_MS;
391
+ return new Promise((resolve) => {
392
+ let child;
393
+ try {
394
+ child = spawn(command, {
395
+ shell: true,
396
+ cwd: ctx.deckDir,
397
+ timeout: timeoutMs,
398
+ killSignal: "SIGKILL",
399
+ signal: ctx.signal,
400
+ stdio: ["ignore", "pipe", "pipe"],
401
+ });
402
+ }
403
+ catch (e) {
404
+ resolve(err(`could not run command: ${e instanceof Error ? e.message : String(e)}`));
405
+ return;
406
+ }
407
+ let out = "";
408
+ child.stdout?.on("data", (d) => {
409
+ out += d.toString("utf8");
410
+ });
411
+ child.stderr?.on("data", (d) => {
412
+ out += d.toString("utf8");
413
+ });
414
+ child.on("error", (e) => {
415
+ resolve(err(`could not run command: ${e.message}`));
416
+ });
417
+ child.on("close", (code, signal) => {
418
+ const ok = code === 0;
419
+ const cap = ok ? BASH_OUTPUT_CAP_OK : BASH_OUTPUT_CAP_ERROR;
420
+ const capped = out.length > cap
421
+ ? `${out.slice(0, cap)}\n... (output truncated at ${cap} chars)`
422
+ : out;
423
+ const timedOutNote = code === null && signal ? `\n[terminated by signal ${signal} -- likely the ${timeoutMs}ms per-command timeout]` : "";
424
+ // The command itself is NOT echoed back here -- it's already sitting in
425
+ // the assistant's own tool_call (which, unlike this result, is never
426
+ // evicted from context -- see evictOldToolResults in loop.ts), so
427
+ // repeating it would just be the same bytes twice on every later turn.
428
+ resolve({
429
+ ok,
430
+ output: `(exit ${code ?? "null"})\n${capped}${timedOutNote}`,
431
+ });
432
+ });
433
+ });
434
+ }
435
+ // -- restart ------------------------------------------------------------------
436
+ // In-process replacement for the bash pattern `npm run restart`: that command
437
+ // spawns node + the castle-web CLI + a WebSocket client just to send the
438
+ // serve ONE `{type:'restart'}` message -- wasted process-spawn latency when
439
+ // the smith loop and the serve already share a process (unlike cursor/claude,
440
+ // which really do need a subprocess to reach the serve). ctx.restart (wired
441
+ // from createAgentServer -> serve.ts) does the exact same invalidate-then-
442
+ // reload-every-tab work directly.
443
+ function restartRun(ctx) {
444
+ if (!ctx.restart) {
445
+ return err("restart has no in-process hook wired up in this context (this deck isn't being run through a serve that supports it) -- fall back to `bash: npm run restart`.");
446
+ }
447
+ ctx.restart();
448
+ return { ok: true, output: "Reloaded the deck in every connected browser tab." };
449
+ }
450
+ const READ_ONLY_ROLES = ["router", "task"];
451
+ const TASK_ONLY_ROLES = ["task"];
452
+ const TOOLS = [
453
+ {
454
+ name: "read_file",
455
+ description: "Read a text file from the deck, optionally a line slice. Text only -- image/binary files are rejected (use view_image for images).",
456
+ parameters: {
457
+ type: "object",
458
+ properties: {
459
+ path: { type: "string", description: "File path relative to the deck directory." },
460
+ offset: { type: "integer", description: "1-indexed line number to start reading from." },
461
+ limit: { type: "integer", description: "Maximum number of lines to read." },
462
+ },
463
+ required: ["path"],
464
+ },
465
+ roles: READ_ONLY_ROLES,
466
+ run: readFileRun,
467
+ },
468
+ {
469
+ name: "list_files",
470
+ description: "List files in the deck matching a glob pattern.",
471
+ parameters: {
472
+ type: "object",
473
+ properties: {
474
+ pattern: { type: "string", description: "Glob pattern, e.g. '**/*.js'. Defaults to '**/*'." },
475
+ path: { type: "string", description: "Directory to search from, relative to the deck root. Defaults to '.'." },
476
+ },
477
+ },
478
+ roles: READ_ONLY_ROLES,
479
+ run: listFilesRun,
480
+ },
481
+ {
482
+ name: "grep",
483
+ description: "Search file contents in the deck for a regular expression.",
484
+ parameters: {
485
+ type: "object",
486
+ properties: {
487
+ pattern: { type: "string", description: "Regular expression to search for." },
488
+ path: { type: "string", description: "Directory to search from. Defaults to the deck root." },
489
+ glob: { type: "string", description: "Only search files matching this glob." },
490
+ case_insensitive: { type: "boolean" },
491
+ },
492
+ required: ["pattern"],
493
+ },
494
+ roles: READ_ONLY_ROLES,
495
+ run: grepRun,
496
+ },
497
+ {
498
+ name: "view_image",
499
+ description: "Look at an image file in the deck (png/jpg/jpeg/gif/webp) -- the image is delivered to you in a follow-up message. Use this for user-attached image paths and deck art; read_file rejects binaries.",
500
+ parameters: {
501
+ type: "object",
502
+ properties: {
503
+ path: { type: "string", description: "Image path relative to the deck directory." },
504
+ },
505
+ required: ["path"],
506
+ },
507
+ // Read-only, so both roles get it: the router may want to look at deck
508
+ // images or user attachments referenced by path just as much as a task.
509
+ roles: READ_ONLY_ROLES,
510
+ run: viewImageRun,
511
+ },
512
+ {
513
+ name: "write_file",
514
+ description: "Create or overwrite a file in the deck, creating parent directories as needed.",
515
+ parameters: {
516
+ type: "object",
517
+ properties: {
518
+ path: { type: "string", description: "File path relative to the deck directory." },
519
+ content: { type: "string", description: "Full file contents to write." },
520
+ },
521
+ required: ["path", "content"],
522
+ },
523
+ roles: TASK_ONLY_ROLES,
524
+ run: writeFileRun,
525
+ },
526
+ {
527
+ name: "edit_file",
528
+ description: "Replace an exact substring in an existing file. old_string must uniquely identify one location unless replace_all is set.",
529
+ parameters: {
530
+ type: "object",
531
+ properties: {
532
+ path: { type: "string", description: "File path relative to the deck directory." },
533
+ old_string: { type: "string", description: "Exact text to find." },
534
+ new_string: { type: "string", description: "Text to replace it with." },
535
+ replace_all: { type: "boolean", description: "Replace every occurrence instead of requiring uniqueness." },
536
+ },
537
+ required: ["path", "old_string", "new_string"],
538
+ },
539
+ roles: TASK_ONLY_ROLES,
540
+ run: editFileRun,
541
+ },
542
+ {
543
+ name: "bash",
544
+ description: "Run a shell command in the deck directory.",
545
+ parameters: {
546
+ type: "object",
547
+ properties: {
548
+ command: { type: "string", description: "Shell command to run." },
549
+ timeout_ms: { type: "integer", description: "Optional per-command timeout override, in milliseconds (max 600000)." },
550
+ },
551
+ required: ["command"],
552
+ },
553
+ roles: TASK_ONLY_ROLES,
554
+ run: bashRun,
555
+ },
556
+ {
557
+ name: "playtest",
558
+ description: PLAYTEST_TOOL_DESCRIPTION,
559
+ parameters: PLAYTEST_TOOL_PARAMETERS,
560
+ // Task-only: the router never builds anything, so it has nothing to
561
+ // playtest, and (unlike the read-only tools) playtest ties up the
562
+ // serve's one shared browser -- a resource task agents own.
563
+ roles: TASK_ONLY_ROLES,
564
+ run: (args, ctx) => runPlaytest(args, ctx.deckDir, ctx.playtest, ctx.signal),
565
+ },
566
+ {
567
+ name: "restart",
568
+ description: "Reload the deck in every connected browser tab, dropping Vite's stale module cache first (same effect as `npm run restart`, applied in-process). Call this after file changes instead of running `npm run restart` via bash.",
569
+ parameters: { type: "object", properties: {} },
570
+ // Task-only: the router never edits the deck, so it never has a reason
571
+ // to reload it.
572
+ roles: TASK_ONLY_ROLES,
573
+ run: (_args, ctx) => restartRun(ctx),
574
+ },
575
+ ];
576
+ export function toolSchemasForRole(role) {
577
+ return TOOLS.filter((t) => t.roles.includes(role)).map((t) => ({
578
+ type: "function",
579
+ function: { name: t.name, description: t.description, parameters: t.parameters },
580
+ }));
581
+ }
582
+ export async function executeTool(name, args, role, ctx) {
583
+ const spec = TOOLS.find((t) => t.name === name);
584
+ if (!spec)
585
+ return err(`unknown tool "${name}".`);
586
+ if (!spec.roles.includes(role)) {
587
+ return err(`the "${name}" tool is not available to ${role} agents${role === "router" ? " (the router is read-only)" : ""}.`);
588
+ }
589
+ const decision = ctx.checkPolicy?.({ tool: name, args }) ?? { allow: true };
590
+ if (!decision.allow) {
591
+ return err(`blocked by policy${decision.reason ? `: ${decision.reason}` : ""}.`);
592
+ }
593
+ try {
594
+ return await spec.run(args, ctx);
595
+ }
596
+ catch (e) {
597
+ return err(`${name} failed: ${e instanceof Error ? e.message : String(e)}`);
598
+ }
599
+ }
600
+ // Activity label vocabulary mirroring claudeToolFeedLabel / genericClaudeToolLabel
601
+ // in agent.ts (concrete for file edits/reads, coarse for search/commands) --
602
+ // see native/loop.ts, which calls this for every tool call regardless of
603
+ // role (unlike the CLI backends, every native tool is known up front, so
604
+ // there is no "unknown tool" case to hide behind labelUnknownTools).
605
+ export function activityLabelForCall(name, args) {
606
+ const p = typeof args.path === "string" ? args.path : "";
607
+ switch (name) {
608
+ case "read_file":
609
+ return p ? `Reading ${baseName(p)}` : "Reading the deck";
610
+ case "view_image":
611
+ return p ? `Viewing ${baseName(p)}` : "Viewing an image";
612
+ case "write_file":
613
+ case "edit_file":
614
+ return p ? `Editing ${baseName(p)}` : "Editing files";
615
+ case "list_files":
616
+ case "grep":
617
+ return "Searching the deck";
618
+ case "playtest":
619
+ return "Playtesting";
620
+ case "restart":
621
+ return "Restarting the deck";
622
+ case "bash": {
623
+ const command = typeof args.command === "string" ? args.command.trim() : "";
624
+ const words = command.split(/\s+/).filter(Boolean).slice(0, 3).join(" ");
625
+ return words ? `Running ${words}` : "Running a command";
626
+ }
627
+ default:
628
+ return "Working";
629
+ }
630
+ }
@@ -0,0 +1,40 @@
1
+ import type { PlaytestExecutor } from "./playtest.js";
2
+ export type NativeRole = "router" | "task";
3
+ export interface NativePlaytestOpts {
4
+ executor: PlaytestExecutor;
5
+ serveUrl: string;
6
+ framesDir: string;
7
+ }
8
+ export interface NativeUsage {
9
+ input_tokens?: number;
10
+ output_tokens?: number;
11
+ cache_creation_input_tokens?: number;
12
+ cache_read_input_tokens?: number;
13
+ }
14
+ export interface NativeRunOpts {
15
+ cwd: string;
16
+ role: NativeRole;
17
+ model: string;
18
+ apiKey: string;
19
+ prompt: string;
20
+ systemReminder?: string;
21
+ attachments?: string[];
22
+ playtest?: NativePlaytestOpts;
23
+ restart?: () => void;
24
+ logPath?: string;
25
+ timeoutMs: number;
26
+ signal?: AbortSignal;
27
+ onDelta?: (delta: string) => void;
28
+ onActivity?: (activity: string | null) => void;
29
+ onThinking?: (delta: string) => void;
30
+ onSpawn?: (pid: number | undefined) => void;
31
+ labelUnknownTools?: boolean;
32
+ }
33
+ export interface NativeRunResult {
34
+ text: string;
35
+ error?: string;
36
+ usage?: NativeUsage;
37
+ filesTouched?: string[];
38
+ playtestFrames?: string[];
39
+ crashed?: boolean;
40
+ }