castle-web-cli 0.4.175 → 0.4.176

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.
@@ -9,6 +9,8 @@ export interface AgentFailure {
9
9
  suggestion?: string;
10
10
  resetAtMs?: number;
11
11
  castleCreditsExhausted?: boolean;
12
+ castleCreditsInsufficient?: boolean;
13
+ castleSpendLimit?: boolean;
12
14
  }
13
15
  export declare function failureForStatus(status: number, body: string, model?: string): AgentFailure | undefined;
14
16
  export declare function classifyProviderError(text: string | undefined, model?: string): AgentFailure | undefined;
@@ -46,8 +46,35 @@ export function failureForStatus(status, body, model) {
46
46
  return config("bad-key");
47
47
  if (status === 402)
48
48
  return config("no-credits");
49
- if (status === 403)
49
+ if (status === 403) {
50
+ // Castle's proxy deliberately uses provider-shaped 403s for its own spend
51
+ // and model gates. A request-size reservation can refuse while the account
52
+ // still has a small positive balance, so pre-flight cannot catch this case.
53
+ // Keep the matches tied to the proxy's Castle-specific copy: a provider's
54
+ // ordinary permission/content 403 must continue to read as a refusal.
55
+ if (/Castle AI request[^.]*balance can(?:not|'t) cover|add credits at castle\.xyz\/credits/i.test(body)) {
56
+ return {
57
+ kind: "limit",
58
+ detail,
59
+ model,
60
+ verbose: `HTTP ${status}: ${body}`,
61
+ castleCreditsInsufficient: true,
62
+ };
63
+ }
64
+ if (/model\s+.+\s+(?:isn['’]t|is not) available on this Castle account/i.test(body)) {
65
+ return config("model-not-allowed");
66
+ }
67
+ if (/Castle AI spending limit reached/i.test(body)) {
68
+ return {
69
+ kind: "limit",
70
+ detail,
71
+ model,
72
+ verbose: `HTTP ${status}: ${body}`,
73
+ castleSpendLimit: true,
74
+ };
75
+ }
50
76
  return config("flagged");
77
+ }
51
78
  if (status === 429 || status >= 500) {
52
79
  return { kind: "transient", detail, model, verbose: `HTTP ${status}: ${body}` };
53
80
  }
@@ -178,9 +205,15 @@ export function failureCopy(opts) {
178
205
  case "config":
179
206
  return `${configCopy(opts.failure)}${tasksNote}`;
180
207
  case "limit":
208
+ if (opts.failure.castleCreditsInsufficient) {
209
+ return `Not enough Castle AI credits for this request — get more at castle.xyz/credits${tasksNote}`;
210
+ }
181
211
  if (opts.failure.castleCreditsExhausted) {
182
212
  return `Out of Castle AI credits — get more at castle.xyz/credits${tasksNote}`;
183
213
  }
214
+ if (opts.failure.castleSpendLimit) {
215
+ return `Castle AI spending limit reached — check your balance at castle.xyz/credits${tasksNote}`;
216
+ }
184
217
  return `Daily Castle AI limit reached${resetsClause(opts.failure.resetAtMs)}. Runs on your own API key or login aren't limited.${tasksNote}`;
185
218
  case "transient":
186
219
  return `OpenRouter is busy right now and I couldn't get through. Send that again in a moment.${tasksNote}`;
package/dist/agent.js CHANGED
@@ -31,7 +31,7 @@ import { applyPlanOps, buildRouterPromptParts, buildTaskPrompt, parsePlanOps, pl
31
31
  import { readCastleJson } from './castleJson.js';
32
32
  import { checkOpenrouterKey, checkOpenrouterModel, openrouterCatalogEntry, primeOpenrouterCatalog, } from './openrouter-catalog.js';
33
33
  import { classifyProviderError, failureCopy, setReaderTimeZone, } from './agent-failures.js';
34
- import { castleCreditsExhausted, fetchAiCredits, fetchBudget, meteringHeaders, newAgentSessionId, reportCursorRun, spendableMicros, withCustomHeaders, } from './metering.js';
34
+ import { castleCreditsExhausted, createRefreshQueue, fetchAiCredits, fetchBudget, meteringHeaders, newAgentSessionId, reportCursorRun, spendableMicros, withCustomHeaders, } from './metering.js';
35
35
  import { anthropicKeyHelperCommand, claudeHasSavedLogin, cursorAuthPath, cursorHasUserLogin, resolveAnthropicAuth, userKey, ANTHROPIC_CREDENTIAL_ENV, ANTHROPIC_PROXY_ENV, CASTLE_USER_KEYS_PATH, } from './byo-auth.js';
36
36
  import { accountsSnapshot, loginProviderFor, watchCredentials, writeCredential, } from './byo-accounts.js';
37
37
  import { cancelLogin, logout, startLogin, submitLoginCode } from './byo-login.js';
@@ -2101,19 +2101,24 @@ function createUsageFeed(opts) {
2101
2101
  let latest = null;
2102
2102
  let credits = null;
2103
2103
  let budgetPolls = 0;
2104
- async function refreshAsync(refreshCredits = false) {
2104
+ async function readUsage(refreshCredits = false) {
2105
2105
  if (!opts.castlePaid()) {
2106
2106
  credits = null;
2107
- publishUsage(null);
2108
- return;
2107
+ return null;
2109
2108
  }
2110
- const [budget, nextCredits] = await Promise.all([
2111
- fetchBudget(),
2112
- refreshCredits ? fetchAiCredits() : Promise.resolve(credits),
2113
- ]);
2114
- if (refreshCredits)
2115
- credits = nextCredits;
2116
- publishUsage(usageFrame(budget, credits));
2109
+ // Budget is the authoritative spendable amount and its read can force a
2110
+ // stale proxy gate to sync after a purchase. Read credits second so the
2111
+ // descriptive fields (plan, rate, reload state) describe that same or a
2112
+ // newer account snapshot instead of racing ahead of the gate refresh.
2113
+ const budget = await fetchBudget();
2114
+ if (refreshCredits) {
2115
+ const nextCredits = await fetchAiCredits();
2116
+ // Once the richer field has loaded, a transient GraphQL failure should
2117
+ // not erase it and fall the UI back to the legacy daily presentation.
2118
+ if (nextCredits)
2119
+ credits = nextCredits;
2120
+ }
2121
+ return usageFrame(budget, credits);
2117
2122
  }
2118
2123
  function publishUsage(next) {
2119
2124
  if (JSON.stringify(next ?? null) === JSON.stringify(latest ?? null))
@@ -2121,13 +2126,18 @@ function createUsageFeed(opts) {
2121
2126
  latest = next;
2122
2127
  opts.broadcast({ type: 'usage', usage: next });
2123
2128
  }
2124
- const refresh = () => void refreshAsync(true);
2129
+ // Focus, host relay, popover-open and run-finished can all arrive together.
2130
+ const refreshQueue = createRefreshQueue({ read: readUsage, publish: publishUsage });
2131
+ const requestRefresh = (withCredits) => {
2132
+ void refreshQueue.request(withCredits);
2133
+ };
2134
+ const refresh = () => requestRefresh(true);
2125
2135
  const stopRunWatch = onAgentRunFinished(refresh);
2126
2136
  const timer = setInterval(() => {
2127
2137
  if (!opts.hasClients())
2128
2138
  return;
2129
2139
  budgetPolls += 1;
2130
- void refreshAsync(budgetPolls % 5 === 0);
2140
+ requestRefresh(budgetPolls % 5 === 0);
2131
2141
  }, USAGE_POLL_MS);
2132
2142
  timer.unref?.();
2133
2143
  refresh();
@@ -66,6 +66,17 @@ export interface AiCredits {
66
66
  };
67
67
  }
68
68
  export declare function spendableMicros(budget: CastleBudget): number | null;
69
+ /**
70
+ * Coalesce asynchronous refresh triggers without letting an older read finish
71
+ * after and overwrite a newer one. `withFullState` is sticky across queued
72
+ * calls: a cheap poll cannot downgrade a pending user-requested full refresh.
73
+ */
74
+ export declare function createRefreshQueue<T>(opts: {
75
+ read: (withFullState: boolean) => Promise<T>;
76
+ publish: (value: T) => void;
77
+ }): {
78
+ request: (withFullState: boolean) => Promise<void>;
79
+ };
69
80
  export declare function castleCreditsExhausted(budget: CastleBudget, credits: AiCredits | null): boolean;
70
81
  /** Rich credit state, or null when ghost/proxy cannot provide the new field. */
71
82
  export declare function fetchAiCredits(): Promise<AiCredits | null>;
package/dist/metering.js CHANGED
@@ -120,6 +120,39 @@ export function spendableMicros(budget) {
120
120
  return null;
121
121
  return Math.max(0, budget.limitMicros - budget.usedMicros);
122
122
  }
123
+ /**
124
+ * Coalesce asynchronous refresh triggers without letting an older read finish
125
+ * after and overwrite a newer one. `withFullState` is sticky across queued
126
+ * calls: a cheap poll cannot downgrade a pending user-requested full refresh.
127
+ */
128
+ export function createRefreshQueue(opts) {
129
+ let queued = false;
130
+ let queuedFullState = false;
131
+ let active = null;
132
+ const request = (withFullState) => {
133
+ queued = true;
134
+ queuedFullState ||= withFullState;
135
+ if (active)
136
+ return active;
137
+ active = (async () => {
138
+ while (queued) {
139
+ const fullState = queuedFullState;
140
+ queued = false;
141
+ queuedFullState = false;
142
+ const value = await opts.read(fullState);
143
+ // If another trigger arrived while this read was in flight, its result
144
+ // is already the one the caller wants. Skip the older intermediate
145
+ // frame instead of flashing it before the trailing refresh completes.
146
+ if (!queued)
147
+ opts.publish(value);
148
+ }
149
+ })().finally(() => {
150
+ active = null;
151
+ });
152
+ return active;
153
+ };
154
+ return { request };
155
+ }
123
156
  const EXHAUSTED_BALANCE_CREDITS = 50;
124
157
  export function castleCreditsExhausted(budget, credits) {
125
158
  if (!budget.blocked || credits?.plan !== "credits")