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