castle-web-cli 0.4.76 → 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.
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
@@ -372,6 +426,12 @@ const DECK_TREE_EXCLUDE = new Set([
372
426
  ".DS_Store",
373
427
  ]);
374
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;
375
435
  // Shallow orientation snapshot for router/task prompts. Best-effort by design:
376
436
  // filesystem hiccups should cost context, not fail an agent turn.
377
437
  function buildDeckTree(deckDir) {
@@ -389,9 +449,8 @@ function buildDeckTree(deckDir) {
389
449
  return a.isDirectory() ? -1 : 1;
390
450
  return a.name.localeCompare(b.name);
391
451
  });
392
- for (const entry of entries) {
393
- if (DECK_TREE_EXCLUDE.has(entry.name))
394
- continue;
452
+ const visible = entries.filter((e) => !DECK_TREE_EXCLUDE.has(e.name));
453
+ for (const entry of visible.slice(0, DECK_TREE_PER_DIR)) {
395
454
  if (lines.length >= DECK_TREE_MAX_ENTRIES) {
396
455
  lines.push(`${prefix}...`);
397
456
  return;
@@ -402,6 +461,15 @@ function buildDeckTree(deckDir) {
402
461
  walk(path.join(dir, entry.name), prefix + " ", depth + 1);
403
462
  }
404
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
+ }
405
473
  };
406
474
  walk(deckDir, "", 0);
407
475
  return lines.join("\n");
@@ -477,8 +545,19 @@ function shellTouchedCandidates(command) {
477
545
  }
478
546
  return out;
479
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
+ }
480
559
  function normalizeTouchedPath(cwd, raw) {
481
- if (!raw || raw.includes("\n"))
560
+ if (!raw || raw.includes("\n") || !looksLikeTouchedPath(raw))
482
561
  return null;
483
562
  const abs = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
484
563
  const rel = path.relative(cwd, abs);
@@ -816,7 +895,10 @@ function runAgentCli(opts) {
816
895
  function persistTaskFile(tasksDir, task) {
817
896
  fs.writeFileSync(path.join(tasksDir, task.id, "task.json"), JSON.stringify(task, null, 2) + "\n");
818
897
  }
819
- // 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.
820
902
  function loadTasks(tasksDir) {
821
903
  const tasks = new Map();
822
904
  for (const entry of fs.existsSync(tasksDir) ? fs.readdirSync(tasksDir) : []) {
@@ -880,15 +962,78 @@ function resolveDeps(tasks, tokens) {
880
962
  }
881
963
  return [...new Set(resolved)];
882
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;
883
989
  function depsSummaryFor(tasks, task) {
884
990
  if (task.after.length === 0)
885
991
  return undefined;
886
992
  const lines = task.after
887
993
  .map((id) => tasks.get(id))
888
994
  .filter((dep) => !!dep)
889
- .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
+ });
890
1009
  return lines.join("\n") || undefined;
891
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
+ }
892
1037
  async function runTaskAgentIn(ctx, task) {
893
1038
  const dir = path.join(ctx.tasksDir, task.id);
894
1039
  const relDir = path.relative(ctx.deckDir, dir);
@@ -902,6 +1047,8 @@ async function runTaskAgentIn(ctx, task) {
902
1047
  depsSummary: ctx.depsSummary,
903
1048
  backend: ctx.backend,
904
1049
  deckTree: buildDeckTree(ctx.deckDir),
1050
+ quickReference: ctx.quickReference,
1051
+ siblings: ctx.siblings,
905
1052
  });
906
1053
  // No /goal wrapper: it makes a fresh evaluator re-check the WHOLE task
907
1054
  // prompt (including user-only "done when you reach wave 5"-style play
@@ -947,6 +1094,7 @@ async function runTaskAgentIn(ctx, task) {
947
1094
  }
948
1095
  };
949
1096
  for (let attempt = 1; attempt <= MAX_TASK_ATTEMPTS; attempt++) {
1097
+ await staggerTaskSpawn();
950
1098
  result = await runAgentCli({
951
1099
  cwd: ctx.deckDir,
952
1100
  command: invocation.command,
@@ -969,8 +1117,11 @@ async function runTaskAgentIn(ctx, task) {
969
1117
  return result;
970
1118
  if (!result.crashed)
971
1119
  return result;
972
- if (attempt < MAX_TASK_ATTEMPTS)
1120
+ if (attempt < MAX_TASK_ATTEMPTS) {
973
1121
  ctx.onRetry(attempt + 1);
1122
+ if (await waitBeforeTaskRetry(attempt, ctx.stopRequested, task.id))
1123
+ return result;
1124
+ }
974
1125
  }
975
1126
  result.error = `agent process kept dying (${MAX_TASK_ATTEMPTS} attempts): ${result.error ?? ""}`;
976
1127
  return result;
@@ -996,7 +1147,15 @@ function startTask(ctx, task) {
996
1147
  backend: ctx.backend(),
997
1148
  claudeModel: ctx.claudeModel(),
998
1149
  stopRequested: ctx.stopRequested,
1150
+ quickReference: ctx.quickReference,
999
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 })),
1000
1159
  onFeed: (entry) => ctx.onFeed(task, entry),
1001
1160
  onRetry: (attempt) => ctx.onRetry(task, attempt),
1002
1161
  onSignal: (signal) => {
@@ -1087,12 +1246,14 @@ function startTask(ctx, task) {
1087
1246
  ctx.rescheduleAll();
1088
1247
  });
1089
1248
  }
1090
- // Halt + remove an active task (castle-stop): a waiting one is cancelled and
1091
- // cleared off the board immediately; a running one gets its agent process
1092
- // killed and is cleared when it finalizes (the stopRequested path acks it).
1093
- // 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).
1094
1255
  function haltTask(task, children, stopRequested, touch) {
1095
- if (task.status === "waiting") {
1256
+ if (task.status === "waiting" || task.status === "blocked") {
1096
1257
  task.status = "interrupted";
1097
1258
  task.acknowledged = true;
1098
1259
  touch(task);
@@ -1125,12 +1286,6 @@ function createTaskStore(opts) {
1125
1286
  persistTaskFile(tasksDir, task);
1126
1287
  opts.onUpdate(task);
1127
1288
  }
1128
- function depsAreSettled(task) {
1129
- return task.after.every((id) => {
1130
- const dep = tasks.get(id);
1131
- return !dep || isTerminal(dep.status);
1132
- });
1133
- }
1134
1289
  function runningCount() {
1135
1290
  let n = 0;
1136
1291
  for (const t of tasks.values())
@@ -1139,8 +1294,20 @@ function createTaskStore(opts) {
1139
1294
  return n;
1140
1295
  }
1141
1296
  function maybeStart(task) {
1142
- if (task.status !== "waiting" || task.acknowledged || !depsAreSettled(task))
1297
+ if (task.status !== "waiting" || task.acknowledged)
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);
1143
1309
  return;
1310
+ }
1144
1311
  // Concurrency cap: at most MAX_CONCURRENT_TASKS agents run at once. Over-cap
1145
1312
  // tasks stay 'waiting' and are restarted -- earliest-created first -- by the
1146
1313
  // onFinished sweep below when a running task frees a slot.
@@ -1168,6 +1335,7 @@ function createTaskStore(opts) {
1168
1335
  children,
1169
1336
  tasks,
1170
1337
  stopRequested,
1338
+ quickReference: opts.quickReference,
1171
1339
  backend: opts.backend,
1172
1340
  claudeModel: opts.claudeModel,
1173
1341
  onStarted: opts.onStarted,
@@ -1243,13 +1411,16 @@ function createTaskStore(opts) {
1243
1411
  acknowledge(id, false);
1244
1412
  }
1245
1413
  // The router stops tasks by title or id (castle-stop fence), or "all" to
1246
- // stop everything still active. Waiting tasks are cancelled outright;
1247
- // running ones get their agent process killed and finalize as interrupted
1248
- // 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).
1249
1418
  function stop(tokens) {
1250
1419
  const ids = meansAll(tokens)
1251
1420
  ? [...tasks.values()]
1252
- .filter((t) => t.status === "running" || t.status === "waiting")
1421
+ .filter((t) => t.status === "running" ||
1422
+ t.status === "waiting" ||
1423
+ t.status === "blocked")
1253
1424
  .map((t) => t.id)
1254
1425
  : resolveDeps(tasks, tokens);
1255
1426
  for (const id of ids) {
@@ -1303,6 +1474,57 @@ function saveAttachments(attachmentsDir, messageId, images) {
1303
1474
  }
1304
1475
  return saved;
1305
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
+ }
1306
1528
  function asPromptTask(task) {
1307
1529
  return {
1308
1530
  id: task.id,
@@ -1311,6 +1533,8 @@ function asPromptTask(task) {
1311
1533
  progress: task.progress,
1312
1534
  notes: task.notes,
1313
1535
  files: task.files,
1536
+ error: task.status === "failed" ? firstErrorLine(task.resultSummary) : undefined,
1537
+ blockedBy: task.status === "blocked" ? task.blockedBy : undefined,
1314
1538
  };
1315
1539
  }
1316
1540
  function asClientTask(task) {
@@ -1399,6 +1623,140 @@ function makeAttachmentHandler(attachmentsDir) {
1399
1623
  return true;
1400
1624
  };
1401
1625
  }
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({
1660
+ deckLabel: ctx.deckLabel,
1661
+ quickReference: ctx.quickReference,
1662
+ deckTree: buildDeckTree(ctx.deckDir),
1663
+ messages: ctx.log.messages
1664
+ .filter((m) => m.role !== "log" &&
1665
+ m.id !== selfMessageId &&
1666
+ m.status !== "streaming")
1667
+ .map((m) => ({
1668
+ role: m.role,
1669
+ // Replace a prior turn's raw ```ask JSON with a readable question list
1670
+ // so the model doesn't re-echo the block verbatim.
1671
+ text: m.role === "assistant" ? humanizeAskBlocks(m.text) : m.text,
1672
+ interrupted: m.interrupted,
1673
+ })),
1674
+ // Only the live board -- match what the user sees. Hide tasks that are
1675
+ // BOTH acknowledged AND finished; an active (running/waiting) task always
1676
+ // shows even if somehow acked, so nothing can ever go invisible mid-work.
1677
+ tasks: ctx.taskStore
1678
+ .sorted()
1679
+ .filter((t) => !(t.acknowledged && isTerminal(t.status)))
1680
+ .map(asPromptTask),
1681
+ instruction,
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
+ }
1402
1760
  // One router turn: stream a reply message, then spawn the directives it
1403
1761
  // emitted (unless a newer user message superseded this turn).
1404
1762
  function runRouterTurnIn(ctx, instruction) {
@@ -1414,6 +1772,7 @@ function runRouterTurnIn(ctx, instruction) {
1414
1772
  ctx.broadcast({ type: "message-add", message });
1415
1773
  let raw = "";
1416
1774
  let visibleSent = 0;
1775
+ const midStream = { scannedUpTo: 0, spawnedTitles: new Set() };
1417
1776
  // Seed the activity line to "thinking" immediately -- covers the otherwise
1418
1777
  // silent spawn + first-token lull (fresh CLI process, prompt processing,
1419
1778
  // extended thinking) before any stream event arrives. The stream then
@@ -1422,28 +1781,7 @@ function runRouterTurnIn(ctx, instruction) {
1422
1781
  // block later re-emits "thinking".
1423
1782
  let lastActivity = "Thinking";
1424
1783
  ctx.broadcast({ type: "message-activity", id: message.id, activity: "Thinking" });
1425
- const prompt = buildRouterPrompt({
1426
- deckLabel: ctx.deckLabel,
1427
- quickReference: ctx.quickReference,
1428
- deckTree: buildDeckTree(ctx.deckDir),
1429
- messages: ctx.log.messages
1430
- .filter((m) => m.role !== "log" && m.id !== message.id && m.status !== "streaming")
1431
- .map((m) => ({
1432
- role: m.role,
1433
- // Replace a prior turn's raw ```ask JSON with a readable question list
1434
- // so the model doesn't re-echo the block verbatim.
1435
- text: m.role === "assistant" ? humanizeAskBlocks(m.text) : m.text,
1436
- interrupted: m.interrupted,
1437
- })),
1438
- // Only the live board -- match what the user sees. Hide tasks that are
1439
- // BOTH acknowledged AND finished; an active (running/waiting) task always
1440
- // shows even if somehow acked, so nothing can ever go invisible mid-work.
1441
- tasks: ctx.taskStore
1442
- .sorted()
1443
- .filter((t) => !(t.acknowledged && isTerminal(t.status)))
1444
- .map(asPromptTask),
1445
- instruction,
1446
- });
1784
+ const prompt = routerTurnPrompt(ctx, instruction, message.id);
1447
1785
  const backend = ctx.backend();
1448
1786
  const invocation = buildAgentInvocation(backend, "router", prompt, ctx.claudeModel());
1449
1787
  void runAgentCli({
@@ -1464,6 +1802,9 @@ function runRouterTurnIn(ctx, instruction) {
1464
1802
  message.text += slice;
1465
1803
  ctx.broadcast({ type: "message-delta", id: message.id, delta: slice });
1466
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);
1467
1808
  },
1468
1809
  onActivity: (activity) => {
1469
1810
  if (activity === lastActivity)
@@ -1474,6 +1815,9 @@ function runRouterTurnIn(ctx, instruction) {
1474
1815
  })
1475
1816
  .then((result) => {
1476
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;
1477
1821
  // The settle path must ALWAYS reach ctx.onSettled() (clears
1478
1822
  // routerRunning + flushes pendingSends). A throw here on Node v25 would
1479
1823
  // otherwise both freeze the composer and crash the serve, so the whole
@@ -1482,18 +1826,7 @@ function runRouterTurnIn(ctx, instruction) {
1482
1826
  try {
1483
1827
  const interrupted = epoch !== ctx.currentEpoch() && !result.ok;
1484
1828
  if (interrupted) {
1485
- // Keep whatever streamed; the continuation turn carries the draft.
1486
- message.status = "done";
1487
- message.interrupted = true;
1488
- ctx.log.persist();
1489
- ctx.broadcast({
1490
- type: "message-done",
1491
- id: message.id,
1492
- text: message.text,
1493
- status: message.status,
1494
- interrupted: true,
1495
- taskIds: [],
1496
- });
1829
+ settleInterruptedTurn(ctx, message);
1497
1830
  return;
1498
1831
  }
1499
1832
  const { cleaned, directives, checkoffs, stops } = extractDirectives(result.finalText);
@@ -1501,8 +1834,11 @@ function runRouterTurnIn(ctx, instruction) {
1501
1834
  ctx.taskStore.checkOff(checkoffs);
1502
1835
  if (result.ok && stops.length > 0)
1503
1836
  ctx.taskStore.stop(stops);
1504
- // Drop directives from stale turns, and any whose title matches a task
1505
- // 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.
1506
1842
  const stale = epoch !== ctx.currentEpoch();
1507
1843
  const inFlight = new Set(ctx.taskStore
1508
1844
  .sorted()
@@ -1510,12 +1846,34 @@ function runRouterTurnIn(ctx, instruction) {
1510
1846
  .map((t) => t.title.toLowerCase()));
1511
1847
  const toSpawn = stale
1512
1848
  ? []
1513
- : directives.filter((d) => !inFlight.has(d.title.toLowerCase()));
1514
- const taskIds = toSpawn.map((d) => ctx.taskStore.spawnFromDirective(d, message.id));
1515
- message.text = result.ok
1516
- ? cleaned
1517
- : `${cleaned ? cleaned + "\n\n" : ""}[router error: ${result.error ?? "unknown"}]`;
1518
- 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
+ }
1519
1877
  if (taskIds.length > 0)
1520
1878
  message.taskIds = taskIds;
1521
1879
  ctx.log.persist();
@@ -1525,6 +1883,7 @@ function runRouterTurnIn(ctx, instruction) {
1525
1883
  text: message.text,
1526
1884
  status: message.status,
1527
1885
  taskIds: message.taskIds ?? [],
1886
+ errorDetail: message.errorDetail,
1528
1887
  });
1529
1888
  }
1530
1889
  catch (err) {
@@ -1536,7 +1895,9 @@ function runRouterTurnIn(ctx, instruction) {
1536
1895
  console.error(`[router] turn callback threw: ${detail}`);
1537
1896
  try {
1538
1897
  message.status = "error";
1539
- 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;
1540
1901
  ctx.log.persist();
1541
1902
  ctx.broadcast({
1542
1903
  type: "message-done",
@@ -1544,6 +1905,7 @@ function runRouterTurnIn(ctx, instruction) {
1544
1905
  text: message.text,
1545
1906
  status: message.status,
1546
1907
  taskIds: message.taskIds ?? [],
1908
+ errorDetail: message.errorDetail,
1547
1909
  });
1548
1910
  }
1549
1911
  catch (inner) {
@@ -1551,7 +1913,7 @@ function runRouterTurnIn(ctx, instruction) {
1551
1913
  }
1552
1914
  }
1553
1915
  finally {
1554
- ctx.onSettled();
1916
+ ctx.onSettled(retryable);
1555
1917
  }
1556
1918
  })
1557
1919
  .catch((err) => {
@@ -1644,75 +2006,113 @@ function startChildRegistry(registryPath, groups) {
1644
2006
  }
1645
2007
  };
1646
2008
  }
1647
- // Mid-run send queue (mirrors djinn's AltManager.pendingSends): a user message
1648
- // sent while the router is mid-turn QUEUES instead of interrupting. It flushes
1649
- // into a single follow-up turn when the current turn settles. An explicit
1650
- // interrupt ("send now" / Stop) kills the run and flushes early. The epoch
1651
- // keeps a killed-but-racing run from spawning tasks. Lives in its own factory
1652
- // so createAgentServer stays within the max-lines budget; the queue-by-default
1653
- // semantics are unchanged.
1654
- function createRouterQueue(deps) {
1655
- const { deckDir, deckLabel, quickReference, agentDir, attachmentsDir, routerChildren, log, broadcast, taskStore, messages, settings, } = deps;
1656
- let userEpoch = 0;
1657
- let routerRunning = false;
1658
- // Durable mid-run sends, mirrored to pending-sends.json until drained.
1659
- const pendingSends = [];
1660
- const pendingPath = path.join(agentDir, "pending-sends.json");
1661
- let pendingInterruptedDraft = "";
1662
- // Persist every mutation so a restart never loses an unsent queued message.
1663
- function persistPending() {
1664
- fs.writeFileSync(pendingPath, JSON.stringify(pendingSends, null, 2) + "\n");
1665
- }
1666
- function queuedSnippets() {
1667
- return pendingSends.map((p) => p.text.trim()).filter(Boolean);
1668
- }
1669
- function broadcastRouterState() {
1670
- broadcast({
1671
- type: "router-state",
1672
- running: routerRunning,
1673
- queued: queuedSnippets(),
1674
- });
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
+ }
1675
2031
  }
1676
- function interruptRouterRuns() {
1677
- const drafts = messages
1678
- .filter((m) => m.role === "assistant" && m.status === "streaming")
1679
- .map((m) => m.text.trim())
1680
- .filter(Boolean);
1681
- for (const child of routerChildren) {
1682
- try {
1683
- child.kill("SIGKILL");
1684
- }
1685
- catch {
1686
- /* already gone */
1687
- }
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");
1688
2082
  }
1689
- return drafts.join("\n\n");
1690
- }
1691
- function runRouterTurn(instruction) {
1692
- runRouterTurnIn({
1693
- deckDir,
1694
- deckLabel,
1695
- quickReference,
1696
- agentDir,
1697
- children: routerChildren,
1698
- log,
1699
- broadcast,
1700
- taskStore,
1701
- currentEpoch: () => userEpoch,
1702
- backend: () => settings.router,
1703
- claudeModel: () => settings.claudeModel,
1704
- onSettled: onRouterSettled,
1705
- }, instruction);
1706
- }
1707
- // Drain queued sends into one follow-up turn; no-op while running or empty.
1708
- function maybeStartRouterTurn() {
1709
- if (routerRunning || pendingSends.length === 0)
1710
- return;
1711
- const drained = pendingSends.splice(0, pendingSends.length);
1712
- const texts = [];
1713
- const attachmentPaths = [];
1714
- for (const item of drained) {
1715
- // Enqueue assigned the durable id/attachments; drain commits them to log.
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) {
1716
2116
  const message = {
1717
2117
  id: item.id,
1718
2118
  role: "user",
@@ -1723,105 +2123,178 @@ function createRouterQueue(deps) {
1723
2123
  if (item.attachments.length > 0)
1724
2124
  message.attachments = item.attachments;
1725
2125
  log.add(message);
1726
- if (item.text.trim())
1727
- texts.push(item.text);
1728
- for (const name of item.attachments) {
1729
- attachmentPaths.push(path.join(".castle", "agent", "attachments", name));
1730
- }
1731
2126
  }
1732
- // The queue is now committed to messages.json; clear its durable mirror.
1733
- persistPending();
1734
- const draft = pendingInterruptedDraft;
1735
- pendingInterruptedDraft = "";
1736
- routerRunning = true;
1737
- broadcastRouterState();
1738
- runRouterTurn(userTurnInstruction({
1739
- messages: texts,
1740
- interruptedDraft: draft || undefined,
1741
- attachments: attachmentPaths,
1742
- }));
1743
- }
1744
- // The turn settled: clear the busy flag, broadcast it, then flush anything
1745
- // that queued mid-turn (a clean end and an interrupt take the same path).
1746
- function onRouterSettled() {
1747
- routerRunning = false;
1748
- broadcastRouterState();
1749
- maybeStartRouterTurn();
1750
- }
1751
- function handleUserMessage(text, images) {
1752
- // Mid-run: queue (don't interrupt). It shows as a queued row in the
1753
- // composer and flushes when the current turn settles. Idle: start now.
1754
- // Persist at enqueue (assign the final message id, save attachments to
1755
- // disk, mirror the queue to pending-sends.json) so a restart before the
1756
- // queue drains can recover the send instead of silently dropping it.
1757
- const id = nanoid(8);
1758
- const attachments = saveAttachments(attachmentsDir, id, images);
1759
- pendingSends.push({ id, text, attachments });
1760
- persistPending();
1761
- if (routerRunning)
1762
- broadcastRouterState();
1763
- else
1764
- maybeStartRouterTurn();
1765
- }
1766
- // "Send now" (a queued row) / Stop (empty composer): kill the running turn,
1767
- // capturing its partial draft so the follow-up turn continues it; the killed
1768
- // run's settle flushes the queue. When idle, just flush (covers a stray
1769
- // interrupt with messages already queued).
1770
- function interruptRouter() {
1771
- if (routerRunning) {
1772
- userEpoch += 1;
1773
- const draft = interruptRouterRuns();
1774
- // Only carry the partial draft forward when a queued message will consume
1775
- // it imminently ("Send now"). A bare Stop (empty composer) must not park
1776
- // the draft, or it leaks into the next unrelated message.
1777
- 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);
1778
2217
  }
1779
2218
  else {
1780
- maybeStartRouterTurn();
2219
+ broadcastQueueState(ctx);
1781
2220
  }
1782
2221
  }
1783
- function cancelQueued(index) {
1784
- if (!Number.isInteger(index) || index < 0 || index >= pendingSends.length)
1785
- return;
1786
- pendingSends.splice(index, 1);
1787
- persistPending();
1788
- broadcastRouterState();
1789
- }
1790
- // Restart recovery: reload the durable queue and re-enqueue only sends that
1791
- // never reached the message log. A send whose id is already in messages.json
1792
- // was committed by a prior drain (its turn ran, finished or not -- matching
1793
- // origin/main, a logged message is considered handled), so re-queueing it
1794
- // would double-deliver; we drop those. Survivors keep their original order
1795
- // and start a follow-up turn, so an interrupted serve resumes them exactly
1796
- // once instead of losing them.
1797
- function recoverPending() {
1798
- const stored = readJsonFile(pendingPath);
1799
- if (!Array.isArray(stored))
1800
- return;
1801
- const committed = new Set(messages.map((m) => m.id));
1802
- for (const item of stored) {
1803
- if (item &&
1804
- typeof item.id === "string" &&
1805
- typeof item.text === "string" &&
1806
- Array.isArray(item.attachments) &&
1807
- !committed.has(item.id)) {
1808
- pendingSends.push({
1809
- id: item.id,
1810
- text: item.text,
1811
- attachments: item.attachments.filter((a) => typeof a === "string"),
1812
- });
1813
- }
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);
1814
2247
  }
1815
- persistPending();
1816
- 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 : "";
1817
2252
  }
1818
- 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);
1819
2292
  return {
1820
- handleUserMessage,
1821
- interruptRouter,
1822
- cancelQueued,
1823
- isRunning: () => routerRunning,
1824
- 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),
1825
2298
  };
1826
2299
  }
1827
2300
  export function createAgentServer(opts) {
@@ -1870,6 +2343,7 @@ export function createAgentServer(opts) {
1870
2343
  deckLabel,
1871
2344
  tasksDir,
1872
2345
  children: taskChildren,
2346
+ quickReference,
1873
2347
  backend: () => settings.tasks,
1874
2348
  claudeModel: () => settings.claudeModel,
1875
2349
  // Task lifecycle stays on the board only -- log lines for it were spam.