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