@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 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