castle-web-cli 0.4.103 → 0.4.105

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.
@@ -11,6 +11,12 @@ export interface AgentFailure {
11
11
  }
12
12
  export declare function failureForStatus(status: number, body: string, model?: string): AgentFailure | undefined;
13
13
  export declare function classifyProviderError(text: string | undefined, model?: string): AgentFailure | undefined;
14
+ /**
15
+ * Record the reader's IANA zone. Junk is ignored rather than stored: an invalid
16
+ * zone makes toLocaleTimeString throw, and the one place that formatting runs is
17
+ * the error path, which must not fail.
18
+ */
19
+ export declare function setReaderTimeZone(zone: string): void;
14
20
  export declare function failureCopy(opts: {
15
21
  failure: AgentFailure;
16
22
  spawnedTasks: boolean;
@@ -10,7 +10,8 @@
10
10
  // what's wrong and points at settings / Castle.
11
11
  // limit - Castle's daily AI allowance for this user is spent. Nothing to
12
12
  // fix in the session and nothing to retry until the reset, so the
13
- // copy gives the time and the one way around it (own credential).
13
+ // copy gives the time (see setReaderTimeZone) and the one way
14
+ // around it (own credential).
14
15
  // transient - the provider was busy or broke. NOT auto-retried either (see
15
16
  // below), but the copy invites the user to send again.
16
17
  // no-work - the model answered instead of working. Config is fine; the
@@ -98,6 +99,41 @@ export function classifyProviderError(text, model) {
98
99
  function quoted(model) {
99
100
  return model ? `"${model}"` : "the model this session is set to";
100
101
  }
102
+ // The zone the person reading the copy is in, as the shell reported it on
103
+ // connect. Module state rather than a parameter because it is a property of the
104
+ // one reader this serve has, not of any particular failure, and every call site
105
+ // would otherwise thread it through a ctx that has no other reason to know.
106
+ let readerTimeZone;
107
+ /**
108
+ * Record the reader's IANA zone. Junk is ignored rather than stored: an invalid
109
+ * zone makes toLocaleTimeString throw, and the one place that formatting runs is
110
+ * the error path, which must not fail.
111
+ */
112
+ export function setReaderTimeZone(zone) {
113
+ try {
114
+ new Intl.DateTimeFormat(undefined, { timeZone: zone });
115
+ }
116
+ catch {
117
+ return;
118
+ }
119
+ readerTimeZone = zone;
120
+ }
121
+ // The reset is a Pacific midnight and this process runs on the sandbox's clock
122
+ // (UTC), so a server-local time is wrong for every reader. Format in the zone
123
+ // the shell reported; with none reported, name the zone we did use rather than
124
+ // print a bare time that reads as local and isn't.
125
+ function resetsClause(atMs) {
126
+ if (!atMs)
127
+ return "";
128
+ const time = new Date(atMs).toLocaleTimeString(undefined, {
129
+ hour: "numeric",
130
+ minute: "2-digit",
131
+ ...(readerTimeZone
132
+ ? { timeZone: readerTimeZone }
133
+ : { timeZoneName: "short" }),
134
+ });
135
+ return ` -- resets ${time}`;
136
+ }
101
137
  // Plain-language copy for a failed run. Deliberately ONE table rather than a
102
138
  // router copy and a task copy: the sentences would be near-identical, and the
103
139
  // duplicate-detection gate (jscpd, threshold 0) is right to reject that.
@@ -140,12 +176,8 @@ export function failureCopy(opts) {
140
176
  switch (opts.failure.kind) {
141
177
  case "config":
142
178
  return `${configCopy(opts.failure)}${tasksNote}`;
143
- case "limit": {
144
- const resets = opts.failure.resetAtMs
145
- ? ` -- resets ${new Date(opts.failure.resetAtMs).toLocaleString()}`
146
- : "";
147
- return `Daily Castle AI limit reached${resets}. Runs on your own API key or login aren't limited.${tasksNote}`;
148
- }
179
+ case "limit":
180
+ return `Daily Castle AI limit reached${resetsClause(opts.failure.resetAtMs)}. Runs on your own API key or login aren't limited.${tasksNote}`;
149
181
  case "transient":
150
182
  return `OpenRouter is busy right now and I couldn't get through. Send that again in a moment.${tasksNote}`;
151
183
  case "no-work":
package/dist/agent.js CHANGED
@@ -23,7 +23,7 @@ import { WebSocketServer } from "ws";
23
23
  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
- import { classifyProviderError, failureCopy, } from "./agent-failures.js";
26
+ import { classifyProviderError, failureCopy, setReaderTimeZone, } from "./agent-failures.js";
27
27
  import { fetchBudget, meteringHeaders, newAgentSessionId, reportCursorRun, withCustomHeaders, } from "./metering.js";
28
28
  import { anthropicKeyHelperCommand, claudeHasSavedLogin, resolveAnthropicAuth, userKey, ANTHROPIC_CREDENTIAL_ENV, ANTHROPIC_PROXY_ENV, CASTLE_USER_KEYS_PATH, } from "./byo-auth.js";
29
29
  import { runAgentNative } from "./native/loop.js";
@@ -41,12 +41,14 @@ export const AGENT_MODEL_CAPS_PREFIX = "/__castle/agent/model-caps";
41
41
  const DEFAULT_SETTINGS = {
42
42
  router: "claude",
43
43
  tasks: "claude",
44
- routerClaudeModel: "opus",
45
- // Tasks default one tier down: task agents run long unattended builds, so
46
- // sonnet's cost/speed wins by default; the operator stays on opus.
47
- tasksClaudeModel: "sonnet",
44
+ // Both roles run the claude CLI routed through OpenRouter at the slug below.
45
+ // Note this takes the Anthropic credential out of play entirely: a role on
46
+ // "openrouter" resolves OpenRouter auth, so a user's `claude /login` or
47
+ // ANTHROPIC_API_KEY no longer applies (see buildAgentInvocation).
48
+ routerClaudeModel: "openrouter",
49
+ tasksClaudeModel: "openrouter",
48
50
  // Free-form -- change to any OpenRouter slug.
49
- routerOpenrouterModel: "openai/gpt-5.6-sol",
51
+ routerOpenrouterModel: "openai/gpt-5.6-terra",
50
52
  tasksOpenrouterModel: "openai/gpt-5.6-terra",
51
53
  // Both roles think at "medium": the operator stays snappy (the user waits
52
54
  // on every operator turn), and task agents' multi-turn tool loops don't pay
@@ -60,10 +62,10 @@ const DEFAULT_SETTINGS = {
60
62
  // which matters for tool-calling fidelity over long loops).
61
63
  routerRouting: "nitro",
62
64
  tasksRouting: "exacto",
63
- // Operator pins OpenAI's priority (low-latency SLA) tier for the default
64
- // sol model; harmless with other slugs since the pin falls back when the
65
- // tag doesn't exist (allow_fallbacks). Tasks stay on auto: high-volume
66
- // background turns should ride the cheapest available capacity.
65
+ // Operator pins OpenAI's priority (low-latency SLA) tier; harmless with a
66
+ // slug that lacks it since the pin falls back when the tag doesn't exist
67
+ // (allow_fallbacks). Tasks stay on auto: high-volume background turns
68
+ // should ride the cheapest available capacity.
67
69
  routerProviderTier: "openai/priority",
68
70
  tasksProviderTier: "",
69
71
  };
@@ -72,16 +74,14 @@ function normalizeBackend(value) {
72
74
  ? value
73
75
  : null;
74
76
  }
75
- // Settings written before the smith backend existed used "openrouter" as a
76
- // BACKEND value meaning "claude CLI routed at OpenRouter" -- exactly what
77
- // backend "claude" + that role's claude model "openrouter" means now. Used
78
- // only when loading persisted settings (normalizeBackend rejects it live) so
79
- // old decks keep their behavior across the upgrade.
80
- function migrateStoredBackend(value) {
81
- if (value === "openrouter")
82
- return "claude";
83
- return normalizeBackend(value);
84
- }
77
+ // Bump to force every deck onto the current DEFAULT_SETTINGS once, discarding
78
+ // what users had chosen: a stored file below this epoch has ALL of its setting
79
+ // fields dropped on load (see loadAgentSettings). Bump ONLY when a default
80
+ // change is meant to override existing choices -- an ordinary one needs no
81
+ // bump, since sparse storage already reaches anyone who never set that field.
82
+ // The wipe is also what retires older file shapes, so a migration for one is
83
+ // only worth writing if it must survive the epoch that introduces it.
84
+ const SETTINGS_EPOCH = 1;
85
85
  function normalizeClaudeModel(value) {
86
86
  return value === "sonnet" ||
87
87
  value === "opus" ||
@@ -141,6 +141,60 @@ function normalizeProviderTier(value) {
141
141
  return null;
142
142
  return trimmed;
143
143
  }
144
+ // Every setting's validator, keyed the same as AgentSettings, so load and save
145
+ // iterate one list rather than repeating the field names per direction.
146
+ const SETTING_NORMALIZERS = {
147
+ router: normalizeBackend,
148
+ tasks: normalizeBackend,
149
+ routerClaudeModel: normalizeClaudeModel,
150
+ tasksClaudeModel: normalizeClaudeModel,
151
+ routerOpenrouterModel: normalizeOpenrouterModel,
152
+ tasksOpenrouterModel: normalizeOpenrouterModel,
153
+ routerReasoningEffort: normalizeReasoningEffort,
154
+ tasksReasoningEffort: normalizeReasoningEffort,
155
+ routerRouting: normalizeRoutingMode,
156
+ tasksRouting: normalizeRoutingMode,
157
+ routerProviderTier: normalizeProviderTier,
158
+ tasksProviderTier: normalizeProviderTier,
159
+ };
160
+ const SETTING_KEYS = Object.keys(SETTING_NORMALIZERS);
161
+ // Sparse ON PURPOSE: only fields that DIFFER from the current defaults are
162
+ // stored, so "absent" means "follow the default" and a later default change
163
+ // reaches every deck that never set that field. Writing the full object
164
+ // instead (as this once did) froze all 12 settings the first time a user
165
+ // touched any one of them, pinning models nobody had chosen.
166
+ //
167
+ // The flip side, accepted: re-selecting the value that is currently the
168
+ // default stores nothing, so that deck moves when the default moves. Nothing
169
+ // is lost relative to the old behavior -- applyAgentSettings already
170
+ // early-returns on a no-op change, so that click never persisted anything.
171
+ function serializeAgentSettings(settings) {
172
+ const out = { settingsEpoch: SETTINGS_EPOCH };
173
+ for (const key of SETTING_KEYS) {
174
+ if (settings[key] !== DEFAULT_SETTINGS[key])
175
+ out[key] = settings[key];
176
+ }
177
+ return JSON.stringify(out, null, 2) + "\n";
178
+ }
179
+ function loadAgentSettings(settingsPath) {
180
+ const stored = readJsonFile(settingsPath);
181
+ const storedEpoch = typeof stored?.settingsEpoch === "number" ? stored.settingsEpoch : 0;
182
+ // A stale file is ignored, not rewritten: leaving it means the wipe is a
183
+ // pure read-side decision that repeats harmlessly until the user's next real
184
+ // change overwrites it in the sparse shape. A newer epoch (a downgraded CLI
185
+ // reading a file from a newer one) is honored rather than wiped.
186
+ if (!stored || storedEpoch < SETTINGS_EPOCH)
187
+ return { ...DEFAULT_SETTINGS };
188
+ const overrides = {};
189
+ for (const key of SETTING_KEYS) {
190
+ if (!(key in stored))
191
+ continue;
192
+ const value = SETTING_NORMALIZERS[key](stored[key]);
193
+ if (value !== null)
194
+ overrides[key] = value;
195
+ }
196
+ return { ...DEFAULT_SETTINGS, ...overrides };
197
+ }
144
198
  // Base for OpenRouter's Anthropic-compatible endpoint (confirmed current, 2026: it
145
199
  // accepts the standard Anthropic Messages API shape -- text/tool-use/extended-thinking
146
200
  // -- for ANY OpenRouter model slug, not just Anthropic ones), which the claude CLI
@@ -2974,7 +3028,7 @@ function applyAgentSettings(incoming, ctx) {
2974
3028
  }
2975
3029
  if (changes.length === 0)
2976
3030
  return;
2977
- fs.writeFileSync(ctx.settingsPath, JSON.stringify(settings, null, 2) + "\n");
3031
+ fs.writeFileSync(ctx.settingsPath, serializeAgentSettings(settings));
2978
3032
  // The value is saved and broadcast IMMEDIATELY -- validation never gates a
2979
3033
  // write. The verdict follows in a second frame once the catalog answers.
2980
3034
  ctx.broadcast({ type: "settings", settings });
@@ -3448,59 +3502,7 @@ export function createAgentServer(opts) {
3448
3502
  // Which CLI backs the router and the task agents -- independently
3449
3503
  // switchable from the settings popover, persisted next to the chat state.
3450
3504
  const settingsPath = path.join(agentDir, "settings.json");
3451
- // Loosely typed on purpose: the file may carry legacy values the current
3452
- // types no longer admit (backend "openrouter", single openrouterModel /
3453
- // claudeModel) -- every field goes through a normalizer/migrator below.
3454
- const storedSettings = readJsonFile(settingsPath);
3455
- // Three legacy shapes migrate on load, chained oldest-first (rewritten in
3456
- // the new shape on the next settings change):
3457
- // - a single `openrouterModel` (pre per-role split) seeds BOTH per-role
3458
- // slug fields;
3459
- // - a single shared `claudeModel` (pre per-role split) seeds BOTH per-role
3460
- // claude-model fields;
3461
- // - backend value "openrouter" ON A ROLE (pre-smith: it meant that role
3462
- // ran claude-via-OpenRouter) becomes backend "claude" + THAT role's
3463
- // claude model "openrouter" -- exactly that behavior today, and now
3464
- // per-role: one legacy openrouter role no longer drags the other role's
3465
- // model along.
3466
- const legacyOpenrouterModel = normalizeOpenrouterModel(storedSettings?.openrouterModel);
3467
- const legacySharedClaudeModel = normalizeClaudeModel(storedSettings?.claudeModel);
3468
- const roleClaudeModel = (role) => {
3469
- // A stored per-role value wins; a legacy openrouter BACKEND on this role
3470
- // forces "openrouter" (those two never coexist in one file -- per-role
3471
- // fields postdate the openrouter backend's removal); else the legacy
3472
- // shared claudeModel, else the default.
3473
- return (normalizeClaudeModel(storedSettings?.[`${role}ClaudeModel`]) ??
3474
- (storedSettings?.[role] === "openrouter"
3475
- ? "openrouter"
3476
- : (legacySharedClaudeModel ?? DEFAULT_SETTINGS[`${role}ClaudeModel`])));
3477
- };
3478
- const settings = {
3479
- router: migrateStoredBackend(storedSettings?.router) ?? DEFAULT_SETTINGS.router,
3480
- tasks: migrateStoredBackend(storedSettings?.tasks) ?? DEFAULT_SETTINGS.tasks,
3481
- routerClaudeModel: roleClaudeModel("router"),
3482
- tasksClaudeModel: roleClaudeModel("tasks"),
3483
- routerOpenrouterModel: normalizeOpenrouterModel(storedSettings?.routerOpenrouterModel) ??
3484
- legacyOpenrouterModel ??
3485
- DEFAULT_SETTINGS.routerOpenrouterModel,
3486
- tasksOpenrouterModel: normalizeOpenrouterModel(storedSettings?.tasksOpenrouterModel) ??
3487
- legacyOpenrouterModel ??
3488
- DEFAULT_SETTINGS.tasksOpenrouterModel,
3489
- routerReasoningEffort: normalizeReasoningEffort(storedSettings?.routerReasoningEffort) ??
3490
- DEFAULT_SETTINGS.routerReasoningEffort,
3491
- tasksReasoningEffort: normalizeReasoningEffort(storedSettings?.tasksReasoningEffort) ??
3492
- DEFAULT_SETTINGS.tasksReasoningEffort,
3493
- routerRouting: normalizeRoutingMode(storedSettings?.routerRouting) ??
3494
- DEFAULT_SETTINGS.routerRouting,
3495
- tasksRouting: normalizeRoutingMode(storedSettings?.tasksRouting) ??
3496
- DEFAULT_SETTINGS.tasksRouting,
3497
- // Provider tier: "" is valid (auto), so keep a normalized "" over the
3498
- // default rather than treating it as absent.
3499
- routerProviderTier: normalizeProviderTier(storedSettings?.routerProviderTier) ??
3500
- DEFAULT_SETTINGS.routerProviderTier,
3501
- tasksProviderTier: normalizeProviderTier(storedSettings?.tasksProviderTier) ??
3502
- DEFAULT_SETTINGS.tasksProviderTier,
3503
- };
3505
+ const settings = loadAgentSettings(settingsPath);
3504
3506
  const usageFeed = createUsageFeed({
3505
3507
  broadcast,
3506
3508
  hasClients: () => clients.size > 0,
@@ -3623,6 +3625,9 @@ export function createAgentServer(opts) {
3623
3625
  else if (msg.type === "set-settings") {
3624
3626
  applySettings(msg);
3625
3627
  }
3628
+ else if (msg.type === "client-timezone" && typeof msg.timeZone === "string") {
3629
+ setReaderTimeZone(msg.timeZone);
3630
+ }
3626
3631
  });
3627
3632
  socket.on("close", () => {
3628
3633
  clients.delete(socket);