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.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 'child_process';
18
- import * as fs from 'fs';
19
- import * as path from 'path';
20
- import { nanoid } from 'nanoid';
21
- import { WebSocketServer } from 'ws';
22
- import { rawDataToString } from './ide.js';
23
- import { buildRouterPrompt, buildTaskPrompt, userTurnInstruction, CLAUDE_TASK_SYSTEM_REMINDER, } from './agent-prompts.js';
24
- export const AGENT_WS_PATH = '/__castle/agent';
25
- export const AGENT_ATTACHMENT_PREFIX = '/__castle/agent/attachments/';
26
- const DEFAULT_SETTINGS = { router: 'claude', tasks: 'claude', claudeModel: 'opus' };
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 === 'cursor' || value === 'claude' ? value : null;
33
+ return value === "cursor" || value === "claude" ? value : null;
29
34
  }
30
35
  function normalizeClaudeModel(value) {
31
- return value === 'sonnet' || value === 'opus' || value === 'fable' ? value : null;
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 === 'claude') {
44
+ if (backend === "claude") {
38
45
  return {
39
- command: 'claude',
46
+ command: "claude",
40
47
  args: [
41
- '-p',
42
- '--verbose',
43
- '--output-format',
44
- 'stream-json',
45
- '--include-partial-messages',
46
- '--permission-mode',
47
- 'auto',
48
- '--model',
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
- '--effort',
51
- 'medium',
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
- '--settings',
62
+ "--settings",
56
63
  '{"enabledPlugins": {}}',
57
- '--strict-mcp-config',
58
- ...(role === 'task' ? ['--append-system-prompt', CLAUDE_TASK_SYSTEM_REMINDER] : []),
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: 'cursor-agent',
73
+ command: "cursor-agent",
65
74
  args: [
66
- '-p',
67
- '--output-format',
68
- 'stream-json',
69
- '--stream-partial-output',
70
- '--trust',
71
- '--model',
72
- 'composer-2.5-fast',
73
- ...(role === 'router' ? ['--mode', 'ask'] : ['--force']),
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 = '```castle-';
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 = ['done', 'failed', 'interrupted'];
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, 'utf8'));
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('```' + name + '[ \\t]*\\r?\\n([\\s\\S]*?)```', 'g');
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, 'castle-done', checkoffs), 'castle-stop', stops);
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, '').split('\n');
140
- const title = (lines.shift() ?? '').trim();
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] ?? '').trim());
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('\n').trim();
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 { cleaned: cleaned.replace(/\n{3,}/g, '\n\n').trim(), directives, checkoffs, stops };
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
- // Claude names tools directly (Read, Edit, Bash, ...).
161
- function claudeToolActivityLabel(name) {
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 (['read', 'glob', 'grep', 'ls', 'webfetch', 'websearch'].some((p) => kind.startsWith(p))) {
164
- return 'reading the deck';
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 (['edit', 'write', 'notebookedit', 'multiedit'].some((p) => kind.startsWith(p))) {
167
- return 'editing files';
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
- if (kind.startsWith('bash'))
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 ? Object.keys(call).find((k) => k.endsWith('ToolCall')) : undefined;
180
- const kind = (key ?? '').slice(0, -'ToolCall'.length).toLowerCase();
181
- if (['read', 'glob', 'grep', 'ls', 'list'].some((p) => kind.startsWith(p))) {
182
- return 'reading the deck';
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
- if (['write', 'edit', 'delete', 'mv'].some((p) => kind.startsWith(p)))
185
- return 'editing files';
186
- if (['shell', 'bash', 'terminal'].some((p) => kind.startsWith(p)))
187
- return 'running a command';
188
- return 'working';
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: process.env,
200
- stdio: ['ignore', 'pipe', 'pipe'],
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 ? fs.createWriteStream(opts.logPath, { flags: 'a' }) : null;
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('SIGKILL');
300
+ child.kill("SIGKILL");
224
301
  }
225
302
  catch {
226
303
  /* already gone */
227
304
  }
228
- settle({ ok: false, finalText: finalText || accumulated, error: 'agent run timed out' });
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('\n\n')) {
243
- delta = (accumulated.endsWith('\n') ? '\n' : '\n\n') + delta;
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 === 'stream_event') {
337
+ if (ev.type === "stream_event") {
253
338
  const e = ev.event;
254
- if (e?.type === 'content_block_start') {
255
- if (e.content_block?.type === 'tool_use') {
339
+ if (e?.type === "content_block_start") {
340
+ if (e.content_block?.type === "tool_use") {
256
341
  needsGap = true;
257
- opts.onActivity?.(claudeToolActivityLabel(String(e.content_block.name ?? '')));
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 === 'thinking') {
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 === 'content_block_delta') {
265
- if (e.delta?.type === 'text_delta' && typeof e.delta.text === 'string' && e.delta.text) {
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 === 'thinking_delta') {
269
- opts.onActivity?.('thinking');
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 === 'result') {
390
+ else if (ev.type === "result") {
274
391
  sawResult = true;
275
- finalText = typeof ev.result === 'string' ? ev.result : accumulated;
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 === 'claude') {
397
+ if (opts.parser === "claude") {
281
398
  handleClaudeEvent(ev);
282
399
  return;
283
400
  }
284
- if (ev.type === 'assistant' && typeof ev.timestamp_ms === 'number') {
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 === 'string' ? 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 === 'tool_call') {
297
- segmentText = '';
413
+ else if (ev.type === "tool_call") {
414
+ segmentText = "";
298
415
  needsGap = true;
299
- if (ev.subtype === 'started')
416
+ if (ev.subtype === "started")
300
417
  opts.onActivity?.(toolActivityLabel(ev));
301
418
  }
302
- else if (ev.type === 'thinking') {
303
- segmentText = '';
419
+ else if (ev.type === "thinking") {
420
+ segmentText = "";
304
421
  needsGap = true;
305
- opts.onActivity?.('thinking');
422
+ opts.onActivity?.("thinking");
306
423
  }
307
- else if (ev.type === 'result') {
424
+ else if (ev.type === "result") {
308
425
  sawResult = true;
309
- finalText = typeof ev.result === 'string' ? ev.result : accumulated;
426
+ finalText = typeof ev.result === "string" ? ev.result : accumulated;
310
427
  resultIsError = ev.is_error === true;
311
428
  }
312
429
  };
313
- child.stdout.on('data', (chunk) => {
314
- lineBuffer += chunk.toString('utf8');
315
- let nl = lineBuffer.indexOf('\n');
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 + '\n');
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('\n');
445
+ nl = lineBuffer.indexOf("\n");
329
446
  }
330
447
  });
331
- child.stderr.on('data', (chunk) => {
332
- stderrTail = (stderrTail + chunk.toString('utf8')).slice(-2000);
448
+ child.stderr.on("data", (chunk) => {
449
+ stderrTail = (stderrTail + chunk.toString("utf8")).slice(-2000);
333
450
  });
334
- child.on('error', (err) => {
335
- settle({ ok: false, finalText: accumulated, error: `could not run cursor-agent: ${err.message}` });
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('close', (code) => {
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 ? undefined : `agent exited ${code}${stderrTail ? `: ${stderrTail.slice(-300)}` : ''}`,
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, 'task.json'), JSON.stringify(task, null, 2) + '\n');
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, 'task.json'));
479
+ const rec = readJsonFile(path.join(tasksDir, entry, "task.json"));
357
480
  if (!rec)
358
481
  continue;
359
- if (rec.status === 'running') {
360
- rec.status = 'interrupted';
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.readFileSync(path.join(dir, 'progress'), 'utf8').trim();
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, 'notes.md'), 'utf8');
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('\n') || undefined;
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, 'progress'),
432
- notesPath: path.join(relDir, 'notes.md'),
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, 'task', taskPrompt, ctx.claudeModel);
443
- let result = { ok: false, finalText: '', error: 'not run' };
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('\n');
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('\n');
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, 'log.jsonl'),
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 === 'running')
633
+ if (t.status === "running")
508
634
  n++;
509
635
  return n;
510
636
  }
511
637
  function maybeStart(task) {
512
- if (task.status !== 'waiting' || task.acknowledged || !depsAreSettled(task))
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, 'progress'), '0\n');
524
- if (!fs.existsSync(path.join(dir, 'notes.md')))
525
- fs.writeFileSync(path.join(dir, 'notes.md'), '');
526
- task.status = 'running';
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 ? 'interrupted' : result.ok ? 'done' : 'failed';
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
- ? 'stopped by the router'
679
+ ? "stopped by the router"
554
680
  : result.ok
555
681
  ? result.finalText.slice(-RESULT_SUMMARY_CHARS)
556
- : `${result.error ?? 'failed'}\n${result.finalText.slice(-RESULT_SUMMARY_CHARS)}`;
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: 'waiting',
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 !== 'running')
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 === 'running' || task.status === 'waiting') {
607
- task.status = 'interrupted';
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() === 'all' || t === '*');
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()].filter((t) => isTerminal(t.status) && !t.acknowledged).map((t) => t.id)
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 === 'waiting') {
637
- task.status = 'interrupted';
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 === 'running') {
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('SIGKILL');
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 === 'running' || t.status === 'waiting')
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 { sorted, get: (id) => tasks.get(id), spawnFromDirective, acknowledge, checkOff, stop, shutdown };
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: 'image/png',
672
- jpg: 'image/jpeg',
673
- jpeg: 'image/jpeg',
674
- gif: 'image/gif',
675
- webp: 'image/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 !== 'string' || dataUrl.length > MAX_ATTACHMENT_BYTES * 1.4)
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] === 'jpeg' ? 'jpg' : 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], 'base64'));
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 ? 'rejected by user' : task.status,
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: 'task-feed', id: task.id, entry });
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() !== '' || m.role === 'user')
733
- .map((m) => (m.status === 'streaming' ? { ...m, status: 'done' } : 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) + '\n');
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: 'message-add', message });
877
+ broadcast({ type: "message-add", message });
741
878
  }
742
879
  function addLog(text) {
743
- add({ id: nanoid(8), role: 'log', text, at: nowIso(), status: 'done' });
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 === 'log' && last.text.startsWith(prefix)) {
885
+ if (last && last.role === "log" && last.text.startsWith(prefix)) {
749
886
  last.text += `, ${item}`;
750
887
  persist();
751
- broadcast({ type: 'message-done', id: last.id, text: last.text, status: 'done' });
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('.').pop() ?? '';
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, { 'content-type': mime, 'cache-control': 'no-store' });
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: 'assistant',
783
- text: '',
924
+ role: "assistant",
925
+ text: "",
784
926
  at: nowIso(),
785
- status: 'streaming',
927
+ status: "streaming",
786
928
  };
787
929
  ctx.log.messages.push(message);
788
- ctx.broadcast({ type: 'message-add', message });
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 !== 'log' && m.id !== message.id && m.status !== 'streaming')
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, 'router', prompt, ctx.claudeModel());
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, 'router-log.jsonl'),
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: 'message-delta', id: message.id, delta: slice });
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: 'message-activity', id: message.id, activity });
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 = 'done';
982
+ message.status = "done";
841
983
  message.interrupted = true;
842
984
  ctx.log.persist();
843
985
  ctx.broadcast({
844
- type: 'message-done',
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 === 'running' || t.status === 'waiting')
1005
+ .filter((t) => t.status === "running" || t.status === "waiting")
864
1006
  .map((t) => t.title.toLowerCase()));
865
- const toSpawn = stale ? [] : directives.filter((d) => !inFlight.has(d.title.toLowerCase()));
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 + '\n\n' : ''}[router error: ${result.error ?? 'unknown'}]`;
870
- message.status = result.ok ? 'done' : 'error';
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: 'message-done',
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 ['router', 'tasks']) {
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) + '\n');
902
- ctx.broadcast({ type: 'settings', settings });
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 !== 'number')
1051
+ if (typeof entry?.pid !== "number")
908
1052
  continue;
909
1053
  try {
910
- const cmd = execFileSync('ps', ['-p', String(entry.pid), '-o', 'command='], {
911
- encoding: 'utf8',
1054
+ const cmd = execFileSync("ps", ["-p", String(entry.pid), "-o", "command="], {
1055
+ encoding: "utf8",
912
1056
  }).trim();
913
- if (cmd.includes('cursor-agent') || cmd.includes('claude')) {
914
- process.kill(entry.pid, 'SIGKILL');
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, '[]\n');
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 === 'number' && child.exitCode === null) {
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 + '\n');
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, '[]\n');
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, '.castle', 'agent');
963
- const tasksDir = path.join(agentDir, 'tasks');
964
- const attachmentsDir = path.join(agentDir, 'attachments');
965
- const messagesPath = path.join(agentDir, 'messages.json');
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, 'children.json');
1116
+ const childRegistryPath = path.join(agentDir, "children.json");
973
1117
  killOrphanAgents(childRegistryPath);
974
- const stopChildRegistry = startChildRegistry(childRegistryPath, [taskChildren, routerChildren]);
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, 'settings.json');
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) ?? DEFAULT_SETTINGS.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: 'task-update', task }),
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 === 'assistant' && m.status === 'streaming')
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('SIGKILL');
1169
+ child.kill("SIGKILL");
1022
1170
  }
1023
1171
  catch {
1024
1172
  /* already gone */
1025
1173
  }
1026
1174
  }
1027
- return drafts.join('\n\n');
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 === 'assistant' && m.status === 'done' && !m.interrupted) {
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 === 'user');
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: 'user',
1212
+ role: "user",
1065
1213
  text,
1066
1214
  at: nowIso(),
1067
- status: 'done',
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('.castle', 'agent', 'attachments', name)),
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 = { type: 'hello', messages, tasks: taskStore.sorted(), settings, feeds: Object.fromEntries(taskFeeds.map) };
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('message', (rawData) => {
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 === 'string' && msg.text.trim() !== '';
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 === 'user-message' && (hasText || hasImages)) {
1102
- handleUserMessage(typeof msg.text === 'string' ? msg.text.trim() : '', msg.images);
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 === 'task-ack' && typeof msg.id === 'string') {
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 === 'set-settings') {
1261
+ else if (msg.type === "set-settings") {
1108
1262
  applySettings(msg);
1109
1263
  }
1110
1264
  });
1111
- socket.on('close', () => {
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 ?? '/', 'http://localhost');
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('SIGKILL');
1281
+ child.kill("SIGKILL");
1128
1282
  }
1129
1283
  catch {
1130
1284
  /* already gone */