castle-web-cli 0.4.81 → 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.
Files changed (40) hide show
  1. package/dist/agent-prompts.d.ts +0 -3
  2. package/dist/agent-prompts.js +3 -8
  3. package/dist/agent.d.ts +1 -2
  4. package/dist/agent.js +327 -157
  5. package/dist/castle-host/host.js +28 -0
  6. package/dist/ide.js +150 -1
  7. package/dist/init.js +1 -1
  8. package/dist/native/loop.js +15 -29
  9. package/dist/native/openrouter.d.ts +5 -1
  10. package/dist/native/openrouter.js +20 -1
  11. package/dist/native/tools.d.ts +0 -1
  12. package/dist/native/tools.js +3 -79
  13. package/dist/native/types.d.ts +4 -1
  14. package/dist/native/types.js +3 -3
  15. package/dist/shell/assets/index-BMkQt27u.css +1 -0
  16. package/dist/shell/assets/index-C9Zhmien.js +142 -0
  17. package/dist/shell/index.html +2 -2
  18. package/dist/shell/operator.png +0 -0
  19. package/kits/basic-2d/CLAUDE.md +27 -22
  20. package/kits/basic-2d/behaviors/Collider.jsx +24 -30
  21. package/kits/basic-2d/behaviors/Layout.jsx +9 -6
  22. package/kits/basic-2d/behaviors/Sprite.jsx +137 -7
  23. package/kits/basic-2d/blueprints/cauldron.scene +3 -5
  24. package/kits/basic-2d/editors/BlueprintLibrary.jsx +11 -11
  25. package/kits/basic-2d/editors/SceneEditor.jsx +212 -50
  26. package/kits/basic-2d/editors/SelectionOverlay.jsx +73 -54
  27. package/kits/basic-2d/editors/inspectorSheet.js +5 -1
  28. package/kits/basic-2d/engine/ScenePlayer.jsx +102 -7
  29. package/kits/basic-2d/engine/autoInspector.jsx +26 -7
  30. package/kits/basic-2d/engine/blueprint.js +109 -11
  31. package/kits/basic-2d/engine/collider.js +146 -0
  32. package/kits/basic-2d/engine/scene.js +53 -30
  33. package/kits/basic-2d/engine/spriteGeometry.js +32 -0
  34. package/kits/basic-2d/engine/ui.jsx +89 -30
  35. package/kits/basic-2d/engine/ui.module.css +157 -53
  36. package/kits/basic-2d/scenes/main.scene +3 -3
  37. package/package.json +2 -1
  38. package/dist/shell/assets/index-D3unT7do.js +0 -141
  39. package/dist/shell/assets/index-RZrw5gQ2.css +0 -1
  40. package/kits/basic-2d/pnpm-workspace.yaml +0 -3
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 conductor stays on opus.
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
- // True when the user has their OWN saved auth for this backend -- a /login, or
524
- // (for cursor, which reuses one auth.json) any saved creds. When so we do NOT
525
- // inject Castle's key, so their auth is used and billed to them.
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 fs.existsSync(path.join(home, ".config", "cursor", "auth.json"));
732
+ return cursorHasUserLogin(home);
533
733
  }
534
734
  return false;
535
735
  }
@@ -787,137 +987,12 @@ function readQuickReference(deckDir) {
787
987
  function readWelcomeMessage(deckDir) {
788
988
  return readClaudeSection(deckDir, "Welcome message");
789
989
  }
790
- const TOUCHED_FILE_LIMIT = 10;
791
- function collectStrings(value, out) {
792
- if (typeof value === "string") {
793
- out.push(value);
794
- }
795
- else if (Array.isArray(value)) {
796
- for (const item of value)
797
- collectStrings(item, out);
798
- }
799
- else if (value && typeof value === "object") {
800
- for (const item of Object.values(value)) {
801
- collectStrings(item, out);
802
- }
803
- }
804
- }
805
- function toolWritesFiles(name) {
806
- const kind = name.toLowerCase();
807
- return ["edit", "write", "notebookedit", "multiedit", "delete"].some((p) => kind.startsWith(p));
808
- }
809
- function toolRunsShell(name) {
810
- const kind = name.toLowerCase();
811
- return (kind.startsWith("bash") ||
812
- kind.startsWith("shell") ||
813
- kind.includes("terminal"));
814
- }
815
- function drawingPathForDrawArg(raw) {
816
- const name = raw.replace(/^['"]|['"]$/g, "").trim();
817
- if (!name || name.startsWith("-") || name.includes("\n"))
818
- return null;
819
- if (name.startsWith("drawings/")) {
820
- return name.endsWith(".pxart") ? name : `${name}.pxart`;
821
- }
822
- return `drawings/${name.endsWith(".pxart") ? name : `${name}.pxart`}`;
823
- }
824
- function shellTouchedCandidates(command) {
825
- const out = [];
826
- const redirectRe = /(?:^|[\s;|])(?:\d*)>>?\s*(?!&)(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))/g;
827
- for (const match of command.matchAll(redirectRe)) {
828
- const target = match[1] ?? match[2] ?? match[3];
829
- if (target)
830
- out.push(target);
831
- }
832
- const drawRe = /npm\s+run\s+draw\s+--\s+([^\s;&|]+)/g;
833
- for (const match of command.matchAll(drawRe)) {
834
- const drawing = drawingPathForDrawArg(match[1] ?? "");
835
- if (drawing)
836
- out.push(drawing);
837
- }
838
- return out;
839
- }
840
- // Guards every touched-file candidate (shell redirects AND tool file-path
841
- // args) against junk that isn't plausibly a path. Added because
842
- // shellTouchedCandidates' redirect regex treats any `>`-plus-token as a
843
- // write target, so a command merely CONTAINING `>=` (e.g. a numeric
844
- // comparison inside a quoted inline JS/awk script) false-matches as a
845
- // redirect to "=" (or "=5" with no space around the `>=`) -- neither looks
846
- // like a real file. A leading "-" is rejected too, mirroring
847
- // drawingPathForDrawArg's flag guard above.
848
- function looksLikeTouchedPath(raw) {
849
- return /[a-zA-Z0-9]/.test(raw) && raw[0] !== "-" && raw[0] !== "=";
850
- }
851
- function normalizeTouchedPath(cwd, raw) {
852
- if (!raw || raw.includes("\n") || !looksLikeTouchedPath(raw))
853
- return null;
854
- const abs = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
855
- const rel = path.relative(cwd, abs);
856
- if (!rel || rel.startsWith("..") || path.isAbsolute(rel))
857
- return null;
858
- const normalized = rel.split(path.sep).join("/");
859
- if (normalized.startsWith(".castle/") || PROGRESS_FILE_RE.test(normalized)) {
860
- return null;
861
- }
862
- return normalized;
863
- }
864
- function addTouchedFiles(files, cwd, toolName, input) {
865
- const candidates = [];
866
- if (toolWritesFiles(toolName)) {
867
- collectStrings([
868
- input.file_path,
869
- input.path,
870
- input.notebook_path,
871
- input.old_path,
872
- input.new_path,
873
- ], candidates);
874
- }
875
- else if (toolRunsShell(toolName) && typeof input.command === "string") {
876
- candidates.push(...shellTouchedCandidates(input.command));
877
- }
878
- else {
879
- return;
880
- }
881
- for (const candidate of candidates) {
882
- const normalized = normalizeTouchedPath(cwd, candidate);
883
- if (normalized)
884
- files.add(normalized);
885
- }
886
- }
887
- function cursorToolNameAndInput(ev) {
888
- const call = ev.tool_call;
889
- const key = call ? Object.keys(call).find((k) => k.endsWith("ToolCall")) : undefined;
890
- if (!call || !key)
891
- return null;
892
- const input = call[key];
893
- const args = input && typeof input === "object"
894
- ? input.args
895
- : undefined;
896
- return {
897
- name: key.slice(0, -"ToolCall".length),
898
- input: args && typeof args === "object"
899
- ? args
900
- : input && typeof input === "object"
901
- ? input
902
- : {},
903
- };
904
- }
905
- function touchedFileList(files) {
906
- const sorted = [...files].sort();
907
- if (sorted.length <= TOUCHED_FILE_LIMIT)
908
- return sorted;
909
- return [
910
- ...sorted.slice(0, TOUCHED_FILE_LIMIT),
911
- `+${sorted.length - TOUCHED_FILE_LIMIT} more`,
912
- ];
913
- }
914
990
  function createAgentStreamState() {
915
991
  return {
916
992
  accumulated: "",
917
993
  finalText: "",
918
994
  resultIsError: false,
919
995
  usage: undefined,
920
- filesTouched: new Set(),
921
996
  sawResult: false,
922
997
  segmentText: "",
923
998
  needsGap: false,
@@ -961,6 +1036,62 @@ function logAgentUsage(label, backend, usage) {
961
1036
  const output = formatTokenCount(usage.output_tokens);
962
1037
  console.error(`[agent usage] ${label} ${backend}: input=${input} cache_read=${read} cache_created=${created} output=${output}`);
963
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
+ }
964
1095
  // Build the per-run stdout event handler over a shared mutable parser state.
965
1096
  // Splitting the cursor + claude stream decoding out of runAgentCli keeps each
966
1097
  // within the max-lines budget; behavior is identical (same delta/activity/
@@ -1043,7 +1174,6 @@ function makeAgentEventHandler(opts, state) {
1043
1174
  catch {
1044
1175
  /* input JSON arrived partial -- fall back to a generic label */
1045
1176
  }
1046
- addTouchedFiles(state.filesTouched, opts.cwd, pending.name, input);
1047
1177
  const label = claudeToolFeedLabel(pending.name, input);
1048
1178
  if (label)
1049
1179
  opts.onActivity?.(label);
@@ -1078,9 +1208,6 @@ function makeAgentEventHandler(opts, state) {
1078
1208
  else if (ev.type === "tool_call") {
1079
1209
  state.segmentText = "";
1080
1210
  state.needsGap = true;
1081
- const tool = cursorToolNameAndInput(ev);
1082
- if (tool)
1083
- addTouchedFiles(state.filesTouched, opts.cwd, tool.name, tool.input);
1084
1211
  if (ev.subtype === "started")
1085
1212
  opts.onActivity?.(toolActivityLabel(ev));
1086
1213
  }
@@ -1140,7 +1267,6 @@ function runAgentCli(opts) {
1140
1267
  finalText: state.finalText || state.accumulated,
1141
1268
  error: "agent run timed out",
1142
1269
  usage: state.usage,
1143
- filesTouched: touchedFileList(state.filesTouched),
1144
1270
  });
1145
1271
  }, opts.timeoutMs);
1146
1272
  const handleEvent = makeAgentEventHandler(opts, state);
@@ -1178,7 +1304,6 @@ function runAgentCli(opts) {
1178
1304
  ok,
1179
1305
  finalText: state.finalText || state.accumulated,
1180
1306
  usage: state.usage,
1181
- filesTouched: touchedFileList(state.filesTouched),
1182
1307
  crashed: !state.sawResult,
1183
1308
  error: ok
1184
1309
  ? undefined
@@ -1189,9 +1314,9 @@ function runAgentCli(opts) {
1189
1314
  }
1190
1315
  // One smith (native castle agent) run, adapted to runAgentCli's contract so
1191
1316
  // every caller downstream of runAgentTurn is backend-agnostic:
1192
- // - NativeRunResult.text -> finalText; error/usage/filesTouched/crashed pass
1193
- // through by name. `ok` is derived as !error && !crashed -- there is no
1194
- // process exit code; those two fields are the whole story.
1317
+ // - NativeRunResult.text -> finalText; error/usage/crashed pass through by
1318
+ // name. `ok` is derived as !error && !crashed -- there is no process exit
1319
+ // code; those two fields are the whole story.
1195
1320
  // - Cancellation: one AbortController per run, registered in the same
1196
1321
  // `children` set the CLI runs use, via a handle whose kill() aborts it
1197
1322
  // (see AgentRunHandle). Interrupts (killRouterChildren), task halts
@@ -1225,6 +1350,10 @@ async function runAgentSmith(opts) {
1225
1350
  role: opts.role,
1226
1351
  model: opts.model,
1227
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,
1228
1357
  prompt: opts.prompt,
1229
1358
  systemReminder: opts.systemReminder,
1230
1359
  attachments: opts.attachments,
@@ -1242,7 +1371,6 @@ async function runAgentSmith(opts) {
1242
1371
  finalText: result.text,
1243
1372
  error: result.error,
1244
1373
  usage: result.usage,
1245
- filesTouched: result.filesTouched,
1246
1374
  playtestFrames: result.playtestFrames,
1247
1375
  crashed: result.crashed,
1248
1376
  };
@@ -1276,6 +1404,7 @@ function runAgentTurn(opts) {
1276
1404
  // appends it to its own system framing).
1277
1405
  systemReminder: opts.role === "task" ? CLAUDE_TASK_SYSTEM_REMINDER : undefined,
1278
1406
  attachments: opts.attachments,
1407
+ openrouterTuning: opts.openrouterTuning,
1279
1408
  timeoutMs: opts.timeoutMs,
1280
1409
  logPath: opts.logPath,
1281
1410
  playtest: opts.playtest,
@@ -1407,10 +1536,6 @@ function depsSummaryFor(tasks, task) {
1407
1536
  .filter((dep) => !!dep)
1408
1537
  .map((dep) => {
1409
1538
  const parts = [`- "${dep.title}" finished ${dep.status}`];
1410
- if (dep.files && dep.files.length > 0)
1411
- parts.push(` files it touched: ${dep.files.join(", ")}`);
1412
- if (dep.suspectNoChanges)
1413
- parts.push(" caution: it finished without touching any tracked files (bash side effects aren't tracked) -- verify its work actually landed before building on it");
1414
1539
  // The agent's own closing prose is the real handoff -- names it created,
1415
1540
  // what it wired, what it left undone. The notes file is player-facing
1416
1541
  // and deliberately stripped of that detail.
@@ -1534,6 +1659,7 @@ async function runTaskAgentIn(ctx, task) {
1534
1659
  prompt: taskPrompt,
1535
1660
  claudeModel: ctx.claudeModel,
1536
1661
  openrouterModel: ctx.openrouterModel,
1662
+ openrouterTuning: ctx.openrouterTuning,
1537
1663
  cwd: ctx.deckDir,
1538
1664
  timeoutMs: TASK_TIMEOUT_MS,
1539
1665
  logPath: path.join(dir, "log.jsonl"),
@@ -1552,7 +1678,7 @@ async function runTaskAgentIn(ctx, task) {
1552
1678
  ctx.onFeed(`[${activity}]`);
1553
1679
  },
1554
1680
  });
1555
- logAgentUsage(`task ${task.id}`, ctx.backend, result.usage);
1681
+ reportRunUsage(path.dirname(ctx.tasksDir), "task", ctx.backend, ctx.claudeModel, ctx.openrouterModel, result.usage, task.id);
1556
1682
  if (ctx.stopRequested.has(task.id))
1557
1683
  return result;
1558
1684
  if (!result.crashed)
@@ -1587,6 +1713,7 @@ function startTask(ctx, task) {
1587
1713
  backend: ctx.backend(),
1588
1714
  claudeModel: ctx.claudeModel(),
1589
1715
  openrouterModel: ctx.openrouterModel(),
1716
+ openrouterTuning: ctx.openrouterTuning(),
1590
1717
  stopRequested: ctx.stopRequested,
1591
1718
  quickReference: ctx.quickReference,
1592
1719
  playtest: ctx.playtest,
@@ -1598,7 +1725,7 @@ function startTask(ctx, task) {
1598
1725
  siblings: ctx
1599
1726
  .sorted()
1600
1727
  .filter((t) => t.id !== task.id && !(t.acknowledged && isTerminal(t.status)))
1601
- .map((t) => ({ title: t.title, status: t.status, files: t.files })),
1728
+ .map((t) => ({ title: t.title, status: t.status })),
1602
1729
  onFeed: (entry) => ctx.onFeed(task, entry),
1603
1730
  onRetry: (attempt) => ctx.onRetry(task, attempt),
1604
1731
  onSignal: (signal) => {
@@ -1638,12 +1765,7 @@ function startTask(ctx, task) {
1638
1765
  task.acknowledged = true;
1639
1766
  if (result.ok && !wasStopped)
1640
1767
  task.progress = 100;
1641
- task.files = result.filesTouched ?? [];
1642
1768
  task.playtestFrames = result.playtestFrames ?? [];
1643
- // Flag, don't fail: see the TaskRecord.suspectNoChanges comment.
1644
- if (task.status === "done" && task.files.length === 0) {
1645
- task.suspectNoChanges = true;
1646
- }
1647
1769
  task.finishedAt = nowIso();
1648
1770
  task.resultSummary = wasStopped
1649
1771
  ? "stopped by the router"
@@ -1787,6 +1909,7 @@ function createTaskStore(opts) {
1787
1909
  backend: opts.backend,
1788
1910
  claudeModel: opts.claudeModel,
1789
1911
  openrouterModel: opts.openrouterModel,
1912
+ openrouterTuning: opts.openrouterTuning,
1790
1913
  playtest: opts.playtest,
1791
1914
  restart: opts.restart,
1792
1915
  onStarted: opts.onStarted,
@@ -1983,10 +2106,8 @@ function asPromptTask(task) {
1983
2106
  status: task.rejected ? "rejected by user" : task.status,
1984
2107
  progress: task.progress,
1985
2108
  notes: task.notes,
1986
- files: task.files,
1987
2109
  error: task.status === "failed" ? firstErrorLine(task.resultSummary) : undefined,
1988
2110
  blockedBy: task.status === "blocked" ? task.blockedBy : undefined,
1989
- suspectNoChanges: task.suspectNoChanges,
1990
2111
  };
1991
2112
  }
1992
2113
  function asClientTask(task) {
@@ -2004,7 +2125,6 @@ function asClientTask(task) {
2004
2125
  phase: task.phase,
2005
2126
  acknowledged: task.acknowledged,
2006
2127
  rejected: task.rejected,
2007
- suspectNoChanges: task.suspectNoChanges,
2008
2128
  playtestFrames: (task.playtestFrames ?? []).map((rel) => `${AGENT_PLAYTEST_PREFIX}${task.id}/${path.basename(rel)}`),
2009
2129
  };
2010
2130
  }
@@ -2305,6 +2425,7 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
2305
2425
  prompt,
2306
2426
  claudeModel: ctx.claudeModel(),
2307
2427
  openrouterModel: ctx.openrouterModel(),
2428
+ openrouterTuning: ctx.openrouterTuning(),
2308
2429
  attachments,
2309
2430
  cwd: ctx.deckDir,
2310
2431
  timeoutMs: ROUTER_TIMEOUT_MS,
@@ -2337,7 +2458,7 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
2337
2458
  },
2338
2459
  })
2339
2460
  .then((result) => {
2340
- logAgentUsage("router", backend, result.usage);
2461
+ reportRunUsage(ctx.agentDir, "router", backend, ctx.claudeModel(), ctx.openrouterModel(), result.usage);
2341
2462
  // Signals the finally -> onSettled(retryable): the turn failed cleanly
2342
2463
  // enough (transient, nothing salvaged) that the queue may re-run it.
2343
2464
  let retryable = false;
@@ -2467,14 +2588,37 @@ function applyAgentSettings(incoming, ctx) {
2467
2588
  const value = normalizeClaudeModel(incoming[key]);
2468
2589
  if (value && value !== settings[key]) {
2469
2590
  settings[key] = value;
2470
- changes.push(`${key === "routerClaudeModel" ? "conductor" : "tasks"} claude model -> ${value}`);
2591
+ changes.push(`${key === "routerClaudeModel" ? "operator" : "tasks"} claude model -> ${value}`);
2471
2592
  }
2472
2593
  }
2473
2594
  for (const key of ["routerOpenrouterModel", "tasksOpenrouterModel"]) {
2474
2595
  const value = normalizeOpenrouterModel(incoming[key]);
2475
2596
  if (value && value !== settings[key]) {
2476
2597
  settings[key] = value;
2477
- changes.push(`${key === "routerOpenrouterModel" ? "conductor" : "tasks"} openrouter model -> ${value}`);
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"}`);
2478
2622
  }
2479
2623
  }
2480
2624
  if (changes.length === 0)
@@ -2639,6 +2783,11 @@ function startRouterTurn(ctx, instruction, attachments = []) {
2639
2783
  backend: () => ctx.settings.router,
2640
2784
  claudeModel: () => ctx.settings.routerClaudeModel,
2641
2785
  openrouterModel: () => ctx.settings.routerOpenrouterModel,
2786
+ openrouterTuning: () => ({
2787
+ reasoningEffort: ctx.settings.routerReasoningEffort,
2788
+ routing: ctx.settings.routerRouting,
2789
+ providerTier: ctx.settings.routerProviderTier,
2790
+ }),
2642
2791
  canAutoRetry: () => !ctx.state.autoRetryUsed && ctx.state.pendingSends.length === 0,
2643
2792
  onSettled: (retryable) => onRouterQueueSettled(ctx, retryable),
2644
2793
  }, instruction, attachments);
@@ -2928,6 +3077,20 @@ export function createAgentServer(opts) {
2928
3077
  tasksOpenrouterModel: normalizeOpenrouterModel(storedSettings?.tasksOpenrouterModel) ??
2929
3078
  legacyOpenrouterModel ??
2930
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,
2931
3094
  };
2932
3095
  const applySettings = (incoming) => applyAgentSettings(incoming, { settings, settingsPath, broadcast });
2933
3096
  const taskFeeds = createTaskFeeds(broadcast);
@@ -2941,6 +3104,11 @@ export function createAgentServer(opts) {
2941
3104
  backend: () => settings.tasks,
2942
3105
  openrouterModel: () => settings.tasksOpenrouterModel,
2943
3106
  claudeModel: () => settings.tasksClaudeModel,
3107
+ openrouterTuning: () => ({
3108
+ reasoningEffort: settings.tasksReasoningEffort,
3109
+ routing: settings.tasksRouting,
3110
+ providerTier: settings.tasksProviderTier,
3111
+ }),
2944
3112
  playtest,
2945
3113
  restart: opts.restart,
2946
3114
  // Task lifecycle stays on the board only -- log lines for it were spam.
@@ -3040,6 +3208,8 @@ export function createAgentServer(opts) {
3040
3208
  const handleAttachment = makeAttachmentHandler(attachmentsDir);
3041
3209
  const handlePlaytestFrame = makePlaytestFrameHandler(tasksDir);
3042
3210
  function handleHttpRequest(req, res, reqPath) {
3211
+ if (reqPath === AGENT_MODEL_CAPS_PREFIX)
3212
+ return handleModelCaps(req, res);
3043
3213
  return handleAttachment(req, res, reqPath) || handlePlaytestFrame(req, res, reqPath);
3044
3214
  }
3045
3215
  function shutdown() {