castle-web-cli 0.4.90 → 0.4.92

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/agent.js CHANGED
@@ -24,6 +24,7 @@ import { rawDataToString } from "./ide.js";
24
24
  import { buildRouterPrompt, buildTaskPrompt, userTurnInstruction, CLAUDE_TASK_SYSTEM_REMINDER, } from "./agent-prompts.js";
25
25
  import { checkOpenrouterKey, checkOpenrouterModel, openrouterCatalogEntry, primeOpenrouterCatalog, } from "./openrouter-catalog.js";
26
26
  import { classifyProviderError, failureCopy, } from "./agent-failures.js";
27
+ import { meteringHeaders, newAgentSessionId, withCustomHeaders, } from "./metering.js";
27
28
  import { runAgentNative } from "./native/loop.js";
28
29
  import { createPlaytestBrowserManager } from "./native/playtest-browser.js";
29
30
  import { createPlaywrightPlaytestExecutor } from "./native/playtest-executor.js";
@@ -343,7 +344,11 @@ const OPENROUTER_ALLOWED_TOOLS = "--allowedTools=Edit,Write,NotebookEdit,Bash";
343
344
  function buildAgentInvocation(backend, role, prompt, claudeModel,
344
345
  // Already resolved for this role by the caller (router turns pass
345
346
  // settings.routerOpenrouterModel, task spawns settings.tasksOpenrouterModel).
346
- openrouterModel) {
347
+ openrouterModel,
348
+ // This run's ledger identity, and the deck dir its castle.json is read from.
349
+ // Only the claude branch can carry it: cursor-agent runs on its own key and
350
+ // never traverses the llm-proxy.
351
+ metering) {
347
352
  if (backend === "claude") {
348
353
  const viaOpenrouter = claudeModel === "openrouter";
349
354
  return {
@@ -379,9 +384,13 @@ openrouterModel) {
379
384
  : []),
380
385
  prompt,
381
386
  ],
382
- env: viaOpenrouter
387
+ env: withCustomHeaders(viaOpenrouter
383
388
  ? envForOpenrouterSpawn(openrouterApiKey())
384
- : envForAgentSpawn(backend),
389
+ : envForAgentSpawn(backend), meteringHeaders({
390
+ deckDir: metering.deckDir,
391
+ sessionId: metering.sessionId,
392
+ route: viaOpenrouter ? "openrouter" : "anthropic",
393
+ })),
385
394
  };
386
395
  }
387
396
  return {
@@ -1456,6 +1465,7 @@ async function runAgentSmith(opts) {
1456
1465
  const result = await runAgentNative({
1457
1466
  cwd: opts.cwd,
1458
1467
  role: opts.role,
1468
+ extraHeaders: opts.extraHeaders,
1459
1469
  model: opts.model,
1460
1470
  apiKey: openrouterApiKey(),
1461
1471
  reasoningEffort: opts.openrouterTuning?.reasoningEffort,
@@ -1574,10 +1584,19 @@ async function runAgentTurn(opts) {
1574
1584
  crashed: false,
1575
1585
  };
1576
1586
  }
1587
+ // One id per call: a router turn, or a single task attempt (the retry loop
1588
+ // in runTaskAgentIn calls this once per attempt, and each attempt is its own
1589
+ // conversation -- nothing is resumed).
1590
+ const sessionId = newAgentSessionId(opts.role);
1577
1591
  if (opts.backend === "smith") {
1578
1592
  return runAgentSmith({
1579
1593
  cwd: opts.cwd,
1580
1594
  role: opts.role,
1595
+ extraHeaders: meteringHeaders({
1596
+ deckDir: opts.cwd,
1597
+ sessionId,
1598
+ route: "openrouter",
1599
+ }),
1581
1600
  model: opts.openrouterModel,
1582
1601
  prompt: opts.prompt,
1583
1602
  // Mirrors claude's --append-system-prompt for tasks (the native loop
@@ -1596,7 +1615,7 @@ async function runAgentTurn(opts) {
1596
1615
  onSpawn: opts.onSpawn,
1597
1616
  });
1598
1617
  }
1599
- const invocation = buildAgentInvocation(opts.backend, opts.role, opts.prompt, opts.claudeModel, opts.openrouterModel);
1618
+ const invocation = buildAgentInvocation(opts.backend, opts.role, opts.prompt, opts.claudeModel, opts.openrouterModel, { sessionId, deckDir: opts.cwd });
1600
1619
  return runAgentCli({
1601
1620
  cwd: opts.cwd,
1602
1621
  command: invocation.command,
@@ -0,0 +1,21 @@
1
+ export type MeteringRoute = "anthropic" | "openrouter";
2
+ /**
3
+ * A fresh id for one agent run -- one router turn (one user message) or one
4
+ * task attempt. Every provider call in that run's agentic loop carries it, so
5
+ * the ledger can price a single turn even though concurrent tasks interleave
6
+ * with chat in wall-clock time. The role prefix keeps "chat spend vs.
7
+ * background-task spend" a prefix match with no extra column.
8
+ */
9
+ export declare function newAgentSessionId(role: "router" | "task"): string;
10
+ export declare function meteringHeaders(opts: {
11
+ deckDir: string;
12
+ sessionId: string;
13
+ route: MeteringRoute;
14
+ }): Record<string, string>;
15
+ /**
16
+ * Merge metering headers into a claude spawn env. ANTHROPIC_CUSTOM_HEADERS is
17
+ * the claude CLI's newline-separated `Name: Value` list, forwarded on every
18
+ * request it makes to ANTHROPIC_BASE_URL. An inherited value is kept, first,
19
+ * rather than clobbered.
20
+ */
21
+ export declare function withCustomHeaders(env: NodeJS.ProcessEnv, headers: Record<string, string>): NodeJS.ProcessEnv;
@@ -0,0 +1,75 @@
1
+ // Per-request metering identity for agent runs inside a Castle sandbox.
2
+ //
3
+ // The per-host llm-proxy (castle-sandboxes/llm-proxy) terminates every
4
+ // provider call a sandbox makes, records a usage row, and strips these two
5
+ // headers before forwarding upstream -- so they never reach Anthropic or
6
+ // OpenRouter. Names must match LLM_SESSION_HEADER / LLM_DECK_HEADER in
7
+ // castle-sandboxes/shared/src/index.ts.
8
+ //
9
+ // The proxy already knows the user (its token is minted per sandbox) and the
10
+ // sandbox. These headers add the two dimensions only this process knows: which
11
+ // deck the work belongs to, and which agent run issued the call.
12
+ import * as fs from "fs";
13
+ import * as path from "path";
14
+ import { randomBytes } from "crypto";
15
+ const SESSION_HEADER = "x-castle-agent-session-id";
16
+ const DECK_HEADER = "x-castle-deck-id";
17
+ /**
18
+ * A fresh id for one agent run -- one router turn (one user message) or one
19
+ * task attempt. Every provider call in that run's agentic loop carries it, so
20
+ * the ledger can price a single turn even though concurrent tasks interleave
21
+ * with chat in wall-clock time. The role prefix keeps "chat spend vs.
22
+ * background-task spend" a prefix match with no extra column.
23
+ */
24
+ export function newAgentSessionId(role) {
25
+ return `${role === "router" ? "r" : "t"}-${randomBytes(12).toString("hex")}`;
26
+ }
27
+ // Outside a sandbox nothing injects a base URL, and the headers would be sent
28
+ // to the provider itself -- handing a deck id to a third party to no purpose.
29
+ function proxyInjected(route) {
30
+ return Boolean(route === "anthropic"
31
+ ? process.env.ANTHROPIC_BASE_URL
32
+ : process.env.OPENROUTER_BASE_URL);
33
+ }
34
+ // castle.json is written by the first save (see save-deck.ts), so an unsaved
35
+ // deck has no Castle id and its spend stays unattributed rather than being
36
+ // labelled with the sandbox-local directory name, which never becomes the
37
+ // deck id. Re-read per run: a deck saved mid-session starts attributing on its
38
+ // next turn.
39
+ function deckIdFor(deckDir) {
40
+ let parsed;
41
+ try {
42
+ parsed = JSON.parse(fs.readFileSync(path.join(deckDir, "castle.json"), "utf8"));
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ if (typeof parsed.deckId !== "string" || !parsed.deckId)
48
+ return null;
49
+ return parsed.deckId.replace(/[\r\n]/g, "") || null;
50
+ }
51
+ export function meteringHeaders(opts) {
52
+ if (!proxyInjected(opts.route))
53
+ return {};
54
+ const deckId = deckIdFor(opts.deckDir);
55
+ return {
56
+ [SESSION_HEADER]: opts.sessionId,
57
+ ...(deckId ? { [DECK_HEADER]: deckId } : {}),
58
+ };
59
+ }
60
+ /**
61
+ * Merge metering headers into a claude spawn env. ANTHROPIC_CUSTOM_HEADERS is
62
+ * the claude CLI's newline-separated `Name: Value` list, forwarded on every
63
+ * request it makes to ANTHROPIC_BASE_URL. An inherited value is kept, first,
64
+ * rather than clobbered.
65
+ */
66
+ export function withCustomHeaders(env, headers) {
67
+ const lines = Object.entries(headers).map(([name, value]) => `${name}: ${value}`);
68
+ if (lines.length === 0)
69
+ return env;
70
+ const inherited = env.ANTHROPIC_CUSTOM_HEADERS;
71
+ return {
72
+ ...env,
73
+ ANTHROPIC_CUSTOM_HEADERS: [...(inherited ? [inherited] : []), ...lines].join("\n"),
74
+ };
75
+ }
@@ -607,6 +607,7 @@ async function runLoop(opts, toolSchemas, log) {
607
607
  reasoningEffort: opts.reasoningEffort ?? REASONING_EFFORT[opts.role],
608
608
  routing: opts.routing,
609
609
  providerTier: opts.providerTier,
610
+ extraHeaders: opts.extraHeaders,
610
611
  maxTokens: MAX_COMPLETION_TOKENS,
611
612
  signal: controller.signal,
612
613
  onDelta: opts.onDelta,
@@ -39,6 +39,7 @@ export interface StreamChatOpts {
39
39
  reasoningEffort?: ORReasoningEffort;
40
40
  routing?: ORRoutingMode;
41
41
  providerTier?: string;
42
+ extraHeaders?: Record<string, string>;
42
43
  maxTokens?: number;
43
44
  signal?: AbortSignal;
44
45
  onDelta?: (delta: string) => void;
@@ -247,6 +247,7 @@ export async function streamChatCompletion(opts) {
247
247
  res = await connectWithRetry({
248
248
  method: "POST",
249
249
  headers: {
250
+ ...opts.extraHeaders,
250
251
  Authorization: `Bearer ${opts.apiKey}`,
251
252
  "Content-Type": "application/json",
252
253
  },
@@ -28,6 +28,7 @@ export interface NativeRunOpts {
28
28
  restart?: () => void;
29
29
  logPath?: string;
30
30
  timeoutMs: number;
31
+ extraHeaders?: Record<string, string>;
31
32
  signal?: AbortSignal;
32
33
  onDelta?: (delta: string) => void;
33
34
  onActivity?: (activity: string | null) => void;
@@ -797,7 +797,9 @@ function useSelectionGesture(args) {
797
797
  const point = { x: raw.x + cam.x, y: raw.y + cam.y };
798
798
  current.canvasRef.current.setPointerCapture(event.pointerId);
799
799
  const scene = makeScene(current.sceneData, behaviorClasses, current.sprites, current.files);
800
- const actor = scene.actorAt(point.x, point.y);
800
+ const stack = scene.actorsAt(point.x, point.y);
801
+ const stackIds = stack.map((a) => a.id);
802
+ const actor = stack[0] ?? null;
801
803
  const drag = {
802
804
  pointerId: event.pointerId,
803
805
  startPoint: point,
@@ -820,7 +822,7 @@ function useSelectionGesture(args) {
820
822
  } else if (current.multiSelectMode) {
821
823
  handleModePointerDown(drag, actor, current);
822
824
  } else {
823
- handleDefaultPointerDown(drag, actor, point, current);
825
+ handleDefaultPointerDown(drag, actor, point, current, stackIds);
824
826
  }
825
827
  drag.moveStarts = collectMoveStarts(current.sceneData, drag.movingActorIds);
826
828
  dragRef.current = drag;
@@ -891,6 +893,13 @@ function useSelectionGesture(args) {
891
893
  finalizeMarquee(drag, current);
892
894
  } else if (drag.kind === 'idle' && !drag.movedFar && !drag.longPressFired) {
893
895
  handleTap(drag, current);
896
+ } else if (
897
+ drag.kind === 'move' &&
898
+ !drag.movedFar &&
899
+ !drag.longPressFired &&
900
+ drag.cycleStack
901
+ ) {
902
+ cycleSelection(drag, current);
894
903
  }
895
904
  current.marqueeRef.current = null;
896
905
  dragRef.current = null;
@@ -1198,13 +1207,23 @@ function handleModePointerDown(drag, actor, current) {
1198
1207
  drag.pendingMarquee = true;
1199
1208
  }
1200
1209
  }
1201
- function handleDefaultPointerDown(drag, actor, point, current) {
1210
+ function handleDefaultPointerDown(drag, actor, point, current, stackIds) {
1202
1211
  if (actor) {
1203
- if (!current.selectedActorIds.includes(actor.id)) {
1212
+ const sel = current.selectedActorIds;
1213
+ if (sel.length === 1 && stackIds.length > 1 && stackIds.includes(sel[0])) {
1214
+ // Overlapping pile with a single selected actor under the cursor: keep it
1215
+ // selected so it stays draggable, and arm click-to-cycle so a stationary
1216
+ // click descends to the next actor beneath it (see cycleSelection).
1217
+ drag.movingActorIds = [...sel];
1218
+ drag.cycleStack = stackIds;
1219
+ } else if (sel.includes(actor.id)) {
1220
+ // Pressed an actor that's part of the current selection: keep the
1221
+ // selection so the whole group stays draggable.
1222
+ drag.movingActorIds = [...sel];
1223
+ } else {
1224
+ // Fresh pick: select the topmost actor under the cursor.
1204
1225
  current.onSelectActorIds([actor.id]);
1205
1226
  drag.movingActorIds = [actor.id];
1206
- } else {
1207
- drag.movingActorIds = [...current.selectedActorIds];
1208
1227
  }
1209
1228
  drag.kind = 'move';
1210
1229
  drag.longPressTimer = window.setTimeout(() => {
@@ -1235,6 +1254,20 @@ function finalizeMarquee(drag, current) {
1235
1254
  for (const id of hits) merged.add(id);
1236
1255
  current.onSelectActorIds([...merged]);
1237
1256
  }
1257
+ // A stationary click on a pile of overlapping actors advances the selection to
1258
+ // the next actor below the currently selected one, wrapping around at the
1259
+ // bottom. This lets repeated clicks in the same spot reach an actor buried under
1260
+ // others that would otherwise always win the topmost hit-test.
1261
+ function cycleSelection(drag, current) {
1262
+ const stack = drag.cycleStack;
1263
+ if (!stack || stack.length < 2) return;
1264
+ const sel = current.selectedActorIds;
1265
+ if (sel.length !== 1) return;
1266
+ const idx = stack.indexOf(sel[0]);
1267
+ if (idx === -1) return;
1268
+ const nextId = stack[(idx + 1) % stack.length];
1269
+ if (nextId !== sel[0]) current.onSelectActorIds([nextId]);
1270
+ }
1238
1271
  function handleTap(drag, current) {
1239
1272
  if (!drag.modeAtStart) return;
1240
1273
  if (drag.startedOnActorId !== null) {
@@ -281,7 +281,15 @@ export class SceneRuntime {
281
281
  }
282
282
 
283
283
  actorAt(x, y) {
284
+ return this.actorsAt(x, y)[0] ?? null;
285
+ }
286
+
287
+ // All actors whose Layout box contains the point, ordered topmost-first (high
288
+ // z -> low z). Used by the editor's click-to-cycle so repeated clicks in the
289
+ // same spot can walk down through overlapping actors.
290
+ actorsAt(x, y) {
284
291
  const actors = this.getActors().slice().reverse();
292
+ const hits = [];
285
293
  for (const actor of actors) {
286
294
  const layout = getLayout(actor);
287
295
  if (!layout) continue;
@@ -291,10 +299,10 @@ export class SceneRuntime {
291
299
  y >= layout.y &&
292
300
  y <= layout.y + layout.height
293
301
  ) {
294
- return actor;
302
+ hits.push(actor);
295
303
  }
296
304
  }
297
- return null;
305
+ return hits;
298
306
  }
299
307
 
300
308
  actorIdsInRect(rect) {