castle-web-cli 0.4.185 → 0.4.186

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
@@ -31,7 +31,7 @@ import { applyPlanOps, buildRouterPromptParts, buildTaskPrompt, parsePlanOps, pl
31
31
  import { readCastleJson } from './castleJson.js';
32
32
  import { checkOpenrouterKey, checkOpenrouterModel, primeOpenrouterCatalog, } from './openrouter-catalog.js';
33
33
  import { classifyProviderError, failureCopy, setReaderTimeZone, } from './agent-failures.js';
34
- import { castleCreditsExhausted, createRefreshQueue, fetchAiCredits, fetchBudget, freeRolesFor, freeTierForRoles, meteringHeaders, modelIsFree, newAgentSessionId, spendableMicros, withCustomHeaders, } from './metering.js';
34
+ import { castleCreditsExhausted, createBudgetRefreshRetry, createRefreshQueue, fetchAiCredits, fetchBudget, freeRolesFor, freeTierForRoles, meteringHeaders, modelIsFree, newAgentSessionId, spendableMicros, withCustomHeaders, } from './metering.js';
35
35
  import { anthropicKeyHelperCommand, 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';
@@ -1537,17 +1537,20 @@ function createUsageFeed(opts) {
1537
1537
  let latest = null;
1538
1538
  let credits = null;
1539
1539
  let budgetPolls = 0;
1540
- async function readUsage(refreshCredits = false) {
1540
+ let stopped = false;
1541
+ async function readUsage(refreshCredits = false, allowFollowUp = true) {
1541
1542
  const settings = opts.settings();
1542
1543
  if (!anyRoleIsCastlePaid(settings)) {
1543
1544
  credits = null;
1544
- return null;
1545
+ return { usage: null, refreshAfterMs: null, allowFollowUp };
1545
1546
  }
1546
1547
  // Budget is the authoritative spendable amount and its read can force a
1547
1548
  // stale proxy gate to sync after a purchase. Read credits second so the
1548
1549
  // descriptive fields (plan, rate, reload state) describe that same or a
1549
1550
  // newer account snapshot instead of racing ahead of the gate refresh.
1550
- const budget = await fetchBudget();
1551
+ // Only a full credit-state refresh asks the host to sync its held budget. Cheap polls and
1552
+ // spend preflight stay local to the proxy, so control-plane health cannot delay a run.
1553
+ const budget = await fetchBudget(refreshCredits);
1551
1554
  if (refreshCredits) {
1552
1555
  const nextCredits = await fetchAiCredits();
1553
1556
  // Once the richer field has loaded, a transient GraphQL failure should
@@ -1555,7 +1558,11 @@ function createUsageFeed(opts) {
1555
1558
  if (nextCredits)
1556
1559
  credits = nextCredits;
1557
1560
  }
1558
- return usageFrame(budget, credits, settings);
1561
+ return {
1562
+ usage: usageFrame(budget, credits, settings),
1563
+ refreshAfterMs: budget?.refreshAfterMs ?? null,
1564
+ allowFollowUp,
1565
+ };
1559
1566
  }
1560
1567
  function publishUsage(next) {
1561
1568
  if (JSON.stringify(next ?? null) === JSON.stringify(latest ?? null))
@@ -1563,11 +1570,24 @@ function createUsageFeed(opts) {
1563
1570
  latest = next;
1564
1571
  opts.broadcast({ type: 'usage', usage: next });
1565
1572
  }
1573
+ function publishRead(read) {
1574
+ if (stopped)
1575
+ return;
1576
+ publishUsage(read.usage);
1577
+ if (!read.allowFollowUp || read.refreshAfterMs === null)
1578
+ return;
1579
+ budgetRetry.schedule(read.refreshAfterMs);
1580
+ }
1566
1581
  // Focus, host relay, popover-open and run-finished can all arrive together.
1567
- const refreshQueue = createRefreshQueue({ read: readUsage, publish: publishUsage });
1568
- const requestRefresh = (withCredits) => {
1569
- void refreshQueue.request(withCredits);
1570
- };
1582
+ const refreshQueue = createRefreshQueue({ read: readUsage, publish: publishRead });
1583
+ const budgetRetry = createBudgetRefreshRetry(() => requestRefresh(true, false, false));
1584
+ function requestRefresh(withCredits, allowFollowUp = withCredits, supersedeRetry = allowFollowUp) {
1585
+ if (stopped)
1586
+ return;
1587
+ if (supersedeRetry)
1588
+ budgetRetry.cancel();
1589
+ void refreshQueue.request(withCredits, allowFollowUp);
1590
+ }
1571
1591
  const refresh = () => requestRefresh(true);
1572
1592
  const stopRunWatch = onAgentRunFinished(refresh);
1573
1593
  const timer = setInterval(() => {
@@ -1582,8 +1602,10 @@ function createUsageFeed(opts) {
1582
1602
  latest: () => latest,
1583
1603
  refresh,
1584
1604
  stop: () => {
1605
+ stopped = true;
1585
1606
  stopRunWatch();
1586
1607
  clearInterval(timer);
1608
+ budgetRetry.dispose();
1587
1609
  },
1588
1610
  };
1589
1611
  }
@@ -20,6 +20,7 @@ export interface CastleBudget {
20
20
  limitMicros: number | null;
21
21
  resetAtMs: number;
22
22
  blocked: boolean;
23
+ refreshAfterMs?: number;
23
24
  blockedModelPrefixes: string[];
24
25
  freeModelPrefixes: string[];
25
26
  }
@@ -84,10 +85,16 @@ export declare function freeRolesFor(spends: readonly RoleSpend[], prefixes: rea
84
85
  * calls: a cheap poll cannot downgrade a pending user-requested full refresh.
85
86
  */
86
87
  export declare function createRefreshQueue<T>(opts: {
87
- read: (withFullState: boolean) => Promise<T>;
88
+ read: (withFullState: boolean, allowFollowUp: boolean) => Promise<T>;
88
89
  publish: (value: T) => void;
89
90
  }): {
90
- request: (withFullState: boolean) => Promise<void>;
91
+ request: (withFullState: boolean, allowFollowUp?: boolean) => Promise<void>;
92
+ };
93
+ export declare function budgetRefreshDelay(value: unknown): number | null;
94
+ export declare function createBudgetRefreshRetry(run: () => void): {
95
+ schedule: (afterMs: unknown) => void;
96
+ cancel: () => void;
97
+ dispose: () => void;
91
98
  };
92
99
  export declare function castleCreditsExhausted(budget: CastleBudget, credits: AiCredits | null): boolean;
93
100
  /** Rich credit state, or null when ghost/proxy cannot provide the new field. */
@@ -102,7 +109,7 @@ export declare function fetchAiCredits(): Promise<AiCredits | null>;
102
109
  * sentence instead of a spawned agent failing against a 403, and so the editor
103
110
  * can show the bar. Neither is worth failing a run over.
104
111
  */
105
- export declare function fetchBudget(): Promise<CastleBudget | null>;
112
+ export declare function fetchBudget(forceRefresh?: boolean): Promise<CastleBudget | null>;
106
113
  export declare function meteringHeaders(opts: {
107
114
  deckDir: string;
108
115
  sessionId: string;
package/dist/metering.js CHANGED
@@ -63,6 +63,7 @@ const CASTLE_BUDGET_PATH = "/castle/budget";
63
63
  const CASTLE_API_PATH = "/castle/api/graphql";
64
64
  // Read before a run starts, so it must not be able to hold one up for long.
65
65
  const BUDGET_FETCH_TIMEOUT_MS = 5_000;
66
+ const MAX_BUDGET_REFRESH_AFTER_MS = 5_000;
66
67
  export function spendableMicros(budget) {
67
68
  if (budget.limitMicros === null)
68
69
  return null;
@@ -130,23 +131,34 @@ export function freeRolesFor(spends, prefixes) {
130
131
  export function createRefreshQueue(opts) {
131
132
  let queued = false;
132
133
  let queuedFullState = false;
134
+ let queuedFollowUp = false;
133
135
  let active = null;
134
- const request = (withFullState) => {
136
+ const request = (withFullState, allowFollowUp = true) => {
135
137
  queued = true;
136
138
  queuedFullState ||= withFullState;
139
+ queuedFollowUp ||= allowFollowUp;
137
140
  if (active)
138
141
  return active;
139
142
  active = (async () => {
140
143
  while (queued) {
141
144
  const fullState = queuedFullState;
145
+ const followUp = queuedFollowUp;
142
146
  queued = false;
143
147
  queuedFullState = false;
144
- const value = await opts.read(fullState);
148
+ queuedFollowUp = false;
149
+ const value = await opts.read(fullState, followUp);
145
150
  // If another trigger arrived while this read was in flight, its result
146
151
  // is already the one the caller wants. Skip the older intermediate
147
152
  // frame instead of flashing it before the trailing refresh completes.
148
- if (!queued)
153
+ // Preserve its stronger intent, though: a cheap poll queued behind a
154
+ // full read must not swallow that read's retry hint.
155
+ if (queued) {
156
+ queuedFullState ||= fullState;
157
+ queuedFollowUp ||= followUp;
158
+ }
159
+ else {
149
160
  opts.publish(value);
161
+ }
150
162
  }
151
163
  })().finally(() => {
152
164
  active = null;
@@ -155,6 +167,40 @@ export function createRefreshQueue(opts) {
155
167
  };
156
168
  return { request };
157
169
  }
170
+ export function budgetRefreshDelay(value) {
171
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
172
+ return null;
173
+ return Math.min(MAX_BUDGET_REFRESH_AFTER_MS, Math.ceil(value));
174
+ }
175
+ export function createBudgetRefreshRetry(run) {
176
+ let timer = null;
177
+ let disposed = false;
178
+ const cancel = () => {
179
+ if (timer)
180
+ clearTimeout(timer);
181
+ timer = null;
182
+ };
183
+ return {
184
+ schedule: (afterMs) => {
185
+ if (disposed)
186
+ return;
187
+ const delay = budgetRefreshDelay(afterMs);
188
+ cancel();
189
+ if (delay === null)
190
+ return;
191
+ timer = setTimeout(() => {
192
+ timer = null;
193
+ run();
194
+ }, delay + 50);
195
+ timer.unref?.();
196
+ },
197
+ cancel,
198
+ dispose: () => {
199
+ disposed = true;
200
+ cancel();
201
+ },
202
+ };
203
+ }
158
204
  const EXHAUSTED_BALANCE_CREDITS = 50;
159
205
  export function castleCreditsExhausted(budget, credits) {
160
206
  if (!budget.blocked || credits?.plan !== "credits")
@@ -246,13 +292,14 @@ export async function fetchAiCredits() {
246
292
  * sentence instead of a spawned agent failing against a 403, and so the editor
247
293
  * can show the bar. Neither is worth failing a run over.
248
294
  */
249
- export async function fetchBudget() {
295
+ export async function fetchBudget(forceRefresh = false) {
250
296
  const base = process.env.CASTLE_LLM_PROXY_URL;
251
297
  const token = process.env.CASTLE_LLM_PROXY_TOKEN;
252
298
  if (!base || !token)
253
299
  return null;
254
300
  try {
255
- const resp = await fetch(`${base}${CASTLE_BUDGET_PATH}`, {
301
+ const suffix = forceRefresh ? '?refresh=1' : '';
302
+ const resp = await fetch(`${base}${CASTLE_BUDGET_PATH}${suffix}`, {
256
303
  headers: { authorization: `Bearer ${token}` },
257
304
  signal: AbortSignal.timeout(BUDGET_FETCH_TIMEOUT_MS),
258
305
  });
@@ -262,11 +309,13 @@ export async function fetchBudget() {
262
309
  if (typeof body?.usedMicros !== "number" || typeof body.blocked !== "boolean") {
263
310
  return null;
264
311
  }
312
+ const refreshAfterMs = budgetRefreshDelay(body.refreshAfterMs);
265
313
  return {
266
314
  usedMicros: body.usedMicros,
267
315
  limitMicros: typeof body.limitMicros === "number" ? body.limitMicros : null,
268
316
  resetAtMs: typeof body.resetAtMs === "number" ? body.resetAtMs : 0,
269
317
  blocked: body.blocked,
318
+ ...(refreshAfterMs !== null ? { refreshAfterMs } : {}),
270
319
  blockedModelPrefixes: Array.isArray(body.blockedModelPrefixes)
271
320
  ? body.blockedModelPrefixes.filter((p) => typeof p === "string")
272
321
  : [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-cli",
3
- "version": "0.4.185",
3
+ "version": "0.4.186",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/castle-xyz/castle-experimental-web.git"