castle-web-cli 0.4.59 → 0.4.61

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