castle-web-cli 0.4.183 → 0.4.184

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,7 @@ export interface AgentFailure {
11
11
  castleCreditsExhausted?: boolean;
12
12
  castleCreditsInsufficient?: boolean;
13
13
  castleSpendLimit?: boolean;
14
+ castleFreeModelLimit?: boolean;
14
15
  }
15
16
  export declare function failureForStatus(status: number, body: string, model?: string): AgentFailure | undefined;
16
17
  export declare function classifyProviderError(text: string | undefined, model?: string): AgentFailure | undefined;
@@ -48,10 +48,32 @@ export function failureForStatus(status, body, model) {
48
48
  return config("no-credits");
49
49
  if (status === 403) {
50
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.
51
+ // and model gates. Keep the matches tied to the proxy's Castle-specific
52
+ // copy: a provider's ordinary permission/content 403 must continue to read
53
+ // as a refusal.
54
+ //
55
+ // The free model's own daily cap is matched FIRST. It is about neither the
56
+ // balance nor the account-wide cap, so whatever else the proxy's sentence
57
+ // happens to mention, any credit copy would send the user off to buy
58
+ // something that cannot help them.
59
+ if (/free model daily limit reached/i.test(body)) {
60
+ // The proxy names the reset as an ISO instant in its prose ("It resets at
61
+ // 2026-09-12T07:00:00.000Z."); it is the only machine-readable field the
62
+ // provider-shaped body has room for, and it is what lets the copy promise
63
+ // a time instead of just "today".
64
+ const resetAt = /resets at (\d{4}-\d{2}-\d{2}T[\d:.]+Z)/i.exec(body);
65
+ const resetAtMs = resetAt ? Date.parse(resetAt[1]) : Number.NaN;
66
+ return {
67
+ kind: "limit",
68
+ detail,
69
+ model,
70
+ verbose: `HTTP ${status}: ${body}`,
71
+ castleFreeModelLimit: true,
72
+ ...(Number.isFinite(resetAtMs) ? { resetAtMs } : {}),
73
+ };
74
+ }
75
+ // A request-size reservation can refuse while the account still has a
76
+ // small positive balance, so pre-flight cannot catch this case.
55
77
  if (/Castle AI request[^.]*balance can(?:not|'t) cover|add credits at castle\.xyz\/credits/i.test(body)) {
56
78
  return {
57
79
  kind: "limit",
@@ -205,6 +227,11 @@ export function failureCopy(opts) {
205
227
  case "config":
206
228
  return `${configCopy(opts.failure)}${tasksNote}`;
207
229
  case "limit":
230
+ // Before the credit cases for the same reason it classifies first: this
231
+ // user is not spending credits, so no amount of buying them helps.
232
+ if (opts.failure.castleFreeModelLimit) {
233
+ return `Today's allowance for the free model is used up${resetsClause(opts.failure.resetAtMs)}. Pick another model in settings to keep going.${tasksNote}`;
234
+ }
208
235
  if (opts.failure.castleCreditsInsufficient) {
209
236
  return `Not enough Operator credits for this request — get more at castle.xyz/credits${tasksNote}`;
210
237
  }
@@ -83,7 +83,6 @@ export interface RouterPromptOpts {
83
83
  deckLabel: string;
84
84
  quickReference?: string;
85
85
  deckTree?: string;
86
- deckContents?: string;
87
86
  messages: PromptMessage[];
88
87
  tasks: PromptTask[];
89
88
  plan?: RouterPlanOpts;
@@ -115,9 +114,7 @@ export declare function buildTaskPrompt(opts: {
115
114
  notesPath: string;
116
115
  handoffPath: string;
117
116
  depsSummary?: string;
118
- backend?: "cursor" | "claude" | "smith";
119
117
  deckTree?: string;
120
- deckContents?: string;
121
118
  quickReference?: string;
122
119
  siblings?: PromptSibling[];
123
120
  plan?: TaskPlanOpts;
@@ -3,7 +3,7 @@
3
3
  // actual deck edits. Kept separate from agent.ts so the orchestration code
4
4
  // stays readable.
5
5
  // How many trailing conversation messages get replayed into each router call.
6
- // Router calls are stateless (a fresh cursor-agent print run each time) so the
6
+ // Router calls are stateless (a fresh headless run each time) so the
7
7
  // transcript is the only memory.
8
8
  const TRANSCRIPT_LIMIT = 40;
9
9
  // Byte ceiling on the replayed transcript, applied AFTER TRANSCRIPT_LIMIT.
@@ -16,9 +16,8 @@ const TRANSCRIPT_LIMIT = 40;
16
16
  // needs a byte budget.
17
17
  //
18
18
  // Sized to leave room for everything else the prompt carries: the rules
19
- // (~10KB), the deck's quick reference (~13KB), the file tree, the smith-only
20
- // deck contents (ROUTER_DECK_CONTENTS_BUDGET, 40KB), the task board, and this
21
- // turn's instruction.
19
+ // (~10KB), the deck's quick reference (~13KB), the file tree, the task board,
20
+ // and this turn's instruction.
22
21
  const TRANSCRIPT_BYTE_BUDGET = 32 * 1024;
23
22
  // The playtest steering's router half, measured as one added bullet on the t1
24
23
  // router ladder (memo 26-09-03-14-01): 15/18 written when asked, 6/6 withheld on
@@ -834,9 +833,6 @@ export function buildRouterPromptParts(opts) {
834
833
  const deckFiles = opts.deckTree?.trim()
835
834
  ? `\n\n== deck files ==\n${opts.deckTree.trim()}`
836
835
  : "";
837
- const deckSource = opts.deckContents?.trim()
838
- ? `\n\n== deck source (current contents -- no need to read_file these to see what's already there) ==\n${opts.deckContents.trim()}`
839
- : "";
840
836
  // Sits with the board, not with the deck identity above: the plan changes on
841
837
  // roughly the turns the board does, so co-locating them means it invalidates
842
838
  // nothing the board wasn't already invalidating.
@@ -844,16 +840,16 @@ export function buildRouterPromptParts(opts) {
844
840
  ? `\n\n== plan ==\n${renderPlanDigest(opts.plan, opts.tasks)}`
845
841
  : "";
846
842
  // Two parts, split for prompt caching. `system` holds what is the same from
847
- // one turn to the next within a serve -- the rules, the deck identity, the
848
- // file tree and (smith) the deck source -- and each backend sends it as
849
- // system text, which is the prefix the provider caches turn after turn. A
850
- // file edit invalidates it; a chat turn does not. `user` holds what changes
851
- // every turn: the transcript, the plan, the board and this instruction.
843
+ // one turn to the next within a serve -- the rules, the deck identity and
844
+ // the file tree -- and is sent as system text, which is the prefix the
845
+ // provider caches turn after turn. A file edit invalidates it; a chat turn
846
+ // does not. `user` holds what changes every turn: the transcript, the plan,
847
+ // the board and this instruction.
852
848
  return {
853
849
  system: `${routerRules(opts.plan !== undefined, opts.playtest === true)}
854
850
 
855
851
  == deck ==
856
- ${opts.deckLabel}${quickReference}${deckFiles}${deckSource}`,
852
+ ${opts.deckLabel}${quickReference}${deckFiles}`,
857
853
  user: `== conversation so far ==
858
854
  ${renderTranscript(opts.messages)}${plan}
859
855
 
@@ -899,10 +895,7 @@ export function userTurnInstruction(opts) {
899
895
  .join("\n\n")}`);
900
896
  }
901
897
  if (opts.attachments && opts.attachments.length > 0) {
902
- // Wording covers both delivery modes: smith gets the images inline as
903
- // content blocks in this same message (its read tool rejects binaries),
904
- // while the CLI backends open the saved files themselves.
905
- parts.push(`The user attached image file(s), saved in the deck at: ${opts.attachments.join(", ")}. If the images are not already visible in this message, open them with your read tool. Take them into account; pass the paths along to task agents that need them.`);
898
+ parts.push(`The user attached image file(s), saved in the deck at: ${opts.attachments.join(", ")}. Open them with your read tool. Take them into account; pass the paths along to task agents that need them.`);
906
899
  }
907
900
  if (opts.refs && opts.refs.length > 0) {
908
901
  parts.push(`The user pointed at: ${opts.refs.map(refPhrase).join(", ")}. Read them with your read tool if you need them to reply. When you spawn a task one of these concerns, name it the same way in that task's prompt -- the path and the lines, or the path and the param -- so the agent reads that place; do not mention it to tasks it does not concern.`);
@@ -928,9 +921,6 @@ export function buildTaskPrompt(opts) {
928
921
  const layout = opts.deckTree?.trim()
929
922
  ? `\n\nDeck files (snapshot at task start; parallel sibling tasks may create files named in your prompt that are absent here):\n\n${opts.deckTree.trim()}\n`
930
923
  : "";
931
- const deckSource = opts.deckContents?.trim()
932
- ? `\n\nDeck source (complete current contents of the deck's text files, snapshot at task start -- you do NOT need to read these files before editing them, edit directly; a file marked "not inlined" is either binary/generated or didn't fit the budget, so read it yourself first if you need it):\n\n${opts.deckContents.trim()}\n`
933
- : "";
934
924
  const planSliceText = opts.plan
935
925
  ? renderTaskPlanSlice(opts.plan.fileText ?? "", opts.plan.item, opts.plan.finishedWork)
936
926
  : "";
@@ -945,20 +935,16 @@ export function buildTaskPrompt(opts) {
945
935
  ? `\n- Before you finish, write these lines to ${opts.handoffPath} -- they are the ONLY record of this task once the task itself is forgotten:\n - \`what: <what now exists, and where>\` -- an existence fact, not a report of activity: "a shop panel in ui/shop.js the bag button opens", never "updated the shop logic". This is what the plan shows as already built, so a later agent extends it instead of rebuilding it.\n - \`files: <the files you created or changed, comma-separated>\`\n - \`knobs: <the values worth tuning, comma-separated>\` -- names only, and only if there are real ones.\n - \`durable: <fact>\` -- the single thing worth remembering that is NOT covered by the three above (a preference the user expressed, a constraint you ran into, an approach that did not work). At most 200 characters. Leave it out only if nothing genuinely qualifies.`
946
936
  : "",
947
937
  ].join("");
948
- // Claude-only: collapsing the wrap-up (progress 90 + notes) into
949
- // one shell call reliably saves 1-2 serial ~7s turns there. Cursor's
950
- // composer sometimes reacts to the same rule with MORE calls, so it stays
951
- // on the plain instructions.
938
+ // Collapsing the wrap-up (progress 90 + notes) into one shell call reliably
939
+ // saves 1-2 serial ~7s turns.
952
940
  // (We deliberately do NOT tell agents to overwrite files via `cat > … <<EOF`
953
941
  // -- blind whole-file rewrites made parallel agents clobber each other's
954
942
  // edits. Read-then-edit is slower but safe; that is the right tradeoff.)
955
943
  const wrapUpList = opts.plan
956
944
  ? "the 90-progress write, the `castle-web save-version` for your paths, writing the notes file, and the handoff lines"
957
945
  : "the 90-progress write, the `castle-web save-version` for your paths, and writing the notes file";
958
- const wrapUp = opts.backend === "claude" || opts.backend === "smith"
959
- ? `\n- Wrap up in ONE tool call, not several: once your last file edit is done, combine ${wrapUpList} into a single shell command (\`;\`-separated so the notes land even if an earlier part hiccups). Then stop -- no extra turns after it.`
960
- : "";
961
- return `You are a background build agent for the Castle deck "${opts.deckLabel}" (current directory). A separate conversation agent dispatched you with one task. Follow the deck's CLAUDE.md / AGENTS.md conventions. Do not reload the served deck -- the person applies your changes when they are ready. \`npm run restart\` exists, but it is HIGHLY flow breaking: it reloads every panel they have open, including the one they are working in. Use it only when a change clearly requires restarting their whole experience.${quickReference}${layout}${deckSource}
946
+ const wrapUp = `\n- Wrap up in ONE tool call, not several: once your last file edit is done, combine ${wrapUpList} into a single shell command (\`;\`-separated so the notes land even if an earlier part hiccups). Then stop -- no extra turns after it.`;
947
+ return `You are a background build agent for the Castle deck "${opts.deckLabel}" (current directory). A separate conversation agent dispatched you with one task. Follow the deck's CLAUDE.md / AGENTS.md conventions. Do not reload the served deck -- the person applies your changes when they are ready. \`npm run restart\` exists, but it is HIGHLY flow breaking: it reloads every panel they have open, including the one they are working in. Use it only when a change clearly requires restarting their whole experience.${quickReference}${layout}
962
948
 
963
949
  Your task (id ${opts.taskId}): ${opts.title}
964
950
 
package/dist/agent.d.ts CHANGED
@@ -1,24 +1,13 @@
1
1
  import * as http from 'http';
2
2
  import { Duplex } from 'stream';
3
- import type { ORReasoningEffort, ORRoutingMode } from './native/openrouter.js';
4
3
  import { type PlaytestToolResult } from './native/playtest.js';
5
4
  export declare const AGENT_WS_PATH = "/__castle/agent";
6
- export declare const AGENT_MODEL_CAPS_PREFIX = "/__castle/agent/model-caps";
7
- export type AgentBackend = 'cursor' | 'claude' | 'smith';
8
5
  export type ClaudeModel = 'sonnet' | 'opus' | 'fable' | 'openrouter';
9
6
  interface AgentSettings {
10
- router: AgentBackend;
11
- tasks: AgentBackend;
12
7
  routerClaudeModel: ClaudeModel;
13
8
  tasksClaudeModel: ClaudeModel;
14
9
  routerOpenrouterModel: string;
15
10
  tasksOpenrouterModel: string;
16
- routerReasoningEffort: ORReasoningEffort;
17
- tasksReasoningEffort: ORReasoningEffort;
18
- routerRouting: ORRoutingMode;
19
- tasksRouting: ORRoutingMode;
20
- routerProviderTier: string;
21
- tasksProviderTier: string;
22
11
  }
23
12
  type OpenrouterSlugKey = 'routerOpenrouterModel' | 'tasksOpenrouterModel';
24
13
  type SettingsWarnings = Partial<Record<OpenrouterSlugKey, {
@@ -58,7 +47,6 @@ interface TaskRecord {
58
47
  durablePending?: boolean;
59
48
  blockedBy?: string[];
60
49
  }
61
- export declare function roleUsesOpenrouter(backend: AgentBackend, claudeModel: ClaudeModel): boolean;
62
50
  export interface DepsState {
63
51
  kind: 'ready' | 'waiting' | 'blocked';
64
52
  blockedBy?: string[];
@@ -73,7 +61,7 @@ export interface AgentServer {
73
61
  /**
74
62
  * Run one playtest on the serve's warm Chromium. Backs the `playtest_request`
75
63
  * control-WS message (serve.ts), which is how a claude task agent reaches
76
- * this through its MCP server -- smith calls runPlaytest directly instead.
64
+ * this through its MCP server.
77
65
  */
78
66
  runPlaytestFor(req: {
79
67
  taskId?: string;