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