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.
- package/dist/agent-failures.d.ts +17 -0
- package/dist/agent-failures.js +151 -0
- package/dist/agent.d.ts +27 -0
- package/dist/agent.js +614 -57
- package/dist/ide.js +150 -1
- package/dist/native/loop.js +40 -1
- package/dist/native/openrouter.d.ts +12 -1
- package/dist/native/openrouter.js +45 -2
- package/dist/native/types.d.ts +6 -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-BOgm5T3W.js +144 -0
- package/dist/shell/assets/index-DonnH--m.css +1 -0
- package/dist/shell/index.html +2 -2
- package/dist/shell/operator.png +0 -0
- package/kits/basic-2d/CLAUDE.md +27 -23
- package/kits/basic-2d/behaviors/Collider.jsx +24 -30
- package/kits/basic-2d/behaviors/Layout.jsx +9 -6
- package/kits/basic-2d/behaviors/Sprite.jsx +137 -7
- package/kits/basic-2d/blueprints/cauldron.scene +3 -5
- package/kits/basic-2d/editors/BlueprintLibrary.jsx +11 -11
- package/kits/basic-2d/editors/SceneEditor.jsx +212 -50
- package/kits/basic-2d/editors/SelectionOverlay.jsx +73 -54
- package/kits/basic-2d/editors/inspectorSheet.js +5 -1
- package/kits/basic-2d/engine/ScenePlayer.jsx +98 -7
- package/kits/basic-2d/engine/autoInspector.jsx +26 -7
- package/kits/basic-2d/engine/blueprint.js +35 -8
- package/kits/basic-2d/engine/collider.js +146 -0
- package/kits/basic-2d/engine/scene.js +53 -30
- package/kits/basic-2d/engine/spriteGeometry.js +32 -0
- package/kits/basic-2d/engine/ui.jsx +89 -30
- package/kits/basic-2d/engine/ui.module.css +157 -53
- package/kits/basic-2d/scenes/main.scene +3 -3
- package/package.json +2 -1
- package/dist/shell/assets/index-ByhgiJoP.js +0 -141
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
1053
|
-
|
|
1054
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1987
|
-
//
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
//
|
|
1996
|
-
//
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
2247
|
-
|
|
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
|
|
2254
|
-
|
|
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" ? "
|
|
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" ? "
|
|
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() {
|