castle-web-cli 0.4.11 → 0.4.13
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.d.ts +31 -0
- package/dist/agent-prompts.js +104 -0
- package/dist/agent.d.ts +17 -0
- package/dist/agent.js +952 -0
- package/dist/chat-client.d.ts +1 -0
- package/dist/chat-client.js +425 -0
- package/dist/commonInstructions.d.ts +1 -0
- package/dist/commonInstructions.js +8 -0
- package/dist/ide-client.js +46 -14
- package/dist/ide.d.ts +2 -0
- package/dist/ide.js +348 -36
- package/dist/init.js +12 -1
- package/dist/serve.js +18 -3
- package/kits/basic-2d/CLAUDE.md +3 -1
- package/kits/basic-3d/.prettierrc +8 -0
- package/kits/basic-3d/CLAUDE.md +162 -0
- package/kits/basic-3d/behaviors/Camera.jsx +56 -0
- package/kits/basic-3d/behaviors/Collider.jsx +78 -0
- package/kits/basic-3d/behaviors/Mesh.jsx +82 -0
- package/kits/basic-3d/behaviors/Model.jsx +61 -0
- package/kits/basic-3d/behaviors/Transform.jsx +35 -0
- package/kits/basic-3d/editors/App.jsx +147 -0
- package/kits/basic-3d/editors/CodeEditor.jsx +112 -0
- package/kits/basic-3d/editors/FileBrowser.jsx +143 -0
- package/kits/basic-3d/editors/ModelEditor.jsx +400 -0
- package/kits/basic-3d/editors/PlayOnly.jsx +14 -0
- package/kits/basic-3d/editors/SceneEditor.jsx +1087 -0
- package/kits/basic-3d/editors/behaviorRegistry.js +24 -0
- package/kits/basic-3d/editors/editorHistory.js +52 -0
- package/kits/basic-3d/editors/viewportRig.js +90 -0
- package/kits/basic-3d/engine/ScenePlayer.jsx +55 -0
- package/kits/basic-3d/engine/SceneUI.jsx +67 -0
- package/kits/basic-3d/engine/SceneViewport.jsx +102 -0
- package/kits/basic-3d/engine/TouchControls.jsx +136 -0
- package/kits/basic-3d/engine/autoInspector.jsx +51 -0
- package/kits/basic-3d/engine/files.js +73 -0
- package/kits/basic-3d/engine/scene.js +502 -0
- package/kits/basic-3d/engine/threeUtil.js +260 -0
- package/kits/basic-3d/engine/ui.jsx +352 -0
- package/kits/basic-3d/engine/ui.module.css +944 -0
- package/kits/basic-3d/eslint.config.js +51 -0
- package/kits/basic-3d/index.html +11 -0
- package/kits/basic-3d/main.jsx +10 -0
- package/kits/basic-3d/models/block.model +14 -0
- package/kits/basic-3d/package-lock.json +2713 -0
- package/kits/basic-3d/package.json +41 -0
- package/kits/basic-3d/scenes/main.scene +76 -0
- package/kits/basic-3d/vite.config.js +1 -0
- package/package.json +6 -1
package/dist/agent.js
ADDED
|
@@ -0,0 +1,952 @@
|
|
|
1
|
+
// Agent panel backend for `castle-web serve`: a conversational "router" agent
|
|
2
|
+
// chats with the user (chat-only -- it never edits files) and spawns
|
|
3
|
+
// background task agents that do the actual deck edits, with maximum
|
|
4
|
+
// concurrency. State lives under .castle/agent/ -- messages.json plus a
|
|
5
|
+
// directory per task -- so the chat survives serve restarts and task agents
|
|
6
|
+
// write progress / notes back through plain files the server polls.
|
|
7
|
+
//
|
|
8
|
+
// One router turn runs at a time: a new user message interrupts the
|
|
9
|
+
// in-flight reply (its partial text stays in the log) and the next turn is
|
|
10
|
+
// told to continue that work plus address the new message. Task lifecycle
|
|
11
|
+
// shows up as small log messages (started / ready for review / completed)
|
|
12
|
+
// instead of extra router turns.
|
|
13
|
+
//
|
|
14
|
+
// Backend CLI: cursor-agent in headless print mode (stream-json). The router
|
|
15
|
+
// runs with --mode ask (read-only at the CLI level); task agents run with
|
|
16
|
+
// --force. Claude support can slot in later behind runAgentCli.
|
|
17
|
+
import { spawn } from 'child_process';
|
|
18
|
+
import * as fs from 'fs';
|
|
19
|
+
import * as path from 'path';
|
|
20
|
+
import { nanoid } from 'nanoid';
|
|
21
|
+
import { WebSocketServer } from 'ws';
|
|
22
|
+
import { rawDataToString } from './ide.js';
|
|
23
|
+
import { buildRouterPrompt, buildTaskPrompt, userTurnInstruction, } from './agent-prompts.js';
|
|
24
|
+
export const AGENT_WS_PATH = '/__castle/agent';
|
|
25
|
+
export const AGENT_ATTACHMENT_PREFIX = '/__castle/agent/attachments/';
|
|
26
|
+
const DEFAULT_SETTINGS = { router: 'claude', tasks: 'claude', claudeModel: 'opus' };
|
|
27
|
+
function normalizeBackend(value) {
|
|
28
|
+
return value === 'cursor' || value === 'claude' ? value : null;
|
|
29
|
+
}
|
|
30
|
+
function normalizeClaudeModel(value) {
|
|
31
|
+
return value === 'sonnet' || value === 'opus' || value === 'fable' ? value : null;
|
|
32
|
+
}
|
|
33
|
+
// Build the headless CLI invocation for a backend/role. Cursor's router runs
|
|
34
|
+
// in read-only ask mode; claude runs permission-mode auto for both roles (NOT
|
|
35
|
+
// plan mode -- that makes it emit plan tool calls) at medium effort.
|
|
36
|
+
function buildAgentInvocation(backend, role, prompt, claudeModel) {
|
|
37
|
+
if (backend === 'claude') {
|
|
38
|
+
return {
|
|
39
|
+
command: 'claude',
|
|
40
|
+
args: [
|
|
41
|
+
'-p',
|
|
42
|
+
'--verbose',
|
|
43
|
+
'--output-format',
|
|
44
|
+
'stream-json',
|
|
45
|
+
'--include-partial-messages',
|
|
46
|
+
'--permission-mode',
|
|
47
|
+
'auto',
|
|
48
|
+
'--model',
|
|
49
|
+
claudeModel,
|
|
50
|
+
'--effort',
|
|
51
|
+
'medium',
|
|
52
|
+
prompt,
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
command: 'cursor-agent',
|
|
58
|
+
args: [
|
|
59
|
+
'-p',
|
|
60
|
+
'--output-format',
|
|
61
|
+
'stream-json',
|
|
62
|
+
'--stream-partial-output',
|
|
63
|
+
'--trust',
|
|
64
|
+
...(role === 'router' ? ['--mode', 'ask'] : ['--force']),
|
|
65
|
+
prompt,
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const ROUTER_TIMEOUT_MS = 3 * 60_000;
|
|
70
|
+
const TASK_TIMEOUT_MS = 30 * 60_000;
|
|
71
|
+
const MAX_TASK_ATTEMPTS = 3;
|
|
72
|
+
const TASK_POLL_MS = 1_000;
|
|
73
|
+
const FENCE_HOLDBACK = '```castle-';
|
|
74
|
+
const RESULT_SUMMARY_CHARS = 600;
|
|
75
|
+
const MAX_ATTACHMENTS = 6;
|
|
76
|
+
const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
|
|
77
|
+
const TERMINAL_STATUSES = ['done', 'failed', 'interrupted'];
|
|
78
|
+
function nowIso() {
|
|
79
|
+
return new Date().toISOString();
|
|
80
|
+
}
|
|
81
|
+
function isTerminal(status) {
|
|
82
|
+
return TERMINAL_STATUSES.includes(status);
|
|
83
|
+
}
|
|
84
|
+
function readJsonFile(filePath) {
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// How much of a router reply is safe to show while it is still streaming:
|
|
93
|
+
// everything before the first castle-task fence, holding back a partial fence
|
|
94
|
+
// marker at the tail so "```cast..." never flashes up before we know what it
|
|
95
|
+
// is. The full cleaned text replaces the streamed text when the reply ends.
|
|
96
|
+
function visibleLength(raw) {
|
|
97
|
+
const idx = raw.indexOf(FENCE_HOLDBACK);
|
|
98
|
+
if (idx >= 0)
|
|
99
|
+
return idx;
|
|
100
|
+
for (let k = FENCE_HOLDBACK.length - 1; k > 0; k--) {
|
|
101
|
+
if (raw.endsWith(FENCE_HOLDBACK.slice(0, k)))
|
|
102
|
+
return raw.length - k;
|
|
103
|
+
}
|
|
104
|
+
return raw.length;
|
|
105
|
+
}
|
|
106
|
+
// Pull ```castle-task fenced directives out of a finished router reply.
|
|
107
|
+
// Block format: title line, then optional "after:" / "supersedes:" lines (in
|
|
108
|
+
// either order), then the task prompt.
|
|
109
|
+
function extractDirectives(full) {
|
|
110
|
+
const directives = [];
|
|
111
|
+
const checkoffs = [];
|
|
112
|
+
const doneRe = /```castle-done[ \t]*\r?\n([\s\S]*?)```/g;
|
|
113
|
+
const withoutDone = full.replace(doneRe, (_match, body) => {
|
|
114
|
+
for (const token of String(body).split(/[,\n]/)) {
|
|
115
|
+
const trimmed = token.trim();
|
|
116
|
+
if (trimmed)
|
|
117
|
+
checkoffs.push(trimmed);
|
|
118
|
+
}
|
|
119
|
+
return '';
|
|
120
|
+
});
|
|
121
|
+
const fenceRe = /```castle-task[ \t]*\r?\n([\s\S]*?)```/g;
|
|
122
|
+
const cleaned = withoutDone.replace(fenceRe, (_match, body) => {
|
|
123
|
+
const lines = String(body).replace(/\r/g, '').split('\n');
|
|
124
|
+
const title = (lines.shift() ?? '').trim();
|
|
125
|
+
const headers = { after: [], supersedes: [] };
|
|
126
|
+
while (lines.length > 0) {
|
|
127
|
+
const headerMatch = /^(after|supersedes):\s*(.*)$/i.exec((lines[0] ?? '').trim());
|
|
128
|
+
if (!headerMatch)
|
|
129
|
+
break;
|
|
130
|
+
lines.shift();
|
|
131
|
+
headers[headerMatch[1].toLowerCase()] = headerMatch[2]
|
|
132
|
+
.split(',')
|
|
133
|
+
.map((s) => s.trim())
|
|
134
|
+
.filter(Boolean);
|
|
135
|
+
}
|
|
136
|
+
const prompt = lines.join('\n').trim();
|
|
137
|
+
if (title) {
|
|
138
|
+
directives.push({ title, after: headers.after, supersedes: headers.supersedes, prompt });
|
|
139
|
+
}
|
|
140
|
+
return '';
|
|
141
|
+
});
|
|
142
|
+
return { cleaned: cleaned.replace(/\n{3,}/g, '\n\n').trim(), directives, checkoffs };
|
|
143
|
+
}
|
|
144
|
+
// Claude names tools directly (Read, Edit, Bash, ...).
|
|
145
|
+
function claudeToolActivityLabel(name) {
|
|
146
|
+
const kind = name.toLowerCase();
|
|
147
|
+
if (['read', 'glob', 'grep', 'ls', 'webfetch', 'websearch'].some((p) => kind.startsWith(p))) {
|
|
148
|
+
return 'reading the deck';
|
|
149
|
+
}
|
|
150
|
+
if (['edit', 'write', 'notebookedit', 'multiedit'].some((p) => kind.startsWith(p))) {
|
|
151
|
+
return 'editing files';
|
|
152
|
+
}
|
|
153
|
+
if (kind.startsWith('bash'))
|
|
154
|
+
return 'running a command';
|
|
155
|
+
if (kind.startsWith('task'))
|
|
156
|
+
return 'delegating';
|
|
157
|
+
return 'working';
|
|
158
|
+
}
|
|
159
|
+
// Human-readable label for a tool_call event, e.g. readToolCall -> "reading
|
|
160
|
+
// the deck". Shown as the streaming message's activity line.
|
|
161
|
+
function toolActivityLabel(ev) {
|
|
162
|
+
const call = ev.tool_call;
|
|
163
|
+
const key = call ? Object.keys(call).find((k) => k.endsWith('ToolCall')) : undefined;
|
|
164
|
+
const kind = (key ?? '').slice(0, -'ToolCall'.length).toLowerCase();
|
|
165
|
+
if (['read', 'glob', 'grep', 'ls', 'list'].some((p) => kind.startsWith(p))) {
|
|
166
|
+
return 'reading the deck';
|
|
167
|
+
}
|
|
168
|
+
if (['write', 'edit', 'delete', 'mv'].some((p) => kind.startsWith(p)))
|
|
169
|
+
return 'editing files';
|
|
170
|
+
if (['shell', 'bash', 'terminal'].some((p) => kind.startsWith(p)))
|
|
171
|
+
return 'running a command';
|
|
172
|
+
return 'working';
|
|
173
|
+
}
|
|
174
|
+
// One headless agent CLI run (cursor or claude), normalized to the same
|
|
175
|
+
// delta/activity/result hooks. Cursor: assistant events carrying timestamp_ms
|
|
176
|
+
// are text deltas; the trailing assistant event without one repeats the whole
|
|
177
|
+
// message (skipped). Claude: stream_event wraps anthropic SSE deltas. Both
|
|
178
|
+
// end with a result event carrying the canonical final text.
|
|
179
|
+
function runAgentCli(opts) {
|
|
180
|
+
return new Promise((resolve) => {
|
|
181
|
+
const child = spawn(opts.command, opts.args, {
|
|
182
|
+
cwd: opts.cwd,
|
|
183
|
+
env: process.env,
|
|
184
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
185
|
+
});
|
|
186
|
+
opts.children.add(child);
|
|
187
|
+
opts.onSpawn?.(child.pid);
|
|
188
|
+
const log = opts.logPath ? fs.createWriteStream(opts.logPath, { flags: 'a' }) : null;
|
|
189
|
+
let settled = false;
|
|
190
|
+
let accumulated = '';
|
|
191
|
+
let finalText = '';
|
|
192
|
+
let resultIsError = false;
|
|
193
|
+
let sawResult = false;
|
|
194
|
+
let stderrTail = '';
|
|
195
|
+
let lineBuffer = '';
|
|
196
|
+
const settle = (result) => {
|
|
197
|
+
if (settled)
|
|
198
|
+
return;
|
|
199
|
+
settled = true;
|
|
200
|
+
clearTimeout(timeout);
|
|
201
|
+
opts.children.delete(child);
|
|
202
|
+
log?.end();
|
|
203
|
+
resolve(result);
|
|
204
|
+
};
|
|
205
|
+
const timeout = setTimeout(() => {
|
|
206
|
+
try {
|
|
207
|
+
child.kill('SIGKILL');
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
/* already gone */
|
|
211
|
+
}
|
|
212
|
+
settle({ ok: false, finalText: finalText || accumulated, error: 'agent run timed out' });
|
|
213
|
+
}, opts.timeoutMs);
|
|
214
|
+
// Cursor closes each text segment (e.g. right before a tool call) by
|
|
215
|
+
// re-emitting the segment's full text as one more delta-shaped event;
|
|
216
|
+
// track the current segment so those re-emissions are dropped instead of
|
|
217
|
+
// duplicating lines. Segment boundaries also need a paragraph gap --
|
|
218
|
+
// cursor starts the next segment without one, which glues "Checking the
|
|
219
|
+
// deck..." lines onto the previous paragraph.
|
|
220
|
+
let segmentText = '';
|
|
221
|
+
let needsGap = false;
|
|
222
|
+
const emitDelta = (rawDelta) => {
|
|
223
|
+
let delta = rawDelta;
|
|
224
|
+
if (needsGap) {
|
|
225
|
+
needsGap = false;
|
|
226
|
+
if (accumulated && !accumulated.endsWith('\n\n')) {
|
|
227
|
+
delta = (accumulated.endsWith('\n') ? '\n' : '\n\n') + delta;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
segmentText += delta;
|
|
231
|
+
accumulated += delta;
|
|
232
|
+
opts.onDelta?.(delta);
|
|
233
|
+
opts.onActivity?.(null);
|
|
234
|
+
};
|
|
235
|
+
const handleClaudeEvent = (ev) => {
|
|
236
|
+
if (ev.type === 'stream_event') {
|
|
237
|
+
const e = ev.event;
|
|
238
|
+
if (e?.type === 'content_block_start') {
|
|
239
|
+
if (e.content_block?.type === 'tool_use') {
|
|
240
|
+
needsGap = true;
|
|
241
|
+
opts.onActivity?.(claudeToolActivityLabel(String(e.content_block.name ?? '')));
|
|
242
|
+
}
|
|
243
|
+
else if (e.content_block?.type === 'thinking') {
|
|
244
|
+
needsGap = true;
|
|
245
|
+
opts.onActivity?.('thinking');
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
else if (e?.type === 'content_block_delta') {
|
|
249
|
+
if (e.delta?.type === 'text_delta' && typeof e.delta.text === 'string' && e.delta.text) {
|
|
250
|
+
emitDelta(e.delta.text);
|
|
251
|
+
}
|
|
252
|
+
else if (e.delta?.type === 'thinking_delta') {
|
|
253
|
+
opts.onActivity?.('thinking');
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
else if (ev.type === 'result') {
|
|
258
|
+
sawResult = true;
|
|
259
|
+
finalText = typeof ev.result === 'string' ? ev.result : accumulated;
|
|
260
|
+
resultIsError = ev.is_error === true;
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
const handleEvent = (ev) => {
|
|
264
|
+
if (opts.parser === 'claude') {
|
|
265
|
+
handleClaudeEvent(ev);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (ev.type === 'assistant' && typeof ev.timestamp_ms === 'number') {
|
|
269
|
+
const message = ev.message;
|
|
270
|
+
const delta = (message?.content ?? [])
|
|
271
|
+
.map((c) => (typeof c?.text === 'string' ? c.text : ''))
|
|
272
|
+
.join('');
|
|
273
|
+
if (!delta)
|
|
274
|
+
return;
|
|
275
|
+
const trimmed = delta.trim();
|
|
276
|
+
if (trimmed.length >= 16 && segmentText.trim().endsWith(trimmed))
|
|
277
|
+
return;
|
|
278
|
+
emitDelta(delta);
|
|
279
|
+
}
|
|
280
|
+
else if (ev.type === 'tool_call') {
|
|
281
|
+
segmentText = '';
|
|
282
|
+
needsGap = true;
|
|
283
|
+
if (ev.subtype === 'started')
|
|
284
|
+
opts.onActivity?.(toolActivityLabel(ev));
|
|
285
|
+
}
|
|
286
|
+
else if (ev.type === 'thinking') {
|
|
287
|
+
segmentText = '';
|
|
288
|
+
needsGap = true;
|
|
289
|
+
opts.onActivity?.('thinking');
|
|
290
|
+
}
|
|
291
|
+
else if (ev.type === 'result') {
|
|
292
|
+
sawResult = true;
|
|
293
|
+
finalText = typeof ev.result === 'string' ? ev.result : accumulated;
|
|
294
|
+
resultIsError = ev.is_error === true;
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
child.stdout.on('data', (chunk) => {
|
|
298
|
+
lineBuffer += chunk.toString('utf8');
|
|
299
|
+
let nl = lineBuffer.indexOf('\n');
|
|
300
|
+
while (nl >= 0) {
|
|
301
|
+
const line = lineBuffer.slice(0, nl);
|
|
302
|
+
lineBuffer = lineBuffer.slice(nl + 1);
|
|
303
|
+
if (line.trim()) {
|
|
304
|
+
log?.write(line + '\n');
|
|
305
|
+
try {
|
|
306
|
+
handleEvent(JSON.parse(line));
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
/* non-JSON noise on stdout -- ignore */
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
nl = lineBuffer.indexOf('\n');
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
child.stderr.on('data', (chunk) => {
|
|
316
|
+
stderrTail = (stderrTail + chunk.toString('utf8')).slice(-2000);
|
|
317
|
+
});
|
|
318
|
+
child.on('error', (err) => {
|
|
319
|
+
settle({ ok: false, finalText: accumulated, error: `could not run cursor-agent: ${err.message}` });
|
|
320
|
+
});
|
|
321
|
+
child.on('close', (code) => {
|
|
322
|
+
const ok = code === 0 && !resultIsError && sawResult;
|
|
323
|
+
settle({
|
|
324
|
+
ok,
|
|
325
|
+
finalText: finalText || accumulated,
|
|
326
|
+
crashed: !sawResult,
|
|
327
|
+
error: ok ? undefined : `agent exited ${code}${stderrTail ? `: ${stderrTail.slice(-300)}` : ''}`,
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
// -- task store ---------------------------------------------------------------
|
|
333
|
+
function persistTaskFile(tasksDir, task) {
|
|
334
|
+
fs.writeFileSync(path.join(tasksDir, task.id, 'task.json'), JSON.stringify(task, null, 2) + '\n');
|
|
335
|
+
}
|
|
336
|
+
// Tasks left "running" by a dead serve are as finished as they will get.
|
|
337
|
+
function loadTasks(tasksDir) {
|
|
338
|
+
const tasks = new Map();
|
|
339
|
+
for (const entry of fs.existsSync(tasksDir) ? fs.readdirSync(tasksDir) : []) {
|
|
340
|
+
const rec = readJsonFile(path.join(tasksDir, entry, 'task.json'));
|
|
341
|
+
if (!rec)
|
|
342
|
+
continue;
|
|
343
|
+
if (rec.status === 'running') {
|
|
344
|
+
rec.status = 'interrupted';
|
|
345
|
+
rec.updatedAt = nowIso();
|
|
346
|
+
persistTaskFile(tasksDir, rec);
|
|
347
|
+
}
|
|
348
|
+
tasks.set(rec.id, rec);
|
|
349
|
+
}
|
|
350
|
+
return tasks;
|
|
351
|
+
}
|
|
352
|
+
// Task agents report through plain files: an integer in `progress`, free
|
|
353
|
+
// text in `notes.md`. Pull both into the record; true when anything changed.
|
|
354
|
+
function refreshTaskFiles(tasksDir, task) {
|
|
355
|
+
const dir = path.join(tasksDir, task.id);
|
|
356
|
+
let changed = false;
|
|
357
|
+
try {
|
|
358
|
+
const rawProgress = fs.readFileSync(path.join(dir, 'progress'), 'utf8').trim();
|
|
359
|
+
const value = Math.max(0, Math.min(100, parseInt(rawProgress, 10)));
|
|
360
|
+
if (Number.isFinite(value) && value !== task.progress) {
|
|
361
|
+
task.progress = value;
|
|
362
|
+
changed = true;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
/* no progress file yet */
|
|
367
|
+
}
|
|
368
|
+
try {
|
|
369
|
+
const notes = fs.readFileSync(path.join(dir, 'notes.md'), 'utf8');
|
|
370
|
+
if (notes !== task.notes) {
|
|
371
|
+
task.notes = notes;
|
|
372
|
+
changed = true;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
/* no notes file yet */
|
|
377
|
+
}
|
|
378
|
+
return changed;
|
|
379
|
+
}
|
|
380
|
+
// "after:" entries may reference task ids or titles -- including titles of
|
|
381
|
+
// tasks spawned earlier in the same reply (spawn order follows block order).
|
|
382
|
+
function resolveDeps(tasks, tokens) {
|
|
383
|
+
const resolved = [];
|
|
384
|
+
for (const token of tokens) {
|
|
385
|
+
if (tasks.has(token)) {
|
|
386
|
+
resolved.push(token);
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
const byTitle = [...tasks.values()]
|
|
390
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
391
|
+
.filter((t) => t.title.toLowerCase() === token.toLowerCase());
|
|
392
|
+
const last = byTitle[byTitle.length - 1];
|
|
393
|
+
if (last)
|
|
394
|
+
resolved.push(last.id);
|
|
395
|
+
}
|
|
396
|
+
return [...new Set(resolved)];
|
|
397
|
+
}
|
|
398
|
+
function depsSummaryFor(tasks, task) {
|
|
399
|
+
if (task.after.length === 0)
|
|
400
|
+
return undefined;
|
|
401
|
+
const lines = task.after
|
|
402
|
+
.map((id) => tasks.get(id))
|
|
403
|
+
.filter((dep) => !!dep)
|
|
404
|
+
.map((dep) => `- "${dep.title}" finished ${dep.status}${dep.notes.trim() ? `; notes: ${dep.notes.trim()}` : ''}`);
|
|
405
|
+
return lines.join('\n') || undefined;
|
|
406
|
+
}
|
|
407
|
+
function createTaskStore(opts) {
|
|
408
|
+
const { deckDir, deckLabel, tasksDir, children } = opts;
|
|
409
|
+
const tasks = loadTasks(tasksDir);
|
|
410
|
+
function sorted() {
|
|
411
|
+
return [...tasks.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
412
|
+
}
|
|
413
|
+
function touch(task) {
|
|
414
|
+
task.updatedAt = nowIso();
|
|
415
|
+
persistTaskFile(tasksDir, task);
|
|
416
|
+
opts.onUpdate(task);
|
|
417
|
+
}
|
|
418
|
+
function depsAreSettled(task) {
|
|
419
|
+
return task.after.every((id) => {
|
|
420
|
+
const dep = tasks.get(id);
|
|
421
|
+
return !dep || isTerminal(dep.status);
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
function maybeStart(task) {
|
|
425
|
+
if (task.status !== 'waiting' || task.acknowledged || !depsAreSettled(task))
|
|
426
|
+
return;
|
|
427
|
+
start(task);
|
|
428
|
+
}
|
|
429
|
+
// Run the task agent, re-running if the process dies mid-task (crash, not
|
|
430
|
+
// a normal finish). After MAX_TASK_ATTEMPTS dead processes the task fails.
|
|
431
|
+
async function runTaskAgent(task, dir) {
|
|
432
|
+
const relDir = path.relative(deckDir, dir);
|
|
433
|
+
const backend = opts.backend();
|
|
434
|
+
const taskPrompt = buildTaskPrompt({
|
|
435
|
+
deckLabel,
|
|
436
|
+
taskId: task.id,
|
|
437
|
+
title: task.title,
|
|
438
|
+
prompt: task.prompt,
|
|
439
|
+
progressPath: path.join(relDir, 'progress'),
|
|
440
|
+
notesPath: path.join(relDir, 'notes.md'),
|
|
441
|
+
depsSummary: depsSummaryFor(tasks, task),
|
|
442
|
+
});
|
|
443
|
+
// /goal commits claude to finishing autonomously; cursor has no
|
|
444
|
+
// equivalent slash command.
|
|
445
|
+
const invocation = buildAgentInvocation(backend, 'task', backend === 'claude' ? `/goal ${taskPrompt}` : taskPrompt, opts.claudeModel());
|
|
446
|
+
let result = { ok: false, finalText: '', error: 'not run' };
|
|
447
|
+
let lineBuf = '';
|
|
448
|
+
const flushFeedLines = (delta) => {
|
|
449
|
+
lineBuf += delta;
|
|
450
|
+
let nl = lineBuf.indexOf('\n');
|
|
451
|
+
while (nl >= 0) {
|
|
452
|
+
const line = lineBuf.slice(0, nl).trim();
|
|
453
|
+
lineBuf = lineBuf.slice(nl + 1);
|
|
454
|
+
if (line)
|
|
455
|
+
opts.onFeed(task, line);
|
|
456
|
+
nl = lineBuf.indexOf('\n');
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
for (let attempt = 1; attempt <= MAX_TASK_ATTEMPTS; attempt++) {
|
|
460
|
+
result = await runAgentCli({
|
|
461
|
+
cwd: deckDir,
|
|
462
|
+
command: invocation.command,
|
|
463
|
+
args: invocation.args,
|
|
464
|
+
parser: backend,
|
|
465
|
+
timeoutMs: TASK_TIMEOUT_MS,
|
|
466
|
+
logPath: path.join(dir, 'log.jsonl'),
|
|
467
|
+
children,
|
|
468
|
+
onSpawn: (pid) => {
|
|
469
|
+
task.pid = pid;
|
|
470
|
+
},
|
|
471
|
+
onDelta: (delta) => flushFeedLines(delta),
|
|
472
|
+
onActivity: (activity) => {
|
|
473
|
+
if (activity)
|
|
474
|
+
opts.onFeed(task, `[${activity}]`);
|
|
475
|
+
},
|
|
476
|
+
});
|
|
477
|
+
if (!result.crashed)
|
|
478
|
+
return result;
|
|
479
|
+
if (attempt < MAX_TASK_ATTEMPTS)
|
|
480
|
+
opts.onRetry(task, attempt + 1);
|
|
481
|
+
}
|
|
482
|
+
result.error = `agent process kept dying (${MAX_TASK_ATTEMPTS} attempts): ${result.error ?? ''}`;
|
|
483
|
+
return result;
|
|
484
|
+
}
|
|
485
|
+
function start(task) {
|
|
486
|
+
const dir = path.join(tasksDir, task.id);
|
|
487
|
+
fs.writeFileSync(path.join(dir, 'progress'), '0\n');
|
|
488
|
+
if (!fs.existsSync(path.join(dir, 'notes.md')))
|
|
489
|
+
fs.writeFileSync(path.join(dir, 'notes.md'), '');
|
|
490
|
+
task.status = 'running';
|
|
491
|
+
task.startedAt = nowIso();
|
|
492
|
+
touch(task);
|
|
493
|
+
opts.onStarted(task);
|
|
494
|
+
void runTaskAgent(task, dir).then((result) => {
|
|
495
|
+
refreshTaskFiles(tasksDir, task);
|
|
496
|
+
task.status = result.ok ? 'done' : 'failed';
|
|
497
|
+
if (result.ok)
|
|
498
|
+
task.progress = 100;
|
|
499
|
+
task.finishedAt = nowIso();
|
|
500
|
+
task.resultSummary = result.ok
|
|
501
|
+
? result.finalText.slice(-RESULT_SUMMARY_CHARS)
|
|
502
|
+
: `${result.error ?? 'failed'}\n${result.finalText.slice(-RESULT_SUMMARY_CHARS)}`;
|
|
503
|
+
touch(task);
|
|
504
|
+
opts.onFinished(task);
|
|
505
|
+
for (const waiting of tasks.values())
|
|
506
|
+
maybeStart(waiting);
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
function spawnFromDirective(directive, originMessageId) {
|
|
510
|
+
// A fix/redo task sweeps the rows it obsoletes off the user's board. A
|
|
511
|
+
// superseded task that never started must not start later either.
|
|
512
|
+
for (const id of resolveDeps(tasks, directive.supersedes)) {
|
|
513
|
+
const old = tasks.get(id);
|
|
514
|
+
if (old && !old.acknowledged) {
|
|
515
|
+
if (old.status === 'waiting')
|
|
516
|
+
old.status = 'interrupted';
|
|
517
|
+
old.acknowledged = true;
|
|
518
|
+
touch(old);
|
|
519
|
+
opts.onSuperseded(old);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
const task = {
|
|
523
|
+
id: nanoid(8),
|
|
524
|
+
title: directive.title,
|
|
525
|
+
prompt: directive.prompt,
|
|
526
|
+
after: resolveDeps(tasks, directive.after),
|
|
527
|
+
status: 'waiting',
|
|
528
|
+
progress: 0,
|
|
529
|
+
notes: '',
|
|
530
|
+
createdAt: nowIso(),
|
|
531
|
+
updatedAt: nowIso(),
|
|
532
|
+
originMessageId,
|
|
533
|
+
};
|
|
534
|
+
fs.mkdirSync(path.join(tasksDir, task.id), { recursive: true });
|
|
535
|
+
tasks.set(task.id, task);
|
|
536
|
+
persistTaskFile(tasksDir, task);
|
|
537
|
+
opts.onUpdate(task);
|
|
538
|
+
maybeStart(task);
|
|
539
|
+
return task.id;
|
|
540
|
+
}
|
|
541
|
+
function acknowledge(id, rejected) {
|
|
542
|
+
const task = tasks.get(id);
|
|
543
|
+
if (!task || !isTerminal(task.status) || task.acknowledged)
|
|
544
|
+
return undefined;
|
|
545
|
+
task.acknowledged = true;
|
|
546
|
+
if (rejected)
|
|
547
|
+
task.rejected = true;
|
|
548
|
+
touch(task);
|
|
549
|
+
return task;
|
|
550
|
+
}
|
|
551
|
+
const pollTimer = setInterval(() => {
|
|
552
|
+
for (const task of tasks.values()) {
|
|
553
|
+
if (task.status !== 'running')
|
|
554
|
+
continue;
|
|
555
|
+
if (refreshTaskFiles(tasksDir, task))
|
|
556
|
+
touch(task);
|
|
557
|
+
}
|
|
558
|
+
}, TASK_POLL_MS);
|
|
559
|
+
function shutdown() {
|
|
560
|
+
clearInterval(pollTimer);
|
|
561
|
+
for (const task of tasks.values()) {
|
|
562
|
+
if (task.status === 'running' || task.status === 'waiting') {
|
|
563
|
+
task.status = 'interrupted';
|
|
564
|
+
task.updatedAt = nowIso();
|
|
565
|
+
persistTaskFile(tasksDir, task);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
// The router checks finished tasks off by title or id (castle-done fence).
|
|
570
|
+
function checkOff(tokens) {
|
|
571
|
+
for (const id of resolveDeps(tasks, tokens))
|
|
572
|
+
acknowledge(id, false);
|
|
573
|
+
}
|
|
574
|
+
return { sorted, get: (id) => tasks.get(id), spawnFromDirective, acknowledge, checkOff, shutdown };
|
|
575
|
+
}
|
|
576
|
+
// -- attachments ----------------------------------------------------------------
|
|
577
|
+
const ATTACHMENT_MIME = {
|
|
578
|
+
png: 'image/png',
|
|
579
|
+
jpg: 'image/jpeg',
|
|
580
|
+
jpeg: 'image/jpeg',
|
|
581
|
+
gif: 'image/gif',
|
|
582
|
+
webp: 'image/webp',
|
|
583
|
+
};
|
|
584
|
+
// Decode pasted/attached images (data URLs) into .castle/agent/attachments/.
|
|
585
|
+
// Returns the saved file names.
|
|
586
|
+
function saveAttachments(attachmentsDir, messageId, images) {
|
|
587
|
+
if (!Array.isArray(images))
|
|
588
|
+
return [];
|
|
589
|
+
const saved = [];
|
|
590
|
+
for (const [index, image] of images.slice(0, MAX_ATTACHMENTS).entries()) {
|
|
591
|
+
const dataUrl = image?.dataUrl;
|
|
592
|
+
if (typeof dataUrl !== 'string' || dataUrl.length > MAX_ATTACHMENT_BYTES * 1.4)
|
|
593
|
+
continue;
|
|
594
|
+
const match = /^data:image\/(png|jpe?g|gif|webp);base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl);
|
|
595
|
+
if (!match)
|
|
596
|
+
continue;
|
|
597
|
+
const ext = match[1] === 'jpeg' ? 'jpg' : match[1];
|
|
598
|
+
const fileName = `${messageId}-${index}.${ext}`;
|
|
599
|
+
try {
|
|
600
|
+
fs.mkdirSync(attachmentsDir, { recursive: true });
|
|
601
|
+
fs.writeFileSync(path.join(attachmentsDir, fileName), Buffer.from(match[2], 'base64'));
|
|
602
|
+
saved.push(fileName);
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
/* disk trouble -- skip this image */
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return saved;
|
|
609
|
+
}
|
|
610
|
+
function asPromptTask(task) {
|
|
611
|
+
return {
|
|
612
|
+
id: task.id,
|
|
613
|
+
title: task.title,
|
|
614
|
+
status: task.rejected ? 'rejected by user' : task.status,
|
|
615
|
+
progress: task.progress,
|
|
616
|
+
notes: task.notes,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
function createTaskFeeds(broadcast) {
|
|
620
|
+
const map = new Map();
|
|
621
|
+
function push(task, entry) {
|
|
622
|
+
let feed = map.get(task.id);
|
|
623
|
+
if (!feed) {
|
|
624
|
+
feed = [];
|
|
625
|
+
map.set(task.id, feed);
|
|
626
|
+
}
|
|
627
|
+
if (feed[feed.length - 1] === entry)
|
|
628
|
+
return;
|
|
629
|
+
feed.push(entry);
|
|
630
|
+
if (feed.length > 80)
|
|
631
|
+
feed.splice(0, feed.length - 80);
|
|
632
|
+
broadcast({ type: 'task-feed', id: task.id, entry });
|
|
633
|
+
}
|
|
634
|
+
return { map, push };
|
|
635
|
+
}
|
|
636
|
+
function createMessageLog(messagesPath, broadcast) {
|
|
637
|
+
const loaded = readJsonFile(messagesPath) ?? [];
|
|
638
|
+
const messages = loaded
|
|
639
|
+
.filter((m) => m.text.trim() !== '' || m.role === 'user')
|
|
640
|
+
.map((m) => (m.status === 'streaming' ? { ...m, status: 'done' } : m));
|
|
641
|
+
function persist() {
|
|
642
|
+
fs.writeFileSync(messagesPath, JSON.stringify(messages, null, 2) + '\n');
|
|
643
|
+
}
|
|
644
|
+
function add(message) {
|
|
645
|
+
messages.push(message);
|
|
646
|
+
persist();
|
|
647
|
+
broadcast({ type: 'message-add', message });
|
|
648
|
+
}
|
|
649
|
+
function addLog(text) {
|
|
650
|
+
add({ id: nanoid(8), role: 'log', text, at: nowIso(), status: 'done' });
|
|
651
|
+
}
|
|
652
|
+
// Consecutive same-prefix log lines collapse into one ("working on: A, B").
|
|
653
|
+
function addGroupedLog(prefix, item) {
|
|
654
|
+
const last = messages[messages.length - 1];
|
|
655
|
+
if (last && last.role === 'log' && last.text.startsWith(prefix)) {
|
|
656
|
+
last.text += `, ${item}`;
|
|
657
|
+
persist();
|
|
658
|
+
broadcast({ type: 'message-done', id: last.id, text: last.text, status: 'done' });
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
addLog(`${prefix}${item}`);
|
|
662
|
+
}
|
|
663
|
+
return { messages, persist, add, addLog, addGroupedLog };
|
|
664
|
+
}
|
|
665
|
+
// Serve attachment images saved under .castle/agent/attachments/.
|
|
666
|
+
function makeAttachmentHandler(attachmentsDir) {
|
|
667
|
+
return (_req, res, reqPath) => {
|
|
668
|
+
if (!reqPath.startsWith(AGENT_ATTACHMENT_PREFIX))
|
|
669
|
+
return false;
|
|
670
|
+
const name = path.basename(reqPath.slice(AGENT_ATTACHMENT_PREFIX.length));
|
|
671
|
+
const ext = name.split('.').pop() ?? '';
|
|
672
|
+
const mime = ATTACHMENT_MIME[ext];
|
|
673
|
+
const filePath = path.join(attachmentsDir, name);
|
|
674
|
+
if (!mime || !fs.existsSync(filePath)) {
|
|
675
|
+
res.writeHead(404).end();
|
|
676
|
+
return true;
|
|
677
|
+
}
|
|
678
|
+
res.writeHead(200, { 'content-type': mime, 'cache-control': 'no-store' });
|
|
679
|
+
fs.createReadStream(filePath).pipe(res);
|
|
680
|
+
return true;
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
// One router turn: stream a reply message, then spawn the directives it
|
|
684
|
+
// emitted (unless a newer user message superseded this turn).
|
|
685
|
+
function runRouterTurnIn(ctx, instruction) {
|
|
686
|
+
const epoch = ctx.currentEpoch();
|
|
687
|
+
const message = {
|
|
688
|
+
id: nanoid(8),
|
|
689
|
+
role: 'assistant',
|
|
690
|
+
text: '',
|
|
691
|
+
at: nowIso(),
|
|
692
|
+
status: 'streaming',
|
|
693
|
+
};
|
|
694
|
+
ctx.log.messages.push(message);
|
|
695
|
+
ctx.broadcast({ type: 'message-add', message });
|
|
696
|
+
let raw = '';
|
|
697
|
+
let visibleSent = 0;
|
|
698
|
+
let lastActivity = null;
|
|
699
|
+
const prompt = buildRouterPrompt({
|
|
700
|
+
deckLabel: ctx.deckLabel,
|
|
701
|
+
messages: ctx.log.messages
|
|
702
|
+
.filter((m) => m.role !== 'log' && m.id !== message.id && m.status !== 'streaming')
|
|
703
|
+
.map((m) => ({ role: m.role, text: m.text })),
|
|
704
|
+
tasks: ctx.taskStore.sorted().map(asPromptTask),
|
|
705
|
+
instruction,
|
|
706
|
+
});
|
|
707
|
+
const backend = ctx.backend();
|
|
708
|
+
const invocation = buildAgentInvocation(backend, 'router', prompt, ctx.claudeModel());
|
|
709
|
+
void runAgentCli({
|
|
710
|
+
cwd: ctx.deckDir,
|
|
711
|
+
command: invocation.command,
|
|
712
|
+
args: invocation.args,
|
|
713
|
+
parser: backend,
|
|
714
|
+
timeoutMs: ROUTER_TIMEOUT_MS,
|
|
715
|
+
logPath: path.join(ctx.agentDir, 'router-log.jsonl'),
|
|
716
|
+
children: ctx.children,
|
|
717
|
+
onDelta: (delta) => {
|
|
718
|
+
raw += delta;
|
|
719
|
+
const visible = visibleLength(raw);
|
|
720
|
+
if (visible > visibleSent) {
|
|
721
|
+
const slice = raw.slice(visibleSent, visible);
|
|
722
|
+
visibleSent = visible;
|
|
723
|
+
message.text += slice;
|
|
724
|
+
ctx.broadcast({ type: 'message-delta', id: message.id, delta: slice });
|
|
725
|
+
}
|
|
726
|
+
},
|
|
727
|
+
onActivity: (activity) => {
|
|
728
|
+
if (activity === lastActivity)
|
|
729
|
+
return;
|
|
730
|
+
lastActivity = activity;
|
|
731
|
+
ctx.broadcast({ type: 'message-activity', id: message.id, activity });
|
|
732
|
+
},
|
|
733
|
+
}).then((result) => {
|
|
734
|
+
const interrupted = epoch !== ctx.currentEpoch() && !result.ok;
|
|
735
|
+
if (interrupted) {
|
|
736
|
+
// Keep whatever streamed; the continuation turn carries the draft.
|
|
737
|
+
message.status = 'done';
|
|
738
|
+
message.interrupted = true;
|
|
739
|
+
ctx.log.persist();
|
|
740
|
+
ctx.broadcast({
|
|
741
|
+
type: 'message-done',
|
|
742
|
+
id: message.id,
|
|
743
|
+
text: message.text,
|
|
744
|
+
status: message.status,
|
|
745
|
+
interrupted: true,
|
|
746
|
+
taskIds: [],
|
|
747
|
+
});
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
const { cleaned, directives, checkoffs } = extractDirectives(result.finalText);
|
|
751
|
+
if (result.ok && checkoffs.length > 0)
|
|
752
|
+
ctx.taskStore.checkOff(checkoffs);
|
|
753
|
+
// Drop directives from stale turns, and any whose title matches a task
|
|
754
|
+
// already in flight (two runs reacting to the same ask).
|
|
755
|
+
const stale = epoch !== ctx.currentEpoch();
|
|
756
|
+
const inFlight = new Set(ctx.taskStore
|
|
757
|
+
.sorted()
|
|
758
|
+
.filter((t) => t.status === 'running' || t.status === 'waiting')
|
|
759
|
+
.map((t) => t.title.toLowerCase()));
|
|
760
|
+
const toSpawn = stale ? [] : directives.filter((d) => !inFlight.has(d.title.toLowerCase()));
|
|
761
|
+
const taskIds = toSpawn.map((d) => ctx.taskStore.spawnFromDirective(d, message.id));
|
|
762
|
+
message.text = result.ok
|
|
763
|
+
? cleaned
|
|
764
|
+
: `${cleaned ? cleaned + '\n\n' : ''}[router error: ${result.error ?? 'unknown'}]`;
|
|
765
|
+
message.status = result.ok ? 'done' : 'error';
|
|
766
|
+
if (taskIds.length > 0)
|
|
767
|
+
message.taskIds = taskIds;
|
|
768
|
+
ctx.log.persist();
|
|
769
|
+
ctx.broadcast({
|
|
770
|
+
type: 'message-done',
|
|
771
|
+
id: message.id,
|
|
772
|
+
text: message.text,
|
|
773
|
+
status: message.status,
|
|
774
|
+
taskIds: message.taskIds ?? [],
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
// Merge settings changes from the client: validate, persist, broadcast, log.
|
|
779
|
+
function applyAgentSettings(incoming, ctx) {
|
|
780
|
+
const { settings } = ctx;
|
|
781
|
+
const changes = [];
|
|
782
|
+
for (const key of ['router', 'tasks']) {
|
|
783
|
+
const value = normalizeBackend(incoming[key]);
|
|
784
|
+
if (value && value !== settings[key]) {
|
|
785
|
+
settings[key] = value;
|
|
786
|
+
changes.push(`${key} agent -> ${value}`);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
const model = normalizeClaudeModel(incoming.claudeModel);
|
|
790
|
+
if (model && model !== settings.claudeModel) {
|
|
791
|
+
settings.claudeModel = model;
|
|
792
|
+
changes.push(`claude model -> ${model}`);
|
|
793
|
+
}
|
|
794
|
+
if (changes.length === 0)
|
|
795
|
+
return;
|
|
796
|
+
fs.writeFileSync(ctx.settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
797
|
+
ctx.broadcast({ type: 'settings', settings });
|
|
798
|
+
}
|
|
799
|
+
export function createAgentServer(opts) {
|
|
800
|
+
const { deckDir, deckLabel } = opts;
|
|
801
|
+
const agentDir = path.join(deckDir, '.castle', 'agent');
|
|
802
|
+
const tasksDir = path.join(agentDir, 'tasks');
|
|
803
|
+
const attachmentsDir = path.join(agentDir, 'attachments');
|
|
804
|
+
const messagesPath = path.join(agentDir, 'messages.json');
|
|
805
|
+
fs.mkdirSync(tasksDir, { recursive: true });
|
|
806
|
+
const taskChildren = new Set();
|
|
807
|
+
const routerChildren = new Set();
|
|
808
|
+
const clients = new Set();
|
|
809
|
+
function broadcast(body) {
|
|
810
|
+
const payload = JSON.stringify(body);
|
|
811
|
+
for (const socket of clients) {
|
|
812
|
+
if (socket.readyState === socket.OPEN)
|
|
813
|
+
socket.send(payload);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
const log = createMessageLog(messagesPath, broadcast);
|
|
817
|
+
const messages = log.messages;
|
|
818
|
+
const addLog = (text) => log.addLog(text);
|
|
819
|
+
// Which CLI backs the router and the task agents -- independently
|
|
820
|
+
// switchable from the settings popover, persisted next to the chat state.
|
|
821
|
+
const settingsPath = path.join(agentDir, 'settings.json');
|
|
822
|
+
const storedSettings = readJsonFile(settingsPath);
|
|
823
|
+
const settings = {
|
|
824
|
+
router: normalizeBackend(storedSettings?.router) ?? DEFAULT_SETTINGS.router,
|
|
825
|
+
tasks: normalizeBackend(storedSettings?.tasks) ?? DEFAULT_SETTINGS.tasks,
|
|
826
|
+
claudeModel: normalizeClaudeModel(storedSettings?.claudeModel) ?? DEFAULT_SETTINGS.claudeModel,
|
|
827
|
+
};
|
|
828
|
+
const applySettings = (incoming) => applyAgentSettings(incoming, { settings, settingsPath, broadcast });
|
|
829
|
+
const taskFeeds = createTaskFeeds(broadcast);
|
|
830
|
+
const taskStore = createTaskStore({
|
|
831
|
+
deckDir,
|
|
832
|
+
deckLabel,
|
|
833
|
+
tasksDir,
|
|
834
|
+
children: taskChildren,
|
|
835
|
+
backend: () => settings.tasks,
|
|
836
|
+
claudeModel: () => settings.claudeModel,
|
|
837
|
+
// Task lifecycle stays on the board only -- log lines for it were spam.
|
|
838
|
+
onUpdate: (task) => broadcast({ type: 'task-update', task }),
|
|
839
|
+
onStarted: () => undefined,
|
|
840
|
+
onRetry: (task, attempt) => addLog(`agent died, retrying (${attempt}/${MAX_TASK_ATTEMPTS}): ${task.title}`),
|
|
841
|
+
onFinished: (task) => taskFeeds.map.delete(task.id),
|
|
842
|
+
onSuperseded: () => undefined,
|
|
843
|
+
onFeed: (task, entry) => taskFeeds.push(task, entry),
|
|
844
|
+
});
|
|
845
|
+
// A new user message interrupts the in-flight router reply: its partial
|
|
846
|
+
// text stays in the log, and the next turn continues both threads. The
|
|
847
|
+
// epoch also keeps a killed-but-racing run from spawning tasks.
|
|
848
|
+
let userEpoch = 0;
|
|
849
|
+
function interruptRouterRuns() {
|
|
850
|
+
const drafts = messages
|
|
851
|
+
.filter((m) => m.role === 'assistant' && m.status === 'streaming')
|
|
852
|
+
.map((m) => m.text.trim())
|
|
853
|
+
.filter(Boolean);
|
|
854
|
+
for (const child of routerChildren) {
|
|
855
|
+
try {
|
|
856
|
+
child.kill('SIGKILL');
|
|
857
|
+
}
|
|
858
|
+
catch {
|
|
859
|
+
/* already gone */
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
return drafts.join('\n\n');
|
|
863
|
+
}
|
|
864
|
+
function runRouterTurn(instruction) {
|
|
865
|
+
runRouterTurnIn({
|
|
866
|
+
deckDir,
|
|
867
|
+
deckLabel,
|
|
868
|
+
agentDir,
|
|
869
|
+
children: routerChildren,
|
|
870
|
+
log,
|
|
871
|
+
broadcast,
|
|
872
|
+
taskStore,
|
|
873
|
+
currentEpoch: () => userEpoch,
|
|
874
|
+
backend: () => settings.router,
|
|
875
|
+
claudeModel: () => settings.claudeModel,
|
|
876
|
+
}, instruction);
|
|
877
|
+
}
|
|
878
|
+
function handleUserMessage(text, images) {
|
|
879
|
+
userEpoch += 1;
|
|
880
|
+
const interruptedDraft = interruptRouterRuns();
|
|
881
|
+
const messageId = nanoid(8);
|
|
882
|
+
const attachments = saveAttachments(attachmentsDir, messageId, images);
|
|
883
|
+
const message = {
|
|
884
|
+
id: messageId,
|
|
885
|
+
role: 'user',
|
|
886
|
+
text,
|
|
887
|
+
at: nowIso(),
|
|
888
|
+
status: 'done',
|
|
889
|
+
};
|
|
890
|
+
if (attachments.length > 0)
|
|
891
|
+
message.attachments = attachments;
|
|
892
|
+
log.add(message);
|
|
893
|
+
runRouterTurn(userTurnInstruction({
|
|
894
|
+
text,
|
|
895
|
+
interruptedDraft: interruptedDraft || undefined,
|
|
896
|
+
attachments: attachments.map((name) => path.join('.castle', 'agent', 'attachments', name)),
|
|
897
|
+
}));
|
|
898
|
+
}
|
|
899
|
+
function handleTaskAck(id, rejected) {
|
|
900
|
+
taskStore.acknowledge(id, rejected);
|
|
901
|
+
}
|
|
902
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
903
|
+
function attachClient(socket) {
|
|
904
|
+
clients.add(socket);
|
|
905
|
+
const hello = { type: 'hello', messages, tasks: taskStore.sorted(), settings, feeds: Object.fromEntries(taskFeeds.map) };
|
|
906
|
+
socket.send(JSON.stringify(hello));
|
|
907
|
+
socket.on('message', (rawData) => {
|
|
908
|
+
let msg;
|
|
909
|
+
try {
|
|
910
|
+
msg = JSON.parse(rawDataToString(rawData));
|
|
911
|
+
}
|
|
912
|
+
catch {
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
const hasText = typeof msg.text === 'string' && msg.text.trim() !== '';
|
|
916
|
+
const hasImages = Array.isArray(msg.images) && msg.images.length > 0;
|
|
917
|
+
if (msg.type === 'user-message' && (hasText || hasImages)) {
|
|
918
|
+
handleUserMessage(typeof msg.text === 'string' ? msg.text.trim() : '', msg.images);
|
|
919
|
+
}
|
|
920
|
+
else if (msg.type === 'task-ack' && typeof msg.id === 'string') {
|
|
921
|
+
handleTaskAck(msg.id, msg.rejected === true);
|
|
922
|
+
}
|
|
923
|
+
else if (msg.type === 'set-settings') {
|
|
924
|
+
applySettings(msg);
|
|
925
|
+
}
|
|
926
|
+
});
|
|
927
|
+
socket.on('close', () => {
|
|
928
|
+
clients.delete(socket);
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
function handleUpgrade(req, socket, head) {
|
|
932
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
933
|
+
if (url.pathname !== AGENT_WS_PATH)
|
|
934
|
+
return false;
|
|
935
|
+
wss.handleUpgrade(req, socket, head, (ws) => attachClient(ws));
|
|
936
|
+
return true;
|
|
937
|
+
}
|
|
938
|
+
const handleHttpRequest = makeAttachmentHandler(attachmentsDir);
|
|
939
|
+
function shutdown() {
|
|
940
|
+
taskStore.shutdown();
|
|
941
|
+
for (const child of [...taskChildren, ...routerChildren]) {
|
|
942
|
+
try {
|
|
943
|
+
child.kill('SIGKILL');
|
|
944
|
+
}
|
|
945
|
+
catch {
|
|
946
|
+
/* already gone */
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
wss.close();
|
|
950
|
+
}
|
|
951
|
+
return { handleUpgrade, handleHttpRequest, shutdown };
|
|
952
|
+
}
|