castle-web-cli 0.4.59 → 0.4.61
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 +3 -2
- package/dist/agent.d.ts +4 -4
- package/dist/agent.js +342 -239
- package/dist/api.d.ts +9 -0
- package/dist/api.js +1 -1
- package/dist/castle-host/host.d.ts +58 -0
- package/dist/castle-host/host.js +457 -0
- package/dist/chat-client.js +199 -96
- package/dist/ide.d.ts +3 -3
- package/dist/ide.js +96 -64
- package/dist/serve.js +45 -1
- package/kits/basic-2d/CLAUDE.md +4 -3
- package/kits/basic-2d/behaviors/Drawing.jsx +5 -2
- package/kits/basic-2d/drawings/default.drawing +70 -0
- package/package.json +5 -2
package/dist/agent.js
CHANGED
|
@@ -14,64 +14,72 @@
|
|
|
14
14
|
// Backend CLI: cursor-agent in headless print mode (stream-json). The router
|
|
15
15
|
// runs with --mode ask (read-only at the CLI level); task agents run with
|
|
16
16
|
// --force. Claude support can slot in later behind runAgentCli.
|
|
17
|
-
import { execFileSync, spawn } from
|
|
18
|
-
import * as fs from
|
|
19
|
-
import * as os from
|
|
20
|
-
import * as path from
|
|
21
|
-
import { nanoid } from
|
|
22
|
-
import { WebSocketServer } from
|
|
23
|
-
import { rawDataToString } from
|
|
24
|
-
import { buildRouterPrompt, buildTaskPrompt, userTurnInstruction, CLAUDE_TASK_SYSTEM_REMINDER, } from
|
|
25
|
-
export const AGENT_WS_PATH =
|
|
26
|
-
export const AGENT_ATTACHMENT_PREFIX =
|
|
27
|
-
const DEFAULT_SETTINGS = {
|
|
17
|
+
import { execFileSync, spawn } from "child_process";
|
|
18
|
+
import * as fs from "fs";
|
|
19
|
+
import * as os from "os";
|
|
20
|
+
import * as path from "path";
|
|
21
|
+
import { nanoid } from "nanoid";
|
|
22
|
+
import { WebSocketServer } from "ws";
|
|
23
|
+
import { rawDataToString } from "./ide.js";
|
|
24
|
+
import { buildRouterPrompt, buildTaskPrompt, userTurnInstruction, CLAUDE_TASK_SYSTEM_REMINDER, } from "./agent-prompts.js";
|
|
25
|
+
export const AGENT_WS_PATH = "/__castle/agent";
|
|
26
|
+
export const AGENT_ATTACHMENT_PREFIX = "/__castle/agent/attachments/";
|
|
27
|
+
const DEFAULT_SETTINGS = {
|
|
28
|
+
router: "claude",
|
|
29
|
+
tasks: "claude",
|
|
30
|
+
claudeModel: "opus",
|
|
31
|
+
};
|
|
28
32
|
function normalizeBackend(value) {
|
|
29
|
-
return value ===
|
|
33
|
+
return value === "cursor" || value === "claude" ? value : null;
|
|
30
34
|
}
|
|
31
35
|
function normalizeClaudeModel(value) {
|
|
32
|
-
return value ===
|
|
36
|
+
return value === "sonnet" || value === "opus" || value === "fable"
|
|
37
|
+
? value
|
|
38
|
+
: null;
|
|
33
39
|
}
|
|
34
40
|
// Build the headless CLI invocation for a backend/role. Cursor's router runs
|
|
35
41
|
// in read-only ask mode; claude runs permission-mode auto for both roles (NOT
|
|
36
42
|
// plan mode -- that makes it emit plan tool calls) at medium effort.
|
|
37
43
|
function buildAgentInvocation(backend, role, prompt, claudeModel) {
|
|
38
|
-
if (backend ===
|
|
44
|
+
if (backend === "claude") {
|
|
39
45
|
return {
|
|
40
|
-
command:
|
|
46
|
+
command: "claude",
|
|
41
47
|
args: [
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
"-p",
|
|
49
|
+
"--verbose",
|
|
50
|
+
"--output-format",
|
|
51
|
+
"stream-json",
|
|
52
|
+
"--include-partial-messages",
|
|
53
|
+
"--permission-mode",
|
|
54
|
+
"auto",
|
|
55
|
+
"--model",
|
|
50
56
|
claudeModel,
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
"--effort",
|
|
58
|
+
"medium",
|
|
53
59
|
// Keep runs independent of the machine's user config: no user plugins
|
|
54
60
|
// (LSP servers etc.), no user MCP servers. CLAUDE.md auto-discovery
|
|
55
61
|
// and OAuth still work.
|
|
56
|
-
|
|
62
|
+
"--settings",
|
|
57
63
|
'{"enabledPlugins": {}}',
|
|
58
|
-
|
|
59
|
-
...(role ===
|
|
64
|
+
"--strict-mcp-config",
|
|
65
|
+
...(role === "task"
|
|
66
|
+
? ["--append-system-prompt", CLAUDE_TASK_SYSTEM_REMINDER]
|
|
67
|
+
: []),
|
|
60
68
|
prompt,
|
|
61
69
|
],
|
|
62
70
|
};
|
|
63
71
|
}
|
|
64
72
|
return {
|
|
65
|
-
command:
|
|
73
|
+
command: "cursor-agent",
|
|
66
74
|
args: [
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
...(role ===
|
|
75
|
+
"-p",
|
|
76
|
+
"--output-format",
|
|
77
|
+
"stream-json",
|
|
78
|
+
"--stream-partial-output",
|
|
79
|
+
"--trust",
|
|
80
|
+
"--model",
|
|
81
|
+
"composer-2.5-fast",
|
|
82
|
+
...(role === "router" ? ["--mode", "ask"] : ["--force"]),
|
|
75
83
|
prompt,
|
|
76
84
|
],
|
|
77
85
|
};
|
|
@@ -84,11 +92,11 @@ const MAX_TASK_ATTEMPTS = 3;
|
|
|
84
92
|
// first, as running ones finish. Conservative default; override via env.
|
|
85
93
|
const MAX_CONCURRENT_TASKS = Number(process.env.CASTLE_MAX_CONCURRENT_TASKS) || 4;
|
|
86
94
|
const TASK_POLL_MS = 1_000;
|
|
87
|
-
const FENCE_HOLDBACK =
|
|
95
|
+
const FENCE_HOLDBACK = "```castle-";
|
|
88
96
|
const RESULT_SUMMARY_CHARS = 600;
|
|
89
97
|
const MAX_ATTACHMENTS = 6;
|
|
90
98
|
const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
|
|
91
|
-
const TERMINAL_STATUSES = [
|
|
99
|
+
const TERMINAL_STATUSES = ["done", "failed", "interrupted"];
|
|
92
100
|
function nowIso() {
|
|
93
101
|
return new Date().toISOString();
|
|
94
102
|
}
|
|
@@ -97,7 +105,7 @@ function isTerminal(status) {
|
|
|
97
105
|
}
|
|
98
106
|
function readJsonFile(filePath) {
|
|
99
107
|
try {
|
|
100
|
-
return JSON.parse(fs.readFileSync(filePath,
|
|
108
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
101
109
|
}
|
|
102
110
|
catch {
|
|
103
111
|
return null;
|
|
@@ -124,97 +132,113 @@ function extractDirectives(full) {
|
|
|
124
132
|
const checkoffs = [];
|
|
125
133
|
const stops = [];
|
|
126
134
|
const listFence = (source, name, into) => {
|
|
127
|
-
const re = new RegExp(
|
|
135
|
+
const re = new RegExp("```" + name + "[ \\t]*\\r?\\n([\\s\\S]*?)```", "g");
|
|
128
136
|
return source.replace(re, (_match, body) => {
|
|
129
137
|
for (const token of String(body).split(/[,\n]/)) {
|
|
130
138
|
const trimmed = token.trim();
|
|
131
139
|
if (trimmed)
|
|
132
140
|
into.push(trimmed);
|
|
133
141
|
}
|
|
134
|
-
return
|
|
142
|
+
return "";
|
|
135
143
|
});
|
|
136
144
|
};
|
|
137
|
-
const withoutDone = listFence(listFence(full,
|
|
145
|
+
const withoutDone = listFence(listFence(full, "castle-done", checkoffs), "castle-stop", stops);
|
|
138
146
|
const fenceRe = /```castle-task[ \t]*\r?\n([\s\S]*?)```/g;
|
|
139
147
|
const cleaned = withoutDone.replace(fenceRe, (_match, body) => {
|
|
140
|
-
const lines = String(body).replace(/\r/g,
|
|
141
|
-
const title = (lines.shift() ??
|
|
148
|
+
const lines = String(body).replace(/\r/g, "").split("\n");
|
|
149
|
+
const title = (lines.shift() ?? "").trim();
|
|
142
150
|
const headers = { after: [] };
|
|
143
151
|
while (lines.length > 0) {
|
|
144
|
-
const headerMatch = /^(after):\s*(.*)$/i.exec((lines[0] ??
|
|
152
|
+
const headerMatch = /^(after):\s*(.*)$/i.exec((lines[0] ?? "").trim());
|
|
145
153
|
if (!headerMatch)
|
|
146
154
|
break;
|
|
147
155
|
lines.shift();
|
|
148
156
|
headers[headerMatch[1].toLowerCase()] = headerMatch[2]
|
|
149
|
-
.split(
|
|
157
|
+
.split(",")
|
|
150
158
|
.map((s) => s.trim())
|
|
151
159
|
.filter(Boolean);
|
|
152
160
|
}
|
|
153
|
-
const prompt = lines.join(
|
|
161
|
+
const prompt = lines.join("\n").trim();
|
|
154
162
|
if (title) {
|
|
155
163
|
directives.push({ title, after: headers.after, prompt });
|
|
156
164
|
}
|
|
157
|
-
return
|
|
165
|
+
return "";
|
|
158
166
|
});
|
|
159
|
-
return {
|
|
167
|
+
return {
|
|
168
|
+
cleaned: cleaned.replace(/\n{3,}/g, "\n\n").trim(),
|
|
169
|
+
directives,
|
|
170
|
+
checkoffs,
|
|
171
|
+
stops,
|
|
172
|
+
};
|
|
160
173
|
}
|
|
161
|
-
|
|
162
|
-
|
|
174
|
+
function baseName(p) {
|
|
175
|
+
const parts = p.split(/[\\/]/).filter(Boolean);
|
|
176
|
+
return parts[parts.length - 1] || p;
|
|
177
|
+
}
|
|
178
|
+
// Matches the per-task progress file an agent writes its 0-100 integer to. We
|
|
179
|
+
// hide those writes from the live feed -- they're constant noise, not work.
|
|
180
|
+
const PROGRESS_FILE_RE = /\.castle\/agent\/tasks\/[^/]+\/progress$/;
|
|
181
|
+
// Feed label for a finished claude tool_use block. We surface ONLY file edits
|
|
182
|
+
// and reads -- commands, searches, and globs are noise in the live feed.
|
|
183
|
+
// Returns null to hide the action (progress-file writes + anything non-edit/read).
|
|
184
|
+
function claudeToolFeedLabel(name, input) {
|
|
163
185
|
const kind = name.toLowerCase();
|
|
164
|
-
if ([
|
|
165
|
-
|
|
186
|
+
if (["edit", "write", "notebookedit", "multiedit"].some((p) => kind.startsWith(p))) {
|
|
187
|
+
const file = String(input.file_path ?? input.path ?? input.notebook_path ?? "");
|
|
188
|
+
if (!file || PROGRESS_FILE_RE.test(file))
|
|
189
|
+
return null;
|
|
190
|
+
return `Editing ${baseName(file)}`;
|
|
166
191
|
}
|
|
167
|
-
if (
|
|
168
|
-
|
|
192
|
+
if (kind.startsWith("read") || kind.startsWith("notebookread")) {
|
|
193
|
+
const file = String(input.file_path ?? input.path ?? "");
|
|
194
|
+
return file ? `Reading ${baseName(file)}` : null;
|
|
169
195
|
}
|
|
170
|
-
|
|
171
|
-
return 'running a command';
|
|
172
|
-
if (kind.startsWith('task'))
|
|
173
|
-
return 'delegating';
|
|
174
|
-
return 'working';
|
|
196
|
+
return null;
|
|
175
197
|
}
|
|
176
198
|
// Human-readable label for a tool_call event, e.g. readToolCall -> "reading
|
|
177
199
|
// the deck". Shown as the streaming message's activity line.
|
|
178
200
|
function toolActivityLabel(ev) {
|
|
179
201
|
const call = ev.tool_call;
|
|
180
|
-
const key = call
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
202
|
+
const key = call
|
|
203
|
+
? Object.keys(call).find((k) => k.endsWith("ToolCall"))
|
|
204
|
+
: undefined;
|
|
205
|
+
const kind = (key ?? "").slice(0, -"ToolCall".length).toLowerCase();
|
|
206
|
+
if (["read", "glob", "grep", "ls", "list"].some((p) => kind.startsWith(p))) {
|
|
207
|
+
return "reading the deck";
|
|
184
208
|
}
|
|
185
|
-
if ([
|
|
186
|
-
return
|
|
187
|
-
if ([
|
|
188
|
-
return
|
|
189
|
-
return
|
|
209
|
+
if (["write", "edit", "delete", "mv"].some((p) => kind.startsWith(p)))
|
|
210
|
+
return "editing files";
|
|
211
|
+
if (["shell", "bash", "terminal"].some((p) => kind.startsWith(p)))
|
|
212
|
+
return "running a command";
|
|
213
|
+
return "working";
|
|
190
214
|
}
|
|
191
215
|
// Castle's agent CLI keys, delivered to the sandbox as a file
|
|
192
216
|
// (~/.castle/keys.json) rather than sandbox-wide env -- so an ambient key can't
|
|
193
217
|
// override a user's own subscription login. Falls back to process.env for
|
|
194
218
|
// older sandboxes that still inject the keys as env.
|
|
195
|
-
const CASTLE_KEYS_PATH = path.join(os.homedir(),
|
|
219
|
+
const CASTLE_KEYS_PATH = path.join(os.homedir(), ".castle", "keys.json");
|
|
196
220
|
function castleKeys() {
|
|
197
221
|
try {
|
|
198
|
-
return JSON.parse(fs.readFileSync(CASTLE_KEYS_PATH,
|
|
222
|
+
return JSON.parse(fs.readFileSync(CASTLE_KEYS_PATH, "utf8"));
|
|
199
223
|
}
|
|
200
224
|
catch {
|
|
201
225
|
return {};
|
|
202
226
|
}
|
|
203
227
|
}
|
|
204
228
|
const BACKEND_KEY_ENV = {
|
|
205
|
-
claude:
|
|
206
|
-
cursor:
|
|
229
|
+
claude: "ANTHROPIC_API_KEY",
|
|
230
|
+
cursor: "CURSOR_API_KEY",
|
|
207
231
|
};
|
|
208
232
|
// True when the user has their OWN saved auth for this backend -- a /login, or
|
|
209
233
|
// (for cursor, which reuses one auth.json) any saved creds. When so we do NOT
|
|
210
234
|
// inject Castle's key, so their auth is used and billed to them.
|
|
211
235
|
function backendHasSavedAuth(backend) {
|
|
212
236
|
const home = os.homedir();
|
|
213
|
-
if (backend ===
|
|
214
|
-
return fs.existsSync(path.join(home,
|
|
237
|
+
if (backend === "claude") {
|
|
238
|
+
return fs.existsSync(path.join(home, ".claude", ".credentials.json"));
|
|
215
239
|
}
|
|
216
|
-
if (backend ===
|
|
217
|
-
return fs.existsSync(path.join(home,
|
|
240
|
+
if (backend === "cursor") {
|
|
241
|
+
return fs.existsSync(path.join(home, ".config", "cursor", "auth.json"));
|
|
218
242
|
}
|
|
219
243
|
return false;
|
|
220
244
|
}
|
|
@@ -248,18 +272,20 @@ function runAgentCli(opts) {
|
|
|
248
272
|
const child = spawn(opts.command, opts.args, {
|
|
249
273
|
cwd: opts.cwd,
|
|
250
274
|
env: envForAgentSpawn(opts.parser),
|
|
251
|
-
stdio: [
|
|
275
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
252
276
|
});
|
|
253
277
|
opts.children.add(child);
|
|
254
278
|
opts.onSpawn?.(child.pid);
|
|
255
|
-
const log = opts.logPath
|
|
279
|
+
const log = opts.logPath
|
|
280
|
+
? fs.createWriteStream(opts.logPath, { flags: "a" })
|
|
281
|
+
: null;
|
|
256
282
|
let settled = false;
|
|
257
|
-
let accumulated =
|
|
258
|
-
let finalText =
|
|
283
|
+
let accumulated = "";
|
|
284
|
+
let finalText = "";
|
|
259
285
|
let resultIsError = false;
|
|
260
286
|
let sawResult = false;
|
|
261
|
-
let stderrTail =
|
|
262
|
-
let lineBuffer =
|
|
287
|
+
let stderrTail = "";
|
|
288
|
+
let lineBuffer = "";
|
|
263
289
|
const settle = (result) => {
|
|
264
290
|
if (settled)
|
|
265
291
|
return;
|
|
@@ -271,12 +297,16 @@ function runAgentCli(opts) {
|
|
|
271
297
|
};
|
|
272
298
|
const timeout = setTimeout(() => {
|
|
273
299
|
try {
|
|
274
|
-
child.kill(
|
|
300
|
+
child.kill("SIGKILL");
|
|
275
301
|
}
|
|
276
302
|
catch {
|
|
277
303
|
/* already gone */
|
|
278
304
|
}
|
|
279
|
-
settle({
|
|
305
|
+
settle({
|
|
306
|
+
ok: false,
|
|
307
|
+
finalText: finalText || accumulated,
|
|
308
|
+
error: "agent run timed out",
|
|
309
|
+
});
|
|
280
310
|
}, opts.timeoutMs);
|
|
281
311
|
// Cursor closes each text segment (e.g. right before a tool call) by
|
|
282
312
|
// re-emitting the segment's full text as one more delta-shaped event;
|
|
@@ -284,14 +314,18 @@ function runAgentCli(opts) {
|
|
|
284
314
|
// duplicating lines. Segment boundaries also need a paragraph gap --
|
|
285
315
|
// cursor starts the next segment without one, which glues "Checking the
|
|
286
316
|
// deck..." lines onto the previous paragraph.
|
|
287
|
-
let segmentText =
|
|
317
|
+
let segmentText = "";
|
|
288
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();
|
|
289
323
|
const emitDelta = (rawDelta) => {
|
|
290
324
|
let delta = rawDelta;
|
|
291
325
|
if (needsGap) {
|
|
292
326
|
needsGap = false;
|
|
293
|
-
if (accumulated && !accumulated.endsWith(
|
|
294
|
-
delta = (accumulated.endsWith(
|
|
327
|
+
if (accumulated && !accumulated.endsWith("\n\n")) {
|
|
328
|
+
delta = (accumulated.endsWith("\n") ? "\n" : "\n\n") + delta;
|
|
295
329
|
}
|
|
296
330
|
}
|
|
297
331
|
segmentText += delta;
|
|
@@ -300,43 +334,75 @@ function runAgentCli(opts) {
|
|
|
300
334
|
opts.onActivity?.(null);
|
|
301
335
|
};
|
|
302
336
|
const handleClaudeEvent = (ev) => {
|
|
303
|
-
if (ev.type ===
|
|
337
|
+
if (ev.type === "stream_event") {
|
|
304
338
|
const e = ev.event;
|
|
305
|
-
if (e?.type ===
|
|
306
|
-
if (e.content_block?.type ===
|
|
339
|
+
if (e?.type === "content_block_start") {
|
|
340
|
+
if (e.content_block?.type === "tool_use") {
|
|
307
341
|
needsGap = true;
|
|
308
|
-
|
|
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
|
+
});
|
|
309
348
|
}
|
|
310
|
-
else if (e.content_block?.type ===
|
|
349
|
+
else if (e.content_block?.type === "thinking") {
|
|
311
350
|
needsGap = true;
|
|
312
|
-
opts.onActivity?.('thinking');
|
|
313
351
|
}
|
|
314
352
|
}
|
|
315
|
-
else if (e?.type ===
|
|
316
|
-
if (e.delta?.type ===
|
|
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) {
|
|
317
357
|
emitDelta(e.delta.text);
|
|
318
358
|
}
|
|
319
|
-
else if (e.delta?.type ===
|
|
320
|
-
|
|
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);
|
|
321
387
|
}
|
|
322
388
|
}
|
|
323
389
|
}
|
|
324
|
-
else if (ev.type ===
|
|
390
|
+
else if (ev.type === "result") {
|
|
325
391
|
sawResult = true;
|
|
326
|
-
finalText = typeof ev.result ===
|
|
392
|
+
finalText = typeof ev.result === "string" ? ev.result : accumulated;
|
|
327
393
|
resultIsError = ev.is_error === true;
|
|
328
394
|
}
|
|
329
395
|
};
|
|
330
396
|
const handleEvent = (ev) => {
|
|
331
|
-
if (opts.parser ===
|
|
397
|
+
if (opts.parser === "claude") {
|
|
332
398
|
handleClaudeEvent(ev);
|
|
333
399
|
return;
|
|
334
400
|
}
|
|
335
|
-
if (ev.type ===
|
|
401
|
+
if (ev.type === "assistant" && typeof ev.timestamp_ms === "number") {
|
|
336
402
|
const message = ev.message;
|
|
337
403
|
const delta = (message?.content ?? [])
|
|
338
|
-
.map((c) => (typeof c?.text ===
|
|
339
|
-
.join(
|
|
404
|
+
.map((c) => (typeof c?.text === "string" ? c.text : ""))
|
|
405
|
+
.join("");
|
|
340
406
|
if (!delta)
|
|
341
407
|
return;
|
|
342
408
|
const trimmed = delta.trim();
|
|
@@ -344,31 +410,31 @@ function runAgentCli(opts) {
|
|
|
344
410
|
return;
|
|
345
411
|
emitDelta(delta);
|
|
346
412
|
}
|
|
347
|
-
else if (ev.type ===
|
|
348
|
-
segmentText =
|
|
413
|
+
else if (ev.type === "tool_call") {
|
|
414
|
+
segmentText = "";
|
|
349
415
|
needsGap = true;
|
|
350
|
-
if (ev.subtype ===
|
|
416
|
+
if (ev.subtype === "started")
|
|
351
417
|
opts.onActivity?.(toolActivityLabel(ev));
|
|
352
418
|
}
|
|
353
|
-
else if (ev.type ===
|
|
354
|
-
segmentText =
|
|
419
|
+
else if (ev.type === "thinking") {
|
|
420
|
+
segmentText = "";
|
|
355
421
|
needsGap = true;
|
|
356
|
-
opts.onActivity?.(
|
|
422
|
+
opts.onActivity?.("thinking");
|
|
357
423
|
}
|
|
358
|
-
else if (ev.type ===
|
|
424
|
+
else if (ev.type === "result") {
|
|
359
425
|
sawResult = true;
|
|
360
|
-
finalText = typeof ev.result ===
|
|
426
|
+
finalText = typeof ev.result === "string" ? ev.result : accumulated;
|
|
361
427
|
resultIsError = ev.is_error === true;
|
|
362
428
|
}
|
|
363
429
|
};
|
|
364
|
-
child.stdout.on(
|
|
365
|
-
lineBuffer += chunk.toString(
|
|
366
|
-
let nl = lineBuffer.indexOf(
|
|
430
|
+
child.stdout.on("data", (chunk) => {
|
|
431
|
+
lineBuffer += chunk.toString("utf8");
|
|
432
|
+
let nl = lineBuffer.indexOf("\n");
|
|
367
433
|
while (nl >= 0) {
|
|
368
434
|
const line = lineBuffer.slice(0, nl);
|
|
369
435
|
lineBuffer = lineBuffer.slice(nl + 1);
|
|
370
436
|
if (line.trim()) {
|
|
371
|
-
log?.write(line +
|
|
437
|
+
log?.write(line + "\n");
|
|
372
438
|
try {
|
|
373
439
|
handleEvent(JSON.parse(line));
|
|
374
440
|
}
|
|
@@ -376,39 +442,45 @@ function runAgentCli(opts) {
|
|
|
376
442
|
/* non-JSON noise on stdout -- ignore */
|
|
377
443
|
}
|
|
378
444
|
}
|
|
379
|
-
nl = lineBuffer.indexOf(
|
|
445
|
+
nl = lineBuffer.indexOf("\n");
|
|
380
446
|
}
|
|
381
447
|
});
|
|
382
|
-
child.stderr.on(
|
|
383
|
-
stderrTail = (stderrTail + chunk.toString(
|
|
448
|
+
child.stderr.on("data", (chunk) => {
|
|
449
|
+
stderrTail = (stderrTail + chunk.toString("utf8")).slice(-2000);
|
|
384
450
|
});
|
|
385
|
-
child.on(
|
|
386
|
-
settle({
|
|
451
|
+
child.on("error", (err) => {
|
|
452
|
+
settle({
|
|
453
|
+
ok: false,
|
|
454
|
+
finalText: accumulated,
|
|
455
|
+
error: `could not run cursor-agent: ${err.message}`,
|
|
456
|
+
});
|
|
387
457
|
});
|
|
388
|
-
child.on(
|
|
458
|
+
child.on("close", (code) => {
|
|
389
459
|
const ok = code === 0 && !resultIsError && sawResult;
|
|
390
460
|
settle({
|
|
391
461
|
ok,
|
|
392
462
|
finalText: finalText || accumulated,
|
|
393
463
|
crashed: !sawResult,
|
|
394
|
-
error: ok
|
|
464
|
+
error: ok
|
|
465
|
+
? undefined
|
|
466
|
+
: `agent exited ${code}${stderrTail ? `: ${stderrTail.slice(-300)}` : ""}`,
|
|
395
467
|
});
|
|
396
468
|
});
|
|
397
469
|
});
|
|
398
470
|
}
|
|
399
471
|
// -- task store ---------------------------------------------------------------
|
|
400
472
|
function persistTaskFile(tasksDir, task) {
|
|
401
|
-
fs.writeFileSync(path.join(tasksDir, task.id,
|
|
473
|
+
fs.writeFileSync(path.join(tasksDir, task.id, "task.json"), JSON.stringify(task, null, 2) + "\n");
|
|
402
474
|
}
|
|
403
475
|
// Tasks left "running" by a dead serve are as finished as they will get.
|
|
404
476
|
function loadTasks(tasksDir) {
|
|
405
477
|
const tasks = new Map();
|
|
406
478
|
for (const entry of fs.existsSync(tasksDir) ? fs.readdirSync(tasksDir) : []) {
|
|
407
|
-
const rec = readJsonFile(path.join(tasksDir, entry,
|
|
479
|
+
const rec = readJsonFile(path.join(tasksDir, entry, "task.json"));
|
|
408
480
|
if (!rec)
|
|
409
481
|
continue;
|
|
410
|
-
if (rec.status ===
|
|
411
|
-
rec.status =
|
|
482
|
+
if (rec.status === "running") {
|
|
483
|
+
rec.status = "interrupted";
|
|
412
484
|
rec.updatedAt = nowIso();
|
|
413
485
|
persistTaskFile(tasksDir, rec);
|
|
414
486
|
}
|
|
@@ -422,7 +494,9 @@ function refreshTaskFiles(tasksDir, task) {
|
|
|
422
494
|
const dir = path.join(tasksDir, task.id);
|
|
423
495
|
let changed = false;
|
|
424
496
|
try {
|
|
425
|
-
const rawProgress = fs
|
|
497
|
+
const rawProgress = fs
|
|
498
|
+
.readFileSync(path.join(dir, "progress"), "utf8")
|
|
499
|
+
.trim();
|
|
426
500
|
const value = Math.max(0, Math.min(100, parseInt(rawProgress, 10)));
|
|
427
501
|
if (Number.isFinite(value) && value !== task.progress) {
|
|
428
502
|
task.progress = value;
|
|
@@ -433,7 +507,7 @@ function refreshTaskFiles(tasksDir, task) {
|
|
|
433
507
|
/* no progress file yet */
|
|
434
508
|
}
|
|
435
509
|
try {
|
|
436
|
-
const notes = fs.readFileSync(path.join(dir,
|
|
510
|
+
const notes = fs.readFileSync(path.join(dir, "notes.md"), "utf8");
|
|
437
511
|
if (notes !== task.notes) {
|
|
438
512
|
task.notes = notes;
|
|
439
513
|
changed = true;
|
|
@@ -468,8 +542,8 @@ function depsSummaryFor(tasks, task) {
|
|
|
468
542
|
const lines = task.after
|
|
469
543
|
.map((id) => tasks.get(id))
|
|
470
544
|
.filter((dep) => !!dep)
|
|
471
|
-
.map((dep) => `- "${dep.title}" finished ${dep.status}${dep.notes.trim() ? `; notes: ${dep.notes.trim()}` :
|
|
472
|
-
return lines.join(
|
|
545
|
+
.map((dep) => `- "${dep.title}" finished ${dep.status}${dep.notes.trim() ? `; notes: ${dep.notes.trim()}` : ""}`);
|
|
546
|
+
return lines.join("\n") || undefined;
|
|
473
547
|
}
|
|
474
548
|
async function runTaskAgentIn(ctx, task) {
|
|
475
549
|
const dir = path.join(ctx.tasksDir, task.id);
|
|
@@ -479,8 +553,8 @@ async function runTaskAgentIn(ctx, task) {
|
|
|
479
553
|
taskId: task.id,
|
|
480
554
|
title: task.title,
|
|
481
555
|
prompt: task.prompt,
|
|
482
|
-
progressPath: path.join(relDir,
|
|
483
|
-
notesPath: path.join(relDir,
|
|
556
|
+
progressPath: path.join(relDir, "progress"),
|
|
557
|
+
notesPath: path.join(relDir, "notes.md"),
|
|
484
558
|
depsSummary: ctx.depsSummary,
|
|
485
559
|
backend: ctx.backend,
|
|
486
560
|
});
|
|
@@ -490,18 +564,18 @@ async function runTaskAgentIn(ctx, task) {
|
|
|
490
564
|
// can't-verify-live-play loop. A single message + the autonomy reminder
|
|
491
565
|
// appended to the system prompt (CLAUDE_TASK_SYSTEM_REMINDER) is enough to
|
|
492
566
|
// get the work done; the agent stops when the code is in, the user verifies.
|
|
493
|
-
const invocation = buildAgentInvocation(ctx.backend,
|
|
494
|
-
let result = { ok: false, finalText:
|
|
495
|
-
let lineBuf =
|
|
567
|
+
const invocation = buildAgentInvocation(ctx.backend, "task", taskPrompt, ctx.claudeModel);
|
|
568
|
+
let result = { ok: false, finalText: "", error: "not run" };
|
|
569
|
+
let lineBuf = "";
|
|
496
570
|
const flushFeedLines = (delta) => {
|
|
497
571
|
lineBuf += delta;
|
|
498
|
-
let nl = lineBuf.indexOf(
|
|
572
|
+
let nl = lineBuf.indexOf("\n");
|
|
499
573
|
while (nl >= 0) {
|
|
500
574
|
const line = lineBuf.slice(0, nl).trim();
|
|
501
575
|
lineBuf = lineBuf.slice(nl + 1);
|
|
502
576
|
if (line)
|
|
503
577
|
ctx.onFeed(line);
|
|
504
|
-
nl = lineBuf.indexOf(
|
|
578
|
+
nl = lineBuf.indexOf("\n");
|
|
505
579
|
}
|
|
506
580
|
};
|
|
507
581
|
for (let attempt = 1; attempt <= MAX_TASK_ATTEMPTS; attempt++) {
|
|
@@ -511,12 +585,13 @@ async function runTaskAgentIn(ctx, task) {
|
|
|
511
585
|
args: invocation.args,
|
|
512
586
|
parser: ctx.backend,
|
|
513
587
|
timeoutMs: TASK_TIMEOUT_MS,
|
|
514
|
-
logPath: path.join(dir,
|
|
588
|
+
logPath: path.join(dir, "log.jsonl"),
|
|
515
589
|
children: ctx.children,
|
|
516
590
|
onSpawn: (pid) => {
|
|
517
591
|
task.pid = pid;
|
|
518
592
|
},
|
|
519
593
|
onDelta: (delta) => flushFeedLines(delta),
|
|
594
|
+
onThinking: (delta) => flushFeedLines(delta),
|
|
520
595
|
onActivity: (activity) => {
|
|
521
596
|
if (activity)
|
|
522
597
|
ctx.onFeed(`[${activity}]`);
|
|
@@ -529,7 +604,7 @@ async function runTaskAgentIn(ctx, task) {
|
|
|
529
604
|
if (attempt < MAX_TASK_ATTEMPTS)
|
|
530
605
|
ctx.onRetry(attempt + 1);
|
|
531
606
|
}
|
|
532
|
-
result.error = `agent process kept dying (${MAX_TASK_ATTEMPTS} attempts): ${result.error ??
|
|
607
|
+
result.error = `agent process kept dying (${MAX_TASK_ATTEMPTS} attempts): ${result.error ?? ""}`;
|
|
533
608
|
return result;
|
|
534
609
|
}
|
|
535
610
|
function createTaskStore(opts) {
|
|
@@ -555,12 +630,12 @@ function createTaskStore(opts) {
|
|
|
555
630
|
function runningCount() {
|
|
556
631
|
let n = 0;
|
|
557
632
|
for (const t of tasks.values())
|
|
558
|
-
if (t.status ===
|
|
633
|
+
if (t.status === "running")
|
|
559
634
|
n++;
|
|
560
635
|
return n;
|
|
561
636
|
}
|
|
562
637
|
function maybeStart(task) {
|
|
563
|
-
if (task.status !==
|
|
638
|
+
if (task.status !== "waiting" || task.acknowledged || !depsAreSettled(task))
|
|
564
639
|
return;
|
|
565
640
|
// Concurrency cap: at most MAX_CONCURRENT_TASKS agents run at once. Over-cap
|
|
566
641
|
// tasks stay 'waiting' and are restarted -- earliest-created first -- by the
|
|
@@ -571,10 +646,10 @@ function createTaskStore(opts) {
|
|
|
571
646
|
}
|
|
572
647
|
function start(task) {
|
|
573
648
|
const dir = path.join(tasksDir, task.id);
|
|
574
|
-
fs.writeFileSync(path.join(dir,
|
|
575
|
-
if (!fs.existsSync(path.join(dir,
|
|
576
|
-
fs.writeFileSync(path.join(dir,
|
|
577
|
-
task.status =
|
|
649
|
+
fs.writeFileSync(path.join(dir, "progress"), "0\n");
|
|
650
|
+
if (!fs.existsSync(path.join(dir, "notes.md")))
|
|
651
|
+
fs.writeFileSync(path.join(dir, "notes.md"), "");
|
|
652
|
+
task.status = "running";
|
|
578
653
|
task.startedAt = nowIso();
|
|
579
654
|
touch(task);
|
|
580
655
|
opts.onStarted(task);
|
|
@@ -593,7 +668,7 @@ function createTaskStore(opts) {
|
|
|
593
668
|
void runTaskAgentIn(runCtx, task).then((result) => {
|
|
594
669
|
refreshTaskFiles(tasksDir, task);
|
|
595
670
|
const wasStopped = stopRequested.delete(task.id);
|
|
596
|
-
task.status = wasStopped ?
|
|
671
|
+
task.status = wasStopped ? "interrupted" : result.ok ? "done" : "failed";
|
|
597
672
|
// A stopped task is cleared off the board (castle-stop = halt + remove).
|
|
598
673
|
if (wasStopped)
|
|
599
674
|
task.acknowledged = true;
|
|
@@ -601,10 +676,10 @@ function createTaskStore(opts) {
|
|
|
601
676
|
task.progress = 100;
|
|
602
677
|
task.finishedAt = nowIso();
|
|
603
678
|
task.resultSummary = wasStopped
|
|
604
|
-
?
|
|
679
|
+
? "stopped by the router"
|
|
605
680
|
: result.ok
|
|
606
681
|
? result.finalText.slice(-RESULT_SUMMARY_CHARS)
|
|
607
|
-
: `${result.error ??
|
|
682
|
+
: `${result.error ?? "failed"}\n${result.finalText.slice(-RESULT_SUMMARY_CHARS)}`;
|
|
608
683
|
touch(task);
|
|
609
684
|
opts.onFinished(task);
|
|
610
685
|
// A slot just freed -- restart eligible waiting tasks, earliest-created
|
|
@@ -619,9 +694,9 @@ function createTaskStore(opts) {
|
|
|
619
694
|
title: directive.title,
|
|
620
695
|
prompt: directive.prompt,
|
|
621
696
|
after: resolveDeps(tasks, directive.after),
|
|
622
|
-
status:
|
|
697
|
+
status: "waiting",
|
|
623
698
|
progress: 0,
|
|
624
|
-
notes:
|
|
699
|
+
notes: "",
|
|
625
700
|
createdAt: nowIso(),
|
|
626
701
|
updatedAt: nowIso(),
|
|
627
702
|
originMessageId,
|
|
@@ -645,7 +720,7 @@ function createTaskStore(opts) {
|
|
|
645
720
|
}
|
|
646
721
|
const pollTimer = setInterval(() => {
|
|
647
722
|
for (const task of tasks.values()) {
|
|
648
|
-
if (task.status !==
|
|
723
|
+
if (task.status !== "running")
|
|
649
724
|
continue;
|
|
650
725
|
if (refreshTaskFiles(tasksDir, task))
|
|
651
726
|
touch(task);
|
|
@@ -654,8 +729,8 @@ function createTaskStore(opts) {
|
|
|
654
729
|
function shutdown() {
|
|
655
730
|
clearInterval(pollTimer);
|
|
656
731
|
for (const task of tasks.values()) {
|
|
657
|
-
if (task.status ===
|
|
658
|
-
task.status =
|
|
732
|
+
if (task.status === "running" || task.status === "waiting") {
|
|
733
|
+
task.status = "interrupted";
|
|
659
734
|
task.updatedAt = nowIso();
|
|
660
735
|
persistTaskFile(tasksDir, task);
|
|
661
736
|
}
|
|
@@ -664,13 +739,15 @@ function createTaskStore(opts) {
|
|
|
664
739
|
// True when a fence body is the special token "all" / "*" (clear/stop
|
|
665
740
|
// everything, no per-task enumeration).
|
|
666
741
|
function meansAll(tokens) {
|
|
667
|
-
return tokens.some((t) => t.toLowerCase() ===
|
|
742
|
+
return tokens.some((t) => t.toLowerCase() === "all" || t === "*");
|
|
668
743
|
}
|
|
669
744
|
// The router checks finished tasks off by title or id (castle-done fence),
|
|
670
745
|
// or "all" to clear every finished row off the board at once.
|
|
671
746
|
function checkOff(tokens) {
|
|
672
747
|
const ids = meansAll(tokens)
|
|
673
|
-
? [...tasks.values()]
|
|
748
|
+
? [...tasks.values()]
|
|
749
|
+
.filter((t) => isTerminal(t.status) && !t.acknowledged)
|
|
750
|
+
.map((t) => t.id)
|
|
674
751
|
: resolveDeps(tasks, tokens);
|
|
675
752
|
for (const id of ids)
|
|
676
753
|
acknowledge(id, false);
|
|
@@ -684,17 +761,17 @@ function createTaskStore(opts) {
|
|
|
684
761
|
// killed and is cleared when it finalizes (the stopRequested path acks it).
|
|
685
762
|
// No-op on terminal tasks.
|
|
686
763
|
function haltTask(task) {
|
|
687
|
-
if (task.status ===
|
|
688
|
-
task.status =
|
|
764
|
+
if (task.status === "waiting") {
|
|
765
|
+
task.status = "interrupted";
|
|
689
766
|
task.acknowledged = true;
|
|
690
767
|
touch(task);
|
|
691
768
|
}
|
|
692
|
-
else if (task.status ===
|
|
769
|
+
else if (task.status === "running") {
|
|
693
770
|
stopRequested.add(task.id);
|
|
694
771
|
for (const child of children) {
|
|
695
772
|
if (child.pid === task.pid) {
|
|
696
773
|
try {
|
|
697
|
-
child.kill(
|
|
774
|
+
child.kill("SIGKILL");
|
|
698
775
|
}
|
|
699
776
|
catch {
|
|
700
777
|
/* already gone */
|
|
@@ -706,7 +783,7 @@ function createTaskStore(opts) {
|
|
|
706
783
|
function stop(tokens) {
|
|
707
784
|
const ids = meansAll(tokens)
|
|
708
785
|
? [...tasks.values()]
|
|
709
|
-
.filter((t) => t.status ===
|
|
786
|
+
.filter((t) => t.status === "running" || t.status === "waiting")
|
|
710
787
|
.map((t) => t.id)
|
|
711
788
|
: resolveDeps(tasks, tokens);
|
|
712
789
|
for (const id of ids) {
|
|
@@ -715,15 +792,23 @@ function createTaskStore(opts) {
|
|
|
715
792
|
haltTask(task);
|
|
716
793
|
}
|
|
717
794
|
}
|
|
718
|
-
return {
|
|
795
|
+
return {
|
|
796
|
+
sorted,
|
|
797
|
+
get: (id) => tasks.get(id),
|
|
798
|
+
spawnFromDirective,
|
|
799
|
+
acknowledge,
|
|
800
|
+
checkOff,
|
|
801
|
+
stop,
|
|
802
|
+
shutdown,
|
|
803
|
+
};
|
|
719
804
|
}
|
|
720
805
|
// -- attachments ----------------------------------------------------------------
|
|
721
806
|
const ATTACHMENT_MIME = {
|
|
722
|
-
png:
|
|
723
|
-
jpg:
|
|
724
|
-
jpeg:
|
|
725
|
-
gif:
|
|
726
|
-
webp:
|
|
807
|
+
png: "image/png",
|
|
808
|
+
jpg: "image/jpeg",
|
|
809
|
+
jpeg: "image/jpeg",
|
|
810
|
+
gif: "image/gif",
|
|
811
|
+
webp: "image/webp",
|
|
727
812
|
};
|
|
728
813
|
// Decode pasted/attached images (data URLs) into .castle/agent/attachments/.
|
|
729
814
|
// Returns the saved file names.
|
|
@@ -733,16 +818,17 @@ function saveAttachments(attachmentsDir, messageId, images) {
|
|
|
733
818
|
const saved = [];
|
|
734
819
|
for (const [index, image] of images.slice(0, MAX_ATTACHMENTS).entries()) {
|
|
735
820
|
const dataUrl = image?.dataUrl;
|
|
736
|
-
if (typeof dataUrl !==
|
|
821
|
+
if (typeof dataUrl !== "string" ||
|
|
822
|
+
dataUrl.length > MAX_ATTACHMENT_BYTES * 1.4)
|
|
737
823
|
continue;
|
|
738
824
|
const match = /^data:image\/(png|jpe?g|gif|webp);base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl);
|
|
739
825
|
if (!match)
|
|
740
826
|
continue;
|
|
741
|
-
const ext = match[1] ===
|
|
827
|
+
const ext = match[1] === "jpeg" ? "jpg" : match[1];
|
|
742
828
|
const fileName = `${messageId}-${index}.${ext}`;
|
|
743
829
|
try {
|
|
744
830
|
fs.mkdirSync(attachmentsDir, { recursive: true });
|
|
745
|
-
fs.writeFileSync(path.join(attachmentsDir, fileName), Buffer.from(match[2],
|
|
831
|
+
fs.writeFileSync(path.join(attachmentsDir, fileName), Buffer.from(match[2], "base64"));
|
|
746
832
|
saved.push(fileName);
|
|
747
833
|
}
|
|
748
834
|
catch {
|
|
@@ -755,7 +841,7 @@ function asPromptTask(task) {
|
|
|
755
841
|
return {
|
|
756
842
|
id: task.id,
|
|
757
843
|
title: task.title,
|
|
758
|
-
status: task.rejected ?
|
|
844
|
+
status: task.rejected ? "rejected by user" : task.status,
|
|
759
845
|
progress: task.progress,
|
|
760
846
|
notes: task.notes,
|
|
761
847
|
};
|
|
@@ -773,33 +859,38 @@ function createTaskFeeds(broadcast) {
|
|
|
773
859
|
feed.push(entry);
|
|
774
860
|
if (feed.length > 80)
|
|
775
861
|
feed.splice(0, feed.length - 80);
|
|
776
|
-
broadcast({ type:
|
|
862
|
+
broadcast({ type: "task-feed", id: task.id, entry });
|
|
777
863
|
}
|
|
778
864
|
return { map, push };
|
|
779
865
|
}
|
|
780
866
|
function createMessageLog(messagesPath, broadcast) {
|
|
781
867
|
const loaded = readJsonFile(messagesPath) ?? [];
|
|
782
868
|
const messages = loaded
|
|
783
|
-
.filter((m) => m.text.trim() !==
|
|
784
|
-
.map((m) =>
|
|
869
|
+
.filter((m) => m.text.trim() !== "" || m.role === "user")
|
|
870
|
+
.map((m) => m.status === "streaming" ? { ...m, status: "done" } : m);
|
|
785
871
|
function persist() {
|
|
786
|
-
fs.writeFileSync(messagesPath, JSON.stringify(messages, null, 2) +
|
|
872
|
+
fs.writeFileSync(messagesPath, JSON.stringify(messages, null, 2) + "\n");
|
|
787
873
|
}
|
|
788
874
|
function add(message) {
|
|
789
875
|
messages.push(message);
|
|
790
876
|
persist();
|
|
791
|
-
broadcast({ type:
|
|
877
|
+
broadcast({ type: "message-add", message });
|
|
792
878
|
}
|
|
793
879
|
function addLog(text) {
|
|
794
|
-
add({ id: nanoid(8), role:
|
|
880
|
+
add({ id: nanoid(8), role: "log", text, at: nowIso(), status: "done" });
|
|
795
881
|
}
|
|
796
882
|
// Consecutive same-prefix log lines collapse into one ("working on: A, B").
|
|
797
883
|
function addGroupedLog(prefix, item) {
|
|
798
884
|
const last = messages[messages.length - 1];
|
|
799
|
-
if (last && last.role ===
|
|
885
|
+
if (last && last.role === "log" && last.text.startsWith(prefix)) {
|
|
800
886
|
last.text += `, ${item}`;
|
|
801
887
|
persist();
|
|
802
|
-
broadcast({
|
|
888
|
+
broadcast({
|
|
889
|
+
type: "message-done",
|
|
890
|
+
id: last.id,
|
|
891
|
+
text: last.text,
|
|
892
|
+
status: "done",
|
|
893
|
+
});
|
|
803
894
|
return;
|
|
804
895
|
}
|
|
805
896
|
addLog(`${prefix}${item}`);
|
|
@@ -812,14 +903,14 @@ function makeAttachmentHandler(attachmentsDir) {
|
|
|
812
903
|
if (!reqPath.startsWith(AGENT_ATTACHMENT_PREFIX))
|
|
813
904
|
return false;
|
|
814
905
|
const name = path.basename(reqPath.slice(AGENT_ATTACHMENT_PREFIX.length));
|
|
815
|
-
const ext = name.split(
|
|
906
|
+
const ext = name.split(".").pop() ?? "";
|
|
816
907
|
const mime = ATTACHMENT_MIME[ext];
|
|
817
908
|
const filePath = path.join(attachmentsDir, name);
|
|
818
909
|
if (!mime || !fs.existsSync(filePath)) {
|
|
819
910
|
res.writeHead(404).end();
|
|
820
911
|
return true;
|
|
821
912
|
}
|
|
822
|
-
res.writeHead(200, {
|
|
913
|
+
res.writeHead(200, { "content-type": mime, "cache-control": "no-store" });
|
|
823
914
|
fs.createReadStream(filePath).pipe(res);
|
|
824
915
|
return true;
|
|
825
916
|
};
|
|
@@ -830,20 +921,20 @@ function runRouterTurnIn(ctx, instruction) {
|
|
|
830
921
|
const epoch = ctx.currentEpoch();
|
|
831
922
|
const message = {
|
|
832
923
|
id: nanoid(8),
|
|
833
|
-
role:
|
|
834
|
-
text:
|
|
924
|
+
role: "assistant",
|
|
925
|
+
text: "",
|
|
835
926
|
at: nowIso(),
|
|
836
|
-
status:
|
|
927
|
+
status: "streaming",
|
|
837
928
|
};
|
|
838
929
|
ctx.log.messages.push(message);
|
|
839
|
-
ctx.broadcast({ type:
|
|
840
|
-
let raw =
|
|
930
|
+
ctx.broadcast({ type: "message-add", message });
|
|
931
|
+
let raw = "";
|
|
841
932
|
let visibleSent = 0;
|
|
842
933
|
let lastActivity = null;
|
|
843
934
|
const prompt = buildRouterPrompt({
|
|
844
935
|
deckLabel: ctx.deckLabel,
|
|
845
936
|
messages: ctx.log.messages
|
|
846
|
-
.filter((m) => m.role !==
|
|
937
|
+
.filter((m) => m.role !== "log" && m.id !== message.id && m.status !== "streaming")
|
|
847
938
|
.map((m) => ({
|
|
848
939
|
role: m.role,
|
|
849
940
|
text: m.text,
|
|
@@ -859,14 +950,14 @@ function runRouterTurnIn(ctx, instruction) {
|
|
|
859
950
|
instruction,
|
|
860
951
|
});
|
|
861
952
|
const backend = ctx.backend();
|
|
862
|
-
const invocation = buildAgentInvocation(backend,
|
|
953
|
+
const invocation = buildAgentInvocation(backend, "router", prompt, ctx.claudeModel());
|
|
863
954
|
void runAgentCli({
|
|
864
955
|
cwd: ctx.deckDir,
|
|
865
956
|
command: invocation.command,
|
|
866
957
|
args: invocation.args,
|
|
867
958
|
parser: backend,
|
|
868
959
|
timeoutMs: ROUTER_TIMEOUT_MS,
|
|
869
|
-
logPath: path.join(ctx.agentDir,
|
|
960
|
+
logPath: path.join(ctx.agentDir, "router-log.jsonl"),
|
|
870
961
|
children: ctx.children,
|
|
871
962
|
onDelta: (delta) => {
|
|
872
963
|
raw += delta;
|
|
@@ -875,24 +966,24 @@ function runRouterTurnIn(ctx, instruction) {
|
|
|
875
966
|
const slice = raw.slice(visibleSent, visible);
|
|
876
967
|
visibleSent = visible;
|
|
877
968
|
message.text += slice;
|
|
878
|
-
ctx.broadcast({ type:
|
|
969
|
+
ctx.broadcast({ type: "message-delta", id: message.id, delta: slice });
|
|
879
970
|
}
|
|
880
971
|
},
|
|
881
972
|
onActivity: (activity) => {
|
|
882
973
|
if (activity === lastActivity)
|
|
883
974
|
return;
|
|
884
975
|
lastActivity = activity;
|
|
885
|
-
ctx.broadcast({ type:
|
|
976
|
+
ctx.broadcast({ type: "message-activity", id: message.id, activity });
|
|
886
977
|
},
|
|
887
978
|
}).then((result) => {
|
|
888
979
|
const interrupted = epoch !== ctx.currentEpoch() && !result.ok;
|
|
889
980
|
if (interrupted) {
|
|
890
981
|
// Keep whatever streamed; the continuation turn carries the draft.
|
|
891
|
-
message.status =
|
|
982
|
+
message.status = "done";
|
|
892
983
|
message.interrupted = true;
|
|
893
984
|
ctx.log.persist();
|
|
894
985
|
ctx.broadcast({
|
|
895
|
-
type:
|
|
986
|
+
type: "message-done",
|
|
896
987
|
id: message.id,
|
|
897
988
|
text: message.text,
|
|
898
989
|
status: message.status,
|
|
@@ -911,19 +1002,21 @@ function runRouterTurnIn(ctx, instruction) {
|
|
|
911
1002
|
const stale = epoch !== ctx.currentEpoch();
|
|
912
1003
|
const inFlight = new Set(ctx.taskStore
|
|
913
1004
|
.sorted()
|
|
914
|
-
.filter((t) => t.status ===
|
|
1005
|
+
.filter((t) => t.status === "running" || t.status === "waiting")
|
|
915
1006
|
.map((t) => t.title.toLowerCase()));
|
|
916
|
-
const toSpawn = stale
|
|
1007
|
+
const toSpawn = stale
|
|
1008
|
+
? []
|
|
1009
|
+
: directives.filter((d) => !inFlight.has(d.title.toLowerCase()));
|
|
917
1010
|
const taskIds = toSpawn.map((d) => ctx.taskStore.spawnFromDirective(d, message.id));
|
|
918
1011
|
message.text = result.ok
|
|
919
1012
|
? cleaned
|
|
920
|
-
: `${cleaned ? cleaned +
|
|
921
|
-
message.status = result.ok ?
|
|
1013
|
+
: `${cleaned ? cleaned + "\n\n" : ""}[router error: ${result.error ?? "unknown"}]`;
|
|
1014
|
+
message.status = result.ok ? "done" : "error";
|
|
922
1015
|
if (taskIds.length > 0)
|
|
923
1016
|
message.taskIds = taskIds;
|
|
924
1017
|
ctx.log.persist();
|
|
925
1018
|
ctx.broadcast({
|
|
926
|
-
type:
|
|
1019
|
+
type: "message-done",
|
|
927
1020
|
id: message.id,
|
|
928
1021
|
text: message.text,
|
|
929
1022
|
status: message.status,
|
|
@@ -935,7 +1028,7 @@ function runRouterTurnIn(ctx, instruction) {
|
|
|
935
1028
|
function applyAgentSettings(incoming, ctx) {
|
|
936
1029
|
const { settings } = ctx;
|
|
937
1030
|
const changes = [];
|
|
938
|
-
for (const key of [
|
|
1031
|
+
for (const key of ["router", "tasks"]) {
|
|
939
1032
|
const value = normalizeBackend(incoming[key]);
|
|
940
1033
|
if (value && value !== settings[key]) {
|
|
941
1034
|
settings[key] = value;
|
|
@@ -949,20 +1042,20 @@ function applyAgentSettings(incoming, ctx) {
|
|
|
949
1042
|
}
|
|
950
1043
|
if (changes.length === 0)
|
|
951
1044
|
return;
|
|
952
|
-
fs.writeFileSync(ctx.settingsPath, JSON.stringify(settings, null, 2) +
|
|
953
|
-
ctx.broadcast({ type:
|
|
1045
|
+
fs.writeFileSync(ctx.settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
1046
|
+
ctx.broadcast({ type: "settings", settings });
|
|
954
1047
|
}
|
|
955
1048
|
function killOrphanAgents(registryPath) {
|
|
956
1049
|
const recorded = readJsonFile(registryPath) ?? [];
|
|
957
1050
|
for (const entry of recorded) {
|
|
958
|
-
if (typeof entry?.pid !==
|
|
1051
|
+
if (typeof entry?.pid !== "number")
|
|
959
1052
|
continue;
|
|
960
1053
|
try {
|
|
961
|
-
const cmd = execFileSync(
|
|
962
|
-
encoding:
|
|
1054
|
+
const cmd = execFileSync("ps", ["-p", String(entry.pid), "-o", "command="], {
|
|
1055
|
+
encoding: "utf8",
|
|
963
1056
|
}).trim();
|
|
964
|
-
if (cmd.includes(
|
|
965
|
-
process.kill(entry.pid,
|
|
1057
|
+
if (cmd.includes("cursor-agent") || cmd.includes("claude")) {
|
|
1058
|
+
process.kill(entry.pid, "SIGKILL");
|
|
966
1059
|
}
|
|
967
1060
|
}
|
|
968
1061
|
catch {
|
|
@@ -970,19 +1063,19 @@ function killOrphanAgents(registryPath) {
|
|
|
970
1063
|
}
|
|
971
1064
|
}
|
|
972
1065
|
try {
|
|
973
|
-
fs.writeFileSync(registryPath,
|
|
1066
|
+
fs.writeFileSync(registryPath, "[]\n");
|
|
974
1067
|
}
|
|
975
1068
|
catch {
|
|
976
1069
|
/* registry dir missing -- created later */
|
|
977
1070
|
}
|
|
978
1071
|
}
|
|
979
1072
|
function startChildRegistry(registryPath, groups) {
|
|
980
|
-
let last =
|
|
1073
|
+
let last = "";
|
|
981
1074
|
const timer = setInterval(() => {
|
|
982
1075
|
const live = [];
|
|
983
1076
|
for (const group of groups) {
|
|
984
1077
|
for (const child of group) {
|
|
985
|
-
if (typeof child.pid ===
|
|
1078
|
+
if (typeof child.pid === "number" && child.exitCode === null) {
|
|
986
1079
|
live.push({ pid: child.pid, command: child.spawnfile });
|
|
987
1080
|
}
|
|
988
1081
|
}
|
|
@@ -992,7 +1085,7 @@ function startChildRegistry(registryPath, groups) {
|
|
|
992
1085
|
return;
|
|
993
1086
|
last = snapshot;
|
|
994
1087
|
try {
|
|
995
|
-
fs.writeFileSync(registryPath, snapshot +
|
|
1088
|
+
fs.writeFileSync(registryPath, snapshot + "\n");
|
|
996
1089
|
}
|
|
997
1090
|
catch {
|
|
998
1091
|
/* best effort */
|
|
@@ -1001,7 +1094,7 @@ function startChildRegistry(registryPath, groups) {
|
|
|
1001
1094
|
return () => {
|
|
1002
1095
|
clearInterval(timer);
|
|
1003
1096
|
try {
|
|
1004
|
-
fs.writeFileSync(registryPath,
|
|
1097
|
+
fs.writeFileSync(registryPath, "[]\n");
|
|
1005
1098
|
}
|
|
1006
1099
|
catch {
|
|
1007
1100
|
/* best effort */
|
|
@@ -1010,19 +1103,22 @@ function startChildRegistry(registryPath, groups) {
|
|
|
1010
1103
|
}
|
|
1011
1104
|
export function createAgentServer(opts) {
|
|
1012
1105
|
const { deckDir, deckLabel } = opts;
|
|
1013
|
-
const agentDir = path.join(deckDir,
|
|
1014
|
-
const tasksDir = path.join(agentDir,
|
|
1015
|
-
const attachmentsDir = path.join(agentDir,
|
|
1016
|
-
const messagesPath = path.join(agentDir,
|
|
1106
|
+
const agentDir = path.join(deckDir, ".castle", "agent");
|
|
1107
|
+
const tasksDir = path.join(agentDir, "tasks");
|
|
1108
|
+
const attachmentsDir = path.join(agentDir, "attachments");
|
|
1109
|
+
const messagesPath = path.join(agentDir, "messages.json");
|
|
1017
1110
|
fs.mkdirSync(tasksDir, { recursive: true });
|
|
1018
1111
|
const taskChildren = new Set();
|
|
1019
1112
|
const routerChildren = new Set();
|
|
1020
1113
|
const clients = new Set();
|
|
1021
1114
|
// Kill agent processes orphaned by a previous serve that died uncleanly,
|
|
1022
1115
|
// then start tracking this serve's own children.
|
|
1023
|
-
const childRegistryPath = path.join(agentDir,
|
|
1116
|
+
const childRegistryPath = path.join(agentDir, "children.json");
|
|
1024
1117
|
killOrphanAgents(childRegistryPath);
|
|
1025
|
-
const stopChildRegistry = startChildRegistry(childRegistryPath, [
|
|
1118
|
+
const stopChildRegistry = startChildRegistry(childRegistryPath, [
|
|
1119
|
+
taskChildren,
|
|
1120
|
+
routerChildren,
|
|
1121
|
+
]);
|
|
1026
1122
|
function broadcast(body) {
|
|
1027
1123
|
const payload = JSON.stringify(body);
|
|
1028
1124
|
for (const socket of clients) {
|
|
@@ -1035,12 +1131,13 @@ export function createAgentServer(opts) {
|
|
|
1035
1131
|
const addLog = (text) => log.addLog(text);
|
|
1036
1132
|
// Which CLI backs the router and the task agents -- independently
|
|
1037
1133
|
// switchable from the settings popover, persisted next to the chat state.
|
|
1038
|
-
const settingsPath = path.join(agentDir,
|
|
1134
|
+
const settingsPath = path.join(agentDir, "settings.json");
|
|
1039
1135
|
const storedSettings = readJsonFile(settingsPath);
|
|
1040
1136
|
const settings = {
|
|
1041
1137
|
router: normalizeBackend(storedSettings?.router) ?? DEFAULT_SETTINGS.router,
|
|
1042
1138
|
tasks: normalizeBackend(storedSettings?.tasks) ?? DEFAULT_SETTINGS.tasks,
|
|
1043
|
-
claudeModel: normalizeClaudeModel(storedSettings?.claudeModel) ??
|
|
1139
|
+
claudeModel: normalizeClaudeModel(storedSettings?.claudeModel) ??
|
|
1140
|
+
DEFAULT_SETTINGS.claudeModel,
|
|
1044
1141
|
};
|
|
1045
1142
|
const applySettings = (incoming) => applyAgentSettings(incoming, { settings, settingsPath, broadcast });
|
|
1046
1143
|
const taskFeeds = createTaskFeeds(broadcast);
|
|
@@ -1052,7 +1149,7 @@ export function createAgentServer(opts) {
|
|
|
1052
1149
|
backend: () => settings.tasks,
|
|
1053
1150
|
claudeModel: () => settings.claudeModel,
|
|
1054
1151
|
// Task lifecycle stays on the board only -- log lines for it were spam.
|
|
1055
|
-
onUpdate: (task) => broadcast({ type:
|
|
1152
|
+
onUpdate: (task) => broadcast({ type: "task-update", task }),
|
|
1056
1153
|
onStarted: () => undefined,
|
|
1057
1154
|
onRetry: (task, attempt) => addLog(`agent died, retrying (${attempt}/${MAX_TASK_ATTEMPTS}): ${task.title}`),
|
|
1058
1155
|
onFinished: (task) => taskFeeds.map.delete(task.id),
|
|
@@ -1064,18 +1161,18 @@ export function createAgentServer(opts) {
|
|
|
1064
1161
|
let userEpoch = 0;
|
|
1065
1162
|
function interruptRouterRuns() {
|
|
1066
1163
|
const drafts = messages
|
|
1067
|
-
.filter((m) => m.role ===
|
|
1164
|
+
.filter((m) => m.role === "assistant" && m.status === "streaming")
|
|
1068
1165
|
.map((m) => m.text.trim())
|
|
1069
1166
|
.filter(Boolean);
|
|
1070
1167
|
for (const child of routerChildren) {
|
|
1071
1168
|
try {
|
|
1072
|
-
child.kill(
|
|
1169
|
+
child.kill("SIGKILL");
|
|
1073
1170
|
}
|
|
1074
1171
|
catch {
|
|
1075
1172
|
/* already gone */
|
|
1076
1173
|
}
|
|
1077
1174
|
}
|
|
1078
|
-
return drafts.join(
|
|
1175
|
+
return drafts.join("\n\n");
|
|
1079
1176
|
}
|
|
1080
1177
|
function runRouterTurn(instruction) {
|
|
1081
1178
|
runRouterTurnIn({
|
|
@@ -1098,12 +1195,12 @@ export function createAgentServer(opts) {
|
|
|
1098
1195
|
let lastAnswered = -1;
|
|
1099
1196
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1100
1197
|
const m = messages[i];
|
|
1101
|
-
if (m.role ===
|
|
1198
|
+
if (m.role === "assistant" && m.status === "done" && !m.interrupted) {
|
|
1102
1199
|
lastAnswered = i;
|
|
1103
1200
|
break;
|
|
1104
1201
|
}
|
|
1105
1202
|
}
|
|
1106
|
-
return messages.slice(lastAnswered + 1).filter((m) => m.role ===
|
|
1203
|
+
return messages.slice(lastAnswered + 1).filter((m) => m.role === "user");
|
|
1107
1204
|
}
|
|
1108
1205
|
function handleUserMessage(text, images) {
|
|
1109
1206
|
userEpoch += 1;
|
|
@@ -1112,10 +1209,10 @@ export function createAgentServer(opts) {
|
|
|
1112
1209
|
const attachments = saveAttachments(attachmentsDir, messageId, images);
|
|
1113
1210
|
const message = {
|
|
1114
1211
|
id: messageId,
|
|
1115
|
-
role:
|
|
1212
|
+
role: "user",
|
|
1116
1213
|
text,
|
|
1117
1214
|
at: nowIso(),
|
|
1118
|
-
status:
|
|
1215
|
+
status: "done",
|
|
1119
1216
|
};
|
|
1120
1217
|
if (attachments.length > 0)
|
|
1121
1218
|
message.attachments = attachments;
|
|
@@ -1128,7 +1225,7 @@ export function createAgentServer(opts) {
|
|
|
1128
1225
|
interruptedDraft: interruptedDraft || undefined,
|
|
1129
1226
|
attachments: pending
|
|
1130
1227
|
.flatMap((m) => m.attachments ?? [])
|
|
1131
|
-
.map((name) => path.join(
|
|
1228
|
+
.map((name) => path.join(".castle", "agent", "attachments", name)),
|
|
1132
1229
|
}));
|
|
1133
1230
|
}
|
|
1134
1231
|
function handleTaskAck(id, rejected) {
|
|
@@ -1137,9 +1234,15 @@ export function createAgentServer(opts) {
|
|
|
1137
1234
|
const wss = new WebSocketServer({ noServer: true });
|
|
1138
1235
|
function attachClient(socket) {
|
|
1139
1236
|
clients.add(socket);
|
|
1140
|
-
const hello = {
|
|
1237
|
+
const hello = {
|
|
1238
|
+
type: "hello",
|
|
1239
|
+
messages,
|
|
1240
|
+
tasks: taskStore.sorted(),
|
|
1241
|
+
settings,
|
|
1242
|
+
feeds: Object.fromEntries(taskFeeds.map),
|
|
1243
|
+
};
|
|
1141
1244
|
socket.send(JSON.stringify(hello));
|
|
1142
|
-
socket.on(
|
|
1245
|
+
socket.on("message", (rawData) => {
|
|
1143
1246
|
let msg;
|
|
1144
1247
|
try {
|
|
1145
1248
|
msg = JSON.parse(rawDataToString(rawData));
|
|
@@ -1147,24 +1250,24 @@ export function createAgentServer(opts) {
|
|
|
1147
1250
|
catch {
|
|
1148
1251
|
return;
|
|
1149
1252
|
}
|
|
1150
|
-
const hasText = typeof msg.text ===
|
|
1253
|
+
const hasText = typeof msg.text === "string" && msg.text.trim() !== "";
|
|
1151
1254
|
const hasImages = Array.isArray(msg.images) && msg.images.length > 0;
|
|
1152
|
-
if (msg.type ===
|
|
1153
|
-
handleUserMessage(typeof msg.text ===
|
|
1255
|
+
if (msg.type === "user-message" && (hasText || hasImages)) {
|
|
1256
|
+
handleUserMessage(typeof msg.text === "string" ? msg.text.trim() : "", msg.images);
|
|
1154
1257
|
}
|
|
1155
|
-
else if (msg.type ===
|
|
1258
|
+
else if (msg.type === "task-ack" && typeof msg.id === "string") {
|
|
1156
1259
|
handleTaskAck(msg.id, msg.rejected === true);
|
|
1157
1260
|
}
|
|
1158
|
-
else if (msg.type ===
|
|
1261
|
+
else if (msg.type === "set-settings") {
|
|
1159
1262
|
applySettings(msg);
|
|
1160
1263
|
}
|
|
1161
1264
|
});
|
|
1162
|
-
socket.on(
|
|
1265
|
+
socket.on("close", () => {
|
|
1163
1266
|
clients.delete(socket);
|
|
1164
1267
|
});
|
|
1165
1268
|
}
|
|
1166
1269
|
function handleUpgrade(req, socket, head) {
|
|
1167
|
-
const url = new URL(req.url ??
|
|
1270
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
1168
1271
|
if (url.pathname !== AGENT_WS_PATH)
|
|
1169
1272
|
return false;
|
|
1170
1273
|
wss.handleUpgrade(req, socket, head, (ws) => attachClient(ws));
|
|
@@ -1175,7 +1278,7 @@ export function createAgentServer(opts) {
|
|
|
1175
1278
|
taskStore.shutdown();
|
|
1176
1279
|
for (const child of [...taskChildren, ...routerChildren]) {
|
|
1177
1280
|
try {
|
|
1178
|
-
child.kill(
|
|
1281
|
+
child.kill("SIGKILL");
|
|
1179
1282
|
}
|
|
1180
1283
|
catch {
|
|
1181
1284
|
/* already gone */
|