castle-web-cli 0.4.115 → 0.4.116
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-prompts.js +2 -2
- package/dist/agent.d.ts +9 -9
- package/dist/agent.js +590 -589
- package/dist/castleJson.d.ts +6 -0
- package/dist/castleJson.js +10 -0
- package/dist/editorConfig.js +27 -3
- package/dist/imports.d.ts +4 -0
- package/dist/imports.js +59 -10
- package/dist/init.d.ts +3 -0
- package/dist/init.js +99 -87
- package/dist/install.d.ts +1 -1
- package/dist/install.js +26 -24
- package/dist/shell/assets/{index-CUamb8rK.js → index-DiPlPGyg.js} +2 -2
- package/dist/shell/index.html +1 -1
- package/dist/vitePlugins.js +1 -1
- package/kits/physics-2d/CLAUDE.md +28 -16
- package/kits/physics-2d/behaviors/Joints.jsx +1 -1
- package/kits/physics-2d/behaviors/Sprite.jsx +17 -12
- package/kits/physics-2d/blueprints/ball.scene +1 -1
- package/kits/physics-2d/blueprints/block.scene +1 -1
- package/kits/physics-2d/blueprints/cauldron.scene +1 -1
- package/kits/physics-2d/blueprints/crate.scene +1 -1
- package/kits/physics-2d/castle.json +11 -3
- package/kits/physics-2d/docs/pxart-format.md +537 -51
- package/kits/physics-2d/editors/PxArtEditor.jsx +1794 -65
- package/kits/physics-2d/editors/brushFit.js +535 -0
- package/kits/physics-2d/editors/brushShapes.js +140 -0
- package/kits/physics-2d/editors/mediaFile.js +12 -1
- package/kits/physics-2d/editors/pathOverlay.js +340 -0
- package/kits/physics-2d/editors/pathTools.js +1906 -0
- package/kits/physics-2d/editors/pixelCanvas.js +13 -0
- package/kits/physics-2d/editors/pixelEditorChrome.jsx +2 -2
- package/kits/physics-2d/editors/pixelGeometry.js +4 -2
- package/kits/physics-2d/editors/pixelInspector.jsx +410 -37
- package/kits/physics-2d/editors/pxArtEditorModel.js +172 -16
- package/kits/physics-2d/editors/pxArtTimeline.jsx +163 -43
- package/kits/physics-2d/editors/pxArtTimeline.module.css +31 -5
- package/kits/physics-2d/engine/assets.js +1 -1
- package/kits/physics-2d/engine/blueprint.js +3 -3
- package/kits/physics-2d/engine/files.js +3 -1
- package/kits/physics-2d/engine/liveReload.js +1 -1
- package/kits/physics-2d/engine/physics/jointArt.js +3 -3
- package/kits/physics-2d/engine/pxart.js +153 -35
- package/kits/physics-2d/engine/pxartPath.js +1356 -0
- package/kits/physics-2d/engine/pxartSmooth.js +276 -125
- package/kits/physics-2d/engine/ui.jsx +22 -1
- package/kits/physics-2d/engine/ui.module.css +36 -12
- package/kits/physics-2d/package-lock.json +1 -1
- package/kits/physics-2d/scripts/draw.mjs +7 -7
- package/kits/physics-2d/scripts/import-svg.mjs +1231 -0
- package/kits/physics-2d/scripts/svg-emission-guide.md +92 -0
- package/package.json +1 -1
- /package/kits/physics-2d/drawings/{block.pxart → block.sprite} +0 -0
- /package/kits/physics-2d/drawings/{cauldron.pxart → cauldron.sprite} +0 -0
- /package/kits/physics-2d/drawings/{joint-rope.pxart → joint-rope.sprite} +0 -0
package/dist/agent.js
CHANGED
|
@@ -14,67 +14,66 @@
|
|
|
14
14
|
// Backend CLI: cursor-agent in headless print mode (stream-json). The router
|
|
15
15
|
// runs with --mode ask (read-only at the CLI level); task agents run with
|
|
16
16
|
// --force. Claude support can slot in later behind runAgentCli.
|
|
17
|
-
import { execFileSync, spawn
|
|
18
|
-
import
|
|
19
|
-
import * as
|
|
20
|
-
import * as
|
|
21
|
-
import
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
|
|
35
|
-
export const
|
|
17
|
+
import { execFileSync, spawn } from 'child_process';
|
|
18
|
+
import { IMPORTS_DIR } from './imports.js';
|
|
19
|
+
import * as fs from 'fs';
|
|
20
|
+
import * as os from 'os';
|
|
21
|
+
import * as path from 'path';
|
|
22
|
+
import { nanoid } from 'nanoid';
|
|
23
|
+
import { WebSocketServer } from 'ws';
|
|
24
|
+
import { rawDataToString } from './ide.js';
|
|
25
|
+
import { buildRouterPrompt, buildTaskPrompt, userTurnInstruction, CLAUDE_TASK_SYSTEM_REMINDER, } from './agent-prompts.js';
|
|
26
|
+
import { checkOpenrouterKey, checkOpenrouterModel, openrouterCatalogEntry, primeOpenrouterCatalog, } from './openrouter-catalog.js';
|
|
27
|
+
import { classifyProviderError, failureCopy, setReaderTimeZone, } from './agent-failures.js';
|
|
28
|
+
import { fetchBudget, meteringHeaders, newAgentSessionId, reportCursorRun, withCustomHeaders, } from './metering.js';
|
|
29
|
+
import { anthropicKeyHelperCommand, claudeHasSavedLogin, cursorAuthPath, cursorHasUserLogin, resolveAnthropicAuth, userKey, ANTHROPIC_CREDENTIAL_ENV, ANTHROPIC_PROXY_ENV, CASTLE_USER_KEYS_PATH, } from './byo-auth.js';
|
|
30
|
+
import { accountsSnapshot, loginProviderFor, watchCredentials, writeCredential, } from './byo-accounts.js';
|
|
31
|
+
import { cancelLogin, logout, startLogin, submitLoginCode } from './byo-login.js';
|
|
32
|
+
import { runAgentNative } from './native/loop.js';
|
|
33
|
+
import { createPlaytestBrowserManager, } from './native/playtest-browser.js';
|
|
34
|
+
import { createPlaywrightPlaytestExecutor } from './native/playtest-executor.js';
|
|
35
|
+
export const AGENT_WS_PATH = '/__castle/agent';
|
|
36
|
+
export const AGENT_ATTACHMENT_PREFIX = '/__castle/agent/attachments/';
|
|
36
37
|
// Playtest frame PNGs (tasks/<id>/playtest/<file>.png), served for the
|
|
37
38
|
// finished-task card's thumbnails -- see makePlaytestFrameHandler.
|
|
38
|
-
export const AGENT_PLAYTEST_PREFIX =
|
|
39
|
+
export const AGENT_PLAYTEST_PREFIX = '/__castle/agent/playtest/';
|
|
39
40
|
// Same-origin proxy for OpenRouter model capabilities (avoids browser CORS
|
|
40
41
|
// against openrouter.ai). GET ?model=<slug> -> ModelCaps JSON. Powers the
|
|
41
42
|
// settings popover's dynamic reasoning-effort / provider-tier pickers.
|
|
42
|
-
export const AGENT_MODEL_CAPS_PREFIX =
|
|
43
|
+
export const AGENT_MODEL_CAPS_PREFIX = '/__castle/agent/model-caps';
|
|
43
44
|
const DEFAULT_SETTINGS = {
|
|
44
|
-
router:
|
|
45
|
-
tasks:
|
|
45
|
+
router: 'claude',
|
|
46
|
+
tasks: 'claude',
|
|
46
47
|
// Both roles run the claude CLI routed through OpenRouter at the slug below.
|
|
47
48
|
// Note this takes the Anthropic credential out of play entirely: a role on
|
|
48
49
|
// "openrouter" resolves OpenRouter auth, so a user's `claude /login` or
|
|
49
50
|
// ANTHROPIC_API_KEY no longer applies (see buildAgentInvocation).
|
|
50
|
-
routerClaudeModel:
|
|
51
|
-
tasksClaudeModel:
|
|
51
|
+
routerClaudeModel: 'openrouter',
|
|
52
|
+
tasksClaudeModel: 'openrouter',
|
|
52
53
|
// Free-form -- change to any OpenRouter slug.
|
|
53
|
-
routerOpenrouterModel:
|
|
54
|
-
tasksOpenrouterModel:
|
|
54
|
+
routerOpenrouterModel: 'openai/gpt-5.6-terra',
|
|
55
|
+
tasksOpenrouterModel: 'openai/gpt-5.6-terra',
|
|
55
56
|
// Both roles think at "medium": the operator stays snappy (the user waits
|
|
56
57
|
// on every operator turn), and task agents' multi-turn tool loops don't pay
|
|
57
58
|
// reasoning tax on mechanical read/edit/run turns. Deep decomposition
|
|
58
59
|
// quality comes from the operator prompt, not a higher effort default.
|
|
59
|
-
routerReasoningEffort:
|
|
60
|
-
tasksReasoningEffort:
|
|
60
|
+
routerReasoningEffort: 'medium',
|
|
61
|
+
tasksReasoningEffort: 'medium',
|
|
61
62
|
// Routing splits by what each role optimizes for: the interactive operator
|
|
62
63
|
// routes for speed (nitro = throughput-sorted endpoints), unattended task
|
|
63
64
|
// agents route for correctness (exacto = benchmark-accurate endpoints,
|
|
64
65
|
// which matters for tool-calling fidelity over long loops).
|
|
65
|
-
routerRouting:
|
|
66
|
-
tasksRouting:
|
|
66
|
+
routerRouting: 'nitro',
|
|
67
|
+
tasksRouting: 'exacto',
|
|
67
68
|
// Operator pins OpenAI's priority (low-latency SLA) tier; harmless with a
|
|
68
69
|
// slug that lacks it since the pin falls back when the tag doesn't exist
|
|
69
70
|
// (allow_fallbacks). Tasks stay on auto: high-volume background turns
|
|
70
71
|
// should ride the cheapest available capacity.
|
|
71
|
-
routerProviderTier:
|
|
72
|
-
tasksProviderTier:
|
|
72
|
+
routerProviderTier: 'openai/priority',
|
|
73
|
+
tasksProviderTier: '',
|
|
73
74
|
};
|
|
74
75
|
function normalizeBackend(value) {
|
|
75
|
-
return value ===
|
|
76
|
-
? value
|
|
77
|
-
: null;
|
|
76
|
+
return value === 'cursor' || value === 'claude' || value === 'smith' ? value : null;
|
|
78
77
|
}
|
|
79
78
|
// Bump to force every deck onto the current DEFAULT_SETTINGS once, discarding
|
|
80
79
|
// what users had chosen: a stored file below this epoch has ALL of its setting
|
|
@@ -85,10 +84,7 @@ function normalizeBackend(value) {
|
|
|
85
84
|
// only worth writing if it must survive the epoch that introduces it.
|
|
86
85
|
const SETTINGS_EPOCH = 1;
|
|
87
86
|
function normalizeClaudeModel(value) {
|
|
88
|
-
return value ===
|
|
89
|
-
value === "opus" ||
|
|
90
|
-
value === "fable" ||
|
|
91
|
-
value === "openrouter"
|
|
87
|
+
return value === 'sonnet' || value === 'opus' || value === 'fable' || value === 'openrouter'
|
|
92
88
|
? value
|
|
93
89
|
: null;
|
|
94
90
|
}
|
|
@@ -97,9 +93,9 @@ function normalizeClaudeModel(value) {
|
|
|
97
93
|
// is the one place that knows both, so the model picker and the pre-flight
|
|
98
94
|
// refusal can't disagree about which models a user actually has.
|
|
99
95
|
const CLAUDE_MODEL_ID_PREFIXES = {
|
|
100
|
-
sonnet:
|
|
101
|
-
opus:
|
|
102
|
-
fable:
|
|
96
|
+
sonnet: 'claude-sonnet-',
|
|
97
|
+
opus: 'claude-opus-',
|
|
98
|
+
fable: 'claude-fable-',
|
|
103
99
|
};
|
|
104
100
|
// Blocked when the two prefixes agree as far as the shorter one goes: the proxy
|
|
105
101
|
// may name a family ("claude-fable-") or one model within it.
|
|
@@ -113,7 +109,7 @@ function claudeModelBlocked(model, budget) {
|
|
|
113
109
|
// against a stray huge paste landing in settings.json / the CLI argv).
|
|
114
110
|
const OPENROUTER_MODEL_MAX_LEN = 200;
|
|
115
111
|
function normalizeOpenrouterModel(value) {
|
|
116
|
-
if (typeof value !==
|
|
112
|
+
if (typeof value !== 'string')
|
|
117
113
|
return null;
|
|
118
114
|
const trimmed = value.trim();
|
|
119
115
|
return trimmed && trimmed.length <= OPENROUTER_MODEL_MAX_LEN ? trimmed : null;
|
|
@@ -123,29 +119,22 @@ function normalizeOpenrouterModel(value) {
|
|
|
123
119
|
// fetches them from the model-caps endpoint to build the picker) and
|
|
124
120
|
// OpenRouter maps an unsupported level to the nearest one anyway.
|
|
125
121
|
const REASONING_EFFORTS = [
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
122
|
+
'none',
|
|
123
|
+
'minimal',
|
|
124
|
+
'low',
|
|
125
|
+
'medium',
|
|
126
|
+
'high',
|
|
127
|
+
'xhigh',
|
|
128
|
+
'max',
|
|
133
129
|
];
|
|
134
130
|
function normalizeReasoningEffort(value) {
|
|
135
131
|
return REASONING_EFFORTS.includes(value)
|
|
136
132
|
? value
|
|
137
133
|
: null;
|
|
138
134
|
}
|
|
139
|
-
const ROUTING_MODES = [
|
|
140
|
-
"balanced",
|
|
141
|
-
"nitro",
|
|
142
|
-
"exacto",
|
|
143
|
-
"floor",
|
|
144
|
-
];
|
|
135
|
+
const ROUTING_MODES = ['balanced', 'nitro', 'exacto', 'floor'];
|
|
145
136
|
function normalizeRoutingMode(value) {
|
|
146
|
-
return ROUTING_MODES.includes(value)
|
|
147
|
-
? value
|
|
148
|
-
: null;
|
|
137
|
+
return ROUTING_MODES.includes(value) ? value : null;
|
|
149
138
|
}
|
|
150
139
|
// Provider tier is an OpenRouter endpoint `tag` ("openai/flex", "azure/eu",
|
|
151
140
|
// ...) which is model-specific, so validation is loose like the model slug.
|
|
@@ -153,7 +142,7 @@ function normalizeRoutingMode(value) {
|
|
|
153
142
|
// this returns "" rather than null for the clear case -- callers must treat
|
|
154
143
|
// null (invalid) and "" (clear) differently.
|
|
155
144
|
function normalizeProviderTier(value) {
|
|
156
|
-
if (typeof value !==
|
|
145
|
+
if (typeof value !== 'string')
|
|
157
146
|
return null;
|
|
158
147
|
const trimmed = value.trim();
|
|
159
148
|
if (trimmed.length > OPENROUTER_MODEL_MAX_LEN)
|
|
@@ -193,11 +182,11 @@ function serializeAgentSettings(settings) {
|
|
|
193
182
|
if (settings[key] !== DEFAULT_SETTINGS[key])
|
|
194
183
|
out[key] = settings[key];
|
|
195
184
|
}
|
|
196
|
-
return JSON.stringify(out, null, 2) +
|
|
185
|
+
return JSON.stringify(out, null, 2) + '\n';
|
|
197
186
|
}
|
|
198
187
|
function loadAgentSettings(settingsPath) {
|
|
199
188
|
const stored = readJsonFile(settingsPath);
|
|
200
|
-
const storedEpoch = typeof stored?.settingsEpoch ===
|
|
189
|
+
const storedEpoch = typeof stored?.settingsEpoch === 'number' ? stored.settingsEpoch : 0;
|
|
201
190
|
// A stale file is ignored, not rewritten: leaving it means the wipe is a
|
|
202
191
|
// pure read-side decision that repeats harmlessly until the user's next real
|
|
203
192
|
// change overwrites it in the sparse shape. A newer epoch (a downgraded CLI
|
|
@@ -225,7 +214,7 @@ function loadAgentSettings(settingsPath) {
|
|
|
225
214
|
// the /v1 the CLI re-adds. Otherwise openrouter.ai directly.
|
|
226
215
|
function openrouterAnthropicBase() {
|
|
227
216
|
const injected = process.env.OPENROUTER_BASE_URL;
|
|
228
|
-
return injected ? injected.replace(/\/v1\/?$/,
|
|
217
|
+
return injected ? injected.replace(/\/v1\/?$/, '') : 'https://openrouter.ai/api';
|
|
229
218
|
}
|
|
230
219
|
// Env for a claude CLI spawn routed at OpenRouter (claudeModel "openrouter",
|
|
231
220
|
// Path A). Two things make this deterministic regardless of the user's own
|
|
@@ -256,21 +245,21 @@ function openrouterAnthropicBase() {
|
|
|
256
245
|
// ANTHROPIC_BASE_URL for a user's OWN OpenRouter key -- straight to openrouter.ai
|
|
257
246
|
// (the CLI appends /v1/messages), bypassing the proxy. The proxy branch instead
|
|
258
247
|
// uses openrouterAnthropicBase() (the injected OPENROUTER_BASE_URL origin).
|
|
259
|
-
const OPENROUTER_DIRECT_ANTHROPIC_BASE =
|
|
248
|
+
const OPENROUTER_DIRECT_ANTHROPIC_BASE = 'https://openrouter.ai/api';
|
|
260
249
|
// OpenAI-shaped chat-completions ORIGIN for the smith native loop on a user's
|
|
261
250
|
// own OpenRouter key (native/openrouter.ts appends /chat/completions).
|
|
262
|
-
const OPENROUTER_DIRECT_CHAT_BASE =
|
|
251
|
+
const OPENROUTER_DIRECT_CHAT_BASE = 'https://openrouter.ai/api/v1';
|
|
263
252
|
function envForOpenrouterSpawn(auth) {
|
|
264
253
|
if (!auth.key) {
|
|
265
254
|
// Unreachable via runAgentTurn (pre-flight rejects a keyless run before
|
|
266
255
|
// any spawn). A backstop, so a future caller that skips pre-flight fails
|
|
267
256
|
// loudly instead of quietly leaking.
|
|
268
|
-
throw new Error(
|
|
257
|
+
throw new Error('envForOpenrouterSpawn: refusing to spawn without an OpenRouter key');
|
|
269
258
|
}
|
|
270
259
|
const env = { ...process.env };
|
|
271
260
|
for (const name of ANTHROPIC_CREDENTIAL_ENV)
|
|
272
261
|
delete env[name];
|
|
273
|
-
if (auth.mode ===
|
|
262
|
+
if (auth.mode === 'user-key') {
|
|
274
263
|
env.ANTHROPIC_BASE_URL = OPENROUTER_DIRECT_ANTHROPIC_BASE;
|
|
275
264
|
// An inherited ANTHROPIC_CUSTOM_HEADERS can carry x-castle-* metering
|
|
276
265
|
// lines (see ANTHROPIC_PROXY_ENV) -- never forward those on a direct run.
|
|
@@ -279,7 +268,7 @@ function envForOpenrouterSpawn(auth) {
|
|
|
279
268
|
else {
|
|
280
269
|
env.ANTHROPIC_BASE_URL = openrouterAnthropicBase();
|
|
281
270
|
}
|
|
282
|
-
env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY =
|
|
271
|
+
env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = '1';
|
|
283
272
|
env.ANTHROPIC_AUTH_TOKEN = auth.key;
|
|
284
273
|
return env;
|
|
285
274
|
}
|
|
@@ -288,15 +277,15 @@ function envForOpenrouterSpawn(auth) {
|
|
|
288
277
|
// claude-via-OpenRouter spawn sends it as ANTHROPIC_AUTH_TOKEN (see
|
|
289
278
|
// envForOpenrouterSpawn). Castle-provided key file first, env fallback --
|
|
290
279
|
// same sourcing order as envForAgentSpawn.
|
|
291
|
-
const OPENROUTER_KEY_NAME =
|
|
280
|
+
const OPENROUTER_KEY_NAME = 'OPENROUTER_API_KEY';
|
|
292
281
|
function openrouterApiKey() {
|
|
293
|
-
return castleKeys()[OPENROUTER_KEY_NAME] ?? process.env[OPENROUTER_KEY_NAME] ??
|
|
282
|
+
return castleKeys()[OPENROUTER_KEY_NAME] ?? process.env[OPENROUTER_KEY_NAME] ?? '';
|
|
294
283
|
}
|
|
295
284
|
const MODEL_CAPS_TTL_MS = 10 * 60_000;
|
|
296
|
-
const OPENROUTER_API_BASE =
|
|
285
|
+
const OPENROUTER_API_BASE = 'https://openrouter.ai/api/v1';
|
|
297
286
|
const modelCapsCache = new Map();
|
|
298
287
|
function asRecord(v) {
|
|
299
|
-
return v && typeof v ===
|
|
288
|
+
return v && typeof v === 'object' ? v : null;
|
|
300
289
|
}
|
|
301
290
|
async function openrouterProviderTiers(slug) {
|
|
302
291
|
const res = await fetch(`${OPENROUTER_API_BASE}/models/${slug}/endpoints`);
|
|
@@ -308,7 +297,7 @@ async function openrouterProviderTiers(slug) {
|
|
|
308
297
|
const tags = [];
|
|
309
298
|
for (const ep of endpoints) {
|
|
310
299
|
const rec = asRecord(ep);
|
|
311
|
-
const tag = rec && typeof rec.tag ===
|
|
300
|
+
const tag = rec && typeof rec.tag === 'string' ? rec.tag : null;
|
|
312
301
|
if (tag && !tags.includes(tag))
|
|
313
302
|
tags.push(tag);
|
|
314
303
|
}
|
|
@@ -332,14 +321,11 @@ async function fetchModelCaps(slug) {
|
|
|
332
321
|
const efforts = reasoning?.supported_efforts;
|
|
333
322
|
const supportedParams = entry?.supportedParameters;
|
|
334
323
|
const acceptsEffort = Array.isArray(supportedParams) &&
|
|
335
|
-
(supportedParams.includes(
|
|
336
|
-
supportedParams.includes("reasoning"));
|
|
324
|
+
(supportedParams.includes('reasoning_effort') || supportedParams.includes('reasoning'));
|
|
337
325
|
if (acceptsEffort && Array.isArray(efforts) && efforts.length > 0) {
|
|
338
|
-
reasoningEfforts = efforts.filter((e) => typeof e ===
|
|
326
|
+
reasoningEfforts = efforts.filter((e) => typeof e === 'string');
|
|
339
327
|
defaultEffort =
|
|
340
|
-
typeof reasoning?.default_effort ===
|
|
341
|
-
? reasoning.default_effort
|
|
342
|
-
: null;
|
|
328
|
+
typeof reasoning?.default_effort === 'string' ? reasoning.default_effort : null;
|
|
343
329
|
}
|
|
344
330
|
}
|
|
345
331
|
catch {
|
|
@@ -367,25 +353,25 @@ async function fetchModelCaps(slug) {
|
|
|
367
353
|
function handleModelCaps(req, res) {
|
|
368
354
|
const send = (status, body) => {
|
|
369
355
|
res.writeHead(status, {
|
|
370
|
-
|
|
371
|
-
|
|
356
|
+
'content-type': 'application/json',
|
|
357
|
+
'cache-control': 'no-store',
|
|
372
358
|
});
|
|
373
359
|
res.end(JSON.stringify(body));
|
|
374
360
|
return true;
|
|
375
361
|
};
|
|
376
|
-
let slug =
|
|
362
|
+
let slug = '';
|
|
377
363
|
try {
|
|
378
|
-
slug = (new URL(req.url ??
|
|
364
|
+
slug = (new URL(req.url ?? '', 'http://localhost').searchParams.get('model') ?? '').trim();
|
|
379
365
|
}
|
|
380
366
|
catch {
|
|
381
|
-
slug =
|
|
367
|
+
slug = '';
|
|
382
368
|
}
|
|
383
369
|
if (!slug || slug.length > OPENROUTER_MODEL_MAX_LEN) {
|
|
384
|
-
return send(400, { error:
|
|
370
|
+
return send(400, { error: 'missing or invalid model' });
|
|
385
371
|
}
|
|
386
372
|
// Strip any routing suffix the client may have on the displayed slug so the
|
|
387
373
|
// OpenRouter lookup hits the base model id.
|
|
388
|
-
const baseSlug = slug.replace(/:(nitro|exacto|floor)$/,
|
|
374
|
+
const baseSlug = slug.replace(/:(nitro|exacto|floor)$/, '');
|
|
389
375
|
fetchModelCaps(baseSlug)
|
|
390
376
|
.then((caps) => send(200, caps))
|
|
391
377
|
.catch(() => send(200, {
|
|
@@ -418,10 +404,10 @@ function handleModelCaps(req, res) {
|
|
|
418
404
|
// to plain-claude spawns, where auto still resolves correctly.
|
|
419
405
|
// Single `=` token because --allowedTools is variadic and would otherwise
|
|
420
406
|
// swallow the trailing prompt positional.
|
|
421
|
-
const OPENROUTER_ALLOWED_TOOLS =
|
|
407
|
+
const OPENROUTER_ALLOWED_TOOLS = '--allowedTools=Edit,Write,NotebookEdit,Bash';
|
|
422
408
|
// Cursor's proprietary model. Also the slug reported to the metering ledger, so
|
|
423
409
|
// the two can never drift into disagreeing about what a cursor row ran on.
|
|
424
|
-
const CURSOR_MODEL =
|
|
410
|
+
const CURSOR_MODEL = 'composer-2.5-fast';
|
|
425
411
|
// Keep runs independent of the machine's user config: no user plugins (LSP
|
|
426
412
|
// servers etc.), no user MCP servers. CLAUDE.md auto-discovery and OAuth still
|
|
427
413
|
// work. On a user's own Anthropic KEY this also carries the apiKeyHelper that
|
|
@@ -429,9 +415,7 @@ const CURSOR_MODEL = "composer-2.5-fast";
|
|
|
429
415
|
function claudeSettingsArg(auth) {
|
|
430
416
|
return JSON.stringify({
|
|
431
417
|
enabledPlugins: {},
|
|
432
|
-
...(auth?.mode ===
|
|
433
|
-
? { apiKeyHelper: anthropicKeyHelperCommand() }
|
|
434
|
-
: {}),
|
|
418
|
+
...(auth?.mode === 'user-key' ? { apiKeyHelper: anthropicKeyHelperCommand() } : {}),
|
|
435
419
|
});
|
|
436
420
|
}
|
|
437
421
|
function buildAgentInvocation(backend, role, prompt, claudeModel,
|
|
@@ -442,74 +426,68 @@ openrouterModel,
|
|
|
442
426
|
// Only the claude branch can carry it: cursor-agent runs on its own key and
|
|
443
427
|
// never traverses the llm-proxy.
|
|
444
428
|
metering) {
|
|
445
|
-
if (backend ===
|
|
446
|
-
const viaOpenrouter = claudeModel ===
|
|
429
|
+
if (backend === 'claude') {
|
|
430
|
+
const viaOpenrouter = claudeModel === 'openrouter';
|
|
447
431
|
const orAuth = viaOpenrouter ? resolveOpenrouterAuth() : null;
|
|
448
432
|
const anAuth = viaOpenrouter ? null : resolveAnthropicAuth();
|
|
449
433
|
// Direct = the user's own credential/login is in play, so this run bypasses
|
|
450
434
|
// the proxy and must NOT carry metering headers (metering.ts would otherwise
|
|
451
435
|
// attach them off process.env and the direct spawn would leak them upstream).
|
|
452
|
-
const direct = viaOpenrouter
|
|
453
|
-
? orAuth.mode === "user-key"
|
|
454
|
-
: anAuth.mode !== "proxy";
|
|
436
|
+
const direct = viaOpenrouter ? orAuth.mode === 'user-key' : anAuth.mode !== 'proxy';
|
|
455
437
|
return {
|
|
456
|
-
command:
|
|
438
|
+
command: 'claude',
|
|
457
439
|
args: [
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
440
|
+
'-p',
|
|
441
|
+
'--verbose',
|
|
442
|
+
'--output-format',
|
|
443
|
+
'stream-json',
|
|
444
|
+
'--include-partial-messages',
|
|
445
|
+
'--permission-mode',
|
|
446
|
+
'auto',
|
|
465
447
|
...(viaOpenrouter ? [OPENROUTER_ALLOWED_TOOLS] : []),
|
|
466
|
-
|
|
448
|
+
'--model',
|
|
467
449
|
viaOpenrouter ? openrouterModel : claudeModel,
|
|
468
|
-
|
|
469
|
-
|
|
450
|
+
'--effort',
|
|
451
|
+
'medium',
|
|
470
452
|
// Newer claude models default thinking display to "omitted" (empty
|
|
471
453
|
// thinking_delta text, signature only); "summarized" restores actual
|
|
472
454
|
// summary text so the shell's expandable thinking transcript has
|
|
473
455
|
// content. Slight time-to-first-text cost (the API streams the
|
|
474
456
|
// summary before prose). Undocumented in --help but honored.
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
457
|
+
'--thinking-display',
|
|
458
|
+
'summarized',
|
|
459
|
+
'--settings',
|
|
478
460
|
claudeSettingsArg(anAuth),
|
|
479
|
-
|
|
480
|
-
...(role ===
|
|
481
|
-
? ["--append-system-prompt", CLAUDE_TASK_SYSTEM_REMINDER]
|
|
482
|
-
: []),
|
|
461
|
+
'--strict-mcp-config',
|
|
462
|
+
...(role === 'task' ? ['--append-system-prompt', CLAUDE_TASK_SYSTEM_REMINDER] : []),
|
|
483
463
|
prompt,
|
|
484
464
|
],
|
|
485
|
-
env: withCustomHeaders(viaOpenrouter
|
|
486
|
-
? envForOpenrouterSpawn(orAuth)
|
|
487
|
-
: envForClaudeSpawn(anAuth), meteringHeaders({
|
|
465
|
+
env: withCustomHeaders(viaOpenrouter ? envForOpenrouterSpawn(orAuth) : envForClaudeSpawn(anAuth), meteringHeaders({
|
|
488
466
|
deckDir: metering.deckDir,
|
|
489
467
|
sessionId: metering.sessionId,
|
|
490
|
-
route: viaOpenrouter ?
|
|
468
|
+
route: viaOpenrouter ? 'openrouter' : 'anthropic',
|
|
491
469
|
direct,
|
|
492
470
|
})),
|
|
493
471
|
};
|
|
494
472
|
}
|
|
495
473
|
return {
|
|
496
|
-
command:
|
|
474
|
+
command: 'cursor-agent',
|
|
497
475
|
args: [
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
476
|
+
'-p',
|
|
477
|
+
'--output-format',
|
|
478
|
+
'stream-json',
|
|
479
|
+
'--stream-partial-output',
|
|
480
|
+
'--trust',
|
|
481
|
+
'--model',
|
|
504
482
|
CURSOR_MODEL,
|
|
505
|
-
...(role ===
|
|
483
|
+
...(role === 'router' ? ['--mode', 'ask'] : ['--force']),
|
|
506
484
|
prompt,
|
|
507
485
|
],
|
|
508
486
|
env: envForAgentSpawn(backend),
|
|
509
487
|
};
|
|
510
488
|
}
|
|
511
489
|
function parserForBackend(backend) {
|
|
512
|
-
return backend ===
|
|
490
|
+
return backend === 'cursor' ? 'cursor' : 'claude';
|
|
513
491
|
}
|
|
514
492
|
const ROUTER_TIMEOUT_MS = 3 * 60_000;
|
|
515
493
|
const TASK_TIMEOUT_MS = 30 * 60_000;
|
|
@@ -537,11 +515,11 @@ const TASK_RETRY_BACKOFF_BASE_MS = Number(process.env.CASTLE_TASK_RETRY_BACKOFF_
|
|
|
537
515
|
// tests / impatient devs.
|
|
538
516
|
const TASK_SPAWN_STAGGER_MS = Number(process.env.CASTLE_TASK_SPAWN_STAGGER_MS) || 400;
|
|
539
517
|
const TASK_POLL_MS = 1_000;
|
|
540
|
-
const FENCE_HOLDBACK =
|
|
518
|
+
const FENCE_HOLDBACK = '```castle-';
|
|
541
519
|
const RESULT_SUMMARY_CHARS = 600;
|
|
542
520
|
const MAX_ATTACHMENTS = 6;
|
|
543
521
|
const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
|
|
544
|
-
const TERMINAL_STATUSES = [
|
|
522
|
+
const TERMINAL_STATUSES = ['done', 'failed', 'interrupted'];
|
|
545
523
|
function nowIso() {
|
|
546
524
|
return new Date().toISOString();
|
|
547
525
|
}
|
|
@@ -550,7 +528,7 @@ function isTerminal(status) {
|
|
|
550
528
|
}
|
|
551
529
|
function readJsonFile(filePath) {
|
|
552
530
|
try {
|
|
553
|
-
return JSON.parse(fs.readFileSync(filePath,
|
|
531
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
554
532
|
}
|
|
555
533
|
catch {
|
|
556
534
|
return null;
|
|
@@ -575,22 +553,22 @@ function visibleLength(raw) {
|
|
|
575
553
|
// extraction below and the mid-stream incremental scanner (runRouterTurnIn),
|
|
576
554
|
// so a fence spawned early behaves identically to one spawned at settle.
|
|
577
555
|
function parseTaskFenceBody(body) {
|
|
578
|
-
const lines = body.replace(/\r/g,
|
|
579
|
-
const title = (lines.shift() ??
|
|
556
|
+
const lines = body.replace(/\r/g, '').split('\n');
|
|
557
|
+
const title = (lines.shift() ?? '').trim();
|
|
580
558
|
if (!title)
|
|
581
559
|
return null;
|
|
582
560
|
const after = [];
|
|
583
561
|
while (lines.length > 0) {
|
|
584
|
-
const headerMatch = /^(after):\s*(.*)$/i.exec((lines[0] ??
|
|
562
|
+
const headerMatch = /^(after):\s*(.*)$/i.exec((lines[0] ?? '').trim());
|
|
585
563
|
if (!headerMatch)
|
|
586
564
|
break;
|
|
587
565
|
lines.shift();
|
|
588
566
|
after.push(...headerMatch[2]
|
|
589
|
-
.split(
|
|
567
|
+
.split(',')
|
|
590
568
|
.map((s) => s.trim())
|
|
591
569
|
.filter(Boolean));
|
|
592
570
|
}
|
|
593
|
-
return { title, after, prompt: lines.join(
|
|
571
|
+
return { title, after, prompt: lines.join('\n').trim() };
|
|
594
572
|
}
|
|
595
573
|
// A fresh RegExp per call -- this is matched with manual .exec() loops in
|
|
596
574
|
// TWO independent call sites (settle-time extractDirectives via .replace, and
|
|
@@ -606,25 +584,25 @@ function extractDirectives(full) {
|
|
|
606
584
|
const checkoffs = [];
|
|
607
585
|
const stops = [];
|
|
608
586
|
const listFence = (source, name, into) => {
|
|
609
|
-
const re = new RegExp(
|
|
587
|
+
const re = new RegExp('```' + name + '[ \\t]*\\r?\\n([\\s\\S]*?)```', 'g');
|
|
610
588
|
return source.replace(re, (_match, body) => {
|
|
611
589
|
for (const token of String(body).split(/[,\n]/)) {
|
|
612
590
|
const trimmed = token.trim();
|
|
613
591
|
if (trimmed)
|
|
614
592
|
into.push(trimmed);
|
|
615
593
|
}
|
|
616
|
-
return
|
|
594
|
+
return '';
|
|
617
595
|
});
|
|
618
596
|
};
|
|
619
|
-
const withoutDone = listFence(listFence(full,
|
|
597
|
+
const withoutDone = listFence(listFence(full, 'castle-done', checkoffs), 'castle-stop', stops);
|
|
620
598
|
const cleaned = withoutDone.replace(taskFenceRegex(), (_match, body) => {
|
|
621
599
|
const directive = parseTaskFenceBody(String(body));
|
|
622
600
|
if (directive)
|
|
623
601
|
directives.push(directive);
|
|
624
|
-
return
|
|
602
|
+
return '';
|
|
625
603
|
});
|
|
626
604
|
return {
|
|
627
|
-
cleaned: cleaned.replace(/\n{3,}/g,
|
|
605
|
+
cleaned: cleaned.replace(/\n{3,}/g, '\n\n').trim(),
|
|
628
606
|
directives,
|
|
629
607
|
checkoffs,
|
|
630
608
|
stops,
|
|
@@ -658,19 +636,13 @@ function scanNewTaskFences(raw, fromIndex) {
|
|
|
658
636
|
// parse it out of the live stream to drive the task card's avatar/phase (and
|
|
659
637
|
// optionally progress), and strip it from the feed so the raw block never
|
|
660
638
|
// shows. The enumerated activity avatars must match the prompt + the client.
|
|
661
|
-
const SIGNAL_AVATARS = new Set([
|
|
662
|
-
"thinking",
|
|
663
|
-
"reading",
|
|
664
|
-
"building",
|
|
665
|
-
"painting",
|
|
666
|
-
"playing",
|
|
667
|
-
]);
|
|
639
|
+
const SIGNAL_AVATARS = new Set(['thinking', 'reading', 'building', 'painting', 'playing']);
|
|
668
640
|
// Parse the `key: value` body of a ```signal block, fail-soft: unknown keys are
|
|
669
641
|
// ignored, progress is clamped 0-100, avatar must be in the enum or it's
|
|
670
642
|
// dropped. `note` and `phase` are aliases for the phase line.
|
|
671
643
|
function parseSignalBody(raw) {
|
|
672
644
|
const sig = {};
|
|
673
|
-
for (const line of raw.split(
|
|
645
|
+
for (const line of raw.split('\n')) {
|
|
674
646
|
const m = /^\s*([a-zA-Z]+)\s*:\s*(.*)$/.exec(line);
|
|
675
647
|
if (!m)
|
|
676
648
|
continue;
|
|
@@ -678,16 +650,16 @@ function parseSignalBody(raw) {
|
|
|
678
650
|
const value = m[2].trim();
|
|
679
651
|
if (!value)
|
|
680
652
|
continue;
|
|
681
|
-
if (key ===
|
|
653
|
+
if (key === 'progress') {
|
|
682
654
|
const n = parseInt(value, 10);
|
|
683
655
|
if (Number.isFinite(n))
|
|
684
656
|
sig.progress = Math.max(0, Math.min(100, n));
|
|
685
657
|
}
|
|
686
|
-
else if (key ===
|
|
658
|
+
else if (key === 'avatar') {
|
|
687
659
|
if (SIGNAL_AVATARS.has(value))
|
|
688
660
|
sig.avatar = value;
|
|
689
661
|
}
|
|
690
|
-
else if (key ===
|
|
662
|
+
else if (key === 'note' || key === 'phase') {
|
|
691
663
|
sig.phase = value;
|
|
692
664
|
}
|
|
693
665
|
}
|
|
@@ -706,20 +678,20 @@ function humanizeAskBlocks(text) {
|
|
|
706
678
|
return whole;
|
|
707
679
|
const lines = data.questions
|
|
708
680
|
.map((q) => {
|
|
709
|
-
if (!q || typeof q.q !==
|
|
681
|
+
if (!q || typeof q.q !== 'string' || !Array.isArray(q.options))
|
|
710
682
|
return null;
|
|
711
683
|
const opts = q.options
|
|
712
|
-
.filter((o) => typeof o ===
|
|
713
|
-
.join(
|
|
684
|
+
.filter((o) => typeof o === 'string' && o.trim() !== '')
|
|
685
|
+
.join(' / ');
|
|
714
686
|
if (!opts)
|
|
715
687
|
return null;
|
|
716
|
-
const mode = q.multi === true ?
|
|
688
|
+
const mode = q.multi === true ? 'pick any' : 'pick one';
|
|
717
689
|
return `- ${q.q} (${mode}): ${opts}`;
|
|
718
690
|
})
|
|
719
691
|
.filter((l) => l !== null);
|
|
720
692
|
if (lines.length === 0)
|
|
721
693
|
return whole;
|
|
722
|
-
return `[You asked the user to choose:\n${lines.join(
|
|
694
|
+
return `[You asked the user to choose:\n${lines.join('\n')}]`;
|
|
723
695
|
}
|
|
724
696
|
catch {
|
|
725
697
|
return whole;
|
|
@@ -735,7 +707,7 @@ function makeSmithRunHandle(controller) {
|
|
|
735
707
|
// Reads as "still running" for symmetry, but nothing consumes it for
|
|
736
708
|
// smith runs: the registry already excludes them by pid sign.
|
|
737
709
|
exitCode: null,
|
|
738
|
-
spawnfile:
|
|
710
|
+
spawnfile: 'castle-smith',
|
|
739
711
|
kill: () => {
|
|
740
712
|
controller.abort();
|
|
741
713
|
return true;
|
|
@@ -749,19 +721,19 @@ function baseName(p) {
|
|
|
749
721
|
// First non-blank line, capped -- the one-line technical reason shown in the
|
|
750
722
|
// UI's collapsed error disclosure. The full text goes to the consoles.
|
|
751
723
|
function firstLine(text, max = 200) {
|
|
752
|
-
const line = (text ??
|
|
753
|
-
.split(
|
|
724
|
+
const line = (text ?? '')
|
|
725
|
+
.split('\n')
|
|
754
726
|
.map((l) => l.trim())
|
|
755
727
|
.find((l) => l.length > 0);
|
|
756
|
-
return (line ??
|
|
728
|
+
return (line ?? '').slice(0, max);
|
|
757
729
|
}
|
|
758
730
|
// First string-typed value among loosely-typed tool inputs, or "" when none is
|
|
759
731
|
// a string (avoids "[object Object]" from String()-ing an object/array value).
|
|
760
732
|
function firstString(...vals) {
|
|
761
733
|
for (const v of vals)
|
|
762
|
-
if (typeof v ===
|
|
734
|
+
if (typeof v === 'string')
|
|
763
735
|
return v;
|
|
764
|
-
return
|
|
736
|
+
return '';
|
|
765
737
|
}
|
|
766
738
|
// Matches the per-task progress file an agent writes its 0-100 integer to. We
|
|
767
739
|
// hide those writes from the live feed -- they're constant noise, not work.
|
|
@@ -771,13 +743,13 @@ const PROGRESS_FILE_RE = /\.castle\/agent\/tasks\/[^/]+\/progress$/;
|
|
|
771
743
|
// Returns null to hide the action (progress-file writes + anything non-edit/read).
|
|
772
744
|
function claudeToolFeedLabel(name, input) {
|
|
773
745
|
const kind = name.toLowerCase();
|
|
774
|
-
if ([
|
|
746
|
+
if (['edit', 'write', 'notebookedit', 'multiedit'].some((p) => kind.startsWith(p))) {
|
|
775
747
|
const file = firstString(input.file_path, input.path, input.notebook_path);
|
|
776
748
|
if (!file || PROGRESS_FILE_RE.test(file))
|
|
777
749
|
return null;
|
|
778
750
|
return `Editing ${baseName(file)}`;
|
|
779
751
|
}
|
|
780
|
-
if (kind.startsWith(
|
|
752
|
+
if (kind.startsWith('read') || kind.startsWith('notebookread')) {
|
|
781
753
|
const file = firstString(input.file_path, input.path);
|
|
782
754
|
return file ? `Reading ${baseName(file)}` : null;
|
|
783
755
|
}
|
|
@@ -790,35 +762,32 @@ function claudeToolFeedLabel(name, input) {
|
|
|
790
762
|
// Mirrors the cursor toolActivityLabel verbs so the two backends read alike.
|
|
791
763
|
function genericClaudeToolLabel(name) {
|
|
792
764
|
const kind = name.toLowerCase();
|
|
793
|
-
if (kind.startsWith(
|
|
794
|
-
return
|
|
795
|
-
if ([
|
|
796
|
-
return
|
|
797
|
-
if (kind.startsWith(
|
|
798
|
-
return
|
|
799
|
-
if ([
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
return "Working";
|
|
765
|
+
if (kind.startsWith('read') || kind.startsWith('notebookread'))
|
|
766
|
+
return 'Reading the deck';
|
|
767
|
+
if (['edit', 'write', 'notebook', 'multiedit'].some((p) => kind.startsWith(p)))
|
|
768
|
+
return 'Editing files';
|
|
769
|
+
if (kind.startsWith('bash') || kind.includes('terminal'))
|
|
770
|
+
return 'Running a command';
|
|
771
|
+
if (['grep', 'glob', 'ls'].some((p) => kind.startsWith(p)) || kind.includes('search'))
|
|
772
|
+
return 'Searching the deck';
|
|
773
|
+
if (kind.startsWith('web'))
|
|
774
|
+
return 'Searching the web';
|
|
775
|
+
return 'Working';
|
|
805
776
|
}
|
|
806
777
|
// Human-readable label for a tool_call event, e.g. readToolCall -> "reading
|
|
807
778
|
// the deck". Shown as the streaming message's activity line.
|
|
808
779
|
function toolActivityLabel(ev) {
|
|
809
780
|
const call = ev.tool_call;
|
|
810
|
-
const key = call
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
if (["read", "glob", "grep", "ls", "list"].some((p) => kind.startsWith(p))) {
|
|
815
|
-
return "Reading the deck";
|
|
781
|
+
const key = call ? Object.keys(call).find((k) => k.endsWith('ToolCall')) : undefined;
|
|
782
|
+
const kind = (key ?? '').slice(0, -'ToolCall'.length).toLowerCase();
|
|
783
|
+
if (['read', 'glob', 'grep', 'ls', 'list'].some((p) => kind.startsWith(p))) {
|
|
784
|
+
return 'Reading the deck';
|
|
816
785
|
}
|
|
817
|
-
if ([
|
|
818
|
-
return
|
|
819
|
-
if ([
|
|
820
|
-
return
|
|
821
|
-
return
|
|
786
|
+
if (['write', 'edit', 'delete', 'mv'].some((p) => kind.startsWith(p)))
|
|
787
|
+
return 'Editing files';
|
|
788
|
+
if (['shell', 'bash', 'terminal'].some((p) => kind.startsWith(p)))
|
|
789
|
+
return 'Running a command';
|
|
790
|
+
return 'Working';
|
|
822
791
|
}
|
|
823
792
|
// Castle's agent CLI keys, delivered to the sandbox as a file
|
|
824
793
|
// (~/.castle/keys.json) rather than sandbox-wide env -- so an ambient key can't
|
|
@@ -830,27 +799,27 @@ function toolActivityLabel(ev) {
|
|
|
830
799
|
// openrouterApiKey), which breaks the keyless and injected-key scenarios in
|
|
831
800
|
// exactly the way that is hard to reproduce on CI. Mirrors the other
|
|
832
801
|
// CASTLE_OPENROUTER_* test overrides.
|
|
833
|
-
const CASTLE_KEYS_PATH = process.env.CASTLE_KEYS_PATH ?? path.join(os.homedir(),
|
|
802
|
+
const CASTLE_KEYS_PATH = process.env.CASTLE_KEYS_PATH ?? path.join(os.homedir(), '.castle', 'keys.json');
|
|
834
803
|
function castleKeys() {
|
|
835
804
|
try {
|
|
836
|
-
return JSON.parse(fs.readFileSync(CASTLE_KEYS_PATH,
|
|
805
|
+
return JSON.parse(fs.readFileSync(CASTLE_KEYS_PATH, 'utf8'));
|
|
837
806
|
}
|
|
838
807
|
catch {
|
|
839
808
|
return {};
|
|
840
809
|
}
|
|
841
810
|
}
|
|
842
811
|
function resolveOpenrouterAuth() {
|
|
843
|
-
const k = userKey(
|
|
812
|
+
const k = userKey('OPENROUTER_API_KEY');
|
|
844
813
|
if (k)
|
|
845
|
-
return { mode:
|
|
846
|
-
return { mode:
|
|
814
|
+
return { mode: 'user-key', key: k };
|
|
815
|
+
return { mode: 'proxy', key: openrouterApiKey() };
|
|
847
816
|
}
|
|
848
817
|
// Keys for the SPAWNING backends' env injection (envForAgentSpawn). Smith is
|
|
849
818
|
// absent by design: it never spawns a CLI -- its OpenRouter key flows through
|
|
850
819
|
// openrouterApiKey() into runAgentNative's Authorization header instead.
|
|
851
820
|
const BACKEND_KEY_ENV = {
|
|
852
|
-
claude:
|
|
853
|
-
cursor:
|
|
821
|
+
claude: 'ANTHROPIC_API_KEY',
|
|
822
|
+
cursor: 'CURSOR_API_KEY',
|
|
854
823
|
};
|
|
855
824
|
// When we inject Castle's key, any auth.json cursor cached from a DIFFERENT key
|
|
856
825
|
// -- a rotated-out old key, or a tester's own key we've chosen to override -- is
|
|
@@ -861,7 +830,7 @@ const BACKEND_KEY_ENV = {
|
|
|
861
830
|
function purgeStaleCursorAuth(home, injectedKey) {
|
|
862
831
|
try {
|
|
863
832
|
const authPath = cursorAuthPath(home);
|
|
864
|
-
const auth = JSON.parse(fs.readFileSync(authPath,
|
|
833
|
+
const auth = JSON.parse(fs.readFileSync(authPath, 'utf8'));
|
|
865
834
|
if (auth.apiKey && auth.apiKey !== injectedKey) {
|
|
866
835
|
fs.rmSync(authPath, { force: true });
|
|
867
836
|
}
|
|
@@ -874,9 +843,9 @@ function purgeStaleCursorAuth(home, injectedKey) {
|
|
|
874
843
|
// route to directly (and bill to them) instead of Castle's proxy / key. The
|
|
875
844
|
// claude side lives in byo-auth.ts, which the editor terminal shares.
|
|
876
845
|
function backendHasSavedAuth(backend) {
|
|
877
|
-
if (backend ===
|
|
846
|
+
if (backend === 'claude')
|
|
878
847
|
return claudeHasSavedLogin();
|
|
879
|
-
if (backend ===
|
|
848
|
+
if (backend === 'cursor')
|
|
880
849
|
return cursorHasUserLogin(os.homedir());
|
|
881
850
|
return false;
|
|
882
851
|
}
|
|
@@ -895,7 +864,7 @@ function backendHasSavedAuth(backend) {
|
|
|
895
864
|
// credential while doing nothing.
|
|
896
865
|
function envForClaudeSpawn(auth) {
|
|
897
866
|
const env = { ...process.env };
|
|
898
|
-
if (auth.mode ===
|
|
867
|
+
if (auth.mode === 'proxy')
|
|
899
868
|
return env;
|
|
900
869
|
for (const name of ANTHROPIC_PROXY_ENV)
|
|
901
870
|
delete env[name];
|
|
@@ -918,7 +887,7 @@ function envForAgentSpawn(backend) {
|
|
|
918
887
|
const val = castleKeys()[keyName] ?? process.env[keyName];
|
|
919
888
|
if (val) {
|
|
920
889
|
env[keyName] = val;
|
|
921
|
-
if (backend ===
|
|
890
|
+
if (backend === 'cursor')
|
|
922
891
|
purgeStaleCursorAuth(os.homedir(), val);
|
|
923
892
|
}
|
|
924
893
|
else {
|
|
@@ -927,13 +896,7 @@ function envForAgentSpawn(backend) {
|
|
|
927
896
|
}
|
|
928
897
|
return env;
|
|
929
898
|
}
|
|
930
|
-
const DECK_TREE_EXCLUDE = new Set([
|
|
931
|
-
"node_modules",
|
|
932
|
-
".castle",
|
|
933
|
-
".git",
|
|
934
|
-
"dist",
|
|
935
|
-
".DS_Store",
|
|
936
|
-
]);
|
|
899
|
+
const DECK_TREE_EXCLUDE = new Set(['node_modules', '.castle', '.git', 'dist', '.DS_Store']);
|
|
937
900
|
const DECK_TREE_MAX_ENTRIES = 200;
|
|
938
901
|
// Per-directory listing cap. A successful deck accumulates hundreds of
|
|
939
902
|
// drawings; without this, one big directory exhausts the global budget
|
|
@@ -978,23 +941,23 @@ function buildDeckTree(deckDir, caps) {
|
|
|
978
941
|
return;
|
|
979
942
|
}
|
|
980
943
|
const isDir = entry.isDirectory();
|
|
981
|
-
lines.push(`${prefix}${entry.name}${isDir ?
|
|
944
|
+
lines.push(`${prefix}${entry.name}${isDir ? '/' : ''}`);
|
|
982
945
|
if (isDir && depth < 1) {
|
|
983
|
-
walk(path.join(dir, entry.name), prefix +
|
|
946
|
+
walk(path.join(dir, entry.name), prefix + ' ', depth + 1);
|
|
984
947
|
}
|
|
985
948
|
}
|
|
986
949
|
const rest = visible.slice(perDir);
|
|
987
950
|
if (rest.length > 0 && lines.length < maxEntries) {
|
|
988
951
|
// Name the overflow's extension when it's uniform ("+214 more .pxart"),
|
|
989
952
|
// since that tells the reader what kind of files dominate the directory.
|
|
990
|
-
const exts = new Set(rest.map((e) => (e.isDirectory() ?
|
|
953
|
+
const exts = new Set(rest.map((e) => (e.isDirectory() ? '/' : path.extname(e.name))));
|
|
991
954
|
const [only] = exts;
|
|
992
|
-
const suffix = exts.size === 1 && only && only !==
|
|
955
|
+
const suffix = exts.size === 1 && only && only !== '/' ? ` ${only}` : '';
|
|
993
956
|
lines.push(`${prefix}(+${rest.length} more${suffix})`);
|
|
994
957
|
}
|
|
995
958
|
};
|
|
996
|
-
walk(deckDir,
|
|
997
|
-
return lines.join(
|
|
959
|
+
walk(deckDir, '', 0);
|
|
960
|
+
return lines.join('\n');
|
|
998
961
|
}
|
|
999
962
|
// Text source extensions buildDeckContents will inline. Deliberately an
|
|
1000
963
|
// allowlist (not "everything readable") -- decks are small web projects, so
|
|
@@ -1015,17 +978,28 @@ function buildDeckTree(deckDir, caps) {
|
|
|
1015
978
|
// anything else outside this list are skipped for the more obvious reason
|
|
1016
979
|
// that they're unreadable as text.
|
|
1017
980
|
const DECK_CONTENTS_TEXT_EXTS = new Set([
|
|
1018
|
-
|
|
1019
|
-
|
|
981
|
+
'.ts',
|
|
982
|
+
'.tsx',
|
|
983
|
+
'.js',
|
|
984
|
+
'.jsx',
|
|
985
|
+
'.mjs',
|
|
986
|
+
'.cjs',
|
|
987
|
+
'.mts',
|
|
988
|
+
'.cts',
|
|
989
|
+
'.json',
|
|
990
|
+
'.css',
|
|
991
|
+
'.html',
|
|
992
|
+
'.md',
|
|
993
|
+
'.txt',
|
|
994
|
+
'.svg',
|
|
995
|
+
'.yml',
|
|
996
|
+
'.yaml',
|
|
997
|
+
'.scene',
|
|
1020
998
|
]);
|
|
1021
999
|
// Generated/lockfiles that happen to match the extension allowlist but are
|
|
1022
1000
|
// never hand-edited -- inlining a lockfile would just spend budget other
|
|
1023
1001
|
// files need for zero benefit (nobody reads a lockfile to plan an edit).
|
|
1024
|
-
const DECK_CONTENTS_SKIP_NAMES = new Set([
|
|
1025
|
-
"package-lock.json",
|
|
1026
|
-
"pnpm-lock.yaml",
|
|
1027
|
-
"yarn.lock",
|
|
1028
|
-
]);
|
|
1002
|
+
const DECK_CONTENTS_SKIP_NAMES = new Set(['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock']);
|
|
1029
1003
|
// Per-file cap: keeps one unusually large source file (a generated bundle
|
|
1030
1004
|
// accidentally left in the tree, a huge scene) from eating the whole budget
|
|
1031
1005
|
// by itself. 16KB comfortably covers real hand-written deck files (kit engine
|
|
@@ -1091,7 +1065,7 @@ function buildDeckContents(deckDir, budget) {
|
|
|
1091
1065
|
const listed = [];
|
|
1092
1066
|
let used = 0;
|
|
1093
1067
|
for (const abs of files) {
|
|
1094
|
-
const rel = path.relative(deckDir, abs).split(path.sep).join(
|
|
1068
|
+
const rel = path.relative(deckDir, abs).split(path.sep).join('/');
|
|
1095
1069
|
if (DECK_CONTENTS_SKIP_NAMES.has(path.basename(rel)))
|
|
1096
1070
|
continue;
|
|
1097
1071
|
if (!DECK_CONTENTS_TEXT_EXTS.has(path.extname(rel).toLowerCase()))
|
|
@@ -1109,13 +1083,13 @@ function buildDeckContents(deckDir, budget) {
|
|
|
1109
1083
|
}
|
|
1110
1084
|
let content;
|
|
1111
1085
|
try {
|
|
1112
|
-
content = fs.readFileSync(abs,
|
|
1086
|
+
content = fs.readFileSync(abs, 'utf8');
|
|
1113
1087
|
}
|
|
1114
1088
|
catch {
|
|
1115
1089
|
listed.push(`${rel} (not inlined -- use read tool)`);
|
|
1116
1090
|
continue;
|
|
1117
1091
|
}
|
|
1118
|
-
if (content.includes(
|
|
1092
|
+
if (content.includes('\u0000')) {
|
|
1119
1093
|
// Looks binary despite the extension allowlist (e.g. a mislabeled
|
|
1120
1094
|
// asset) -- skip it exactly like read_file's own NUL sniff would.
|
|
1121
1095
|
listed.push(`${rel} (not inlined -- use read tool)`);
|
|
@@ -1126,19 +1100,19 @@ function buildDeckContents(deckDir, budget) {
|
|
|
1126
1100
|
}
|
|
1127
1101
|
const parts = [];
|
|
1128
1102
|
if (inlined.length > 0)
|
|
1129
|
-
parts.push(inlined.join(
|
|
1103
|
+
parts.push(inlined.join('\n\n'));
|
|
1130
1104
|
if (listed.length > 0) {
|
|
1131
|
-
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(
|
|
1105
|
+
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')}`);
|
|
1132
1106
|
}
|
|
1133
|
-
return parts.join(
|
|
1107
|
+
return parts.join('\n\n');
|
|
1134
1108
|
}
|
|
1135
1109
|
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?";
|
|
1136
1110
|
function readClaudeSection(deckDir, section) {
|
|
1137
|
-
const headingRe = new RegExp(`^## ${section}\\s*$`,
|
|
1138
|
-
for (const name of [
|
|
1139
|
-
let raw =
|
|
1111
|
+
const headingRe = new RegExp(`^## ${section}\\s*$`, 'im');
|
|
1112
|
+
for (const name of ['CLAUDE.md', 'AGENTS.md']) {
|
|
1113
|
+
let raw = '';
|
|
1140
1114
|
try {
|
|
1141
|
-
raw = fs.readFileSync(path.join(deckDir, name),
|
|
1115
|
+
raw = fs.readFileSync(path.join(deckDir, name), 'utf8');
|
|
1142
1116
|
}
|
|
1143
1117
|
catch {
|
|
1144
1118
|
continue;
|
|
@@ -1146,53 +1120,108 @@ function readClaudeSection(deckDir, section) {
|
|
|
1146
1120
|
const heading = headingRe.exec(raw);
|
|
1147
1121
|
if (!heading || heading.index === undefined)
|
|
1148
1122
|
continue;
|
|
1149
|
-
const bodyStart = raw.indexOf(
|
|
1123
|
+
const bodyStart = raw.indexOf('\n', heading.index);
|
|
1150
1124
|
if (bodyStart < 0)
|
|
1151
|
-
return
|
|
1125
|
+
return '';
|
|
1152
1126
|
const rest = raw.slice(bodyStart + 1);
|
|
1153
1127
|
const nextHeading = /^##\s+/m.exec(rest);
|
|
1154
1128
|
return (nextHeading ? rest.slice(0, nextHeading.index) : rest).trim();
|
|
1155
1129
|
}
|
|
1156
|
-
return
|
|
1130
|
+
return '';
|
|
1131
|
+
}
|
|
1132
|
+
// Which import the deck's entry actually runs, read off index.html. The kit that
|
|
1133
|
+
// is MOUNTED is the one whose vocabulary applies, so its docs lead.
|
|
1134
|
+
function entryImportAlias(deckDir) {
|
|
1135
|
+
try {
|
|
1136
|
+
const html = fs.readFileSync(path.join(deckDir, 'index.html'), 'utf8');
|
|
1137
|
+
const match = new RegExp(`src="/?${IMPORTS_DIR}/([^/"]+)/`).exec(html);
|
|
1138
|
+
return match ? match[1] : null;
|
|
1139
|
+
}
|
|
1140
|
+
catch {
|
|
1141
|
+
return null;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
function importAliases(deckDir) {
|
|
1145
|
+
try {
|
|
1146
|
+
return fs.readdirSync(path.join(deckDir, IMPORTS_DIR)).sort();
|
|
1147
|
+
}
|
|
1148
|
+
catch {
|
|
1149
|
+
return [];
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
// A deck built on a kit holds almost no docs of its own -- `init` writes it a
|
|
1153
|
+
// CLAUDE.md that POINTS at the kit's. Reading only the deck's own file therefore
|
|
1154
|
+
// found nothing, and the agent was then told "there is no documentation, do not
|
|
1155
|
+
// search for one", so it never learned the kit's vocabulary at all. Gather the
|
|
1156
|
+
// deck's own section plus every import's, entry kit first.
|
|
1157
|
+
function readClaudeSectionWithImports(deckDir, section) {
|
|
1158
|
+
const parts = [];
|
|
1159
|
+
const own = readClaudeSection(deckDir, section);
|
|
1160
|
+
if (own)
|
|
1161
|
+
parts.push(own);
|
|
1162
|
+
const entry = entryImportAlias(deckDir);
|
|
1163
|
+
const aliases = importAliases(deckDir);
|
|
1164
|
+
const ordered = entry ? [entry, ...aliases.filter((a) => a !== entry)] : aliases;
|
|
1165
|
+
for (const alias of ordered) {
|
|
1166
|
+
const text = readClaudeSection(path.join(deckDir, IMPORTS_DIR, alias), section);
|
|
1167
|
+
if (!text)
|
|
1168
|
+
continue;
|
|
1169
|
+
const role = alias === entry ? 'this deck runs on it' : 'imported, not the entry';
|
|
1170
|
+
parts.push(`### from the ${alias} kit (${role})\n\n${text}`);
|
|
1171
|
+
}
|
|
1172
|
+
return parts.join('\n\n');
|
|
1157
1173
|
}
|
|
1158
1174
|
function readQuickReference(deckDir) {
|
|
1159
|
-
return
|
|
1175
|
+
return readClaudeSectionWithImports(deckDir, 'Quick reference');
|
|
1160
1176
|
}
|
|
1177
|
+
// One greeting, not a pile of them: the mounted kit's welcome is the deck's.
|
|
1161
1178
|
function readWelcomeMessage(deckDir) {
|
|
1162
|
-
|
|
1179
|
+
const own = readClaudeSection(deckDir, 'Welcome message');
|
|
1180
|
+
if (own)
|
|
1181
|
+
return own;
|
|
1182
|
+
const entry = entryImportAlias(deckDir);
|
|
1183
|
+
const ordered = entry
|
|
1184
|
+
? [entry, ...importAliases(deckDir).filter((a) => a !== entry)]
|
|
1185
|
+
: importAliases(deckDir);
|
|
1186
|
+
for (const alias of ordered) {
|
|
1187
|
+
const text = readClaudeSection(path.join(deckDir, IMPORTS_DIR, alias), 'Welcome message');
|
|
1188
|
+
if (text)
|
|
1189
|
+
return text;
|
|
1190
|
+
}
|
|
1191
|
+
return '';
|
|
1163
1192
|
}
|
|
1164
1193
|
function createAgentStreamState() {
|
|
1165
1194
|
return {
|
|
1166
|
-
accumulated:
|
|
1167
|
-
finalText:
|
|
1195
|
+
accumulated: '',
|
|
1196
|
+
finalText: '',
|
|
1168
1197
|
resultIsError: false,
|
|
1169
1198
|
usage: undefined,
|
|
1170
1199
|
sawResult: false,
|
|
1171
|
-
segmentText:
|
|
1200
|
+
segmentText: '',
|
|
1172
1201
|
needsGap: false,
|
|
1173
1202
|
pendingTools: new Map(),
|
|
1174
1203
|
};
|
|
1175
1204
|
}
|
|
1176
1205
|
function parseCliUsage(raw) {
|
|
1177
|
-
if (!raw || typeof raw !==
|
|
1206
|
+
if (!raw || typeof raw !== 'object')
|
|
1178
1207
|
return undefined;
|
|
1179
1208
|
const src = raw;
|
|
1180
1209
|
const usage = {};
|
|
1181
1210
|
for (const key of [
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1211
|
+
'input_tokens',
|
|
1212
|
+
'output_tokens',
|
|
1213
|
+
'cache_creation_input_tokens',
|
|
1214
|
+
'cache_read_input_tokens',
|
|
1186
1215
|
]) {
|
|
1187
1216
|
const value = src[key];
|
|
1188
|
-
if (typeof value ===
|
|
1217
|
+
if (typeof value === 'number')
|
|
1189
1218
|
usage[key] = value;
|
|
1190
1219
|
}
|
|
1191
1220
|
return Object.keys(usage).length > 0 ? usage : undefined;
|
|
1192
1221
|
}
|
|
1193
1222
|
function formatTokenCount(value) {
|
|
1194
|
-
if (typeof value !==
|
|
1195
|
-
return
|
|
1223
|
+
if (typeof value !== 'number')
|
|
1224
|
+
return '?';
|
|
1196
1225
|
if (value >= 1000)
|
|
1197
1226
|
return `${(value / 1000).toFixed(1)}k`;
|
|
1198
1227
|
return String(value);
|
|
@@ -1228,8 +1257,8 @@ function makeAgentEventHandler(opts, state) {
|
|
|
1228
1257
|
let delta = rawDelta;
|
|
1229
1258
|
if (state.needsGap) {
|
|
1230
1259
|
state.needsGap = false;
|
|
1231
|
-
if (state.accumulated && !state.accumulated.endsWith(
|
|
1232
|
-
delta = (state.accumulated.endsWith(
|
|
1260
|
+
if (state.accumulated && !state.accumulated.endsWith('\n\n')) {
|
|
1261
|
+
delta = (state.accumulated.endsWith('\n') ? '\n' : '\n\n') + delta;
|
|
1233
1262
|
}
|
|
1234
1263
|
}
|
|
1235
1264
|
state.segmentText += delta;
|
|
@@ -1238,12 +1267,12 @@ function makeAgentEventHandler(opts, state) {
|
|
|
1238
1267
|
opts.onActivity?.(null);
|
|
1239
1268
|
};
|
|
1240
1269
|
const handleClaudeEvent = (ev) => {
|
|
1241
|
-
if (ev.type ===
|
|
1270
|
+
if (ev.type === 'stream_event') {
|
|
1242
1271
|
const e = ev.event;
|
|
1243
|
-
if (e?.type ===
|
|
1244
|
-
if (e.content_block?.type ===
|
|
1272
|
+
if (e?.type === 'content_block_start') {
|
|
1273
|
+
if (e.content_block?.type === 'tool_use') {
|
|
1245
1274
|
state.needsGap = true;
|
|
1246
|
-
const name = String(e.content_block.name ??
|
|
1275
|
+
const name = String(e.content_block.name ?? '');
|
|
1247
1276
|
// Router: show a coarse label NOW (name is known) so the activity
|
|
1248
1277
|
// line isn't stuck on the prior state while the tool runs; the
|
|
1249
1278
|
// concrete label (with the file) refines it at content_block_stop.
|
|
@@ -1251,43 +1280,39 @@ function makeAgentEventHandler(opts, state) {
|
|
|
1251
1280
|
opts.onActivity?.(genericClaudeToolLabel(name));
|
|
1252
1281
|
// Hold the concrete label until content_block_stop, once the input
|
|
1253
1282
|
// (file / command) has streamed in, so we can name it concretely.
|
|
1254
|
-
state.pendingTools.set(e.index ?? -1, { name, buf:
|
|
1283
|
+
state.pendingTools.set(e.index ?? -1, { name, buf: '' });
|
|
1255
1284
|
}
|
|
1256
|
-
else if (e.content_block?.type ===
|
|
1285
|
+
else if (e.content_block?.type === 'thinking') {
|
|
1257
1286
|
state.needsGap = true;
|
|
1258
1287
|
// Surface extended thinking as the activity line (mirrors the cursor
|
|
1259
1288
|
// path's `thinking` signal). The next text delta clears it via
|
|
1260
1289
|
// emitDelta's onActivity(null); a tool block relabels it.
|
|
1261
|
-
opts.onActivity?.(
|
|
1290
|
+
opts.onActivity?.('Thinking');
|
|
1262
1291
|
}
|
|
1263
1292
|
}
|
|
1264
|
-
else if (e?.type ===
|
|
1265
|
-
if (e.delta?.type ===
|
|
1266
|
-
typeof e.delta.text === "string" &&
|
|
1267
|
-
e.delta.text) {
|
|
1293
|
+
else if (e?.type === 'content_block_delta') {
|
|
1294
|
+
if (e.delta?.type === 'text_delta' && typeof e.delta.text === 'string' && e.delta.text) {
|
|
1268
1295
|
emitDelta(e.delta.text);
|
|
1269
1296
|
}
|
|
1270
|
-
else if (e.delta?.type ===
|
|
1271
|
-
typeof e.delta.thinking ===
|
|
1297
|
+
else if (e.delta?.type === 'thinking_delta' &&
|
|
1298
|
+
typeof e.delta.thinking === 'string' &&
|
|
1272
1299
|
e.delta.thinking) {
|
|
1273
1300
|
opts.onThinking?.(e.delta.thinking);
|
|
1274
1301
|
}
|
|
1275
|
-
else if (e.delta?.type ===
|
|
1276
|
-
typeof e.delta.partial_json ===
|
|
1302
|
+
else if (e.delta?.type === 'input_json_delta' &&
|
|
1303
|
+
typeof e.delta.partial_json === 'string') {
|
|
1277
1304
|
const pending = state.pendingTools.get(e.index ?? -1);
|
|
1278
1305
|
if (pending)
|
|
1279
1306
|
pending.buf += e.delta.partial_json;
|
|
1280
1307
|
}
|
|
1281
1308
|
}
|
|
1282
|
-
else if (e?.type ===
|
|
1309
|
+
else if (e?.type === 'content_block_stop') {
|
|
1283
1310
|
const pending = state.pendingTools.get(e.index ?? -1);
|
|
1284
1311
|
if (pending) {
|
|
1285
1312
|
state.pendingTools.delete(e.index ?? -1);
|
|
1286
1313
|
let input = {};
|
|
1287
1314
|
try {
|
|
1288
|
-
input = pending.buf
|
|
1289
|
-
? JSON.parse(pending.buf)
|
|
1290
|
-
: {};
|
|
1315
|
+
input = pending.buf ? JSON.parse(pending.buf) : {};
|
|
1291
1316
|
}
|
|
1292
1317
|
catch {
|
|
1293
1318
|
/* input JSON arrived partial -- fall back to a generic label */
|
|
@@ -1298,28 +1323,27 @@ function makeAgentEventHandler(opts, state) {
|
|
|
1298
1323
|
}
|
|
1299
1324
|
}
|
|
1300
1325
|
}
|
|
1301
|
-
else if (ev.type ===
|
|
1326
|
+
else if (ev.type === 'result') {
|
|
1302
1327
|
state.sawResult = true;
|
|
1303
1328
|
// An EMPTY string result falls back to the streamed text. Models that
|
|
1304
1329
|
// return their blocks as `text, thinking` (gemini through OpenRouter's
|
|
1305
1330
|
// anthropic-compatible endpoint) end the turn on an empty thinking block,
|
|
1306
1331
|
// and the CLI reports `result: ""` even though the answer streamed fine.
|
|
1307
|
-
state.finalText =
|
|
1308
|
-
typeof ev.result === "string" && ev.result ? ev.result : state.accumulated;
|
|
1332
|
+
state.finalText = typeof ev.result === 'string' && ev.result ? ev.result : state.accumulated;
|
|
1309
1333
|
state.resultIsError = ev.is_error === true;
|
|
1310
1334
|
state.usage = parseCliUsage(ev.usage);
|
|
1311
1335
|
}
|
|
1312
1336
|
};
|
|
1313
1337
|
return (ev) => {
|
|
1314
|
-
if (opts.parser ===
|
|
1338
|
+
if (opts.parser === 'claude') {
|
|
1315
1339
|
handleClaudeEvent(ev);
|
|
1316
1340
|
return;
|
|
1317
1341
|
}
|
|
1318
|
-
if (ev.type ===
|
|
1342
|
+
if (ev.type === 'assistant' && typeof ev.timestamp_ms === 'number') {
|
|
1319
1343
|
const message = ev.message;
|
|
1320
1344
|
const delta = (message?.content ?? [])
|
|
1321
|
-
.map((c) => (typeof c?.text ===
|
|
1322
|
-
.join(
|
|
1345
|
+
.map((c) => (typeof c?.text === 'string' ? c.text : ''))
|
|
1346
|
+
.join('');
|
|
1323
1347
|
if (!delta)
|
|
1324
1348
|
return;
|
|
1325
1349
|
const trimmed = delta.trim();
|
|
@@ -1327,25 +1351,24 @@ function makeAgentEventHandler(opts, state) {
|
|
|
1327
1351
|
return;
|
|
1328
1352
|
emitDelta(delta);
|
|
1329
1353
|
}
|
|
1330
|
-
else if (ev.type ===
|
|
1331
|
-
state.segmentText =
|
|
1354
|
+
else if (ev.type === 'tool_call') {
|
|
1355
|
+
state.segmentText = '';
|
|
1332
1356
|
state.needsGap = true;
|
|
1333
|
-
if (ev.subtype ===
|
|
1357
|
+
if (ev.subtype === 'started')
|
|
1334
1358
|
opts.onActivity?.(toolActivityLabel(ev));
|
|
1335
1359
|
}
|
|
1336
|
-
else if (ev.type ===
|
|
1337
|
-
state.segmentText =
|
|
1360
|
+
else if (ev.type === 'thinking') {
|
|
1361
|
+
state.segmentText = '';
|
|
1338
1362
|
state.needsGap = true;
|
|
1339
|
-
opts.onActivity?.(
|
|
1363
|
+
opts.onActivity?.('Thinking');
|
|
1340
1364
|
}
|
|
1341
|
-
else if (ev.type ===
|
|
1365
|
+
else if (ev.type === 'result') {
|
|
1342
1366
|
state.sawResult = true;
|
|
1343
1367
|
// An EMPTY string result falls back to the streamed text. Models that
|
|
1344
1368
|
// return their blocks as `text, thinking` (gemini through OpenRouter's
|
|
1345
1369
|
// anthropic-compatible endpoint) end the turn on an empty thinking block,
|
|
1346
1370
|
// and the CLI reports `result: ""` even though the answer streamed fine.
|
|
1347
|
-
state.finalText =
|
|
1348
|
-
typeof ev.result === "string" && ev.result ? ev.result : state.accumulated;
|
|
1371
|
+
state.finalText = typeof ev.result === 'string' && ev.result ? ev.result : state.accumulated;
|
|
1349
1372
|
state.resultIsError = ev.is_error === true;
|
|
1350
1373
|
state.usage = parseCliUsage(ev.usage);
|
|
1351
1374
|
}
|
|
@@ -1372,27 +1395,25 @@ function runAgentCli(opts) {
|
|
|
1372
1395
|
child = spawn(opts.command, opts.args, {
|
|
1373
1396
|
cwd: opts.cwd,
|
|
1374
1397
|
env: opts.env,
|
|
1375
|
-
stdio: [
|
|
1398
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1376
1399
|
});
|
|
1377
1400
|
}
|
|
1378
1401
|
catch (err) {
|
|
1379
1402
|
const message = err instanceof Error ? err.message : String(err);
|
|
1380
1403
|
resolve({
|
|
1381
1404
|
ok: false,
|
|
1382
|
-
finalText:
|
|
1405
|
+
finalText: '',
|
|
1383
1406
|
error: `could not run ${opts.command}: ${message}`,
|
|
1384
|
-
failure: { kind:
|
|
1407
|
+
failure: { kind: 'spawn', detail: `${opts.command}: ${message}` },
|
|
1385
1408
|
});
|
|
1386
1409
|
return;
|
|
1387
1410
|
}
|
|
1388
1411
|
opts.children.add(child);
|
|
1389
1412
|
opts.onSpawn?.(child.pid);
|
|
1390
|
-
const log = opts.logPath
|
|
1391
|
-
? fs.createWriteStream(opts.logPath, { flags: "a" })
|
|
1392
|
-
: null;
|
|
1413
|
+
const log = opts.logPath ? fs.createWriteStream(opts.logPath, { flags: 'a' }) : null;
|
|
1393
1414
|
let settled = false;
|
|
1394
|
-
let stderrTail =
|
|
1395
|
-
let lineBuffer =
|
|
1415
|
+
let stderrTail = '';
|
|
1416
|
+
let lineBuffer = '';
|
|
1396
1417
|
const state = createAgentStreamState();
|
|
1397
1418
|
const settle = (result) => {
|
|
1398
1419
|
if (settled)
|
|
@@ -1405,7 +1426,7 @@ function runAgentCli(opts) {
|
|
|
1405
1426
|
};
|
|
1406
1427
|
const timeout = setTimeout(() => {
|
|
1407
1428
|
try {
|
|
1408
|
-
child.kill(
|
|
1429
|
+
child.kill('SIGKILL');
|
|
1409
1430
|
}
|
|
1410
1431
|
catch {
|
|
1411
1432
|
/* already gone */
|
|
@@ -1413,19 +1434,19 @@ function runAgentCli(opts) {
|
|
|
1413
1434
|
settle({
|
|
1414
1435
|
ok: false,
|
|
1415
1436
|
finalText: state.finalText || state.accumulated,
|
|
1416
|
-
error:
|
|
1437
|
+
error: 'agent run timed out',
|
|
1417
1438
|
usage: state.usage,
|
|
1418
1439
|
});
|
|
1419
1440
|
}, opts.timeoutMs);
|
|
1420
1441
|
const handleEvent = makeAgentEventHandler(opts, state);
|
|
1421
|
-
child.stdout.on(
|
|
1422
|
-
lineBuffer += chunk.toString(
|
|
1423
|
-
let nl = lineBuffer.indexOf(
|
|
1442
|
+
child.stdout.on('data', (chunk) => {
|
|
1443
|
+
lineBuffer += chunk.toString('utf8');
|
|
1444
|
+
let nl = lineBuffer.indexOf('\n');
|
|
1424
1445
|
while (nl >= 0) {
|
|
1425
1446
|
const line = lineBuffer.slice(0, nl);
|
|
1426
1447
|
lineBuffer = lineBuffer.slice(nl + 1);
|
|
1427
1448
|
if (line.trim()) {
|
|
1428
|
-
log?.write(line +
|
|
1449
|
+
log?.write(line + '\n');
|
|
1429
1450
|
try {
|
|
1430
1451
|
handleEvent(JSON.parse(line));
|
|
1431
1452
|
}
|
|
@@ -1433,13 +1454,13 @@ function runAgentCli(opts) {
|
|
|
1433
1454
|
/* non-JSON noise on stdout -- ignore */
|
|
1434
1455
|
}
|
|
1435
1456
|
}
|
|
1436
|
-
nl = lineBuffer.indexOf(
|
|
1457
|
+
nl = lineBuffer.indexOf('\n');
|
|
1437
1458
|
}
|
|
1438
1459
|
});
|
|
1439
|
-
child.stderr.on(
|
|
1440
|
-
stderrTail = (stderrTail + chunk.toString(
|
|
1460
|
+
child.stderr.on('data', (chunk) => {
|
|
1461
|
+
stderrTail = (stderrTail + chunk.toString('utf8')).slice(-2000);
|
|
1441
1462
|
});
|
|
1442
|
-
child.on(
|
|
1463
|
+
child.on('error', (err) => {
|
|
1443
1464
|
settle({
|
|
1444
1465
|
ok: false,
|
|
1445
1466
|
finalText: state.accumulated,
|
|
@@ -1447,10 +1468,10 @@ function runAgentCli(opts) {
|
|
|
1447
1468
|
// actually being spawned -- this used to say "cursor-agent" for every
|
|
1448
1469
|
// backend, so a missing `claude` reported the wrong tool.
|
|
1449
1470
|
error: `could not run ${opts.command}: ${err.message}`,
|
|
1450
|
-
failure: { kind:
|
|
1471
|
+
failure: { kind: 'spawn', detail: `${opts.command}: ${err.message}` },
|
|
1451
1472
|
});
|
|
1452
1473
|
});
|
|
1453
|
-
child.on(
|
|
1474
|
+
child.on('close', (code) => {
|
|
1454
1475
|
const ok = code === 0 && !state.resultIsError && state.sawResult;
|
|
1455
1476
|
// A provider error does NOT arrive on stderr: the claude CLI reports it
|
|
1456
1477
|
// in the stream-json result event (is_error:true) and exits 0, leaving
|
|
@@ -1466,11 +1487,11 @@ function runAgentCli(opts) {
|
|
|
1466
1487
|
// as before this feature.
|
|
1467
1488
|
const classified = ok || !opts.openrouterModel
|
|
1468
1489
|
? undefined
|
|
1469
|
-
: classifyProviderError(state.finalText, opts.openrouterModel) ??
|
|
1470
|
-
classifyProviderError(stderrTail, opts.openrouterModel);
|
|
1490
|
+
: (classifyProviderError(state.finalText, opts.openrouterModel) ??
|
|
1491
|
+
classifyProviderError(stderrTail, opts.openrouterModel));
|
|
1471
1492
|
const error = ok
|
|
1472
1493
|
? undefined
|
|
1473
|
-
: `agent exited ${code}${stderrTail ? `: ${stderrTail.slice(-300)}` :
|
|
1494
|
+
: `agent exited ${code}${stderrTail ? `: ${stderrTail.slice(-300)}` : ''}`;
|
|
1474
1495
|
settle({
|
|
1475
1496
|
ok,
|
|
1476
1497
|
finalText: state.finalText || state.accumulated,
|
|
@@ -1481,7 +1502,7 @@ function runAgentCli(opts) {
|
|
|
1481
1502
|
? {
|
|
1482
1503
|
...classified,
|
|
1483
1504
|
detail: firstLine(state.finalText) || classified.detail,
|
|
1484
|
-
verbose: [state.finalText, stderrTail].filter(Boolean).join(
|
|
1505
|
+
verbose: [state.finalText, stderrTail].filter(Boolean).join('\n'),
|
|
1485
1506
|
}
|
|
1486
1507
|
: undefined,
|
|
1487
1508
|
});
|
|
@@ -1560,7 +1581,7 @@ async function runAgentSmith(opts) {
|
|
|
1560
1581
|
// instead of rejecting through startTask's catch.
|
|
1561
1582
|
return {
|
|
1562
1583
|
ok: false,
|
|
1563
|
-
finalText:
|
|
1584
|
+
finalText: '',
|
|
1564
1585
|
error: `could not run openrouter: ${err instanceof Error ? err.message : String(err)}`,
|
|
1565
1586
|
};
|
|
1566
1587
|
}
|
|
@@ -1573,10 +1594,10 @@ async function runAgentSmith(opts) {
|
|
|
1573
1594
|
// conductor.tsx (it decides whether to show the slug field) -- keep them in
|
|
1574
1595
|
// step; this one decides whether the slug is worth validating at all.
|
|
1575
1596
|
export function roleUsesOpenrouter(backend, claudeModel) {
|
|
1576
|
-
return backend ===
|
|
1597
|
+
return backend === 'smith' || (backend === 'claude' && claudeModel === 'openrouter');
|
|
1577
1598
|
}
|
|
1578
1599
|
function configFailure(reason, detail, extra) {
|
|
1579
|
-
return { kind:
|
|
1600
|
+
return { kind: 'config', reason, detail, ...extra };
|
|
1580
1601
|
}
|
|
1581
1602
|
// Everything decidable about an OpenRouter run BEFORE spending anything on it.
|
|
1582
1603
|
// Returns a failure to surface as-is, or null to proceed.
|
|
@@ -1593,32 +1614,32 @@ async function preflightOpenrouterRun(opts) {
|
|
|
1593
1614
|
const auth = opts.orAuth ?? resolveOpenrouterAuth();
|
|
1594
1615
|
const apiKey = auth.key;
|
|
1595
1616
|
if (!apiKey) {
|
|
1596
|
-
return configFailure(
|
|
1617
|
+
return configFailure('no-key', `no ${OPENROUTER_KEY_NAME} is set (checked ${CASTLE_USER_KEYS_PATH}, ${CASTLE_KEYS_PATH}, and the environment)`);
|
|
1597
1618
|
}
|
|
1598
1619
|
const model = opts.openrouterModel.trim();
|
|
1599
1620
|
if (!model) {
|
|
1600
|
-
return configFailure(
|
|
1621
|
+
return configFailure('unknown-model', 'no OpenRouter model is set for this role');
|
|
1601
1622
|
}
|
|
1602
1623
|
// Key and slug checks are independent, so overlap them rather than paying
|
|
1603
1624
|
// both round-trips in series. Both are cached and single-flighted.
|
|
1604
1625
|
const [key, slug] = await Promise.all([
|
|
1605
|
-
checkOpenrouterKey(apiKey, { direct: auth.mode ===
|
|
1626
|
+
checkOpenrouterKey(apiKey, { direct: auth.mode === 'user-key' }),
|
|
1606
1627
|
checkOpenrouterModel(model),
|
|
1607
1628
|
]);
|
|
1608
|
-
if (key.status ===
|
|
1609
|
-
return configFailure(
|
|
1629
|
+
if (key.status === 'bad-key') {
|
|
1630
|
+
return configFailure('bad-key', `OpenRouter rejected ${OPENROUTER_KEY_NAME}`);
|
|
1610
1631
|
}
|
|
1611
|
-
if (key.status ===
|
|
1612
|
-
return configFailure(
|
|
1632
|
+
if (key.status === 'no-credits') {
|
|
1633
|
+
return configFailure('no-credits', 'the OpenRouter key is out of credits');
|
|
1613
1634
|
}
|
|
1614
|
-
if (slug.status ===
|
|
1615
|
-
return configFailure(
|
|
1635
|
+
if (slug.status === 'unknown-model') {
|
|
1636
|
+
return configFailure('unknown-model', `OpenRouter has no model "${model}"`, {
|
|
1616
1637
|
model,
|
|
1617
1638
|
suggestion: slug.suggestions[0],
|
|
1618
1639
|
});
|
|
1619
1640
|
}
|
|
1620
|
-
if (slug.status ===
|
|
1621
|
-
return configFailure(
|
|
1641
|
+
if (slug.status === 'no-tools') {
|
|
1642
|
+
return configFailure('no-tools', `"${model}" does not support tool calling`, {
|
|
1622
1643
|
model,
|
|
1623
1644
|
});
|
|
1624
1645
|
}
|
|
@@ -1652,11 +1673,11 @@ function notifyAgentRunFinished() {
|
|
|
1652
1673
|
// cursor run on Castle's key stays gated, and cursor is unmetered (its traffic
|
|
1653
1674
|
// never reaches the proxy), so an ungated one would be a free ride.
|
|
1654
1675
|
function runIsCastlePaid(backend, claudeModel, orAuth) {
|
|
1655
|
-
if (backend ===
|
|
1676
|
+
if (backend === 'cursor')
|
|
1656
1677
|
return !cursorHasUserLogin(os.homedir());
|
|
1657
1678
|
return roleUsesOpenrouter(backend, claudeModel)
|
|
1658
|
-
? (orAuth ?? resolveOpenrouterAuth()).mode ===
|
|
1659
|
-
: resolveAnthropicAuth().mode ===
|
|
1679
|
+
? (orAuth ?? resolveOpenrouterAuth()).mode === 'proxy'
|
|
1680
|
+
: resolveAnthropicAuth().mode === 'proxy';
|
|
1660
1681
|
}
|
|
1661
1682
|
// Whether Castle's budget is this editor's to spend at all, which is what makes
|
|
1662
1683
|
// the usage bar worth drawing. Both roles count, and they can disagree: an
|
|
@@ -1687,8 +1708,8 @@ async function castleSpendRefusal(backend, claudeModel, orAuth) {
|
|
|
1687
1708
|
return null;
|
|
1688
1709
|
if (claudeModelBlocked(claudeModel, budget)) {
|
|
1689
1710
|
return {
|
|
1690
|
-
kind:
|
|
1691
|
-
reason:
|
|
1711
|
+
kind: 'config',
|
|
1712
|
+
reason: 'model-not-allowed',
|
|
1692
1713
|
detail: `${claudeModel} is not available on this Castle account`,
|
|
1693
1714
|
model: claudeModel,
|
|
1694
1715
|
};
|
|
@@ -1696,8 +1717,8 @@ async function castleSpendRefusal(backend, claudeModel, orAuth) {
|
|
|
1696
1717
|
if (!budget.blocked)
|
|
1697
1718
|
return null;
|
|
1698
1719
|
return {
|
|
1699
|
-
kind:
|
|
1700
|
-
detail:
|
|
1720
|
+
kind: 'limit',
|
|
1721
|
+
detail: 'daily Castle AI limit reached',
|
|
1701
1722
|
resetAtMs: budget.resetAtMs || undefined,
|
|
1702
1723
|
};
|
|
1703
1724
|
}
|
|
@@ -1705,7 +1726,7 @@ async function castleSpendRefusal(backend, claudeModel, orAuth) {
|
|
|
1705
1726
|
// finished run already refreshes. This is for the spend this serve never sees
|
|
1706
1727
|
// -- a `claude` invoked straight from the sandbox terminal.
|
|
1707
1728
|
const USAGE_POLL_MS = 60_000;
|
|
1708
|
-
const PICKER_CLAUDE_MODELS = [
|
|
1729
|
+
const PICKER_CLAUDE_MODELS = ['sonnet', 'opus', 'fable'];
|
|
1709
1730
|
/**
|
|
1710
1731
|
* Which of the picker's claude models this editor can't use. Gated on the
|
|
1711
1732
|
* ANTHROPIC credential specifically, not on `anyRoleIsCastlePaid` (which draws
|
|
@@ -1717,7 +1738,7 @@ const PICKER_CLAUDE_MODELS = ["sonnet", "opus", "fable"];
|
|
|
1717
1738
|
* resolveAnthropicAuth, so no role's settings enter into this.
|
|
1718
1739
|
*/
|
|
1719
1740
|
function blockedClaudeModels(budget) {
|
|
1720
|
-
if (resolveAnthropicAuth().mode !==
|
|
1741
|
+
if (resolveAnthropicAuth().mode !== 'proxy')
|
|
1721
1742
|
return [];
|
|
1722
1743
|
return PICKER_CLAUDE_MODELS.filter((m) => claudeModelBlocked(m, budget));
|
|
1723
1744
|
}
|
|
@@ -1752,7 +1773,7 @@ function createUsageFeed(opts) {
|
|
|
1752
1773
|
if (JSON.stringify(next ?? null) === JSON.stringify(latest ?? null))
|
|
1753
1774
|
return;
|
|
1754
1775
|
latest = next;
|
|
1755
|
-
opts.broadcast({ type:
|
|
1776
|
+
opts.broadcast({ type: 'usage', usage: next });
|
|
1756
1777
|
}
|
|
1757
1778
|
const refresh = () => void refreshAsync();
|
|
1758
1779
|
const stopRunWatch = onAgentRunFinished(refresh);
|
|
@@ -1792,7 +1813,7 @@ async function runAgentTurn(opts) {
|
|
|
1792
1813
|
if (failure) {
|
|
1793
1814
|
return {
|
|
1794
1815
|
ok: false,
|
|
1795
|
-
finalText:
|
|
1816
|
+
finalText: '',
|
|
1796
1817
|
error: failure.detail,
|
|
1797
1818
|
failure,
|
|
1798
1819
|
// NOT "crashed": nothing ran. crashed drives the task retry loop, and a
|
|
@@ -1805,9 +1826,9 @@ async function runAgentTurn(opts) {
|
|
|
1805
1826
|
// conversation -- nothing is resumed).
|
|
1806
1827
|
const sessionId = newAgentSessionId(opts.role);
|
|
1807
1828
|
const settled = (run) => run.finally(() => notifyAgentRunFinished());
|
|
1808
|
-
if (opts.backend ===
|
|
1829
|
+
if (opts.backend === 'smith') {
|
|
1809
1830
|
// roleUsesOpenrouter is true for smith, so orAuth is non-null here.
|
|
1810
|
-
const direct = orAuth.mode ===
|
|
1831
|
+
const direct = orAuth.mode === 'user-key';
|
|
1811
1832
|
return settled(runAgentSmith({
|
|
1812
1833
|
cwd: opts.cwd,
|
|
1813
1834
|
role: opts.role,
|
|
@@ -1816,14 +1837,14 @@ async function runAgentTurn(opts) {
|
|
|
1816
1837
|
extraHeaders: meteringHeaders({
|
|
1817
1838
|
deckDir: opts.cwd,
|
|
1818
1839
|
sessionId,
|
|
1819
|
-
route:
|
|
1840
|
+
route: 'openrouter',
|
|
1820
1841
|
direct,
|
|
1821
1842
|
}),
|
|
1822
1843
|
model: opts.openrouterModel,
|
|
1823
1844
|
prompt: opts.prompt,
|
|
1824
1845
|
// Mirrors claude's --append-system-prompt for tasks (the native loop
|
|
1825
1846
|
// appends it to its own system framing).
|
|
1826
|
-
systemReminder: opts.role ===
|
|
1847
|
+
systemReminder: opts.role === 'task' ? CLAUDE_TASK_SYSTEM_REMINDER : undefined,
|
|
1827
1848
|
attachments: opts.attachments,
|
|
1828
1849
|
openrouterTuning: opts.openrouterTuning,
|
|
1829
1850
|
timeoutMs: opts.timeoutMs,
|
|
@@ -1860,7 +1881,7 @@ async function runAgentTurn(opts) {
|
|
|
1860
1881
|
// Cursor is the only backend whose traffic never reaches the llm-proxy, so its
|
|
1861
1882
|
// run is reported from here. Every other backend is already recorded upstream,
|
|
1862
1883
|
// and reporting them here too would double-count them in the same table.
|
|
1863
|
-
if (opts.backend !==
|
|
1884
|
+
if (opts.backend !== 'cursor')
|
|
1864
1885
|
return settled(run);
|
|
1865
1886
|
return settled(run.then((result) => {
|
|
1866
1887
|
reportCursorRun({
|
|
@@ -1875,7 +1896,7 @@ async function runAgentTurn(opts) {
|
|
|
1875
1896
|
}
|
|
1876
1897
|
// -- task store ---------------------------------------------------------------
|
|
1877
1898
|
function persistTaskFile(tasksDir, task) {
|
|
1878
|
-
fs.writeFileSync(path.join(tasksDir, task.id,
|
|
1899
|
+
fs.writeFileSync(path.join(tasksDir, task.id, 'task.json'), JSON.stringify(task, null, 2) + '\n');
|
|
1879
1900
|
}
|
|
1880
1901
|
// Tasks left "running" by a dead serve are as finished as they will get. A
|
|
1881
1902
|
// persisted "blocked" task is left as-is: it is not "waiting", so maybeStart
|
|
@@ -1884,11 +1905,11 @@ function persistTaskFile(tasksDir, task) {
|
|
|
1884
1905
|
function loadTasks(tasksDir) {
|
|
1885
1906
|
const tasks = new Map();
|
|
1886
1907
|
for (const entry of fs.existsSync(tasksDir) ? fs.readdirSync(tasksDir) : []) {
|
|
1887
|
-
const rec = readJsonFile(path.join(tasksDir, entry,
|
|
1908
|
+
const rec = readJsonFile(path.join(tasksDir, entry, 'task.json'));
|
|
1888
1909
|
if (!rec)
|
|
1889
1910
|
continue;
|
|
1890
|
-
if (rec.status ===
|
|
1891
|
-
rec.status =
|
|
1911
|
+
if (rec.status === 'running') {
|
|
1912
|
+
rec.status = 'interrupted';
|
|
1892
1913
|
rec.updatedAt = nowIso();
|
|
1893
1914
|
persistTaskFile(tasksDir, rec);
|
|
1894
1915
|
}
|
|
@@ -1902,9 +1923,7 @@ function refreshTaskFiles(tasksDir, task) {
|
|
|
1902
1923
|
const dir = path.join(tasksDir, task.id);
|
|
1903
1924
|
let changed = false;
|
|
1904
1925
|
try {
|
|
1905
|
-
const rawProgress = fs
|
|
1906
|
-
.readFileSync(path.join(dir, "progress"), "utf8")
|
|
1907
|
-
.trim();
|
|
1926
|
+
const rawProgress = fs.readFileSync(path.join(dir, 'progress'), 'utf8').trim();
|
|
1908
1927
|
const value = Math.max(0, Math.min(100, parseInt(rawProgress, 10)));
|
|
1909
1928
|
if (Number.isFinite(value) && value !== task.progress) {
|
|
1910
1929
|
task.progress = value;
|
|
@@ -1915,7 +1934,7 @@ function refreshTaskFiles(tasksDir, task) {
|
|
|
1915
1934
|
/* no progress file yet */
|
|
1916
1935
|
}
|
|
1917
1936
|
try {
|
|
1918
|
-
const notes = fs.readFileSync(path.join(dir,
|
|
1937
|
+
const notes = fs.readFileSync(path.join(dir, 'notes.md'), 'utf8');
|
|
1919
1938
|
if (notes !== task.notes) {
|
|
1920
1939
|
task.notes = notes;
|
|
1921
1940
|
changed = true;
|
|
@@ -1951,9 +1970,9 @@ export function classifyDeps(tasks, task) {
|
|
|
1951
1970
|
const dep = tasks.get(id);
|
|
1952
1971
|
// A dep id that no longer resolves to a task (its row was cleared) or one
|
|
1953
1972
|
// that finished "done" is satisfied -- nothing left to wait on.
|
|
1954
|
-
if (!dep || dep.status ===
|
|
1973
|
+
if (!dep || dep.status === 'done')
|
|
1955
1974
|
continue;
|
|
1956
|
-
if (dep.status ===
|
|
1975
|
+
if (dep.status === 'failed' || dep.status === 'interrupted') {
|
|
1957
1976
|
blockedBy.push(dep.title);
|
|
1958
1977
|
}
|
|
1959
1978
|
else {
|
|
@@ -1961,8 +1980,8 @@ export function classifyDeps(tasks, task) {
|
|
|
1961
1980
|
}
|
|
1962
1981
|
}
|
|
1963
1982
|
if (blockedBy.length > 0)
|
|
1964
|
-
return { kind:
|
|
1965
|
-
return { kind: waiting ?
|
|
1983
|
+
return { kind: 'blocked', blockedBy };
|
|
1984
|
+
return { kind: waiting ? 'waiting' : 'ready' };
|
|
1966
1985
|
}
|
|
1967
1986
|
// Cap on how much of an upstream task's wrap-up prose rides into a dependent's
|
|
1968
1987
|
// prompt. resultSummary is already capped at RESULT_SUMMARY_CHARS; this trims
|
|
@@ -1981,12 +2000,12 @@ function depsSummaryFor(tasks, task) {
|
|
|
1981
2000
|
// and deliberately stripped of that detail.
|
|
1982
2001
|
const summary = dep.resultSummary?.trim();
|
|
1983
2002
|
if (summary)
|
|
1984
|
-
parts.push(` its wrap-up: ${summary.slice(-DEP_SUMMARY_CHARS).replace(/\n+/g,
|
|
2003
|
+
parts.push(` its wrap-up: ${summary.slice(-DEP_SUMMARY_CHARS).replace(/\n+/g, ' ')}`);
|
|
1985
2004
|
if (dep.notes.trim())
|
|
1986
2005
|
parts.push(` player notes: ${dep.notes.trim()}`);
|
|
1987
|
-
return parts.join(
|
|
2006
|
+
return parts.join('\n');
|
|
1988
2007
|
});
|
|
1989
|
-
return lines.join(
|
|
2008
|
+
return lines.join('\n') || undefined;
|
|
1990
2009
|
}
|
|
1991
2010
|
function sleep(ms) {
|
|
1992
2011
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -2020,7 +2039,7 @@ async function runTaskAgentIn(ctx, task) {
|
|
|
2020
2039
|
// Deck source inlining is smith-only (see buildDeckContents): cursor/claude
|
|
2021
2040
|
// read files themselves turn over turn, so computing and sending this
|
|
2022
2041
|
// would be pure prompt bloat for them with no offsetting benefit.
|
|
2023
|
-
const isSmith = ctx.backend ===
|
|
2042
|
+
const isSmith = ctx.backend === 'smith';
|
|
2024
2043
|
const deckContents = isSmith
|
|
2025
2044
|
? buildDeckContents(ctx.deckDir, TASK_DECK_CONTENTS_BUDGET)
|
|
2026
2045
|
: undefined;
|
|
@@ -2029,8 +2048,8 @@ async function runTaskAgentIn(ctx, task) {
|
|
|
2029
2048
|
taskId: task.id,
|
|
2030
2049
|
title: task.title,
|
|
2031
2050
|
prompt: task.prompt,
|
|
2032
|
-
progressPath: path.join(relDir,
|
|
2033
|
-
notesPath: path.join(relDir,
|
|
2051
|
+
progressPath: path.join(relDir, 'progress'),
|
|
2052
|
+
notesPath: path.join(relDir, 'notes.md'),
|
|
2034
2053
|
depsSummary: ctx.depsSummary,
|
|
2035
2054
|
backend: ctx.backend,
|
|
2036
2055
|
// Slimmed once contents are inlined -- see DECK_TREE_SLIM_* above.
|
|
@@ -2053,38 +2072,38 @@ async function runTaskAgentIn(ctx, task) {
|
|
|
2053
2072
|
// read/plan turns instead of stalling its first playtest. Smith-only:
|
|
2054
2073
|
// playtest is a native-loop tool, so CLI-backend tasks would download a
|
|
2055
2074
|
// browser they can never use.
|
|
2056
|
-
if (ctx.backend ===
|
|
2075
|
+
if (ctx.backend === 'smith')
|
|
2057
2076
|
ctx.playtest?.prewarm?.();
|
|
2058
|
-
let result = { ok: false, finalText:
|
|
2059
|
-
let lineBuf =
|
|
2077
|
+
let result = { ok: false, finalText: '', error: 'not run' };
|
|
2078
|
+
let lineBuf = '';
|
|
2060
2079
|
// ```signal blocks span multiple lines and must NOT show in the live feed:
|
|
2061
2080
|
// track whether we're inside one (line-state machine). Lines arrive complete
|
|
2062
2081
|
// (flushFeedLines only emits up to a newline), so a partial fence never
|
|
2063
2082
|
// flashes. On close we parse the body and hand it to ctx.onSignal.
|
|
2064
2083
|
let inSignal = false;
|
|
2065
|
-
let signalBody =
|
|
2084
|
+
let signalBody = '';
|
|
2066
2085
|
const flushFeedLines = (delta) => {
|
|
2067
2086
|
lineBuf += delta;
|
|
2068
|
-
let nl = lineBuf.indexOf(
|
|
2087
|
+
let nl = lineBuf.indexOf('\n');
|
|
2069
2088
|
while (nl >= 0) {
|
|
2070
2089
|
const rawLine = lineBuf.slice(0, nl);
|
|
2071
2090
|
lineBuf = lineBuf.slice(nl + 1);
|
|
2072
|
-
nl = lineBuf.indexOf(
|
|
2091
|
+
nl = lineBuf.indexOf('\n');
|
|
2073
2092
|
const line = rawLine.trim();
|
|
2074
2093
|
if (inSignal) {
|
|
2075
|
-
if (line ===
|
|
2094
|
+
if (line === '```') {
|
|
2076
2095
|
inSignal = false;
|
|
2077
2096
|
ctx.onSignal(parseSignalBody(signalBody));
|
|
2078
|
-
signalBody =
|
|
2097
|
+
signalBody = '';
|
|
2079
2098
|
}
|
|
2080
2099
|
else {
|
|
2081
|
-
signalBody += rawLine +
|
|
2100
|
+
signalBody += rawLine + '\n';
|
|
2082
2101
|
}
|
|
2083
2102
|
continue;
|
|
2084
2103
|
}
|
|
2085
|
-
if (line ===
|
|
2104
|
+
if (line === '```signal') {
|
|
2086
2105
|
inSignal = true;
|
|
2087
|
-
signalBody =
|
|
2106
|
+
signalBody = '';
|
|
2088
2107
|
continue;
|
|
2089
2108
|
}
|
|
2090
2109
|
if (line)
|
|
@@ -2095,16 +2114,20 @@ async function runTaskAgentIn(ctx, task) {
|
|
|
2095
2114
|
await staggerTaskSpawn();
|
|
2096
2115
|
result = await runAgentTurn({
|
|
2097
2116
|
backend: ctx.backend,
|
|
2098
|
-
role:
|
|
2117
|
+
role: 'task',
|
|
2099
2118
|
prompt: taskPrompt,
|
|
2100
2119
|
claudeModel: ctx.claudeModel,
|
|
2101
2120
|
openrouterModel: ctx.openrouterModel,
|
|
2102
2121
|
openrouterTuning: ctx.openrouterTuning,
|
|
2103
2122
|
cwd: ctx.deckDir,
|
|
2104
2123
|
timeoutMs: TASK_TIMEOUT_MS,
|
|
2105
|
-
logPath: path.join(dir,
|
|
2124
|
+
logPath: path.join(dir, 'log.jsonl'),
|
|
2106
2125
|
playtest: ctx.playtest
|
|
2107
|
-
? {
|
|
2126
|
+
? {
|
|
2127
|
+
executor: ctx.playtest.executor,
|
|
2128
|
+
serveUrl: ctx.playtest.serveUrl,
|
|
2129
|
+
framesDir: path.join(dir, 'playtest'),
|
|
2130
|
+
}
|
|
2108
2131
|
: undefined,
|
|
2109
2132
|
restart: ctx.restart,
|
|
2110
2133
|
children: ctx.children,
|
|
@@ -2128,7 +2151,7 @@ async function runTaskAgentIn(ctx, task) {
|
|
|
2128
2151
|
// lands here (crashed = never saw a result), and retrying that burns all
|
|
2129
2152
|
// three attempts, backoff included, on config that cannot change between
|
|
2130
2153
|
// them. Deterministic failures get exactly one attempt.
|
|
2131
|
-
if (result.failure?.kind ===
|
|
2154
|
+
if (result.failure?.kind === 'config')
|
|
2132
2155
|
return result;
|
|
2133
2156
|
if (attempt < MAX_TASK_ATTEMPTS) {
|
|
2134
2157
|
ctx.onRetry(attempt + 1);
|
|
@@ -2136,7 +2159,7 @@ async function runTaskAgentIn(ctx, task) {
|
|
|
2136
2159
|
return result;
|
|
2137
2160
|
}
|
|
2138
2161
|
}
|
|
2139
|
-
result.error = `agent process kept dying (${MAX_TASK_ATTEMPTS} attempts): ${result.error ??
|
|
2162
|
+
result.error = `agent process kept dying (${MAX_TASK_ATTEMPTS} attempts): ${result.error ?? ''}`;
|
|
2140
2163
|
return result;
|
|
2141
2164
|
}
|
|
2142
2165
|
// Launch a task's agent run and wire up its finalization. The settle path is
|
|
@@ -2145,10 +2168,10 @@ async function runTaskAgentIn(ctx, task) {
|
|
|
2145
2168
|
// (by skipping the slot free) nor crash the serve.
|
|
2146
2169
|
function startTask(ctx, task) {
|
|
2147
2170
|
const dir = path.join(ctx.tasksDir, task.id);
|
|
2148
|
-
fs.writeFileSync(path.join(dir,
|
|
2149
|
-
if (!fs.existsSync(path.join(dir,
|
|
2150
|
-
fs.writeFileSync(path.join(dir,
|
|
2151
|
-
task.status =
|
|
2171
|
+
fs.writeFileSync(path.join(dir, 'progress'), '0\n');
|
|
2172
|
+
if (!fs.existsSync(path.join(dir, 'notes.md')))
|
|
2173
|
+
fs.writeFileSync(path.join(dir, 'notes.md'), '');
|
|
2174
|
+
task.status = 'running';
|
|
2152
2175
|
task.startedAt = nowIso();
|
|
2153
2176
|
ctx.touch(task);
|
|
2154
2177
|
ctx.onStarted(task);
|
|
@@ -2177,8 +2200,7 @@ function startTask(ctx, task) {
|
|
|
2177
2200
|
onRetry: (attempt) => ctx.onRetry(task, attempt),
|
|
2178
2201
|
onSignal: (signal) => {
|
|
2179
2202
|
let changed = false;
|
|
2180
|
-
if (typeof signal.progress ===
|
|
2181
|
-
signal.progress !== task.progress) {
|
|
2203
|
+
if (typeof signal.progress === 'number' && signal.progress !== task.progress) {
|
|
2182
2204
|
task.progress = signal.progress;
|
|
2183
2205
|
changed = true;
|
|
2184
2206
|
}
|
|
@@ -2202,11 +2224,7 @@ function startTask(ctx, task) {
|
|
|
2202
2224
|
try {
|
|
2203
2225
|
refreshTaskFiles(ctx.tasksDir, task);
|
|
2204
2226
|
const wasStopped = ctx.stopRequested.delete(task.id);
|
|
2205
|
-
task.status = wasStopped
|
|
2206
|
-
? "interrupted"
|
|
2207
|
-
: result.ok
|
|
2208
|
-
? "done"
|
|
2209
|
-
: "failed";
|
|
2227
|
+
task.status = wasStopped ? 'interrupted' : result.ok ? 'done' : 'failed';
|
|
2210
2228
|
// A stopped task is cleared off the board (castle-stop = halt + remove).
|
|
2211
2229
|
if (wasStopped)
|
|
2212
2230
|
task.acknowledged = true;
|
|
@@ -2215,10 +2233,10 @@ function startTask(ctx, task) {
|
|
|
2215
2233
|
task.playtestFrames = result.playtestFrames ?? [];
|
|
2216
2234
|
task.finishedAt = nowIso();
|
|
2217
2235
|
task.resultSummary = wasStopped
|
|
2218
|
-
?
|
|
2236
|
+
? 'stopped by the router'
|
|
2219
2237
|
: result.ok
|
|
2220
2238
|
? result.finalText.slice(-RESULT_SUMMARY_CHARS)
|
|
2221
|
-
: `${result.error ??
|
|
2239
|
+
: `${result.error ?? 'failed'}\n${result.finalText.slice(-RESULT_SUMMARY_CHARS)}`;
|
|
2222
2240
|
if (!wasStopped && !result.ok) {
|
|
2223
2241
|
const failure = resolveFailure(result);
|
|
2224
2242
|
// spawnedTasks/willRetry are router-turn concepts; a task card has
|
|
@@ -2235,11 +2253,11 @@ function startTask(ctx, task) {
|
|
|
2235
2253
|
ctx.touch(task);
|
|
2236
2254
|
}
|
|
2237
2255
|
catch (err) {
|
|
2238
|
-
console.error(`[task ${task.id}] finalization threw: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
2256
|
+
console.error(`[task ${task.id}] finalization threw: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
|
|
2239
2257
|
// Best-effort: still mark the task terminal so it can't hang forever.
|
|
2240
2258
|
try {
|
|
2241
2259
|
ctx.stopRequested.delete(task.id);
|
|
2242
|
-
task.status =
|
|
2260
|
+
task.status = 'failed';
|
|
2243
2261
|
task.finishedAt = nowIso();
|
|
2244
2262
|
task.resultSummary = `internal error finalizing task: ${err instanceof Error ? err.message : String(err)}`;
|
|
2245
2263
|
ctx.touch(task);
|
|
@@ -2254,17 +2272,17 @@ function startTask(ctx, task) {
|
|
|
2254
2272
|
ctx.onFinished(task);
|
|
2255
2273
|
}
|
|
2256
2274
|
catch (err) {
|
|
2257
|
-
console.error(`[task ${task.id}] onFinished threw: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
2275
|
+
console.error(`[task ${task.id}] onFinished threw: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
|
|
2258
2276
|
}
|
|
2259
2277
|
ctx.rescheduleAll();
|
|
2260
2278
|
}
|
|
2261
2279
|
})
|
|
2262
2280
|
.catch((err) => {
|
|
2263
2281
|
// runTaskAgentIn itself rejected. Free the slot so the board keeps moving.
|
|
2264
|
-
console.error(`[task ${task.id}] run promise rejected: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
2282
|
+
console.error(`[task ${task.id}] run promise rejected: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
|
|
2265
2283
|
try {
|
|
2266
2284
|
ctx.stopRequested.delete(task.id);
|
|
2267
|
-
task.status =
|
|
2285
|
+
task.status = 'failed';
|
|
2268
2286
|
task.finishedAt = nowIso();
|
|
2269
2287
|
task.resultSummary = `task run failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
2270
2288
|
ctx.touch(task);
|
|
@@ -2283,17 +2301,17 @@ function startTask(ctx, task) {
|
|
|
2283
2301
|
// process, so it collapses the same way "waiting" does -- this is the only
|
|
2284
2302
|
// way a blocked row ever clears (see ROUTER_RULES).
|
|
2285
2303
|
function haltTask(task, children, stopRequested, touch) {
|
|
2286
|
-
if (task.status ===
|
|
2287
|
-
task.status =
|
|
2304
|
+
if (task.status === 'waiting' || task.status === 'blocked') {
|
|
2305
|
+
task.status = 'interrupted';
|
|
2288
2306
|
task.acknowledged = true;
|
|
2289
2307
|
touch(task);
|
|
2290
2308
|
}
|
|
2291
|
-
else if (task.status ===
|
|
2309
|
+
else if (task.status === 'running') {
|
|
2292
2310
|
stopRequested.add(task.id);
|
|
2293
2311
|
for (const child of children) {
|
|
2294
2312
|
if (child.pid === task.pid) {
|
|
2295
2313
|
try {
|
|
2296
|
-
child.kill(
|
|
2314
|
+
child.kill('SIGKILL');
|
|
2297
2315
|
}
|
|
2298
2316
|
catch {
|
|
2299
2317
|
/* already gone */
|
|
@@ -2319,21 +2337,21 @@ function createTaskStore(opts) {
|
|
|
2319
2337
|
function runningCount() {
|
|
2320
2338
|
let n = 0;
|
|
2321
2339
|
for (const t of tasks.values())
|
|
2322
|
-
if (t.status ===
|
|
2340
|
+
if (t.status === 'running')
|
|
2323
2341
|
n++;
|
|
2324
2342
|
return n;
|
|
2325
2343
|
}
|
|
2326
2344
|
function maybeStart(task) {
|
|
2327
|
-
if (task.status !==
|
|
2345
|
+
if (task.status !== 'waiting' || task.acknowledged)
|
|
2328
2346
|
return;
|
|
2329
2347
|
const deps = classifyDeps(tasks, task);
|
|
2330
|
-
if (deps.kind ===
|
|
2348
|
+
if (deps.kind === 'waiting')
|
|
2331
2349
|
return;
|
|
2332
2350
|
// A dep finalized failed/interrupted -- this task can never do its job
|
|
2333
2351
|
// (the output it needed never materialized), so it flips to "blocked"
|
|
2334
2352
|
// instead of ever starting. Only the router clears it (castle-stop).
|
|
2335
|
-
if (deps.kind ===
|
|
2336
|
-
task.status =
|
|
2353
|
+
if (deps.kind === 'blocked') {
|
|
2354
|
+
task.status = 'blocked';
|
|
2337
2355
|
task.blockedBy = deps.blockedBy;
|
|
2338
2356
|
touch(task);
|
|
2339
2357
|
return;
|
|
@@ -2354,7 +2372,7 @@ function createTaskStore(opts) {
|
|
|
2354
2372
|
maybeStart(waiting);
|
|
2355
2373
|
}
|
|
2356
2374
|
catch (err) {
|
|
2357
|
-
console.error(`[task ${waiting.id}] maybeStart threw: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
2375
|
+
console.error(`[task ${waiting.id}] maybeStart threw: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
|
|
2358
2376
|
}
|
|
2359
2377
|
}
|
|
2360
2378
|
}
|
|
@@ -2386,9 +2404,9 @@ function createTaskStore(opts) {
|
|
|
2386
2404
|
title: directive.title,
|
|
2387
2405
|
prompt: directive.prompt,
|
|
2388
2406
|
after: resolveDeps(tasks, directive.after),
|
|
2389
|
-
status:
|
|
2407
|
+
status: 'waiting',
|
|
2390
2408
|
progress: 0,
|
|
2391
|
-
notes:
|
|
2409
|
+
notes: '',
|
|
2392
2410
|
createdAt: nowIso(),
|
|
2393
2411
|
updatedAt: nowIso(),
|
|
2394
2412
|
originMessageId,
|
|
@@ -2412,7 +2430,7 @@ function createTaskStore(opts) {
|
|
|
2412
2430
|
}
|
|
2413
2431
|
const pollTimer = setInterval(() => {
|
|
2414
2432
|
for (const task of tasks.values()) {
|
|
2415
|
-
if (task.status !==
|
|
2433
|
+
if (task.status !== 'running')
|
|
2416
2434
|
continue;
|
|
2417
2435
|
if (refreshTaskFiles(tasksDir, task))
|
|
2418
2436
|
touch(task);
|
|
@@ -2421,8 +2439,8 @@ function createTaskStore(opts) {
|
|
|
2421
2439
|
function shutdown() {
|
|
2422
2440
|
clearInterval(pollTimer);
|
|
2423
2441
|
for (const task of tasks.values()) {
|
|
2424
|
-
if (task.status ===
|
|
2425
|
-
task.status =
|
|
2442
|
+
if (task.status === 'running' || task.status === 'waiting') {
|
|
2443
|
+
task.status = 'interrupted';
|
|
2426
2444
|
task.updatedAt = nowIso();
|
|
2427
2445
|
persistTaskFile(tasksDir, task);
|
|
2428
2446
|
}
|
|
@@ -2431,15 +2449,13 @@ function createTaskStore(opts) {
|
|
|
2431
2449
|
// True when a fence body is the special token "all" / "*" (clear/stop
|
|
2432
2450
|
// everything, no per-task enumeration).
|
|
2433
2451
|
function meansAll(tokens) {
|
|
2434
|
-
return tokens.some((t) => t.toLowerCase() ===
|
|
2452
|
+
return tokens.some((t) => t.toLowerCase() === 'all' || t === '*');
|
|
2435
2453
|
}
|
|
2436
2454
|
// The router checks finished tasks off by title or id (castle-done fence),
|
|
2437
2455
|
// or "all" to clear every finished row off the board at once.
|
|
2438
2456
|
function checkOff(tokens) {
|
|
2439
2457
|
const ids = meansAll(tokens)
|
|
2440
|
-
? [...tasks.values()]
|
|
2441
|
-
.filter((t) => isTerminal(t.status) && !t.acknowledged)
|
|
2442
|
-
.map((t) => t.id)
|
|
2458
|
+
? [...tasks.values()].filter((t) => isTerminal(t.status) && !t.acknowledged).map((t) => t.id)
|
|
2443
2459
|
: resolveDeps(tasks, tokens);
|
|
2444
2460
|
for (const id of ids)
|
|
2445
2461
|
acknowledge(id, false);
|
|
@@ -2452,9 +2468,7 @@ function createTaskStore(opts) {
|
|
|
2452
2468
|
function stop(tokens) {
|
|
2453
2469
|
const ids = meansAll(tokens)
|
|
2454
2470
|
? [...tasks.values()]
|
|
2455
|
-
.filter((t) => t.status ===
|
|
2456
|
-
t.status === "waiting" ||
|
|
2457
|
-
t.status === "blocked")
|
|
2471
|
+
.filter((t) => t.status === 'running' || t.status === 'waiting' || t.status === 'blocked')
|
|
2458
2472
|
.map((t) => t.id)
|
|
2459
2473
|
: resolveDeps(tasks, tokens);
|
|
2460
2474
|
for (const id of ids) {
|
|
@@ -2475,11 +2489,11 @@ function createTaskStore(opts) {
|
|
|
2475
2489
|
}
|
|
2476
2490
|
// -- attachments ----------------------------------------------------------------
|
|
2477
2491
|
const ATTACHMENT_MIME = {
|
|
2478
|
-
png:
|
|
2479
|
-
jpg:
|
|
2480
|
-
jpeg:
|
|
2481
|
-
gif:
|
|
2482
|
-
webp:
|
|
2492
|
+
png: 'image/png',
|
|
2493
|
+
jpg: 'image/jpeg',
|
|
2494
|
+
jpeg: 'image/jpeg',
|
|
2495
|
+
gif: 'image/gif',
|
|
2496
|
+
webp: 'image/webp',
|
|
2483
2497
|
};
|
|
2484
2498
|
// Decode pasted/attached images (data URLs) into .castle/agent/attachments/.
|
|
2485
2499
|
// Returns the saved file names.
|
|
@@ -2489,17 +2503,16 @@ function saveAttachments(attachmentsDir, messageId, images) {
|
|
|
2489
2503
|
const saved = [];
|
|
2490
2504
|
for (const [index, image] of images.slice(0, MAX_ATTACHMENTS).entries()) {
|
|
2491
2505
|
const dataUrl = image?.dataUrl;
|
|
2492
|
-
if (typeof dataUrl !==
|
|
2493
|
-
dataUrl.length > MAX_ATTACHMENT_BYTES * 1.4)
|
|
2506
|
+
if (typeof dataUrl !== 'string' || dataUrl.length > MAX_ATTACHMENT_BYTES * 1.4)
|
|
2494
2507
|
continue;
|
|
2495
2508
|
const match = /^data:image\/(png|jpe?g|gif|webp);base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl);
|
|
2496
2509
|
if (!match)
|
|
2497
2510
|
continue;
|
|
2498
|
-
const ext = match[1] ===
|
|
2511
|
+
const ext = match[1] === 'jpeg' ? 'jpg' : match[1];
|
|
2499
2512
|
const fileName = `${messageId}-${index}.${ext}`;
|
|
2500
2513
|
try {
|
|
2501
2514
|
fs.mkdirSync(attachmentsDir, { recursive: true });
|
|
2502
|
-
fs.writeFileSync(path.join(attachmentsDir, fileName), Buffer.from(match[2],
|
|
2515
|
+
fs.writeFileSync(path.join(attachmentsDir, fileName), Buffer.from(match[2], 'base64'));
|
|
2503
2516
|
saved.push(fileName);
|
|
2504
2517
|
}
|
|
2505
2518
|
catch {
|
|
@@ -2539,13 +2552,13 @@ function looksLikeErrorLine(line) {
|
|
|
2539
2552
|
}
|
|
2540
2553
|
function firstErrorLine(resultSummary) {
|
|
2541
2554
|
const lines = resultSummary
|
|
2542
|
-
?.split(
|
|
2555
|
+
?.split('\n')
|
|
2543
2556
|
.map((l) => l.trim())
|
|
2544
2557
|
.filter((l) => l.length > 0) ?? [];
|
|
2545
2558
|
if (lines.length === 0)
|
|
2546
2559
|
return undefined;
|
|
2547
2560
|
const first = lines[0];
|
|
2548
|
-
if (first ===
|
|
2561
|
+
if (first === 'agent exited 0') {
|
|
2549
2562
|
return (lines[1] ?? first).slice(0, ERROR_PREVIEW_CHARS);
|
|
2550
2563
|
}
|
|
2551
2564
|
if (!EXIT_WRAPPER_RE.test(first) || looksLikeErrorLine(first)) {
|
|
@@ -2563,11 +2576,11 @@ function asPromptTask(task) {
|
|
|
2563
2576
|
return {
|
|
2564
2577
|
id: task.id,
|
|
2565
2578
|
title: task.title,
|
|
2566
|
-
status: task.rejected ?
|
|
2579
|
+
status: task.rejected ? 'rejected by user' : task.status,
|
|
2567
2580
|
progress: task.progress,
|
|
2568
2581
|
notes: task.notes,
|
|
2569
|
-
error: task.status ===
|
|
2570
|
-
blockedBy: task.status ===
|
|
2582
|
+
error: task.status === 'failed' ? firstErrorLine(task.resultSummary) : undefined,
|
|
2583
|
+
blockedBy: task.status === 'blocked' ? task.blockedBy : undefined,
|
|
2571
2584
|
};
|
|
2572
2585
|
}
|
|
2573
2586
|
function asClientTask(task) {
|
|
@@ -2603,7 +2616,7 @@ function createTaskFeeds(broadcast) {
|
|
|
2603
2616
|
feed.push(entry);
|
|
2604
2617
|
if (feed.length > 80)
|
|
2605
2618
|
feed.splice(0, feed.length - 80);
|
|
2606
|
-
broadcast({ type:
|
|
2619
|
+
broadcast({ type: 'task-feed', id: task.id, entry });
|
|
2607
2620
|
}
|
|
2608
2621
|
return { map, push };
|
|
2609
2622
|
}
|
|
@@ -2619,7 +2632,7 @@ function createMessageThinking(broadcast) {
|
|
|
2619
2632
|
else {
|
|
2620
2633
|
map.set(id, { text: delta, firstAt: now, lastAt: now });
|
|
2621
2634
|
}
|
|
2622
|
-
broadcast({ type:
|
|
2635
|
+
broadcast({ type: 'message-thinking-delta', id, delta });
|
|
2623
2636
|
}
|
|
2624
2637
|
function snapshot() {
|
|
2625
2638
|
return Object.fromEntries([...map].map(([id, e]) => [id, { text: e.text, ms: e.lastAt - e.firstAt }]));
|
|
@@ -2629,40 +2642,40 @@ function createMessageThinking(broadcast) {
|
|
|
2629
2642
|
function createMessageLog(messagesPath, broadcast, welcomeMessage) {
|
|
2630
2643
|
const loaded = readJsonFile(messagesPath) ?? [];
|
|
2631
2644
|
const messages = loaded
|
|
2632
|
-
.filter((m) => m.text.trim() !==
|
|
2633
|
-
.map((m) => m.status ===
|
|
2645
|
+
.filter((m) => m.text.trim() !== '' || m.role === 'user')
|
|
2646
|
+
.map((m) => (m.status === 'streaming' ? { ...m, status: 'done' } : m));
|
|
2634
2647
|
function persist() {
|
|
2635
|
-
fs.writeFileSync(messagesPath, JSON.stringify(messages, null, 2) +
|
|
2648
|
+
fs.writeFileSync(messagesPath, JSON.stringify(messages, null, 2) + '\n');
|
|
2636
2649
|
}
|
|
2637
2650
|
if (messages.length === 0) {
|
|
2638
2651
|
messages.push({
|
|
2639
2652
|
id: nanoid(8),
|
|
2640
|
-
role:
|
|
2653
|
+
role: 'assistant',
|
|
2641
2654
|
text: welcomeMessage,
|
|
2642
2655
|
at: nowIso(),
|
|
2643
|
-
status:
|
|
2656
|
+
status: 'done',
|
|
2644
2657
|
});
|
|
2645
2658
|
persist();
|
|
2646
2659
|
}
|
|
2647
2660
|
function add(message) {
|
|
2648
2661
|
messages.push(message);
|
|
2649
2662
|
persist();
|
|
2650
|
-
broadcast({ type:
|
|
2663
|
+
broadcast({ type: 'message-add', message });
|
|
2651
2664
|
}
|
|
2652
2665
|
function addLog(text) {
|
|
2653
|
-
add({ id: nanoid(8), role:
|
|
2666
|
+
add({ id: nanoid(8), role: 'log', text, at: nowIso(), status: 'done' });
|
|
2654
2667
|
}
|
|
2655
2668
|
// Consecutive same-prefix log lines collapse into one ("working on: A, B").
|
|
2656
2669
|
function addGroupedLog(prefix, item) {
|
|
2657
2670
|
const last = messages[messages.length - 1];
|
|
2658
|
-
if (last && last.role ===
|
|
2671
|
+
if (last && last.role === 'log' && last.text.startsWith(prefix)) {
|
|
2659
2672
|
last.text += `, ${item}`;
|
|
2660
2673
|
persist();
|
|
2661
2674
|
broadcast({
|
|
2662
|
-
type:
|
|
2675
|
+
type: 'message-done',
|
|
2663
2676
|
id: last.id,
|
|
2664
2677
|
text: last.text,
|
|
2665
|
-
status:
|
|
2678
|
+
status: 'done',
|
|
2666
2679
|
});
|
|
2667
2680
|
return;
|
|
2668
2681
|
}
|
|
@@ -2676,14 +2689,14 @@ function makeAttachmentHandler(attachmentsDir) {
|
|
|
2676
2689
|
if (!reqPath.startsWith(AGENT_ATTACHMENT_PREFIX))
|
|
2677
2690
|
return false;
|
|
2678
2691
|
const name = path.basename(reqPath.slice(AGENT_ATTACHMENT_PREFIX.length));
|
|
2679
|
-
const ext = name.split(
|
|
2692
|
+
const ext = name.split('.').pop() ?? '';
|
|
2680
2693
|
const mime = ATTACHMENT_MIME[ext];
|
|
2681
2694
|
const filePath = path.join(attachmentsDir, name);
|
|
2682
2695
|
if (!mime || !fs.existsSync(filePath)) {
|
|
2683
2696
|
res.writeHead(404).end();
|
|
2684
2697
|
return true;
|
|
2685
2698
|
}
|
|
2686
|
-
res.writeHead(200, {
|
|
2699
|
+
res.writeHead(200, { 'content-type': mime, 'cache-control': 'no-store' });
|
|
2687
2700
|
fs.createReadStream(filePath).pipe(res);
|
|
2688
2701
|
return true;
|
|
2689
2702
|
};
|
|
@@ -2696,15 +2709,15 @@ function makePlaytestFrameHandler(tasksDir) {
|
|
|
2696
2709
|
if (!reqPath.startsWith(AGENT_PLAYTEST_PREFIX))
|
|
2697
2710
|
return false;
|
|
2698
2711
|
const rest = reqPath.slice(AGENT_PLAYTEST_PREFIX.length);
|
|
2699
|
-
const slash = rest.indexOf(
|
|
2700
|
-
const taskId = slash >= 0 ? path.basename(rest.slice(0, slash)) :
|
|
2701
|
-
const name = slash >= 0 ? path.basename(rest.slice(slash + 1)) :
|
|
2702
|
-
const filePath = path.join(tasksDir, taskId,
|
|
2703
|
-
if (!taskId || !name || !name.endsWith(
|
|
2712
|
+
const slash = rest.indexOf('/');
|
|
2713
|
+
const taskId = slash >= 0 ? path.basename(rest.slice(0, slash)) : '';
|
|
2714
|
+
const name = slash >= 0 ? path.basename(rest.slice(slash + 1)) : '';
|
|
2715
|
+
const filePath = path.join(tasksDir, taskId, 'playtest', name);
|
|
2716
|
+
if (!taskId || !name || !name.endsWith('.png') || !fs.existsSync(filePath)) {
|
|
2704
2717
|
res.writeHead(404).end();
|
|
2705
2718
|
return true;
|
|
2706
2719
|
}
|
|
2707
|
-
res.writeHead(200, {
|
|
2720
|
+
res.writeHead(200, { 'content-type': 'image/png', 'cache-control': 'no-store' });
|
|
2708
2721
|
fs.createReadStream(filePath).pipe(res);
|
|
2709
2722
|
return true;
|
|
2710
2723
|
};
|
|
@@ -2724,12 +2737,12 @@ function resolveFailure(result) {
|
|
|
2724
2737
|
if (result.failure)
|
|
2725
2738
|
return result.failure;
|
|
2726
2739
|
const error = result.error;
|
|
2727
|
-
const kind = error?.startsWith(
|
|
2728
|
-
?
|
|
2729
|
-
: error ===
|
|
2730
|
-
?
|
|
2731
|
-
:
|
|
2732
|
-
return { kind, detail: error ??
|
|
2740
|
+
const kind = error?.startsWith('could not run')
|
|
2741
|
+
? 'spawn'
|
|
2742
|
+
: error === 'agent run timed out'
|
|
2743
|
+
? 'timeout'
|
|
2744
|
+
: 'exit';
|
|
2745
|
+
return { kind, detail: error ?? 'unknown failure' };
|
|
2733
2746
|
}
|
|
2734
2747
|
// Assemble the full stateless prompt for one router turn: rules + deck
|
|
2735
2748
|
// context + transcript replay (minus log lines and the in-flight reply) +
|
|
@@ -2738,25 +2751,21 @@ function routerTurnPrompt(ctx, instruction, selfMessageId) {
|
|
|
2738
2751
|
// Smith-only, smaller budget than a task's -- see TASK_DECK_CONTENTS_BUDGET/
|
|
2739
2752
|
// ROUTER_DECK_CONTENTS_BUDGET's comment (the router prompt is already the
|
|
2740
2753
|
// largest one this serve builds).
|
|
2741
|
-
const isSmith = ctx.backend() ===
|
|
2754
|
+
const isSmith = ctx.backend() === 'smith';
|
|
2742
2755
|
return buildRouterPrompt({
|
|
2743
2756
|
deckLabel: ctx.deckLabel,
|
|
2744
2757
|
quickReference: ctx.quickReference,
|
|
2745
2758
|
deckTree: buildDeckTree(ctx.deckDir, isSmith
|
|
2746
2759
|
? { maxEntries: DECK_TREE_SLIM_MAX_ENTRIES, perDir: DECK_TREE_SLIM_PER_DIR }
|
|
2747
2760
|
: undefined),
|
|
2748
|
-
deckContents: isSmith
|
|
2749
|
-
? buildDeckContents(ctx.deckDir, ROUTER_DECK_CONTENTS_BUDGET)
|
|
2750
|
-
: undefined,
|
|
2761
|
+
deckContents: isSmith ? buildDeckContents(ctx.deckDir, ROUTER_DECK_CONTENTS_BUDGET) : undefined,
|
|
2751
2762
|
messages: ctx.log.messages
|
|
2752
|
-
.filter((m) => m.role !==
|
|
2753
|
-
m.id !== selfMessageId &&
|
|
2754
|
-
m.status !== "streaming")
|
|
2763
|
+
.filter((m) => m.role !== 'log' && m.id !== selfMessageId && m.status !== 'streaming')
|
|
2755
2764
|
.map((m) => ({
|
|
2756
2765
|
role: m.role,
|
|
2757
2766
|
// Replace a prior turn's raw ```ask JSON with a readable question list
|
|
2758
2767
|
// so the model doesn't re-echo the block verbatim.
|
|
2759
|
-
text: m.role ===
|
|
2768
|
+
text: m.role === 'assistant' ? humanizeAskBlocks(m.text) : m.text,
|
|
2760
2769
|
interrupted: m.interrupted,
|
|
2761
2770
|
})),
|
|
2762
2771
|
// Only the live board -- match what the user sees. Hide tasks that are
|
|
@@ -2781,26 +2790,26 @@ function routerTurnPrompt(ctx, instruction, selfMessageId) {
|
|
|
2781
2790
|
// anchor, so it always takes the interrupted-draft (kept) path instead.
|
|
2782
2791
|
function settleInterruptedTurn(ctx, message) {
|
|
2783
2792
|
const hasSpawnedTasks = (message.taskIds?.length ?? 0) > 0;
|
|
2784
|
-
if (message.text.trim() ===
|
|
2793
|
+
if (message.text.trim() === '' && !hasSpawnedTasks) {
|
|
2785
2794
|
const idx = ctx.log.messages.indexOf(message);
|
|
2786
2795
|
if (idx >= 0)
|
|
2787
2796
|
ctx.log.messages.splice(idx, 1);
|
|
2788
2797
|
ctx.log.persist();
|
|
2789
2798
|
ctx.broadcast({
|
|
2790
|
-
type:
|
|
2799
|
+
type: 'message-done',
|
|
2791
2800
|
id: message.id,
|
|
2792
|
-
text:
|
|
2793
|
-
status:
|
|
2801
|
+
text: '',
|
|
2802
|
+
status: 'done',
|
|
2794
2803
|
});
|
|
2795
2804
|
return;
|
|
2796
2805
|
}
|
|
2797
2806
|
// Keep whatever streamed and/or spawned; the continuation turn carries the
|
|
2798
2807
|
// text draft (tasks already launched by this turn just stay on the board).
|
|
2799
|
-
message.status =
|
|
2808
|
+
message.status = 'done';
|
|
2800
2809
|
message.interrupted = true;
|
|
2801
2810
|
ctx.log.persist();
|
|
2802
2811
|
ctx.broadcast({
|
|
2803
|
-
type:
|
|
2812
|
+
type: 'message-done',
|
|
2804
2813
|
id: message.id,
|
|
2805
2814
|
text: message.text,
|
|
2806
2815
|
status: message.status,
|
|
@@ -2822,7 +2831,7 @@ function spawnCompletedTaskFences(ctx, message, midStream, raw) {
|
|
|
2822
2831
|
return;
|
|
2823
2832
|
const inFlight = new Set(ctx.taskStore
|
|
2824
2833
|
.sorted()
|
|
2825
|
-
.filter((t) => t.status ===
|
|
2834
|
+
.filter((t) => t.status === 'running' || t.status === 'waiting')
|
|
2826
2835
|
.map((t) => t.title.toLowerCase()));
|
|
2827
2836
|
const newIds = [];
|
|
2828
2837
|
for (const directive of directives) {
|
|
@@ -2839,9 +2848,9 @@ function spawnCompletedTaskFences(ctx, message, midStream, raw) {
|
|
|
2839
2848
|
// ends) -- this delta only carries the updated taskIds so connected clients
|
|
2840
2849
|
// can react (e.g. show task chips) before the turn finishes.
|
|
2841
2850
|
ctx.broadcast({
|
|
2842
|
-
type:
|
|
2851
|
+
type: 'message-delta',
|
|
2843
2852
|
id: message.id,
|
|
2844
|
-
delta:
|
|
2853
|
+
delta: '',
|
|
2845
2854
|
taskIds: message.taskIds,
|
|
2846
2855
|
});
|
|
2847
2856
|
}
|
|
@@ -2854,14 +2863,14 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
|
|
|
2854
2863
|
const epoch = ctx.currentEpoch();
|
|
2855
2864
|
const message = {
|
|
2856
2865
|
id: nanoid(8),
|
|
2857
|
-
role:
|
|
2858
|
-
text:
|
|
2866
|
+
role: 'assistant',
|
|
2867
|
+
text: '',
|
|
2859
2868
|
at: nowIso(),
|
|
2860
|
-
status:
|
|
2869
|
+
status: 'streaming',
|
|
2861
2870
|
};
|
|
2862
2871
|
ctx.log.messages.push(message);
|
|
2863
|
-
ctx.broadcast({ type:
|
|
2864
|
-
let raw =
|
|
2872
|
+
ctx.broadcast({ type: 'message-add', message });
|
|
2873
|
+
let raw = '';
|
|
2865
2874
|
let visibleSent = 0;
|
|
2866
2875
|
const midStream = { scannedUpTo: 0, spawnedTitles: new Set() };
|
|
2867
2876
|
// Seed the activity line to "thinking" immediately -- covers the otherwise
|
|
@@ -2870,13 +2879,13 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
|
|
|
2870
2879
|
// overrides it: a tool relabels it, the first text delta clears it. Seeding
|
|
2871
2880
|
// lastActivity too dedups the redundant broadcast when the claude thinking
|
|
2872
2881
|
// block later re-emits "thinking".
|
|
2873
|
-
let lastActivity =
|
|
2874
|
-
ctx.broadcast({ type:
|
|
2882
|
+
let lastActivity = 'Thinking';
|
|
2883
|
+
ctx.broadcast({ type: 'message-activity', id: message.id, activity: 'Thinking' });
|
|
2875
2884
|
const prompt = routerTurnPrompt(ctx, instruction, message.id);
|
|
2876
2885
|
const backend = ctx.backend();
|
|
2877
2886
|
void runAgentTurn({
|
|
2878
2887
|
backend,
|
|
2879
|
-
role:
|
|
2888
|
+
role: 'router',
|
|
2880
2889
|
prompt,
|
|
2881
2890
|
claudeModel: ctx.claudeModel(),
|
|
2882
2891
|
openrouterModel: ctx.openrouterModel(),
|
|
@@ -2886,7 +2895,7 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
|
|
|
2886
2895
|
timeoutMs: ROUTER_TIMEOUT_MS,
|
|
2887
2896
|
// CLI backends append raw stream-json; smith appends structured run
|
|
2888
2897
|
// events (see createRunLogger in native/loop.ts). Same file either way.
|
|
2889
|
-
logPath: path.join(ctx.agentDir,
|
|
2898
|
+
logPath: path.join(ctx.agentDir, 'router-log.jsonl'),
|
|
2890
2899
|
children: ctx.children,
|
|
2891
2900
|
labelUnknownTools: true,
|
|
2892
2901
|
onDelta: (delta) => {
|
|
@@ -2896,7 +2905,7 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
|
|
|
2896
2905
|
const slice = raw.slice(visibleSent, visible);
|
|
2897
2906
|
visibleSent = visible;
|
|
2898
2907
|
message.text += slice;
|
|
2899
|
-
ctx.broadcast({ type:
|
|
2908
|
+
ctx.broadcast({ type: 'message-delta', id: message.id, delta: slice });
|
|
2900
2909
|
}
|
|
2901
2910
|
// Spawning changes WHEN a fence is acted on, not what is displayed --
|
|
2902
2911
|
// the holdback above still hides fenced text until the reply ends.
|
|
@@ -2906,14 +2915,14 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
|
|
|
2906
2915
|
if (activity === lastActivity)
|
|
2907
2916
|
return;
|
|
2908
2917
|
lastActivity = activity;
|
|
2909
|
-
ctx.broadcast({ type:
|
|
2918
|
+
ctx.broadcast({ type: 'message-activity', id: message.id, activity });
|
|
2910
2919
|
},
|
|
2911
2920
|
onThinking: (delta) => {
|
|
2912
2921
|
ctx.messageThinking.append(message.id, delta);
|
|
2913
2922
|
},
|
|
2914
2923
|
})
|
|
2915
2924
|
.then((result) => {
|
|
2916
|
-
logAgentUsage(
|
|
2925
|
+
logAgentUsage('router', backend, result.usage);
|
|
2917
2926
|
// Signals the finally -> onSettled(retryable): the turn failed cleanly
|
|
2918
2927
|
// enough (transient, nothing salvaged) that the queue may re-run it.
|
|
2919
2928
|
let retryable = false;
|
|
@@ -2943,7 +2952,7 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
|
|
|
2943
2952
|
const stale = epoch !== ctx.currentEpoch();
|
|
2944
2953
|
const inFlight = new Set(ctx.taskStore
|
|
2945
2954
|
.sorted()
|
|
2946
|
-
.filter((t) => t.status ===
|
|
2955
|
+
.filter((t) => t.status === 'running' || t.status === 'waiting')
|
|
2947
2956
|
.map((t) => t.title.toLowerCase()));
|
|
2948
2957
|
const toSpawn = stale
|
|
2949
2958
|
? []
|
|
@@ -2953,7 +2962,7 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
|
|
|
2953
2962
|
const taskIds = [...(message.taskIds ?? []), ...newlySpawnedIds];
|
|
2954
2963
|
if (result.ok) {
|
|
2955
2964
|
message.text = cleaned;
|
|
2956
|
-
message.status =
|
|
2965
|
+
message.status = 'done';
|
|
2957
2966
|
}
|
|
2958
2967
|
else {
|
|
2959
2968
|
const failure = resolveFailure(result);
|
|
@@ -2970,27 +2979,25 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
|
|
|
2970
2979
|
// different reason: it has ALREADY been retried at the transport
|
|
2971
2980
|
// layer (3 backed-off connects), and a turn-level retry on top just
|
|
2972
2981
|
// doubles the wait with the composer frozen.
|
|
2973
|
-
const salvaged = cleaned !==
|
|
2974
|
-
retryable = failure.kind ===
|
|
2982
|
+
const salvaged = cleaned !== '' || taskIds.length > 0;
|
|
2983
|
+
retryable = failure.kind === 'exit' && !salvaged;
|
|
2975
2984
|
const copy = failureCopy({
|
|
2976
2985
|
failure,
|
|
2977
2986
|
spawnedTasks: taskIds.length > 0,
|
|
2978
2987
|
willRetry: retryable && ctx.canAutoRetry(),
|
|
2979
2988
|
});
|
|
2980
2989
|
message.text = cleaned ? `${cleaned}\n\n${copy}` : copy;
|
|
2981
|
-
message.status =
|
|
2982
|
-
message.errorDetail = failure.detail || result.error ||
|
|
2990
|
+
message.status = 'error';
|
|
2991
|
+
message.errorDetail = failure.detail || result.error || 'unknown failure';
|
|
2983
2992
|
errorVerbose = failure.verbose;
|
|
2984
|
-
const label = failure.reason
|
|
2985
|
-
? `${failure.kind}/${failure.reason}`
|
|
2986
|
-
: failure.kind;
|
|
2993
|
+
const label = failure.reason ? `${failure.kind}/${failure.reason}` : failure.kind;
|
|
2987
2994
|
console.error(`[router] turn failed (${label}): ${failure.verbose ?? message.errorDetail}`);
|
|
2988
2995
|
}
|
|
2989
2996
|
if (taskIds.length > 0)
|
|
2990
2997
|
message.taskIds = taskIds;
|
|
2991
2998
|
ctx.log.persist();
|
|
2992
2999
|
ctx.broadcast({
|
|
2993
|
-
type:
|
|
3000
|
+
type: 'message-done',
|
|
2994
3001
|
id: message.id,
|
|
2995
3002
|
text: message.text,
|
|
2996
3003
|
status: message.status,
|
|
@@ -3012,13 +3019,13 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
|
|
|
3012
3019
|
const short = err instanceof Error ? err.message : String(err);
|
|
3013
3020
|
console.error(`[router] turn callback threw: ${detail}`);
|
|
3014
3021
|
try {
|
|
3015
|
-
message.status =
|
|
3022
|
+
message.status = 'error';
|
|
3016
3023
|
const copy = "Something went wrong on my end partway through. Send another message and I'll pick it back up.";
|
|
3017
|
-
message.text = `${message.text ? message.text +
|
|
3024
|
+
message.text = `${message.text ? message.text + '\n\n' : ''}${copy}`;
|
|
3018
3025
|
message.errorDetail = short;
|
|
3019
3026
|
ctx.log.persist();
|
|
3020
3027
|
ctx.broadcast({
|
|
3021
|
-
type:
|
|
3028
|
+
type: 'message-done',
|
|
3022
3029
|
id: message.id,
|
|
3023
3030
|
text: message.text,
|
|
3024
3031
|
status: message.status,
|
|
@@ -3027,7 +3034,7 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
|
|
|
3027
3034
|
});
|
|
3028
3035
|
}
|
|
3029
3036
|
catch (inner) {
|
|
3030
|
-
console.error(`[router] failed to surface turn error: ${inner instanceof Error ? inner.stack ?? inner.message : String(inner)}`);
|
|
3037
|
+
console.error(`[router] failed to surface turn error: ${inner instanceof Error ? (inner.stack ?? inner.message) : String(inner)}`);
|
|
3031
3038
|
}
|
|
3032
3039
|
}
|
|
3033
3040
|
finally {
|
|
@@ -3038,12 +3045,12 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
|
|
|
3038
3045
|
// runAgentCli itself rejected (it normally resolves with result.ok=false,
|
|
3039
3046
|
// so this is the rare hard-failure path). Still settle so routerRunning
|
|
3040
3047
|
// can't stick true and freeze the composer.
|
|
3041
|
-
console.error(`[router] turn promise rejected: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
3048
|
+
console.error(`[router] turn promise rejected: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
|
|
3042
3049
|
try {
|
|
3043
3050
|
ctx.onSettled();
|
|
3044
3051
|
}
|
|
3045
3052
|
catch (inner) {
|
|
3046
|
-
console.error(`[router] onSettled threw during rejection recovery: ${inner instanceof Error ? inner.stack ?? inner.message : String(inner)}`);
|
|
3053
|
+
console.error(`[router] onSettled threw during rejection recovery: ${inner instanceof Error ? (inner.stack ?? inner.message) : String(inner)}`);
|
|
3047
3054
|
}
|
|
3048
3055
|
});
|
|
3049
3056
|
}
|
|
@@ -3051,48 +3058,48 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
|
|
|
3051
3058
|
function applyAgentSettings(incoming, ctx) {
|
|
3052
3059
|
const { settings } = ctx;
|
|
3053
3060
|
const changes = [];
|
|
3054
|
-
for (const key of [
|
|
3061
|
+
for (const key of ['router', 'tasks']) {
|
|
3055
3062
|
const value = normalizeBackend(incoming[key]);
|
|
3056
3063
|
if (value && value !== settings[key]) {
|
|
3057
3064
|
settings[key] = value;
|
|
3058
3065
|
changes.push(`${key} agent -> ${value}`);
|
|
3059
3066
|
}
|
|
3060
3067
|
}
|
|
3061
|
-
for (const key of [
|
|
3068
|
+
for (const key of ['routerClaudeModel', 'tasksClaudeModel']) {
|
|
3062
3069
|
const value = normalizeClaudeModel(incoming[key]);
|
|
3063
3070
|
if (value && value !== settings[key]) {
|
|
3064
3071
|
settings[key] = value;
|
|
3065
|
-
changes.push(`${key ===
|
|
3072
|
+
changes.push(`${key === 'routerClaudeModel' ? 'operator' : 'tasks'} claude model -> ${value}`);
|
|
3066
3073
|
}
|
|
3067
3074
|
}
|
|
3068
|
-
for (const key of [
|
|
3075
|
+
for (const key of ['routerOpenrouterModel', 'tasksOpenrouterModel']) {
|
|
3069
3076
|
const value = normalizeOpenrouterModel(incoming[key]);
|
|
3070
3077
|
if (value && value !== settings[key]) {
|
|
3071
3078
|
settings[key] = value;
|
|
3072
|
-
changes.push(`${key ===
|
|
3079
|
+
changes.push(`${key === 'routerOpenrouterModel' ? 'operator' : 'tasks'} openrouter model -> ${value}`);
|
|
3073
3080
|
}
|
|
3074
3081
|
}
|
|
3075
|
-
for (const key of [
|
|
3082
|
+
for (const key of ['routerReasoningEffort', 'tasksReasoningEffort']) {
|
|
3076
3083
|
const value = normalizeReasoningEffort(incoming[key]);
|
|
3077
3084
|
if (value && value !== settings[key]) {
|
|
3078
3085
|
settings[key] = value;
|
|
3079
|
-
changes.push(`${key ===
|
|
3086
|
+
changes.push(`${key === 'routerReasoningEffort' ? 'operator' : 'tasks'} reasoning effort -> ${value}`);
|
|
3080
3087
|
}
|
|
3081
3088
|
}
|
|
3082
|
-
for (const key of [
|
|
3089
|
+
for (const key of ['routerRouting', 'tasksRouting']) {
|
|
3083
3090
|
const value = normalizeRoutingMode(incoming[key]);
|
|
3084
3091
|
if (value && value !== settings[key]) {
|
|
3085
3092
|
settings[key] = value;
|
|
3086
|
-
changes.push(`${key ===
|
|
3093
|
+
changes.push(`${key === 'routerRouting' ? 'operator' : 'tasks'} routing -> ${value}`);
|
|
3087
3094
|
}
|
|
3088
3095
|
}
|
|
3089
|
-
for (const key of [
|
|
3096
|
+
for (const key of ['routerProviderTier', 'tasksProviderTier']) {
|
|
3090
3097
|
// "" is a valid value (auto), so check for null (invalid) explicitly
|
|
3091
3098
|
// rather than truthiness -- otherwise the tier could never be cleared.
|
|
3092
3099
|
const value = normalizeProviderTier(incoming[key]);
|
|
3093
3100
|
if (value !== null && value !== settings[key]) {
|
|
3094
3101
|
settings[key] = value;
|
|
3095
|
-
changes.push(`${key ===
|
|
3102
|
+
changes.push(`${key === 'routerProviderTier' ? 'operator' : 'tasks'} provider tier -> ${value || 'auto'}`);
|
|
3096
3103
|
}
|
|
3097
3104
|
}
|
|
3098
3105
|
if (changes.length === 0)
|
|
@@ -3100,7 +3107,7 @@ function applyAgentSettings(incoming, ctx) {
|
|
|
3100
3107
|
fs.writeFileSync(ctx.settingsPath, serializeAgentSettings(settings));
|
|
3101
3108
|
// The value is saved and broadcast IMMEDIATELY -- validation never gates a
|
|
3102
3109
|
// write. The verdict follows in a second frame once the catalog answers.
|
|
3103
|
-
ctx.broadcast({ type:
|
|
3110
|
+
ctx.broadcast({ type: 'settings', settings });
|
|
3104
3111
|
void broadcastSettingsWarnings(ctx);
|
|
3105
3112
|
}
|
|
3106
3113
|
// Belt to watchCredentials' braces: a directory that didn't exist at boot has
|
|
@@ -3117,11 +3124,11 @@ const ACCOUNTS_POLL_MS = 15_000;
|
|
|
3117
3124
|
// The snapshot carries presence and a hint only; no frame from here ever moves
|
|
3118
3125
|
// a key value toward a browser.
|
|
3119
3126
|
function createAccountsFeed(opts) {
|
|
3120
|
-
let last =
|
|
3127
|
+
let last = '';
|
|
3121
3128
|
// Tracked apart from the whole snapshot so a sign-in's phase transitions
|
|
3122
3129
|
// (starting -> awaiting-code -> ...) don't each cost a budget fetch: they
|
|
3123
3130
|
// move `login`, never a credential.
|
|
3124
|
-
let lastProviders =
|
|
3131
|
+
let lastProviders = '';
|
|
3125
3132
|
function push(error) {
|
|
3126
3133
|
const accounts = accountsSnapshot();
|
|
3127
3134
|
const serialized = JSON.stringify(accounts);
|
|
@@ -3138,7 +3145,7 @@ function createAccountsFeed(opts) {
|
|
|
3138
3145
|
return;
|
|
3139
3146
|
last = serialized;
|
|
3140
3147
|
opts.broadcast({
|
|
3141
|
-
type:
|
|
3148
|
+
type: 'accounts',
|
|
3142
3149
|
accounts,
|
|
3143
3150
|
...(error ? { accountsError: error } : {}),
|
|
3144
3151
|
});
|
|
@@ -3165,26 +3172,26 @@ function createAccountsFeed(opts) {
|
|
|
3165
3172
|
};
|
|
3166
3173
|
}
|
|
3167
3174
|
function applyCredentialChange(msg, ctx) {
|
|
3168
|
-
const clearing = msg.type ===
|
|
3169
|
-
if (!clearing && typeof msg.value !==
|
|
3175
|
+
const clearing = msg.type === 'clear-credential';
|
|
3176
|
+
if (!clearing && typeof msg.value !== 'string')
|
|
3170
3177
|
return;
|
|
3171
3178
|
const result = writeCredential(msg.id, clearing ? null : msg.value);
|
|
3172
3179
|
ctx.pushAccounts(result.ok
|
|
3173
3180
|
? undefined
|
|
3174
|
-
: { id: typeof msg.id ===
|
|
3181
|
+
: { id: typeof msg.id === 'string' ? msg.id : '', message: result.message });
|
|
3175
3182
|
}
|
|
3176
3183
|
// Which slugs are worth a verdict: only roles actually routed at OpenRouter
|
|
3177
3184
|
// (otherwise we'd warn about an inert leftover value), and only when a slug is
|
|
3178
3185
|
// set at all.
|
|
3179
3186
|
function slugKeysToValidate(settings) {
|
|
3180
3187
|
const keys = [];
|
|
3181
|
-
if (roleUsesOpenrouter(settings.router ??
|
|
3188
|
+
if (roleUsesOpenrouter(settings.router ?? 'claude', settings.routerClaudeModel ?? 'opus') &&
|
|
3182
3189
|
settings.routerOpenrouterModel) {
|
|
3183
|
-
keys.push(
|
|
3190
|
+
keys.push('routerOpenrouterModel');
|
|
3184
3191
|
}
|
|
3185
|
-
if (roleUsesOpenrouter(settings.tasks ??
|
|
3192
|
+
if (roleUsesOpenrouter(settings.tasks ?? 'claude', settings.tasksClaudeModel ?? 'sonnet') &&
|
|
3186
3193
|
settings.tasksOpenrouterModel) {
|
|
3187
|
-
keys.push(
|
|
3194
|
+
keys.push('tasksOpenrouterModel');
|
|
3188
3195
|
}
|
|
3189
3196
|
return keys;
|
|
3190
3197
|
}
|
|
@@ -3200,20 +3207,20 @@ export async function computeSettingsWarnings(settings) {
|
|
|
3200
3207
|
if (!model)
|
|
3201
3208
|
return;
|
|
3202
3209
|
const check = await checkOpenrouterModel(model);
|
|
3203
|
-
if (check.status ===
|
|
3210
|
+
if (check.status === 'unknown-model') {
|
|
3204
3211
|
// suggestion is carried SEPARATELY (not baked into message) so the
|
|
3205
3212
|
// client can render it as a one-click fix rather than plain text.
|
|
3206
3213
|
out[key] = {
|
|
3207
3214
|
model,
|
|
3208
|
-
status:
|
|
3209
|
-
message:
|
|
3215
|
+
status: 'unknown-model',
|
|
3216
|
+
message: 'No such model on OpenRouter.',
|
|
3210
3217
|
suggestion: check.suggestions[0],
|
|
3211
3218
|
};
|
|
3212
3219
|
}
|
|
3213
|
-
else if (check.status ===
|
|
3220
|
+
else if (check.status === 'no-tools') {
|
|
3214
3221
|
out[key] = {
|
|
3215
3222
|
model,
|
|
3216
|
-
status:
|
|
3223
|
+
status: 'no-tools',
|
|
3217
3224
|
message: "This model can't use tools, so it can't do work. Pick another.",
|
|
3218
3225
|
};
|
|
3219
3226
|
}
|
|
@@ -3222,19 +3229,19 @@ export async function computeSettingsWarnings(settings) {
|
|
|
3222
3229
|
}
|
|
3223
3230
|
async function broadcastSettingsWarnings(ctx) {
|
|
3224
3231
|
const settingsWarnings = await computeSettingsWarnings(ctx.settings);
|
|
3225
|
-
ctx.broadcast({ type:
|
|
3232
|
+
ctx.broadcast({ type: 'settings', settings: ctx.settings, settingsWarnings });
|
|
3226
3233
|
}
|
|
3227
3234
|
function killOrphanAgents(registryPath) {
|
|
3228
3235
|
const recorded = readJsonFile(registryPath) ?? [];
|
|
3229
3236
|
for (const entry of recorded) {
|
|
3230
|
-
if (typeof entry?.pid !==
|
|
3237
|
+
if (typeof entry?.pid !== 'number')
|
|
3231
3238
|
continue;
|
|
3232
3239
|
try {
|
|
3233
|
-
const cmd = execFileSync(
|
|
3234
|
-
encoding:
|
|
3240
|
+
const cmd = execFileSync('ps', ['-p', String(entry.pid), '-o', 'command='], {
|
|
3241
|
+
encoding: 'utf8',
|
|
3235
3242
|
}).trim();
|
|
3236
|
-
if (cmd.includes(
|
|
3237
|
-
process.kill(entry.pid,
|
|
3243
|
+
if (cmd.includes('cursor-agent') || cmd.includes('claude')) {
|
|
3244
|
+
process.kill(entry.pid, 'SIGKILL');
|
|
3238
3245
|
}
|
|
3239
3246
|
}
|
|
3240
3247
|
catch {
|
|
@@ -3242,23 +3249,21 @@ function killOrphanAgents(registryPath) {
|
|
|
3242
3249
|
}
|
|
3243
3250
|
}
|
|
3244
3251
|
try {
|
|
3245
|
-
fs.writeFileSync(registryPath,
|
|
3252
|
+
fs.writeFileSync(registryPath, '[]\n');
|
|
3246
3253
|
}
|
|
3247
3254
|
catch {
|
|
3248
3255
|
/* registry dir missing -- created later */
|
|
3249
3256
|
}
|
|
3250
3257
|
}
|
|
3251
3258
|
function startChildRegistry(registryPath, groups) {
|
|
3252
|
-
let last =
|
|
3259
|
+
let last = '';
|
|
3253
3260
|
const timer = setInterval(() => {
|
|
3254
3261
|
const live = [];
|
|
3255
3262
|
for (const group of groups) {
|
|
3256
3263
|
for (const child of group) {
|
|
3257
3264
|
// pid > 0 excludes smith runs (negative pseudo-pids, no OS process
|
|
3258
3265
|
// for the orphan sweep to kill -- see AgentRunHandle).
|
|
3259
|
-
if (typeof child.pid ===
|
|
3260
|
-
child.pid > 0 &&
|
|
3261
|
-
child.exitCode === null) {
|
|
3266
|
+
if (typeof child.pid === 'number' && child.pid > 0 && child.exitCode === null) {
|
|
3262
3267
|
live.push({ pid: child.pid, command: child.spawnfile });
|
|
3263
3268
|
}
|
|
3264
3269
|
}
|
|
@@ -3268,7 +3273,7 @@ function startChildRegistry(registryPath, groups) {
|
|
|
3268
3273
|
return;
|
|
3269
3274
|
last = snapshot;
|
|
3270
3275
|
try {
|
|
3271
|
-
fs.writeFileSync(registryPath, snapshot +
|
|
3276
|
+
fs.writeFileSync(registryPath, snapshot + '\n');
|
|
3272
3277
|
}
|
|
3273
3278
|
catch {
|
|
3274
3279
|
/* best effort */
|
|
@@ -3277,7 +3282,7 @@ function startChildRegistry(registryPath, groups) {
|
|
|
3277
3282
|
return () => {
|
|
3278
3283
|
clearInterval(timer);
|
|
3279
3284
|
try {
|
|
3280
|
-
fs.writeFileSync(registryPath,
|
|
3285
|
+
fs.writeFileSync(registryPath, '[]\n');
|
|
3281
3286
|
}
|
|
3282
3287
|
catch {
|
|
3283
3288
|
/* best effort */
|
|
@@ -3296,14 +3301,14 @@ function loadRecoverableSends(pendingPath, committedIds) {
|
|
|
3296
3301
|
const recovered = [];
|
|
3297
3302
|
for (const item of stored) {
|
|
3298
3303
|
if (item &&
|
|
3299
|
-
typeof item.id ===
|
|
3300
|
-
typeof item.text ===
|
|
3304
|
+
typeof item.id === 'string' &&
|
|
3305
|
+
typeof item.text === 'string' &&
|
|
3301
3306
|
Array.isArray(item.attachments) &&
|
|
3302
3307
|
!committedIds.has(item.id)) {
|
|
3303
3308
|
recovered.push({
|
|
3304
3309
|
id: item.id,
|
|
3305
3310
|
text: item.text,
|
|
3306
|
-
attachments: item.attachments.filter((a) => typeof a ===
|
|
3311
|
+
attachments: item.attachments.filter((a) => typeof a === 'string'),
|
|
3307
3312
|
});
|
|
3308
3313
|
}
|
|
3309
3314
|
}
|
|
@@ -3317,9 +3322,9 @@ function loadRecoverableSends(pendingPath, committedIds) {
|
|
|
3317
3322
|
// cannot be un-launched, so folding it away would abandon in-flight work, not
|
|
3318
3323
|
// just discard stale intent.
|
|
3319
3324
|
function turnHasVisibleOutput(messages) {
|
|
3320
|
-
return messages.some((m) => m.role ===
|
|
3321
|
-
m.status ===
|
|
3322
|
-
(m.text.trim() !==
|
|
3325
|
+
return messages.some((m) => m.role === 'assistant' &&
|
|
3326
|
+
m.status === 'streaming' &&
|
|
3327
|
+
(m.text.trim() !== '' || (m.taskIds?.length ?? 0) > 0));
|
|
3323
3328
|
}
|
|
3324
3329
|
// Re-queue a turn's already-logged user messages (its `inFlightSends`) onto
|
|
3325
3330
|
// the front of the pending queue, marked `logged` so the next drain composes
|
|
@@ -3335,14 +3340,14 @@ function reclaimInFlightSends(pendingSends, inFlightSends) {
|
|
|
3335
3340
|
// Mirror the in-memory queue to disk. Called on every mutation (enqueue,
|
|
3336
3341
|
// drain, cancel, recover) so a restart never loses an unsent queued message.
|
|
3337
3342
|
function persistPendingSends(ctx) {
|
|
3338
|
-
fs.writeFileSync(ctx.pendingPath, JSON.stringify(ctx.state.pendingSends, null, 2) +
|
|
3343
|
+
fs.writeFileSync(ctx.pendingPath, JSON.stringify(ctx.state.pendingSends, null, 2) + '\n');
|
|
3339
3344
|
}
|
|
3340
3345
|
function computeQueuedSnippets(pendingSends) {
|
|
3341
3346
|
return pendingSends.map((p) => p.text.trim()).filter(Boolean);
|
|
3342
3347
|
}
|
|
3343
3348
|
function broadcastQueueState(ctx) {
|
|
3344
3349
|
ctx.broadcast({
|
|
3345
|
-
type:
|
|
3350
|
+
type: 'router-state',
|
|
3346
3351
|
running: ctx.state.routerRunning,
|
|
3347
3352
|
queued: computeQueuedSnippets(ctx.state.pendingSends),
|
|
3348
3353
|
});
|
|
@@ -3351,18 +3356,18 @@ function broadcastQueueState(ctx) {
|
|
|
3351
3356
|
// they had streamed (joined) so a manual interrupt can carry it forward.
|
|
3352
3357
|
function killRouterChildren(ctx) {
|
|
3353
3358
|
const drafts = ctx.messages
|
|
3354
|
-
.filter((m) => m.role ===
|
|
3359
|
+
.filter((m) => m.role === 'assistant' && m.status === 'streaming')
|
|
3355
3360
|
.map((m) => m.text.trim())
|
|
3356
3361
|
.filter(Boolean);
|
|
3357
3362
|
for (const child of ctx.routerChildren) {
|
|
3358
3363
|
try {
|
|
3359
|
-
child.kill(
|
|
3364
|
+
child.kill('SIGKILL');
|
|
3360
3365
|
}
|
|
3361
3366
|
catch {
|
|
3362
3367
|
/* already gone */
|
|
3363
3368
|
}
|
|
3364
3369
|
}
|
|
3365
|
-
return drafts.join(
|
|
3370
|
+
return drafts.join('\n\n');
|
|
3366
3371
|
}
|
|
3367
3372
|
function startRouterTurn(ctx, instruction, attachments = []) {
|
|
3368
3373
|
ctx.state.lastInstruction = instruction;
|
|
@@ -3401,10 +3406,10 @@ function commitDrainedSends(drained, log) {
|
|
|
3401
3406
|
if (!item.logged) {
|
|
3402
3407
|
const message = {
|
|
3403
3408
|
id: item.id,
|
|
3404
|
-
role:
|
|
3409
|
+
role: 'user',
|
|
3405
3410
|
text: item.text,
|
|
3406
3411
|
at: nowIso(),
|
|
3407
|
-
status:
|
|
3412
|
+
status: 'done',
|
|
3408
3413
|
};
|
|
3409
3414
|
if (item.attachments.length > 0)
|
|
3410
3415
|
message.attachments = item.attachments;
|
|
@@ -3413,7 +3418,7 @@ function commitDrainedSends(drained, log) {
|
|
|
3413
3418
|
if (item.text.trim())
|
|
3414
3419
|
texts.push(item.text);
|
|
3415
3420
|
for (const name of item.attachments) {
|
|
3416
|
-
attachmentPaths.push(path.join(
|
|
3421
|
+
attachmentPaths.push(path.join('.castle', 'agent', 'attachments', name));
|
|
3417
3422
|
}
|
|
3418
3423
|
}
|
|
3419
3424
|
return { texts, attachmentPaths };
|
|
@@ -3436,7 +3441,7 @@ function maybeStartRouterQueueTurn(ctx) {
|
|
|
3436
3441
|
// The queue is now committed to messages.json; clear its durable mirror.
|
|
3437
3442
|
persistPendingSends(ctx);
|
|
3438
3443
|
const draft = state.pendingInterruptedDraft;
|
|
3439
|
-
state.pendingInterruptedDraft =
|
|
3444
|
+
state.pendingInterruptedDraft = '';
|
|
3440
3445
|
state.routerRunning = true;
|
|
3441
3446
|
broadcastQueueState(ctx);
|
|
3442
3447
|
startRouterTurn(ctx, userTurnInstruction({
|
|
@@ -3534,7 +3539,7 @@ function interruptRouterQueue(ctx) {
|
|
|
3534
3539
|
// Only carry the partial draft forward when a queued message will consume
|
|
3535
3540
|
// it imminently ("Send now"). A bare Stop (empty composer) must not park
|
|
3536
3541
|
// the draft, or it leaks into the next unrelated message.
|
|
3537
|
-
state.pendingInterruptedDraft = hasFollowUp ? draft :
|
|
3542
|
+
state.pendingInterruptedDraft = hasFollowUp ? draft : '';
|
|
3538
3543
|
}
|
|
3539
3544
|
else {
|
|
3540
3545
|
maybeStartRouterQueueTurn(ctx);
|
|
@@ -3558,16 +3563,16 @@ function cancelQueuedSend(ctx, index) {
|
|
|
3558
3563
|
function createRouterQueue(deps) {
|
|
3559
3564
|
const ctx = {
|
|
3560
3565
|
...deps,
|
|
3561
|
-
pendingPath: path.join(deps.agentDir,
|
|
3566
|
+
pendingPath: path.join(deps.agentDir, 'pending-sends.json'),
|
|
3562
3567
|
state: {
|
|
3563
3568
|
userEpoch: 0,
|
|
3564
3569
|
routerRunning: false,
|
|
3565
3570
|
pendingSends: [],
|
|
3566
3571
|
inFlightSends: [],
|
|
3567
|
-
pendingInterruptedDraft:
|
|
3572
|
+
pendingInterruptedDraft: '',
|
|
3568
3573
|
autoRetryUsed: false,
|
|
3569
3574
|
autoFoldUsed: false,
|
|
3570
|
-
lastInstruction:
|
|
3575
|
+
lastInstruction: '',
|
|
3571
3576
|
lastAttachments: [],
|
|
3572
3577
|
},
|
|
3573
3578
|
};
|
|
@@ -3588,10 +3593,10 @@ export function createAgentServer(opts) {
|
|
|
3588
3593
|
const { deckDir, deckLabel } = opts;
|
|
3589
3594
|
const quickReference = readQuickReference(deckDir);
|
|
3590
3595
|
const welcomeMessage = readWelcomeMessage(deckDir) || DEFAULT_WELCOME_MESSAGE;
|
|
3591
|
-
const agentDir = path.join(deckDir,
|
|
3592
|
-
const tasksDir = path.join(agentDir,
|
|
3593
|
-
const attachmentsDir = path.join(agentDir,
|
|
3594
|
-
const messagesPath = path.join(agentDir,
|
|
3596
|
+
const agentDir = path.join(deckDir, '.castle', 'agent');
|
|
3597
|
+
const tasksDir = path.join(agentDir, 'tasks');
|
|
3598
|
+
const attachmentsDir = path.join(agentDir, 'attachments');
|
|
3599
|
+
const messagesPath = path.join(agentDir, 'messages.json');
|
|
3595
3600
|
fs.mkdirSync(tasksDir, { recursive: true });
|
|
3596
3601
|
// Warm the OpenRouter catalog now so the first pre-flight and the first
|
|
3597
3602
|
// popover open read a cache instead of paying for the fetch. Fire-and-forget
|
|
@@ -3622,12 +3627,9 @@ export function createAgentServer(opts) {
|
|
|
3622
3627
|
const clients = new Set();
|
|
3623
3628
|
// Kill agent processes orphaned by a previous serve that died uncleanly,
|
|
3624
3629
|
// then start tracking this serve's own children.
|
|
3625
|
-
const childRegistryPath = path.join(agentDir,
|
|
3630
|
+
const childRegistryPath = path.join(agentDir, 'children.json');
|
|
3626
3631
|
killOrphanAgents(childRegistryPath);
|
|
3627
|
-
const stopChildRegistry = startChildRegistry(childRegistryPath, [
|
|
3628
|
-
taskChildren,
|
|
3629
|
-
routerChildren,
|
|
3630
|
-
]);
|
|
3632
|
+
const stopChildRegistry = startChildRegistry(childRegistryPath, [taskChildren, routerChildren]);
|
|
3631
3633
|
function broadcast(body) {
|
|
3632
3634
|
const payload = JSON.stringify(body);
|
|
3633
3635
|
for (const socket of clients) {
|
|
@@ -3640,7 +3642,7 @@ export function createAgentServer(opts) {
|
|
|
3640
3642
|
const addLog = (text) => log.addLog(text);
|
|
3641
3643
|
// Which CLI backs the router and the task agents -- independently
|
|
3642
3644
|
// switchable from the settings popover, persisted next to the chat state.
|
|
3643
|
-
const settingsPath = path.join(agentDir,
|
|
3645
|
+
const settingsPath = path.join(agentDir, 'settings.json');
|
|
3644
3646
|
const settings = loadAgentSettings(settingsPath);
|
|
3645
3647
|
const usageFeed = createUsageFeed({
|
|
3646
3648
|
broadcast,
|
|
@@ -3677,7 +3679,7 @@ export function createAgentServer(opts) {
|
|
|
3677
3679
|
playtest,
|
|
3678
3680
|
restart: opts.restart,
|
|
3679
3681
|
// Task lifecycle stays on the board only -- log lines for it were spam.
|
|
3680
|
-
onUpdate: (task) => broadcast({ type:
|
|
3682
|
+
onUpdate: (task) => broadcast({ type: 'task-update', task: asClientTask(task) }),
|
|
3681
3683
|
onStarted: () => undefined,
|
|
3682
3684
|
onRetry: (task, attempt) => addLog(`agent died, retrying (${attempt}/${MAX_TASK_ATTEMPTS}): ${task.title}`),
|
|
3683
3685
|
onFinished: (task) => taskFeeds.map.delete(task.id),
|
|
@@ -3705,10 +3707,10 @@ export function createAgentServer(opts) {
|
|
|
3705
3707
|
// (so it respects the send queue).
|
|
3706
3708
|
function handlePickerChoice(id, answers, text) {
|
|
3707
3709
|
const msg = messages.find((m) => m.id === id);
|
|
3708
|
-
if (msg && msg.role ===
|
|
3710
|
+
if (msg && msg.role === 'assistant' && answers && typeof answers === 'object') {
|
|
3709
3711
|
msg.pickerAnswers = answers;
|
|
3710
3712
|
log.persist();
|
|
3711
|
-
broadcast({ type:
|
|
3713
|
+
broadcast({ type: 'message-picker', id, pickerAnswers: msg.pickerAnswers });
|
|
3712
3714
|
}
|
|
3713
3715
|
if (text.trim())
|
|
3714
3716
|
routerQueue.handleUserMessage(text.trim(), undefined);
|
|
@@ -3720,7 +3722,7 @@ export function createAgentServer(opts) {
|
|
|
3720
3722
|
function attachClient(socket) {
|
|
3721
3723
|
clients.add(socket);
|
|
3722
3724
|
const hello = {
|
|
3723
|
-
type:
|
|
3725
|
+
type: 'hello',
|
|
3724
3726
|
messages,
|
|
3725
3727
|
tasks: taskStore.sorted().map(asClientTask),
|
|
3726
3728
|
settings,
|
|
@@ -3742,7 +3744,7 @@ export function createAgentServer(opts) {
|
|
|
3742
3744
|
// nothing changed), so a migrated or hand-edited slug would stay silent.
|
|
3743
3745
|
// The catalog is primed at boot, so in practice this lands immediately.
|
|
3744
3746
|
void broadcastSettingsWarnings({ settings, broadcast });
|
|
3745
|
-
socket.on(
|
|
3747
|
+
socket.on('message', (rawData) => {
|
|
3746
3748
|
let msg;
|
|
3747
3749
|
try {
|
|
3748
3750
|
msg = JSON.parse(rawDataToString(rawData));
|
|
@@ -3750,56 +3752,55 @@ export function createAgentServer(opts) {
|
|
|
3750
3752
|
catch {
|
|
3751
3753
|
return;
|
|
3752
3754
|
}
|
|
3753
|
-
const hasText = typeof msg.text ===
|
|
3755
|
+
const hasText = typeof msg.text === 'string' && msg.text.trim() !== '';
|
|
3754
3756
|
const hasImages = Array.isArray(msg.images) && msg.images.length > 0;
|
|
3755
|
-
if (msg.type ===
|
|
3756
|
-
routerQueue.handleUserMessage(typeof msg.text ===
|
|
3757
|
+
if (msg.type === 'user-message' && (hasText || hasImages)) {
|
|
3758
|
+
routerQueue.handleUserMessage(typeof msg.text === 'string' ? msg.text.trim() : '', msg.images);
|
|
3757
3759
|
}
|
|
3758
|
-
else if (msg.type ===
|
|
3760
|
+
else if (msg.type === 'interrupt') {
|
|
3759
3761
|
routerQueue.interruptRouter();
|
|
3760
3762
|
}
|
|
3761
|
-
else if (msg.type ===
|
|
3763
|
+
else if (msg.type === 'cancel-queued' && typeof msg.index === 'number') {
|
|
3762
3764
|
routerQueue.cancelQueued(msg.index);
|
|
3763
3765
|
}
|
|
3764
|
-
else if (msg.type ===
|
|
3765
|
-
handlePickerChoice(msg.id, msg.answers, typeof msg.text ===
|
|
3766
|
+
else if (msg.type === 'picker-choice' && typeof msg.id === 'string') {
|
|
3767
|
+
handlePickerChoice(msg.id, msg.answers, typeof msg.text === 'string' ? msg.text : '');
|
|
3766
3768
|
}
|
|
3767
|
-
else if (msg.type ===
|
|
3769
|
+
else if (msg.type === 'task-ack' && typeof msg.id === 'string') {
|
|
3768
3770
|
handleTaskAck(msg.id, msg.rejected === true);
|
|
3769
3771
|
}
|
|
3770
|
-
else if (msg.type ===
|
|
3772
|
+
else if (msg.type === 'set-settings') {
|
|
3771
3773
|
applySettings(msg);
|
|
3772
3774
|
}
|
|
3773
|
-
else if (msg.type ===
|
|
3774
|
-
msg.type === "clear-credential") {
|
|
3775
|
+
else if (msg.type === 'set-credential' || msg.type === 'clear-credential') {
|
|
3775
3776
|
applyCredentialChange(msg, { pushAccounts: accountsFeed.push });
|
|
3776
3777
|
}
|
|
3777
|
-
else if (msg.type ===
|
|
3778
|
+
else if (msg.type === 'account-login') {
|
|
3778
3779
|
const provider = loginProviderFor(msg.id);
|
|
3779
3780
|
if (provider)
|
|
3780
3781
|
startLogin(provider, () => accountsFeed.push());
|
|
3781
3782
|
}
|
|
3782
|
-
else if (msg.type ===
|
|
3783
|
+
else if (msg.type === 'account-login-code' && typeof msg.value === 'string') {
|
|
3783
3784
|
submitLoginCode(msg.value);
|
|
3784
3785
|
}
|
|
3785
|
-
else if (msg.type ===
|
|
3786
|
+
else if (msg.type === 'account-login-cancel') {
|
|
3786
3787
|
cancelLogin();
|
|
3787
3788
|
}
|
|
3788
|
-
else if (msg.type ===
|
|
3789
|
+
else if (msg.type === 'account-logout') {
|
|
3789
3790
|
const provider = loginProviderFor(msg.id);
|
|
3790
3791
|
if (provider)
|
|
3791
3792
|
logout(provider, () => accountsFeed.push());
|
|
3792
3793
|
}
|
|
3793
|
-
else if (msg.type ===
|
|
3794
|
+
else if (msg.type === 'client-timezone' && typeof msg.timeZone === 'string') {
|
|
3794
3795
|
setReaderTimeZone(msg.timeZone);
|
|
3795
3796
|
}
|
|
3796
3797
|
});
|
|
3797
|
-
socket.on(
|
|
3798
|
+
socket.on('close', () => {
|
|
3798
3799
|
clients.delete(socket);
|
|
3799
3800
|
});
|
|
3800
3801
|
}
|
|
3801
3802
|
function handleUpgrade(req, socket, head) {
|
|
3802
|
-
const url = new URL(req.url ??
|
|
3803
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
3803
3804
|
if (url.pathname !== AGENT_WS_PATH)
|
|
3804
3805
|
return false;
|
|
3805
3806
|
wss.handleUpgrade(req, socket, head, (ws) => attachClient(ws));
|
|
@@ -3816,7 +3817,7 @@ export function createAgentServer(opts) {
|
|
|
3816
3817
|
taskStore.shutdown();
|
|
3817
3818
|
for (const child of [...taskChildren, ...routerChildren]) {
|
|
3818
3819
|
try {
|
|
3819
|
-
child.kill(
|
|
3820
|
+
child.kill('SIGKILL');
|
|
3820
3821
|
}
|
|
3821
3822
|
catch {
|
|
3822
3823
|
/* already gone */
|