castle-web-cli 0.4.82 → 0.4.83

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/agent.d.ts CHANGED
@@ -3,6 +3,7 @@ import { Duplex } from "stream";
3
3
  export declare const AGENT_WS_PATH = "/__castle/agent";
4
4
  export declare const AGENT_ATTACHMENT_PREFIX = "/__castle/agent/attachments/";
5
5
  export declare const AGENT_PLAYTEST_PREFIX = "/__castle/agent/playtest/";
6
+ export declare const AGENT_MODEL_CAPS_PREFIX = "/__castle/agent/model-caps";
6
7
  export type AgentBackend = "cursor" | "claude" | "smith";
7
8
  export type ClaudeModel = "sonnet" | "opus" | "fable" | "openrouter";
8
9
  type TaskStatus = "waiting" | "running" | "blocked" | "done" | "failed" | "interrupted";
package/dist/agent.js CHANGED
@@ -30,16 +30,38 @@ export const AGENT_ATTACHMENT_PREFIX = "/__castle/agent/attachments/";
30
30
  // Playtest frame PNGs (tasks/<id>/playtest/<file>.png), served for the
31
31
  // finished-task card's thumbnails -- see makePlaytestFrameHandler.
32
32
  export const AGENT_PLAYTEST_PREFIX = "/__castle/agent/playtest/";
33
+ // Same-origin proxy for OpenRouter model capabilities (avoids browser CORS
34
+ // against openrouter.ai). GET ?model=<slug> -> ModelCaps JSON. Powers the
35
+ // settings popover's dynamic reasoning-effort / provider-tier pickers.
36
+ export const AGENT_MODEL_CAPS_PREFIX = "/__castle/agent/model-caps";
33
37
  const DEFAULT_SETTINGS = {
34
38
  router: "claude",
35
39
  tasks: "claude",
36
40
  routerClaudeModel: "opus",
37
41
  // Tasks default one tier down: task agents run long unattended builds, so
38
- // sonnet's cost/speed wins by default; the conductor stays on opus.
42
+ // sonnet's cost/speed wins by default; the operator stays on opus.
39
43
  tasksClaudeModel: "sonnet",
40
44
  // Free-form -- change to any OpenRouter slug.
41
45
  routerOpenrouterModel: "openai/gpt-5.6-sol",
42
46
  tasksOpenrouterModel: "openai/gpt-5.6-terra",
47
+ // Both roles think at "medium": the operator stays snappy (the user waits
48
+ // on every operator turn), and task agents' multi-turn tool loops don't pay
49
+ // reasoning tax on mechanical read/edit/run turns. Deep decomposition
50
+ // quality comes from the operator prompt, not a higher effort default.
51
+ routerReasoningEffort: "medium",
52
+ tasksReasoningEffort: "medium",
53
+ // Routing splits by what each role optimizes for: the interactive operator
54
+ // routes for speed (nitro = throughput-sorted endpoints), unattended task
55
+ // agents route for correctness (exacto = benchmark-accurate endpoints,
56
+ // which matters for tool-calling fidelity over long loops).
57
+ routerRouting: "nitro",
58
+ tasksRouting: "exacto",
59
+ // Operator pins OpenAI's priority (low-latency SLA) tier for the default
60
+ // sol model; harmless with other slugs since the pin falls back when the
61
+ // tag doesn't exist (allow_fallbacks). Tasks stay on auto: high-volume
62
+ // background turns should ride the cheapest available capacity.
63
+ routerProviderTier: "openai/priority",
64
+ tasksProviderTier: "",
43
65
  };
44
66
  function normalizeBackend(value) {
45
67
  return value === "cursor" || value === "claude" || value === "smith"
@@ -73,6 +95,48 @@ function normalizeOpenrouterModel(value) {
73
95
  const trimmed = value.trim();
74
96
  return trimmed && trimmed.length <= OPENROUTER_MODEL_MAX_LEN ? trimmed : null;
75
97
  }
98
+ // OpenRouter's full effort superset -- validated against the union rather than
99
+ // a per-model list because supported efforts are model-specific (the client
100
+ // fetches them from the model-caps endpoint to build the picker) and
101
+ // OpenRouter maps an unsupported level to the nearest one anyway.
102
+ const REASONING_EFFORTS = [
103
+ "none",
104
+ "minimal",
105
+ "low",
106
+ "medium",
107
+ "high",
108
+ "xhigh",
109
+ "max",
110
+ ];
111
+ function normalizeReasoningEffort(value) {
112
+ return REASONING_EFFORTS.includes(value)
113
+ ? value
114
+ : null;
115
+ }
116
+ const ROUTING_MODES = [
117
+ "balanced",
118
+ "nitro",
119
+ "exacto",
120
+ "floor",
121
+ ];
122
+ function normalizeRoutingMode(value) {
123
+ return ROUTING_MODES.includes(value)
124
+ ? value
125
+ : null;
126
+ }
127
+ // Provider tier is an OpenRouter endpoint `tag` ("openai/flex", "azure/eu",
128
+ // ...) which is model-specific, so validation is loose like the model slug.
129
+ // Unlike the slug, empty string is VALID and meaningful: "auto" (no pin), so
130
+ // this returns "" rather than null for the clear case -- callers must treat
131
+ // null (invalid) and "" (clear) differently.
132
+ function normalizeProviderTier(value) {
133
+ if (typeof value !== "string")
134
+ return null;
135
+ const trimmed = value.trim();
136
+ if (trimmed.length > OPENROUTER_MODEL_MAX_LEN)
137
+ return null;
138
+ return trimmed;
139
+ }
76
140
  // OpenRouter's Anthropic-compatible endpoint (confirmed current, 2026: it
77
141
  // accepts the standard Anthropic Messages API shape -- text/tool-use/
78
142
  // extended-thinking -- for ANY OpenRouter model slug, not just Anthropic
@@ -116,6 +180,127 @@ const OPENROUTER_KEY_NAME = "OPENROUTER_API_KEY";
116
180
  function openrouterApiKey() {
117
181
  return castleKeys()[OPENROUTER_KEY_NAME] ?? process.env[OPENROUTER_KEY_NAME] ?? "";
118
182
  }
183
+ const MODEL_CAPS_TTL_MS = 10 * 60_000;
184
+ const OPENROUTER_API_BASE = "https://openrouter.ai/api/v1";
185
+ const modelCapsCache = new Map();
186
+ let modelsListCache = null;
187
+ function asRecord(v) {
188
+ return v && typeof v === "object" ? v : null;
189
+ }
190
+ async function openrouterModelsById() {
191
+ if (modelsListCache && Date.now() - modelsListCache.at < MODEL_CAPS_TTL_MS) {
192
+ return modelsListCache.byId;
193
+ }
194
+ const res = await fetch(`${OPENROUTER_API_BASE}/models`);
195
+ if (!res.ok)
196
+ throw new Error(`models list HTTP ${res.status}`);
197
+ const json = asRecord(await res.json());
198
+ const data = json && Array.isArray(json.data) ? json.data : [];
199
+ const byId = new Map();
200
+ for (const entry of data) {
201
+ const rec = asRecord(entry);
202
+ const id = rec && typeof rec.id === "string" ? rec.id : null;
203
+ if (id)
204
+ byId.set(id, entry);
205
+ }
206
+ modelsListCache = { byId, at: Date.now() };
207
+ return byId;
208
+ }
209
+ async function openrouterProviderTiers(slug) {
210
+ const res = await fetch(`${OPENROUTER_API_BASE}/models/${slug}/endpoints`);
211
+ if (!res.ok)
212
+ return [];
213
+ const json = asRecord(await res.json());
214
+ const data = asRecord(json?.data);
215
+ const endpoints = data && Array.isArray(data.endpoints) ? data.endpoints : [];
216
+ const tags = [];
217
+ for (const ep of endpoints) {
218
+ const rec = asRecord(ep);
219
+ const tag = rec && typeof rec.tag === "string" ? rec.tag : null;
220
+ if (tag && !tags.includes(tag))
221
+ tags.push(tag);
222
+ }
223
+ return tags;
224
+ }
225
+ async function fetchModelCaps(slug) {
226
+ const cached = modelCapsCache.get(slug);
227
+ if (cached && Date.now() - cached.at < MODEL_CAPS_TTL_MS)
228
+ return cached.caps;
229
+ // Best-effort per source: a failure in either leaves that half empty rather
230
+ // than failing the whole lookup, so a bad slug still yields a usable (empty)
231
+ // caps object the client can render as "no dynamic options".
232
+ let reasoningEfforts = null;
233
+ let defaultEffort = null;
234
+ try {
235
+ const model = asRecord((await openrouterModelsById()).get(slug));
236
+ const reasoning = asRecord(model?.reasoning);
237
+ const efforts = reasoning?.supported_efforts;
238
+ const supportedParams = model?.supported_parameters;
239
+ const acceptsEffort = Array.isArray(supportedParams) &&
240
+ (supportedParams.includes("reasoning_effort") ||
241
+ supportedParams.includes("reasoning"));
242
+ if (acceptsEffort && Array.isArray(efforts) && efforts.length > 0) {
243
+ reasoningEfforts = efforts.filter((e) => typeof e === "string");
244
+ defaultEffort =
245
+ typeof reasoning?.default_effort === "string"
246
+ ? reasoning.default_effort
247
+ : null;
248
+ }
249
+ }
250
+ catch {
251
+ // leave reasoning fields null
252
+ }
253
+ let providerTiers = [];
254
+ try {
255
+ providerTiers = await openrouterProviderTiers(slug);
256
+ }
257
+ catch {
258
+ // leave providerTiers empty
259
+ }
260
+ const caps = {
261
+ model: slug,
262
+ reasoningEfforts,
263
+ defaultEffort,
264
+ providerTiers,
265
+ };
266
+ modelCapsCache.set(slug, { caps, at: Date.now() });
267
+ return caps;
268
+ }
269
+ // GET AGENT_MODEL_CAPS_PREFIX?model=<slug>. Returns 400 for a missing/oversized
270
+ // slug, 200 ModelCaps otherwise (empty caps on upstream failure -- see
271
+ // fetchModelCaps). reqPath is already query-stripped; parse req.url for it.
272
+ function handleModelCaps(req, res) {
273
+ const send = (status, body) => {
274
+ res.writeHead(status, {
275
+ "content-type": "application/json",
276
+ "cache-control": "no-store",
277
+ });
278
+ res.end(JSON.stringify(body));
279
+ return true;
280
+ };
281
+ let slug = "";
282
+ try {
283
+ slug = (new URL(req.url ?? "", "http://localhost").searchParams.get("model") ?? "").trim();
284
+ }
285
+ catch {
286
+ slug = "";
287
+ }
288
+ if (!slug || slug.length > OPENROUTER_MODEL_MAX_LEN) {
289
+ return send(400, { error: "missing or invalid model" });
290
+ }
291
+ // Strip any routing suffix the client may have on the displayed slug so the
292
+ // OpenRouter lookup hits the base model id.
293
+ const baseSlug = slug.replace(/:(nitro|exacto|floor)$/, "");
294
+ fetchModelCaps(baseSlug)
295
+ .then((caps) => send(200, caps))
296
+ .catch(() => send(200, {
297
+ model: baseSlug,
298
+ reasoningEfforts: null,
299
+ defaultEffort: null,
300
+ providerTiers: [],
301
+ }));
302
+ return true;
303
+ }
119
304
  // Build the headless CLI invocation for a spawning backend/role (smith never
120
305
  // comes through here -- it has no CLI process; see runAgentSmith). Cursor's
121
306
  // router runs in read-only ask mode; claude runs permission-mode auto for
@@ -520,16 +705,31 @@ const BACKEND_KEY_ENV = {
520
705
  claude: "ANTHROPIC_API_KEY",
521
706
  cursor: "CURSOR_API_KEY",
522
707
  };
523
- // True when the user has their OWN saved auth for this backend -- a /login, or
524
- // (for cursor, which reuses one auth.json) any saved creds. When so we do NOT
525
- // inject Castle's key, so their auth is used and billed to them.
708
+ // cursor-agent rewrites ~/.config/cursor/auth.json on every successful run,
709
+ // including Castle's own CURSOR_API_KEY runs -- so the file existing does NOT
710
+ // mean a user logged in. A real login (OAuth, or a tester's own key) overwrites
711
+ // it and drops Castle's apiKey; a file whose apiKey is still Castle's key is just
712
+ // our cache. Treating that cache as a login would suppress the injected key, and
713
+ // once its ~60-min token expires cursor-agent (no headless refresh) fails auth.
714
+ function cursorHasUserLogin(home) {
715
+ const authPath = path.join(home, ".config", "cursor", "auth.json");
716
+ try {
717
+ const auth = JSON.parse(fs.readFileSync(authPath, "utf8"));
718
+ return auth.apiKey !== castleKeys().CURSOR_API_KEY;
719
+ }
720
+ catch {
721
+ return false;
722
+ }
723
+ }
724
+ // True when the user has their OWN saved auth for this backend -- a login that we
725
+ // should defer to (and bill to them) instead of injecting Castle's key.
526
726
  function backendHasSavedAuth(backend) {
527
727
  const home = os.homedir();
528
728
  if (backend === "claude") {
529
729
  return fs.existsSync(path.join(home, ".claude", ".credentials.json"));
530
730
  }
531
731
  if (backend === "cursor") {
532
- return fs.existsSync(path.join(home, ".config", "cursor", "auth.json"));
732
+ return cursorHasUserLogin(home);
533
733
  }
534
734
  return false;
535
735
  }
@@ -836,6 +1036,62 @@ function logAgentUsage(label, backend, usage) {
836
1036
  const output = formatTokenCount(usage.output_tokens);
837
1037
  console.error(`[agent usage] ${label} ${backend}: input=${input} cache_read=${read} cache_created=${created} output=${output}`);
838
1038
  }
1039
+ // Per-deck machine-readable usage ledger, appended to <deckDir>/.castle/agent/,
1040
+ // harvested out-of-band (by the cloud launcher) for rough per-user token
1041
+ // metering. Distinct from logAgentUsage's stderr line, which is lossy
1042
+ // (rounds to "3.2k") and gets truncated when the serve restarts.
1043
+ const USAGE_LEDGER_FILE = "usage.jsonl";
1044
+ // The ledger is only useful where the cloud launcher harvests it, so the managed
1045
+ // sandbox environments set CASTLE_USAGE_LEDGER=1 (E2B via cloudSandbox.serveOnPort,
1046
+ // castle-sandboxes via its image). A local `castle-web serve` leaves it unset, so
1047
+ // dev deck dirs don't accumulate a ledger nothing reads.
1048
+ const USAGE_LEDGER_ENABLED = process.env.CASTLE_USAGE_LEDGER === "1";
1049
+ // Concrete model behind a finished run: smith and claude-via-OpenRouter both
1050
+ // bill the OpenRouter slug; plain claude bills its own slug; cursor has no
1051
+ // per-model split tracked here.
1052
+ function resolveRunModel(backend, claudeModel, openrouterModel) {
1053
+ if (backend === "smith")
1054
+ return openrouterModel;
1055
+ if (backend === "claude") {
1056
+ return claudeModel === "openrouter" ? openrouterModel : claudeModel;
1057
+ }
1058
+ return backend;
1059
+ }
1060
+ // One record per finished run: the human-readable stderr line PLUS a precise
1061
+ // append-only JSONL line in the deck's usage ledger. Precise counts (not the
1062
+ // stderr line's rounded values) and self-describing (id/role/backend/model) so
1063
+ // the harvester can attribute and de-dup. Best-effort: a metering write must
1064
+ // never fail an agent run.
1065
+ function reportRunUsage(agentDir, role, backend, claudeModel, openrouterModel, usage, taskId) {
1066
+ logAgentUsage(taskId ? `task ${taskId}` : "router", backend, usage);
1067
+ if (!USAGE_LEDGER_ENABLED)
1068
+ return;
1069
+ // cursor-agent's stream-json doesn't report token usage, so a cursor run has no
1070
+ // `usage`; still record it (zero counts, tokens_reported:false) for run-count
1071
+ // visibility. Other backends only write once they actually produced usage.
1072
+ if (!usage && backend !== "cursor")
1073
+ return;
1074
+ try {
1075
+ const line = JSON.stringify({
1076
+ at: nowIso(),
1077
+ id: nanoid(),
1078
+ role,
1079
+ backend,
1080
+ model: resolveRunModel(backend, claudeModel, openrouterModel),
1081
+ ...(taskId ? { taskId } : {}),
1082
+ tokens_reported: usage !== undefined,
1083
+ input_tokens: usage?.input_tokens ?? 0,
1084
+ output_tokens: usage?.output_tokens ?? 0,
1085
+ cache_read_input_tokens: usage?.cache_read_input_tokens ?? 0,
1086
+ cache_creation_input_tokens: usage?.cache_creation_input_tokens ?? 0,
1087
+ });
1088
+ fs.mkdirSync(agentDir, { recursive: true });
1089
+ fs.appendFileSync(path.join(agentDir, USAGE_LEDGER_FILE), line + "\n");
1090
+ }
1091
+ catch {
1092
+ /* best-effort: a metering write must never fail an agent run */
1093
+ }
1094
+ }
839
1095
  // Build the per-run stdout event handler over a shared mutable parser state.
840
1096
  // Splitting the cursor + claude stream decoding out of runAgentCli keeps each
841
1097
  // within the max-lines budget; behavior is identical (same delta/activity/
@@ -1094,6 +1350,10 @@ async function runAgentSmith(opts) {
1094
1350
  role: opts.role,
1095
1351
  model: opts.model,
1096
1352
  apiKey: openrouterApiKey(),
1353
+ reasoningEffort: opts.openrouterTuning?.reasoningEffort,
1354
+ routing: opts.openrouterTuning?.routing,
1355
+ // "" (auto) becomes undefined so no provider.order is sent.
1356
+ providerTier: opts.openrouterTuning?.providerTier || undefined,
1097
1357
  prompt: opts.prompt,
1098
1358
  systemReminder: opts.systemReminder,
1099
1359
  attachments: opts.attachments,
@@ -1144,6 +1404,7 @@ function runAgentTurn(opts) {
1144
1404
  // appends it to its own system framing).
1145
1405
  systemReminder: opts.role === "task" ? CLAUDE_TASK_SYSTEM_REMINDER : undefined,
1146
1406
  attachments: opts.attachments,
1407
+ openrouterTuning: opts.openrouterTuning,
1147
1408
  timeoutMs: opts.timeoutMs,
1148
1409
  logPath: opts.logPath,
1149
1410
  playtest: opts.playtest,
@@ -1398,6 +1659,7 @@ async function runTaskAgentIn(ctx, task) {
1398
1659
  prompt: taskPrompt,
1399
1660
  claudeModel: ctx.claudeModel,
1400
1661
  openrouterModel: ctx.openrouterModel,
1662
+ openrouterTuning: ctx.openrouterTuning,
1401
1663
  cwd: ctx.deckDir,
1402
1664
  timeoutMs: TASK_TIMEOUT_MS,
1403
1665
  logPath: path.join(dir, "log.jsonl"),
@@ -1416,7 +1678,7 @@ async function runTaskAgentIn(ctx, task) {
1416
1678
  ctx.onFeed(`[${activity}]`);
1417
1679
  },
1418
1680
  });
1419
- logAgentUsage(`task ${task.id}`, ctx.backend, result.usage);
1681
+ reportRunUsage(path.dirname(ctx.tasksDir), "task", ctx.backend, ctx.claudeModel, ctx.openrouterModel, result.usage, task.id);
1420
1682
  if (ctx.stopRequested.has(task.id))
1421
1683
  return result;
1422
1684
  if (!result.crashed)
@@ -1451,6 +1713,7 @@ function startTask(ctx, task) {
1451
1713
  backend: ctx.backend(),
1452
1714
  claudeModel: ctx.claudeModel(),
1453
1715
  openrouterModel: ctx.openrouterModel(),
1716
+ openrouterTuning: ctx.openrouterTuning(),
1454
1717
  stopRequested: ctx.stopRequested,
1455
1718
  quickReference: ctx.quickReference,
1456
1719
  playtest: ctx.playtest,
@@ -1646,6 +1909,7 @@ function createTaskStore(opts) {
1646
1909
  backend: opts.backend,
1647
1910
  claudeModel: opts.claudeModel,
1648
1911
  openrouterModel: opts.openrouterModel,
1912
+ openrouterTuning: opts.openrouterTuning,
1649
1913
  playtest: opts.playtest,
1650
1914
  restart: opts.restart,
1651
1915
  onStarted: opts.onStarted,
@@ -2161,6 +2425,7 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
2161
2425
  prompt,
2162
2426
  claudeModel: ctx.claudeModel(),
2163
2427
  openrouterModel: ctx.openrouterModel(),
2428
+ openrouterTuning: ctx.openrouterTuning(),
2164
2429
  attachments,
2165
2430
  cwd: ctx.deckDir,
2166
2431
  timeoutMs: ROUTER_TIMEOUT_MS,
@@ -2193,7 +2458,7 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
2193
2458
  },
2194
2459
  })
2195
2460
  .then((result) => {
2196
- logAgentUsage("router", backend, result.usage);
2461
+ reportRunUsage(ctx.agentDir, "router", backend, ctx.claudeModel(), ctx.openrouterModel(), result.usage);
2197
2462
  // Signals the finally -> onSettled(retryable): the turn failed cleanly
2198
2463
  // enough (transient, nothing salvaged) that the queue may re-run it.
2199
2464
  let retryable = false;
@@ -2323,14 +2588,37 @@ function applyAgentSettings(incoming, ctx) {
2323
2588
  const value = normalizeClaudeModel(incoming[key]);
2324
2589
  if (value && value !== settings[key]) {
2325
2590
  settings[key] = value;
2326
- changes.push(`${key === "routerClaudeModel" ? "conductor" : "tasks"} claude model -> ${value}`);
2591
+ changes.push(`${key === "routerClaudeModel" ? "operator" : "tasks"} claude model -> ${value}`);
2327
2592
  }
2328
2593
  }
2329
2594
  for (const key of ["routerOpenrouterModel", "tasksOpenrouterModel"]) {
2330
2595
  const value = normalizeOpenrouterModel(incoming[key]);
2331
2596
  if (value && value !== settings[key]) {
2332
2597
  settings[key] = value;
2333
- changes.push(`${key === "routerOpenrouterModel" ? "conductor" : "tasks"} openrouter model -> ${value}`);
2598
+ changes.push(`${key === "routerOpenrouterModel" ? "operator" : "tasks"} openrouter model -> ${value}`);
2599
+ }
2600
+ }
2601
+ for (const key of ["routerReasoningEffort", "tasksReasoningEffort"]) {
2602
+ const value = normalizeReasoningEffort(incoming[key]);
2603
+ if (value && value !== settings[key]) {
2604
+ settings[key] = value;
2605
+ changes.push(`${key === "routerReasoningEffort" ? "operator" : "tasks"} reasoning effort -> ${value}`);
2606
+ }
2607
+ }
2608
+ for (const key of ["routerRouting", "tasksRouting"]) {
2609
+ const value = normalizeRoutingMode(incoming[key]);
2610
+ if (value && value !== settings[key]) {
2611
+ settings[key] = value;
2612
+ changes.push(`${key === "routerRouting" ? "operator" : "tasks"} routing -> ${value}`);
2613
+ }
2614
+ }
2615
+ for (const key of ["routerProviderTier", "tasksProviderTier"]) {
2616
+ // "" is a valid value (auto), so check for null (invalid) explicitly
2617
+ // rather than truthiness -- otherwise the tier could never be cleared.
2618
+ const value = normalizeProviderTier(incoming[key]);
2619
+ if (value !== null && value !== settings[key]) {
2620
+ settings[key] = value;
2621
+ changes.push(`${key === "routerProviderTier" ? "operator" : "tasks"} provider tier -> ${value || "auto"}`);
2334
2622
  }
2335
2623
  }
2336
2624
  if (changes.length === 0)
@@ -2495,6 +2783,11 @@ function startRouterTurn(ctx, instruction, attachments = []) {
2495
2783
  backend: () => ctx.settings.router,
2496
2784
  claudeModel: () => ctx.settings.routerClaudeModel,
2497
2785
  openrouterModel: () => ctx.settings.routerOpenrouterModel,
2786
+ openrouterTuning: () => ({
2787
+ reasoningEffort: ctx.settings.routerReasoningEffort,
2788
+ routing: ctx.settings.routerRouting,
2789
+ providerTier: ctx.settings.routerProviderTier,
2790
+ }),
2498
2791
  canAutoRetry: () => !ctx.state.autoRetryUsed && ctx.state.pendingSends.length === 0,
2499
2792
  onSettled: (retryable) => onRouterQueueSettled(ctx, retryable),
2500
2793
  }, instruction, attachments);
@@ -2784,6 +3077,20 @@ export function createAgentServer(opts) {
2784
3077
  tasksOpenrouterModel: normalizeOpenrouterModel(storedSettings?.tasksOpenrouterModel) ??
2785
3078
  legacyOpenrouterModel ??
2786
3079
  DEFAULT_SETTINGS.tasksOpenrouterModel,
3080
+ routerReasoningEffort: normalizeReasoningEffort(storedSettings?.routerReasoningEffort) ??
3081
+ DEFAULT_SETTINGS.routerReasoningEffort,
3082
+ tasksReasoningEffort: normalizeReasoningEffort(storedSettings?.tasksReasoningEffort) ??
3083
+ DEFAULT_SETTINGS.tasksReasoningEffort,
3084
+ routerRouting: normalizeRoutingMode(storedSettings?.routerRouting) ??
3085
+ DEFAULT_SETTINGS.routerRouting,
3086
+ tasksRouting: normalizeRoutingMode(storedSettings?.tasksRouting) ??
3087
+ DEFAULT_SETTINGS.tasksRouting,
3088
+ // Provider tier: "" is valid (auto), so keep a normalized "" over the
3089
+ // default rather than treating it as absent.
3090
+ routerProviderTier: normalizeProviderTier(storedSettings?.routerProviderTier) ??
3091
+ DEFAULT_SETTINGS.routerProviderTier,
3092
+ tasksProviderTier: normalizeProviderTier(storedSettings?.tasksProviderTier) ??
3093
+ DEFAULT_SETTINGS.tasksProviderTier,
2787
3094
  };
2788
3095
  const applySettings = (incoming) => applyAgentSettings(incoming, { settings, settingsPath, broadcast });
2789
3096
  const taskFeeds = createTaskFeeds(broadcast);
@@ -2797,6 +3104,11 @@ export function createAgentServer(opts) {
2797
3104
  backend: () => settings.tasks,
2798
3105
  openrouterModel: () => settings.tasksOpenrouterModel,
2799
3106
  claudeModel: () => settings.tasksClaudeModel,
3107
+ openrouterTuning: () => ({
3108
+ reasoningEffort: settings.tasksReasoningEffort,
3109
+ routing: settings.tasksRouting,
3110
+ providerTier: settings.tasksProviderTier,
3111
+ }),
2800
3112
  playtest,
2801
3113
  restart: opts.restart,
2802
3114
  // Task lifecycle stays on the board only -- log lines for it were spam.
@@ -2896,6 +3208,8 @@ export function createAgentServer(opts) {
2896
3208
  const handleAttachment = makeAttachmentHandler(attachmentsDir);
2897
3209
  const handlePlaytestFrame = makePlaytestFrameHandler(tasksDir);
2898
3210
  function handleHttpRequest(req, res, reqPath) {
3211
+ if (reqPath === AGENT_MODEL_CAPS_PREFIX)
3212
+ return handleModelCaps(req, res);
2899
3213
  return handleAttachment(req, res, reqPath) || handlePlaytestFrame(req, res, reqPath);
2900
3214
  }
2901
3215
  function shutdown() {
package/dist/ide.js CHANGED
@@ -268,6 +268,136 @@ function handleFilesWrite(deckDir, req, res) {
268
268
  }
269
269
  })();
270
270
  }
271
+ // Add `<rel>/**` to the deck's editor.visiblePaths so files created in a
272
+ // newly-made folder show in the curated Files tree. Only touches a deck that is
273
+ // ALREADY curated (non-empty visiblePaths) -- when visiblePaths is empty
274
+ // everything is visible, and adding a glob would wrongly start hiding things.
275
+ // No-op if an existing glob already covers the folder. Returns whether it wrote.
276
+ function ensureVisiblePath(deckDir, rel) {
277
+ const file = path.join(deckDir, "castle.json");
278
+ let data;
279
+ try {
280
+ data = JSON.parse(fs.readFileSync(file, "utf8"));
281
+ }
282
+ catch {
283
+ return false; // no castle.json yet (deck never saved) -> treat as not curated
284
+ }
285
+ const visible = data.editor && Array.isArray(data.editor.visiblePaths)
286
+ ? data.editor.visiblePaths.filter((v) => typeof v === "string")
287
+ : null;
288
+ if (!visible || visible.length === 0)
289
+ return false; // not curated -> all visible
290
+ const glob = `${rel}/**`;
291
+ if (visible.includes(glob))
292
+ return false;
293
+ if (picomatch(visible)(`${rel}/__probe__`))
294
+ return false; // already covered
295
+ visible.push(glob);
296
+ data.editor.visiblePaths = visible;
297
+ try {
298
+ fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, "utf8");
299
+ return true;
300
+ }
301
+ catch {
302
+ return false;
303
+ }
304
+ }
305
+ function handleFilesMkdir(deckDir, req, res) {
306
+ void (async () => {
307
+ let body;
308
+ try {
309
+ body = JSON.parse(await readRequestBody(req));
310
+ }
311
+ catch {
312
+ return sendJson(res, 400, { error: "Invalid JSON body." });
313
+ }
314
+ const resolved = resolveDeckPath(deckDir, body.path);
315
+ if (!resolved.ok)
316
+ return sendJson(res, 400, { error: resolved.error });
317
+ try {
318
+ fs.mkdirSync(resolved.abs, { recursive: true });
319
+ }
320
+ catch (err) {
321
+ const message = err instanceof Error ? err.message : String(err);
322
+ return sendJson(res, 500, { error: `Could not create folder ${resolved.rel}: ${message}` });
323
+ }
324
+ const visiblePathAdded = ensureVisiblePath(deckDir, resolved.rel);
325
+ sendJson(res, 200, { ok: true, path: resolved.rel, visiblePathAdded });
326
+ })();
327
+ }
328
+ // True when two paths resolve to the same underlying file (same inode+device) --
329
+ // e.g. the source and target of a case-only rename on a case-insensitive FS.
330
+ function isSameFile(a, b) {
331
+ try {
332
+ const sa = fs.statSync(a);
333
+ const sb = fs.statSync(b);
334
+ return sa.ino === sb.ino && sa.dev === sb.dev;
335
+ }
336
+ catch {
337
+ return false;
338
+ }
339
+ }
340
+ function handleFilesRename(deckDir, req, res) {
341
+ void (async () => {
342
+ let body;
343
+ try {
344
+ body = JSON.parse(await readRequestBody(req));
345
+ }
346
+ catch {
347
+ return sendJson(res, 400, { error: "Invalid JSON body." });
348
+ }
349
+ const from = resolveDeckPath(deckDir, body.from);
350
+ if (!from.ok)
351
+ return sendJson(res, 400, { error: from.error });
352
+ const to = resolveDeckPath(deckDir, body.to);
353
+ if (!to.ok)
354
+ return sendJson(res, 400, { error: to.error });
355
+ if (!fs.existsSync(from.abs)) {
356
+ return sendJson(res, 404, { error: `Not found: ${from.rel}` });
357
+ }
358
+ // Block a collision with a DIFFERENT existing file. On a case-insensitive
359
+ // filesystem (default on macOS/Windows) `to` can "exist" only because it is
360
+ // `from` under a different case -- a case-only rename like bounce.jsx ->
361
+ // Bounce.jsx. Allow that by treating same-inode as not-a-collision.
362
+ if (from.abs !== to.abs && fs.existsSync(to.abs) && !isSameFile(from.abs, to.abs)) {
363
+ return sendJson(res, 409, { error: `Already exists: ${to.rel}` });
364
+ }
365
+ try {
366
+ fs.mkdirSync(path.dirname(to.abs), { recursive: true });
367
+ fs.renameSync(from.abs, to.abs);
368
+ sendJson(res, 200, { ok: true, path: to.rel });
369
+ }
370
+ catch (err) {
371
+ const message = err instanceof Error ? err.message : String(err);
372
+ sendJson(res, 500, { error: `Could not rename ${from.rel}: ${message}` });
373
+ }
374
+ })();
375
+ }
376
+ function handleFilesDelete(deckDir, req, res) {
377
+ void (async () => {
378
+ let body;
379
+ try {
380
+ body = JSON.parse(await readRequestBody(req));
381
+ }
382
+ catch {
383
+ return sendJson(res, 400, { error: "Invalid JSON body." });
384
+ }
385
+ const resolved = resolveDeckPath(deckDir, body.path);
386
+ if (!resolved.ok)
387
+ return sendJson(res, 400, { error: resolved.error });
388
+ if (!fs.existsSync(resolved.abs)) {
389
+ return sendJson(res, 404, { error: `Not found: ${resolved.rel}` });
390
+ }
391
+ try {
392
+ fs.rmSync(resolved.abs, { recursive: true, force: true });
393
+ sendJson(res, 200, { ok: true, path: resolved.rel });
394
+ }
395
+ catch (err) {
396
+ const message = err instanceof Error ? err.message : String(err);
397
+ sendJson(res, 500, { error: `Could not delete ${resolved.rel}: ${message}` });
398
+ }
399
+ })();
400
+ }
271
401
  // The builtin Files + code-editor backend: list / read / write deck files and
272
402
  // report kit-owned editor extensions. Paths are deck-relative; resolveDeckPath
273
403
  // rejects traversal and protected dirs.
@@ -286,7 +416,14 @@ function handleFilesApi(deckDir, req, res, reqPath) {
286
416
  return true;
287
417
  }
288
418
  if (action === "list") {
289
- const files = filterDeckFiles(listDeckFiles(deckDir), readEditorConfig(deckDir));
419
+ // `?all=1` returns the unfiltered listing (the "show hidden files & folders"
420
+ // toggle) -- still minus the always-ignored dirs (node_modules/.castle/...),
421
+ // just without the deck's visible/hidden path curation.
422
+ const url = new URL(req.url ?? "/", "http://localhost");
423
+ const listed = listDeckFiles(deckDir);
424
+ const files = url.searchParams.get("all") === "1"
425
+ ? listed
426
+ : filterDeckFiles(listed, readEditorConfig(deckDir));
290
427
  sendJson(res, 200, { files });
291
428
  return true;
292
429
  }
@@ -308,6 +445,18 @@ function handleFilesApi(deckDir, req, res, reqPath) {
308
445
  handleFilesWrite(deckDir, req, res);
309
446
  return true;
310
447
  }
448
+ if (action === "rename") {
449
+ handleFilesRename(deckDir, req, res);
450
+ return true;
451
+ }
452
+ if (action === "delete") {
453
+ handleFilesDelete(deckDir, req, res);
454
+ return true;
455
+ }
456
+ if (action === "mkdir") {
457
+ handleFilesMkdir(deckDir, req, res);
458
+ return true;
459
+ }
311
460
  return sendJson(res, 404, { error: `Unknown files action: ${action}` }), true;
312
461
  }
313
462
  function defaultShell() {
@@ -598,7 +598,11 @@ async function runLoop(opts, toolSchemas, log) {
598
598
  model: opts.model,
599
599
  messages,
600
600
  tools: toolSchemas,
601
- reasoningEffort: REASONING_EFFORT[opts.role],
601
+ // Settings-driven per-role effort; falls back to the built-in table
602
+ // when a caller doesn't supply one (e.g. the QA harness).
603
+ reasoningEffort: opts.reasoningEffort ?? REASONING_EFFORT[opts.role],
604
+ routing: opts.routing,
605
+ providerTier: opts.providerTier,
602
606
  maxTokens: MAX_COMPLETION_TOKENS,
603
607
  signal: controller.signal,
604
608
  onDelta: opts.onDelta,