annotask 0.0.8 → 0.0.9

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/README.md CHANGED
@@ -15,15 +15,16 @@ Visual markup tool for web apps. Annotate your UI in the browser — pins, arrow
15
15
  Annotate the UI:
16
16
  pin elements, draw
17
17
  sections, add notes, ──> /annotask-apply
18
- describe what you want Reads tasks from Annotask API
19
- Edits source files
20
- Marks tasks as "ready for review"
21
- Review changes in <──
22
- Annotask shell:
18
+ describe what you want Fetches pending tasks
19
+ Locks each task (in_progress)
20
+ Applies code change
21
+ Marks for review — one at a time
22
+ Review changes live <──
23
+ as they come in:
23
24
  Accept ✓ or Deny ✗
24
- (denied tasks return
25
- with your feedback
26
- for the agent to retry)
25
+ (denied tasks include your
26
+ feedback for the agent
27
+ to retry with corrections)
27
28
  ```
28
29
 
29
30
  ### How it works
@@ -32,9 +33,9 @@ Visual markup tool for web apps. Annotate your UI in the browser — pins, arrow
32
33
 
33
34
  2. **Tasks are created** — Each annotation becomes a structured task with full context: source file, line number, component name, element tag, surrounding layout, and your intent in plain language.
34
35
 
35
- 3. **Your coding agent applies the tasks** — Invoke `/annotask-apply` in Claude Code (or the equivalent skill in your agent). It fetches pending tasks from the Annotask API, edits the source files, and marks each task as ready for review.
36
+ 3. **Your coding agent applies the tasks** — Invoke `/annotask-apply` in Claude Code (or the equivalent skill in your agent). It processes tasks one at a time: locks the task (`in_progress`), applies the code change, then marks it for review so you can start reviewing immediately while later tasks are still being applied.
36
37
 
37
- 4. **You review** — Back in the Annotask shell, accept or deny each change. Denied tasks return to the queue with your feedback so the agent can retry with corrections.
38
+ 4. **You review** — In the Annotask shell, click any task to open the detail drawer — see the full markdown description, screenshots, element context, interaction history, and source files. Accept or deny each change. Denied tasks include your feedback so the agent can retry with corrections.
38
39
 
39
40
  ## Agent Setup
40
41
 
@@ -166,9 +167,21 @@ Start your dev server, then open:
166
167
  - **Page-level scanning** — Runs axe-core WCAG analysis on the entire page from the A11y panel
167
168
  - **Violation cards** — Shows impact level, rule, description, and affected element count
168
169
  - **One-click fix tasks** — Create tasks from violations with full context (HTML snippets, CSS selectors, source file/line, and fix suggestions)
170
+ - **Locally bundled** — axe-core and html2canvas are shipped with the package (no CDN dependency, works offline and under CSP)
171
+
172
+ ### Task detail drawer
173
+ - **Slide-out detail view** — Click any task in the sidebar to open a full detail drawer
174
+ - **Markdown descriptions** — Task descriptions support full GitHub-flavored Markdown (rendered with `marked`)
175
+ - **Inline editing** — Click the rendered description to switch to a markdown editor; save with Ctrl+Enter
176
+ - **Screenshot thumbnails** — Clickable thumbnails with full-screen lightbox preview
177
+ - **Element display** — Shows selected element(s) with tag, classes, and component name
178
+ - **Multi-file view** — Shows all source files involved (primary, arrow targets, multi-element)
179
+ - **Interaction log** — History displayed as a numbered action log with route navigation and click details
180
+ - **JSON view** — Toggle raw JSON view of the complete task object with copy button
181
+ - **Accept/Deny actions** — Review tasks directly from the detail drawer (deny form includes screenshot, history, and DOM context options)
169
182
 
170
183
  ### Screenshots
171
- - **Snipping tool** — Click "Add Screenshot" on any task form, then drag a region or click for full-page capture
184
+ - **Snipping tool** — Click "Add Screenshot" on any task form or deny form, then drag a region or click for full-page capture
172
185
  - **Thumbnail preview** — Screenshot appears as a preview on the task form before submitting (removable)
173
186
  - **Task-attached** — Screenshots are stored on the server and referenced by filename in the task
174
187
  - **Multimodal AI context** — AI agents can download and view screenshots for visual understanding of what the user sees
@@ -188,8 +201,10 @@ These optional features give the AI agent richer context beyond just "change thi
188
201
  > The agent can find breakpoints by reading config files and stylesheets — and usually will. This pre-detects them so the agent has the project's breakpoint system immediately alongside the viewport dimensions on each task. When a task is created at 375px, the agent can instantly map that to the right Tailwind prefix, Bootstrap tier, or custom media query without searching the codebase first.
189
202
 
190
203
  ### Infrastructure
191
- - **Change reports** — Structured JSON of all changes, ready for agents to consume
192
- - **Task pipeline** — Create, review, accept, or deny design change tasks
204
+ - **Change reports** — Structured JSON of all changes, ready for agents to consume (supports `?mfe=` filtering)
205
+ - **Task pipeline** — `pending → in_progress → review accepted/denied` lifecycle with live status updates
206
+ - **Security** — CORS restricted to localhost origins, PATCH field whitelisting, postMessage sender validation
207
+ - **Async I/O** — In-memory task cache with atomic file writes (no race conditions under concurrent access)
193
208
  - **CLI** — `annotask tasks`, `annotask report`, `annotask watch` for terminal access
194
209
  - **API** — HTTP and WebSocket endpoints for programmatic access
195
210
 
@@ -207,15 +222,17 @@ Options: `--port=N`, `--host=H`, `--server=URL` (override server.json), `--mfe=N
207
222
 
208
223
  ## API
209
224
 
210
- - `GET /__annotask/api/report` — Current change report
225
+ - `GET /__annotask/api/report` — Current change report (supports `?mfe=NAME` filter)
211
226
  - `GET /__annotask/api/tasks` — Task list (supports `?mfe=NAME` filter)
212
227
  - `POST /__annotask/api/tasks` — Create a task
213
- - `PATCH /__annotask/api/tasks/:id` — Update task status
214
- - `POST /__annotask/api/screenshots` — Upload a screenshot (base64 PNG)
228
+ - `PATCH /__annotask/api/tasks/:id` — Update task (whitelisted fields: status, description, feedback, screenshot, viewport, etc.)
229
+ - `POST /__annotask/api/screenshots` — Upload a screenshot (base64 PNG, max 4MB)
215
230
  - `GET /__annotask/screenshots/:filename` — Serve a screenshot
216
231
  - `GET /__annotask/api/status` — Health check
217
232
  - `ws://localhost:5173/__annotask/ws` — Live WebSocket stream
218
233
 
234
+ CORS is restricted to localhost origins. Mutating requests from non-local origins are rejected.
235
+
219
236
  ## Supported Frameworks
220
237
 
221
238
  | Framework | Vite | Webpack |
@@ -230,16 +247,17 @@ Options: `--port=N`, `--host=H`, `--server=URL` (override server.json), `--mfe=N
230
247
  ## Limitations
231
248
 
232
249
  - **Dev mode only** — Annotask only runs in dev servers (Vite or Webpack), never in production builds
233
- - **Local only** — API and WebSocket endpoints are unauthenticated (same model as Vite HMR)
250
+ - **Local only** — API and WebSocket endpoints are localhost-restricted (CORS enforced, same model as Vite HMR)
234
251
  - **Source mapping** — Works best with component files (`.vue`, `.tsx`, `.svelte`, `.astro`, `.html`); dynamic components and render functions may not map correctly
235
252
 
236
253
  ## Development
237
254
 
238
255
  ```bash
239
256
  pnpm install
240
- pnpm build # Build shell + plugin + CLI
257
+ pnpm build # Build shell + plugin + CLI + vendor deps
241
258
  pnpm dev:vue-vite # Start Vue test app with Annotask
242
- pnpm test # Run tests
259
+ pnpm test # Run unit tests
260
+ pnpm typecheck # Type-check (tsc + vue-tsc)
243
261
  pnpm test:e2e # Run E2E tests (all frameworks)
244
262
  ```
245
263
 
@@ -249,6 +267,8 @@ pnpm test:e2e # Run E2E tests (all frameworks)
249
267
  - `src/server/` — HTTP API, WebSocket server, shell serving, project state
250
268
  - `src/webpack/` — Webpack plugin and transform loader
251
269
  - `src/shell/` — Design tool UI (Vue 3 app, pre-built into dist/shell/)
270
+ - `src/shell/composables/` — Vue composables (style editor, tasks, screenshots, keyboard shortcuts, a11y scanner, etc.)
271
+ - `src/shell/components/` — UI components (inspector tabs, overlays, task detail drawer, report viewer, etc.)
252
272
  - `src/shared/` — Shared types (postMessage bridge protocol)
253
273
  - `src/schema.ts` — TypeScript types for change reports
254
274
  - `src/cli/` — CLI tool for terminal interaction
@@ -0,0 +1,496 @@
1
+ // src/server/api.ts
2
+ import fsp from "fs/promises";
3
+ import nodePath from "path";
4
+ var MAX_BODY_SIZE = 4194304;
5
+ var VALID_TASK_STATUSES = /* @__PURE__ */ new Set(["pending", "in_progress", "applied", "review", "accepted", "denied"]);
6
+ var PATCHABLE_TASK_FIELDS = /* @__PURE__ */ new Set([
7
+ "status",
8
+ "description",
9
+ "notes",
10
+ "screenshot",
11
+ "feedback",
12
+ "intent",
13
+ "action",
14
+ "context",
15
+ "viewport",
16
+ "interaction_history",
17
+ "element_context",
18
+ "mfe"
19
+ ]);
20
+ function readBody(req) {
21
+ return new Promise((resolve, reject) => {
22
+ let body = "";
23
+ let size = 0;
24
+ req.on("data", (chunk) => {
25
+ size += chunk.length;
26
+ if (size > MAX_BODY_SIZE) {
27
+ req.destroy();
28
+ reject(new Error("Request body too large"));
29
+ return;
30
+ }
31
+ body += chunk.toString();
32
+ });
33
+ req.on("end", () => resolve(body));
34
+ req.on("error", reject);
35
+ });
36
+ }
37
+ function parseJSON(raw) {
38
+ try {
39
+ return { ok: true, data: JSON.parse(raw) };
40
+ } catch {
41
+ return { ok: false };
42
+ }
43
+ }
44
+ function sendError(res, status, message) {
45
+ res.statusCode = status;
46
+ res.end(JSON.stringify({ error: message }));
47
+ }
48
+ function isLocalOrigin(origin) {
49
+ if (!origin) return true;
50
+ try {
51
+ const url = new URL(origin);
52
+ const host = url.hostname;
53
+ return host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1";
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+ function getCorsOrigin(req) {
59
+ const origin = req.headers.origin;
60
+ if (!origin) return null;
61
+ return isLocalOrigin(origin) ? origin : null;
62
+ }
63
+ function createAPIMiddleware(options) {
64
+ return async (req, res, next) => {
65
+ if (req.url?.startsWith("/__annotask/screenshots/") && req.method === "GET") {
66
+ const filename = req.url.replace("/__annotask/screenshots/", "").replace(/\?.*$/, "");
67
+ if (!/^[a-zA-Z0-9_-]+\.png$/.test(filename)) {
68
+ res.statusCode = 400;
69
+ res.end("Invalid filename");
70
+ return;
71
+ }
72
+ const filePath = nodePath.join(options.projectRoot, ".annotask", "screenshots", filename);
73
+ try {
74
+ const data = await fsp.readFile(filePath);
75
+ res.setHeader("Content-Type", "image/png");
76
+ res.setHeader("Cache-Control", "public, max-age=3600");
77
+ const corsOrigin2 = getCorsOrigin(req);
78
+ if (corsOrigin2) res.setHeader("Access-Control-Allow-Origin", corsOrigin2);
79
+ res.end(data);
80
+ } catch {
81
+ res.statusCode = 404;
82
+ res.end("Not found");
83
+ }
84
+ return;
85
+ }
86
+ if (!req.url?.startsWith("/__annotask/api/")) return next();
87
+ const path3 = req.url.replace("/__annotask/api/", "");
88
+ const corsOrigin = getCorsOrigin(req);
89
+ res.setHeader("Content-Type", "application/json");
90
+ if (corsOrigin) {
91
+ res.setHeader("Access-Control-Allow-Origin", corsOrigin);
92
+ res.setHeader("Vary", "Origin");
93
+ }
94
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS");
95
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
96
+ res.setHeader("Cache-Control", "no-cache");
97
+ if (req.method === "OPTIONS") {
98
+ res.statusCode = 200;
99
+ res.end();
100
+ return;
101
+ }
102
+ if ((req.method === "POST" || req.method === "PATCH") && !isLocalOrigin(req.headers.origin)) {
103
+ return sendError(res, 403, "Forbidden: non-local origin");
104
+ }
105
+ if (path3 === "report" && req.method === "GET") {
106
+ const report = options.getReport() ?? { version: "1.0", changes: [] };
107
+ const urlObj = new URL(req.url, `http://${req.headers.host || "localhost"}`);
108
+ const mfeFilter = urlObj.searchParams.get("mfe");
109
+ if (mfeFilter && report.changes) {
110
+ const filtered = { ...report, changes: report.changes.filter((c) => c.mfe === mfeFilter) };
111
+ res.end(JSON.stringify(filtered, null, 2));
112
+ } else {
113
+ res.end(JSON.stringify(report, null, 2));
114
+ }
115
+ return;
116
+ }
117
+ if (path3 === "config" && req.method === "GET") {
118
+ res.end(JSON.stringify(options.getConfig(), null, 2));
119
+ return;
120
+ }
121
+ if (path3 === "design-spec" && req.method === "GET") {
122
+ res.end(JSON.stringify(options.getDesignSpec(), null, 2));
123
+ return;
124
+ }
125
+ if (path3.startsWith("tasks") && !path3.startsWith("tasks/") && req.method === "GET") {
126
+ const urlObj = new URL(req.url, `http://${req.headers.host || "localhost"}`);
127
+ const mfeFilter = urlObj.searchParams.get("mfe");
128
+ const taskData = options.getTasks();
129
+ if (mfeFilter) {
130
+ const filtered = { ...taskData, tasks: taskData.tasks.filter((t) => t.mfe === mfeFilter) };
131
+ res.end(JSON.stringify(filtered, null, 2));
132
+ } else {
133
+ res.end(JSON.stringify(taskData, null, 2));
134
+ }
135
+ return;
136
+ }
137
+ if (path3 === "tasks" && req.method === "POST") {
138
+ let raw;
139
+ try {
140
+ raw = await readBody(req);
141
+ } catch {
142
+ return sendError(res, 413, "Request body too large");
143
+ }
144
+ const parsed = parseJSON(raw);
145
+ if (!parsed.ok) return sendError(res, 400, "Invalid JSON body");
146
+ const body = parsed.data;
147
+ if (!body || typeof body !== "object" || Array.isArray(body)) return sendError(res, 400, "Request body must be a JSON object");
148
+ if (typeof body.type !== "string" || !body.type) return sendError(res, 400, "Missing required field: type (string)");
149
+ if (typeof body.description !== "string") return sendError(res, 400, "Missing required field: description (string)");
150
+ res.end(JSON.stringify(options.addTask(body), null, 2));
151
+ return;
152
+ }
153
+ if (path3 === "screenshots" && req.method === "POST") {
154
+ let raw;
155
+ try {
156
+ raw = await readBody(req);
157
+ } catch {
158
+ return sendError(res, 413, "Request body too large");
159
+ }
160
+ const parsed = parseJSON(raw);
161
+ if (!parsed.ok) return sendError(res, 400, "Invalid JSON body");
162
+ const body = parsed.data;
163
+ if (!body.data || typeof body.data !== "string") return sendError(res, 400, "Missing data field");
164
+ const match = body.data.match(/^data:image\/png;base64,(.+)$/);
165
+ if (!match) return sendError(res, 400, "Invalid PNG data URL");
166
+ const buffer = Buffer.from(match[1], "base64");
167
+ if (buffer.length > 4 * 1024 * 1024) return sendError(res, 413, "Screenshot too large (max 4MB)");
168
+ const filename = `screenshot-${Date.now()}-${Math.random().toString(36).slice(2, 7)}.png`;
169
+ const dir = nodePath.join(options.projectRoot, ".annotask", "screenshots");
170
+ await fsp.mkdir(dir, { recursive: true });
171
+ await fsp.writeFile(nodePath.join(dir, filename), buffer);
172
+ res.end(JSON.stringify({ filename }));
173
+ return;
174
+ }
175
+ if (path3.startsWith("tasks/") && req.method === "PATCH") {
176
+ const id = path3.replace("tasks/", "");
177
+ let raw;
178
+ try {
179
+ raw = await readBody(req);
180
+ } catch {
181
+ return sendError(res, 413, "Request body too large");
182
+ }
183
+ const parsed = parseJSON(raw);
184
+ if (!parsed.ok) return sendError(res, 400, "Invalid JSON body");
185
+ const body = parsed.data;
186
+ if (!body || typeof body !== "object" || Array.isArray(body)) return sendError(res, 400, "Request body must be a JSON object");
187
+ if (body.status !== void 0 && !VALID_TASK_STATUSES.has(body.status)) {
188
+ return sendError(res, 400, `Invalid status. Must be one of: ${[...VALID_TASK_STATUSES].join(", ")}`);
189
+ }
190
+ const sanitized = {};
191
+ for (const key of Object.keys(body)) {
192
+ if (PATCHABLE_TASK_FIELDS.has(key)) {
193
+ sanitized[key] = body[key];
194
+ }
195
+ }
196
+ res.end(JSON.stringify(options.updateTask(id, sanitized), null, 2));
197
+ return;
198
+ }
199
+ if (path3 === "status" && req.method === "GET") {
200
+ res.end(JSON.stringify({ status: "ok", tool: "annotask" }));
201
+ return;
202
+ }
203
+ res.statusCode = 404;
204
+ res.end(JSON.stringify({ error: "Not found" }));
205
+ };
206
+ }
207
+
208
+ // src/server/ws-server.ts
209
+ import { WebSocketServer, WebSocket } from "ws";
210
+ function createWSServer() {
211
+ let currentReport = null;
212
+ const clients = /* @__PURE__ */ new Set();
213
+ const wss = new WebSocketServer({ noServer: true });
214
+ wss.on("connection", (ws) => {
215
+ clients.add(ws);
216
+ if (currentReport) {
217
+ ws.send(JSON.stringify({ event: "report:current", data: currentReport, timestamp: Date.now() }));
218
+ }
219
+ ws.on("message", (raw) => {
220
+ try {
221
+ const msg = JSON.parse(raw.toString());
222
+ if (msg.event === "report:updated") {
223
+ currentReport = msg.data;
224
+ for (const client of clients) {
225
+ if (client !== ws && client.readyState === WebSocket.OPEN) {
226
+ client.send(JSON.stringify({ event: "report:updated", data: msg.data, timestamp: Date.now() }));
227
+ }
228
+ }
229
+ }
230
+ if (msg.event === "changes:cleared") {
231
+ currentReport = null;
232
+ for (const client of clients) {
233
+ if (client !== ws && client.readyState === WebSocket.OPEN) {
234
+ client.send(JSON.stringify({ event: "changes:cleared", data: null, timestamp: Date.now() }));
235
+ }
236
+ }
237
+ }
238
+ if (msg.event === "get:report") {
239
+ ws.send(JSON.stringify({ event: "report:current", data: currentReport, timestamp: Date.now() }));
240
+ }
241
+ } catch {
242
+ }
243
+ });
244
+ ws.on("close", () => {
245
+ clients.delete(ws);
246
+ });
247
+ });
248
+ return {
249
+ handleUpgrade(req, socket, head) {
250
+ wss.handleUpgrade(req, socket, head, (ws) => {
251
+ wss.emit("connection", ws, req);
252
+ });
253
+ },
254
+ broadcast(event, data) {
255
+ const msg = JSON.stringify({ event, data, timestamp: Date.now() });
256
+ for (const client of clients) {
257
+ if (client.readyState === WebSocket.OPEN) client.send(msg);
258
+ }
259
+ },
260
+ getReport() {
261
+ return currentReport;
262
+ },
263
+ clients
264
+ };
265
+ }
266
+
267
+ // src/server/serve-shell.ts
268
+ import fsp2 from "fs/promises";
269
+ import path from "path";
270
+ import { fileURLToPath } from "url";
271
+ var __dirname = path.dirname(fileURLToPath(import.meta.url));
272
+ function findShellDist() {
273
+ return path.resolve(__dirname, "shell");
274
+ }
275
+ function findVendorDist() {
276
+ return path.resolve(__dirname, "vendor");
277
+ }
278
+ function createShellMiddleware() {
279
+ const shellDist = findShellDist();
280
+ const vendorDist = findVendorDist();
281
+ return async (req, res, next) => {
282
+ if (!req.url?.startsWith("/__annotask")) return next();
283
+ const origin = req.headers.origin;
284
+ if (origin) {
285
+ try {
286
+ const host = new URL(origin).hostname;
287
+ if (host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1") {
288
+ res.setHeader("Access-Control-Allow-Origin", origin);
289
+ res.setHeader("Vary", "Origin");
290
+ }
291
+ } catch {
292
+ }
293
+ }
294
+ let filePath = req.url.replace("/__annotask", "") || "/";
295
+ const queryIndex = filePath.indexOf("?");
296
+ if (queryIndex !== -1) filePath = filePath.slice(0, queryIndex);
297
+ if (filePath === "/" || filePath === "") filePath = "/index.html";
298
+ if (filePath.startsWith("/api/") || filePath === "/ws") return next();
299
+ if (filePath.startsWith("/vendor/")) {
300
+ const vendorFile = path.join(vendorDist, filePath.replace("/vendor/", ""));
301
+ if (!vendorFile.startsWith(vendorDist)) {
302
+ res.statusCode = 403;
303
+ res.end("Forbidden");
304
+ return;
305
+ }
306
+ try {
307
+ const data = await fsp2.readFile(vendorFile);
308
+ res.setHeader("Content-Type", "application/javascript");
309
+ res.setHeader("Cache-Control", "public, max-age=86400");
310
+ res.end(data);
311
+ } catch {
312
+ res.statusCode = 404;
313
+ res.end("Vendor file not found");
314
+ return;
315
+ }
316
+ return;
317
+ }
318
+ const fullPath = path.join(shellDist, filePath);
319
+ if (!fullPath.startsWith(shellDist)) {
320
+ res.statusCode = 403;
321
+ res.end("Forbidden");
322
+ return;
323
+ }
324
+ try {
325
+ const stat = await fsp2.stat(fullPath);
326
+ if (!stat.isFile()) throw new Error("not a file");
327
+ const ext = path.extname(fullPath);
328
+ const contentTypes = {
329
+ ".html": "text/html",
330
+ ".js": "application/javascript",
331
+ ".css": "text/css",
332
+ ".json": "application/json",
333
+ ".svg": "image/svg+xml",
334
+ ".png": "image/png",
335
+ ".ico": "image/x-icon"
336
+ };
337
+ res.setHeader("Content-Type", contentTypes[ext] || "application/octet-stream");
338
+ const data = await fsp2.readFile(fullPath);
339
+ res.end(data);
340
+ } catch {
341
+ const indexPath = path.join(shellDist, "index.html");
342
+ try {
343
+ const html = await fsp2.readFile(indexPath, "utf-8");
344
+ res.setHeader("Content-Type", "text/html");
345
+ res.end(html);
346
+ } catch {
347
+ res.statusCode = 404;
348
+ res.end("Annotask shell not built. Run: pnpm build:shell");
349
+ }
350
+ }
351
+ };
352
+ }
353
+
354
+ // src/server/state.ts
355
+ import fs from "fs";
356
+ import fsp3 from "fs/promises";
357
+ import path2 from "path";
358
+ var DEFAULT_DESIGN_SPEC = {
359
+ initialized: false,
360
+ version: "1.0",
361
+ framework: null,
362
+ colors: [],
363
+ typography: { families: [], scale: [], weights: [] },
364
+ spacing: [],
365
+ borders: { radius: [] },
366
+ icons: null,
367
+ components: null
368
+ };
369
+ async function atomicWrite(filePath, data) {
370
+ const dir = path2.dirname(filePath);
371
+ await fsp3.mkdir(dir, { recursive: true });
372
+ const tmpPath = filePath + `.tmp.${process.pid}.${Date.now()}`;
373
+ await fsp3.writeFile(tmpPath, data, "utf-8");
374
+ await fsp3.rename(tmpPath, filePath);
375
+ }
376
+ function createProjectState(projectRoot, broadcast) {
377
+ let cachedDesignSpec = null;
378
+ let specWatcher = null;
379
+ const tasksPath = path2.join(projectRoot, ".annotask", "tasks.json");
380
+ let taskCache = null;
381
+ let writeQueue = Promise.resolve();
382
+ function loadTasksSync() {
383
+ if (taskCache) return taskCache;
384
+ try {
385
+ taskCache = JSON.parse(fs.readFileSync(tasksPath, "utf-8"));
386
+ } catch {
387
+ taskCache = { version: "1.0", tasks: [] };
388
+ }
389
+ return taskCache;
390
+ }
391
+ function flushTasks() {
392
+ const data = taskCache;
393
+ if (!data) return;
394
+ writeQueue = writeQueue.then(() => atomicWrite(tasksPath, JSON.stringify(data, null, 2))).catch(() => {
395
+ });
396
+ }
397
+ function getDesignSpec() {
398
+ if (cachedDesignSpec !== null) return cachedDesignSpec;
399
+ const specPath = path2.join(projectRoot, ".annotask", "design-spec.json");
400
+ try {
401
+ cachedDesignSpec = { initialized: true, ...JSON.parse(fs.readFileSync(specPath, "utf-8")) };
402
+ } catch {
403
+ cachedDesignSpec = DEFAULT_DESIGN_SPEC;
404
+ }
405
+ if (!specWatcher) {
406
+ const configDir = path2.join(projectRoot, ".annotask");
407
+ try {
408
+ if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true });
409
+ specWatcher = fs.watch(configDir, (_, filename) => {
410
+ cachedDesignSpec = null;
411
+ if (filename === "design-spec.json") broadcast("designspec:updated", null);
412
+ if (filename === "tasks.json") {
413
+ taskCache = null;
414
+ }
415
+ });
416
+ } catch {
417
+ cachedDesignSpec = null;
418
+ }
419
+ }
420
+ return cachedDesignSpec ?? DEFAULT_DESIGN_SPEC;
421
+ }
422
+ function getConfig() {
423
+ const spec = getDesignSpec();
424
+ return { initialized: !!spec?.initialized, ...spec };
425
+ }
426
+ function addTask(task) {
427
+ const data = loadTasksSync();
428
+ const id = `task-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
429
+ const newTask = { id, status: "pending", createdAt: Date.now(), updatedAt: Date.now(), ...task };
430
+ data.tasks.push(newTask);
431
+ flushTasks();
432
+ broadcast("tasks:updated", data);
433
+ return newTask;
434
+ }
435
+ function updateTask(id, updates) {
436
+ const data = loadTasksSync();
437
+ const task = data.tasks.find((t) => t.id === id);
438
+ if (!task) return { error: "Task not found" };
439
+ Object.assign(task, updates, { updatedAt: Date.now() });
440
+ if (updates.status === "accepted") {
441
+ if (task.screenshot) {
442
+ const screenshotPath = path2.join(projectRoot, ".annotask", "screenshots", task.screenshot);
443
+ fsp3.unlink(screenshotPath).catch(() => {
444
+ });
445
+ }
446
+ data.tasks = data.tasks.filter((t) => t.id !== id);
447
+ }
448
+ flushTasks();
449
+ broadcast("tasks:updated", data);
450
+ return task;
451
+ }
452
+ function dispose() {
453
+ if (specWatcher) {
454
+ specWatcher.close();
455
+ specWatcher = null;
456
+ }
457
+ }
458
+ return { getDesignSpec, getConfig, getTasks: loadTasksSync, addTask, updateTask, dispose };
459
+ }
460
+
461
+ // src/server/index.ts
462
+ function createAnnotaskServer(options) {
463
+ const wsServer = createWSServer();
464
+ const state = createProjectState(options.projectRoot, wsServer.broadcast);
465
+ const apiMiddleware = createAPIMiddleware({
466
+ projectRoot: options.projectRoot,
467
+ getReport: () => wsServer.getReport(),
468
+ getConfig: () => state.getConfig(),
469
+ getDesignSpec: () => state.getDesignSpec(),
470
+ getTasks: () => state.getTasks(),
471
+ addTask: (task) => state.addTask(task),
472
+ updateTask: (id, updates) => state.updateTask(id, updates)
473
+ });
474
+ const shellMiddleware = createShellMiddleware();
475
+ const middleware = (req, res, next) => {
476
+ apiMiddleware(req, res, () => {
477
+ shellMiddleware(req, res, next);
478
+ });
479
+ };
480
+ return {
481
+ middleware,
482
+ handleUpgrade: (req, socket, head) => wsServer.handleUpgrade(req, socket, head),
483
+ broadcast: (event, data) => wsServer.broadcast(event, data),
484
+ getReport: () => wsServer.getReport(),
485
+ dispose: () => state.dispose()
486
+ };
487
+ }
488
+
489
+ export {
490
+ createAPIMiddleware,
491
+ createWSServer,
492
+ createShellMiddleware,
493
+ createProjectState,
494
+ createAnnotaskServer
495
+ };
496
+ //# sourceMappingURL=chunk-7S23HMBH.js.map