castle-web-cli 0.4.75 → 0.4.76

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 (68) hide show
  1. package/dist/agent-prompts.d.ts +4 -0
  2. package/dist/agent-prompts.js +20 -8
  3. package/dist/agent.js +301 -28
  4. package/dist/castle-host/host.js +59 -5
  5. package/dist/commonInstructions.d.ts +1 -1
  6. package/dist/commonInstructions.js +4 -0
  7. package/dist/ide.d.ts +1 -0
  8. package/dist/ide.js +250 -1
  9. package/dist/init.js +46 -4
  10. package/dist/save-deck.js +8 -1
  11. package/dist/serve.js +27 -1
  12. package/dist/shell/assets/index-DNWEQd4R.js +141 -0
  13. package/dist/shell/assets/{index-WNbOHPBj.css → index-DuKq-Grp.css} +1 -1
  14. package/dist/shell/index.html +2 -2
  15. package/kits/basic-2d/CLAUDE.md +13 -5
  16. package/kits/basic-2d/castle.json +15 -0
  17. package/kits/basic-2d/drawings/pig.pxart +22 -55
  18. package/kits/basic-2d/editors/PxArtEditor.jsx +237 -4
  19. package/kits/basic-2d/editors/SingleEditor.jsx +6 -51
  20. package/kits/basic-2d/editors/pixelEditorChrome.jsx +17 -11
  21. package/kits/basic-2d/editors/pixelGeometry.js +95 -0
  22. package/kits/basic-2d/editors/pixelInspector.jsx +229 -51
  23. package/kits/basic-2d/editors/pxArtTools.js +109 -1
  24. package/kits/basic-2d/engine/ScenePlayer.jsx +11 -160
  25. package/kits/basic-2d/engine/ui.jsx +15 -0
  26. package/kits/basic-2d/engine/ui.module.css +44 -117
  27. package/kits/basic-2d/main.jsx +6 -10
  28. package/package.json +10 -1
  29. package/dist/shell/assets/index-Dfn29Bkt.js +0 -108
  30. package/kits/basic-2d/editors/App.jsx +0 -226
  31. package/kits/basic-2d/editors/CodeEditor.jsx +0 -79
  32. package/kits/basic-2d/editors/FileBrowser.jsx +0 -349
  33. package/kits/basic-2d/editors/codeTheme.js +0 -135
  34. package/kits/basic-2d/engine/playConsole.js +0 -66
  35. package/kits/basic-3d/.prettierrc +0 -8
  36. package/kits/basic-3d/CLAUDE.md +0 -162
  37. package/kits/basic-3d/behaviors/Camera.jsx +0 -56
  38. package/kits/basic-3d/behaviors/Collider.jsx +0 -78
  39. package/kits/basic-3d/behaviors/Mesh.jsx +0 -82
  40. package/kits/basic-3d/behaviors/Model.jsx +0 -61
  41. package/kits/basic-3d/behaviors/Transform.jsx +0 -35
  42. package/kits/basic-3d/editors/App.jsx +0 -147
  43. package/kits/basic-3d/editors/CodeEditor.jsx +0 -112
  44. package/kits/basic-3d/editors/FileBrowser.jsx +0 -143
  45. package/kits/basic-3d/editors/ModelEditor.jsx +0 -400
  46. package/kits/basic-3d/editors/PlayOnly.jsx +0 -22
  47. package/kits/basic-3d/editors/SceneEditor.jsx +0 -1081
  48. package/kits/basic-3d/editors/behaviorRegistry.js +0 -24
  49. package/kits/basic-3d/editors/editorHistory.js +0 -52
  50. package/kits/basic-3d/editors/viewportRig.js +0 -90
  51. package/kits/basic-3d/engine/ScenePlayer.jsx +0 -58
  52. package/kits/basic-3d/engine/SceneUI.jsx +0 -67
  53. package/kits/basic-3d/engine/SceneViewport.jsx +0 -102
  54. package/kits/basic-3d/engine/autoInspector.jsx +0 -51
  55. package/kits/basic-3d/engine/files.js +0 -73
  56. package/kits/basic-3d/engine/scene.js +0 -502
  57. package/kits/basic-3d/engine/threeUtil.js +0 -260
  58. package/kits/basic-3d/engine/ui.jsx +0 -352
  59. package/kits/basic-3d/engine/ui.module.css +0 -944
  60. package/kits/basic-3d/eslint.config.js +0 -51
  61. package/kits/basic-3d/index.html +0 -11
  62. package/kits/basic-3d/main.jsx +0 -10
  63. package/kits/basic-3d/models/block.model +0 -14
  64. package/kits/basic-3d/package-lock.json +0 -2713
  65. package/kits/basic-3d/package.json +0 -41
  66. package/kits/basic-3d/pnpm-lock.yaml +0 -1769
  67. package/kits/basic-3d/scenes/main.scene +0 -76
  68. package/kits/basic-3d/vite.config.js +0 -1
@@ -9,9 +9,12 @@ export interface PromptTask {
9
9
  status: string;
10
10
  progress: number;
11
11
  notes: string;
12
+ files?: string[];
12
13
  }
13
14
  export declare function buildRouterPrompt(opts: {
14
15
  deckLabel: string;
16
+ quickReference?: string;
17
+ deckTree?: string;
15
18
  messages: PromptMessage[];
16
19
  tasks: PromptTask[];
17
20
  instruction: string;
@@ -30,5 +33,6 @@ export declare function buildTaskPrompt(opts: {
30
33
  notesPath: string;
31
34
  depsSummary?: string;
32
35
  backend?: "cursor" | "claude";
36
+ deckTree?: string;
33
37
  }): string;
34
38
  export declare const CLAUDE_TASK_SYSTEM_REMINDER = "Castle background task agent: work autonomously to completion -- never pause to ask questions or wait for confirmation; finish the task end-to-end, then stop. Prefer the quickest viable change that fully does the job.";
@@ -8,11 +8,11 @@
8
8
  const TRANSCRIPT_LIMIT = 40;
9
9
  const ROUTER_RULES = `You are Castle's create assistant: the fast conversational router for a game-making session. The deck (game project) lives in the current directory and runs live in a pane right next to this chat.
10
10
 
11
- What a deck is: a normal web project served by vite -- index.html plus plain JS/JSX modules, with real npm dependencies (more can be installed), the castle-web-sdk package, and usually a kit framework whose engine, behaviors, scenes, editors, and drawings are ordinary files in this directory. The web platform is fully available (DOM, canvas, npm libraries like react, three, etc.). The deck's CLAUDE.md / AGENTS.md describes the specific setup. NEVER claim something is impossible or unsupported on the platform without checking the deck's files first.
11
+ What a deck is: a normal web project served by vite -- index.html plus plain JS/JSX modules, with real npm dependencies (more can be installed), the castle-web-sdk package, and usually a kit framework whose engine, behaviors, scenes, editors, and drawings are ordinary files in this directory. The web platform is fully available (DOM, canvas, npm libraries like react, three, etc.). The deck's Quick reference and file list below describe its setup; the full CLAUDE.md / AGENTS.md has deeper detail. NEVER claim something is impossible or unsupported on the platform without checking that context (or, for specifics it doesn't cover, the deck's files) first.
12
12
 
13
13
  Hard rules:
14
14
  - You NEVER edit files or run state-changing commands. All building and fixing happens through background task agents -- always hand the longer work to them.
15
- - You are the fast lane: get to your final reply as quickly as possible. When the user reports something broken, do NOT dig into the code to diagnose it first -- spawn a task whose job is to investigate AND fix it. Only read deck files when your reply itself needs them (answering a question about the deck, grounding a claim -- never make things up); never read as pre-work before spawning a task.
15
+ - You are the fast lane: get to your final reply as quickly as possible. When the user reports something broken, do NOT dig into the code to diagnose it first -- spawn a task whose job is to investigate AND fix it. Only read deck files when your reply itself needs them (answering a question about the deck, grounding a claim -- never make things up); never read as pre-work before spawning a task, and never read files just to learn conventions already covered by the Quick reference.
16
16
  - Launch a SET of small steps the user tests one by one -- a pipeline, never one big task they wait on, never untestable fragments. One interacting mechanic = one task (paddle + ball + bricks = one playable core, not three). First step = the smallest genuinely playable thing; later steps build it out. Match breadth to ambition ("basic" = a few steps; "go wild" = many). You're optimizing the user's taste and feedback -- more small testable steps = more points where they steer it into something theirs.
17
17
  - The whole goal: every piece of work TESTABLE in actual gameplay ASAP. Start every task as early as possible and run them in PARALLEL. Do NOT break tasks down by which files they touch, and never add \`after:\` just to avoid two tasks editing the same file -- tasks make surgical edits and overlap fine. The only real dependency between tasks is INFORMATION: a task is blocked only when it needs a fact it does not yet have.
18
18
  - Dissolve those information-dependencies with a CONTRACT decided up front, instead of serializing tasks. Before spawning, pin the shared names the tasks will agree on -- scene file names, behavior names, drawing file names, tags -- and give each parallel task the exact names it must CREATE or REFERENCE. Then they all run at once and converge to a working game with NO follow-up wiring task: the scene task places actors referencing behaviors and drawings by their agreed names before those files even exist; the behavior tasks create those behavior names; the drawing tasks create those drawing files. (Art: pin a drawing name like drawings/ship up front -> the scene task points an actor at it while another task draws it, in parallel; no place-it-later task.) Fall back to a separate waiting wire-in task (\`after:\` the maker, on the least possible) ONLY when a task truly needs another's actual produced output, not just its agreed name. Working out this breakdown -- what to parallelize, which names to pin up front -- is the core of your job and differs per game.
@@ -47,6 +47,7 @@ comma-separated active-task titles or ids, or \`all\`
47
47
  - Never claim the board is cleared without actually emitting the fence.
48
48
  - Tasks are one-and-done -- when the user gives feedback on a finished task, spawn a new fix task (and \`castle-done\` the old row) rather than reopening it.
49
49
  - Task agents are capable coding agents working in this same deck directory, but they know nothing about this conversation beyond your prompt.
50
+ - Board rows may include \`files:\` for finished work. Use those touched-file lists to aim follow-up/fix tasks and to keep shared names consistent without rereading the deck.
50
51
 
51
52
  Asking with options (the \`\`\`ask block). When you need the user to settle a few choices at once, emit ONE fenced block tagged \`ask\` containing JSON -- it renders inline in the chat as grouped options they tap and submit together (far better than stacking questions they can only half-answer). Reach for it to pin a direction fast when their ask is vague ("make me a game" -> ask what kind), NOT to interrogate. Options only, no free text.
52
53
 
@@ -86,22 +87,29 @@ function renderTasks(tasks) {
86
87
  return tasks
87
88
  .map((t) => {
88
89
  const notes = t.notes.trim() ? ` -- notes: ${t.notes.trim()}` : "";
89
- return `- [${t.status} ${t.progress}%] ${t.title} (${t.id})${notes}`;
90
+ const files = t.files && t.files.length > 0 ? ` -- files: ${t.files.join(", ")}` : "";
91
+ return `- [${t.status} ${t.progress}%] ${t.title} (${t.id})${notes}${files}`;
90
92
  })
91
93
  .join("\n");
92
94
  }
93
95
  export function buildRouterPrompt(opts) {
96
+ const quickReference = opts.quickReference?.trim()
97
+ ? `\n\n== how this deck works ==\n${opts.quickReference.trim()}`
98
+ : "";
99
+ const deckFiles = opts.deckTree?.trim()
100
+ ? `\n\n== deck files ==\n${opts.deckTree.trim()}`
101
+ : "";
94
102
  return `${ROUTER_RULES}
95
103
 
96
104
  == deck ==
97
- ${opts.deckLabel}
98
-
99
- == background tasks ==
100
- ${renderTasks(opts.tasks)}
105
+ ${opts.deckLabel}${quickReference}${deckFiles}
101
106
 
102
107
  == conversation so far ==
103
108
  ${renderTranscript(opts.messages)}
104
109
 
110
+ == background tasks ==
111
+ ${renderTasks(opts.tasks)}
112
+
105
113
  == now ==
106
114
  ${opts.instruction}
107
115
 
@@ -130,6 +138,9 @@ export function buildTaskPrompt(opts) {
130
138
  const deps = opts.depsSummary
131
139
  ? `\n\nThis task waited on earlier tasks:\n${opts.depsSummary}\n`
132
140
  : "";
141
+ const layout = opts.deckTree?.trim()
142
+ ? `\n\nDeck files (snapshot at task start; parallel sibling tasks may create files named in your prompt that are absent here):\n\n${opts.deckTree.trim()}\n`
143
+ : "";
133
144
  // Claude-only: collapsing the wrap-up (progress 90 + restart + notes) into
134
145
  // one shell call reliably saves 1-2 serial ~7s turns there. Cursor's
135
146
  // composer sometimes reacts to the same rule with MORE calls, so it stays
@@ -140,13 +151,14 @@ export function buildTaskPrompt(opts) {
140
151
  const wrapUp = opts.backend === "claude"
141
152
  ? `\n- Wrap up in ONE tool call, not several: once your last file edit is done, combine the 90-progress write, the final \`npm run restart\`, and writing the notes file into a single shell command (\`;\`-separated so the notes land even if the restart hiccups). Then stop -- no extra turns after it.`
142
153
  : "";
143
- return `You are a background build agent for the Castle deck "${opts.deckLabel}" (current directory). A separate conversation agent dispatched you with one task. Follow the deck's CLAUDE.md / AGENTS.md conventions, and reload the served deck after changes (\`npm run restart\`).
154
+ return `You are a background build agent for the Castle deck "${opts.deckLabel}" (current directory). A separate conversation agent dispatched you with one task. Follow the deck's CLAUDE.md / AGENTS.md conventions, and reload the served deck after changes (\`npm run restart\`).${layout}
144
155
 
145
156
  Your task (id ${opts.taskId}): ${opts.title}
146
157
 
147
158
  ${opts.prompt}${deps}
148
159
 
149
160
  Operating rules:
161
+ - The file snapshot above is for orientation. Prefer going straight to the files this task touches, but if this is an investigation or debugging task, survey what you need to understand the failure.
150
162
  - Speed is of the essence -- go for the quickest viable, testable change, unless this prompt explicitly tells you to take longer and dive deep. It must still amount to at least one meaningful step up (one or a few features, pieces of art, etc.), never a token gesture.
151
163
  - The USER is the verifier -- the whole tasks system exists so the user playtests every change themselves. Your first priority is to finish as soon as possible with the change genuinely in place and reachable in the running deck, so the user can test it right away. Do NOT run verification (screenshots especially) unless you are really sure it will catch something a re-read of your own change cannot -- and even then at most one cheap check, never a retry loop. Time spent verifying is time the user is left waiting.
152
164
  - The moment implementation is complete and you switch to verifying, write 90 to the progress file -- verification time must not read as stalled progress.
package/dist/agent.js CHANGED
@@ -277,6 +277,26 @@ function claudeToolFeedLabel(name, input) {
277
277
  }
278
278
  return null;
279
279
  }
280
+ // Coarse activity label for a claude tool from its NAME alone (available at
281
+ // content_block_start, before the input JSON streams in). Router-only fallback
282
+ // so the streaming reply's activity line reflects EVERY tool -- including the
283
+ // commands/searches claudeToolFeedLabel intentionally drops from task feeds.
284
+ // Mirrors the cursor toolActivityLabel verbs so the two backends read alike.
285
+ function genericClaudeToolLabel(name) {
286
+ const kind = name.toLowerCase();
287
+ if (kind.startsWith("read") || kind.startsWith("notebookread"))
288
+ return "Reading the deck";
289
+ if (["edit", "write", "notebook", "multiedit"].some((p) => kind.startsWith(p)))
290
+ return "Editing files";
291
+ if (kind.startsWith("bash") || kind.includes("terminal"))
292
+ return "Running a command";
293
+ if (["grep", "glob", "ls"].some((p) => kind.startsWith(p)) ||
294
+ kind.includes("search"))
295
+ return "Searching the deck";
296
+ if (kind.startsWith("web"))
297
+ return "Searching the web";
298
+ return "Working";
299
+ }
280
300
  // Human-readable label for a tool_call event, e.g. readToolCall -> "reading
281
301
  // the deck". Shown as the streaming message's activity line.
282
302
  function toolActivityLabel(ev) {
@@ -286,13 +306,13 @@ function toolActivityLabel(ev) {
286
306
  : undefined;
287
307
  const kind = (key ?? "").slice(0, -"ToolCall".length).toLowerCase();
288
308
  if (["read", "glob", "grep", "ls", "list"].some((p) => kind.startsWith(p))) {
289
- return "reading the deck";
309
+ return "Reading the deck";
290
310
  }
291
311
  if (["write", "edit", "delete", "mv"].some((p) => kind.startsWith(p)))
292
- return "editing files";
312
+ return "Editing files";
293
313
  if (["shell", "bash", "terminal"].some((p) => kind.startsWith(p)))
294
- return "running a command";
295
- return "working";
314
+ return "Running a command";
315
+ return "Working";
296
316
  }
297
317
  // Castle's agent CLI keys, delivered to the sandbox as a file
298
318
  // (~/.castle/keys.json) rather than sandbox-wide env -- so an ambient key can't
@@ -344,17 +364,228 @@ function envForAgentSpawn(backend) {
344
364
  }
345
365
  return env;
346
366
  }
367
+ const DECK_TREE_EXCLUDE = new Set([
368
+ "node_modules",
369
+ ".castle",
370
+ ".git",
371
+ "dist",
372
+ ".DS_Store",
373
+ ]);
374
+ const DECK_TREE_MAX_ENTRIES = 200;
375
+ // Shallow orientation snapshot for router/task prompts. Best-effort by design:
376
+ // filesystem hiccups should cost context, not fail an agent turn.
377
+ function buildDeckTree(deckDir) {
378
+ const lines = [];
379
+ const walk = (dir, prefix, depth) => {
380
+ let entries;
381
+ try {
382
+ entries = fs.readdirSync(dir, { withFileTypes: true });
383
+ }
384
+ catch {
385
+ return;
386
+ }
387
+ entries.sort((a, b) => {
388
+ if (a.isDirectory() !== b.isDirectory())
389
+ return a.isDirectory() ? -1 : 1;
390
+ return a.name.localeCompare(b.name);
391
+ });
392
+ for (const entry of entries) {
393
+ if (DECK_TREE_EXCLUDE.has(entry.name))
394
+ continue;
395
+ if (lines.length >= DECK_TREE_MAX_ENTRIES) {
396
+ lines.push(`${prefix}...`);
397
+ return;
398
+ }
399
+ const isDir = entry.isDirectory();
400
+ lines.push(`${prefix}${entry.name}${isDir ? "/" : ""}`);
401
+ if (isDir && depth < 1) {
402
+ walk(path.join(dir, entry.name), prefix + " ", depth + 1);
403
+ }
404
+ }
405
+ };
406
+ walk(deckDir, "", 0);
407
+ return lines.join("\n");
408
+ }
409
+ function readQuickReference(deckDir) {
410
+ for (const name of ["CLAUDE.md", "AGENTS.md"]) {
411
+ let raw = "";
412
+ try {
413
+ raw = fs.readFileSync(path.join(deckDir, name), "utf8");
414
+ }
415
+ catch {
416
+ continue;
417
+ }
418
+ const heading = /^## Quick reference\s*$/im.exec(raw);
419
+ if (!heading || heading.index === undefined)
420
+ continue;
421
+ const bodyStart = raw.indexOf("\n", heading.index);
422
+ if (bodyStart < 0)
423
+ return "";
424
+ const rest = raw.slice(bodyStart + 1);
425
+ const nextHeading = /^##\s+/m.exec(rest);
426
+ return (nextHeading ? rest.slice(0, nextHeading.index) : rest).trim();
427
+ }
428
+ return "";
429
+ }
430
+ const TOUCHED_FILE_LIMIT = 10;
431
+ function collectStrings(value, out) {
432
+ if (typeof value === "string") {
433
+ out.push(value);
434
+ }
435
+ else if (Array.isArray(value)) {
436
+ for (const item of value)
437
+ collectStrings(item, out);
438
+ }
439
+ else if (value && typeof value === "object") {
440
+ for (const item of Object.values(value)) {
441
+ collectStrings(item, out);
442
+ }
443
+ }
444
+ }
445
+ function toolWritesFiles(name) {
446
+ const kind = name.toLowerCase();
447
+ return ["edit", "write", "notebookedit", "multiedit", "delete"].some((p) => kind.startsWith(p));
448
+ }
449
+ function toolRunsShell(name) {
450
+ const kind = name.toLowerCase();
451
+ return (kind.startsWith("bash") ||
452
+ kind.startsWith("shell") ||
453
+ kind.includes("terminal"));
454
+ }
455
+ function drawingPathForDrawArg(raw) {
456
+ const name = raw.replace(/^['"]|['"]$/g, "").trim();
457
+ if (!name || name.startsWith("-") || name.includes("\n"))
458
+ return null;
459
+ if (name.startsWith("drawings/")) {
460
+ return name.endsWith(".pxart") ? name : `${name}.pxart`;
461
+ }
462
+ return `drawings/${name.endsWith(".pxart") ? name : `${name}.pxart`}`;
463
+ }
464
+ function shellTouchedCandidates(command) {
465
+ const out = [];
466
+ const redirectRe = /(?:^|[\s;|])(?:\d*)>>?\s*(?!&)(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))/g;
467
+ for (const match of command.matchAll(redirectRe)) {
468
+ const target = match[1] ?? match[2] ?? match[3];
469
+ if (target)
470
+ out.push(target);
471
+ }
472
+ const drawRe = /npm\s+run\s+draw\s+--\s+([^\s;&|]+)/g;
473
+ for (const match of command.matchAll(drawRe)) {
474
+ const drawing = drawingPathForDrawArg(match[1] ?? "");
475
+ if (drawing)
476
+ out.push(drawing);
477
+ }
478
+ return out;
479
+ }
480
+ function normalizeTouchedPath(cwd, raw) {
481
+ if (!raw || raw.includes("\n"))
482
+ return null;
483
+ const abs = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
484
+ const rel = path.relative(cwd, abs);
485
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel))
486
+ return null;
487
+ const normalized = rel.split(path.sep).join("/");
488
+ if (normalized.startsWith(".castle/") || PROGRESS_FILE_RE.test(normalized)) {
489
+ return null;
490
+ }
491
+ return normalized;
492
+ }
493
+ function addTouchedFiles(files, cwd, toolName, input) {
494
+ const candidates = [];
495
+ if (toolWritesFiles(toolName)) {
496
+ collectStrings([
497
+ input.file_path,
498
+ input.path,
499
+ input.notebook_path,
500
+ input.old_path,
501
+ input.new_path,
502
+ ], candidates);
503
+ }
504
+ else if (toolRunsShell(toolName) && typeof input.command === "string") {
505
+ candidates.push(...shellTouchedCandidates(input.command));
506
+ }
507
+ else {
508
+ return;
509
+ }
510
+ for (const candidate of candidates) {
511
+ const normalized = normalizeTouchedPath(cwd, candidate);
512
+ if (normalized)
513
+ files.add(normalized);
514
+ }
515
+ }
516
+ function cursorToolNameAndInput(ev) {
517
+ const call = ev.tool_call;
518
+ const key = call ? Object.keys(call).find((k) => k.endsWith("ToolCall")) : undefined;
519
+ if (!call || !key)
520
+ return null;
521
+ const input = call[key];
522
+ const args = input && typeof input === "object"
523
+ ? input.args
524
+ : undefined;
525
+ return {
526
+ name: key.slice(0, -"ToolCall".length),
527
+ input: args && typeof args === "object"
528
+ ? args
529
+ : input && typeof input === "object"
530
+ ? input
531
+ : {},
532
+ };
533
+ }
534
+ function touchedFileList(files) {
535
+ const sorted = [...files].sort();
536
+ if (sorted.length <= TOUCHED_FILE_LIMIT)
537
+ return sorted;
538
+ return [
539
+ ...sorted.slice(0, TOUCHED_FILE_LIMIT),
540
+ `+${sorted.length - TOUCHED_FILE_LIMIT} more`,
541
+ ];
542
+ }
347
543
  function createAgentStreamState() {
348
544
  return {
349
545
  accumulated: "",
350
546
  finalText: "",
351
547
  resultIsError: false,
548
+ usage: undefined,
549
+ filesTouched: new Set(),
352
550
  sawResult: false,
353
551
  segmentText: "",
354
552
  needsGap: false,
355
553
  pendingTools: new Map(),
356
554
  };
357
555
  }
556
+ function parseCliUsage(raw) {
557
+ if (!raw || typeof raw !== "object")
558
+ return undefined;
559
+ const src = raw;
560
+ const usage = {};
561
+ for (const key of [
562
+ "input_tokens",
563
+ "output_tokens",
564
+ "cache_creation_input_tokens",
565
+ "cache_read_input_tokens",
566
+ ]) {
567
+ const value = src[key];
568
+ if (typeof value === "number")
569
+ usage[key] = value;
570
+ }
571
+ return Object.keys(usage).length > 0 ? usage : undefined;
572
+ }
573
+ function formatTokenCount(value) {
574
+ if (typeof value !== "number")
575
+ return "?";
576
+ if (value >= 1000)
577
+ return `${(value / 1000).toFixed(1)}k`;
578
+ return String(value);
579
+ }
580
+ function logRouterUsage(backend, usage) {
581
+ if (!usage)
582
+ return;
583
+ const input = formatTokenCount(usage.input_tokens);
584
+ const read = formatTokenCount(usage.cache_read_input_tokens);
585
+ const created = formatTokenCount(usage.cache_creation_input_tokens);
586
+ const output = formatTokenCount(usage.output_tokens);
587
+ console.error(`[agent usage] router ${backend}: input=${input} cache_read=${read} cache_created=${created} output=${output}`);
588
+ }
358
589
  // Build the per-run stdout event handler over a shared mutable parser state.
359
590
  // Splitting the cursor + claude stream decoding out of runAgentCli keeps each
360
591
  // within the max-lines budget; behavior is identical (same delta/activity/
@@ -388,15 +619,22 @@ function makeAgentEventHandler(opts, state) {
388
619
  if (e?.type === "content_block_start") {
389
620
  if (e.content_block?.type === "tool_use") {
390
621
  state.needsGap = true;
391
- // Hold the label until content_block_stop, once the input (file /
392
- // command) has streamed in, so we can name it concretely.
393
- state.pendingTools.set(e.index ?? -1, {
394
- name: String(e.content_block.name ?? ""),
395
- buf: "",
396
- });
622
+ const name = String(e.content_block.name ?? "");
623
+ // Router: show a coarse label NOW (name is known) so the activity
624
+ // line isn't stuck on the prior state while the tool runs; the
625
+ // concrete label (with the file) refines it at content_block_stop.
626
+ if (opts.labelUnknownTools)
627
+ opts.onActivity?.(genericClaudeToolLabel(name));
628
+ // Hold the concrete label until content_block_stop, once the input
629
+ // (file / command) has streamed in, so we can name it concretely.
630
+ state.pendingTools.set(e.index ?? -1, { name, buf: "" });
397
631
  }
398
632
  else if (e.content_block?.type === "thinking") {
399
633
  state.needsGap = true;
634
+ // Surface extended thinking as the activity line (mirrors the cursor
635
+ // path's `thinking` signal). The next text delta clears it via
636
+ // emitDelta's onActivity(null); a tool block relabels it.
637
+ opts.onActivity?.("Thinking");
400
638
  }
401
639
  }
402
640
  else if (e?.type === "content_block_delta") {
@@ -430,6 +668,7 @@ function makeAgentEventHandler(opts, state) {
430
668
  catch {
431
669
  /* input JSON arrived partial -- fall back to a generic label */
432
670
  }
671
+ addTouchedFiles(state.filesTouched, opts.cwd, pending.name, input);
433
672
  const label = claudeToolFeedLabel(pending.name, input);
434
673
  if (label)
435
674
  opts.onActivity?.(label);
@@ -441,6 +680,7 @@ function makeAgentEventHandler(opts, state) {
441
680
  state.finalText =
442
681
  typeof ev.result === "string" ? ev.result : state.accumulated;
443
682
  state.resultIsError = ev.is_error === true;
683
+ state.usage = parseCliUsage(ev.usage);
444
684
  }
445
685
  };
446
686
  return (ev) => {
@@ -463,19 +703,23 @@ function makeAgentEventHandler(opts, state) {
463
703
  else if (ev.type === "tool_call") {
464
704
  state.segmentText = "";
465
705
  state.needsGap = true;
706
+ const tool = cursorToolNameAndInput(ev);
707
+ if (tool)
708
+ addTouchedFiles(state.filesTouched, opts.cwd, tool.name, tool.input);
466
709
  if (ev.subtype === "started")
467
710
  opts.onActivity?.(toolActivityLabel(ev));
468
711
  }
469
712
  else if (ev.type === "thinking") {
470
713
  state.segmentText = "";
471
714
  state.needsGap = true;
472
- opts.onActivity?.("thinking");
715
+ opts.onActivity?.("Thinking");
473
716
  }
474
717
  else if (ev.type === "result") {
475
718
  state.sawResult = true;
476
719
  state.finalText =
477
720
  typeof ev.result === "string" ? ev.result : state.accumulated;
478
721
  state.resultIsError = ev.is_error === true;
722
+ state.usage = parseCliUsage(ev.usage);
479
723
  }
480
724
  };
481
725
  }
@@ -520,6 +764,8 @@ function runAgentCli(opts) {
520
764
  ok: false,
521
765
  finalText: state.finalText || state.accumulated,
522
766
  error: "agent run timed out",
767
+ usage: state.usage,
768
+ filesTouched: touchedFileList(state.filesTouched),
523
769
  });
524
770
  }, opts.timeoutMs);
525
771
  const handleEvent = makeAgentEventHandler(opts, state);
@@ -556,6 +802,8 @@ function runAgentCli(opts) {
556
802
  settle({
557
803
  ok,
558
804
  finalText: state.finalText || state.accumulated,
805
+ usage: state.usage,
806
+ filesTouched: touchedFileList(state.filesTouched),
559
807
  crashed: !state.sawResult,
560
808
  error: ok
561
809
  ? undefined
@@ -653,6 +901,7 @@ async function runTaskAgentIn(ctx, task) {
653
901
  notesPath: path.join(relDir, "notes.md"),
654
902
  depsSummary: ctx.depsSummary,
655
903
  backend: ctx.backend,
904
+ deckTree: buildDeckTree(ctx.deckDir),
656
905
  });
657
906
  // No /goal wrapper: it makes a fresh evaluator re-check the WHOLE task
658
907
  // prompt (including user-only "done when you reach wave 5"-style play
@@ -787,6 +1036,7 @@ function startTask(ctx, task) {
787
1036
  task.acknowledged = true;
788
1037
  if (result.ok && !wasStopped)
789
1038
  task.progress = 100;
1039
+ task.files = result.filesTouched ?? [];
790
1040
  task.finishedAt = nowIso();
791
1041
  task.resultSummary = wasStopped
792
1042
  ? "stopped by the router"
@@ -1060,6 +1310,24 @@ function asPromptTask(task) {
1060
1310
  status: task.rejected ? "rejected by user" : task.status,
1061
1311
  progress: task.progress,
1062
1312
  notes: task.notes,
1313
+ files: task.files,
1314
+ };
1315
+ }
1316
+ function asClientTask(task) {
1317
+ return {
1318
+ id: task.id,
1319
+ title: task.title,
1320
+ status: task.status,
1321
+ progress: task.progress,
1322
+ notes: task.notes,
1323
+ createdAt: task.createdAt,
1324
+ startedAt: task.startedAt,
1325
+ finishedAt: task.finishedAt,
1326
+ resultSummary: task.resultSummary,
1327
+ avatar: task.avatar,
1328
+ phase: task.phase,
1329
+ acknowledged: task.acknowledged,
1330
+ rejected: task.rejected,
1063
1331
  };
1064
1332
  }
1065
1333
  function createTaskFeeds(broadcast) {
@@ -1146,9 +1414,18 @@ function runRouterTurnIn(ctx, instruction) {
1146
1414
  ctx.broadcast({ type: "message-add", message });
1147
1415
  let raw = "";
1148
1416
  let visibleSent = 0;
1149
- let lastActivity = null;
1417
+ // Seed the activity line to "thinking" immediately -- covers the otherwise
1418
+ // silent spawn + first-token lull (fresh CLI process, prompt processing,
1419
+ // extended thinking) before any stream event arrives. The stream then
1420
+ // overrides it: a tool relabels it, the first text delta clears it. Seeding
1421
+ // lastActivity too dedups the redundant broadcast when the claude thinking
1422
+ // block later re-emits "thinking".
1423
+ let lastActivity = "Thinking";
1424
+ ctx.broadcast({ type: "message-activity", id: message.id, activity: "Thinking" });
1150
1425
  const prompt = buildRouterPrompt({
1151
1426
  deckLabel: ctx.deckLabel,
1427
+ quickReference: ctx.quickReference,
1428
+ deckTree: buildDeckTree(ctx.deckDir),
1152
1429
  messages: ctx.log.messages
1153
1430
  .filter((m) => m.role !== "log" && m.id !== message.id && m.status !== "streaming")
1154
1431
  .map((m) => ({
@@ -1177,6 +1454,7 @@ function runRouterTurnIn(ctx, instruction) {
1177
1454
  timeoutMs: ROUTER_TIMEOUT_MS,
1178
1455
  logPath: path.join(ctx.agentDir, "router-log.jsonl"),
1179
1456
  children: ctx.children,
1457
+ labelUnknownTools: true,
1180
1458
  onDelta: (delta) => {
1181
1459
  raw += delta;
1182
1460
  const visible = visibleLength(raw);
@@ -1195,6 +1473,7 @@ function runRouterTurnIn(ctx, instruction) {
1195
1473
  },
1196
1474
  })
1197
1475
  .then((result) => {
1476
+ logRouterUsage(backend, result.usage);
1198
1477
  // The settle path must ALWAYS reach ctx.onSettled() (clears
1199
1478
  // routerRunning + flushes pendingSends). A throw here on Node v25 would
1200
1479
  // otherwise both freeze the composer and crash the serve, so the whole
@@ -1373,18 +1652,14 @@ function startChildRegistry(registryPath, groups) {
1373
1652
  // so createAgentServer stays within the max-lines budget; the queue-by-default
1374
1653
  // semantics are unchanged.
1375
1654
  function createRouterQueue(deps) {
1376
- const { deckDir, deckLabel, agentDir, attachmentsDir, routerChildren, log, broadcast, taskStore, messages, settings, } = deps;
1655
+ const { deckDir, deckLabel, quickReference, agentDir, attachmentsDir, routerChildren, log, broadcast, taskStore, messages, settings, } = deps;
1377
1656
  let userEpoch = 0;
1378
1657
  let routerRunning = false;
1379
- // Queued mid-run sends. Each entry carries the message id it will be logged
1380
- // under and the attachment filenames already saved to disk at enqueue time,
1381
- // so the whole queue is durable: it is mirrored to pending-sends.json and can
1382
- // be recovered if the serve restarts before the queue drains.
1658
+ // Durable mid-run sends, mirrored to pending-sends.json until drained.
1383
1659
  const pendingSends = [];
1384
1660
  const pendingPath = path.join(agentDir, "pending-sends.json");
1385
1661
  let pendingInterruptedDraft = "";
1386
- // Mirror the in-memory queue to disk. Called on every mutation (enqueue,
1387
- // drain, cancel, recover) so a restart never loses an unsent queued message.
1662
+ // Persist every mutation so a restart never loses an unsent queued message.
1388
1663
  function persistPending() {
1389
1664
  fs.writeFileSync(pendingPath, JSON.stringify(pendingSends, null, 2) + "\n");
1390
1665
  }
@@ -1417,6 +1692,7 @@ function createRouterQueue(deps) {
1417
1692
  runRouterTurnIn({
1418
1693
  deckDir,
1419
1694
  deckLabel,
1695
+ quickReference,
1420
1696
  agentDir,
1421
1697
  children: routerChildren,
1422
1698
  log,
@@ -1428,10 +1704,7 @@ function createRouterQueue(deps) {
1428
1704
  onSettled: onRouterSettled,
1429
1705
  }, instruction);
1430
1706
  }
1431
- // Drain the queue into the log as real user messages and start one follow-up
1432
- // turn addressing them all (a burst batches into a single turn). A pending
1433
- // interrupted draft from a "send now" / Stop is carried into the instruction.
1434
- // No-op while a turn is running or the queue is empty.
1707
+ // Drain queued sends into one follow-up turn; no-op while running or empty.
1435
1708
  function maybeStartRouterTurn() {
1436
1709
  if (routerRunning || pendingSends.length === 0)
1437
1710
  return;
@@ -1439,9 +1712,7 @@ function createRouterQueue(deps) {
1439
1712
  const texts = [];
1440
1713
  const attachmentPaths = [];
1441
1714
  for (const item of drained) {
1442
- // The message id and attachments were assigned/saved at enqueue time and
1443
- // persisted in pending-sends.json, so the drain just commits them to the
1444
- // message log under that same id (recovery dedupes on it; see below).
1715
+ // Enqueue assigned the durable id/attachments; drain commits them to log.
1445
1716
  const message = {
1446
1717
  id: item.id,
1447
1718
  role: "user",
@@ -1555,6 +1826,7 @@ function createRouterQueue(deps) {
1555
1826
  }
1556
1827
  export function createAgentServer(opts) {
1557
1828
  const { deckDir, deckLabel } = opts;
1829
+ const quickReference = readQuickReference(deckDir);
1558
1830
  const agentDir = path.join(deckDir, ".castle", "agent");
1559
1831
  const tasksDir = path.join(agentDir, "tasks");
1560
1832
  const attachmentsDir = path.join(agentDir, "attachments");
@@ -1601,7 +1873,7 @@ export function createAgentServer(opts) {
1601
1873
  backend: () => settings.tasks,
1602
1874
  claudeModel: () => settings.claudeModel,
1603
1875
  // Task lifecycle stays on the board only -- log lines for it were spam.
1604
- onUpdate: (task) => broadcast({ type: "task-update", task }),
1876
+ onUpdate: (task) => broadcast({ type: "task-update", task: asClientTask(task) }),
1605
1877
  onStarted: () => undefined,
1606
1878
  onRetry: (task, attempt) => addLog(`agent died, retrying (${attempt}/${MAX_TASK_ATTEMPTS}): ${task.title}`),
1607
1879
  onFinished: (task) => taskFeeds.map.delete(task.id),
@@ -1612,6 +1884,7 @@ export function createAgentServer(opts) {
1612
1884
  const routerQueue = createRouterQueue({
1613
1885
  deckDir,
1614
1886
  deckLabel,
1887
+ quickReference,
1615
1888
  agentDir,
1616
1889
  attachmentsDir,
1617
1890
  routerChildren,
@@ -1644,7 +1917,7 @@ export function createAgentServer(opts) {
1644
1917
  const hello = {
1645
1918
  type: "hello",
1646
1919
  messages,
1647
- tasks: taskStore.sorted(),
1920
+ tasks: taskStore.sorted().map(asClientTask),
1648
1921
  settings,
1649
1922
  feeds: Object.fromEntries(taskFeeds.map),
1650
1923
  running: routerQueue.isRunning(),