castle-web-cli 0.4.82 → 0.4.84

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 (37) hide show
  1. package/dist/agent-failures.d.ts +17 -0
  2. package/dist/agent-failures.js +151 -0
  3. package/dist/agent.d.ts +27 -0
  4. package/dist/agent.js +614 -57
  5. package/dist/ide.js +150 -1
  6. package/dist/native/loop.js +40 -1
  7. package/dist/native/openrouter.d.ts +12 -1
  8. package/dist/native/openrouter.js +45 -2
  9. package/dist/native/types.d.ts +6 -0
  10. package/dist/native/types.js +0 -38
  11. package/dist/openrouter-catalog.d.ts +28 -0
  12. package/dist/openrouter-catalog.js +299 -0
  13. package/dist/shell/assets/index-BOgm5T3W.js +144 -0
  14. package/dist/shell/assets/index-DonnH--m.css +1 -0
  15. package/dist/shell/index.html +2 -2
  16. package/dist/shell/operator.png +0 -0
  17. package/kits/basic-2d/CLAUDE.md +27 -23
  18. package/kits/basic-2d/behaviors/Collider.jsx +24 -30
  19. package/kits/basic-2d/behaviors/Layout.jsx +9 -6
  20. package/kits/basic-2d/behaviors/Sprite.jsx +137 -7
  21. package/kits/basic-2d/blueprints/cauldron.scene +3 -5
  22. package/kits/basic-2d/editors/BlueprintLibrary.jsx +11 -11
  23. package/kits/basic-2d/editors/SceneEditor.jsx +212 -50
  24. package/kits/basic-2d/editors/SelectionOverlay.jsx +73 -54
  25. package/kits/basic-2d/editors/inspectorSheet.js +5 -1
  26. package/kits/basic-2d/engine/ScenePlayer.jsx +98 -7
  27. package/kits/basic-2d/engine/autoInspector.jsx +26 -7
  28. package/kits/basic-2d/engine/blueprint.js +35 -8
  29. package/kits/basic-2d/engine/collider.js +146 -0
  30. package/kits/basic-2d/engine/scene.js +53 -30
  31. package/kits/basic-2d/engine/spriteGeometry.js +32 -0
  32. package/kits/basic-2d/engine/ui.jsx +89 -30
  33. package/kits/basic-2d/engine/ui.module.css +157 -53
  34. package/kits/basic-2d/scenes/main.scene +3 -3
  35. package/package.json +2 -1
  36. package/dist/shell/assets/index-ByhgiJoP.js +0 -141
  37. package/dist/shell/assets/index-D6hM_VlW.css +0 -1
package/dist/agent.js CHANGED
@@ -22,6 +22,8 @@ import { nanoid } from "nanoid";
22
22
  import { WebSocketServer } from "ws";
23
23
  import { rawDataToString } from "./ide.js";
24
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, } from "./agent-failures.js";
25
27
  import { runAgentNative } from "./native/loop.js";
26
28
  import { createPlaytestBrowserManager } from "./native/playtest-browser.js";
27
29
  import { createPlaywrightPlaytestExecutor } from "./native/playtest-executor.js";
@@ -30,16 +32,38 @@ export const AGENT_ATTACHMENT_PREFIX = "/__castle/agent/attachments/";
30
32
  // Playtest frame PNGs (tasks/<id>/playtest/<file>.png), served for the
31
33
  // finished-task card's thumbnails -- see makePlaytestFrameHandler.
32
34
  export const AGENT_PLAYTEST_PREFIX = "/__castle/agent/playtest/";
35
+ // Same-origin proxy for OpenRouter model capabilities (avoids browser CORS
36
+ // against openrouter.ai). GET ?model=<slug> -> ModelCaps JSON. Powers the
37
+ // settings popover's dynamic reasoning-effort / provider-tier pickers.
38
+ export const AGENT_MODEL_CAPS_PREFIX = "/__castle/agent/model-caps";
33
39
  const DEFAULT_SETTINGS = {
34
40
  router: "claude",
35
41
  tasks: "claude",
36
42
  routerClaudeModel: "opus",
37
43
  // 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.
44
+ // sonnet's cost/speed wins by default; the operator stays on opus.
39
45
  tasksClaudeModel: "sonnet",
40
46
  // Free-form -- change to any OpenRouter slug.
41
47
  routerOpenrouterModel: "openai/gpt-5.6-sol",
42
48
  tasksOpenrouterModel: "openai/gpt-5.6-terra",
49
+ // Both roles think at "medium": the operator stays snappy (the user waits
50
+ // on every operator turn), and task agents' multi-turn tool loops don't pay
51
+ // reasoning tax on mechanical read/edit/run turns. Deep decomposition
52
+ // quality comes from the operator prompt, not a higher effort default.
53
+ routerReasoningEffort: "medium",
54
+ tasksReasoningEffort: "medium",
55
+ // Routing splits by what each role optimizes for: the interactive operator
56
+ // routes for speed (nitro = throughput-sorted endpoints), unattended task
57
+ // agents route for correctness (exacto = benchmark-accurate endpoints,
58
+ // which matters for tool-calling fidelity over long loops).
59
+ routerRouting: "nitro",
60
+ tasksRouting: "exacto",
61
+ // Operator pins OpenAI's priority (low-latency SLA) tier for the default
62
+ // sol model; harmless with other slugs since the pin falls back when the
63
+ // tag doesn't exist (allow_fallbacks). Tasks stay on auto: high-volume
64
+ // background turns should ride the cheapest available capacity.
65
+ routerProviderTier: "openai/priority",
66
+ tasksProviderTier: "",
43
67
  };
44
68
  function normalizeBackend(value) {
45
69
  return value === "cursor" || value === "claude" || value === "smith"
@@ -73,12 +97,65 @@ function normalizeOpenrouterModel(value) {
73
97
  const trimmed = value.trim();
74
98
  return trimmed && trimmed.length <= OPENROUTER_MODEL_MAX_LEN ? trimmed : null;
75
99
  }
100
+ // OpenRouter's full effort superset -- validated against the union rather than
101
+ // a per-model list because supported efforts are model-specific (the client
102
+ // fetches them from the model-caps endpoint to build the picker) and
103
+ // OpenRouter maps an unsupported level to the nearest one anyway.
104
+ const REASONING_EFFORTS = [
105
+ "none",
106
+ "minimal",
107
+ "low",
108
+ "medium",
109
+ "high",
110
+ "xhigh",
111
+ "max",
112
+ ];
113
+ function normalizeReasoningEffort(value) {
114
+ return REASONING_EFFORTS.includes(value)
115
+ ? value
116
+ : null;
117
+ }
118
+ const ROUTING_MODES = [
119
+ "balanced",
120
+ "nitro",
121
+ "exacto",
122
+ "floor",
123
+ ];
124
+ function normalizeRoutingMode(value) {
125
+ return ROUTING_MODES.includes(value)
126
+ ? value
127
+ : null;
128
+ }
129
+ // Provider tier is an OpenRouter endpoint `tag` ("openai/flex", "azure/eu",
130
+ // ...) which is model-specific, so validation is loose like the model slug.
131
+ // Unlike the slug, empty string is VALID and meaningful: "auto" (no pin), so
132
+ // this returns "" rather than null for the clear case -- callers must treat
133
+ // null (invalid) and "" (clear) differently.
134
+ function normalizeProviderTier(value) {
135
+ if (typeof value !== "string")
136
+ return null;
137
+ const trimmed = value.trim();
138
+ if (trimmed.length > OPENROUTER_MODEL_MAX_LEN)
139
+ return null;
140
+ return trimmed;
141
+ }
76
142
  // OpenRouter's Anthropic-compatible endpoint (confirmed current, 2026: it
77
143
  // accepts the standard Anthropic Messages API shape -- text/tool-use/
78
144
  // extended-thinking -- for ANY OpenRouter model slug, not just Anthropic
79
145
  // ones). Using it directly means claude CLI's OWN stream-json + tool loop
80
146
  // talks to OpenRouter with zero translation layer -- no proxy needed.
81
147
  const OPENROUTER_BASE_URL = "https://openrouter.ai/api";
148
+ // Anthropic credential sources the claude CLI will fall back to on its own.
149
+ // This env points the CLI at a THIRD PARTY, so every one of these has to be
150
+ // cleared or that third party receives the user's Anthropic credential. Note
151
+ // there is no file to check for here: on macOS the CLI reads a saved login
152
+ // from the Keychain, so `~/.claude/.credentials.json` can be absent while the
153
+ // CLI is still perfectly able to authenticate as the user.
154
+ const ANTHROPIC_CREDENTIAL_ENV = [
155
+ "ANTHROPIC_API_KEY",
156
+ "ANTHROPIC_AUTH_TOKEN_HELPER",
157
+ "CLAUDE_CODE_OAUTH_TOKEN",
158
+ ];
82
159
  // Env for a claude CLI spawn routed at OpenRouter (claudeModel "openrouter",
83
160
  // Path A). Two things make this deterministic regardless of the user's own
84
161
  // Anthropic auth (verified live against the real `claude` binary while
@@ -95,16 +172,29 @@ const OPENROUTER_BASE_URL = "https://openrouter.ai/api";
95
172
  // slug like "openai/gpt-5.1" 404s with a synthetic "model_not_found" and
96
173
  // NEVER reaches the network. Setting it makes the CLI accept any --model
97
174
  // string and actually send the request upstream.
98
- function envForOpenrouterSpawn() {
175
+ //
176
+ // `apiKey` is REQUIRED and must be non-empty, and that is a security boundary
177
+ // rather than a convenience. This function previously set ANTHROPIC_AUTH_TOKEN
178
+ // only `if (val)`, which meant a session with no OPENROUTER_API_KEY still
179
+ // pointed ANTHROPIC_BASE_URL at openrouter.ai with no token of its own -- and
180
+ // the CLI dutifully fell back to the user's saved Anthropic login and sent it
181
+ // there. Confirmed against the real binary and a logging server standing in
182
+ // for openrouter.ai: it sent `Authorization: Bearer sk-ant-...`. Callers must
183
+ // resolve a key first (preflightOpenrouterRun does); there is deliberately no
184
+ // code path from here to openrouter.ai without an OpenRouter token.
185
+ function envForOpenrouterSpawn(apiKey) {
186
+ if (!apiKey) {
187
+ // Unreachable via runAgentTurn (pre-flight rejects a keyless run before
188
+ // any spawn). A backstop, so a future caller that skips pre-flight fails
189
+ // loudly instead of quietly leaking.
190
+ throw new Error("envForOpenrouterSpawn: refusing to spawn without an OpenRouter key");
191
+ }
99
192
  const env = { ...process.env };
100
- delete env.ANTHROPIC_API_KEY;
193
+ for (const name of ANTHROPIC_CREDENTIAL_ENV)
194
+ delete env[name];
101
195
  env.ANTHROPIC_BASE_URL = OPENROUTER_BASE_URL;
102
196
  env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
103
- const val = openrouterApiKey();
104
- if (val)
105
- env.ANTHROPIC_AUTH_TOKEN = val;
106
- else
107
- delete env.ANTHROPIC_AUTH_TOKEN;
197
+ env.ANTHROPIC_AUTH_TOKEN = apiKey;
108
198
  return env;
109
199
  }
110
200
  // The one OpenRouter credential, shared by BOTH OpenRouter paths: smith's
@@ -116,6 +206,110 @@ const OPENROUTER_KEY_NAME = "OPENROUTER_API_KEY";
116
206
  function openrouterApiKey() {
117
207
  return castleKeys()[OPENROUTER_KEY_NAME] ?? process.env[OPENROUTER_KEY_NAME] ?? "";
118
208
  }
209
+ const MODEL_CAPS_TTL_MS = 10 * 60_000;
210
+ const OPENROUTER_API_BASE = "https://openrouter.ai/api/v1";
211
+ const modelCapsCache = new Map();
212
+ function asRecord(v) {
213
+ return v && typeof v === "object" ? v : null;
214
+ }
215
+ async function openrouterProviderTiers(slug) {
216
+ const res = await fetch(`${OPENROUTER_API_BASE}/models/${slug}/endpoints`);
217
+ if (!res.ok)
218
+ return [];
219
+ const json = asRecord(await res.json());
220
+ const data = asRecord(json?.data);
221
+ const endpoints = data && Array.isArray(data.endpoints) ? data.endpoints : [];
222
+ const tags = [];
223
+ for (const ep of endpoints) {
224
+ const rec = asRecord(ep);
225
+ const tag = rec && typeof rec.tag === "string" ? rec.tag : null;
226
+ if (tag && !tags.includes(tag))
227
+ tags.push(tag);
228
+ }
229
+ return tags;
230
+ }
231
+ async function fetchModelCaps(slug) {
232
+ const cached = modelCapsCache.get(slug);
233
+ if (cached && Date.now() - cached.at < MODEL_CAPS_TTL_MS)
234
+ return cached.caps;
235
+ // Best-effort per source: a failure in either leaves that half empty rather
236
+ // than failing the whole lookup, so a bad slug still yields a usable (empty)
237
+ // caps object the client can render as "no dynamic options".
238
+ let reasoningEfforts = null;
239
+ let defaultEffort = null;
240
+ try {
241
+ // Shared catalog (openrouter-catalog.ts) rather than a second /models
242
+ // fetch: it's the same list the pre-flight slug check reads, and it brings
243
+ // a disk-backed stale-while-revalidate cache with it.
244
+ const entry = await openrouterCatalogEntry(slug);
245
+ const reasoning = asRecord(entry?.reasoning);
246
+ const efforts = reasoning?.supported_efforts;
247
+ const supportedParams = entry?.supportedParameters;
248
+ const acceptsEffort = Array.isArray(supportedParams) &&
249
+ (supportedParams.includes("reasoning_effort") ||
250
+ supportedParams.includes("reasoning"));
251
+ if (acceptsEffort && Array.isArray(efforts) && efforts.length > 0) {
252
+ reasoningEfforts = efforts.filter((e) => typeof e === "string");
253
+ defaultEffort =
254
+ typeof reasoning?.default_effort === "string"
255
+ ? reasoning.default_effort
256
+ : null;
257
+ }
258
+ }
259
+ catch {
260
+ // leave reasoning fields null
261
+ }
262
+ let providerTiers = [];
263
+ try {
264
+ providerTiers = await openrouterProviderTiers(slug);
265
+ }
266
+ catch {
267
+ // leave providerTiers empty
268
+ }
269
+ const caps = {
270
+ model: slug,
271
+ reasoningEfforts,
272
+ defaultEffort,
273
+ providerTiers,
274
+ };
275
+ modelCapsCache.set(slug, { caps, at: Date.now() });
276
+ return caps;
277
+ }
278
+ // GET AGENT_MODEL_CAPS_PREFIX?model=<slug>. Returns 400 for a missing/oversized
279
+ // slug, 200 ModelCaps otherwise (empty caps on upstream failure -- see
280
+ // fetchModelCaps). reqPath is already query-stripped; parse req.url for it.
281
+ function handleModelCaps(req, res) {
282
+ const send = (status, body) => {
283
+ res.writeHead(status, {
284
+ "content-type": "application/json",
285
+ "cache-control": "no-store",
286
+ });
287
+ res.end(JSON.stringify(body));
288
+ return true;
289
+ };
290
+ let slug = "";
291
+ try {
292
+ slug = (new URL(req.url ?? "", "http://localhost").searchParams.get("model") ?? "").trim();
293
+ }
294
+ catch {
295
+ slug = "";
296
+ }
297
+ if (!slug || slug.length > OPENROUTER_MODEL_MAX_LEN) {
298
+ return send(400, { error: "missing or invalid model" });
299
+ }
300
+ // Strip any routing suffix the client may have on the displayed slug so the
301
+ // OpenRouter lookup hits the base model id.
302
+ const baseSlug = slug.replace(/:(nitro|exacto|floor)$/, "");
303
+ fetchModelCaps(baseSlug)
304
+ .then((caps) => send(200, caps))
305
+ .catch(() => send(200, {
306
+ model: baseSlug,
307
+ reasoningEfforts: null,
308
+ defaultEffort: null,
309
+ providerTiers: [],
310
+ }));
311
+ return true;
312
+ }
119
313
  // Build the headless CLI invocation for a spawning backend/role (smith never
120
314
  // comes through here -- it has no CLI process; see runAgentSmith). Cursor's
121
315
  // router runs in read-only ask mode; claude runs permission-mode auto for
@@ -178,7 +372,9 @@ openrouterModel) {
178
372
  : []),
179
373
  prompt,
180
374
  ],
181
- env: viaOpenrouter ? envForOpenrouterSpawn() : envForAgentSpawn(backend),
375
+ env: viaOpenrouter
376
+ ? envForOpenrouterSpawn(openrouterApiKey())
377
+ : envForAgentSpawn(backend),
182
378
  };
183
379
  }
184
380
  return {
@@ -435,6 +631,15 @@ function baseName(p) {
435
631
  const parts = p.split(/[\\/]/).filter(Boolean);
436
632
  return parts[parts.length - 1] || p;
437
633
  }
634
+ // First non-blank line, capped -- the one-line technical reason shown in the
635
+ // UI's collapsed error disclosure. The full text goes to the consoles.
636
+ function firstLine(text, max = 200) {
637
+ const line = (text ?? "")
638
+ .split("\n")
639
+ .map((l) => l.trim())
640
+ .find((l) => l.length > 0);
641
+ return (line ?? "").slice(0, max);
642
+ }
438
643
  // First string-typed value among loosely-typed tool inputs, or "" when none is
439
644
  // a string (avoids "[object Object]" from String()-ing an object/array value).
440
645
  function firstString(...vals) {
@@ -504,7 +709,13 @@ function toolActivityLabel(ev) {
504
709
  // (~/.castle/keys.json) rather than sandbox-wide env -- so an ambient key can't
505
710
  // override a user's own subscription login. Falls back to process.env for
506
711
  // older sandboxes that still inject the keys as env.
507
- const CASTLE_KEYS_PATH = path.join(os.homedir(), ".castle", "keys.json");
712
+ // Overridable so the QA battery can isolate from a developer's REAL
713
+ // ~/.castle/keys.json. Without this, a machine that has a live OPENROUTER_API_KEY
714
+ // there shadows the fake key the harness injects via env (keys-file-first, see
715
+ // openrouterApiKey), which breaks the keyless and injected-key scenarios in
716
+ // exactly the way that is hard to reproduce on CI. Mirrors the other
717
+ // CASTLE_OPENROUTER_* test overrides.
718
+ const CASTLE_KEYS_PATH = process.env.CASTLE_KEYS_PATH ?? path.join(os.homedir(), ".castle", "keys.json");
508
719
  function castleKeys() {
509
720
  try {
510
721
  return JSON.parse(fs.readFileSync(CASTLE_KEYS_PATH, "utf8"));
@@ -520,16 +731,41 @@ const BACKEND_KEY_ENV = {
520
731
  claude: "ANTHROPIC_API_KEY",
521
732
  cursor: "CURSOR_API_KEY",
522
733
  };
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.
734
+ // cursor-agent rewrites ~/.config/cursor/auth.json on every successful run,
735
+ // including Castle's own CURSOR_API_KEY runs -- so the file existing does NOT
736
+ // mean a user logged in. A real login (OAuth, or a tester's own key) overwrites
737
+ // it and drops Castle's apiKey; a file whose apiKey is still Castle's key is just
738
+ // our cache. Treating that cache as a login would suppress the injected key, and
739
+ // once its ~60-min token expires cursor-agent (no headless refresh) fails auth.
740
+ function cursorHasUserLogin(home) {
741
+ const authPath = path.join(home, ".config", "cursor", "auth.json");
742
+ try {
743
+ const auth = JSON.parse(fs.readFileSync(authPath, "utf8"));
744
+ return auth.apiKey !== castleKeys().CURSOR_API_KEY;
745
+ }
746
+ catch {
747
+ return false;
748
+ }
749
+ }
750
+ // True when the user has their OWN saved auth for this backend -- a login that we
751
+ // should defer to (and bill to them) instead of injecting Castle's key.
752
+ //
753
+ // KNOWN GAP (macOS): the claude check is a false negative for most logged-in
754
+ // users. `claude /login` stores credentials in the KEYCHAIN there, not in
755
+ // ~/.claude/.credentials.json, so this returns false and Castle's key gets
756
+ // injected and billed even though the user has a perfectly good subscription
757
+ // login the CLI would have used. Verified while tracing the OpenRouter
758
+ // credential leak: on a machine with no .credentials.json at all, the CLI still
759
+ // authenticated from the Keychain. Left alone deliberately -- reading the
760
+ // Keychain (`security find-generic-password`) changes who pays for a run, which
761
+ // is a product decision, not a cleanup.
526
762
  function backendHasSavedAuth(backend) {
527
763
  const home = os.homedir();
528
764
  if (backend === "claude") {
529
765
  return fs.existsSync(path.join(home, ".claude", ".credentials.json"));
530
766
  }
531
767
  if (backend === "cursor") {
532
- return fs.existsSync(path.join(home, ".config", "cursor", "auth.json"));
768
+ return cursorHasUserLogin(home);
533
769
  }
534
770
  return false;
535
771
  }
@@ -836,6 +1072,67 @@ function logAgentUsage(label, backend, usage) {
836
1072
  const output = formatTokenCount(usage.output_tokens);
837
1073
  console.error(`[agent usage] ${label} ${backend}: input=${input} cache_read=${read} cache_created=${created} output=${output}`);
838
1074
  }
1075
+ // Per-deck machine-readable usage ledger, appended to <deckDir>/.castle/agent/,
1076
+ // harvested out-of-band (by the cloud launcher) for rough per-user token
1077
+ // metering. Distinct from logAgentUsage's stderr line, which is lossy
1078
+ // (rounds to "3.2k") and gets truncated when the serve restarts.
1079
+ const USAGE_LEDGER_FILE = "usage.jsonl";
1080
+ // The ledger is only useful where the cloud launcher harvests it, so the managed
1081
+ // sandbox environments set CASTLE_USAGE_LEDGER=1 (E2B via cloudSandbox.serveOnPort,
1082
+ // castle-sandboxes via its image). A local `castle-web serve` leaves it unset, so
1083
+ // dev deck dirs don't accumulate a ledger nothing reads.
1084
+ const USAGE_LEDGER_ENABLED = process.env.CASTLE_USAGE_LEDGER === "1";
1085
+ // Concrete model behind a finished run: smith and claude-via-OpenRouter both
1086
+ // bill the OpenRouter slug; plain claude bills its own slug; cursor has no
1087
+ // per-model split tracked here.
1088
+ function resolveRunModel(backend, claudeModel, openrouterModel) {
1089
+ if (backend === "smith")
1090
+ return openrouterModel;
1091
+ if (backend === "claude") {
1092
+ return claudeModel === "openrouter" ? openrouterModel : claudeModel;
1093
+ }
1094
+ return backend;
1095
+ }
1096
+ // One record per finished run: the human-readable stderr line PLUS a precise
1097
+ // append-only JSONL line in the deck's usage ledger. Precise counts (not the
1098
+ // stderr line's rounded values) and self-describing (id/role/backend/model) so
1099
+ // the harvester can attribute and de-dup. Best-effort: a metering write must
1100
+ // never fail an agent run.
1101
+ function reportRunUsage(agentDir, role, backend, claudeModel, openrouterModel, result, taskId) {
1102
+ const usage = result.usage;
1103
+ logAgentUsage(taskId ? `task ${taskId}` : "router", backend, usage);
1104
+ if (!USAGE_LEDGER_ENABLED)
1105
+ return;
1106
+ // cursor-agent's stream-json doesn't report token usage, so a cursor run has no
1107
+ // `usage`; still record it (zero counts, tokens_reported:false) for run-count
1108
+ // visibility. Other backends only write once they actually produced usage.
1109
+ if (!usage && backend !== "cursor")
1110
+ return;
1111
+ try {
1112
+ const line = JSON.stringify({
1113
+ at: nowIso(),
1114
+ id: nanoid(),
1115
+ role,
1116
+ backend,
1117
+ model: resolveRunModel(backend, claudeModel, openrouterModel),
1118
+ ...(taskId ? { taskId } : {}),
1119
+ // A failed run still resolves with a usage object of all zeros (an auth
1120
+ // error emits a result event with zeroed counts), so its zeros would
1121
+ // otherwise be indistinguishable downstream from a real "measured zero".
1122
+ ...(result.ok ? {} : { failed: true }),
1123
+ tokens_reported: usage !== undefined,
1124
+ input_tokens: usage?.input_tokens ?? 0,
1125
+ output_tokens: usage?.output_tokens ?? 0,
1126
+ cache_read_input_tokens: usage?.cache_read_input_tokens ?? 0,
1127
+ cache_creation_input_tokens: usage?.cache_creation_input_tokens ?? 0,
1128
+ });
1129
+ fs.mkdirSync(agentDir, { recursive: true });
1130
+ fs.appendFileSync(path.join(agentDir, USAGE_LEDGER_FILE), line + "\n");
1131
+ }
1132
+ catch {
1133
+ /* best-effort: a metering write must never fail an agent run */
1134
+ }
1135
+ }
839
1136
  // Build the per-run stdout event handler over a shared mutable parser state.
840
1137
  // Splitting the cursor + claude stream decoding out of runAgentCli keeps each
841
1138
  // within the max-lines budget; behavior is identical (same delta/activity/
@@ -1039,19 +1336,47 @@ function runAgentCli(opts) {
1039
1336
  settle({
1040
1337
  ok: false,
1041
1338
  finalText: state.accumulated,
1042
- error: `could not run cursor-agent: ${err.message}`,
1339
+ // The binary itself wouldn't start (ENOENT, EACCES). Names the command
1340
+ // actually being spawned -- this used to say "cursor-agent" for every
1341
+ // backend, so a missing `claude` reported the wrong tool.
1342
+ error: `could not run ${opts.command}: ${err.message}`,
1343
+ failure: { kind: "spawn", detail: `${opts.command}: ${err.message}` },
1043
1344
  });
1044
1345
  });
1045
1346
  child.on("close", (code) => {
1046
1347
  const ok = code === 0 && !state.resultIsError && state.sawResult;
1348
+ // A provider error does NOT arrive on stderr: the claude CLI reports it
1349
+ // in the stream-json result event (is_error:true) and exits 0, leaving
1350
+ // stderr empty -- verified against the real binary. So classify against
1351
+ // the result text first, with stderr as the fallback for the shapes that
1352
+ // do write there. Both are read BEFORE the 300-char truncation below.
1353
+ //
1354
+ // ONLY for OpenRouter-routed runs (opts.openrouterModel is set only when
1355
+ // roleUsesOpenrouter): the classifier's copy is OpenRouter-branded, so a
1356
+ // plain claude/cursor failure must NOT run through it -- an Anthropic 529
1357
+ // would otherwise read as "OpenRouter is busy", naming the wrong
1358
+ // provider. Plain runs keep their provider-neutral "exit" copy, exactly
1359
+ // as before this feature.
1360
+ const classified = ok || !opts.openrouterModel
1361
+ ? undefined
1362
+ : classifyProviderError(state.finalText, opts.openrouterModel) ??
1363
+ classifyProviderError(stderrTail, opts.openrouterModel);
1364
+ const error = ok
1365
+ ? undefined
1366
+ : `agent exited ${code}${stderrTail ? `: ${stderrTail.slice(-300)}` : ""}`;
1047
1367
  settle({
1048
1368
  ok,
1049
1369
  finalText: state.finalText || state.accumulated,
1050
1370
  usage: state.usage,
1051
1371
  crashed: !state.sawResult,
1052
- error: ok
1053
- ? undefined
1054
- : `agent exited ${code}${stderrTail ? `: ${stderrTail.slice(-300)}` : ""}`,
1372
+ error,
1373
+ failure: classified
1374
+ ? {
1375
+ ...classified,
1376
+ detail: firstLine(state.finalText) || classified.detail,
1377
+ verbose: [state.finalText, stderrTail].filter(Boolean).join("\n"),
1378
+ }
1379
+ : undefined,
1055
1380
  });
1056
1381
  });
1057
1382
  });
@@ -1094,6 +1419,10 @@ async function runAgentSmith(opts) {
1094
1419
  role: opts.role,
1095
1420
  model: opts.model,
1096
1421
  apiKey: openrouterApiKey(),
1422
+ reasoningEffort: opts.openrouterTuning?.reasoningEffort,
1423
+ routing: opts.openrouterTuning?.routing,
1424
+ // "" (auto) becomes undefined so no provider.order is sent.
1425
+ providerTier: opts.openrouterTuning?.providerTier || undefined,
1097
1426
  prompt: opts.prompt,
1098
1427
  systemReminder: opts.systemReminder,
1099
1428
  attachments: opts.attachments,
@@ -1110,6 +1439,7 @@ async function runAgentSmith(opts) {
1110
1439
  ok: !result.error && !result.crashed,
1111
1440
  finalText: result.text,
1112
1441
  error: result.error,
1442
+ failure: result.failure,
1113
1443
  usage: result.usage,
1114
1444
  playtestFrames: result.playtestFrames,
1115
1445
  crashed: result.crashed,
@@ -1129,11 +1459,82 @@ async function runAgentSmith(opts) {
1129
1459
  opts.children.delete(handle);
1130
1460
  }
1131
1461
  }
1462
+ // True when a role's path goes through OpenRouter: the smith native loop, or
1463
+ // the claude CLI routed at OpenRouter. The client has a twin of this in
1464
+ // conductor.tsx (it decides whether to show the slug field) -- keep them in
1465
+ // step; this one decides whether the slug is worth validating at all.
1466
+ export function roleUsesOpenrouter(backend, claudeModel) {
1467
+ return backend === "smith" || (backend === "claude" && claudeModel === "openrouter");
1468
+ }
1469
+ function configFailure(reason, detail, extra) {
1470
+ return { kind: "config", reason, detail, ...extra };
1471
+ }
1472
+ // Everything decidable about an OpenRouter run BEFORE spending anything on it.
1473
+ // Returns a failure to surface as-is, or null to proceed.
1474
+ //
1475
+ // Ordering is deliberate: the key is checked first because a keyless run must
1476
+ // never reach envForOpenrouterSpawn (see the credential-leak note there), and
1477
+ // because a bad key otherwise costs over two minutes of internal CLI retries.
1478
+ // Everything here either answers definitively or falls open -- checkOpenrouter*
1479
+ // resolve "unavailable" on any network trouble, and we allow the run rather
1480
+ // than invent a verdict.
1481
+ async function preflightOpenrouterRun(opts) {
1482
+ if (!roleUsesOpenrouter(opts.backend, opts.claudeModel))
1483
+ return null;
1484
+ const apiKey = openrouterApiKey();
1485
+ if (!apiKey) {
1486
+ return configFailure("no-key", `no ${OPENROUTER_KEY_NAME} is set (checked ${CASTLE_KEYS_PATH} and the environment)`);
1487
+ }
1488
+ const model = opts.openrouterModel.trim();
1489
+ if (!model) {
1490
+ return configFailure("unknown-model", "no OpenRouter model is set for this role");
1491
+ }
1492
+ // Key and slug checks are independent, so overlap them rather than paying
1493
+ // both round-trips in series. Both are cached and single-flighted.
1494
+ const [key, slug] = await Promise.all([
1495
+ checkOpenrouterKey(apiKey),
1496
+ checkOpenrouterModel(model),
1497
+ ]);
1498
+ if (key.status === "bad-key") {
1499
+ return configFailure("bad-key", `OpenRouter rejected ${OPENROUTER_KEY_NAME}`);
1500
+ }
1501
+ if (key.status === "no-credits") {
1502
+ return configFailure("no-credits", "the OpenRouter key is out of credits");
1503
+ }
1504
+ if (slug.status === "unknown-model") {
1505
+ return configFailure("unknown-model", `OpenRouter has no model "${model}"`, {
1506
+ model,
1507
+ suggestion: slug.suggestions[0],
1508
+ });
1509
+ }
1510
+ if (slug.status === "no-tools") {
1511
+ return configFailure("no-tools", `"${model}" does not support tool calling`, {
1512
+ model,
1513
+ });
1514
+ }
1515
+ return null;
1516
+ }
1132
1517
  // The one backend dispatch point for running an agent turn: smith runs
1133
1518
  // in-process (runAgentSmith -> runAgentNative); cursor/claude spawn a CLI
1134
1519
  // (buildAgentInvocation -> runAgentCli). Everything downstream consumes the
1135
1520
  // same CliRunResult contract either way.
1136
- function runAgentTurn(opts) {
1521
+ async function runAgentTurn(opts) {
1522
+ // Deterministic config errors stop here: nothing spawned, no request issued,
1523
+ // nothing billed. Returned (not thrown) because the callers' catch paths
1524
+ // emit generic "something went wrong" copy, which would bury the specific
1525
+ // reason this pre-flight exists to produce.
1526
+ const failure = await preflightOpenrouterRun(opts);
1527
+ if (failure) {
1528
+ return {
1529
+ ok: false,
1530
+ finalText: "",
1531
+ error: failure.detail,
1532
+ failure,
1533
+ // NOT "crashed": nothing ran. crashed drives the task retry loop, and a
1534
+ // config failure is exactly what must not be retried.
1535
+ crashed: false,
1536
+ };
1537
+ }
1137
1538
  if (opts.backend === "smith") {
1138
1539
  return runAgentSmith({
1139
1540
  cwd: opts.cwd,
@@ -1144,6 +1545,7 @@ function runAgentTurn(opts) {
1144
1545
  // appends it to its own system framing).
1145
1546
  systemReminder: opts.role === "task" ? CLAUDE_TASK_SYSTEM_REMINDER : undefined,
1146
1547
  attachments: opts.attachments,
1548
+ openrouterTuning: opts.openrouterTuning,
1147
1549
  timeoutMs: opts.timeoutMs,
1148
1550
  logPath: opts.logPath,
1149
1551
  playtest: opts.playtest,
@@ -1170,6 +1572,9 @@ function runAgentTurn(opts) {
1170
1572
  onThinking: opts.onThinking,
1171
1573
  onSpawn: opts.onSpawn,
1172
1574
  labelUnknownTools: opts.labelUnknownTools,
1575
+ openrouterModel: roleUsesOpenrouter(opts.backend, opts.claudeModel)
1576
+ ? opts.openrouterModel
1577
+ : undefined,
1173
1578
  });
1174
1579
  }
1175
1580
  // -- task store ---------------------------------------------------------------
@@ -1398,6 +1803,7 @@ async function runTaskAgentIn(ctx, task) {
1398
1803
  prompt: taskPrompt,
1399
1804
  claudeModel: ctx.claudeModel,
1400
1805
  openrouterModel: ctx.openrouterModel,
1806
+ openrouterTuning: ctx.openrouterTuning,
1401
1807
  cwd: ctx.deckDir,
1402
1808
  timeoutMs: TASK_TIMEOUT_MS,
1403
1809
  logPath: path.join(dir, "log.jsonl"),
@@ -1416,11 +1822,18 @@ async function runTaskAgentIn(ctx, task) {
1416
1822
  ctx.onFeed(`[${activity}]`);
1417
1823
  },
1418
1824
  });
1419
- logAgentUsage(`task ${task.id}`, ctx.backend, result.usage);
1825
+ reportRunUsage(path.dirname(ctx.tasksDir), "task", ctx.backend, ctx.claudeModel, ctx.openrouterModel, result, task.id);
1420
1826
  if (ctx.stopRequested.has(task.id))
1421
1827
  return result;
1422
1828
  if (!result.crashed)
1423
1829
  return result;
1830
+ // A crash normally means "the process died, try again" -- but a claude CLI
1831
+ // that dies on a bad key or slug BEFORE emitting its result event also
1832
+ // lands here (crashed = never saw a result), and retrying that burns all
1833
+ // three attempts, backoff included, on config that cannot change between
1834
+ // them. Deterministic failures get exactly one attempt.
1835
+ if (result.failure?.kind === "config")
1836
+ return result;
1424
1837
  if (attempt < MAX_TASK_ATTEMPTS) {
1425
1838
  ctx.onRetry(attempt + 1);
1426
1839
  if (await waitBeforeTaskRetry(attempt, ctx.stopRequested, task.id))
@@ -1451,6 +1864,7 @@ function startTask(ctx, task) {
1451
1864
  backend: ctx.backend(),
1452
1865
  claudeModel: ctx.claudeModel(),
1453
1866
  openrouterModel: ctx.openrouterModel(),
1867
+ openrouterTuning: ctx.openrouterTuning(),
1454
1868
  stopRequested: ctx.stopRequested,
1455
1869
  quickReference: ctx.quickReference,
1456
1870
  playtest: ctx.playtest,
@@ -1509,6 +1923,19 @@ function startTask(ctx, task) {
1509
1923
  : result.ok
1510
1924
  ? result.finalText.slice(-RESULT_SUMMARY_CHARS)
1511
1925
  : `${result.error ?? "failed"}\n${result.finalText.slice(-RESULT_SUMMARY_CHARS)}`;
1926
+ if (!wasStopped && !result.ok) {
1927
+ const failure = resolveFailure(result);
1928
+ // spawnedTasks/willRetry are router-turn concepts; a task card has
1929
+ // neither. Same copy table either way -- see failureCopy.
1930
+ task.errorCopy = failureCopy({
1931
+ failure,
1932
+ spawnedTasks: false,
1933
+ willRetry: false,
1934
+ });
1935
+ task.errorDetail = failure.detail || result.error;
1936
+ const label = failure.reason ? `${failure.kind}/${failure.reason}` : failure.kind;
1937
+ console.error(`[task ${task.id}] failed (${label}): ${failure.verbose ?? task.errorDetail}`);
1938
+ }
1512
1939
  ctx.touch(task);
1513
1940
  }
1514
1941
  catch (err) {
@@ -1646,6 +2073,7 @@ function createTaskStore(opts) {
1646
2073
  backend: opts.backend,
1647
2074
  claudeModel: opts.claudeModel,
1648
2075
  openrouterModel: opts.openrouterModel,
2076
+ openrouterTuning: opts.openrouterTuning,
1649
2077
  playtest: opts.playtest,
1650
2078
  restart: opts.restart,
1651
2079
  onStarted: opts.onStarted,
@@ -1861,6 +2289,8 @@ function asClientTask(task) {
1861
2289
  phase: task.phase,
1862
2290
  acknowledged: task.acknowledged,
1863
2291
  rejected: task.rejected,
2292
+ errorCopy: task.errorCopy,
2293
+ errorDetail: task.errorDetail,
1864
2294
  playtestFrames: (task.playtestFrames ?? []).map((rel) => `${AGENT_PLAYTEST_PREFIX}${task.id}/${path.basename(rel)}`),
1865
2295
  };
1866
2296
  }
@@ -1983,34 +2413,27 @@ function makePlaytestFrameHandler(tasksDir) {
1983
2413
  return true;
1984
2414
  };
1985
2415
  }
1986
- // Classify from the error strings runAgentCli actually produces (see its
1987
- // child.on("error"), timeout, and close handlers).
1988
- function classifyRouterFailure(error) {
1989
- if (error?.startsWith("could not run"))
1990
- return "spawn";
1991
- if (error === "agent run timed out")
1992
- return "timeout";
1993
- return "exit";
1994
- }
1995
- // Plain-language copy for a failed turn. `salvaged` = the turn already
1996
- // produced something the user can see (streamed text and/or spawned tasks),
1997
- // so "pick it back up" framing fits; a turn that died with nothing is a clean
1998
- // hiccup. `willRetry` = the queue is about to re-run this instruction itself.
1999
- function routerFailureCopy(opts) {
2000
- if (opts.willRetry) {
2001
- return "Something went wrong on my end -- give me a moment to try that again.";
2002
- }
2003
- const tasksNote = opts.spawnedTasks
2004
- ? " The steps I already kicked off are still running."
2005
- : "";
2006
- switch (opts.kind) {
2007
- case "spawn":
2008
- return `I couldn't start working on that -- something in this setup isn't right. If this keeps happening, the person running this session needs to take a look.${tasksNote}`;
2009
- case "timeout":
2010
- return `That took me too long and I had to stop partway. Send another message and I'll pick it back up.${tasksNote}`;
2011
- case "exit":
2012
- return `Something went wrong on my end partway through. Send another message and I'll pick it back up.${tasksNote}`;
2013
- }
2416
+ // -- router failure handling ---------------------------------------------------
2417
+ // A failed router turn settles gracefully: the chat gets short plain-language
2418
+ // copy (classified by failure kind), and the raw CLI error rides along as
2419
+ // `errorDetail` for the client to reveal on demand -- never spliced into the
2420
+ // message text, where it would both read as machinery at the wrong register
2421
+ // and replay into every later turn's transcript.
2422
+ // The structured failure a run reported, or one reconstructed from the error
2423
+ // string for the paths that don't carry one (timeouts, interrupts, anything a
2424
+ // classifier miss left unlabelled). Reconstruction keeps the pre-existing
2425
+ // buckets exactly as they were -- an unclassified failure must behave the way
2426
+ // it did before agent-failures.ts existed.
2427
+ function resolveFailure(result) {
2428
+ if (result.failure)
2429
+ return result.failure;
2430
+ const error = result.error;
2431
+ const kind = error?.startsWith("could not run")
2432
+ ? "spawn"
2433
+ : error === "agent run timed out"
2434
+ ? "timeout"
2435
+ : "exit";
2436
+ return { kind, detail: error ?? "unknown failure" };
2014
2437
  }
2015
2438
  // Assemble the full stateless prompt for one router turn: rules + deck
2016
2439
  // context + transcript replay (minus log lines and the in-flight reply) +
@@ -2161,6 +2584,7 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
2161
2584
  prompt,
2162
2585
  claudeModel: ctx.claudeModel(),
2163
2586
  openrouterModel: ctx.openrouterModel(),
2587
+ openrouterTuning: ctx.openrouterTuning(),
2164
2588
  attachments,
2165
2589
  cwd: ctx.deckDir,
2166
2590
  timeoutMs: ROUTER_TIMEOUT_MS,
@@ -2193,10 +2617,12 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
2193
2617
  },
2194
2618
  })
2195
2619
  .then((result) => {
2196
- logAgentUsage("router", backend, result.usage);
2620
+ reportRunUsage(ctx.agentDir, "router", backend, ctx.claudeModel(), ctx.openrouterModel(), result);
2197
2621
  // Signals the finally -> onSettled(retryable): the turn failed cleanly
2198
2622
  // enough (transient, nothing salvaged) that the queue may re-run it.
2199
2623
  let retryable = false;
2624
+ // Full provider text for the browser console, when a failure carried one.
2625
+ let errorVerbose;
2200
2626
  // The settle path must ALWAYS reach ctx.onSettled() (clears
2201
2627
  // routerRunning + flushes pendingSends). A throw here on Node v25 would
2202
2628
  // otherwise both freeze the composer and crash the serve, so the whole
@@ -2234,24 +2660,35 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
2234
2660
  message.status = "done";
2235
2661
  }
2236
2662
  else {
2237
- const kind = classifyRouterFailure(result.error);
2663
+ const failure = resolveFailure(result);
2238
2664
  // Only a turn that produced NOTHING visible is safe to silently
2239
2665
  // re-run: with streamed text or spawned tasks in play, a retry
2240
2666
  // would answer the same instruction twice (and could re-spawn
2241
2667
  // near-duplicate tasks past the title dedup). Spawn failures are
2242
2668
  // persistent (the CLI itself won't launch) and timeouts are too
2243
2669
  // expensive to repeat blind, so only "exit" crashes retry.
2670
+ //
2671
+ // "config" and "no-work" are deterministic -- the same slug, key, or
2672
+ // model would fail again identically, so a retry is pure latency in
2673
+ // front of the same message. "transient" is excluded too, but for a
2674
+ // different reason: it has ALREADY been retried at the transport
2675
+ // layer (3 backed-off connects), and a turn-level retry on top just
2676
+ // doubles the wait with the composer frozen.
2244
2677
  const salvaged = cleaned !== "" || taskIds.length > 0;
2245
- retryable = kind === "exit" && !salvaged;
2246
- const copy = routerFailureCopy({
2247
- kind,
2678
+ retryable = failure.kind === "exit" && !salvaged;
2679
+ const copy = failureCopy({
2680
+ failure,
2248
2681
  spawnedTasks: taskIds.length > 0,
2249
2682
  willRetry: retryable && ctx.canAutoRetry(),
2250
2683
  });
2251
2684
  message.text = cleaned ? `${cleaned}\n\n${copy}` : copy;
2252
2685
  message.status = "error";
2253
- message.errorDetail = result.error ?? "unknown failure";
2254
- console.error(`[router] turn failed (${kind}): ${message.errorDetail}`);
2686
+ message.errorDetail = failure.detail || result.error || "unknown failure";
2687
+ errorVerbose = failure.verbose;
2688
+ const label = failure.reason
2689
+ ? `${failure.kind}/${failure.reason}`
2690
+ : failure.kind;
2691
+ console.error(`[router] turn failed (${label}): ${failure.verbose ?? message.errorDetail}`);
2255
2692
  }
2256
2693
  if (taskIds.length > 0)
2257
2694
  message.taskIds = taskIds;
@@ -2263,6 +2700,12 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
2263
2700
  status: message.status,
2264
2701
  taskIds: message.taskIds ?? [],
2265
2702
  errorDetail: message.errorDetail,
2703
+ // Broadcast-only, never persisted: the client console.errors this so
2704
+ // the whole provider response is inspectable without leaving the
2705
+ // browser. Keeping it out of MessageRecord is the same call
2706
+ // errorDetail already makes -- a multi-KB dump has no business in
2707
+ // messages.json or a replayed transcript.
2708
+ errorVerbose,
2266
2709
  });
2267
2710
  }
2268
2711
  catch (err) {
@@ -2323,20 +2766,97 @@ function applyAgentSettings(incoming, ctx) {
2323
2766
  const value = normalizeClaudeModel(incoming[key]);
2324
2767
  if (value && value !== settings[key]) {
2325
2768
  settings[key] = value;
2326
- changes.push(`${key === "routerClaudeModel" ? "conductor" : "tasks"} claude model -> ${value}`);
2769
+ changes.push(`${key === "routerClaudeModel" ? "operator" : "tasks"} claude model -> ${value}`);
2327
2770
  }
2328
2771
  }
2329
2772
  for (const key of ["routerOpenrouterModel", "tasksOpenrouterModel"]) {
2330
2773
  const value = normalizeOpenrouterModel(incoming[key]);
2331
2774
  if (value && value !== settings[key]) {
2332
2775
  settings[key] = value;
2333
- changes.push(`${key === "routerOpenrouterModel" ? "conductor" : "tasks"} openrouter model -> ${value}`);
2776
+ changes.push(`${key === "routerOpenrouterModel" ? "operator" : "tasks"} openrouter model -> ${value}`);
2777
+ }
2778
+ }
2779
+ for (const key of ["routerReasoningEffort", "tasksReasoningEffort"]) {
2780
+ const value = normalizeReasoningEffort(incoming[key]);
2781
+ if (value && value !== settings[key]) {
2782
+ settings[key] = value;
2783
+ changes.push(`${key === "routerReasoningEffort" ? "operator" : "tasks"} reasoning effort -> ${value}`);
2784
+ }
2785
+ }
2786
+ for (const key of ["routerRouting", "tasksRouting"]) {
2787
+ const value = normalizeRoutingMode(incoming[key]);
2788
+ if (value && value !== settings[key]) {
2789
+ settings[key] = value;
2790
+ changes.push(`${key === "routerRouting" ? "operator" : "tasks"} routing -> ${value}`);
2791
+ }
2792
+ }
2793
+ for (const key of ["routerProviderTier", "tasksProviderTier"]) {
2794
+ // "" is a valid value (auto), so check for null (invalid) explicitly
2795
+ // rather than truthiness -- otherwise the tier could never be cleared.
2796
+ const value = normalizeProviderTier(incoming[key]);
2797
+ if (value !== null && value !== settings[key]) {
2798
+ settings[key] = value;
2799
+ changes.push(`${key === "routerProviderTier" ? "operator" : "tasks"} provider tier -> ${value || "auto"}`);
2334
2800
  }
2335
2801
  }
2336
2802
  if (changes.length === 0)
2337
2803
  return;
2338
2804
  fs.writeFileSync(ctx.settingsPath, JSON.stringify(settings, null, 2) + "\n");
2805
+ // The value is saved and broadcast IMMEDIATELY -- validation never gates a
2806
+ // write. The verdict follows in a second frame once the catalog answers.
2339
2807
  ctx.broadcast({ type: "settings", settings });
2808
+ void broadcastSettingsWarnings(ctx);
2809
+ }
2810
+ // Which slugs are worth a verdict: only roles actually routed at OpenRouter
2811
+ // (otherwise we'd warn about an inert leftover value), and only when a slug is
2812
+ // set at all.
2813
+ function slugKeysToValidate(settings) {
2814
+ const keys = [];
2815
+ if (roleUsesOpenrouter(settings.router ?? "claude", settings.routerClaudeModel ?? "opus") &&
2816
+ settings.routerOpenrouterModel) {
2817
+ keys.push("routerOpenrouterModel");
2818
+ }
2819
+ if (roleUsesOpenrouter(settings.tasks ?? "claude", settings.tasksClaudeModel ?? "sonnet") &&
2820
+ settings.tasksOpenrouterModel) {
2821
+ keys.push("tasksOpenrouterModel");
2822
+ }
2823
+ return keys;
2824
+ }
2825
+ // Advisory verdicts for the settings popover. Every warning carries the slug it
2826
+ // was computed FOR: this is async, so by the time it lands the user may have
2827
+ // typed something else, and the client drops a verdict whose model no longer
2828
+ // matches (see warningFor). An unreachable catalog yields no warnings at all --
2829
+ // silence, never a guess.
2830
+ export async function computeSettingsWarnings(settings) {
2831
+ const out = {};
2832
+ await Promise.all(slugKeysToValidate(settings).map(async (key) => {
2833
+ const model = settings[key];
2834
+ if (!model)
2835
+ return;
2836
+ const check = await checkOpenrouterModel(model);
2837
+ if (check.status === "unknown-model") {
2838
+ // suggestion is carried SEPARATELY (not baked into message) so the
2839
+ // client can render it as a one-click fix rather than plain text.
2840
+ out[key] = {
2841
+ model,
2842
+ status: "unknown-model",
2843
+ message: "No such model on OpenRouter.",
2844
+ suggestion: check.suggestions[0],
2845
+ };
2846
+ }
2847
+ else if (check.status === "no-tools") {
2848
+ out[key] = {
2849
+ model,
2850
+ status: "no-tools",
2851
+ message: "This model can't use tools, so it can't do work. Pick another.",
2852
+ };
2853
+ }
2854
+ }));
2855
+ return out;
2856
+ }
2857
+ async function broadcastSettingsWarnings(ctx) {
2858
+ const settingsWarnings = await computeSettingsWarnings(ctx.settings);
2859
+ ctx.broadcast({ type: "settings", settings: ctx.settings, settingsWarnings });
2340
2860
  }
2341
2861
  function killOrphanAgents(registryPath) {
2342
2862
  const recorded = readJsonFile(registryPath) ?? [];
@@ -2495,6 +3015,11 @@ function startRouterTurn(ctx, instruction, attachments = []) {
2495
3015
  backend: () => ctx.settings.router,
2496
3016
  claudeModel: () => ctx.settings.routerClaudeModel,
2497
3017
  openrouterModel: () => ctx.settings.routerOpenrouterModel,
3018
+ openrouterTuning: () => ({
3019
+ reasoningEffort: ctx.settings.routerReasoningEffort,
3020
+ routing: ctx.settings.routerRouting,
3021
+ providerTier: ctx.settings.routerProviderTier,
3022
+ }),
2498
3023
  canAutoRetry: () => !ctx.state.autoRetryUsed && ctx.state.pendingSends.length === 0,
2499
3024
  onSettled: (retryable) => onRouterQueueSettled(ctx, retryable),
2500
3025
  }, instruction, attachments);
@@ -2702,6 +3227,10 @@ export function createAgentServer(opts) {
2702
3227
  const attachmentsDir = path.join(agentDir, "attachments");
2703
3228
  const messagesPath = path.join(agentDir, "messages.json");
2704
3229
  fs.mkdirSync(tasksDir, { recursive: true });
3230
+ // Warm the OpenRouter catalog now so the first pre-flight and the first
3231
+ // popover open read a cache instead of paying for the fetch. Fire-and-forget
3232
+ // by design -- nothing here depends on it, and it falls open if it fails.
3233
+ primeOpenrouterCatalog();
2705
3234
  // ONE warm Chromium for the serve's entire lifetime, shared by every
2706
3235
  // playtest call (each call still gets its own fresh browser context +
2707
3236
  // page -- see playtest-executor.ts). Lazily launched on first use; never
@@ -2784,6 +3313,20 @@ export function createAgentServer(opts) {
2784
3313
  tasksOpenrouterModel: normalizeOpenrouterModel(storedSettings?.tasksOpenrouterModel) ??
2785
3314
  legacyOpenrouterModel ??
2786
3315
  DEFAULT_SETTINGS.tasksOpenrouterModel,
3316
+ routerReasoningEffort: normalizeReasoningEffort(storedSettings?.routerReasoningEffort) ??
3317
+ DEFAULT_SETTINGS.routerReasoningEffort,
3318
+ tasksReasoningEffort: normalizeReasoningEffort(storedSettings?.tasksReasoningEffort) ??
3319
+ DEFAULT_SETTINGS.tasksReasoningEffort,
3320
+ routerRouting: normalizeRoutingMode(storedSettings?.routerRouting) ??
3321
+ DEFAULT_SETTINGS.routerRouting,
3322
+ tasksRouting: normalizeRoutingMode(storedSettings?.tasksRouting) ??
3323
+ DEFAULT_SETTINGS.tasksRouting,
3324
+ // Provider tier: "" is valid (auto), so keep a normalized "" over the
3325
+ // default rather than treating it as absent.
3326
+ routerProviderTier: normalizeProviderTier(storedSettings?.routerProviderTier) ??
3327
+ DEFAULT_SETTINGS.routerProviderTier,
3328
+ tasksProviderTier: normalizeProviderTier(storedSettings?.tasksProviderTier) ??
3329
+ DEFAULT_SETTINGS.tasksProviderTier,
2787
3330
  };
2788
3331
  const applySettings = (incoming) => applyAgentSettings(incoming, { settings, settingsPath, broadcast });
2789
3332
  const taskFeeds = createTaskFeeds(broadcast);
@@ -2797,6 +3340,11 @@ export function createAgentServer(opts) {
2797
3340
  backend: () => settings.tasks,
2798
3341
  openrouterModel: () => settings.tasksOpenrouterModel,
2799
3342
  claudeModel: () => settings.tasksClaudeModel,
3343
+ openrouterTuning: () => ({
3344
+ reasoningEffort: settings.tasksReasoningEffort,
3345
+ routing: settings.tasksRouting,
3346
+ providerTier: settings.tasksProviderTier,
3347
+ }),
2800
3348
  playtest,
2801
3349
  restart: opts.restart,
2802
3350
  // Task lifecycle stays on the board only -- log lines for it were spam.
@@ -2853,6 +3401,13 @@ export function createAgentServer(opts) {
2853
3401
  queued: routerQueue.queuedSnippets(),
2854
3402
  };
2855
3403
  socket.send(JSON.stringify(hello));
3404
+ // Slug verdicts follow hello rather than riding it: they're async, and the
3405
+ // boot snapshot must never wait on a network call. This is what makes a
3406
+ // stored-but-bad slug warn the first time the popover opens -- without it,
3407
+ // applyAgentSettings only ever validates on an edit (it early-returns when
3408
+ // nothing changed), so a migrated or hand-edited slug would stay silent.
3409
+ // The catalog is primed at boot, so in practice this lands immediately.
3410
+ void broadcastSettingsWarnings({ settings, broadcast });
2856
3411
  socket.on("message", (rawData) => {
2857
3412
  let msg;
2858
3413
  try {
@@ -2896,6 +3451,8 @@ export function createAgentServer(opts) {
2896
3451
  const handleAttachment = makeAttachmentHandler(attachmentsDir);
2897
3452
  const handlePlaytestFrame = makePlaytestFrameHandler(tasksDir);
2898
3453
  function handleHttpRequest(req, res, reqPath) {
3454
+ if (reqPath === AGENT_MODEL_CAPS_PREFIX)
3455
+ return handleModelCaps(req, res);
2899
3456
  return handleAttachment(req, res, reqPath) || handlePlaytestFrame(req, res, reqPath);
2900
3457
  }
2901
3458
  function shutdown() {