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