castle-web-cli 0.4.81 → 0.4.83

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 (40) hide show
  1. package/dist/agent-prompts.d.ts +0 -3
  2. package/dist/agent-prompts.js +3 -8
  3. package/dist/agent.d.ts +1 -2
  4. package/dist/agent.js +327 -157
  5. package/dist/castle-host/host.js +28 -0
  6. package/dist/ide.js +150 -1
  7. package/dist/init.js +1 -1
  8. package/dist/native/loop.js +15 -29
  9. package/dist/native/openrouter.d.ts +5 -1
  10. package/dist/native/openrouter.js +20 -1
  11. package/dist/native/tools.d.ts +0 -1
  12. package/dist/native/tools.js +3 -79
  13. package/dist/native/types.d.ts +4 -1
  14. package/dist/native/types.js +3 -3
  15. package/dist/shell/assets/index-BMkQt27u.css +1 -0
  16. package/dist/shell/assets/index-C9Zhmien.js +142 -0
  17. package/dist/shell/index.html +2 -2
  18. package/dist/shell/operator.png +0 -0
  19. package/kits/basic-2d/CLAUDE.md +27 -22
  20. package/kits/basic-2d/behaviors/Collider.jsx +24 -30
  21. package/kits/basic-2d/behaviors/Layout.jsx +9 -6
  22. package/kits/basic-2d/behaviors/Sprite.jsx +137 -7
  23. package/kits/basic-2d/blueprints/cauldron.scene +3 -5
  24. package/kits/basic-2d/editors/BlueprintLibrary.jsx +11 -11
  25. package/kits/basic-2d/editors/SceneEditor.jsx +212 -50
  26. package/kits/basic-2d/editors/SelectionOverlay.jsx +73 -54
  27. package/kits/basic-2d/editors/inspectorSheet.js +5 -1
  28. package/kits/basic-2d/engine/ScenePlayer.jsx +102 -7
  29. package/kits/basic-2d/engine/autoInspector.jsx +26 -7
  30. package/kits/basic-2d/engine/blueprint.js +109 -11
  31. package/kits/basic-2d/engine/collider.js +146 -0
  32. package/kits/basic-2d/engine/scene.js +53 -30
  33. package/kits/basic-2d/engine/spriteGeometry.js +32 -0
  34. package/kits/basic-2d/engine/ui.jsx +89 -30
  35. package/kits/basic-2d/engine/ui.module.css +157 -53
  36. package/kits/basic-2d/scenes/main.scene +3 -3
  37. package/package.json +2 -1
  38. package/dist/shell/assets/index-D3unT7do.js +0 -141
  39. package/dist/shell/assets/index-RZrw5gQ2.css +0 -1
  40. package/kits/basic-2d/pnpm-workspace.yaml +0 -3
@@ -28,6 +28,7 @@ const COMMAND_NAMES = [
28
28
  "pass.offer",
29
29
  "portal.open",
30
30
  "portal.prefetch",
31
+ "haptics.play",
31
32
  ];
32
33
  // Platform/capability commands: NOT serviced by graphqlFetch. They're dispatched
33
34
  // to the host's optional platformHandler (mobile renders native UI; web shows an
@@ -38,6 +39,7 @@ const PLATFORM_COMMAND_NAMES = [
38
39
  "pass.offer",
39
40
  "portal.open",
40
41
  "portal.prefetch",
42
+ "haptics.play",
41
43
  ];
42
44
  function isCommandName(value) {
43
45
  return (typeof value === "string" &&
@@ -106,6 +108,7 @@ function runCommand(ctx, command, params, caps) {
106
108
  case "pass.offer":
107
109
  case "portal.open":
108
110
  case "portal.prefetch":
111
+ case "haptics.play":
109
112
  return runPlatformCommand(ctx, command, params, caps);
110
113
  }
111
114
  }
@@ -122,6 +125,8 @@ async function runPlatformCommand(ctx, command, params, caps) {
122
125
  return portalOpen(ctx, params, caps);
123
126
  case "portal.prefetch":
124
127
  return portalPrefetch(ctx, params, caps);
128
+ case "haptics.play":
129
+ return hapticsPlay(ctx, params, caps);
125
130
  default:
126
131
  return unavailableOutcome();
127
132
  }
@@ -212,6 +217,29 @@ function normalizePortalPrefetchOutcome(value) {
212
217
  }
213
218
  return { status: "unavailable" };
214
219
  }
220
+ // A haptic is a device effect, not deck-scoped state, so — unlike pass/portal —
221
+ // no deckId is required; the style is validated and handed straight to the
222
+ // host's platformHandler. Hosts that can't play a haptic (dev CLI — no handler;
223
+ // a browser with no vibration API) get a normalized `unavailable`, never an
224
+ // error.
225
+ async function hapticsPlay(ctx, params, caps) {
226
+ const style = asString(params.style, "style", "haptics.play");
227
+ if (!caps.platformHandler)
228
+ return { status: "unavailable" };
229
+ const outcome = await caps.platformHandler("haptics.play", { style }, ctx);
230
+ return normalizeHapticsOutcome(outcome);
231
+ }
232
+ function normalizeHapticsOutcome(value) {
233
+ const record = typeof value === "object" && value !== null
234
+ ? value
235
+ : {};
236
+ const status = record.status;
237
+ const valid = ["triggered", "unavailable"];
238
+ if (typeof status === "string" && valid.includes(status)) {
239
+ return { status: status };
240
+ }
241
+ return { status: "unavailable" };
242
+ }
215
243
  function unavailableOutcome() {
216
244
  return { status: "unavailable" };
217
245
  }
package/dist/ide.js CHANGED
@@ -268,6 +268,136 @@ function handleFilesWrite(deckDir, req, res) {
268
268
  }
269
269
  })();
270
270
  }
271
+ // Add `<rel>/**` to the deck's editor.visiblePaths so files created in a
272
+ // newly-made folder show in the curated Files tree. Only touches a deck that is
273
+ // ALREADY curated (non-empty visiblePaths) -- when visiblePaths is empty
274
+ // everything is visible, and adding a glob would wrongly start hiding things.
275
+ // No-op if an existing glob already covers the folder. Returns whether it wrote.
276
+ function ensureVisiblePath(deckDir, rel) {
277
+ const file = path.join(deckDir, "castle.json");
278
+ let data;
279
+ try {
280
+ data = JSON.parse(fs.readFileSync(file, "utf8"));
281
+ }
282
+ catch {
283
+ return false; // no castle.json yet (deck never saved) -> treat as not curated
284
+ }
285
+ const visible = data.editor && Array.isArray(data.editor.visiblePaths)
286
+ ? data.editor.visiblePaths.filter((v) => typeof v === "string")
287
+ : null;
288
+ if (!visible || visible.length === 0)
289
+ return false; // not curated -> all visible
290
+ const glob = `${rel}/**`;
291
+ if (visible.includes(glob))
292
+ return false;
293
+ if (picomatch(visible)(`${rel}/__probe__`))
294
+ return false; // already covered
295
+ visible.push(glob);
296
+ data.editor.visiblePaths = visible;
297
+ try {
298
+ fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, "utf8");
299
+ return true;
300
+ }
301
+ catch {
302
+ return false;
303
+ }
304
+ }
305
+ function handleFilesMkdir(deckDir, req, res) {
306
+ void (async () => {
307
+ let body;
308
+ try {
309
+ body = JSON.parse(await readRequestBody(req));
310
+ }
311
+ catch {
312
+ return sendJson(res, 400, { error: "Invalid JSON body." });
313
+ }
314
+ const resolved = resolveDeckPath(deckDir, body.path);
315
+ if (!resolved.ok)
316
+ return sendJson(res, 400, { error: resolved.error });
317
+ try {
318
+ fs.mkdirSync(resolved.abs, { recursive: true });
319
+ }
320
+ catch (err) {
321
+ const message = err instanceof Error ? err.message : String(err);
322
+ return sendJson(res, 500, { error: `Could not create folder ${resolved.rel}: ${message}` });
323
+ }
324
+ const visiblePathAdded = ensureVisiblePath(deckDir, resolved.rel);
325
+ sendJson(res, 200, { ok: true, path: resolved.rel, visiblePathAdded });
326
+ })();
327
+ }
328
+ // True when two paths resolve to the same underlying file (same inode+device) --
329
+ // e.g. the source and target of a case-only rename on a case-insensitive FS.
330
+ function isSameFile(a, b) {
331
+ try {
332
+ const sa = fs.statSync(a);
333
+ const sb = fs.statSync(b);
334
+ return sa.ino === sb.ino && sa.dev === sb.dev;
335
+ }
336
+ catch {
337
+ return false;
338
+ }
339
+ }
340
+ function handleFilesRename(deckDir, req, res) {
341
+ void (async () => {
342
+ let body;
343
+ try {
344
+ body = JSON.parse(await readRequestBody(req));
345
+ }
346
+ catch {
347
+ return sendJson(res, 400, { error: "Invalid JSON body." });
348
+ }
349
+ const from = resolveDeckPath(deckDir, body.from);
350
+ if (!from.ok)
351
+ return sendJson(res, 400, { error: from.error });
352
+ const to = resolveDeckPath(deckDir, body.to);
353
+ if (!to.ok)
354
+ return sendJson(res, 400, { error: to.error });
355
+ if (!fs.existsSync(from.abs)) {
356
+ return sendJson(res, 404, { error: `Not found: ${from.rel}` });
357
+ }
358
+ // Block a collision with a DIFFERENT existing file. On a case-insensitive
359
+ // filesystem (default on macOS/Windows) `to` can "exist" only because it is
360
+ // `from` under a different case -- a case-only rename like bounce.jsx ->
361
+ // Bounce.jsx. Allow that by treating same-inode as not-a-collision.
362
+ if (from.abs !== to.abs && fs.existsSync(to.abs) && !isSameFile(from.abs, to.abs)) {
363
+ return sendJson(res, 409, { error: `Already exists: ${to.rel}` });
364
+ }
365
+ try {
366
+ fs.mkdirSync(path.dirname(to.abs), { recursive: true });
367
+ fs.renameSync(from.abs, to.abs);
368
+ sendJson(res, 200, { ok: true, path: to.rel });
369
+ }
370
+ catch (err) {
371
+ const message = err instanceof Error ? err.message : String(err);
372
+ sendJson(res, 500, { error: `Could not rename ${from.rel}: ${message}` });
373
+ }
374
+ })();
375
+ }
376
+ function handleFilesDelete(deckDir, req, res) {
377
+ void (async () => {
378
+ let body;
379
+ try {
380
+ body = JSON.parse(await readRequestBody(req));
381
+ }
382
+ catch {
383
+ return sendJson(res, 400, { error: "Invalid JSON body." });
384
+ }
385
+ const resolved = resolveDeckPath(deckDir, body.path);
386
+ if (!resolved.ok)
387
+ return sendJson(res, 400, { error: resolved.error });
388
+ if (!fs.existsSync(resolved.abs)) {
389
+ return sendJson(res, 404, { error: `Not found: ${resolved.rel}` });
390
+ }
391
+ try {
392
+ fs.rmSync(resolved.abs, { recursive: true, force: true });
393
+ sendJson(res, 200, { ok: true, path: resolved.rel });
394
+ }
395
+ catch (err) {
396
+ const message = err instanceof Error ? err.message : String(err);
397
+ sendJson(res, 500, { error: `Could not delete ${resolved.rel}: ${message}` });
398
+ }
399
+ })();
400
+ }
271
401
  // The builtin Files + code-editor backend: list / read / write deck files and
272
402
  // report kit-owned editor extensions. Paths are deck-relative; resolveDeckPath
273
403
  // rejects traversal and protected dirs.
@@ -286,7 +416,14 @@ function handleFilesApi(deckDir, req, res, reqPath) {
286
416
  return true;
287
417
  }
288
418
  if (action === "list") {
289
- const files = filterDeckFiles(listDeckFiles(deckDir), readEditorConfig(deckDir));
419
+ // `?all=1` returns the unfiltered listing (the "show hidden files & folders"
420
+ // toggle) -- still minus the always-ignored dirs (node_modules/.castle/...),
421
+ // just without the deck's visible/hidden path curation.
422
+ const url = new URL(req.url ?? "/", "http://localhost");
423
+ const listed = listDeckFiles(deckDir);
424
+ const files = url.searchParams.get("all") === "1"
425
+ ? listed
426
+ : filterDeckFiles(listed, readEditorConfig(deckDir));
290
427
  sendJson(res, 200, { files });
291
428
  return true;
292
429
  }
@@ -308,6 +445,18 @@ function handleFilesApi(deckDir, req, res, reqPath) {
308
445
  handleFilesWrite(deckDir, req, res);
309
446
  return true;
310
447
  }
448
+ if (action === "rename") {
449
+ handleFilesRename(deckDir, req, res);
450
+ return true;
451
+ }
452
+ if (action === "delete") {
453
+ handleFilesDelete(deckDir, req, res);
454
+ return true;
455
+ }
456
+ if (action === "mkdir") {
457
+ handleFilesMkdir(deckDir, req, res);
458
+ return true;
459
+ }
311
460
  return sendJson(res, 404, { error: `Unknown files action: ${action}` }), true;
312
461
  }
313
462
  function defaultShell() {
package/dist/init.js CHANGED
@@ -35,7 +35,7 @@ const DEFAULT_KIT = "basic-2d";
35
35
  // Registry version of castle-web-sdk to inject when scaffolding from a
36
36
  // globally-installed castle-web (not from inside the workspace). Bumped
37
37
  // alongside cli/sdk version bumps.
38
- const PUBLISHED_SDK_VERSION = "0.4.9";
38
+ const PUBLISHED_SDK_VERSION = "0.4.10";
39
39
  // Never copied into a fresh deck: build/dependency junk. castle.json IS copied
40
40
  // (the kit ships a config-only one with the editor layout / file filters), but
41
41
  // `scaffoldFromKit` strips any identity fields off it first -- a fresh deck has
@@ -21,16 +21,9 @@ import { toolSchemasForRole, executeTool, activityLabelForCall, } from "./tools.
21
21
  // Safety valve against a model that never stops calling tools -- distinct
22
22
  // from timeoutMs, which bounds wall-clock time regardless of iteration count.
23
23
  const MAX_ITERATIONS = 40;
24
- const TOUCHED_FILE_LIMIT = 10;
25
- function touchedFileList(files) {
26
- const sorted = [...files].sort();
27
- if (sorted.length <= TOUCHED_FILE_LIMIT)
28
- return sorted;
29
- return [...sorted.slice(0, TOUCHED_FILE_LIMIT), `+${sorted.length - TOUCHED_FILE_LIMIT} more`];
30
- }
31
- // playtest frames never hit the TOUCHED_FILE_LIMIT truncation above (a run
32
- // is capped at PLAYTEST_MAX_CALLS_PER_RUN calls x PLAYTEST_MAX_SHOTS frames
33
- // each -- at most 24 -- small enough to list in full for the task card).
24
+ // playtest frames are capped at PLAYTEST_MAX_CALLS_PER_RUN calls x
25
+ // PLAYTEST_MAX_SHOTS frames each -- at most 24 -- small enough to list in
26
+ // full for the task card, with no truncation needed.
34
27
  function playtestFrameList(frames) {
35
28
  return [...frames].sort();
36
29
  }
@@ -399,11 +392,11 @@ function groupToolCalls(toolCalls) {
399
392
  // Executes one tool call and reports its own activity label -- everything
400
393
  // error handling and result-shape wise is identical to the old sequential
401
394
  // loop; only the caller (runToolCalls) changed, to run several of these
402
- // concurrently within a group. Bookkeeping shared across calls (filesTouched,
403
- // the labels map, the log) is intentionally NOT touched in here -- the
404
- // caller applies it after Promise.all resolves, walking the group in its
405
- // ORIGINAL order, so concurrent completion order never affects what lands in
406
- // the message array or the log.
395
+ // concurrently within a group. Bookkeeping shared across calls (the labels
396
+ // map, the log) is intentionally NOT touched in here -- the caller applies
397
+ // it after Promise.all resolves, walking the group in its ORIGINAL order, so
398
+ // concurrent completion order never affects what lands in the message array
399
+ // or the log.
407
400
  async function runOneToolCall(call, role, ctx, onActivity) {
408
401
  const name = call.function?.name ?? "";
409
402
  const { args, error } = parseToolArgs(call.function?.arguments ?? "");
@@ -418,7 +411,7 @@ async function runOneToolCall(call, role, ctx, onActivity) {
418
411
  onActivity?.(null);
419
412
  return { call, name, args, result };
420
413
  }
421
- async function runToolCalls(toolCalls, role, ctx, filesTouched, playtestFrames, labels, imageLabels, log, onActivity) {
414
+ async function runToolCalls(toolCalls, role, ctx, playtestFrames, labels, imageLabels, log, onActivity) {
422
415
  const results = [];
423
416
  // Images a call produced this batch (view_image, playtest, ...). Delivered
424
417
  // as synthetic role:"user" messages AFTER all the batch's tool results --
@@ -435,9 +428,6 @@ async function runToolCalls(toolCalls, role, ctx, filesTouched, playtestFrames,
435
428
  const resolved = await Promise.all(group.map((call) => runOneToolCall(call, role, ctx, onActivity)));
436
429
  for (const { call, name, args, result } of resolved) {
437
430
  labels.set(call.id, toolCallLabel(name, args));
438
- if (result.filesTouched)
439
- for (const f of result.filesTouched)
440
- filesTouched.add(f);
441
431
  if (result.playtestFrames)
442
432
  for (const f of result.playtestFrames)
443
433
  playtestFrames.add(f);
@@ -502,9 +492,6 @@ export async function runAgentNative(opts) {
502
492
  ...(result.error ? { error: result.error } : {}),
503
493
  ...(result.crashed ? { crashed: true } : {}),
504
494
  ...(result.usage ? { usage: result.usage } : {}),
505
- ...(result.filesTouched && result.filesTouched.length > 0
506
- ? { filesTouched: result.filesTouched }
507
- : {}),
508
495
  ...(result.playtestFrames && result.playtestFrames.length > 0
509
496
  ? { playtestFrames: result.playtestFrames }
510
497
  : {}),
@@ -571,7 +558,6 @@ async function runLoop(opts, toolSchemas, log) {
571
558
  }
572
559
  : undefined,
573
560
  };
574
- const filesTouched = new Set();
575
561
  const playtestFrames = new Set();
576
562
  const toolLabels = new Map();
577
563
  // Synthetic view_image carrier messages, by identity -- see runToolCalls
@@ -590,7 +576,6 @@ async function runLoop(opts, toolSchemas, log) {
590
576
  text: finalText,
591
577
  error: timeoutFired ? "agent run timed out" : "agent run stopped",
592
578
  usage: totalUsage,
593
- filesTouched: touchedFileList(filesTouched),
594
579
  playtestFrames: playtestFrameList(playtestFrames),
595
580
  };
596
581
  };
@@ -613,7 +598,11 @@ async function runLoop(opts, toolSchemas, log) {
613
598
  model: opts.model,
614
599
  messages,
615
600
  tools: toolSchemas,
616
- reasoningEffort: REASONING_EFFORT[opts.role],
601
+ // Settings-driven per-role effort; falls back to the built-in table
602
+ // when a caller doesn't supply one (e.g. the QA harness).
603
+ reasoningEffort: opts.reasoningEffort ?? REASONING_EFFORT[opts.role],
604
+ routing: opts.routing,
605
+ providerTier: opts.providerTier,
617
606
  maxTokens: MAX_COMPLETION_TOKENS,
618
607
  signal: controller.signal,
619
608
  onDelta: opts.onDelta,
@@ -647,7 +636,6 @@ async function runLoop(opts, toolSchemas, log) {
647
636
  text: finalText,
648
637
  error: streamResult.error ?? "openrouter stream ended without a final response",
649
638
  usage: totalUsage,
650
- filesTouched: touchedFileList(filesTouched),
651
639
  playtestFrames: playtestFrameList(playtestFrames),
652
640
  crashed: streamResult.crashed,
653
641
  };
@@ -672,7 +660,6 @@ async function runLoop(opts, toolSchemas, log) {
672
660
  return {
673
661
  text: finalText,
674
662
  usage: totalUsage,
675
- filesTouched: touchedFileList(filesTouched),
676
663
  playtestFrames: playtestFrameList(playtestFrames),
677
664
  };
678
665
  }
@@ -681,7 +668,7 @@ async function runLoop(opts, toolSchemas, log) {
681
668
  content: streamResult.message.content || null,
682
669
  tool_calls: toolCalls,
683
670
  });
684
- const toolResults = await runToolCalls(toolCalls, opts.role, toolCtx, filesTouched, playtestFrames, toolLabels, imageLabels, log, opts.onActivity);
671
+ const toolResults = await runToolCalls(toolCalls, opts.role, toolCtx, playtestFrames, toolLabels, imageLabels, log, opts.onActivity);
685
672
  messages.push(...toolResults);
686
673
  }
687
674
  }
@@ -692,7 +679,6 @@ async function runLoop(opts, toolSchemas, log) {
692
679
  text: finalText,
693
680
  error: "agent exceeded the maximum number of tool-call iterations",
694
681
  usage: totalUsage,
695
- filesTouched: touchedFileList(filesTouched),
696
682
  playtestFrames: playtestFrameList(playtestFrames),
697
683
  };
698
684
  }
@@ -27,13 +27,17 @@ export interface ORAssistantMessage {
27
27
  content: string;
28
28
  tool_calls?: ORToolCall[];
29
29
  }
30
- export type ORReasoningEffort = "low" | "medium";
30
+ export type ORReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
31
+ export type ORRoutingMode = "balanced" | "nitro" | "exacto" | "floor";
32
+ export declare function applyRoutingMode(model: string, routing?: ORRoutingMode): string;
31
33
  export interface StreamChatOpts {
32
34
  apiKey: string;
33
35
  model: string;
34
36
  messages: ORMessage[];
35
37
  tools?: unknown[];
36
38
  reasoningEffort?: ORReasoningEffort;
39
+ routing?: ORRoutingMode;
40
+ providerTier?: string;
37
41
  maxTokens?: number;
38
42
  signal?: AbortSignal;
39
43
  onDelta?: (delta: string) => void;
@@ -24,6 +24,20 @@ function openrouterUrl() {
24
24
  const DEFAULT_MAX_RETRIES = 2; // -> 3 total connect attempts
25
25
  const RETRY_BASE_MS = 500;
26
26
  const RETRY_MAX_MS = 4_000;
27
+ const ROUTING_SUFFIX = {
28
+ nitro: ":nitro",
29
+ exacto: ":exacto",
30
+ floor: ":floor",
31
+ };
32
+ // Append the routing-mode suffix to a model slug, first stripping any suffix
33
+ // we might have added on a previous turn (or that the user typed into the
34
+ // free-form slug field) so switching modes doesn't stack `:nitro:floor`.
35
+ export function applyRoutingMode(model, routing) {
36
+ const base = model.replace(/:(nitro|exacto|floor)$/, "");
37
+ if (!routing || routing === "balanced")
38
+ return base;
39
+ return base + ROUTING_SUFFIX[routing];
40
+ }
27
41
  function sleep(ms) {
28
42
  return new Promise((resolve) => setTimeout(resolve, ms));
29
43
  }
@@ -188,7 +202,8 @@ function finalizeToolCalls(pending) {
188
202
  }
189
203
  export async function streamChatCompletion(opts) {
190
204
  const body = {
191
- model: opts.model,
205
+ // Routing mode rides the slug as a suffix (see applyRoutingMode).
206
+ model: applyRoutingMode(opts.model, opts.routing),
192
207
  messages: opts.messages,
193
208
  stream: true,
194
209
  // Deprecated on OpenRouter's side (usage is always included now) but
@@ -199,6 +214,10 @@ export async function streamChatCompletion(opts) {
199
214
  // See ORReasoningEffort / StreamChatOpts.reasoningEffort above for the
200
215
  // doc reference and the graceful-degradation guarantee this relies on.
201
216
  ...(opts.reasoningEffort ? { reasoning: { effort: opts.reasoningEffort } } : {}),
217
+ // Pin a provider tier when requested (see StreamChatOpts.providerTier).
218
+ ...(opts.providerTier
219
+ ? { provider: { order: [opts.providerTier], allow_fallbacks: true } }
220
+ : {}),
202
221
  // See StreamChatOpts.maxTokens above -- bounds one completion call's
203
222
  // total output (content + tool-call arguments + reasoning).
204
223
  ...(opts.maxTokens !== undefined ? { max_tokens: opts.maxTokens } : {}),
@@ -18,7 +18,6 @@ export interface ToolExecContext {
18
18
  export interface ToolCallResult {
19
19
  ok: boolean;
20
20
  output: string;
21
- filesTouched?: string[];
22
21
  images?: Array<{
23
22
  label: string;
24
23
  dataUrl: string;
@@ -21,17 +21,6 @@ import { PLAYTEST_TOOL_DESCRIPTION, PLAYTEST_TOOL_PARAMETERS, runPlaytest, } fro
21
21
  function err(message) {
22
22
  return { ok: false, output: `Error: ${message}` };
23
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
24
  // Mirrors DECK_TREE_EXCLUDE in agent.ts.
36
25
  const IGNORED_DIRS = new Set(["node_modules", ".castle", ".git", "dist", ".DS_Store"]);
37
26
  const MAX_WALK_FILES = 20_000;
@@ -41,8 +30,7 @@ function baseName(p) {
41
30
  }
42
31
  // Resolves a tool-supplied path against the deck dir and rejects any escape
43
32
  // (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).
33
+ // and a deck-root-relative path (forward-slashed, for display in tool output).
46
34
  function resolveInDeck(deckDir, rawPath) {
47
35
  if (typeof rawPath !== "string" || rawPath.trim() === "")
48
36
  return null;
@@ -169,8 +157,8 @@ const VIEW_IMAGE_SIZE_CAP = 4 * 1024 * 1024;
169
157
  //
170
158
  // Path confinement is the same resolveInDeck as every file tool; note that
171
159
  // 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).
160
+ // they are reachable here (the .castle/ exclusions elsewhere apply to tree
161
+ // walks, never to reads).
174
162
  function viewImageRun(args, ctx) {
175
163
  const resolved = resolveInDeck(ctx.deckDir, args.path);
176
164
  if (!resolved)
@@ -218,11 +206,9 @@ function writeFileRun(args, ctx) {
218
206
  catch (e) {
219
207
  return err(`could not write ${resolved.rel}: ${e instanceof Error ? e.message : String(e)}`);
220
208
  }
221
- const filesTouched = isTrackedTouch(resolved.rel) ? [resolved.rel] : [];
222
209
  return {
223
210
  ok: true,
224
211
  output: `Wrote ${resolved.rel} (${Buffer.byteLength(args.content, "utf8")} bytes).`,
225
- filesTouched,
226
212
  };
227
213
  }
228
214
  // -- edit_file ------------------------------------------------------------------
@@ -264,11 +250,9 @@ function editFileRun(args, ctx) {
264
250
  catch (e) {
265
251
  return err(`could not write ${resolved.rel}: ${e instanceof Error ? e.message : String(e)}`);
266
252
  }
267
- const filesTouched = isTrackedTouch(resolved.rel) ? [resolved.rel] : [];
268
253
  return {
269
254
  ok: true,
270
255
  output: `Edited ${resolved.rel}${replaceAll ? ` (${count} replacements)` : ""}.`,
271
- filesTouched,
272
256
  };
273
257
  }
274
258
  // -- list_files -----------------------------------------------------------------
@@ -366,61 +350,6 @@ function grepRun(args, ctx) {
366
350
  output: results.join("\n") + (truncated ? `\n... (capped at ${GREP_MAX_RESULTS} matches)` : ""),
367
351
  };
368
352
  }
369
- // -- bash filesTouched heuristic ---------------------------------------------
370
- // Mirrors shellTouchedCandidates/drawingPathForDrawArg/looksLikeTouchedPath in
371
- // agent.ts's CLI stream parser, so a smith task's bash-redirect writes are
372
- // tracked the same way a cursor/claude task's shell-tool writes are. Before
373
- // this, bash had NO filesTouched signal at all here -- a smith task that
374
- // wrote its result via a shell redirect (heredoc, `>`, `npm run draw --`,
375
- // etc.) instead of write_file/edit_file still landed with an empty
376
- // filesTouched and tripped the false "no changes" caution despite genuinely
377
- // writing to disk. (This is the gap agent-prompts.ts's renderTasks used to
378
- // paper over with a "bash side effects aren't tracked" caveat.)
379
- const SHELL_REDIRECT_RE = /(?:^|[\s;|])(?:\d*)>>?\s*(?!&)(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))/g;
380
- const DRAW_RE = /npm\s+run\s+draw\s+--\s+([^\s;&|]+)/g;
381
- function drawingPathForDrawArg(raw) {
382
- const name = raw.replace(/^['"]|['"]$/g, "").trim();
383
- if (!name || name.startsWith("-") || name.includes("\n"))
384
- return null;
385
- if (name.startsWith("drawings/")) {
386
- return name.endsWith(".pxart") ? name : `${name}.pxart`;
387
- }
388
- return `drawings/${name.endsWith(".pxart") ? name : `${name}.pxart`}`;
389
- }
390
- // Guards every redirect candidate against junk that isn't plausibly a path --
391
- // the redirect regex treats any `>`-plus-token as a write target, so e.g. a
392
- // numeric comparison inside a quoted inline script (`>=5`) false-matches.
393
- function looksLikeTouchedPath(raw) {
394
- return /[a-zA-Z0-9]/.test(raw) && raw[0] !== "-" && raw[0] !== "=";
395
- }
396
- function shellTouchedCandidates(command) {
397
- const out = [];
398
- for (const match of command.matchAll(SHELL_REDIRECT_RE)) {
399
- const target = match[1] ?? match[2] ?? match[3];
400
- if (target)
401
- out.push(target);
402
- }
403
- for (const match of command.matchAll(DRAW_RE)) {
404
- const drawing = drawingPathForDrawArg(match[1] ?? "");
405
- if (drawing)
406
- out.push(drawing);
407
- }
408
- return out;
409
- }
410
- // Resolves each shell-redirect candidate against the deck dir the same way
411
- // write_file/edit_file do (path escapes rejected, .castle/ and the progress
412
- // file excluded) -- see resolveInDeck/isTrackedTouch above.
413
- function bashFilesTouched(deckDir, command) {
414
- const out = [];
415
- for (const candidate of shellTouchedCandidates(command)) {
416
- if (!looksLikeTouchedPath(candidate) || candidate.includes("\n"))
417
- continue;
418
- const resolved = resolveInDeck(deckDir, candidate);
419
- if (resolved && isTrackedTouch(resolved.rel))
420
- out.push(resolved.rel);
421
- }
422
- return out;
423
- }
424
353
  // -- bash -------------------------------------------------------------------
425
354
  // Full shell, trusted -- matches today's --force trust level for task agents
426
355
  // (ratified; not revisited here). cwd is always the deck dir; per-call
@@ -480,14 +409,9 @@ function bashRun(args, ctx) {
480
409
  // the assistant's own tool_call (which, unlike this result, is never
481
410
  // evicted from context -- see evictOldToolResults in loop.ts), so
482
411
  // repeating it would just be the same bytes twice on every later turn.
483
- // filesTouched is derived from the command text itself (not gated on
484
- // `ok`): a shell redirect creates/truncates its target as soon as the
485
- // shell sets it up, before the command even runs, so the write already
486
- // landed even if the command that followed the redirect then failed.
487
412
  resolve({
488
413
  ok,
489
414
  output: `(exit ${code ?? "null"})\n${capped}${timedOutNote}`,
490
- filesTouched: bashFilesTouched(ctx.deckDir, command),
491
415
  });
492
416
  });
493
417
  });
@@ -1,4 +1,5 @@
1
1
  import type { PlaytestExecutor } from "./playtest.js";
2
+ import type { ORReasoningEffort, ORRoutingMode } from "./openrouter.js";
2
3
  export type NativeRole = "router" | "task";
3
4
  export interface NativePlaytestOpts {
4
5
  executor: PlaytestExecutor;
@@ -16,6 +17,9 @@ export interface NativeRunOpts {
16
17
  role: NativeRole;
17
18
  model: string;
18
19
  apiKey: string;
20
+ reasoningEffort?: ORReasoningEffort;
21
+ routing?: ORRoutingMode;
22
+ providerTier?: string;
19
23
  prompt: string;
20
24
  systemReminder?: string;
21
25
  attachments?: string[];
@@ -34,7 +38,6 @@ export interface NativeRunResult {
34
38
  text: string;
35
39
  error?: string;
36
40
  usage?: NativeUsage;
37
- filesTouched?: string[];
38
41
  playtestFrames?: string[];
39
42
  crashed?: boolean;
40
43
  }
@@ -4,8 +4,8 @@
4
4
  // These MIRROR the non-exported CliRunOpts / CliRunResult / CliUsage
5
5
  // interfaces in cli/src/agent.ts (see runAgentCli there), which normalize
6
6
  // the headless `claude` / `cursor-agent` CLI backends into onDelta/onActivity/
7
- // onThinking hooks plus a { finalText, error, usage, filesTouched, crashed }
8
- // result. This file is a SEPARATE set of types for now (new-files-only
7
+ // onThinking hooks plus a { finalText, error, usage, crashed } result. This
8
+ // file is a SEPARATE set of types for now (new-files-only
9
9
  // constraint -- agent.ts is being edited concurrently by a sibling change),
10
10
  // not a re-export, so field names differ in a few places. When the native
11
11
  // backend is wired into agent.ts, expect one of these two outcomes:
@@ -14,7 +14,7 @@
14
14
  // (command/args/parser and children become backend-specific extras), or
15
15
  // 2. the call site maps between the two shapes directly:
16
16
  // - NativeRunResult.text -> CliRunResult.finalText
17
- // - NativeRunResult.error/usage/filesTouched/crashed -> same names
17
+ // - NativeRunResult.error/usage/crashed -> same names
18
18
  // - CliRunResult.ok <- derived as
19
19
  // `!result.error && !result.crashed` (no native equivalent of
20
20
  // a process exit code -- "ok" always follows from the other