castle-web-cli 0.4.75 → 0.4.77

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 (74) hide show
  1. package/dist/agent-prompts.d.ts +13 -0
  2. package/dist/agent-prompts.js +43 -13
  3. package/dist/agent.d.ts +29 -0
  4. package/dist/agent.js +1004 -257
  5. package/dist/castle-host/host.js +59 -5
  6. package/dist/commonInstructions.d.ts +1 -1
  7. package/dist/commonInstructions.js +11 -1
  8. package/dist/filesChanged.d.ts +25 -0
  9. package/dist/filesChanged.js +140 -0
  10. package/dist/ide.d.ts +1 -0
  11. package/dist/ide.js +250 -1
  12. package/dist/init.js +46 -4
  13. package/dist/save-deck.js +8 -1
  14. package/dist/serve.js +45 -3
  15. package/dist/shell/assets/index-CvHiGhAV.js +141 -0
  16. package/dist/shell/assets/{index-WNbOHPBj.css → index-QteLRDnK.css} +1 -1
  17. package/dist/shell/index.html +2 -2
  18. package/kits/basic-2d/CLAUDE.md +13 -5
  19. package/kits/basic-2d/castle.json +15 -0
  20. package/kits/basic-2d/drawings/pig.pxart +22 -55
  21. package/kits/basic-2d/editors/PlayOnly.jsx +8 -3
  22. package/kits/basic-2d/editors/PxArtEditor.jsx +237 -4
  23. package/kits/basic-2d/editors/SingleEditor.jsx +30 -59
  24. package/kits/basic-2d/editors/pixelEditorChrome.jsx +17 -11
  25. package/kits/basic-2d/editors/pixelGeometry.js +95 -0
  26. package/kits/basic-2d/editors/pixelInspector.jsx +229 -51
  27. package/kits/basic-2d/editors/pxArtTools.js +109 -1
  28. package/kits/basic-2d/engine/ScenePlayer.jsx +11 -160
  29. package/kits/basic-2d/engine/liveReload.js +88 -0
  30. package/kits/basic-2d/engine/ui.jsx +15 -0
  31. package/kits/basic-2d/engine/ui.module.css +44 -117
  32. package/kits/basic-2d/main.jsx +6 -10
  33. package/package.json +10 -1
  34. package/dist/shell/assets/index-Dfn29Bkt.js +0 -108
  35. package/kits/basic-2d/editors/App.jsx +0 -226
  36. package/kits/basic-2d/editors/CodeEditor.jsx +0 -79
  37. package/kits/basic-2d/editors/FileBrowser.jsx +0 -349
  38. package/kits/basic-2d/editors/codeTheme.js +0 -135
  39. package/kits/basic-2d/engine/playConsole.js +0 -66
  40. package/kits/basic-2d/pnpm-workspace.yaml +0 -3
  41. package/kits/basic-3d/.prettierrc +0 -8
  42. package/kits/basic-3d/CLAUDE.md +0 -162
  43. package/kits/basic-3d/behaviors/Camera.jsx +0 -56
  44. package/kits/basic-3d/behaviors/Collider.jsx +0 -78
  45. package/kits/basic-3d/behaviors/Mesh.jsx +0 -82
  46. package/kits/basic-3d/behaviors/Model.jsx +0 -61
  47. package/kits/basic-3d/behaviors/Transform.jsx +0 -35
  48. package/kits/basic-3d/editors/App.jsx +0 -147
  49. package/kits/basic-3d/editors/CodeEditor.jsx +0 -112
  50. package/kits/basic-3d/editors/FileBrowser.jsx +0 -143
  51. package/kits/basic-3d/editors/ModelEditor.jsx +0 -400
  52. package/kits/basic-3d/editors/PlayOnly.jsx +0 -22
  53. package/kits/basic-3d/editors/SceneEditor.jsx +0 -1081
  54. package/kits/basic-3d/editors/behaviorRegistry.js +0 -24
  55. package/kits/basic-3d/editors/editorHistory.js +0 -52
  56. package/kits/basic-3d/editors/viewportRig.js +0 -90
  57. package/kits/basic-3d/engine/ScenePlayer.jsx +0 -58
  58. package/kits/basic-3d/engine/SceneUI.jsx +0 -67
  59. package/kits/basic-3d/engine/SceneViewport.jsx +0 -102
  60. package/kits/basic-3d/engine/autoInspector.jsx +0 -51
  61. package/kits/basic-3d/engine/files.js +0 -73
  62. package/kits/basic-3d/engine/scene.js +0 -502
  63. package/kits/basic-3d/engine/threeUtil.js +0 -260
  64. package/kits/basic-3d/engine/ui.jsx +0 -352
  65. package/kits/basic-3d/engine/ui.module.css +0 -944
  66. package/kits/basic-3d/eslint.config.js +0 -51
  67. package/kits/basic-3d/index.html +0 -11
  68. package/kits/basic-3d/main.jsx +0 -10
  69. package/kits/basic-3d/models/block.model +0 -14
  70. package/kits/basic-3d/package-lock.json +0 -2713
  71. package/kits/basic-3d/package.json +0 -41
  72. package/kits/basic-3d/pnpm-lock.yaml +0 -1769
  73. package/kits/basic-3d/scenes/main.scene +0 -76
  74. package/kits/basic-3d/vite.config.js +0 -1
package/dist/agent.js CHANGED
@@ -91,6 +91,24 @@ const MAX_TASK_ATTEMPTS = 3;
91
91
  // limits. Over-cap tasks stay queued ('waiting') and start, earliest-created
92
92
  // first, as running ones finish. Conservative default; override via env.
93
93
  const MAX_CONCURRENT_TASKS = Number(process.env.CASTLE_MAX_CONCURRENT_TASKS) || 4;
94
+ // Base backoff (ms) before a crashed task agent is relaunched, scaled by
95
+ // attempt number and jittered (see waitBeforeTaskRetry). Incident (2026-07):
96
+ // the router spawned 4 parallel tasks whose cursor-agent processes all
97
+ // crashed at startup within ~1.3s with a macOS keychain error ("Security
98
+ // command failed: Security process exited with code: 44" -- credential
99
+ // lookup contention when many cursor-agent processes start at once). The old
100
+ // instant retry burned all 3 attempts of every task inside that same ~6s
101
+ // contention window (11 agent processes launched in ~7s); the one retry that
102
+ // happened to land ~2s later succeeded, so growing + jittered spacing
103
+ // between attempts gives the keychain time to clear. Override for tests /
104
+ // impatient devs, same pattern as CASTLE_MAX_CONCURRENT_TASKS above.
105
+ const TASK_RETRY_BACKOFF_BASE_MS = Number(process.env.CASTLE_TASK_RETRY_BACKOFF_MS) || 1500;
106
+ // Minimal spacing enforced between successive task-agent PROCESS LAUNCHES
107
+ // (see staggerTaskSpawn) -- the initial simultaneous spawn is itself part of
108
+ // the same thundering herd as the retry storm above, independent of it.
109
+ // Router turns are unaffected: only one ever runs at a time. Override for
110
+ // tests / impatient devs.
111
+ const TASK_SPAWN_STAGGER_MS = Number(process.env.CASTLE_TASK_SPAWN_STAGGER_MS) || 400;
94
112
  const TASK_POLL_MS = 1_000;
95
113
  const FENCE_HOLDBACK = "```castle-";
96
114
  const RESULT_SUMMARY_CHARS = 600;
@@ -125,6 +143,35 @@ function visibleLength(raw) {
125
143
  }
126
144
  return raw.length;
127
145
  }
146
+ // Parse one ```castle-task fence's body (title line, optional "after:" line,
147
+ // then the prompt) into a directive. Shared by the settle-time full-text
148
+ // extraction below and the mid-stream incremental scanner (runRouterTurnIn),
149
+ // so a fence spawned early behaves identically to one spawned at settle.
150
+ function parseTaskFenceBody(body) {
151
+ const lines = body.replace(/\r/g, "").split("\n");
152
+ const title = (lines.shift() ?? "").trim();
153
+ if (!title)
154
+ return null;
155
+ const after = [];
156
+ while (lines.length > 0) {
157
+ const headerMatch = /^(after):\s*(.*)$/i.exec((lines[0] ?? "").trim());
158
+ if (!headerMatch)
159
+ break;
160
+ lines.shift();
161
+ after.push(...headerMatch[2]
162
+ .split(",")
163
+ .map((s) => s.trim())
164
+ .filter(Boolean));
165
+ }
166
+ return { title, after, prompt: lines.join("\n").trim() };
167
+ }
168
+ // A fresh RegExp per call -- this is matched with manual .exec() loops in
169
+ // TWO independent call sites (settle-time extractDirectives via .replace, and
170
+ // the mid-stream scanNewTaskFences via a lastIndex-seeded loop) that must
171
+ // never share mutable lastIndex state.
172
+ function taskFenceRegex() {
173
+ return /```castle-task[ \t]*\r?\n([\s\S]*?)```/g;
174
+ }
128
175
  // Pull ```castle-task fenced directives out of a finished router reply.
129
176
  // Block format: title line, then an optional "after:" line, then the prompt.
130
177
  function extractDirectives(full) {
@@ -143,25 +190,10 @@ function extractDirectives(full) {
143
190
  });
144
191
  };
145
192
  const withoutDone = listFence(listFence(full, "castle-done", checkoffs), "castle-stop", stops);
146
- const fenceRe = /```castle-task[ \t]*\r?\n([\s\S]*?)```/g;
147
- const cleaned = withoutDone.replace(fenceRe, (_match, body) => {
148
- const lines = String(body).replace(/\r/g, "").split("\n");
149
- const title = (lines.shift() ?? "").trim();
150
- const headers = { after: [] };
151
- while (lines.length > 0) {
152
- const headerMatch = /^(after):\s*(.*)$/i.exec((lines[0] ?? "").trim());
153
- if (!headerMatch)
154
- break;
155
- lines.shift();
156
- headers[headerMatch[1].toLowerCase()] = headerMatch[2]
157
- .split(",")
158
- .map((s) => s.trim())
159
- .filter(Boolean);
160
- }
161
- const prompt = lines.join("\n").trim();
162
- if (title) {
163
- directives.push({ title, after: headers.after, prompt });
164
- }
193
+ const cleaned = withoutDone.replace(taskFenceRegex(), (_match, body) => {
194
+ const directive = parseTaskFenceBody(String(body));
195
+ if (directive)
196
+ directives.push(directive);
165
197
  return "";
166
198
  });
167
199
  return {
@@ -171,6 +203,28 @@ function extractDirectives(full) {
171
203
  stops,
172
204
  };
173
205
  }
206
+ // Scan `raw` for ```castle-task fences that have FULLY closed since
207
+ // `fromIndex` -- i.e. their closing ``` has already streamed in -- and parse
208
+ // each into a directive. Returns the index just past the last one consumed,
209
+ // so the next call only looks at genuinely new text. castle-done / castle-
210
+ // stop fences are deliberately NOT scanned here: they stay settle-only (see
211
+ // runRouterTurnIn) since they are cheap and order-sensitive, and acting on a
212
+ // stop/done fence before the reply is even finished streaming would be
213
+ // surprising.
214
+ function scanNewTaskFences(raw, fromIndex) {
215
+ const re = taskFenceRegex();
216
+ re.lastIndex = fromIndex;
217
+ const directives = [];
218
+ let nextIndex = fromIndex;
219
+ let match;
220
+ while ((match = re.exec(raw))) {
221
+ const directive = parseTaskFenceBody(match[1]);
222
+ if (directive)
223
+ directives.push(directive);
224
+ nextIndex = re.lastIndex;
225
+ }
226
+ return { directives, nextIndex };
227
+ }
174
228
  // -- agent signals (```signal blocks from task agents) -----------------------
175
229
  // A running task agent narrates in prose and periodically emits ONE fenced
176
230
  // ```signal block of progress metadata (mirrors djinn's lib/markdown.ts). We
@@ -277,6 +331,26 @@ function claudeToolFeedLabel(name, input) {
277
331
  }
278
332
  return null;
279
333
  }
334
+ // Coarse activity label for a claude tool from its NAME alone (available at
335
+ // content_block_start, before the input JSON streams in). Router-only fallback
336
+ // so the streaming reply's activity line reflects EVERY tool -- including the
337
+ // commands/searches claudeToolFeedLabel intentionally drops from task feeds.
338
+ // Mirrors the cursor toolActivityLabel verbs so the two backends read alike.
339
+ function genericClaudeToolLabel(name) {
340
+ const kind = name.toLowerCase();
341
+ if (kind.startsWith("read") || kind.startsWith("notebookread"))
342
+ return "Reading the deck";
343
+ if (["edit", "write", "notebook", "multiedit"].some((p) => kind.startsWith(p)))
344
+ return "Editing files";
345
+ if (kind.startsWith("bash") || kind.includes("terminal"))
346
+ return "Running a command";
347
+ if (["grep", "glob", "ls"].some((p) => kind.startsWith(p)) ||
348
+ kind.includes("search"))
349
+ return "Searching the deck";
350
+ if (kind.startsWith("web"))
351
+ return "Searching the web";
352
+ return "Working";
353
+ }
280
354
  // Human-readable label for a tool_call event, e.g. readToolCall -> "reading
281
355
  // the deck". Shown as the streaming message's activity line.
282
356
  function toolActivityLabel(ev) {
@@ -286,13 +360,13 @@ function toolActivityLabel(ev) {
286
360
  : undefined;
287
361
  const kind = (key ?? "").slice(0, -"ToolCall".length).toLowerCase();
288
362
  if (["read", "glob", "grep", "ls", "list"].some((p) => kind.startsWith(p))) {
289
- return "reading the deck";
363
+ return "Reading the deck";
290
364
  }
291
365
  if (["write", "edit", "delete", "mv"].some((p) => kind.startsWith(p)))
292
- return "editing files";
366
+ return "Editing files";
293
367
  if (["shell", "bash", "terminal"].some((p) => kind.startsWith(p)))
294
- return "running a command";
295
- return "working";
368
+ return "Running a command";
369
+ return "Working";
296
370
  }
297
371
  // Castle's agent CLI keys, delivered to the sandbox as a file
298
372
  // (~/.castle/keys.json) rather than sandbox-wide env -- so an ambient key can't
@@ -344,17 +418,253 @@ function envForAgentSpawn(backend) {
344
418
  }
345
419
  return env;
346
420
  }
421
+ const DECK_TREE_EXCLUDE = new Set([
422
+ "node_modules",
423
+ ".castle",
424
+ ".git",
425
+ "dist",
426
+ ".DS_Store",
427
+ ]);
428
+ const DECK_TREE_MAX_ENTRIES = 200;
429
+ // Per-directory listing cap. A successful deck accumulates hundreds of
430
+ // drawings; without this, one big directory exhausts the global budget
431
+ // depth-first and every directory sorting after it (scenes/ included) vanishes
432
+ // from the snapshot entirely. Summarizing the overflow as "(+N more .pxart)"
433
+ // keeps every directory visible and turns the count itself into signal.
434
+ const DECK_TREE_PER_DIR = 15;
435
+ // Shallow orientation snapshot for router/task prompts. Best-effort by design:
436
+ // filesystem hiccups should cost context, not fail an agent turn.
437
+ function buildDeckTree(deckDir) {
438
+ const lines = [];
439
+ const walk = (dir, prefix, depth) => {
440
+ let entries;
441
+ try {
442
+ entries = fs.readdirSync(dir, { withFileTypes: true });
443
+ }
444
+ catch {
445
+ return;
446
+ }
447
+ entries.sort((a, b) => {
448
+ if (a.isDirectory() !== b.isDirectory())
449
+ return a.isDirectory() ? -1 : 1;
450
+ return a.name.localeCompare(b.name);
451
+ });
452
+ const visible = entries.filter((e) => !DECK_TREE_EXCLUDE.has(e.name));
453
+ for (const entry of visible.slice(0, DECK_TREE_PER_DIR)) {
454
+ if (lines.length >= DECK_TREE_MAX_ENTRIES) {
455
+ lines.push(`${prefix}...`);
456
+ return;
457
+ }
458
+ const isDir = entry.isDirectory();
459
+ lines.push(`${prefix}${entry.name}${isDir ? "/" : ""}`);
460
+ if (isDir && depth < 1) {
461
+ walk(path.join(dir, entry.name), prefix + " ", depth + 1);
462
+ }
463
+ }
464
+ const rest = visible.slice(DECK_TREE_PER_DIR);
465
+ if (rest.length > 0 && lines.length < DECK_TREE_MAX_ENTRIES) {
466
+ // Name the overflow's extension when it's uniform ("+214 more .pxart"),
467
+ // since that tells the reader what kind of files dominate the directory.
468
+ const exts = new Set(rest.map((e) => (e.isDirectory() ? "/" : path.extname(e.name))));
469
+ const [only] = exts;
470
+ const suffix = exts.size === 1 && only && only !== "/" ? ` ${only}` : "";
471
+ lines.push(`${prefix}(+${rest.length} more${suffix})`);
472
+ }
473
+ };
474
+ walk(deckDir, "", 0);
475
+ return lines.join("\n");
476
+ }
477
+ function readQuickReference(deckDir) {
478
+ for (const name of ["CLAUDE.md", "AGENTS.md"]) {
479
+ let raw = "";
480
+ try {
481
+ raw = fs.readFileSync(path.join(deckDir, name), "utf8");
482
+ }
483
+ catch {
484
+ continue;
485
+ }
486
+ const heading = /^## Quick reference\s*$/im.exec(raw);
487
+ if (!heading || heading.index === undefined)
488
+ continue;
489
+ const bodyStart = raw.indexOf("\n", heading.index);
490
+ if (bodyStart < 0)
491
+ return "";
492
+ const rest = raw.slice(bodyStart + 1);
493
+ const nextHeading = /^##\s+/m.exec(rest);
494
+ return (nextHeading ? rest.slice(0, nextHeading.index) : rest).trim();
495
+ }
496
+ return "";
497
+ }
498
+ const TOUCHED_FILE_LIMIT = 10;
499
+ function collectStrings(value, out) {
500
+ if (typeof value === "string") {
501
+ out.push(value);
502
+ }
503
+ else if (Array.isArray(value)) {
504
+ for (const item of value)
505
+ collectStrings(item, out);
506
+ }
507
+ else if (value && typeof value === "object") {
508
+ for (const item of Object.values(value)) {
509
+ collectStrings(item, out);
510
+ }
511
+ }
512
+ }
513
+ function toolWritesFiles(name) {
514
+ const kind = name.toLowerCase();
515
+ return ["edit", "write", "notebookedit", "multiedit", "delete"].some((p) => kind.startsWith(p));
516
+ }
517
+ function toolRunsShell(name) {
518
+ const kind = name.toLowerCase();
519
+ return (kind.startsWith("bash") ||
520
+ kind.startsWith("shell") ||
521
+ kind.includes("terminal"));
522
+ }
523
+ function drawingPathForDrawArg(raw) {
524
+ const name = raw.replace(/^['"]|['"]$/g, "").trim();
525
+ if (!name || name.startsWith("-") || name.includes("\n"))
526
+ return null;
527
+ if (name.startsWith("drawings/")) {
528
+ return name.endsWith(".pxart") ? name : `${name}.pxart`;
529
+ }
530
+ return `drawings/${name.endsWith(".pxart") ? name : `${name}.pxart`}`;
531
+ }
532
+ function shellTouchedCandidates(command) {
533
+ const out = [];
534
+ const redirectRe = /(?:^|[\s;|])(?:\d*)>>?\s*(?!&)(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))/g;
535
+ for (const match of command.matchAll(redirectRe)) {
536
+ const target = match[1] ?? match[2] ?? match[3];
537
+ if (target)
538
+ out.push(target);
539
+ }
540
+ const drawRe = /npm\s+run\s+draw\s+--\s+([^\s;&|]+)/g;
541
+ for (const match of command.matchAll(drawRe)) {
542
+ const drawing = drawingPathForDrawArg(match[1] ?? "");
543
+ if (drawing)
544
+ out.push(drawing);
545
+ }
546
+ return out;
547
+ }
548
+ // Guards every touched-file candidate (shell redirects AND tool file-path
549
+ // args) against junk that isn't plausibly a path. Added because
550
+ // shellTouchedCandidates' redirect regex treats any `>`-plus-token as a
551
+ // write target, so a command merely CONTAINING `>=` (e.g. a numeric
552
+ // comparison inside a quoted inline JS/awk script) false-matches as a
553
+ // redirect to "=" (or "=5" with no space around the `>=`) -- neither looks
554
+ // like a real file. A leading "-" is rejected too, mirroring
555
+ // drawingPathForDrawArg's flag guard above.
556
+ function looksLikeTouchedPath(raw) {
557
+ return /[a-zA-Z0-9]/.test(raw) && raw[0] !== "-" && raw[0] !== "=";
558
+ }
559
+ function normalizeTouchedPath(cwd, raw) {
560
+ if (!raw || raw.includes("\n") || !looksLikeTouchedPath(raw))
561
+ return null;
562
+ const abs = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
563
+ const rel = path.relative(cwd, abs);
564
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel))
565
+ return null;
566
+ const normalized = rel.split(path.sep).join("/");
567
+ if (normalized.startsWith(".castle/") || PROGRESS_FILE_RE.test(normalized)) {
568
+ return null;
569
+ }
570
+ return normalized;
571
+ }
572
+ function addTouchedFiles(files, cwd, toolName, input) {
573
+ const candidates = [];
574
+ if (toolWritesFiles(toolName)) {
575
+ collectStrings([
576
+ input.file_path,
577
+ input.path,
578
+ input.notebook_path,
579
+ input.old_path,
580
+ input.new_path,
581
+ ], candidates);
582
+ }
583
+ else if (toolRunsShell(toolName) && typeof input.command === "string") {
584
+ candidates.push(...shellTouchedCandidates(input.command));
585
+ }
586
+ else {
587
+ return;
588
+ }
589
+ for (const candidate of candidates) {
590
+ const normalized = normalizeTouchedPath(cwd, candidate);
591
+ if (normalized)
592
+ files.add(normalized);
593
+ }
594
+ }
595
+ function cursorToolNameAndInput(ev) {
596
+ const call = ev.tool_call;
597
+ const key = call ? Object.keys(call).find((k) => k.endsWith("ToolCall")) : undefined;
598
+ if (!call || !key)
599
+ return null;
600
+ const input = call[key];
601
+ const args = input && typeof input === "object"
602
+ ? input.args
603
+ : undefined;
604
+ return {
605
+ name: key.slice(0, -"ToolCall".length),
606
+ input: args && typeof args === "object"
607
+ ? args
608
+ : input && typeof input === "object"
609
+ ? input
610
+ : {},
611
+ };
612
+ }
613
+ function touchedFileList(files) {
614
+ const sorted = [...files].sort();
615
+ if (sorted.length <= TOUCHED_FILE_LIMIT)
616
+ return sorted;
617
+ return [
618
+ ...sorted.slice(0, TOUCHED_FILE_LIMIT),
619
+ `+${sorted.length - TOUCHED_FILE_LIMIT} more`,
620
+ ];
621
+ }
347
622
  function createAgentStreamState() {
348
623
  return {
349
624
  accumulated: "",
350
625
  finalText: "",
351
626
  resultIsError: false,
627
+ usage: undefined,
628
+ filesTouched: new Set(),
352
629
  sawResult: false,
353
630
  segmentText: "",
354
631
  needsGap: false,
355
632
  pendingTools: new Map(),
356
633
  };
357
634
  }
635
+ function parseCliUsage(raw) {
636
+ if (!raw || typeof raw !== "object")
637
+ return undefined;
638
+ const src = raw;
639
+ const usage = {};
640
+ for (const key of [
641
+ "input_tokens",
642
+ "output_tokens",
643
+ "cache_creation_input_tokens",
644
+ "cache_read_input_tokens",
645
+ ]) {
646
+ const value = src[key];
647
+ if (typeof value === "number")
648
+ usage[key] = value;
649
+ }
650
+ return Object.keys(usage).length > 0 ? usage : undefined;
651
+ }
652
+ function formatTokenCount(value) {
653
+ if (typeof value !== "number")
654
+ return "?";
655
+ if (value >= 1000)
656
+ return `${(value / 1000).toFixed(1)}k`;
657
+ return String(value);
658
+ }
659
+ function logRouterUsage(backend, usage) {
660
+ if (!usage)
661
+ return;
662
+ const input = formatTokenCount(usage.input_tokens);
663
+ const read = formatTokenCount(usage.cache_read_input_tokens);
664
+ const created = formatTokenCount(usage.cache_creation_input_tokens);
665
+ const output = formatTokenCount(usage.output_tokens);
666
+ console.error(`[agent usage] router ${backend}: input=${input} cache_read=${read} cache_created=${created} output=${output}`);
667
+ }
358
668
  // Build the per-run stdout event handler over a shared mutable parser state.
359
669
  // Splitting the cursor + claude stream decoding out of runAgentCli keeps each
360
670
  // within the max-lines budget; behavior is identical (same delta/activity/
@@ -388,15 +698,22 @@ function makeAgentEventHandler(opts, state) {
388
698
  if (e?.type === "content_block_start") {
389
699
  if (e.content_block?.type === "tool_use") {
390
700
  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
- });
701
+ const name = String(e.content_block.name ?? "");
702
+ // Router: show a coarse label NOW (name is known) so the activity
703
+ // line isn't stuck on the prior state while the tool runs; the
704
+ // concrete label (with the file) refines it at content_block_stop.
705
+ if (opts.labelUnknownTools)
706
+ opts.onActivity?.(genericClaudeToolLabel(name));
707
+ // Hold the concrete label until content_block_stop, once the input
708
+ // (file / command) has streamed in, so we can name it concretely.
709
+ state.pendingTools.set(e.index ?? -1, { name, buf: "" });
397
710
  }
398
711
  else if (e.content_block?.type === "thinking") {
399
712
  state.needsGap = true;
713
+ // Surface extended thinking as the activity line (mirrors the cursor
714
+ // path's `thinking` signal). The next text delta clears it via
715
+ // emitDelta's onActivity(null); a tool block relabels it.
716
+ opts.onActivity?.("Thinking");
400
717
  }
401
718
  }
402
719
  else if (e?.type === "content_block_delta") {
@@ -430,6 +747,7 @@ function makeAgentEventHandler(opts, state) {
430
747
  catch {
431
748
  /* input JSON arrived partial -- fall back to a generic label */
432
749
  }
750
+ addTouchedFiles(state.filesTouched, opts.cwd, pending.name, input);
433
751
  const label = claudeToolFeedLabel(pending.name, input);
434
752
  if (label)
435
753
  opts.onActivity?.(label);
@@ -441,6 +759,7 @@ function makeAgentEventHandler(opts, state) {
441
759
  state.finalText =
442
760
  typeof ev.result === "string" ? ev.result : state.accumulated;
443
761
  state.resultIsError = ev.is_error === true;
762
+ state.usage = parseCliUsage(ev.usage);
444
763
  }
445
764
  };
446
765
  return (ev) => {
@@ -463,19 +782,23 @@ function makeAgentEventHandler(opts, state) {
463
782
  else if (ev.type === "tool_call") {
464
783
  state.segmentText = "";
465
784
  state.needsGap = true;
785
+ const tool = cursorToolNameAndInput(ev);
786
+ if (tool)
787
+ addTouchedFiles(state.filesTouched, opts.cwd, tool.name, tool.input);
466
788
  if (ev.subtype === "started")
467
789
  opts.onActivity?.(toolActivityLabel(ev));
468
790
  }
469
791
  else if (ev.type === "thinking") {
470
792
  state.segmentText = "";
471
793
  state.needsGap = true;
472
- opts.onActivity?.("thinking");
794
+ opts.onActivity?.("Thinking");
473
795
  }
474
796
  else if (ev.type === "result") {
475
797
  state.sawResult = true;
476
798
  state.finalText =
477
799
  typeof ev.result === "string" ? ev.result : state.accumulated;
478
800
  state.resultIsError = ev.is_error === true;
801
+ state.usage = parseCliUsage(ev.usage);
479
802
  }
480
803
  };
481
804
  }
@@ -520,6 +843,8 @@ function runAgentCli(opts) {
520
843
  ok: false,
521
844
  finalText: state.finalText || state.accumulated,
522
845
  error: "agent run timed out",
846
+ usage: state.usage,
847
+ filesTouched: touchedFileList(state.filesTouched),
523
848
  });
524
849
  }, opts.timeoutMs);
525
850
  const handleEvent = makeAgentEventHandler(opts, state);
@@ -556,6 +881,8 @@ function runAgentCli(opts) {
556
881
  settle({
557
882
  ok,
558
883
  finalText: state.finalText || state.accumulated,
884
+ usage: state.usage,
885
+ filesTouched: touchedFileList(state.filesTouched),
559
886
  crashed: !state.sawResult,
560
887
  error: ok
561
888
  ? undefined
@@ -568,7 +895,10 @@ function runAgentCli(opts) {
568
895
  function persistTaskFile(tasksDir, task) {
569
896
  fs.writeFileSync(path.join(tasksDir, task.id, "task.json"), JSON.stringify(task, null, 2) + "\n");
570
897
  }
571
- // Tasks left "running" by a dead serve are as finished as they will get.
898
+ // Tasks left "running" by a dead serve are as finished as they will get. A
899
+ // persisted "blocked" task is left as-is: it is not "waiting", so maybeStart
900
+ // never reconsiders it and it can't wedge or auto-start; it just sits on the
901
+ // board (blockedBy intact) until the router stops it, same as before restart.
572
902
  function loadTasks(tasksDir) {
573
903
  const tasks = new Map();
574
904
  for (const entry of fs.existsSync(tasksDir) ? fs.readdirSync(tasksDir) : []) {
@@ -632,15 +962,78 @@ function resolveDeps(tasks, tokens) {
632
962
  }
633
963
  return [...new Set(resolved)];
634
964
  }
965
+ export function classifyDeps(tasks, task) {
966
+ const blockedBy = [];
967
+ let waiting = false;
968
+ for (const id of task.after) {
969
+ const dep = tasks.get(id);
970
+ // A dep id that no longer resolves to a task (its row was cleared) or one
971
+ // that finished "done" is satisfied -- nothing left to wait on.
972
+ if (!dep || dep.status === "done")
973
+ continue;
974
+ if (dep.status === "failed" || dep.status === "interrupted") {
975
+ blockedBy.push(dep.title);
976
+ }
977
+ else {
978
+ waiting = true;
979
+ }
980
+ }
981
+ if (blockedBy.length > 0)
982
+ return { kind: "blocked", blockedBy };
983
+ return { kind: waiting ? "waiting" : "ready" };
984
+ }
985
+ // Cap on how much of an upstream task's wrap-up prose rides into a dependent's
986
+ // prompt. resultSummary is already capped at RESULT_SUMMARY_CHARS; this trims
987
+ // further so a multi-dep task doesn't front-load pages of handoff.
988
+ const DEP_SUMMARY_CHARS = 400;
635
989
  function depsSummaryFor(tasks, task) {
636
990
  if (task.after.length === 0)
637
991
  return undefined;
638
992
  const lines = task.after
639
993
  .map((id) => tasks.get(id))
640
994
  .filter((dep) => !!dep)
641
- .map((dep) => `- "${dep.title}" finished ${dep.status}${dep.notes.trim() ? `; notes: ${dep.notes.trim()}` : ""}`);
995
+ .map((dep) => {
996
+ const parts = [`- "${dep.title}" finished ${dep.status}`];
997
+ if (dep.files && dep.files.length > 0)
998
+ parts.push(` files it touched: ${dep.files.join(", ")}`);
999
+ // The agent's own closing prose is the real handoff -- names it created,
1000
+ // what it wired, what it left undone. The notes file is player-facing
1001
+ // and deliberately stripped of that detail.
1002
+ const summary = dep.resultSummary?.trim();
1003
+ if (summary)
1004
+ parts.push(` its wrap-up: ${summary.slice(-DEP_SUMMARY_CHARS).replace(/\n+/g, " ")}`);
1005
+ if (dep.notes.trim())
1006
+ parts.push(` player notes: ${dep.notes.trim()}`);
1007
+ return parts.join("\n");
1008
+ });
642
1009
  return lines.join("\n") || undefined;
643
1010
  }
1011
+ function sleep(ms) {
1012
+ return new Promise((resolve) => setTimeout(resolve, ms));
1013
+ }
1014
+ // Chained-promise gate spacing out task-agent PROCESS LAUNCHES -- deliberately
1015
+ // NOT a queue class, just a promise each launch chains onto. Why: the
1016
+ // 2026-07 keychain-contention incident (see TASK_SPAWN_STAGGER_MS above) was
1017
+ // triggered by several cursor-agent processes starting at the exact same
1018
+ // instant; spacing consecutive launches out by TASK_SPAWN_STAGGER_MS avoids
1019
+ // that without limiting how many can run concurrently once they're up.
1020
+ let taskSpawnGate = Promise.resolve();
1021
+ function staggerTaskSpawn() {
1022
+ const readyToLaunch = taskSpawnGate;
1023
+ taskSpawnGate = readyToLaunch.then(() => sleep(TASK_SPAWN_STAGGER_MS));
1024
+ return readyToLaunch;
1025
+ }
1026
+ // Jittered, attempt-scaled backoff before relaunching a crashed task agent
1027
+ // (see TASK_RETRY_BACKOFF_BASE_MS's comment for why). Checks stopRequested
1028
+ // both before AND after the sleep -- a stop landing mid-backoff must not
1029
+ // relaunch the task. Returns true if the caller should give up retrying.
1030
+ async function waitBeforeTaskRetry(attempt, stopRequested, taskId) {
1031
+ if (stopRequested.has(taskId))
1032
+ return true;
1033
+ const backoffMs = TASK_RETRY_BACKOFF_BASE_MS * attempt + Math.random() * 1000;
1034
+ await sleep(backoffMs);
1035
+ return stopRequested.has(taskId);
1036
+ }
644
1037
  async function runTaskAgentIn(ctx, task) {
645
1038
  const dir = path.join(ctx.tasksDir, task.id);
646
1039
  const relDir = path.relative(ctx.deckDir, dir);
@@ -653,6 +1046,9 @@ async function runTaskAgentIn(ctx, task) {
653
1046
  notesPath: path.join(relDir, "notes.md"),
654
1047
  depsSummary: ctx.depsSummary,
655
1048
  backend: ctx.backend,
1049
+ deckTree: buildDeckTree(ctx.deckDir),
1050
+ quickReference: ctx.quickReference,
1051
+ siblings: ctx.siblings,
656
1052
  });
657
1053
  // No /goal wrapper: it makes a fresh evaluator re-check the WHOLE task
658
1054
  // prompt (including user-only "done when you reach wave 5"-style play
@@ -698,6 +1094,7 @@ async function runTaskAgentIn(ctx, task) {
698
1094
  }
699
1095
  };
700
1096
  for (let attempt = 1; attempt <= MAX_TASK_ATTEMPTS; attempt++) {
1097
+ await staggerTaskSpawn();
701
1098
  result = await runAgentCli({
702
1099
  cwd: ctx.deckDir,
703
1100
  command: invocation.command,
@@ -720,8 +1117,11 @@ async function runTaskAgentIn(ctx, task) {
720
1117
  return result;
721
1118
  if (!result.crashed)
722
1119
  return result;
723
- if (attempt < MAX_TASK_ATTEMPTS)
1120
+ if (attempt < MAX_TASK_ATTEMPTS) {
724
1121
  ctx.onRetry(attempt + 1);
1122
+ if (await waitBeforeTaskRetry(attempt, ctx.stopRequested, task.id))
1123
+ return result;
1124
+ }
725
1125
  }
726
1126
  result.error = `agent process kept dying (${MAX_TASK_ATTEMPTS} attempts): ${result.error ?? ""}`;
727
1127
  return result;
@@ -747,7 +1147,15 @@ function startTask(ctx, task) {
747
1147
  backend: ctx.backend(),
748
1148
  claudeModel: ctx.claudeModel(),
749
1149
  stopRequested: ctx.stopRequested,
1150
+ quickReference: ctx.quickReference,
750
1151
  depsSummary: depsSummaryFor(ctx.tasks, task),
1152
+ // Same visibility rule as the router's board (hide acked+finished rows),
1153
+ // minus this task itself. Snapshot at start -- consistent with the deck
1154
+ // tree, and the prompt says so.
1155
+ siblings: ctx
1156
+ .sorted()
1157
+ .filter((t) => t.id !== task.id && !(t.acknowledged && isTerminal(t.status)))
1158
+ .map((t) => ({ title: t.title, status: t.status, files: t.files })),
751
1159
  onFeed: (entry) => ctx.onFeed(task, entry),
752
1160
  onRetry: (attempt) => ctx.onRetry(task, attempt),
753
1161
  onSignal: (signal) => {
@@ -787,6 +1195,7 @@ function startTask(ctx, task) {
787
1195
  task.acknowledged = true;
788
1196
  if (result.ok && !wasStopped)
789
1197
  task.progress = 100;
1198
+ task.files = result.filesTouched ?? [];
790
1199
  task.finishedAt = nowIso();
791
1200
  task.resultSummary = wasStopped
792
1201
  ? "stopped by the router"
@@ -837,12 +1246,14 @@ function startTask(ctx, task) {
837
1246
  ctx.rescheduleAll();
838
1247
  });
839
1248
  }
840
- // Halt + remove an active task (castle-stop): a waiting one is cancelled and
841
- // cleared off the board immediately; a running one gets its agent process
842
- // killed and is cleared when it finalizes (the stopRequested path acks it).
843
- // No-op on terminal tasks.
1249
+ // Halt + remove an active task (castle-stop): a waiting or blocked one is
1250
+ // cancelled and cleared off the board immediately; a running one gets its
1251
+ // agent process killed and is cleared when it finalizes (the stopRequested
1252
+ // path acks it). No-op on terminal tasks. A "blocked" task never got a
1253
+ // process, so it collapses the same way "waiting" does -- this is the only
1254
+ // way a blocked row ever clears (see ROUTER_RULES).
844
1255
  function haltTask(task, children, stopRequested, touch) {
845
- if (task.status === "waiting") {
1256
+ if (task.status === "waiting" || task.status === "blocked") {
846
1257
  task.status = "interrupted";
847
1258
  task.acknowledged = true;
848
1259
  touch(task);
@@ -875,12 +1286,6 @@ function createTaskStore(opts) {
875
1286
  persistTaskFile(tasksDir, task);
876
1287
  opts.onUpdate(task);
877
1288
  }
878
- function depsAreSettled(task) {
879
- return task.after.every((id) => {
880
- const dep = tasks.get(id);
881
- return !dep || isTerminal(dep.status);
882
- });
883
- }
884
1289
  function runningCount() {
885
1290
  let n = 0;
886
1291
  for (const t of tasks.values())
@@ -889,8 +1294,20 @@ function createTaskStore(opts) {
889
1294
  return n;
890
1295
  }
891
1296
  function maybeStart(task) {
892
- if (task.status !== "waiting" || task.acknowledged || !depsAreSettled(task))
1297
+ if (task.status !== "waiting" || task.acknowledged)
893
1298
  return;
1299
+ const deps = classifyDeps(tasks, task);
1300
+ if (deps.kind === "waiting")
1301
+ return;
1302
+ // A dep finalized failed/interrupted -- this task can never do its job
1303
+ // (the output it needed never materialized), so it flips to "blocked"
1304
+ // instead of ever starting. Only the router clears it (castle-stop).
1305
+ if (deps.kind === "blocked") {
1306
+ task.status = "blocked";
1307
+ task.blockedBy = deps.blockedBy;
1308
+ touch(task);
1309
+ return;
1310
+ }
894
1311
  // Concurrency cap: at most MAX_CONCURRENT_TASKS agents run at once. Over-cap
895
1312
  // tasks stay 'waiting' and are restarted -- earliest-created first -- by the
896
1313
  // onFinished sweep below when a running task frees a slot.
@@ -918,6 +1335,7 @@ function createTaskStore(opts) {
918
1335
  children,
919
1336
  tasks,
920
1337
  stopRequested,
1338
+ quickReference: opts.quickReference,
921
1339
  backend: opts.backend,
922
1340
  claudeModel: opts.claudeModel,
923
1341
  onStarted: opts.onStarted,
@@ -993,13 +1411,16 @@ function createTaskStore(opts) {
993
1411
  acknowledge(id, false);
994
1412
  }
995
1413
  // The router stops tasks by title or id (castle-stop fence), or "all" to
996
- // stop everything still active. Waiting tasks are cancelled outright;
997
- // running ones get their agent process killed and finalize as interrupted
998
- // via the stopRequested path (see haltTask).
1414
+ // stop everything still active. Waiting (and blocked -- a waiting task that
1415
+ // will never start) tasks are cancelled outright; running ones get their
1416
+ // agent process killed and finalize as interrupted via the stopRequested
1417
+ // path (see haltTask).
999
1418
  function stop(tokens) {
1000
1419
  const ids = meansAll(tokens)
1001
1420
  ? [...tasks.values()]
1002
- .filter((t) => t.status === "running" || t.status === "waiting")
1421
+ .filter((t) => t.status === "running" ||
1422
+ t.status === "waiting" ||
1423
+ t.status === "blocked")
1003
1424
  .map((t) => t.id)
1004
1425
  : resolveDeps(tasks, tokens);
1005
1426
  for (const id of ids) {
@@ -1053,6 +1474,57 @@ function saveAttachments(attachmentsDir, messageId, images) {
1053
1474
  }
1054
1475
  return saved;
1055
1476
  }
1477
+ // Cap on the failure detail shown in a board row -- just enough for the
1478
+ // router to reason about what went wrong, not the full crash dump.
1479
+ const ERROR_PREVIEW_CHARS = 200;
1480
+ // A failed task's resultSummary starts with the error text (see startTask's
1481
+ // finalization block) followed by trailing output; the first non-empty line
1482
+ // is USUALLY the router-relevant part, with two exceptions handled below.
1483
+ //
1484
+ // Exception 1 (pre-existing): a clean-exit self-reported failure (stream-json
1485
+ // `result` event with is_error: true, exit code 0) leaves runAgentCli's
1486
+ // generic "agent exited 0" here with no stderr tail to make it informative --
1487
+ // the agent's own stated reason is the next line (the finalText tail).
1488
+ //
1489
+ // Exception 2: runAgentCli's exit-code wrapper ("agent exited N[: stderr
1490
+ // tail]", possibly re-wrapped as "agent process kept dying (N attempts):
1491
+ // ...") embeds the crashed process's raw, possibly multi-line stderr
1492
+ // verbatim. If that stderr itself contains newlines, the actually
1493
+ // informative line can land one or more lines below this wrapper -- e.g. the
1494
+ // 2026-07 keychain incident, where line 1 was a useless
1495
+ // "agent process kept dying (3 attempts): agent exited 1: cursor-retrieval:
1496
+ // tracing to '/var/folders/.../cursor_retrieval....log'" while the real
1497
+ // "Error: Security command failed: Security process exited with code: 44"
1498
+ // sat right below it. Only these two wrapper shapes get this treatment --
1499
+ // "agent run timed out" and "could not run cursor-agent: <message>" (the
1500
+ // other two runAgentCli error strings) are already the whole story on line 1
1501
+ // and are left alone, same as before.
1502
+ const EXIT_WRAPPER_RE = /^agent (exited \d+|process kept dying)/;
1503
+ function looksLikeErrorLine(line) {
1504
+ return /\berror\b/i.test(line);
1505
+ }
1506
+ function firstErrorLine(resultSummary) {
1507
+ const lines = resultSummary
1508
+ ?.split("\n")
1509
+ .map((l) => l.trim())
1510
+ .filter((l) => l.length > 0) ?? [];
1511
+ if (lines.length === 0)
1512
+ return undefined;
1513
+ const first = lines[0];
1514
+ if (first === "agent exited 0") {
1515
+ return (lines[1] ?? first).slice(0, ERROR_PREVIEW_CHARS);
1516
+ }
1517
+ if (!EXIT_WRAPPER_RE.test(first) || looksLikeErrorLine(first)) {
1518
+ return first.slice(0, ERROR_PREVIEW_CHARS);
1519
+ }
1520
+ const betterLine = lines.slice(1).find(looksLikeErrorLine);
1521
+ if (!betterLine)
1522
+ return first.slice(0, ERROR_PREVIEW_CHARS);
1523
+ // Keep the wrapper as context (e.g. "3 attempts") when it fits; otherwise
1524
+ // the real error line must stay fully visible within the cap on its own.
1525
+ const combined = `${first}: ${betterLine}`;
1526
+ return (combined.length <= ERROR_PREVIEW_CHARS ? combined : betterLine).slice(0, ERROR_PREVIEW_CHARS);
1527
+ }
1056
1528
  function asPromptTask(task) {
1057
1529
  return {
1058
1530
  id: task.id,
@@ -1060,6 +1532,26 @@ function asPromptTask(task) {
1060
1532
  status: task.rejected ? "rejected by user" : task.status,
1061
1533
  progress: task.progress,
1062
1534
  notes: task.notes,
1535
+ files: task.files,
1536
+ error: task.status === "failed" ? firstErrorLine(task.resultSummary) : undefined,
1537
+ blockedBy: task.status === "blocked" ? task.blockedBy : undefined,
1538
+ };
1539
+ }
1540
+ function asClientTask(task) {
1541
+ return {
1542
+ id: task.id,
1543
+ title: task.title,
1544
+ status: task.status,
1545
+ progress: task.progress,
1546
+ notes: task.notes,
1547
+ createdAt: task.createdAt,
1548
+ startedAt: task.startedAt,
1549
+ finishedAt: task.finishedAt,
1550
+ resultSummary: task.resultSummary,
1551
+ avatar: task.avatar,
1552
+ phase: task.phase,
1553
+ acknowledged: task.acknowledged,
1554
+ rejected: task.rejected,
1063
1555
  };
1064
1556
  }
1065
1557
  function createTaskFeeds(broadcast) {
@@ -1131,26 +1623,47 @@ function makeAttachmentHandler(attachmentsDir) {
1131
1623
  return true;
1132
1624
  };
1133
1625
  }
1134
- // One router turn: stream a reply message, then spawn the directives it
1135
- // emitted (unless a newer user message superseded this turn).
1136
- function runRouterTurnIn(ctx, instruction) {
1137
- const epoch = ctx.currentEpoch();
1138
- const message = {
1139
- id: nanoid(8),
1140
- role: "assistant",
1141
- text: "",
1142
- at: nowIso(),
1143
- status: "streaming",
1144
- };
1145
- ctx.log.messages.push(message);
1146
- ctx.broadcast({ type: "message-add", message });
1147
- let raw = "";
1148
- let visibleSent = 0;
1149
- let lastActivity = null;
1150
- const prompt = buildRouterPrompt({
1626
+ // Classify from the error strings runAgentCli actually produces (see its
1627
+ // child.on("error"), timeout, and close handlers).
1628
+ function classifyRouterFailure(error) {
1629
+ if (error?.startsWith("could not run"))
1630
+ return "spawn";
1631
+ if (error === "agent run timed out")
1632
+ return "timeout";
1633
+ return "exit";
1634
+ }
1635
+ // Plain-language copy for a failed turn. `salvaged` = the turn already
1636
+ // produced something the user can see (streamed text and/or spawned tasks),
1637
+ // so "pick it back up" framing fits; a turn that died with nothing is a clean
1638
+ // hiccup. `willRetry` = the queue is about to re-run this instruction itself.
1639
+ function routerFailureCopy(opts) {
1640
+ if (opts.willRetry) {
1641
+ return "Something went wrong on my end -- give me a moment to try that again.";
1642
+ }
1643
+ const tasksNote = opts.spawnedTasks
1644
+ ? " The steps I already kicked off are still running."
1645
+ : "";
1646
+ switch (opts.kind) {
1647
+ case "spawn":
1648
+ return `I couldn't start working on that -- something in this setup isn't right. If this keeps happening, the person running this session needs to take a look.${tasksNote}`;
1649
+ case "timeout":
1650
+ return `That took me too long and I had to stop partway. Send another message and I'll pick it back up.${tasksNote}`;
1651
+ case "exit":
1652
+ return `Something went wrong on my end partway through. Send another message and I'll pick it back up.${tasksNote}`;
1653
+ }
1654
+ }
1655
+ // Assemble the full stateless prompt for one router turn: rules + deck
1656
+ // context + transcript replay (minus log lines and the in-flight reply) +
1657
+ // the live board + this turn's instruction.
1658
+ function routerTurnPrompt(ctx, instruction, selfMessageId) {
1659
+ return buildRouterPrompt({
1151
1660
  deckLabel: ctx.deckLabel,
1661
+ quickReference: ctx.quickReference,
1662
+ deckTree: buildDeckTree(ctx.deckDir),
1152
1663
  messages: ctx.log.messages
1153
- .filter((m) => m.role !== "log" && m.id !== message.id && m.status !== "streaming")
1664
+ .filter((m) => m.role !== "log" &&
1665
+ m.id !== selfMessageId &&
1666
+ m.status !== "streaming")
1154
1667
  .map((m) => ({
1155
1668
  role: m.role,
1156
1669
  // Replace a prior turn's raw ```ask JSON with a readable question list
@@ -1167,6 +1680,108 @@ function runRouterTurnIn(ctx, instruction) {
1167
1680
  .map(asPromptTask),
1168
1681
  instruction,
1169
1682
  });
1683
+ }
1684
+ // A turn killed before producing anything visible (a fold, or a manual Stop
1685
+ // pressed during the pre-text lull) leaves an empty husk that would otherwise
1686
+ // replay into every future transcript as a blank assistant line -- drop it
1687
+ // from the log instead. Decided here, with final knowledge, rather than at
1688
+ // kill time: a text delta can race the SIGKILL and still land in
1689
+ // message.text, in which case this falls through to the normal
1690
+ // interrupted-draft path so the continuation turn can carry it. A message
1691
+ // with spawned taskIds is NEVER dropped even with empty text -- those tasks
1692
+ // are already running and the UI's task chips need this message as their
1693
+ // anchor, so it always takes the interrupted-draft (kept) path instead.
1694
+ function settleInterruptedTurn(ctx, message) {
1695
+ const hasSpawnedTasks = (message.taskIds?.length ?? 0) > 0;
1696
+ if (message.text.trim() === "" && !hasSpawnedTasks) {
1697
+ const idx = ctx.log.messages.indexOf(message);
1698
+ if (idx >= 0)
1699
+ ctx.log.messages.splice(idx, 1);
1700
+ ctx.log.persist();
1701
+ ctx.broadcast({
1702
+ type: "message-done",
1703
+ id: message.id,
1704
+ text: "",
1705
+ status: "done",
1706
+ });
1707
+ return;
1708
+ }
1709
+ // Keep whatever streamed and/or spawned; the continuation turn carries the
1710
+ // text draft (tasks already launched by this turn just stay on the board).
1711
+ message.status = "done";
1712
+ message.interrupted = true;
1713
+ ctx.log.persist();
1714
+ ctx.broadcast({
1715
+ type: "message-done",
1716
+ id: message.id,
1717
+ text: message.text,
1718
+ status: message.status,
1719
+ interrupted: true,
1720
+ taskIds: message.taskIds ?? [],
1721
+ });
1722
+ }
1723
+ // Spawn ```castle-task fences AS EACH ONE completes during streaming, instead
1724
+ // of waiting for the whole reply to settle -- shaves the time-to-first-task
1725
+ // off long replies. Mirrors the settle path's spawn logic exactly: same
1726
+ // directive parsing (parseTaskFenceBody), same in-flight title dedup, and the
1727
+ // same `after:` resolution against the live task board -- which by now
1728
+ // already includes any tasks spawned earlier in this same reply, so an
1729
+ // `after:` referencing an earlier fence's title resolves correctly.
1730
+ function spawnCompletedTaskFences(ctx, message, midStream, raw) {
1731
+ const { directives, nextIndex } = scanNewTaskFences(raw, midStream.scannedUpTo);
1732
+ midStream.scannedUpTo = nextIndex;
1733
+ if (directives.length === 0)
1734
+ return;
1735
+ const inFlight = new Set(ctx.taskStore
1736
+ .sorted()
1737
+ .filter((t) => t.status === "running" || t.status === "waiting")
1738
+ .map((t) => t.title.toLowerCase()));
1739
+ const newIds = [];
1740
+ for (const directive of directives) {
1741
+ const key = directive.title.toLowerCase();
1742
+ if (inFlight.has(key) || midStream.spawnedTitles.has(key))
1743
+ continue;
1744
+ newIds.push(ctx.taskStore.spawnFromDirective(directive, message.id));
1745
+ midStream.spawnedTitles.add(key);
1746
+ }
1747
+ if (newIds.length === 0)
1748
+ return;
1749
+ message.taskIds = [...(message.taskIds ?? []), ...newIds];
1750
+ // No text change (the fence stays hidden by the holdback until the reply
1751
+ // ends) -- this delta only carries the updated taskIds so connected clients
1752
+ // can react (e.g. show task chips) before the turn finishes.
1753
+ ctx.broadcast({
1754
+ type: "message-delta",
1755
+ id: message.id,
1756
+ delta: "",
1757
+ taskIds: message.taskIds,
1758
+ });
1759
+ }
1760
+ // One router turn: stream a reply message, then spawn the directives it
1761
+ // emitted (unless a newer user message superseded this turn).
1762
+ function runRouterTurnIn(ctx, instruction) {
1763
+ const epoch = ctx.currentEpoch();
1764
+ const message = {
1765
+ id: nanoid(8),
1766
+ role: "assistant",
1767
+ text: "",
1768
+ at: nowIso(),
1769
+ status: "streaming",
1770
+ };
1771
+ ctx.log.messages.push(message);
1772
+ ctx.broadcast({ type: "message-add", message });
1773
+ let raw = "";
1774
+ let visibleSent = 0;
1775
+ const midStream = { scannedUpTo: 0, spawnedTitles: new Set() };
1776
+ // Seed the activity line to "thinking" immediately -- covers the otherwise
1777
+ // silent spawn + first-token lull (fresh CLI process, prompt processing,
1778
+ // extended thinking) before any stream event arrives. The stream then
1779
+ // overrides it: a tool relabels it, the first text delta clears it. Seeding
1780
+ // lastActivity too dedups the redundant broadcast when the claude thinking
1781
+ // block later re-emits "thinking".
1782
+ let lastActivity = "Thinking";
1783
+ ctx.broadcast({ type: "message-activity", id: message.id, activity: "Thinking" });
1784
+ const prompt = routerTurnPrompt(ctx, instruction, message.id);
1170
1785
  const backend = ctx.backend();
1171
1786
  const invocation = buildAgentInvocation(backend, "router", prompt, ctx.claudeModel());
1172
1787
  void runAgentCli({
@@ -1177,6 +1792,7 @@ function runRouterTurnIn(ctx, instruction) {
1177
1792
  timeoutMs: ROUTER_TIMEOUT_MS,
1178
1793
  logPath: path.join(ctx.agentDir, "router-log.jsonl"),
1179
1794
  children: ctx.children,
1795
+ labelUnknownTools: true,
1180
1796
  onDelta: (delta) => {
1181
1797
  raw += delta;
1182
1798
  const visible = visibleLength(raw);
@@ -1186,6 +1802,9 @@ function runRouterTurnIn(ctx, instruction) {
1186
1802
  message.text += slice;
1187
1803
  ctx.broadcast({ type: "message-delta", id: message.id, delta: slice });
1188
1804
  }
1805
+ // Spawning changes WHEN a fence is acted on, not what is displayed --
1806
+ // the holdback above still hides fenced text until the reply ends.
1807
+ spawnCompletedTaskFences(ctx, message, midStream, raw);
1189
1808
  },
1190
1809
  onActivity: (activity) => {
1191
1810
  if (activity === lastActivity)
@@ -1195,6 +1814,10 @@ function runRouterTurnIn(ctx, instruction) {
1195
1814
  },
1196
1815
  })
1197
1816
  .then((result) => {
1817
+ logRouterUsage(backend, result.usage);
1818
+ // Signals the finally -> onSettled(retryable): the turn failed cleanly
1819
+ // enough (transient, nothing salvaged) that the queue may re-run it.
1820
+ let retryable = false;
1198
1821
  // The settle path must ALWAYS reach ctx.onSettled() (clears
1199
1822
  // routerRunning + flushes pendingSends). A throw here on Node v25 would
1200
1823
  // otherwise both freeze the composer and crash the serve, so the whole
@@ -1203,18 +1826,7 @@ function runRouterTurnIn(ctx, instruction) {
1203
1826
  try {
1204
1827
  const interrupted = epoch !== ctx.currentEpoch() && !result.ok;
1205
1828
  if (interrupted) {
1206
- // Keep whatever streamed; the continuation turn carries the draft.
1207
- message.status = "done";
1208
- message.interrupted = true;
1209
- ctx.log.persist();
1210
- ctx.broadcast({
1211
- type: "message-done",
1212
- id: message.id,
1213
- text: message.text,
1214
- status: message.status,
1215
- interrupted: true,
1216
- taskIds: [],
1217
- });
1829
+ settleInterruptedTurn(ctx, message);
1218
1830
  return;
1219
1831
  }
1220
1832
  const { cleaned, directives, checkoffs, stops } = extractDirectives(result.finalText);
@@ -1222,8 +1834,11 @@ function runRouterTurnIn(ctx, instruction) {
1222
1834
  ctx.taskStore.checkOff(checkoffs);
1223
1835
  if (result.ok && stops.length > 0)
1224
1836
  ctx.taskStore.stop(stops);
1225
- // Drop directives from stale turns, and any whose title matches a task
1226
- // already in flight (two runs reacting to the same ask).
1837
+ // Drop directives from stale turns, any whose title matches a task
1838
+ // already in flight (two runs reacting to the same ask), and any
1839
+ // already spawned mid-stream by spawnCompletedTaskFences above --
1840
+ // otherwise the settle-time full-text parse would launch a second
1841
+ // copy of the same fence.
1227
1842
  const stale = epoch !== ctx.currentEpoch();
1228
1843
  const inFlight = new Set(ctx.taskStore
1229
1844
  .sorted()
@@ -1231,12 +1846,34 @@ function runRouterTurnIn(ctx, instruction) {
1231
1846
  .map((t) => t.title.toLowerCase()));
1232
1847
  const toSpawn = stale
1233
1848
  ? []
1234
- : directives.filter((d) => !inFlight.has(d.title.toLowerCase()));
1235
- const taskIds = toSpawn.map((d) => ctx.taskStore.spawnFromDirective(d, message.id));
1236
- message.text = result.ok
1237
- ? cleaned
1238
- : `${cleaned ? cleaned + "\n\n" : ""}[router error: ${result.error ?? "unknown"}]`;
1239
- message.status = result.ok ? "done" : "error";
1849
+ : directives.filter((d) => !inFlight.has(d.title.toLowerCase()) &&
1850
+ !midStream.spawnedTitles.has(d.title.toLowerCase()));
1851
+ const newlySpawnedIds = toSpawn.map((d) => ctx.taskStore.spawnFromDirective(d, message.id));
1852
+ const taskIds = [...(message.taskIds ?? []), ...newlySpawnedIds];
1853
+ if (result.ok) {
1854
+ message.text = cleaned;
1855
+ message.status = "done";
1856
+ }
1857
+ else {
1858
+ const kind = classifyRouterFailure(result.error);
1859
+ // Only a turn that produced NOTHING visible is safe to silently
1860
+ // re-run: with streamed text or spawned tasks in play, a retry
1861
+ // would answer the same instruction twice (and could re-spawn
1862
+ // near-duplicate tasks past the title dedup). Spawn failures are
1863
+ // persistent (the CLI itself won't launch) and timeouts are too
1864
+ // expensive to repeat blind, so only "exit" crashes retry.
1865
+ const salvaged = cleaned !== "" || taskIds.length > 0;
1866
+ retryable = kind === "exit" && !salvaged;
1867
+ const copy = routerFailureCopy({
1868
+ kind,
1869
+ spawnedTasks: taskIds.length > 0,
1870
+ willRetry: retryable && ctx.canAutoRetry(),
1871
+ });
1872
+ message.text = cleaned ? `${cleaned}\n\n${copy}` : copy;
1873
+ message.status = "error";
1874
+ message.errorDetail = result.error ?? "unknown failure";
1875
+ console.error(`[router] turn failed (${kind}): ${message.errorDetail}`);
1876
+ }
1240
1877
  if (taskIds.length > 0)
1241
1878
  message.taskIds = taskIds;
1242
1879
  ctx.log.persist();
@@ -1246,6 +1883,7 @@ function runRouterTurnIn(ctx, instruction) {
1246
1883
  text: message.text,
1247
1884
  status: message.status,
1248
1885
  taskIds: message.taskIds ?? [],
1886
+ errorDetail: message.errorDetail,
1249
1887
  });
1250
1888
  }
1251
1889
  catch (err) {
@@ -1257,7 +1895,9 @@ function runRouterTurnIn(ctx, instruction) {
1257
1895
  console.error(`[router] turn callback threw: ${detail}`);
1258
1896
  try {
1259
1897
  message.status = "error";
1260
- message.text = `${message.text ? message.text + "\n\n" : ""}[router error: ${short}]`;
1898
+ const copy = "Something went wrong on my end partway through. Send another message and I'll pick it back up.";
1899
+ message.text = `${message.text ? message.text + "\n\n" : ""}${copy}`;
1900
+ message.errorDetail = short;
1261
1901
  ctx.log.persist();
1262
1902
  ctx.broadcast({
1263
1903
  type: "message-done",
@@ -1265,6 +1905,7 @@ function runRouterTurnIn(ctx, instruction) {
1265
1905
  text: message.text,
1266
1906
  status: message.status,
1267
1907
  taskIds: message.taskIds ?? [],
1908
+ errorDetail: message.errorDetail,
1268
1909
  });
1269
1910
  }
1270
1911
  catch (inner) {
@@ -1272,7 +1913,7 @@ function runRouterTurnIn(ctx, instruction) {
1272
1913
  }
1273
1914
  }
1274
1915
  finally {
1275
- ctx.onSettled();
1916
+ ctx.onSettled(retryable);
1276
1917
  }
1277
1918
  })
1278
1919
  .catch((err) => {
@@ -1365,83 +2006,113 @@ function startChildRegistry(registryPath, groups) {
1365
2006
  }
1366
2007
  };
1367
2008
  }
1368
- // Mid-run send queue (mirrors djinn's AltManager.pendingSends): a user message
1369
- // sent while the router is mid-turn QUEUES instead of interrupting. It flushes
1370
- // into a single follow-up turn when the current turn settles. An explicit
1371
- // interrupt ("send now" / Stop) kills the run and flushes early. The epoch
1372
- // keeps a killed-but-racing run from spawning tasks. Lives in its own factory
1373
- // so createAgentServer stays within the max-lines budget; the queue-by-default
1374
- // semantics are unchanged.
1375
- function createRouterQueue(deps) {
1376
- const { deckDir, deckLabel, agentDir, attachmentsDir, routerChildren, log, broadcast, taskStore, messages, settings, } = deps;
1377
- let userEpoch = 0;
1378
- 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.
1383
- const pendingSends = [];
1384
- const pendingPath = path.join(agentDir, "pending-sends.json");
1385
- 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.
1388
- function persistPending() {
1389
- fs.writeFileSync(pendingPath, JSON.stringify(pendingSends, null, 2) + "\n");
1390
- }
1391
- function queuedSnippets() {
1392
- return pendingSends.map((p) => p.text.trim()).filter(Boolean);
1393
- }
1394
- function broadcastRouterState() {
1395
- broadcast({
1396
- type: "router-state",
1397
- running: routerRunning,
1398
- queued: queuedSnippets(),
1399
- });
2009
+ // Restart recovery: load the durable queue mirror, keeping only well-formed
2010
+ // sends that never reached the message log. A send whose id is already in
2011
+ // messages.json was committed by a prior drain (its turn ran, finished or
2012
+ // not -- a logged message is considered handled), so re-queueing it would
2013
+ // double-deliver. Order is preserved.
2014
+ function loadRecoverableSends(pendingPath, committedIds) {
2015
+ const stored = readJsonFile(pendingPath);
2016
+ if (!Array.isArray(stored))
2017
+ return [];
2018
+ const recovered = [];
2019
+ for (const item of stored) {
2020
+ if (item &&
2021
+ typeof item.id === "string" &&
2022
+ typeof item.text === "string" &&
2023
+ Array.isArray(item.attachments) &&
2024
+ !committedIds.has(item.id)) {
2025
+ recovered.push({
2026
+ id: item.id,
2027
+ text: item.text,
2028
+ attachments: item.attachments.filter((a) => typeof a === "string"),
2029
+ });
2030
+ }
1400
2031
  }
1401
- function interruptRouterRuns() {
1402
- const drafts = messages
1403
- .filter((m) => m.role === "assistant" && m.status === "streaming")
1404
- .map((m) => m.text.trim())
1405
- .filter(Boolean);
1406
- for (const child of routerChildren) {
1407
- try {
1408
- child.kill("SIGKILL");
1409
- }
1410
- catch {
1411
- /* already gone */
1412
- }
2032
+ return recovered;
2033
+ }
2034
+ // A turn's user messages are only "spent" once the turn has produced visible
2035
+ // output -- text or side effects committed. Until then, killing the turn
2036
+ // (fold or manual interrupt) must not lose them: they have to survive and
2037
+ // re-enter the next instruction. A turn that has spawned tasks counts as
2038
+ // having visible output too, even with no text yet -- a launched task agent
2039
+ // cannot be un-launched, so folding it away would abandon in-flight work, not
2040
+ // just discard stale intent.
2041
+ function turnHasVisibleOutput(messages) {
2042
+ return messages.some((m) => m.role === "assistant" &&
2043
+ m.status === "streaming" &&
2044
+ (m.text.trim() !== "" || (m.taskIds?.length ?? 0) > 0));
2045
+ }
2046
+ // Re-queue a turn's already-logged user messages (its `inFlightSends`) onto
2047
+ // the front of the pending queue, marked `logged` so the next drain composes
2048
+ // them into the instruction again without adding a second user bubble. Shared
2049
+ // by the auto-fold path and the manual interrupt path -- both lose the
2050
+ // original messages from instruction composition the same way if a turn is
2051
+ // killed before producing anything visible.
2052
+ function reclaimInFlightSends(pendingSends, inFlightSends) {
2053
+ if (inFlightSends.length === 0)
2054
+ return;
2055
+ pendingSends.unshift(...inFlightSends.map((s) => ({ ...s, logged: true })));
2056
+ }
2057
+ // Mirror the in-memory queue to disk. Called on every mutation (enqueue,
2058
+ // drain, cancel, recover) so a restart never loses an unsent queued message.
2059
+ function persistPendingSends(ctx) {
2060
+ fs.writeFileSync(ctx.pendingPath, JSON.stringify(ctx.state.pendingSends, null, 2) + "\n");
2061
+ }
2062
+ function computeQueuedSnippets(pendingSends) {
2063
+ return pendingSends.map((p) => p.text.trim()).filter(Boolean);
2064
+ }
2065
+ function broadcastQueueState(ctx) {
2066
+ ctx.broadcast({
2067
+ type: "router-state",
2068
+ running: ctx.state.routerRunning,
2069
+ queued: computeQueuedSnippets(ctx.state.pendingSends),
2070
+ });
2071
+ }
2072
+ // Kill all in-flight router child processes, returning any partial draft text
2073
+ // they had streamed (joined) so a manual interrupt can carry it forward.
2074
+ function killRouterChildren(ctx) {
2075
+ const drafts = ctx.messages
2076
+ .filter((m) => m.role === "assistant" && m.status === "streaming")
2077
+ .map((m) => m.text.trim())
2078
+ .filter(Boolean);
2079
+ for (const child of ctx.routerChildren) {
2080
+ try {
2081
+ child.kill("SIGKILL");
1413
2082
  }
1414
- return drafts.join("\n\n");
1415
- }
1416
- function runRouterTurn(instruction) {
1417
- runRouterTurnIn({
1418
- deckDir,
1419
- deckLabel,
1420
- agentDir,
1421
- children: routerChildren,
1422
- log,
1423
- broadcast,
1424
- taskStore,
1425
- currentEpoch: () => userEpoch,
1426
- backend: () => settings.router,
1427
- claudeModel: () => settings.claudeModel,
1428
- onSettled: onRouterSettled,
1429
- }, instruction);
1430
- }
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.
1435
- function maybeStartRouterTurn() {
1436
- if (routerRunning || pendingSends.length === 0)
1437
- return;
1438
- const drained = pendingSends.splice(0, pendingSends.length);
1439
- const texts = [];
1440
- const attachmentPaths = [];
1441
- 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).
2083
+ catch {
2084
+ /* already gone */
2085
+ }
2086
+ }
2087
+ return drafts.join("\n\n");
2088
+ }
2089
+ function startRouterTurn(ctx, instruction) {
2090
+ ctx.state.lastInstruction = instruction;
2091
+ runRouterTurnIn({
2092
+ deckDir: ctx.deckDir,
2093
+ deckLabel: ctx.deckLabel,
2094
+ quickReference: ctx.quickReference,
2095
+ agentDir: ctx.agentDir,
2096
+ children: ctx.routerChildren,
2097
+ log: ctx.log,
2098
+ broadcast: ctx.broadcast,
2099
+ taskStore: ctx.taskStore,
2100
+ currentEpoch: () => ctx.state.userEpoch,
2101
+ backend: () => ctx.settings.router,
2102
+ claudeModel: () => ctx.settings.claudeModel,
2103
+ canAutoRetry: () => !ctx.state.autoRetryUsed && ctx.state.pendingSends.length === 0,
2104
+ onSettled: (retryable) => onRouterQueueSettled(ctx, retryable),
2105
+ }, instruction);
2106
+ }
2107
+ // Commit each drained send to the message log (skipping ones already logged
2108
+ // -- see PendingSend.logged -- so a re-carried send doesn't double its user
2109
+ // bubble) and collect the instruction pieces: message texts and attachment
2110
+ // paths, in drain order.
2111
+ function commitDrainedSends(drained, log) {
2112
+ const texts = [];
2113
+ const attachmentPaths = [];
2114
+ for (const item of drained) {
2115
+ if (!item.logged) {
1445
2116
  const message = {
1446
2117
  id: item.id,
1447
2118
  role: "user",
@@ -1452,109 +2123,183 @@ function createRouterQueue(deps) {
1452
2123
  if (item.attachments.length > 0)
1453
2124
  message.attachments = item.attachments;
1454
2125
  log.add(message);
1455
- if (item.text.trim())
1456
- texts.push(item.text);
1457
- for (const name of item.attachments) {
1458
- attachmentPaths.push(path.join(".castle", "agent", "attachments", name));
1459
- }
1460
2126
  }
1461
- // The queue is now committed to messages.json; clear its durable mirror.
1462
- persistPending();
1463
- const draft = pendingInterruptedDraft;
1464
- pendingInterruptedDraft = "";
1465
- routerRunning = true;
1466
- broadcastRouterState();
1467
- runRouterTurn(userTurnInstruction({
1468
- messages: texts,
1469
- interruptedDraft: draft || undefined,
1470
- attachments: attachmentPaths,
1471
- }));
1472
- }
1473
- // The turn settled: clear the busy flag, broadcast it, then flush anything
1474
- // that queued mid-turn (a clean end and an interrupt take the same path).
1475
- function onRouterSettled() {
1476
- routerRunning = false;
1477
- broadcastRouterState();
1478
- maybeStartRouterTurn();
1479
- }
1480
- function handleUserMessage(text, images) {
1481
- // Mid-run: queue (don't interrupt). It shows as a queued row in the
1482
- // composer and flushes when the current turn settles. Idle: start now.
1483
- // Persist at enqueue (assign the final message id, save attachments to
1484
- // disk, mirror the queue to pending-sends.json) so a restart before the
1485
- // queue drains can recover the send instead of silently dropping it.
1486
- const id = nanoid(8);
1487
- const attachments = saveAttachments(attachmentsDir, id, images);
1488
- pendingSends.push({ id, text, attachments });
1489
- persistPending();
1490
- if (routerRunning)
1491
- broadcastRouterState();
1492
- else
1493
- maybeStartRouterTurn();
1494
- }
1495
- // "Send now" (a queued row) / Stop (empty composer): kill the running turn,
1496
- // capturing its partial draft so the follow-up turn continues it; the killed
1497
- // run's settle flushes the queue. When idle, just flush (covers a stray
1498
- // interrupt with messages already queued).
1499
- function interruptRouter() {
1500
- if (routerRunning) {
1501
- userEpoch += 1;
1502
- const draft = interruptRouterRuns();
1503
- // Only carry the partial draft forward when a queued message will consume
1504
- // it imminently ("Send now"). A bare Stop (empty composer) must not park
1505
- // the draft, or it leaks into the next unrelated message.
1506
- pendingInterruptedDraft = pendingSends.length > 0 ? draft : "";
2127
+ if (item.text.trim())
2128
+ texts.push(item.text);
2129
+ for (const name of item.attachments) {
2130
+ attachmentPaths.push(path.join(".castle", "agent", "attachments", name));
2131
+ }
2132
+ }
2133
+ return { texts, attachmentPaths };
2134
+ }
2135
+ // Drain the queue into the log as real user messages and start one follow-up
2136
+ // turn addressing them all (a burst batches into a single turn). A pending
2137
+ // interrupted draft from a "send now" / Stop is carried into the instruction.
2138
+ // No-op while a turn is running or the queue is empty.
2139
+ function maybeStartRouterQueueTurn(ctx) {
2140
+ const { state } = ctx;
2141
+ if (state.routerRunning || state.pendingSends.length === 0)
2142
+ return;
2143
+ state.autoRetryUsed = false;
2144
+ state.autoFoldUsed = false;
2145
+ const drained = state.pendingSends.splice(0, state.pendingSends.length);
2146
+ // Kept so a fold or manual interrupt of THIS turn can hand these sends back
2147
+ // to reclaimInFlightSends if it dies before producing anything visible.
2148
+ state.inFlightSends = drained;
2149
+ const { texts, attachmentPaths } = commitDrainedSends(drained, ctx.log);
2150
+ // The queue is now committed to messages.json; clear its durable mirror.
2151
+ persistPendingSends(ctx);
2152
+ const draft = state.pendingInterruptedDraft;
2153
+ state.pendingInterruptedDraft = "";
2154
+ state.routerRunning = true;
2155
+ broadcastQueueState(ctx);
2156
+ startRouterTurn(ctx, userTurnInstruction({
2157
+ messages: texts,
2158
+ interruptedDraft: draft || undefined,
2159
+ attachments: attachmentPaths,
2160
+ }));
2161
+ }
2162
+ // The turn settled: clear the busy flag, broadcast it, then flush anything
2163
+ // that queued mid-turn (a clean end and an interrupt take the same path). A
2164
+ // retryable failure (transient crash, nothing salvaged) re-runs the same
2165
+ // instruction once instead -- unless a queued user message is waiting, in
2166
+ // which case flushing it supersedes the retry (its turn re-covers things).
2167
+ function onRouterQueueSettled(ctx, retryable) {
2168
+ const { state } = ctx;
2169
+ state.routerRunning = false;
2170
+ if (retryable &&
2171
+ !state.autoRetryUsed &&
2172
+ state.pendingSends.length === 0 &&
2173
+ state.lastInstruction) {
2174
+ state.autoRetryUsed = true;
2175
+ state.routerRunning = true;
2176
+ broadcastQueueState(ctx);
2177
+ startRouterTurn(ctx, state.lastInstruction);
2178
+ return;
2179
+ }
2180
+ broadcastQueueState(ctx);
2181
+ maybeStartRouterQueueTurn(ctx);
2182
+ }
2183
+ // Auto-interrupt ("fold") a pre-text router turn into the very message that
2184
+ // triggered it: the turn hasn't produced anything visible yet, so restarting
2185
+ // it fresh -- covering the original message(s) AND the new one -- beats
2186
+ // letting it finish answering stale intent. No draft to park: a pre-text turn
2187
+ // never streamed anything to continue. The killed run's interrupted-settle
2188
+ // branch (settleInterruptedTurn) drops its now-empty message, and its
2189
+ // onSettled -> maybeStartRouterQueueTurn drains the re-queued batch into one
2190
+ // combined instruction.
2191
+ function foldRouterQueueTurn(ctx) {
2192
+ const { state } = ctx;
2193
+ state.userEpoch += 1;
2194
+ killRouterChildren(ctx);
2195
+ reclaimInFlightSends(state.pendingSends, state.inFlightSends);
2196
+ persistPendingSends(ctx);
2197
+ state.autoFoldUsed = true;
2198
+ broadcastQueueState(ctx);
2199
+ }
2200
+ function handleQueueUserMessage(ctx, text, images) {
2201
+ const { state } = ctx;
2202
+ // Mid-run: queue (don't interrupt). It shows as a queued row in the
2203
+ // composer and flushes when the current turn settles. Idle: start now.
2204
+ // Persist at enqueue (assign the final message id, save attachments to
2205
+ // disk, mirror the queue to pending-sends.json) so a restart before the
2206
+ // queue drains can recover the send instead of silently dropping it.
2207
+ const id = nanoid(8);
2208
+ const attachments = saveAttachments(ctx.attachmentsDir, id, images);
2209
+ state.pendingSends.push({ id, text, attachments });
2210
+ persistPendingSends(ctx);
2211
+ if (state.routerRunning) {
2212
+ // The new send was just pushed above, so folding now (which unshifts the
2213
+ // old in-flight batch ahead of it) yields the correct order: [old
2214
+ // messages..., this new one].
2215
+ if (!state.autoFoldUsed && !turnHasVisibleOutput(ctx.messages)) {
2216
+ foldRouterQueueTurn(ctx);
1507
2217
  }
1508
2218
  else {
1509
- maybeStartRouterTurn();
2219
+ broadcastQueueState(ctx);
1510
2220
  }
1511
2221
  }
1512
- function cancelQueued(index) {
1513
- if (!Number.isInteger(index) || index < 0 || index >= pendingSends.length)
1514
- return;
1515
- pendingSends.splice(index, 1);
1516
- persistPending();
1517
- broadcastRouterState();
1518
- }
1519
- // Restart recovery: reload the durable queue and re-enqueue only sends that
1520
- // never reached the message log. A send whose id is already in messages.json
1521
- // was committed by a prior drain (its turn ran, finished or not -- matching
1522
- // origin/main, a logged message is considered handled), so re-queueing it
1523
- // would double-deliver; we drop those. Survivors keep their original order
1524
- // and start a follow-up turn, so an interrupted serve resumes them exactly
1525
- // once instead of losing them.
1526
- function recoverPending() {
1527
- const stored = readJsonFile(pendingPath);
1528
- if (!Array.isArray(stored))
1529
- return;
1530
- const committed = new Set(messages.map((m) => m.id));
1531
- for (const item of stored) {
1532
- if (item &&
1533
- typeof item.id === "string" &&
1534
- typeof item.text === "string" &&
1535
- Array.isArray(item.attachments) &&
1536
- !committed.has(item.id)) {
1537
- pendingSends.push({
1538
- id: item.id,
1539
- text: item.text,
1540
- attachments: item.attachments.filter((a) => typeof a === "string"),
1541
- });
1542
- }
2222
+ else {
2223
+ maybeStartRouterQueueTurn(ctx);
2224
+ }
2225
+ }
2226
+ // "Send now" (a queued row) / Stop (empty composer): kill the running turn,
2227
+ // capturing its partial draft so the follow-up turn continues it; the killed
2228
+ // run's settle flushes the queue. When idle, just flush (covers a stray
2229
+ // interrupt with messages already queued).
2230
+ function interruptRouterQueue(ctx) {
2231
+ const { state } = ctx;
2232
+ if (state.routerRunning) {
2233
+ // A follow-up already queued ("Send now") means the user wants the turn
2234
+ // continued with more intent; an empty queue means a bare Stop -- abandon
2235
+ // the turn outright. Captured before reclaim, which would otherwise fill
2236
+ // the queue and make every Stop look like a Send now.
2237
+ const hasFollowUp = state.pendingSends.length > 0;
2238
+ state.userEpoch += 1;
2239
+ const draft = killRouterChildren(ctx);
2240
+ // Only a continued turn ("Send now") reclaims its in-flight sends so they
2241
+ // re-enter the next instruction. A bare Stop must NOT reclaim: doing so
2242
+ // re-queues the just-killed message and the settle immediately restarts an
2243
+ // identical turn -- the Stop appears to have no effect.
2244
+ if (hasFollowUp && !turnHasVisibleOutput(ctx.messages)) {
2245
+ reclaimInFlightSends(state.pendingSends, state.inFlightSends);
2246
+ persistPendingSends(ctx);
1543
2247
  }
1544
- persistPending();
1545
- maybeStartRouterTurn();
2248
+ // Only carry the partial draft forward when a queued message will consume
2249
+ // it imminently ("Send now"). A bare Stop (empty composer) must not park
2250
+ // the draft, or it leaks into the next unrelated message.
2251
+ state.pendingInterruptedDraft = hasFollowUp ? draft : "";
1546
2252
  }
1547
- recoverPending();
2253
+ else {
2254
+ maybeStartRouterQueueTurn(ctx);
2255
+ }
2256
+ }
2257
+ function cancelQueuedSend(ctx, index) {
2258
+ const { state } = ctx;
2259
+ if (!Number.isInteger(index) || index < 0 || index >= state.pendingSends.length)
2260
+ return;
2261
+ state.pendingSends.splice(index, 1);
2262
+ persistPendingSends(ctx);
2263
+ broadcastQueueState(ctx);
2264
+ }
2265
+ // Mid-run send queue (mirrors djinn's AltManager.pendingSends): a user message
2266
+ // sent while the router is mid-turn QUEUES instead of interrupting. It flushes
2267
+ // into a single follow-up turn when the current turn settles. An explicit
2268
+ // interrupt ("send now" / Stop) kills the run and flushes early. The epoch
2269
+ // keeps a killed-but-racing run from spawning tasks. Lives in its own factory
2270
+ // so createAgentServer stays within the max-lines budget; the queue-by-default
2271
+ // semantics are unchanged.
2272
+ function createRouterQueue(deps) {
2273
+ const ctx = {
2274
+ ...deps,
2275
+ pendingPath: path.join(deps.agentDir, "pending-sends.json"),
2276
+ state: {
2277
+ userEpoch: 0,
2278
+ routerRunning: false,
2279
+ pendingSends: [],
2280
+ inFlightSends: [],
2281
+ pendingInterruptedDraft: "",
2282
+ autoRetryUsed: false,
2283
+ autoFoldUsed: false,
2284
+ lastInstruction: "",
2285
+ },
2286
+ };
2287
+ // Restart recovery: re-enqueue sends that never reached the message log,
2288
+ // so an interrupted serve resumes them exactly once instead of losing them.
2289
+ ctx.state.pendingSends.push(...loadRecoverableSends(ctx.pendingPath, new Set(deps.messages.map((m) => m.id))));
2290
+ persistPendingSends(ctx);
2291
+ maybeStartRouterQueueTurn(ctx);
1548
2292
  return {
1549
- handleUserMessage,
1550
- interruptRouter,
1551
- cancelQueued,
1552
- isRunning: () => routerRunning,
1553
- queuedSnippets,
2293
+ handleUserMessage: (text, images) => handleQueueUserMessage(ctx, text, images),
2294
+ interruptRouter: () => interruptRouterQueue(ctx),
2295
+ cancelQueued: (index) => cancelQueuedSend(ctx, index),
2296
+ isRunning: () => ctx.state.routerRunning,
2297
+ queuedSnippets: () => computeQueuedSnippets(ctx.state.pendingSends),
1554
2298
  };
1555
2299
  }
1556
2300
  export function createAgentServer(opts) {
1557
2301
  const { deckDir, deckLabel } = opts;
2302
+ const quickReference = readQuickReference(deckDir);
1558
2303
  const agentDir = path.join(deckDir, ".castle", "agent");
1559
2304
  const tasksDir = path.join(agentDir, "tasks");
1560
2305
  const attachmentsDir = path.join(agentDir, "attachments");
@@ -1598,10 +2343,11 @@ export function createAgentServer(opts) {
1598
2343
  deckLabel,
1599
2344
  tasksDir,
1600
2345
  children: taskChildren,
2346
+ quickReference,
1601
2347
  backend: () => settings.tasks,
1602
2348
  claudeModel: () => settings.claudeModel,
1603
2349
  // Task lifecycle stays on the board only -- log lines for it were spam.
1604
- onUpdate: (task) => broadcast({ type: "task-update", task }),
2350
+ onUpdate: (task) => broadcast({ type: "task-update", task: asClientTask(task) }),
1605
2351
  onStarted: () => undefined,
1606
2352
  onRetry: (task, attempt) => addLog(`agent died, retrying (${attempt}/${MAX_TASK_ATTEMPTS}): ${task.title}`),
1607
2353
  onFinished: (task) => taskFeeds.map.delete(task.id),
@@ -1612,6 +2358,7 @@ export function createAgentServer(opts) {
1612
2358
  const routerQueue = createRouterQueue({
1613
2359
  deckDir,
1614
2360
  deckLabel,
2361
+ quickReference,
1615
2362
  agentDir,
1616
2363
  attachmentsDir,
1617
2364
  routerChildren,
@@ -1644,7 +2391,7 @@ export function createAgentServer(opts) {
1644
2391
  const hello = {
1645
2392
  type: "hello",
1646
2393
  messages,
1647
- tasks: taskStore.sorted(),
2394
+ tasks: taskStore.sorted().map(asClientTask),
1648
2395
  settings,
1649
2396
  feeds: Object.fromEntries(taskFeeds.map),
1650
2397
  running: routerQueue.isRunning(),