castle-web-cli 0.4.83 → 0.4.85
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-failures.d.ts +17 -0
- package/dist/agent-failures.js +151 -0
- package/dist/agent.d.ts +26 -0
- package/dist/agent.js +317 -74
- package/dist/ide.js +35 -13
- package/dist/native/loop.js +35 -0
- package/dist/native/openrouter.d.ts +7 -0
- package/dist/native/openrouter.js +25 -1
- package/dist/native/types.d.ts +2 -0
- package/dist/native/types.js +0 -38
- package/dist/openrouter-catalog.d.ts +28 -0
- package/dist/openrouter-catalog.js +299 -0
- package/dist/shell/assets/{index-C9Zhmien.js → index-BJLaUTJE.js} +60 -58
- package/dist/shell/assets/{index-BMkQt27u.css → index-DonnH--m.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/kits/physics-2d/.prettierrc +8 -0
- package/kits/physics-2d/CLAUDE.md +329 -0
- package/kits/physics-2d/behaviors/Camera.jsx +43 -0
- package/kits/physics-2d/behaviors/Collider.jsx +199 -0
- package/kits/physics-2d/behaviors/Goal.jsx +29 -0
- package/kits/physics-2d/behaviors/Layout.jsx +53 -0
- package/kits/physics-2d/behaviors/Sprite.jsx +352 -0
- package/kits/physics-2d/behaviors/tint.js +47 -0
- package/kits/physics-2d/blueprints/ball.scene +14 -0
- package/kits/physics-2d/blueprints/block.scene +12 -0
- package/kits/physics-2d/blueprints/cauldron.scene +18 -0
- package/kits/physics-2d/blueprints/crate.scene +14 -0
- package/kits/physics-2d/blueprints/goal.scene +12 -0
- package/kits/physics-2d/castle.json +13 -0
- package/kits/physics-2d/docs/pxart-format.md +377 -0
- package/kits/physics-2d/drawings/block.pxart +25 -0
- package/kits/physics-2d/drawings/cauldron.pxart +113 -0
- package/kits/physics-2d/editors/BlueprintLibrary.jsx +247 -0
- package/kits/physics-2d/editors/ErrorBoundary.jsx +59 -0
- package/kits/physics-2d/editors/PlayOnly.jsx +31 -0
- package/kits/physics-2d/editors/PxArtEditor.jsx +954 -0
- package/kits/physics-2d/editors/SceneEditor.jsx +1681 -0
- package/kits/physics-2d/editors/SelectionOverlay.jsx +909 -0
- package/kits/physics-2d/editors/SingleEditor.jsx +122 -0
- package/kits/physics-2d/editors/behaviorRegistry.js +30 -0
- package/kits/physics-2d/editors/editorHistory.js +157 -0
- package/kits/physics-2d/editors/inspectorSheet.js +13 -0
- package/kits/physics-2d/editors/pixelCanvas.js +11 -0
- package/kits/physics-2d/editors/pixelEditorChrome.jsx +74 -0
- package/kits/physics-2d/editors/pixelGeometry.js +140 -0
- package/kits/physics-2d/editors/pixelInspector.jsx +633 -0
- package/kits/physics-2d/editors/pxArtEditorModel.js +732 -0
- package/kits/physics-2d/editors/pxArtPlayback.js +92 -0
- package/kits/physics-2d/editors/pxArtTimeline.jsx +752 -0
- package/kits/physics-2d/editors/pxArtTimeline.module.css +506 -0
- package/kits/physics-2d/editors/pxArtTools.js +232 -0
- package/kits/physics-2d/editors/useArtboardFit.js +102 -0
- package/kits/physics-2d/engine/ScenePlayer.jsx +196 -0
- package/kits/physics-2d/engine/SceneUI.jsx +59 -0
- package/kits/physics-2d/engine/assets.js +15 -0
- package/kits/physics-2d/engine/autoInspector.jsx +70 -0
- package/kits/physics-2d/engine/blueprint.js +521 -0
- package/kits/physics-2d/engine/collider.js +196 -0
- package/kits/physics-2d/engine/files.js +117 -0
- package/kits/physics-2d/engine/liveReload.js +88 -0
- package/kits/physics-2d/engine/pxart.js +1032 -0
- package/kits/physics-2d/engine/pxartSmooth.js +222 -0
- package/kits/physics-2d/engine/scene.js +686 -0
- package/kits/physics-2d/engine/spriteGeometry.js +32 -0
- package/kits/physics-2d/engine/ui.jsx +688 -0
- package/kits/physics-2d/engine/ui.module.css +2287 -0
- package/kits/physics-2d/eslint.config.js +71 -0
- package/kits/physics-2d/index.html +24 -0
- package/kits/physics-2d/main.jsx +24 -0
- package/kits/physics-2d/package-lock.json +2706 -0
- package/kits/physics-2d/package.json +42 -0
- package/kits/physics-2d/physics/PhysicsSystem.js +290 -0
- package/kits/physics-2d/physics/behaviors/AnalogStick.jsx +101 -0
- package/kits/physics-2d/physics/behaviors/Draggable.jsx +79 -0
- package/kits/physics-2d/physics/behaviors/RigidBody.jsx +55 -0
- package/kits/physics-2d/physics/behaviors/Slingshot.jsx +118 -0
- package/kits/physics-2d/physics/controls.js +79 -0
- package/kits/physics-2d/physics/index.js +26 -0
- package/kits/physics-2d/physics/matterBridge.js +126 -0
- package/kits/physics-2d/pnpm-lock.yaml +1761 -0
- package/kits/physics-2d/scenes/main.scene +12 -0
- package/kits/physics-2d/scenes/sandbox.scene +13 -0
- package/kits/physics-2d/scripts/draw.mjs +121 -0
- package/kits/physics-2d/vite.config.js +1 -0
- package/package.json +1 -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";
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
1309
|
-
|
|
1310
|
-
|
|
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
|
|
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
|
-
//
|
|
2251
|
-
//
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
//
|
|
2260
|
-
//
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
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
|
|
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
|
|
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 =
|
|
2512
|
-
|
|
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
|
|
2519
|
-
|
|
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 {
|