castle-web-cli 0.4.72 → 0.4.73

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 (57) hide show
  1. package/dist/agent-prompts.js +22 -3
  2. package/dist/agent.js +731 -313
  3. package/dist/init.js +1 -1
  4. package/dist/shell/assets/index-Dfn29Bkt.js +108 -0
  5. package/dist/shell/assets/{index-CVEnWuGV.css → index-WNbOHPBj.css} +1 -1
  6. package/dist/shell/index.html +2 -2
  7. package/dist/vitePlugins.js +3 -2
  8. package/kits/basic-2d/CLAUDE.md +29 -8
  9. package/kits/basic-2d/behaviors/Collider.jsx +6 -4
  10. package/kits/basic-2d/behaviors/Layout.jsx +2 -2
  11. package/kits/basic-2d/behaviors/Sprite.jsx +210 -0
  12. package/kits/basic-2d/behaviors/tint.js +47 -0
  13. package/kits/basic-2d/docs/pxart-format.md +298 -0
  14. package/kits/basic-2d/drawings/pig.pxart +59 -0
  15. package/kits/basic-2d/editors/App.jsx +125 -76
  16. package/kits/basic-2d/editors/CodeEditor.jsx +9 -45
  17. package/kits/basic-2d/editors/FileBrowser.jsx +234 -47
  18. package/kits/basic-2d/editors/PlayOnly.jsx +9 -7
  19. package/kits/basic-2d/editors/PxArtEditor.jsx +662 -0
  20. package/kits/basic-2d/editors/SceneEditor.jsx +587 -221
  21. package/kits/basic-2d/editors/SelectionOverlay.jsx +808 -0
  22. package/kits/basic-2d/editors/SingleEditor.jsx +38 -20
  23. package/kits/basic-2d/editors/codeTheme.js +135 -0
  24. package/kits/basic-2d/editors/editorHistory.js +44 -17
  25. package/kits/basic-2d/editors/inspectorSheet.js +23 -0
  26. package/kits/basic-2d/editors/pixelCanvas.js +11 -0
  27. package/kits/basic-2d/editors/pixelEditorChrome.jsx +55 -0
  28. package/kits/basic-2d/editors/pixelGeometry.js +45 -0
  29. package/kits/basic-2d/editors/pixelInspector.jsx +416 -0
  30. package/kits/basic-2d/editors/pxArtEditorModel.js +718 -0
  31. package/kits/basic-2d/editors/pxArtPlayback.js +92 -0
  32. package/kits/basic-2d/editors/pxArtTimeline.jsx +752 -0
  33. package/kits/basic-2d/editors/pxArtTimeline.module.css +506 -0
  34. package/kits/basic-2d/editors/pxArtTools.js +124 -0
  35. package/kits/basic-2d/editors/useArtboardFit.js +102 -0
  36. package/kits/basic-2d/engine/ScenePlayer.jsx +10 -4
  37. package/kits/basic-2d/engine/SceneUI.jsx +3 -11
  38. package/kits/basic-2d/engine/assets.js +15 -0
  39. package/kits/basic-2d/engine/files.js +57 -2
  40. package/kits/basic-2d/engine/pxart.js +985 -0
  41. package/kits/basic-2d/engine/scene.js +222 -41
  42. package/kits/basic-2d/engine/ui.jsx +155 -26
  43. package/kits/basic-2d/engine/ui.module.css +1280 -344
  44. package/kits/basic-2d/eslint.config.js +21 -0
  45. package/kits/basic-2d/index.html +13 -0
  46. package/kits/basic-2d/package.json +1 -0
  47. package/kits/basic-2d/pnpm-lock.yaml +5 -5
  48. package/kits/basic-2d/scenes/main.scene +19 -26
  49. package/kits/basic-2d/scripts/draw.mjs +121 -0
  50. package/kits/basic-3d/editors/PlayOnly.jsx +9 -1
  51. package/kits/basic-3d/engine/ScenePlayer.jsx +7 -1
  52. package/package.json +1 -1
  53. package/dist/shell/assets/index-vmdKwUE1.js +0 -106
  54. package/kits/basic-2d/behaviors/Drawing.jsx +0 -142
  55. package/kits/basic-2d/drawings/block.drawing +0 -70
  56. package/kits/basic-2d/drawings/default.drawing +0 -70
  57. package/kits/basic-2d/editors/DrawingEditor.jsx +0 -224
package/dist/agent.js CHANGED
@@ -171,10 +171,92 @@ function extractDirectives(full) {
171
171
  stops,
172
172
  };
173
173
  }
174
+ // -- agent signals (```signal blocks from task agents) -----------------------
175
+ // A running task agent narrates in prose and periodically emits ONE fenced
176
+ // ```signal block of progress metadata (mirrors djinn's lib/markdown.ts). We
177
+ // parse it out of the live stream to drive the task card's avatar/phase (and
178
+ // optionally progress), and strip it from the feed so the raw block never
179
+ // shows. The enumerated activity avatars must match the prompt + the client.
180
+ const SIGNAL_AVATARS = new Set([
181
+ "thinking",
182
+ "reading",
183
+ "building",
184
+ "painting",
185
+ "playing",
186
+ ]);
187
+ // Parse the `key: value` body of a ```signal block, fail-soft: unknown keys are
188
+ // ignored, progress is clamped 0-100, avatar must be in the enum or it's
189
+ // dropped. `note` and `phase` are aliases for the phase line.
190
+ function parseSignalBody(raw) {
191
+ const sig = {};
192
+ for (const line of raw.split("\n")) {
193
+ const m = /^\s*([a-zA-Z]+)\s*:\s*(.*)$/.exec(line);
194
+ if (!m)
195
+ continue;
196
+ const key = m[1].toLowerCase();
197
+ const value = m[2].trim();
198
+ if (!value)
199
+ continue;
200
+ if (key === "progress") {
201
+ const n = parseInt(value, 10);
202
+ if (Number.isFinite(n))
203
+ sig.progress = Math.max(0, Math.min(100, n));
204
+ }
205
+ else if (key === "avatar") {
206
+ if (SIGNAL_AVATARS.has(value))
207
+ sig.avatar = value;
208
+ }
209
+ else if (key === "note" || key === "phase") {
210
+ sig.phase = value;
211
+ }
212
+ }
213
+ return sig;
214
+ }
215
+ // Rewrite complete ```ask picker blocks in a prior assistant turn into a
216
+ // readable line list before replaying the transcript to the router, so the
217
+ // model sees the question it posed (not raw JSON it might echo back). Malformed
218
+ // blocks are left untouched. Mirrors djinn's humanizeAskBlocks.
219
+ const ASK_FENCE = /```ask[ \t]*\r?\n([\s\S]*?)```/g;
220
+ function humanizeAskBlocks(text) {
221
+ return text.replace(ASK_FENCE, (whole, body) => {
222
+ try {
223
+ const data = JSON.parse(body);
224
+ if (!data || !Array.isArray(data.questions))
225
+ return whole;
226
+ const lines = data.questions
227
+ .map((q) => {
228
+ if (!q || typeof q.q !== "string" || !Array.isArray(q.options))
229
+ return null;
230
+ const opts = q.options
231
+ .filter((o) => typeof o === "string" && o.trim() !== "")
232
+ .join(" / ");
233
+ if (!opts)
234
+ return null;
235
+ const mode = q.multi === true ? "pick any" : "pick one";
236
+ return `- ${q.q} (${mode}): ${opts}`;
237
+ })
238
+ .filter((l) => l !== null);
239
+ if (lines.length === 0)
240
+ return whole;
241
+ return `[You asked the user to choose:\n${lines.join("\n")}]`;
242
+ }
243
+ catch {
244
+ return whole;
245
+ }
246
+ });
247
+ }
174
248
  function baseName(p) {
175
249
  const parts = p.split(/[\\/]/).filter(Boolean);
176
250
  return parts[parts.length - 1] || p;
177
251
  }
252
+ // First string-typed value among loosely-typed tool inputs, or "" when none is
253
+ // a string (avoids "[object Object]" from String()-ing an object/array value).
254
+ function firstString(...vals) {
255
+ for (const v of vals)
256
+ if (typeof v === "string")
257
+ return v;
258
+ return "";
259
+ }
178
260
  // Matches the per-task progress file an agent writes its 0-100 integer to. We
179
261
  // hide those writes from the live feed -- they're constant noise, not work.
180
262
  const PROGRESS_FILE_RE = /\.castle\/agent\/tasks\/[^/]+\/progress$/;
@@ -184,13 +266,13 @@ const PROGRESS_FILE_RE = /\.castle\/agent\/tasks\/[^/]+\/progress$/;
184
266
  function claudeToolFeedLabel(name, input) {
185
267
  const kind = name.toLowerCase();
186
268
  if (["edit", "write", "notebookedit", "multiedit"].some((p) => kind.startsWith(p))) {
187
- const file = String(input.file_path ?? input.path ?? input.notebook_path ?? "");
269
+ const file = firstString(input.file_path, input.path, input.notebook_path);
188
270
  if (!file || PROGRESS_FILE_RE.test(file))
189
271
  return null;
190
272
  return `Editing ${baseName(file)}`;
191
273
  }
192
274
  if (kind.startsWith("read") || kind.startsWith("notebookread")) {
193
- const file = String(input.file_path ?? input.path ?? "");
275
+ const file = firstString(input.file_path, input.path);
194
276
  return file ? `Reading ${baseName(file)}` : null;
195
277
  }
196
278
  return null;
@@ -262,6 +344,141 @@ function envForAgentSpawn(backend) {
262
344
  }
263
345
  return env;
264
346
  }
347
+ function createAgentStreamState() {
348
+ return {
349
+ accumulated: "",
350
+ finalText: "",
351
+ resultIsError: false,
352
+ sawResult: false,
353
+ segmentText: "",
354
+ needsGap: false,
355
+ pendingTools: new Map(),
356
+ };
357
+ }
358
+ // Build the per-run stdout event handler over a shared mutable parser state.
359
+ // Splitting the cursor + claude stream decoding out of runAgentCli keeps each
360
+ // within the max-lines budget; behavior is identical (same delta/activity/
361
+ // result hooks and dedupe/segment-gap handling).
362
+ //
363
+ // Cursor closes each text segment (e.g. right before a tool call) by
364
+ // re-emitting the segment's full text as one more delta-shaped event; track the
365
+ // current segment so those re-emissions are dropped instead of duplicating
366
+ // lines. Segment boundaries also need a paragraph gap -- cursor starts the next
367
+ // segment without one, which glues "Checking the deck..." lines onto the
368
+ // previous paragraph. Claude tool_use blocks stream their input JSON by block
369
+ // index, accumulated in pendingTools so content_block_stop can label them with
370
+ // the real file / command (and drop progress-file writes).
371
+ function makeAgentEventHandler(opts, state) {
372
+ const emitDelta = (rawDelta) => {
373
+ let delta = rawDelta;
374
+ if (state.needsGap) {
375
+ state.needsGap = false;
376
+ if (state.accumulated && !state.accumulated.endsWith("\n\n")) {
377
+ delta = (state.accumulated.endsWith("\n") ? "\n" : "\n\n") + delta;
378
+ }
379
+ }
380
+ state.segmentText += delta;
381
+ state.accumulated += delta;
382
+ opts.onDelta?.(delta);
383
+ opts.onActivity?.(null);
384
+ };
385
+ const handleClaudeEvent = (ev) => {
386
+ if (ev.type === "stream_event") {
387
+ const e = ev.event;
388
+ if (e?.type === "content_block_start") {
389
+ if (e.content_block?.type === "tool_use") {
390
+ 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
+ });
397
+ }
398
+ else if (e.content_block?.type === "thinking") {
399
+ state.needsGap = true;
400
+ }
401
+ }
402
+ else if (e?.type === "content_block_delta") {
403
+ if (e.delta?.type === "text_delta" &&
404
+ typeof e.delta.text === "string" &&
405
+ e.delta.text) {
406
+ emitDelta(e.delta.text);
407
+ }
408
+ else if (e.delta?.type === "thinking_delta" &&
409
+ typeof e.delta.thinking === "string" &&
410
+ e.delta.thinking) {
411
+ opts.onThinking?.(e.delta.thinking);
412
+ }
413
+ else if (e.delta?.type === "input_json_delta" &&
414
+ typeof e.delta.partial_json === "string") {
415
+ const pending = state.pendingTools.get(e.index ?? -1);
416
+ if (pending)
417
+ pending.buf += e.delta.partial_json;
418
+ }
419
+ }
420
+ else if (e?.type === "content_block_stop") {
421
+ const pending = state.pendingTools.get(e.index ?? -1);
422
+ if (pending) {
423
+ state.pendingTools.delete(e.index ?? -1);
424
+ let input = {};
425
+ try {
426
+ input = pending.buf
427
+ ? JSON.parse(pending.buf)
428
+ : {};
429
+ }
430
+ catch {
431
+ /* input JSON arrived partial -- fall back to a generic label */
432
+ }
433
+ const label = claudeToolFeedLabel(pending.name, input);
434
+ if (label)
435
+ opts.onActivity?.(label);
436
+ }
437
+ }
438
+ }
439
+ else if (ev.type === "result") {
440
+ state.sawResult = true;
441
+ state.finalText =
442
+ typeof ev.result === "string" ? ev.result : state.accumulated;
443
+ state.resultIsError = ev.is_error === true;
444
+ }
445
+ };
446
+ return (ev) => {
447
+ if (opts.parser === "claude") {
448
+ handleClaudeEvent(ev);
449
+ return;
450
+ }
451
+ if (ev.type === "assistant" && typeof ev.timestamp_ms === "number") {
452
+ const message = ev.message;
453
+ const delta = (message?.content ?? [])
454
+ .map((c) => (typeof c?.text === "string" ? c.text : ""))
455
+ .join("");
456
+ if (!delta)
457
+ return;
458
+ const trimmed = delta.trim();
459
+ if (trimmed.length >= 16 && state.segmentText.trim().endsWith(trimmed))
460
+ return;
461
+ emitDelta(delta);
462
+ }
463
+ else if (ev.type === "tool_call") {
464
+ state.segmentText = "";
465
+ state.needsGap = true;
466
+ if (ev.subtype === "started")
467
+ opts.onActivity?.(toolActivityLabel(ev));
468
+ }
469
+ else if (ev.type === "thinking") {
470
+ state.segmentText = "";
471
+ state.needsGap = true;
472
+ opts.onActivity?.("thinking");
473
+ }
474
+ else if (ev.type === "result") {
475
+ state.sawResult = true;
476
+ state.finalText =
477
+ typeof ev.result === "string" ? ev.result : state.accumulated;
478
+ state.resultIsError = ev.is_error === true;
479
+ }
480
+ };
481
+ }
265
482
  // One headless agent CLI run (cursor or claude), normalized to the same
266
483
  // delta/activity/result hooks. Cursor: assistant events carrying timestamp_ms
267
484
  // are text deltas; the trailing assistant event without one repeats the whole
@@ -280,12 +497,9 @@ function runAgentCli(opts) {
280
497
  ? fs.createWriteStream(opts.logPath, { flags: "a" })
281
498
  : null;
282
499
  let settled = false;
283
- let accumulated = "";
284
- let finalText = "";
285
- let resultIsError = false;
286
- let sawResult = false;
287
500
  let stderrTail = "";
288
501
  let lineBuffer = "";
502
+ const state = createAgentStreamState();
289
503
  const settle = (result) => {
290
504
  if (settled)
291
505
  return;
@@ -304,129 +518,11 @@ function runAgentCli(opts) {
304
518
  }
305
519
  settle({
306
520
  ok: false,
307
- finalText: finalText || accumulated,
521
+ finalText: state.finalText || state.accumulated,
308
522
  error: "agent run timed out",
309
523
  });
310
524
  }, opts.timeoutMs);
311
- // Cursor closes each text segment (e.g. right before a tool call) by
312
- // re-emitting the segment's full text as one more delta-shaped event;
313
- // track the current segment so those re-emissions are dropped instead of
314
- // duplicating lines. Segment boundaries also need a paragraph gap --
315
- // cursor starts the next segment without one, which glues "Checking the
316
- // deck..." lines onto the previous paragraph.
317
- let segmentText = "";
318
- let needsGap = false;
319
- // Accumulate each claude tool_use block's streamed input JSON by block
320
- // index, so at content_block_stop we can label it with the real file /
321
- // command (and drop progress-file writes).
322
- const pendingTools = new Map();
323
- const emitDelta = (rawDelta) => {
324
- let delta = rawDelta;
325
- if (needsGap) {
326
- needsGap = false;
327
- if (accumulated && !accumulated.endsWith("\n\n")) {
328
- delta = (accumulated.endsWith("\n") ? "\n" : "\n\n") + delta;
329
- }
330
- }
331
- segmentText += delta;
332
- accumulated += delta;
333
- opts.onDelta?.(delta);
334
- opts.onActivity?.(null);
335
- };
336
- const handleClaudeEvent = (ev) => {
337
- if (ev.type === "stream_event") {
338
- const e = ev.event;
339
- if (e?.type === "content_block_start") {
340
- if (e.content_block?.type === "tool_use") {
341
- needsGap = true;
342
- // Hold the label until content_block_stop, once the input (file /
343
- // command) has streamed in, so we can name it concretely.
344
- pendingTools.set(e.index ?? -1, {
345
- name: String(e.content_block.name ?? ""),
346
- buf: "",
347
- });
348
- }
349
- else if (e.content_block?.type === "thinking") {
350
- needsGap = true;
351
- }
352
- }
353
- else if (e?.type === "content_block_delta") {
354
- if (e.delta?.type === "text_delta" &&
355
- typeof e.delta.text === "string" &&
356
- e.delta.text) {
357
- emitDelta(e.delta.text);
358
- }
359
- else if (e.delta?.type === "thinking_delta" &&
360
- typeof e.delta.thinking === "string" &&
361
- e.delta.thinking) {
362
- opts.onThinking?.(e.delta.thinking);
363
- }
364
- else if (e.delta?.type === "input_json_delta" &&
365
- typeof e.delta.partial_json === "string") {
366
- const pending = pendingTools.get(e.index ?? -1);
367
- if (pending)
368
- pending.buf += e.delta.partial_json;
369
- }
370
- }
371
- else if (e?.type === "content_block_stop") {
372
- const pending = pendingTools.get(e.index ?? -1);
373
- if (pending) {
374
- pendingTools.delete(e.index ?? -1);
375
- let input = {};
376
- try {
377
- input = pending.buf
378
- ? JSON.parse(pending.buf)
379
- : {};
380
- }
381
- catch {
382
- /* input JSON arrived partial -- fall back to a generic label */
383
- }
384
- const label = claudeToolFeedLabel(pending.name, input);
385
- if (label)
386
- opts.onActivity?.(label);
387
- }
388
- }
389
- }
390
- else if (ev.type === "result") {
391
- sawResult = true;
392
- finalText = typeof ev.result === "string" ? ev.result : accumulated;
393
- resultIsError = ev.is_error === true;
394
- }
395
- };
396
- const handleEvent = (ev) => {
397
- if (opts.parser === "claude") {
398
- handleClaudeEvent(ev);
399
- return;
400
- }
401
- if (ev.type === "assistant" && typeof ev.timestamp_ms === "number") {
402
- const message = ev.message;
403
- const delta = (message?.content ?? [])
404
- .map((c) => (typeof c?.text === "string" ? c.text : ""))
405
- .join("");
406
- if (!delta)
407
- return;
408
- const trimmed = delta.trim();
409
- if (trimmed.length >= 16 && segmentText.trim().endsWith(trimmed))
410
- return;
411
- emitDelta(delta);
412
- }
413
- else if (ev.type === "tool_call") {
414
- segmentText = "";
415
- needsGap = true;
416
- if (ev.subtype === "started")
417
- opts.onActivity?.(toolActivityLabel(ev));
418
- }
419
- else if (ev.type === "thinking") {
420
- segmentText = "";
421
- needsGap = true;
422
- opts.onActivity?.("thinking");
423
- }
424
- else if (ev.type === "result") {
425
- sawResult = true;
426
- finalText = typeof ev.result === "string" ? ev.result : accumulated;
427
- resultIsError = ev.is_error === true;
428
- }
429
- };
525
+ const handleEvent = makeAgentEventHandler(opts, state);
430
526
  child.stdout.on("data", (chunk) => {
431
527
  lineBuffer += chunk.toString("utf8");
432
528
  let nl = lineBuffer.indexOf("\n");
@@ -451,16 +547,16 @@ function runAgentCli(opts) {
451
547
  child.on("error", (err) => {
452
548
  settle({
453
549
  ok: false,
454
- finalText: accumulated,
550
+ finalText: state.accumulated,
455
551
  error: `could not run cursor-agent: ${err.message}`,
456
552
  });
457
553
  });
458
554
  child.on("close", (code) => {
459
- const ok = code === 0 && !resultIsError && sawResult;
555
+ const ok = code === 0 && !state.resultIsError && state.sawResult;
460
556
  settle({
461
557
  ok,
462
- finalText: finalText || accumulated,
463
- crashed: !sawResult,
558
+ finalText: state.finalText || state.accumulated,
559
+ crashed: !state.sawResult,
464
560
  error: ok
465
561
  ? undefined
466
562
  : `agent exited ${code}${stderrTail ? `: ${stderrTail.slice(-300)}` : ""}`,
@@ -567,15 +663,38 @@ async function runTaskAgentIn(ctx, task) {
567
663
  const invocation = buildAgentInvocation(ctx.backend, "task", taskPrompt, ctx.claudeModel);
568
664
  let result = { ok: false, finalText: "", error: "not run" };
569
665
  let lineBuf = "";
666
+ // ```signal blocks span multiple lines and must NOT show in the live feed:
667
+ // track whether we're inside one (line-state machine). Lines arrive complete
668
+ // (flushFeedLines only emits up to a newline), so a partial fence never
669
+ // flashes. On close we parse the body and hand it to ctx.onSignal.
670
+ let inSignal = false;
671
+ let signalBody = "";
570
672
  const flushFeedLines = (delta) => {
571
673
  lineBuf += delta;
572
674
  let nl = lineBuf.indexOf("\n");
573
675
  while (nl >= 0) {
574
- const line = lineBuf.slice(0, nl).trim();
676
+ const rawLine = lineBuf.slice(0, nl);
575
677
  lineBuf = lineBuf.slice(nl + 1);
678
+ nl = lineBuf.indexOf("\n");
679
+ const line = rawLine.trim();
680
+ if (inSignal) {
681
+ if (line === "```") {
682
+ inSignal = false;
683
+ ctx.onSignal(parseSignalBody(signalBody));
684
+ signalBody = "";
685
+ }
686
+ else {
687
+ signalBody += rawLine + "\n";
688
+ }
689
+ continue;
690
+ }
691
+ if (line === "```signal") {
692
+ inSignal = true;
693
+ signalBody = "";
694
+ continue;
695
+ }
576
696
  if (line)
577
697
  ctx.onFeed(line);
578
- nl = lineBuf.indexOf("\n");
579
698
  }
580
699
  };
581
700
  for (let attempt = 1; attempt <= MAX_TASK_ATTEMPTS; attempt++) {
@@ -607,6 +726,141 @@ async function runTaskAgentIn(ctx, task) {
607
726
  result.error = `agent process kept dying (${MAX_TASK_ATTEMPTS} attempts): ${result.error ?? ""}`;
608
727
  return result;
609
728
  }
729
+ // Launch a task's agent run and wire up its finalization. The settle path is
730
+ // hardened: result-finalization, onFinished, and the reschedule sweep each run
731
+ // under their own try/catch so one bad task turn can neither wedge the board
732
+ // (by skipping the slot free) nor crash the serve.
733
+ function startTask(ctx, task) {
734
+ const dir = path.join(ctx.tasksDir, task.id);
735
+ fs.writeFileSync(path.join(dir, "progress"), "0\n");
736
+ if (!fs.existsSync(path.join(dir, "notes.md")))
737
+ fs.writeFileSync(path.join(dir, "notes.md"), "");
738
+ task.status = "running";
739
+ task.startedAt = nowIso();
740
+ ctx.touch(task);
741
+ ctx.onStarted(task);
742
+ const runCtx = {
743
+ deckDir: ctx.deckDir,
744
+ deckLabel: ctx.deckLabel,
745
+ tasksDir: ctx.tasksDir,
746
+ children: ctx.children,
747
+ backend: ctx.backend(),
748
+ claudeModel: ctx.claudeModel(),
749
+ stopRequested: ctx.stopRequested,
750
+ depsSummary: depsSummaryFor(ctx.tasks, task),
751
+ onFeed: (entry) => ctx.onFeed(task, entry),
752
+ onRetry: (attempt) => ctx.onRetry(task, attempt),
753
+ onSignal: (signal) => {
754
+ let changed = false;
755
+ if (typeof signal.progress === "number" &&
756
+ signal.progress !== task.progress) {
757
+ task.progress = signal.progress;
758
+ changed = true;
759
+ }
760
+ if (signal.avatar && signal.avatar !== task.avatar) {
761
+ task.avatar = signal.avatar;
762
+ changed = true;
763
+ }
764
+ if (signal.phase && signal.phase !== task.phase) {
765
+ task.phase = signal.phase;
766
+ changed = true;
767
+ }
768
+ if (changed)
769
+ ctx.touch(task);
770
+ },
771
+ };
772
+ void runTaskAgentIn(runCtx, task)
773
+ .then((result) => {
774
+ // Finalization (fs writes), onFinished, and the reschedule sweep must all
775
+ // survive a throw -- otherwise a single bad task turn could both skip
776
+ // freeing the slot (wedging the board) and crash the serve.
777
+ try {
778
+ refreshTaskFiles(ctx.tasksDir, task);
779
+ const wasStopped = ctx.stopRequested.delete(task.id);
780
+ task.status = wasStopped
781
+ ? "interrupted"
782
+ : result.ok
783
+ ? "done"
784
+ : "failed";
785
+ // A stopped task is cleared off the board (castle-stop = halt + remove).
786
+ if (wasStopped)
787
+ task.acknowledged = true;
788
+ if (result.ok && !wasStopped)
789
+ task.progress = 100;
790
+ task.finishedAt = nowIso();
791
+ task.resultSummary = wasStopped
792
+ ? "stopped by the router"
793
+ : result.ok
794
+ ? result.finalText.slice(-RESULT_SUMMARY_CHARS)
795
+ : `${result.error ?? "failed"}\n${result.finalText.slice(-RESULT_SUMMARY_CHARS)}`;
796
+ ctx.touch(task);
797
+ }
798
+ catch (err) {
799
+ console.error(`[task ${task.id}] finalization threw: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
800
+ // Best-effort: still mark the task terminal so it can't hang forever.
801
+ try {
802
+ ctx.stopRequested.delete(task.id);
803
+ task.status = "failed";
804
+ task.finishedAt = nowIso();
805
+ task.resultSummary = `internal error finalizing task: ${err instanceof Error ? err.message : String(err)}`;
806
+ ctx.touch(task);
807
+ }
808
+ catch {
809
+ /* ignore -- the finally still frees the concurrency slot */
810
+ }
811
+ }
812
+ finally {
813
+ // Always notify and free the slot.
814
+ try {
815
+ ctx.onFinished(task);
816
+ }
817
+ catch (err) {
818
+ console.error(`[task ${task.id}] onFinished threw: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
819
+ }
820
+ ctx.rescheduleAll();
821
+ }
822
+ })
823
+ .catch((err) => {
824
+ // runTaskAgentIn itself rejected. Free the slot so the board keeps moving.
825
+ console.error(`[task ${task.id}] run promise rejected: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
826
+ try {
827
+ ctx.stopRequested.delete(task.id);
828
+ task.status = "failed";
829
+ task.finishedAt = nowIso();
830
+ task.resultSummary = `task run failed: ${err instanceof Error ? err.message : String(err)}`;
831
+ ctx.touch(task);
832
+ ctx.onFinished(task);
833
+ }
834
+ catch {
835
+ /* ignore */
836
+ }
837
+ ctx.rescheduleAll();
838
+ });
839
+ }
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.
844
+ function haltTask(task, children, stopRequested, touch) {
845
+ if (task.status === "waiting") {
846
+ task.status = "interrupted";
847
+ task.acknowledged = true;
848
+ touch(task);
849
+ }
850
+ else if (task.status === "running") {
851
+ stopRequested.add(task.id);
852
+ for (const child of children) {
853
+ if (child.pid === task.pid) {
854
+ try {
855
+ child.kill("SIGKILL");
856
+ }
857
+ catch {
858
+ /* already gone */
859
+ }
860
+ }
861
+ }
862
+ }
863
+ }
610
864
  function createTaskStore(opts) {
611
865
  const { deckDir, deckLabel, tasksDir, children } = opts;
612
866
  const tasks = loadTasks(tasksDir);
@@ -642,52 +896,38 @@ function createTaskStore(opts) {
642
896
  // onFinished sweep below when a running task frees a slot.
643
897
  if (runningCount() >= MAX_CONCURRENT_TASKS)
644
898
  return;
645
- start(task);
899
+ startTask(schedulerCtx, task);
646
900
  }
647
- function start(task) {
648
- const dir = path.join(tasksDir, task.id);
649
- fs.writeFileSync(path.join(dir, "progress"), "0\n");
650
- if (!fs.existsSync(path.join(dir, "notes.md")))
651
- fs.writeFileSync(path.join(dir, "notes.md"), "");
652
- task.status = "running";
653
- task.startedAt = nowIso();
654
- touch(task);
655
- opts.onStarted(task);
656
- const runCtx = {
657
- deckDir,
658
- deckLabel,
659
- tasksDir,
660
- children,
661
- backend: opts.backend(),
662
- claudeModel: opts.claudeModel(),
663
- stopRequested,
664
- depsSummary: depsSummaryFor(tasks, task),
665
- onFeed: (entry) => opts.onFeed(task, entry),
666
- onRetry: (attempt) => opts.onRetry(task, attempt),
667
- };
668
- void runTaskAgentIn(runCtx, task).then((result) => {
669
- refreshTaskFiles(tasksDir, task);
670
- const wasStopped = stopRequested.delete(task.id);
671
- task.status = wasStopped ? "interrupted" : result.ok ? "done" : "failed";
672
- // A stopped task is cleared off the board (castle-stop = halt + remove).
673
- if (wasStopped)
674
- task.acknowledged = true;
675
- if (result.ok && !wasStopped)
676
- task.progress = 100;
677
- task.finishedAt = nowIso();
678
- task.resultSummary = wasStopped
679
- ? "stopped by the router"
680
- : result.ok
681
- ? result.finalText.slice(-RESULT_SUMMARY_CHARS)
682
- : `${result.error ?? "failed"}\n${result.finalText.slice(-RESULT_SUMMARY_CHARS)}`;
683
- touch(task);
684
- opts.onFinished(task);
685
- // A slot just freed -- restart eligible waiting tasks, earliest-created
686
- // first, up to the concurrency cap.
687
- for (const waiting of sorted())
901
+ // Restart eligible waiting tasks, earliest-created first, up to the
902
+ // concurrency cap. Each start is guarded so one failure can't wedge the rest
903
+ // of the board. Passed to startTask so a finished run can free its slot.
904
+ function rescheduleAll() {
905
+ for (const waiting of sorted()) {
906
+ try {
688
907
  maybeStart(waiting);
689
- });
908
+ }
909
+ catch (err) {
910
+ console.error(`[task ${waiting.id}] maybeStart threw: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
911
+ }
912
+ }
690
913
  }
914
+ const schedulerCtx = {
915
+ deckDir,
916
+ deckLabel,
917
+ tasksDir,
918
+ children,
919
+ tasks,
920
+ stopRequested,
921
+ backend: opts.backend,
922
+ claudeModel: opts.claudeModel,
923
+ onStarted: opts.onStarted,
924
+ onFinished: opts.onFinished,
925
+ onRetry: opts.onRetry,
926
+ onFeed: opts.onFeed,
927
+ touch,
928
+ sorted,
929
+ rescheduleAll,
930
+ };
691
931
  function spawnFromDirective(directive, originMessageId) {
692
932
  const task = {
693
933
  id: nanoid(8),
@@ -755,31 +995,7 @@ function createTaskStore(opts) {
755
995
  // The router stops tasks by title or id (castle-stop fence), or "all" to
756
996
  // stop everything still active. Waiting tasks are cancelled outright;
757
997
  // running ones get their agent process killed and finalize as interrupted
758
- // via the stopRequested path.
759
- // Halt + remove an active task (castle-stop): a waiting one is cancelled and
760
- // cleared off the board immediately; a running one gets its agent process
761
- // killed and is cleared when it finalizes (the stopRequested path acks it).
762
- // No-op on terminal tasks.
763
- function haltTask(task) {
764
- if (task.status === "waiting") {
765
- task.status = "interrupted";
766
- task.acknowledged = true;
767
- touch(task);
768
- }
769
- else if (task.status === "running") {
770
- stopRequested.add(task.id);
771
- for (const child of children) {
772
- if (child.pid === task.pid) {
773
- try {
774
- child.kill("SIGKILL");
775
- }
776
- catch {
777
- /* already gone */
778
- }
779
- }
780
- }
781
- }
782
- }
998
+ // via the stopRequested path (see haltTask).
783
999
  function stop(tokens) {
784
1000
  const ids = meansAll(tokens)
785
1001
  ? [...tasks.values()]
@@ -789,7 +1005,7 @@ function createTaskStore(opts) {
789
1005
  for (const id of ids) {
790
1006
  const task = tasks.get(id);
791
1007
  if (task)
792
- haltTask(task);
1008
+ haltTask(task, children, stopRequested, touch);
793
1009
  }
794
1010
  }
795
1011
  return {
@@ -937,7 +1153,9 @@ function runRouterTurnIn(ctx, instruction) {
937
1153
  .filter((m) => m.role !== "log" && m.id !== message.id && m.status !== "streaming")
938
1154
  .map((m) => ({
939
1155
  role: m.role,
940
- text: m.text,
1156
+ // Replace a prior turn's raw ```ask JSON with a readable question list
1157
+ // so the model doesn't re-echo the block verbatim.
1158
+ text: m.role === "assistant" ? humanizeAskBlocks(m.text) : m.text,
941
1159
  interrupted: m.interrupted,
942
1160
  })),
943
1161
  // Only the live board -- match what the user sees. Hide tasks that are
@@ -975,53 +1193,99 @@ function runRouterTurnIn(ctx, instruction) {
975
1193
  lastActivity = activity;
976
1194
  ctx.broadcast({ type: "message-activity", id: message.id, activity });
977
1195
  },
978
- }).then((result) => {
979
- const interrupted = epoch !== ctx.currentEpoch() && !result.ok;
980
- if (interrupted) {
981
- // Keep whatever streamed; the continuation turn carries the draft.
982
- message.status = "done";
983
- message.interrupted = true;
1196
+ })
1197
+ .then((result) => {
1198
+ // The settle path must ALWAYS reach ctx.onSettled() (clears
1199
+ // routerRunning + flushes pendingSends). A throw here on Node v25 would
1200
+ // otherwise both freeze the composer and crash the serve, so the whole
1201
+ // body runs under try/catch/finally and every terminal branch falls
1202
+ // through to the finally rather than calling onSettled() inline.
1203
+ try {
1204
+ const interrupted = epoch !== ctx.currentEpoch() && !result.ok;
1205
+ 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
+ });
1218
+ return;
1219
+ }
1220
+ const { cleaned, directives, checkoffs, stops } = extractDirectives(result.finalText);
1221
+ if (result.ok && checkoffs.length > 0)
1222
+ ctx.taskStore.checkOff(checkoffs);
1223
+ if (result.ok && stops.length > 0)
1224
+ 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).
1227
+ const stale = epoch !== ctx.currentEpoch();
1228
+ const inFlight = new Set(ctx.taskStore
1229
+ .sorted()
1230
+ .filter((t) => t.status === "running" || t.status === "waiting")
1231
+ .map((t) => t.title.toLowerCase()));
1232
+ const toSpawn = stale
1233
+ ? []
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";
1240
+ if (taskIds.length > 0)
1241
+ message.taskIds = taskIds;
984
1242
  ctx.log.persist();
985
1243
  ctx.broadcast({
986
1244
  type: "message-done",
987
1245
  id: message.id,
988
1246
  text: message.text,
989
1247
  status: message.status,
990
- interrupted: true,
991
- taskIds: [],
1248
+ taskIds: message.taskIds ?? [],
992
1249
  });
993
- return;
994
1250
  }
995
- const { cleaned, directives, checkoffs, stops } = extractDirectives(result.finalText);
996
- if (result.ok && checkoffs.length > 0)
997
- ctx.taskStore.checkOff(checkoffs);
998
- if (result.ok && stops.length > 0)
999
- ctx.taskStore.stop(stops);
1000
- // Drop directives from stale turns, and any whose title matches a task
1001
- // already in flight (two runs reacting to the same ask).
1002
- const stale = epoch !== ctx.currentEpoch();
1003
- const inFlight = new Set(ctx.taskStore
1004
- .sorted()
1005
- .filter((t) => t.status === "running" || t.status === "waiting")
1006
- .map((t) => t.title.toLowerCase()));
1007
- const toSpawn = stale
1008
- ? []
1009
- : directives.filter((d) => !inFlight.has(d.title.toLowerCase()));
1010
- const taskIds = toSpawn.map((d) => ctx.taskStore.spawnFromDirective(d, message.id));
1011
- message.text = result.ok
1012
- ? cleaned
1013
- : `${cleaned ? cleaned + "\n\n" : ""}[router error: ${result.error ?? "unknown"}]`;
1014
- message.status = result.ok ? "done" : "error";
1015
- if (taskIds.length > 0)
1016
- message.taskIds = taskIds;
1017
- ctx.log.persist();
1018
- ctx.broadcast({
1019
- type: "message-done",
1020
- id: message.id,
1021
- text: message.text,
1022
- status: message.status,
1023
- taskIds: message.taskIds ?? [],
1024
- });
1251
+ catch (err) {
1252
+ // Surface the failure in the UI instead of silently hanging: mark the
1253
+ // in-flight assistant message as errored, persist, and broadcast so the
1254
+ // composer un-freezes and the user sees what happened.
1255
+ const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
1256
+ const short = err instanceof Error ? err.message : String(err);
1257
+ console.error(`[router] turn callback threw: ${detail}`);
1258
+ try {
1259
+ message.status = "error";
1260
+ message.text = `${message.text ? message.text + "\n\n" : ""}[router error: ${short}]`;
1261
+ ctx.log.persist();
1262
+ ctx.broadcast({
1263
+ type: "message-done",
1264
+ id: message.id,
1265
+ text: message.text,
1266
+ status: message.status,
1267
+ taskIds: message.taskIds ?? [],
1268
+ });
1269
+ }
1270
+ catch (inner) {
1271
+ console.error(`[router] failed to surface turn error: ${inner instanceof Error ? inner.stack ?? inner.message : String(inner)}`);
1272
+ }
1273
+ }
1274
+ finally {
1275
+ ctx.onSettled();
1276
+ }
1277
+ })
1278
+ .catch((err) => {
1279
+ // runAgentCli itself rejected (it normally resolves with result.ok=false,
1280
+ // so this is the rare hard-failure path). Still settle so routerRunning
1281
+ // can't stick true and freeze the composer.
1282
+ console.error(`[router] turn promise rejected: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
1283
+ try {
1284
+ ctx.onSettled();
1285
+ }
1286
+ catch (inner) {
1287
+ console.error(`[router] onSettled threw during rejection recovery: ${inner instanceof Error ? inner.stack ?? inner.message : String(inner)}`);
1288
+ }
1025
1289
  });
1026
1290
  }
1027
1291
  // Merge settings changes from the client: validate, persist, broadcast, log.
@@ -1101,6 +1365,194 @@ function startChildRegistry(registryPath, groups) {
1101
1365
  }
1102
1366
  };
1103
1367
  }
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
+ });
1400
+ }
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
+ }
1413
+ }
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).
1445
+ const message = {
1446
+ id: item.id,
1447
+ role: "user",
1448
+ text: item.text,
1449
+ at: nowIso(),
1450
+ status: "done",
1451
+ };
1452
+ if (item.attachments.length > 0)
1453
+ message.attachments = item.attachments;
1454
+ 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
+ }
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 : "";
1507
+ }
1508
+ else {
1509
+ maybeStartRouterTurn();
1510
+ }
1511
+ }
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
+ }
1543
+ }
1544
+ persistPending();
1545
+ maybeStartRouterTurn();
1546
+ }
1547
+ recoverPending();
1548
+ return {
1549
+ handleUserMessage,
1550
+ interruptRouter,
1551
+ cancelQueued,
1552
+ isRunning: () => routerRunning,
1553
+ queuedSnippets,
1554
+ };
1555
+ }
1104
1556
  export function createAgentServer(opts) {
1105
1557
  const { deckDir, deckLabel } = opts;
1106
1558
  const agentDir = path.join(deckDir, ".castle", "agent");
@@ -1155,78 +1607,33 @@ export function createAgentServer(opts) {
1155
1607
  onFinished: (task) => taskFeeds.map.delete(task.id),
1156
1608
  onFeed: (task, entry) => taskFeeds.push(task, entry),
1157
1609
  });
1158
- // A new user message interrupts the in-flight router reply: its partial
1159
- // text stays in the log, and the next turn continues both threads. The
1160
- // epoch also keeps a killed-but-racing run from spawning tasks.
1161
- let userEpoch = 0;
1162
- function interruptRouterRuns() {
1163
- const drafts = messages
1164
- .filter((m) => m.role === "assistant" && m.status === "streaming")
1165
- .map((m) => m.text.trim())
1166
- .filter(Boolean);
1167
- for (const child of routerChildren) {
1168
- try {
1169
- child.kill("SIGKILL");
1170
- }
1171
- catch {
1172
- /* already gone */
1173
- }
1174
- }
1175
- return drafts.join("\n\n");
1176
- }
1177
- function runRouterTurn(instruction) {
1178
- runRouterTurnIn({
1179
- deckDir,
1180
- deckLabel,
1181
- agentDir,
1182
- children: routerChildren,
1183
- log,
1184
- broadcast,
1185
- taskStore,
1186
- currentEpoch: () => userEpoch,
1187
- backend: () => settings.router,
1188
- claudeModel: () => settings.claudeModel,
1189
- }, instruction);
1190
- }
1191
- // User messages awaiting a reply: everything since the last COMPLETED (done,
1192
- // not interrupted) assistant message. Usually just the latest, but a rapid
1193
- // burst leaves several queued -- all must be addressed, not only the last.
1194
- function pendingUserMessages() {
1195
- let lastAnswered = -1;
1196
- for (let i = messages.length - 1; i >= 0; i--) {
1197
- const m = messages[i];
1198
- if (m.role === "assistant" && m.status === "done" && !m.interrupted) {
1199
- lastAnswered = i;
1200
- break;
1201
- }
1610
+ // The mid-run send queue (queue-by-default, "send now"/Stop interrupt, epoch
1611
+ // guard). Lives in its own factory; createAgentServer just wires it up.
1612
+ const routerQueue = createRouterQueue({
1613
+ deckDir,
1614
+ deckLabel,
1615
+ agentDir,
1616
+ attachmentsDir,
1617
+ routerChildren,
1618
+ log,
1619
+ broadcast,
1620
+ taskStore,
1621
+ messages,
1622
+ settings,
1623
+ });
1624
+ // The user answered an embedded ```ask picker: record the selections on the
1625
+ // assistant message (locks the widget for every client, persists across
1626
+ // reloads) and route the composed reply through the normal user-message path
1627
+ // (so it respects the send queue).
1628
+ function handlePickerChoice(id, answers, text) {
1629
+ const msg = messages.find((m) => m.id === id);
1630
+ if (msg && msg.role === "assistant" && answers && typeof answers === "object") {
1631
+ msg.pickerAnswers = answers;
1632
+ log.persist();
1633
+ broadcast({ type: "message-picker", id, pickerAnswers: msg.pickerAnswers });
1202
1634
  }
1203
- return messages.slice(lastAnswered + 1).filter((m) => m.role === "user");
1204
- }
1205
- function handleUserMessage(text, images) {
1206
- userEpoch += 1;
1207
- const interruptedDraft = interruptRouterRuns();
1208
- const messageId = nanoid(8);
1209
- const attachments = saveAttachments(attachmentsDir, messageId, images);
1210
- const message = {
1211
- id: messageId,
1212
- role: "user",
1213
- text,
1214
- at: nowIso(),
1215
- status: "done",
1216
- };
1217
- if (attachments.length > 0)
1218
- message.attachments = attachments;
1219
- log.add(message);
1220
- // Address every still-unanswered user message (this one plus any earlier
1221
- // burst messages that interrupted prior turns), not just the latest.
1222
- const pending = pendingUserMessages();
1223
- runRouterTurn(userTurnInstruction({
1224
- messages: pending.map((m) => m.text),
1225
- interruptedDraft: interruptedDraft || undefined,
1226
- attachments: pending
1227
- .flatMap((m) => m.attachments ?? [])
1228
- .map((name) => path.join(".castle", "agent", "attachments", name)),
1229
- }));
1635
+ if (text.trim())
1636
+ routerQueue.handleUserMessage(text.trim(), undefined);
1230
1637
  }
1231
1638
  function handleTaskAck(id, rejected) {
1232
1639
  taskStore.acknowledge(id, rejected);
@@ -1240,6 +1647,8 @@ export function createAgentServer(opts) {
1240
1647
  tasks: taskStore.sorted(),
1241
1648
  settings,
1242
1649
  feeds: Object.fromEntries(taskFeeds.map),
1650
+ running: routerQueue.isRunning(),
1651
+ queued: routerQueue.queuedSnippets(),
1243
1652
  };
1244
1653
  socket.send(JSON.stringify(hello));
1245
1654
  socket.on("message", (rawData) => {
@@ -1253,7 +1662,16 @@ export function createAgentServer(opts) {
1253
1662
  const hasText = typeof msg.text === "string" && msg.text.trim() !== "";
1254
1663
  const hasImages = Array.isArray(msg.images) && msg.images.length > 0;
1255
1664
  if (msg.type === "user-message" && (hasText || hasImages)) {
1256
- handleUserMessage(typeof msg.text === "string" ? msg.text.trim() : "", msg.images);
1665
+ routerQueue.handleUserMessage(typeof msg.text === "string" ? msg.text.trim() : "", msg.images);
1666
+ }
1667
+ else if (msg.type === "interrupt") {
1668
+ routerQueue.interruptRouter();
1669
+ }
1670
+ else if (msg.type === "cancel-queued" && typeof msg.index === "number") {
1671
+ routerQueue.cancelQueued(msg.index);
1672
+ }
1673
+ else if (msg.type === "picker-choice" && typeof msg.id === "string") {
1674
+ handlePickerChoice(msg.id, msg.answers, typeof msg.text === "string" ? msg.text : "");
1257
1675
  }
1258
1676
  else if (msg.type === "task-ack" && typeof msg.id === "string") {
1259
1677
  handleTaskAck(msg.id, msg.rejected === true);