@nicmeriano/spool-server 0.0.1
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/bin.js +2184 -0
- package/dist/bin.js.map +1 -0
- package/dist/index.d.ts +426 -0
- package/dist/index.js +2195 -0
- package/dist/index.js.map +1 -0
- package/package.json +61 -0
package/dist/bin.js
ADDED
|
@@ -0,0 +1,2184 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
#!/usr/bin/env node
|
|
3
|
+
|
|
4
|
+
// src/orchestrator/index.ts
|
|
5
|
+
import { Hono as Hono7 } from "hono";
|
|
6
|
+
import { cors } from "hono/cors";
|
|
7
|
+
import { createAdaptorServer } from "@hono/node-server";
|
|
8
|
+
import { watch } from "chokidar";
|
|
9
|
+
import * as crypto from "crypto";
|
|
10
|
+
import * as fs2 from "fs";
|
|
11
|
+
import * as path2 from "path";
|
|
12
|
+
import { fileURLToPath } from "url";
|
|
13
|
+
import { createRequire } from "module";
|
|
14
|
+
|
|
15
|
+
// src/orchestrator/types.ts
|
|
16
|
+
var CLAUDE_MODEL = "claude-opus-4-5-20251101";
|
|
17
|
+
function createEmptyState(appId) {
|
|
18
|
+
return {
|
|
19
|
+
appId,
|
|
20
|
+
status: "idle",
|
|
21
|
+
currentTaskId: null,
|
|
22
|
+
pages: {}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function findAnnotationInState(state, annotationId) {
|
|
26
|
+
for (const [url, page] of Object.entries(state.pages)) {
|
|
27
|
+
const annotation = page.annotations.find((a) => a.id === annotationId);
|
|
28
|
+
if (annotation) {
|
|
29
|
+
return { annotation, pageUrl: url };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
function updateAnnotationInState(state, annotationId, updates) {
|
|
35
|
+
const newPages = {};
|
|
36
|
+
for (const [url, page] of Object.entries(state.pages)) {
|
|
37
|
+
const annotationIndex = page.annotations.findIndex((a) => a.id === annotationId);
|
|
38
|
+
if (annotationIndex !== -1) {
|
|
39
|
+
const newAnnotations = [...page.annotations];
|
|
40
|
+
newAnnotations[annotationIndex] = {
|
|
41
|
+
...newAnnotations[annotationIndex],
|
|
42
|
+
...updates
|
|
43
|
+
};
|
|
44
|
+
newPages[url] = {
|
|
45
|
+
...page,
|
|
46
|
+
annotations: newAnnotations,
|
|
47
|
+
lastModified: (/* @__PURE__ */ new Date()).toISOString()
|
|
48
|
+
};
|
|
49
|
+
} else {
|
|
50
|
+
newPages[url] = page;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
...state,
|
|
55
|
+
pages: newPages
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// src/orchestrator/routes/events.ts
|
|
60
|
+
import { Hono } from "hono";
|
|
61
|
+
import { streamSSE } from "hono/streaming";
|
|
62
|
+
import { execFile } from "child_process";
|
|
63
|
+
import { platform } from "os";
|
|
64
|
+
var sseClients = /* @__PURE__ */ new Map();
|
|
65
|
+
var clientIdCounter = 0;
|
|
66
|
+
function broadcast(message) {
|
|
67
|
+
const data = JSON.stringify(message);
|
|
68
|
+
for (const client of sseClients.values()) {
|
|
69
|
+
client.send(message.type, data);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function getClientCount() {
|
|
73
|
+
return sseClients.size;
|
|
74
|
+
}
|
|
75
|
+
function createEventsRoutes(ctx) {
|
|
76
|
+
const app = new Hono();
|
|
77
|
+
app.get("/events", (c) => {
|
|
78
|
+
return streamSSE(c, async (stream) => {
|
|
79
|
+
const clientId = `sse_${++clientIdCounter}`;
|
|
80
|
+
const client = {
|
|
81
|
+
id: clientId,
|
|
82
|
+
send: (event, data) => {
|
|
83
|
+
stream.writeSSE({ event, data }).catch(() => {
|
|
84
|
+
sseClients.delete(clientId);
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
close: () => {
|
|
88
|
+
sseClients.delete(clientId);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
sseClients.set(clientId, client);
|
|
92
|
+
console.error(`[spool] SSE client connected: ${clientId} (total: ${sseClients.size})`);
|
|
93
|
+
const state = ctx.getState();
|
|
94
|
+
const initMsg = {
|
|
95
|
+
type: "state:update",
|
|
96
|
+
taskId: state.currentTaskId,
|
|
97
|
+
state
|
|
98
|
+
};
|
|
99
|
+
await stream.writeSSE({ event: "state:update", data: JSON.stringify(initMsg) });
|
|
100
|
+
const keepalive = setInterval(() => {
|
|
101
|
+
stream.writeSSE({ event: "ping", data: "" }).catch(() => {
|
|
102
|
+
clearInterval(keepalive);
|
|
103
|
+
sseClients.delete(clientId);
|
|
104
|
+
});
|
|
105
|
+
}, 3e4);
|
|
106
|
+
stream.onAbort(() => {
|
|
107
|
+
clearInterval(keepalive);
|
|
108
|
+
sseClients.delete(clientId);
|
|
109
|
+
console.error(`[spool] SSE client disconnected: ${clientId} (total: ${sseClients.size})`);
|
|
110
|
+
});
|
|
111
|
+
await new Promise(() => {
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
app.post("/app-url", async (c) => {
|
|
116
|
+
const body = await c.req.json();
|
|
117
|
+
console.error(`[spool] Received app URL: ${body.url}`);
|
|
118
|
+
const host = c.req.header("host") || "localhost:3142";
|
|
119
|
+
const shellUrl = `http://${host}`;
|
|
120
|
+
console.error(`[spool] Opening ${shellUrl}`);
|
|
121
|
+
const cmd = platform() === "darwin" ? "open" : platform() === "win32" ? "start" : "xdg-open";
|
|
122
|
+
execFile(cmd, [shellUrl]);
|
|
123
|
+
let state = ctx.getState();
|
|
124
|
+
state = { ...state, appUrl: body.url };
|
|
125
|
+
ctx.setState(state);
|
|
126
|
+
ctx.saveState(state);
|
|
127
|
+
broadcast({ type: "app:url", url: body.url });
|
|
128
|
+
const stateUpdate = {
|
|
129
|
+
type: "state:update",
|
|
130
|
+
taskId: state.currentTaskId,
|
|
131
|
+
state
|
|
132
|
+
};
|
|
133
|
+
broadcast(stateUpdate);
|
|
134
|
+
return c.json({ ok: true });
|
|
135
|
+
});
|
|
136
|
+
return app;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// src/orchestrator/routes/annotations.ts
|
|
140
|
+
import { Hono as Hono2 } from "hono";
|
|
141
|
+
import { execFileSync } from "child_process";
|
|
142
|
+
function createAnnotationsRoutes(ctx) {
|
|
143
|
+
const app = new Hono2();
|
|
144
|
+
app.post("/", async (c) => {
|
|
145
|
+
const { url, annotation } = await c.req.json();
|
|
146
|
+
console.error(`[spool] annotation:create received for ${url}`);
|
|
147
|
+
const state = ctx.getState();
|
|
148
|
+
if (!state.pages[url]) {
|
|
149
|
+
state.pages[url] = {
|
|
150
|
+
url,
|
|
151
|
+
annotations: [],
|
|
152
|
+
lastModified: (/* @__PURE__ */ new Date()).toISOString()
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
state.pages[url].annotations.push(annotation);
|
|
156
|
+
state.pages[url].lastModified = (/* @__PURE__ */ new Date()).toISOString();
|
|
157
|
+
ctx.saveState(state);
|
|
158
|
+
ctx.setState(state);
|
|
159
|
+
const update = {
|
|
160
|
+
type: "state:update",
|
|
161
|
+
taskId: state.currentTaskId,
|
|
162
|
+
state
|
|
163
|
+
};
|
|
164
|
+
broadcast(update);
|
|
165
|
+
return c.json({ ok: true });
|
|
166
|
+
});
|
|
167
|
+
app.patch("/:id", async (c) => {
|
|
168
|
+
const id = c.req.param("id");
|
|
169
|
+
const changes = await c.req.json();
|
|
170
|
+
console.error(`[spool] annotation:update received for ${id}`);
|
|
171
|
+
const state = ctx.getState();
|
|
172
|
+
let found = false;
|
|
173
|
+
for (const page of Object.values(state.pages)) {
|
|
174
|
+
const index = page.annotations.findIndex((a) => a.id === id);
|
|
175
|
+
if (index !== -1) {
|
|
176
|
+
const current = page.annotations[index];
|
|
177
|
+
if (current.status === "in_progress" && changes.status) {
|
|
178
|
+
const { status: _status, doneAt: _doneAt, doneBy: _doneBy, ...safeChanges } = changes;
|
|
179
|
+
page.annotations[index] = { ...current, ...safeChanges };
|
|
180
|
+
} else {
|
|
181
|
+
page.annotations[index] = { ...current, ...changes };
|
|
182
|
+
}
|
|
183
|
+
page.lastModified = (/* @__PURE__ */ new Date()).toISOString();
|
|
184
|
+
found = true;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (!found) {
|
|
189
|
+
return c.json({ error: "Annotation not found" }, 404);
|
|
190
|
+
}
|
|
191
|
+
ctx.saveState(state);
|
|
192
|
+
ctx.setState(state);
|
|
193
|
+
const update = {
|
|
194
|
+
type: "state:update",
|
|
195
|
+
taskId: state.currentTaskId,
|
|
196
|
+
state
|
|
197
|
+
};
|
|
198
|
+
broadcast(update);
|
|
199
|
+
return c.json({ ok: true });
|
|
200
|
+
});
|
|
201
|
+
app.delete("/:id", async (c) => {
|
|
202
|
+
const id = c.req.param("id");
|
|
203
|
+
console.error(`[spool] annotation:delete received for ${id}`);
|
|
204
|
+
const state = ctx.getState();
|
|
205
|
+
let deleted = false;
|
|
206
|
+
for (const page of Object.values(state.pages)) {
|
|
207
|
+
const index = page.annotations.findIndex((a) => a.id === id);
|
|
208
|
+
if (index !== -1) {
|
|
209
|
+
page.annotations.splice(index, 1);
|
|
210
|
+
page.lastModified = (/* @__PURE__ */ new Date()).toISOString();
|
|
211
|
+
deleted = true;
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (!deleted) {
|
|
216
|
+
return c.json({ error: "Annotation not found" }, 404);
|
|
217
|
+
}
|
|
218
|
+
ctx.saveState(state);
|
|
219
|
+
ctx.setState(state);
|
|
220
|
+
const update = {
|
|
221
|
+
type: "state:update",
|
|
222
|
+
taskId: state.currentTaskId,
|
|
223
|
+
state
|
|
224
|
+
};
|
|
225
|
+
broadcast(update);
|
|
226
|
+
return c.json({ ok: true });
|
|
227
|
+
});
|
|
228
|
+
app.post("/:id/revert", async (c) => {
|
|
229
|
+
const id = c.req.param("id");
|
|
230
|
+
console.error(`[spool] annotation:revert for ${id}`);
|
|
231
|
+
let state = ctx.getState();
|
|
232
|
+
const found = findAnnotationInState(state, id);
|
|
233
|
+
if (!found || found.annotation.status !== "done" || !found.annotation.commitSha) {
|
|
234
|
+
return c.json({ success: false, error: "No revertible commit found" });
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
execFileSync("git", ["revert", "--no-edit", found.annotation.commitSha], {
|
|
238
|
+
cwd: ctx.projectDir,
|
|
239
|
+
encoding: "utf-8"
|
|
240
|
+
});
|
|
241
|
+
state = updateAnnotationInState(state, id, {
|
|
242
|
+
status: "open",
|
|
243
|
+
doneAt: void 0,
|
|
244
|
+
doneBy: void 0,
|
|
245
|
+
commitSha: void 0
|
|
246
|
+
});
|
|
247
|
+
ctx.saveState(state);
|
|
248
|
+
ctx.setState(state);
|
|
249
|
+
broadcast({ type: "state:update", taskId: null, state });
|
|
250
|
+
console.error(`[spool] Successfully reverted ${id}`);
|
|
251
|
+
return c.json({ success: true });
|
|
252
|
+
} catch (error) {
|
|
253
|
+
try {
|
|
254
|
+
execFileSync("git", ["revert", "--abort"], { cwd: ctx.projectDir });
|
|
255
|
+
} catch {
|
|
256
|
+
}
|
|
257
|
+
console.error(`[spool] Revert failed for ${id}:`, error);
|
|
258
|
+
return c.json({
|
|
259
|
+
success: false,
|
|
260
|
+
error: "Revert failed \u2014 code has likely changed since this annotation was implemented"
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
return app;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// src/orchestrator/routes/changes.ts
|
|
268
|
+
import { Hono as Hono3 } from "hono";
|
|
269
|
+
|
|
270
|
+
// src/orchestrator/pending-changes-processor.ts
|
|
271
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
272
|
+
|
|
273
|
+
// src/orchestrator/agent-options.ts
|
|
274
|
+
import * as fs from "fs";
|
|
275
|
+
import * as path from "path";
|
|
276
|
+
function isInsideProject(abs, boundary) {
|
|
277
|
+
return abs === boundary || abs.startsWith(boundary + path.sep);
|
|
278
|
+
}
|
|
279
|
+
function createPathEnforcer(projectDir2) {
|
|
280
|
+
const resolved = path.resolve(projectDir2);
|
|
281
|
+
return async (toolName, input, _options) => {
|
|
282
|
+
const filePath = input.file_path || input.path;
|
|
283
|
+
if (filePath) {
|
|
284
|
+
const abs = path.resolve(resolved, filePath);
|
|
285
|
+
if (!isInsideProject(abs, resolved)) {
|
|
286
|
+
return { behavior: "deny", message: `Cannot access files outside project directory: ${filePath}` };
|
|
287
|
+
}
|
|
288
|
+
if (["Read", "Edit", "Write"].includes(toolName)) {
|
|
289
|
+
try {
|
|
290
|
+
const realPath = fs.realpathSync(abs);
|
|
291
|
+
if (!isInsideProject(realPath, resolved)) {
|
|
292
|
+
return { behavior: "deny", message: `Cannot access files outside project directory (symlink): ${filePath}` };
|
|
293
|
+
}
|
|
294
|
+
} catch {
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (toolName === "Glob" || toolName === "Grep") {
|
|
299
|
+
const pattern = input.pattern;
|
|
300
|
+
if (pattern && pattern.includes("..")) {
|
|
301
|
+
return { behavior: "deny", message: "Patterns cannot traverse parent directories" };
|
|
302
|
+
}
|
|
303
|
+
if (!input.path) {
|
|
304
|
+
return { behavior: "allow", updatedInput: { ...input, path: resolved } };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (toolName === "Bash") {
|
|
308
|
+
const command = input.command;
|
|
309
|
+
if (command) {
|
|
310
|
+
const normalized = command.replace(/\s+/g, " ");
|
|
311
|
+
if (normalized.includes("..") && !normalized.startsWith("git ")) {
|
|
312
|
+
return { behavior: "deny", message: `Cannot run commands referencing paths outside project directory` };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return { behavior: "allow", updatedInput: input };
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// src/orchestrator/pending-changes-processor.ts
|
|
321
|
+
function buildSystemPrompt(cwd2) {
|
|
322
|
+
return `You are implementing UI changes from a pending changes queue. Users use a visual editing tool in their browser to specify changes, and your job is to implement them in the source code.
|
|
323
|
+
|
|
324
|
+
## Working Directory
|
|
325
|
+
|
|
326
|
+
Your working directory is: ${cwd2}
|
|
327
|
+
You can ONLY access files within this directory. Do not attempt to access files outside it.
|
|
328
|
+
|
|
329
|
+
## Tools
|
|
330
|
+
|
|
331
|
+
You have the following tools available:
|
|
332
|
+
- **Read**: Read file contents. Parameter: file_path (absolute path)
|
|
333
|
+
- **Edit**: Replace text in a file. Parameters: file_path, old_string, new_string. Read a file before editing it.
|
|
334
|
+
- **Write**: Create or overwrite a file. Parameters: file_path, content
|
|
335
|
+
- **Glob**: Find files by pattern. Parameters: pattern, path (optional directory)
|
|
336
|
+
- **Grep**: Search file contents. Parameters: pattern, path (optional directory)
|
|
337
|
+
|
|
338
|
+
Always use absolute paths based on your working directory.
|
|
339
|
+
|
|
340
|
+
## Change Types
|
|
341
|
+
|
|
342
|
+
There are three types of changes you'll handle:
|
|
343
|
+
|
|
344
|
+
### 1. Note Changes
|
|
345
|
+
User describes a change in natural language. Read the description and implement it.
|
|
346
|
+
Example: "Make this button blue" or "Add more padding to this card"
|
|
347
|
+
|
|
348
|
+
### 2. Style Changes
|
|
349
|
+
User modified CSS properties using a visual editor. Apply these style changes to the component.
|
|
350
|
+
The \`styles\` object contains the new values, and \`originalStyles\` contains what was there before.
|
|
351
|
+
You should update the component's styling to match the new values.
|
|
352
|
+
|
|
353
|
+
### 3. Variant Changes
|
|
354
|
+
User selected an AI-generated HTML variant. Replace the element's current HTML/JSX with the selected variant.
|
|
355
|
+
The \`selectedVariantIndex\` indicates which variant to use from the \`variants\` array.
|
|
356
|
+
|
|
357
|
+
## Workflow
|
|
358
|
+
|
|
359
|
+
For each change:
|
|
360
|
+
|
|
361
|
+
1. Read the \`element.componentFile\` to find the source file
|
|
362
|
+
2. Locate the element using \`selector\` and \`element.componentName\`
|
|
363
|
+
3. Implement the change based on its type
|
|
364
|
+
4. After completing each change, output: "CHANGE_COMPLETE: <change-id>"
|
|
365
|
+
|
|
366
|
+
## Key Fields to Use
|
|
367
|
+
|
|
368
|
+
- \`selector\`: CSS selector identifying the element
|
|
369
|
+
- \`element.componentName\`: React component name
|
|
370
|
+
- \`element.componentFile\`: Path to the source file
|
|
371
|
+
- For notes: \`content\` - the user's description
|
|
372
|
+
- For styles: \`styles\` - the CSS properties to apply
|
|
373
|
+
- For variants: \`variants[selectedVariantIndex].html\` - the new HTML
|
|
374
|
+
|
|
375
|
+
## Constraints
|
|
376
|
+
|
|
377
|
+
- Make minimal, focused changes
|
|
378
|
+
- Follow existing code patterns and conventions
|
|
379
|
+
- Use Tailwind classes if the project uses Tailwind
|
|
380
|
+
- For style changes, prefer inline styles or add to existing className if using CSS modules`;
|
|
381
|
+
}
|
|
382
|
+
function formatChange(change, index) {
|
|
383
|
+
const component = change.element.componentFile ? `${change.element.componentName || "Unknown"} (${change.element.componentFile})` : change.element.selector || "Unknown element";
|
|
384
|
+
let description = "";
|
|
385
|
+
switch (change.type) {
|
|
386
|
+
case "note": {
|
|
387
|
+
const noteChange = change;
|
|
388
|
+
description = `**Note**: "${noteChange.content}"`;
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
case "style": {
|
|
392
|
+
const styleChange = change;
|
|
393
|
+
const styleList = Object.entries(styleChange.styles).map(([key, value]) => `${key}: ${value}`).join(", ");
|
|
394
|
+
description = `**Style changes**: { ${styleList} }`;
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
case "variant": {
|
|
398
|
+
const variantChange = change;
|
|
399
|
+
const selectedVariant = variantChange.variants[variantChange.selectedVariantIndex];
|
|
400
|
+
description = `**Variant**: Replace with:
|
|
401
|
+
\`\`\`html
|
|
402
|
+
${selectedVariant?.html || "(no variant selected)"}
|
|
403
|
+
\`\`\``;
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return `${index + 1}. **${change.id}** - ${component}
|
|
408
|
+
Type: ${change.type}
|
|
409
|
+
Selector: \`${change.selector}\`
|
|
410
|
+
${description}`;
|
|
411
|
+
}
|
|
412
|
+
function buildTaskPrompt(changes) {
|
|
413
|
+
if (changes.length === 0) {
|
|
414
|
+
return "There are no pending changes to apply.";
|
|
415
|
+
}
|
|
416
|
+
const changesList = changes.map((c, i) => formatChange(c, i)).join("\n\n");
|
|
417
|
+
return `## Pending Changes to Apply
|
|
418
|
+
|
|
419
|
+
${changesList}
|
|
420
|
+
|
|
421
|
+
Please implement these changes. For each change:
|
|
422
|
+
1. Read the component file
|
|
423
|
+
2. Make the required modifications
|
|
424
|
+
3. Output "CHANGE_COMPLETE: <change-id>" when done
|
|
425
|
+
|
|
426
|
+
Start with the first change and work through them in order.`;
|
|
427
|
+
}
|
|
428
|
+
async function processChanges(options) {
|
|
429
|
+
const { changes, cwd: cwd2, callbacks, abortController } = options;
|
|
430
|
+
if (changes.length === 0) {
|
|
431
|
+
return {
|
|
432
|
+
success: true,
|
|
433
|
+
changesProcessed: 0,
|
|
434
|
+
errors: []
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
for (const change of changes) {
|
|
438
|
+
callbacks.onChangeProgress(change.id, "in_progress");
|
|
439
|
+
}
|
|
440
|
+
const taskPrompt = buildTaskPrompt(changes);
|
|
441
|
+
const systemPrompt = buildSystemPrompt(cwd2);
|
|
442
|
+
const errors = [];
|
|
443
|
+
let changesProcessed = 0;
|
|
444
|
+
const changeIds = new Set(changes.map((c) => c.id));
|
|
445
|
+
const completedIds = /* @__PURE__ */ new Set();
|
|
446
|
+
let textBuffer = "";
|
|
447
|
+
try {
|
|
448
|
+
const stream = query({
|
|
449
|
+
prompt: taskPrompt,
|
|
450
|
+
options: {
|
|
451
|
+
systemPrompt,
|
|
452
|
+
cwd: cwd2,
|
|
453
|
+
abortController,
|
|
454
|
+
tools: { type: "preset", preset: "claude_code" },
|
|
455
|
+
allowedTools: ["Read", "Edit", "Write", "Glob", "Grep"],
|
|
456
|
+
// Enforce project directory boundaries
|
|
457
|
+
canUseTool: createPathEnforcer(cwd2),
|
|
458
|
+
permissionMode: "acceptEdits",
|
|
459
|
+
includePartialMessages: true,
|
|
460
|
+
// Don't load external CLAUDE.md (would leak monorepo context into the isolated agent)
|
|
461
|
+
settingSources: [],
|
|
462
|
+
model: CLAUDE_MODEL
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
for await (const message of stream) {
|
|
466
|
+
if (abortController.signal.aborted) {
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
switch (message.type) {
|
|
470
|
+
case "assistant": {
|
|
471
|
+
const assistantMessage = message.message;
|
|
472
|
+
if (assistantMessage.content) {
|
|
473
|
+
for (const block of assistantMessage.content) {
|
|
474
|
+
if (block.type === "tool_use") {
|
|
475
|
+
callbacks.onToolCall(block.name, block.input);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
481
|
+
case "stream_event": {
|
|
482
|
+
const event = message.event;
|
|
483
|
+
if (event.type === "content_block_delta") {
|
|
484
|
+
const delta = event.delta;
|
|
485
|
+
if ("text" in delta) {
|
|
486
|
+
callbacks.onText(delta.text, true);
|
|
487
|
+
textBuffer += delta.text;
|
|
488
|
+
const regex = /CHANGE_COMPLETE:\s*(\S+)/g;
|
|
489
|
+
let match;
|
|
490
|
+
while ((match = regex.exec(textBuffer)) !== null) {
|
|
491
|
+
const changeId = match[1];
|
|
492
|
+
if (changeIds.has(changeId) && !completedIds.has(changeId)) {
|
|
493
|
+
completedIds.add(changeId);
|
|
494
|
+
changesProcessed++;
|
|
495
|
+
callbacks.onChangeProgress(changeId, "done");
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (textBuffer.length > 500) {
|
|
499
|
+
textBuffer = textBuffer.slice(-200);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
case "result": {
|
|
506
|
+
if (message.subtype === "success") {
|
|
507
|
+
for (const change of changes) {
|
|
508
|
+
if (!completedIds.has(change.id)) {
|
|
509
|
+
completedIds.add(change.id);
|
|
510
|
+
changesProcessed++;
|
|
511
|
+
callbacks.onChangeProgress(change.id, "done");
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
} else {
|
|
515
|
+
const resultErrors = "errors" in message ? message.errors : [];
|
|
516
|
+
errors.push(...resultErrors);
|
|
517
|
+
for (const change of changes) {
|
|
518
|
+
if (!completedIds.has(change.id)) {
|
|
519
|
+
callbacks.onChangeProgress(change.id, "error", resultErrors.join(", ") || "Unknown error");
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if (abortController.signal.aborted) {
|
|
528
|
+
for (const change of changes) {
|
|
529
|
+
if (!completedIds.has(change.id)) {
|
|
530
|
+
callbacks.onChangeProgress(change.id, "error", "Task cancelled");
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return {
|
|
534
|
+
success: false,
|
|
535
|
+
changesProcessed,
|
|
536
|
+
errors: ["Task cancelled"]
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
return {
|
|
540
|
+
success: errors.length === 0,
|
|
541
|
+
changesProcessed,
|
|
542
|
+
errors
|
|
543
|
+
};
|
|
544
|
+
} catch (error) {
|
|
545
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
546
|
+
errors.push(errorMessage);
|
|
547
|
+
for (const change of changes) {
|
|
548
|
+
if (!completedIds.has(change.id)) {
|
|
549
|
+
callbacks.onChangeProgress(change.id, "error", errorMessage);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return {
|
|
553
|
+
success: false,
|
|
554
|
+
changesProcessed,
|
|
555
|
+
errors
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// src/orchestrator/routes/changes.ts
|
|
561
|
+
function createChangesRoutes(ctx) {
|
|
562
|
+
const app = new Hono3();
|
|
563
|
+
app.post("/", async (c) => {
|
|
564
|
+
const { url, change } = await c.req.json();
|
|
565
|
+
console.error(`[spool] change:add for ${url}`);
|
|
566
|
+
const state = ctx.getState();
|
|
567
|
+
if (!state.pages[url]) {
|
|
568
|
+
state.pages[url] = {
|
|
569
|
+
url,
|
|
570
|
+
annotations: [],
|
|
571
|
+
pendingChanges: [],
|
|
572
|
+
lastModified: (/* @__PURE__ */ new Date()).toISOString()
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
if (!state.pages[url].pendingChanges) {
|
|
576
|
+
state.pages[url].pendingChanges = [];
|
|
577
|
+
}
|
|
578
|
+
state.pages[url].pendingChanges.push(change);
|
|
579
|
+
state.pages[url].lastModified = (/* @__PURE__ */ new Date()).toISOString();
|
|
580
|
+
ctx.saveState(state);
|
|
581
|
+
ctx.setState(state);
|
|
582
|
+
const update = {
|
|
583
|
+
type: "state:update",
|
|
584
|
+
taskId: state.currentTaskId,
|
|
585
|
+
state
|
|
586
|
+
};
|
|
587
|
+
broadcast(update);
|
|
588
|
+
return c.json({ ok: true });
|
|
589
|
+
});
|
|
590
|
+
app.delete("/:id", async (c) => {
|
|
591
|
+
const changeId = c.req.param("id");
|
|
592
|
+
console.error(`[spool] change:remove for ${changeId}`);
|
|
593
|
+
const state = ctx.getState();
|
|
594
|
+
let removed = false;
|
|
595
|
+
for (const page of Object.values(state.pages)) {
|
|
596
|
+
if (page.pendingChanges) {
|
|
597
|
+
const index = page.pendingChanges.findIndex((ch) => ch.id === changeId);
|
|
598
|
+
if (index !== -1) {
|
|
599
|
+
page.pendingChanges.splice(index, 1);
|
|
600
|
+
page.lastModified = (/* @__PURE__ */ new Date()).toISOString();
|
|
601
|
+
removed = true;
|
|
602
|
+
break;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (!removed) {
|
|
607
|
+
return c.json({ error: "Change not found" }, 404);
|
|
608
|
+
}
|
|
609
|
+
ctx.saveState(state);
|
|
610
|
+
ctx.setState(state);
|
|
611
|
+
const update = {
|
|
612
|
+
type: "state:update",
|
|
613
|
+
taskId: state.currentTaskId,
|
|
614
|
+
state
|
|
615
|
+
};
|
|
616
|
+
broadcast(update);
|
|
617
|
+
return c.json({ ok: true });
|
|
618
|
+
});
|
|
619
|
+
app.post("/apply-all", async (c) => {
|
|
620
|
+
const { url } = await c.req.json();
|
|
621
|
+
console.error(`[spool] change:apply_all for ${url}`);
|
|
622
|
+
const state = ctx.getState();
|
|
623
|
+
const page = state.pages[url];
|
|
624
|
+
if (!page || !page.pendingChanges || page.pendingChanges.length === 0) {
|
|
625
|
+
return c.json({ accepted: true, message: "No pending changes to apply" });
|
|
626
|
+
}
|
|
627
|
+
const pendingChanges = page.pendingChanges.filter((ch) => ch.status === "pending");
|
|
628
|
+
if (pendingChanges.length === 0) {
|
|
629
|
+
return c.json({ accepted: true, message: "No pending changes to apply" });
|
|
630
|
+
}
|
|
631
|
+
const applyAbortController = new AbortController();
|
|
632
|
+
(async () => {
|
|
633
|
+
for (const change of pendingChanges) {
|
|
634
|
+
const idx = page.pendingChanges.findIndex((ch) => ch.id === change.id);
|
|
635
|
+
if (idx !== -1) {
|
|
636
|
+
page.pendingChanges[idx] = { ...change, status: "in_progress" };
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
ctx.saveState(state);
|
|
640
|
+
const result = await processChanges({
|
|
641
|
+
changes: pendingChanges,
|
|
642
|
+
cwd: ctx.projectDir,
|
|
643
|
+
abortController: applyAbortController,
|
|
644
|
+
callbacks: {
|
|
645
|
+
onText: (content, isPartial) => {
|
|
646
|
+
const textMsg = {
|
|
647
|
+
type: "stream:text",
|
|
648
|
+
taskId: "apply_all",
|
|
649
|
+
content,
|
|
650
|
+
isPartial
|
|
651
|
+
};
|
|
652
|
+
broadcast(textMsg);
|
|
653
|
+
},
|
|
654
|
+
onToolCall: (toolName, toolInput) => {
|
|
655
|
+
const toolMsg = {
|
|
656
|
+
type: "stream:tool_call",
|
|
657
|
+
taskId: "apply_all",
|
|
658
|
+
toolName,
|
|
659
|
+
toolInput
|
|
660
|
+
};
|
|
661
|
+
broadcast(toolMsg);
|
|
662
|
+
},
|
|
663
|
+
onChangeProgress: (changeId, status, error) => {
|
|
664
|
+
const idx = page.pendingChanges.findIndex((ch) => ch.id === changeId);
|
|
665
|
+
if (idx !== -1) {
|
|
666
|
+
page.pendingChanges[idx] = {
|
|
667
|
+
...page.pendingChanges[idx],
|
|
668
|
+
status,
|
|
669
|
+
errorMessage: error
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
ctx.saveState(state);
|
|
673
|
+
const progressMsg = {
|
|
674
|
+
type: "apply:progress",
|
|
675
|
+
changeId,
|
|
676
|
+
status,
|
|
677
|
+
error
|
|
678
|
+
};
|
|
679
|
+
broadcast(progressMsg);
|
|
680
|
+
const stateUpdate = {
|
|
681
|
+
type: "state:update",
|
|
682
|
+
taskId: state.currentTaskId,
|
|
683
|
+
state
|
|
684
|
+
};
|
|
685
|
+
broadcast(stateUpdate);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
const complete = {
|
|
690
|
+
type: "apply:complete",
|
|
691
|
+
success: result.success,
|
|
692
|
+
message: result.errors.length > 0 ? `Applied ${result.changesProcessed} changes, errors: ${result.errors.join(", ")}` : `Applied ${result.changesProcessed} changes`
|
|
693
|
+
};
|
|
694
|
+
broadcast(complete);
|
|
695
|
+
const finalUpdate = {
|
|
696
|
+
type: "state:update",
|
|
697
|
+
taskId: state.currentTaskId,
|
|
698
|
+
state
|
|
699
|
+
};
|
|
700
|
+
broadcast(finalUpdate);
|
|
701
|
+
})();
|
|
702
|
+
return c.json({ accepted: true });
|
|
703
|
+
});
|
|
704
|
+
app.post("/:id/apply", async (c) => {
|
|
705
|
+
const changeId = c.req.param("id");
|
|
706
|
+
const { url } = await c.req.json();
|
|
707
|
+
console.error(`[spool] change:apply for ${changeId} at ${url}`);
|
|
708
|
+
const state = ctx.getState();
|
|
709
|
+
const page = state.pages[url];
|
|
710
|
+
if (!page || !page.pendingChanges) {
|
|
711
|
+
return c.json({ accepted: false, error: "Page not found" }, 404);
|
|
712
|
+
}
|
|
713
|
+
const change = page.pendingChanges.find((ch) => ch.id === changeId);
|
|
714
|
+
if (!change) {
|
|
715
|
+
return c.json({ accepted: false, error: "Change not found" }, 404);
|
|
716
|
+
}
|
|
717
|
+
if (change.status !== "pending") {
|
|
718
|
+
return c.json({ accepted: false, error: "Change is not pending" }, 400);
|
|
719
|
+
}
|
|
720
|
+
const singleAbortController = new AbortController();
|
|
721
|
+
(async () => {
|
|
722
|
+
const idx = page.pendingChanges.findIndex((ch) => ch.id === changeId);
|
|
723
|
+
if (idx !== -1) {
|
|
724
|
+
page.pendingChanges[idx] = { ...change, status: "in_progress" };
|
|
725
|
+
}
|
|
726
|
+
ctx.saveState(state);
|
|
727
|
+
const result = await processChanges({
|
|
728
|
+
changes: [change],
|
|
729
|
+
cwd: ctx.projectDir,
|
|
730
|
+
abortController: singleAbortController,
|
|
731
|
+
callbacks: {
|
|
732
|
+
onText: (content, isPartial) => {
|
|
733
|
+
const textMsg = {
|
|
734
|
+
type: "stream:text",
|
|
735
|
+
taskId: `apply_${changeId}`,
|
|
736
|
+
content,
|
|
737
|
+
isPartial
|
|
738
|
+
};
|
|
739
|
+
broadcast(textMsg);
|
|
740
|
+
},
|
|
741
|
+
onToolCall: (toolName, toolInput) => {
|
|
742
|
+
const toolMsg = {
|
|
743
|
+
type: "stream:tool_call",
|
|
744
|
+
taskId: `apply_${changeId}`,
|
|
745
|
+
toolName,
|
|
746
|
+
toolInput
|
|
747
|
+
};
|
|
748
|
+
broadcast(toolMsg);
|
|
749
|
+
},
|
|
750
|
+
onChangeProgress: (id, status, error) => {
|
|
751
|
+
const i = page.pendingChanges.findIndex((ch) => ch.id === id);
|
|
752
|
+
if (i !== -1) {
|
|
753
|
+
page.pendingChanges[i] = {
|
|
754
|
+
...page.pendingChanges[i],
|
|
755
|
+
status,
|
|
756
|
+
errorMessage: error
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
ctx.saveState(state);
|
|
760
|
+
broadcast({ type: "apply:progress", changeId: id, status, error });
|
|
761
|
+
broadcast({ type: "state:update", taskId: state.currentTaskId, state });
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
broadcast({
|
|
766
|
+
type: "apply:complete",
|
|
767
|
+
success: result.success,
|
|
768
|
+
message: result.errors.length > 0 ? `Error: ${result.errors.join(", ")}` : "Change applied successfully"
|
|
769
|
+
});
|
|
770
|
+
broadcast({ type: "state:update", taskId: state.currentTaskId, state });
|
|
771
|
+
})();
|
|
772
|
+
return c.json({ accepted: true });
|
|
773
|
+
});
|
|
774
|
+
app.post("/apply-direct", async (c) => {
|
|
775
|
+
const { change } = await c.req.json();
|
|
776
|
+
console.error(`[spool] change:apply_direct for ${change.selector}`);
|
|
777
|
+
const directAbortController = new AbortController();
|
|
778
|
+
broadcast({ type: "apply:progress", changeId: change.id, status: "in_progress" });
|
|
779
|
+
(async () => {
|
|
780
|
+
const result = await processChanges({
|
|
781
|
+
changes: [{ ...change, status: "in_progress" }],
|
|
782
|
+
cwd: ctx.projectDir,
|
|
783
|
+
abortController: directAbortController,
|
|
784
|
+
callbacks: {
|
|
785
|
+
onText: (content, isPartial) => {
|
|
786
|
+
broadcast({
|
|
787
|
+
type: "stream:text",
|
|
788
|
+
taskId: `apply_direct_${change.id}`,
|
|
789
|
+
content,
|
|
790
|
+
isPartial
|
|
791
|
+
});
|
|
792
|
+
},
|
|
793
|
+
onToolCall: (toolName, toolInput) => {
|
|
794
|
+
broadcast({
|
|
795
|
+
type: "stream:tool_call",
|
|
796
|
+
taskId: `apply_direct_${change.id}`,
|
|
797
|
+
toolName,
|
|
798
|
+
toolInput
|
|
799
|
+
});
|
|
800
|
+
},
|
|
801
|
+
onChangeProgress: (id, status, error) => {
|
|
802
|
+
broadcast({ type: "apply:progress", changeId: id, status, error });
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
broadcast({
|
|
807
|
+
type: "apply:complete",
|
|
808
|
+
success: result.success,
|
|
809
|
+
message: result.errors.length > 0 ? `Error: ${result.errors.join(", ")}` : "Change applied successfully"
|
|
810
|
+
});
|
|
811
|
+
})();
|
|
812
|
+
return c.json({ accepted: true });
|
|
813
|
+
});
|
|
814
|
+
return app;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// src/orchestrator/routes/tasks.ts
|
|
818
|
+
import { Hono as Hono4 } from "hono";
|
|
819
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
820
|
+
|
|
821
|
+
// src/orchestrator/agent-runner.ts
|
|
822
|
+
import { query as query2 } from "@anthropic-ai/claude-agent-sdk";
|
|
823
|
+
|
|
824
|
+
// src/orchestrator/system-prompt.ts
|
|
825
|
+
var ANNOTATION_INSTRUCTIONS = `You are implementing UI feedback from annotations. Users mark up UI elements in their browser and describe changes they want. Your job is to implement those changes.
|
|
826
|
+
|
|
827
|
+
## Your Role
|
|
828
|
+
|
|
829
|
+
Read annotations from .spool/state.json and implement the requested changes to the codebase.
|
|
830
|
+
|
|
831
|
+
## Workflow
|
|
832
|
+
|
|
833
|
+
For each annotation you implement:
|
|
834
|
+
|
|
835
|
+
1. **Mark as in_progress**: Before starting work, update the annotation's status to "in_progress" in .spool/state.json. This shows a spinner in the user's browser.
|
|
836
|
+
|
|
837
|
+
2. **Understand the request**: Read the annotation's:
|
|
838
|
+
- \`element.componentFile\` - The React component file to modify
|
|
839
|
+
- \`element.componentName\` - The component name
|
|
840
|
+
- \`element.selector\` - CSS selector identifying the element
|
|
841
|
+
- \`comments[].content\` - User's description of the desired change
|
|
842
|
+
- \`selectors[]\` - (optional) Additional CSS selectors for multi-element annotations
|
|
843
|
+
- \`elements[]\` - (optional) Additional element metadata for multi-element annotations
|
|
844
|
+
|
|
845
|
+
**Multi-element annotations**: When \`selectors\` and \`elements\` arrays are present, the user selected multiple elements together. Apply the requested change to ALL elements (the primary \`selector\`/\`element\` AND each item in \`selectors\`/\`elements\`).
|
|
846
|
+
|
|
847
|
+
3. **Implement the change**: Make the minimal code changes needed. Follow existing patterns in the codebase.
|
|
848
|
+
|
|
849
|
+
4. **Mark as done**: After successfully implementing, update the annotation's status to "done" in .spool/state.json. Set:
|
|
850
|
+
- \`status: "done"\`
|
|
851
|
+
- \`doneAt: "<current ISO timestamp>"\`
|
|
852
|
+
- \`doneBy: { id: "claude", name: "Claude" }\`
|
|
853
|
+
|
|
854
|
+
## Updating state.json
|
|
855
|
+
|
|
856
|
+
The state file is at \`.spool/state.json\`. Use the Edit tool to update annotation statuses. The structure is:
|
|
857
|
+
|
|
858
|
+
\`\`\`json
|
|
859
|
+
{
|
|
860
|
+
"pages": {
|
|
861
|
+
"<url>": {
|
|
862
|
+
"annotations": [
|
|
863
|
+
{
|
|
864
|
+
"id": "ann_xxx",
|
|
865
|
+
"status": "open", // Change to "in_progress" then "done"
|
|
866
|
+
"doneAt": null, // Set to ISO timestamp when done
|
|
867
|
+
"doneBy": null, // Set to { "id": "claude", "name": "Claude" }
|
|
868
|
+
...
|
|
869
|
+
}
|
|
870
|
+
]
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
\`\`\`
|
|
875
|
+
|
|
876
|
+
To mark as in_progress, edit:
|
|
877
|
+
\`\`\`
|
|
878
|
+
"status": "open"
|
|
879
|
+
\`\`\`
|
|
880
|
+
to:
|
|
881
|
+
\`\`\`
|
|
882
|
+
"status": "in_progress"
|
|
883
|
+
\`\`\`
|
|
884
|
+
|
|
885
|
+
To mark as done, edit:
|
|
886
|
+
\`\`\`
|
|
887
|
+
"status": "in_progress",
|
|
888
|
+
"doneAt": null,
|
|
889
|
+
"doneBy": null
|
|
890
|
+
\`\`\`
|
|
891
|
+
to:
|
|
892
|
+
\`\`\`
|
|
893
|
+
"status": "done",
|
|
894
|
+
"doneAt": "<timestamp>",
|
|
895
|
+
"doneBy": { "id": "claude", "name": "Claude" },
|
|
896
|
+
"commitSha": "<sha from git rev-parse HEAD>"
|
|
897
|
+
\`\`\`
|
|
898
|
+
|
|
899
|
+
## Git Commits
|
|
900
|
+
|
|
901
|
+
After implementing each annotation's code changes (before marking as done):
|
|
902
|
+
|
|
903
|
+
1. Stage only the files you modified for this annotation:
|
|
904
|
+
\`git add <file1> <file2> ...\`
|
|
905
|
+
Do NOT use \`git add -A\` or \`git add .\` \u2014 only add files you explicitly changed for this annotation.
|
|
906
|
+
|
|
907
|
+
2. Commit with the annotation ID in the message:
|
|
908
|
+
\`git commit -m "[spool] <annotation-id>: <brief description of change>"\`
|
|
909
|
+
|
|
910
|
+
3. Get the commit SHA:
|
|
911
|
+
Run \`git rev-parse HEAD\` and note the output.
|
|
912
|
+
|
|
913
|
+
4. When marking done in state.json, include the commitSha field:
|
|
914
|
+
\`"status": "done"\`,
|
|
915
|
+
\`"doneAt": "<timestamp>"\`,
|
|
916
|
+
\`"doneBy": { "id": "claude", "name": "Claude" }\`,
|
|
917
|
+
\`"commitSha": "<sha from step 3>"\`
|
|
918
|
+
|
|
919
|
+
## Answering Questions
|
|
920
|
+
|
|
921
|
+
If an annotation's comment is a question (asking for information, clarification, or guidance rather than requesting a code change), add a reply comment to the annotation's comments array with your answer. The reply should have \`author: { "id": "claude", "name": "Claude" }\`. Then mark the annotation as done.
|
|
922
|
+
|
|
923
|
+
## Constraints
|
|
924
|
+
|
|
925
|
+
- Always update state.json status fields before and after implementing
|
|
926
|
+
- Make minimal, focused changes - don't refactor unrelated code
|
|
927
|
+
- Follow existing code patterns and conventions
|
|
928
|
+
- If an annotation is unclear, implement your best interpretation
|
|
929
|
+
- Process annotations one at a time, completing each before starting the next`;
|
|
930
|
+
function buildAgentSystemPrompt(cwd2) {
|
|
931
|
+
return `${ANNOTATION_INSTRUCTIONS}
|
|
932
|
+
|
|
933
|
+
## Working Directory
|
|
934
|
+
|
|
935
|
+
Your working directory is: ${cwd2}
|
|
936
|
+
You can ONLY access files within this directory. Do not attempt to access files outside it.
|
|
937
|
+
|
|
938
|
+
## Tools
|
|
939
|
+
|
|
940
|
+
You have the following tools available:
|
|
941
|
+
- **Read**: Read file contents. Parameter: file_path (absolute path)
|
|
942
|
+
- **Edit**: Replace text in a file. Parameters: file_path, old_string, new_string. Read a file before editing it.
|
|
943
|
+
- **Write**: Create or overwrite a file. Parameters: file_path, content
|
|
944
|
+
- **Glob**: Find files by pattern. Parameters: pattern, path (optional directory)
|
|
945
|
+
- **Grep**: Search file contents. Parameters: pattern, path (optional directory)
|
|
946
|
+
- **Bash**: Run shell commands. Only git commands are allowed (git add, git commit, git rev-parse).
|
|
947
|
+
|
|
948
|
+
Always use absolute paths based on your working directory.`;
|
|
949
|
+
}
|
|
950
|
+
function buildTaskPrompt2(annotations) {
|
|
951
|
+
if (annotations.length === 0) {
|
|
952
|
+
return "There are no open annotations to address.";
|
|
953
|
+
}
|
|
954
|
+
const annotationList = annotations.map((a, i) => {
|
|
955
|
+
const comment = a.comments[0]?.content || "(no comment)";
|
|
956
|
+
const component = a.element?.componentFile ? `${a.element.componentName || "Unknown"} (${a.element.componentFile})` : a.element?.selector || "Unknown element";
|
|
957
|
+
let entry = `${i + 1}. **${a.id}** - ${component}
|
|
958
|
+
Request: "${comment}"`;
|
|
959
|
+
if (a.elements && a.elements.length > 0) {
|
|
960
|
+
const additionalList = a.elements.map((el) => {
|
|
961
|
+
if (el.componentFile) {
|
|
962
|
+
return ` - ${el.componentName || "Unknown"} (${el.componentFile})`;
|
|
963
|
+
}
|
|
964
|
+
return ` - ${el.selector || "Unknown element"}`;
|
|
965
|
+
}).join("\n");
|
|
966
|
+
entry += `
|
|
967
|
+
Additional elements:
|
|
968
|
+
${additionalList}`;
|
|
969
|
+
} else if (a.selectors && a.selectors.length > 0) {
|
|
970
|
+
entry += `
|
|
971
|
+
Additional selectors: ${a.selectors.join(", ")}`;
|
|
972
|
+
}
|
|
973
|
+
return entry;
|
|
974
|
+
}).join("\n\n");
|
|
975
|
+
return `## Annotations to Address
|
|
976
|
+
|
|
977
|
+
${annotationList}
|
|
978
|
+
|
|
979
|
+
Please implement these changes. For each annotation:
|
|
980
|
+
1. Mark it as in_progress in .spool/state.json
|
|
981
|
+
2. Make the code changes
|
|
982
|
+
3. Mark it as done in .spool/state.json
|
|
983
|
+
|
|
984
|
+
Start with the first annotation.`;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// src/orchestrator/agent-runner.ts
|
|
988
|
+
function getOpenAnnotations(state, annotationIds) {
|
|
989
|
+
const open = [];
|
|
990
|
+
for (const page of Object.values(state.pages)) {
|
|
991
|
+
for (const annotation of page.annotations) {
|
|
992
|
+
if (annotation.status !== "open") continue;
|
|
993
|
+
if (annotationIds && annotationIds.length > 0) {
|
|
994
|
+
if (annotationIds.includes(annotation.id)) {
|
|
995
|
+
open.push(annotation);
|
|
996
|
+
}
|
|
997
|
+
} else {
|
|
998
|
+
open.push(annotation);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
return open;
|
|
1003
|
+
}
|
|
1004
|
+
function toSummaries(annotations) {
|
|
1005
|
+
return annotations.map((a) => ({
|
|
1006
|
+
id: a.id,
|
|
1007
|
+
selector: a.selector,
|
|
1008
|
+
selectors: a.selectors,
|
|
1009
|
+
comments: a.comments.map((c) => ({ content: c.content })),
|
|
1010
|
+
element: a.element ? {
|
|
1011
|
+
selector: a.element.selector,
|
|
1012
|
+
componentName: a.element.componentName,
|
|
1013
|
+
componentFile: a.element.componentFile
|
|
1014
|
+
} : void 0,
|
|
1015
|
+
elements: a.elements?.map((el) => ({
|
|
1016
|
+
selector: el.selector,
|
|
1017
|
+
componentName: el.componentName,
|
|
1018
|
+
componentFile: el.componentFile
|
|
1019
|
+
}))
|
|
1020
|
+
}));
|
|
1021
|
+
}
|
|
1022
|
+
async function runAgent(options) {
|
|
1023
|
+
const { taskId, payload, state, cwd: cwd2, callbacks, abortController } = options;
|
|
1024
|
+
const openAnnotations = getOpenAnnotations(state, payload.annotationIds);
|
|
1025
|
+
if (openAnnotations.length === 0) {
|
|
1026
|
+
return {
|
|
1027
|
+
success: true,
|
|
1028
|
+
annotationsAddressed: 0,
|
|
1029
|
+
message: "No open annotations to address"
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
const summaries = toSummaries(openAnnotations);
|
|
1033
|
+
const taskPrompt = buildTaskPrompt2(summaries);
|
|
1034
|
+
const fullPrompt = payload.feedback ? `${taskPrompt}
|
|
1035
|
+
|
|
1036
|
+
## Additional Context from User
|
|
1037
|
+
|
|
1038
|
+
${payload.feedback}` : taskPrompt;
|
|
1039
|
+
let annotationsAddressed = 0;
|
|
1040
|
+
let hasEmittedText = false;
|
|
1041
|
+
try {
|
|
1042
|
+
const stream = query2({
|
|
1043
|
+
prompt: fullPrompt,
|
|
1044
|
+
options: {
|
|
1045
|
+
systemPrompt: buildAgentSystemPrompt(cwd2),
|
|
1046
|
+
cwd: cwd2,
|
|
1047
|
+
abortController,
|
|
1048
|
+
// Use Claude Code's tools for file operations
|
|
1049
|
+
tools: { type: "preset", preset: "claude_code" },
|
|
1050
|
+
// Enable Skills and git commands for per-annotation commits
|
|
1051
|
+
allowedTools: ["Skill", "Bash(git add*)", "Bash(git commit*)", "Bash(git rev-parse*)"],
|
|
1052
|
+
// Block web access tools
|
|
1053
|
+
disallowedTools: ["WebFetch", "WebSearch"],
|
|
1054
|
+
// Enforce project directory boundaries
|
|
1055
|
+
canUseTool: createPathEnforcer(cwd2),
|
|
1056
|
+
// Auto-accept edits since user initiated this
|
|
1057
|
+
permissionMode: "acceptEdits",
|
|
1058
|
+
// Include partial messages for streaming
|
|
1059
|
+
includePartialMessages: true,
|
|
1060
|
+
// Don't load external CLAUDE.md (would leak monorepo context into the isolated agent)
|
|
1061
|
+
settingSources: [],
|
|
1062
|
+
model: CLAUDE_MODEL
|
|
1063
|
+
}
|
|
1064
|
+
});
|
|
1065
|
+
for await (const message of stream) {
|
|
1066
|
+
if (abortController.signal.aborted) {
|
|
1067
|
+
break;
|
|
1068
|
+
}
|
|
1069
|
+
switch (message.type) {
|
|
1070
|
+
case "assistant": {
|
|
1071
|
+
const assistantMessage = message.message;
|
|
1072
|
+
if (assistantMessage.content) {
|
|
1073
|
+
for (const block of assistantMessage.content) {
|
|
1074
|
+
if (block.type === "tool_use") {
|
|
1075
|
+
callbacks.onToolCall(block.name, block.input);
|
|
1076
|
+
if (block.name === "Edit" && typeof block.input === "object" && block.input !== null) {
|
|
1077
|
+
const input = block.input;
|
|
1078
|
+
if (input.file_path?.includes("state.json") && input.new_string?.includes('"status": "done"')) {
|
|
1079
|
+
annotationsAddressed++;
|
|
1080
|
+
callbacks.onStateChange();
|
|
1081
|
+
} else if (input.file_path?.includes("state.json") && input.new_string?.includes('"status": "in_progress"')) {
|
|
1082
|
+
callbacks.onStateChange();
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
break;
|
|
1089
|
+
}
|
|
1090
|
+
case "stream_event": {
|
|
1091
|
+
const event = message.event;
|
|
1092
|
+
if (event.type === "content_block_start") {
|
|
1093
|
+
const contentBlock = event.content_block;
|
|
1094
|
+
if (contentBlock?.type === "text" && hasEmittedText) {
|
|
1095
|
+
callbacks.onText("\n\n", true);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
if (event.type === "content_block_delta") {
|
|
1099
|
+
const delta = event.delta;
|
|
1100
|
+
if ("text" in delta) {
|
|
1101
|
+
callbacks.onText(delta.text, true);
|
|
1102
|
+
hasEmittedText = true;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
break;
|
|
1106
|
+
}
|
|
1107
|
+
case "result": {
|
|
1108
|
+
if (message.subtype === "success") {
|
|
1109
|
+
return {
|
|
1110
|
+
success: true,
|
|
1111
|
+
annotationsAddressed,
|
|
1112
|
+
message: message.result
|
|
1113
|
+
};
|
|
1114
|
+
} else {
|
|
1115
|
+
const errors = "errors" in message ? message.errors : [];
|
|
1116
|
+
return {
|
|
1117
|
+
success: false,
|
|
1118
|
+
annotationsAddressed,
|
|
1119
|
+
message: errors.join(", ") || "Agent execution failed"
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
return {
|
|
1126
|
+
success: abortController.signal.aborted ? false : true,
|
|
1127
|
+
annotationsAddressed,
|
|
1128
|
+
message: abortController.signal.aborted ? "Task cancelled" : "Completed"
|
|
1129
|
+
};
|
|
1130
|
+
} catch (error) {
|
|
1131
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1132
|
+
return {
|
|
1133
|
+
success: false,
|
|
1134
|
+
annotationsAddressed,
|
|
1135
|
+
message: `Agent error: ${errorMessage}`
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// src/orchestrator/routes/tasks.ts
|
|
1141
|
+
function createTasksRoutes(ctx) {
|
|
1142
|
+
const app = new Hono4();
|
|
1143
|
+
app.post("/", async (c) => {
|
|
1144
|
+
const { id: taskId, payload } = await c.req.json();
|
|
1145
|
+
console.error(`[spool] task:submit received`);
|
|
1146
|
+
console.error(` Task ID: ${taskId}`);
|
|
1147
|
+
console.error(` Action: ${payload.action}`);
|
|
1148
|
+
console.error(` Annotation IDs: ${payload.annotationIds?.join(", ") || "all ready"}`);
|
|
1149
|
+
let state = ctx.getState();
|
|
1150
|
+
if (state.status === "generating") {
|
|
1151
|
+
return c.json(
|
|
1152
|
+
{ error: { code: "ALREADY_GENERATING", message: "A task is already in progress" } },
|
|
1153
|
+
409
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
state = { ...state, status: "generating", currentTaskId: taskId };
|
|
1157
|
+
ctx.saveState(state);
|
|
1158
|
+
ctx.setState(state);
|
|
1159
|
+
broadcast({ type: "task:ack", taskId, status: "started" });
|
|
1160
|
+
const abortController = new AbortController();
|
|
1161
|
+
ctx.setTaskAbortController(abortController);
|
|
1162
|
+
let originalBranch = null;
|
|
1163
|
+
const branchName = `spool/${taskId}`;
|
|
1164
|
+
try {
|
|
1165
|
+
originalBranch = execFileSync2("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
1166
|
+
cwd: ctx.projectDir,
|
|
1167
|
+
encoding: "utf-8"
|
|
1168
|
+
}).trim();
|
|
1169
|
+
execFileSync2("git", ["checkout", "-b", branchName], { cwd: ctx.projectDir, encoding: "utf-8" });
|
|
1170
|
+
console.error(`[spool] Created branch ${branchName} from ${originalBranch}`);
|
|
1171
|
+
} catch (e) {
|
|
1172
|
+
console.error(`[spool] Could not create branch: ${e}`);
|
|
1173
|
+
originalBranch = null;
|
|
1174
|
+
}
|
|
1175
|
+
runAgent({
|
|
1176
|
+
taskId,
|
|
1177
|
+
payload,
|
|
1178
|
+
state,
|
|
1179
|
+
cwd: ctx.projectDir,
|
|
1180
|
+
abortController,
|
|
1181
|
+
callbacks: {
|
|
1182
|
+
onText: (content, isPartial) => {
|
|
1183
|
+
broadcast({ type: "stream:text", taskId, content, isPartial });
|
|
1184
|
+
},
|
|
1185
|
+
onToolCall: (toolName, toolInput) => {
|
|
1186
|
+
broadcast({ type: "stream:tool_call", taskId, toolName, toolInput });
|
|
1187
|
+
},
|
|
1188
|
+
onStateChange: () => {
|
|
1189
|
+
const newState = ctx.loadState();
|
|
1190
|
+
const currentState2 = ctx.getState();
|
|
1191
|
+
if (JSON.stringify(newState) !== JSON.stringify(currentState2)) {
|
|
1192
|
+
ctx.setState(newState);
|
|
1193
|
+
broadcast({ type: "state:update", taskId, state: newState });
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
}).then((result) => {
|
|
1198
|
+
console.error(`[spool] Task ${taskId} completed:`, result);
|
|
1199
|
+
if (originalBranch) {
|
|
1200
|
+
try {
|
|
1201
|
+
execFileSync2("git", ["checkout", originalBranch], { cwd: ctx.projectDir, encoding: "utf-8" });
|
|
1202
|
+
execFileSync2(
|
|
1203
|
+
"git",
|
|
1204
|
+
["merge", "--no-ff", branchName, "-m", `Merge annotation changes (${taskId})`],
|
|
1205
|
+
{ cwd: ctx.projectDir, encoding: "utf-8" }
|
|
1206
|
+
);
|
|
1207
|
+
execFileSync2("git", ["branch", "-d", branchName], { cwd: ctx.projectDir, encoding: "utf-8" });
|
|
1208
|
+
console.error(`[spool] Merged ${branchName} into ${originalBranch}`);
|
|
1209
|
+
} catch (e) {
|
|
1210
|
+
console.error(`[spool] Branch merge/cleanup error: ${e}`);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
let s = ctx.getState();
|
|
1214
|
+
s = { ...s, status: "idle", currentTaskId: null };
|
|
1215
|
+
ctx.saveState(s);
|
|
1216
|
+
ctx.setState(s);
|
|
1217
|
+
broadcast({ type: "task:complete", taskId, result });
|
|
1218
|
+
if (ctx.getTaskAbortController() === abortController) {
|
|
1219
|
+
ctx.setTaskAbortController(null);
|
|
1220
|
+
}
|
|
1221
|
+
}).catch((error) => {
|
|
1222
|
+
console.error(`[spool] Task ${taskId} error:`, error);
|
|
1223
|
+
if (originalBranch) {
|
|
1224
|
+
try {
|
|
1225
|
+
execFileSync2("git", ["checkout", originalBranch], { cwd: ctx.projectDir, encoding: "utf-8" });
|
|
1226
|
+
execFileSync2("git", ["branch", "-D", branchName], { cwd: ctx.projectDir, encoding: "utf-8" });
|
|
1227
|
+
} catch {
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
let s = ctx.getState();
|
|
1231
|
+
s = { ...s, status: "idle", currentTaskId: null };
|
|
1232
|
+
ctx.saveState(s);
|
|
1233
|
+
ctx.setState(s);
|
|
1234
|
+
broadcast({
|
|
1235
|
+
type: "task:error",
|
|
1236
|
+
taskId,
|
|
1237
|
+
error: {
|
|
1238
|
+
code: "EXECUTION_ERROR",
|
|
1239
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1240
|
+
}
|
|
1241
|
+
});
|
|
1242
|
+
if (ctx.getTaskAbortController() === abortController) {
|
|
1243
|
+
ctx.setTaskAbortController(null);
|
|
1244
|
+
}
|
|
1245
|
+
});
|
|
1246
|
+
return c.json({ taskId, status: "started" });
|
|
1247
|
+
});
|
|
1248
|
+
app.post("/:id/cancel", async (c) => {
|
|
1249
|
+
const taskId = c.req.param("id");
|
|
1250
|
+
console.error(`[spool] task:cancel received for ${taskId}`);
|
|
1251
|
+
const state = ctx.getState();
|
|
1252
|
+
if (state.currentTaskId !== taskId) {
|
|
1253
|
+
return c.json({ error: "Task is not current" }, 404);
|
|
1254
|
+
}
|
|
1255
|
+
const ac = ctx.getTaskAbortController();
|
|
1256
|
+
if (ac) {
|
|
1257
|
+
ac.abort();
|
|
1258
|
+
ctx.setTaskAbortController(null);
|
|
1259
|
+
}
|
|
1260
|
+
const newState = { ...state, status: "idle", currentTaskId: null };
|
|
1261
|
+
ctx.saveState(newState);
|
|
1262
|
+
ctx.setState(newState);
|
|
1263
|
+
return c.json({ ok: true });
|
|
1264
|
+
});
|
|
1265
|
+
return app;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// src/orchestrator/routes/variants.ts
|
|
1269
|
+
import { Hono as Hono5 } from "hono";
|
|
1270
|
+
|
|
1271
|
+
// src/orchestrator/variant-generator.ts
|
|
1272
|
+
import { query as query3 } from "@anthropic-ai/claude-agent-sdk";
|
|
1273
|
+
function buildSystemPrompt2() {
|
|
1274
|
+
return `You are a UI design assistant that generates alternative HTML/JSX variants for UI elements.
|
|
1275
|
+
|
|
1276
|
+
## Your Task
|
|
1277
|
+
|
|
1278
|
+
Given an element's current state and a user's prompt, generate 3-6 alternative HTML/JSX variants.
|
|
1279
|
+
|
|
1280
|
+
## Output Format
|
|
1281
|
+
|
|
1282
|
+
You MUST output EXACTLY the following JSON structure and nothing else:
|
|
1283
|
+
|
|
1284
|
+
\`\`\`json
|
|
1285
|
+
{
|
|
1286
|
+
"variants": [
|
|
1287
|
+
{
|
|
1288
|
+
"html": "<button class=\\"...\\">....</button>",
|
|
1289
|
+
"description": "Brief description of this variant"
|
|
1290
|
+
},
|
|
1291
|
+
{
|
|
1292
|
+
"html": "<button class=\\"...\\">....</button>",
|
|
1293
|
+
"description": "Brief description of this variant"
|
|
1294
|
+
}
|
|
1295
|
+
]
|
|
1296
|
+
}
|
|
1297
|
+
\`\`\`
|
|
1298
|
+
|
|
1299
|
+
## Guidelines
|
|
1300
|
+
|
|
1301
|
+
1. Generate 3-6 meaningful alternatives based on the user's prompt
|
|
1302
|
+
2. Each variant should be a complete, self-contained HTML/JSX snippet
|
|
1303
|
+
3. Use Tailwind CSS classes for styling (assume the project uses Tailwind)
|
|
1304
|
+
4. Include brief descriptions explaining what makes each variant different
|
|
1305
|
+
5. Variants should be progressively more creative/different from the original
|
|
1306
|
+
6. Ensure all variants are semantically equivalent (same purpose/functionality)
|
|
1307
|
+
7. Output ONLY the JSON - no explanations before or after
|
|
1308
|
+
|
|
1309
|
+
## Examples
|
|
1310
|
+
|
|
1311
|
+
If user says "make it more prominent":
|
|
1312
|
+
- Variant 1: Larger font and more padding
|
|
1313
|
+
- Variant 2: Bold colors and shadow
|
|
1314
|
+
- Variant 3: Animated hover effect
|
|
1315
|
+
- Variant 4: Gradient background
|
|
1316
|
+
|
|
1317
|
+
If user says "make it more subtle":
|
|
1318
|
+
- Variant 1: Lighter colors
|
|
1319
|
+
- Variant 2: Reduced padding and smaller text
|
|
1320
|
+
- Variant 3: Ghost/outline style
|
|
1321
|
+
- Variant 4: Minimal with icon only
|
|
1322
|
+
`;
|
|
1323
|
+
}
|
|
1324
|
+
function buildTaskPrompt3(payload) {
|
|
1325
|
+
const { element, prompt, currentHtml } = payload;
|
|
1326
|
+
const elementInfo = [
|
|
1327
|
+
`Tag: ${element.tagName}`,
|
|
1328
|
+
element.componentName && `Component: ${element.componentName}`,
|
|
1329
|
+
element.componentFile && `File: ${element.componentFile}`
|
|
1330
|
+
].filter(Boolean).join("\n");
|
|
1331
|
+
return `## Element Information
|
|
1332
|
+
|
|
1333
|
+
${elementInfo}
|
|
1334
|
+
|
|
1335
|
+
## Current HTML
|
|
1336
|
+
${currentHtml || "(not provided - generate based on element info)"}
|
|
1337
|
+
|
|
1338
|
+
## User Request
|
|
1339
|
+
"${prompt}"
|
|
1340
|
+
|
|
1341
|
+
Generate 3-6 HTML/JSX variants based on the user's request. Output ONLY the JSON structure described.`;
|
|
1342
|
+
}
|
|
1343
|
+
function parseVariants(text) {
|
|
1344
|
+
console.error("[variant-generator] Parsing response, length:", text.length);
|
|
1345
|
+
console.error("[variant-generator] Response preview:", text.slice(0, 300));
|
|
1346
|
+
const jsonBlockMatch = text.match(/```json\s*([\s\S]*?)\s*```/);
|
|
1347
|
+
if (jsonBlockMatch) {
|
|
1348
|
+
console.error("[variant-generator] Found JSON code block");
|
|
1349
|
+
try {
|
|
1350
|
+
const parsed = JSON.parse(jsonBlockMatch[1]);
|
|
1351
|
+
if (Array.isArray(parsed.variants)) {
|
|
1352
|
+
return parsed.variants.map((v, i) => ({
|
|
1353
|
+
html: v.html || "",
|
|
1354
|
+
metadata: { description: v.description || `Variant ${i + 1}` }
|
|
1355
|
+
}));
|
|
1356
|
+
}
|
|
1357
|
+
} catch (e) {
|
|
1358
|
+
console.error("[variant-generator] Failed to parse JSON block:", e);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
const objectMatch = text.match(/\{[\s\S]*?"variants"\s*:\s*\[[\s\S]*?\]\s*\}/);
|
|
1362
|
+
if (objectMatch) {
|
|
1363
|
+
console.error("[variant-generator] Found JSON object with variants key");
|
|
1364
|
+
try {
|
|
1365
|
+
const parsed = JSON.parse(objectMatch[0]);
|
|
1366
|
+
if (Array.isArray(parsed.variants)) {
|
|
1367
|
+
return parsed.variants.map((v, i) => ({
|
|
1368
|
+
html: v.html || "",
|
|
1369
|
+
metadata: { description: v.description || `Variant ${i + 1}` }
|
|
1370
|
+
}));
|
|
1371
|
+
}
|
|
1372
|
+
} catch (e) {
|
|
1373
|
+
console.error("[variant-generator] Failed to parse object match:", e);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
const arrayMatch = text.match(/\[\s*\{[\s\S]*?"html"[\s\S]*?\}\s*\]/);
|
|
1377
|
+
if (arrayMatch) {
|
|
1378
|
+
console.error("[variant-generator] Found array with html objects");
|
|
1379
|
+
try {
|
|
1380
|
+
const parsed = JSON.parse(arrayMatch[0]);
|
|
1381
|
+
if (Array.isArray(parsed)) {
|
|
1382
|
+
return parsed.map((v, i) => ({
|
|
1383
|
+
html: v.html || "",
|
|
1384
|
+
metadata: { description: v.description || `Variant ${i + 1}` }
|
|
1385
|
+
}));
|
|
1386
|
+
}
|
|
1387
|
+
} catch (e) {
|
|
1388
|
+
console.error("[variant-generator] Failed to parse array:", e);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
try {
|
|
1392
|
+
const parsed = JSON.parse(text.trim());
|
|
1393
|
+
if (Array.isArray(parsed.variants)) {
|
|
1394
|
+
return parsed.variants.map((v, i) => ({
|
|
1395
|
+
html: v.html || "",
|
|
1396
|
+
metadata: { description: v.description || `Variant ${i + 1}` }
|
|
1397
|
+
}));
|
|
1398
|
+
}
|
|
1399
|
+
if (Array.isArray(parsed)) {
|
|
1400
|
+
return parsed.map((v, i) => ({
|
|
1401
|
+
html: v.html || "",
|
|
1402
|
+
metadata: { description: v.description || `Variant ${i + 1}` }
|
|
1403
|
+
}));
|
|
1404
|
+
}
|
|
1405
|
+
} catch (e) {
|
|
1406
|
+
}
|
|
1407
|
+
console.error("[variant-generator] Could not parse variants from response");
|
|
1408
|
+
console.error("[variant-generator] Full response:", text);
|
|
1409
|
+
return [];
|
|
1410
|
+
}
|
|
1411
|
+
async function generateVariants(payload, cwd2) {
|
|
1412
|
+
const systemPrompt = buildSystemPrompt2();
|
|
1413
|
+
const taskPrompt = buildTaskPrompt3(payload);
|
|
1414
|
+
let responseText = "";
|
|
1415
|
+
console.error("[variant-generator] Starting variant generation");
|
|
1416
|
+
console.error("[variant-generator] Prompt:", payload.prompt);
|
|
1417
|
+
console.error("[variant-generator] Element:", payload.element.tagName, payload.element.componentName);
|
|
1418
|
+
try {
|
|
1419
|
+
const stream = query3({
|
|
1420
|
+
prompt: taskPrompt,
|
|
1421
|
+
options: {
|
|
1422
|
+
systemPrompt,
|
|
1423
|
+
cwd: cwd2,
|
|
1424
|
+
tools: { type: "preset", preset: "claude_code" },
|
|
1425
|
+
allowedTools: [],
|
|
1426
|
+
// No tools needed for this task
|
|
1427
|
+
permissionMode: "acceptEdits",
|
|
1428
|
+
includePartialMessages: false,
|
|
1429
|
+
model: CLAUDE_MODEL,
|
|
1430
|
+
maxTurns: 1
|
|
1431
|
+
// Single turn is enough for variant generation
|
|
1432
|
+
}
|
|
1433
|
+
});
|
|
1434
|
+
for await (const message of stream) {
|
|
1435
|
+
switch (message.type) {
|
|
1436
|
+
case "stream_event": {
|
|
1437
|
+
const event = message.event;
|
|
1438
|
+
if (event.type === "content_block_delta") {
|
|
1439
|
+
const delta = event.delta;
|
|
1440
|
+
if ("text" in delta) {
|
|
1441
|
+
responseText += delta.text;
|
|
1442
|
+
if (responseText.length % 500 < 50) {
|
|
1443
|
+
console.error("[variant-generator] Received", responseText.length, "chars so far");
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
break;
|
|
1448
|
+
}
|
|
1449
|
+
case "assistant": {
|
|
1450
|
+
const assistantMessage = message.message;
|
|
1451
|
+
if (assistantMessage.content) {
|
|
1452
|
+
for (const block of assistantMessage.content) {
|
|
1453
|
+
if (block.type === "text" && block.text) {
|
|
1454
|
+
console.error("[variant-generator] Got text from assistant message, length:", block.text.length);
|
|
1455
|
+
if (!responseText) {
|
|
1456
|
+
responseText = block.text;
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
break;
|
|
1462
|
+
}
|
|
1463
|
+
case "result": {
|
|
1464
|
+
if (message.subtype !== "success") {
|
|
1465
|
+
const errors = "errors" in message ? message.errors : [];
|
|
1466
|
+
return {
|
|
1467
|
+
variants: [],
|
|
1468
|
+
error: errors.join(", ") || "Failed to generate variants"
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
break;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
console.error("[variant-generator] Stream complete, total response length:", responseText.length);
|
|
1476
|
+
const variants = parseVariants(responseText);
|
|
1477
|
+
console.error("[variant-generator] Parsed", variants.length, "variants");
|
|
1478
|
+
if (variants.length === 0) {
|
|
1479
|
+
return {
|
|
1480
|
+
variants: [],
|
|
1481
|
+
error: "Failed to parse variants from response"
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
return { variants };
|
|
1485
|
+
} catch (error) {
|
|
1486
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1487
|
+
console.error("[variant-generator] Error:", errorMessage);
|
|
1488
|
+
return {
|
|
1489
|
+
variants: [],
|
|
1490
|
+
error: errorMessage
|
|
1491
|
+
};
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// src/orchestrator/routes/variants.ts
|
|
1496
|
+
function createVariantsRoutes(ctx) {
|
|
1497
|
+
const app = new Hono5();
|
|
1498
|
+
app.post("/generate", async (c) => {
|
|
1499
|
+
const { selector, prompt, elementHtml, elementInfo } = await c.req.json();
|
|
1500
|
+
console.error(`[spool] variant:generate for ${selector}`);
|
|
1501
|
+
console.error(` Prompt: ${prompt}`);
|
|
1502
|
+
try {
|
|
1503
|
+
const result = await generateVariants(
|
|
1504
|
+
{
|
|
1505
|
+
selector,
|
|
1506
|
+
element: {
|
|
1507
|
+
tagName: elementInfo.tagName,
|
|
1508
|
+
componentName: elementInfo.componentName,
|
|
1509
|
+
componentFile: elementInfo.componentFile
|
|
1510
|
+
},
|
|
1511
|
+
prompt,
|
|
1512
|
+
currentHtml: elementHtml
|
|
1513
|
+
},
|
|
1514
|
+
ctx.projectDir
|
|
1515
|
+
);
|
|
1516
|
+
return c.json({ variants: result.variants, error: result.error });
|
|
1517
|
+
} catch (error) {
|
|
1518
|
+
console.error("[spool] variant:generate error:", error);
|
|
1519
|
+
return c.json({
|
|
1520
|
+
variants: [],
|
|
1521
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
});
|
|
1525
|
+
return app;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
// src/orchestrator/routes/chat.ts
|
|
1529
|
+
import { Hono as Hono6 } from "hono";
|
|
1530
|
+
|
|
1531
|
+
// src/orchestrator/chat-handler.ts
|
|
1532
|
+
import { query as query4 } from "@anthropic-ai/claude-agent-sdk";
|
|
1533
|
+
function buildChatSystemPrompt(cwd2) {
|
|
1534
|
+
return `You are an AI assistant helping a developer with their React application through a chat interface.
|
|
1535
|
+
The developer can reference specific UI elements in their messages by clicking on them in the browser.
|
|
1536
|
+
|
|
1537
|
+
## Working Directory
|
|
1538
|
+
|
|
1539
|
+
Your working directory is: ${cwd2}
|
|
1540
|
+
You can ONLY access files within this directory. Do not attempt to access files outside it.
|
|
1541
|
+
|
|
1542
|
+
## Tools
|
|
1543
|
+
|
|
1544
|
+
You have the following tools available:
|
|
1545
|
+
- **Read**: Read file contents. Parameter: file_path (absolute path)
|
|
1546
|
+
- **Edit**: Replace text in a file. Parameters: file_path, old_string, new_string
|
|
1547
|
+
- **Write**: Create or overwrite a file. Parameters: file_path, content
|
|
1548
|
+
- **Glob**: Find files by pattern. Parameters: pattern, path (optional directory)
|
|
1549
|
+
- **Grep**: Search file contents. Parameters: pattern, path (optional directory)
|
|
1550
|
+
|
|
1551
|
+
Always use absolute paths based on your working directory. Read a file before editing it.
|
|
1552
|
+
|
|
1553
|
+
## Element References
|
|
1554
|
+
|
|
1555
|
+
When the user references UI elements, you'll see them in the prompt with their details:
|
|
1556
|
+
- CSS selector to locate the element
|
|
1557
|
+
- Component name and file location (if available)
|
|
1558
|
+
- Text content of the element
|
|
1559
|
+
|
|
1560
|
+
Use this information to understand what the user is talking about and make targeted changes.
|
|
1561
|
+
|
|
1562
|
+
## Capabilities
|
|
1563
|
+
|
|
1564
|
+
You can:
|
|
1565
|
+
1. **Answer questions** about the codebase, UI elements, or implementation approaches
|
|
1566
|
+
2. **Make code changes** when the user requests modifications to their UI or code
|
|
1567
|
+
3. **Explain code** and help the user understand how things work
|
|
1568
|
+
4. **Suggest improvements** when asked for recommendations
|
|
1569
|
+
|
|
1570
|
+
## Guidelines
|
|
1571
|
+
|
|
1572
|
+
- Be conversational and helpful
|
|
1573
|
+
- When making changes, explain what you're doing
|
|
1574
|
+
- If the user's request is unclear, ask clarifying questions
|
|
1575
|
+
- Keep responses focused and relevant to the user's question
|
|
1576
|
+
- When referencing files, use the component file paths provided in element references when available
|
|
1577
|
+
- Focus exclusively on the user's React application code`;
|
|
1578
|
+
}
|
|
1579
|
+
function buildChatPrompt(content, elementRefs, history) {
|
|
1580
|
+
const parts = [];
|
|
1581
|
+
if (history.length > 0) {
|
|
1582
|
+
parts.push("## Conversation History\n");
|
|
1583
|
+
for (const msg of history) {
|
|
1584
|
+
const role = msg.role === "user" ? "User" : "Assistant";
|
|
1585
|
+
parts.push(`**${role}:** ${msg.content}
|
|
1586
|
+
`);
|
|
1587
|
+
}
|
|
1588
|
+
parts.push("");
|
|
1589
|
+
}
|
|
1590
|
+
if (elementRefs.length > 0) {
|
|
1591
|
+
parts.push("## Referenced UI Elements\n");
|
|
1592
|
+
for (const ref of elementRefs) {
|
|
1593
|
+
parts.push(`### Element: ${ref.element.componentName || ref.element.tagName}`);
|
|
1594
|
+
parts.push(`- **Selector:** \`${ref.selector}\``);
|
|
1595
|
+
if (ref.element.componentName) {
|
|
1596
|
+
parts.push(`- **Component:** ${ref.element.componentName}`);
|
|
1597
|
+
}
|
|
1598
|
+
if (ref.element.componentFile) {
|
|
1599
|
+
parts.push(`- **File:** ${ref.element.componentFile}${ref.element.lineNumber ? `:${ref.element.lineNumber}` : ""}`);
|
|
1600
|
+
}
|
|
1601
|
+
if (ref.element.textContent) {
|
|
1602
|
+
const truncated = ref.element.textContent.length > 100 ? ref.element.textContent.slice(0, 100) + "..." : ref.element.textContent;
|
|
1603
|
+
parts.push(`- **Text:** "${truncated}"`);
|
|
1604
|
+
}
|
|
1605
|
+
if (ref.element.className) {
|
|
1606
|
+
parts.push(`- **Classes:** ${ref.element.className}`);
|
|
1607
|
+
}
|
|
1608
|
+
parts.push("");
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
parts.push("## Current Message\n");
|
|
1612
|
+
parts.push(content);
|
|
1613
|
+
return parts.join("\n");
|
|
1614
|
+
}
|
|
1615
|
+
async function handleChat(options) {
|
|
1616
|
+
const { messageId, content, elementRefs, history, cwd: cwd2, callbacks, abortController } = options;
|
|
1617
|
+
const prompt = buildChatPrompt(content, elementRefs, history);
|
|
1618
|
+
let responseContent = "";
|
|
1619
|
+
try {
|
|
1620
|
+
const stream = query4({
|
|
1621
|
+
prompt,
|
|
1622
|
+
options: {
|
|
1623
|
+
systemPrompt: buildChatSystemPrompt(cwd2),
|
|
1624
|
+
cwd: cwd2,
|
|
1625
|
+
abortController,
|
|
1626
|
+
// Use Claude Code's tools for file operations
|
|
1627
|
+
tools: { type: "preset", preset: "claude_code" },
|
|
1628
|
+
// Only allow file-access tools — no Bash, WebFetch, or WebSearch
|
|
1629
|
+
allowedTools: ["Read", "Edit", "Write", "Glob", "Grep"],
|
|
1630
|
+
// Enforce project directory boundaries
|
|
1631
|
+
canUseTool: createPathEnforcer(cwd2),
|
|
1632
|
+
// Auto-accept edits since user initiated this via chat
|
|
1633
|
+
permissionMode: "acceptEdits",
|
|
1634
|
+
// Include partial messages for streaming
|
|
1635
|
+
includePartialMessages: true,
|
|
1636
|
+
// Don't load external CLAUDE.md (would leak monorepo context into the isolated agent)
|
|
1637
|
+
settingSources: [],
|
|
1638
|
+
model: CLAUDE_MODEL
|
|
1639
|
+
}
|
|
1640
|
+
});
|
|
1641
|
+
for await (const message of stream) {
|
|
1642
|
+
if (abortController.signal.aborted) {
|
|
1643
|
+
break;
|
|
1644
|
+
}
|
|
1645
|
+
switch (message.type) {
|
|
1646
|
+
case "assistant": {
|
|
1647
|
+
const assistantMessage = message.message;
|
|
1648
|
+
if (assistantMessage.content) {
|
|
1649
|
+
for (const block of assistantMessage.content) {
|
|
1650
|
+
if (block.type === "tool_use") {
|
|
1651
|
+
callbacks.onToolCall(block.name, block.input);
|
|
1652
|
+
} else if (block.type === "text") {
|
|
1653
|
+
if (!responseContent.includes(block.text)) {
|
|
1654
|
+
responseContent += block.text;
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
break;
|
|
1660
|
+
}
|
|
1661
|
+
case "stream_event": {
|
|
1662
|
+
const event = message.event;
|
|
1663
|
+
if (event.type === "content_block_start") {
|
|
1664
|
+
const contentBlock = event.content_block;
|
|
1665
|
+
if (contentBlock?.type === "text" && responseContent.length > 0) {
|
|
1666
|
+
if (!responseContent.endsWith("\n\n")) {
|
|
1667
|
+
const sep2 = responseContent.endsWith("\n") ? "\n" : "\n\n";
|
|
1668
|
+
responseContent += sep2;
|
|
1669
|
+
callbacks.onText(sep2, true);
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
if (event.type === "content_block_delta") {
|
|
1674
|
+
const delta = event.delta;
|
|
1675
|
+
if ("text" in delta) {
|
|
1676
|
+
responseContent += delta.text;
|
|
1677
|
+
callbacks.onText(delta.text, true);
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
break;
|
|
1681
|
+
}
|
|
1682
|
+
case "user": {
|
|
1683
|
+
break;
|
|
1684
|
+
}
|
|
1685
|
+
case "result": {
|
|
1686
|
+
if (message.subtype === "success") {
|
|
1687
|
+
return {
|
|
1688
|
+
success: true,
|
|
1689
|
+
responseContent
|
|
1690
|
+
};
|
|
1691
|
+
} else {
|
|
1692
|
+
const errors = "errors" in message ? message.errors : [];
|
|
1693
|
+
return {
|
|
1694
|
+
success: false,
|
|
1695
|
+
error: errors.join(", ") || "Chat request failed",
|
|
1696
|
+
responseContent
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
return {
|
|
1703
|
+
success: !abortController.signal.aborted,
|
|
1704
|
+
error: abortController.signal.aborted ? "Chat cancelled" : void 0,
|
|
1705
|
+
responseContent
|
|
1706
|
+
};
|
|
1707
|
+
} catch (error) {
|
|
1708
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1709
|
+
return {
|
|
1710
|
+
success: false,
|
|
1711
|
+
error: `Chat error: ${errorMessage}`,
|
|
1712
|
+
responseContent
|
|
1713
|
+
};
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// src/orchestrator/routes/chat.ts
|
|
1718
|
+
function createChatRoutes(ctx) {
|
|
1719
|
+
const app = new Hono6();
|
|
1720
|
+
app.post("/", async (c) => {
|
|
1721
|
+
const { id: messageId, content, elementRefs, history } = await c.req.json();
|
|
1722
|
+
console.error(`[spool] chat:send received`);
|
|
1723
|
+
console.error(` Message ID: ${messageId}`);
|
|
1724
|
+
console.error(` Content: ${content.slice(0, 100)}${content.length > 100 ? "..." : ""}`);
|
|
1725
|
+
console.error(` Element refs: ${elementRefs.length}`);
|
|
1726
|
+
const prevAc = ctx.getChatAbortController();
|
|
1727
|
+
if (prevAc) {
|
|
1728
|
+
console.error(`[spool] Chat already in progress, aborting previous`);
|
|
1729
|
+
prevAc.abort();
|
|
1730
|
+
}
|
|
1731
|
+
const abortController = new AbortController();
|
|
1732
|
+
ctx.setChatAbortController(abortController);
|
|
1733
|
+
ctx.setChatMessageId(messageId);
|
|
1734
|
+
handleChat({
|
|
1735
|
+
messageId,
|
|
1736
|
+
content,
|
|
1737
|
+
elementRefs,
|
|
1738
|
+
history,
|
|
1739
|
+
cwd: ctx.projectDir,
|
|
1740
|
+
abortController,
|
|
1741
|
+
callbacks: {
|
|
1742
|
+
onText: (text, isPartial) => {
|
|
1743
|
+
broadcast({ type: "chat:stream", messageId, content: text, isPartial });
|
|
1744
|
+
},
|
|
1745
|
+
onToolCall: (toolName, toolInput) => {
|
|
1746
|
+
broadcast({ type: "chat:tool_call", messageId, toolName, toolInput });
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
}).then((result) => {
|
|
1750
|
+
console.error(`[spool] Chat ${messageId} completed:`, result.success);
|
|
1751
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
1752
|
+
const updatedHistory = [
|
|
1753
|
+
...history,
|
|
1754
|
+
{
|
|
1755
|
+
id: messageId + "_user",
|
|
1756
|
+
role: "user",
|
|
1757
|
+
content,
|
|
1758
|
+
elementRefs,
|
|
1759
|
+
timestamp
|
|
1760
|
+
}
|
|
1761
|
+
];
|
|
1762
|
+
if (result.responseContent) {
|
|
1763
|
+
updatedHistory.push({
|
|
1764
|
+
id: messageId,
|
|
1765
|
+
role: "assistant",
|
|
1766
|
+
content: result.responseContent,
|
|
1767
|
+
timestamp
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
let state = ctx.getState();
|
|
1771
|
+
state = { ...state, chatMessages: updatedHistory };
|
|
1772
|
+
ctx.saveState(state);
|
|
1773
|
+
ctx.setState(state);
|
|
1774
|
+
broadcast({ type: "state:update", taskId: state.currentTaskId, state });
|
|
1775
|
+
broadcast({
|
|
1776
|
+
type: "chat:complete",
|
|
1777
|
+
messageId,
|
|
1778
|
+
success: result.success,
|
|
1779
|
+
error: result.error
|
|
1780
|
+
});
|
|
1781
|
+
if (ctx.getChatMessageId() === messageId) {
|
|
1782
|
+
ctx.setChatAbortController(null);
|
|
1783
|
+
ctx.setChatMessageId(null);
|
|
1784
|
+
}
|
|
1785
|
+
}).catch((error) => {
|
|
1786
|
+
console.error(`[spool] Chat ${messageId} error:`, error);
|
|
1787
|
+
broadcast({
|
|
1788
|
+
type: "chat:complete",
|
|
1789
|
+
messageId,
|
|
1790
|
+
success: false,
|
|
1791
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1792
|
+
});
|
|
1793
|
+
if (ctx.getChatMessageId() === messageId) {
|
|
1794
|
+
ctx.setChatAbortController(null);
|
|
1795
|
+
ctx.setChatMessageId(null);
|
|
1796
|
+
}
|
|
1797
|
+
});
|
|
1798
|
+
return c.json({ messageId });
|
|
1799
|
+
});
|
|
1800
|
+
app.post("/cancel", async (c) => {
|
|
1801
|
+
const { messageId } = await c.req.json();
|
|
1802
|
+
console.error(`[spool] chat:cancel for ${messageId}`);
|
|
1803
|
+
if (ctx.getChatMessageId() !== messageId) {
|
|
1804
|
+
return c.json({ error: "Not current chat" }, 404);
|
|
1805
|
+
}
|
|
1806
|
+
const ac = ctx.getChatAbortController();
|
|
1807
|
+
if (ac) {
|
|
1808
|
+
ac.abort();
|
|
1809
|
+
ctx.setChatAbortController(null);
|
|
1810
|
+
ctx.setChatMessageId(null);
|
|
1811
|
+
}
|
|
1812
|
+
return c.json({ ok: true });
|
|
1813
|
+
});
|
|
1814
|
+
app.put("/history", async (c) => {
|
|
1815
|
+
const { messages } = await c.req.json();
|
|
1816
|
+
console.error(`[spool] chat:history:update (${messages.length} messages)`);
|
|
1817
|
+
let state = ctx.getState();
|
|
1818
|
+
state = { ...state, chatMessages: messages };
|
|
1819
|
+
ctx.saveState(state);
|
|
1820
|
+
ctx.setState(state);
|
|
1821
|
+
broadcast({ type: "state:update", taskId: state.currentTaskId, state });
|
|
1822
|
+
return c.json({ ok: true });
|
|
1823
|
+
});
|
|
1824
|
+
return app;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
// src/orchestrator/index.ts
|
|
1828
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
1829
|
+
var __dirname = path2.dirname(__filename);
|
|
1830
|
+
var require2 = createRequire(import.meta.url);
|
|
1831
|
+
var DEFAULT_PORT = 3142;
|
|
1832
|
+
var STATE_FILE = ".spool/state.json";
|
|
1833
|
+
function generateAppId(dir) {
|
|
1834
|
+
const hash = crypto.createHash("sha256").update(dir).digest("hex").slice(0, 8);
|
|
1835
|
+
return `spool_${hash}`;
|
|
1836
|
+
}
|
|
1837
|
+
function parseProjectDir() {
|
|
1838
|
+
const args2 = process.argv;
|
|
1839
|
+
const idx = args2.indexOf("--project-dir");
|
|
1840
|
+
if (idx !== -1 && idx + 1 < args2.length) {
|
|
1841
|
+
const dir = path2.resolve(args2[idx + 1]);
|
|
1842
|
+
if (!fs2.existsSync(dir) || !fs2.statSync(dir).isDirectory()) {
|
|
1843
|
+
console.error(`[annotate-server] --project-dir "${args2[idx + 1]}" is not a valid directory`);
|
|
1844
|
+
process.exit(1);
|
|
1845
|
+
}
|
|
1846
|
+
return dir;
|
|
1847
|
+
}
|
|
1848
|
+
return process.cwd();
|
|
1849
|
+
}
|
|
1850
|
+
var projectDir = parseProjectDir();
|
|
1851
|
+
var currentState = createEmptyState(generateAppId(projectDir));
|
|
1852
|
+
var stateFilePath;
|
|
1853
|
+
function getStateFilePath() {
|
|
1854
|
+
if (!stateFilePath) {
|
|
1855
|
+
stateFilePath = path2.resolve(projectDir, STATE_FILE);
|
|
1856
|
+
}
|
|
1857
|
+
return stateFilePath;
|
|
1858
|
+
}
|
|
1859
|
+
function ensureStateDir() {
|
|
1860
|
+
const dir = path2.dirname(getStateFilePath());
|
|
1861
|
+
if (!fs2.existsSync(dir)) {
|
|
1862
|
+
fs2.mkdirSync(dir, { recursive: true });
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
function loadState() {
|
|
1866
|
+
const filePath = getStateFilePath();
|
|
1867
|
+
if (fs2.existsSync(filePath)) {
|
|
1868
|
+
try {
|
|
1869
|
+
const content = fs2.readFileSync(filePath, "utf-8");
|
|
1870
|
+
const state = JSON.parse(content);
|
|
1871
|
+
console.error(`[spool] Loaded state from ${filePath}`);
|
|
1872
|
+
return state;
|
|
1873
|
+
} catch (error) {
|
|
1874
|
+
console.error(`[spool] Error loading state:`, error);
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
console.error(`[spool] No state file found, starting fresh`);
|
|
1878
|
+
return createEmptyState(generateAppId(projectDir));
|
|
1879
|
+
}
|
|
1880
|
+
function saveState(state) {
|
|
1881
|
+
ensureStateDir();
|
|
1882
|
+
const filePath = getStateFilePath();
|
|
1883
|
+
try {
|
|
1884
|
+
fs2.writeFileSync(filePath, JSON.stringify(state, null, 2), "utf-8");
|
|
1885
|
+
console.error(`[spool] Saved state to ${filePath}`);
|
|
1886
|
+
} catch (error) {
|
|
1887
|
+
console.error(`[spool] Error saving state:`, error);
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
var currentTaskAbortController = null;
|
|
1891
|
+
var currentChatAbortController = null;
|
|
1892
|
+
var currentChatMessageId = null;
|
|
1893
|
+
var fileWatcher = null;
|
|
1894
|
+
function startFileWatcher() {
|
|
1895
|
+
const filePath = getStateFilePath();
|
|
1896
|
+
ensureStateDir();
|
|
1897
|
+
fileWatcher = watch(filePath, {
|
|
1898
|
+
persistent: true,
|
|
1899
|
+
ignoreInitial: true,
|
|
1900
|
+
awaitWriteFinish: {
|
|
1901
|
+
stabilityThreshold: 100,
|
|
1902
|
+
pollInterval: 50
|
|
1903
|
+
}
|
|
1904
|
+
});
|
|
1905
|
+
fileWatcher.on("change", () => {
|
|
1906
|
+
console.error(`[spool] State file changed, reloading...`);
|
|
1907
|
+
const newState = loadState();
|
|
1908
|
+
if (JSON.stringify(newState) !== JSON.stringify(currentState)) {
|
|
1909
|
+
currentState = newState;
|
|
1910
|
+
const update = {
|
|
1911
|
+
type: "state:update",
|
|
1912
|
+
taskId: currentState.currentTaskId,
|
|
1913
|
+
state: currentState
|
|
1914
|
+
};
|
|
1915
|
+
broadcast(update);
|
|
1916
|
+
}
|
|
1917
|
+
});
|
|
1918
|
+
fileWatcher.on("add", () => {
|
|
1919
|
+
console.error(`[spool] State file created`);
|
|
1920
|
+
currentState = loadState();
|
|
1921
|
+
const update = {
|
|
1922
|
+
type: "state:update",
|
|
1923
|
+
taskId: currentState.currentTaskId,
|
|
1924
|
+
state: currentState
|
|
1925
|
+
};
|
|
1926
|
+
broadcast(update);
|
|
1927
|
+
});
|
|
1928
|
+
fileWatcher.on("error", (error) => {
|
|
1929
|
+
console.error(`[spool] File watcher error:`, error);
|
|
1930
|
+
});
|
|
1931
|
+
console.error(`[spool] Watching ${filePath} for changes`);
|
|
1932
|
+
}
|
|
1933
|
+
function stopFileWatcher() {
|
|
1934
|
+
if (fileWatcher) {
|
|
1935
|
+
fileWatcher.close();
|
|
1936
|
+
fileWatcher = null;
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
function findInjectScript() {
|
|
1940
|
+
try {
|
|
1941
|
+
const injectPkg = require2.resolve("@nicmeriano/spool-inject/package.json");
|
|
1942
|
+
const injectDir = path2.dirname(injectPkg);
|
|
1943
|
+
const injectPath = path2.join(injectDir, "dist", "inject.js");
|
|
1944
|
+
if (fs2.existsSync(injectPath)) {
|
|
1945
|
+
return injectPath;
|
|
1946
|
+
}
|
|
1947
|
+
} catch {
|
|
1948
|
+
}
|
|
1949
|
+
const possiblePaths = [
|
|
1950
|
+
path2.resolve(__dirname, "../../inject/dist/inject.js"),
|
|
1951
|
+
path2.resolve(__dirname, "../../../inject/dist/inject.js")
|
|
1952
|
+
];
|
|
1953
|
+
for (const scriptPath of possiblePaths) {
|
|
1954
|
+
if (fs2.existsSync(scriptPath)) {
|
|
1955
|
+
return scriptPath;
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
return null;
|
|
1959
|
+
}
|
|
1960
|
+
var MIME_TYPES = {
|
|
1961
|
+
".html": "text/html",
|
|
1962
|
+
".js": "application/javascript",
|
|
1963
|
+
".css": "text/css",
|
|
1964
|
+
".json": "application/json",
|
|
1965
|
+
".png": "image/png",
|
|
1966
|
+
".jpg": "image/jpeg",
|
|
1967
|
+
".gif": "image/gif",
|
|
1968
|
+
".svg": "image/svg+xml",
|
|
1969
|
+
".ico": "image/x-icon",
|
|
1970
|
+
".woff": "font/woff",
|
|
1971
|
+
".woff2": "font/woff2",
|
|
1972
|
+
".ttf": "font/ttf"
|
|
1973
|
+
};
|
|
1974
|
+
function findShellDist() {
|
|
1975
|
+
try {
|
|
1976
|
+
const shellPkg = require2.resolve("@nicmeriano/spool-shell/package.json");
|
|
1977
|
+
const shellDir = path2.dirname(shellPkg);
|
|
1978
|
+
const distDir = path2.join(shellDir, "dist");
|
|
1979
|
+
if (fs2.existsSync(distDir)) {
|
|
1980
|
+
return distDir;
|
|
1981
|
+
}
|
|
1982
|
+
} catch {
|
|
1983
|
+
}
|
|
1984
|
+
const possiblePaths = [
|
|
1985
|
+
path2.resolve(__dirname, "../../shell/dist"),
|
|
1986
|
+
path2.resolve(process.cwd(), "packages/shell/dist"),
|
|
1987
|
+
path2.resolve(process.cwd(), "../shell/dist")
|
|
1988
|
+
];
|
|
1989
|
+
for (const distPath of possiblePaths) {
|
|
1990
|
+
if (fs2.existsSync(distPath)) {
|
|
1991
|
+
return distPath;
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
return null;
|
|
1995
|
+
}
|
|
1996
|
+
async function startServer(options = {}) {
|
|
1997
|
+
const port = options.port ?? parseInt(process.env.PORT || String(DEFAULT_PORT), 10);
|
|
1998
|
+
if (options.cwd) {
|
|
1999
|
+
projectDir = path2.resolve(options.cwd);
|
|
2000
|
+
stateFilePath = "";
|
|
2001
|
+
}
|
|
2002
|
+
console.error(`[spool] Project directory: ${projectDir}`);
|
|
2003
|
+
currentState = loadState();
|
|
2004
|
+
if (currentState.appUrl) {
|
|
2005
|
+
currentState.appUrl = void 0;
|
|
2006
|
+
}
|
|
2007
|
+
if (currentState.status === "generating") {
|
|
2008
|
+
console.error(`[spool] Recovering from interrupted task, resetting state`);
|
|
2009
|
+
currentState.status = "idle";
|
|
2010
|
+
currentState.currentTaskId = null;
|
|
2011
|
+
for (const page of Object.values(currentState.pages)) {
|
|
2012
|
+
for (const ann of page.annotations) {
|
|
2013
|
+
if (ann.status === "in_progress") {
|
|
2014
|
+
ann.status = "open";
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
saveState(currentState);
|
|
2019
|
+
}
|
|
2020
|
+
const routeCtx = {
|
|
2021
|
+
getState: () => currentState,
|
|
2022
|
+
setState: (s) => {
|
|
2023
|
+
currentState = s;
|
|
2024
|
+
},
|
|
2025
|
+
saveState,
|
|
2026
|
+
loadState,
|
|
2027
|
+
projectDir,
|
|
2028
|
+
getTaskAbortController: () => currentTaskAbortController,
|
|
2029
|
+
setTaskAbortController: (ac) => {
|
|
2030
|
+
currentTaskAbortController = ac;
|
|
2031
|
+
},
|
|
2032
|
+
getChatAbortController: () => currentChatAbortController,
|
|
2033
|
+
setChatAbortController: (ac) => {
|
|
2034
|
+
currentChatAbortController = ac;
|
|
2035
|
+
},
|
|
2036
|
+
getChatMessageId: () => currentChatMessageId,
|
|
2037
|
+
setChatMessageId: (id) => {
|
|
2038
|
+
currentChatMessageId = id;
|
|
2039
|
+
}
|
|
2040
|
+
};
|
|
2041
|
+
const app = new Hono7();
|
|
2042
|
+
app.use("/*", cors({
|
|
2043
|
+
origin: (origin) => {
|
|
2044
|
+
if (!origin) return "*";
|
|
2045
|
+
try {
|
|
2046
|
+
const hostname = new URL(origin).hostname;
|
|
2047
|
+
return hostname === "localhost" || hostname === "127.0.0.1" ? origin : null;
|
|
2048
|
+
} catch {
|
|
2049
|
+
return null;
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
}));
|
|
2053
|
+
app.route("/api", createEventsRoutes(routeCtx));
|
|
2054
|
+
app.route("/api/annotations", createAnnotationsRoutes(routeCtx));
|
|
2055
|
+
app.route("/api/changes", createChangesRoutes(routeCtx));
|
|
2056
|
+
app.route("/api/tasks", createTasksRoutes(routeCtx));
|
|
2057
|
+
app.route("/api/variants", createVariantsRoutes(routeCtx));
|
|
2058
|
+
app.route("/api/chat", createChatRoutes(routeCtx));
|
|
2059
|
+
app.get("/health", (c) => {
|
|
2060
|
+
const openCount = Object.values(currentState.pages).flatMap((p) => p.annotations).filter((a) => a.status === "open").length;
|
|
2061
|
+
return c.json({
|
|
2062
|
+
status: "ok",
|
|
2063
|
+
open_annotations: openCount,
|
|
2064
|
+
connected_clients: getClientCount()
|
|
2065
|
+
});
|
|
2066
|
+
});
|
|
2067
|
+
app.get("/inject.js", (c) => {
|
|
2068
|
+
const injectPath = findInjectScript();
|
|
2069
|
+
if (!injectPath) {
|
|
2070
|
+
console.error(`[spool] inject.js not found. Run 'pnpm build' in packages/inject.`);
|
|
2071
|
+
return c.text("inject.js not found - please build the inject package", 404);
|
|
2072
|
+
}
|
|
2073
|
+
try {
|
|
2074
|
+
const content = fs2.readFileSync(injectPath, "utf-8");
|
|
2075
|
+
return c.text(content, 200, {
|
|
2076
|
+
"Content-Type": "application/javascript",
|
|
2077
|
+
"Cache-Control": "no-cache"
|
|
2078
|
+
});
|
|
2079
|
+
} catch (error) {
|
|
2080
|
+
console.error(`[spool] Error reading inject.js:`, error);
|
|
2081
|
+
return c.text("Error reading inject.js", 500);
|
|
2082
|
+
}
|
|
2083
|
+
});
|
|
2084
|
+
app.get("/*", (c) => {
|
|
2085
|
+
const shellDist = findShellDist();
|
|
2086
|
+
if (!shellDist) {
|
|
2087
|
+
return c.text("Not Found", 404);
|
|
2088
|
+
}
|
|
2089
|
+
const urlPath = new URL(c.req.url).pathname;
|
|
2090
|
+
const filePath = path2.join(shellDist, urlPath);
|
|
2091
|
+
if (fs2.existsSync(filePath) && fs2.statSync(filePath).isFile()) {
|
|
2092
|
+
const ext = path2.extname(filePath);
|
|
2093
|
+
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
2094
|
+
try {
|
|
2095
|
+
const content = fs2.readFileSync(filePath);
|
|
2096
|
+
return new Response(content, {
|
|
2097
|
+
status: 200,
|
|
2098
|
+
headers: { "Content-Type": contentType }
|
|
2099
|
+
});
|
|
2100
|
+
} catch {
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
const indexPath = path2.join(shellDist, "index.html");
|
|
2104
|
+
if (fs2.existsSync(indexPath)) {
|
|
2105
|
+
try {
|
|
2106
|
+
const content = fs2.readFileSync(indexPath, "utf-8");
|
|
2107
|
+
return c.html(content);
|
|
2108
|
+
} catch {
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
return c.text("Not Found", 404);
|
|
2112
|
+
});
|
|
2113
|
+
startFileWatcher();
|
|
2114
|
+
const server = createAdaptorServer(app);
|
|
2115
|
+
const MAX_PORT_ATTEMPTS = 20;
|
|
2116
|
+
await new Promise((resolve3, reject) => {
|
|
2117
|
+
let currentPort = port;
|
|
2118
|
+
let attempts = 0;
|
|
2119
|
+
const tryListen = () => {
|
|
2120
|
+
server.once("error", onError);
|
|
2121
|
+
server.listen(currentPort, () => {
|
|
2122
|
+
server.removeListener("error", onError);
|
|
2123
|
+
const actualPort = currentPort;
|
|
2124
|
+
console.error(`[spool] HTTP + SSE server listening on http://localhost:${actualPort}`);
|
|
2125
|
+
if (actualPort !== port) {
|
|
2126
|
+
console.error(`[spool] Port ${port} was in use, using ${actualPort} instead`);
|
|
2127
|
+
}
|
|
2128
|
+
console.error(`[spool] SSE endpoint at http://localhost:${actualPort}/api/events`);
|
|
2129
|
+
console.error(`[spool] Health check at http://localhost:${actualPort}/health`);
|
|
2130
|
+
console.error(`[spool] Inject script at http://localhost:${actualPort}/inject.js`);
|
|
2131
|
+
console.error(`[spool] State file: ${getStateFilePath()}`);
|
|
2132
|
+
try {
|
|
2133
|
+
ensureStateDir();
|
|
2134
|
+
const portFilePath = path2.resolve(projectDir, ".spool/port");
|
|
2135
|
+
fs2.writeFileSync(portFilePath, String(actualPort), "utf-8");
|
|
2136
|
+
console.error(`[spool] Wrote port ${actualPort} to ${portFilePath}`);
|
|
2137
|
+
} catch (e) {
|
|
2138
|
+
console.error(`[spool] Failed to write port file:`, e);
|
|
2139
|
+
}
|
|
2140
|
+
resolve3();
|
|
2141
|
+
});
|
|
2142
|
+
};
|
|
2143
|
+
function onError(err) {
|
|
2144
|
+
if (err.code === "EADDRINUSE") {
|
|
2145
|
+
attempts++;
|
|
2146
|
+
if (attempts >= MAX_PORT_ATTEMPTS) {
|
|
2147
|
+
console.error(`[spool] Could not find an available port after ${MAX_PORT_ATTEMPTS} attempts`);
|
|
2148
|
+
stopFileWatcher();
|
|
2149
|
+
reject(err);
|
|
2150
|
+
return;
|
|
2151
|
+
}
|
|
2152
|
+
currentPort++;
|
|
2153
|
+
console.error(`[spool] Port ${currentPort - 1} is in use, trying ${currentPort}...`);
|
|
2154
|
+
tryListen();
|
|
2155
|
+
} else {
|
|
2156
|
+
reject(err);
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
tryListen();
|
|
2160
|
+
});
|
|
2161
|
+
const shutdown = () => {
|
|
2162
|
+
console.error(`[spool] Shutting down...`);
|
|
2163
|
+
stopFileWatcher();
|
|
2164
|
+
try {
|
|
2165
|
+
const portFilePath = path2.resolve(projectDir, ".spool/port");
|
|
2166
|
+
if (fs2.existsSync(portFilePath)) fs2.unlinkSync(portFilePath);
|
|
2167
|
+
} catch {
|
|
2168
|
+
}
|
|
2169
|
+
server.close();
|
|
2170
|
+
process.exit(0);
|
|
2171
|
+
};
|
|
2172
|
+
process.on("SIGINT", shutdown);
|
|
2173
|
+
process.on("SIGTERM", shutdown);
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
// src/bin.ts
|
|
2177
|
+
var args = process.argv.slice(2);
|
|
2178
|
+
var projectDirIdx = args.indexOf("--project-dir");
|
|
2179
|
+
var cwd = projectDirIdx !== -1 && projectDirIdx + 1 < args.length ? args[projectDirIdx + 1] : void 0;
|
|
2180
|
+
startServer({ cwd }).catch((error) => {
|
|
2181
|
+
console.error(`[spool] Fatal error:`, error);
|
|
2182
|
+
process.exit(1);
|
|
2183
|
+
});
|
|
2184
|
+
//# sourceMappingURL=bin.js.map
|