@longshot/cli 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1250 @@
1
+ import { Hono } from "hono";
2
+ import { serve } from "@hono/node-server";
3
+ import { serveStatic } from "@hono/node-server/serve-static";
4
+ import { readFile as fsReadFile } from "node:fs/promises";
5
+ import { existsSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { marked } from "marked";
8
+ import * as store from "./store.js";
9
+ import * as git from "./git.js";
10
+ import { spawnAgent, chatWithClaude } from "./agent.js";
11
+ import { enqueue, getQueueState, pendingDiffs, persistPendingDiffs, reloadPendingDiffs, startQueueProcessor, findConflictItem, resolveConflictRestart, resolveConflictAgent, resolveConflictManual, resolveConflictDone } from "./queue.js";
12
+ import { chatPage } from "./views/chat.js";
13
+ import { specPage } from "./views/spec.js";
14
+ import { diffPage } from "./views/diff.js";
15
+ import { historyPage } from "./views/history.js";
16
+ import { tasksListPage, taskDetailPage } from "./views/tasks.js";
17
+ import { runPage } from "./views/run.js";
18
+ import { agentProgressPage } from "./views/agent-progress.js";
19
+ import { verifyPage, verifyLogPage } from "./views/verify.js";
20
+ import { servicesPage, serviceLogsPage } from "./views/services.js";
21
+ import { branchesPage } from "./views/branches.js";
22
+ import * as services from "./services.js";
23
+ import * as projects from "./projects.js";
24
+ const app = new Hono();
25
+ const PORT = parseInt(process.env.PORT || "3333", 10);
26
+ const runningVerifications = [];
27
+ // --- Static files ---
28
+ app.use("/style.css", serveStatic({ root: "./public" }));
29
+ // --- Project API routes (multi-project mode) ---
30
+ app.get("/api/projects", (c) => {
31
+ if (!projects.isMultiProject())
32
+ return c.json({ multiProject: false, projects: [] });
33
+ return c.json({
34
+ multiProject: true,
35
+ projects: projects.readProjects(),
36
+ current: projects.getCurrentProjectName(),
37
+ });
38
+ });
39
+ app.get("/api/projects/current", (c) => {
40
+ return c.json({
41
+ multiProject: projects.isMultiProject(),
42
+ current: projects.getCurrentProjectName(),
43
+ });
44
+ });
45
+ app.post("/api/projects/current", async (c) => {
46
+ if (!projects.isMultiProject())
47
+ return c.json({ error: "Not in multi-project mode" }, 400);
48
+ const body = await c.req.json();
49
+ if (!body.name)
50
+ return c.json({ error: "No project name" }, 400);
51
+ const ok = projects.switchProject(body.name);
52
+ if (!ok)
53
+ return c.json({ error: "Project not found" }, 404);
54
+ // Reload pending diffs for the new project
55
+ reloadPendingDiffs();
56
+ return c.json({ ok: true, current: body.name });
57
+ });
58
+ app.post("/api/projects", async (c) => {
59
+ if (!projects.isMultiProject())
60
+ return c.json({ error: "Not in multi-project mode" }, 400);
61
+ const body = await c.req.json();
62
+ if (!body.name?.trim())
63
+ return c.json({ error: "No project name" }, 400);
64
+ const result = projects.createProject(body.name.trim());
65
+ if (!result.ok)
66
+ return c.json({ error: result.error }, 400);
67
+ // Switch to the new project
68
+ projects.switchProject(body.name.trim());
69
+ reloadPendingDiffs();
70
+ return c.json({ ok: true }, 201);
71
+ });
72
+ app.post("/api/projects/adopt", async (c) => {
73
+ if (!projects.isMultiProject())
74
+ return c.json({ error: "Not in multi-project mode" }, 400);
75
+ const body = await c.req.json();
76
+ if (!body.name?.trim())
77
+ return c.json({ error: "No project name" }, 400);
78
+ const result = projects.adoptProject(body.name.trim());
79
+ if (!result.ok) {
80
+ const status = result.error?.includes("not found") ? 404 : 400;
81
+ return c.json({ error: result.error }, status);
82
+ }
83
+ reloadPendingDiffs();
84
+ return c.json({ ok: true });
85
+ });
86
+ app.get("/api/projects/available", (c) => {
87
+ if (!projects.isMultiProject())
88
+ return c.json({ available: [] });
89
+ return c.json({ available: projects.listAvailableSubdirs() });
90
+ });
91
+ // --- Routes ---
92
+ app.get("/", (c) => c.redirect("/chat"));
93
+ // Agent status (for polling)
94
+ app.get("/api/agent-status", (c) => {
95
+ const qs = getQueueState();
96
+ return c.json({ running: qs.agentRunning, runId: qs.currentRunId });
97
+ });
98
+ // Chat — Q&A with Claude + notepad
99
+ app.get("/chat", async (c) => {
100
+ const allMessages = await store.readConversation();
101
+ const messages = allMessages.slice(-50); // Show last 50 in UI
102
+ const pending = [];
103
+ for (const [diffId, diff] of pendingDiffs) {
104
+ pending.push({ diffId, summary: diff.task });
105
+ }
106
+ const qs = getQueueState();
107
+ const queue = await store.readQueue();
108
+ const queueCounts = store.getQueueCounts(queue);
109
+ const svcList = await services.getServicesWithStatus();
110
+ const chatServices = svcList.map((s) => ({ id: s.id, name: s.name, status: s.status }));
111
+ const historyEntries = await store.readHistory();
112
+ const recentHistory = historyEntries.slice(-10).reverse();
113
+ return c.html(chatPage(messages, pending, qs.agentRunning, qs.currentRunId, chatPending, queueCounts.pending, chatServices, recentHistory));
114
+ });
115
+ app.post("/chat", async (c) => {
116
+ const body = await c.req.json();
117
+ const message = body.message?.trim();
118
+ if (!message) {
119
+ return c.json({ error: "No message" }, 400);
120
+ }
121
+ let messages = await store.readConversation();
122
+ messages.push({
123
+ role: "user",
124
+ content: message,
125
+ timestamp: new Date().toISOString(),
126
+ });
127
+ messages = await store.archiveConversationIfNeeded(messages);
128
+ await store.saveConversation(messages);
129
+ return c.json({ ok: true });
130
+ });
131
+ // Chat with Claude (read-only, no tools)
132
+ let chatPending = false;
133
+ const taskChatPending = new Map();
134
+ app.post("/api/chat/send", async (c) => {
135
+ const body = await c.req.json();
136
+ const message = body.message?.trim();
137
+ if (!message)
138
+ return c.json({ error: "No message" }, 400);
139
+ if (chatPending)
140
+ return c.json({ error: "Chat already pending" }, 409);
141
+ // Save user message
142
+ let messages = await store.readConversation();
143
+ messages.push({
144
+ role: "user",
145
+ content: message,
146
+ timestamp: new Date().toISOString(),
147
+ });
148
+ messages = await store.archiveConversationIfNeeded(messages);
149
+ await store.saveConversation(messages);
150
+ // Spawn read-only Claude in background
151
+ chatPending = true;
152
+ (async () => {
153
+ try {
154
+ const response = await chatWithClaude(messages, projects.getProjectRoot());
155
+ let updated = await store.readConversation();
156
+ updated.push({
157
+ role: "assistant",
158
+ content: response,
159
+ timestamp: new Date().toISOString(),
160
+ });
161
+ updated = await store.archiveConversationIfNeeded(updated);
162
+ await store.saveConversation(updated);
163
+ }
164
+ catch (err) {
165
+ console.error("Chat error:", err);
166
+ let updated = await store.readConversation();
167
+ updated.push({
168
+ role: "assistant",
169
+ content: "Sorry, something went wrong getting a response.",
170
+ timestamp: new Date().toISOString(),
171
+ });
172
+ await store.saveConversation(updated);
173
+ }
174
+ finally {
175
+ chatPending = false;
176
+ }
177
+ })();
178
+ return c.json({ ok: true });
179
+ });
180
+ // Add service via Claude chat
181
+ let addServicePending = false;
182
+ app.post("/api/chat/add-service", async (c) => {
183
+ const body = await c.req.json();
184
+ const message = body.message?.trim();
185
+ if (!message)
186
+ return c.json({ error: "No message" }, 400);
187
+ if (addServicePending || chatPending)
188
+ return c.json({ error: "Chat already pending" }, 409);
189
+ // Save user message
190
+ let messages = await store.readConversation();
191
+ messages.push({
192
+ role: "user",
193
+ content: message,
194
+ timestamp: new Date().toISOString(),
195
+ });
196
+ messages = await store.archiveConversationIfNeeded(messages);
197
+ await store.saveConversation(messages);
198
+ // Spawn read-only Claude with service-extraction system prompt
199
+ addServicePending = true;
200
+ chatPending = true;
201
+ (async () => {
202
+ try {
203
+ const systemPrompt = `You are a helpful assistant. The user wants to add a background service (a long-running shell command like a dev server, watcher, etc.).
204
+
205
+ Extract a service definition from their message and respond with:
206
+ 1. A brief confirmation message
207
+ 2. A JSON block with the service definition
208
+
209
+ The JSON block MUST be wrapped in a code fence like this:
210
+ \`\`\`json
211
+ { "name": "Service Name", "command": "the shell command", "cwd": "/optional/working/directory" }
212
+ \`\`\`
213
+
214
+ Rules:
215
+ - "name" should be a short, descriptive name (e.g. "Vite Dev Server", "TypeScript Watcher")
216
+ - "command" is the shell command to run
217
+ - "cwd" is optional — only include it if the user specifies a working directory
218
+ - If you cannot determine a reasonable service definition from the message, respond normally without any JSON block`;
219
+ const response = await chatWithClaude(messages, projects.getProjectRoot(), { systemPrompt });
220
+ // Try to extract JSON service definition from the response
221
+ const jsonMatch = response.match(/```json\s*\n?([\s\S]*?)\n?\s*```/);
222
+ let service = null;
223
+ if (jsonMatch) {
224
+ try {
225
+ const parsed = JSON.parse(jsonMatch[1]);
226
+ if (parsed.name && parsed.command) {
227
+ const created = await store.createService(parsed.name.trim(), parsed.command.trim(), parsed.cwd?.trim() || undefined);
228
+ service = created;
229
+ }
230
+ }
231
+ catch {
232
+ // JSON parse failed — treat as normal chat response
233
+ }
234
+ }
235
+ // Save Claude's response to conversation
236
+ let updated = await store.readConversation();
237
+ updated.push({
238
+ role: "assistant",
239
+ content: response,
240
+ timestamp: new Date().toISOString(),
241
+ });
242
+ updated = await store.archiveConversationIfNeeded(updated);
243
+ await store.saveConversation(updated);
244
+ }
245
+ catch (err) {
246
+ console.error("Add service chat error:", err);
247
+ let updated = await store.readConversation();
248
+ updated.push({
249
+ role: "assistant",
250
+ content: "Sorry, something went wrong getting a response.",
251
+ timestamp: new Date().toISOString(),
252
+ });
253
+ await store.saveConversation(updated);
254
+ }
255
+ finally {
256
+ addServicePending = false;
257
+ chatPending = false;
258
+ }
259
+ })();
260
+ return c.json({ ok: true });
261
+ });
262
+ app.get("/api/chat/status", (c) => {
263
+ return c.json({ pending: chatPending });
264
+ });
265
+ app.get("/api/chat/messages", async (c) => {
266
+ const after = parseInt(c.req.query("after") || "0", 10);
267
+ const allMessages = await store.readConversation();
268
+ // Filter out agent messages (those with runId) — display-only
269
+ const filtered = allMessages.filter((m) => !m.runId);
270
+ return c.json({
271
+ messages: filtered.slice(after).map((m) => ({
272
+ ...m,
273
+ html: marked.parse(m.content, { async: false }),
274
+ })),
275
+ total: filtered.length,
276
+ pending: chatPending,
277
+ });
278
+ });
279
+ app.post("/chat/reset", async (c) => {
280
+ await store.clearConversation();
281
+ return c.redirect("/chat");
282
+ });
283
+ // --- Task chat helpers ---
284
+ async function buildTaskChatPrompt(taskId, task, diffContent) {
285
+ const spec = await store.readSpec();
286
+ const diffSection = diffContent
287
+ ? `\nCurrent diff (changes made so far):\n\`\`\`diff\n${diffContent.slice(0, 10000)}\n\`\`\`\n`
288
+ : "";
289
+ return `You are a helpful assistant for a software project. You can read files and search the codebase to answer questions accurately, but do not modify anything. You have access to Read, Glob, Grep, and LS tools for discovery.
290
+
291
+ Project spec:
292
+ ${spec}
293
+
294
+ Task #${taskId}: ${task.meta.title}
295
+ Task spec:
296
+ ${task.spec}
297
+ ${diffSection}`;
298
+ }
299
+ // --- Task chat routes ---
300
+ app.get("/api/tasks/:id/chat", async (c) => {
301
+ const id = c.req.param("id");
302
+ const taskIdStr = String(id);
303
+ const after = parseInt(c.req.query("after") || "0", 10);
304
+ const messages = await store.readTaskConversation(taskIdStr);
305
+ const pending = taskChatPending.get(id) || false;
306
+ return c.json({
307
+ messages: messages.slice(after).map((m) => ({
308
+ ...m,
309
+ html: marked.parse(m.content, { async: false }),
310
+ })),
311
+ total: messages.length,
312
+ pending,
313
+ });
314
+ });
315
+ app.post("/api/tasks/:id/chat", async (c) => {
316
+ const id = c.req.param("id");
317
+ const body = await c.req.json();
318
+ const message = body.message?.trim();
319
+ if (!message)
320
+ return c.json({ error: "No message" }, 400);
321
+ if (taskChatPending.get(id))
322
+ return c.json({ error: "Chat already pending" }, 409);
323
+ const task = await store.getTask(id);
324
+ if (!task)
325
+ return c.json({ error: "Task not found" }, 404);
326
+ // Save user message to task conversation
327
+ let messages = await store.readTaskConversation(id);
328
+ messages.push({
329
+ role: "user",
330
+ content: message,
331
+ timestamp: new Date().toISOString(),
332
+ });
333
+ messages = await store.archiveTaskConversationIfNeeded(id, messages);
334
+ await store.saveTaskConversation(id, messages);
335
+ // Spawn read-only Claude in background with task context
336
+ taskChatPending.set(id, true);
337
+ (async () => {
338
+ try {
339
+ const systemPrompt = await buildTaskChatPrompt(id, task);
340
+ const taskConv = await store.readTaskConversation(id);
341
+ const response = await chatWithClaude(taskConv, projects.getProjectRoot(), { systemPrompt });
342
+ let updated = await store.readTaskConversation(id);
343
+ updated.push({
344
+ role: "assistant",
345
+ content: response,
346
+ timestamp: new Date().toISOString(),
347
+ });
348
+ updated = await store.archiveTaskConversationIfNeeded(id, updated);
349
+ await store.saveTaskConversation(id, updated);
350
+ }
351
+ catch (err) {
352
+ console.error("Task chat error:", err);
353
+ let updated = await store.readTaskConversation(id);
354
+ updated.push({
355
+ role: "assistant",
356
+ content: "Sorry, something went wrong getting a response.",
357
+ timestamp: new Date().toISOString(),
358
+ });
359
+ await store.saveTaskConversation(id, updated);
360
+ }
361
+ finally {
362
+ taskChatPending.set(id, false);
363
+ }
364
+ })();
365
+ return c.json({ ok: true });
366
+ });
367
+ // --- Agent lifecycle routes ---
368
+ app.post("/agent/draft-task", async (c) => {
369
+ const body = await c.req.json().catch(() => ({}));
370
+ const userMessage = body.message?.trim();
371
+ if (!userMessage) {
372
+ return c.json({ error: "No message" }, 400);
373
+ }
374
+ const item = await enqueue("draft-task", { message: userMessage });
375
+ return c.json({ ok: true, id: item.id, taskId: item.params.taskId });
376
+ });
377
+ app.post("/agent/refine-task/:id", async (c) => {
378
+ const id = c.req.param("id");
379
+ const task = await store.getTask(id);
380
+ if (!task)
381
+ return c.json({ error: "Task not found" }, 404);
382
+ const body = await c.req.json().catch(() => ({}));
383
+ const userMessage = body.message?.trim() || "Refine this task spec based on recent chat feedback.";
384
+ const item = await enqueue("refine-task", { taskId: id, message: userMessage });
385
+ return c.json({ ok: true, id: item.id });
386
+ });
387
+ // Agent progress page
388
+ app.get("/agent/:runId", async (c) => {
389
+ const runId = c.req.param("runId");
390
+ const events = await store.readRunLog(runId);
391
+ const qs = getQueueState();
392
+ const isRunning = qs.agentRunning && qs.currentRunId === runId;
393
+ return c.html(agentProgressPage(runId, events, isRunning));
394
+ });
395
+ // Agent events polling endpoint
396
+ app.get("/api/agent/:runId/events", async (c) => {
397
+ const runId = c.req.param("runId");
398
+ const after = parseInt(c.req.query("after") || "0", 10);
399
+ const events = await store.readRunLog(runId);
400
+ const newEvents = events.slice(after);
401
+ const qs = getQueueState();
402
+ const isRunning = qs.agentRunning && qs.currentRunId === runId;
403
+ // Check if there's a diff_ready or auto_committed event
404
+ let diffId = null;
405
+ let autoCommitted = false;
406
+ for (const ev of events) {
407
+ if (ev.type === "diff_ready") {
408
+ diffId = ev.diffId;
409
+ }
410
+ if (ev.type === "auto_committed") {
411
+ autoCommitted = true;
412
+ }
413
+ }
414
+ // Check if agent is done (has a done event or diff_ready or agent_done_no_changes or auto_committed or agent_done_no_commit)
415
+ const isDone = events.some((e) => e.type === "done" || e.type === "diff_ready" || e.type === "agent_done_no_changes" || e.type === "auto_committed" || e.type === "agent_done_no_commit");
416
+ return c.json({
417
+ events: newEvents,
418
+ total: events.length,
419
+ done: isDone && !isRunning,
420
+ running: isRunning,
421
+ diffId,
422
+ autoCommitted,
423
+ });
424
+ });
425
+ // Tasks API (for polling)
426
+ app.get("/api/tasks", async (c) => {
427
+ const tasks = await store.listTasks();
428
+ reloadPendingDiffs();
429
+ const taskDiffs = {};
430
+ for (const [diffId, diff] of pendingDiffs) {
431
+ if (diff.taskId) {
432
+ taskDiffs[diff.taskId] = diffId;
433
+ }
434
+ }
435
+ const qs = getQueueState();
436
+ const queue = await store.readQueue();
437
+ let agentTaskId = null;
438
+ if (qs.agentRunning && qs.currentRunId) {
439
+ const running = queue.find((item) => item.status === "running");
440
+ if (running?.params.taskId)
441
+ agentTaskId = running.params.taskId;
442
+ }
443
+ // Collect task IDs with pending/running queue items
444
+ const pendingQueueTaskIds = queue
445
+ .filter((item) => (item.status === "pending" || item.status === "running") && item.params.taskId)
446
+ .map((item) => item.params.taskId);
447
+ return c.json({
448
+ tasks,
449
+ taskDiffs,
450
+ agentRunning: qs.agentRunning,
451
+ currentRunId: qs.currentRunId,
452
+ agentTaskId,
453
+ pendingQueueTaskIds,
454
+ });
455
+ });
456
+ app.get("/api/tasks/:id/status", async (c) => {
457
+ const id = c.req.param("id");
458
+ const task = await store.getTask(id);
459
+ if (!task)
460
+ return c.json({ error: "Task not found" }, 404);
461
+ const qs = getQueueState();
462
+ const queue = await store.readQueue();
463
+ let agentTaskId = null;
464
+ if (qs.agentRunning && qs.currentRunId) {
465
+ const running = queue.find((item) => item.status === "running");
466
+ if (running?.params.taskId)
467
+ agentTaskId = running.params.taskId;
468
+ }
469
+ reloadPendingDiffs();
470
+ let pendingDiffId = null;
471
+ for (const [diffId, diff] of pendingDiffs) {
472
+ if (diff.taskId === id) {
473
+ pendingDiffId = diffId;
474
+ break;
475
+ }
476
+ }
477
+ const hasPendingQueueItem = queue.some((item) => (item.status === "pending" || item.status === "running") && item.params.taskId === id);
478
+ // Find conflict error if in conflict status
479
+ let conflictError = null;
480
+ if (task.meta.status === "conflict") {
481
+ const conflictItem = findConflictItem(queue, id);
482
+ if (conflictItem) {
483
+ conflictError = conflictItem.error || "Merge conflict occurred";
484
+ }
485
+ }
486
+ const specHtml = task.spec
487
+ ? marked.parse(task.spec, { async: false })
488
+ : '<p class="empty-state">No task spec written yet.</p>';
489
+ return c.json({
490
+ status: task.meta.status,
491
+ title: task.meta.title,
492
+ agentRunning: qs.agentRunning,
493
+ currentRunId: qs.currentRunId,
494
+ agentTaskId,
495
+ pendingDiffId,
496
+ chatPending: taskChatPending.get(id) || false,
497
+ hasSpec: store.hasRealSpec(task.spec),
498
+ hasPendingQueueItem,
499
+ conflictError,
500
+ specHtml,
501
+ });
502
+ });
503
+ // Tasks
504
+ app.get("/tasks", async (c) => {
505
+ const tasks = await store.listTasks();
506
+ // Build pending diffs map: taskId -> diffId
507
+ reloadPendingDiffs();
508
+ const taskDiffs = new Map();
509
+ for (const [diffId, diff] of pendingDiffs) {
510
+ if (diff.taskId) {
511
+ taskDiffs.set(diff.taskId, diffId);
512
+ }
513
+ }
514
+ // Determine which task has a running agent and which have pending queue items
515
+ const qs = getQueueState();
516
+ const queue = await store.readQueue();
517
+ let agentTaskId = null;
518
+ if (qs.agentRunning && qs.currentRunId) {
519
+ const running = queue.find((item) => item.status === "running");
520
+ if (running?.params.taskId)
521
+ agentTaskId = running.params.taskId;
522
+ }
523
+ const pendingQueueTaskIds = new Set(queue
524
+ .filter((item) => (item.status === "pending" || item.status === "running") && item.params.taskId)
525
+ .map((item) => item.params.taskId));
526
+ return c.html(tasksListPage(tasks, taskDiffs, qs.agentRunning, qs.currentRunId, agentTaskId, pendingQueueTaskIds));
527
+ });
528
+ app.get("/tasks/:id", async (c) => {
529
+ const id = c.req.param("id");
530
+ const task = await store.getTask(id);
531
+ if (!task)
532
+ return c.html("<p>Task not found.</p>", 404);
533
+ const taskMessages = (task.meta.status === "drafting" || task.meta.status === "refining")
534
+ ? await store.readTaskConversation(id)
535
+ : [];
536
+ const taskChatIsPending = taskChatPending.get(id) || false;
537
+ const report = task.meta.status === "complete"
538
+ ? await store.getTaskReport(id)
539
+ : null;
540
+ const qs = getQueueState();
541
+ const queue = await store.readQueue();
542
+ let detailAgentTaskId = null;
543
+ if (qs.agentRunning && qs.currentRunId) {
544
+ const running = queue.find((item) => item.status === "running");
545
+ if (running?.params.taskId)
546
+ detailAgentTaskId = running.params.taskId;
547
+ }
548
+ // Look up pending diff for this task
549
+ reloadPendingDiffs();
550
+ let pendingDiffId;
551
+ for (const [diffId, diff] of pendingDiffs) {
552
+ if (diff.taskId === id) {
553
+ pendingDiffId = diffId;
554
+ break;
555
+ }
556
+ }
557
+ const hasPendingQueueItem = queue.some((item) => (item.status === "pending" || item.status === "running") && item.params.taskId === id);
558
+ // Find conflict error if in conflict status
559
+ let conflictError;
560
+ if (task.meta.status === "conflict") {
561
+ const conflictItem = findConflictItem(queue, task.meta.id);
562
+ if (conflictItem) {
563
+ conflictError = conflictItem.error || "Merge conflict occurred";
564
+ }
565
+ }
566
+ return c.html(taskDetailPage(task.meta, task.spec, qs.agentRunning, qs.currentRunId, taskMessages, taskChatIsPending, report, detailAgentTaskId, pendingDiffId, hasPendingQueueItem, conflictError));
567
+ });
568
+ app.post("/tasks/:id/status", async (c) => {
569
+ const id = c.req.param("id");
570
+ const body = await c.req.json();
571
+ if (body.status === "rejected") {
572
+ // Cancel task: clean up pending queue items and pending diffs
573
+ const queue = await store.readQueue();
574
+ for (const item of queue) {
575
+ if (item.params.taskId === id && item.status === "pending") {
576
+ await store.removeQueueItem(item.id);
577
+ }
578
+ }
579
+ // Clean up any pending diff for this task
580
+ reloadPendingDiffs();
581
+ for (const [diffId, diff] of pendingDiffs) {
582
+ if (diff.taskId === id) {
583
+ if (diff.stashRef) {
584
+ await git.stashDrop(projects.getProjectRoot(), diff.stashRef);
585
+ }
586
+ pendingDiffs.delete(diffId);
587
+ persistPendingDiffs(pendingDiffs);
588
+ break;
589
+ }
590
+ }
591
+ await store.updateTaskStatus(id, "rejected");
592
+ await store.appendHistory({
593
+ timestamp: new Date().toISOString(),
594
+ type: "rejection",
595
+ summary: `Cancelled task #${id}`,
596
+ });
597
+ return c.json({ ok: true });
598
+ }
599
+ if (body.status === "in_progress") {
600
+ const item = await enqueue("start-work", { taskId: id });
601
+ return c.json({ ok: true, id: item.id });
602
+ }
603
+ // When marking ready, commit spec + conversation
604
+ if (body.status === "ready") {
605
+ const task = await store.getTask(id);
606
+ if (task) {
607
+ if (!store.hasRealSpec(task.spec)) {
608
+ return c.json({ error: "Cannot mark ready — task spec is still a placeholder. Wait for the drafting agent to finish or refine the spec first." }, 400);
609
+ }
610
+ await store.updateTaskStatus(id, "ready");
611
+ try {
612
+ const specPath = store.getTaskSpecPath(id, task.meta.slug);
613
+ const convPath = store.getTaskConversationPath(id, task.meta.slug);
614
+ await git.ensureRepo(projects.getProjectRoot());
615
+ // Stage specific files and commit
616
+ const { execFile } = await import("node:child_process");
617
+ const { promisify } = await import("node:util");
618
+ const execAsync = promisify(execFile);
619
+ await execAsync("git", ["add", specPath, convPath], { cwd: projects.getProjectRoot() });
620
+ try {
621
+ await execAsync("git", ["commit", "-m", `task #${id}: Mark ready — ${task.meta.title}`], { cwd: projects.getProjectRoot() });
622
+ }
623
+ catch {
624
+ // Nothing to commit (no changes to spec/conversation)
625
+ }
626
+ }
627
+ catch (err) {
628
+ console.error("Mark ready commit error:", err);
629
+ }
630
+ return c.json({ ok: true });
631
+ }
632
+ }
633
+ await store.updateTaskStatus(id, body.status);
634
+ return c.json({ ok: true });
635
+ });
636
+ // Spec
637
+ app.get("/spec", async (c) => {
638
+ const spec = await store.readSpec();
639
+ return c.html(specPage(spec));
640
+ });
641
+ app.get("/api/spec", async (c) => {
642
+ const spec = await store.readSpec();
643
+ return c.text(spec);
644
+ });
645
+ // Diff review
646
+ app.get("/diff/:id", async (c) => {
647
+ const id = c.req.param("id");
648
+ reloadPendingDiffs();
649
+ const pending = pendingDiffs.get(id);
650
+ if (!pending) {
651
+ return c.html("<p>Diff not found or already reviewed.</p>", 404);
652
+ }
653
+ const chatMessages = pending.taskId
654
+ ? await store.readTaskConversation(pending.taskId)
655
+ : [];
656
+ const chatPending = pending.taskId
657
+ ? taskChatPending.get(pending.taskId) || false
658
+ : false;
659
+ // Extract acceptance criteria from task spec if available
660
+ let acceptanceCriteria;
661
+ if (pending.taskId) {
662
+ const taskSpec = await store.readTaskSpec(pending.taskId);
663
+ if (taskSpec) {
664
+ const match = taskSpec.match(/\n## Acceptance criteria\s*\n([\s\S]*?)(?=\n## |\n---|\s*$)/i);
665
+ if (match) {
666
+ acceptanceCriteria = match[1].trim();
667
+ }
668
+ }
669
+ }
670
+ return c.html(diffPage(id, pending.diffContent, pending.task, pending.taskId, chatMessages, chatPending, acceptanceCriteria, pending.agentSummary, pending.checks));
671
+ });
672
+ app.post("/diff/:id/approve", async (c) => {
673
+ const id = c.req.param("id");
674
+ reloadPendingDiffs();
675
+ const pending = pendingDiffs.get(id);
676
+ if (!pending) {
677
+ return c.json({ ok: false, error: "Diff not found" }, 404);
678
+ }
679
+ // Apply stash + commit immediately (no queue)
680
+ try {
681
+ if (pending.stashRef) {
682
+ await git.stashApply(pending.cwd, pending.stashRef);
683
+ }
684
+ const commitHash = await git.commit(pending.cwd, `checkpoint: ${pending.task}`);
685
+ if (pending.stashRef) {
686
+ await git.stashDrop(pending.cwd, pending.stashRef);
687
+ }
688
+ await store.appendHistory({
689
+ timestamp: new Date().toISOString(),
690
+ type: "checkpoint",
691
+ summary: pending.task,
692
+ details: `commit: ${commitHash}, from: ${pending.snapshotHash}`,
693
+ });
694
+ pendingDiffs.delete(id);
695
+ persistPendingDiffs(pendingDiffs);
696
+ }
697
+ catch (err) {
698
+ return c.json({ ok: false, error: err.message }, 500);
699
+ }
700
+ // If this was a task diff, enqueue spec-update agent
701
+ if (pending.taskId && !pending.specUpdate) {
702
+ const item = await enqueue("spec-update", { taskId: pending.taskId });
703
+ return c.json({ ok: true, id: item.id, specUpdate: true });
704
+ }
705
+ return c.json({ ok: true, specUpdate: false });
706
+ });
707
+ app.post("/diff/:id/reject", async (c) => {
708
+ const id = c.req.param("id");
709
+ reloadPendingDiffs();
710
+ const pending = pendingDiffs.get(id);
711
+ if (!pending) {
712
+ return c.json({ ok: false, error: "Diff not found" }, 404);
713
+ }
714
+ if (pending.stashRef) {
715
+ await git.stashDrop(pending.cwd, pending.stashRef);
716
+ }
717
+ await store.appendHistory({
718
+ timestamp: new Date().toISOString(),
719
+ type: "rejection",
720
+ summary: `Rejected: ${pending.task}`,
721
+ details: `Snapshot: ${pending.snapshotHash}`,
722
+ });
723
+ pendingDiffs.delete(id);
724
+ persistPendingDiffs(pendingDiffs);
725
+ return c.json({ ok: true });
726
+ });
727
+ // Fix task (targeted fix during diff review)
728
+ app.post("/api/tasks/:id/fix", async (c) => {
729
+ const taskId = c.req.param("id");
730
+ const body = await c.req.json();
731
+ const message = body.message?.trim();
732
+ if (!message)
733
+ return c.json({ error: "No message" }, 400);
734
+ const task = await store.getTask(taskId);
735
+ if (!task)
736
+ return c.json({ error: "Task not found" }, 404);
737
+ const item = await enqueue("fix-task", { taskId, message, diffId: body.diffId });
738
+ return c.json({ ok: true, id: item.id });
739
+ });
740
+ // --- Conflict resolution ---
741
+ app.post("/api/tasks/:id/conflict/restart", async (c) => {
742
+ const taskId = c.req.param("id");
743
+ const ok = await resolveConflictRestart(taskId);
744
+ if (!ok)
745
+ return c.json({ error: "No conflict found for this task" }, 404);
746
+ return c.json({ ok: true });
747
+ });
748
+ app.post("/api/tasks/:id/conflict/agent-resolve", async (c) => {
749
+ const taskId = c.req.param("id");
750
+ const item = await resolveConflictAgent(taskId);
751
+ if (!item)
752
+ return c.json({ error: "No conflict found for this task" }, 404);
753
+ return c.json({ ok: true, id: item.id });
754
+ });
755
+ app.post("/api/tasks/:id/conflict/manual", async (c) => {
756
+ const taskId = c.req.param("id");
757
+ const result = await resolveConflictManual(taskId);
758
+ if (!result)
759
+ return c.json({ error: "No conflict found for this task" }, 404);
760
+ return c.json({ ok: true, conflicts: result.conflicts });
761
+ });
762
+ app.post("/api/tasks/:id/conflict/done", async (c) => {
763
+ const taskId = c.req.param("id");
764
+ const diffId = await resolveConflictDone(taskId);
765
+ if (!diffId)
766
+ return c.json({ error: "No changes to review" }, 400);
767
+ return c.json({ ok: true, diffId });
768
+ });
769
+ // --- Services ---
770
+ app.get("/services", async (c) => {
771
+ const svcList = await services.getServicesWithStatus();
772
+ return c.html(servicesPage(svcList));
773
+ });
774
+ app.get("/services/:id/logs", async (c) => {
775
+ const id = c.req.param("id");
776
+ const service = await store.getService(id);
777
+ if (!service)
778
+ return c.html("<p>Service not found.</p>", 404);
779
+ const status = services.getServiceStatus(id);
780
+ const logs = await services.getServiceLogs(id);
781
+ return c.html(serviceLogsPage({ ...service, ...status }, logs));
782
+ });
783
+ app.get("/api/services", async (c) => {
784
+ const svcList = await services.getServicesWithStatus();
785
+ return c.json(svcList);
786
+ });
787
+ app.post("/api/services", async (c) => {
788
+ const body = await c.req.json();
789
+ if (!body.name?.trim() || !body.command?.trim()) {
790
+ return c.json({ error: "Name and command required" }, 400);
791
+ }
792
+ const service = await store.createService(body.name.trim(), body.command.trim(), body.cwd?.trim() || undefined);
793
+ return c.json(service, 201);
794
+ });
795
+ app.put("/api/services/:id", async (c) => {
796
+ const id = c.req.param("id");
797
+ const status = services.getServiceStatus(id);
798
+ if (status.status === "running") {
799
+ return c.json({ error: "Cannot edit a running service" }, 400);
800
+ }
801
+ const body = await c.req.json();
802
+ const updated = await store.updateService(id, {
803
+ name: body.name?.trim(),
804
+ command: body.command?.trim(),
805
+ cwd: body.cwd?.trim(),
806
+ });
807
+ if (!updated)
808
+ return c.json({ error: "Service not found" }, 404);
809
+ return c.json({ ok: true });
810
+ });
811
+ app.delete("/api/services/:id", async (c) => {
812
+ const id = c.req.param("id");
813
+ const status = services.getServiceStatus(id);
814
+ if (status.status === "running") {
815
+ return c.json({ error: "Cannot delete a running service" }, 400);
816
+ }
817
+ const deleted = await store.deleteService(id);
818
+ if (!deleted)
819
+ return c.json({ error: "Service not found" }, 404);
820
+ return c.json({ ok: true });
821
+ });
822
+ app.post("/api/services/:id/start", async (c) => {
823
+ const id = c.req.param("id");
824
+ const result = await services.startService(id);
825
+ if (!result.ok)
826
+ return c.json({ error: result.error }, 400);
827
+ return c.json({ ok: true });
828
+ });
829
+ app.post("/api/services/:id/stop", async (c) => {
830
+ const id = c.req.param("id");
831
+ const result = await services.stopService(id);
832
+ if (!result.ok)
833
+ return c.json({ error: result.error }, 400);
834
+ return c.json({ ok: true });
835
+ });
836
+ app.post("/api/services/:id/restart", async (c) => {
837
+ const id = c.req.param("id");
838
+ const result = await services.restartService(id);
839
+ if (!result.ok)
840
+ return c.json({ error: result.error }, 400);
841
+ return c.json({ ok: true });
842
+ });
843
+ app.get("/api/services/:id/logs", async (c) => {
844
+ const id = c.req.param("id");
845
+ const tailLines = parseInt(c.req.query("lines") || "200", 10);
846
+ const logs = await services.getServiceLogs(id, tailLines);
847
+ return c.json({ logs });
848
+ });
849
+ app.post("/api/services/:id/logs/clear", async (c) => {
850
+ const id = c.req.param("id");
851
+ await services.clearServiceLogs(id);
852
+ return c.json({ ok: true });
853
+ });
854
+ // --- Git sync routes ---
855
+ app.get("/api/git/status", async (c) => {
856
+ const cwd = projects.getProjectRoot();
857
+ const status = await git.getRemoteStatus(cwd);
858
+ const outgoing = status.ahead > 0 ? await git.getOutgoingCommits(cwd) : [];
859
+ return c.json({ ...status, outgoing });
860
+ });
861
+ app.post("/api/git/push", async (c) => {
862
+ const result = await git.push(projects.getProjectRoot());
863
+ return c.json(result);
864
+ });
865
+ app.post("/api/git/pull", async (c) => {
866
+ const body = await c.req.json().catch(() => ({}));
867
+ const result = body.rebase
868
+ ? await git.pullRebase(projects.getProjectRoot())
869
+ : await git.pull(projects.getProjectRoot());
870
+ return c.json(result);
871
+ });
872
+ // --- Branch management routes ---
873
+ app.get("/branches", async (c) => {
874
+ const cwd = projects.getProjectRoot();
875
+ const { local, current } = await git.listBranches(cwd);
876
+ const branchState = await store.readBranches();
877
+ // Check for pending diffs
878
+ reloadPendingDiffs();
879
+ const hasPendingDiffs = pendingDiffs.size > 0;
880
+ const branches = local.map((name) => ({
881
+ name,
882
+ isCurrent: name === current,
883
+ info: branchState.branches.find((b) => b.name === name),
884
+ }));
885
+ return c.html(branchesPage(branches, current, hasPendingDiffs));
886
+ });
887
+ app.get("/api/branches", async (c) => {
888
+ const cwd = projects.getProjectRoot();
889
+ const { local, current } = await git.listBranches(cwd);
890
+ const branchState = await store.readBranches();
891
+ return c.json({ branches: local, current, state: branchState });
892
+ });
893
+ app.post("/api/branches", async (c) => {
894
+ const body = await c.req.json();
895
+ const name = body.name?.trim();
896
+ if (!name)
897
+ return c.json({ error: "No branch name" }, 400);
898
+ // Validate branch name (basic)
899
+ if (/[\s~^:?*\[\\]/.test(name)) {
900
+ return c.json({ error: "Invalid branch name" }, 400);
901
+ }
902
+ const cwd = projects.getProjectRoot();
903
+ try {
904
+ const currentBranch = await git.getCurrentBranch(cwd);
905
+ await git.createBranch(cwd, name);
906
+ // Track in branch state
907
+ const state = await store.readBranches();
908
+ state.active = name;
909
+ state.branches.push({
910
+ name,
911
+ createdFrom: currentBranch,
912
+ createdAt: new Date().toISOString(),
913
+ taskIds: [],
914
+ });
915
+ await store.saveBranches(state);
916
+ return c.json({ ok: true, branch: name });
917
+ }
918
+ catch (err) {
919
+ return c.json({ error: err.stderr || err.message }, 400);
920
+ }
921
+ });
922
+ app.post("/api/branches/switch", async (c) => {
923
+ const body = await c.req.json();
924
+ const name = body.name?.trim();
925
+ if (!name)
926
+ return c.json({ error: "No branch name" }, 400);
927
+ // Block if pending diffs exist
928
+ reloadPendingDiffs();
929
+ if (pendingDiffs.size > 0) {
930
+ return c.json({ error: "Cannot switch branches while diffs are pending review. Approve or reject them first." }, 400);
931
+ }
932
+ const cwd = projects.getProjectRoot();
933
+ try {
934
+ await git.switchBranch(cwd, name);
935
+ // Update branch state
936
+ const state = await store.readBranches();
937
+ const isDefault = name === "master" || name === "main";
938
+ state.active = isDefault ? null : name;
939
+ await store.saveBranches(state);
940
+ return c.json({ ok: true, branch: name });
941
+ }
942
+ catch (err) {
943
+ return c.json({ error: err.stderr || err.message }, 400);
944
+ }
945
+ });
946
+ app.post("/api/branches/:name/push", async (c) => {
947
+ const name = c.req.param("name");
948
+ const cwd = projects.getProjectRoot();
949
+ const result = await git.pushBranch(cwd, name);
950
+ return c.json(result);
951
+ });
952
+ app.post("/api/branches/:name/merge", async (c) => {
953
+ const name = c.req.param("name");
954
+ const cwd = projects.getProjectRoot();
955
+ const branchState = await store.readBranches();
956
+ const branchInfo = branchState.branches.find((b) => b.name === name);
957
+ const target = branchInfo?.createdFrom || "master";
958
+ const result = await git.mergeBranch(cwd, name, target);
959
+ if (result.success) {
960
+ // Update branch state — switch active to target
961
+ const isDefault = target === "master" || target === "main";
962
+ branchState.active = isDefault ? null : target;
963
+ await store.saveBranches(branchState);
964
+ }
965
+ return c.json(result);
966
+ });
967
+ app.delete("/api/branches/:name", async (c) => {
968
+ const name = c.req.param("name");
969
+ const cwd = projects.getProjectRoot();
970
+ // Don't delete current branch
971
+ const current = await git.getCurrentBranch(cwd);
972
+ if (current === name) {
973
+ return c.json({ error: "Cannot delete the current branch" }, 400);
974
+ }
975
+ const result = await git.deleteBranch(cwd, name);
976
+ if (result.success) {
977
+ // Remove from branch state
978
+ const state = await store.readBranches();
979
+ state.branches = state.branches.filter((b) => b.name !== name);
980
+ if (state.active === name)
981
+ state.active = null;
982
+ await store.saveBranches(state);
983
+ }
984
+ return c.json(result);
985
+ });
986
+ // Queue management
987
+ app.get("/api/queue", async (c) => {
988
+ const queue = await store.readQueue();
989
+ return c.json(queue);
990
+ });
991
+ app.post("/api/queue/:id/remove", async (c) => {
992
+ const id = c.req.param("id");
993
+ const removed = await store.removeQueueItem(id);
994
+ return c.json({ ok: removed });
995
+ });
996
+ // History
997
+ app.get("/history", async (c) => {
998
+ const entries = await store.readHistory();
999
+ return c.html(historyPage(entries));
1000
+ });
1001
+ // History API (for polling)
1002
+ app.get("/api/history", async (c) => {
1003
+ const entries = await store.readHistory();
1004
+ return c.json({ entries, total: entries.length });
1005
+ });
1006
+ // Run logs
1007
+ app.get("/run/:id", async (c) => {
1008
+ const id = c.req.param("id");
1009
+ const events = await store.readRunLog(id);
1010
+ if (events.length === 0) {
1011
+ return c.html("<p>Run not found.</p>", 404);
1012
+ }
1013
+ return c.html(runPage(id, events));
1014
+ });
1015
+ // --- Verification routes ---
1016
+ // Verify page
1017
+ app.get("/task/:id/verify", async (c) => {
1018
+ const taskId = c.req.param("id");
1019
+ const diffId = c.req.query("diffId") || "";
1020
+ const pending = diffId ? pendingDiffs.get(diffId) : null;
1021
+ const taskSummary = pending?.task || `Task #${taskId}`;
1022
+ const results = await store.listVerifyResults(taskId);
1023
+ return c.html(verifyPage(taskId, diffId, taskSummary, results, runningVerifications));
1024
+ });
1025
+ // Spawn verification agent
1026
+ app.post("/task/:id/verify", async (c) => {
1027
+ const taskId = c.req.param("id");
1028
+ const body = await c.req.json();
1029
+ const prompt = body.prompt?.trim();
1030
+ const diffId = body.diffId;
1031
+ if (!prompt)
1032
+ return c.json({ error: "No prompt" }, 400);
1033
+ const task = await store.getTask(taskId);
1034
+ if (!task)
1035
+ return c.json({ error: "Task not found" }, 404);
1036
+ const pending = diffId ? pendingDiffs.get(diffId) : null;
1037
+ const diffContent = pending?.diffContent || "";
1038
+ const runNumber = await store.nextVerifyRunNumber(taskId);
1039
+ const verifyDirPath = await store.ensureVerifyDir(taskId);
1040
+ const padded = String(runNumber).padStart(3, "0");
1041
+ // Load MCP config for verification agents
1042
+ const mcpConfig = await store.readMcpConfig();
1043
+ // Build available tools section based on MCP config
1044
+ const mcpToolsSection = mcpConfig?.mcpServers
1045
+ ? `\n## Available Tools
1046
+ You have access to MCP servers for enhanced verification:
1047
+ ${Object.keys(mcpConfig.mcpServers).includes("playwright") ? `- **Playwright** — browser automation: navigate to URLs, take screenshots, click elements, fill forms
1048
+ - The app runs on http://localhost:${PORT}
1049
+ - Use these tools to visually verify UI changes` : ""}
1050
+ ${Object.keys(mcpConfig.mcpServers)
1051
+ .filter((k) => k !== "playwright")
1052
+ .map((k) => `- **${k}** MCP server`)
1053
+ .join("\n")}
1054
+ `
1055
+ : "";
1056
+ // Build verification agent prompt
1057
+ const verifyPrompt = `You are a verification agent. Your job is to verify that changes work correctly.
1058
+
1059
+ ## Task
1060
+ ${task.spec}
1061
+
1062
+ ## Git Diff Being Reviewed
1063
+ \`\`\`diff
1064
+ ${diffContent.slice(0, 10000)}
1065
+ \`\`\`
1066
+
1067
+ ## Your Verification Task
1068
+ ${prompt}
1069
+ ${mcpToolsSection}
1070
+ ## Instructions
1071
+ 1. Perform the verification described above
1072
+ 2. Save any output artifacts (screenshots, test output, etc.) to: ${verifyDirPath}/
1073
+ - Name files with prefix: run-${padded}-
1074
+ - Example: run-${padded}-screenshot.png
1075
+ 3. When done, write a JSON result file to: ${verifyDirPath}/run-${padded}-result.json
1076
+ With this format:
1077
+ \`\`\`json
1078
+ {
1079
+ "runNumber": ${runNumber},
1080
+ "status": "pass" or "fail" or "error",
1081
+ "summary": "Brief description of what you found",
1082
+ "prompt": ${JSON.stringify(prompt)},
1083
+ "artifacts": ["list of artifact filenames you saved"],
1084
+ "startedAt": "${new Date().toISOString()}",
1085
+ "completedAt": "<ISO timestamp when done>"
1086
+ }
1087
+ \`\`\`
1088
+ 4. Do NOT modify any source code — you are only verifying
1089
+ 5. You may run processes, tests, use puppeteer, curl, etc.`;
1090
+ // Save initial result as running
1091
+ const initialResult = {
1092
+ runNumber,
1093
+ status: "running",
1094
+ summary: "",
1095
+ prompt,
1096
+ artifacts: [],
1097
+ startedAt: new Date().toISOString(),
1098
+ };
1099
+ await store.saveVerifyResult(taskId, initialResult);
1100
+ await store.appendVerifyEvent(taskId, runNumber, {
1101
+ ts: new Date().toISOString(),
1102
+ type: "user_message",
1103
+ content: prompt,
1104
+ });
1105
+ const tracking = {
1106
+ runNumber,
1107
+ taskId,
1108
+ done: false,
1109
+ lastEvent: "Starting...",
1110
+ };
1111
+ runningVerifications.push(tracking);
1112
+ // Spawn agent (fire and forget)
1113
+ (async () => {
1114
+ try {
1115
+ const agent = spawnAgent(verifyPrompt, projects.getProjectRoot(), 10 * 60 * 1000, mcpConfig ?? undefined);
1116
+ for await (const event of agent.events) {
1117
+ await store.appendVerifyEvent(taskId, runNumber, {
1118
+ ts: new Date().toISOString(),
1119
+ ...event,
1120
+ });
1121
+ if (event.type === "tool_use") {
1122
+ tracking.lastEvent = `Running: ${event.name}`;
1123
+ }
1124
+ else if (event.type === "text") {
1125
+ tracking.lastEvent = event.content.slice(0, 80);
1126
+ }
1127
+ }
1128
+ // Check if agent wrote a result file
1129
+ const agentResult = await store.readVerifyResult(taskId, runNumber);
1130
+ if (!agentResult || agentResult.status === "running") {
1131
+ await store.saveVerifyResult(taskId, {
1132
+ ...initialResult,
1133
+ status: "error",
1134
+ summary: "Agent finished without writing a result",
1135
+ completedAt: new Date().toISOString(),
1136
+ });
1137
+ }
1138
+ }
1139
+ catch (err) {
1140
+ console.error(`Verification agent error:`, err);
1141
+ await store.appendVerifyEvent(taskId, runNumber, {
1142
+ ts: new Date().toISOString(),
1143
+ type: "error",
1144
+ message: err.message,
1145
+ });
1146
+ await store.saveVerifyResult(taskId, {
1147
+ ...initialResult,
1148
+ status: "error",
1149
+ summary: err.message,
1150
+ completedAt: new Date().toISOString(),
1151
+ });
1152
+ }
1153
+ finally {
1154
+ tracking.done = true;
1155
+ const idx = runningVerifications.indexOf(tracking);
1156
+ if (idx >= 0)
1157
+ runningVerifications.splice(idx, 1);
1158
+ }
1159
+ })();
1160
+ return c.json({ ok: true, runNumber });
1161
+ });
1162
+ // Verification run status (for polling)
1163
+ app.get("/task/:id/verify/:runNum/status", async (c) => {
1164
+ const taskId = c.req.param("id");
1165
+ const runNum = parseInt(c.req.param("runNum"), 10);
1166
+ const tracking = runningVerifications.find((v) => v.taskId === taskId && v.runNumber === runNum);
1167
+ if (tracking) {
1168
+ return c.json({ done: false, lastEvent: tracking.lastEvent });
1169
+ }
1170
+ const result = await store.readVerifyResult(taskId, runNum);
1171
+ return c.json({
1172
+ done: true,
1173
+ status: result?.status || "unknown",
1174
+ lastEvent: result?.summary || "Complete",
1175
+ });
1176
+ });
1177
+ // Verification results list
1178
+ app.get("/task/:id/verify/results", async (c) => {
1179
+ const taskId = c.req.param("id");
1180
+ const results = await store.listVerifyResults(taskId);
1181
+ return c.json(results);
1182
+ });
1183
+ // Verification run log page
1184
+ app.get("/task/:id/verify/:runNum/log", async (c) => {
1185
+ const taskId = c.req.param("id");
1186
+ const runNum = parseInt(c.req.param("runNum"), 10);
1187
+ const diffId = c.req.query("diffId") || "";
1188
+ const events = await store.readVerifyLog(taskId, runNum);
1189
+ return c.html(verifyLogPage(taskId, runNum, events, diffId));
1190
+ });
1191
+ // Serve verification artifacts
1192
+ app.get("/task/:id/verify/artifact/:runNum/:filename", async (c) => {
1193
+ const taskId = c.req.param("id");
1194
+ const filename = c.req.param("filename");
1195
+ const verifyDirPath = await store.ensureVerifyDir(taskId);
1196
+ const filePath = join(verifyDirPath, filename);
1197
+ if (!existsSync(filePath)) {
1198
+ return c.text("Not found", 404);
1199
+ }
1200
+ const content = await fsReadFile(filePath);
1201
+ const ext = filename.split(".").pop()?.toLowerCase() || "";
1202
+ const mimeTypes = {
1203
+ png: "image/png",
1204
+ jpg: "image/jpeg",
1205
+ jpeg: "image/jpeg",
1206
+ gif: "image/gif",
1207
+ webp: "image/webp",
1208
+ json: "application/json",
1209
+ txt: "text/plain",
1210
+ log: "text/plain",
1211
+ };
1212
+ const contentType = mimeTypes[ext] || "application/octet-stream";
1213
+ return new Response(content, {
1214
+ headers: { "Content-Type": contentType },
1215
+ });
1216
+ });
1217
+ // Export app for testing
1218
+ export { app };
1219
+ export async function startServer() {
1220
+ // Detect multi-project mode
1221
+ projects.detectMode();
1222
+ // Startup cleanup and queue processor
1223
+ try {
1224
+ const deleted = await store.cleanOldRuns();
1225
+ if (deleted > 0)
1226
+ console.log(`Cleaned ${deleted} old run files`);
1227
+ await store.truncateHistoryIfNeeded();
1228
+ await startQueueProcessor();
1229
+ }
1230
+ catch (err) {
1231
+ console.error("Startup cleanup error:", err);
1232
+ }
1233
+ // Start
1234
+ serve({ fetch: app.fetch, port: PORT, hostname: "0.0.0.0" }, (info) => {
1235
+ console.log(`longshot running on http://0.0.0.0:${info.port}`);
1236
+ if (projects.isMultiProject()) {
1237
+ console.log(`Multi-project mode — root: ${projects.getRootDir()}`);
1238
+ const current = projects.getCurrentProjectName();
1239
+ if (current)
1240
+ console.log(`Active project: ${current}`);
1241
+ }
1242
+ else {
1243
+ console.log(`Project root: ${projects.getProjectRoot()}`);
1244
+ }
1245
+ });
1246
+ }
1247
+ // Only start server when not imported as a module for testing
1248
+ if (!process.env.TESTING) {
1249
+ startServer();
1250
+ }