castle-web-cli 0.4.115 → 0.4.116

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.
Files changed (55) hide show
  1. package/dist/agent-prompts.js +2 -2
  2. package/dist/agent.d.ts +9 -9
  3. package/dist/agent.js +590 -589
  4. package/dist/castleJson.d.ts +6 -0
  5. package/dist/castleJson.js +10 -0
  6. package/dist/editorConfig.js +27 -3
  7. package/dist/imports.d.ts +4 -0
  8. package/dist/imports.js +59 -10
  9. package/dist/init.d.ts +3 -0
  10. package/dist/init.js +99 -87
  11. package/dist/install.d.ts +1 -1
  12. package/dist/install.js +26 -24
  13. package/dist/shell/assets/{index-CUamb8rK.js → index-DiPlPGyg.js} +2 -2
  14. package/dist/shell/index.html +1 -1
  15. package/dist/vitePlugins.js +1 -1
  16. package/kits/physics-2d/CLAUDE.md +28 -16
  17. package/kits/physics-2d/behaviors/Joints.jsx +1 -1
  18. package/kits/physics-2d/behaviors/Sprite.jsx +17 -12
  19. package/kits/physics-2d/blueprints/ball.scene +1 -1
  20. package/kits/physics-2d/blueprints/block.scene +1 -1
  21. package/kits/physics-2d/blueprints/cauldron.scene +1 -1
  22. package/kits/physics-2d/blueprints/crate.scene +1 -1
  23. package/kits/physics-2d/castle.json +11 -3
  24. package/kits/physics-2d/docs/pxart-format.md +537 -51
  25. package/kits/physics-2d/editors/PxArtEditor.jsx +1794 -65
  26. package/kits/physics-2d/editors/brushFit.js +535 -0
  27. package/kits/physics-2d/editors/brushShapes.js +140 -0
  28. package/kits/physics-2d/editors/mediaFile.js +12 -1
  29. package/kits/physics-2d/editors/pathOverlay.js +340 -0
  30. package/kits/physics-2d/editors/pathTools.js +1906 -0
  31. package/kits/physics-2d/editors/pixelCanvas.js +13 -0
  32. package/kits/physics-2d/editors/pixelEditorChrome.jsx +2 -2
  33. package/kits/physics-2d/editors/pixelGeometry.js +4 -2
  34. package/kits/physics-2d/editors/pixelInspector.jsx +410 -37
  35. package/kits/physics-2d/editors/pxArtEditorModel.js +172 -16
  36. package/kits/physics-2d/editors/pxArtTimeline.jsx +163 -43
  37. package/kits/physics-2d/editors/pxArtTimeline.module.css +31 -5
  38. package/kits/physics-2d/engine/assets.js +1 -1
  39. package/kits/physics-2d/engine/blueprint.js +3 -3
  40. package/kits/physics-2d/engine/files.js +3 -1
  41. package/kits/physics-2d/engine/liveReload.js +1 -1
  42. package/kits/physics-2d/engine/physics/jointArt.js +3 -3
  43. package/kits/physics-2d/engine/pxart.js +153 -35
  44. package/kits/physics-2d/engine/pxartPath.js +1356 -0
  45. package/kits/physics-2d/engine/pxartSmooth.js +276 -125
  46. package/kits/physics-2d/engine/ui.jsx +22 -1
  47. package/kits/physics-2d/engine/ui.module.css +36 -12
  48. package/kits/physics-2d/package-lock.json +1 -1
  49. package/kits/physics-2d/scripts/draw.mjs +7 -7
  50. package/kits/physics-2d/scripts/import-svg.mjs +1231 -0
  51. package/kits/physics-2d/scripts/svg-emission-guide.md +92 -0
  52. package/package.json +1 -1
  53. /package/kits/physics-2d/drawings/{block.pxart → block.sprite} +0 -0
  54. /package/kits/physics-2d/drawings/{cauldron.pxart → cauldron.sprite} +0 -0
  55. /package/kits/physics-2d/drawings/{joint-rope.pxart → joint-rope.sprite} +0 -0
package/dist/agent.js CHANGED
@@ -14,67 +14,66 @@
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
- import { checkOpenrouterKey, checkOpenrouterModel, openrouterCatalogEntry, primeOpenrouterCatalog, } from "./openrouter-catalog.js";
26
- import { classifyProviderError, failureCopy, setReaderTimeZone, } from "./agent-failures.js";
27
- import { fetchBudget, meteringHeaders, newAgentSessionId, reportCursorRun, withCustomHeaders, } from "./metering.js";
28
- import { anthropicKeyHelperCommand, claudeHasSavedLogin, cursorAuthPath, cursorHasUserLogin, resolveAnthropicAuth, userKey, ANTHROPIC_CREDENTIAL_ENV, ANTHROPIC_PROXY_ENV, CASTLE_USER_KEYS_PATH, } from "./byo-auth.js";
29
- import { accountsSnapshot, loginProviderFor, watchCredentials, writeCredential, } from "./byo-accounts.js";
30
- import { cancelLogin, logout, startLogin, submitLoginCode, } from "./byo-login.js";
31
- import { runAgentNative } from "./native/loop.js";
32
- import { createPlaytestBrowserManager } from "./native/playtest-browser.js";
33
- import { createPlaywrightPlaytestExecutor } from "./native/playtest-executor.js";
34
- export const AGENT_WS_PATH = "/__castle/agent";
35
- export const AGENT_ATTACHMENT_PREFIX = "/__castle/agent/attachments/";
17
+ import { execFileSync, spawn } from 'child_process';
18
+ import { IMPORTS_DIR } from './imports.js';
19
+ import * as fs from 'fs';
20
+ import * as os from 'os';
21
+ import * as path from 'path';
22
+ import { nanoid } from 'nanoid';
23
+ import { WebSocketServer } from 'ws';
24
+ import { rawDataToString } from './ide.js';
25
+ import { buildRouterPrompt, buildTaskPrompt, userTurnInstruction, CLAUDE_TASK_SYSTEM_REMINDER, } from './agent-prompts.js';
26
+ import { checkOpenrouterKey, checkOpenrouterModel, openrouterCatalogEntry, primeOpenrouterCatalog, } from './openrouter-catalog.js';
27
+ import { classifyProviderError, failureCopy, setReaderTimeZone, } from './agent-failures.js';
28
+ import { fetchBudget, meteringHeaders, newAgentSessionId, reportCursorRun, withCustomHeaders, } from './metering.js';
29
+ import { anthropicKeyHelperCommand, claudeHasSavedLogin, cursorAuthPath, cursorHasUserLogin, resolveAnthropicAuth, userKey, ANTHROPIC_CREDENTIAL_ENV, ANTHROPIC_PROXY_ENV, CASTLE_USER_KEYS_PATH, } from './byo-auth.js';
30
+ import { accountsSnapshot, loginProviderFor, watchCredentials, writeCredential, } from './byo-accounts.js';
31
+ import { cancelLogin, logout, startLogin, submitLoginCode } from './byo-login.js';
32
+ import { runAgentNative } from './native/loop.js';
33
+ import { createPlaytestBrowserManager, } from './native/playtest-browser.js';
34
+ import { createPlaywrightPlaytestExecutor } from './native/playtest-executor.js';
35
+ export const AGENT_WS_PATH = '/__castle/agent';
36
+ export const AGENT_ATTACHMENT_PREFIX = '/__castle/agent/attachments/';
36
37
  // Playtest frame PNGs (tasks/<id>/playtest/<file>.png), served for the
37
38
  // finished-task card's thumbnails -- see makePlaytestFrameHandler.
38
- export const AGENT_PLAYTEST_PREFIX = "/__castle/agent/playtest/";
39
+ export const AGENT_PLAYTEST_PREFIX = '/__castle/agent/playtest/';
39
40
  // Same-origin proxy for OpenRouter model capabilities (avoids browser CORS
40
41
  // against openrouter.ai). GET ?model=<slug> -> ModelCaps JSON. Powers the
41
42
  // settings popover's dynamic reasoning-effort / provider-tier pickers.
42
- export const AGENT_MODEL_CAPS_PREFIX = "/__castle/agent/model-caps";
43
+ export const AGENT_MODEL_CAPS_PREFIX = '/__castle/agent/model-caps';
43
44
  const DEFAULT_SETTINGS = {
44
- router: "claude",
45
- tasks: "claude",
45
+ router: 'claude',
46
+ tasks: 'claude',
46
47
  // Both roles run the claude CLI routed through OpenRouter at the slug below.
47
48
  // Note this takes the Anthropic credential out of play entirely: a role on
48
49
  // "openrouter" resolves OpenRouter auth, so a user's `claude /login` or
49
50
  // ANTHROPIC_API_KEY no longer applies (see buildAgentInvocation).
50
- routerClaudeModel: "openrouter",
51
- tasksClaudeModel: "openrouter",
51
+ routerClaudeModel: 'openrouter',
52
+ tasksClaudeModel: 'openrouter',
52
53
  // Free-form -- change to any OpenRouter slug.
53
- routerOpenrouterModel: "openai/gpt-5.6-terra",
54
- tasksOpenrouterModel: "openai/gpt-5.6-terra",
54
+ routerOpenrouterModel: 'openai/gpt-5.6-terra',
55
+ tasksOpenrouterModel: 'openai/gpt-5.6-terra',
55
56
  // Both roles think at "medium": the operator stays snappy (the user waits
56
57
  // on every operator turn), and task agents' multi-turn tool loops don't pay
57
58
  // reasoning tax on mechanical read/edit/run turns. Deep decomposition
58
59
  // quality comes from the operator prompt, not a higher effort default.
59
- routerReasoningEffort: "medium",
60
- tasksReasoningEffort: "medium",
60
+ routerReasoningEffort: 'medium',
61
+ tasksReasoningEffort: 'medium',
61
62
  // Routing splits by what each role optimizes for: the interactive operator
62
63
  // routes for speed (nitro = throughput-sorted endpoints), unattended task
63
64
  // agents route for correctness (exacto = benchmark-accurate endpoints,
64
65
  // which matters for tool-calling fidelity over long loops).
65
- routerRouting: "nitro",
66
- tasksRouting: "exacto",
66
+ routerRouting: 'nitro',
67
+ tasksRouting: 'exacto',
67
68
  // Operator pins OpenAI's priority (low-latency SLA) tier; harmless with a
68
69
  // slug that lacks it since the pin falls back when the tag doesn't exist
69
70
  // (allow_fallbacks). Tasks stay on auto: high-volume background turns
70
71
  // should ride the cheapest available capacity.
71
- routerProviderTier: "openai/priority",
72
- tasksProviderTier: "",
72
+ routerProviderTier: 'openai/priority',
73
+ tasksProviderTier: '',
73
74
  };
74
75
  function normalizeBackend(value) {
75
- return value === "cursor" || value === "claude" || value === "smith"
76
- ? value
77
- : null;
76
+ return value === 'cursor' || value === 'claude' || value === 'smith' ? value : null;
78
77
  }
79
78
  // Bump to force every deck onto the current DEFAULT_SETTINGS once, discarding
80
79
  // what users had chosen: a stored file below this epoch has ALL of its setting
@@ -85,10 +84,7 @@ function normalizeBackend(value) {
85
84
  // only worth writing if it must survive the epoch that introduces it.
86
85
  const SETTINGS_EPOCH = 1;
87
86
  function normalizeClaudeModel(value) {
88
- return value === "sonnet" ||
89
- value === "opus" ||
90
- value === "fable" ||
91
- value === "openrouter"
87
+ return value === 'sonnet' || value === 'opus' || value === 'fable' || value === 'openrouter'
92
88
  ? value
93
89
  : null;
94
90
  }
@@ -97,9 +93,9 @@ function normalizeClaudeModel(value) {
97
93
  // is the one place that knows both, so the model picker and the pre-flight
98
94
  // refusal can't disagree about which models a user actually has.
99
95
  const CLAUDE_MODEL_ID_PREFIXES = {
100
- sonnet: "claude-sonnet-",
101
- opus: "claude-opus-",
102
- fable: "claude-fable-",
96
+ sonnet: 'claude-sonnet-',
97
+ opus: 'claude-opus-',
98
+ fable: 'claude-fable-',
103
99
  };
104
100
  // Blocked when the two prefixes agree as far as the shorter one goes: the proxy
105
101
  // may name a family ("claude-fable-") or one model within it.
@@ -113,7 +109,7 @@ function claudeModelBlocked(model, budget) {
113
109
  // against a stray huge paste landing in settings.json / the CLI argv).
114
110
  const OPENROUTER_MODEL_MAX_LEN = 200;
115
111
  function normalizeOpenrouterModel(value) {
116
- if (typeof value !== "string")
112
+ if (typeof value !== 'string')
117
113
  return null;
118
114
  const trimmed = value.trim();
119
115
  return trimmed && trimmed.length <= OPENROUTER_MODEL_MAX_LEN ? trimmed : null;
@@ -123,29 +119,22 @@ function normalizeOpenrouterModel(value) {
123
119
  // fetches them from the model-caps endpoint to build the picker) and
124
120
  // OpenRouter maps an unsupported level to the nearest one anyway.
125
121
  const REASONING_EFFORTS = [
126
- "none",
127
- "minimal",
128
- "low",
129
- "medium",
130
- "high",
131
- "xhigh",
132
- "max",
122
+ 'none',
123
+ 'minimal',
124
+ 'low',
125
+ 'medium',
126
+ 'high',
127
+ 'xhigh',
128
+ 'max',
133
129
  ];
134
130
  function normalizeReasoningEffort(value) {
135
131
  return REASONING_EFFORTS.includes(value)
136
132
  ? value
137
133
  : null;
138
134
  }
139
- const ROUTING_MODES = [
140
- "balanced",
141
- "nitro",
142
- "exacto",
143
- "floor",
144
- ];
135
+ const ROUTING_MODES = ['balanced', 'nitro', 'exacto', 'floor'];
145
136
  function normalizeRoutingMode(value) {
146
- return ROUTING_MODES.includes(value)
147
- ? value
148
- : null;
137
+ return ROUTING_MODES.includes(value) ? value : null;
149
138
  }
150
139
  // Provider tier is an OpenRouter endpoint `tag` ("openai/flex", "azure/eu",
151
140
  // ...) which is model-specific, so validation is loose like the model slug.
@@ -153,7 +142,7 @@ function normalizeRoutingMode(value) {
153
142
  // this returns "" rather than null for the clear case -- callers must treat
154
143
  // null (invalid) and "" (clear) differently.
155
144
  function normalizeProviderTier(value) {
156
- if (typeof value !== "string")
145
+ if (typeof value !== 'string')
157
146
  return null;
158
147
  const trimmed = value.trim();
159
148
  if (trimmed.length > OPENROUTER_MODEL_MAX_LEN)
@@ -193,11 +182,11 @@ function serializeAgentSettings(settings) {
193
182
  if (settings[key] !== DEFAULT_SETTINGS[key])
194
183
  out[key] = settings[key];
195
184
  }
196
- return JSON.stringify(out, null, 2) + "\n";
185
+ return JSON.stringify(out, null, 2) + '\n';
197
186
  }
198
187
  function loadAgentSettings(settingsPath) {
199
188
  const stored = readJsonFile(settingsPath);
200
- const storedEpoch = typeof stored?.settingsEpoch === "number" ? stored.settingsEpoch : 0;
189
+ const storedEpoch = typeof stored?.settingsEpoch === 'number' ? stored.settingsEpoch : 0;
201
190
  // A stale file is ignored, not rewritten: leaving it means the wipe is a
202
191
  // pure read-side decision that repeats harmlessly until the user's next real
203
192
  // change overwrites it in the sparse shape. A newer epoch (a downgraded CLI
@@ -225,7 +214,7 @@ function loadAgentSettings(settingsPath) {
225
214
  // the /v1 the CLI re-adds. Otherwise openrouter.ai directly.
226
215
  function openrouterAnthropicBase() {
227
216
  const injected = process.env.OPENROUTER_BASE_URL;
228
- return injected ? injected.replace(/\/v1\/?$/, "") : "https://openrouter.ai/api";
217
+ return injected ? injected.replace(/\/v1\/?$/, '') : 'https://openrouter.ai/api';
229
218
  }
230
219
  // Env for a claude CLI spawn routed at OpenRouter (claudeModel "openrouter",
231
220
  // Path A). Two things make this deterministic regardless of the user's own
@@ -256,21 +245,21 @@ function openrouterAnthropicBase() {
256
245
  // ANTHROPIC_BASE_URL for a user's OWN OpenRouter key -- straight to openrouter.ai
257
246
  // (the CLI appends /v1/messages), bypassing the proxy. The proxy branch instead
258
247
  // uses openrouterAnthropicBase() (the injected OPENROUTER_BASE_URL origin).
259
- const OPENROUTER_DIRECT_ANTHROPIC_BASE = "https://openrouter.ai/api";
248
+ const OPENROUTER_DIRECT_ANTHROPIC_BASE = 'https://openrouter.ai/api';
260
249
  // OpenAI-shaped chat-completions ORIGIN for the smith native loop on a user's
261
250
  // own OpenRouter key (native/openrouter.ts appends /chat/completions).
262
- const OPENROUTER_DIRECT_CHAT_BASE = "https://openrouter.ai/api/v1";
251
+ const OPENROUTER_DIRECT_CHAT_BASE = 'https://openrouter.ai/api/v1';
263
252
  function envForOpenrouterSpawn(auth) {
264
253
  if (!auth.key) {
265
254
  // Unreachable via runAgentTurn (pre-flight rejects a keyless run before
266
255
  // any spawn). A backstop, so a future caller that skips pre-flight fails
267
256
  // loudly instead of quietly leaking.
268
- throw new Error("envForOpenrouterSpawn: refusing to spawn without an OpenRouter key");
257
+ throw new Error('envForOpenrouterSpawn: refusing to spawn without an OpenRouter key');
269
258
  }
270
259
  const env = { ...process.env };
271
260
  for (const name of ANTHROPIC_CREDENTIAL_ENV)
272
261
  delete env[name];
273
- if (auth.mode === "user-key") {
262
+ if (auth.mode === 'user-key') {
274
263
  env.ANTHROPIC_BASE_URL = OPENROUTER_DIRECT_ANTHROPIC_BASE;
275
264
  // An inherited ANTHROPIC_CUSTOM_HEADERS can carry x-castle-* metering
276
265
  // lines (see ANTHROPIC_PROXY_ENV) -- never forward those on a direct run.
@@ -279,7 +268,7 @@ function envForOpenrouterSpawn(auth) {
279
268
  else {
280
269
  env.ANTHROPIC_BASE_URL = openrouterAnthropicBase();
281
270
  }
282
- env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
271
+ env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = '1';
283
272
  env.ANTHROPIC_AUTH_TOKEN = auth.key;
284
273
  return env;
285
274
  }
@@ -288,15 +277,15 @@ function envForOpenrouterSpawn(auth) {
288
277
  // claude-via-OpenRouter spawn sends it as ANTHROPIC_AUTH_TOKEN (see
289
278
  // envForOpenrouterSpawn). Castle-provided key file first, env fallback --
290
279
  // same sourcing order as envForAgentSpawn.
291
- const OPENROUTER_KEY_NAME = "OPENROUTER_API_KEY";
280
+ const OPENROUTER_KEY_NAME = 'OPENROUTER_API_KEY';
292
281
  function openrouterApiKey() {
293
- return castleKeys()[OPENROUTER_KEY_NAME] ?? process.env[OPENROUTER_KEY_NAME] ?? "";
282
+ return castleKeys()[OPENROUTER_KEY_NAME] ?? process.env[OPENROUTER_KEY_NAME] ?? '';
294
283
  }
295
284
  const MODEL_CAPS_TTL_MS = 10 * 60_000;
296
- const OPENROUTER_API_BASE = "https://openrouter.ai/api/v1";
285
+ const OPENROUTER_API_BASE = 'https://openrouter.ai/api/v1';
297
286
  const modelCapsCache = new Map();
298
287
  function asRecord(v) {
299
- return v && typeof v === "object" ? v : null;
288
+ return v && typeof v === 'object' ? v : null;
300
289
  }
301
290
  async function openrouterProviderTiers(slug) {
302
291
  const res = await fetch(`${OPENROUTER_API_BASE}/models/${slug}/endpoints`);
@@ -308,7 +297,7 @@ async function openrouterProviderTiers(slug) {
308
297
  const tags = [];
309
298
  for (const ep of endpoints) {
310
299
  const rec = asRecord(ep);
311
- const tag = rec && typeof rec.tag === "string" ? rec.tag : null;
300
+ const tag = rec && typeof rec.tag === 'string' ? rec.tag : null;
312
301
  if (tag && !tags.includes(tag))
313
302
  tags.push(tag);
314
303
  }
@@ -332,14 +321,11 @@ async function fetchModelCaps(slug) {
332
321
  const efforts = reasoning?.supported_efforts;
333
322
  const supportedParams = entry?.supportedParameters;
334
323
  const acceptsEffort = Array.isArray(supportedParams) &&
335
- (supportedParams.includes("reasoning_effort") ||
336
- supportedParams.includes("reasoning"));
324
+ (supportedParams.includes('reasoning_effort') || supportedParams.includes('reasoning'));
337
325
  if (acceptsEffort && Array.isArray(efforts) && efforts.length > 0) {
338
- reasoningEfforts = efforts.filter((e) => typeof e === "string");
326
+ reasoningEfforts = efforts.filter((e) => typeof e === 'string');
339
327
  defaultEffort =
340
- typeof reasoning?.default_effort === "string"
341
- ? reasoning.default_effort
342
- : null;
328
+ typeof reasoning?.default_effort === 'string' ? reasoning.default_effort : null;
343
329
  }
344
330
  }
345
331
  catch {
@@ -367,25 +353,25 @@ async function fetchModelCaps(slug) {
367
353
  function handleModelCaps(req, res) {
368
354
  const send = (status, body) => {
369
355
  res.writeHead(status, {
370
- "content-type": "application/json",
371
- "cache-control": "no-store",
356
+ 'content-type': 'application/json',
357
+ 'cache-control': 'no-store',
372
358
  });
373
359
  res.end(JSON.stringify(body));
374
360
  return true;
375
361
  };
376
- let slug = "";
362
+ let slug = '';
377
363
  try {
378
- slug = (new URL(req.url ?? "", "http://localhost").searchParams.get("model") ?? "").trim();
364
+ slug = (new URL(req.url ?? '', 'http://localhost').searchParams.get('model') ?? '').trim();
379
365
  }
380
366
  catch {
381
- slug = "";
367
+ slug = '';
382
368
  }
383
369
  if (!slug || slug.length > OPENROUTER_MODEL_MAX_LEN) {
384
- return send(400, { error: "missing or invalid model" });
370
+ return send(400, { error: 'missing or invalid model' });
385
371
  }
386
372
  // Strip any routing suffix the client may have on the displayed slug so the
387
373
  // OpenRouter lookup hits the base model id.
388
- const baseSlug = slug.replace(/:(nitro|exacto|floor)$/, "");
374
+ const baseSlug = slug.replace(/:(nitro|exacto|floor)$/, '');
389
375
  fetchModelCaps(baseSlug)
390
376
  .then((caps) => send(200, caps))
391
377
  .catch(() => send(200, {
@@ -418,10 +404,10 @@ function handleModelCaps(req, res) {
418
404
  // to plain-claude spawns, where auto still resolves correctly.
419
405
  // Single `=` token because --allowedTools is variadic and would otherwise
420
406
  // swallow the trailing prompt positional.
421
- const OPENROUTER_ALLOWED_TOOLS = "--allowedTools=Edit,Write,NotebookEdit,Bash";
407
+ const OPENROUTER_ALLOWED_TOOLS = '--allowedTools=Edit,Write,NotebookEdit,Bash';
422
408
  // Cursor's proprietary model. Also the slug reported to the metering ledger, so
423
409
  // the two can never drift into disagreeing about what a cursor row ran on.
424
- const CURSOR_MODEL = "composer-2.5-fast";
410
+ const CURSOR_MODEL = 'composer-2.5-fast';
425
411
  // Keep runs independent of the machine's user config: no user plugins (LSP
426
412
  // servers etc.), no user MCP servers. CLAUDE.md auto-discovery and OAuth still
427
413
  // work. On a user's own Anthropic KEY this also carries the apiKeyHelper that
@@ -429,9 +415,7 @@ const CURSOR_MODEL = "composer-2.5-fast";
429
415
  function claudeSettingsArg(auth) {
430
416
  return JSON.stringify({
431
417
  enabledPlugins: {},
432
- ...(auth?.mode === "user-key"
433
- ? { apiKeyHelper: anthropicKeyHelperCommand() }
434
- : {}),
418
+ ...(auth?.mode === 'user-key' ? { apiKeyHelper: anthropicKeyHelperCommand() } : {}),
435
419
  });
436
420
  }
437
421
  function buildAgentInvocation(backend, role, prompt, claudeModel,
@@ -442,74 +426,68 @@ openrouterModel,
442
426
  // Only the claude branch can carry it: cursor-agent runs on its own key and
443
427
  // never traverses the llm-proxy.
444
428
  metering) {
445
- if (backend === "claude") {
446
- const viaOpenrouter = claudeModel === "openrouter";
429
+ if (backend === 'claude') {
430
+ const viaOpenrouter = claudeModel === 'openrouter';
447
431
  const orAuth = viaOpenrouter ? resolveOpenrouterAuth() : null;
448
432
  const anAuth = viaOpenrouter ? null : resolveAnthropicAuth();
449
433
  // Direct = the user's own credential/login is in play, so this run bypasses
450
434
  // the proxy and must NOT carry metering headers (metering.ts would otherwise
451
435
  // attach them off process.env and the direct spawn would leak them upstream).
452
- const direct = viaOpenrouter
453
- ? orAuth.mode === "user-key"
454
- : anAuth.mode !== "proxy";
436
+ const direct = viaOpenrouter ? orAuth.mode === 'user-key' : anAuth.mode !== 'proxy';
455
437
  return {
456
- command: "claude",
438
+ command: 'claude',
457
439
  args: [
458
- "-p",
459
- "--verbose",
460
- "--output-format",
461
- "stream-json",
462
- "--include-partial-messages",
463
- "--permission-mode",
464
- "auto",
440
+ '-p',
441
+ '--verbose',
442
+ '--output-format',
443
+ 'stream-json',
444
+ '--include-partial-messages',
445
+ '--permission-mode',
446
+ 'auto',
465
447
  ...(viaOpenrouter ? [OPENROUTER_ALLOWED_TOOLS] : []),
466
- "--model",
448
+ '--model',
467
449
  viaOpenrouter ? openrouterModel : claudeModel,
468
- "--effort",
469
- "medium",
450
+ '--effort',
451
+ 'medium',
470
452
  // Newer claude models default thinking display to "omitted" (empty
471
453
  // thinking_delta text, signature only); "summarized" restores actual
472
454
  // summary text so the shell's expandable thinking transcript has
473
455
  // content. Slight time-to-first-text cost (the API streams the
474
456
  // summary before prose). Undocumented in --help but honored.
475
- "--thinking-display",
476
- "summarized",
477
- "--settings",
457
+ '--thinking-display',
458
+ 'summarized',
459
+ '--settings',
478
460
  claudeSettingsArg(anAuth),
479
- "--strict-mcp-config",
480
- ...(role === "task"
481
- ? ["--append-system-prompt", CLAUDE_TASK_SYSTEM_REMINDER]
482
- : []),
461
+ '--strict-mcp-config',
462
+ ...(role === 'task' ? ['--append-system-prompt', CLAUDE_TASK_SYSTEM_REMINDER] : []),
483
463
  prompt,
484
464
  ],
485
- env: withCustomHeaders(viaOpenrouter
486
- ? envForOpenrouterSpawn(orAuth)
487
- : envForClaudeSpawn(anAuth), meteringHeaders({
465
+ env: withCustomHeaders(viaOpenrouter ? envForOpenrouterSpawn(orAuth) : envForClaudeSpawn(anAuth), meteringHeaders({
488
466
  deckDir: metering.deckDir,
489
467
  sessionId: metering.sessionId,
490
- route: viaOpenrouter ? "openrouter" : "anthropic",
468
+ route: viaOpenrouter ? 'openrouter' : 'anthropic',
491
469
  direct,
492
470
  })),
493
471
  };
494
472
  }
495
473
  return {
496
- command: "cursor-agent",
474
+ command: 'cursor-agent',
497
475
  args: [
498
- "-p",
499
- "--output-format",
500
- "stream-json",
501
- "--stream-partial-output",
502
- "--trust",
503
- "--model",
476
+ '-p',
477
+ '--output-format',
478
+ 'stream-json',
479
+ '--stream-partial-output',
480
+ '--trust',
481
+ '--model',
504
482
  CURSOR_MODEL,
505
- ...(role === "router" ? ["--mode", "ask"] : ["--force"]),
483
+ ...(role === 'router' ? ['--mode', 'ask'] : ['--force']),
506
484
  prompt,
507
485
  ],
508
486
  env: envForAgentSpawn(backend),
509
487
  };
510
488
  }
511
489
  function parserForBackend(backend) {
512
- return backend === "cursor" ? "cursor" : "claude";
490
+ return backend === 'cursor' ? 'cursor' : 'claude';
513
491
  }
514
492
  const ROUTER_TIMEOUT_MS = 3 * 60_000;
515
493
  const TASK_TIMEOUT_MS = 30 * 60_000;
@@ -537,11 +515,11 @@ const TASK_RETRY_BACKOFF_BASE_MS = Number(process.env.CASTLE_TASK_RETRY_BACKOFF_
537
515
  // tests / impatient devs.
538
516
  const TASK_SPAWN_STAGGER_MS = Number(process.env.CASTLE_TASK_SPAWN_STAGGER_MS) || 400;
539
517
  const TASK_POLL_MS = 1_000;
540
- const FENCE_HOLDBACK = "```castle-";
518
+ const FENCE_HOLDBACK = '```castle-';
541
519
  const RESULT_SUMMARY_CHARS = 600;
542
520
  const MAX_ATTACHMENTS = 6;
543
521
  const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
544
- const TERMINAL_STATUSES = ["done", "failed", "interrupted"];
522
+ const TERMINAL_STATUSES = ['done', 'failed', 'interrupted'];
545
523
  function nowIso() {
546
524
  return new Date().toISOString();
547
525
  }
@@ -550,7 +528,7 @@ function isTerminal(status) {
550
528
  }
551
529
  function readJsonFile(filePath) {
552
530
  try {
553
- return JSON.parse(fs.readFileSync(filePath, "utf8"));
531
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
554
532
  }
555
533
  catch {
556
534
  return null;
@@ -575,22 +553,22 @@ function visibleLength(raw) {
575
553
  // extraction below and the mid-stream incremental scanner (runRouterTurnIn),
576
554
  // so a fence spawned early behaves identically to one spawned at settle.
577
555
  function parseTaskFenceBody(body) {
578
- const lines = body.replace(/\r/g, "").split("\n");
579
- const title = (lines.shift() ?? "").trim();
556
+ const lines = body.replace(/\r/g, '').split('\n');
557
+ const title = (lines.shift() ?? '').trim();
580
558
  if (!title)
581
559
  return null;
582
560
  const after = [];
583
561
  while (lines.length > 0) {
584
- const headerMatch = /^(after):\s*(.*)$/i.exec((lines[0] ?? "").trim());
562
+ const headerMatch = /^(after):\s*(.*)$/i.exec((lines[0] ?? '').trim());
585
563
  if (!headerMatch)
586
564
  break;
587
565
  lines.shift();
588
566
  after.push(...headerMatch[2]
589
- .split(",")
567
+ .split(',')
590
568
  .map((s) => s.trim())
591
569
  .filter(Boolean));
592
570
  }
593
- return { title, after, prompt: lines.join("\n").trim() };
571
+ return { title, after, prompt: lines.join('\n').trim() };
594
572
  }
595
573
  // A fresh RegExp per call -- this is matched with manual .exec() loops in
596
574
  // TWO independent call sites (settle-time extractDirectives via .replace, and
@@ -606,25 +584,25 @@ function extractDirectives(full) {
606
584
  const checkoffs = [];
607
585
  const stops = [];
608
586
  const listFence = (source, name, into) => {
609
- const re = new RegExp("```" + name + "[ \\t]*\\r?\\n([\\s\\S]*?)```", "g");
587
+ const re = new RegExp('```' + name + '[ \\t]*\\r?\\n([\\s\\S]*?)```', 'g');
610
588
  return source.replace(re, (_match, body) => {
611
589
  for (const token of String(body).split(/[,\n]/)) {
612
590
  const trimmed = token.trim();
613
591
  if (trimmed)
614
592
  into.push(trimmed);
615
593
  }
616
- return "";
594
+ return '';
617
595
  });
618
596
  };
619
- const withoutDone = listFence(listFence(full, "castle-done", checkoffs), "castle-stop", stops);
597
+ const withoutDone = listFence(listFence(full, 'castle-done', checkoffs), 'castle-stop', stops);
620
598
  const cleaned = withoutDone.replace(taskFenceRegex(), (_match, body) => {
621
599
  const directive = parseTaskFenceBody(String(body));
622
600
  if (directive)
623
601
  directives.push(directive);
624
- return "";
602
+ return '';
625
603
  });
626
604
  return {
627
- cleaned: cleaned.replace(/\n{3,}/g, "\n\n").trim(),
605
+ cleaned: cleaned.replace(/\n{3,}/g, '\n\n').trim(),
628
606
  directives,
629
607
  checkoffs,
630
608
  stops,
@@ -658,19 +636,13 @@ function scanNewTaskFences(raw, fromIndex) {
658
636
  // parse it out of the live stream to drive the task card's avatar/phase (and
659
637
  // optionally progress), and strip it from the feed so the raw block never
660
638
  // shows. The enumerated activity avatars must match the prompt + the client.
661
- const SIGNAL_AVATARS = new Set([
662
- "thinking",
663
- "reading",
664
- "building",
665
- "painting",
666
- "playing",
667
- ]);
639
+ const SIGNAL_AVATARS = new Set(['thinking', 'reading', 'building', 'painting', 'playing']);
668
640
  // Parse the `key: value` body of a ```signal block, fail-soft: unknown keys are
669
641
  // ignored, progress is clamped 0-100, avatar must be in the enum or it's
670
642
  // dropped. `note` and `phase` are aliases for the phase line.
671
643
  function parseSignalBody(raw) {
672
644
  const sig = {};
673
- for (const line of raw.split("\n")) {
645
+ for (const line of raw.split('\n')) {
674
646
  const m = /^\s*([a-zA-Z]+)\s*:\s*(.*)$/.exec(line);
675
647
  if (!m)
676
648
  continue;
@@ -678,16 +650,16 @@ function parseSignalBody(raw) {
678
650
  const value = m[2].trim();
679
651
  if (!value)
680
652
  continue;
681
- if (key === "progress") {
653
+ if (key === 'progress') {
682
654
  const n = parseInt(value, 10);
683
655
  if (Number.isFinite(n))
684
656
  sig.progress = Math.max(0, Math.min(100, n));
685
657
  }
686
- else if (key === "avatar") {
658
+ else if (key === 'avatar') {
687
659
  if (SIGNAL_AVATARS.has(value))
688
660
  sig.avatar = value;
689
661
  }
690
- else if (key === "note" || key === "phase") {
662
+ else if (key === 'note' || key === 'phase') {
691
663
  sig.phase = value;
692
664
  }
693
665
  }
@@ -706,20 +678,20 @@ function humanizeAskBlocks(text) {
706
678
  return whole;
707
679
  const lines = data.questions
708
680
  .map((q) => {
709
- if (!q || typeof q.q !== "string" || !Array.isArray(q.options))
681
+ if (!q || typeof q.q !== 'string' || !Array.isArray(q.options))
710
682
  return null;
711
683
  const opts = q.options
712
- .filter((o) => typeof o === "string" && o.trim() !== "")
713
- .join(" / ");
684
+ .filter((o) => typeof o === 'string' && o.trim() !== '')
685
+ .join(' / ');
714
686
  if (!opts)
715
687
  return null;
716
- const mode = q.multi === true ? "pick any" : "pick one";
688
+ const mode = q.multi === true ? 'pick any' : 'pick one';
717
689
  return `- ${q.q} (${mode}): ${opts}`;
718
690
  })
719
691
  .filter((l) => l !== null);
720
692
  if (lines.length === 0)
721
693
  return whole;
722
- return `[You asked the user to choose:\n${lines.join("\n")}]`;
694
+ return `[You asked the user to choose:\n${lines.join('\n')}]`;
723
695
  }
724
696
  catch {
725
697
  return whole;
@@ -735,7 +707,7 @@ function makeSmithRunHandle(controller) {
735
707
  // Reads as "still running" for symmetry, but nothing consumes it for
736
708
  // smith runs: the registry already excludes them by pid sign.
737
709
  exitCode: null,
738
- spawnfile: "castle-smith",
710
+ spawnfile: 'castle-smith',
739
711
  kill: () => {
740
712
  controller.abort();
741
713
  return true;
@@ -749,19 +721,19 @@ function baseName(p) {
749
721
  // First non-blank line, capped -- the one-line technical reason shown in the
750
722
  // UI's collapsed error disclosure. The full text goes to the consoles.
751
723
  function firstLine(text, max = 200) {
752
- const line = (text ?? "")
753
- .split("\n")
724
+ const line = (text ?? '')
725
+ .split('\n')
754
726
  .map((l) => l.trim())
755
727
  .find((l) => l.length > 0);
756
- return (line ?? "").slice(0, max);
728
+ return (line ?? '').slice(0, max);
757
729
  }
758
730
  // First string-typed value among loosely-typed tool inputs, or "" when none is
759
731
  // a string (avoids "[object Object]" from String()-ing an object/array value).
760
732
  function firstString(...vals) {
761
733
  for (const v of vals)
762
- if (typeof v === "string")
734
+ if (typeof v === 'string')
763
735
  return v;
764
- return "";
736
+ return '';
765
737
  }
766
738
  // Matches the per-task progress file an agent writes its 0-100 integer to. We
767
739
  // hide those writes from the live feed -- they're constant noise, not work.
@@ -771,13 +743,13 @@ const PROGRESS_FILE_RE = /\.castle\/agent\/tasks\/[^/]+\/progress$/;
771
743
  // Returns null to hide the action (progress-file writes + anything non-edit/read).
772
744
  function claudeToolFeedLabel(name, input) {
773
745
  const kind = name.toLowerCase();
774
- if (["edit", "write", "notebookedit", "multiedit"].some((p) => kind.startsWith(p))) {
746
+ if (['edit', 'write', 'notebookedit', 'multiedit'].some((p) => kind.startsWith(p))) {
775
747
  const file = firstString(input.file_path, input.path, input.notebook_path);
776
748
  if (!file || PROGRESS_FILE_RE.test(file))
777
749
  return null;
778
750
  return `Editing ${baseName(file)}`;
779
751
  }
780
- if (kind.startsWith("read") || kind.startsWith("notebookread")) {
752
+ if (kind.startsWith('read') || kind.startsWith('notebookread')) {
781
753
  const file = firstString(input.file_path, input.path);
782
754
  return file ? `Reading ${baseName(file)}` : null;
783
755
  }
@@ -790,35 +762,32 @@ function claudeToolFeedLabel(name, input) {
790
762
  // Mirrors the cursor toolActivityLabel verbs so the two backends read alike.
791
763
  function genericClaudeToolLabel(name) {
792
764
  const kind = name.toLowerCase();
793
- if (kind.startsWith("read") || kind.startsWith("notebookread"))
794
- return "Reading the deck";
795
- if (["edit", "write", "notebook", "multiedit"].some((p) => kind.startsWith(p)))
796
- return "Editing files";
797
- if (kind.startsWith("bash") || kind.includes("terminal"))
798
- return "Running a command";
799
- if (["grep", "glob", "ls"].some((p) => kind.startsWith(p)) ||
800
- kind.includes("search"))
801
- return "Searching the deck";
802
- if (kind.startsWith("web"))
803
- return "Searching the web";
804
- return "Working";
765
+ if (kind.startsWith('read') || kind.startsWith('notebookread'))
766
+ return 'Reading the deck';
767
+ if (['edit', 'write', 'notebook', 'multiedit'].some((p) => kind.startsWith(p)))
768
+ return 'Editing files';
769
+ if (kind.startsWith('bash') || kind.includes('terminal'))
770
+ return 'Running a command';
771
+ if (['grep', 'glob', 'ls'].some((p) => kind.startsWith(p)) || kind.includes('search'))
772
+ return 'Searching the deck';
773
+ if (kind.startsWith('web'))
774
+ return 'Searching the web';
775
+ return 'Working';
805
776
  }
806
777
  // Human-readable label for a tool_call event, e.g. readToolCall -> "reading
807
778
  // the deck". Shown as the streaming message's activity line.
808
779
  function toolActivityLabel(ev) {
809
780
  const call = ev.tool_call;
810
- const key = call
811
- ? Object.keys(call).find((k) => k.endsWith("ToolCall"))
812
- : undefined;
813
- const kind = (key ?? "").slice(0, -"ToolCall".length).toLowerCase();
814
- if (["read", "glob", "grep", "ls", "list"].some((p) => kind.startsWith(p))) {
815
- return "Reading the deck";
781
+ const key = call ? Object.keys(call).find((k) => k.endsWith('ToolCall')) : undefined;
782
+ const kind = (key ?? '').slice(0, -'ToolCall'.length).toLowerCase();
783
+ if (['read', 'glob', 'grep', 'ls', 'list'].some((p) => kind.startsWith(p))) {
784
+ return 'Reading the deck';
816
785
  }
817
- if (["write", "edit", "delete", "mv"].some((p) => kind.startsWith(p)))
818
- return "Editing files";
819
- if (["shell", "bash", "terminal"].some((p) => kind.startsWith(p)))
820
- return "Running a command";
821
- return "Working";
786
+ if (['write', 'edit', 'delete', 'mv'].some((p) => kind.startsWith(p)))
787
+ return 'Editing files';
788
+ if (['shell', 'bash', 'terminal'].some((p) => kind.startsWith(p)))
789
+ return 'Running a command';
790
+ return 'Working';
822
791
  }
823
792
  // Castle's agent CLI keys, delivered to the sandbox as a file
824
793
  // (~/.castle/keys.json) rather than sandbox-wide env -- so an ambient key can't
@@ -830,27 +799,27 @@ function toolActivityLabel(ev) {
830
799
  // openrouterApiKey), which breaks the keyless and injected-key scenarios in
831
800
  // exactly the way that is hard to reproduce on CI. Mirrors the other
832
801
  // CASTLE_OPENROUTER_* test overrides.
833
- const CASTLE_KEYS_PATH = process.env.CASTLE_KEYS_PATH ?? path.join(os.homedir(), ".castle", "keys.json");
802
+ const CASTLE_KEYS_PATH = process.env.CASTLE_KEYS_PATH ?? path.join(os.homedir(), '.castle', 'keys.json');
834
803
  function castleKeys() {
835
804
  try {
836
- return JSON.parse(fs.readFileSync(CASTLE_KEYS_PATH, "utf8"));
805
+ return JSON.parse(fs.readFileSync(CASTLE_KEYS_PATH, 'utf8'));
837
806
  }
838
807
  catch {
839
808
  return {};
840
809
  }
841
810
  }
842
811
  function resolveOpenrouterAuth() {
843
- const k = userKey("OPENROUTER_API_KEY");
812
+ const k = userKey('OPENROUTER_API_KEY');
844
813
  if (k)
845
- return { mode: "user-key", key: k };
846
- return { mode: "proxy", key: openrouterApiKey() };
814
+ return { mode: 'user-key', key: k };
815
+ return { mode: 'proxy', key: openrouterApiKey() };
847
816
  }
848
817
  // Keys for the SPAWNING backends' env injection (envForAgentSpawn). Smith is
849
818
  // absent by design: it never spawns a CLI -- its OpenRouter key flows through
850
819
  // openrouterApiKey() into runAgentNative's Authorization header instead.
851
820
  const BACKEND_KEY_ENV = {
852
- claude: "ANTHROPIC_API_KEY",
853
- cursor: "CURSOR_API_KEY",
821
+ claude: 'ANTHROPIC_API_KEY',
822
+ cursor: 'CURSOR_API_KEY',
854
823
  };
855
824
  // When we inject Castle's key, any auth.json cursor cached from a DIFFERENT key
856
825
  // -- a rotated-out old key, or a tester's own key we've chosen to override -- is
@@ -861,7 +830,7 @@ const BACKEND_KEY_ENV = {
861
830
  function purgeStaleCursorAuth(home, injectedKey) {
862
831
  try {
863
832
  const authPath = cursorAuthPath(home);
864
- const auth = JSON.parse(fs.readFileSync(authPath, "utf8"));
833
+ const auth = JSON.parse(fs.readFileSync(authPath, 'utf8'));
865
834
  if (auth.apiKey && auth.apiKey !== injectedKey) {
866
835
  fs.rmSync(authPath, { force: true });
867
836
  }
@@ -874,9 +843,9 @@ function purgeStaleCursorAuth(home, injectedKey) {
874
843
  // route to directly (and bill to them) instead of Castle's proxy / key. The
875
844
  // claude side lives in byo-auth.ts, which the editor terminal shares.
876
845
  function backendHasSavedAuth(backend) {
877
- if (backend === "claude")
846
+ if (backend === 'claude')
878
847
  return claudeHasSavedLogin();
879
- if (backend === "cursor")
848
+ if (backend === 'cursor')
880
849
  return cursorHasUserLogin(os.homedir());
881
850
  return false;
882
851
  }
@@ -895,7 +864,7 @@ function backendHasSavedAuth(backend) {
895
864
  // credential while doing nothing.
896
865
  function envForClaudeSpawn(auth) {
897
866
  const env = { ...process.env };
898
- if (auth.mode === "proxy")
867
+ if (auth.mode === 'proxy')
899
868
  return env;
900
869
  for (const name of ANTHROPIC_PROXY_ENV)
901
870
  delete env[name];
@@ -918,7 +887,7 @@ function envForAgentSpawn(backend) {
918
887
  const val = castleKeys()[keyName] ?? process.env[keyName];
919
888
  if (val) {
920
889
  env[keyName] = val;
921
- if (backend === "cursor")
890
+ if (backend === 'cursor')
922
891
  purgeStaleCursorAuth(os.homedir(), val);
923
892
  }
924
893
  else {
@@ -927,13 +896,7 @@ function envForAgentSpawn(backend) {
927
896
  }
928
897
  return env;
929
898
  }
930
- const DECK_TREE_EXCLUDE = new Set([
931
- "node_modules",
932
- ".castle",
933
- ".git",
934
- "dist",
935
- ".DS_Store",
936
- ]);
899
+ const DECK_TREE_EXCLUDE = new Set(['node_modules', '.castle', '.git', 'dist', '.DS_Store']);
937
900
  const DECK_TREE_MAX_ENTRIES = 200;
938
901
  // Per-directory listing cap. A successful deck accumulates hundreds of
939
902
  // drawings; without this, one big directory exhausts the global budget
@@ -978,23 +941,23 @@ function buildDeckTree(deckDir, caps) {
978
941
  return;
979
942
  }
980
943
  const isDir = entry.isDirectory();
981
- lines.push(`${prefix}${entry.name}${isDir ? "/" : ""}`);
944
+ lines.push(`${prefix}${entry.name}${isDir ? '/' : ''}`);
982
945
  if (isDir && depth < 1) {
983
- walk(path.join(dir, entry.name), prefix + " ", depth + 1);
946
+ walk(path.join(dir, entry.name), prefix + ' ', depth + 1);
984
947
  }
985
948
  }
986
949
  const rest = visible.slice(perDir);
987
950
  if (rest.length > 0 && lines.length < maxEntries) {
988
951
  // Name the overflow's extension when it's uniform ("+214 more .pxart"),
989
952
  // since that tells the reader what kind of files dominate the directory.
990
- const exts = new Set(rest.map((e) => (e.isDirectory() ? "/" : path.extname(e.name))));
953
+ const exts = new Set(rest.map((e) => (e.isDirectory() ? '/' : path.extname(e.name))));
991
954
  const [only] = exts;
992
- const suffix = exts.size === 1 && only && only !== "/" ? ` ${only}` : "";
955
+ const suffix = exts.size === 1 && only && only !== '/' ? ` ${only}` : '';
993
956
  lines.push(`${prefix}(+${rest.length} more${suffix})`);
994
957
  }
995
958
  };
996
- walk(deckDir, "", 0);
997
- return lines.join("\n");
959
+ walk(deckDir, '', 0);
960
+ return lines.join('\n');
998
961
  }
999
962
  // Text source extensions buildDeckContents will inline. Deliberately an
1000
963
  // allowlist (not "everything readable") -- decks are small web projects, so
@@ -1015,17 +978,28 @@ function buildDeckTree(deckDir, caps) {
1015
978
  // anything else outside this list are skipped for the more obvious reason
1016
979
  // that they're unreadable as text.
1017
980
  const DECK_CONTENTS_TEXT_EXTS = new Set([
1018
- ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts",
1019
- ".json", ".css", ".html", ".md", ".txt", ".svg", ".yml", ".yaml", ".scene",
981
+ '.ts',
982
+ '.tsx',
983
+ '.js',
984
+ '.jsx',
985
+ '.mjs',
986
+ '.cjs',
987
+ '.mts',
988
+ '.cts',
989
+ '.json',
990
+ '.css',
991
+ '.html',
992
+ '.md',
993
+ '.txt',
994
+ '.svg',
995
+ '.yml',
996
+ '.yaml',
997
+ '.scene',
1020
998
  ]);
1021
999
  // Generated/lockfiles that happen to match the extension allowlist but are
1022
1000
  // never hand-edited -- inlining a lockfile would just spend budget other
1023
1001
  // files need for zero benefit (nobody reads a lockfile to plan an edit).
1024
- const DECK_CONTENTS_SKIP_NAMES = new Set([
1025
- "package-lock.json",
1026
- "pnpm-lock.yaml",
1027
- "yarn.lock",
1028
- ]);
1002
+ const DECK_CONTENTS_SKIP_NAMES = new Set(['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock']);
1029
1003
  // Per-file cap: keeps one unusually large source file (a generated bundle
1030
1004
  // accidentally left in the tree, a huge scene) from eating the whole budget
1031
1005
  // by itself. 16KB comfortably covers real hand-written deck files (kit engine
@@ -1091,7 +1065,7 @@ function buildDeckContents(deckDir, budget) {
1091
1065
  const listed = [];
1092
1066
  let used = 0;
1093
1067
  for (const abs of files) {
1094
- const rel = path.relative(deckDir, abs).split(path.sep).join("/");
1068
+ const rel = path.relative(deckDir, abs).split(path.sep).join('/');
1095
1069
  if (DECK_CONTENTS_SKIP_NAMES.has(path.basename(rel)))
1096
1070
  continue;
1097
1071
  if (!DECK_CONTENTS_TEXT_EXTS.has(path.extname(rel).toLowerCase()))
@@ -1109,13 +1083,13 @@ function buildDeckContents(deckDir, budget) {
1109
1083
  }
1110
1084
  let content;
1111
1085
  try {
1112
- content = fs.readFileSync(abs, "utf8");
1086
+ content = fs.readFileSync(abs, 'utf8');
1113
1087
  }
1114
1088
  catch {
1115
1089
  listed.push(`${rel} (not inlined -- use read tool)`);
1116
1090
  continue;
1117
1091
  }
1118
- if (content.includes("\u0000")) {
1092
+ if (content.includes('\u0000')) {
1119
1093
  // Looks binary despite the extension allowlist (e.g. a mislabeled
1120
1094
  // asset) -- skip it exactly like read_file's own NUL sniff would.
1121
1095
  listed.push(`${rel} (not inlined -- use read tool)`);
@@ -1126,19 +1100,19 @@ function buildDeckContents(deckDir, budget) {
1126
1100
  }
1127
1101
  const parts = [];
1128
1102
  if (inlined.length > 0)
1129
- parts.push(inlined.join("\n\n"));
1103
+ parts.push(inlined.join('\n\n'));
1130
1104
  if (listed.length > 0) {
1131
- parts.push(`(not inlined -- binary/generated, over the per-file cap, or past the total budget; read these yourself if you need them):\n${listed.join("\n")}`);
1105
+ parts.push(`(not inlined -- binary/generated, over the per-file cap, or past the total budget; read these yourself if you need them):\n${listed.join('\n')}`);
1132
1106
  }
1133
- return parts.join("\n\n");
1107
+ return parts.join('\n\n');
1134
1108
  }
1135
1109
  const DEFAULT_WELCOME_MESSAGE = "Welcome to an early test of Castle's new engine! You're starting with a blank deck, without the official art or scene editors, but if HTML and JavaScript can do it then I can help you build it.\n\nDo you already know what you want to make, or do you want to figure it out together?";
1136
1110
  function readClaudeSection(deckDir, section) {
1137
- const headingRe = new RegExp(`^## ${section}\\s*$`, "im");
1138
- for (const name of ["CLAUDE.md", "AGENTS.md"]) {
1139
- let raw = "";
1111
+ const headingRe = new RegExp(`^## ${section}\\s*$`, 'im');
1112
+ for (const name of ['CLAUDE.md', 'AGENTS.md']) {
1113
+ let raw = '';
1140
1114
  try {
1141
- raw = fs.readFileSync(path.join(deckDir, name), "utf8");
1115
+ raw = fs.readFileSync(path.join(deckDir, name), 'utf8');
1142
1116
  }
1143
1117
  catch {
1144
1118
  continue;
@@ -1146,53 +1120,108 @@ function readClaudeSection(deckDir, section) {
1146
1120
  const heading = headingRe.exec(raw);
1147
1121
  if (!heading || heading.index === undefined)
1148
1122
  continue;
1149
- const bodyStart = raw.indexOf("\n", heading.index);
1123
+ const bodyStart = raw.indexOf('\n', heading.index);
1150
1124
  if (bodyStart < 0)
1151
- return "";
1125
+ return '';
1152
1126
  const rest = raw.slice(bodyStart + 1);
1153
1127
  const nextHeading = /^##\s+/m.exec(rest);
1154
1128
  return (nextHeading ? rest.slice(0, nextHeading.index) : rest).trim();
1155
1129
  }
1156
- return "";
1130
+ return '';
1131
+ }
1132
+ // Which import the deck's entry actually runs, read off index.html. The kit that
1133
+ // is MOUNTED is the one whose vocabulary applies, so its docs lead.
1134
+ function entryImportAlias(deckDir) {
1135
+ try {
1136
+ const html = fs.readFileSync(path.join(deckDir, 'index.html'), 'utf8');
1137
+ const match = new RegExp(`src="/?${IMPORTS_DIR}/([^/"]+)/`).exec(html);
1138
+ return match ? match[1] : null;
1139
+ }
1140
+ catch {
1141
+ return null;
1142
+ }
1143
+ }
1144
+ function importAliases(deckDir) {
1145
+ try {
1146
+ return fs.readdirSync(path.join(deckDir, IMPORTS_DIR)).sort();
1147
+ }
1148
+ catch {
1149
+ return [];
1150
+ }
1151
+ }
1152
+ // A deck built on a kit holds almost no docs of its own -- `init` writes it a
1153
+ // CLAUDE.md that POINTS at the kit's. Reading only the deck's own file therefore
1154
+ // found nothing, and the agent was then told "there is no documentation, do not
1155
+ // search for one", so it never learned the kit's vocabulary at all. Gather the
1156
+ // deck's own section plus every import's, entry kit first.
1157
+ function readClaudeSectionWithImports(deckDir, section) {
1158
+ const parts = [];
1159
+ const own = readClaudeSection(deckDir, section);
1160
+ if (own)
1161
+ parts.push(own);
1162
+ const entry = entryImportAlias(deckDir);
1163
+ const aliases = importAliases(deckDir);
1164
+ const ordered = entry ? [entry, ...aliases.filter((a) => a !== entry)] : aliases;
1165
+ for (const alias of ordered) {
1166
+ const text = readClaudeSection(path.join(deckDir, IMPORTS_DIR, alias), section);
1167
+ if (!text)
1168
+ continue;
1169
+ const role = alias === entry ? 'this deck runs on it' : 'imported, not the entry';
1170
+ parts.push(`### from the ${alias} kit (${role})\n\n${text}`);
1171
+ }
1172
+ return parts.join('\n\n');
1157
1173
  }
1158
1174
  function readQuickReference(deckDir) {
1159
- return readClaudeSection(deckDir, "Quick reference");
1175
+ return readClaudeSectionWithImports(deckDir, 'Quick reference');
1160
1176
  }
1177
+ // One greeting, not a pile of them: the mounted kit's welcome is the deck's.
1161
1178
  function readWelcomeMessage(deckDir) {
1162
- return readClaudeSection(deckDir, "Welcome message");
1179
+ const own = readClaudeSection(deckDir, 'Welcome message');
1180
+ if (own)
1181
+ return own;
1182
+ const entry = entryImportAlias(deckDir);
1183
+ const ordered = entry
1184
+ ? [entry, ...importAliases(deckDir).filter((a) => a !== entry)]
1185
+ : importAliases(deckDir);
1186
+ for (const alias of ordered) {
1187
+ const text = readClaudeSection(path.join(deckDir, IMPORTS_DIR, alias), 'Welcome message');
1188
+ if (text)
1189
+ return text;
1190
+ }
1191
+ return '';
1163
1192
  }
1164
1193
  function createAgentStreamState() {
1165
1194
  return {
1166
- accumulated: "",
1167
- finalText: "",
1195
+ accumulated: '',
1196
+ finalText: '',
1168
1197
  resultIsError: false,
1169
1198
  usage: undefined,
1170
1199
  sawResult: false,
1171
- segmentText: "",
1200
+ segmentText: '',
1172
1201
  needsGap: false,
1173
1202
  pendingTools: new Map(),
1174
1203
  };
1175
1204
  }
1176
1205
  function parseCliUsage(raw) {
1177
- if (!raw || typeof raw !== "object")
1206
+ if (!raw || typeof raw !== 'object')
1178
1207
  return undefined;
1179
1208
  const src = raw;
1180
1209
  const usage = {};
1181
1210
  for (const key of [
1182
- "input_tokens",
1183
- "output_tokens",
1184
- "cache_creation_input_tokens",
1185
- "cache_read_input_tokens",
1211
+ 'input_tokens',
1212
+ 'output_tokens',
1213
+ 'cache_creation_input_tokens',
1214
+ 'cache_read_input_tokens',
1186
1215
  ]) {
1187
1216
  const value = src[key];
1188
- if (typeof value === "number")
1217
+ if (typeof value === 'number')
1189
1218
  usage[key] = value;
1190
1219
  }
1191
1220
  return Object.keys(usage).length > 0 ? usage : undefined;
1192
1221
  }
1193
1222
  function formatTokenCount(value) {
1194
- if (typeof value !== "number")
1195
- return "?";
1223
+ if (typeof value !== 'number')
1224
+ return '?';
1196
1225
  if (value >= 1000)
1197
1226
  return `${(value / 1000).toFixed(1)}k`;
1198
1227
  return String(value);
@@ -1228,8 +1257,8 @@ function makeAgentEventHandler(opts, state) {
1228
1257
  let delta = rawDelta;
1229
1258
  if (state.needsGap) {
1230
1259
  state.needsGap = false;
1231
- if (state.accumulated && !state.accumulated.endsWith("\n\n")) {
1232
- delta = (state.accumulated.endsWith("\n") ? "\n" : "\n\n") + delta;
1260
+ if (state.accumulated && !state.accumulated.endsWith('\n\n')) {
1261
+ delta = (state.accumulated.endsWith('\n') ? '\n' : '\n\n') + delta;
1233
1262
  }
1234
1263
  }
1235
1264
  state.segmentText += delta;
@@ -1238,12 +1267,12 @@ function makeAgentEventHandler(opts, state) {
1238
1267
  opts.onActivity?.(null);
1239
1268
  };
1240
1269
  const handleClaudeEvent = (ev) => {
1241
- if (ev.type === "stream_event") {
1270
+ if (ev.type === 'stream_event') {
1242
1271
  const e = ev.event;
1243
- if (e?.type === "content_block_start") {
1244
- if (e.content_block?.type === "tool_use") {
1272
+ if (e?.type === 'content_block_start') {
1273
+ if (e.content_block?.type === 'tool_use') {
1245
1274
  state.needsGap = true;
1246
- const name = String(e.content_block.name ?? "");
1275
+ const name = String(e.content_block.name ?? '');
1247
1276
  // Router: show a coarse label NOW (name is known) so the activity
1248
1277
  // line isn't stuck on the prior state while the tool runs; the
1249
1278
  // concrete label (with the file) refines it at content_block_stop.
@@ -1251,43 +1280,39 @@ function makeAgentEventHandler(opts, state) {
1251
1280
  opts.onActivity?.(genericClaudeToolLabel(name));
1252
1281
  // Hold the concrete label until content_block_stop, once the input
1253
1282
  // (file / command) has streamed in, so we can name it concretely.
1254
- state.pendingTools.set(e.index ?? -1, { name, buf: "" });
1283
+ state.pendingTools.set(e.index ?? -1, { name, buf: '' });
1255
1284
  }
1256
- else if (e.content_block?.type === "thinking") {
1285
+ else if (e.content_block?.type === 'thinking') {
1257
1286
  state.needsGap = true;
1258
1287
  // Surface extended thinking as the activity line (mirrors the cursor
1259
1288
  // path's `thinking` signal). The next text delta clears it via
1260
1289
  // emitDelta's onActivity(null); a tool block relabels it.
1261
- opts.onActivity?.("Thinking");
1290
+ opts.onActivity?.('Thinking');
1262
1291
  }
1263
1292
  }
1264
- else if (e?.type === "content_block_delta") {
1265
- if (e.delta?.type === "text_delta" &&
1266
- typeof e.delta.text === "string" &&
1267
- e.delta.text) {
1293
+ else if (e?.type === 'content_block_delta') {
1294
+ if (e.delta?.type === 'text_delta' && typeof e.delta.text === 'string' && e.delta.text) {
1268
1295
  emitDelta(e.delta.text);
1269
1296
  }
1270
- else if (e.delta?.type === "thinking_delta" &&
1271
- typeof e.delta.thinking === "string" &&
1297
+ else if (e.delta?.type === 'thinking_delta' &&
1298
+ typeof e.delta.thinking === 'string' &&
1272
1299
  e.delta.thinking) {
1273
1300
  opts.onThinking?.(e.delta.thinking);
1274
1301
  }
1275
- else if (e.delta?.type === "input_json_delta" &&
1276
- typeof e.delta.partial_json === "string") {
1302
+ else if (e.delta?.type === 'input_json_delta' &&
1303
+ typeof e.delta.partial_json === 'string') {
1277
1304
  const pending = state.pendingTools.get(e.index ?? -1);
1278
1305
  if (pending)
1279
1306
  pending.buf += e.delta.partial_json;
1280
1307
  }
1281
1308
  }
1282
- else if (e?.type === "content_block_stop") {
1309
+ else if (e?.type === 'content_block_stop') {
1283
1310
  const pending = state.pendingTools.get(e.index ?? -1);
1284
1311
  if (pending) {
1285
1312
  state.pendingTools.delete(e.index ?? -1);
1286
1313
  let input = {};
1287
1314
  try {
1288
- input = pending.buf
1289
- ? JSON.parse(pending.buf)
1290
- : {};
1315
+ input = pending.buf ? JSON.parse(pending.buf) : {};
1291
1316
  }
1292
1317
  catch {
1293
1318
  /* input JSON arrived partial -- fall back to a generic label */
@@ -1298,28 +1323,27 @@ function makeAgentEventHandler(opts, state) {
1298
1323
  }
1299
1324
  }
1300
1325
  }
1301
- else if (ev.type === "result") {
1326
+ else if (ev.type === 'result') {
1302
1327
  state.sawResult = true;
1303
1328
  // An EMPTY string result falls back to the streamed text. Models that
1304
1329
  // return their blocks as `text, thinking` (gemini through OpenRouter's
1305
1330
  // anthropic-compatible endpoint) end the turn on an empty thinking block,
1306
1331
  // and the CLI reports `result: ""` even though the answer streamed fine.
1307
- state.finalText =
1308
- typeof ev.result === "string" && ev.result ? ev.result : state.accumulated;
1332
+ state.finalText = typeof ev.result === 'string' && ev.result ? ev.result : state.accumulated;
1309
1333
  state.resultIsError = ev.is_error === true;
1310
1334
  state.usage = parseCliUsage(ev.usage);
1311
1335
  }
1312
1336
  };
1313
1337
  return (ev) => {
1314
- if (opts.parser === "claude") {
1338
+ if (opts.parser === 'claude') {
1315
1339
  handleClaudeEvent(ev);
1316
1340
  return;
1317
1341
  }
1318
- if (ev.type === "assistant" && typeof ev.timestamp_ms === "number") {
1342
+ if (ev.type === 'assistant' && typeof ev.timestamp_ms === 'number') {
1319
1343
  const message = ev.message;
1320
1344
  const delta = (message?.content ?? [])
1321
- .map((c) => (typeof c?.text === "string" ? c.text : ""))
1322
- .join("");
1345
+ .map((c) => (typeof c?.text === 'string' ? c.text : ''))
1346
+ .join('');
1323
1347
  if (!delta)
1324
1348
  return;
1325
1349
  const trimmed = delta.trim();
@@ -1327,25 +1351,24 @@ function makeAgentEventHandler(opts, state) {
1327
1351
  return;
1328
1352
  emitDelta(delta);
1329
1353
  }
1330
- else if (ev.type === "tool_call") {
1331
- state.segmentText = "";
1354
+ else if (ev.type === 'tool_call') {
1355
+ state.segmentText = '';
1332
1356
  state.needsGap = true;
1333
- if (ev.subtype === "started")
1357
+ if (ev.subtype === 'started')
1334
1358
  opts.onActivity?.(toolActivityLabel(ev));
1335
1359
  }
1336
- else if (ev.type === "thinking") {
1337
- state.segmentText = "";
1360
+ else if (ev.type === 'thinking') {
1361
+ state.segmentText = '';
1338
1362
  state.needsGap = true;
1339
- opts.onActivity?.("Thinking");
1363
+ opts.onActivity?.('Thinking');
1340
1364
  }
1341
- else if (ev.type === "result") {
1365
+ else if (ev.type === 'result') {
1342
1366
  state.sawResult = true;
1343
1367
  // An EMPTY string result falls back to the streamed text. Models that
1344
1368
  // return their blocks as `text, thinking` (gemini through OpenRouter's
1345
1369
  // anthropic-compatible endpoint) end the turn on an empty thinking block,
1346
1370
  // and the CLI reports `result: ""` even though the answer streamed fine.
1347
- state.finalText =
1348
- typeof ev.result === "string" && ev.result ? ev.result : state.accumulated;
1371
+ state.finalText = typeof ev.result === 'string' && ev.result ? ev.result : state.accumulated;
1349
1372
  state.resultIsError = ev.is_error === true;
1350
1373
  state.usage = parseCliUsage(ev.usage);
1351
1374
  }
@@ -1372,27 +1395,25 @@ function runAgentCli(opts) {
1372
1395
  child = spawn(opts.command, opts.args, {
1373
1396
  cwd: opts.cwd,
1374
1397
  env: opts.env,
1375
- stdio: ["ignore", "pipe", "pipe"],
1398
+ stdio: ['ignore', 'pipe', 'pipe'],
1376
1399
  });
1377
1400
  }
1378
1401
  catch (err) {
1379
1402
  const message = err instanceof Error ? err.message : String(err);
1380
1403
  resolve({
1381
1404
  ok: false,
1382
- finalText: "",
1405
+ finalText: '',
1383
1406
  error: `could not run ${opts.command}: ${message}`,
1384
- failure: { kind: "spawn", detail: `${opts.command}: ${message}` },
1407
+ failure: { kind: 'spawn', detail: `${opts.command}: ${message}` },
1385
1408
  });
1386
1409
  return;
1387
1410
  }
1388
1411
  opts.children.add(child);
1389
1412
  opts.onSpawn?.(child.pid);
1390
- const log = opts.logPath
1391
- ? fs.createWriteStream(opts.logPath, { flags: "a" })
1392
- : null;
1413
+ const log = opts.logPath ? fs.createWriteStream(opts.logPath, { flags: 'a' }) : null;
1393
1414
  let settled = false;
1394
- let stderrTail = "";
1395
- let lineBuffer = "";
1415
+ let stderrTail = '';
1416
+ let lineBuffer = '';
1396
1417
  const state = createAgentStreamState();
1397
1418
  const settle = (result) => {
1398
1419
  if (settled)
@@ -1405,7 +1426,7 @@ function runAgentCli(opts) {
1405
1426
  };
1406
1427
  const timeout = setTimeout(() => {
1407
1428
  try {
1408
- child.kill("SIGKILL");
1429
+ child.kill('SIGKILL');
1409
1430
  }
1410
1431
  catch {
1411
1432
  /* already gone */
@@ -1413,19 +1434,19 @@ function runAgentCli(opts) {
1413
1434
  settle({
1414
1435
  ok: false,
1415
1436
  finalText: state.finalText || state.accumulated,
1416
- error: "agent run timed out",
1437
+ error: 'agent run timed out',
1417
1438
  usage: state.usage,
1418
1439
  });
1419
1440
  }, opts.timeoutMs);
1420
1441
  const handleEvent = makeAgentEventHandler(opts, state);
1421
- child.stdout.on("data", (chunk) => {
1422
- lineBuffer += chunk.toString("utf8");
1423
- let nl = lineBuffer.indexOf("\n");
1442
+ child.stdout.on('data', (chunk) => {
1443
+ lineBuffer += chunk.toString('utf8');
1444
+ let nl = lineBuffer.indexOf('\n');
1424
1445
  while (nl >= 0) {
1425
1446
  const line = lineBuffer.slice(0, nl);
1426
1447
  lineBuffer = lineBuffer.slice(nl + 1);
1427
1448
  if (line.trim()) {
1428
- log?.write(line + "\n");
1449
+ log?.write(line + '\n');
1429
1450
  try {
1430
1451
  handleEvent(JSON.parse(line));
1431
1452
  }
@@ -1433,13 +1454,13 @@ function runAgentCli(opts) {
1433
1454
  /* non-JSON noise on stdout -- ignore */
1434
1455
  }
1435
1456
  }
1436
- nl = lineBuffer.indexOf("\n");
1457
+ nl = lineBuffer.indexOf('\n');
1437
1458
  }
1438
1459
  });
1439
- child.stderr.on("data", (chunk) => {
1440
- stderrTail = (stderrTail + chunk.toString("utf8")).slice(-2000);
1460
+ child.stderr.on('data', (chunk) => {
1461
+ stderrTail = (stderrTail + chunk.toString('utf8')).slice(-2000);
1441
1462
  });
1442
- child.on("error", (err) => {
1463
+ child.on('error', (err) => {
1443
1464
  settle({
1444
1465
  ok: false,
1445
1466
  finalText: state.accumulated,
@@ -1447,10 +1468,10 @@ function runAgentCli(opts) {
1447
1468
  // actually being spawned -- this used to say "cursor-agent" for every
1448
1469
  // backend, so a missing `claude` reported the wrong tool.
1449
1470
  error: `could not run ${opts.command}: ${err.message}`,
1450
- failure: { kind: "spawn", detail: `${opts.command}: ${err.message}` },
1471
+ failure: { kind: 'spawn', detail: `${opts.command}: ${err.message}` },
1451
1472
  });
1452
1473
  });
1453
- child.on("close", (code) => {
1474
+ child.on('close', (code) => {
1454
1475
  const ok = code === 0 && !state.resultIsError && state.sawResult;
1455
1476
  // A provider error does NOT arrive on stderr: the claude CLI reports it
1456
1477
  // in the stream-json result event (is_error:true) and exits 0, leaving
@@ -1466,11 +1487,11 @@ function runAgentCli(opts) {
1466
1487
  // as before this feature.
1467
1488
  const classified = ok || !opts.openrouterModel
1468
1489
  ? undefined
1469
- : classifyProviderError(state.finalText, opts.openrouterModel) ??
1470
- classifyProviderError(stderrTail, opts.openrouterModel);
1490
+ : (classifyProviderError(state.finalText, opts.openrouterModel) ??
1491
+ classifyProviderError(stderrTail, opts.openrouterModel));
1471
1492
  const error = ok
1472
1493
  ? undefined
1473
- : `agent exited ${code}${stderrTail ? `: ${stderrTail.slice(-300)}` : ""}`;
1494
+ : `agent exited ${code}${stderrTail ? `: ${stderrTail.slice(-300)}` : ''}`;
1474
1495
  settle({
1475
1496
  ok,
1476
1497
  finalText: state.finalText || state.accumulated,
@@ -1481,7 +1502,7 @@ function runAgentCli(opts) {
1481
1502
  ? {
1482
1503
  ...classified,
1483
1504
  detail: firstLine(state.finalText) || classified.detail,
1484
- verbose: [state.finalText, stderrTail].filter(Boolean).join("\n"),
1505
+ verbose: [state.finalText, stderrTail].filter(Boolean).join('\n'),
1485
1506
  }
1486
1507
  : undefined,
1487
1508
  });
@@ -1560,7 +1581,7 @@ async function runAgentSmith(opts) {
1560
1581
  // instead of rejecting through startTask's catch.
1561
1582
  return {
1562
1583
  ok: false,
1563
- finalText: "",
1584
+ finalText: '',
1564
1585
  error: `could not run openrouter: ${err instanceof Error ? err.message : String(err)}`,
1565
1586
  };
1566
1587
  }
@@ -1573,10 +1594,10 @@ async function runAgentSmith(opts) {
1573
1594
  // conductor.tsx (it decides whether to show the slug field) -- keep them in
1574
1595
  // step; this one decides whether the slug is worth validating at all.
1575
1596
  export function roleUsesOpenrouter(backend, claudeModel) {
1576
- return backend === "smith" || (backend === "claude" && claudeModel === "openrouter");
1597
+ return backend === 'smith' || (backend === 'claude' && claudeModel === 'openrouter');
1577
1598
  }
1578
1599
  function configFailure(reason, detail, extra) {
1579
- return { kind: "config", reason, detail, ...extra };
1600
+ return { kind: 'config', reason, detail, ...extra };
1580
1601
  }
1581
1602
  // Everything decidable about an OpenRouter run BEFORE spending anything on it.
1582
1603
  // Returns a failure to surface as-is, or null to proceed.
@@ -1593,32 +1614,32 @@ async function preflightOpenrouterRun(opts) {
1593
1614
  const auth = opts.orAuth ?? resolveOpenrouterAuth();
1594
1615
  const apiKey = auth.key;
1595
1616
  if (!apiKey) {
1596
- return configFailure("no-key", `no ${OPENROUTER_KEY_NAME} is set (checked ${CASTLE_USER_KEYS_PATH}, ${CASTLE_KEYS_PATH}, and the environment)`);
1617
+ return configFailure('no-key', `no ${OPENROUTER_KEY_NAME} is set (checked ${CASTLE_USER_KEYS_PATH}, ${CASTLE_KEYS_PATH}, and the environment)`);
1597
1618
  }
1598
1619
  const model = opts.openrouterModel.trim();
1599
1620
  if (!model) {
1600
- return configFailure("unknown-model", "no OpenRouter model is set for this role");
1621
+ return configFailure('unknown-model', 'no OpenRouter model is set for this role');
1601
1622
  }
1602
1623
  // Key and slug checks are independent, so overlap them rather than paying
1603
1624
  // both round-trips in series. Both are cached and single-flighted.
1604
1625
  const [key, slug] = await Promise.all([
1605
- checkOpenrouterKey(apiKey, { direct: auth.mode === "user-key" }),
1626
+ checkOpenrouterKey(apiKey, { direct: auth.mode === 'user-key' }),
1606
1627
  checkOpenrouterModel(model),
1607
1628
  ]);
1608
- if (key.status === "bad-key") {
1609
- return configFailure("bad-key", `OpenRouter rejected ${OPENROUTER_KEY_NAME}`);
1629
+ if (key.status === 'bad-key') {
1630
+ return configFailure('bad-key', `OpenRouter rejected ${OPENROUTER_KEY_NAME}`);
1610
1631
  }
1611
- if (key.status === "no-credits") {
1612
- return configFailure("no-credits", "the OpenRouter key is out of credits");
1632
+ if (key.status === 'no-credits') {
1633
+ return configFailure('no-credits', 'the OpenRouter key is out of credits');
1613
1634
  }
1614
- if (slug.status === "unknown-model") {
1615
- return configFailure("unknown-model", `OpenRouter has no model "${model}"`, {
1635
+ if (slug.status === 'unknown-model') {
1636
+ return configFailure('unknown-model', `OpenRouter has no model "${model}"`, {
1616
1637
  model,
1617
1638
  suggestion: slug.suggestions[0],
1618
1639
  });
1619
1640
  }
1620
- if (slug.status === "no-tools") {
1621
- return configFailure("no-tools", `"${model}" does not support tool calling`, {
1641
+ if (slug.status === 'no-tools') {
1642
+ return configFailure('no-tools', `"${model}" does not support tool calling`, {
1622
1643
  model,
1623
1644
  });
1624
1645
  }
@@ -1652,11 +1673,11 @@ function notifyAgentRunFinished() {
1652
1673
  // cursor run on Castle's key stays gated, and cursor is unmetered (its traffic
1653
1674
  // never reaches the proxy), so an ungated one would be a free ride.
1654
1675
  function runIsCastlePaid(backend, claudeModel, orAuth) {
1655
- if (backend === "cursor")
1676
+ if (backend === 'cursor')
1656
1677
  return !cursorHasUserLogin(os.homedir());
1657
1678
  return roleUsesOpenrouter(backend, claudeModel)
1658
- ? (orAuth ?? resolveOpenrouterAuth()).mode === "proxy"
1659
- : resolveAnthropicAuth().mode === "proxy";
1679
+ ? (orAuth ?? resolveOpenrouterAuth()).mode === 'proxy'
1680
+ : resolveAnthropicAuth().mode === 'proxy';
1660
1681
  }
1661
1682
  // Whether Castle's budget is this editor's to spend at all, which is what makes
1662
1683
  // the usage bar worth drawing. Both roles count, and they can disagree: an
@@ -1687,8 +1708,8 @@ async function castleSpendRefusal(backend, claudeModel, orAuth) {
1687
1708
  return null;
1688
1709
  if (claudeModelBlocked(claudeModel, budget)) {
1689
1710
  return {
1690
- kind: "config",
1691
- reason: "model-not-allowed",
1711
+ kind: 'config',
1712
+ reason: 'model-not-allowed',
1692
1713
  detail: `${claudeModel} is not available on this Castle account`,
1693
1714
  model: claudeModel,
1694
1715
  };
@@ -1696,8 +1717,8 @@ async function castleSpendRefusal(backend, claudeModel, orAuth) {
1696
1717
  if (!budget.blocked)
1697
1718
  return null;
1698
1719
  return {
1699
- kind: "limit",
1700
- detail: "daily Castle AI limit reached",
1720
+ kind: 'limit',
1721
+ detail: 'daily Castle AI limit reached',
1701
1722
  resetAtMs: budget.resetAtMs || undefined,
1702
1723
  };
1703
1724
  }
@@ -1705,7 +1726,7 @@ async function castleSpendRefusal(backend, claudeModel, orAuth) {
1705
1726
  // finished run already refreshes. This is for the spend this serve never sees
1706
1727
  // -- a `claude` invoked straight from the sandbox terminal.
1707
1728
  const USAGE_POLL_MS = 60_000;
1708
- const PICKER_CLAUDE_MODELS = ["sonnet", "opus", "fable"];
1729
+ const PICKER_CLAUDE_MODELS = ['sonnet', 'opus', 'fable'];
1709
1730
  /**
1710
1731
  * Which of the picker's claude models this editor can't use. Gated on the
1711
1732
  * ANTHROPIC credential specifically, not on `anyRoleIsCastlePaid` (which draws
@@ -1717,7 +1738,7 @@ const PICKER_CLAUDE_MODELS = ["sonnet", "opus", "fable"];
1717
1738
  * resolveAnthropicAuth, so no role's settings enter into this.
1718
1739
  */
1719
1740
  function blockedClaudeModels(budget) {
1720
- if (resolveAnthropicAuth().mode !== "proxy")
1741
+ if (resolveAnthropicAuth().mode !== 'proxy')
1721
1742
  return [];
1722
1743
  return PICKER_CLAUDE_MODELS.filter((m) => claudeModelBlocked(m, budget));
1723
1744
  }
@@ -1752,7 +1773,7 @@ function createUsageFeed(opts) {
1752
1773
  if (JSON.stringify(next ?? null) === JSON.stringify(latest ?? null))
1753
1774
  return;
1754
1775
  latest = next;
1755
- opts.broadcast({ type: "usage", usage: next });
1776
+ opts.broadcast({ type: 'usage', usage: next });
1756
1777
  }
1757
1778
  const refresh = () => void refreshAsync();
1758
1779
  const stopRunWatch = onAgentRunFinished(refresh);
@@ -1792,7 +1813,7 @@ async function runAgentTurn(opts) {
1792
1813
  if (failure) {
1793
1814
  return {
1794
1815
  ok: false,
1795
- finalText: "",
1816
+ finalText: '',
1796
1817
  error: failure.detail,
1797
1818
  failure,
1798
1819
  // NOT "crashed": nothing ran. crashed drives the task retry loop, and a
@@ -1805,9 +1826,9 @@ async function runAgentTurn(opts) {
1805
1826
  // conversation -- nothing is resumed).
1806
1827
  const sessionId = newAgentSessionId(opts.role);
1807
1828
  const settled = (run) => run.finally(() => notifyAgentRunFinished());
1808
- if (opts.backend === "smith") {
1829
+ if (opts.backend === 'smith') {
1809
1830
  // roleUsesOpenrouter is true for smith, so orAuth is non-null here.
1810
- const direct = orAuth.mode === "user-key";
1831
+ const direct = orAuth.mode === 'user-key';
1811
1832
  return settled(runAgentSmith({
1812
1833
  cwd: opts.cwd,
1813
1834
  role: opts.role,
@@ -1816,14 +1837,14 @@ async function runAgentTurn(opts) {
1816
1837
  extraHeaders: meteringHeaders({
1817
1838
  deckDir: opts.cwd,
1818
1839
  sessionId,
1819
- route: "openrouter",
1840
+ route: 'openrouter',
1820
1841
  direct,
1821
1842
  }),
1822
1843
  model: opts.openrouterModel,
1823
1844
  prompt: opts.prompt,
1824
1845
  // Mirrors claude's --append-system-prompt for tasks (the native loop
1825
1846
  // appends it to its own system framing).
1826
- systemReminder: opts.role === "task" ? CLAUDE_TASK_SYSTEM_REMINDER : undefined,
1847
+ systemReminder: opts.role === 'task' ? CLAUDE_TASK_SYSTEM_REMINDER : undefined,
1827
1848
  attachments: opts.attachments,
1828
1849
  openrouterTuning: opts.openrouterTuning,
1829
1850
  timeoutMs: opts.timeoutMs,
@@ -1860,7 +1881,7 @@ async function runAgentTurn(opts) {
1860
1881
  // Cursor is the only backend whose traffic never reaches the llm-proxy, so its
1861
1882
  // run is reported from here. Every other backend is already recorded upstream,
1862
1883
  // and reporting them here too would double-count them in the same table.
1863
- if (opts.backend !== "cursor")
1884
+ if (opts.backend !== 'cursor')
1864
1885
  return settled(run);
1865
1886
  return settled(run.then((result) => {
1866
1887
  reportCursorRun({
@@ -1875,7 +1896,7 @@ async function runAgentTurn(opts) {
1875
1896
  }
1876
1897
  // -- task store ---------------------------------------------------------------
1877
1898
  function persistTaskFile(tasksDir, task) {
1878
- fs.writeFileSync(path.join(tasksDir, task.id, "task.json"), JSON.stringify(task, null, 2) + "\n");
1899
+ fs.writeFileSync(path.join(tasksDir, task.id, 'task.json'), JSON.stringify(task, null, 2) + '\n');
1879
1900
  }
1880
1901
  // Tasks left "running" by a dead serve are as finished as they will get. A
1881
1902
  // persisted "blocked" task is left as-is: it is not "waiting", so maybeStart
@@ -1884,11 +1905,11 @@ function persistTaskFile(tasksDir, task) {
1884
1905
  function loadTasks(tasksDir) {
1885
1906
  const tasks = new Map();
1886
1907
  for (const entry of fs.existsSync(tasksDir) ? fs.readdirSync(tasksDir) : []) {
1887
- const rec = readJsonFile(path.join(tasksDir, entry, "task.json"));
1908
+ const rec = readJsonFile(path.join(tasksDir, entry, 'task.json'));
1888
1909
  if (!rec)
1889
1910
  continue;
1890
- if (rec.status === "running") {
1891
- rec.status = "interrupted";
1911
+ if (rec.status === 'running') {
1912
+ rec.status = 'interrupted';
1892
1913
  rec.updatedAt = nowIso();
1893
1914
  persistTaskFile(tasksDir, rec);
1894
1915
  }
@@ -1902,9 +1923,7 @@ function refreshTaskFiles(tasksDir, task) {
1902
1923
  const dir = path.join(tasksDir, task.id);
1903
1924
  let changed = false;
1904
1925
  try {
1905
- const rawProgress = fs
1906
- .readFileSync(path.join(dir, "progress"), "utf8")
1907
- .trim();
1926
+ const rawProgress = fs.readFileSync(path.join(dir, 'progress'), 'utf8').trim();
1908
1927
  const value = Math.max(0, Math.min(100, parseInt(rawProgress, 10)));
1909
1928
  if (Number.isFinite(value) && value !== task.progress) {
1910
1929
  task.progress = value;
@@ -1915,7 +1934,7 @@ function refreshTaskFiles(tasksDir, task) {
1915
1934
  /* no progress file yet */
1916
1935
  }
1917
1936
  try {
1918
- const notes = fs.readFileSync(path.join(dir, "notes.md"), "utf8");
1937
+ const notes = fs.readFileSync(path.join(dir, 'notes.md'), 'utf8');
1919
1938
  if (notes !== task.notes) {
1920
1939
  task.notes = notes;
1921
1940
  changed = true;
@@ -1951,9 +1970,9 @@ export function classifyDeps(tasks, task) {
1951
1970
  const dep = tasks.get(id);
1952
1971
  // A dep id that no longer resolves to a task (its row was cleared) or one
1953
1972
  // that finished "done" is satisfied -- nothing left to wait on.
1954
- if (!dep || dep.status === "done")
1973
+ if (!dep || dep.status === 'done')
1955
1974
  continue;
1956
- if (dep.status === "failed" || dep.status === "interrupted") {
1975
+ if (dep.status === 'failed' || dep.status === 'interrupted') {
1957
1976
  blockedBy.push(dep.title);
1958
1977
  }
1959
1978
  else {
@@ -1961,8 +1980,8 @@ export function classifyDeps(tasks, task) {
1961
1980
  }
1962
1981
  }
1963
1982
  if (blockedBy.length > 0)
1964
- return { kind: "blocked", blockedBy };
1965
- return { kind: waiting ? "waiting" : "ready" };
1983
+ return { kind: 'blocked', blockedBy };
1984
+ return { kind: waiting ? 'waiting' : 'ready' };
1966
1985
  }
1967
1986
  // Cap on how much of an upstream task's wrap-up prose rides into a dependent's
1968
1987
  // prompt. resultSummary is already capped at RESULT_SUMMARY_CHARS; this trims
@@ -1981,12 +2000,12 @@ function depsSummaryFor(tasks, task) {
1981
2000
  // and deliberately stripped of that detail.
1982
2001
  const summary = dep.resultSummary?.trim();
1983
2002
  if (summary)
1984
- parts.push(` its wrap-up: ${summary.slice(-DEP_SUMMARY_CHARS).replace(/\n+/g, " ")}`);
2003
+ parts.push(` its wrap-up: ${summary.slice(-DEP_SUMMARY_CHARS).replace(/\n+/g, ' ')}`);
1985
2004
  if (dep.notes.trim())
1986
2005
  parts.push(` player notes: ${dep.notes.trim()}`);
1987
- return parts.join("\n");
2006
+ return parts.join('\n');
1988
2007
  });
1989
- return lines.join("\n") || undefined;
2008
+ return lines.join('\n') || undefined;
1990
2009
  }
1991
2010
  function sleep(ms) {
1992
2011
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -2020,7 +2039,7 @@ async function runTaskAgentIn(ctx, task) {
2020
2039
  // Deck source inlining is smith-only (see buildDeckContents): cursor/claude
2021
2040
  // read files themselves turn over turn, so computing and sending this
2022
2041
  // would be pure prompt bloat for them with no offsetting benefit.
2023
- const isSmith = ctx.backend === "smith";
2042
+ const isSmith = ctx.backend === 'smith';
2024
2043
  const deckContents = isSmith
2025
2044
  ? buildDeckContents(ctx.deckDir, TASK_DECK_CONTENTS_BUDGET)
2026
2045
  : undefined;
@@ -2029,8 +2048,8 @@ async function runTaskAgentIn(ctx, task) {
2029
2048
  taskId: task.id,
2030
2049
  title: task.title,
2031
2050
  prompt: task.prompt,
2032
- progressPath: path.join(relDir, "progress"),
2033
- notesPath: path.join(relDir, "notes.md"),
2051
+ progressPath: path.join(relDir, 'progress'),
2052
+ notesPath: path.join(relDir, 'notes.md'),
2034
2053
  depsSummary: ctx.depsSummary,
2035
2054
  backend: ctx.backend,
2036
2055
  // Slimmed once contents are inlined -- see DECK_TREE_SLIM_* above.
@@ -2053,38 +2072,38 @@ async function runTaskAgentIn(ctx, task) {
2053
2072
  // read/plan turns instead of stalling its first playtest. Smith-only:
2054
2073
  // playtest is a native-loop tool, so CLI-backend tasks would download a
2055
2074
  // browser they can never use.
2056
- if (ctx.backend === "smith")
2075
+ if (ctx.backend === 'smith')
2057
2076
  ctx.playtest?.prewarm?.();
2058
- let result = { ok: false, finalText: "", error: "not run" };
2059
- let lineBuf = "";
2077
+ let result = { ok: false, finalText: '', error: 'not run' };
2078
+ let lineBuf = '';
2060
2079
  // ```signal blocks span multiple lines and must NOT show in the live feed:
2061
2080
  // track whether we're inside one (line-state machine). Lines arrive complete
2062
2081
  // (flushFeedLines only emits up to a newline), so a partial fence never
2063
2082
  // flashes. On close we parse the body and hand it to ctx.onSignal.
2064
2083
  let inSignal = false;
2065
- let signalBody = "";
2084
+ let signalBody = '';
2066
2085
  const flushFeedLines = (delta) => {
2067
2086
  lineBuf += delta;
2068
- let nl = lineBuf.indexOf("\n");
2087
+ let nl = lineBuf.indexOf('\n');
2069
2088
  while (nl >= 0) {
2070
2089
  const rawLine = lineBuf.slice(0, nl);
2071
2090
  lineBuf = lineBuf.slice(nl + 1);
2072
- nl = lineBuf.indexOf("\n");
2091
+ nl = lineBuf.indexOf('\n');
2073
2092
  const line = rawLine.trim();
2074
2093
  if (inSignal) {
2075
- if (line === "```") {
2094
+ if (line === '```') {
2076
2095
  inSignal = false;
2077
2096
  ctx.onSignal(parseSignalBody(signalBody));
2078
- signalBody = "";
2097
+ signalBody = '';
2079
2098
  }
2080
2099
  else {
2081
- signalBody += rawLine + "\n";
2100
+ signalBody += rawLine + '\n';
2082
2101
  }
2083
2102
  continue;
2084
2103
  }
2085
- if (line === "```signal") {
2104
+ if (line === '```signal') {
2086
2105
  inSignal = true;
2087
- signalBody = "";
2106
+ signalBody = '';
2088
2107
  continue;
2089
2108
  }
2090
2109
  if (line)
@@ -2095,16 +2114,20 @@ async function runTaskAgentIn(ctx, task) {
2095
2114
  await staggerTaskSpawn();
2096
2115
  result = await runAgentTurn({
2097
2116
  backend: ctx.backend,
2098
- role: "task",
2117
+ role: 'task',
2099
2118
  prompt: taskPrompt,
2100
2119
  claudeModel: ctx.claudeModel,
2101
2120
  openrouterModel: ctx.openrouterModel,
2102
2121
  openrouterTuning: ctx.openrouterTuning,
2103
2122
  cwd: ctx.deckDir,
2104
2123
  timeoutMs: TASK_TIMEOUT_MS,
2105
- logPath: path.join(dir, "log.jsonl"),
2124
+ logPath: path.join(dir, 'log.jsonl'),
2106
2125
  playtest: ctx.playtest
2107
- ? { executor: ctx.playtest.executor, serveUrl: ctx.playtest.serveUrl, framesDir: path.join(dir, "playtest") }
2126
+ ? {
2127
+ executor: ctx.playtest.executor,
2128
+ serveUrl: ctx.playtest.serveUrl,
2129
+ framesDir: path.join(dir, 'playtest'),
2130
+ }
2108
2131
  : undefined,
2109
2132
  restart: ctx.restart,
2110
2133
  children: ctx.children,
@@ -2128,7 +2151,7 @@ async function runTaskAgentIn(ctx, task) {
2128
2151
  // lands here (crashed = never saw a result), and retrying that burns all
2129
2152
  // three attempts, backoff included, on config that cannot change between
2130
2153
  // them. Deterministic failures get exactly one attempt.
2131
- if (result.failure?.kind === "config")
2154
+ if (result.failure?.kind === 'config')
2132
2155
  return result;
2133
2156
  if (attempt < MAX_TASK_ATTEMPTS) {
2134
2157
  ctx.onRetry(attempt + 1);
@@ -2136,7 +2159,7 @@ async function runTaskAgentIn(ctx, task) {
2136
2159
  return result;
2137
2160
  }
2138
2161
  }
2139
- result.error = `agent process kept dying (${MAX_TASK_ATTEMPTS} attempts): ${result.error ?? ""}`;
2162
+ result.error = `agent process kept dying (${MAX_TASK_ATTEMPTS} attempts): ${result.error ?? ''}`;
2140
2163
  return result;
2141
2164
  }
2142
2165
  // Launch a task's agent run and wire up its finalization. The settle path is
@@ -2145,10 +2168,10 @@ async function runTaskAgentIn(ctx, task) {
2145
2168
  // (by skipping the slot free) nor crash the serve.
2146
2169
  function startTask(ctx, task) {
2147
2170
  const dir = path.join(ctx.tasksDir, task.id);
2148
- fs.writeFileSync(path.join(dir, "progress"), "0\n");
2149
- if (!fs.existsSync(path.join(dir, "notes.md")))
2150
- fs.writeFileSync(path.join(dir, "notes.md"), "");
2151
- task.status = "running";
2171
+ fs.writeFileSync(path.join(dir, 'progress'), '0\n');
2172
+ if (!fs.existsSync(path.join(dir, 'notes.md')))
2173
+ fs.writeFileSync(path.join(dir, 'notes.md'), '');
2174
+ task.status = 'running';
2152
2175
  task.startedAt = nowIso();
2153
2176
  ctx.touch(task);
2154
2177
  ctx.onStarted(task);
@@ -2177,8 +2200,7 @@ function startTask(ctx, task) {
2177
2200
  onRetry: (attempt) => ctx.onRetry(task, attempt),
2178
2201
  onSignal: (signal) => {
2179
2202
  let changed = false;
2180
- if (typeof signal.progress === "number" &&
2181
- signal.progress !== task.progress) {
2203
+ if (typeof signal.progress === 'number' && signal.progress !== task.progress) {
2182
2204
  task.progress = signal.progress;
2183
2205
  changed = true;
2184
2206
  }
@@ -2202,11 +2224,7 @@ function startTask(ctx, task) {
2202
2224
  try {
2203
2225
  refreshTaskFiles(ctx.tasksDir, task);
2204
2226
  const wasStopped = ctx.stopRequested.delete(task.id);
2205
- task.status = wasStopped
2206
- ? "interrupted"
2207
- : result.ok
2208
- ? "done"
2209
- : "failed";
2227
+ task.status = wasStopped ? 'interrupted' : result.ok ? 'done' : 'failed';
2210
2228
  // A stopped task is cleared off the board (castle-stop = halt + remove).
2211
2229
  if (wasStopped)
2212
2230
  task.acknowledged = true;
@@ -2215,10 +2233,10 @@ function startTask(ctx, task) {
2215
2233
  task.playtestFrames = result.playtestFrames ?? [];
2216
2234
  task.finishedAt = nowIso();
2217
2235
  task.resultSummary = wasStopped
2218
- ? "stopped by the router"
2236
+ ? 'stopped by the router'
2219
2237
  : result.ok
2220
2238
  ? result.finalText.slice(-RESULT_SUMMARY_CHARS)
2221
- : `${result.error ?? "failed"}\n${result.finalText.slice(-RESULT_SUMMARY_CHARS)}`;
2239
+ : `${result.error ?? 'failed'}\n${result.finalText.slice(-RESULT_SUMMARY_CHARS)}`;
2222
2240
  if (!wasStopped && !result.ok) {
2223
2241
  const failure = resolveFailure(result);
2224
2242
  // spawnedTasks/willRetry are router-turn concepts; a task card has
@@ -2235,11 +2253,11 @@ function startTask(ctx, task) {
2235
2253
  ctx.touch(task);
2236
2254
  }
2237
2255
  catch (err) {
2238
- console.error(`[task ${task.id}] finalization threw: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
2256
+ console.error(`[task ${task.id}] finalization threw: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
2239
2257
  // Best-effort: still mark the task terminal so it can't hang forever.
2240
2258
  try {
2241
2259
  ctx.stopRequested.delete(task.id);
2242
- task.status = "failed";
2260
+ task.status = 'failed';
2243
2261
  task.finishedAt = nowIso();
2244
2262
  task.resultSummary = `internal error finalizing task: ${err instanceof Error ? err.message : String(err)}`;
2245
2263
  ctx.touch(task);
@@ -2254,17 +2272,17 @@ function startTask(ctx, task) {
2254
2272
  ctx.onFinished(task);
2255
2273
  }
2256
2274
  catch (err) {
2257
- console.error(`[task ${task.id}] onFinished threw: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
2275
+ console.error(`[task ${task.id}] onFinished threw: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
2258
2276
  }
2259
2277
  ctx.rescheduleAll();
2260
2278
  }
2261
2279
  })
2262
2280
  .catch((err) => {
2263
2281
  // runTaskAgentIn itself rejected. Free the slot so the board keeps moving.
2264
- console.error(`[task ${task.id}] run promise rejected: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
2282
+ console.error(`[task ${task.id}] run promise rejected: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
2265
2283
  try {
2266
2284
  ctx.stopRequested.delete(task.id);
2267
- task.status = "failed";
2285
+ task.status = 'failed';
2268
2286
  task.finishedAt = nowIso();
2269
2287
  task.resultSummary = `task run failed: ${err instanceof Error ? err.message : String(err)}`;
2270
2288
  ctx.touch(task);
@@ -2283,17 +2301,17 @@ function startTask(ctx, task) {
2283
2301
  // process, so it collapses the same way "waiting" does -- this is the only
2284
2302
  // way a blocked row ever clears (see ROUTER_RULES).
2285
2303
  function haltTask(task, children, stopRequested, touch) {
2286
- if (task.status === "waiting" || task.status === "blocked") {
2287
- task.status = "interrupted";
2304
+ if (task.status === 'waiting' || task.status === 'blocked') {
2305
+ task.status = 'interrupted';
2288
2306
  task.acknowledged = true;
2289
2307
  touch(task);
2290
2308
  }
2291
- else if (task.status === "running") {
2309
+ else if (task.status === 'running') {
2292
2310
  stopRequested.add(task.id);
2293
2311
  for (const child of children) {
2294
2312
  if (child.pid === task.pid) {
2295
2313
  try {
2296
- child.kill("SIGKILL");
2314
+ child.kill('SIGKILL');
2297
2315
  }
2298
2316
  catch {
2299
2317
  /* already gone */
@@ -2319,21 +2337,21 @@ function createTaskStore(opts) {
2319
2337
  function runningCount() {
2320
2338
  let n = 0;
2321
2339
  for (const t of tasks.values())
2322
- if (t.status === "running")
2340
+ if (t.status === 'running')
2323
2341
  n++;
2324
2342
  return n;
2325
2343
  }
2326
2344
  function maybeStart(task) {
2327
- if (task.status !== "waiting" || task.acknowledged)
2345
+ if (task.status !== 'waiting' || task.acknowledged)
2328
2346
  return;
2329
2347
  const deps = classifyDeps(tasks, task);
2330
- if (deps.kind === "waiting")
2348
+ if (deps.kind === 'waiting')
2331
2349
  return;
2332
2350
  // A dep finalized failed/interrupted -- this task can never do its job
2333
2351
  // (the output it needed never materialized), so it flips to "blocked"
2334
2352
  // instead of ever starting. Only the router clears it (castle-stop).
2335
- if (deps.kind === "blocked") {
2336
- task.status = "blocked";
2353
+ if (deps.kind === 'blocked') {
2354
+ task.status = 'blocked';
2337
2355
  task.blockedBy = deps.blockedBy;
2338
2356
  touch(task);
2339
2357
  return;
@@ -2354,7 +2372,7 @@ function createTaskStore(opts) {
2354
2372
  maybeStart(waiting);
2355
2373
  }
2356
2374
  catch (err) {
2357
- console.error(`[task ${waiting.id}] maybeStart threw: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
2375
+ console.error(`[task ${waiting.id}] maybeStart threw: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
2358
2376
  }
2359
2377
  }
2360
2378
  }
@@ -2386,9 +2404,9 @@ function createTaskStore(opts) {
2386
2404
  title: directive.title,
2387
2405
  prompt: directive.prompt,
2388
2406
  after: resolveDeps(tasks, directive.after),
2389
- status: "waiting",
2407
+ status: 'waiting',
2390
2408
  progress: 0,
2391
- notes: "",
2409
+ notes: '',
2392
2410
  createdAt: nowIso(),
2393
2411
  updatedAt: nowIso(),
2394
2412
  originMessageId,
@@ -2412,7 +2430,7 @@ function createTaskStore(opts) {
2412
2430
  }
2413
2431
  const pollTimer = setInterval(() => {
2414
2432
  for (const task of tasks.values()) {
2415
- if (task.status !== "running")
2433
+ if (task.status !== 'running')
2416
2434
  continue;
2417
2435
  if (refreshTaskFiles(tasksDir, task))
2418
2436
  touch(task);
@@ -2421,8 +2439,8 @@ function createTaskStore(opts) {
2421
2439
  function shutdown() {
2422
2440
  clearInterval(pollTimer);
2423
2441
  for (const task of tasks.values()) {
2424
- if (task.status === "running" || task.status === "waiting") {
2425
- task.status = "interrupted";
2442
+ if (task.status === 'running' || task.status === 'waiting') {
2443
+ task.status = 'interrupted';
2426
2444
  task.updatedAt = nowIso();
2427
2445
  persistTaskFile(tasksDir, task);
2428
2446
  }
@@ -2431,15 +2449,13 @@ function createTaskStore(opts) {
2431
2449
  // True when a fence body is the special token "all" / "*" (clear/stop
2432
2450
  // everything, no per-task enumeration).
2433
2451
  function meansAll(tokens) {
2434
- return tokens.some((t) => t.toLowerCase() === "all" || t === "*");
2452
+ return tokens.some((t) => t.toLowerCase() === 'all' || t === '*');
2435
2453
  }
2436
2454
  // The router checks finished tasks off by title or id (castle-done fence),
2437
2455
  // or "all" to clear every finished row off the board at once.
2438
2456
  function checkOff(tokens) {
2439
2457
  const ids = meansAll(tokens)
2440
- ? [...tasks.values()]
2441
- .filter((t) => isTerminal(t.status) && !t.acknowledged)
2442
- .map((t) => t.id)
2458
+ ? [...tasks.values()].filter((t) => isTerminal(t.status) && !t.acknowledged).map((t) => t.id)
2443
2459
  : resolveDeps(tasks, tokens);
2444
2460
  for (const id of ids)
2445
2461
  acknowledge(id, false);
@@ -2452,9 +2468,7 @@ function createTaskStore(opts) {
2452
2468
  function stop(tokens) {
2453
2469
  const ids = meansAll(tokens)
2454
2470
  ? [...tasks.values()]
2455
- .filter((t) => t.status === "running" ||
2456
- t.status === "waiting" ||
2457
- t.status === "blocked")
2471
+ .filter((t) => t.status === 'running' || t.status === 'waiting' || t.status === 'blocked')
2458
2472
  .map((t) => t.id)
2459
2473
  : resolveDeps(tasks, tokens);
2460
2474
  for (const id of ids) {
@@ -2475,11 +2489,11 @@ function createTaskStore(opts) {
2475
2489
  }
2476
2490
  // -- attachments ----------------------------------------------------------------
2477
2491
  const ATTACHMENT_MIME = {
2478
- png: "image/png",
2479
- jpg: "image/jpeg",
2480
- jpeg: "image/jpeg",
2481
- gif: "image/gif",
2482
- webp: "image/webp",
2492
+ png: 'image/png',
2493
+ jpg: 'image/jpeg',
2494
+ jpeg: 'image/jpeg',
2495
+ gif: 'image/gif',
2496
+ webp: 'image/webp',
2483
2497
  };
2484
2498
  // Decode pasted/attached images (data URLs) into .castle/agent/attachments/.
2485
2499
  // Returns the saved file names.
@@ -2489,17 +2503,16 @@ function saveAttachments(attachmentsDir, messageId, images) {
2489
2503
  const saved = [];
2490
2504
  for (const [index, image] of images.slice(0, MAX_ATTACHMENTS).entries()) {
2491
2505
  const dataUrl = image?.dataUrl;
2492
- if (typeof dataUrl !== "string" ||
2493
- dataUrl.length > MAX_ATTACHMENT_BYTES * 1.4)
2506
+ if (typeof dataUrl !== 'string' || dataUrl.length > MAX_ATTACHMENT_BYTES * 1.4)
2494
2507
  continue;
2495
2508
  const match = /^data:image\/(png|jpe?g|gif|webp);base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl);
2496
2509
  if (!match)
2497
2510
  continue;
2498
- const ext = match[1] === "jpeg" ? "jpg" : match[1];
2511
+ const ext = match[1] === 'jpeg' ? 'jpg' : match[1];
2499
2512
  const fileName = `${messageId}-${index}.${ext}`;
2500
2513
  try {
2501
2514
  fs.mkdirSync(attachmentsDir, { recursive: true });
2502
- fs.writeFileSync(path.join(attachmentsDir, fileName), Buffer.from(match[2], "base64"));
2515
+ fs.writeFileSync(path.join(attachmentsDir, fileName), Buffer.from(match[2], 'base64'));
2503
2516
  saved.push(fileName);
2504
2517
  }
2505
2518
  catch {
@@ -2539,13 +2552,13 @@ function looksLikeErrorLine(line) {
2539
2552
  }
2540
2553
  function firstErrorLine(resultSummary) {
2541
2554
  const lines = resultSummary
2542
- ?.split("\n")
2555
+ ?.split('\n')
2543
2556
  .map((l) => l.trim())
2544
2557
  .filter((l) => l.length > 0) ?? [];
2545
2558
  if (lines.length === 0)
2546
2559
  return undefined;
2547
2560
  const first = lines[0];
2548
- if (first === "agent exited 0") {
2561
+ if (first === 'agent exited 0') {
2549
2562
  return (lines[1] ?? first).slice(0, ERROR_PREVIEW_CHARS);
2550
2563
  }
2551
2564
  if (!EXIT_WRAPPER_RE.test(first) || looksLikeErrorLine(first)) {
@@ -2563,11 +2576,11 @@ function asPromptTask(task) {
2563
2576
  return {
2564
2577
  id: task.id,
2565
2578
  title: task.title,
2566
- status: task.rejected ? "rejected by user" : task.status,
2579
+ status: task.rejected ? 'rejected by user' : task.status,
2567
2580
  progress: task.progress,
2568
2581
  notes: task.notes,
2569
- error: task.status === "failed" ? firstErrorLine(task.resultSummary) : undefined,
2570
- blockedBy: task.status === "blocked" ? task.blockedBy : undefined,
2582
+ error: task.status === 'failed' ? firstErrorLine(task.resultSummary) : undefined,
2583
+ blockedBy: task.status === 'blocked' ? task.blockedBy : undefined,
2571
2584
  };
2572
2585
  }
2573
2586
  function asClientTask(task) {
@@ -2603,7 +2616,7 @@ function createTaskFeeds(broadcast) {
2603
2616
  feed.push(entry);
2604
2617
  if (feed.length > 80)
2605
2618
  feed.splice(0, feed.length - 80);
2606
- broadcast({ type: "task-feed", id: task.id, entry });
2619
+ broadcast({ type: 'task-feed', id: task.id, entry });
2607
2620
  }
2608
2621
  return { map, push };
2609
2622
  }
@@ -2619,7 +2632,7 @@ function createMessageThinking(broadcast) {
2619
2632
  else {
2620
2633
  map.set(id, { text: delta, firstAt: now, lastAt: now });
2621
2634
  }
2622
- broadcast({ type: "message-thinking-delta", id, delta });
2635
+ broadcast({ type: 'message-thinking-delta', id, delta });
2623
2636
  }
2624
2637
  function snapshot() {
2625
2638
  return Object.fromEntries([...map].map(([id, e]) => [id, { text: e.text, ms: e.lastAt - e.firstAt }]));
@@ -2629,40 +2642,40 @@ function createMessageThinking(broadcast) {
2629
2642
  function createMessageLog(messagesPath, broadcast, welcomeMessage) {
2630
2643
  const loaded = readJsonFile(messagesPath) ?? [];
2631
2644
  const messages = loaded
2632
- .filter((m) => m.text.trim() !== "" || m.role === "user")
2633
- .map((m) => m.status === "streaming" ? { ...m, status: "done" } : m);
2645
+ .filter((m) => m.text.trim() !== '' || m.role === 'user')
2646
+ .map((m) => (m.status === 'streaming' ? { ...m, status: 'done' } : m));
2634
2647
  function persist() {
2635
- fs.writeFileSync(messagesPath, JSON.stringify(messages, null, 2) + "\n");
2648
+ fs.writeFileSync(messagesPath, JSON.stringify(messages, null, 2) + '\n');
2636
2649
  }
2637
2650
  if (messages.length === 0) {
2638
2651
  messages.push({
2639
2652
  id: nanoid(8),
2640
- role: "assistant",
2653
+ role: 'assistant',
2641
2654
  text: welcomeMessage,
2642
2655
  at: nowIso(),
2643
- status: "done",
2656
+ status: 'done',
2644
2657
  });
2645
2658
  persist();
2646
2659
  }
2647
2660
  function add(message) {
2648
2661
  messages.push(message);
2649
2662
  persist();
2650
- broadcast({ type: "message-add", message });
2663
+ broadcast({ type: 'message-add', message });
2651
2664
  }
2652
2665
  function addLog(text) {
2653
- add({ id: nanoid(8), role: "log", text, at: nowIso(), status: "done" });
2666
+ add({ id: nanoid(8), role: 'log', text, at: nowIso(), status: 'done' });
2654
2667
  }
2655
2668
  // Consecutive same-prefix log lines collapse into one ("working on: A, B").
2656
2669
  function addGroupedLog(prefix, item) {
2657
2670
  const last = messages[messages.length - 1];
2658
- if (last && last.role === "log" && last.text.startsWith(prefix)) {
2671
+ if (last && last.role === 'log' && last.text.startsWith(prefix)) {
2659
2672
  last.text += `, ${item}`;
2660
2673
  persist();
2661
2674
  broadcast({
2662
- type: "message-done",
2675
+ type: 'message-done',
2663
2676
  id: last.id,
2664
2677
  text: last.text,
2665
- status: "done",
2678
+ status: 'done',
2666
2679
  });
2667
2680
  return;
2668
2681
  }
@@ -2676,14 +2689,14 @@ function makeAttachmentHandler(attachmentsDir) {
2676
2689
  if (!reqPath.startsWith(AGENT_ATTACHMENT_PREFIX))
2677
2690
  return false;
2678
2691
  const name = path.basename(reqPath.slice(AGENT_ATTACHMENT_PREFIX.length));
2679
- const ext = name.split(".").pop() ?? "";
2692
+ const ext = name.split('.').pop() ?? '';
2680
2693
  const mime = ATTACHMENT_MIME[ext];
2681
2694
  const filePath = path.join(attachmentsDir, name);
2682
2695
  if (!mime || !fs.existsSync(filePath)) {
2683
2696
  res.writeHead(404).end();
2684
2697
  return true;
2685
2698
  }
2686
- res.writeHead(200, { "content-type": mime, "cache-control": "no-store" });
2699
+ res.writeHead(200, { 'content-type': mime, 'cache-control': 'no-store' });
2687
2700
  fs.createReadStream(filePath).pipe(res);
2688
2701
  return true;
2689
2702
  };
@@ -2696,15 +2709,15 @@ function makePlaytestFrameHandler(tasksDir) {
2696
2709
  if (!reqPath.startsWith(AGENT_PLAYTEST_PREFIX))
2697
2710
  return false;
2698
2711
  const rest = reqPath.slice(AGENT_PLAYTEST_PREFIX.length);
2699
- const slash = rest.indexOf("/");
2700
- const taskId = slash >= 0 ? path.basename(rest.slice(0, slash)) : "";
2701
- const name = slash >= 0 ? path.basename(rest.slice(slash + 1)) : "";
2702
- const filePath = path.join(tasksDir, taskId, "playtest", name);
2703
- if (!taskId || !name || !name.endsWith(".png") || !fs.existsSync(filePath)) {
2712
+ const slash = rest.indexOf('/');
2713
+ const taskId = slash >= 0 ? path.basename(rest.slice(0, slash)) : '';
2714
+ const name = slash >= 0 ? path.basename(rest.slice(slash + 1)) : '';
2715
+ const filePath = path.join(tasksDir, taskId, 'playtest', name);
2716
+ if (!taskId || !name || !name.endsWith('.png') || !fs.existsSync(filePath)) {
2704
2717
  res.writeHead(404).end();
2705
2718
  return true;
2706
2719
  }
2707
- res.writeHead(200, { "content-type": "image/png", "cache-control": "no-store" });
2720
+ res.writeHead(200, { 'content-type': 'image/png', 'cache-control': 'no-store' });
2708
2721
  fs.createReadStream(filePath).pipe(res);
2709
2722
  return true;
2710
2723
  };
@@ -2724,12 +2737,12 @@ function resolveFailure(result) {
2724
2737
  if (result.failure)
2725
2738
  return result.failure;
2726
2739
  const error = result.error;
2727
- const kind = error?.startsWith("could not run")
2728
- ? "spawn"
2729
- : error === "agent run timed out"
2730
- ? "timeout"
2731
- : "exit";
2732
- return { kind, detail: error ?? "unknown failure" };
2740
+ const kind = error?.startsWith('could not run')
2741
+ ? 'spawn'
2742
+ : error === 'agent run timed out'
2743
+ ? 'timeout'
2744
+ : 'exit';
2745
+ return { kind, detail: error ?? 'unknown failure' };
2733
2746
  }
2734
2747
  // Assemble the full stateless prompt for one router turn: rules + deck
2735
2748
  // context + transcript replay (minus log lines and the in-flight reply) +
@@ -2738,25 +2751,21 @@ function routerTurnPrompt(ctx, instruction, selfMessageId) {
2738
2751
  // Smith-only, smaller budget than a task's -- see TASK_DECK_CONTENTS_BUDGET/
2739
2752
  // ROUTER_DECK_CONTENTS_BUDGET's comment (the router prompt is already the
2740
2753
  // largest one this serve builds).
2741
- const isSmith = ctx.backend() === "smith";
2754
+ const isSmith = ctx.backend() === 'smith';
2742
2755
  return buildRouterPrompt({
2743
2756
  deckLabel: ctx.deckLabel,
2744
2757
  quickReference: ctx.quickReference,
2745
2758
  deckTree: buildDeckTree(ctx.deckDir, isSmith
2746
2759
  ? { maxEntries: DECK_TREE_SLIM_MAX_ENTRIES, perDir: DECK_TREE_SLIM_PER_DIR }
2747
2760
  : undefined),
2748
- deckContents: isSmith
2749
- ? buildDeckContents(ctx.deckDir, ROUTER_DECK_CONTENTS_BUDGET)
2750
- : undefined,
2761
+ deckContents: isSmith ? buildDeckContents(ctx.deckDir, ROUTER_DECK_CONTENTS_BUDGET) : undefined,
2751
2762
  messages: ctx.log.messages
2752
- .filter((m) => m.role !== "log" &&
2753
- m.id !== selfMessageId &&
2754
- m.status !== "streaming")
2763
+ .filter((m) => m.role !== 'log' && m.id !== selfMessageId && m.status !== 'streaming')
2755
2764
  .map((m) => ({
2756
2765
  role: m.role,
2757
2766
  // Replace a prior turn's raw ```ask JSON with a readable question list
2758
2767
  // so the model doesn't re-echo the block verbatim.
2759
- text: m.role === "assistant" ? humanizeAskBlocks(m.text) : m.text,
2768
+ text: m.role === 'assistant' ? humanizeAskBlocks(m.text) : m.text,
2760
2769
  interrupted: m.interrupted,
2761
2770
  })),
2762
2771
  // Only the live board -- match what the user sees. Hide tasks that are
@@ -2781,26 +2790,26 @@ function routerTurnPrompt(ctx, instruction, selfMessageId) {
2781
2790
  // anchor, so it always takes the interrupted-draft (kept) path instead.
2782
2791
  function settleInterruptedTurn(ctx, message) {
2783
2792
  const hasSpawnedTasks = (message.taskIds?.length ?? 0) > 0;
2784
- if (message.text.trim() === "" && !hasSpawnedTasks) {
2793
+ if (message.text.trim() === '' && !hasSpawnedTasks) {
2785
2794
  const idx = ctx.log.messages.indexOf(message);
2786
2795
  if (idx >= 0)
2787
2796
  ctx.log.messages.splice(idx, 1);
2788
2797
  ctx.log.persist();
2789
2798
  ctx.broadcast({
2790
- type: "message-done",
2799
+ type: 'message-done',
2791
2800
  id: message.id,
2792
- text: "",
2793
- status: "done",
2801
+ text: '',
2802
+ status: 'done',
2794
2803
  });
2795
2804
  return;
2796
2805
  }
2797
2806
  // Keep whatever streamed and/or spawned; the continuation turn carries the
2798
2807
  // text draft (tasks already launched by this turn just stay on the board).
2799
- message.status = "done";
2808
+ message.status = 'done';
2800
2809
  message.interrupted = true;
2801
2810
  ctx.log.persist();
2802
2811
  ctx.broadcast({
2803
- type: "message-done",
2812
+ type: 'message-done',
2804
2813
  id: message.id,
2805
2814
  text: message.text,
2806
2815
  status: message.status,
@@ -2822,7 +2831,7 @@ function spawnCompletedTaskFences(ctx, message, midStream, raw) {
2822
2831
  return;
2823
2832
  const inFlight = new Set(ctx.taskStore
2824
2833
  .sorted()
2825
- .filter((t) => t.status === "running" || t.status === "waiting")
2834
+ .filter((t) => t.status === 'running' || t.status === 'waiting')
2826
2835
  .map((t) => t.title.toLowerCase()));
2827
2836
  const newIds = [];
2828
2837
  for (const directive of directives) {
@@ -2839,9 +2848,9 @@ function spawnCompletedTaskFences(ctx, message, midStream, raw) {
2839
2848
  // ends) -- this delta only carries the updated taskIds so connected clients
2840
2849
  // can react (e.g. show task chips) before the turn finishes.
2841
2850
  ctx.broadcast({
2842
- type: "message-delta",
2851
+ type: 'message-delta',
2843
2852
  id: message.id,
2844
- delta: "",
2853
+ delta: '',
2845
2854
  taskIds: message.taskIds,
2846
2855
  });
2847
2856
  }
@@ -2854,14 +2863,14 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
2854
2863
  const epoch = ctx.currentEpoch();
2855
2864
  const message = {
2856
2865
  id: nanoid(8),
2857
- role: "assistant",
2858
- text: "",
2866
+ role: 'assistant',
2867
+ text: '',
2859
2868
  at: nowIso(),
2860
- status: "streaming",
2869
+ status: 'streaming',
2861
2870
  };
2862
2871
  ctx.log.messages.push(message);
2863
- ctx.broadcast({ type: "message-add", message });
2864
- let raw = "";
2872
+ ctx.broadcast({ type: 'message-add', message });
2873
+ let raw = '';
2865
2874
  let visibleSent = 0;
2866
2875
  const midStream = { scannedUpTo: 0, spawnedTitles: new Set() };
2867
2876
  // Seed the activity line to "thinking" immediately -- covers the otherwise
@@ -2870,13 +2879,13 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
2870
2879
  // overrides it: a tool relabels it, the first text delta clears it. Seeding
2871
2880
  // lastActivity too dedups the redundant broadcast when the claude thinking
2872
2881
  // block later re-emits "thinking".
2873
- let lastActivity = "Thinking";
2874
- ctx.broadcast({ type: "message-activity", id: message.id, activity: "Thinking" });
2882
+ let lastActivity = 'Thinking';
2883
+ ctx.broadcast({ type: 'message-activity', id: message.id, activity: 'Thinking' });
2875
2884
  const prompt = routerTurnPrompt(ctx, instruction, message.id);
2876
2885
  const backend = ctx.backend();
2877
2886
  void runAgentTurn({
2878
2887
  backend,
2879
- role: "router",
2888
+ role: 'router',
2880
2889
  prompt,
2881
2890
  claudeModel: ctx.claudeModel(),
2882
2891
  openrouterModel: ctx.openrouterModel(),
@@ -2886,7 +2895,7 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
2886
2895
  timeoutMs: ROUTER_TIMEOUT_MS,
2887
2896
  // CLI backends append raw stream-json; smith appends structured run
2888
2897
  // events (see createRunLogger in native/loop.ts). Same file either way.
2889
- logPath: path.join(ctx.agentDir, "router-log.jsonl"),
2898
+ logPath: path.join(ctx.agentDir, 'router-log.jsonl'),
2890
2899
  children: ctx.children,
2891
2900
  labelUnknownTools: true,
2892
2901
  onDelta: (delta) => {
@@ -2896,7 +2905,7 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
2896
2905
  const slice = raw.slice(visibleSent, visible);
2897
2906
  visibleSent = visible;
2898
2907
  message.text += slice;
2899
- ctx.broadcast({ type: "message-delta", id: message.id, delta: slice });
2908
+ ctx.broadcast({ type: 'message-delta', id: message.id, delta: slice });
2900
2909
  }
2901
2910
  // Spawning changes WHEN a fence is acted on, not what is displayed --
2902
2911
  // the holdback above still hides fenced text until the reply ends.
@@ -2906,14 +2915,14 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
2906
2915
  if (activity === lastActivity)
2907
2916
  return;
2908
2917
  lastActivity = activity;
2909
- ctx.broadcast({ type: "message-activity", id: message.id, activity });
2918
+ ctx.broadcast({ type: 'message-activity', id: message.id, activity });
2910
2919
  },
2911
2920
  onThinking: (delta) => {
2912
2921
  ctx.messageThinking.append(message.id, delta);
2913
2922
  },
2914
2923
  })
2915
2924
  .then((result) => {
2916
- logAgentUsage("router", backend, result.usage);
2925
+ logAgentUsage('router', backend, result.usage);
2917
2926
  // Signals the finally -> onSettled(retryable): the turn failed cleanly
2918
2927
  // enough (transient, nothing salvaged) that the queue may re-run it.
2919
2928
  let retryable = false;
@@ -2943,7 +2952,7 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
2943
2952
  const stale = epoch !== ctx.currentEpoch();
2944
2953
  const inFlight = new Set(ctx.taskStore
2945
2954
  .sorted()
2946
- .filter((t) => t.status === "running" || t.status === "waiting")
2955
+ .filter((t) => t.status === 'running' || t.status === 'waiting')
2947
2956
  .map((t) => t.title.toLowerCase()));
2948
2957
  const toSpawn = stale
2949
2958
  ? []
@@ -2953,7 +2962,7 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
2953
2962
  const taskIds = [...(message.taskIds ?? []), ...newlySpawnedIds];
2954
2963
  if (result.ok) {
2955
2964
  message.text = cleaned;
2956
- message.status = "done";
2965
+ message.status = 'done';
2957
2966
  }
2958
2967
  else {
2959
2968
  const failure = resolveFailure(result);
@@ -2970,27 +2979,25 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
2970
2979
  // different reason: it has ALREADY been retried at the transport
2971
2980
  // layer (3 backed-off connects), and a turn-level retry on top just
2972
2981
  // doubles the wait with the composer frozen.
2973
- const salvaged = cleaned !== "" || taskIds.length > 0;
2974
- retryable = failure.kind === "exit" && !salvaged;
2982
+ const salvaged = cleaned !== '' || taskIds.length > 0;
2983
+ retryable = failure.kind === 'exit' && !salvaged;
2975
2984
  const copy = failureCopy({
2976
2985
  failure,
2977
2986
  spawnedTasks: taskIds.length > 0,
2978
2987
  willRetry: retryable && ctx.canAutoRetry(),
2979
2988
  });
2980
2989
  message.text = cleaned ? `${cleaned}\n\n${copy}` : copy;
2981
- message.status = "error";
2982
- message.errorDetail = failure.detail || result.error || "unknown failure";
2990
+ message.status = 'error';
2991
+ message.errorDetail = failure.detail || result.error || 'unknown failure';
2983
2992
  errorVerbose = failure.verbose;
2984
- const label = failure.reason
2985
- ? `${failure.kind}/${failure.reason}`
2986
- : failure.kind;
2993
+ const label = failure.reason ? `${failure.kind}/${failure.reason}` : failure.kind;
2987
2994
  console.error(`[router] turn failed (${label}): ${failure.verbose ?? message.errorDetail}`);
2988
2995
  }
2989
2996
  if (taskIds.length > 0)
2990
2997
  message.taskIds = taskIds;
2991
2998
  ctx.log.persist();
2992
2999
  ctx.broadcast({
2993
- type: "message-done",
3000
+ type: 'message-done',
2994
3001
  id: message.id,
2995
3002
  text: message.text,
2996
3003
  status: message.status,
@@ -3012,13 +3019,13 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
3012
3019
  const short = err instanceof Error ? err.message : String(err);
3013
3020
  console.error(`[router] turn callback threw: ${detail}`);
3014
3021
  try {
3015
- message.status = "error";
3022
+ message.status = 'error';
3016
3023
  const copy = "Something went wrong on my end partway through. Send another message and I'll pick it back up.";
3017
- message.text = `${message.text ? message.text + "\n\n" : ""}${copy}`;
3024
+ message.text = `${message.text ? message.text + '\n\n' : ''}${copy}`;
3018
3025
  message.errorDetail = short;
3019
3026
  ctx.log.persist();
3020
3027
  ctx.broadcast({
3021
- type: "message-done",
3028
+ type: 'message-done',
3022
3029
  id: message.id,
3023
3030
  text: message.text,
3024
3031
  status: message.status,
@@ -3027,7 +3034,7 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
3027
3034
  });
3028
3035
  }
3029
3036
  catch (inner) {
3030
- console.error(`[router] failed to surface turn error: ${inner instanceof Error ? inner.stack ?? inner.message : String(inner)}`);
3037
+ console.error(`[router] failed to surface turn error: ${inner instanceof Error ? (inner.stack ?? inner.message) : String(inner)}`);
3031
3038
  }
3032
3039
  }
3033
3040
  finally {
@@ -3038,12 +3045,12 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
3038
3045
  // runAgentCli itself rejected (it normally resolves with result.ok=false,
3039
3046
  // so this is the rare hard-failure path). Still settle so routerRunning
3040
3047
  // can't stick true and freeze the composer.
3041
- console.error(`[router] turn promise rejected: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
3048
+ console.error(`[router] turn promise rejected: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
3042
3049
  try {
3043
3050
  ctx.onSettled();
3044
3051
  }
3045
3052
  catch (inner) {
3046
- console.error(`[router] onSettled threw during rejection recovery: ${inner instanceof Error ? inner.stack ?? inner.message : String(inner)}`);
3053
+ console.error(`[router] onSettled threw during rejection recovery: ${inner instanceof Error ? (inner.stack ?? inner.message) : String(inner)}`);
3047
3054
  }
3048
3055
  });
3049
3056
  }
@@ -3051,48 +3058,48 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
3051
3058
  function applyAgentSettings(incoming, ctx) {
3052
3059
  const { settings } = ctx;
3053
3060
  const changes = [];
3054
- for (const key of ["router", "tasks"]) {
3061
+ for (const key of ['router', 'tasks']) {
3055
3062
  const value = normalizeBackend(incoming[key]);
3056
3063
  if (value && value !== settings[key]) {
3057
3064
  settings[key] = value;
3058
3065
  changes.push(`${key} agent -> ${value}`);
3059
3066
  }
3060
3067
  }
3061
- for (const key of ["routerClaudeModel", "tasksClaudeModel"]) {
3068
+ for (const key of ['routerClaudeModel', 'tasksClaudeModel']) {
3062
3069
  const value = normalizeClaudeModel(incoming[key]);
3063
3070
  if (value && value !== settings[key]) {
3064
3071
  settings[key] = value;
3065
- changes.push(`${key === "routerClaudeModel" ? "operator" : "tasks"} claude model -> ${value}`);
3072
+ changes.push(`${key === 'routerClaudeModel' ? 'operator' : 'tasks'} claude model -> ${value}`);
3066
3073
  }
3067
3074
  }
3068
- for (const key of ["routerOpenrouterModel", "tasksOpenrouterModel"]) {
3075
+ for (const key of ['routerOpenrouterModel', 'tasksOpenrouterModel']) {
3069
3076
  const value = normalizeOpenrouterModel(incoming[key]);
3070
3077
  if (value && value !== settings[key]) {
3071
3078
  settings[key] = value;
3072
- changes.push(`${key === "routerOpenrouterModel" ? "operator" : "tasks"} openrouter model -> ${value}`);
3079
+ changes.push(`${key === 'routerOpenrouterModel' ? 'operator' : 'tasks'} openrouter model -> ${value}`);
3073
3080
  }
3074
3081
  }
3075
- for (const key of ["routerReasoningEffort", "tasksReasoningEffort"]) {
3082
+ for (const key of ['routerReasoningEffort', 'tasksReasoningEffort']) {
3076
3083
  const value = normalizeReasoningEffort(incoming[key]);
3077
3084
  if (value && value !== settings[key]) {
3078
3085
  settings[key] = value;
3079
- changes.push(`${key === "routerReasoningEffort" ? "operator" : "tasks"} reasoning effort -> ${value}`);
3086
+ changes.push(`${key === 'routerReasoningEffort' ? 'operator' : 'tasks'} reasoning effort -> ${value}`);
3080
3087
  }
3081
3088
  }
3082
- for (const key of ["routerRouting", "tasksRouting"]) {
3089
+ for (const key of ['routerRouting', 'tasksRouting']) {
3083
3090
  const value = normalizeRoutingMode(incoming[key]);
3084
3091
  if (value && value !== settings[key]) {
3085
3092
  settings[key] = value;
3086
- changes.push(`${key === "routerRouting" ? "operator" : "tasks"} routing -> ${value}`);
3093
+ changes.push(`${key === 'routerRouting' ? 'operator' : 'tasks'} routing -> ${value}`);
3087
3094
  }
3088
3095
  }
3089
- for (const key of ["routerProviderTier", "tasksProviderTier"]) {
3096
+ for (const key of ['routerProviderTier', 'tasksProviderTier']) {
3090
3097
  // "" is a valid value (auto), so check for null (invalid) explicitly
3091
3098
  // rather than truthiness -- otherwise the tier could never be cleared.
3092
3099
  const value = normalizeProviderTier(incoming[key]);
3093
3100
  if (value !== null && value !== settings[key]) {
3094
3101
  settings[key] = value;
3095
- changes.push(`${key === "routerProviderTier" ? "operator" : "tasks"} provider tier -> ${value || "auto"}`);
3102
+ changes.push(`${key === 'routerProviderTier' ? 'operator' : 'tasks'} provider tier -> ${value || 'auto'}`);
3096
3103
  }
3097
3104
  }
3098
3105
  if (changes.length === 0)
@@ -3100,7 +3107,7 @@ function applyAgentSettings(incoming, ctx) {
3100
3107
  fs.writeFileSync(ctx.settingsPath, serializeAgentSettings(settings));
3101
3108
  // The value is saved and broadcast IMMEDIATELY -- validation never gates a
3102
3109
  // write. The verdict follows in a second frame once the catalog answers.
3103
- ctx.broadcast({ type: "settings", settings });
3110
+ ctx.broadcast({ type: 'settings', settings });
3104
3111
  void broadcastSettingsWarnings(ctx);
3105
3112
  }
3106
3113
  // Belt to watchCredentials' braces: a directory that didn't exist at boot has
@@ -3117,11 +3124,11 @@ const ACCOUNTS_POLL_MS = 15_000;
3117
3124
  // The snapshot carries presence and a hint only; no frame from here ever moves
3118
3125
  // a key value toward a browser.
3119
3126
  function createAccountsFeed(opts) {
3120
- let last = "";
3127
+ let last = '';
3121
3128
  // Tracked apart from the whole snapshot so a sign-in's phase transitions
3122
3129
  // (starting -> awaiting-code -> ...) don't each cost a budget fetch: they
3123
3130
  // move `login`, never a credential.
3124
- let lastProviders = "";
3131
+ let lastProviders = '';
3125
3132
  function push(error) {
3126
3133
  const accounts = accountsSnapshot();
3127
3134
  const serialized = JSON.stringify(accounts);
@@ -3138,7 +3145,7 @@ function createAccountsFeed(opts) {
3138
3145
  return;
3139
3146
  last = serialized;
3140
3147
  opts.broadcast({
3141
- type: "accounts",
3148
+ type: 'accounts',
3142
3149
  accounts,
3143
3150
  ...(error ? { accountsError: error } : {}),
3144
3151
  });
@@ -3165,26 +3172,26 @@ function createAccountsFeed(opts) {
3165
3172
  };
3166
3173
  }
3167
3174
  function applyCredentialChange(msg, ctx) {
3168
- const clearing = msg.type === "clear-credential";
3169
- if (!clearing && typeof msg.value !== "string")
3175
+ const clearing = msg.type === 'clear-credential';
3176
+ if (!clearing && typeof msg.value !== 'string')
3170
3177
  return;
3171
3178
  const result = writeCredential(msg.id, clearing ? null : msg.value);
3172
3179
  ctx.pushAccounts(result.ok
3173
3180
  ? undefined
3174
- : { id: typeof msg.id === "string" ? msg.id : "", message: result.message });
3181
+ : { id: typeof msg.id === 'string' ? msg.id : '', message: result.message });
3175
3182
  }
3176
3183
  // Which slugs are worth a verdict: only roles actually routed at OpenRouter
3177
3184
  // (otherwise we'd warn about an inert leftover value), and only when a slug is
3178
3185
  // set at all.
3179
3186
  function slugKeysToValidate(settings) {
3180
3187
  const keys = [];
3181
- if (roleUsesOpenrouter(settings.router ?? "claude", settings.routerClaudeModel ?? "opus") &&
3188
+ if (roleUsesOpenrouter(settings.router ?? 'claude', settings.routerClaudeModel ?? 'opus') &&
3182
3189
  settings.routerOpenrouterModel) {
3183
- keys.push("routerOpenrouterModel");
3190
+ keys.push('routerOpenrouterModel');
3184
3191
  }
3185
- if (roleUsesOpenrouter(settings.tasks ?? "claude", settings.tasksClaudeModel ?? "sonnet") &&
3192
+ if (roleUsesOpenrouter(settings.tasks ?? 'claude', settings.tasksClaudeModel ?? 'sonnet') &&
3186
3193
  settings.tasksOpenrouterModel) {
3187
- keys.push("tasksOpenrouterModel");
3194
+ keys.push('tasksOpenrouterModel');
3188
3195
  }
3189
3196
  return keys;
3190
3197
  }
@@ -3200,20 +3207,20 @@ export async function computeSettingsWarnings(settings) {
3200
3207
  if (!model)
3201
3208
  return;
3202
3209
  const check = await checkOpenrouterModel(model);
3203
- if (check.status === "unknown-model") {
3210
+ if (check.status === 'unknown-model') {
3204
3211
  // suggestion is carried SEPARATELY (not baked into message) so the
3205
3212
  // client can render it as a one-click fix rather than plain text.
3206
3213
  out[key] = {
3207
3214
  model,
3208
- status: "unknown-model",
3209
- message: "No such model on OpenRouter.",
3215
+ status: 'unknown-model',
3216
+ message: 'No such model on OpenRouter.',
3210
3217
  suggestion: check.suggestions[0],
3211
3218
  };
3212
3219
  }
3213
- else if (check.status === "no-tools") {
3220
+ else if (check.status === 'no-tools') {
3214
3221
  out[key] = {
3215
3222
  model,
3216
- status: "no-tools",
3223
+ status: 'no-tools',
3217
3224
  message: "This model can't use tools, so it can't do work. Pick another.",
3218
3225
  };
3219
3226
  }
@@ -3222,19 +3229,19 @@ export async function computeSettingsWarnings(settings) {
3222
3229
  }
3223
3230
  async function broadcastSettingsWarnings(ctx) {
3224
3231
  const settingsWarnings = await computeSettingsWarnings(ctx.settings);
3225
- ctx.broadcast({ type: "settings", settings: ctx.settings, settingsWarnings });
3232
+ ctx.broadcast({ type: 'settings', settings: ctx.settings, settingsWarnings });
3226
3233
  }
3227
3234
  function killOrphanAgents(registryPath) {
3228
3235
  const recorded = readJsonFile(registryPath) ?? [];
3229
3236
  for (const entry of recorded) {
3230
- if (typeof entry?.pid !== "number")
3237
+ if (typeof entry?.pid !== 'number')
3231
3238
  continue;
3232
3239
  try {
3233
- const cmd = execFileSync("ps", ["-p", String(entry.pid), "-o", "command="], {
3234
- encoding: "utf8",
3240
+ const cmd = execFileSync('ps', ['-p', String(entry.pid), '-o', 'command='], {
3241
+ encoding: 'utf8',
3235
3242
  }).trim();
3236
- if (cmd.includes("cursor-agent") || cmd.includes("claude")) {
3237
- process.kill(entry.pid, "SIGKILL");
3243
+ if (cmd.includes('cursor-agent') || cmd.includes('claude')) {
3244
+ process.kill(entry.pid, 'SIGKILL');
3238
3245
  }
3239
3246
  }
3240
3247
  catch {
@@ -3242,23 +3249,21 @@ function killOrphanAgents(registryPath) {
3242
3249
  }
3243
3250
  }
3244
3251
  try {
3245
- fs.writeFileSync(registryPath, "[]\n");
3252
+ fs.writeFileSync(registryPath, '[]\n');
3246
3253
  }
3247
3254
  catch {
3248
3255
  /* registry dir missing -- created later */
3249
3256
  }
3250
3257
  }
3251
3258
  function startChildRegistry(registryPath, groups) {
3252
- let last = "";
3259
+ let last = '';
3253
3260
  const timer = setInterval(() => {
3254
3261
  const live = [];
3255
3262
  for (const group of groups) {
3256
3263
  for (const child of group) {
3257
3264
  // pid > 0 excludes smith runs (negative pseudo-pids, no OS process
3258
3265
  // for the orphan sweep to kill -- see AgentRunHandle).
3259
- if (typeof child.pid === "number" &&
3260
- child.pid > 0 &&
3261
- child.exitCode === null) {
3266
+ if (typeof child.pid === 'number' && child.pid > 0 && child.exitCode === null) {
3262
3267
  live.push({ pid: child.pid, command: child.spawnfile });
3263
3268
  }
3264
3269
  }
@@ -3268,7 +3273,7 @@ function startChildRegistry(registryPath, groups) {
3268
3273
  return;
3269
3274
  last = snapshot;
3270
3275
  try {
3271
- fs.writeFileSync(registryPath, snapshot + "\n");
3276
+ fs.writeFileSync(registryPath, snapshot + '\n');
3272
3277
  }
3273
3278
  catch {
3274
3279
  /* best effort */
@@ -3277,7 +3282,7 @@ function startChildRegistry(registryPath, groups) {
3277
3282
  return () => {
3278
3283
  clearInterval(timer);
3279
3284
  try {
3280
- fs.writeFileSync(registryPath, "[]\n");
3285
+ fs.writeFileSync(registryPath, '[]\n');
3281
3286
  }
3282
3287
  catch {
3283
3288
  /* best effort */
@@ -3296,14 +3301,14 @@ function loadRecoverableSends(pendingPath, committedIds) {
3296
3301
  const recovered = [];
3297
3302
  for (const item of stored) {
3298
3303
  if (item &&
3299
- typeof item.id === "string" &&
3300
- typeof item.text === "string" &&
3304
+ typeof item.id === 'string' &&
3305
+ typeof item.text === 'string' &&
3301
3306
  Array.isArray(item.attachments) &&
3302
3307
  !committedIds.has(item.id)) {
3303
3308
  recovered.push({
3304
3309
  id: item.id,
3305
3310
  text: item.text,
3306
- attachments: item.attachments.filter((a) => typeof a === "string"),
3311
+ attachments: item.attachments.filter((a) => typeof a === 'string'),
3307
3312
  });
3308
3313
  }
3309
3314
  }
@@ -3317,9 +3322,9 @@ function loadRecoverableSends(pendingPath, committedIds) {
3317
3322
  // cannot be un-launched, so folding it away would abandon in-flight work, not
3318
3323
  // just discard stale intent.
3319
3324
  function turnHasVisibleOutput(messages) {
3320
- return messages.some((m) => m.role === "assistant" &&
3321
- m.status === "streaming" &&
3322
- (m.text.trim() !== "" || (m.taskIds?.length ?? 0) > 0));
3325
+ return messages.some((m) => m.role === 'assistant' &&
3326
+ m.status === 'streaming' &&
3327
+ (m.text.trim() !== '' || (m.taskIds?.length ?? 0) > 0));
3323
3328
  }
3324
3329
  // Re-queue a turn's already-logged user messages (its `inFlightSends`) onto
3325
3330
  // the front of the pending queue, marked `logged` so the next drain composes
@@ -3335,14 +3340,14 @@ function reclaimInFlightSends(pendingSends, inFlightSends) {
3335
3340
  // Mirror the in-memory queue to disk. Called on every mutation (enqueue,
3336
3341
  // drain, cancel, recover) so a restart never loses an unsent queued message.
3337
3342
  function persistPendingSends(ctx) {
3338
- fs.writeFileSync(ctx.pendingPath, JSON.stringify(ctx.state.pendingSends, null, 2) + "\n");
3343
+ fs.writeFileSync(ctx.pendingPath, JSON.stringify(ctx.state.pendingSends, null, 2) + '\n');
3339
3344
  }
3340
3345
  function computeQueuedSnippets(pendingSends) {
3341
3346
  return pendingSends.map((p) => p.text.trim()).filter(Boolean);
3342
3347
  }
3343
3348
  function broadcastQueueState(ctx) {
3344
3349
  ctx.broadcast({
3345
- type: "router-state",
3350
+ type: 'router-state',
3346
3351
  running: ctx.state.routerRunning,
3347
3352
  queued: computeQueuedSnippets(ctx.state.pendingSends),
3348
3353
  });
@@ -3351,18 +3356,18 @@ function broadcastQueueState(ctx) {
3351
3356
  // they had streamed (joined) so a manual interrupt can carry it forward.
3352
3357
  function killRouterChildren(ctx) {
3353
3358
  const drafts = ctx.messages
3354
- .filter((m) => m.role === "assistant" && m.status === "streaming")
3359
+ .filter((m) => m.role === 'assistant' && m.status === 'streaming')
3355
3360
  .map((m) => m.text.trim())
3356
3361
  .filter(Boolean);
3357
3362
  for (const child of ctx.routerChildren) {
3358
3363
  try {
3359
- child.kill("SIGKILL");
3364
+ child.kill('SIGKILL');
3360
3365
  }
3361
3366
  catch {
3362
3367
  /* already gone */
3363
3368
  }
3364
3369
  }
3365
- return drafts.join("\n\n");
3370
+ return drafts.join('\n\n');
3366
3371
  }
3367
3372
  function startRouterTurn(ctx, instruction, attachments = []) {
3368
3373
  ctx.state.lastInstruction = instruction;
@@ -3401,10 +3406,10 @@ function commitDrainedSends(drained, log) {
3401
3406
  if (!item.logged) {
3402
3407
  const message = {
3403
3408
  id: item.id,
3404
- role: "user",
3409
+ role: 'user',
3405
3410
  text: item.text,
3406
3411
  at: nowIso(),
3407
- status: "done",
3412
+ status: 'done',
3408
3413
  };
3409
3414
  if (item.attachments.length > 0)
3410
3415
  message.attachments = item.attachments;
@@ -3413,7 +3418,7 @@ function commitDrainedSends(drained, log) {
3413
3418
  if (item.text.trim())
3414
3419
  texts.push(item.text);
3415
3420
  for (const name of item.attachments) {
3416
- attachmentPaths.push(path.join(".castle", "agent", "attachments", name));
3421
+ attachmentPaths.push(path.join('.castle', 'agent', 'attachments', name));
3417
3422
  }
3418
3423
  }
3419
3424
  return { texts, attachmentPaths };
@@ -3436,7 +3441,7 @@ function maybeStartRouterQueueTurn(ctx) {
3436
3441
  // The queue is now committed to messages.json; clear its durable mirror.
3437
3442
  persistPendingSends(ctx);
3438
3443
  const draft = state.pendingInterruptedDraft;
3439
- state.pendingInterruptedDraft = "";
3444
+ state.pendingInterruptedDraft = '';
3440
3445
  state.routerRunning = true;
3441
3446
  broadcastQueueState(ctx);
3442
3447
  startRouterTurn(ctx, userTurnInstruction({
@@ -3534,7 +3539,7 @@ function interruptRouterQueue(ctx) {
3534
3539
  // Only carry the partial draft forward when a queued message will consume
3535
3540
  // it imminently ("Send now"). A bare Stop (empty composer) must not park
3536
3541
  // the draft, or it leaks into the next unrelated message.
3537
- state.pendingInterruptedDraft = hasFollowUp ? draft : "";
3542
+ state.pendingInterruptedDraft = hasFollowUp ? draft : '';
3538
3543
  }
3539
3544
  else {
3540
3545
  maybeStartRouterQueueTurn(ctx);
@@ -3558,16 +3563,16 @@ function cancelQueuedSend(ctx, index) {
3558
3563
  function createRouterQueue(deps) {
3559
3564
  const ctx = {
3560
3565
  ...deps,
3561
- pendingPath: path.join(deps.agentDir, "pending-sends.json"),
3566
+ pendingPath: path.join(deps.agentDir, 'pending-sends.json'),
3562
3567
  state: {
3563
3568
  userEpoch: 0,
3564
3569
  routerRunning: false,
3565
3570
  pendingSends: [],
3566
3571
  inFlightSends: [],
3567
- pendingInterruptedDraft: "",
3572
+ pendingInterruptedDraft: '',
3568
3573
  autoRetryUsed: false,
3569
3574
  autoFoldUsed: false,
3570
- lastInstruction: "",
3575
+ lastInstruction: '',
3571
3576
  lastAttachments: [],
3572
3577
  },
3573
3578
  };
@@ -3588,10 +3593,10 @@ export function createAgentServer(opts) {
3588
3593
  const { deckDir, deckLabel } = opts;
3589
3594
  const quickReference = readQuickReference(deckDir);
3590
3595
  const welcomeMessage = readWelcomeMessage(deckDir) || DEFAULT_WELCOME_MESSAGE;
3591
- const agentDir = path.join(deckDir, ".castle", "agent");
3592
- const tasksDir = path.join(agentDir, "tasks");
3593
- const attachmentsDir = path.join(agentDir, "attachments");
3594
- const messagesPath = path.join(agentDir, "messages.json");
3596
+ const agentDir = path.join(deckDir, '.castle', 'agent');
3597
+ const tasksDir = path.join(agentDir, 'tasks');
3598
+ const attachmentsDir = path.join(agentDir, 'attachments');
3599
+ const messagesPath = path.join(agentDir, 'messages.json');
3595
3600
  fs.mkdirSync(tasksDir, { recursive: true });
3596
3601
  // Warm the OpenRouter catalog now so the first pre-flight and the first
3597
3602
  // popover open read a cache instead of paying for the fetch. Fire-and-forget
@@ -3622,12 +3627,9 @@ export function createAgentServer(opts) {
3622
3627
  const clients = new Set();
3623
3628
  // Kill agent processes orphaned by a previous serve that died uncleanly,
3624
3629
  // then start tracking this serve's own children.
3625
- const childRegistryPath = path.join(agentDir, "children.json");
3630
+ const childRegistryPath = path.join(agentDir, 'children.json');
3626
3631
  killOrphanAgents(childRegistryPath);
3627
- const stopChildRegistry = startChildRegistry(childRegistryPath, [
3628
- taskChildren,
3629
- routerChildren,
3630
- ]);
3632
+ const stopChildRegistry = startChildRegistry(childRegistryPath, [taskChildren, routerChildren]);
3631
3633
  function broadcast(body) {
3632
3634
  const payload = JSON.stringify(body);
3633
3635
  for (const socket of clients) {
@@ -3640,7 +3642,7 @@ export function createAgentServer(opts) {
3640
3642
  const addLog = (text) => log.addLog(text);
3641
3643
  // Which CLI backs the router and the task agents -- independently
3642
3644
  // switchable from the settings popover, persisted next to the chat state.
3643
- const settingsPath = path.join(agentDir, "settings.json");
3645
+ const settingsPath = path.join(agentDir, 'settings.json');
3644
3646
  const settings = loadAgentSettings(settingsPath);
3645
3647
  const usageFeed = createUsageFeed({
3646
3648
  broadcast,
@@ -3677,7 +3679,7 @@ export function createAgentServer(opts) {
3677
3679
  playtest,
3678
3680
  restart: opts.restart,
3679
3681
  // Task lifecycle stays on the board only -- log lines for it were spam.
3680
- onUpdate: (task) => broadcast({ type: "task-update", task: asClientTask(task) }),
3682
+ onUpdate: (task) => broadcast({ type: 'task-update', task: asClientTask(task) }),
3681
3683
  onStarted: () => undefined,
3682
3684
  onRetry: (task, attempt) => addLog(`agent died, retrying (${attempt}/${MAX_TASK_ATTEMPTS}): ${task.title}`),
3683
3685
  onFinished: (task) => taskFeeds.map.delete(task.id),
@@ -3705,10 +3707,10 @@ export function createAgentServer(opts) {
3705
3707
  // (so it respects the send queue).
3706
3708
  function handlePickerChoice(id, answers, text) {
3707
3709
  const msg = messages.find((m) => m.id === id);
3708
- if (msg && msg.role === "assistant" && answers && typeof answers === "object") {
3710
+ if (msg && msg.role === 'assistant' && answers && typeof answers === 'object') {
3709
3711
  msg.pickerAnswers = answers;
3710
3712
  log.persist();
3711
- broadcast({ type: "message-picker", id, pickerAnswers: msg.pickerAnswers });
3713
+ broadcast({ type: 'message-picker', id, pickerAnswers: msg.pickerAnswers });
3712
3714
  }
3713
3715
  if (text.trim())
3714
3716
  routerQueue.handleUserMessage(text.trim(), undefined);
@@ -3720,7 +3722,7 @@ export function createAgentServer(opts) {
3720
3722
  function attachClient(socket) {
3721
3723
  clients.add(socket);
3722
3724
  const hello = {
3723
- type: "hello",
3725
+ type: 'hello',
3724
3726
  messages,
3725
3727
  tasks: taskStore.sorted().map(asClientTask),
3726
3728
  settings,
@@ -3742,7 +3744,7 @@ export function createAgentServer(opts) {
3742
3744
  // nothing changed), so a migrated or hand-edited slug would stay silent.
3743
3745
  // The catalog is primed at boot, so in practice this lands immediately.
3744
3746
  void broadcastSettingsWarnings({ settings, broadcast });
3745
- socket.on("message", (rawData) => {
3747
+ socket.on('message', (rawData) => {
3746
3748
  let msg;
3747
3749
  try {
3748
3750
  msg = JSON.parse(rawDataToString(rawData));
@@ -3750,56 +3752,55 @@ export function createAgentServer(opts) {
3750
3752
  catch {
3751
3753
  return;
3752
3754
  }
3753
- const hasText = typeof msg.text === "string" && msg.text.trim() !== "";
3755
+ const hasText = typeof msg.text === 'string' && msg.text.trim() !== '';
3754
3756
  const hasImages = Array.isArray(msg.images) && msg.images.length > 0;
3755
- if (msg.type === "user-message" && (hasText || hasImages)) {
3756
- routerQueue.handleUserMessage(typeof msg.text === "string" ? msg.text.trim() : "", msg.images);
3757
+ if (msg.type === 'user-message' && (hasText || hasImages)) {
3758
+ routerQueue.handleUserMessage(typeof msg.text === 'string' ? msg.text.trim() : '', msg.images);
3757
3759
  }
3758
- else if (msg.type === "interrupt") {
3760
+ else if (msg.type === 'interrupt') {
3759
3761
  routerQueue.interruptRouter();
3760
3762
  }
3761
- else if (msg.type === "cancel-queued" && typeof msg.index === "number") {
3763
+ else if (msg.type === 'cancel-queued' && typeof msg.index === 'number') {
3762
3764
  routerQueue.cancelQueued(msg.index);
3763
3765
  }
3764
- else if (msg.type === "picker-choice" && typeof msg.id === "string") {
3765
- handlePickerChoice(msg.id, msg.answers, typeof msg.text === "string" ? msg.text : "");
3766
+ else if (msg.type === 'picker-choice' && typeof msg.id === 'string') {
3767
+ handlePickerChoice(msg.id, msg.answers, typeof msg.text === 'string' ? msg.text : '');
3766
3768
  }
3767
- else if (msg.type === "task-ack" && typeof msg.id === "string") {
3769
+ else if (msg.type === 'task-ack' && typeof msg.id === 'string') {
3768
3770
  handleTaskAck(msg.id, msg.rejected === true);
3769
3771
  }
3770
- else if (msg.type === "set-settings") {
3772
+ else if (msg.type === 'set-settings') {
3771
3773
  applySettings(msg);
3772
3774
  }
3773
- else if (msg.type === "set-credential" ||
3774
- msg.type === "clear-credential") {
3775
+ else if (msg.type === 'set-credential' || msg.type === 'clear-credential') {
3775
3776
  applyCredentialChange(msg, { pushAccounts: accountsFeed.push });
3776
3777
  }
3777
- else if (msg.type === "account-login") {
3778
+ else if (msg.type === 'account-login') {
3778
3779
  const provider = loginProviderFor(msg.id);
3779
3780
  if (provider)
3780
3781
  startLogin(provider, () => accountsFeed.push());
3781
3782
  }
3782
- else if (msg.type === "account-login-code" && typeof msg.value === "string") {
3783
+ else if (msg.type === 'account-login-code' && typeof msg.value === 'string') {
3783
3784
  submitLoginCode(msg.value);
3784
3785
  }
3785
- else if (msg.type === "account-login-cancel") {
3786
+ else if (msg.type === 'account-login-cancel') {
3786
3787
  cancelLogin();
3787
3788
  }
3788
- else if (msg.type === "account-logout") {
3789
+ else if (msg.type === 'account-logout') {
3789
3790
  const provider = loginProviderFor(msg.id);
3790
3791
  if (provider)
3791
3792
  logout(provider, () => accountsFeed.push());
3792
3793
  }
3793
- else if (msg.type === "client-timezone" && typeof msg.timeZone === "string") {
3794
+ else if (msg.type === 'client-timezone' && typeof msg.timeZone === 'string') {
3794
3795
  setReaderTimeZone(msg.timeZone);
3795
3796
  }
3796
3797
  });
3797
- socket.on("close", () => {
3798
+ socket.on('close', () => {
3798
3799
  clients.delete(socket);
3799
3800
  });
3800
3801
  }
3801
3802
  function handleUpgrade(req, socket, head) {
3802
- const url = new URL(req.url ?? "/", "http://localhost");
3803
+ const url = new URL(req.url ?? '/', 'http://localhost');
3803
3804
  if (url.pathname !== AGENT_WS_PATH)
3804
3805
  return false;
3805
3806
  wss.handleUpgrade(req, socket, head, (ws) => attachClient(ws));
@@ -3816,7 +3817,7 @@ export function createAgentServer(opts) {
3816
3817
  taskStore.shutdown();
3817
3818
  for (const child of [...taskChildren, ...routerChildren]) {
3818
3819
  try {
3819
- child.kill("SIGKILL");
3820
+ child.kill('SIGKILL');
3820
3821
  }
3821
3822
  catch {
3822
3823
  /* already gone */