castle-web-cli 0.4.183 → 0.4.185

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 (36) hide show
  1. package/dist/agent-failures.d.ts +1 -0
  2. package/dist/agent-failures.js +31 -4
  3. package/dist/agent-prompts.d.ts +0 -3
  4. package/dist/agent-prompts.js +14 -28
  5. package/dist/agent.d.ts +1 -13
  6. package/dist/agent.js +191 -938
  7. package/dist/byo-accounts.d.ts +4 -4
  8. package/dist/byo-accounts.js +17 -38
  9. package/dist/byo-auth.d.ts +2 -5
  10. package/dist/byo-auth.js +4 -52
  11. package/dist/byo-login.d.ts +4 -5
  12. package/dist/byo-login.js +28 -56
  13. package/dist/mcpPlaytest.js +1 -2
  14. package/dist/metering.d.ts +35 -23
  15. package/dist/metering.js +57 -52
  16. package/dist/openrouter-catalog.d.ts +0 -1
  17. package/dist/openrouter-catalog.js +0 -11
  18. package/dist/platformDoc.d.ts +1 -1
  19. package/dist/platformDoc.js +9 -12
  20. package/dist/serve.js +3 -5
  21. package/dist/shell/assets/index-BHWfx7Tz.css +1 -0
  22. package/dist/shell/assets/index-BMu42DTS.js +449 -0
  23. package/dist/shell/index.html +2 -2
  24. package/kits/physics-2d/CLAUDE.md +5 -1
  25. package/kits/physics-2d/castle.json +1 -1
  26. package/kits/physics-2d/editors/SceneEditor.jsx +2 -1
  27. package/kits/physics-2d/engine/autoInspector.jsx +32 -3
  28. package/kits/physics-2d/engine/fields/fields.jsx +305 -5
  29. package/kits/physics-2d/engine/fields/fields.module.css +76 -0
  30. package/kits/physics-2d/engine/paletteField.jsx +8 -3
  31. package/kits/physics-2d/engine/spriteField.jsx +3 -3
  32. package/kits/physics-2d/engine/ui.jsx +5 -277
  33. package/kits/physics-2d/engine/ui.module.css +0 -69
  34. package/package.json +1 -1
  35. package/dist/shell/assets/index-DnNy-Z5u.js +0 -447
  36. package/dist/shell/assets/index-cdsH2lna.css +0 -1
@@ -1,5 +1,5 @@
1
1
  import { type LoginProvider, type LoginState } from "./byo-login.js";
2
- export type ProviderId = "anthropic" | "openrouter" | "cursor";
2
+ export type ProviderId = "anthropic" | "openrouter";
3
3
  export interface ProviderStatus {
4
4
  id: ProviderId;
5
5
  label: string;
@@ -21,9 +21,9 @@ export declare function accountsSnapshot(): AccountsState;
21
21
  /**
22
22
  * Call `onChange` when a credential this module reports on changes on disk.
23
23
  *
24
- * The editor is not the only writer: `claude /login` and `cursor-agent login`
25
- * in the terminal panel, an agent run that refreshes an OAuth token, and a
26
- * hand-edited `user-keys.json` all change the answer behind its back. Without
24
+ * The editor is not the only writer: `claude /login` in the terminal panel, an
25
+ * agent run that refreshes an OAuth token, and a hand-edited
26
+ * `user-keys.json` all change the answer behind its back. Without
27
27
  * this the account state is whatever was true when the page loaded, so a
28
28
  * terminal sign-in looks like it did nothing until a reload.
29
29
  *
@@ -8,9 +8,8 @@
8
8
  // is allowed to write. The login subprocesses live in byo-login.ts; this module
9
9
  // only reports their state as part of one snapshot.
10
10
  import * as fs from "fs";
11
- import * as os from "os";
12
11
  import * as path from "path";
13
- import { CASTLE_USER_KEYS_PATH, claudeCredentialsPath, claudeHasSavedLogin, cursorAuthPath, cursorHasUserLogin, readUserKeys, } from "./byo-auth.js";
12
+ import { CASTLE_USER_KEYS_PATH, claudeCredentialsPath, claudeHasSavedLogin, readUserKeys, } from "./byo-auth.js";
14
13
  import { activeLogin } from "./byo-login.js";
15
14
  import { inCastleSandbox } from "./metering.js";
16
15
  // Providers, not credentials: Anthropic is reachable by EITHER a key or a
@@ -21,19 +20,13 @@ const PROVIDERS = [
21
20
  {
22
21
  id: "anthropic",
23
22
  label: "Anthropic",
24
- key: { env: "ANTHROPIC_API_KEY", placeholder: "sk-ant-…", offered: true },
23
+ key: { env: "ANTHROPIC_API_KEY", placeholder: "sk-ant-…" },
25
24
  login: "claude",
26
25
  },
27
26
  {
28
27
  id: "openrouter",
29
28
  label: "OpenRouter",
30
- key: { env: "OPENROUTER_API_KEY", placeholder: "sk-or-…", offered: true },
31
- },
32
- {
33
- id: "cursor",
34
- label: "Cursor",
35
- key: { env: "CURSOR_API_KEY", placeholder: "key_…", offered: false },
36
- login: "cursor",
29
+ key: { env: "OPENROUTER_API_KEY", placeholder: "sk-or-…" },
37
30
  },
38
31
  ];
39
32
  // A stored value is never sent toward a browser -- only this. Short values get
@@ -41,39 +34,30 @@ const PROVIDERS = [
41
34
  function hintFor(value) {
42
35
  return value.length >= 12 ? `…${value.slice(-4)}` : "…";
43
36
  }
44
- function hasLogin(provider) {
45
- return provider === "claude"
46
- ? claudeHasSavedLogin()
47
- : cursorHasUserLogin(os.homedir());
48
- }
49
- // Offered at all: a key field the user may fill, or a login they may start.
50
- function isOffered(d) {
51
- return Boolean(d.key?.offered) || Boolean(d.login);
52
- }
53
37
  function descriptorFor(id) {
54
38
  if (typeof id !== "string")
55
39
  return null;
56
- return PROVIDERS.find((d) => isOffered(d) && d.id === id) ?? null;
40
+ return PROVIDERS.find((d) => d.id === id) ?? null;
57
41
  }
58
42
  export function accountsSnapshot() {
59
43
  // Empty outside a sandbox, which hides the whole surface. A local serve has no
60
- // Castle budget to escape, and the host machine's own claude/cursor logins are
61
- // ALREADY what its CLIs use -- offering to "sign in" there would report the
44
+ // Castle budget to escape, and the host machine's own claude login is ALREADY
45
+ // what its CLI uses -- offering to "sign in" there would report the
62
46
  // developer's existing login back to them as a Castle setting, and invite them
63
47
  // to sign out of it from a deck editor.
64
48
  if (!inCastleSandbox())
65
49
  return { providers: [], login: null };
66
50
  const stored = readUserKeys();
67
51
  return {
68
- providers: PROVIDERS.filter(isOffered).map((d) => {
52
+ providers: PROVIDERS.map((d) => {
69
53
  // typeof, not just truthiness: the file is hand-editable, and a
70
54
  // non-string value would throw on trim -- see readUserKeys.
71
- const raw = d.key?.offered ? stored[d.key.env] : undefined;
55
+ const raw = d.key ? stored[d.key.env] : undefined;
72
56
  const value = typeof raw === "string" ? raw.trim() : undefined;
73
57
  return {
74
58
  id: d.id,
75
59
  label: d.label,
76
- ...(d.key?.offered
60
+ ...(d.key
77
61
  ? {
78
62
  key: {
79
63
  placeholder: d.key.placeholder,
@@ -83,7 +67,7 @@ export function accountsSnapshot() {
83
67
  }
84
68
  : {}),
85
69
  ...(d.login
86
- ? { login: { provider: d.login, loggedIn: hasLogin(d.login) } }
70
+ ? { login: { provider: d.login, loggedIn: claudeHasSavedLogin() } }
87
71
  : {}),
88
72
  };
89
73
  }),
@@ -97,9 +81,9 @@ const WATCH_DEBOUNCE_MS = 250;
97
81
  /**
98
82
  * Call `onChange` when a credential this module reports on changes on disk.
99
83
  *
100
- * The editor is not the only writer: `claude /login` and `cursor-agent login`
101
- * in the terminal panel, an agent run that refreshes an OAuth token, and a
102
- * hand-edited `user-keys.json` all change the answer behind its back. Without
84
+ * The editor is not the only writer: `claude /login` in the terminal panel, an
85
+ * agent run that refreshes an OAuth token, and a hand-edited
86
+ * `user-keys.json` all change the answer behind its back. Without
103
87
  * this the account state is whatever was true when the page loaded, so a
104
88
  * terminal sign-in looks like it did nothing until a reload.
105
89
  *
@@ -111,11 +95,7 @@ const WATCH_DEBOUNCE_MS = 250;
111
95
  * costs freshness, never correctness.
112
96
  */
113
97
  export function watchCredentials(onChange) {
114
- const dirs = new Set([
115
- claudeCredentialsPath(),
116
- cursorAuthPath(os.homedir()),
117
- CASTLE_USER_KEYS_PATH,
118
- ].map((p) => path.dirname(p)));
98
+ const dirs = new Set([claudeCredentialsPath(), CASTLE_USER_KEYS_PATH].map((p) => path.dirname(p)));
119
99
  let timer = null;
120
100
  const fire = () => {
121
101
  if (timer)
@@ -203,7 +183,7 @@ export function writeCredential(id, value) {
203
183
  return { ok: false, message: "only available in a Castle sandbox" };
204
184
  }
205
185
  const descriptor = descriptorFor(id);
206
- if (!descriptor?.key?.offered)
186
+ if (!descriptor?.key)
207
187
  return { ok: false, message: "unknown credential" };
208
188
  const env = descriptor.key.env;
209
189
  const stored = readUserKeys();
@@ -230,9 +210,8 @@ export function writeCredential(id, value) {
230
210
  // The login provider a client-supplied id names, or null. Keeps the WS handler
231
211
  // from having to know the descriptor table. Null outside a sandbox for the
232
212
  // same reason writeCredential refuses there -- most of all so no client can
233
- // run `claude auth logout` / `cursor-agent logout` against the host machine's
234
- // own login, which is exactly the outcome hiding the surface was meant to
235
- // prevent.
213
+ // run `claude auth logout` against the host machine's own login, which is
214
+ // exactly the outcome hiding the surface was meant to prevent.
236
215
  export function loginProviderFor(id) {
237
216
  if (!inCastleSandbox())
238
217
  return null;
@@ -1,5 +1,5 @@
1
1
  export declare const CASTLE_USER_KEYS_PATH: string;
2
- export type UserKeyName = "ANTHROPIC_API_KEY" | "OPENROUTER_API_KEY" | "CURSOR_API_KEY";
2
+ export type UserKeyName = "ANTHROPIC_API_KEY" | "OPENROUTER_API_KEY";
3
3
  export declare function readUserKeys(): Record<string, string>;
4
4
  export declare function userKey(envName: UserKeyName): string | null;
5
5
  export type AnthropicAuth = {
@@ -19,17 +19,14 @@ export type OpenrouterAuth = {
19
19
  };
20
20
  export declare function claudeCredentialsPath(): string;
21
21
  export declare function claudeHasSavedLogin(): boolean;
22
- export declare function cursorAuthPath(home: string): string;
23
- export declare function cursorHasUserLogin(home: string): boolean;
24
22
  export declare function resolveAnthropicAuth(): AnthropicAuth;
25
23
  export declare const ANTHROPIC_CREDENTIAL_ENV: readonly ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN_HELPER", "CLAUDE_CODE_OAUTH_TOKEN"];
26
24
  export declare const ANTHROPIC_PROXY_ENV: readonly ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_CUSTOM_HEADERS", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN_HELPER", "CLAUDE_CODE_OAUTH_TOKEN"];
27
25
  export declare function anthropicKeyHelperCommand(): string;
28
26
  export declare function envForUserShell(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
29
- export declare const SHIMMED_CLIS: readonly ["claude", "cursor-agent"];
27
+ export declare const SHIMMED_CLIS: readonly ["claude"];
30
28
  export type ShimmedCli = (typeof SHIMMED_CLIS)[number];
31
29
  export declare function claudeShellEnvScript(env: NodeJS.ProcessEnv, args?: readonly string[]): string;
32
- export declare function cursorShellEnvScript(env: NodeJS.ProcessEnv): string;
33
30
  export declare function shimEnvScript(cli: string, env: NodeJS.ProcessEnv, shimDir: string, args?: readonly string[]): string;
34
31
  export declare function ensureClaudeOnboarded(): void;
35
32
  export declare function installCliShims(deckDir: string): string | null;
package/dist/byo-auth.js CHANGED
@@ -94,32 +94,6 @@ export function claudeCredentialsPath() {
94
94
  export function claudeHasSavedLogin() {
95
95
  return fs.existsSync(claudeCredentialsPath());
96
96
  }
97
- // cursor-agent rewrites ~/.config/cursor/auth.json on every successful run,
98
- // including Castle's own CURSOR_API_KEY runs -- so the file existing does NOT
99
- // mean a user logged in. Distinguish by the apiKey field: a real login is OAuth,
100
- // which drops apiKey and leaves only session tokens; any auth.json that still
101
- // carries an apiKey is an env-key cache cursor wrote from an injected key --
102
- // OURS, including a PREVIOUS key after a rotation. Treating that cache as a login
103
- // suppresses the injected key and drops cursor into the stale (dead) session.
104
- //
105
- // An earlier apiKey === castleKeys().CURSOR_API_KEY comparison misfired on a key
106
- // switch: the stale cache holds the OLD key, reads as "!= current" => "user
107
- // login" => key withheld => auth fails until auth.json is deleted by hand.
108
- // Deferring only on OAuth is rotation-proof. The cost: a tester's own-API-key
109
- // login is no longer distinguishable from our stale cache, so it is not deferred
110
- // to -- OAuth login still is (the common bypass path).
111
- export function cursorAuthPath(home) {
112
- return path.join(home, ".config", "cursor", "auth.json");
113
- }
114
- export function cursorHasUserLogin(home) {
115
- try {
116
- const auth = JSON.parse(fs.readFileSync(cursorAuthPath(home), "utf8"));
117
- return !auth.apiKey && !!auth.accessToken;
118
- }
119
- catch {
120
- return false;
121
- }
122
- }
123
97
  export function resolveAnthropicAuth() {
124
98
  const k = userKey("ANTHROPIC_API_KEY");
125
99
  if (k)
@@ -209,13 +183,6 @@ export function envForUserShell(base) {
209
183
  if (anthropic.mode === "user-key")
210
184
  env.ANTHROPIC_API_KEY = anthropic.key;
211
185
  }
212
- // The same withholding envForAgentSpawn does for a cursor run: Castle's key
213
- // would let cursor-agent consider itself authenticated and never look at the
214
- // user's own OAuth session, so their terminal would keep spending Castle's
215
- // budget after they signed in. Cursor has no key of its own to put back --
216
- // user-keys.json's CURSOR_API_KEY is described but not yet injected anywhere.
217
- if (cursorHasUserLogin(os.homedir()))
218
- stash("CURSOR_API_KEY");
219
186
  const openrouter = userKey("OPENROUTER_API_KEY");
220
187
  if (openrouter) {
221
188
  env.OPENROUTER_API_KEY = openrouter;
@@ -254,9 +221,9 @@ function realpath(p) {
254
221
  return path.resolve(p);
255
222
  }
256
223
  }
257
- // The CLIs a shim is installed for. Both take a credential from the environment
258
- // that Castle may be supplying, so both need the decision re-made per run.
259
- export const SHIMMED_CLIS = ["claude", "cursor-agent"];
224
+ // The CLIs a shim is installed for. Each takes a credential from the
225
+ // environment that Castle may be supplying, so the decision is re-made per run.
226
+ export const SHIMMED_CLIS = ["claude"];
260
227
  // The real binary the shim should exec: the first executable one on `pathValue`
261
228
  // that isn't the shim itself. Skipping by resolved directory (not by string) is
262
229
  // what keeps the shim from exec'ing itself forever.
@@ -319,23 +286,8 @@ export function claudeShellEnvScript(env, args = []) {
319
286
  }
320
287
  return lines.join("\n");
321
288
  }
322
- // The cursor half. Simpler, because there is one credential and no proxy pair:
323
- // Castle's CURSOR_API_KEY is either withheld (the user has their own OAuth
324
- // session, which cursor-agent would otherwise never look at) or put back from
325
- // the stash when they sign out again.
326
- export function cursorShellEnvScript(env) {
327
- if (cursorHasUserLogin(os.homedir()))
328
- return "unset CURSOR_API_KEY";
329
- const stashed = env[PROXY_STASH_PREFIX + "CURSOR_API_KEY"];
330
- if (stashed !== undefined && env.CURSOR_API_KEY === undefined) {
331
- return `CURSOR_API_KEY=${shellQuote(stashed)}; export CURSOR_API_KEY`;
332
- }
333
- return "";
334
- }
335
289
  export function shimEnvScript(cli, env, shimDir, args = []) {
336
- const lines = cli === "cursor-agent"
337
- ? cursorShellEnvScript(env)
338
- : claudeShellEnvScript(env, args);
290
+ const lines = claudeShellEnvScript(env, args);
339
291
  const real = findRealBin(env.PATH, shimDir, cli);
340
292
  const all = [lines, real ? `CASTLE_REAL_BIN=${shellQuote(real)}` : ""].filter((l) => l !== "");
341
293
  return all.length > 0 ? all.join("\n") + "\n" : "";
@@ -1,5 +1,5 @@
1
- export type LoginProvider = "claude" | "cursor";
2
- export type LoginPhase = "starting" | "awaiting-user" | "awaiting-code" | "verifying" | "error";
1
+ export type LoginProvider = "claude";
2
+ export type LoginPhase = "starting" | "awaiting-code" | "verifying" | "error";
3
3
  export interface LoginState {
4
4
  provider: LoginProvider;
5
5
  phase: LoginPhase;
@@ -7,8 +7,7 @@ export interface LoginState {
7
7
  message?: string;
8
8
  }
9
9
  export declare function activeLogin(): LoginState | null;
10
- export declare function providerHasLogin(provider: LoginProvider): boolean;
11
- export declare function startLogin(provider: LoginProvider, onChange: () => void): void;
10
+ export declare function startLogin(onChange: () => void): void;
12
11
  export declare function submitLoginCode(code: string): void;
13
- export declare function logout(provider: LoginProvider, onChange: () => void): void;
12
+ export declare function logout(onChange: () => void): void;
14
13
  export declare function cancelLogin(): void;
package/dist/byo-login.js CHANGED
@@ -1,59 +1,38 @@
1
- // Driving `claude auth login` / `cursor-agent login` from the editor, so a user
2
- // can put a run on their own subscription without knowing the terminal exists.
1
+ // Driving `claude auth login` from the editor, so a user can put a run on their
2
+ // own subscription without knowing the terminal exists.
3
3
  //
4
- // Both CLIs are usable HEADLESS -- measured, not documented (probed 2026-07-30
5
- // against claude 2.1.220 and the current cursor-agent):
4
+ // The CLI is usable HEADLESS -- measured, not documented (probed 2026-07-30
5
+ // against claude 2.1.220): `claude auth login` with no TTY and stdin from a
6
+ // pipe prints "Opening browser to sign in…" then the OAuth URL, then blocks
7
+ // reading a pasted code from STDIN. The URL is wrapped in an OSC-8 hyperlink,
8
+ // so it appears TWICE in the raw bytes.
6
9
  //
7
- // claude auth login with no TTY and stdin from a pipe, prints
8
- // "Opening browser to sign in…" then the OAuth URL, then
9
- // blocks reading a pasted code from STDIN. The URL is
10
- // wrapped in an OSC-8 hyperlink, so it appears TWICE in
11
- // the raw bytes.
12
- // cursor-agent login with NO_OPEN_BROWSER=1, prints
13
- // "Open a browser and navigate to this link: <url>" and
14
- // then POLLS the challenge itself -- no code to paste.
15
- //
16
- // Neither uses a localhost callback, which is what makes this work at all in a
17
- // sandbox: the browser is on the user's machine and the CLI is in a container,
18
- // so a loopback redirect would have nowhere to land.
10
+ // It does not use a localhost callback, which is what makes this work at all in
11
+ // a sandbox: the browser is on the user's machine and the CLI is in a
12
+ // container, so a loopback redirect would have nowhere to land.
19
13
  //
20
14
  // `claude setup-token` is NOT usable here: with no TTY it prints nothing, and
21
15
  // under one it launches the full Claude Code TUI.
22
16
  import { spawn } from "child_process";
23
- import * as os from "os";
24
- import { ANTHROPIC_PROXY_ENV, claudeHasSavedLogin, cursorHasUserLogin, } from "./byo-auth.js";
17
+ import { ANTHROPIC_PROXY_ENV, claudeHasSavedLogin } from "./byo-auth.js";
25
18
  // A login is a singleton: two at once would race for the same credential file,
26
19
  // and the UI only ever offers one. A second start replaces the first.
27
20
  let active = null;
28
21
  export function activeLogin() {
29
22
  return active ? active.state : null;
30
23
  }
31
- export function providerHasLogin(provider) {
32
- return provider === "claude"
33
- ? claudeHasSavedLogin()
34
- : cursorHasUserLogin(os.homedir());
35
- }
36
24
  // Long enough for a real sign-in (find the browser, log in, maybe sign up),
37
25
  // short enough that an abandoned flow doesn't leave a child running forever.
38
26
  // The env override is a QA seam, like CASTLE_USER_KEYS_PATH: ten minutes is
39
27
  // unwaitable in a test.
40
28
  const LOGIN_TIMEOUT_MS = Number(process.env.CASTLE_LOGIN_TIMEOUT_MS ?? "") || 10 * 60 * 1000;
41
- // OSC-8 hyperlinks and colour codes come through on both CLIs; strip them
42
- // before matching so a URL isn't cut short at an escape byte.
29
+ // OSC-8 hyperlinks and colour codes come through; strip them before matching
30
+ // so a URL isn't cut short at an escape byte.
43
31
  // eslint-disable-next-line no-control-regex
44
32
  const ANSI = /\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g;
45
- const URL_PATTERN = {
46
- claude: /https:\/\/claude\.com\/\S*oauth\S*/,
47
- cursor: /https:\/\/cursor\.com\/\S*/,
48
- };
49
- function commandFor(provider) {
33
+ const URL_PATTERN = /https:\/\/claude\.com\/\S*oauth\S*/;
34
+ function commandFor() {
50
35
  const env = { ...process.env, NO_OPEN_BROWSER: "1" };
51
- if (provider === "cursor") {
52
- // Castle's key would let cursor-agent consider itself authenticated and
53
- // skip the OAuth it was just asked for.
54
- delete env.CURSOR_API_KEY;
55
- return { file: "cursor-agent", args: ["login"], env };
56
- }
57
36
  // Castle's proxy pair outranks a claude.ai login (see byo-auth), and the
58
37
  // sandbox always has it set -- leaving it in place would have claude decide
59
38
  // it is already authenticated and skip the flow entirely.
@@ -107,28 +86,22 @@ function handleOutput(text) {
107
86
  return;
108
87
  }
109
88
  if (!active.state.url) {
110
- const match = URL_PATTERN[active.provider].exec(clean);
111
- if (match) {
112
- publish({
113
- url: match[0],
114
- // claude will ask for a pasted code next; cursor polls on its own.
115
- phase: active.provider === "claude" ? "awaiting-code" : "awaiting-user",
116
- });
117
- }
89
+ const match = URL_PATTERN.exec(clean);
90
+ if (match)
91
+ publish({ url: match[0], phase: "awaiting-code" });
118
92
  }
119
93
  }
120
- export function startLogin(provider, onChange) {
94
+ export function startLogin(onChange) {
121
95
  cancelLogin();
122
- const { file, args, env } = commandFor(provider);
96
+ const { file, args, env } = commandFor();
123
97
  let child;
124
98
  try {
125
99
  child = spawn(file, args, { env, stdio: ["pipe", "pipe", "pipe"] });
126
100
  }
127
101
  catch {
128
102
  active = {
129
- provider,
130
103
  child: null,
131
- state: { provider, phase: "error", message: `could not run ${file}` },
104
+ state: { provider: "claude", phase: "error", message: `could not run ${file}` },
132
105
  timer: setTimeout(() => undefined, 0),
133
106
  onChange,
134
107
  };
@@ -136,9 +109,8 @@ export function startLogin(provider, onChange) {
136
109
  return;
137
110
  }
138
111
  active = {
139
- provider,
140
112
  child,
141
- state: { provider, phase: "starting" },
113
+ state: { provider: "claude", phase: "starting" },
142
114
  timer: setTimeout(() => finish("Timed out waiting for sign-in."), LOGIN_TIMEOUT_MS),
143
115
  onChange,
144
116
  };
@@ -157,7 +129,7 @@ export function startLogin(provider, onChange) {
157
129
  // The RESOLVER decides, never the exit code: what matters is whether the
158
130
  // credential this process routes on is now there. A CLI that exits 0
159
131
  // without leaving one behind must not read as success.
160
- if (providerHasLogin(provider))
132
+ if (claudeHasSavedLogin())
161
133
  finish(null);
162
134
  else
163
135
  finish("Sign-in did not complete.");
@@ -176,12 +148,12 @@ export function submitLoginCode(code) {
176
148
  // Sign out, so the modal isn't a one-way door. Fire-and-forget by shape but
177
149
  // awaited for its close, because the snapshot is only right once the CLI has
178
150
  // actually dropped the credential -- the resolver, again, not the exit code.
179
- export function logout(provider, onChange) {
151
+ export function logout(onChange) {
180
152
  cancelLogin();
181
- const { file, args, env } = provider === "cursor"
182
- ? { file: "cursor-agent", args: ["logout"], env: process.env }
183
- : { file: "claude", args: ["auth", "logout"], env: commandFor("claude").env };
184
- const child = spawn(file, args, { env, stdio: "ignore" });
153
+ const child = spawn("claude", ["auth", "logout"], {
154
+ env: commandFor().env,
155
+ stdio: "ignore",
156
+ });
185
157
  child.on("error", onChange);
186
158
  child.on("close", onChange);
187
159
  }
@@ -1,6 +1,5 @@
1
1
  // A stdio MCP server exposing ONE tool -- `playtest` -- to a spawned claude
2
- // task agent, so the claude backend gets the capability smith already has
3
- // natively (native/tools.ts). It owns no browser: every call is relayed to the
2
+ // task agent. It owns no browser: every call is relayed to the
4
3
  // running serve over its loopback control WebSocket (`playtest_request` in
5
4
  // serve.ts), which is where the one warm Chromium and the task's frames dir
6
5
  // live.
@@ -7,29 +7,6 @@ export type MeteringRoute = "anthropic" | "openrouter";
7
7
  * background-task spend" a prefix match with no extra column.
8
8
  */
9
9
  export declare function newAgentSessionId(role: "router" | "task"): string;
10
- /**
11
- * Report a cursor run to the llm-proxy, which stamps the user + sandbox from
12
- * our token and forwards it to the ledger alongside the anthropic/openrouter
13
- * rows the proxy records itself.
14
- *
15
- * Cursor needs this because it is the one backend the proxy cannot see:
16
- * cursor-agent has no base-URL override, so it talks to Cursor directly on its
17
- * own key. There are no token counts to send -- cursor-agent's stream-json
18
- * reports none -- so a row is a run count with its deck, agent run, model, and
19
- * outcome, and the ledger's NULL tokens read as "not measured".
20
- *
21
- * Fire-and-forget by contract: the run is already over and its output already
22
- * reached the user, so every failure here is swallowed. A dropped post loses
23
- * one row rather than retrying, which is the right trade for a signal whose
24
- * value is aggregate.
25
- */
26
- export declare function reportCursorRun(opts: {
27
- deckDir: string;
28
- sessionId: string;
29
- model: string;
30
- durationMs: number;
31
- ok: boolean;
32
- }): void;
33
10
  /**
34
11
  * True when this serve runs inside a Castle sandbox -- i.e. the host injected the
35
12
  * llm-proxy pair, which is what makes a run metered and limit-gated in the first
@@ -44,6 +21,7 @@ export interface CastleBudget {
44
21
  resetAtMs: number;
45
22
  blocked: boolean;
46
23
  blockedModelPrefixes: string[];
24
+ freeModelPrefixes: string[];
47
25
  }
48
26
  export interface AiCredits {
49
27
  plan: "credits" | "daily";
@@ -66,6 +44,40 @@ export interface AiCredits {
66
44
  };
67
45
  }
68
46
  export declare function spendableMicros(budget: CastleBudget): number | null;
47
+ /**
48
+ * Whether Castle foots the bill for this model, so its runs are free to the
49
+ * user however their credits stand.
50
+ *
51
+ * A prefix matches either the full slug or the part after the last "/", so a
52
+ * vendor-qualified prefix still covers the variant suffixes OpenRouter allows
53
+ * ("meta/muse-spark-1.3-contributor:nitro" is the same model, billed the same)
54
+ * while stopping short of a shorter sibling ("meta/muse-spark-1.3" is a
55
+ * different model and is NOT free). Matching is the loose kind on purpose:
56
+ * being wrong here costs one proxy 403 with its own copy, never a run that
57
+ * silently spends credits the user was told were not being spent -- which is
58
+ * why the unqualified form is accepted too, rather than demanding the vendor
59
+ * segment the user may have typed differently.
60
+ */
61
+ export declare function modelIsFree(model: string, prefixes: readonly string[]): boolean;
62
+ /** One agent role as the free-tier question sees it. */
63
+ export interface RoleSpend {
64
+ castlePaid: boolean;
65
+ slug: string | null;
66
+ }
67
+ /**
68
+ * Whether this editor is entirely on models Castle pays for -- the signal the
69
+ * UI uses to stop talking about credits.
70
+ *
71
+ * "Every Castle-paid role", not "any": one role left on a metered model still
72
+ * spends the user's balance, and a UI that hid credits then would hide the
73
+ * thing about to run out. With no Castle-paid role at all there is nothing to
74
+ * call free (the usage frame is null in that case anyway), so it answers false.
75
+ */
76
+ export declare function freeTierForRoles(roles: readonly RoleSpend[], prefixes: readonly string[]): boolean;
77
+ export declare function freeRolesFor(spends: readonly RoleSpend[], prefixes: readonly string[]): {
78
+ router: boolean;
79
+ tasks: boolean;
80
+ };
69
81
  /**
70
82
  * Coalesce asynchronous refresh triggers without letting an older read finish
71
83
  * after and overwrite a newer one. `withFullState` is sticky across queued
package/dist/metering.js CHANGED
@@ -48,58 +48,6 @@ function deckIdFor(deckDir) {
48
48
  return null;
49
49
  return parsed.deckId.replace(/[\r\n]/g, "") || null;
50
50
  }
51
- // Path on the proxy that accepts a run the proxy never routed. Must match
52
- // CASTLE_USAGE_PATH in castle-sandboxes/shared/src/index.ts.
53
- const CASTLE_USAGE_PATH = "/castle/usage";
54
- // A metering post must never delay a turn that has already finished, and its
55
- // result is never awaited -- so a proxy that has gone away costs one socket
56
- // timeout in the background, not a hung run.
57
- const USAGE_POST_TIMEOUT_MS = 5_000;
58
- /**
59
- * Report a cursor run to the llm-proxy, which stamps the user + sandbox from
60
- * our token and forwards it to the ledger alongside the anthropic/openrouter
61
- * rows the proxy records itself.
62
- *
63
- * Cursor needs this because it is the one backend the proxy cannot see:
64
- * cursor-agent has no base-URL override, so it talks to Cursor directly on its
65
- * own key. There are no token counts to send -- cursor-agent's stream-json
66
- * reports none -- so a row is a run count with its deck, agent run, model, and
67
- * outcome, and the ledger's NULL tokens read as "not measured".
68
- *
69
- * Fire-and-forget by contract: the run is already over and its output already
70
- * reached the user, so every failure here is swallowed. A dropped post loses
71
- * one row rather than retrying, which is the right trade for a signal whose
72
- * value is aggregate.
73
- */
74
- export function reportCursorRun(opts) {
75
- const base = process.env.CASTLE_LLM_PROXY_URL;
76
- const token = process.env.CASTLE_LLM_PROXY_TOKEN;
77
- // Absent outside a sandbox, and on a host whose proxy predates this route --
78
- // both cases correctly report nothing rather than posting into the void.
79
- if (!base || !token)
80
- return;
81
- const deckId = deckIdFor(opts.deckDir);
82
- void fetch(`${base}${CASTLE_USAGE_PATH}`, {
83
- method: "POST",
84
- headers: {
85
- "content-type": "application/json",
86
- authorization: `Bearer ${token}`,
87
- },
88
- body: JSON.stringify({
89
- provider: "cursor",
90
- model: opts.model,
91
- ...(deckId ? { deckId } : {}),
92
- agentSessionId: opts.sessionId,
93
- durationMs: opts.durationMs,
94
- // A cursor run has no HTTP status; the ledger's column is one, and
95
- // `>= 400 means it failed` is the reading every other row already gets.
96
- status: opts.ok ? 200 : 500,
97
- }),
98
- signal: AbortSignal.timeout(USAGE_POST_TIMEOUT_MS),
99
- }).catch(() => {
100
- /* best-effort: metering must never surface in a finished run */
101
- });
102
- }
103
51
  /**
104
52
  * True when this serve runs inside a Castle sandbox -- i.e. the host injected the
105
53
  * llm-proxy pair, which is what makes a run metered and limit-gated in the first
@@ -120,6 +68,60 @@ export function spendableMicros(budget) {
120
68
  return null;
121
69
  return Math.max(0, budget.limitMicros - budget.usedMicros);
122
70
  }
71
+ /**
72
+ * Whether Castle foots the bill for this model, so its runs are free to the
73
+ * user however their credits stand.
74
+ *
75
+ * A prefix matches either the full slug or the part after the last "/", so a
76
+ * vendor-qualified prefix still covers the variant suffixes OpenRouter allows
77
+ * ("meta/muse-spark-1.3-contributor:nitro" is the same model, billed the same)
78
+ * while stopping short of a shorter sibling ("meta/muse-spark-1.3" is a
79
+ * different model and is NOT free). Matching is the loose kind on purpose:
80
+ * being wrong here costs one proxy 403 with its own copy, never a run that
81
+ * silently spends credits the user was told were not being spent -- which is
82
+ * why the unqualified form is accepted too, rather than demanding the vendor
83
+ * segment the user may have typed differently.
84
+ */
85
+ export function modelIsFree(model, prefixes) {
86
+ const slug = model.trim().toLowerCase();
87
+ if (!slug)
88
+ return false;
89
+ const bare = slug.slice(slug.lastIndexOf("/") + 1);
90
+ return prefixes.some((raw) => {
91
+ const prefix = raw.trim().toLowerCase();
92
+ // An empty prefix would make every model free -- refuse it rather than
93
+ // hand a malformed server list the power to turn metering off.
94
+ if (!prefix)
95
+ return false;
96
+ return slug.startsWith(prefix) || bare.startsWith(prefix);
97
+ });
98
+ }
99
+ /**
100
+ * Whether this editor is entirely on models Castle pays for -- the signal the
101
+ * UI uses to stop talking about credits.
102
+ *
103
+ * "Every Castle-paid role", not "any": one role left on a metered model still
104
+ * spends the user's balance, and a UI that hid credits then would hide the
105
+ * thing about to run out. With no Castle-paid role at all there is nothing to
106
+ * call free (the usage frame is null in that case anyway), so it answers false.
107
+ */
108
+ export function freeTierForRoles(roles, prefixes) {
109
+ const paid = roles.filter((role) => role.castlePaid);
110
+ return (paid.length > 0 &&
111
+ paid.every((role) => role.slug !== null && modelIsFree(role.slug, prefixes)));
112
+ }
113
+ // Per-role version of the free-tier question, for the picker rather than the
114
+ // bill: which Castle-paid role is sitting on a free model. `freeTier` answers
115
+ // for the whole editor and cannot say which of the two rows earns the chip.
116
+ // A role paid through the user's credential, or on a fixed claude alias with
117
+ // no slug, is never free.
118
+ export function freeRolesFor(spends, prefixes) {
119
+ const [router, tasks] = spends;
120
+ const free = (spend) => spend.castlePaid &&
121
+ spend.slug !== null &&
122
+ modelIsFree(spend.slug, prefixes);
123
+ return { router: free(router), tasks: free(tasks) };
124
+ }
123
125
  /**
124
126
  * Coalesce asynchronous refresh triggers without letting an older read finish
125
127
  * after and overwrite a newer one. `withFullState` is sticky across queued
@@ -268,6 +270,9 @@ export async function fetchBudget() {
268
270
  blockedModelPrefixes: Array.isArray(body.blockedModelPrefixes)
269
271
  ? body.blockedModelPrefixes.filter((p) => typeof p === "string")
270
272
  : [],
273
+ freeModelPrefixes: Array.isArray(body.freeModelPrefixes)
274
+ ? body.freeModelPrefixes.filter((p) => typeof p === "string")
275
+ : [],
271
276
  };
272
277
  }
273
278
  catch {
@@ -27,4 +27,3 @@ export declare function checkOpenrouterKey(apiKey: string, opts?: {
27
27
  direct?: boolean;
28
28
  }): Promise<KeyCheck>;
29
29
  export declare function checkOpenrouterModel(slug: string): Promise<ModelCheck>;
30
- export declare function openrouterCatalogEntry(slug: string): Promise<CatalogEntry | null>;