claude-kanban 0.1.0

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.
@@ -0,0 +1,3413 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/bin/cli.ts
4
+ import { Command } from "commander";
5
+ import chalk from "chalk";
6
+ import open from "open";
7
+
8
+ // src/server/index.ts
9
+ import express from "express";
10
+ import { createServer as createHttpServer } from "http";
11
+ import { Server as SocketIOServer } from "socket.io";
12
+ import { join as join5, dirname } from "path";
13
+ import { fileURLToPath } from "url";
14
+ import { existsSync as existsSync3 } from "fs";
15
+
16
+ // src/server/services/executor.ts
17
+ import { spawn } from "child_process";
18
+ import { join as join4 } from "path";
19
+ import { writeFileSync as writeFileSync4, unlinkSync, mkdirSync as mkdirSync2, existsSync as existsSync2, appendFileSync as appendFileSync2, readFileSync as readFileSync4 } from "fs";
20
+ import { EventEmitter } from "events";
21
+
22
+ // src/server/services/project.ts
23
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs";
24
+ import { join, basename } from "path";
25
+ var KANBAN_DIR = ".claude-kanban";
26
+ var SCRIPTS_DIR = "scripts";
27
+ async function isProjectInitialized(projectPath) {
28
+ const kanbanDir = join(projectPath, KANBAN_DIR);
29
+ const prdPath = join(kanbanDir, "prd.json");
30
+ const configPath = join(kanbanDir, "config.json");
31
+ return existsSync(kanbanDir) && existsSync(prdPath) && existsSync(configPath);
32
+ }
33
+ function detectPackageManager(projectPath) {
34
+ if (existsSync(join(projectPath, "pnpm-lock.yaml"))) return "pnpm";
35
+ if (existsSync(join(projectPath, "yarn.lock"))) return "yarn";
36
+ if (existsSync(join(projectPath, "bun.lockb"))) return "bun";
37
+ return "npm";
38
+ }
39
+ function detectProjectCommands(projectPath) {
40
+ const pm = detectPackageManager(projectPath);
41
+ const run = pm === "npm" ? "npm run" : pm;
42
+ const packageJsonPath = join(projectPath, "package.json");
43
+ let scripts = {};
44
+ if (existsSync(packageJsonPath)) {
45
+ try {
46
+ const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
47
+ scripts = pkg.scripts || {};
48
+ } catch {
49
+ }
50
+ }
51
+ const testCommand = scripts.test ? `${run} test` : 'echo "No test command configured"';
52
+ const typecheckCommand = scripts.typecheck ? `${run} typecheck` : scripts["type-check"] ? `${run} type-check` : existsSync(join(projectPath, "tsconfig.json")) ? `${run === "npm run" ? "npx" : pm} tsc --noEmit` : 'echo "No typecheck command configured"';
53
+ const buildCommand = scripts.build ? `${run} build` : 'echo "No build command configured"';
54
+ return { testCommand, typecheckCommand, buildCommand };
55
+ }
56
+ function createInitialPRD(projectName) {
57
+ return {
58
+ version: "1.0",
59
+ projectName,
60
+ tasks: []
61
+ };
62
+ }
63
+ function createInitialConfig(projectPath) {
64
+ const { testCommand, typecheckCommand, buildCommand } = detectProjectCommands(projectPath);
65
+ return {
66
+ version: "1.0",
67
+ agent: {
68
+ command: "claude",
69
+ permissionMode: "acceptEdits",
70
+ model: null
71
+ },
72
+ project: {
73
+ testCommand,
74
+ typecheckCommand,
75
+ buildCommand
76
+ },
77
+ ui: {
78
+ port: 4242,
79
+ theme: "system"
80
+ },
81
+ execution: {
82
+ maxConcurrent: 3,
83
+ timeout: 30
84
+ // 30 minutes
85
+ }
86
+ };
87
+ }
88
+ function createRalphScript(config) {
89
+ const { testCommand, typecheckCommand } = config.project;
90
+ return `#!/bin/bash
91
+ set -e
92
+
93
+ if [ -z "$1" ]; then
94
+ echo "Usage: $0 <iterations>"
95
+ exit 1
96
+ fi
97
+
98
+ KANBAN_DIR=".claude-kanban"
99
+
100
+ for ((i=1; i<=$1; i++)); do
101
+ echo ""
102
+ echo "=== Iteration $i of $1 ==="
103
+ echo ""
104
+
105
+ result=$(${config.agent.command} --permission-mode ${config.agent.permissionMode} -p \\
106
+ "@$KANBAN_DIR/prd.json" \\
107
+ "@$KANBAN_DIR/progress.txt" \\
108
+ "You are working on tasks from a Kanban board. Follow these steps:
109
+
110
+ 1. Read the PRD to find the highest-priority task with status 'ready'.
111
+ Priority order: critical > high > medium > low
112
+ If multiple tasks have the same priority, pick the first one.
113
+
114
+ 2. Work on ONLY that single task. Do not touch other tasks.
115
+
116
+ 3. Check that types pass via: ${typecheckCommand}
117
+ Check that tests pass via: ${testCommand}
118
+
119
+ 4. When the task is complete:
120
+ - Update the task's 'passes' field to true
121
+ - Update the task's 'status' field to 'completed'
122
+
123
+ 5. Append your progress to progress.txt with:
124
+ - Date and time
125
+ - Task ID and title
126
+ - What you implemented
127
+ - Files changed
128
+ - Any notes for future work
129
+
130
+ 6. Make a git commit with a descriptive message.
131
+
132
+ IMPORTANT: Only work on ONE task per iteration.
133
+
134
+ If all tasks with status 'ready' are complete (passes: true), output <promise>COMPLETE</promise>.")
135
+
136
+ echo "$result"
137
+
138
+ if [[ "$result" == *"<promise>COMPLETE</promise>"* ]]; then
139
+ echo ""
140
+ echo "=== All tasks complete! ==="
141
+ exit 0
142
+ fi
143
+ done
144
+
145
+ echo ""
146
+ echo "=== Completed $1 iterations ==="
147
+ `;
148
+ }
149
+ function createRalphOnceScript(config) {
150
+ const { testCommand, typecheckCommand } = config.project;
151
+ return `#!/bin/bash
152
+ set -e
153
+
154
+ KANBAN_DIR=".claude-kanban"
155
+ TASK_ID="$1"
156
+
157
+ if [ -z "$TASK_ID" ]; then
158
+ # No task ID specified, let Claude pick
159
+ ${config.agent.command} --permission-mode ${config.agent.permissionMode} -p \\
160
+ "@$KANBAN_DIR/prd.json" \\
161
+ "@$KANBAN_DIR/progress.txt" \\
162
+ "You are working on tasks from a Kanban board. Follow these steps:
163
+
164
+ 1. Read the PRD to find the highest-priority task with status 'ready'.
165
+ Priority order: critical > high > medium > low
166
+
167
+ 2. Work on ONLY that single task.
168
+
169
+ 3. Check that types pass via: ${typecheckCommand}
170
+ Check that tests pass via: ${testCommand}
171
+
172
+ 4. When complete:
173
+ - Update the task's 'passes' field to true
174
+ - Update the task's 'status' field to 'completed'
175
+
176
+ 5. Append progress to progress.txt.
177
+
178
+ 6. Make a git commit.
179
+
180
+ If all 'ready' tasks are complete, output <promise>COMPLETE</promise>."
181
+ else
182
+ # Specific task ID provided
183
+ ${config.agent.command} --permission-mode ${config.agent.permissionMode} -p \\
184
+ "@$KANBAN_DIR/prd.json" \\
185
+ "@$KANBAN_DIR/progress.txt" \\
186
+ "You are working on a specific task from the Kanban board.
187
+
188
+ TASK ID: $TASK_ID
189
+
190
+ Find this task in the PRD and work on it. Follow these steps:
191
+
192
+ 1. Locate the task with id '$TASK_ID' in the PRD.
193
+
194
+ 2. Implement the feature as described.
195
+
196
+ 3. Check that types pass via: ${typecheckCommand}
197
+ Check that tests pass via: ${testCommand}
198
+
199
+ 4. When complete:
200
+ - Update the task's 'passes' field to true
201
+ - Update the task's 'status' field to 'completed'
202
+
203
+ 5. Append progress to progress.txt.
204
+
205
+ 6. Make a git commit.
206
+
207
+ If the task is complete, output <promise>COMPLETE</promise>."
208
+ fi
209
+ `;
210
+ }
211
+ async function initializeProject(projectPath, reset = false) {
212
+ const kanbanDir = join(projectPath, KANBAN_DIR);
213
+ const scriptsDir = join(projectPath, SCRIPTS_DIR);
214
+ const projectName = basename(projectPath);
215
+ if (!existsSync(kanbanDir)) {
216
+ mkdirSync(kanbanDir, { recursive: true });
217
+ }
218
+ if (!existsSync(scriptsDir)) {
219
+ mkdirSync(scriptsDir, { recursive: true });
220
+ }
221
+ const prdPath = join(kanbanDir, "prd.json");
222
+ if (!existsSync(prdPath) || reset) {
223
+ const prd = createInitialPRD(projectName);
224
+ writeFileSync(prdPath, JSON.stringify(prd, null, 2));
225
+ }
226
+ const configPath = join(kanbanDir, "config.json");
227
+ let config;
228
+ if (!existsSync(configPath)) {
229
+ config = createInitialConfig(projectPath);
230
+ writeFileSync(configPath, JSON.stringify(config, null, 2));
231
+ } else {
232
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
233
+ }
234
+ const progressPath = join(kanbanDir, "progress.txt");
235
+ if (!existsSync(progressPath)) {
236
+ const date = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
237
+ writeFileSync(progressPath, `# Claude Kanban Progress Log
238
+
239
+ Project: ${projectName}
240
+ Created: ${date}
241
+
242
+ ---
243
+
244
+ `);
245
+ }
246
+ const ralphPath = join(scriptsDir, "ralph.sh");
247
+ const ralphOncePath = join(scriptsDir, "ralph-once.sh");
248
+ writeFileSync(ralphPath, createRalphScript(config));
249
+ writeFileSync(ralphOncePath, createRalphOnceScript(config));
250
+ try {
251
+ const { chmodSync } = await import("fs");
252
+ chmodSync(ralphPath, 493);
253
+ chmodSync(ralphOncePath, 493);
254
+ } catch {
255
+ }
256
+ }
257
+ function getConfig(projectPath) {
258
+ const configPath = join(projectPath, KANBAN_DIR, "config.json");
259
+ return JSON.parse(readFileSync(configPath, "utf-8"));
260
+ }
261
+ function saveConfig(projectPath, config) {
262
+ const configPath = join(projectPath, KANBAN_DIR, "config.json");
263
+ writeFileSync(configPath, JSON.stringify(config, null, 2));
264
+ }
265
+
266
+ // src/server/services/prd.ts
267
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
268
+ import { join as join2 } from "path";
269
+ import { nanoid } from "nanoid";
270
+ var KANBAN_DIR2 = ".claude-kanban";
271
+ function getPRDPath(projectPath) {
272
+ return join2(projectPath, KANBAN_DIR2, "prd.json");
273
+ }
274
+ function readPRD(projectPath) {
275
+ const path = getPRDPath(projectPath);
276
+ return JSON.parse(readFileSync2(path, "utf-8"));
277
+ }
278
+ function writePRD(projectPath, prd) {
279
+ const path = getPRDPath(projectPath);
280
+ writeFileSync2(path, JSON.stringify(prd, null, 2));
281
+ }
282
+ function getAllTasks(projectPath) {
283
+ const prd = readPRD(projectPath);
284
+ return prd.tasks;
285
+ }
286
+ function getTaskById(projectPath, taskId) {
287
+ const prd = readPRD(projectPath);
288
+ return prd.tasks.find((t) => t.id === taskId);
289
+ }
290
+ function getTasksByStatus(projectPath, status) {
291
+ const prd = readPRD(projectPath);
292
+ return prd.tasks.filter((t) => t.status === status);
293
+ }
294
+ function createTask(projectPath, request) {
295
+ const prd = readPRD(projectPath);
296
+ const now = (/* @__PURE__ */ new Date()).toISOString();
297
+ const task = {
298
+ id: `task_${nanoid(8)}`,
299
+ title: request.title,
300
+ description: request.description,
301
+ category: request.category || "functional",
302
+ priority: request.priority || "medium",
303
+ status: request.status || "draft",
304
+ steps: request.steps || [],
305
+ passes: false,
306
+ createdAt: now,
307
+ updatedAt: now,
308
+ executionHistory: []
309
+ };
310
+ prd.tasks.push(task);
311
+ writePRD(projectPath, prd);
312
+ return task;
313
+ }
314
+ function updateTask(projectPath, taskId, updates) {
315
+ const prd = readPRD(projectPath);
316
+ const taskIndex = prd.tasks.findIndex((t) => t.id === taskId);
317
+ if (taskIndex === -1) {
318
+ return null;
319
+ }
320
+ const task = prd.tasks[taskIndex];
321
+ const updatedTask = {
322
+ ...task,
323
+ ...updates,
324
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
325
+ };
326
+ prd.tasks[taskIndex] = updatedTask;
327
+ writePRD(projectPath, prd);
328
+ return updatedTask;
329
+ }
330
+ function deleteTask(projectPath, taskId) {
331
+ const prd = readPRD(projectPath);
332
+ const initialLength = prd.tasks.length;
333
+ prd.tasks = prd.tasks.filter((t) => t.id !== taskId);
334
+ if (prd.tasks.length < initialLength) {
335
+ writePRD(projectPath, prd);
336
+ return true;
337
+ }
338
+ return false;
339
+ }
340
+ function addExecutionEntry(projectPath, taskId, entry) {
341
+ const prd = readPRD(projectPath);
342
+ const taskIndex = prd.tasks.findIndex((t) => t.id === taskId);
343
+ if (taskIndex === -1) {
344
+ return null;
345
+ }
346
+ prd.tasks[taskIndex].executionHistory.push(entry);
347
+ prd.tasks[taskIndex].updatedAt = (/* @__PURE__ */ new Date()).toISOString();
348
+ writePRD(projectPath, prd);
349
+ return prd.tasks[taskIndex];
350
+ }
351
+ function getNextReadyTask(projectPath) {
352
+ const readyTasks = getTasksByStatus(projectPath, "ready");
353
+ if (readyTasks.length === 0) {
354
+ return null;
355
+ }
356
+ const priorityOrder = {
357
+ critical: 0,
358
+ high: 1,
359
+ medium: 2,
360
+ low: 3
361
+ };
362
+ readyTasks.sort((a, b) => {
363
+ const priorityDiff = (priorityOrder[a.priority] || 2) - (priorityOrder[b.priority] || 2);
364
+ if (priorityDiff !== 0) return priorityDiff;
365
+ return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
366
+ });
367
+ return readyTasks[0];
368
+ }
369
+ function getTaskCounts(projectPath) {
370
+ const tasks = getAllTasks(projectPath);
371
+ const counts = {
372
+ draft: 0,
373
+ ready: 0,
374
+ in_progress: 0,
375
+ completed: 0,
376
+ failed: 0
377
+ };
378
+ for (const task of tasks) {
379
+ counts[task.status]++;
380
+ }
381
+ return counts;
382
+ }
383
+
384
+ // src/server/services/progress.ts
385
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync3, appendFileSync } from "fs";
386
+ import { join as join3 } from "path";
387
+ var KANBAN_DIR3 = ".claude-kanban";
388
+ function getProgressPath(projectPath) {
389
+ return join3(projectPath, KANBAN_DIR3, "progress.txt");
390
+ }
391
+ function readProgress(projectPath) {
392
+ const path = getProgressPath(projectPath);
393
+ return readFileSync3(path, "utf-8");
394
+ }
395
+ function appendProgress(projectPath, entry) {
396
+ const path = getProgressPath(projectPath);
397
+ appendFileSync(path, entry + "\n");
398
+ }
399
+ function logTaskExecution(projectPath, options) {
400
+ const now = /* @__PURE__ */ new Date();
401
+ const dateStr = now.toISOString().split("T")[0];
402
+ const timeStr = now.toTimeString().split(" ")[0];
403
+ const durationStr = formatDuration(options.duration);
404
+ const statusEmoji = options.status === "completed" ? "\u2713" : options.status === "failed" ? "\u2717" : "\u25CB";
405
+ let entry = `
406
+ ## ${dateStr} ${timeStr}
407
+
408
+ `;
409
+ entry += `### Task: ${options.taskId} - ${options.taskTitle}
410
+ `;
411
+ entry += `Status: ${statusEmoji} ${options.status.toUpperCase()}
412
+ `;
413
+ entry += `Duration: ${durationStr}
414
+ `;
415
+ if (options.error) {
416
+ entry += `
417
+ Error: ${options.error}
418
+ `;
419
+ }
420
+ entry += "\n---\n";
421
+ appendProgress(projectPath, entry);
422
+ }
423
+ function formatDuration(ms) {
424
+ const seconds = Math.floor(ms / 1e3);
425
+ const minutes = Math.floor(seconds / 60);
426
+ const hours = Math.floor(minutes / 60);
427
+ if (hours > 0) {
428
+ return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
429
+ }
430
+ if (minutes > 0) {
431
+ return `${minutes}m ${seconds % 60}s`;
432
+ }
433
+ return `${seconds}s`;
434
+ }
435
+ function getRecentProgress(projectPath, lines = 100) {
436
+ const content = readProgress(projectPath);
437
+ const allLines = content.split("\n");
438
+ if (allLines.length <= lines) {
439
+ return content;
440
+ }
441
+ return allLines.slice(-lines).join("\n");
442
+ }
443
+
444
+ // src/server/services/executor.ts
445
+ var KANBAN_DIR4 = ".claude-kanban";
446
+ var LOGS_DIR = "logs";
447
+ var TaskExecutor = class extends EventEmitter {
448
+ projectPath;
449
+ runningTasks = /* @__PURE__ */ new Map();
450
+ afkMode = false;
451
+ afkIteration = 0;
452
+ afkMaxIterations = 0;
453
+ afkTasksCompleted = 0;
454
+ constructor(projectPath) {
455
+ super();
456
+ this.projectPath = projectPath;
457
+ this.ensureLogsDir();
458
+ }
459
+ /**
460
+ * Ensure logs directory exists
461
+ */
462
+ ensureLogsDir() {
463
+ const logsPath = join4(this.projectPath, KANBAN_DIR4, LOGS_DIR);
464
+ if (!existsSync2(logsPath)) {
465
+ mkdirSync2(logsPath, { recursive: true });
466
+ }
467
+ }
468
+ /**
469
+ * Get log file path for a task
470
+ */
471
+ getLogFilePath(taskId) {
472
+ return join4(this.projectPath, KANBAN_DIR4, LOGS_DIR, `${taskId}.log`);
473
+ }
474
+ /**
475
+ * Initialize log file for a task (clear existing)
476
+ */
477
+ initLogFile(taskId) {
478
+ const logPath = this.getLogFilePath(taskId);
479
+ writeFileSync4(logPath, "");
480
+ }
481
+ /**
482
+ * Append to task log file
483
+ */
484
+ appendToLog(taskId, text) {
485
+ const logPath = this.getLogFilePath(taskId);
486
+ appendFileSync2(logPath, text);
487
+ }
488
+ /**
489
+ * Read task log file
490
+ */
491
+ getTaskLog(taskId) {
492
+ const logPath = this.getLogFilePath(taskId);
493
+ if (!existsSync2(logPath)) return null;
494
+ return readFileSync4(logPath, "utf-8");
495
+ }
496
+ /**
497
+ * Get number of currently running tasks
498
+ */
499
+ getRunningCount() {
500
+ return this.runningTasks.size;
501
+ }
502
+ /**
503
+ * Check if a task is running
504
+ */
505
+ isTaskRunning(taskId) {
506
+ return this.runningTasks.has(taskId);
507
+ }
508
+ /**
509
+ * Get all running task IDs
510
+ */
511
+ getRunningTaskIds() {
512
+ return Array.from(this.runningTasks.keys());
513
+ }
514
+ /**
515
+ * Get running task output
516
+ */
517
+ getTaskOutput(taskId) {
518
+ return this.runningTasks.get(taskId)?.output;
519
+ }
520
+ /**
521
+ * Build the prompt for a specific task
522
+ */
523
+ buildTaskPrompt(task, config) {
524
+ const kanbanDir = join4(this.projectPath, KANBAN_DIR4);
525
+ const prdPath = join4(kanbanDir, "prd.json");
526
+ const progressPath = join4(kanbanDir, "progress.txt");
527
+ const stepsText = task.steps.length > 0 ? `
528
+ Verification steps:
529
+ ${task.steps.map((s, i) => `${i + 1}. ${s}`).join("\n")}` : "";
530
+ return `You are an AI coding agent. Complete the following task:
531
+
532
+ ## TASK
533
+ Title: ${task.title}
534
+ Category: ${task.category}
535
+ Priority: ${task.priority}
536
+
537
+ ${task.description}
538
+ ${stepsText}
539
+
540
+ ## INSTRUCTIONS
541
+ 1. Implement this task as described above.
542
+
543
+ 2. Verify your work:
544
+ - Run typecheck: ${config.project.typecheckCommand}
545
+ - Run tests: ${config.project.testCommand}
546
+
547
+ 3. When complete, update the task in ${prdPath}:
548
+ - Find the task with id "${task.id}"
549
+ - Set "passes": true
550
+ - Set "status": "completed"
551
+
552
+ 4. Document your work in ${progressPath}:
553
+ - What you implemented and files changed
554
+ - Key decisions made and why
555
+ - Gotchas, edge cases, or tricky parts discovered
556
+ - Useful patterns or approaches that worked well
557
+ - Anything a future agent should know about this area of the codebase
558
+
559
+ 5. Make a git commit with a descriptive message.
560
+
561
+ Focus only on this task. When successfully complete, output: <promise>COMPLETE</promise>`;
562
+ }
563
+ /**
564
+ * Run a specific task
565
+ */
566
+ async runTask(taskId) {
567
+ const config = getConfig(this.projectPath);
568
+ const task = getTaskById(this.projectPath, taskId);
569
+ if (!task) {
570
+ throw new Error(`Task not found: ${taskId}`);
571
+ }
572
+ if (this.isTaskRunning(taskId)) {
573
+ throw new Error(`Task already running: ${taskId}`);
574
+ }
575
+ const maxConcurrent = config.execution.maxConcurrent || 3;
576
+ if (this.getRunningCount() >= maxConcurrent) {
577
+ throw new Error(`Maximum concurrent tasks (${maxConcurrent}) reached`);
578
+ }
579
+ updateTask(this.projectPath, taskId, { status: "in_progress" });
580
+ const startedAt = /* @__PURE__ */ new Date();
581
+ const prompt = this.buildTaskPrompt(task, config);
582
+ const kanbanDir = join4(this.projectPath, KANBAN_DIR4);
583
+ const prdPath = join4(kanbanDir, "prd.json");
584
+ const progressPath = join4(kanbanDir, "progress.txt");
585
+ const promptFile = join4(kanbanDir, `prompt-${taskId}.txt`);
586
+ writeFileSync4(promptFile, prompt);
587
+ const args = [];
588
+ if (config.agent.model) {
589
+ args.push("--model", config.agent.model);
590
+ }
591
+ args.push("--permission-mode", config.agent.permissionMode);
592
+ args.push("-p");
593
+ args.push("--verbose");
594
+ args.push("--output-format", "stream-json");
595
+ args.push(`@${promptFile}`);
596
+ const commandDisplay = `${config.agent.command} ${args.join(" ")}`;
597
+ const fullCommand = `${config.agent.command} ${args.join(" ")}`;
598
+ console.log("[executor] Command:", fullCommand);
599
+ console.log("[executor] CWD:", this.projectPath);
600
+ const childProcess = spawn("bash", ["-c", fullCommand], {
601
+ cwd: this.projectPath,
602
+ env: {
603
+ ...process.env,
604
+ TERM: "xterm-256color",
605
+ FORCE_COLOR: "0",
606
+ // Disable colors to avoid escape codes
607
+ NO_COLOR: "1"
608
+ // Standard way to disable colors
609
+ },
610
+ stdio: ["ignore", "pipe", "pipe"]
611
+ // Close stdin since we don't need interactive input
612
+ });
613
+ const runningTask = {
614
+ taskId,
615
+ process: childProcess,
616
+ startedAt,
617
+ output: []
618
+ };
619
+ this.runningTasks.set(taskId, runningTask);
620
+ this.initLogFile(taskId);
621
+ const logOutput = (line) => {
622
+ this.appendToLog(taskId, line);
623
+ runningTask.output.push(line);
624
+ this.emit("task:output", { taskId, line, lineType: "stdout" });
625
+ };
626
+ this.emit("task:started", { taskId, timestamp: startedAt.toISOString() });
627
+ logOutput(`[claude-kanban] Starting task: ${task.title}
628
+ `);
629
+ logOutput(`[claude-kanban] Command: ${commandDisplay}
630
+ `);
631
+ logOutput(`[claude-kanban] Process spawned (PID: ${childProcess.pid})
632
+ `);
633
+ let stdoutBuffer = "";
634
+ childProcess.stdout?.on("data", (data) => {
635
+ stdoutBuffer += data.toString();
636
+ const lines = stdoutBuffer.split("\n");
637
+ stdoutBuffer = lines.pop() || "";
638
+ for (const line of lines) {
639
+ if (!line.trim()) continue;
640
+ try {
641
+ const json = JSON.parse(line);
642
+ let text = "";
643
+ if (json.type === "assistant" && json.message?.content) {
644
+ for (const block of json.message.content) {
645
+ if (block.type === "text") {
646
+ text += block.text;
647
+ } else if (block.type === "tool_use") {
648
+ text += `[Tool: ${block.name}]
649
+ `;
650
+ }
651
+ }
652
+ } else if (json.type === "content_block_delta" && json.delta?.text) {
653
+ text = json.delta.text;
654
+ } else if (json.type === "result" && json.result) {
655
+ text = `
656
+ [Result: ${json.result}]
657
+ `;
658
+ }
659
+ if (text) {
660
+ logOutput(text);
661
+ }
662
+ } catch {
663
+ const cleanText = line.replace(/\x1B\[[0-9;]*[A-Za-z]/g, "");
664
+ if (cleanText.trim()) {
665
+ logOutput(cleanText + "\n");
666
+ }
667
+ }
668
+ }
669
+ });
670
+ childProcess.stderr?.on("data", (data) => {
671
+ const text = data.toString();
672
+ logOutput(`[stderr] ${text}`);
673
+ });
674
+ childProcess.on("spawn", () => {
675
+ console.log("[executor] Process spawned successfully");
676
+ });
677
+ childProcess.on("error", (error) => {
678
+ console.log("[executor] Spawn error:", error.message);
679
+ this.emit("task:output", { taskId, line: `[claude-kanban] Error: ${error.message}
680
+ `, lineType: "stderr" });
681
+ try {
682
+ unlinkSync(promptFile);
683
+ } catch {
684
+ }
685
+ updateTask(this.projectPath, taskId, { status: "failed", passes: false });
686
+ const endedAt = /* @__PURE__ */ new Date();
687
+ addExecutionEntry(this.projectPath, taskId, {
688
+ startedAt: startedAt.toISOString(),
689
+ endedAt: endedAt.toISOString(),
690
+ status: "failed",
691
+ duration: endedAt.getTime() - startedAt.getTime(),
692
+ error: error.message
693
+ });
694
+ this.emit("task:failed", { taskId, error: error.message });
695
+ this.runningTasks.delete(taskId);
696
+ });
697
+ childProcess.on("close", (code, signal) => {
698
+ console.log("[executor] Process closed with code:", code, "signal:", signal);
699
+ try {
700
+ unlinkSync(promptFile);
701
+ } catch {
702
+ }
703
+ logOutput(`[claude-kanban] Process exited with code ${code}
704
+ `);
705
+ this.handleTaskComplete(taskId, code, startedAt);
706
+ });
707
+ const timeoutMs = (config.execution.timeout || 30) * 60 * 1e3;
708
+ setTimeout(() => {
709
+ if (this.isTaskRunning(taskId)) {
710
+ this.cancelTask(taskId, "Timeout exceeded");
711
+ }
712
+ }, timeoutMs);
713
+ }
714
+ /**
715
+ * Handle task completion
716
+ */
717
+ handleTaskComplete(taskId, exitCode, startedAt) {
718
+ const runningTask = this.runningTasks.get(taskId);
719
+ if (!runningTask) return;
720
+ const endedAt = /* @__PURE__ */ new Date();
721
+ const duration = endedAt.getTime() - startedAt.getTime();
722
+ const output = runningTask.output.join("");
723
+ const isComplete = output.includes("<promise>COMPLETE</promise>");
724
+ const task = getTaskById(this.projectPath, taskId);
725
+ if (isComplete || exitCode === 0) {
726
+ updateTask(this.projectPath, taskId, {
727
+ status: "completed",
728
+ passes: true
729
+ });
730
+ addExecutionEntry(this.projectPath, taskId, {
731
+ startedAt: startedAt.toISOString(),
732
+ endedAt: endedAt.toISOString(),
733
+ status: "completed",
734
+ duration
735
+ });
736
+ logTaskExecution(this.projectPath, {
737
+ taskId,
738
+ taskTitle: task?.title || "Unknown",
739
+ status: "completed",
740
+ duration
741
+ });
742
+ this.emit("task:completed", { taskId, duration });
743
+ this.afkTasksCompleted++;
744
+ } else {
745
+ updateTask(this.projectPath, taskId, {
746
+ status: "failed",
747
+ passes: false
748
+ });
749
+ const error = `Process exited with code ${exitCode}`;
750
+ addExecutionEntry(this.projectPath, taskId, {
751
+ startedAt: startedAt.toISOString(),
752
+ endedAt: endedAt.toISOString(),
753
+ status: "failed",
754
+ duration,
755
+ error
756
+ });
757
+ logTaskExecution(this.projectPath, {
758
+ taskId,
759
+ taskTitle: task?.title || "Unknown",
760
+ status: "failed",
761
+ duration,
762
+ error
763
+ });
764
+ this.emit("task:failed", { taskId, error });
765
+ }
766
+ this.runningTasks.delete(taskId);
767
+ if (this.afkMode) {
768
+ this.continueAFKMode();
769
+ }
770
+ }
771
+ /**
772
+ * Cancel a running task
773
+ */
774
+ cancelTask(taskId, reason = "Cancelled by user") {
775
+ const runningTask = this.runningTasks.get(taskId);
776
+ if (!runningTask) return false;
777
+ const startedAt = runningTask.startedAt;
778
+ const endedAt = /* @__PURE__ */ new Date();
779
+ const duration = endedAt.getTime() - startedAt.getTime();
780
+ const task = getTaskById(this.projectPath, taskId);
781
+ try {
782
+ runningTask.process.kill("SIGTERM");
783
+ setTimeout(() => {
784
+ try {
785
+ if (!runningTask.process.killed) {
786
+ runningTask.process.kill("SIGKILL");
787
+ }
788
+ } catch {
789
+ }
790
+ }, 2e3);
791
+ } catch {
792
+ }
793
+ updateTask(this.projectPath, taskId, {
794
+ status: "ready"
795
+ });
796
+ addExecutionEntry(this.projectPath, taskId, {
797
+ startedAt: startedAt.toISOString(),
798
+ endedAt: endedAt.toISOString(),
799
+ status: "cancelled",
800
+ duration,
801
+ error: reason
802
+ });
803
+ logTaskExecution(this.projectPath, {
804
+ taskId,
805
+ taskTitle: task?.title || "Unknown",
806
+ status: "cancelled",
807
+ duration,
808
+ error: reason
809
+ });
810
+ this.emit("task:cancelled", { taskId });
811
+ this.runningTasks.delete(taskId);
812
+ return true;
813
+ }
814
+ /**
815
+ * Start AFK mode
816
+ */
817
+ startAFKMode(maxIterations, concurrent) {
818
+ if (this.afkMode) {
819
+ throw new Error("AFK mode already running");
820
+ }
821
+ this.afkMode = true;
822
+ this.afkIteration = 0;
823
+ this.afkMaxIterations = maxIterations;
824
+ this.afkTasksCompleted = 0;
825
+ this.emitAFKStatus();
826
+ this.continueAFKMode(concurrent);
827
+ }
828
+ /**
829
+ * Continue AFK mode - pick up next tasks
830
+ */
831
+ continueAFKMode(concurrent = 1) {
832
+ if (!this.afkMode) return;
833
+ if (this.afkIteration >= this.afkMaxIterations) {
834
+ this.stopAFKMode();
835
+ return;
836
+ }
837
+ const config = getConfig(this.projectPath);
838
+ const maxConcurrent = Math.min(concurrent, config.execution.maxConcurrent || 3);
839
+ while (this.getRunningCount() < maxConcurrent) {
840
+ const nextTask = getNextReadyTask(this.projectPath);
841
+ if (!nextTask) {
842
+ if (this.getRunningCount() === 0) {
843
+ this.stopAFKMode();
844
+ }
845
+ break;
846
+ }
847
+ this.afkIteration++;
848
+ this.runTask(nextTask.id).catch((error) => {
849
+ console.error("AFK task error:", error);
850
+ });
851
+ this.emitAFKStatus();
852
+ }
853
+ }
854
+ /**
855
+ * Stop AFK mode
856
+ */
857
+ stopAFKMode() {
858
+ this.afkMode = false;
859
+ this.emitAFKStatus();
860
+ }
861
+ /**
862
+ * Emit AFK status
863
+ */
864
+ emitAFKStatus() {
865
+ this.emit("afk:status", {
866
+ running: this.afkMode,
867
+ currentIteration: this.afkIteration,
868
+ maxIterations: this.afkMaxIterations,
869
+ tasksCompleted: this.afkTasksCompleted
870
+ });
871
+ }
872
+ /**
873
+ * Get AFK status
874
+ */
875
+ getAFKStatus() {
876
+ return {
877
+ running: this.afkMode,
878
+ currentIteration: this.afkIteration,
879
+ maxIterations: this.afkMaxIterations,
880
+ tasksCompleted: this.afkTasksCompleted
881
+ };
882
+ }
883
+ /**
884
+ * Cancel all running tasks
885
+ */
886
+ cancelAll() {
887
+ for (const [taskId, runningTask] of this.runningTasks.entries()) {
888
+ try {
889
+ runningTask.process.kill("SIGKILL");
890
+ } catch {
891
+ }
892
+ this.runningTasks.delete(taskId);
893
+ }
894
+ this.stopAFKMode();
895
+ }
896
+ };
897
+
898
+ // src/server/services/templates.ts
899
+ var taskTemplates = [
900
+ {
901
+ id: "auth-login",
902
+ name: "Authentication - Login",
903
+ icon: "\u{1F510}",
904
+ description: "User login functionality with email/password",
905
+ category: "functional",
906
+ priority: "high",
907
+ titleTemplate: "Add user login functionality",
908
+ descriptionTemplate: `Implement user login with email and password authentication.
909
+
910
+ Requirements:
911
+ - Login form with email and password fields
912
+ - Form validation with error messages
913
+ - Submit credentials to auth API
914
+ - Handle success (redirect) and failure (show error)
915
+ - Store auth token/session`,
916
+ stepsTemplate: [
917
+ "Navigate to /login",
918
+ "Verify login form displays with email and password fields",
919
+ "Enter invalid credentials and verify error message",
920
+ "Enter valid credentials and submit",
921
+ "Verify redirect to dashboard/home",
922
+ "Verify auth state persists on page reload"
923
+ ]
924
+ },
925
+ {
926
+ id: "auth-signup",
927
+ name: "Authentication - Signup",
928
+ icon: "\u{1F510}",
929
+ description: "User registration with validation",
930
+ category: "functional",
931
+ priority: "high",
932
+ titleTemplate: "Add user signup/registration",
933
+ descriptionTemplate: `Implement user registration with form validation.
934
+
935
+ Requirements:
936
+ - Signup form with name, email, password fields
937
+ - Password confirmation field
938
+ - Client-side validation
939
+ - Submit to registration API
940
+ - Handle success and error states`,
941
+ stepsTemplate: [
942
+ "Navigate to /signup",
943
+ "Verify signup form displays all required fields",
944
+ "Submit with invalid data and verify validation errors",
945
+ "Submit with valid data",
946
+ "Verify success message or redirect",
947
+ "Verify new user can log in"
948
+ ]
949
+ },
950
+ {
951
+ id: "auth-logout",
952
+ name: "Authentication - Logout",
953
+ icon: "\u{1F510}",
954
+ description: "User logout functionality",
955
+ category: "functional",
956
+ priority: "medium",
957
+ titleTemplate: "Add user logout functionality",
958
+ descriptionTemplate: `Implement logout functionality.
959
+
960
+ Requirements:
961
+ - Logout button/link in navigation
962
+ - Clear auth token/session on logout
963
+ - Redirect to login or home page
964
+ - Protect routes after logout`,
965
+ stepsTemplate: [
966
+ "Log in as a user",
967
+ "Click logout button",
968
+ "Verify redirect to login/home page",
969
+ "Verify auth state is cleared",
970
+ "Verify protected routes redirect to login"
971
+ ]
972
+ },
973
+ {
974
+ id: "crud-create",
975
+ name: "CRUD - Create",
976
+ icon: "\u{1F4DD}",
977
+ description: "Create new entity form",
978
+ category: "functional",
979
+ priority: "medium",
980
+ titleTemplate: "Add create [Entity] form",
981
+ descriptionTemplate: `Implement form to create a new [Entity].
982
+
983
+ Requirements:
984
+ - Form with all required fields
985
+ - Client-side validation
986
+ - Submit to create API endpoint
987
+ - Handle loading, success, and error states
988
+ - Redirect or show success message after creation`,
989
+ stepsTemplate: [
990
+ "Navigate to create form",
991
+ "Verify all form fields display correctly",
992
+ "Submit empty form and verify validation",
993
+ "Fill in valid data and submit",
994
+ "Verify entity is created",
995
+ "Verify redirect or success message"
996
+ ]
997
+ },
998
+ {
999
+ id: "crud-read",
1000
+ name: "CRUD - Read/List",
1001
+ icon: "\u{1F4DD}",
1002
+ description: "List and view entities",
1003
+ category: "functional",
1004
+ priority: "medium",
1005
+ titleTemplate: "Add [Entity] list view",
1006
+ descriptionTemplate: `Implement list view for [Entity] items.
1007
+
1008
+ Requirements:
1009
+ - Fetch and display list of entities
1010
+ - Show loading state
1011
+ - Handle empty state
1012
+ - Display relevant fields for each item
1013
+ - Link to detail view`,
1014
+ stepsTemplate: [
1015
+ "Navigate to list page",
1016
+ "Verify loading state displays",
1017
+ "Verify items render correctly",
1018
+ "Verify empty state when no items",
1019
+ "Click item and verify navigation to detail"
1020
+ ]
1021
+ },
1022
+ {
1023
+ id: "crud-update",
1024
+ name: "CRUD - Update",
1025
+ icon: "\u{1F4DD}",
1026
+ description: "Edit existing entity",
1027
+ category: "functional",
1028
+ priority: "medium",
1029
+ titleTemplate: "Add edit [Entity] form",
1030
+ descriptionTemplate: `Implement form to edit an existing [Entity].
1031
+
1032
+ Requirements:
1033
+ - Pre-populate form with existing data
1034
+ - Allow modification of fields
1035
+ - Submit changes to update API
1036
+ - Handle validation and errors
1037
+ - Show success feedback`,
1038
+ stepsTemplate: [
1039
+ "Navigate to edit form for existing item",
1040
+ "Verify form pre-populates with current data",
1041
+ "Modify fields",
1042
+ "Submit form",
1043
+ "Verify changes are saved",
1044
+ "Verify updated data displays correctly"
1045
+ ]
1046
+ },
1047
+ {
1048
+ id: "crud-delete",
1049
+ name: "CRUD - Delete",
1050
+ icon: "\u{1F4DD}",
1051
+ description: "Delete entity with confirmation",
1052
+ category: "functional",
1053
+ priority: "medium",
1054
+ titleTemplate: "Add delete [Entity] functionality",
1055
+ descriptionTemplate: `Implement delete functionality for [Entity].
1056
+
1057
+ Requirements:
1058
+ - Delete button on item or list
1059
+ - Confirmation dialog before delete
1060
+ - Call delete API endpoint
1061
+ - Remove item from UI on success
1062
+ - Handle errors gracefully`,
1063
+ stepsTemplate: [
1064
+ "Navigate to item with delete option",
1065
+ "Click delete button",
1066
+ "Verify confirmation dialog appears",
1067
+ "Confirm deletion",
1068
+ "Verify item is removed from list",
1069
+ "Verify item no longer accessible"
1070
+ ]
1071
+ },
1072
+ {
1073
+ id: "api-endpoint",
1074
+ name: "API Endpoint",
1075
+ icon: "\u{1F310}",
1076
+ description: "REST API endpoint with validation",
1077
+ category: "functional",
1078
+ priority: "medium",
1079
+ titleTemplate: "Add [METHOD] /api/[resource] endpoint",
1080
+ descriptionTemplate: `Implement REST API endpoint.
1081
+
1082
+ Requirements:
1083
+ - Define route handler
1084
+ - Validate request body/params
1085
+ - Implement business logic
1086
+ - Return appropriate response codes
1087
+ - Add error handling`,
1088
+ stepsTemplate: [
1089
+ "Endpoint responds to correct HTTP method",
1090
+ "Invalid requests return 400 with error details",
1091
+ "Valid requests process correctly",
1092
+ "Returns appropriate status codes",
1093
+ "Handles edge cases gracefully"
1094
+ ]
1095
+ },
1096
+ {
1097
+ id: "ui-component",
1098
+ name: "UI Component",
1099
+ icon: "\u{1F3A8}",
1100
+ description: "Reusable UI component",
1101
+ category: "ui",
1102
+ priority: "medium",
1103
+ titleTemplate: "Create [ComponentName] component",
1104
+ descriptionTemplate: `Create a reusable UI component.
1105
+
1106
+ Requirements:
1107
+ - Component accepts appropriate props
1108
+ - Handles different states (loading, error, empty)
1109
+ - Follows design system/styling conventions
1110
+ - Accessible (keyboard, screen reader)
1111
+ - Includes any needed interactivity`,
1112
+ stepsTemplate: [
1113
+ "Component renders without errors",
1114
+ "Props affect rendering correctly",
1115
+ "Different states display appropriately",
1116
+ "Component is accessible",
1117
+ "Interactive elements work correctly"
1118
+ ]
1119
+ },
1120
+ {
1121
+ id: "form-validation",
1122
+ name: "Form with Validation",
1123
+ icon: "\u{1F4C4}",
1124
+ description: "Form with client-side validation",
1125
+ category: "ui",
1126
+ priority: "medium",
1127
+ titleTemplate: "Create [FormName] form with validation",
1128
+ descriptionTemplate: `Create a form with comprehensive validation.
1129
+
1130
+ Requirements:
1131
+ - All necessary input fields
1132
+ - Real-time validation feedback
1133
+ - Clear error messages
1134
+ - Submit button state management
1135
+ - Form submission handling`,
1136
+ stepsTemplate: [
1137
+ "Form displays all required fields",
1138
+ "Invalid input shows error message",
1139
+ "Valid input clears error",
1140
+ "Submit disabled when form invalid",
1141
+ "Form submits with valid data"
1142
+ ]
1143
+ },
1144
+ {
1145
+ id: "test-unit",
1146
+ name: "Unit Tests",
1147
+ icon: "\u{1F9EA}",
1148
+ description: "Unit test suite for module",
1149
+ category: "testing",
1150
+ priority: "medium",
1151
+ titleTemplate: "Add unit tests for [module/component]",
1152
+ descriptionTemplate: `Write comprehensive unit tests.
1153
+
1154
+ Requirements:
1155
+ - Test all public functions/methods
1156
+ - Cover edge cases
1157
+ - Test error handling
1158
+ - Achieve good coverage
1159
+ - Tests should be fast and isolated`,
1160
+ stepsTemplate: [
1161
+ "All tests pass",
1162
+ "Core functionality covered",
1163
+ "Edge cases tested",
1164
+ "Error cases handled",
1165
+ "No flaky tests"
1166
+ ]
1167
+ },
1168
+ {
1169
+ id: "test-e2e",
1170
+ name: "E2E Tests",
1171
+ icon: "\u{1F9EA}",
1172
+ description: "End-to-end test for user flow",
1173
+ category: "testing",
1174
+ priority: "medium",
1175
+ titleTemplate: "Add E2E tests for [feature/flow]",
1176
+ descriptionTemplate: `Write end-to-end tests for user flow.
1177
+
1178
+ Requirements:
1179
+ - Test complete user journey
1180
+ - Use realistic test data
1181
+ - Verify UI and data changes
1182
+ - Handle async operations
1183
+ - Clean up test data`,
1184
+ stepsTemplate: [
1185
+ "Tests run successfully",
1186
+ "User flow completes correctly",
1187
+ "UI updates verified",
1188
+ "Data persisted correctly",
1189
+ "Tests are reliable (not flaky)"
1190
+ ]
1191
+ }
1192
+ ];
1193
+ function getAllTemplates() {
1194
+ return taskTemplates;
1195
+ }
1196
+ function getTemplateById(id) {
1197
+ return taskTemplates.find((t) => t.id === id);
1198
+ }
1199
+
1200
+ // src/server/services/ai.ts
1201
+ import { spawn as spawn2 } from "child_process";
1202
+ async function generateTaskFromPrompt(projectPath, userPrompt) {
1203
+ const config = getConfig(projectPath);
1204
+ const systemPrompt = `You are a task generator for a Kanban board used in software development.
1205
+ Given a user's description of what they want to build, generate a structured task.
1206
+
1207
+ Respond with ONLY valid JSON in this exact format:
1208
+ {
1209
+ "title": "Short, action-oriented title (max 80 chars)",
1210
+ "description": "Detailed description of what needs to be implemented",
1211
+ "category": "functional|ui|bug|enhancement|testing|refactor",
1212
+ "priority": "low|medium|high|critical",
1213
+ "steps": ["Step 1 to verify", "Step 2 to verify", "..."]
1214
+ }
1215
+
1216
+ Guidelines:
1217
+ - Title should be concise and start with a verb (Add, Create, Implement, Fix, etc.)
1218
+ - Description should be comprehensive but focused
1219
+ - Steps should be verification steps to confirm the feature works
1220
+ - Include 3-7 verification steps
1221
+ - Choose appropriate category based on the work type
1222
+ - Priority should reflect typical importance (most features are medium)
1223
+
1224
+ Respond with ONLY the JSON, no other text.`;
1225
+ const fullPrompt = `${systemPrompt}
1226
+
1227
+ User request: ${userPrompt}`;
1228
+ return new Promise((resolve, reject) => {
1229
+ const args = [
1230
+ "--permission-mode",
1231
+ "ask",
1232
+ "-p",
1233
+ fullPrompt
1234
+ ];
1235
+ if (config.agent.model) {
1236
+ args.unshift("--model", config.agent.model);
1237
+ }
1238
+ let output = "";
1239
+ let errorOutput = "";
1240
+ const proc = spawn2(config.agent.command, args, {
1241
+ cwd: projectPath,
1242
+ shell: true,
1243
+ env: { ...process.env }
1244
+ });
1245
+ proc.stdout?.on("data", (data) => {
1246
+ output += data.toString();
1247
+ });
1248
+ proc.stderr?.on("data", (data) => {
1249
+ errorOutput += data.toString();
1250
+ });
1251
+ proc.on("close", (code) => {
1252
+ if (code !== 0) {
1253
+ reject(new Error(`AI generation failed: ${errorOutput || "Unknown error"}`));
1254
+ return;
1255
+ }
1256
+ try {
1257
+ const jsonMatch = output.match(/\{[\s\S]*\}/);
1258
+ if (!jsonMatch) {
1259
+ throw new Error("No JSON found in response");
1260
+ }
1261
+ const parsed = JSON.parse(jsonMatch[0]);
1262
+ if (!parsed.title || !parsed.description) {
1263
+ throw new Error("Missing required fields in response");
1264
+ }
1265
+ const task = {
1266
+ title: String(parsed.title).slice(0, 200),
1267
+ description: String(parsed.description),
1268
+ category: validateCategory(parsed.category),
1269
+ priority: validatePriority(parsed.priority),
1270
+ steps: Array.isArray(parsed.steps) ? parsed.steps.map(String) : [],
1271
+ status: "draft"
1272
+ };
1273
+ resolve(task);
1274
+ } catch (parseError) {
1275
+ reject(new Error(`Failed to parse AI response: ${parseError}`));
1276
+ }
1277
+ });
1278
+ proc.on("error", (error) => {
1279
+ reject(new Error(`Failed to spawn AI process: ${error.message}`));
1280
+ });
1281
+ setTimeout(() => {
1282
+ proc.kill();
1283
+ reject(new Error("AI generation timed out"));
1284
+ }, 6e4);
1285
+ });
1286
+ }
1287
+ function validateCategory(category) {
1288
+ const valid = ["functional", "ui", "bug", "enhancement", "testing", "refactor"];
1289
+ if (typeof category === "string" && valid.includes(category)) {
1290
+ return category;
1291
+ }
1292
+ return "functional";
1293
+ }
1294
+ function validatePriority(priority) {
1295
+ const valid = ["low", "medium", "high", "critical"];
1296
+ if (typeof priority === "string" && valid.includes(priority)) {
1297
+ return priority;
1298
+ }
1299
+ return "medium";
1300
+ }
1301
+
1302
+ // src/server/index.ts
1303
+ var __dirname2 = dirname(fileURLToPath(import.meta.url));
1304
+ async function createServer(projectPath, port) {
1305
+ const app = express();
1306
+ const httpServer = createHttpServer(app);
1307
+ const io = new SocketIOServer(httpServer, {
1308
+ cors: { origin: "*" }
1309
+ });
1310
+ app.use(express.json());
1311
+ const executor = new TaskExecutor(projectPath);
1312
+ executor.on("task:started", (data) => io.emit("task:started", data));
1313
+ executor.on("task:output", (data) => io.emit("task:output", data));
1314
+ executor.on("task:completed", (data) => io.emit("task:completed", data));
1315
+ executor.on("task:failed", (data) => io.emit("task:failed", data));
1316
+ executor.on("task:cancelled", (data) => io.emit("task:cancelled", data));
1317
+ executor.on("afk:status", (data) => io.emit("afk:status", data));
1318
+ app.get("/api/tasks", (_req, res) => {
1319
+ try {
1320
+ const tasks = getAllTasks(projectPath);
1321
+ res.json({ tasks });
1322
+ } catch (error) {
1323
+ res.status(500).json({ error: String(error) });
1324
+ }
1325
+ });
1326
+ app.post("/api/tasks", (req, res) => {
1327
+ try {
1328
+ const request = req.body;
1329
+ if (!request.title || !request.description) {
1330
+ res.status(400).json({ error: "Title and description are required" });
1331
+ return;
1332
+ }
1333
+ const task = createTask(projectPath, request);
1334
+ io.emit("task:created", task);
1335
+ res.status(201).json({ task });
1336
+ } catch (error) {
1337
+ res.status(500).json({ error: String(error) });
1338
+ }
1339
+ });
1340
+ app.post("/api/tasks/generate", async (req, res) => {
1341
+ try {
1342
+ const { prompt } = req.body;
1343
+ if (!prompt) {
1344
+ res.status(400).json({ error: "Prompt is required" });
1345
+ return;
1346
+ }
1347
+ const taskRequest = await generateTaskFromPrompt(projectPath, prompt);
1348
+ res.json({ task: taskRequest });
1349
+ } catch (error) {
1350
+ res.status(500).json({ error: String(error) });
1351
+ }
1352
+ });
1353
+ app.get("/api/tasks/:id", (req, res) => {
1354
+ try {
1355
+ const task = getTaskById(projectPath, req.params.id);
1356
+ if (!task) {
1357
+ res.status(404).json({ error: "Task not found" });
1358
+ return;
1359
+ }
1360
+ res.json({ task });
1361
+ } catch (error) {
1362
+ res.status(500).json({ error: String(error) });
1363
+ }
1364
+ });
1365
+ app.put("/api/tasks/:id", (req, res) => {
1366
+ try {
1367
+ const updates = req.body;
1368
+ const task = updateTask(projectPath, req.params.id, updates);
1369
+ if (!task) {
1370
+ res.status(404).json({ error: "Task not found" });
1371
+ return;
1372
+ }
1373
+ io.emit("task:updated", task);
1374
+ res.json({ task });
1375
+ } catch (error) {
1376
+ res.status(500).json({ error: String(error) });
1377
+ }
1378
+ });
1379
+ app.delete("/api/tasks/:id", (req, res) => {
1380
+ try {
1381
+ const deleted = deleteTask(projectPath, req.params.id);
1382
+ if (!deleted) {
1383
+ res.status(404).json({ error: "Task not found" });
1384
+ return;
1385
+ }
1386
+ io.emit("task:deleted", { id: req.params.id });
1387
+ res.json({ success: true });
1388
+ } catch (error) {
1389
+ res.status(500).json({ error: String(error) });
1390
+ }
1391
+ });
1392
+ app.post("/api/tasks/:id/run", async (req, res) => {
1393
+ try {
1394
+ await executor.runTask(req.params.id);
1395
+ res.json({ success: true });
1396
+ } catch (error) {
1397
+ res.status(400).json({ error: String(error) });
1398
+ }
1399
+ });
1400
+ app.post("/api/tasks/:id/cancel", (req, res) => {
1401
+ try {
1402
+ const cancelled = executor.cancelTask(req.params.id);
1403
+ if (!cancelled) {
1404
+ res.status(404).json({ error: "Task not running" });
1405
+ return;
1406
+ }
1407
+ res.json({ success: true });
1408
+ } catch (error) {
1409
+ res.status(500).json({ error: String(error) });
1410
+ }
1411
+ });
1412
+ app.post("/api/tasks/:id/retry", async (req, res) => {
1413
+ try {
1414
+ const task = updateTask(projectPath, req.params.id, {
1415
+ status: "ready",
1416
+ passes: false
1417
+ });
1418
+ if (!task) {
1419
+ res.status(404).json({ error: "Task not found" });
1420
+ return;
1421
+ }
1422
+ io.emit("task:updated", task);
1423
+ if (req.body.autoRun) {
1424
+ await executor.runTask(req.params.id);
1425
+ }
1426
+ res.json({ task });
1427
+ } catch (error) {
1428
+ res.status(500).json({ error: String(error) });
1429
+ }
1430
+ });
1431
+ app.get("/api/tasks/:id/logs", (req, res) => {
1432
+ try {
1433
+ const logs = executor.getTaskLog(req.params.id);
1434
+ if (logs === null) {
1435
+ res.json({ logs: "" });
1436
+ return;
1437
+ }
1438
+ res.json({ logs });
1439
+ } catch (error) {
1440
+ res.status(500).json({ error: String(error) });
1441
+ }
1442
+ });
1443
+ app.get("/api/tasks/:id/output", (req, res) => {
1444
+ try {
1445
+ const output = executor.getTaskOutput(req.params.id);
1446
+ if (!output) {
1447
+ res.status(404).json({ error: "Task not running or not found" });
1448
+ return;
1449
+ }
1450
+ res.json({ output });
1451
+ } catch (error) {
1452
+ res.status(500).json({ error: String(error) });
1453
+ }
1454
+ });
1455
+ app.get("/api/progress", (req, res) => {
1456
+ try {
1457
+ const lines = parseInt(String(req.query.lines)) || 100;
1458
+ const content = getRecentProgress(projectPath, lines);
1459
+ res.json({ content });
1460
+ } catch (error) {
1461
+ res.status(500).json({ error: String(error) });
1462
+ }
1463
+ });
1464
+ app.get("/api/config", (_req, res) => {
1465
+ try {
1466
+ const config = getConfig(projectPath);
1467
+ res.json({ config });
1468
+ } catch (error) {
1469
+ res.status(500).json({ error: String(error) });
1470
+ }
1471
+ });
1472
+ app.put("/api/config", (req, res) => {
1473
+ try {
1474
+ const currentConfig = getConfig(projectPath);
1475
+ const updatedConfig = { ...currentConfig, ...req.body };
1476
+ saveConfig(projectPath, updatedConfig);
1477
+ res.json({ config: updatedConfig });
1478
+ } catch (error) {
1479
+ res.status(500).json({ error: String(error) });
1480
+ }
1481
+ });
1482
+ app.get("/api/templates", (_req, res) => {
1483
+ try {
1484
+ const templates = getAllTemplates();
1485
+ res.json({ templates });
1486
+ } catch (error) {
1487
+ res.status(500).json({ error: String(error) });
1488
+ }
1489
+ });
1490
+ app.get("/api/templates/:id", (req, res) => {
1491
+ try {
1492
+ const template = getTemplateById(req.params.id);
1493
+ if (!template) {
1494
+ res.status(404).json({ error: "Template not found" });
1495
+ return;
1496
+ }
1497
+ res.json({ template });
1498
+ } catch (error) {
1499
+ res.status(500).json({ error: String(error) });
1500
+ }
1501
+ });
1502
+ app.post("/api/afk/start", (req, res) => {
1503
+ try {
1504
+ const { maxIterations, concurrent } = req.body;
1505
+ executor.startAFKMode(maxIterations || 10, concurrent || 1);
1506
+ res.json({ success: true, status: executor.getAFKStatus() });
1507
+ } catch (error) {
1508
+ res.status(400).json({ error: String(error) });
1509
+ }
1510
+ });
1511
+ app.post("/api/afk/stop", (_req, res) => {
1512
+ try {
1513
+ executor.stopAFKMode();
1514
+ res.json({ success: true });
1515
+ } catch (error) {
1516
+ res.status(500).json({ error: String(error) });
1517
+ }
1518
+ });
1519
+ app.get("/api/afk/status", (_req, res) => {
1520
+ try {
1521
+ const status = executor.getAFKStatus();
1522
+ res.json({ status });
1523
+ } catch (error) {
1524
+ res.status(500).json({ error: String(error) });
1525
+ }
1526
+ });
1527
+ app.get("/api/running", (_req, res) => {
1528
+ try {
1529
+ const taskIds = executor.getRunningTaskIds();
1530
+ res.json({ running: taskIds, count: taskIds.length });
1531
+ } catch (error) {
1532
+ res.status(500).json({ error: String(error) });
1533
+ }
1534
+ });
1535
+ app.get("/api/stats", (_req, res) => {
1536
+ try {
1537
+ const counts = getTaskCounts(projectPath);
1538
+ const running = executor.getRunningCount();
1539
+ const afk = executor.getAFKStatus();
1540
+ res.json({ counts, running, afk });
1541
+ } catch (error) {
1542
+ res.status(500).json({ error: String(error) });
1543
+ }
1544
+ });
1545
+ const clientPath = join5(__dirname2, "..", "client");
1546
+ if (existsSync3(clientPath)) {
1547
+ app.use(express.static(clientPath));
1548
+ }
1549
+ app.get("*", (_req, res) => {
1550
+ res.send(getClientHTML());
1551
+ });
1552
+ io.on("connection", (socket) => {
1553
+ console.log("Client connected");
1554
+ const runningIds = executor.getRunningTaskIds();
1555
+ const taskLogs = {};
1556
+ for (const taskId of runningIds) {
1557
+ const logs = executor.getTaskLog(taskId);
1558
+ if (logs) {
1559
+ taskLogs[taskId] = logs;
1560
+ }
1561
+ }
1562
+ socket.emit("init", {
1563
+ tasks: getAllTasks(projectPath),
1564
+ running: runningIds,
1565
+ afk: executor.getAFKStatus(),
1566
+ taskLogs
1567
+ // Include logs for running tasks
1568
+ });
1569
+ socket.on("get-logs", (taskId) => {
1570
+ const logs = executor.getTaskLog(taskId);
1571
+ socket.emit("task-logs", { taskId, logs: logs || "" });
1572
+ });
1573
+ socket.on("disconnect", () => {
1574
+ console.log("Client disconnected");
1575
+ });
1576
+ });
1577
+ return new Promise((resolve, reject) => {
1578
+ httpServer.on("error", (error) => {
1579
+ if (error.code === "EADDRINUSE") {
1580
+ reject(new Error(`Port ${port} is already in use. Try a different port with --port`));
1581
+ } else {
1582
+ reject(error);
1583
+ }
1584
+ });
1585
+ httpServer.listen(port, () => {
1586
+ httpServer.cleanup = () => {
1587
+ console.log("Cleaning up executor...");
1588
+ executor.cancelAll();
1589
+ io.close();
1590
+ };
1591
+ resolve(httpServer);
1592
+ });
1593
+ });
1594
+ }
1595
+ function getClientHTML() {
1596
+ return `<!DOCTYPE html>
1597
+ <html lang="en">
1598
+ <head>
1599
+ <meta charset="UTF-8">
1600
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1601
+ <title>Claude Kanban</title>
1602
+ <link rel="preconnect" href="https://fonts.googleapis.com">
1603
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1604
+ <link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600&family=DM+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
1605
+ <script src="https://cdn.tailwindcss.com"></script>
1606
+ <script src="/socket.io/socket.io.js"></script>
1607
+ <script>
1608
+ tailwind.config = {
1609
+ theme: {
1610
+ extend: {
1611
+ fontFamily: {
1612
+ display: ['DM Sans', 'system-ui', 'sans-serif'],
1613
+ sans: ['DM Sans', 'system-ui', 'sans-serif'],
1614
+ mono: ['IBM Plex Mono', 'monospace'],
1615
+ },
1616
+ colors: {
1617
+ canvas: {
1618
+ DEFAULT: '#ffffff',
1619
+ 50: '#fafafa',
1620
+ 100: '#f5f5f5',
1621
+ 200: '#e5e5e5',
1622
+ 300: '#d4d4d4',
1623
+ 400: '#a3a3a3',
1624
+ 500: '#737373',
1625
+ 600: '#525252',
1626
+ 700: '#404040',
1627
+ 800: '#262626',
1628
+ 900: '#171717',
1629
+ },
1630
+ accent: {
1631
+ DEFAULT: '#f97316',
1632
+ light: '#fb923c',
1633
+ dark: '#ea580c',
1634
+ muted: 'rgba(249, 115, 22, 0.1)',
1635
+ },
1636
+ status: {
1637
+ draft: '#a3a3a3',
1638
+ ready: '#3b82f6',
1639
+ running: '#f97316',
1640
+ success: '#22c55e',
1641
+ failed: '#ef4444',
1642
+ }
1643
+ },
1644
+ },
1645
+ },
1646
+ };
1647
+ </script>
1648
+ <style>
1649
+ /* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1650
+ CLEAN LIGHT THEME - Inspired by vibe-kanban
1651
+ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 */
1652
+
1653
+ :root {
1654
+ --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
1655
+ --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
1656
+ }
1657
+
1658
+ * { box-sizing: border-box; }
1659
+
1660
+ html {
1661
+ background: #fafafa;
1662
+ color: #171717;
1663
+ }
1664
+
1665
+ body {
1666
+ font-family: 'DM Sans', system-ui, sans-serif;
1667
+ background: #fafafa;
1668
+ min-height: 100vh;
1669
+ overflow-x: hidden;
1670
+ }
1671
+
1672
+ /* Clean scrollbar */
1673
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
1674
+ ::-webkit-scrollbar-track { background: transparent; }
1675
+ ::-webkit-scrollbar-thumb {
1676
+ background: rgba(0, 0, 0, 0.15);
1677
+ border-radius: 3px;
1678
+ }
1679
+ ::-webkit-scrollbar-thumb:hover { background: rgba(0, 0, 0, 0.25); }
1680
+
1681
+ /* \u2500\u2500\u2500 Typography \u2500\u2500\u2500 */
1682
+ .font-display { font-family: 'DM Sans', system-ui, sans-serif; }
1683
+ .font-mono { font-family: 'IBM Plex Mono', monospace; }
1684
+
1685
+ /* \u2500\u2500\u2500 Card System \u2500\u2500\u2500 */
1686
+ .card {
1687
+ background: #ffffff;
1688
+ border: 1px solid #e5e5e5;
1689
+ border-radius: 8px;
1690
+ transition: all 0.2s var(--ease-out-expo);
1691
+ }
1692
+ .card:hover {
1693
+ border-color: #d4d4d4;
1694
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
1695
+ }
1696
+
1697
+ /* \u2500\u2500\u2500 Task Card - Minimal like vibe-kanban \u2500\u2500\u2500 */
1698
+ .task-card {
1699
+ cursor: pointer;
1700
+ padding: 12px 14px;
1701
+ }
1702
+ .task-card:hover {
1703
+ background: #fafafa;
1704
+ }
1705
+ .task-card.selected {
1706
+ border-color: #f97316;
1707
+ background: rgba(249, 115, 22, 0.03);
1708
+ }
1709
+
1710
+ /* \u2500\u2500\u2500 Drag & Drop \u2500\u2500\u2500 */
1711
+ .dragging {
1712
+ opacity: 0.9;
1713
+ transform: scale(1.02) rotate(0.5deg);
1714
+ box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
1715
+ z-index: 1000;
1716
+ }
1717
+ .drag-over {
1718
+ background: rgba(249, 115, 22, 0.05);
1719
+ border: 2px dashed #f97316;
1720
+ border-radius: 8px;
1721
+ }
1722
+
1723
+ /* \u2500\u2500\u2500 Column Headers - Minimal \u2500\u2500\u2500 */
1724
+ .column-header {
1725
+ display: flex;
1726
+ align-items: center;
1727
+ gap: 8px;
1728
+ padding: 8px 4px;
1729
+ margin-bottom: 8px;
1730
+ border-bottom: 1px solid #f0f0f0;
1731
+ }
1732
+ .status-dot {
1733
+ width: 8px;
1734
+ height: 8px;
1735
+ border-radius: 50%;
1736
+ }
1737
+ .status-dot-draft { background: #a3a3a3; }
1738
+ .status-dot-ready { background: #3b82f6; }
1739
+ .status-dot-in_progress { background: #f97316; }
1740
+ .status-dot-completed { background: #22c55e; }
1741
+ .status-dot-failed { background: #ef4444; }
1742
+
1743
+ /* \u2500\u2500\u2500 Buttons \u2500\u2500\u2500 */
1744
+ .btn {
1745
+ font-weight: 500;
1746
+ border-radius: 6px;
1747
+ transition: all 0.15s ease;
1748
+ cursor: pointer;
1749
+ border: none;
1750
+ }
1751
+ .btn:active { transform: scale(0.98); }
1752
+
1753
+ .btn-primary {
1754
+ background: #f97316;
1755
+ color: white;
1756
+ }
1757
+ .btn-primary:hover {
1758
+ background: #ea580c;
1759
+ }
1760
+
1761
+ .btn-ghost {
1762
+ background: transparent;
1763
+ border: 1px solid #e5e5e5;
1764
+ color: #525252;
1765
+ }
1766
+ .btn-ghost:hover {
1767
+ background: #f5f5f5;
1768
+ border-color: #d4d4d4;
1769
+ }
1770
+
1771
+ .btn-danger {
1772
+ background: #fef2f2;
1773
+ color: #ef4444;
1774
+ border: 1px solid #fecaca;
1775
+ }
1776
+ .btn-danger:hover {
1777
+ background: #fee2e2;
1778
+ }
1779
+
1780
+ /* \u2500\u2500\u2500 Side Panel (pushes content, not overlay) \u2500\u2500\u2500 */
1781
+ .side-panel {
1782
+ width: 420px;
1783
+ flex-shrink: 0;
1784
+ background: #ffffff;
1785
+ border-left: 1px solid #e5e5e5;
1786
+ display: flex;
1787
+ flex-direction: column;
1788
+ height: calc(100vh - 57px);
1789
+ overflow: hidden;
1790
+ }
1791
+ .main-content {
1792
+ flex: 1;
1793
+ min-width: 0;
1794
+ transition: all 0.2s var(--ease-out-expo);
1795
+ }
1796
+ .side-panel-header {
1797
+ padding: 16px 20px;
1798
+ border-bottom: 1px solid #e5e5e5;
1799
+ flex-shrink: 0;
1800
+ }
1801
+ .side-panel-body {
1802
+ flex: 1;
1803
+ overflow: hidden;
1804
+ display: flex;
1805
+ flex-direction: column;
1806
+ min-height: 0;
1807
+ }
1808
+ .side-panel-tabs {
1809
+ display: flex;
1810
+ border-bottom: 1px solid #e5e5e5;
1811
+ padding: 0 20px;
1812
+ flex-shrink: 0;
1813
+ }
1814
+ .side-panel-tab {
1815
+ padding: 12px 16px;
1816
+ color: #737373;
1817
+ cursor: pointer;
1818
+ border-bottom: 2px solid transparent;
1819
+ transition: all 0.15s ease;
1820
+ }
1821
+ .side-panel-tab:hover { color: #171717; }
1822
+ .side-panel-tab.active {
1823
+ color: #171717;
1824
+ border-bottom-color: #f97316;
1825
+ }
1826
+
1827
+ /* \u2500\u2500\u2500 Terminal/Log Panel \u2500\u2500\u2500 */
1828
+ .log-container {
1829
+ background: #1a1a1a;
1830
+ color: #e5e5e5;
1831
+ font-family: 'IBM Plex Mono', monospace;
1832
+ font-size: 12px;
1833
+ line-height: 1.6;
1834
+ padding: 16px;
1835
+ flex: 1;
1836
+ overflow-y: auto;
1837
+ overflow-x: hidden;
1838
+ min-height: 0;
1839
+ }
1840
+ .log-line {
1841
+ padding: 2px 0;
1842
+ word-wrap: break-word;
1843
+ white-space: pre-wrap;
1844
+ }
1845
+ .log-line:hover {
1846
+ background: rgba(255, 255, 255, 0.05);
1847
+ }
1848
+
1849
+ /* ANSI colors */
1850
+ .ansi-red { color: #f87171; }
1851
+ .ansi-green { color: #86efac; }
1852
+ .ansi-yellow { color: #fde047; }
1853
+ .ansi-blue { color: #93c5fd; }
1854
+ .ansi-magenta { color: #e879f9; }
1855
+ .ansi-cyan { color: #67e8f9; }
1856
+ .ansi-bold { font-weight: 600; }
1857
+ .log-path { color: #93c5fd; }
1858
+ .log-error { color: #fca5a5; }
1859
+ .log-success { color: #86efac; }
1860
+
1861
+ /* \u2500\u2500\u2500 Tab System \u2500\u2500\u2500 */
1862
+ .tab {
1863
+ padding: 10px 16px;
1864
+ color: #737373;
1865
+ cursor: pointer;
1866
+ border-bottom: 2px solid transparent;
1867
+ transition: all 0.15s ease;
1868
+ }
1869
+ .tab:hover { color: #171717; }
1870
+ .tab.active {
1871
+ color: #171717;
1872
+ border-bottom-color: #f97316;
1873
+ }
1874
+
1875
+ /* \u2500\u2500\u2500 Modal \u2500\u2500\u2500 */
1876
+ .modal-backdrop {
1877
+ background: rgba(0, 0, 0, 0.5);
1878
+ backdrop-filter: blur(4px);
1879
+ }
1880
+ .modal-content {
1881
+ animation: modal-enter 0.2s var(--ease-out-expo);
1882
+ }
1883
+ @keyframes modal-enter {
1884
+ from { opacity: 0; transform: scale(0.95); }
1885
+ to { opacity: 1; transform: scale(1); }
1886
+ }
1887
+
1888
+ /* \u2500\u2500\u2500 Form Inputs \u2500\u2500\u2500 */
1889
+ .input {
1890
+ background: #ffffff;
1891
+ border: 1px solid #e5e5e5;
1892
+ border-radius: 6px;
1893
+ padding: 10px 12px;
1894
+ color: #171717;
1895
+ transition: all 0.15s ease;
1896
+ font-size: 14px;
1897
+ }
1898
+ .input::placeholder { color: #a3a3a3; }
1899
+ .input:focus {
1900
+ outline: none;
1901
+ border-color: #f97316;
1902
+ box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.1);
1903
+ }
1904
+
1905
+ /* \u2500\u2500\u2500 Toast Notifications \u2500\u2500\u2500 */
1906
+ .toast-container {
1907
+ position: fixed;
1908
+ bottom: 24px;
1909
+ right: 24px;
1910
+ z-index: 9999;
1911
+ display: flex;
1912
+ flex-direction: column;
1913
+ gap: 8px;
1914
+ }
1915
+ .toast {
1916
+ padding: 12px 16px;
1917
+ border-radius: 8px;
1918
+ background: #ffffff;
1919
+ border: 1px solid #e5e5e5;
1920
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
1921
+ animation: toast-enter 0.3s var(--ease-out-expo);
1922
+ display: flex;
1923
+ align-items: center;
1924
+ gap: 8px;
1925
+ color: #171717;
1926
+ }
1927
+ @keyframes toast-enter {
1928
+ from { opacity: 0; transform: translateY(8px); }
1929
+ to { opacity: 1; transform: translateY(0); }
1930
+ }
1931
+ .toast-success { border-left: 3px solid #22c55e; }
1932
+ .toast-error { border-left: 3px solid #ef4444; }
1933
+ .toast-info { border-left: 3px solid #3b82f6; }
1934
+ .toast-warning { border-left: 3px solid #f97316; }
1935
+
1936
+ /* \u2500\u2500\u2500 Status Badges \u2500\u2500\u2500 */
1937
+ .status-badge {
1938
+ font-size: 12px;
1939
+ font-weight: 500;
1940
+ padding: 4px 10px;
1941
+ border-radius: 12px;
1942
+ display: inline-flex;
1943
+ align-items: center;
1944
+ gap: 6px;
1945
+ }
1946
+ .status-badge-draft { background: #f5f5f5; color: #737373; }
1947
+ .status-badge-ready { background: #eff6ff; color: #3b82f6; }
1948
+ .status-badge-in_progress { background: #fff7ed; color: #f97316; }
1949
+ .status-badge-completed { background: #f0fdf4; color: #22c55e; }
1950
+ .status-badge-failed { background: #fef2f2; color: #ef4444; }
1951
+
1952
+ /* \u2500\u2500\u2500 Priority Badges \u2500\u2500\u2500 */
1953
+ .badge {
1954
+ font-size: 11px;
1955
+ font-weight: 500;
1956
+ padding: 2px 8px;
1957
+ border-radius: 4px;
1958
+ }
1959
+ .badge-critical { background: #fef2f2; color: #ef4444; }
1960
+ .badge-high { background: #fff7ed; color: #f97316; }
1961
+ .badge-medium { background: #eff6ff; color: #3b82f6; }
1962
+ .badge-low { background: #f5f5f5; color: #737373; }
1963
+
1964
+ /* \u2500\u2500\u2500 Stats Pills \u2500\u2500\u2500 */
1965
+ .stat-pill {
1966
+ display: inline-flex;
1967
+ align-items: center;
1968
+ gap: 6px;
1969
+ padding: 4px 10px;
1970
+ background: #f5f5f5;
1971
+ border-radius: 12px;
1972
+ font-size: 12px;
1973
+ font-weight: 500;
1974
+ color: #525252;
1975
+ }
1976
+ .stat-dot {
1977
+ width: 6px;
1978
+ height: 6px;
1979
+ border-radius: 50%;
1980
+ }
1981
+
1982
+ /* \u2500\u2500\u2500 Column Container \u2500\u2500\u2500 */
1983
+ .column-container {
1984
+ background: #ffffff;
1985
+ border: 1px solid #e5e5e5;
1986
+ border-radius: 12px;
1987
+ padding: 12px;
1988
+ min-height: 500px;
1989
+ }
1990
+
1991
+ /* \u2500\u2500\u2500 Column Drop Zone \u2500\u2500\u2500 */
1992
+ .column-zone {
1993
+ min-height: 400px;
1994
+ padding: 4px;
1995
+ transition: all 0.2s ease;
1996
+ }
1997
+
1998
+ /* \u2500\u2500\u2500 Chat Input \u2500\u2500\u2500 */
1999
+ .chat-input-container {
2000
+ position: fixed;
2001
+ bottom: 0;
2002
+ left: 0;
2003
+ right: 0;
2004
+ padding: 16px 24px;
2005
+ background: #ffffff;
2006
+ border-top: 1px solid #e5e5e5;
2007
+ z-index: 50;
2008
+ }
2009
+ .chat-input {
2010
+ display: flex;
2011
+ align-items: center;
2012
+ gap: 12px;
2013
+ background: #f5f5f5;
2014
+ border: 1px solid #e5e5e5;
2015
+ border-radius: 8px;
2016
+ padding: 10px 16px;
2017
+ }
2018
+ .chat-input input {
2019
+ flex: 1;
2020
+ background: transparent;
2021
+ border: none;
2022
+ outline: none;
2023
+ font-size: 14px;
2024
+ color: #171717;
2025
+ }
2026
+ .chat-input input::placeholder { color: #a3a3a3; }
2027
+ .chat-input button {
2028
+ background: #f97316;
2029
+ color: white;
2030
+ border: none;
2031
+ border-radius: 6px;
2032
+ padding: 8px 16px;
2033
+ font-weight: 500;
2034
+ cursor: pointer;
2035
+ transition: background 0.15s;
2036
+ }
2037
+ .chat-input button:hover { background: #ea580c; }
2038
+
2039
+ /* \u2500\u2500\u2500 Details Grid \u2500\u2500\u2500 */
2040
+ .details-grid {
2041
+ display: grid;
2042
+ grid-template-columns: repeat(2, 1fr);
2043
+ gap: 16px;
2044
+ padding: 16px 20px;
2045
+ border-bottom: 1px solid #e5e5e5;
2046
+ flex-shrink: 0;
2047
+ }
2048
+ .details-item {
2049
+ display: flex;
2050
+ flex-direction: column;
2051
+ gap: 4px;
2052
+ }
2053
+ .details-label {
2054
+ font-size: 11px;
2055
+ font-weight: 500;
2056
+ color: #737373;
2057
+ text-transform: uppercase;
2058
+ letter-spacing: 0.5px;
2059
+ }
2060
+ .details-value {
2061
+ font-size: 13px;
2062
+ color: #171717;
2063
+ }
2064
+
2065
+ /* \u2500\u2500\u2500 Page animations \u2500\u2500\u2500 */
2066
+ .fade-in {
2067
+ animation: fade-in 0.3s ease;
2068
+ }
2069
+ @keyframes fade-in {
2070
+ from { opacity: 0; }
2071
+ to { opacity: 1; }
2072
+ }
2073
+ </style>
2074
+ </head>
2075
+ <body class="grain">
2076
+ <div id="app"></div>
2077
+ <div id="toast-container" class="toast-container"></div>
2078
+ <script type="module">
2079
+ ${getClientJS()}
2080
+ </script>
2081
+ </body>
2082
+ </html>`;
2083
+ }
2084
+ function getClientJS() {
2085
+ return `
2086
+ // State
2087
+ let state = {
2088
+ tasks: [],
2089
+ running: [],
2090
+ afk: { running: false, currentIteration: 0, maxIterations: 0, tasksCompleted: 0 },
2091
+ templates: [],
2092
+ config: null,
2093
+ selectedTask: null,
2094
+ taskOutput: {},
2095
+ taskStartTime: {},
2096
+ activeTab: null,
2097
+ showModal: null,
2098
+ aiPrompt: '',
2099
+ aiGenerating: false,
2100
+ editingTask: null,
2101
+ searchQuery: '',
2102
+ logSearch: '',
2103
+ showLineNumbers: false,
2104
+ autoScroll: true,
2105
+ logFullscreen: false,
2106
+ closedTabs: new Set(),
2107
+ sidePanel: null, // task id for side panel
2108
+ sidePanelTab: 'logs', // 'logs' or 'details'
2109
+ darkMode: localStorage.getItem('darkMode') === 'true', // Add dark mode state
2110
+ };
2111
+
2112
+ // Toast notifications
2113
+ function showToast(message, type = 'info') {
2114
+ const container = document.getElementById('toast-container');
2115
+ const toast = document.createElement('div');
2116
+ toast.className = 'toast toast-' + type;
2117
+ const icons = { success: '\u2713', error: '\u2715', info: '\u2139', warning: '\u26A0' };
2118
+ toast.innerHTML = '<span>' + (icons[type] || '') + '</span><span>' + escapeHtml(message) + '</span>';
2119
+ container.appendChild(toast);
2120
+ setTimeout(() => toast.remove(), 4000);
2121
+ }
2122
+
2123
+ // ANSI color parser
2124
+ function parseAnsi(text) {
2125
+ const ansiRegex = /\\x1B\\[([0-9;]*)m/g;
2126
+ let result = '';
2127
+ let lastIndex = 0;
2128
+ let currentClasses = [];
2129
+
2130
+ text.replace(ansiRegex, (match, codes, offset) => {
2131
+ result += escapeHtml(text.slice(lastIndex, offset));
2132
+ lastIndex = offset + match.length;
2133
+
2134
+ codes.split(';').forEach(code => {
2135
+ const c = parseInt(code);
2136
+ if (c === 0) currentClasses = [];
2137
+ else if (c === 1) currentClasses.push('ansi-bold');
2138
+ else if (c === 2) currentClasses.push('ansi-dim');
2139
+ else if (c === 3) currentClasses.push('ansi-italic');
2140
+ else if (c === 4) currentClasses.push('ansi-underline');
2141
+ else if (c === 30) currentClasses.push('ansi-black');
2142
+ else if (c === 31) currentClasses.push('ansi-red');
2143
+ else if (c === 32) currentClasses.push('ansi-green');
2144
+ else if (c === 33) currentClasses.push('ansi-yellow');
2145
+ else if (c === 34) currentClasses.push('ansi-blue');
2146
+ else if (c === 35) currentClasses.push('ansi-magenta');
2147
+ else if (c === 36) currentClasses.push('ansi-cyan');
2148
+ else if (c === 37) currentClasses.push('ansi-white');
2149
+ else if (c === 90) currentClasses.push('ansi-bright-black');
2150
+ else if (c === 91) currentClasses.push('ansi-bright-red');
2151
+ else if (c === 92) currentClasses.push('ansi-bright-green');
2152
+ else if (c === 93) currentClasses.push('ansi-bright-yellow');
2153
+ else if (c === 94) currentClasses.push('ansi-bright-blue');
2154
+ else if (c === 95) currentClasses.push('ansi-bright-magenta');
2155
+ else if (c === 96) currentClasses.push('ansi-bright-cyan');
2156
+ else if (c === 97) currentClasses.push('ansi-bright-white');
2157
+ });
2158
+
2159
+ if (currentClasses.length > 0) {
2160
+ result += '<span class="' + currentClasses.join(' ') + '">';
2161
+ }
2162
+ });
2163
+
2164
+ result += escapeHtml(text.slice(lastIndex));
2165
+ return result;
2166
+ }
2167
+
2168
+ // Syntax highlight log lines
2169
+ function highlightLog(text) {
2170
+ let highlighted = parseAnsi(text);
2171
+ // Highlight file paths
2172
+ highlighted = highlighted.replace(/([\\/\\w.-]+\\.(ts|js|tsx|jsx|json|md|css|html))/g, '<span class="log-path">$1</span>');
2173
+ // Highlight errors
2174
+ highlighted = highlighted.replace(/(error|Error|ERROR|failed|Failed|FAILED)/g, '<span class="log-error">$1</span>');
2175
+ // Highlight success
2176
+ highlighted = highlighted.replace(/(success|Success|SUCCESS|complete|Complete|COMPLETE|passed|Passed|PASSED)/g, '<span class="log-success">$1</span>');
2177
+ // Highlight warnings
2178
+ highlighted = highlighted.replace(/(warning|Warning|WARNING|warn|Warn|WARN)/g, '<span class="log-warning">$1</span>');
2179
+ return highlighted;
2180
+ }
2181
+
2182
+ // Format elapsed time
2183
+ function formatElapsed(startTime) {
2184
+ if (!startTime) return '';
2185
+ const elapsed = Date.now() - new Date(startTime).getTime();
2186
+ const seconds = Math.floor(elapsed / 1000);
2187
+ const minutes = Math.floor(seconds / 60);
2188
+ const hours = Math.floor(minutes / 60);
2189
+ if (hours > 0) return hours + 'h ' + (minutes % 60) + 'm';
2190
+ if (minutes > 0) return minutes + 'm ' + (seconds % 60) + 's';
2191
+ return seconds + 's';
2192
+ }
2193
+
2194
+ // Socket connection
2195
+ const socket = io();
2196
+
2197
+ socket.on('init', (data) => {
2198
+ state.tasks = data.tasks;
2199
+ state.running = data.running;
2200
+ state.afk = data.afk;
2201
+
2202
+ // Load persisted logs for running tasks
2203
+ if (data.taskLogs) {
2204
+ for (const [taskId, logs] of Object.entries(data.taskLogs)) {
2205
+ if (logs) {
2206
+ // Parse logs into lines for the taskOutput format
2207
+ state.taskOutput[taskId] = String(logs).split('\\n').filter(l => l).map(text => ({
2208
+ text: text + '\\n',
2209
+ timestamp: new Date().toISOString()
2210
+ }));
2211
+ }
2212
+ }
2213
+ }
2214
+
2215
+ render();
2216
+ });
2217
+
2218
+ // Handle logs response from server
2219
+ socket.on('task-logs', ({ taskId, logs }) => {
2220
+ if (logs) {
2221
+ state.taskOutput[taskId] = logs.split('\\n').filter(l => l).map(text => ({
2222
+ text: text + '\\n',
2223
+ timestamp: new Date().toISOString()
2224
+ }));
2225
+ render();
2226
+ if (state.autoScroll) {
2227
+ requestAnimationFrame(() => scrollSidePanelLog());
2228
+ }
2229
+ }
2230
+ });
2231
+
2232
+ socket.on('task:created', (task) => {
2233
+ state.tasks.push(task);
2234
+ showToast('Task created: ' + task.title, 'success');
2235
+ render();
2236
+ });
2237
+
2238
+ socket.on('task:updated', (task) => {
2239
+ const idx = state.tasks.findIndex(t => t.id === task.id);
2240
+ if (idx >= 0) state.tasks[idx] = task;
2241
+ render();
2242
+ });
2243
+
2244
+ socket.on('task:deleted', ({ id }) => {
2245
+ state.tasks = state.tasks.filter(t => t.id !== id);
2246
+ render();
2247
+ });
2248
+
2249
+ socket.on('task:started', ({ taskId, timestamp }) => {
2250
+ if (!state.running.includes(taskId)) state.running.push(taskId);
2251
+ const task = state.tasks.find(t => t.id === taskId);
2252
+ if (task) task.status = 'in_progress';
2253
+ state.taskOutput[taskId] = [];
2254
+ state.taskStartTime[taskId] = timestamp || new Date().toISOString();
2255
+ state.activeTab = taskId;
2256
+ state.closedTabs.delete(taskId);
2257
+ showToast('Task started: ' + (task?.title || taskId), 'info');
2258
+ render();
2259
+ });
2260
+
2261
+ socket.on('task:output', ({ taskId, line }) => {
2262
+ if (!state.taskOutput[taskId]) state.taskOutput[taskId] = [];
2263
+ state.taskOutput[taskId].push({ text: line, timestamp: new Date().toISOString() });
2264
+ render();
2265
+ // Auto-scroll after DOM update
2266
+ if (state.autoScroll) {
2267
+ requestAnimationFrame(() => {
2268
+ scrollLogToBottom();
2269
+ scrollSidePanelLog();
2270
+ });
2271
+ }
2272
+ });
2273
+
2274
+ socket.on('task:completed', ({ taskId }) => {
2275
+ state.running = state.running.filter(id => id !== taskId);
2276
+ const task = state.tasks.find(t => t.id === taskId);
2277
+ if (task) { task.status = 'completed'; task.passes = true; }
2278
+ showToast('Task completed: ' + (task?.title || taskId), 'success');
2279
+ render();
2280
+ });
2281
+
2282
+ socket.on('task:failed', ({ taskId }) => {
2283
+ state.running = state.running.filter(id => id !== taskId);
2284
+ const task = state.tasks.find(t => t.id === taskId);
2285
+ if (task) { task.status = 'failed'; task.passes = false; }
2286
+ showToast('Task failed: ' + (task?.title || taskId), 'error');
2287
+ render();
2288
+ });
2289
+
2290
+ socket.on('task:cancelled', ({ taskId }) => {
2291
+ state.running = state.running.filter(id => id !== taskId);
2292
+ const task = state.tasks.find(t => t.id === taskId);
2293
+ if (task) task.status = 'ready';
2294
+ showToast('Task cancelled', 'warning');
2295
+ render();
2296
+ });
2297
+
2298
+ socket.on('afk:status', (status) => {
2299
+ state.afk = status;
2300
+ render();
2301
+ });
2302
+
2303
+ // Load templates
2304
+ fetch('/api/templates').then(r => r.json()).then(data => {
2305
+ state.templates = data.templates;
2306
+ });
2307
+
2308
+ // Load config
2309
+ fetch('/api/config').then(r => r.json()).then(data => {
2310
+ state.config = data.config;
2311
+ });
2312
+
2313
+ // API calls
2314
+ async function createTask(task) {
2315
+ const res = await fetch('/api/tasks', {
2316
+ method: 'POST',
2317
+ headers: { 'Content-Type': 'application/json' },
2318
+ body: JSON.stringify(task)
2319
+ });
2320
+ return res.json();
2321
+ }
2322
+
2323
+ async function updateTask(id, updates) {
2324
+ const res = await fetch('/api/tasks/' + id, {
2325
+ method: 'PUT',
2326
+ headers: { 'Content-Type': 'application/json' },
2327
+ body: JSON.stringify(updates)
2328
+ });
2329
+ return res.json();
2330
+ }
2331
+
2332
+ async function deleteTask(id) {
2333
+ await fetch('/api/tasks/' + id, { method: 'DELETE' });
2334
+ }
2335
+
2336
+ async function runTask(id) {
2337
+ await fetch('/api/tasks/' + id + '/run', { method: 'POST' });
2338
+ }
2339
+
2340
+ async function cancelTask(id) {
2341
+ await fetch('/api/tasks/' + id + '/cancel', { method: 'POST' });
2342
+ }
2343
+
2344
+ async function retryTask(id) {
2345
+ await fetch('/api/tasks/' + id + '/retry', {
2346
+ method: 'POST',
2347
+ headers: { 'Content-Type': 'application/json' },
2348
+ body: JSON.stringify({ autoRun: false })
2349
+ });
2350
+ }
2351
+
2352
+ async function generateTask(prompt) {
2353
+ state.aiGenerating = true;
2354
+ render();
2355
+ try {
2356
+ const res = await fetch('/api/tasks/generate', {
2357
+ method: 'POST',
2358
+ headers: { 'Content-Type': 'application/json' },
2359
+ body: JSON.stringify({ prompt })
2360
+ });
2361
+ const data = await res.json();
2362
+ state.aiGenerating = false;
2363
+ return data.task;
2364
+ } catch (e) {
2365
+ state.aiGenerating = false;
2366
+ throw e;
2367
+ }
2368
+ }
2369
+
2370
+ async function startAFK(maxIterations, concurrent) {
2371
+ await fetch('/api/afk/start', {
2372
+ method: 'POST',
2373
+ headers: { 'Content-Type': 'application/json' },
2374
+ body: JSON.stringify({ maxIterations, concurrent })
2375
+ });
2376
+ }
2377
+
2378
+ async function stopAFK() {
2379
+ await fetch('/api/afk/stop', { method: 'POST' });
2380
+ }
2381
+
2382
+ // Enhanced Drag and drop
2383
+ let draggedTask = null;
2384
+ let draggedElement = null;
2385
+
2386
+ function handleDragStart(e, taskId) {
2387
+ draggedTask = taskId;
2388
+ draggedElement = e.target;
2389
+ e.target.classList.add('dragging');
2390
+ e.dataTransfer.effectAllowed = 'move';
2391
+ e.dataTransfer.setData('text/plain', taskId);
2392
+
2393
+ // Add visual feedback to valid drop zones
2394
+ setTimeout(() => {
2395
+ document.querySelectorAll('.column-drop-zone').forEach(zone => {
2396
+ const status = zone.dataset.status;
2397
+ if (status !== 'in_progress') {
2398
+ zone.classList.add('border-dashed', 'border-2', 'border-blue-400/30');
2399
+ }
2400
+ });
2401
+ }, 0);
2402
+ }
2403
+
2404
+ function handleDragEnd(e) {
2405
+ e.target.classList.remove('dragging');
2406
+ document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
2407
+ document.querySelectorAll('.column-drop-zone').forEach(zone => {
2408
+ zone.classList.remove('border-dashed', 'border-2', 'border-blue-400/30');
2409
+ });
2410
+ draggedTask = null;
2411
+ draggedElement = null;
2412
+ }
2413
+
2414
+ function handleDragOver(e) {
2415
+ e.preventDefault();
2416
+ e.dataTransfer.dropEffect = 'move';
2417
+ const zone = e.currentTarget;
2418
+ if (!zone.classList.contains('drag-over') && zone.dataset.status !== 'in_progress') {
2419
+ zone.classList.add('drag-over');
2420
+ }
2421
+ }
2422
+
2423
+ function handleDragLeave(e) {
2424
+ // Only remove if actually leaving the zone
2425
+ const rect = e.currentTarget.getBoundingClientRect();
2426
+ const x = e.clientX;
2427
+ const y = e.clientY;
2428
+ if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
2429
+ e.currentTarget.classList.remove('drag-over');
2430
+ }
2431
+ }
2432
+
2433
+ function handleDrop(e, newStatus) {
2434
+ e.preventDefault();
2435
+ e.currentTarget.classList.remove('drag-over');
2436
+ if (draggedTask && newStatus !== 'in_progress') {
2437
+ const task = state.tasks.find(t => t.id === draggedTask);
2438
+ if (task && task.status !== newStatus) {
2439
+ updateTask(draggedTask, { status: newStatus });
2440
+ showToast('Moved to ' + newStatus.replace('_', ' '), 'info');
2441
+ }
2442
+ }
2443
+ draggedTask = null;
2444
+ }
2445
+
2446
+ function scrollLogToBottom() {
2447
+ const logEl = document.getElementById('log-content');
2448
+ if (logEl) logEl.scrollTop = logEl.scrollHeight;
2449
+ }
2450
+
2451
+ function scrollSidePanelLog() {
2452
+ const logEl = document.getElementById('side-panel-log');
2453
+ if (logEl) logEl.scrollTop = logEl.scrollHeight;
2454
+ }
2455
+
2456
+ function copyLogToClipboard() {
2457
+ const output = state.taskOutput[state.activeTab] || [];
2458
+ const text = output.map(l => l.text || l).join('');
2459
+ navigator.clipboard.writeText(text).then(() => {
2460
+ showToast('Log copied to clipboard', 'success');
2461
+ });
2462
+ }
2463
+
2464
+ function clearLog(taskId) {
2465
+ state.taskOutput[taskId] = [];
2466
+ render();
2467
+ }
2468
+
2469
+ function closeLogTab(taskId) {
2470
+ state.closedTabs.add(taskId);
2471
+ if (state.activeTab === taskId) {
2472
+ const remaining = Object.keys(state.taskOutput).filter(id => !state.closedTabs.has(id));
2473
+ state.activeTab = remaining[0] || null;
2474
+ }
2475
+ render();
2476
+ }
2477
+
2478
+ function toggleLogFullscreen() {
2479
+ state.logFullscreen = !state.logFullscreen;
2480
+ render();
2481
+ }
2482
+
2483
+ const categoryIcons = {
2484
+ functional: '\u2699\uFE0F',
2485
+ ui: '\u{1F3A8}',
2486
+ bug: '\u{1F41B}',
2487
+ enhancement: '\u2728',
2488
+ testing: '\u{1F9EA}',
2489
+ refactor: '\u{1F527}'
2490
+ };
2491
+
2492
+ // Render functions
2493
+ function renderCard(task) {
2494
+ const isRunning = state.running.includes(task.id);
2495
+ const isSelected = state.sidePanel === task.id;
2496
+
2497
+ return \`
2498
+ <div class="card task-card mb-2 group \${isRunning ? 'running' : ''} \${isSelected ? 'selected' : ''}"
2499
+ onclick="openSidePanel('\${task.id}')"
2500
+ draggable="\${!isRunning}"
2501
+ ondragstart="handleDragStart(event, '\${task.id}')"
2502
+ ondragend="handleDragEnd(event)">
2503
+ <div class="flex justify-between items-start gap-2">
2504
+ <h3 class="font-medium text-canvas-800 text-sm leading-snug">\${escapeHtml(task.title)}</h3>
2505
+ <button onclick="event.stopPropagation(); showTaskMenu('\${task.id}')"
2506
+ class="text-canvas-400 hover:text-canvas-600 p-1 -mr-1 opacity-0 group-hover:opacity-100">
2507
+ \u22EF
2508
+ </button>
2509
+ </div>
2510
+ <p class="text-xs text-canvas-500 mt-1 line-clamp-2">\${escapeHtml(task.description.substring(0, 80))}\${task.description.length > 80 ? '...' : ''}</p>
2511
+ \${isRunning ? \`
2512
+ <div class="flex items-center gap-2 mt-2 text-xs text-status-running">
2513
+ <span class="w-1.5 h-1.5 bg-status-running rounded-full animate-pulse"></span>
2514
+ Running...
2515
+ </div>
2516
+ \` : ''}
2517
+ </div>
2518
+ \`;
2519
+ }
2520
+
2521
+ function openSidePanel(taskId) {
2522
+ state.sidePanel = taskId;
2523
+ state.activeTab = taskId;
2524
+ state.closedTabs.delete(taskId);
2525
+
2526
+ // Request logs from server if not already loaded
2527
+ if (!state.taskOutput[taskId] || state.taskOutput[taskId].length === 0) {
2528
+ socket.emit('get-logs', taskId);
2529
+ }
2530
+
2531
+ render();
2532
+
2533
+ // Scroll to bottom after render
2534
+ if (state.autoScroll) {
2535
+ requestAnimationFrame(() => scrollSidePanelLog());
2536
+ }
2537
+ }
2538
+
2539
+ function closeSidePanel() {
2540
+ state.sidePanel = null;
2541
+ render();
2542
+ }
2543
+
2544
+ function showTaskMenu(taskId) {
2545
+ // For now just open side panel, could add dropdown menu later
2546
+ openSidePanel(taskId);
2547
+ }
2548
+
2549
+ function renderColumn(status, title, tasks) {
2550
+ const columnTasks = tasks.filter(t => t.status === status);
2551
+ const statusLabels = {
2552
+ draft: 'To Do',
2553
+ ready: 'Ready',
2554
+ in_progress: 'In Progress',
2555
+ completed: 'Done',
2556
+ failed: 'Failed'
2557
+ };
2558
+ const taskCount = columnTasks.length;
2559
+
2560
+ return \`
2561
+ <div class="flex-1 min-w-[240px] max-w-[300px]">
2562
+ <div class="column-container">
2563
+ <div class="column-header">
2564
+ <span class="status-dot status-dot-\${status}"></span>
2565
+ <span class="font-medium text-canvas-700 text-sm">\${statusLabels[status] || title}</span>
2566
+ <span class="text-xs text-canvas-400 ml-auto">\${taskCount}</span>
2567
+ </div>
2568
+ <div class="column-zone column-drop-zone max-h-[calc(100vh-280px)] overflow-y-auto"
2569
+ data-status="\${status}"
2570
+ ondragover="handleDragOver(event)"
2571
+ ondragleave="handleDragLeave(event)"
2572
+ ondrop="handleDrop(event, '\${status}')">
2573
+ \${columnTasks.map(t => renderCard(t)).join('')}
2574
+ \${columnTasks.length === 0 ? \`
2575
+ <div class="flex items-center justify-center py-12 text-canvas-400 text-sm">
2576
+ No tasks
2577
+ </div>
2578
+ \` : ''}
2579
+ </div>
2580
+ </div>
2581
+ </div>
2582
+ \`;
2583
+ }
2584
+
2585
+ function renderLog() {
2586
+ const allTabs = Object.keys(state.taskOutput).filter(id => !state.closedTabs.has(id));
2587
+ const activeOutput = state.taskOutput[state.activeTab] || [];
2588
+ const activeTask = state.tasks.find(t => t.id === state.activeTab);
2589
+ const isRunning = state.running.includes(state.activeTab);
2590
+
2591
+ // Filter output based on search
2592
+ let filteredOutput = activeOutput;
2593
+ if (state.logSearch) {
2594
+ const searchLower = state.logSearch.toLowerCase();
2595
+ filteredOutput = activeOutput.filter(l => {
2596
+ const text = l.text || l;
2597
+ return text.toLowerCase().includes(searchLower);
2598
+ });
2599
+ }
2600
+
2601
+ const fullscreenClass = state.logFullscreen ? 'fixed inset-4 z-50' : 'mt-6';
2602
+ const logHeight = state.logFullscreen ? 'calc(100vh - 180px)' : '300px';
2603
+
2604
+ return \`
2605
+ <div class="terminal \${fullscreenClass} \${state.logFullscreen ? 'shadow-2xl' : ''}">
2606
+ <!-- Tab bar -->
2607
+ <div class="terminal-header flex items-center rounded-t-xl">
2608
+ <div class="flex-1 flex items-center overflow-x-auto">
2609
+ \${allTabs.length > 0 ? allTabs.map(id => {
2610
+ const task = state.tasks.find(t => t.id === id);
2611
+ const isActive = state.activeTab === id;
2612
+ const isTaskRunning = state.running.includes(id);
2613
+ return \`
2614
+ <div class="tab flex items-center gap-2 group \${isActive ? 'active' : ''}"
2615
+ onclick="state.activeTab = '\${id}'; render();">
2616
+ <span class="w-2 h-2 rounded-full \${isTaskRunning ? 'bg-status-running animate-pulse' : task?.status === 'completed' ? 'bg-status-success' : task?.status === 'failed' ? 'bg-status-failed' : 'bg-canvas-400'}"></span>
2617
+ <span class="text-sm truncate max-w-[150px]">\${task?.title?.substring(0, 25) || id}</span>
2618
+ <button onclick="event.stopPropagation(); closeLogTab('\${id}')"
2619
+ class="opacity-0 group-hover:opacity-100 text-canvas-500 hover:text-status-failed transition-opacity ml-1">
2620
+ \u2715
2621
+ </button>
2622
+ </div>
2623
+ \`;
2624
+ }).join('') : '<div class="px-4 py-2.5 text-canvas-500 text-sm">No logs</div>'}
2625
+ </div>
2626
+
2627
+ <!-- Controls -->
2628
+ <div class="flex items-center gap-1 px-3 border-l border-white/5">
2629
+ <button onclick="state.showLineNumbers = !state.showLineNumbers; render();"
2630
+ class="btn btn-ghost p-1.5 text-xs tooltip" data-tooltip="\${state.showLineNumbers ? 'Hide' : 'Show'} line numbers">
2631
+ #
2632
+ </button>
2633
+ <button onclick="state.autoScroll = !state.autoScroll; render();"
2634
+ class="btn btn-ghost p-1.5 text-xs \${state.autoScroll ? 'text-accent' : ''} tooltip" data-tooltip="Auto-scroll \${state.autoScroll ? 'on' : 'off'}">
2635
+ \u2193
2636
+ </button>
2637
+ <button onclick="copyLogToClipboard()"
2638
+ class="btn btn-ghost p-1.5 text-xs tooltip" data-tooltip="Copy to clipboard">
2639
+ \u{1F4CB}
2640
+ </button>
2641
+ <button onclick="clearLog('\${state.activeTab}')"
2642
+ class="btn btn-ghost p-1.5 text-xs tooltip" data-tooltip="Clear log">
2643
+ \u{1F5D1}
2644
+ </button>
2645
+ <button onclick="toggleLogFullscreen()"
2646
+ class="btn btn-ghost p-1.5 text-xs tooltip" data-tooltip="\${state.logFullscreen ? 'Exit' : 'Enter'} fullscreen">
2647
+ \${state.logFullscreen ? '\u2299' : '\u26F6'}
2648
+ </button>
2649
+ </div>
2650
+ </div>
2651
+
2652
+ <!-- Search and task info bar -->
2653
+ <div class="flex items-center justify-between px-4 py-2.5 bg-canvas-50/50 border-b border-white/5">
2654
+ <div class="flex items-center gap-3">
2655
+ <span class="text-sm font-display font-medium text-canvas-700">
2656
+ \${activeTask ? activeTask.title : 'Execution Log'}
2657
+ </span>
2658
+ \${isRunning ? \`
2659
+ <span class="stat-pill text-status-running border-status-running/20">
2660
+ <span class="stat-dot bg-status-running animate-pulse"></span>
2661
+ Running \${formatElapsed(state.taskStartTime[state.activeTab])}
2662
+ </span>
2663
+ \` : ''}
2664
+ <span class="text-xs text-canvas-500 font-mono">\${filteredOutput.length} lines</span>
2665
+ </div>
2666
+ <div class="flex items-center gap-2">
2667
+ <div class="relative">
2668
+ <input type="text"
2669
+ placeholder="Search logs..."
2670
+ value="\${escapeHtml(state.logSearch)}"
2671
+ oninput="state.logSearch = this.value; render();"
2672
+ class="input text-xs py-1.5 pr-8 w-48">
2673
+ \${state.logSearch ? \`
2674
+ <button onclick="state.logSearch = ''; render();"
2675
+ class="absolute right-2 top-1/2 -translate-y-1/2 text-canvas-500 hover:text-canvas-700">
2676
+ \u2715
2677
+ </button>
2678
+ \` : ''}
2679
+ </div>
2680
+ </div>
2681
+ </div>
2682
+
2683
+ <!-- Log content -->
2684
+ <div id="log-content"
2685
+ class="p-4 overflow-y-auto text-canvas-600 rounded-b-xl font-mono"
2686
+ style="height: \${logHeight}">
2687
+ \${filteredOutput.length > 0
2688
+ ? filteredOutput.map((l, i) => {
2689
+ const text = l.text || l;
2690
+ const timestamp = l.timestamp ? new Date(l.timestamp).toLocaleTimeString() : '';
2691
+ return \`
2692
+ <div class="log-line flex items-start gap-3">
2693
+ \${state.showLineNumbers ? \`<span class="text-canvas-400 select-none w-8 text-right flex-shrink-0">\${i + 1}</span>\` : ''}
2694
+ \${timestamp ? \`<span class="text-canvas-400 select-none flex-shrink-0">\${timestamp}</span>\` : ''}
2695
+ <span class="whitespace-pre-wrap flex-1">\${highlightLog(text)}</span>
2696
+ </div>
2697
+ \`;
2698
+ }).join('')
2699
+ : \`<div class="flex flex-col items-center justify-center h-full text-canvas-500">
2700
+ <span class="text-4xl mb-3 opacity-20">\u{1F4CB}</span>
2701
+ <span class="text-sm">\${state.logSearch ? 'No matching lines' : 'No output yet. Run a task to see logs here.'}</span>
2702
+ </div>\`}
2703
+ </div>
2704
+ </div>
2705
+ \`;
2706
+ }
2707
+
2708
+ function renderModal() {
2709
+ if (!state.showModal) return '';
2710
+
2711
+ if (state.showModal === 'new' || state.showModal === 'edit') {
2712
+ const task = state.editingTask || { title: '', description: '', category: 'functional', priority: 'medium', steps: [], status: 'draft' };
2713
+ const isEdit = state.showModal === 'edit';
2714
+
2715
+ return \`
2716
+ <div class="modal-backdrop fixed inset-0 flex items-center justify-center z-50" onclick="if(event.target === event.currentTarget) { state.showModal = null; state.editingTask = null; render(); }">
2717
+ <div class="modal-content card rounded-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
2718
+ <div class="px-6 py-4 border-b border-white/5 flex justify-between items-center">
2719
+ <h3 class="font-display font-semibold text-canvas-800 text-lg">\${isEdit ? '\u270F\uFE0F Edit Task' : '\u{1F4DD} New Task'}</h3>
2720
+ <button onclick="state.showModal = null; state.editingTask = null; render();" class="btn btn-ghost p-1.5 text-canvas-500 hover:text-canvas-700">\u2715</button>
2721
+ </div>
2722
+ <form onsubmit="handleTaskSubmit(event, \${isEdit})" class="p-6 space-y-5">
2723
+ <div>
2724
+ <label class="block text-sm font-medium text-canvas-700 mb-2">Title *</label>
2725
+ <input type="text" name="title" value="\${escapeHtml(task.title)}" required
2726
+ class="input w-full" placeholder="Add login functionality">
2727
+ </div>
2728
+ <div>
2729
+ <label class="block text-sm font-medium text-canvas-700 mb-2">Description *</label>
2730
+ <textarea name="description" rows="4" required
2731
+ class="input w-full resize-none" placeholder="Describe what needs to be done...">\${escapeHtml(task.description)}</textarea>
2732
+ </div>
2733
+ <div class="grid grid-cols-2 gap-4">
2734
+ <div>
2735
+ <label class="block text-sm font-medium text-canvas-700 mb-2">Category</label>
2736
+ <select name="category" class="input w-full">
2737
+ <option value="functional" \${task.category === 'functional' ? 'selected' : ''}>\u2699\uFE0F Functional</option>
2738
+ <option value="ui" \${task.category === 'ui' ? 'selected' : ''}>\u{1F3A8} UI</option>
2739
+ <option value="bug" \${task.category === 'bug' ? 'selected' : ''}>\u{1F41B} Bug</option>
2740
+ <option value="enhancement" \${task.category === 'enhancement' ? 'selected' : ''}>\u2728 Enhancement</option>
2741
+ <option value="testing" \${task.category === 'testing' ? 'selected' : ''}>\u{1F9EA} Testing</option>
2742
+ <option value="refactor" \${task.category === 'refactor' ? 'selected' : ''}>\u{1F527} Refactor</option>
2743
+ </select>
2744
+ </div>
2745
+ <div>
2746
+ <label class="block text-sm font-medium text-canvas-700 mb-2">Priority</label>
2747
+ <select name="priority" class="input w-full">
2748
+ <option value="low" \${task.priority === 'low' ? 'selected' : ''}>Low</option>
2749
+ <option value="medium" \${task.priority === 'medium' ? 'selected' : ''}>Medium</option>
2750
+ <option value="high" \${task.priority === 'high' ? 'selected' : ''}>High</option>
2751
+ <option value="critical" \${task.priority === 'critical' ? 'selected' : ''}>Critical</option>
2752
+ </select>
2753
+ </div>
2754
+ </div>
2755
+ <div>
2756
+ <label class="block text-sm font-medium text-canvas-700 mb-2">Verification Steps (one per line)</label>
2757
+ <textarea name="steps" rows="4"
2758
+ class="input w-full resize-none font-mono text-sm" placeholder="Navigate to /login&#10;Enter valid credentials&#10;Click submit button&#10;Verify redirect">\${task.steps.join('\\n')}</textarea>
2759
+ </div>
2760
+ <div class="flex justify-end gap-3 pt-4 border-t border-white/5">
2761
+ <button type="button" onclick="state.showModal = null; state.editingTask = null; render();"
2762
+ class="btn btn-ghost px-4 py-2.5">Cancel</button>
2763
+ \${!isEdit ? \`
2764
+ <button type="submit" name="action" value="draft"
2765
+ class="btn btn-ghost px-4 py-2.5">Save as Draft</button>
2766
+ \` : ''}
2767
+ <button type="submit" name="action" value="\${isEdit ? 'save' : 'ready'}"
2768
+ class="btn btn-primary px-5 py-2.5">\${isEdit ? 'Save Changes' : 'Create Ready'}</button>
2769
+ </div>
2770
+ </form>
2771
+ </div>
2772
+ </div>
2773
+ \`;
2774
+ }
2775
+
2776
+ if (state.showModal === 'templates') {
2777
+ return \`
2778
+ <div class="modal-backdrop fixed inset-0 flex items-center justify-center z-50" onclick="if(event.target === event.currentTarget) { state.showModal = null; render(); }">
2779
+ <div class="modal-content card rounded-xl w-full max-w-2xl mx-4 max-h-[80vh] overflow-y-auto">
2780
+ <div class="px-6 py-4 border-b border-white/5 flex justify-between items-center sticky top-0 bg-canvas-100/95 backdrop-blur rounded-t-xl z-10">
2781
+ <h3 class="font-display font-semibold text-canvas-800 text-lg">\u{1F4CB} Task Templates</h3>
2782
+ <button onclick="state.showModal = null; render();" class="btn btn-ghost p-1.5 text-canvas-500 hover:text-canvas-700">\u2715</button>
2783
+ </div>
2784
+ <div class="p-6 grid grid-cols-2 gap-4">
2785
+ \${state.templates.map(t => \`
2786
+ <button onclick="applyTemplate('\${t.id}')"
2787
+ class="card text-left p-4 hover:border-accent/30 transition-all group">
2788
+ <div class="font-display font-medium text-canvas-800 group-hover:text-accent transition-colors">\${t.icon} \${t.name}</div>
2789
+ <div class="text-sm text-canvas-500 mt-1">\${t.description}</div>
2790
+ </button>
2791
+ \`).join('')}
2792
+ </div>
2793
+ </div>
2794
+ </div>
2795
+ \`;
2796
+ }
2797
+
2798
+ if (state.showModal === 'afk') {
2799
+ return \`
2800
+ <div class="modal-backdrop fixed inset-0 flex items-center justify-center z-50" onclick="if(event.target === event.currentTarget) { state.showModal = null; render(); }">
2801
+ <div class="modal-content card rounded-xl w-full max-w-md mx-4">
2802
+ <div class="px-6 py-4 border-b border-white/5 flex justify-between items-center">
2803
+ <h3 class="font-display font-semibold text-canvas-800 text-lg">\u{1F504} AFK Mode</h3>
2804
+ <button onclick="state.showModal = null; render();" class="btn btn-ghost p-1.5 text-canvas-500 hover:text-canvas-700">\u2715</button>
2805
+ </div>
2806
+ <div class="p-6">
2807
+ <p class="text-sm text-canvas-600 mb-5">Run the agent in a loop, automatically picking up tasks from the "Ready" column until complete.</p>
2808
+ <div class="space-y-4">
2809
+ <div>
2810
+ <label class="block text-sm font-medium text-canvas-700 mb-2">Maximum Iterations</label>
2811
+ <input type="number" id="afk-iterations" value="10" min="1" max="100"
2812
+ class="input w-full">
2813
+ </div>
2814
+ <div>
2815
+ <label class="block text-sm font-medium text-canvas-700 mb-2">Concurrent Tasks</label>
2816
+ <select id="afk-concurrent" class="input w-full">
2817
+ <option value="1">1 (Sequential)</option>
2818
+ <option value="2">2</option>
2819
+ <option value="3">3 (Max)</option>
2820
+ </select>
2821
+ </div>
2822
+ <div class="bg-status-running/10 border border-status-running/20 rounded-lg p-3">
2823
+ <p class="text-xs text-status-running">\u26A0\uFE0F You can close this tab - the agent will continue running. Check back later or watch the terminal output.</p>
2824
+ </div>
2825
+ </div>
2826
+ <div class="flex justify-end gap-3 mt-6">
2827
+ <button onclick="state.showModal = null; render();"
2828
+ class="btn btn-ghost px-4 py-2.5">Cancel</button>
2829
+ <button onclick="handleStartAFK()"
2830
+ class="btn px-5 py-2.5 bg-status-success text-canvas font-medium">\u{1F680} Start AFK Mode</button>
2831
+ </div>
2832
+ </div>
2833
+ </div>
2834
+ </div>
2835
+ \`;
2836
+ }
2837
+
2838
+ return '';
2839
+ }
2840
+
2841
+ function renderSidePanel() {
2842
+ if (!state.sidePanel) return '';
2843
+
2844
+ const task = state.tasks.find(t => t.id === state.sidePanel);
2845
+ if (!task) return '';
2846
+
2847
+ const isRunning = state.running.includes(task.id);
2848
+ const output = state.taskOutput[task.id] || [];
2849
+ const startTime = state.taskStartTime[task.id];
2850
+ const lastExec = task.executionHistory?.[task.executionHistory.length - 1];
2851
+
2852
+ return \`
2853
+ <div class="side-panel">
2854
+ <!-- Header -->
2855
+ <div class="side-panel-header">
2856
+ <div class="flex justify-between items-start">
2857
+ <div class="flex-1 pr-4">
2858
+ <h2 class="font-semibold text-canvas-900 text-lg leading-tight">\${escapeHtml(task.title)}</h2>
2859
+ <div class="flex items-center gap-2 mt-2">
2860
+ <span class="status-badge status-badge-\${task.status}">
2861
+ <span class="w-1.5 h-1.5 rounded-full bg-current \${isRunning ? 'animate-pulse' : ''}"></span>
2862
+ \${task.status.replace('_', ' ')}
2863
+ </span>
2864
+ </div>
2865
+ </div>
2866
+ <div class="flex items-center gap-1">
2867
+ <button onclick="state.editingTask = state.tasks.find(t => t.id === '\${task.id}'); state.showModal = 'edit'; render();"
2868
+ class="btn btn-ghost p-2 text-canvas-500 hover:text-canvas-700" title="Edit">
2869
+ \u270F\uFE0F
2870
+ </button>
2871
+ <button onclick="if(confirm('Delete this task?')) { deleteTask('\${task.id}'); closeSidePanel(); }"
2872
+ class="btn btn-ghost p-2 text-canvas-500 hover:text-status-failed" title="Delete">
2873
+ \u{1F5D1}\uFE0F
2874
+ </button>
2875
+ <button onclick="closeSidePanel()" class="btn btn-ghost p-2 text-canvas-500 hover:text-canvas-700" title="Close">
2876
+ \u2715
2877
+ </button>
2878
+ </div>
2879
+ </div>
2880
+ </div>
2881
+
2882
+ <!-- Description -->
2883
+ <div class="px-5 py-4 border-b border-canvas-200 flex-shrink-0">
2884
+ <p class="text-sm text-canvas-600 leading-relaxed">\${escapeHtml(task.description)}</p>
2885
+ \${task.steps && task.steps.length > 0 ? \`
2886
+ <div class="mt-3">
2887
+ <div class="text-xs font-medium text-canvas-500 mb-2">Steps:</div>
2888
+ <ul class="text-sm text-canvas-600 space-y-1">
2889
+ \${task.steps.map(s => \`<li class="flex gap-2"><span class="text-canvas-400">\u2022</span>\${escapeHtml(s)}</li>\`).join('')}
2890
+ </ul>
2891
+ </div>
2892
+ \` : ''}
2893
+ </div>
2894
+
2895
+ <!-- Task Details Grid -->
2896
+ <div class="details-grid">
2897
+ <div class="details-item">
2898
+ <span class="details-label">Priority</span>
2899
+ <span class="details-value capitalize">\${task.priority}</span>
2900
+ </div>
2901
+ <div class="details-item">
2902
+ <span class="details-label">Category</span>
2903
+ <span class="details-value">\${categoryIcons[task.category] || ''} \${task.category}</span>
2904
+ </div>
2905
+ \${startTime || lastExec ? \`
2906
+ <div class="details-item">
2907
+ <span class="details-label">Started</span>
2908
+ <span class="details-value">\${new Date(startTime || lastExec?.startedAt).toLocaleString()}</span>
2909
+ </div>
2910
+ \` : ''}
2911
+ \${lastExec?.duration ? \`
2912
+ <div class="details-item">
2913
+ <span class="details-label">Duration</span>
2914
+ <span class="details-value">\${Math.round(lastExec.duration / 1000)}s</span>
2915
+ </div>
2916
+ \` : ''}
2917
+ </div>
2918
+
2919
+ <!-- Action Buttons -->
2920
+ <div class="px-5 py-4 border-b border-canvas-200 flex gap-2 flex-shrink-0">
2921
+ \${task.status === 'draft' ? \`
2922
+ <button onclick="updateTask('\${task.id}', { status: 'ready' })" class="btn btn-primary px-4 py-2 text-sm">
2923
+ \u2192 Move to Ready
2924
+ </button>
2925
+ \` : ''}
2926
+ \${task.status === 'ready' ? \`
2927
+ <button onclick="runTask('\${task.id}')" class="btn btn-primary px-4 py-2 text-sm">
2928
+ \u25B6 Run Task
2929
+ </button>
2930
+ \` : ''}
2931
+ \${task.status === 'in_progress' ? \`
2932
+ <button onclick="cancelTask('\${task.id}')" class="btn btn-danger px-4 py-2 text-sm">
2933
+ \u23F9 Stop Attempt
2934
+ </button>
2935
+ \` : ''}
2936
+ \${task.status === 'failed' ? \`
2937
+ <button onclick="retryTask('\${task.id}')" class="btn btn-primary px-4 py-2 text-sm">
2938
+ \u21BB Retry
2939
+ </button>
2940
+ \` : ''}
2941
+ </div>
2942
+
2943
+ <!-- Tabs -->
2944
+ <div class="side-panel-tabs">
2945
+ <div class="side-panel-tab \${state.sidePanelTab === 'logs' ? 'active' : ''}" onclick="state.sidePanelTab = 'logs'; render();">
2946
+ \u{1F4CB} Logs
2947
+ </div>
2948
+ <div class="side-panel-tab \${state.sidePanelTab === 'details' ? 'active' : ''}" onclick="state.sidePanelTab = 'details'; render();">
2949
+ \u{1F4C4} Details
2950
+ </div>
2951
+ </div>
2952
+
2953
+ <!-- Tab Content -->
2954
+ <div class="side-panel-body">
2955
+ \${state.sidePanelTab === 'logs' ? \`
2956
+ <div class="log-container" id="side-panel-log">
2957
+ \${output.length > 0
2958
+ ? output.map((l, i) => {
2959
+ const text = l.text || l;
2960
+ return \`<div class="log-line">\${highlightLog(text)}</div>\`;
2961
+ }).join('')
2962
+ : \`<div class="text-canvas-400 text-sm">\${isRunning ? 'Waiting for output...' : 'No logs available. Run the task to see output.'}</div>\`
2963
+ }
2964
+ </div>
2965
+ \` : \`
2966
+ <div class="p-5">
2967
+ <div class="text-sm text-canvas-600">
2968
+ <div class="mb-4">
2969
+ <div class="text-xs font-medium text-canvas-500 mb-1">Created</div>
2970
+ <div>\${new Date(task.createdAt).toLocaleString()}</div>
2971
+ </div>
2972
+ <div class="mb-4">
2973
+ <div class="text-xs font-medium text-canvas-500 mb-1">Last Updated</div>
2974
+ <div>\${new Date(task.updatedAt).toLocaleString()}</div>
2975
+ </div>
2976
+ \${task.executionHistory && task.executionHistory.length > 0 ? \`
2977
+ <div>
2978
+ <div class="text-xs font-medium text-canvas-500 mb-2">Execution History</div>
2979
+ <div class="space-y-2">
2980
+ \${task.executionHistory.slice(-5).reverse().map(exec => \`
2981
+ <div class="bg-canvas-100 rounded p-2 text-xs">
2982
+ <div class="flex justify-between">
2983
+ <span class="\${exec.status === 'completed' ? 'text-status-success' : 'text-status-failed'}">\${exec.status}</span>
2984
+ <span class="text-canvas-500">\${Math.round(exec.duration / 1000)}s</span>
2985
+ </div>
2986
+ <div class="text-canvas-500 mt-1">\${new Date(exec.startedAt).toLocaleString()}</div>
2987
+ \${exec.error ? \`<div class="text-status-failed mt-1">\${escapeHtml(exec.error)}</div>\` : ''}
2988
+ </div>
2989
+ \`).join('')}
2990
+ </div>
2991
+ </div>
2992
+ \` : ''}
2993
+ </div>
2994
+ </div>
2995
+ \`}
2996
+ </div>
2997
+ </div>
2998
+ \`;
2999
+ }
3000
+
3001
+ function renderAFKBar() {
3002
+ if (!state.afk.running) return '';
3003
+ const progress = (state.afk.currentIteration / state.afk.maxIterations) * 100;
3004
+ return \`
3005
+ <div class="bg-gradient-to-r from-status-success/10 to-status-success/5 border-b border-status-success/20 px-6 py-3">
3006
+ <div class="flex items-center justify-between">
3007
+ <div class="flex items-center gap-5">
3008
+ <div class="flex items-center gap-2">
3009
+ <span class="text-xl animate-pulse">\u{1F504}</span>
3010
+ <span class="text-sm font-display font-semibold text-status-success">AFK Mode Active</span>
3011
+ </div>
3012
+ <div class="h-4 w-px bg-status-success/20"></div>
3013
+ <div class="flex items-center gap-4">
3014
+ <div class="stat-pill border-status-success/20">
3015
+ <span class="text-xs text-canvas-500">Iteration</span>
3016
+ <span class="text-sm font-mono text-status-success">\${state.afk.currentIteration}/\${state.afk.maxIterations}</span>
3017
+ </div>
3018
+ <div class="w-32 h-1.5 bg-canvas-200 rounded-full overflow-hidden">
3019
+ <div class="h-full bg-gradient-to-r from-status-success to-status-success/70 rounded-full transition-all" style="width: \${progress}%"></div>
3020
+ </div>
3021
+ </div>
3022
+ <div class="h-4 w-px bg-status-success/20"></div>
3023
+ <div class="stat-pill border-status-success/20">
3024
+ <span class="text-xs text-canvas-500">Completed</span>
3025
+ <span class="text-sm font-mono text-status-success">\${state.afk.tasksCompleted}</span>
3026
+ </div>
3027
+ </div>
3028
+ <button onclick="stopAFK()"
3029
+ class="btn text-sm px-4 py-1.5 bg-status-failed/15 hover:bg-status-failed/25 text-status-failed border border-status-failed/20">
3030
+ \u23F9 Stop AFK
3031
+ </button>
3032
+ </div>
3033
+ </div>
3034
+ \`;
3035
+ }
3036
+
3037
+ function renderStats() {
3038
+ const counts = {
3039
+ draft: state.tasks.filter(t => t.status === 'draft').length,
3040
+ ready: state.tasks.filter(t => t.status === 'ready').length,
3041
+ in_progress: state.tasks.filter(t => t.status === 'in_progress').length,
3042
+ completed: state.tasks.filter(t => t.status === 'completed').length,
3043
+ failed: state.tasks.filter(t => t.status === 'failed').length,
3044
+ };
3045
+ const total = state.tasks.length;
3046
+
3047
+ return \`
3048
+ <div class="flex items-center gap-3">
3049
+ <div class="stat-pill">
3050
+ <span class="text-xs text-canvas-500">Total</span>
3051
+ <span class="text-sm font-semibold text-canvas-700">\${total}</span>
3052
+ </div>
3053
+ \${counts.in_progress > 0 ? \`
3054
+ <div class="stat-pill border-status-running/20">
3055
+ <span class="stat-dot bg-status-running animate-pulse"></span>
3056
+ <span class="text-xs text-status-running font-medium">\${counts.in_progress} running</span>
3057
+ </div>
3058
+ \` : ''}
3059
+ \${counts.completed > 0 ? \`
3060
+ <div class="stat-pill border-status-success/20">
3061
+ <span class="stat-dot bg-status-success"></span>
3062
+ <span class="text-xs text-status-success">\${counts.completed} done</span>
3063
+ </div>
3064
+ \` : ''}
3065
+ </div>
3066
+ \`;
3067
+ }
3068
+
3069
+ // Event handlers
3070
+ async function handleTaskSubmit(e, isEdit) {
3071
+ e.preventDefault();
3072
+ const form = e.target;
3073
+ const data = new FormData(form);
3074
+ const action = e.submitter?.value || 'ready';
3075
+
3076
+ const task = {
3077
+ title: data.get('title'),
3078
+ description: data.get('description'),
3079
+ category: data.get('category'),
3080
+ priority: data.get('priority'),
3081
+ steps: data.get('steps').split('\\n').filter(s => s.trim()),
3082
+ status: action === 'draft' ? 'draft' : 'ready'
3083
+ };
3084
+
3085
+ if (isEdit && state.editingTask) {
3086
+ await updateTask(state.editingTask.id, task);
3087
+ } else {
3088
+ await createTask(task);
3089
+ }
3090
+
3091
+ state.showModal = null;
3092
+ state.editingTask = null;
3093
+ render();
3094
+ }
3095
+
3096
+ async function handleAIGenerate() {
3097
+ const prompt = document.getElementById('ai-prompt').value;
3098
+ if (!prompt.trim()) return;
3099
+
3100
+ state.aiPrompt = prompt;
3101
+ try {
3102
+ const task = await generateTask(prompt);
3103
+ state.editingTask = task;
3104
+ state.showModal = 'edit';
3105
+ state.aiPrompt = '';
3106
+ } catch (e) {
3107
+ alert('Failed to generate task: ' + e.message);
3108
+ }
3109
+ render();
3110
+ }
3111
+
3112
+ function applyTemplate(templateId) {
3113
+ const template = state.templates.find(t => t.id === templateId);
3114
+ if (!template) return;
3115
+
3116
+ state.editingTask = {
3117
+ title: template.titleTemplate,
3118
+ description: template.descriptionTemplate,
3119
+ category: template.category,
3120
+ priority: template.priority,
3121
+ steps: template.stepsTemplate,
3122
+ status: 'draft'
3123
+ };
3124
+ state.showModal = 'edit';
3125
+ render();
3126
+ }
3127
+
3128
+ function handleStartAFK() {
3129
+ const iterations = parseInt(document.getElementById('afk-iterations').value) || 10;
3130
+ const concurrent = parseInt(document.getElementById('afk-concurrent').value) || 1;
3131
+ startAFK(iterations, concurrent);
3132
+ state.showModal = null;
3133
+ render();
3134
+ }
3135
+
3136
+ // Filter tasks based on search query
3137
+ function filterTasks(tasks) {
3138
+ if (!state.searchQuery) return tasks;
3139
+ const query = state.searchQuery.toLowerCase();
3140
+ return tasks.filter(t =>
3141
+ t.title.toLowerCase().includes(query) ||
3142
+ t.description.toLowerCase().includes(query) ||
3143
+ t.category.toLowerCase().includes(query)
3144
+ );
3145
+ }
3146
+
3147
+ // Utility
3148
+ function escapeHtml(str) {
3149
+ if (!str) return '';
3150
+ return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
3151
+ }
3152
+
3153
+ // Main render
3154
+ function render() {
3155
+ const app = document.getElementById('app');
3156
+ const hasSidePanel = state.sidePanel !== null;
3157
+
3158
+ app.innerHTML = \`
3159
+ <div class="min-h-screen flex flex-col bg-canvas-50">
3160
+ <!-- Header -->
3161
+ <header class="sticky top-0 z-40 bg-white border-b border-canvas-200">
3162
+ <div class="px-6 py-3 flex items-center justify-between">
3163
+ <div class="flex items-center gap-6">
3164
+ <h1 class="text-lg font-semibold text-canvas-900">Claude Kanban</h1>
3165
+ <div class="flex items-center gap-2">
3166
+ <input type="text"
3167
+ placeholder="Search tasks..."
3168
+ value="\${escapeHtml(state.searchQuery)}"
3169
+ oninput="state.searchQuery = this.value; render();"
3170
+ class="input text-sm py-1.5 w-48">
3171
+ </div>
3172
+ </div>
3173
+ <div class="flex items-center gap-2">
3174
+ <button onclick="state.showModal = 'new'; render();"
3175
+ class="btn btn-primary px-4 py-2 text-sm">
3176
+ + Add Task
3177
+ </button>
3178
+ <button onclick="state.showModal = 'afk'; render();"
3179
+ class="btn btn-ghost px-3 py-2 text-sm \${state.afk.running ? 'text-status-success' : ''}"
3180
+ title="AFK Mode">
3181
+ \u{1F504} \${state.afk.running ? 'AFK On' : 'AFK'}
3182
+ </button>
3183
+ </div>
3184
+ </div>
3185
+ </header>
3186
+
3187
+ \${renderAFKBar()}
3188
+
3189
+ <!-- Main Content Area - Flex container for board + sidebar -->
3190
+ <div class="flex flex-1 overflow-hidden">
3191
+ <!-- Kanban Board -->
3192
+ <main class="main-content overflow-x-auto p-6">
3193
+ <div class="flex gap-4">
3194
+ \${renderColumn('draft', 'To Do', filterTasks(state.tasks))}
3195
+ \${renderColumn('ready', 'Ready', filterTasks(state.tasks))}
3196
+ \${renderColumn('in_progress', 'In Progress', filterTasks(state.tasks))}
3197
+ \${renderColumn('completed', 'Done', filterTasks(state.tasks))}
3198
+ \${renderColumn('failed', 'Failed', filterTasks(state.tasks))}
3199
+ </div>
3200
+ </main>
3201
+
3202
+ <!-- Side Panel (pushes content when open) -->
3203
+ \${hasSidePanel ? renderSidePanel() : ''}
3204
+ </div>
3205
+
3206
+ \${renderModal()}
3207
+ </div>
3208
+ \`;
3209
+
3210
+ // Auto-scroll side panel logs
3211
+ if (hasSidePanel && state.sidePanelTab === 'logs' && state.autoScroll) {
3212
+ const logEl = document.getElementById('side-panel-log');
3213
+ if (logEl) logEl.scrollTop = logEl.scrollHeight;
3214
+ }
3215
+
3216
+ // Update running task elapsed times every second
3217
+ if (state.running.length > 0 && !window.elapsedInterval) {
3218
+ window.elapsedInterval = setInterval(() => {
3219
+ if (state.running.length > 0) render();
3220
+ else {
3221
+ clearInterval(window.elapsedInterval);
3222
+ window.elapsedInterval = null;
3223
+ }
3224
+ }, 1000);
3225
+ }
3226
+ }
3227
+
3228
+ // Expose to window for inline handlers
3229
+ window.state = state;
3230
+ window.render = render;
3231
+ window.createTask = createTask;
3232
+ window.updateTask = updateTask;
3233
+ window.deleteTask = deleteTask;
3234
+ window.runTask = runTask;
3235
+ window.cancelTask = cancelTask;
3236
+ window.retryTask = retryTask;
3237
+ window.generateTask = generateTask;
3238
+ window.startAFK = startAFK;
3239
+ window.stopAFK = stopAFK;
3240
+ window.handleDragStart = handleDragStart;
3241
+ window.handleDragEnd = handleDragEnd;
3242
+ window.handleDragOver = handleDragOver;
3243
+ window.handleDragLeave = handleDragLeave;
3244
+ window.handleDrop = handleDrop;
3245
+ window.handleTaskSubmit = handleTaskSubmit;
3246
+ window.handleAIGenerate = handleAIGenerate;
3247
+ window.applyTemplate = applyTemplate;
3248
+ window.handleStartAFK = handleStartAFK;
3249
+ window.showToast = showToast;
3250
+ window.scrollLogToBottom = scrollLogToBottom;
3251
+ window.copyLogToClipboard = copyLogToClipboard;
3252
+ window.clearLog = clearLog;
3253
+ window.closeLogTab = closeLogTab;
3254
+ window.toggleLogFullscreen = toggleLogFullscreen;
3255
+ window.escapeHtml = escapeHtml;
3256
+ window.openSidePanel = openSidePanel;
3257
+ window.closeSidePanel = closeSidePanel;
3258
+ window.showTaskMenu = showTaskMenu;
3259
+ window.filterTasks = filterTasks;
3260
+ window.scrollSidePanelLog = scrollSidePanelLog;
3261
+
3262
+ // Keyboard shortcuts
3263
+ document.addEventListener('keydown', (e) => {
3264
+ // Ignore if typing in input
3265
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
3266
+
3267
+ // ? - Show help
3268
+ if (e.key === '?') {
3269
+ showToast('Shortcuts: N=New, T=Templates, F=AFK, Esc=Close modal', 'info');
3270
+ }
3271
+ // n - New task
3272
+ if (e.key === 'n' && !e.metaKey && !e.ctrlKey) {
3273
+ state.showModal = 'new';
3274
+ render();
3275
+ }
3276
+ // t - Templates
3277
+ if (e.key === 't' && !e.metaKey && !e.ctrlKey) {
3278
+ state.showModal = 'templates';
3279
+ render();
3280
+ }
3281
+ // f - AFK mode
3282
+ if (e.key === 'f' && !e.metaKey && !e.ctrlKey && !state.afk.running) {
3283
+ state.showModal = 'afk';
3284
+ render();
3285
+ }
3286
+ // Escape - Close modal or side panel
3287
+ if (e.key === 'Escape') {
3288
+ if (state.showModal) {
3289
+ state.showModal = null;
3290
+ state.editingTask = null;
3291
+ render();
3292
+ } else if (state.sidePanel) {
3293
+ closeSidePanel();
3294
+ }
3295
+ }
3296
+ // / - Focus log search
3297
+ if (e.key === '/' && !state.showModal) {
3298
+ e.preventDefault();
3299
+ const searchInput = document.querySelector('#log-content ~ input, input[placeholder="Search logs..."]');
3300
+ if (searchInput) searchInput.focus();
3301
+ }
3302
+ });
3303
+
3304
+ // Initial render
3305
+ render();
3306
+ `;
3307
+ }
3308
+
3309
+ // src/server/utils/port.ts
3310
+ import { createServer as createServer2 } from "net";
3311
+ function isPortAvailable(port) {
3312
+ return new Promise((resolve) => {
3313
+ const server = createServer2();
3314
+ server.once("error", () => {
3315
+ resolve(false);
3316
+ });
3317
+ server.once("listening", () => {
3318
+ server.close();
3319
+ resolve(true);
3320
+ });
3321
+ server.listen(port, "127.0.0.1");
3322
+ });
3323
+ }
3324
+ async function findAvailablePort(startPort, maxAttempts = 10) {
3325
+ for (let i = 0; i < maxAttempts; i++) {
3326
+ const port = startPort + i;
3327
+ if (await isPortAvailable(port)) {
3328
+ return port;
3329
+ }
3330
+ }
3331
+ throw new Error(`No available port found in range ${startPort}-${startPort + maxAttempts - 1}`);
3332
+ }
3333
+
3334
+ // src/bin/cli.ts
3335
+ var VERSION = "0.1.0";
3336
+ var banner = `
3337
+ ${chalk.cyan("\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557")}
3338
+ ${chalk.cyan("\u2551")} ${chalk.bold.white("Claude Kanban")} ${chalk.gray(`v${VERSION}`)} ${chalk.cyan("\u2551")}
3339
+ ${chalk.cyan("\u2551")} ${chalk.gray("Visual AI-powered development")} ${chalk.cyan("\u2551")}
3340
+ ${chalk.cyan("\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D")}
3341
+ `;
3342
+ async function main() {
3343
+ const program = new Command();
3344
+ program.name("claude-kanban").description("Visual Kanban board for AI-powered development with Claude").version(VERSION).option("-p, --port <number>", "Port to run server on", "4242").option("-n, --no-open", "Do not auto-open browser").option("--init", "Re-initialize project files").option("--reset", "Reset all tasks (keeps config)").action(async (options) => {
3345
+ console.log(banner);
3346
+ const cwd = process.cwd();
3347
+ const initialized = await isProjectInitialized(cwd);
3348
+ if (!initialized || options.init) {
3349
+ console.log(chalk.yellow("Initializing project..."));
3350
+ try {
3351
+ await initializeProject(cwd, options.reset);
3352
+ console.log(chalk.green("\u2713 Project initialized successfully"));
3353
+ } catch (error) {
3354
+ console.error(chalk.red("Failed to initialize project:"), error);
3355
+ process.exit(1);
3356
+ }
3357
+ } else {
3358
+ console.log(chalk.gray("Found existing configuration"));
3359
+ }
3360
+ let port = parseInt(options.port, 10);
3361
+ try {
3362
+ port = await findAvailablePort(port);
3363
+ } catch {
3364
+ console.error(chalk.red(`Port ${port} is in use and no alternatives found`));
3365
+ process.exit(1);
3366
+ }
3367
+ console.log(chalk.gray(`Starting server on port ${port}...`));
3368
+ try {
3369
+ const server = await createServer(cwd, port);
3370
+ const url = `http://localhost:${port}`;
3371
+ console.log(chalk.green(`
3372
+ \u2713 Server running at ${chalk.bold(url)}
3373
+ `));
3374
+ if (options.open !== false) {
3375
+ console.log(chalk.gray("Opening browser..."));
3376
+ await open(url);
3377
+ }
3378
+ let isShuttingDown = false;
3379
+ const shutdown = () => {
3380
+ if (isShuttingDown) {
3381
+ console.log(chalk.red("\nForce exiting..."));
3382
+ process.exit(1);
3383
+ }
3384
+ isShuttingDown = true;
3385
+ console.log(chalk.yellow("\nShutting down..."));
3386
+ if (server.cleanup) {
3387
+ server.cleanup();
3388
+ }
3389
+ const forceExitTimeout = setTimeout(() => {
3390
+ console.log(chalk.red("Forcing exit after timeout"));
3391
+ process.exit(0);
3392
+ }, 3e3);
3393
+ server.close(() => {
3394
+ clearTimeout(forceExitTimeout);
3395
+ console.log(chalk.green("\u2713 Server stopped"));
3396
+ process.exit(0);
3397
+ });
3398
+ };
3399
+ process.on("SIGINT", shutdown);
3400
+ process.on("SIGTERM", shutdown);
3401
+ console.log(chalk.gray("Press Ctrl+C to stop\n"));
3402
+ } catch (error) {
3403
+ console.error(chalk.red("Failed to start server:"), error);
3404
+ process.exit(1);
3405
+ }
3406
+ });
3407
+ await program.parseAsync();
3408
+ }
3409
+ main().catch((error) => {
3410
+ console.error(chalk.red("Fatal error:"), error);
3411
+ process.exit(1);
3412
+ });
3413
+ //# sourceMappingURL=cli.js.map