castle-web-cli 0.4.182 → 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.
- package/dist/agent-failures.d.ts +1 -0
- package/dist/agent-failures.js +31 -4
- package/dist/agent-prompts.d.ts +0 -3
- package/dist/agent-prompts.js +14 -28
- package/dist/agent.d.ts +1 -13
- package/dist/agent.js +187 -938
- package/dist/byo-accounts.d.ts +4 -4
- package/dist/byo-accounts.js +17 -38
- package/dist/byo-auth.d.ts +2 -5
- package/dist/byo-auth.js +4 -52
- package/dist/byo-login.d.ts +4 -5
- package/dist/byo-login.js +28 -56
- package/dist/mcpPlaytest.js +1 -2
- package/dist/metering.d.ts +31 -23
- package/dist/metering.js +45 -52
- package/dist/openrouter-catalog.d.ts +0 -1
- package/dist/openrouter-catalog.js +0 -11
- package/dist/platformDoc.d.ts +1 -1
- package/dist/platformDoc.js +9 -12
- package/dist/serve.js +3 -5
- package/dist/shell/assets/{index-EGp86HKZ.js → index-g4l7hWIQ.js} +85 -83
- package/dist/shell/assets/index-y7CK6t3j.css +1 -0
- package/dist/shell/index.html +2 -2
- package/kits/physics-2d/CLAUDE.md +5 -1
- package/kits/physics-2d/castle.json +1 -1
- package/kits/physics-2d/editors/SceneEditor.jsx +2 -1
- package/kits/physics-2d/engine/autoInspector.jsx +32 -3
- package/kits/physics-2d/engine/fields/fields.jsx +305 -5
- package/kits/physics-2d/engine/fields/fields.module.css +81 -2
- package/kits/physics-2d/engine/paletteField.jsx +8 -3
- package/kits/physics-2d/engine/spriteField.jsx +3 -3
- package/kits/physics-2d/engine/ui.jsx +5 -277
- package/kits/physics-2d/engine/ui.module.css +0 -69
- package/package.json +1 -1
- package/dist/shell/assets/index-C5D4GoKe.css +0 -1
package/dist/agent.js
CHANGED
|
@@ -11,9 +11,8 @@
|
|
|
11
11
|
// shows up as small log messages (started / ready for review / completed)
|
|
12
12
|
// instead of extra router turns.
|
|
13
13
|
//
|
|
14
|
-
// Backend CLI:
|
|
15
|
-
//
|
|
16
|
-
// --force. Claude support can slot in later behind runAgentCli.
|
|
14
|
+
// Backend CLI: the claude CLI in headless print mode (stream-json), behind
|
|
15
|
+
// runAgentCli.
|
|
17
16
|
import { execFileSync, spawn } from 'child_process';
|
|
18
17
|
import { quickReferenceFor } from './platformDoc.js';
|
|
19
18
|
import { IMPORTS_DIR } from './imports.js';
|
|
@@ -30,25 +29,18 @@ import { atomicWriteFileSync } from './atomicFile.js';
|
|
|
30
29
|
import { AGENT_ATTACHMENT_PREFIX, AGENT_PLAYTEST_PREFIX, PLAN_FILE } from './localPaths.js';
|
|
31
30
|
import { applyPlanOps, buildRouterPromptParts, buildTaskPrompt, parsePlanOps, planOpenQuestionLines, truncateToBytes, userTurnInstruction, CLAUDE_TASK_SYSTEM_REMINDER, } from './agent-prompts.js';
|
|
32
31
|
import { readCastleJson } from './castleJson.js';
|
|
33
|
-
import { checkOpenrouterKey, checkOpenrouterModel,
|
|
32
|
+
import { checkOpenrouterKey, checkOpenrouterModel, primeOpenrouterCatalog, } from './openrouter-catalog.js';
|
|
34
33
|
import { classifyProviderError, failureCopy, setReaderTimeZone, } from './agent-failures.js';
|
|
35
|
-
import { castleCreditsExhausted, createRefreshQueue, fetchAiCredits, fetchBudget, meteringHeaders,
|
|
36
|
-
import { anthropicKeyHelperCommand,
|
|
34
|
+
import { castleCreditsExhausted, createRefreshQueue, fetchAiCredits, fetchBudget, freeTierForRoles, meteringHeaders, modelIsFree, newAgentSessionId, spendableMicros, withCustomHeaders, } from './metering.js';
|
|
35
|
+
import { anthropicKeyHelperCommand, resolveAnthropicAuth, userKey, ANTHROPIC_CREDENTIAL_ENV, ANTHROPIC_PROXY_ENV, CASTLE_USER_KEYS_PATH, } from './byo-auth.js';
|
|
37
36
|
import { accountsSnapshot, loginProviderFor, watchCredentials, writeCredential, } from './byo-accounts.js';
|
|
38
37
|
import { cancelLogin, logout, startLogin, submitLoginCode } from './byo-login.js';
|
|
39
|
-
import { runAgentNative } from './native/loop.js';
|
|
40
38
|
import { createPlaytestBrowserManager, } from './native/playtest-browser.js';
|
|
41
39
|
import { createPlaywrightPlaytestExecutor } from './native/playtest-executor.js';
|
|
42
40
|
import { createRemotePlaytestExecutor } from './native/playtest-remote.js';
|
|
43
41
|
import { runPlaytest, } from './native/playtest.js';
|
|
44
42
|
export const AGENT_WS_PATH = '/__castle/agent';
|
|
45
|
-
// Same-origin proxy for OpenRouter model capabilities (avoids browser CORS
|
|
46
|
-
// against openrouter.ai). GET ?model=<slug> -> ModelCaps JSON. Powers the
|
|
47
|
-
// settings popover's dynamic reasoning-effort / provider-tier pickers.
|
|
48
|
-
export const AGENT_MODEL_CAPS_PREFIX = '/__castle/agent/model-caps';
|
|
49
43
|
const DEFAULT_SETTINGS = {
|
|
50
|
-
router: 'claude',
|
|
51
|
-
tasks: 'claude',
|
|
52
44
|
// Both roles run the claude CLI routed through OpenRouter at the slug below.
|
|
53
45
|
// Note this takes the Anthropic credential out of play entirely: a role on
|
|
54
46
|
// "openrouter" resolves OpenRouter auth, so a user's `claude /login` or
|
|
@@ -56,27 +48,13 @@ const DEFAULT_SETTINGS = {
|
|
|
56
48
|
// exactly why a user who HAS one defaults to ANTHROPIC_DEFAULT_MODELS below.
|
|
57
49
|
routerClaudeModel: 'openrouter',
|
|
58
50
|
tasksClaudeModel: 'openrouter',
|
|
59
|
-
// Free-form -- change to any OpenRouter slug.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
routerReasoningEffort: 'medium',
|
|
67
|
-
tasksReasoningEffort: 'medium',
|
|
68
|
-
// Routing splits by what each role optimizes for: the interactive operator
|
|
69
|
-
// routes for speed (nitro = throughput-sorted endpoints), unattended task
|
|
70
|
-
// agents route for correctness (exacto = benchmark-accurate endpoints,
|
|
71
|
-
// which matters for tool-calling fidelity over long loops).
|
|
72
|
-
routerRouting: 'nitro',
|
|
73
|
-
tasksRouting: 'exacto',
|
|
74
|
-
// Operator pins OpenAI's priority (low-latency SLA) tier; harmless with a
|
|
75
|
-
// slug that lacks it since the pin falls back when the tag doesn't exist
|
|
76
|
-
// (allow_fallbacks). Tasks stay on auto: high-volume background turns
|
|
77
|
-
// should ride the cheapest available capacity.
|
|
78
|
-
routerProviderTier: 'openai/priority',
|
|
79
|
-
tasksProviderTier: '',
|
|
51
|
+
// Free-form -- change to any OpenRouter slug. Both roles default to the
|
|
52
|
+
// model Castle pays for (the proxy's freeModelPrefixes list), so an editor
|
|
53
|
+
// that has never touched settings costs the user nothing and never runs into
|
|
54
|
+
// a credit balance -- see freeTierForRoles / the free-model skip in
|
|
55
|
+
// castleSpendRefusal. Picking any other slug puts that role back on credits.
|
|
56
|
+
routerOpenrouterModel: 'meta/muse-spark-1.3-contributor',
|
|
57
|
+
tasksOpenrouterModel: 'meta/muse-spark-1.3-contributor',
|
|
80
58
|
};
|
|
81
59
|
// What the two claude-model defaults become once the user has an Anthropic
|
|
82
60
|
// credential of their own -- a key or a claude.ai login, which
|
|
@@ -97,9 +75,6 @@ function defaultSettings() {
|
|
|
97
75
|
? { ...DEFAULT_SETTINGS }
|
|
98
76
|
: { ...DEFAULT_SETTINGS, ...ANTHROPIC_DEFAULT_MODELS };
|
|
99
77
|
}
|
|
100
|
-
function normalizeBackend(value) {
|
|
101
|
-
return value === 'cursor' || value === 'claude' || value === 'smith' ? value : null;
|
|
102
|
-
}
|
|
103
78
|
// Bump to force every deck onto the current DEFAULT_SETTINGS once, discarding
|
|
104
79
|
// what users had chosen: a stored file below this epoch has ALL of its setting
|
|
105
80
|
// fields dropped on load (see loadAgentSettings). Bump ONLY when a default
|
|
@@ -107,7 +82,13 @@ function normalizeBackend(value) {
|
|
|
107
82
|
// bump, since sparse storage already reaches anyone who never set that field.
|
|
108
83
|
// The wipe is also what retires older file shapes, so a migration for one is
|
|
109
84
|
// only worth writing if it must survive the epoch that introduces it.
|
|
110
|
-
|
|
85
|
+
//
|
|
86
|
+
// 2: the OpenRouter default moved to the model Castle pays for. Deliberately a
|
|
87
|
+
// bump rather than an ordinary default change -- decks that had already stored
|
|
88
|
+
// a slug (including the one that was the default when they stored it) would
|
|
89
|
+
// otherwise keep spending credits on a metered model while every user around
|
|
90
|
+
// them runs free.
|
|
91
|
+
const SETTINGS_EPOCH = 2;
|
|
111
92
|
function normalizeClaudeModel(value) {
|
|
112
93
|
return value === 'sonnet' || value === 'opus' || value === 'fable' || value === 'openrouter'
|
|
113
94
|
? value
|
|
@@ -139,62 +120,19 @@ function normalizeOpenrouterModel(value) {
|
|
|
139
120
|
const trimmed = value.trim();
|
|
140
121
|
return trimmed && trimmed.length <= OPENROUTER_MODEL_MAX_LEN ? trimmed : null;
|
|
141
122
|
}
|
|
142
|
-
// OpenRouter's full effort superset -- validated against the union rather than
|
|
143
|
-
// a per-model list because supported efforts are model-specific (the client
|
|
144
|
-
// fetches them from the model-caps endpoint to build the picker) and
|
|
145
|
-
// OpenRouter maps an unsupported level to the nearest one anyway.
|
|
146
|
-
const REASONING_EFFORTS = [
|
|
147
|
-
'none',
|
|
148
|
-
'minimal',
|
|
149
|
-
'low',
|
|
150
|
-
'medium',
|
|
151
|
-
'high',
|
|
152
|
-
'xhigh',
|
|
153
|
-
'max',
|
|
154
|
-
];
|
|
155
|
-
function normalizeReasoningEffort(value) {
|
|
156
|
-
return REASONING_EFFORTS.includes(value)
|
|
157
|
-
? value
|
|
158
|
-
: null;
|
|
159
|
-
}
|
|
160
|
-
const ROUTING_MODES = ['balanced', 'nitro', 'exacto', 'floor'];
|
|
161
|
-
function normalizeRoutingMode(value) {
|
|
162
|
-
return ROUTING_MODES.includes(value) ? value : null;
|
|
163
|
-
}
|
|
164
|
-
// Provider tier is an OpenRouter endpoint `tag` ("openai/flex", "azure/eu",
|
|
165
|
-
// ...) which is model-specific, so validation is loose like the model slug.
|
|
166
|
-
// Unlike the slug, empty string is VALID and meaningful: "auto" (no pin), so
|
|
167
|
-
// this returns "" rather than null for the clear case -- callers must treat
|
|
168
|
-
// null (invalid) and "" (clear) differently.
|
|
169
|
-
function normalizeProviderTier(value) {
|
|
170
|
-
if (typeof value !== 'string')
|
|
171
|
-
return null;
|
|
172
|
-
const trimmed = value.trim();
|
|
173
|
-
if (trimmed.length > OPENROUTER_MODEL_MAX_LEN)
|
|
174
|
-
return null;
|
|
175
|
-
return trimmed;
|
|
176
|
-
}
|
|
177
123
|
// Every setting's validator, keyed the same as AgentSettings, so load and save
|
|
178
124
|
// iterate one list rather than repeating the field names per direction.
|
|
179
125
|
const SETTING_NORMALIZERS = {
|
|
180
|
-
router: normalizeBackend,
|
|
181
|
-
tasks: normalizeBackend,
|
|
182
126
|
routerClaudeModel: normalizeClaudeModel,
|
|
183
127
|
tasksClaudeModel: normalizeClaudeModel,
|
|
184
128
|
routerOpenrouterModel: normalizeOpenrouterModel,
|
|
185
129
|
tasksOpenrouterModel: normalizeOpenrouterModel,
|
|
186
|
-
routerReasoningEffort: normalizeReasoningEffort,
|
|
187
|
-
tasksReasoningEffort: normalizeReasoningEffort,
|
|
188
|
-
routerRouting: normalizeRoutingMode,
|
|
189
|
-
tasksRouting: normalizeRoutingMode,
|
|
190
|
-
routerProviderTier: normalizeProviderTier,
|
|
191
|
-
tasksProviderTier: normalizeProviderTier,
|
|
192
130
|
};
|
|
193
131
|
const SETTING_KEYS = Object.keys(SETTING_NORMALIZERS);
|
|
194
132
|
// Sparse ON PURPOSE: only fields that DIFFER from the current defaults are
|
|
195
133
|
// stored, so "absent" means "follow the default" and a later default change
|
|
196
134
|
// reaches every deck that never set that field. Writing the full object
|
|
197
|
-
// instead (as this once did) froze
|
|
135
|
+
// instead (as this once did) froze every setting the first time a user
|
|
198
136
|
// touched any one of them, pinning models nobody had chosen.
|
|
199
137
|
//
|
|
200
138
|
// The flip side, accepted: re-selecting the value that is currently the
|
|
@@ -294,9 +232,6 @@ function openrouterAnthropicBase() {
|
|
|
294
232
|
// (the CLI appends /v1/messages), bypassing the proxy. The proxy branch instead
|
|
295
233
|
// uses openrouterAnthropicBase() (the injected OPENROUTER_BASE_URL origin).
|
|
296
234
|
const OPENROUTER_DIRECT_ANTHROPIC_BASE = 'https://openrouter.ai/api';
|
|
297
|
-
// OpenAI-shaped chat-completions ORIGIN for the smith native loop on a user's
|
|
298
|
-
// own OpenRouter key (native/openrouter.ts appends /chat/completions).
|
|
299
|
-
const OPENROUTER_DIRECT_CHAT_BASE = 'https://openrouter.ai/api/v1';
|
|
300
235
|
function envForOpenrouterSpawn(auth) {
|
|
301
236
|
if (!auth.key) {
|
|
302
237
|
// Unreachable via runAgentTurn (pre-flight rejects a keyless run before
|
|
@@ -320,121 +255,16 @@ function envForOpenrouterSpawn(auth) {
|
|
|
320
255
|
env.ANTHROPIC_AUTH_TOKEN = auth.key;
|
|
321
256
|
return env;
|
|
322
257
|
}
|
|
323
|
-
// The
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
// envForOpenrouterSpawn). Castle-provided key file first, env fallback --
|
|
327
|
-
// same sourcing order as envForAgentSpawn.
|
|
258
|
+
// The OpenRouter credential the claude-via-OpenRouter spawn sends as
|
|
259
|
+
// ANTHROPIC_AUTH_TOKEN (see envForOpenrouterSpawn). Castle-provided key file
|
|
260
|
+
// first, env fallback.
|
|
328
261
|
const OPENROUTER_KEY_NAME = 'OPENROUTER_API_KEY';
|
|
329
262
|
function openrouterApiKey() {
|
|
330
263
|
return castleKeys()[OPENROUTER_KEY_NAME] ?? process.env[OPENROUTER_KEY_NAME] ?? '';
|
|
331
264
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
function asRecord(v) {
|
|
336
|
-
return v && typeof v === 'object' ? v : null;
|
|
337
|
-
}
|
|
338
|
-
async function openrouterProviderTiers(slug) {
|
|
339
|
-
const res = await fetch(`${OPENROUTER_API_BASE}/models/${slug}/endpoints`);
|
|
340
|
-
if (!res.ok)
|
|
341
|
-
return [];
|
|
342
|
-
const json = asRecord(await res.json());
|
|
343
|
-
const data = asRecord(json?.data);
|
|
344
|
-
const endpoints = data && Array.isArray(data.endpoints) ? data.endpoints : [];
|
|
345
|
-
const tags = [];
|
|
346
|
-
for (const ep of endpoints) {
|
|
347
|
-
const rec = asRecord(ep);
|
|
348
|
-
const tag = rec && typeof rec.tag === 'string' ? rec.tag : null;
|
|
349
|
-
if (tag && !tags.includes(tag))
|
|
350
|
-
tags.push(tag);
|
|
351
|
-
}
|
|
352
|
-
return tags;
|
|
353
|
-
}
|
|
354
|
-
async function fetchModelCaps(slug) {
|
|
355
|
-
const cached = modelCapsCache.get(slug);
|
|
356
|
-
if (cached && Date.now() - cached.at < MODEL_CAPS_TTL_MS)
|
|
357
|
-
return cached.caps;
|
|
358
|
-
// Best-effort per source: a failure in either leaves that half empty rather
|
|
359
|
-
// than failing the whole lookup, so a bad slug still yields a usable (empty)
|
|
360
|
-
// caps object the client can render as "no dynamic options".
|
|
361
|
-
let reasoningEfforts = null;
|
|
362
|
-
let defaultEffort = null;
|
|
363
|
-
try {
|
|
364
|
-
// Shared catalog (openrouter-catalog.ts) rather than a second /models
|
|
365
|
-
// fetch: it's the same list the pre-flight slug check reads, and it brings
|
|
366
|
-
// a disk-backed stale-while-revalidate cache with it.
|
|
367
|
-
const entry = await openrouterCatalogEntry(slug);
|
|
368
|
-
const reasoning = asRecord(entry?.reasoning);
|
|
369
|
-
const efforts = reasoning?.supported_efforts;
|
|
370
|
-
const supportedParams = entry?.supportedParameters;
|
|
371
|
-
const acceptsEffort = Array.isArray(supportedParams) &&
|
|
372
|
-
(supportedParams.includes('reasoning_effort') || supportedParams.includes('reasoning'));
|
|
373
|
-
if (acceptsEffort && Array.isArray(efforts) && efforts.length > 0) {
|
|
374
|
-
reasoningEfforts = efforts.filter((e) => typeof e === 'string');
|
|
375
|
-
defaultEffort =
|
|
376
|
-
typeof reasoning?.default_effort === 'string' ? reasoning.default_effort : null;
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
catch {
|
|
380
|
-
// leave reasoning fields null
|
|
381
|
-
}
|
|
382
|
-
let providerTiers = [];
|
|
383
|
-
try {
|
|
384
|
-
providerTiers = await openrouterProviderTiers(slug);
|
|
385
|
-
}
|
|
386
|
-
catch {
|
|
387
|
-
// leave providerTiers empty
|
|
388
|
-
}
|
|
389
|
-
const caps = {
|
|
390
|
-
model: slug,
|
|
391
|
-
reasoningEfforts,
|
|
392
|
-
defaultEffort,
|
|
393
|
-
providerTiers,
|
|
394
|
-
};
|
|
395
|
-
modelCapsCache.set(slug, { caps, at: Date.now() });
|
|
396
|
-
return caps;
|
|
397
|
-
}
|
|
398
|
-
// GET AGENT_MODEL_CAPS_PREFIX?model=<slug>. Returns 400 for a missing/oversized
|
|
399
|
-
// slug, 200 ModelCaps otherwise (empty caps on upstream failure -- see
|
|
400
|
-
// fetchModelCaps). reqPath is already query-stripped; parse req.url for it.
|
|
401
|
-
function handleModelCaps(req, res) {
|
|
402
|
-
const send = (status, body) => {
|
|
403
|
-
res.writeHead(status, {
|
|
404
|
-
'content-type': 'application/json',
|
|
405
|
-
'cache-control': 'no-store',
|
|
406
|
-
});
|
|
407
|
-
res.end(JSON.stringify(body));
|
|
408
|
-
return true;
|
|
409
|
-
};
|
|
410
|
-
let slug = '';
|
|
411
|
-
try {
|
|
412
|
-
slug = (new URL(req.url ?? '', 'http://localhost').searchParams.get('model') ?? '').trim();
|
|
413
|
-
}
|
|
414
|
-
catch {
|
|
415
|
-
slug = '';
|
|
416
|
-
}
|
|
417
|
-
if (!slug || slug.length > OPENROUTER_MODEL_MAX_LEN) {
|
|
418
|
-
return send(400, { error: 'missing or invalid model' });
|
|
419
|
-
}
|
|
420
|
-
// Strip any routing suffix the client may have on the displayed slug so the
|
|
421
|
-
// OpenRouter lookup hits the base model id.
|
|
422
|
-
const baseSlug = slug.replace(/:(nitro|exacto|floor)$/, '');
|
|
423
|
-
fetchModelCaps(baseSlug)
|
|
424
|
-
.then((caps) => send(200, caps))
|
|
425
|
-
.catch(() => send(200, {
|
|
426
|
-
model: baseSlug,
|
|
427
|
-
reasoningEfforts: null,
|
|
428
|
-
defaultEffort: null,
|
|
429
|
-
providerTiers: [],
|
|
430
|
-
}));
|
|
431
|
-
return true;
|
|
432
|
-
}
|
|
433
|
-
// Build the headless CLI invocation for a spawning backend/role (smith never
|
|
434
|
-
// comes through here -- it has no CLI process; see runAgentSmith). Cursor's
|
|
435
|
-
// router runs in read-only ask mode; hosted claude runs bypassPermissions for
|
|
436
|
-
// both roles because the container is already the permission boundary. Local
|
|
437
|
-
// claude stays on auto (NOT plan mode -- that makes it emit plan tool calls)
|
|
265
|
+
// Build the headless CLI invocation for a role. Hosted claude runs
|
|
266
|
+
// bypassPermissions for both roles because the container is already the
|
|
267
|
+
// permission boundary. Local claude stays on auto (NOT plan mode -- that makes it emit plan tool calls)
|
|
438
268
|
// at medium effort. claudeModel "openrouter" keeps the SAME claude binary and
|
|
439
269
|
// swaps only the model slug and env (see envForOpenrouterSpawn).
|
|
440
270
|
// claude CLI 2.1.198 SILENTLY resolves `--permission-mode auto` to "default"
|
|
@@ -453,8 +283,8 @@ function handleModelCaps(req, res) {
|
|
|
453
283
|
const OPENROUTER_ALLOWED_TOOLS = ['Edit', 'Write', 'NotebookEdit', 'Bash'];
|
|
454
284
|
// The tool id claude assigns our MCP server's `playtest` tool: mcp__<server>__<tool>,
|
|
455
285
|
// where <server> is the key under mcpServers in --mcp-config (see mcpConfigArg).
|
|
456
|
-
// Task-only
|
|
457
|
-
//
|
|
286
|
+
// Task-only -- a router turn has no deck to play and no frames dir to write
|
|
287
|
+
// into.
|
|
458
288
|
const CLAUDE_PLAYTEST_TOOL = 'mcp__castle__playtest';
|
|
459
289
|
// Single `=` token because --allowedTools is variadic and would otherwise
|
|
460
290
|
// swallow the trailing prompt positional.
|
|
@@ -489,9 +319,6 @@ function mcpConfigArg(deckDir, taskId) {
|
|
|
489
319
|
});
|
|
490
320
|
return `--mcp-config=${config}`;
|
|
491
321
|
}
|
|
492
|
-
// Cursor's proprietary model. Also the slug reported to the metering ledger, so
|
|
493
|
-
// the two can never drift into disagreeing about what a cursor row ran on.
|
|
494
|
-
const CURSOR_MODEL = 'composer-2.5-fast';
|
|
495
322
|
// Keep runs independent of the machine's user config: no user plugins (LSP
|
|
496
323
|
// servers etc.), no user MCP servers. CLAUDE.md auto-discovery and OAuth still
|
|
497
324
|
// work. On a user's own Anthropic KEY this also carries the apiKeyHelper that
|
|
@@ -517,96 +344,73 @@ const ROUTER_CLAUDE_ENV = {
|
|
|
517
344
|
CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS: '1',
|
|
518
345
|
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '1',
|
|
519
346
|
};
|
|
520
|
-
function buildAgentInvocation(
|
|
347
|
+
function buildAgentInvocation(role, prompt, claudeModel,
|
|
521
348
|
// Already resolved for this role by the caller (router turns pass
|
|
522
349
|
// settings.routerOpenrouterModel, task spawns settings.tasksOpenrouterModel).
|
|
523
350
|
openrouterModel,
|
|
524
351
|
// This run's ledger identity, and the deck dir its castle.json is read from.
|
|
525
|
-
// Only the claude branch can carry it: cursor-agent runs on its own key and
|
|
526
|
-
// never traverses the llm-proxy.
|
|
527
352
|
metering,
|
|
528
353
|
// The task this spawn belongs to, so its playtest frames land under
|
|
529
354
|
// tasks/<id>/playtest/ and count against that task's call budget. Absent for
|
|
530
355
|
// router turns, which get no playtest tool at all.
|
|
531
356
|
taskId,
|
|
532
|
-
// The stable part of a router prompt
|
|
533
|
-
//
|
|
534
|
-
// gets it ahead of the prompt.
|
|
357
|
+
// The stable part of a router prompt, passed as appended system text so the
|
|
358
|
+
// CLI's own cache breakpoints cover it.
|
|
535
359
|
systemPrompt) {
|
|
536
360
|
const systemText = [systemPrompt, role === 'task' ? CLAUDE_TASK_SYSTEM_REMINDER : undefined]
|
|
537
361
|
.filter((text) => Boolean(text))
|
|
538
362
|
.join('\n\n');
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
const direct = viaOpenrouter ? orAuth.mode === 'user-key' : anAuth.mode !== 'proxy';
|
|
548
|
-
return {
|
|
549
|
-
command: 'claude',
|
|
550
|
-
args: [
|
|
551
|
-
'-p',
|
|
552
|
-
'--verbose',
|
|
553
|
-
'--output-format',
|
|
554
|
-
'stream-json',
|
|
555
|
-
'--include-partial-messages',
|
|
556
|
-
'--permission-mode',
|
|
557
|
-
hosted ? 'bypassPermissions' : 'auto',
|
|
558
|
-
...(!hosted && viaOpenrouter ? [openrouterAllowedToolsArg(role)] : []),
|
|
559
|
-
'--model',
|
|
560
|
-
viaOpenrouter ? openrouterModel : claudeModel,
|
|
561
|
-
'--effort',
|
|
562
|
-
'medium',
|
|
563
|
-
// Newer claude models default thinking display to "omitted" (empty
|
|
564
|
-
// thinking_delta text, signature only); "summarized" restores actual
|
|
565
|
-
// summary text so the shell's expandable thinking transcript has
|
|
566
|
-
// content. Slight time-to-first-text cost (the API streams the
|
|
567
|
-
// summary before prose). Undocumented in --help but honored.
|
|
568
|
-
'--thinking-display',
|
|
569
|
-
'summarized',
|
|
570
|
-
'--settings',
|
|
571
|
-
claudeSettingsArg(anAuth),
|
|
572
|
-
'--strict-mcp-config',
|
|
573
|
-
...(role === 'task' ? [mcpConfigArg(metering.deckDir, taskId)] : []),
|
|
574
|
-
...(systemText ? ['--append-system-prompt', systemText] : []),
|
|
575
|
-
prompt,
|
|
576
|
-
],
|
|
577
|
-
env: {
|
|
578
|
-
...withCustomHeaders(viaOpenrouter ? envForOpenrouterSpawn(orAuth) : envForClaudeSpawn(anAuth), meteringHeaders({
|
|
579
|
-
deckDir: metering.deckDir,
|
|
580
|
-
sessionId: metering.sessionId,
|
|
581
|
-
route: viaOpenrouter ? 'openrouter' : 'anthropic',
|
|
582
|
-
direct,
|
|
583
|
-
})),
|
|
584
|
-
...(role === 'router' ? ROUTER_CLAUDE_ENV : {}),
|
|
585
|
-
// Claude Code 2.1.258 refuses bypassPermissions as root unless the
|
|
586
|
-
// child explicitly identifies itself as running in a sandbox.
|
|
587
|
-
...(hosted ? { IS_SANDBOX: '1' } : {}),
|
|
588
|
-
},
|
|
589
|
-
};
|
|
590
|
-
}
|
|
363
|
+
const hosted = Boolean(process.env.CASTLE_SANDBOX_ID);
|
|
364
|
+
const viaOpenrouter = claudeModel === 'openrouter';
|
|
365
|
+
const orAuth = viaOpenrouter ? resolveOpenrouterAuth() : null;
|
|
366
|
+
const anAuth = viaOpenrouter ? null : resolveAnthropicAuth();
|
|
367
|
+
// Direct = the user's own credential/login is in play, so this run bypasses
|
|
368
|
+
// the proxy and must NOT carry metering headers (metering.ts would otherwise
|
|
369
|
+
// attach them off process.env and the direct spawn would leak them upstream).
|
|
370
|
+
const direct = viaOpenrouter ? orAuth.mode === 'user-key' : anAuth.mode !== 'proxy';
|
|
591
371
|
return {
|
|
592
|
-
command: '
|
|
372
|
+
command: 'claude',
|
|
593
373
|
args: [
|
|
594
374
|
'-p',
|
|
375
|
+
'--verbose',
|
|
595
376
|
'--output-format',
|
|
596
377
|
'stream-json',
|
|
597
|
-
'--
|
|
598
|
-
'--
|
|
378
|
+
'--include-partial-messages',
|
|
379
|
+
'--permission-mode',
|
|
380
|
+
hosted ? 'bypassPermissions' : 'auto',
|
|
381
|
+
...(!hosted && viaOpenrouter ? [openrouterAllowedToolsArg(role)] : []),
|
|
599
382
|
'--model',
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
383
|
+
viaOpenrouter ? openrouterModel : claudeModel,
|
|
384
|
+
'--effort',
|
|
385
|
+
'medium',
|
|
386
|
+
// Newer claude models default thinking display to "omitted" (empty
|
|
387
|
+
// thinking_delta text, signature only); "summarized" restores actual
|
|
388
|
+
// summary text so the shell's expandable thinking transcript has
|
|
389
|
+
// content. Slight time-to-first-text cost (the API streams the
|
|
390
|
+
// summary before prose). Undocumented in --help but honored.
|
|
391
|
+
'--thinking-display',
|
|
392
|
+
'summarized',
|
|
393
|
+
'--settings',
|
|
394
|
+
claudeSettingsArg(anAuth),
|
|
395
|
+
'--strict-mcp-config',
|
|
396
|
+
...(role === 'task' ? [mcpConfigArg(metering.deckDir, taskId)] : []),
|
|
397
|
+
...(systemText ? ['--append-system-prompt', systemText] : []),
|
|
398
|
+
prompt,
|
|
603
399
|
],
|
|
604
|
-
env:
|
|
400
|
+
env: {
|
|
401
|
+
...withCustomHeaders(viaOpenrouter ? envForOpenrouterSpawn(orAuth) : envForClaudeSpawn(anAuth), meteringHeaders({
|
|
402
|
+
deckDir: metering.deckDir,
|
|
403
|
+
sessionId: metering.sessionId,
|
|
404
|
+
route: viaOpenrouter ? 'openrouter' : 'anthropic',
|
|
405
|
+
direct,
|
|
406
|
+
})),
|
|
407
|
+
...(role === 'router' ? ROUTER_CLAUDE_ENV : {}),
|
|
408
|
+
// Claude Code 2.1.258 refuses bypassPermissions as root unless the
|
|
409
|
+
// child explicitly identifies itself as running in a sandbox.
|
|
410
|
+
...(hosted ? { IS_SANDBOX: '1' } : {}),
|
|
411
|
+
},
|
|
605
412
|
};
|
|
606
413
|
}
|
|
607
|
-
function parserForBackend(backend) {
|
|
608
|
-
return backend === 'cursor' ? 'cursor' : 'claude';
|
|
609
|
-
}
|
|
610
414
|
const ROUTER_TIMEOUT_MS = 3 * 60_000;
|
|
611
415
|
const TASK_TIMEOUT_MS = 30 * 60_000;
|
|
612
416
|
const MAX_TASK_ATTEMPTS = 3;
|
|
@@ -616,10 +420,10 @@ const MAX_TASK_ATTEMPTS = 3;
|
|
|
616
420
|
const MAX_CONCURRENT_TASKS = Number(process.env.CASTLE_MAX_CONCURRENT_TASKS) || 4;
|
|
617
421
|
// Base backoff (ms) before a crashed task agent is relaunched, scaled by
|
|
618
422
|
// attempt number and jittered (see waitBeforeTaskRetry). Incident (2026-07):
|
|
619
|
-
// the router spawned 4 parallel tasks whose
|
|
423
|
+
// the router spawned 4 parallel tasks whose agent CLI processes all
|
|
620
424
|
// crashed at startup within ~1.3s with a macOS keychain error ("Security
|
|
621
425
|
// command failed: Security process exited with code: 44" -- credential
|
|
622
|
-
// lookup contention when many
|
|
426
|
+
// lookup contention when many agent CLI processes start at once). The old
|
|
623
427
|
// instant retry burned all 3 attempts of every task inside that same ~6s
|
|
624
428
|
// contention window (11 agent processes launched in ~7s); the one retry that
|
|
625
429
|
// happened to land ~2s later succeeded, so growing + jittered spacing
|
|
@@ -774,11 +578,8 @@ function planDefault() {
|
|
|
774
578
|
// The playtest steering: one router rule plus the task prompt's carve-out
|
|
775
579
|
// (PLAYTEST_ROUTER_RULE in agent-prompts.ts). On unless the serve was told
|
|
776
580
|
// `CASTLE_AGENT_PLAYTEST=off` (the only env value that does anything; no
|
|
777
|
-
// per-deck switch)
|
|
778
|
-
|
|
779
|
-
function playtestSteering(tasksBackend) {
|
|
780
|
-
if (tasksBackend === 'cursor')
|
|
781
|
-
return false;
|
|
581
|
+
// per-deck switch).
|
|
582
|
+
function playtestSteering() {
|
|
782
583
|
return process.env.CASTLE_AGENT_PLAYTEST !== 'off';
|
|
783
584
|
}
|
|
784
585
|
function planSession(deckDir) {
|
|
@@ -1019,22 +820,6 @@ function humanizeAskBlocks(text) {
|
|
|
1019
820
|
}
|
|
1020
821
|
});
|
|
1021
822
|
}
|
|
1022
|
-
// Monotonic source of smith pseudo-pids (see AgentRunHandle.pid).
|
|
1023
|
-
let smithRunSeq = 0;
|
|
1024
|
-
function makeSmithRunHandle(controller) {
|
|
1025
|
-
smithRunSeq += 1;
|
|
1026
|
-
return {
|
|
1027
|
-
pid: -smithRunSeq,
|
|
1028
|
-
// Reads as "still running" for symmetry, but nothing consumes it for
|
|
1029
|
-
// smith runs: the registry already excludes them by pid sign.
|
|
1030
|
-
exitCode: null,
|
|
1031
|
-
spawnfile: 'castle-smith',
|
|
1032
|
-
kill: () => {
|
|
1033
|
-
controller.abort();
|
|
1034
|
-
return true;
|
|
1035
|
-
},
|
|
1036
|
-
};
|
|
1037
|
-
}
|
|
1038
823
|
function baseName(p) {
|
|
1039
824
|
const parts = p.split(/[\\/]/).filter(Boolean);
|
|
1040
825
|
return parts[parts.length - 1] || p;
|
|
@@ -1082,7 +867,6 @@ function claudeToolFeedLabel(name, input) {
|
|
|
1082
867
|
// content_block_start, before the input JSON streams in). Router-only fallback
|
|
1083
868
|
// so the streaming reply's activity line reflects EVERY tool -- including the
|
|
1084
869
|
// commands/searches claudeToolFeedLabel intentionally drops from task feeds.
|
|
1085
|
-
// Mirrors the cursor toolActivityLabel verbs so the two backends read alike.
|
|
1086
870
|
function genericClaudeToolLabel(name) {
|
|
1087
871
|
const kind = name.toLowerCase();
|
|
1088
872
|
if (kind === CLAUDE_PLAYTEST_TOOL)
|
|
@@ -1099,21 +883,6 @@ function genericClaudeToolLabel(name) {
|
|
|
1099
883
|
return 'Searching the web';
|
|
1100
884
|
return 'Working';
|
|
1101
885
|
}
|
|
1102
|
-
// Human-readable label for a tool_call event, e.g. readToolCall -> "reading
|
|
1103
|
-
// the deck". Shown as the streaming message's activity line.
|
|
1104
|
-
function toolActivityLabel(ev) {
|
|
1105
|
-
const call = ev.tool_call;
|
|
1106
|
-
const key = call ? Object.keys(call).find((k) => k.endsWith('ToolCall')) : undefined;
|
|
1107
|
-
const kind = (key ?? '').slice(0, -'ToolCall'.length).toLowerCase();
|
|
1108
|
-
if (['read', 'glob', 'grep', 'ls', 'list'].some((p) => kind.startsWith(p))) {
|
|
1109
|
-
return 'Reading the deck';
|
|
1110
|
-
}
|
|
1111
|
-
if (['write', 'edit', 'delete', 'mv'].some((p) => kind.startsWith(p)))
|
|
1112
|
-
return 'Editing files';
|
|
1113
|
-
if (['shell', 'bash', 'terminal'].some((p) => kind.startsWith(p)))
|
|
1114
|
-
return 'Running a command';
|
|
1115
|
-
return 'Working';
|
|
1116
|
-
}
|
|
1117
886
|
// Castle's agent CLI keys, delivered to the sandbox as a file
|
|
1118
887
|
// (~/.castle/keys.json) rather than sandbox-wide env -- so an ambient key can't
|
|
1119
888
|
// override a user's own subscription login. Falls back to process.env for
|
|
@@ -1139,41 +908,6 @@ function resolveOpenrouterAuth() {
|
|
|
1139
908
|
return { mode: 'user-key', key: k };
|
|
1140
909
|
return { mode: 'proxy', key: openrouterApiKey() };
|
|
1141
910
|
}
|
|
1142
|
-
// Keys for the SPAWNING backends' env injection (envForAgentSpawn). Smith is
|
|
1143
|
-
// absent by design: it never spawns a CLI -- its OpenRouter key flows through
|
|
1144
|
-
// openrouterApiKey() into runAgentNative's Authorization header instead.
|
|
1145
|
-
const BACKEND_KEY_ENV = {
|
|
1146
|
-
claude: 'ANTHROPIC_API_KEY',
|
|
1147
|
-
cursor: 'CURSOR_API_KEY',
|
|
1148
|
-
};
|
|
1149
|
-
// When we inject Castle's key, any auth.json cursor cached from a DIFFERENT key
|
|
1150
|
-
// -- a rotated-out old key, or a tester's own key we've chosen to override -- is
|
|
1151
|
-
// dead weight: cursor re-auths from the injected env key and ignores it. Leaving
|
|
1152
|
-
// it means a stale API key sits on the sandbox fs, so drop it and keep only the
|
|
1153
|
-
// live key cached. Best-effort; an absent file is the normal case. An OAuth login
|
|
1154
|
-
// (no apiKey field) is never reached here -- that path withholds the key instead.
|
|
1155
|
-
function purgeStaleCursorAuth(home, injectedKey) {
|
|
1156
|
-
try {
|
|
1157
|
-
const authPath = cursorAuthPath(home);
|
|
1158
|
-
const auth = JSON.parse(fs.readFileSync(authPath, 'utf8'));
|
|
1159
|
-
if (auth.apiKey && auth.apiKey !== injectedKey) {
|
|
1160
|
-
fs.rmSync(authPath, { force: true });
|
|
1161
|
-
}
|
|
1162
|
-
}
|
|
1163
|
-
catch {
|
|
1164
|
-
// no auth.json, or unreadable/unparseable -- nothing to purge
|
|
1165
|
-
}
|
|
1166
|
-
}
|
|
1167
|
-
// True when the user has their OWN saved auth for this backend -- a login we
|
|
1168
|
-
// route to directly (and bill to them) instead of Castle's proxy / key. The
|
|
1169
|
-
// claude side lives in byo-auth.ts, which the editor terminal shares.
|
|
1170
|
-
function backendHasSavedAuth(backend) {
|
|
1171
|
-
if (backend === 'claude')
|
|
1172
|
-
return claudeHasSavedLogin();
|
|
1173
|
-
if (backend === 'cursor')
|
|
1174
|
-
return cursorHasUserLogin(os.homedir());
|
|
1175
|
-
return false;
|
|
1176
|
-
}
|
|
1177
911
|
// Env for the plain claude CLI path (NOT claude-via-OpenRouter -- that's
|
|
1178
912
|
// envForOpenrouterSpawn). resolveAnthropicAuth decides the routing:
|
|
1179
913
|
// - proxy: inherit the host-injected ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN
|
|
@@ -1195,32 +929,6 @@ function envForClaudeSpawn(auth) {
|
|
|
1195
929
|
delete env[name];
|
|
1196
930
|
return env;
|
|
1197
931
|
}
|
|
1198
|
-
// Env for a cursor-agent spawn: inject Castle's key ONLY when the backend has no
|
|
1199
|
-
// saved auth of the user's own. This is what lets internal testers run on their
|
|
1200
|
-
// own subscription (log in once in the terminal) instead of Castle's key. The
|
|
1201
|
-
// claude path no longer routes through here -- see envForClaudeSpawn /
|
|
1202
|
-
// resolveAnthropicAuth (this stays generic but is only ever called for cursor).
|
|
1203
|
-
function envForAgentSpawn(backend) {
|
|
1204
|
-
const env = { ...process.env };
|
|
1205
|
-
const keyName = BACKEND_KEY_ENV[backend];
|
|
1206
|
-
if (!keyName)
|
|
1207
|
-
return env;
|
|
1208
|
-
if (backendHasSavedAuth(backend)) {
|
|
1209
|
-
delete env[keyName];
|
|
1210
|
-
}
|
|
1211
|
-
else {
|
|
1212
|
-
const val = castleKeys()[keyName] ?? process.env[keyName];
|
|
1213
|
-
if (val) {
|
|
1214
|
-
env[keyName] = val;
|
|
1215
|
-
if (backend === 'cursor')
|
|
1216
|
-
purgeStaleCursorAuth(os.homedir(), val);
|
|
1217
|
-
}
|
|
1218
|
-
else {
|
|
1219
|
-
delete env[keyName];
|
|
1220
|
-
}
|
|
1221
|
-
}
|
|
1222
|
-
return env;
|
|
1223
|
-
}
|
|
1224
932
|
const DECK_TREE_EXCLUDE = new Set(['node_modules', '.castle', '.git', 'dist', '.DS_Store']);
|
|
1225
933
|
const DECK_TREE_MAX_ENTRIES = 200;
|
|
1226
934
|
// Per-directory listing cap. A successful deck accumulates hundreds of
|
|
@@ -1229,22 +937,11 @@ const DECK_TREE_MAX_ENTRIES = 200;
|
|
|
1229
937
|
// from the snapshot entirely. Summarizing the overflow as "(+N more .pxart)"
|
|
1230
938
|
// keeps every directory visible and turns the count itself into signal.
|
|
1231
939
|
const DECK_TREE_PER_DIR = 15;
|
|
1232
|
-
// Smith-only slimmed caps for the tree section on a turn where
|
|
1233
|
-
// buildDeckContents (below) already inlined the deck's actual source -- the
|
|
1234
|
-
// tree's job shrinks from "the listing" to "orientation for whatever isn't
|
|
1235
|
-
// inlined" (art/binaries, and any overflow buildDeckContents had to list
|
|
1236
|
-
// instead of inline), so it doesn't need the full 200-entry/15-per-dir detail
|
|
1237
|
-
// the CLI backends still get (they have no contents section to lean on).
|
|
1238
|
-
const DECK_TREE_SLIM_MAX_ENTRIES = 60;
|
|
1239
|
-
const DECK_TREE_SLIM_PER_DIR = 8;
|
|
1240
940
|
// Shallow orientation snapshot for router/task prompts. Best-effort by design:
|
|
1241
|
-
// filesystem hiccups should cost context, not fail an agent turn.
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
function buildDeckTree(deckDir, caps) {
|
|
1246
|
-
const maxEntries = caps?.maxEntries ?? DECK_TREE_MAX_ENTRIES;
|
|
1247
|
-
const perDir = caps?.perDir ?? DECK_TREE_PER_DIR;
|
|
941
|
+
// filesystem hiccups should cost context, not fail an agent turn.
|
|
942
|
+
function buildDeckTree(deckDir) {
|
|
943
|
+
const maxEntries = DECK_TREE_MAX_ENTRIES;
|
|
944
|
+
const perDir = DECK_TREE_PER_DIR;
|
|
1248
945
|
const lines = [];
|
|
1249
946
|
const walk = (dir, prefix, depth) => {
|
|
1250
947
|
let entries;
|
|
@@ -1284,153 +981,6 @@ function buildDeckTree(deckDir, caps) {
|
|
|
1284
981
|
walk(deckDir, '', 0);
|
|
1285
982
|
return lines.join('\n');
|
|
1286
983
|
}
|
|
1287
|
-
// Text source extensions buildDeckContents will inline. Deliberately an
|
|
1288
|
-
// allowlist (not "everything readable") -- decks are small web projects, so
|
|
1289
|
-
// this covers what an agent actually hand-edits. SVG is included: it's a text
|
|
1290
|
-
// format read_file already treats as source (view_image explicitly excludes
|
|
1291
|
-
// it for the same reason). .scene is included too, and deliberately not
|
|
1292
|
-
// filtered out despite looking like data: scenes/*.scene and
|
|
1293
|
-
// blueprints/*.scene are JSON (actors, behaviors, layout), and they are
|
|
1294
|
-
// exactly what a router/task agent orients on first when it opens a kit deck
|
|
1295
|
-
// -- leaving them out just forces the hand-read this inlining exists to
|
|
1296
|
-
// avoid (castle.json needs no separate entry -- it's already covered by
|
|
1297
|
-
// `.json`). .pxart is deliberately NOT added even though it's also
|
|
1298
|
-
// JSON-ish text: pixel-art drawings are bulky per-pixel grid data, not
|
|
1299
|
-
// orientation material an agent reads to understand the deck, and they're
|
|
1300
|
-
// meant to be produced by a tool/command, not hand-typed -- inlining them
|
|
1301
|
-
// would just burn budget for content the agent isn't meant to
|
|
1302
|
-
// read-then-copy-edit. True binary/art formats (images, audio, fonts) and
|
|
1303
|
-
// anything else outside this list are skipped for the more obvious reason
|
|
1304
|
-
// that they're unreadable as text.
|
|
1305
|
-
const DECK_CONTENTS_TEXT_EXTS = new Set([
|
|
1306
|
-
'.ts',
|
|
1307
|
-
'.tsx',
|
|
1308
|
-
'.js',
|
|
1309
|
-
'.jsx',
|
|
1310
|
-
'.mjs',
|
|
1311
|
-
'.cjs',
|
|
1312
|
-
'.mts',
|
|
1313
|
-
'.cts',
|
|
1314
|
-
'.json',
|
|
1315
|
-
'.css',
|
|
1316
|
-
'.html',
|
|
1317
|
-
'.md',
|
|
1318
|
-
'.txt',
|
|
1319
|
-
'.svg',
|
|
1320
|
-
'.yml',
|
|
1321
|
-
'.yaml',
|
|
1322
|
-
'.scene',
|
|
1323
|
-
]);
|
|
1324
|
-
// Generated/lockfiles that happen to match the extension allowlist but are
|
|
1325
|
-
// never hand-edited -- inlining a lockfile would just spend budget other
|
|
1326
|
-
// files need for zero benefit (nobody reads a lockfile to plan an edit).
|
|
1327
|
-
const DECK_CONTENTS_SKIP_NAMES = new Set(['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock']);
|
|
1328
|
-
// Per-file cap: keeps one unusually large source file (a generated bundle
|
|
1329
|
-
// accidentally left in the tree, a huge scene) from eating the whole budget
|
|
1330
|
-
// by itself. 16KB comfortably covers real hand-written deck files (kit engine
|
|
1331
|
-
// modules run a few KB each) while still being small next to the total budget
|
|
1332
|
-
// below.
|
|
1333
|
-
const DECK_CONTENTS_PER_FILE_CAP = 16 * 1024;
|
|
1334
|
-
// Safety valve against a pathological tree (mirrors tools.ts's
|
|
1335
|
-
// MAX_WALK_FILES) -- decks are modest-sized web projects, this is not a
|
|
1336
|
-
// normal limit.
|
|
1337
|
-
const DECK_CONTENTS_MAX_WALK_FILES = 5_000;
|
|
1338
|
-
// Smith-only total budgets (bytes), passed by the call sites below. The
|
|
1339
|
-
// router's prompt is already the largest (rules + full transcript + the
|
|
1340
|
-
// whole task board), so it gets a tighter ceiling than a task's -- a task
|
|
1341
|
-
// prompt is comparatively lean and can afford to carry more of the deck.
|
|
1342
|
-
const TASK_DECK_CONTENTS_BUDGET = 80 * 1024;
|
|
1343
|
-
const ROUTER_DECK_CONTENTS_BUDGET = 40 * 1024;
|
|
1344
|
-
// Full-content inlining for smith prompts (buildTaskPrompt/buildRouterPrompt's
|
|
1345
|
-
// `deckContents`): unlike buildDeckTree (shallow, name-only, same for every
|
|
1346
|
-
// backend), this walks the ENTIRE file set the tree covers -- no depth limit
|
|
1347
|
-
// -- because the point is to hand smith the deck's actual code, not just its
|
|
1348
|
-
// shape. Only smith needs this: cursor/claude read files with their own tools
|
|
1349
|
-
// turn over turn, but smith's native loop pays a full round-trip (a tool call
|
|
1350
|
-
// + its result re-entering context on every later iteration) for every read,
|
|
1351
|
-
// and Castle decks are tiny enough that this rarely costs more than one
|
|
1352
|
-
// inlining would anyway -- so front-loading it removes those round trips
|
|
1353
|
-
// entirely for the common small/greenfield deck.
|
|
1354
|
-
//
|
|
1355
|
-
// `budget` (bytes) is the caller's total ceiling (tasks get more room than
|
|
1356
|
-
// the router -- see the call sites in runTaskAgentIn / routerTurnPrompt).
|
|
1357
|
-
// Degrades gracefully under both caps: a file over DECK_CONTENTS_PER_FILE_CAP,
|
|
1358
|
-
// or one that would push the running total over `budget`, is listed by name
|
|
1359
|
-
// instead of inlined (deterministic order -- once the budget is spent every
|
|
1360
|
-
// later file lists too, it never back-fills a smaller one out of order).
|
|
1361
|
-
function buildDeckContents(deckDir, budget) {
|
|
1362
|
-
const files = [];
|
|
1363
|
-
const walk = (dir) => {
|
|
1364
|
-
if (files.length >= DECK_CONTENTS_MAX_WALK_FILES)
|
|
1365
|
-
return;
|
|
1366
|
-
let entries;
|
|
1367
|
-
try {
|
|
1368
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1369
|
-
}
|
|
1370
|
-
catch {
|
|
1371
|
-
return;
|
|
1372
|
-
}
|
|
1373
|
-
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
1374
|
-
for (const entry of entries) {
|
|
1375
|
-
if (DECK_TREE_EXCLUDE.has(entry.name))
|
|
1376
|
-
continue;
|
|
1377
|
-
const abs = path.join(dir, entry.name);
|
|
1378
|
-
if (entry.isDirectory()) {
|
|
1379
|
-
walk(abs);
|
|
1380
|
-
}
|
|
1381
|
-
else if (entry.isFile()) {
|
|
1382
|
-
files.push(abs);
|
|
1383
|
-
if (files.length >= DECK_CONTENTS_MAX_WALK_FILES)
|
|
1384
|
-
return;
|
|
1385
|
-
}
|
|
1386
|
-
}
|
|
1387
|
-
};
|
|
1388
|
-
walk(deckDir);
|
|
1389
|
-
const inlined = [];
|
|
1390
|
-
const listed = [];
|
|
1391
|
-
let used = 0;
|
|
1392
|
-
for (const abs of files) {
|
|
1393
|
-
const rel = path.relative(deckDir, abs).split(path.sep).join('/');
|
|
1394
|
-
if (DECK_CONTENTS_SKIP_NAMES.has(path.basename(rel)))
|
|
1395
|
-
continue;
|
|
1396
|
-
if (!DECK_CONTENTS_TEXT_EXTS.has(path.extname(rel).toLowerCase()))
|
|
1397
|
-
continue;
|
|
1398
|
-
let stat;
|
|
1399
|
-
try {
|
|
1400
|
-
stat = fs.statSync(abs);
|
|
1401
|
-
}
|
|
1402
|
-
catch {
|
|
1403
|
-
continue;
|
|
1404
|
-
}
|
|
1405
|
-
if (stat.size > DECK_CONTENTS_PER_FILE_CAP || used + stat.size > budget) {
|
|
1406
|
-
listed.push(`${rel} (${stat.size}b, not inlined -- use read tool)`);
|
|
1407
|
-
continue;
|
|
1408
|
-
}
|
|
1409
|
-
let content;
|
|
1410
|
-
try {
|
|
1411
|
-
content = fs.readFileSync(abs, 'utf8');
|
|
1412
|
-
}
|
|
1413
|
-
catch {
|
|
1414
|
-
listed.push(`${rel} (not inlined -- use read tool)`);
|
|
1415
|
-
continue;
|
|
1416
|
-
}
|
|
1417
|
-
if (content.includes('\u0000')) {
|
|
1418
|
-
// Looks binary despite the extension allowlist (e.g. a mislabeled
|
|
1419
|
-
// asset) -- skip it exactly like read_file's own NUL sniff would.
|
|
1420
|
-
listed.push(`${rel} (not inlined -- use read tool)`);
|
|
1421
|
-
continue;
|
|
1422
|
-
}
|
|
1423
|
-
inlined.push(`--- ${rel} ---\n${content}`);
|
|
1424
|
-
used += stat.size;
|
|
1425
|
-
}
|
|
1426
|
-
const parts = [];
|
|
1427
|
-
if (inlined.length > 0)
|
|
1428
|
-
parts.push(inlined.join('\n\n'));
|
|
1429
|
-
if (listed.length > 0) {
|
|
1430
|
-
parts.push(`(not inlined -- binary/generated, over the per-file cap, or past the total budget; read these yourself if you need them):\n${listed.join('\n')}`);
|
|
1431
|
-
}
|
|
1432
|
-
return parts.join('\n\n');
|
|
1433
|
-
}
|
|
1434
984
|
const DEFAULT_WELCOME_MESSAGE = "Welcome to an early test of Castle's new engine! You're starting with a blank deck, without the official art or scene editors, but if HTML and JavaScript can do it then I can help you build it.\n\nDo you already know what you want to make, or do you want to figure it out together?";
|
|
1435
985
|
function readClaudeSection(deckDir, section) {
|
|
1436
986
|
const headingRe = new RegExp(`^## ${section}\\s*$`, 'im');
|
|
@@ -1525,7 +1075,6 @@ function createAgentStreamState() {
|
|
|
1525
1075
|
resultIsError: false,
|
|
1526
1076
|
usage: undefined,
|
|
1527
1077
|
sawResult: false,
|
|
1528
|
-
segmentText: '',
|
|
1529
1078
|
needsGap: false,
|
|
1530
1079
|
pendingTools: new Map(),
|
|
1531
1080
|
};
|
|
@@ -1554,32 +1103,24 @@ function formatTokenCount(value) {
|
|
|
1554
1103
|
return `${(value / 1000).toFixed(1)}k`;
|
|
1555
1104
|
return String(value);
|
|
1556
1105
|
}
|
|
1557
|
-
// One usage line per finished agent run
|
|
1558
|
-
//
|
|
1559
|
-
//
|
|
1560
|
-
//
|
|
1561
|
-
function logAgentUsage(label,
|
|
1106
|
+
// One usage line per finished agent run so the serve's stderr is a running
|
|
1107
|
+
// cost ledger for BOTH roles: `label` is "router" or "task <id>". Task runs
|
|
1108
|
+
// log once per attempt (each attempt is its own CLI process with its own
|
|
1109
|
+
// usage).
|
|
1110
|
+
function logAgentUsage(label, usage) {
|
|
1562
1111
|
if (!usage)
|
|
1563
1112
|
return;
|
|
1564
1113
|
const input = formatTokenCount(usage.input_tokens);
|
|
1565
1114
|
const read = formatTokenCount(usage.cache_read_input_tokens);
|
|
1566
1115
|
const created = formatTokenCount(usage.cache_creation_input_tokens);
|
|
1567
1116
|
const output = formatTokenCount(usage.output_tokens);
|
|
1568
|
-
console.error(`[agent usage] ${label}
|
|
1117
|
+
console.error(`[agent usage] ${label}: input=${input} cache_read=${read} cache_created=${created} output=${output}`);
|
|
1569
1118
|
}
|
|
1570
1119
|
// Build the per-run stdout event handler over a shared mutable parser state.
|
|
1571
|
-
// Splitting the
|
|
1572
|
-
//
|
|
1573
|
-
//
|
|
1574
|
-
//
|
|
1575
|
-
// Cursor closes each text segment (e.g. right before a tool call) by
|
|
1576
|
-
// re-emitting the segment's full text as one more delta-shaped event; track the
|
|
1577
|
-
// current segment so those re-emissions are dropped instead of duplicating
|
|
1578
|
-
// lines. Segment boundaries also need a paragraph gap -- cursor starts the next
|
|
1579
|
-
// segment without one, which glues "Checking the deck..." lines onto the
|
|
1580
|
-
// previous paragraph. Claude tool_use blocks stream their input JSON by block
|
|
1581
|
-
// index, accumulated in pendingTools so content_block_stop can label them with
|
|
1582
|
-
// the real file / command (and drop progress-file writes).
|
|
1120
|
+
// Splitting the stream decoding out of runAgentCli keeps each within the
|
|
1121
|
+
// max-lines budget. A tool_use block streams its input JSON by block index,
|
|
1122
|
+
// accumulated in pendingTools so content_block_stop can label it with the
|
|
1123
|
+
// real file / command (and drop progress-file writes).
|
|
1583
1124
|
function makeAgentEventHandler(opts, state) {
|
|
1584
1125
|
const emitDelta = (rawDelta) => {
|
|
1585
1126
|
let delta = rawDelta;
|
|
@@ -1589,7 +1130,6 @@ function makeAgentEventHandler(opts, state) {
|
|
|
1589
1130
|
delta = (state.accumulated.endsWith('\n') ? '\n' : '\n\n') + delta;
|
|
1590
1131
|
}
|
|
1591
1132
|
}
|
|
1592
|
-
state.segmentText += delta;
|
|
1593
1133
|
state.accumulated += delta;
|
|
1594
1134
|
opts.onDelta?.(delta);
|
|
1595
1135
|
opts.onActivity?.(null);
|
|
@@ -1612,9 +1152,8 @@ function makeAgentEventHandler(opts, state) {
|
|
|
1612
1152
|
}
|
|
1613
1153
|
else if (e.content_block?.type === 'thinking') {
|
|
1614
1154
|
state.needsGap = true;
|
|
1615
|
-
// Surface extended thinking as the activity line
|
|
1616
|
-
//
|
|
1617
|
-
// emitDelta's onActivity(null); a tool block relabels it.
|
|
1155
|
+
// Surface extended thinking as the activity line. The next text delta
|
|
1156
|
+
// clears it via emitDelta's onActivity(null); a tool block relabels it.
|
|
1618
1157
|
opts.onActivity?.('Thinking');
|
|
1619
1158
|
}
|
|
1620
1159
|
}
|
|
@@ -1662,51 +1201,11 @@ function makeAgentEventHandler(opts, state) {
|
|
|
1662
1201
|
state.usage = parseCliUsage(ev.usage);
|
|
1663
1202
|
}
|
|
1664
1203
|
};
|
|
1665
|
-
return
|
|
1666
|
-
if (opts.parser === 'claude') {
|
|
1667
|
-
handleClaudeEvent(ev);
|
|
1668
|
-
return;
|
|
1669
|
-
}
|
|
1670
|
-
if (ev.type === 'assistant' && typeof ev.timestamp_ms === 'number') {
|
|
1671
|
-
const message = ev.message;
|
|
1672
|
-
const delta = (message?.content ?? [])
|
|
1673
|
-
.map((c) => (typeof c?.text === 'string' ? c.text : ''))
|
|
1674
|
-
.join('');
|
|
1675
|
-
if (!delta)
|
|
1676
|
-
return;
|
|
1677
|
-
const trimmed = delta.trim();
|
|
1678
|
-
if (trimmed.length >= 16 && state.segmentText.trim().endsWith(trimmed))
|
|
1679
|
-
return;
|
|
1680
|
-
emitDelta(delta);
|
|
1681
|
-
}
|
|
1682
|
-
else if (ev.type === 'tool_call') {
|
|
1683
|
-
state.segmentText = '';
|
|
1684
|
-
state.needsGap = true;
|
|
1685
|
-
if (ev.subtype === 'started')
|
|
1686
|
-
opts.onActivity?.(toolActivityLabel(ev));
|
|
1687
|
-
}
|
|
1688
|
-
else if (ev.type === 'thinking') {
|
|
1689
|
-
state.segmentText = '';
|
|
1690
|
-
state.needsGap = true;
|
|
1691
|
-
opts.onActivity?.('Thinking');
|
|
1692
|
-
}
|
|
1693
|
-
else if (ev.type === 'result') {
|
|
1694
|
-
state.sawResult = true;
|
|
1695
|
-
// An EMPTY string result falls back to the streamed text. Models that
|
|
1696
|
-
// return their blocks as `text, thinking` (gemini through OpenRouter's
|
|
1697
|
-
// anthropic-compatible endpoint) end the turn on an empty thinking block,
|
|
1698
|
-
// and the CLI reports `result: ""` even though the answer streamed fine.
|
|
1699
|
-
state.finalText = typeof ev.result === 'string' && ev.result ? ev.result : state.accumulated;
|
|
1700
|
-
state.resultIsError = ev.is_error === true;
|
|
1701
|
-
state.usage = parseCliUsage(ev.usage);
|
|
1702
|
-
}
|
|
1703
|
-
};
|
|
1204
|
+
return handleClaudeEvent;
|
|
1704
1205
|
}
|
|
1705
|
-
// One headless
|
|
1706
|
-
//
|
|
1707
|
-
//
|
|
1708
|
-
// message (skipped). Claude: stream_event wraps anthropic SSE deltas. Both
|
|
1709
|
-
// end with a result event carrying the canonical final text.
|
|
1206
|
+
// One headless claude CLI run, normalized to delta/activity/result hooks:
|
|
1207
|
+
// stream_event wraps anthropic SSE deltas, and a result event carries the
|
|
1208
|
+
// canonical final text.
|
|
1710
1209
|
function runAgentCli(opts) {
|
|
1711
1210
|
return new Promise((resolve) => {
|
|
1712
1211
|
// spawn() throws SYNCHRONOUSLY for the failures the OS rejects at exec
|
|
@@ -1792,9 +1291,7 @@ function runAgentCli(opts) {
|
|
|
1792
1291
|
settle({
|
|
1793
1292
|
ok: false,
|
|
1794
1293
|
finalText: state.accumulated,
|
|
1795
|
-
// The binary itself wouldn't start (ENOENT, EACCES).
|
|
1796
|
-
// actually being spawned -- this used to say "cursor-agent" for every
|
|
1797
|
-
// backend, so a missing `claude` reported the wrong tool.
|
|
1294
|
+
// The binary itself wouldn't start (ENOENT, EACCES).
|
|
1798
1295
|
error: `could not run ${opts.command}: ${err.message}`,
|
|
1799
1296
|
failure: { kind: 'spawn', detail: `${opts.command}: ${err.message}` },
|
|
1800
1297
|
});
|
|
@@ -1808,8 +1305,9 @@ function runAgentCli(opts) {
|
|
|
1808
1305
|
// do write there. Both are read BEFORE the 300-char truncation below.
|
|
1809
1306
|
//
|
|
1810
1307
|
// ONLY for OpenRouter-routed runs (opts.openrouterModel is set only when
|
|
1811
|
-
//
|
|
1812
|
-
//
|
|
1308
|
+
// the role's model is "openrouter"): the classifier's copy is
|
|
1309
|
+
// OpenRouter-branded, so a
|
|
1310
|
+
// plain claude failure must NOT run through it -- an Anthropic 529
|
|
1813
1311
|
// would otherwise read as "OpenRouter is busy", naming the wrong
|
|
1814
1312
|
// provider. Plain runs keep their provider-neutral "exit" copy, exactly
|
|
1815
1313
|
// as before this feature.
|
|
@@ -1837,94 +1335,6 @@ function runAgentCli(opts) {
|
|
|
1837
1335
|
});
|
|
1838
1336
|
});
|
|
1839
1337
|
}
|
|
1840
|
-
// One smith (native castle agent) run, adapted to runAgentCli's contract so
|
|
1841
|
-
// every caller downstream of runAgentTurn is backend-agnostic:
|
|
1842
|
-
// - NativeRunResult.text -> finalText; error/usage/crashed pass through by
|
|
1843
|
-
// name. `ok` is derived as !error && !crashed -- there is no process exit
|
|
1844
|
-
// code; those two fields are the whole story.
|
|
1845
|
-
// - Cancellation: one AbortController per run, registered in the same
|
|
1846
|
-
// `children` set the CLI runs use, via a handle whose kill() aborts it
|
|
1847
|
-
// (see AgentRunHandle). Interrupts (killRouterChildren), task halts
|
|
1848
|
-
// (haltTask), and serve shutdown all just work. An aborted run resolves
|
|
1849
|
-
// with error "agent run stopped" and NO crashed flag -- same downstream
|
|
1850
|
-
// behavior as a SIGKILLed CLI child (router: the epoch check routes to
|
|
1851
|
-
// settleInterruptedTurn; tasks: stopRequested returns before the retry
|
|
1852
|
-
// check).
|
|
1853
|
-
// - Timeout is enforced INSIDE runAgentNative (same "agent run timed out"
|
|
1854
|
-
// error string runAgentCli produces, so classifyRouterFailure's buckets
|
|
1855
|
-
// hold); connect failures surface as "could not run openrouter: ..."
|
|
1856
|
-
// matching the "could not run" spawn bucket.
|
|
1857
|
-
// - logPath appends structured JSONL run events to the SAME files the CLI
|
|
1858
|
-
// backends stream their raw log into (tasks/<id>/log.jsonl,
|
|
1859
|
-
// .castle/agent/router-log.jsonl) -- see createRunLogger in native/loop.ts
|
|
1860
|
-
// for the line shapes.
|
|
1861
|
-
// - attachments (router turns): deck-relative image paths that become
|
|
1862
|
-
// image_url content parts in the run's initial user message -- the native
|
|
1863
|
-
// tool set can't read image bytes (read_file rejects binaries), unlike the
|
|
1864
|
-
// CLI agents which re-read attachment files themselves.
|
|
1865
|
-
async function runAgentSmith(opts) {
|
|
1866
|
-
const controller = new AbortController();
|
|
1867
|
-
const handle = makeSmithRunHandle(controller);
|
|
1868
|
-
opts.children.add(handle);
|
|
1869
|
-
// The pseudo-pid lands in task.pid so haltTask can find this run's handle
|
|
1870
|
-
// in the shared children set, exactly like a CLI child's real pid.
|
|
1871
|
-
opts.onSpawn?.(handle.pid);
|
|
1872
|
-
try {
|
|
1873
|
-
const result = await runAgentNative({
|
|
1874
|
-
cwd: opts.cwd,
|
|
1875
|
-
role: opts.role,
|
|
1876
|
-
extraHeaders: opts.extraHeaders,
|
|
1877
|
-
model: opts.model,
|
|
1878
|
-
apiKey: opts.apiKey,
|
|
1879
|
-
baseUrl: opts.baseUrl,
|
|
1880
|
-
reasoningEffort: opts.openrouterTuning?.reasoningEffort,
|
|
1881
|
-
routing: opts.openrouterTuning?.routing,
|
|
1882
|
-
// "" (auto) becomes undefined so no provider.order is sent.
|
|
1883
|
-
providerTier: opts.openrouterTuning?.providerTier || undefined,
|
|
1884
|
-
prompt: opts.prompt,
|
|
1885
|
-
system: opts.system,
|
|
1886
|
-
systemReminder: opts.systemReminder,
|
|
1887
|
-
attachments: opts.attachments,
|
|
1888
|
-
timeoutMs: opts.timeoutMs,
|
|
1889
|
-
logPath: opts.logPath,
|
|
1890
|
-
playtest: opts.playtest,
|
|
1891
|
-
restart: opts.restart,
|
|
1892
|
-
signal: controller.signal,
|
|
1893
|
-
onDelta: opts.onDelta,
|
|
1894
|
-
onActivity: opts.onActivity,
|
|
1895
|
-
onThinking: opts.onThinking,
|
|
1896
|
-
});
|
|
1897
|
-
return {
|
|
1898
|
-
ok: !result.error && !result.crashed,
|
|
1899
|
-
finalText: result.text,
|
|
1900
|
-
error: result.error,
|
|
1901
|
-
failure: result.failure,
|
|
1902
|
-
usage: result.usage,
|
|
1903
|
-
playtestFrames: result.playtestFrames,
|
|
1904
|
-
crashed: result.crashed,
|
|
1905
|
-
};
|
|
1906
|
-
}
|
|
1907
|
-
catch (err) {
|
|
1908
|
-
// runAgentNative resolves (never rejects) by contract; this is pure
|
|
1909
|
-
// belt-and-braces so an unexpected throw degrades like a spawn failure
|
|
1910
|
-
// instead of rejecting through startTask's catch.
|
|
1911
|
-
return {
|
|
1912
|
-
ok: false,
|
|
1913
|
-
finalText: '',
|
|
1914
|
-
error: `could not run openrouter: ${err instanceof Error ? err.message : String(err)}`,
|
|
1915
|
-
};
|
|
1916
|
-
}
|
|
1917
|
-
finally {
|
|
1918
|
-
opts.children.delete(handle);
|
|
1919
|
-
}
|
|
1920
|
-
}
|
|
1921
|
-
// True when a role's path goes through OpenRouter: the smith native loop, or
|
|
1922
|
-
// the claude CLI routed at OpenRouter. The client has a twin of this in
|
|
1923
|
-
// conductor.tsx (it decides whether to show the slug field) -- keep them in
|
|
1924
|
-
// step; this one decides whether the slug is worth validating at all.
|
|
1925
|
-
export function roleUsesOpenrouter(backend, claudeModel) {
|
|
1926
|
-
return backend === 'smith' || (backend === 'claude' && claudeModel === 'openrouter');
|
|
1927
|
-
}
|
|
1928
1338
|
function configFailure(reason, detail, extra) {
|
|
1929
1339
|
return { kind: 'config', reason, detail, ...extra };
|
|
1930
1340
|
}
|
|
@@ -1938,7 +1348,7 @@ function configFailure(reason, detail, extra) {
|
|
|
1938
1348
|
// resolve "unavailable" on any network trouble, and we allow the run rather
|
|
1939
1349
|
// than invent a verdict.
|
|
1940
1350
|
async function preflightOpenrouterRun(opts) {
|
|
1941
|
-
if (
|
|
1351
|
+
if (opts.claudeModel !== 'openrouter')
|
|
1942
1352
|
return null;
|
|
1943
1353
|
const auth = opts.orAuth ?? resolveOpenrouterAuth();
|
|
1944
1354
|
const apiKey = auth.key;
|
|
@@ -1989,37 +1399,37 @@ function notifyAgentRunFinished() {
|
|
|
1989
1399
|
listener();
|
|
1990
1400
|
}
|
|
1991
1401
|
// True when THIS run spends Castle's credential rather than the user's own.
|
|
1992
|
-
// Resolves exactly as buildAgentInvocation
|
|
1993
|
-
//
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
// envForAgentSpawn withholds Castle's key when the user has an OAuth login, so
|
|
1997
|
-
// a run with one is already going to their account and the daily limit is not
|
|
1998
|
-
// Castle's to enforce over it. It used to answer `true` unconditionally, which
|
|
1999
|
-
// refused a user on their own subscription once Castle's budget ran out --
|
|
2000
|
-
// while the editor offered them that sign-in and promised it bypassed the
|
|
2001
|
-
// limit. Deferring only to a LOGIN is what keeps this from being a hole: a
|
|
2002
|
-
// cursor run on Castle's key stays gated, and cursor is unmetered (its traffic
|
|
2003
|
-
// never reaches the proxy), so an ungated one would be a free ride.
|
|
2004
|
-
function runIsCastlePaid(backend, claudeModel, orAuth) {
|
|
2005
|
-
if (backend === 'cursor')
|
|
2006
|
-
return !cursorHasUserLogin(os.homedir());
|
|
2007
|
-
return roleUsesOpenrouter(backend, claudeModel)
|
|
1402
|
+
// Resolves exactly as buildAgentInvocation will, one step earlier, so the
|
|
1403
|
+
// gate and the run can never disagree about who is paying.
|
|
1404
|
+
function runIsCastlePaid(claudeModel, orAuth) {
|
|
1405
|
+
return claudeModel === 'openrouter'
|
|
2008
1406
|
? (orAuth ?? resolveOpenrouterAuth()).mode === 'proxy'
|
|
2009
1407
|
: resolveAnthropicAuth().mode === 'proxy';
|
|
2010
1408
|
}
|
|
2011
1409
|
// Whether Castle's budget is this editor's to spend at all, which is what makes
|
|
2012
1410
|
// the usage bar worth drawing. Both roles count, and they can disagree: an
|
|
2013
|
-
// Anthropic login covers
|
|
2014
|
-
// still spends Castle's. Re-read per refresh, so adding or
|
|
2015
|
-
// credential (or switching a role's
|
|
2016
|
-
//
|
|
2017
|
-
// A cursor role on Castle's key does draw a bar its own runs never move --
|
|
2018
|
-
// cursor is unmetered by contract. That is a display question, not a payment
|
|
2019
|
-
// one, and it is not settled here.
|
|
1411
|
+
// Anthropic login covers a role on a claude alias while a role routed through
|
|
1412
|
+
// OpenRouter still spends Castle's key. Re-read per refresh, so adding or
|
|
1413
|
+
// removing a credential (or switching a role's model) moves the bar on the
|
|
1414
|
+
// next one.
|
|
2020
1415
|
function anyRoleIsCastlePaid(settings) {
|
|
2021
|
-
return (
|
|
2022
|
-
|
|
1416
|
+
return roleSpends(settings).some((role) => role.castlePaid);
|
|
1417
|
+
}
|
|
1418
|
+
// The two roles as the spend questions see them: who pays, and -- when the
|
|
1419
|
+
// path goes through OpenRouter at all -- which slug the payment is for. Both
|
|
1420
|
+
// halves are answered the way the RUN answers them (runIsCastlePaid), so "this
|
|
1421
|
+
// editor is free" can never disagree with what the proxy actually bills. A role
|
|
1422
|
+
// that doesn't reach OpenRouter carries no slug: the fixed claude aliases are
|
|
1423
|
+
// metered whatever is free.
|
|
1424
|
+
function roleSpends(settings) {
|
|
1425
|
+
return ['router', 'tasks'].map((role) => {
|
|
1426
|
+
const claudeModel = role === 'router' ? settings.routerClaudeModel : settings.tasksClaudeModel;
|
|
1427
|
+
const slug = role === 'router' ? settings.routerOpenrouterModel : settings.tasksOpenrouterModel;
|
|
1428
|
+
return {
|
|
1429
|
+
castlePaid: runIsCastlePaid(claudeModel, null),
|
|
1430
|
+
slug: claudeModel === 'openrouter' ? slug : null,
|
|
1431
|
+
};
|
|
1432
|
+
});
|
|
2023
1433
|
}
|
|
2024
1434
|
// The proxy 403s a spent-out user -- or one who asked for a model they don't
|
|
2025
1435
|
// have -- mid-stream, which a CLI surfaces as a generic provider error after a
|
|
@@ -2029,13 +1439,16 @@ function anyRoleIsCastlePaid(settings) {
|
|
|
2029
1439
|
// Only the claude aliases are checked, which is exactly what the picker offers.
|
|
2030
1440
|
// A free-form OpenRouter slug naming a restricted model is left to the proxy:
|
|
2031
1441
|
// resolving an arbitrary slug to what it bills as is its job, not the editor's.
|
|
2032
|
-
async function castleSpendRefusal(
|
|
2033
|
-
if (!runIsCastlePaid(
|
|
1442
|
+
async function castleSpendRefusal(claudeModel, openrouterModel, orAuth) {
|
|
1443
|
+
if (!runIsCastlePaid(claudeModel, orAuth))
|
|
2034
1444
|
return null;
|
|
2035
1445
|
const budget = await fetchBudget();
|
|
2036
1446
|
if (!budget)
|
|
2037
1447
|
return null;
|
|
2038
|
-
|
|
1448
|
+
// Only a run on a fixed alias reaches the proxy as that alias; on
|
|
1449
|
+
// claude-via-OpenRouter the stored alias is an inactive leftover, and
|
|
1450
|
+
// refusing on it would block a run that never asks for it.
|
|
1451
|
+
if (claudeModel !== 'openrouter' && claudeModelBlocked(claudeModel, budget)) {
|
|
2039
1452
|
return {
|
|
2040
1453
|
kind: 'config',
|
|
2041
1454
|
reason: 'model-not-allowed',
|
|
@@ -2043,6 +1456,15 @@ async function castleSpendRefusal(backend, claudeModel, orAuth) {
|
|
|
2043
1456
|
model: claudeModel,
|
|
2044
1457
|
};
|
|
2045
1458
|
}
|
|
1459
|
+
// Credit state cannot pre-empt a model Castle pays for: an empty balance and
|
|
1460
|
+
// a reached daily cap both describe budget this run will not touch, and
|
|
1461
|
+
// refusing here would strand a user on the free default with no way to keep
|
|
1462
|
+
// working. The free model has its own (invisible) daily cap, enforced where
|
|
1463
|
+
// every other spend rule is -- at the proxy, whose 403 carries copy of its
|
|
1464
|
+
// own (castleFreeModelLimit in agent-failures).
|
|
1465
|
+
if (claudeModel === 'openrouter' && modelIsFree(openrouterModel, budget.freeModelPrefixes)) {
|
|
1466
|
+
return null;
|
|
1467
|
+
}
|
|
2046
1468
|
if (!budget.blocked)
|
|
2047
1469
|
return null;
|
|
2048
1470
|
const credits = await fetchAiCredits();
|
|
@@ -2068,8 +1490,8 @@ const PICKER_CLAUDE_MODELS = ['sonnet', 'opus', 'fable'];
|
|
|
2068
1490
|
* Which of the picker's claude models this editor can't use. Gated on the
|
|
2069
1491
|
* ANTHROPIC credential specifically, not on `anyRoleIsCastlePaid` (which draws
|
|
2070
1492
|
* the usage bar): those two disagree exactly when one role runs on Castle's
|
|
2071
|
-
* OpenRouter key
|
|
2072
|
-
*
|
|
1493
|
+
* OpenRouter key while the user's own Anthropic key or login covers every
|
|
1494
|
+
* claude run. Those runs never reach the proxy, so
|
|
2073
1495
|
* nothing about them is Castle's to restrict, and the picker must keep offering
|
|
2074
1496
|
* the model. A claude run on a fixed alias always resolves through
|
|
2075
1497
|
* resolveAnthropicAuth, so no role's settings enter into this.
|
|
@@ -2079,7 +1501,7 @@ function blockedClaudeModels(budget) {
|
|
|
2079
1501
|
return [];
|
|
2080
1502
|
return PICKER_CLAUDE_MODELS.filter((m) => claudeModelBlocked(m, budget));
|
|
2081
1503
|
}
|
|
2082
|
-
function usageFrame(budget, credits) {
|
|
1504
|
+
function usageFrame(budget, credits, settings) {
|
|
2083
1505
|
if (!budget)
|
|
2084
1506
|
return null;
|
|
2085
1507
|
return {
|
|
@@ -2091,6 +1513,7 @@ function usageFrame(budget, credits) {
|
|
|
2091
1513
|
credits,
|
|
2092
1514
|
creditsExhausted: castleCreditsExhausted(budget, credits),
|
|
2093
1515
|
blockedClaudeModels: blockedClaudeModels(budget),
|
|
1516
|
+
freeTier: freeTierForRoles(roleSpends(settings), budget.freeModelPrefixes),
|
|
2094
1517
|
};
|
|
2095
1518
|
}
|
|
2096
1519
|
/**
|
|
@@ -2111,7 +1534,8 @@ function createUsageFeed(opts) {
|
|
|
2111
1534
|
let credits = null;
|
|
2112
1535
|
let budgetPolls = 0;
|
|
2113
1536
|
async function readUsage(refreshCredits = false) {
|
|
2114
|
-
|
|
1537
|
+
const settings = opts.settings();
|
|
1538
|
+
if (!anyRoleIsCastlePaid(settings)) {
|
|
2115
1539
|
credits = null;
|
|
2116
1540
|
return null;
|
|
2117
1541
|
}
|
|
@@ -2127,7 +1551,7 @@ function createUsageFeed(opts) {
|
|
|
2127
1551
|
if (nextCredits)
|
|
2128
1552
|
credits = nextCredits;
|
|
2129
1553
|
}
|
|
2130
|
-
return usageFrame(budget, credits);
|
|
1554
|
+
return usageFrame(budget, credits, settings);
|
|
2131
1555
|
}
|
|
2132
1556
|
function publishUsage(next) {
|
|
2133
1557
|
if (JSON.stringify(next ?? null) === JSON.stringify(latest ?? null))
|
|
@@ -2159,17 +1583,13 @@ function createUsageFeed(opts) {
|
|
|
2159
1583
|
},
|
|
2160
1584
|
};
|
|
2161
1585
|
}
|
|
2162
|
-
//
|
|
2163
|
-
//
|
|
2164
|
-
// (buildAgentInvocation -> runAgentCli). Everything downstream consumes the
|
|
2165
|
-
// same CliRunResult contract either way.
|
|
1586
|
+
// One agent turn: buildAgentInvocation -> runAgentCli, with the pre-flight
|
|
1587
|
+
// refusals that stop a run before it spawns.
|
|
2166
1588
|
async function runAgentTurn(opts) {
|
|
2167
1589
|
// Resolve the OpenRouter credential ONCE and reuse it for pre-flight, the run,
|
|
2168
1590
|
// and metering, so validation, spend, and the direct/proxy routing decision
|
|
2169
1591
|
// can never diverge. Null on a non-OpenRouter path.
|
|
2170
|
-
const orAuth =
|
|
2171
|
-
? resolveOpenrouterAuth()
|
|
2172
|
-
: null;
|
|
1592
|
+
const orAuth = opts.claudeModel === 'openrouter' ? resolveOpenrouterAuth() : null;
|
|
2173
1593
|
// Deterministic config errors stop here: nothing spawned, no request issued,
|
|
2174
1594
|
// nothing billed. Returned (not thrown) because the callers' catch paths
|
|
2175
1595
|
// emit generic "something went wrong" copy, which would bury the specific
|
|
@@ -2177,7 +1597,7 @@ async function runAgentTurn(opts) {
|
|
|
2177
1597
|
// refuses is the same shape of answer, and comes second so a misconfigured
|
|
2178
1598
|
// run is still reported as misconfigured.
|
|
2179
1599
|
const failure = (await preflightOpenrouterRun({ ...opts, orAuth })) ??
|
|
2180
|
-
(await castleSpendRefusal(opts.
|
|
1600
|
+
(await castleSpendRefusal(opts.claudeModel, opts.openrouterModel, orAuth));
|
|
2181
1601
|
if (failure) {
|
|
2182
1602
|
return {
|
|
2183
1603
|
ok: false,
|
|
@@ -2194,46 +1614,11 @@ async function runAgentTurn(opts) {
|
|
|
2194
1614
|
// conversation -- nothing is resumed).
|
|
2195
1615
|
const sessionId = newAgentSessionId(opts.role);
|
|
2196
1616
|
const settled = (run) => run.finally(() => notifyAgentRunFinished());
|
|
2197
|
-
|
|
2198
|
-
// roleUsesOpenrouter is true for smith, so orAuth is non-null here.
|
|
2199
|
-
const direct = orAuth.mode === 'user-key';
|
|
2200
|
-
return settled(runAgentSmith({
|
|
2201
|
-
cwd: opts.cwd,
|
|
2202
|
-
role: opts.role,
|
|
2203
|
-
apiKey: orAuth.key,
|
|
2204
|
-
baseUrl: direct ? OPENROUTER_DIRECT_CHAT_BASE : undefined,
|
|
2205
|
-
extraHeaders: meteringHeaders({
|
|
2206
|
-
deckDir: opts.cwd,
|
|
2207
|
-
sessionId,
|
|
2208
|
-
route: 'openrouter',
|
|
2209
|
-
direct,
|
|
2210
|
-
}),
|
|
2211
|
-
model: opts.openrouterModel,
|
|
2212
|
-
prompt: opts.prompt,
|
|
2213
|
-
system: opts.systemPrompt,
|
|
2214
|
-
// Mirrors claude's --append-system-prompt for tasks (the native loop
|
|
2215
|
-
// appends it to its own system framing).
|
|
2216
|
-
systemReminder: opts.role === 'task' ? CLAUDE_TASK_SYSTEM_REMINDER : undefined,
|
|
2217
|
-
attachments: opts.attachments,
|
|
2218
|
-
openrouterTuning: opts.openrouterTuning,
|
|
2219
|
-
timeoutMs: opts.timeoutMs,
|
|
2220
|
-
logPath: opts.logPath,
|
|
2221
|
-
playtest: opts.playtest,
|
|
2222
|
-
restart: opts.restart,
|
|
2223
|
-
children: opts.children,
|
|
2224
|
-
onDelta: opts.onDelta,
|
|
2225
|
-
onActivity: opts.onActivity,
|
|
2226
|
-
onThinking: opts.onThinking,
|
|
2227
|
-
onSpawn: opts.onSpawn,
|
|
2228
|
-
}));
|
|
2229
|
-
}
|
|
2230
|
-
const invocation = buildAgentInvocation(opts.backend, opts.role, opts.prompt, opts.claudeModel, opts.openrouterModel, { sessionId, deckDir: opts.cwd }, opts.taskId, opts.systemPrompt);
|
|
2231
|
-
const startedMs = Date.now();
|
|
1617
|
+
const invocation = buildAgentInvocation(opts.role, opts.prompt, opts.claudeModel, opts.openrouterModel, { sessionId, deckDir: opts.cwd }, opts.taskId, opts.systemPrompt);
|
|
2232
1618
|
const run = runAgentCli({
|
|
2233
1619
|
cwd: opts.cwd,
|
|
2234
1620
|
command: invocation.command,
|
|
2235
1621
|
args: invocation.args,
|
|
2236
|
-
parser: parserForBackend(opts.backend),
|
|
2237
1622
|
env: invocation.env,
|
|
2238
1623
|
timeoutMs: opts.timeoutMs,
|
|
2239
1624
|
logPath: opts.logPath,
|
|
@@ -2243,25 +1628,9 @@ async function runAgentTurn(opts) {
|
|
|
2243
1628
|
onThinking: opts.onThinking,
|
|
2244
1629
|
onSpawn: opts.onSpawn,
|
|
2245
1630
|
labelUnknownTools: opts.labelUnknownTools,
|
|
2246
|
-
openrouterModel:
|
|
2247
|
-
? opts.openrouterModel
|
|
2248
|
-
: undefined,
|
|
1631
|
+
openrouterModel: opts.claudeModel === 'openrouter' ? opts.openrouterModel : undefined,
|
|
2249
1632
|
});
|
|
2250
|
-
|
|
2251
|
-
// run is reported from here. Every other backend is already recorded upstream,
|
|
2252
|
-
// and reporting them here too would double-count them in the same table.
|
|
2253
|
-
if (opts.backend !== 'cursor')
|
|
2254
|
-
return settled(run);
|
|
2255
|
-
return settled(run.then((result) => {
|
|
2256
|
-
reportCursorRun({
|
|
2257
|
-
deckDir: opts.cwd,
|
|
2258
|
-
sessionId,
|
|
2259
|
-
model: CURSOR_MODEL,
|
|
2260
|
-
durationMs: Date.now() - startedMs,
|
|
2261
|
-
ok: result.ok,
|
|
2262
|
-
});
|
|
2263
|
-
return result;
|
|
2264
|
-
}));
|
|
1633
|
+
return settled(run);
|
|
2265
1634
|
}
|
|
2266
1635
|
// -- task store ---------------------------------------------------------------
|
|
2267
1636
|
function persistTaskFile(tasksDir, task) {
|
|
@@ -2513,7 +1882,7 @@ function sleep(ms) {
|
|
|
2513
1882
|
// Chained-promise gate spacing out task-agent PROCESS LAUNCHES -- deliberately
|
|
2514
1883
|
// NOT a queue class, just a promise each launch chains onto. Why: the
|
|
2515
1884
|
// 2026-07 keychain-contention incident (see TASK_SPAWN_STAGGER_MS above) was
|
|
2516
|
-
// triggered by several
|
|
1885
|
+
// triggered by several agent CLI processes starting at the exact same
|
|
2517
1886
|
// instant; spacing consecutive launches out by TASK_SPAWN_STAGGER_MS avoids
|
|
2518
1887
|
// that without limiting how many can run concurrently once they're up.
|
|
2519
1888
|
let taskSpawnGate = Promise.resolve();
|
|
@@ -2536,13 +1905,6 @@ async function waitBeforeTaskRetry(attempt, stopRequested, taskId) {
|
|
|
2536
1905
|
async function runTaskAgentIn(ctx, task) {
|
|
2537
1906
|
const dir = path.join(ctx.tasksDir, task.id);
|
|
2538
1907
|
const relDir = path.relative(ctx.deckDir, dir);
|
|
2539
|
-
// Deck source inlining is smith-only (see buildDeckContents): cursor/claude
|
|
2540
|
-
// read files themselves turn over turn, so computing and sending this
|
|
2541
|
-
// would be pure prompt bloat for them with no offsetting benefit.
|
|
2542
|
-
const isSmith = ctx.backend === 'smith';
|
|
2543
|
-
const deckContents = isSmith
|
|
2544
|
-
? buildDeckContents(ctx.deckDir, TASK_DECK_CONTENTS_BUDGET)
|
|
2545
|
-
: undefined;
|
|
2546
1908
|
const taskPrompt = buildTaskPrompt({
|
|
2547
1909
|
deckLabel: ctx.deckLabel,
|
|
2548
1910
|
taskId: task.id,
|
|
@@ -2552,14 +1914,9 @@ async function runTaskAgentIn(ctx, task) {
|
|
|
2552
1914
|
notesPath: path.join(relDir, 'notes.md'),
|
|
2553
1915
|
handoffPath: path.join(relDir, 'handoff'),
|
|
2554
1916
|
depsSummary: ctx.depsSummary,
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
deckTree: buildDeckTree(ctx.deckDir, isSmith
|
|
2559
|
-
? { maxEntries: DECK_TREE_SLIM_MAX_ENTRIES, perDir: DECK_TREE_SLIM_PER_DIR }
|
|
2560
|
-
: undefined),
|
|
2561
|
-
deckContents,
|
|
2562
|
-
quickReference: quickReferenceFor(ctx.deckDir, ctx.quickReference, ctx.backend),
|
|
1917
|
+
playtest: playtestSteering(),
|
|
1918
|
+
deckTree: buildDeckTree(ctx.deckDir),
|
|
1919
|
+
quickReference: quickReferenceFor(ctx.deckDir, ctx.quickReference),
|
|
2563
1920
|
plan: taskPlanOpts(ctx.deckDir, task.item, ctx.finishedWork),
|
|
2564
1921
|
siblings: ctx.siblings,
|
|
2565
1922
|
});
|
|
@@ -2569,14 +1926,6 @@ async function runTaskAgentIn(ctx, task) {
|
|
|
2569
1926
|
// can't-verify-live-play loop. A single message + the autonomy reminder
|
|
2570
1927
|
// appended to the system prompt (CLAUDE_TASK_SYSTEM_REMINDER) is enough to
|
|
2571
1928
|
// get the work done; the agent stops when the code is in, the user verifies.
|
|
2572
|
-
// Pre-warm the playtest browser download as soon as a smith task starts
|
|
2573
|
-
// (fire-and-forget; single-flight shared with any real playtest call), so
|
|
2574
|
-
// the ~250MB one-time install usually completes during the agent's early
|
|
2575
|
-
// read/plan turns instead of stalling its first playtest. Smith-only:
|
|
2576
|
-
// playtest is a native-loop tool, so CLI-backend tasks would download a
|
|
2577
|
-
// browser they can never use.
|
|
2578
|
-
if (ctx.backend === 'smith')
|
|
2579
|
-
ctx.playtest?.prewarm?.();
|
|
2580
1929
|
let result = { ok: false, finalText: '', error: 'not run' };
|
|
2581
1930
|
let lineBuf = '';
|
|
2582
1931
|
// ```signal blocks span multiple lines and must NOT show in the live feed:
|
|
@@ -2616,24 +1965,14 @@ async function runTaskAgentIn(ctx, task) {
|
|
|
2616
1965
|
for (let attempt = 1; attempt <= MAX_TASK_ATTEMPTS; attempt++) {
|
|
2617
1966
|
await staggerTaskSpawn();
|
|
2618
1967
|
result = await runAgentTurn({
|
|
2619
|
-
backend: ctx.backend,
|
|
2620
1968
|
role: 'task',
|
|
2621
1969
|
prompt: taskPrompt,
|
|
2622
1970
|
claudeModel: ctx.claudeModel,
|
|
2623
1971
|
openrouterModel: ctx.openrouterModel,
|
|
2624
|
-
openrouterTuning: ctx.openrouterTuning,
|
|
2625
1972
|
cwd: ctx.deckDir,
|
|
2626
1973
|
timeoutMs: TASK_TIMEOUT_MS,
|
|
2627
1974
|
logPath: path.join(dir, 'log.jsonl'),
|
|
2628
|
-
playtest: ctx.playtest
|
|
2629
|
-
? {
|
|
2630
|
-
executor: ctx.playtest.executor,
|
|
2631
|
-
serveUrl: ctx.playtest.serveUrl,
|
|
2632
|
-
framesDir: path.join(dir, 'playtest'),
|
|
2633
|
-
}
|
|
2634
|
-
: undefined,
|
|
2635
1975
|
taskId: task.id,
|
|
2636
|
-
restart: ctx.restart,
|
|
2637
1976
|
children: ctx.children,
|
|
2638
1977
|
onSpawn: (pid) => {
|
|
2639
1978
|
task.pid = pid;
|
|
@@ -2645,7 +1984,7 @@ async function runTaskAgentIn(ctx, task) {
|
|
|
2645
1984
|
ctx.onFeed(`[${activity}]`);
|
|
2646
1985
|
},
|
|
2647
1986
|
});
|
|
2648
|
-
logAgentUsage(`task ${task.id}`,
|
|
1987
|
+
logAgentUsage(`task ${task.id}`, result.usage);
|
|
2649
1988
|
if (ctx.stopRequested.has(task.id))
|
|
2650
1989
|
return result;
|
|
2651
1990
|
if (!result.crashed)
|
|
@@ -2684,14 +2023,11 @@ function startTask(ctx, task) {
|
|
|
2684
2023
|
deckLabel: ctx.deckLabel,
|
|
2685
2024
|
tasksDir: ctx.tasksDir,
|
|
2686
2025
|
children: ctx.children,
|
|
2687
|
-
backend: ctx.backend(),
|
|
2688
2026
|
claudeModel: ctx.claudeModel(),
|
|
2689
2027
|
openrouterModel: ctx.openrouterModel(),
|
|
2690
|
-
openrouterTuning: ctx.openrouterTuning(),
|
|
2691
2028
|
stopRequested: ctx.stopRequested,
|
|
2692
2029
|
quickReference: ctx.quickReference,
|
|
2693
2030
|
playtest: ctx.playtest,
|
|
2694
|
-
restart: ctx.restart,
|
|
2695
2031
|
depsSummary: depsSummaryFor(ctx.tasks, task),
|
|
2696
2032
|
// Same visibility rule as the router's board (hide acked+finished rows),
|
|
2697
2033
|
// minus this task itself. Snapshot at start -- consistent with the deck
|
|
@@ -2737,12 +2073,6 @@ function startTask(ctx, task) {
|
|
|
2737
2073
|
task.acknowledged = true;
|
|
2738
2074
|
if (result.ok && !wasStopped)
|
|
2739
2075
|
task.progress = 100;
|
|
2740
|
-
// Smith reports its frames on the run result; a claude agent's reach
|
|
2741
|
-
// the record through runPlaytestFor as each call lands. Overwriting
|
|
2742
|
-
// with `?? []` would erase the latter, since a CLI backend's result
|
|
2743
|
-
// never carries them.
|
|
2744
|
-
if (result.playtestFrames)
|
|
2745
|
-
task.playtestFrames = result.playtestFrames;
|
|
2746
2076
|
task.finishedAt = nowIso();
|
|
2747
2077
|
task.resultSummary = wasStopped
|
|
2748
2078
|
? 'stopped by the router'
|
|
@@ -2900,12 +2230,9 @@ function createTaskStore(opts) {
|
|
|
2900
2230
|
tasks,
|
|
2901
2231
|
stopRequested,
|
|
2902
2232
|
quickReference: opts.quickReference,
|
|
2903
|
-
backend: opts.backend,
|
|
2904
2233
|
claudeModel: opts.claudeModel,
|
|
2905
2234
|
openrouterModel: opts.openrouterModel,
|
|
2906
|
-
openrouterTuning: opts.openrouterTuning,
|
|
2907
2235
|
playtest: opts.playtest,
|
|
2908
|
-
restart: opts.restart,
|
|
2909
2236
|
onStarted: opts.onStarted,
|
|
2910
2237
|
onFinished: opts.onFinished,
|
|
2911
2238
|
onRetry: opts.onRetry,
|
|
@@ -3160,7 +2487,7 @@ const ERROR_PREVIEW_CHARS = 200;
|
|
|
3160
2487
|
// tracing to '/var/folders/.../cursor_retrieval....log'" while the real
|
|
3161
2488
|
// "Error: Security command failed: Security process exited with code: 44"
|
|
3162
2489
|
// sat right below it. Only these two wrapper shapes get this treatment --
|
|
3163
|
-
// "agent run timed out" and "could not run
|
|
2490
|
+
// "agent run timed out" and "could not run claude: <message>" (the
|
|
3164
2491
|
// other two runAgentCli error strings) are already the whole story on line 1
|
|
3165
2492
|
// and are left alone, same as before.
|
|
3166
2493
|
const EXIT_WRAPPER_RE = /^agent (exited \d+|process kept dying)/;
|
|
@@ -3398,18 +2725,11 @@ function resolveFailure(result) {
|
|
|
3398
2725
|
// context + transcript replay (minus log lines and the in-flight reply) +
|
|
3399
2726
|
// the live board + this turn's instruction.
|
|
3400
2727
|
function routerTurnPrompt(ctx, instruction, selfMessageId, plan) {
|
|
3401
|
-
// Smith-only, smaller budget than a task's -- see TASK_DECK_CONTENTS_BUDGET/
|
|
3402
|
-
// ROUTER_DECK_CONTENTS_BUDGET's comment (the router prompt is already the
|
|
3403
|
-
// largest one this serve builds).
|
|
3404
|
-
const isSmith = ctx.backend() === 'smith';
|
|
3405
2728
|
return buildRouterPromptParts({
|
|
3406
2729
|
deckLabel: ctx.deckLabel,
|
|
3407
|
-
quickReference: quickReferenceFor(ctx.deckDir, ctx.quickReference
|
|
3408
|
-
deckTree: buildDeckTree(ctx.deckDir,
|
|
3409
|
-
|
|
3410
|
-
: undefined),
|
|
3411
|
-
deckContents: isSmith ? buildDeckContents(ctx.deckDir, ROUTER_DECK_CONTENTS_BUDGET) : undefined,
|
|
3412
|
-
playtest: playtestSteering(ctx.tasksBackend()),
|
|
2730
|
+
quickReference: quickReferenceFor(ctx.deckDir, ctx.quickReference),
|
|
2731
|
+
deckTree: buildDeckTree(ctx.deckDir),
|
|
2732
|
+
playtest: playtestSteering(),
|
|
3413
2733
|
messages: ctx.log.messages
|
|
3414
2734
|
.filter((m) => m.role !== 'log' && m.id !== selfMessageId && m.status !== 'streaming')
|
|
3415
2735
|
.map((m) => ({
|
|
@@ -3508,10 +2828,7 @@ function spawnCompletedTaskFences(ctx, message, midStream, raw) {
|
|
|
3508
2828
|
}
|
|
3509
2829
|
// One router turn: stream a reply message, then spawn the directives it
|
|
3510
2830
|
// emitted (unless a newer user message superseded this turn).
|
|
3511
|
-
|
|
3512
|
-
// smith consumes them (image content blocks), the CLI backends read the
|
|
3513
|
-
// files themselves via the paths already named in the instruction text.
|
|
3514
|
-
function runRouterTurnIn(ctx, instruction, attachments = []) {
|
|
2831
|
+
function runRouterTurnIn(ctx, instruction) {
|
|
3515
2832
|
const epoch = ctx.currentEpoch();
|
|
3516
2833
|
const message = {
|
|
3517
2834
|
id: nanoid(8),
|
|
@@ -3534,20 +2851,14 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
|
|
|
3534
2851
|
let lastActivity = 'Thinking';
|
|
3535
2852
|
ctx.broadcast({ type: 'message-activity', id: message.id, activity: 'Thinking' });
|
|
3536
2853
|
const prompt = routerTurnPrompt(ctx, instruction, message.id, readPlanSnapshot(ctx.deckDir));
|
|
3537
|
-
const backend = ctx.backend();
|
|
3538
2854
|
void runAgentTurn({
|
|
3539
|
-
backend,
|
|
3540
2855
|
role: 'router',
|
|
3541
2856
|
prompt: prompt.user,
|
|
3542
2857
|
systemPrompt: prompt.system,
|
|
3543
2858
|
claudeModel: ctx.claudeModel(),
|
|
3544
2859
|
openrouterModel: ctx.openrouterModel(),
|
|
3545
|
-
openrouterTuning: ctx.openrouterTuning(),
|
|
3546
|
-
attachments,
|
|
3547
2860
|
cwd: ctx.deckDir,
|
|
3548
2861
|
timeoutMs: ROUTER_TIMEOUT_MS,
|
|
3549
|
-
// CLI backends append raw stream-json; smith appends structured run
|
|
3550
|
-
// events (see createRunLogger in native/loop.ts). Same file either way.
|
|
3551
2862
|
logPath: path.join(ctx.agentDir, 'router-log.jsonl'),
|
|
3552
2863
|
children: ctx.children,
|
|
3553
2864
|
labelUnknownTools: true,
|
|
@@ -3575,7 +2886,7 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
|
|
|
3575
2886
|
},
|
|
3576
2887
|
})
|
|
3577
2888
|
.then((result) => {
|
|
3578
|
-
logAgentUsage('router',
|
|
2889
|
+
logAgentUsage('router', result.usage);
|
|
3579
2890
|
// Signals the finally -> onSettled(retryable): the turn failed cleanly
|
|
3580
2891
|
// enough (transient, nothing salvaged) that the queue may re-run it.
|
|
3581
2892
|
let retryable = false;
|
|
@@ -3726,13 +3037,6 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
|
|
|
3726
3037
|
function applyAgentSettings(incoming, ctx) {
|
|
3727
3038
|
const { settings } = ctx;
|
|
3728
3039
|
const changes = [];
|
|
3729
|
-
for (const key of ['router', 'tasks']) {
|
|
3730
|
-
const value = normalizeBackend(incoming[key]);
|
|
3731
|
-
if (value && value !== settings[key]) {
|
|
3732
|
-
settings[key] = value;
|
|
3733
|
-
changes.push(`${key} agent -> ${value}`);
|
|
3734
|
-
}
|
|
3735
|
-
}
|
|
3736
3040
|
for (const key of ['routerClaudeModel', 'tasksClaudeModel']) {
|
|
3737
3041
|
const value = normalizeClaudeModel(incoming[key]);
|
|
3738
3042
|
if (value && value !== settings[key]) {
|
|
@@ -3747,29 +3051,6 @@ function applyAgentSettings(incoming, ctx) {
|
|
|
3747
3051
|
changes.push(`${key === 'routerOpenrouterModel' ? 'operator' : 'tasks'} openrouter model -> ${value}`);
|
|
3748
3052
|
}
|
|
3749
3053
|
}
|
|
3750
|
-
for (const key of ['routerReasoningEffort', 'tasksReasoningEffort']) {
|
|
3751
|
-
const value = normalizeReasoningEffort(incoming[key]);
|
|
3752
|
-
if (value && value !== settings[key]) {
|
|
3753
|
-
settings[key] = value;
|
|
3754
|
-
changes.push(`${key === 'routerReasoningEffort' ? 'operator' : 'tasks'} reasoning effort -> ${value}`);
|
|
3755
|
-
}
|
|
3756
|
-
}
|
|
3757
|
-
for (const key of ['routerRouting', 'tasksRouting']) {
|
|
3758
|
-
const value = normalizeRoutingMode(incoming[key]);
|
|
3759
|
-
if (value && value !== settings[key]) {
|
|
3760
|
-
settings[key] = value;
|
|
3761
|
-
changes.push(`${key === 'routerRouting' ? 'operator' : 'tasks'} routing -> ${value}`);
|
|
3762
|
-
}
|
|
3763
|
-
}
|
|
3764
|
-
for (const key of ['routerProviderTier', 'tasksProviderTier']) {
|
|
3765
|
-
// "" is a valid value (auto), so check for null (invalid) explicitly
|
|
3766
|
-
// rather than truthiness -- otherwise the tier could never be cleared.
|
|
3767
|
-
const value = normalizeProviderTier(incoming[key]);
|
|
3768
|
-
if (value !== null && value !== settings[key]) {
|
|
3769
|
-
settings[key] = value;
|
|
3770
|
-
changes.push(`${key === 'routerProviderTier' ? 'operator' : 'tasks'} provider tier -> ${value || 'auto'}`);
|
|
3771
|
-
}
|
|
3772
|
-
}
|
|
3773
3054
|
if (changes.length === 0)
|
|
3774
3055
|
return;
|
|
3775
3056
|
atomicWriteFileSync(ctx.settingsPath, serializeAgentSettings(settings));
|
|
@@ -3784,8 +3065,8 @@ function applyAgentSettings(incoming, ctx) {
|
|
|
3784
3065
|
const ACCOUNTS_POLL_MS = 15_000;
|
|
3785
3066
|
// The editor's view of which credentials exist, kept live. Unlike settings --
|
|
3786
3067
|
// which nothing outside this process writes -- a credential changes behind the
|
|
3787
|
-
// editor's back all the time: `claude /login`
|
|
3788
|
-
//
|
|
3068
|
+
// editor's back all the time: `claude /login` in the terminal panel, a
|
|
3069
|
+
// hand-edited user-keys.json, an agent run that refreshes an
|
|
3789
3070
|
// OAuth token. Push those, or the popover reports whatever was true when the
|
|
3790
3071
|
// page loaded and a terminal sign-in reads as having done nothing.
|
|
3791
3072
|
//
|
|
@@ -3853,12 +3134,10 @@ function applyCredentialChange(msg, ctx) {
|
|
|
3853
3134
|
// set at all.
|
|
3854
3135
|
function slugKeysToValidate(settings) {
|
|
3855
3136
|
const keys = [];
|
|
3856
|
-
if (
|
|
3857
|
-
settings.routerOpenrouterModel) {
|
|
3137
|
+
if (settings.routerClaudeModel === 'openrouter' && settings.routerOpenrouterModel) {
|
|
3858
3138
|
keys.push('routerOpenrouterModel');
|
|
3859
3139
|
}
|
|
3860
|
-
if (
|
|
3861
|
-
settings.tasksOpenrouterModel) {
|
|
3140
|
+
if (settings.tasksClaudeModel === 'openrouter' && settings.tasksOpenrouterModel) {
|
|
3862
3141
|
keys.push('tasksOpenrouterModel');
|
|
3863
3142
|
}
|
|
3864
3143
|
return keys;
|
|
@@ -3908,7 +3187,7 @@ function killOrphanAgents(registryPath) {
|
|
|
3908
3187
|
const cmd = execFileSync('ps', ['-p', String(entry.pid), '-o', 'command='], {
|
|
3909
3188
|
encoding: 'utf8',
|
|
3910
3189
|
}).trim();
|
|
3911
|
-
if (cmd.includes('
|
|
3190
|
+
if (cmd.includes('claude')) {
|
|
3912
3191
|
process.kill(entry.pid, 'SIGKILL');
|
|
3913
3192
|
}
|
|
3914
3193
|
}
|
|
@@ -3929,9 +3208,7 @@ function startChildRegistry(registryPath, groups) {
|
|
|
3929
3208
|
const live = [];
|
|
3930
3209
|
for (const group of groups) {
|
|
3931
3210
|
for (const child of group) {
|
|
3932
|
-
|
|
3933
|
-
// for the orphan sweep to kill -- see AgentRunHandle).
|
|
3934
|
-
if (typeof child.pid === 'number' && child.pid > 0 && child.exitCode === null) {
|
|
3211
|
+
if (typeof child.pid === 'number' && child.exitCode === null) {
|
|
3935
3212
|
live.push({ pid: child.pid, command: child.spawnfile });
|
|
3936
3213
|
}
|
|
3937
3214
|
}
|
|
@@ -4042,9 +3319,8 @@ function killRouterChildren(ctx) {
|
|
|
4042
3319
|
}
|
|
4043
3320
|
return drafts.join('\n\n');
|
|
4044
3321
|
}
|
|
4045
|
-
function startRouterTurn(ctx, instruction
|
|
3322
|
+
function startRouterTurn(ctx, instruction) {
|
|
4046
3323
|
ctx.state.lastInstruction = instruction;
|
|
4047
|
-
ctx.state.lastAttachments = attachments;
|
|
4048
3324
|
runRouterTurnIn({
|
|
4049
3325
|
deckDir: ctx.deckDir,
|
|
4050
3326
|
deckLabel: ctx.deckLabel,
|
|
@@ -4056,18 +3332,11 @@ function startRouterTurn(ctx, instruction, attachments = []) {
|
|
|
4056
3332
|
taskStore: ctx.taskStore,
|
|
4057
3333
|
messageThinking: ctx.messageThinking,
|
|
4058
3334
|
currentEpoch: () => ctx.state.userEpoch,
|
|
4059
|
-
backend: () => ctx.settings.router,
|
|
4060
|
-
tasksBackend: () => ctx.settings.tasks,
|
|
4061
3335
|
claudeModel: () => ctx.settings.routerClaudeModel,
|
|
4062
3336
|
openrouterModel: () => ctx.settings.routerOpenrouterModel,
|
|
4063
|
-
openrouterTuning: () => ({
|
|
4064
|
-
reasoningEffort: ctx.settings.routerReasoningEffort,
|
|
4065
|
-
routing: ctx.settings.routerRouting,
|
|
4066
|
-
providerTier: ctx.settings.routerProviderTier,
|
|
4067
|
-
}),
|
|
4068
3337
|
canAutoRetry: () => !ctx.state.autoRetryUsed && ctx.state.pendingSends.length === 0,
|
|
4069
3338
|
onSettled: (retryable) => onRouterQueueSettled(ctx, retryable),
|
|
4070
|
-
}, instruction
|
|
3339
|
+
}, instruction);
|
|
4071
3340
|
}
|
|
4072
3341
|
// Commit each drained send to the message log (skipping ones already logged
|
|
4073
3342
|
// -- see PendingSend.logged -- so a re-carried send doesn't double its user
|
|
@@ -4127,7 +3396,7 @@ function maybeStartRouterQueueTurn(ctx) {
|
|
|
4127
3396
|
interruptedDraft: draft || undefined,
|
|
4128
3397
|
attachments: attachmentPaths,
|
|
4129
3398
|
refs,
|
|
4130
|
-
})
|
|
3399
|
+
}));
|
|
4131
3400
|
}
|
|
4132
3401
|
// The turn settled: clear the busy flag, broadcast it, then flush anything
|
|
4133
3402
|
// that queued mid-turn (a clean end and an interrupt take the same path). A
|
|
@@ -4144,7 +3413,7 @@ function onRouterQueueSettled(ctx, retryable) {
|
|
|
4144
3413
|
state.autoRetryUsed = true;
|
|
4145
3414
|
state.routerRunning = true;
|
|
4146
3415
|
broadcastQueueState(ctx);
|
|
4147
|
-
startRouterTurn(ctx, state.lastInstruction
|
|
3416
|
+
startRouterTurn(ctx, state.lastInstruction);
|
|
4148
3417
|
return;
|
|
4149
3418
|
}
|
|
4150
3419
|
broadcastQueueState(ctx);
|
|
@@ -4279,7 +3548,6 @@ function createRouterQueue(deps) {
|
|
|
4279
3548
|
autoRetryUsed: false,
|
|
4280
3549
|
autoFoldUsed: false,
|
|
4281
3550
|
lastInstruction: '',
|
|
4282
|
-
lastAttachments: [],
|
|
4283
3551
|
},
|
|
4284
3552
|
};
|
|
4285
3553
|
// Restart recovery: re-enqueue sends that never reached the message log,
|
|
@@ -4298,12 +3566,10 @@ function createRouterQueue(deps) {
|
|
|
4298
3566
|
// The serve-side half of the claude backend's playtest tool: resolve which
|
|
4299
3567
|
// task's frames dir and call budget a request belongs to, run it on the shared
|
|
4300
3568
|
// warm Chromium, and record the frames on the task so its card shows them.
|
|
4301
|
-
// Smith never comes through here -- native/loop.ts builds its own ctx.
|
|
4302
3569
|
function makePlaytestRunner(deps) {
|
|
4303
|
-
// Per-TASK playtest budget
|
|
4304
|
-
//
|
|
4305
|
-
//
|
|
4306
|
-
// spending the same four calls.
|
|
3570
|
+
// Per-TASK playtest budget: a retried task keeps spending the same
|
|
3571
|
+
// PLAYTEST_MAX_CALLS_PER_RUN calls rather than getting a fresh set per
|
|
3572
|
+
// attempt.
|
|
4307
3573
|
const callCounts = new Map();
|
|
4308
3574
|
return async function runPlaytestFor(req) {
|
|
4309
3575
|
const task = req.taskId ? deps.taskStore.get(req.taskId) : undefined;
|
|
@@ -4374,12 +3640,6 @@ export function createAgentServer(opts) {
|
|
|
4374
3640
|
// with a navigation error. `localhost` resolves correctly under
|
|
4375
3641
|
// every binding mode (default ::1, --host 0.0.0.0, explicit IPs).
|
|
4376
3642
|
serveUrl: opts.playtestServeUrl ?? `http://localhost:${opts.port}`,
|
|
4377
|
-
// Kicks off the one-time Chromium download in the background at
|
|
4378
|
-
// smith-task start (see runTaskAgentIn) -- single-flight with any
|
|
4379
|
-
// real playtest call, silent on every failure path.
|
|
4380
|
-
prewarm: playtestBrowserManager
|
|
4381
|
-
? () => playtestBrowserManager.prewarm()
|
|
4382
|
-
: undefined,
|
|
4383
3643
|
}
|
|
4384
3644
|
: undefined;
|
|
4385
3645
|
const taskChildren = new Set();
|
|
@@ -4407,7 +3667,7 @@ export function createAgentServer(opts) {
|
|
|
4407
3667
|
const usageFeed = createUsageFeed({
|
|
4408
3668
|
broadcast,
|
|
4409
3669
|
hasClients: () => clients.size > 0,
|
|
4410
|
-
|
|
3670
|
+
settings: () => settings,
|
|
4411
3671
|
});
|
|
4412
3672
|
const accountsFeed = createAccountsFeed({
|
|
4413
3673
|
broadcast,
|
|
@@ -4424,8 +3684,8 @@ export function createAgentServer(opts) {
|
|
|
4424
3684
|
});
|
|
4425
3685
|
const applySettings = (incoming) => {
|
|
4426
3686
|
applyAgentSettings(incoming, { settings, settingsPath, broadcast });
|
|
4427
|
-
// A
|
|
4428
|
-
//
|
|
3687
|
+
// A model switch can change who pays, and whether that spend is free at
|
|
3688
|
+
// all (usage.freeTier), so the bar follows the change instead of the poll.
|
|
4429
3689
|
usageFeed.refresh();
|
|
4430
3690
|
};
|
|
4431
3691
|
const taskFeeds = createTaskFeeds(broadcast, tasksDir);
|
|
@@ -4436,16 +3696,9 @@ export function createAgentServer(opts) {
|
|
|
4436
3696
|
tasksDir,
|
|
4437
3697
|
children: taskChildren,
|
|
4438
3698
|
quickReference,
|
|
4439
|
-
backend: () => settings.tasks,
|
|
4440
3699
|
openrouterModel: () => settings.tasksOpenrouterModel,
|
|
4441
3700
|
claudeModel: () => settings.tasksClaudeModel,
|
|
4442
|
-
openrouterTuning: () => ({
|
|
4443
|
-
reasoningEffort: settings.tasksReasoningEffort,
|
|
4444
|
-
routing: settings.tasksRouting,
|
|
4445
|
-
providerTier: settings.tasksProviderTier,
|
|
4446
|
-
}),
|
|
4447
3701
|
playtest,
|
|
4448
|
-
restart: opts.restart,
|
|
4449
3702
|
// Task lifecycle stays on the board only -- log lines for it were spam.
|
|
4450
3703
|
onUpdate: (task) => broadcast({ type: 'task-update', task: asClientTask(task, opts.signPath) }),
|
|
4451
3704
|
onStarted: () => undefined,
|
|
@@ -4600,9 +3853,8 @@ export function createAgentServer(opts) {
|
|
|
4600
3853
|
applyCredentialChange(msg, { pushAccounts: accountsFeed.push });
|
|
4601
3854
|
}
|
|
4602
3855
|
else if (msg.type === 'account-login') {
|
|
4603
|
-
|
|
4604
|
-
|
|
4605
|
-
startLogin(provider, () => accountsFeed.push());
|
|
3856
|
+
if (loginProviderFor(msg.id))
|
|
3857
|
+
startLogin(() => accountsFeed.push());
|
|
4606
3858
|
}
|
|
4607
3859
|
else if (msg.type === 'account-login-code' && typeof msg.value === 'string') {
|
|
4608
3860
|
submitLoginCode(msg.value);
|
|
@@ -4611,9 +3863,8 @@ export function createAgentServer(opts) {
|
|
|
4611
3863
|
cancelLogin();
|
|
4612
3864
|
}
|
|
4613
3865
|
else if (msg.type === 'account-logout') {
|
|
4614
|
-
|
|
4615
|
-
|
|
4616
|
-
logout(provider, () => accountsFeed.push());
|
|
3866
|
+
if (loginProviderFor(msg.id))
|
|
3867
|
+
logout(() => accountsFeed.push());
|
|
4617
3868
|
}
|
|
4618
3869
|
else if (msg.type === 'client-timezone' && typeof msg.timeZone === 'string') {
|
|
4619
3870
|
setReaderTimeZone(msg.timeZone);
|
|
@@ -4642,8 +3893,6 @@ export function createAgentServer(opts) {
|
|
|
4642
3893
|
const handleAttachment = makeAttachmentHandler(attachmentsDir);
|
|
4643
3894
|
const handlePlaytestFrame = makePlaytestFrameHandler(tasksDir);
|
|
4644
3895
|
function handleHttpRequest(req, res, reqPath) {
|
|
4645
|
-
if (reqPath === AGENT_MODEL_CAPS_PREFIX)
|
|
4646
|
-
return handleModelCaps(req, res);
|
|
4647
3896
|
return handleAttachment(req, res, reqPath) || handlePlaytestFrame(req, res, reqPath);
|
|
4648
3897
|
}
|
|
4649
3898
|
function shutdown() {
|