castle-web-cli 0.4.83 → 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.
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";
@@ -143,6 +145,17 @@ function normalizeProviderTier(value) {
143
145
  // ones). Using it directly means claude CLI's OWN stream-json + tool loop
144
146
  // talks to OpenRouter with zero translation layer -- no proxy needed.
145
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
+ ];
146
159
  // Env for a claude CLI spawn routed at OpenRouter (claudeModel "openrouter",
147
160
  // Path A). Two things make this deterministic regardless of the user's own
148
161
  // Anthropic auth (verified live against the real `claude` binary while
@@ -159,16 +172,29 @@ const OPENROUTER_BASE_URL = "https://openrouter.ai/api";
159
172
  // slug like "openai/gpt-5.1" 404s with a synthetic "model_not_found" and
160
173
  // NEVER reaches the network. Setting it makes the CLI accept any --model
161
174
  // string and actually send the request upstream.
162
- 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
+ }
163
192
  const env = { ...process.env };
164
- delete env.ANTHROPIC_API_KEY;
193
+ for (const name of ANTHROPIC_CREDENTIAL_ENV)
194
+ delete env[name];
165
195
  env.ANTHROPIC_BASE_URL = OPENROUTER_BASE_URL;
166
196
  env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
167
- const val = openrouterApiKey();
168
- if (val)
169
- env.ANTHROPIC_AUTH_TOKEN = val;
170
- else
171
- delete env.ANTHROPIC_AUTH_TOKEN;
197
+ env.ANTHROPIC_AUTH_TOKEN = apiKey;
172
198
  return env;
173
199
  }
174
200
  // The one OpenRouter credential, shared by BOTH OpenRouter paths: smith's
@@ -183,29 +209,9 @@ function openrouterApiKey() {
183
209
  const MODEL_CAPS_TTL_MS = 10 * 60_000;
184
210
  const OPENROUTER_API_BASE = "https://openrouter.ai/api/v1";
185
211
  const modelCapsCache = new Map();
186
- let modelsListCache = null;
187
212
  function asRecord(v) {
188
213
  return v && typeof v === "object" ? v : null;
189
214
  }
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
215
  async function openrouterProviderTiers(slug) {
210
216
  const res = await fetch(`${OPENROUTER_API_BASE}/models/${slug}/endpoints`);
211
217
  if (!res.ok)
@@ -232,10 +238,13 @@ async function fetchModelCaps(slug) {
232
238
  let reasoningEfforts = null;
233
239
  let defaultEffort = null;
234
240
  try {
235
- const model = asRecord((await openrouterModelsById()).get(slug));
236
- const reasoning = asRecord(model?.reasoning);
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);
237
246
  const efforts = reasoning?.supported_efforts;
238
- const supportedParams = model?.supported_parameters;
247
+ const supportedParams = entry?.supportedParameters;
239
248
  const acceptsEffort = Array.isArray(supportedParams) &&
240
249
  (supportedParams.includes("reasoning_effort") ||
241
250
  supportedParams.includes("reasoning"));
@@ -363,7 +372,9 @@ openrouterModel) {
363
372
  : []),
364
373
  prompt,
365
374
  ],
366
- env: viaOpenrouter ? envForOpenrouterSpawn() : envForAgentSpawn(backend),
375
+ env: viaOpenrouter
376
+ ? envForOpenrouterSpawn(openrouterApiKey())
377
+ : envForAgentSpawn(backend),
367
378
  };
368
379
  }
369
380
  return {
@@ -620,6 +631,15 @@ function baseName(p) {
620
631
  const parts = p.split(/[\\/]/).filter(Boolean);
621
632
  return parts[parts.length - 1] || p;
622
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
+ }
623
643
  // First string-typed value among loosely-typed tool inputs, or "" when none is
624
644
  // a string (avoids "[object Object]" from String()-ing an object/array value).
625
645
  function firstString(...vals) {
@@ -689,7 +709,13 @@ function toolActivityLabel(ev) {
689
709
  // (~/.castle/keys.json) rather than sandbox-wide env -- so an ambient key can't
690
710
  // override a user's own subscription login. Falls back to process.env for
691
711
  // older sandboxes that still inject the keys as env.
692
- 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");
693
719
  function castleKeys() {
694
720
  try {
695
721
  return JSON.parse(fs.readFileSync(CASTLE_KEYS_PATH, "utf8"));
@@ -723,6 +749,16 @@ function cursorHasUserLogin(home) {
723
749
  }
724
750
  // True when the user has their OWN saved auth for this backend -- a login that we
725
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.
726
762
  function backendHasSavedAuth(backend) {
727
763
  const home = os.homedir();
728
764
  if (backend === "claude") {
@@ -1062,7 +1098,8 @@ function resolveRunModel(backend, claudeModel, openrouterModel) {
1062
1098
  // stderr line's rounded values) and self-describing (id/role/backend/model) so
1063
1099
  // the harvester can attribute and de-dup. Best-effort: a metering write must
1064
1100
  // never fail an agent run.
1065
- function reportRunUsage(agentDir, role, backend, claudeModel, openrouterModel, usage, taskId) {
1101
+ function reportRunUsage(agentDir, role, backend, claudeModel, openrouterModel, result, taskId) {
1102
+ const usage = result.usage;
1066
1103
  logAgentUsage(taskId ? `task ${taskId}` : "router", backend, usage);
1067
1104
  if (!USAGE_LEDGER_ENABLED)
1068
1105
  return;
@@ -1079,6 +1116,10 @@ function reportRunUsage(agentDir, role, backend, claudeModel, openrouterModel, u
1079
1116
  backend,
1080
1117
  model: resolveRunModel(backend, claudeModel, openrouterModel),
1081
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 }),
1082
1123
  tokens_reported: usage !== undefined,
1083
1124
  input_tokens: usage?.input_tokens ?? 0,
1084
1125
  output_tokens: usage?.output_tokens ?? 0,
@@ -1295,19 +1336,47 @@ function runAgentCli(opts) {
1295
1336
  settle({
1296
1337
  ok: false,
1297
1338
  finalText: state.accumulated,
1298
- 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}` },
1299
1344
  });
1300
1345
  });
1301
1346
  child.on("close", (code) => {
1302
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)}` : ""}`;
1303
1367
  settle({
1304
1368
  ok,
1305
1369
  finalText: state.finalText || state.accumulated,
1306
1370
  usage: state.usage,
1307
1371
  crashed: !state.sawResult,
1308
- error: ok
1309
- ? undefined
1310
- : `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,
1311
1380
  });
1312
1381
  });
1313
1382
  });
@@ -1370,6 +1439,7 @@ async function runAgentSmith(opts) {
1370
1439
  ok: !result.error && !result.crashed,
1371
1440
  finalText: result.text,
1372
1441
  error: result.error,
1442
+ failure: result.failure,
1373
1443
  usage: result.usage,
1374
1444
  playtestFrames: result.playtestFrames,
1375
1445
  crashed: result.crashed,
@@ -1389,11 +1459,82 @@ async function runAgentSmith(opts) {
1389
1459
  opts.children.delete(handle);
1390
1460
  }
1391
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
+ }
1392
1517
  // The one backend dispatch point for running an agent turn: smith runs
1393
1518
  // in-process (runAgentSmith -> runAgentNative); cursor/claude spawn a CLI
1394
1519
  // (buildAgentInvocation -> runAgentCli). Everything downstream consumes the
1395
1520
  // same CliRunResult contract either way.
1396
- 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
+ }
1397
1538
  if (opts.backend === "smith") {
1398
1539
  return runAgentSmith({
1399
1540
  cwd: opts.cwd,
@@ -1431,6 +1572,9 @@ function runAgentTurn(opts) {
1431
1572
  onThinking: opts.onThinking,
1432
1573
  onSpawn: opts.onSpawn,
1433
1574
  labelUnknownTools: opts.labelUnknownTools,
1575
+ openrouterModel: roleUsesOpenrouter(opts.backend, opts.claudeModel)
1576
+ ? opts.openrouterModel
1577
+ : undefined,
1434
1578
  });
1435
1579
  }
1436
1580
  // -- task store ---------------------------------------------------------------
@@ -1678,11 +1822,18 @@ async function runTaskAgentIn(ctx, task) {
1678
1822
  ctx.onFeed(`[${activity}]`);
1679
1823
  },
1680
1824
  });
1681
- reportRunUsage(path.dirname(ctx.tasksDir), "task", ctx.backend, ctx.claudeModel, ctx.openrouterModel, result.usage, task.id);
1825
+ reportRunUsage(path.dirname(ctx.tasksDir), "task", ctx.backend, ctx.claudeModel, ctx.openrouterModel, result, task.id);
1682
1826
  if (ctx.stopRequested.has(task.id))
1683
1827
  return result;
1684
1828
  if (!result.crashed)
1685
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;
1686
1837
  if (attempt < MAX_TASK_ATTEMPTS) {
1687
1838
  ctx.onRetry(attempt + 1);
1688
1839
  if (await waitBeforeTaskRetry(attempt, ctx.stopRequested, task.id))
@@ -1772,6 +1923,19 @@ function startTask(ctx, task) {
1772
1923
  : result.ok
1773
1924
  ? result.finalText.slice(-RESULT_SUMMARY_CHARS)
1774
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
+ }
1775
1939
  ctx.touch(task);
1776
1940
  }
1777
1941
  catch (err) {
@@ -2125,6 +2289,8 @@ function asClientTask(task) {
2125
2289
  phase: task.phase,
2126
2290
  acknowledged: task.acknowledged,
2127
2291
  rejected: task.rejected,
2292
+ errorCopy: task.errorCopy,
2293
+ errorDetail: task.errorDetail,
2128
2294
  playtestFrames: (task.playtestFrames ?? []).map((rel) => `${AGENT_PLAYTEST_PREFIX}${task.id}/${path.basename(rel)}`),
2129
2295
  };
2130
2296
  }
@@ -2247,34 +2413,27 @@ function makePlaytestFrameHandler(tasksDir) {
2247
2413
  return true;
2248
2414
  };
2249
2415
  }
2250
- // Classify from the error strings runAgentCli actually produces (see its
2251
- // child.on("error"), timeout, and close handlers).
2252
- function classifyRouterFailure(error) {
2253
- if (error?.startsWith("could not run"))
2254
- return "spawn";
2255
- if (error === "agent run timed out")
2256
- return "timeout";
2257
- return "exit";
2258
- }
2259
- // Plain-language copy for a failed turn. `salvaged` = the turn already
2260
- // produced something the user can see (streamed text and/or spawned tasks),
2261
- // so "pick it back up" framing fits; a turn that died with nothing is a clean
2262
- // hiccup. `willRetry` = the queue is about to re-run this instruction itself.
2263
- function routerFailureCopy(opts) {
2264
- if (opts.willRetry) {
2265
- return "Something went wrong on my end -- give me a moment to try that again.";
2266
- }
2267
- const tasksNote = opts.spawnedTasks
2268
- ? " The steps I already kicked off are still running."
2269
- : "";
2270
- switch (opts.kind) {
2271
- case "spawn":
2272
- 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}`;
2273
- case "timeout":
2274
- return `That took me too long and I had to stop partway. Send another message and I'll pick it back up.${tasksNote}`;
2275
- case "exit":
2276
- return `Something went wrong on my end partway through. Send another message and I'll pick it back up.${tasksNote}`;
2277
- }
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" };
2278
2437
  }
2279
2438
  // Assemble the full stateless prompt for one router turn: rules + deck
2280
2439
  // context + transcript replay (minus log lines and the in-flight reply) +
@@ -2458,10 +2617,12 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
2458
2617
  },
2459
2618
  })
2460
2619
  .then((result) => {
2461
- reportRunUsage(ctx.agentDir, "router", backend, ctx.claudeModel(), ctx.openrouterModel(), result.usage);
2620
+ reportRunUsage(ctx.agentDir, "router", backend, ctx.claudeModel(), ctx.openrouterModel(), result);
2462
2621
  // Signals the finally -> onSettled(retryable): the turn failed cleanly
2463
2622
  // enough (transient, nothing salvaged) that the queue may re-run it.
2464
2623
  let retryable = false;
2624
+ // Full provider text for the browser console, when a failure carried one.
2625
+ let errorVerbose;
2465
2626
  // The settle path must ALWAYS reach ctx.onSettled() (clears
2466
2627
  // routerRunning + flushes pendingSends). A throw here on Node v25 would
2467
2628
  // otherwise both freeze the composer and crash the serve, so the whole
@@ -2499,24 +2660,35 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
2499
2660
  message.status = "done";
2500
2661
  }
2501
2662
  else {
2502
- const kind = classifyRouterFailure(result.error);
2663
+ const failure = resolveFailure(result);
2503
2664
  // Only a turn that produced NOTHING visible is safe to silently
2504
2665
  // re-run: with streamed text or spawned tasks in play, a retry
2505
2666
  // would answer the same instruction twice (and could re-spawn
2506
2667
  // near-duplicate tasks past the title dedup). Spawn failures are
2507
2668
  // persistent (the CLI itself won't launch) and timeouts are too
2508
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.
2509
2677
  const salvaged = cleaned !== "" || taskIds.length > 0;
2510
- retryable = kind === "exit" && !salvaged;
2511
- const copy = routerFailureCopy({
2512
- kind,
2678
+ retryable = failure.kind === "exit" && !salvaged;
2679
+ const copy = failureCopy({
2680
+ failure,
2513
2681
  spawnedTasks: taskIds.length > 0,
2514
2682
  willRetry: retryable && ctx.canAutoRetry(),
2515
2683
  });
2516
2684
  message.text = cleaned ? `${cleaned}\n\n${copy}` : copy;
2517
2685
  message.status = "error";
2518
- message.errorDetail = result.error ?? "unknown failure";
2519
- 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}`);
2520
2692
  }
2521
2693
  if (taskIds.length > 0)
2522
2694
  message.taskIds = taskIds;
@@ -2528,6 +2700,12 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
2528
2700
  status: message.status,
2529
2701
  taskIds: message.taskIds ?? [],
2530
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,
2531
2709
  });
2532
2710
  }
2533
2711
  catch (err) {
@@ -2624,7 +2802,61 @@ function applyAgentSettings(incoming, ctx) {
2624
2802
  if (changes.length === 0)
2625
2803
  return;
2626
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.
2627
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 });
2628
2860
  }
2629
2861
  function killOrphanAgents(registryPath) {
2630
2862
  const recorded = readJsonFile(registryPath) ?? [];
@@ -2995,6 +3227,10 @@ export function createAgentServer(opts) {
2995
3227
  const attachmentsDir = path.join(agentDir, "attachments");
2996
3228
  const messagesPath = path.join(agentDir, "messages.json");
2997
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();
2998
3234
  // ONE warm Chromium for the serve's entire lifetime, shared by every
2999
3235
  // playtest call (each call still gets its own fresh browser context +
3000
3236
  // page -- see playtest-executor.ts). Lazily launched on first use; never
@@ -3165,6 +3401,13 @@ export function createAgentServer(opts) {
3165
3401
  queued: routerQueue.queuedSnippets(),
3166
3402
  };
3167
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 });
3168
3411
  socket.on("message", (rawData) => {
3169
3412
  let msg;
3170
3413
  try {
@@ -565,6 +565,10 @@ async function runLoop(opts, toolSchemas, log) {
565
565
  const imageLabels = new Map();
566
566
  let finalText = "";
567
567
  let totalUsage;
568
+ // Whether this run has called ANY tool, across every iteration -- not just
569
+ // the current one. Scoped to the run because the question is "did this task
570
+ // do anything at all", which one iteration can't answer.
571
+ let usedAnyTool = false;
568
572
  // Once our own timeout/external-abort fires, whatever streamChatCompletion
569
573
  // reports is moot -- the abort IS the reason for stopping, so we always
570
574
  // attribute the final error to it (rather than a network error that might
@@ -635,6 +639,9 @@ async function runLoop(opts, toolSchemas, log) {
635
639
  return {
636
640
  text: finalText,
637
641
  error: streamResult.error ?? "openrouter stream ended without a final response",
642
+ // Present only for an HTTP status streamChat could label; absent for
643
+ // a dropped connection, which stays unclassified on purpose.
644
+ failure: streamResult.failure,
638
645
  usage: totalUsage,
639
646
  playtestFrames: playtestFrameList(playtestFrames),
640
647
  crashed: streamResult.crashed,
@@ -657,12 +664,40 @@ async function runLoop(opts, toolSchemas, log) {
657
664
  : {}),
658
665
  });
659
666
  if (toolCalls.length === 0) {
667
+ // No tool calls is the normal way a run ends -- the model is done and
668
+ // signing off. But a TASK that reaches this on iteration 1, having
669
+ // never called a single tool, did no work: it chatted. That used to
670
+ // return a bare success, so the card finalized "done" at 100% with
671
+ // nothing changed -- the loudest silent failure this backend had.
672
+ //
673
+ // Only tasks: the router legitimately answers in plain text, and a
674
+ // task that used tools and THEN signs off with prose is fine.
675
+ //
676
+ // This narrows the failure class rather than closing it: a task whose
677
+ // only tool calls were reads still counts as "used a tool" here and
678
+ // can still finish having changed nothing. Detecting that needs a
679
+ // notion of which tools mutate, which is a bigger change than this.
680
+ if (opts.role === "task" && !usedAnyTool) {
681
+ return {
682
+ text: finalText,
683
+ error: "the model replied without calling any tools, so nothing was done",
684
+ failure: {
685
+ kind: "no-work",
686
+ detail: "model returned prose with no tool calls",
687
+ model: opts.model,
688
+ verbose: finalText,
689
+ },
690
+ usage: totalUsage,
691
+ playtestFrames: playtestFrameList(playtestFrames),
692
+ };
693
+ }
660
694
  return {
661
695
  text: finalText,
662
696
  usage: totalUsage,
663
697
  playtestFrames: playtestFrameList(playtestFrames),
664
698
  };
665
699
  }
700
+ usedAnyTool = true;
666
701
  messages.push({
667
702
  role: "assistant",
668
703
  content: streamResult.message.content || null,