castle-web-cli 0.4.75 → 0.4.76
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 +4 -0
- package/dist/agent-prompts.js +20 -8
- package/dist/agent.js +301 -28
- package/dist/castle-host/host.js +59 -5
- package/dist/commonInstructions.d.ts +1 -1
- package/dist/commonInstructions.js +4 -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 +27 -1
- package/dist/shell/assets/index-DNWEQd4R.js +141 -0
- package/dist/shell/assets/{index-WNbOHPBj.css → index-DuKq-Grp.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/PxArtEditor.jsx +237 -4
- package/kits/basic-2d/editors/SingleEditor.jsx +6 -51
- 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/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-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-prompts.d.ts
CHANGED
|
@@ -9,9 +9,12 @@ export interface PromptTask {
|
|
|
9
9
|
status: string;
|
|
10
10
|
progress: number;
|
|
11
11
|
notes: string;
|
|
12
|
+
files?: string[];
|
|
12
13
|
}
|
|
13
14
|
export declare function buildRouterPrompt(opts: {
|
|
14
15
|
deckLabel: string;
|
|
16
|
+
quickReference?: string;
|
|
17
|
+
deckTree?: string;
|
|
15
18
|
messages: PromptMessage[];
|
|
16
19
|
tasks: PromptTask[];
|
|
17
20
|
instruction: string;
|
|
@@ -30,5 +33,6 @@ export declare function buildTaskPrompt(opts: {
|
|
|
30
33
|
notesPath: string;
|
|
31
34
|
depsSummary?: string;
|
|
32
35
|
backend?: "cursor" | "claude";
|
|
36
|
+
deckTree?: string;
|
|
33
37
|
}): string;
|
|
34
38
|
export declare const CLAUDE_TASK_SYSTEM_REMINDER = "Castle background task agent: work autonomously to completion -- never pause to ask questions or wait for confirmation; finish the task end-to-end, then stop. Prefer the quickest viable change that fully does the job.";
|
package/dist/agent-prompts.js
CHANGED
|
@@ -8,11 +8,11 @@
|
|
|
8
8
|
const TRANSCRIPT_LIMIT = 40;
|
|
9
9
|
const ROUTER_RULES = `You are Castle's create assistant: the fast conversational router for a game-making session. The deck (game project) lives in the current directory and runs live in a pane right next to this chat.
|
|
10
10
|
|
|
11
|
-
What a deck is: a normal web project served by vite -- index.html plus plain JS/JSX modules, with real npm dependencies (more can be installed), the castle-web-sdk package, and usually a kit framework whose engine, behaviors, scenes, editors, and drawings are ordinary files in this directory. The web platform is fully available (DOM, canvas, npm libraries like react, three, etc.). The deck's CLAUDE.md / AGENTS.md
|
|
11
|
+
What a deck is: a normal web project served by vite -- index.html plus plain JS/JSX modules, with real npm dependencies (more can be installed), the castle-web-sdk package, and usually a kit framework whose engine, behaviors, scenes, editors, and drawings are ordinary files in this directory. The web platform is fully available (DOM, canvas, npm libraries like react, three, etc.). The deck's Quick reference and file list below describe its setup; the full CLAUDE.md / AGENTS.md has deeper detail. NEVER claim something is impossible or unsupported on the platform without checking that context (or, for specifics it doesn't cover, the deck's files) first.
|
|
12
12
|
|
|
13
13
|
Hard rules:
|
|
14
14
|
- You NEVER edit files or run state-changing commands. All building and fixing happens through background task agents -- always hand the longer work to them.
|
|
15
|
-
- You are the fast lane: get to your final reply as quickly as possible. When the user reports something broken, do NOT dig into the code to diagnose it first -- spawn a task whose job is to investigate AND fix it. Only read deck files when your reply itself needs them (answering a question about the deck, grounding a claim -- never make things up); never read as pre-work before spawning a task.
|
|
15
|
+
- You are the fast lane: get to your final reply as quickly as possible. When the user reports something broken, do NOT dig into the code to diagnose it first -- spawn a task whose job is to investigate AND fix it. Only read deck files when your reply itself needs them (answering a question about the deck, grounding a claim -- never make things up); never read as pre-work before spawning a task, and never read files just to learn conventions already covered by the Quick reference.
|
|
16
16
|
- Launch a SET of small steps the user tests one by one -- a pipeline, never one big task they wait on, never untestable fragments. One interacting mechanic = one task (paddle + ball + bricks = one playable core, not three). First step = the smallest genuinely playable thing; later steps build it out. Match breadth to ambition ("basic" = a few steps; "go wild" = many). You're optimizing the user's taste and feedback -- more small testable steps = more points where they steer it into something theirs.
|
|
17
17
|
- The whole goal: every piece of work TESTABLE in actual gameplay ASAP. Start every task as early as possible and run them in PARALLEL. Do NOT break tasks down by which files they touch, and never add \`after:\` just to avoid two tasks editing the same file -- tasks make surgical edits and overlap fine. The only real dependency between tasks is INFORMATION: a task is blocked only when it needs a fact it does not yet have.
|
|
18
18
|
- Dissolve those information-dependencies with a CONTRACT decided up front, instead of serializing tasks. Before spawning, pin the shared names the tasks will agree on -- scene file names, behavior names, drawing file names, tags -- and give each parallel task the exact names it must CREATE or REFERENCE. Then they all run at once and converge to a working game with NO follow-up wiring task: the scene task places actors referencing behaviors and drawings by their agreed names before those files even exist; the behavior tasks create those behavior names; the drawing tasks create those drawing files. (Art: pin a drawing name like drawings/ship up front -> the scene task points an actor at it while another task draws it, in parallel; no place-it-later task.) Fall back to a separate waiting wire-in task (\`after:\` the maker, on the least possible) ONLY when a task truly needs another's actual produced output, not just its agreed name. Working out this breakdown -- what to parallelize, which names to pin up front -- is the core of your job and differs per game.
|
|
@@ -47,6 +47,7 @@ comma-separated active-task titles or ids, or \`all\`
|
|
|
47
47
|
- Never claim the board is cleared without actually emitting the fence.
|
|
48
48
|
- Tasks are one-and-done -- when the user gives feedback on a finished task, spawn a new fix task (and \`castle-done\` the old row) rather than reopening it.
|
|
49
49
|
- Task agents are capable coding agents working in this same deck directory, but they know nothing about this conversation beyond your prompt.
|
|
50
|
+
- Board rows may include \`files:\` for finished work. Use those touched-file lists to aim follow-up/fix tasks and to keep shared names consistent without rereading the deck.
|
|
50
51
|
|
|
51
52
|
Asking with options (the \`\`\`ask block). When you need the user to settle a few choices at once, emit ONE fenced block tagged \`ask\` containing JSON -- it renders inline in the chat as grouped options they tap and submit together (far better than stacking questions they can only half-answer). Reach for it to pin a direction fast when their ask is vague ("make me a game" -> ask what kind), NOT to interrogate. Options only, no free text.
|
|
52
53
|
|
|
@@ -86,22 +87,29 @@ function renderTasks(tasks) {
|
|
|
86
87
|
return tasks
|
|
87
88
|
.map((t) => {
|
|
88
89
|
const notes = t.notes.trim() ? ` -- notes: ${t.notes.trim()}` : "";
|
|
89
|
-
|
|
90
|
+
const files = t.files && t.files.length > 0 ? ` -- files: ${t.files.join(", ")}` : "";
|
|
91
|
+
return `- [${t.status} ${t.progress}%] ${t.title} (${t.id})${notes}${files}`;
|
|
90
92
|
})
|
|
91
93
|
.join("\n");
|
|
92
94
|
}
|
|
93
95
|
export function buildRouterPrompt(opts) {
|
|
96
|
+
const quickReference = opts.quickReference?.trim()
|
|
97
|
+
? `\n\n== how this deck works ==\n${opts.quickReference.trim()}`
|
|
98
|
+
: "";
|
|
99
|
+
const deckFiles = opts.deckTree?.trim()
|
|
100
|
+
? `\n\n== deck files ==\n${opts.deckTree.trim()}`
|
|
101
|
+
: "";
|
|
94
102
|
return `${ROUTER_RULES}
|
|
95
103
|
|
|
96
104
|
== deck ==
|
|
97
|
-
${opts.deckLabel}
|
|
98
|
-
|
|
99
|
-
== background tasks ==
|
|
100
|
-
${renderTasks(opts.tasks)}
|
|
105
|
+
${opts.deckLabel}${quickReference}${deckFiles}
|
|
101
106
|
|
|
102
107
|
== conversation so far ==
|
|
103
108
|
${renderTranscript(opts.messages)}
|
|
104
109
|
|
|
110
|
+
== background tasks ==
|
|
111
|
+
${renderTasks(opts.tasks)}
|
|
112
|
+
|
|
105
113
|
== now ==
|
|
106
114
|
${opts.instruction}
|
|
107
115
|
|
|
@@ -130,6 +138,9 @@ export function buildTaskPrompt(opts) {
|
|
|
130
138
|
const deps = opts.depsSummary
|
|
131
139
|
? `\n\nThis task waited on earlier tasks:\n${opts.depsSummary}\n`
|
|
132
140
|
: "";
|
|
141
|
+
const layout = opts.deckTree?.trim()
|
|
142
|
+
? `\n\nDeck files (snapshot at task start; parallel sibling tasks may create files named in your prompt that are absent here):\n\n${opts.deckTree.trim()}\n`
|
|
143
|
+
: "";
|
|
133
144
|
// Claude-only: collapsing the wrap-up (progress 90 + restart + notes) into
|
|
134
145
|
// one shell call reliably saves 1-2 serial ~7s turns there. Cursor's
|
|
135
146
|
// composer sometimes reacts to the same rule with MORE calls, so it stays
|
|
@@ -140,13 +151,14 @@ export function buildTaskPrompt(opts) {
|
|
|
140
151
|
const wrapUp = opts.backend === "claude"
|
|
141
152
|
? `\n- Wrap up in ONE tool call, not several: once your last file edit is done, combine the 90-progress write, the final \`npm run restart\`, and writing the notes file into a single shell command (\`;\`-separated so the notes land even if the restart hiccups). Then stop -- no extra turns after it.`
|
|
142
153
|
: "";
|
|
143
|
-
return `You are a background build agent for the Castle deck "${opts.deckLabel}" (current directory). A separate conversation agent dispatched you with one task. Follow the deck's CLAUDE.md / AGENTS.md conventions, and reload the served deck after changes (\`npm run restart\`)
|
|
154
|
+
return `You are a background build agent for the Castle deck "${opts.deckLabel}" (current directory). A separate conversation agent dispatched you with one task. Follow the deck's CLAUDE.md / AGENTS.md conventions, and reload the served deck after changes (\`npm run restart\`).${layout}
|
|
144
155
|
|
|
145
156
|
Your task (id ${opts.taskId}): ${opts.title}
|
|
146
157
|
|
|
147
158
|
${opts.prompt}${deps}
|
|
148
159
|
|
|
149
160
|
Operating rules:
|
|
161
|
+
- The file snapshot above is for orientation. Prefer going straight to the files this task touches, but if this is an investigation or debugging task, survey what you need to understand the failure.
|
|
150
162
|
- Speed is of the essence -- go for the quickest viable, testable change, unless this prompt explicitly tells you to take longer and dive deep. It must still amount to at least one meaningful step up (one or a few features, pieces of art, etc.), never a token gesture.
|
|
151
163
|
- The USER is the verifier -- the whole tasks system exists so the user playtests every change themselves. Your first priority is to finish as soon as possible with the change genuinely in place and reachable in the running deck, so the user can test it right away. Do NOT run verification (screenshots especially) unless you are really sure it will catch something a re-read of your own change cannot -- and even then at most one cheap check, never a retry loop. Time spent verifying is time the user is left waiting.
|
|
152
164
|
- The moment implementation is complete and you switch to verifying, write 90 to the progress file -- verification time must not read as stalled progress.
|
package/dist/agent.js
CHANGED
|
@@ -277,6 +277,26 @@ function claudeToolFeedLabel(name, input) {
|
|
|
277
277
|
}
|
|
278
278
|
return null;
|
|
279
279
|
}
|
|
280
|
+
// Coarse activity label for a claude tool from its NAME alone (available at
|
|
281
|
+
// content_block_start, before the input JSON streams in). Router-only fallback
|
|
282
|
+
// so the streaming reply's activity line reflects EVERY tool -- including the
|
|
283
|
+
// commands/searches claudeToolFeedLabel intentionally drops from task feeds.
|
|
284
|
+
// Mirrors the cursor toolActivityLabel verbs so the two backends read alike.
|
|
285
|
+
function genericClaudeToolLabel(name) {
|
|
286
|
+
const kind = name.toLowerCase();
|
|
287
|
+
if (kind.startsWith("read") || kind.startsWith("notebookread"))
|
|
288
|
+
return "Reading the deck";
|
|
289
|
+
if (["edit", "write", "notebook", "multiedit"].some((p) => kind.startsWith(p)))
|
|
290
|
+
return "Editing files";
|
|
291
|
+
if (kind.startsWith("bash") || kind.includes("terminal"))
|
|
292
|
+
return "Running a command";
|
|
293
|
+
if (["grep", "glob", "ls"].some((p) => kind.startsWith(p)) ||
|
|
294
|
+
kind.includes("search"))
|
|
295
|
+
return "Searching the deck";
|
|
296
|
+
if (kind.startsWith("web"))
|
|
297
|
+
return "Searching the web";
|
|
298
|
+
return "Working";
|
|
299
|
+
}
|
|
280
300
|
// Human-readable label for a tool_call event, e.g. readToolCall -> "reading
|
|
281
301
|
// the deck". Shown as the streaming message's activity line.
|
|
282
302
|
function toolActivityLabel(ev) {
|
|
@@ -286,13 +306,13 @@ function toolActivityLabel(ev) {
|
|
|
286
306
|
: undefined;
|
|
287
307
|
const kind = (key ?? "").slice(0, -"ToolCall".length).toLowerCase();
|
|
288
308
|
if (["read", "glob", "grep", "ls", "list"].some((p) => kind.startsWith(p))) {
|
|
289
|
-
return "
|
|
309
|
+
return "Reading the deck";
|
|
290
310
|
}
|
|
291
311
|
if (["write", "edit", "delete", "mv"].some((p) => kind.startsWith(p)))
|
|
292
|
-
return "
|
|
312
|
+
return "Editing files";
|
|
293
313
|
if (["shell", "bash", "terminal"].some((p) => kind.startsWith(p)))
|
|
294
|
-
return "
|
|
295
|
-
return "
|
|
314
|
+
return "Running a command";
|
|
315
|
+
return "Working";
|
|
296
316
|
}
|
|
297
317
|
// Castle's agent CLI keys, delivered to the sandbox as a file
|
|
298
318
|
// (~/.castle/keys.json) rather than sandbox-wide env -- so an ambient key can't
|
|
@@ -344,17 +364,228 @@ function envForAgentSpawn(backend) {
|
|
|
344
364
|
}
|
|
345
365
|
return env;
|
|
346
366
|
}
|
|
367
|
+
const DECK_TREE_EXCLUDE = new Set([
|
|
368
|
+
"node_modules",
|
|
369
|
+
".castle",
|
|
370
|
+
".git",
|
|
371
|
+
"dist",
|
|
372
|
+
".DS_Store",
|
|
373
|
+
]);
|
|
374
|
+
const DECK_TREE_MAX_ENTRIES = 200;
|
|
375
|
+
// Shallow orientation snapshot for router/task prompts. Best-effort by design:
|
|
376
|
+
// filesystem hiccups should cost context, not fail an agent turn.
|
|
377
|
+
function buildDeckTree(deckDir) {
|
|
378
|
+
const lines = [];
|
|
379
|
+
const walk = (dir, prefix, depth) => {
|
|
380
|
+
let entries;
|
|
381
|
+
try {
|
|
382
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
entries.sort((a, b) => {
|
|
388
|
+
if (a.isDirectory() !== b.isDirectory())
|
|
389
|
+
return a.isDirectory() ? -1 : 1;
|
|
390
|
+
return a.name.localeCompare(b.name);
|
|
391
|
+
});
|
|
392
|
+
for (const entry of entries) {
|
|
393
|
+
if (DECK_TREE_EXCLUDE.has(entry.name))
|
|
394
|
+
continue;
|
|
395
|
+
if (lines.length >= DECK_TREE_MAX_ENTRIES) {
|
|
396
|
+
lines.push(`${prefix}...`);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const isDir = entry.isDirectory();
|
|
400
|
+
lines.push(`${prefix}${entry.name}${isDir ? "/" : ""}`);
|
|
401
|
+
if (isDir && depth < 1) {
|
|
402
|
+
walk(path.join(dir, entry.name), prefix + " ", depth + 1);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
walk(deckDir, "", 0);
|
|
407
|
+
return lines.join("\n");
|
|
408
|
+
}
|
|
409
|
+
function readQuickReference(deckDir) {
|
|
410
|
+
for (const name of ["CLAUDE.md", "AGENTS.md"]) {
|
|
411
|
+
let raw = "";
|
|
412
|
+
try {
|
|
413
|
+
raw = fs.readFileSync(path.join(deckDir, name), "utf8");
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const heading = /^## Quick reference\s*$/im.exec(raw);
|
|
419
|
+
if (!heading || heading.index === undefined)
|
|
420
|
+
continue;
|
|
421
|
+
const bodyStart = raw.indexOf("\n", heading.index);
|
|
422
|
+
if (bodyStart < 0)
|
|
423
|
+
return "";
|
|
424
|
+
const rest = raw.slice(bodyStart + 1);
|
|
425
|
+
const nextHeading = /^##\s+/m.exec(rest);
|
|
426
|
+
return (nextHeading ? rest.slice(0, nextHeading.index) : rest).trim();
|
|
427
|
+
}
|
|
428
|
+
return "";
|
|
429
|
+
}
|
|
430
|
+
const TOUCHED_FILE_LIMIT = 10;
|
|
431
|
+
function collectStrings(value, out) {
|
|
432
|
+
if (typeof value === "string") {
|
|
433
|
+
out.push(value);
|
|
434
|
+
}
|
|
435
|
+
else if (Array.isArray(value)) {
|
|
436
|
+
for (const item of value)
|
|
437
|
+
collectStrings(item, out);
|
|
438
|
+
}
|
|
439
|
+
else if (value && typeof value === "object") {
|
|
440
|
+
for (const item of Object.values(value)) {
|
|
441
|
+
collectStrings(item, out);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
function toolWritesFiles(name) {
|
|
446
|
+
const kind = name.toLowerCase();
|
|
447
|
+
return ["edit", "write", "notebookedit", "multiedit", "delete"].some((p) => kind.startsWith(p));
|
|
448
|
+
}
|
|
449
|
+
function toolRunsShell(name) {
|
|
450
|
+
const kind = name.toLowerCase();
|
|
451
|
+
return (kind.startsWith("bash") ||
|
|
452
|
+
kind.startsWith("shell") ||
|
|
453
|
+
kind.includes("terminal"));
|
|
454
|
+
}
|
|
455
|
+
function drawingPathForDrawArg(raw) {
|
|
456
|
+
const name = raw.replace(/^['"]|['"]$/g, "").trim();
|
|
457
|
+
if (!name || name.startsWith("-") || name.includes("\n"))
|
|
458
|
+
return null;
|
|
459
|
+
if (name.startsWith("drawings/")) {
|
|
460
|
+
return name.endsWith(".pxart") ? name : `${name}.pxart`;
|
|
461
|
+
}
|
|
462
|
+
return `drawings/${name.endsWith(".pxart") ? name : `${name}.pxart`}`;
|
|
463
|
+
}
|
|
464
|
+
function shellTouchedCandidates(command) {
|
|
465
|
+
const out = [];
|
|
466
|
+
const redirectRe = /(?:^|[\s;|])(?:\d*)>>?\s*(?!&)(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))/g;
|
|
467
|
+
for (const match of command.matchAll(redirectRe)) {
|
|
468
|
+
const target = match[1] ?? match[2] ?? match[3];
|
|
469
|
+
if (target)
|
|
470
|
+
out.push(target);
|
|
471
|
+
}
|
|
472
|
+
const drawRe = /npm\s+run\s+draw\s+--\s+([^\s;&|]+)/g;
|
|
473
|
+
for (const match of command.matchAll(drawRe)) {
|
|
474
|
+
const drawing = drawingPathForDrawArg(match[1] ?? "");
|
|
475
|
+
if (drawing)
|
|
476
|
+
out.push(drawing);
|
|
477
|
+
}
|
|
478
|
+
return out;
|
|
479
|
+
}
|
|
480
|
+
function normalizeTouchedPath(cwd, raw) {
|
|
481
|
+
if (!raw || raw.includes("\n"))
|
|
482
|
+
return null;
|
|
483
|
+
const abs = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
|
|
484
|
+
const rel = path.relative(cwd, abs);
|
|
485
|
+
if (!rel || rel.startsWith("..") || path.isAbsolute(rel))
|
|
486
|
+
return null;
|
|
487
|
+
const normalized = rel.split(path.sep).join("/");
|
|
488
|
+
if (normalized.startsWith(".castle/") || PROGRESS_FILE_RE.test(normalized)) {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
return normalized;
|
|
492
|
+
}
|
|
493
|
+
function addTouchedFiles(files, cwd, toolName, input) {
|
|
494
|
+
const candidates = [];
|
|
495
|
+
if (toolWritesFiles(toolName)) {
|
|
496
|
+
collectStrings([
|
|
497
|
+
input.file_path,
|
|
498
|
+
input.path,
|
|
499
|
+
input.notebook_path,
|
|
500
|
+
input.old_path,
|
|
501
|
+
input.new_path,
|
|
502
|
+
], candidates);
|
|
503
|
+
}
|
|
504
|
+
else if (toolRunsShell(toolName) && typeof input.command === "string") {
|
|
505
|
+
candidates.push(...shellTouchedCandidates(input.command));
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
for (const candidate of candidates) {
|
|
511
|
+
const normalized = normalizeTouchedPath(cwd, candidate);
|
|
512
|
+
if (normalized)
|
|
513
|
+
files.add(normalized);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
function cursorToolNameAndInput(ev) {
|
|
517
|
+
const call = ev.tool_call;
|
|
518
|
+
const key = call ? Object.keys(call).find((k) => k.endsWith("ToolCall")) : undefined;
|
|
519
|
+
if (!call || !key)
|
|
520
|
+
return null;
|
|
521
|
+
const input = call[key];
|
|
522
|
+
const args = input && typeof input === "object"
|
|
523
|
+
? input.args
|
|
524
|
+
: undefined;
|
|
525
|
+
return {
|
|
526
|
+
name: key.slice(0, -"ToolCall".length),
|
|
527
|
+
input: args && typeof args === "object"
|
|
528
|
+
? args
|
|
529
|
+
: input && typeof input === "object"
|
|
530
|
+
? input
|
|
531
|
+
: {},
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
function touchedFileList(files) {
|
|
535
|
+
const sorted = [...files].sort();
|
|
536
|
+
if (sorted.length <= TOUCHED_FILE_LIMIT)
|
|
537
|
+
return sorted;
|
|
538
|
+
return [
|
|
539
|
+
...sorted.slice(0, TOUCHED_FILE_LIMIT),
|
|
540
|
+
`+${sorted.length - TOUCHED_FILE_LIMIT} more`,
|
|
541
|
+
];
|
|
542
|
+
}
|
|
347
543
|
function createAgentStreamState() {
|
|
348
544
|
return {
|
|
349
545
|
accumulated: "",
|
|
350
546
|
finalText: "",
|
|
351
547
|
resultIsError: false,
|
|
548
|
+
usage: undefined,
|
|
549
|
+
filesTouched: new Set(),
|
|
352
550
|
sawResult: false,
|
|
353
551
|
segmentText: "",
|
|
354
552
|
needsGap: false,
|
|
355
553
|
pendingTools: new Map(),
|
|
356
554
|
};
|
|
357
555
|
}
|
|
556
|
+
function parseCliUsage(raw) {
|
|
557
|
+
if (!raw || typeof raw !== "object")
|
|
558
|
+
return undefined;
|
|
559
|
+
const src = raw;
|
|
560
|
+
const usage = {};
|
|
561
|
+
for (const key of [
|
|
562
|
+
"input_tokens",
|
|
563
|
+
"output_tokens",
|
|
564
|
+
"cache_creation_input_tokens",
|
|
565
|
+
"cache_read_input_tokens",
|
|
566
|
+
]) {
|
|
567
|
+
const value = src[key];
|
|
568
|
+
if (typeof value === "number")
|
|
569
|
+
usage[key] = value;
|
|
570
|
+
}
|
|
571
|
+
return Object.keys(usage).length > 0 ? usage : undefined;
|
|
572
|
+
}
|
|
573
|
+
function formatTokenCount(value) {
|
|
574
|
+
if (typeof value !== "number")
|
|
575
|
+
return "?";
|
|
576
|
+
if (value >= 1000)
|
|
577
|
+
return `${(value / 1000).toFixed(1)}k`;
|
|
578
|
+
return String(value);
|
|
579
|
+
}
|
|
580
|
+
function logRouterUsage(backend, usage) {
|
|
581
|
+
if (!usage)
|
|
582
|
+
return;
|
|
583
|
+
const input = formatTokenCount(usage.input_tokens);
|
|
584
|
+
const read = formatTokenCount(usage.cache_read_input_tokens);
|
|
585
|
+
const created = formatTokenCount(usage.cache_creation_input_tokens);
|
|
586
|
+
const output = formatTokenCount(usage.output_tokens);
|
|
587
|
+
console.error(`[agent usage] router ${backend}: input=${input} cache_read=${read} cache_created=${created} output=${output}`);
|
|
588
|
+
}
|
|
358
589
|
// Build the per-run stdout event handler over a shared mutable parser state.
|
|
359
590
|
// Splitting the cursor + claude stream decoding out of runAgentCli keeps each
|
|
360
591
|
// within the max-lines budget; behavior is identical (same delta/activity/
|
|
@@ -388,15 +619,22 @@ function makeAgentEventHandler(opts, state) {
|
|
|
388
619
|
if (e?.type === "content_block_start") {
|
|
389
620
|
if (e.content_block?.type === "tool_use") {
|
|
390
621
|
state.needsGap = true;
|
|
391
|
-
|
|
392
|
-
//
|
|
393
|
-
state
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
622
|
+
const name = String(e.content_block.name ?? "");
|
|
623
|
+
// Router: show a coarse label NOW (name is known) so the activity
|
|
624
|
+
// line isn't stuck on the prior state while the tool runs; the
|
|
625
|
+
// concrete label (with the file) refines it at content_block_stop.
|
|
626
|
+
if (opts.labelUnknownTools)
|
|
627
|
+
opts.onActivity?.(genericClaudeToolLabel(name));
|
|
628
|
+
// Hold the concrete label until content_block_stop, once the input
|
|
629
|
+
// (file / command) has streamed in, so we can name it concretely.
|
|
630
|
+
state.pendingTools.set(e.index ?? -1, { name, buf: "" });
|
|
397
631
|
}
|
|
398
632
|
else if (e.content_block?.type === "thinking") {
|
|
399
633
|
state.needsGap = true;
|
|
634
|
+
// Surface extended thinking as the activity line (mirrors the cursor
|
|
635
|
+
// path's `thinking` signal). The next text delta clears it via
|
|
636
|
+
// emitDelta's onActivity(null); a tool block relabels it.
|
|
637
|
+
opts.onActivity?.("Thinking");
|
|
400
638
|
}
|
|
401
639
|
}
|
|
402
640
|
else if (e?.type === "content_block_delta") {
|
|
@@ -430,6 +668,7 @@ function makeAgentEventHandler(opts, state) {
|
|
|
430
668
|
catch {
|
|
431
669
|
/* input JSON arrived partial -- fall back to a generic label */
|
|
432
670
|
}
|
|
671
|
+
addTouchedFiles(state.filesTouched, opts.cwd, pending.name, input);
|
|
433
672
|
const label = claudeToolFeedLabel(pending.name, input);
|
|
434
673
|
if (label)
|
|
435
674
|
opts.onActivity?.(label);
|
|
@@ -441,6 +680,7 @@ function makeAgentEventHandler(opts, state) {
|
|
|
441
680
|
state.finalText =
|
|
442
681
|
typeof ev.result === "string" ? ev.result : state.accumulated;
|
|
443
682
|
state.resultIsError = ev.is_error === true;
|
|
683
|
+
state.usage = parseCliUsage(ev.usage);
|
|
444
684
|
}
|
|
445
685
|
};
|
|
446
686
|
return (ev) => {
|
|
@@ -463,19 +703,23 @@ function makeAgentEventHandler(opts, state) {
|
|
|
463
703
|
else if (ev.type === "tool_call") {
|
|
464
704
|
state.segmentText = "";
|
|
465
705
|
state.needsGap = true;
|
|
706
|
+
const tool = cursorToolNameAndInput(ev);
|
|
707
|
+
if (tool)
|
|
708
|
+
addTouchedFiles(state.filesTouched, opts.cwd, tool.name, tool.input);
|
|
466
709
|
if (ev.subtype === "started")
|
|
467
710
|
opts.onActivity?.(toolActivityLabel(ev));
|
|
468
711
|
}
|
|
469
712
|
else if (ev.type === "thinking") {
|
|
470
713
|
state.segmentText = "";
|
|
471
714
|
state.needsGap = true;
|
|
472
|
-
opts.onActivity?.("
|
|
715
|
+
opts.onActivity?.("Thinking");
|
|
473
716
|
}
|
|
474
717
|
else if (ev.type === "result") {
|
|
475
718
|
state.sawResult = true;
|
|
476
719
|
state.finalText =
|
|
477
720
|
typeof ev.result === "string" ? ev.result : state.accumulated;
|
|
478
721
|
state.resultIsError = ev.is_error === true;
|
|
722
|
+
state.usage = parseCliUsage(ev.usage);
|
|
479
723
|
}
|
|
480
724
|
};
|
|
481
725
|
}
|
|
@@ -520,6 +764,8 @@ function runAgentCli(opts) {
|
|
|
520
764
|
ok: false,
|
|
521
765
|
finalText: state.finalText || state.accumulated,
|
|
522
766
|
error: "agent run timed out",
|
|
767
|
+
usage: state.usage,
|
|
768
|
+
filesTouched: touchedFileList(state.filesTouched),
|
|
523
769
|
});
|
|
524
770
|
}, opts.timeoutMs);
|
|
525
771
|
const handleEvent = makeAgentEventHandler(opts, state);
|
|
@@ -556,6 +802,8 @@ function runAgentCli(opts) {
|
|
|
556
802
|
settle({
|
|
557
803
|
ok,
|
|
558
804
|
finalText: state.finalText || state.accumulated,
|
|
805
|
+
usage: state.usage,
|
|
806
|
+
filesTouched: touchedFileList(state.filesTouched),
|
|
559
807
|
crashed: !state.sawResult,
|
|
560
808
|
error: ok
|
|
561
809
|
? undefined
|
|
@@ -653,6 +901,7 @@ async function runTaskAgentIn(ctx, task) {
|
|
|
653
901
|
notesPath: path.join(relDir, "notes.md"),
|
|
654
902
|
depsSummary: ctx.depsSummary,
|
|
655
903
|
backend: ctx.backend,
|
|
904
|
+
deckTree: buildDeckTree(ctx.deckDir),
|
|
656
905
|
});
|
|
657
906
|
// No /goal wrapper: it makes a fresh evaluator re-check the WHOLE task
|
|
658
907
|
// prompt (including user-only "done when you reach wave 5"-style play
|
|
@@ -787,6 +1036,7 @@ function startTask(ctx, task) {
|
|
|
787
1036
|
task.acknowledged = true;
|
|
788
1037
|
if (result.ok && !wasStopped)
|
|
789
1038
|
task.progress = 100;
|
|
1039
|
+
task.files = result.filesTouched ?? [];
|
|
790
1040
|
task.finishedAt = nowIso();
|
|
791
1041
|
task.resultSummary = wasStopped
|
|
792
1042
|
? "stopped by the router"
|
|
@@ -1060,6 +1310,24 @@ function asPromptTask(task) {
|
|
|
1060
1310
|
status: task.rejected ? "rejected by user" : task.status,
|
|
1061
1311
|
progress: task.progress,
|
|
1062
1312
|
notes: task.notes,
|
|
1313
|
+
files: task.files,
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
function asClientTask(task) {
|
|
1317
|
+
return {
|
|
1318
|
+
id: task.id,
|
|
1319
|
+
title: task.title,
|
|
1320
|
+
status: task.status,
|
|
1321
|
+
progress: task.progress,
|
|
1322
|
+
notes: task.notes,
|
|
1323
|
+
createdAt: task.createdAt,
|
|
1324
|
+
startedAt: task.startedAt,
|
|
1325
|
+
finishedAt: task.finishedAt,
|
|
1326
|
+
resultSummary: task.resultSummary,
|
|
1327
|
+
avatar: task.avatar,
|
|
1328
|
+
phase: task.phase,
|
|
1329
|
+
acknowledged: task.acknowledged,
|
|
1330
|
+
rejected: task.rejected,
|
|
1063
1331
|
};
|
|
1064
1332
|
}
|
|
1065
1333
|
function createTaskFeeds(broadcast) {
|
|
@@ -1146,9 +1414,18 @@ function runRouterTurnIn(ctx, instruction) {
|
|
|
1146
1414
|
ctx.broadcast({ type: "message-add", message });
|
|
1147
1415
|
let raw = "";
|
|
1148
1416
|
let visibleSent = 0;
|
|
1149
|
-
|
|
1417
|
+
// Seed the activity line to "thinking" immediately -- covers the otherwise
|
|
1418
|
+
// silent spawn + first-token lull (fresh CLI process, prompt processing,
|
|
1419
|
+
// extended thinking) before any stream event arrives. The stream then
|
|
1420
|
+
// overrides it: a tool relabels it, the first text delta clears it. Seeding
|
|
1421
|
+
// lastActivity too dedups the redundant broadcast when the claude thinking
|
|
1422
|
+
// block later re-emits "thinking".
|
|
1423
|
+
let lastActivity = "Thinking";
|
|
1424
|
+
ctx.broadcast({ type: "message-activity", id: message.id, activity: "Thinking" });
|
|
1150
1425
|
const prompt = buildRouterPrompt({
|
|
1151
1426
|
deckLabel: ctx.deckLabel,
|
|
1427
|
+
quickReference: ctx.quickReference,
|
|
1428
|
+
deckTree: buildDeckTree(ctx.deckDir),
|
|
1152
1429
|
messages: ctx.log.messages
|
|
1153
1430
|
.filter((m) => m.role !== "log" && m.id !== message.id && m.status !== "streaming")
|
|
1154
1431
|
.map((m) => ({
|
|
@@ -1177,6 +1454,7 @@ function runRouterTurnIn(ctx, instruction) {
|
|
|
1177
1454
|
timeoutMs: ROUTER_TIMEOUT_MS,
|
|
1178
1455
|
logPath: path.join(ctx.agentDir, "router-log.jsonl"),
|
|
1179
1456
|
children: ctx.children,
|
|
1457
|
+
labelUnknownTools: true,
|
|
1180
1458
|
onDelta: (delta) => {
|
|
1181
1459
|
raw += delta;
|
|
1182
1460
|
const visible = visibleLength(raw);
|
|
@@ -1195,6 +1473,7 @@ function runRouterTurnIn(ctx, instruction) {
|
|
|
1195
1473
|
},
|
|
1196
1474
|
})
|
|
1197
1475
|
.then((result) => {
|
|
1476
|
+
logRouterUsage(backend, result.usage);
|
|
1198
1477
|
// The settle path must ALWAYS reach ctx.onSettled() (clears
|
|
1199
1478
|
// routerRunning + flushes pendingSends). A throw here on Node v25 would
|
|
1200
1479
|
// otherwise both freeze the composer and crash the serve, so the whole
|
|
@@ -1373,18 +1652,14 @@ function startChildRegistry(registryPath, groups) {
|
|
|
1373
1652
|
// so createAgentServer stays within the max-lines budget; the queue-by-default
|
|
1374
1653
|
// semantics are unchanged.
|
|
1375
1654
|
function createRouterQueue(deps) {
|
|
1376
|
-
const { deckDir, deckLabel, agentDir, attachmentsDir, routerChildren, log, broadcast, taskStore, messages, settings, } = deps;
|
|
1655
|
+
const { deckDir, deckLabel, quickReference, agentDir, attachmentsDir, routerChildren, log, broadcast, taskStore, messages, settings, } = deps;
|
|
1377
1656
|
let userEpoch = 0;
|
|
1378
1657
|
let routerRunning = false;
|
|
1379
|
-
//
|
|
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.
|
|
1658
|
+
// Durable mid-run sends, mirrored to pending-sends.json until drained.
|
|
1383
1659
|
const pendingSends = [];
|
|
1384
1660
|
const pendingPath = path.join(agentDir, "pending-sends.json");
|
|
1385
1661
|
let pendingInterruptedDraft = "";
|
|
1386
|
-
//
|
|
1387
|
-
// drain, cancel, recover) so a restart never loses an unsent queued message.
|
|
1662
|
+
// Persist every mutation so a restart never loses an unsent queued message.
|
|
1388
1663
|
function persistPending() {
|
|
1389
1664
|
fs.writeFileSync(pendingPath, JSON.stringify(pendingSends, null, 2) + "\n");
|
|
1390
1665
|
}
|
|
@@ -1417,6 +1692,7 @@ function createRouterQueue(deps) {
|
|
|
1417
1692
|
runRouterTurnIn({
|
|
1418
1693
|
deckDir,
|
|
1419
1694
|
deckLabel,
|
|
1695
|
+
quickReference,
|
|
1420
1696
|
agentDir,
|
|
1421
1697
|
children: routerChildren,
|
|
1422
1698
|
log,
|
|
@@ -1428,10 +1704,7 @@ function createRouterQueue(deps) {
|
|
|
1428
1704
|
onSettled: onRouterSettled,
|
|
1429
1705
|
}, instruction);
|
|
1430
1706
|
}
|
|
1431
|
-
// Drain
|
|
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.
|
|
1707
|
+
// Drain queued sends into one follow-up turn; no-op while running or empty.
|
|
1435
1708
|
function maybeStartRouterTurn() {
|
|
1436
1709
|
if (routerRunning || pendingSends.length === 0)
|
|
1437
1710
|
return;
|
|
@@ -1439,9 +1712,7 @@ function createRouterQueue(deps) {
|
|
|
1439
1712
|
const texts = [];
|
|
1440
1713
|
const attachmentPaths = [];
|
|
1441
1714
|
for (const item of drained) {
|
|
1442
|
-
//
|
|
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).
|
|
1715
|
+
// Enqueue assigned the durable id/attachments; drain commits them to log.
|
|
1445
1716
|
const message = {
|
|
1446
1717
|
id: item.id,
|
|
1447
1718
|
role: "user",
|
|
@@ -1555,6 +1826,7 @@ function createRouterQueue(deps) {
|
|
|
1555
1826
|
}
|
|
1556
1827
|
export function createAgentServer(opts) {
|
|
1557
1828
|
const { deckDir, deckLabel } = opts;
|
|
1829
|
+
const quickReference = readQuickReference(deckDir);
|
|
1558
1830
|
const agentDir = path.join(deckDir, ".castle", "agent");
|
|
1559
1831
|
const tasksDir = path.join(agentDir, "tasks");
|
|
1560
1832
|
const attachmentsDir = path.join(agentDir, "attachments");
|
|
@@ -1601,7 +1873,7 @@ export function createAgentServer(opts) {
|
|
|
1601
1873
|
backend: () => settings.tasks,
|
|
1602
1874
|
claudeModel: () => settings.claudeModel,
|
|
1603
1875
|
// Task lifecycle stays on the board only -- log lines for it were spam.
|
|
1604
|
-
onUpdate: (task) => broadcast({ type: "task-update", task }),
|
|
1876
|
+
onUpdate: (task) => broadcast({ type: "task-update", task: asClientTask(task) }),
|
|
1605
1877
|
onStarted: () => undefined,
|
|
1606
1878
|
onRetry: (task, attempt) => addLog(`agent died, retrying (${attempt}/${MAX_TASK_ATTEMPTS}): ${task.title}`),
|
|
1607
1879
|
onFinished: (task) => taskFeeds.map.delete(task.id),
|
|
@@ -1612,6 +1884,7 @@ export function createAgentServer(opts) {
|
|
|
1612
1884
|
const routerQueue = createRouterQueue({
|
|
1613
1885
|
deckDir,
|
|
1614
1886
|
deckLabel,
|
|
1887
|
+
quickReference,
|
|
1615
1888
|
agentDir,
|
|
1616
1889
|
attachmentsDir,
|
|
1617
1890
|
routerChildren,
|
|
@@ -1644,7 +1917,7 @@ export function createAgentServer(opts) {
|
|
|
1644
1917
|
const hello = {
|
|
1645
1918
|
type: "hello",
|
|
1646
1919
|
messages,
|
|
1647
|
-
tasks: taskStore.sorted(),
|
|
1920
|
+
tasks: taskStore.sorted().map(asClientTask),
|
|
1648
1921
|
settings,
|
|
1649
1922
|
feeds: Object.fromEntries(taskFeeds.map),
|
|
1650
1923
|
running: routerQueue.isRunning(),
|