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