create-claude-kanban 3.1.0 → 3.2.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.
@@ -12,35 +12,18 @@ delete process.env.CLAUDECODE;
12
12
  */
13
13
 
14
14
  var config;
15
- try {
16
- var configPath = require("path").join(__dirname, "..", "kanban.config.json");
17
- if (require("fs").existsSync(configPath)) {
18
- var raw = JSON.parse(require("fs").readFileSync(configPath, "utf8"));
19
- config = {
20
- port: raw.port || 4040,
21
- projectName: (raw.projects && raw.projects[0] && raw.projects[0].name) || "Project",
22
- slack: {
23
- webhookUrl: process.env.SLACK_AGENT_WEBHOOK || "",
24
- botToken: process.env.SLACK_BOT_TOKEN || "",
25
- appToken: process.env.SLACK_APP_TOKEN || "",
26
- channelId: process.env.SLACK_CHANNEL_ID || "",
27
- adminUsers: (process.env.SLACK_ADMIN_USERS || "").split(",").filter(Boolean),
28
- command: (raw.slack && raw.slack.command) || "/kanban",
29
- },
30
- };
31
- } else { throw new Error("no config"); }
32
- } catch {
15
+ try { config = require("claude-kanban/lib/config.cjs"); } catch {
33
16
  try { require("dotenv").config({ path: require("path").join(__dirname, "..", ".env") }); } catch {}
34
17
  config = {
35
18
  port: parseInt(process.env.PORT) || 4040,
36
- projectName: "Project",
19
+ projectName: "APEX",
37
20
  slack: {
38
21
  webhookUrl: process.env.SLACK_AGENT_WEBHOOK || "",
39
22
  botToken: process.env.SLACK_BOT_TOKEN || "",
40
23
  appToken: process.env.SLACK_APP_TOKEN || "",
41
24
  channelId: process.env.SLACK_CHANNEL_ID || "",
42
25
  adminUsers: (process.env.SLACK_ADMIN_USERS || "").split(",").filter(Boolean),
43
- command: process.env.SLACK_COMMAND || "/kanban",
26
+ command: process.env.SLACK_COMMAND || "/apex",
44
27
  },
45
28
  };
46
29
  }
@@ -56,6 +39,7 @@ const PROJECT_NAME = config.projectName;
56
39
  const TASKS_DIR = path.join(os.homedir(), ".claude", "tasks");
57
40
  const KANBAN_DIR = path.join(TASKS_DIR, "kanban");
58
41
  const ACTIVITY_FILE = path.join(TASKS_DIR, "activity.jsonl");
42
+ const ARCHIVES_DIR = path.join(TASKS_DIR, "_archives");
59
43
 
60
44
  if (!fs.existsSync(KANBAN_DIR)) {
61
45
  fs.mkdirSync(KANBAN_DIR, { recursive: true });
@@ -70,7 +54,7 @@ function readProjects() {
70
54
  return JSON.parse(fs.readFileSync(PROJECTS_FILE, "utf-8"));
71
55
  }
72
56
  } catch {}
73
- var defaults = [{ id: "default", name: PROJECT_NAME, dir: "kanban", color: "#3B82F6" }];
57
+ var defaults = [{ id: "apex", name: "APEX", dir: "kanban", color: "#3B82F6" }];
74
58
  writeProjects(defaults);
75
59
  return defaults;
76
60
  }
@@ -150,60 +134,7 @@ function getThreadTs(taskId) {
150
134
  // ── Chat Session ──
151
135
  let chatSessionId = null;
152
136
  let chatSessionModel = null;
153
-
154
- // ── Agent Activity Tracker (v3: zombie detection) ──
155
- var agentTracker = {}; // taskId -> { pid, startedAt, lastActivity, agent, subject }
156
- var STALE_THRESHOLD = 30 * 60 * 1000; // 30 min
157
-
158
- function trackAgent(taskId, pid, agent, subject) {
159
- agentTracker[String(taskId)] = { pid: pid, startedAt: Date.now(), lastActivity: Date.now(), agent: agent || "", subject: subject || "" };
160
- }
161
- function touchAgent(taskId) {
162
- if (agentTracker[String(taskId)]) agentTracker[String(taskId)].lastActivity = Date.now();
163
- }
164
- function untrackAgent(taskId) {
165
- delete agentTracker[String(taskId)];
166
- }
167
- function isProcessAlive(pid) {
168
- try { process.kill(pid, 0); return true; } catch (e) { return false; }
169
- }
170
- function checkZombies() {
171
- var now = Date.now();
172
- var zombies = [];
173
- Object.keys(agentTracker).forEach(function (tid) {
174
- var info = agentTracker[tid];
175
- var alive = isProcessAlive(info.pid);
176
- var stale = (now - info.lastActivity) > STALE_THRESHOLD;
177
- if (!alive || stale) {
178
- zombies.push({ taskId: tid, reason: !alive ? "process_dead" : "stale", agent: info.agent, subject: info.subject, lastActivity: info.lastActivity, startedAt: info.startedAt });
179
- if (!alive) {
180
- untrackAgent(tid);
181
- // Auto-mark stale in task file
182
- try {
183
- var t = readTask(tid);
184
- if (t && t.status === "in_progress") {
185
- updateTask(tid, { _stale: true, _staleReason: "process_dead", _staleAt: new Date().toISOString() });
186
- logActivity({ type: "updated", taskId: tid, subject: info.subject, detail: "Zombie detected: agent process dead" });
187
- broadcastRaw({ type: "zombie", taskId: tid, reason: "process_dead" });
188
- }
189
- } catch (e) {}
190
- } else if (stale) {
191
- try {
192
- var t = readTask(tid);
193
- if (t && t.status === "in_progress" && !t._stale) {
194
- updateTask(tid, { _stale: true, _staleReason: "timeout", _staleAt: new Date().toISOString() });
195
- logActivity({ type: "updated", taskId: tid, subject: info.subject, detail: "Zombie detected: no activity for 30min" });
196
- broadcastRaw({ type: "zombie", taskId: tid, reason: "stale" });
197
- }
198
- } catch (e) {}
199
- }
200
- }
201
- });
202
- return zombies;
203
- }
204
-
205
- // Run zombie check every 60 seconds
206
- setInterval(checkZombies, 60000);
137
+ let chatSessionProject = null;
207
138
 
208
139
  // ── Orchestrator Prompt System ──
209
140
  const ORCHESTRATOR_FILE = path.join(os.homedir(), ".claude", "orchestrator.md");
@@ -324,6 +255,19 @@ function executeAction(action) {
324
255
  return results;
325
256
  }
326
257
 
258
+ function getProjectSourceDir(projectId) {
259
+ var projects = readProjects();
260
+ var proj = projects.find(function(p) { return p.id === projectId; });
261
+ return proj ? (proj.sourceDir || "") : "";
262
+ }
263
+
264
+ function readProjectClaudeMd(projectId) {
265
+ var srcDir = getProjectSourceDir(projectId);
266
+ if (!srcDir) return "";
267
+ var claudeMdPath = path.join(srcDir, "CLAUDE.md");
268
+ try { return fs.readFileSync(claudeMdPath, "utf-8"); } catch { return ""; }
269
+ }
270
+
327
271
  function buildChatSystemPrompt(tasks, projectName, currentProjectId) {
328
272
  var orchestratorPrompt = readOrchestratorPrompt();
329
273
  var history = readOrchestratorHistory(5);
@@ -374,9 +318,17 @@ function buildChatSystemPrompt(tasks, projectName, currentProjectId) {
374
318
  prompt += "You are the Orchestrator for the " + projectName + " Kanban board.\n\n";
375
319
  }
376
320
  prompt += "## Current Board State\n";
321
+ var srcDir = getProjectSourceDir(currentProjectId);
377
322
  prompt += "Active Project: " + projectName + " (" + (currentProjectId || "") + ")\n";
378
- prompt += "Working directory: " + path.join(__dirname, "..") + "\n\n";
323
+ prompt += "Working directory: " + (srcDir || path.join(__dirname, "..")) + "\n\n";
379
324
  prompt += (taskDetail || "(보드 비어있음)") + "\n\n";
325
+
326
+ // Inject project-specific CLAUDE.md
327
+ var claudeMd = readProjectClaudeMd(currentProjectId);
328
+ if (claudeMd) {
329
+ prompt += "## Project Specification (CLAUDE.md)\n" + claudeMd + "\n\n";
330
+ }
331
+
380
332
  if (crossProjectInfo) {
381
333
  prompt += crossProjectInfo;
382
334
  }
@@ -600,11 +552,6 @@ function readActivity(since, limit) {
600
552
  }
601
553
 
602
554
  // ── Task 파일 읽기 ──
603
- function readTask(id) {
604
- var all = readAllTasks();
605
- return all.find(function(t) { return String(t.id) === String(id); }) || null;
606
- }
607
-
608
555
  function readAllTasks(projectFilter) {
609
556
  const tasks = [];
610
557
  if (!fs.existsSync(TASKS_DIR)) return tasks;
@@ -628,7 +575,7 @@ function readAllTasks(projectFilter) {
628
575
  }
629
576
  const sessionPath = path.join(TASKS_DIR, session.name);
630
577
  const files = fs.readdirSync(sessionPath).filter(
631
- (f) => f.endsWith(".json") && f !== ".lock"
578
+ (f) => f.endsWith(".json") && f !== ".lock" && f !== "chat-history.json"
632
579
  );
633
580
  for (const file of files) {
634
581
  try {
@@ -670,7 +617,7 @@ function getNextId() {
670
617
  function createTask(data) {
671
618
  const id = getNextId();
672
619
  const now = new Date().toISOString();
673
- var projectId = data.project || readProjects()[0].id || "default";
620
+ var projectId = data.project || readProjects()[0].id || "apex";
674
621
  var projectDir = getProjectDir(projectId);
675
622
  if (!fs.existsSync(projectDir)) fs.mkdirSync(projectDir, { recursive: true });
676
623
  const task = {
@@ -743,9 +690,6 @@ function updateTask(id, data) {
743
690
  if (data.reportPath !== undefined) task.reportPath = data.reportPath;
744
691
  if (data.reportSummary !== undefined) task.reportSummary = data.reportSummary;
745
692
  if (data.parentId !== undefined) task.parentId = data.parentId;
746
- if (data._stale !== undefined) task._stale = data._stale;
747
- if (data._staleReason !== undefined) task._staleReason = data._staleReason;
748
- if (data._staleAt !== undefined) task._staleAt = data._staleAt;
749
693
  task.updatedAt = now;
750
694
  fs.writeFileSync(filePath, JSON.stringify(task, null, 2));
751
695
 
@@ -935,18 +879,8 @@ function buildExecutorPrompt(task) {
935
879
  if (agentPrompt) parts.push(agentPrompt);
936
880
 
937
881
  parts.push("## 태스크\n\n- **Task ID**: " + task.id + "\n- **Subject**: " + task.subject + "\n\n" + (task.description || "(설명 없음)"));
938
-
939
- // v3: Include review feedback from comments
940
- if (task.comments && task.comments.length > 0) {
941
- var reviewComments = task.comments.filter(function (c) { return c.author === "reviewer"; });
942
- if (reviewComments.length > 0) {
943
- parts.push("## 리뷰 피드백\n\n이전 실행에서 리뷰어가 남긴 피드백입니다. 반드시 반영하세요:\n\n" +
944
- reviewComments.map(function (c) { return "- " + c.text + " (" + c.createdAt + ")"; }).join("\n"));
945
- }
946
- }
947
-
948
882
  parts.push("작업 디렉토리: " + path.join(__dirname, ".."));
949
- parts.push("## Slack Progress Reporting\n\n진행 상황을 Slack에 보고할 때 반드시 아래 API를 사용한다:\n\n```bash\ncurl -s -X POST http://localhost:" + PORT + "/api/tasks/" + task.id + "/slack \\\n -H \"Content-Type: application/json\" \\\n -d '{\"text\":\"progress message\"}'\n```\n\n`$SLACK_AGENT_WEBHOOK` 직접 사용 금지. 반드시 위 API를 통해 보고.");
883
+ parts.push("## Slack Progress Reporting\n\n진행 상황을 Slack에 보고할 때 반드시 아래 API를 사용한다:\n\n```bash\ncurl -s -X POST http://localhost:4040/api/tasks/" + task.id + "/slack \\\n -H \"Content-Type: application/json\" \\\n -d '{\"text\":\"progress message\"}'\n```\n\n`$SLACK_AGENT_WEBHOOK` 직접 사용 금지. 반드시 위 API를 통해 보고.");
950
884
 
951
885
  return parts.join("\n\n---\n\n");
952
886
  }
@@ -960,14 +894,14 @@ function spawnExecutor(task) {
960
894
  var execEnv = Object.assign({}, process.env);
961
895
  delete execEnv.ANTHROPIC_API_KEY; // Max 구독 OAuth 사용
962
896
  delete execEnv.CLAUDECODE; // executor가 nested로 오인하지 않도록
897
+ var execCwd = getProjectSourceDir(task.project) || path.join(__dirname, "..");
963
898
  const proc = spawn("claude", ["-p", "--verbose", "--output-format", "stream-json", "--model", model, "--no-session-persistence", "--dangerously-skip-permissions"], {
964
- cwd: path.join(__dirname, ".."),
899
+ cwd: execCwd,
965
900
  env: execEnv,
966
901
  stdio: ["pipe", "pipe", "pipe"],
967
902
  });
968
903
 
969
904
  activeExec = { process: proc, taskId: taskId, output: "" };
970
- trackAgent(taskId, proc.pid, task.agent, task.subject);
971
905
  broadcastRaw({ type: "exec_start", taskId: taskId, subject: task.subject, agent: task.agent || "", model: model });
972
906
  logActivity({ type: "started", taskId: taskId, subject: task.subject, detail: "Agent: " + (task.agent || "none") + " | Model: " + model });
973
907
 
@@ -991,7 +925,6 @@ function spawnExecutor(task) {
991
925
  else if (json.type === "content_block_delta" && json.delta) text = json.delta.text || "";
992
926
  if (text && activeExec) {
993
927
  activeExec.output += text;
994
- touchAgent(taskId);
995
928
  broadcastRaw({ type: "exec", taskId: taskId, chunk: text });
996
929
  }
997
930
  } catch (e) {}
@@ -1005,7 +938,6 @@ function spawnExecutor(task) {
1005
938
  proc.on("close", function (code) {
1006
939
  var output = activeExec ? activeExec.output : "";
1007
940
  activeExec = null;
1008
- untrackAgent(taskId);
1009
941
  if (code === 0) {
1010
942
  updateTask(taskId, { status: "in_review", reportSummary: output.slice(0, 500) });
1011
943
  broadcastRaw({ type: "exec_done", taskId: taskId, exitCode: 0 });
@@ -1386,8 +1318,9 @@ function handleSlackAsk(question, channelId, client) {
1386
1318
 
1387
1319
  var askEnv = Object.assign({}, process.env);
1388
1320
  delete askEnv.ANTHROPIC_API_KEY;
1321
+ var askCwd = getProjectSourceDir(defaultProjId) || path.join(__dirname, "..");
1389
1322
  var proc = spawn("claude", ["-p", "--output-format", "text", "--model", "sonnet", "--no-session-persistence", "--dangerously-skip-permissions"], {
1390
- cwd: path.join(__dirname, ".."),
1323
+ cwd: askCwd,
1391
1324
  env: askEnv,
1392
1325
  stdio: ["pipe", "pipe", "pipe"],
1393
1326
  });
@@ -1481,6 +1414,13 @@ function watchTasks() {
1481
1414
  broadcast({ type: "update", tasks });
1482
1415
  }
1483
1416
 
1417
+ // macOS fs.watch fires multiple events per single file change — debounce
1418
+ let _changeTimer = null;
1419
+ function debouncedOnFileChange() {
1420
+ if (_changeTimer) clearTimeout(_changeTimer);
1421
+ _changeTimer = setTimeout(() => { _changeTimer = null; onFileChange(); }, 300);
1422
+ }
1423
+
1484
1424
  function scanAndWatch() {
1485
1425
  if (!fs.existsSync(TASKS_DIR)) return;
1486
1426
  try {
@@ -1491,7 +1431,7 @@ function watchTasks() {
1491
1431
  if (!watched.has(sp)) {
1492
1432
  watched.add(sp);
1493
1433
  try {
1494
- fs.watch(sp, { persistent: false }, () => onFileChange());
1434
+ fs.watch(sp, { persistent: false }, () => debouncedOnFileChange());
1495
1435
  } catch {}
1496
1436
  }
1497
1437
  }
@@ -1500,13 +1440,376 @@ function watchTasks() {
1500
1440
  try {
1501
1441
  fs.watch(TASKS_DIR, { persistent: false }, () => {
1502
1442
  scanAndWatch();
1503
- onFileChange();
1443
+ debouncedOnFileChange();
1504
1444
  });
1505
1445
  } catch {}
1506
1446
  scanAndWatch();
1507
1447
  setInterval(() => broadcast({ type: "update", tasks: readAllTasks() }), 2000);
1508
1448
  }
1509
1449
 
1450
+ // ── Archive ──
1451
+ function archiveTasks(projectId, taskIds) {
1452
+ var projects = readProjects();
1453
+ var proj = projects.find(function(p) { return p.id === projectId; });
1454
+ if (!proj) throw new Error("Project not found: " + projectId);
1455
+ var dirName = proj.dir || proj.id;
1456
+
1457
+ // Collect completed tasks
1458
+ var allTasks = readAllTasks(projectId);
1459
+ var completedTasks = allTasks.filter(function(t) {
1460
+ return t.status === "completed" && t._editable;
1461
+ });
1462
+ if (taskIds && taskIds.length > 0) {
1463
+ completedTasks = completedTasks.filter(function(t) {
1464
+ return taskIds.indexOf(String(t.id)) >= 0;
1465
+ });
1466
+ }
1467
+ if (completedTasks.length === 0) return { ok: true, date: null, count: 0 };
1468
+
1469
+ // Build children lookup
1470
+ var childrenMap = {};
1471
+ allTasks.forEach(function(t) {
1472
+ if (t.parentId) {
1473
+ if (!childrenMap[t.parentId]) childrenMap[t.parentId] = [];
1474
+ childrenMap[t.parentId].push(String(t.id));
1475
+ }
1476
+ });
1477
+
1478
+ // Build archive task records
1479
+ var now = new Date();
1480
+ var dateStr = now.toISOString().slice(0, 10);
1481
+ var archiveTasks = completedTasks.map(function(t) {
1482
+ var elapsed = null;
1483
+ if (t.startedAt && t.completedAt) {
1484
+ elapsed = Math.round((new Date(t.completedAt) - new Date(t.startedAt)) / 1000);
1485
+ }
1486
+ return {
1487
+ id: String(t.id),
1488
+ subject: t.subject || "",
1489
+ description: t.description || "",
1490
+ status: t.status,
1491
+ priority: t.priority || "medium",
1492
+ agent: t.agent || "",
1493
+ owner: t.owner || "",
1494
+ parentId: t.parentId || null,
1495
+ children: childrenMap[String(t.id)] || [],
1496
+ createdAt: t.createdAt || null,
1497
+ startedAt: t.startedAt || null,
1498
+ completedAt: t.completedAt || null,
1499
+ elapsedSec: elapsed,
1500
+ reportSummary: t.reportSummary || null,
1501
+ reportPath: t.reportPath || null,
1502
+ };
1503
+ });
1504
+
1505
+ // Merge into date file
1506
+ var archiveDir = path.join(ARCHIVES_DIR, dirName);
1507
+ if (!fs.existsSync(archiveDir)) fs.mkdirSync(archiveDir, { recursive: true });
1508
+ var archiveFile = path.join(archiveDir, dateStr + ".json");
1509
+
1510
+ var existing = null;
1511
+ if (fs.existsSync(archiveFile)) {
1512
+ try { existing = JSON.parse(fs.readFileSync(archiveFile, "utf-8")); } catch {}
1513
+ }
1514
+
1515
+ var agents = new Set();
1516
+ archiveTasks.forEach(function(t) { if (t.agent) agents.add(t.agent); });
1517
+
1518
+ if (existing && existing.tasks) {
1519
+ // Merge: deduplicate by ID
1520
+ var existingIds = new Set(existing.tasks.map(function(t) { return t.id; }));
1521
+ archiveTasks.forEach(function(t) {
1522
+ if (!existingIds.has(t.id)) {
1523
+ existing.tasks.push(t);
1524
+ existingIds.add(t.id);
1525
+ }
1526
+ });
1527
+ existing.tasks.forEach(function(t) { if (t.agent) agents.add(t.agent); });
1528
+ existing.archivedAt = now.toISOString();
1529
+ existing.stats = { total: existing.tasks.length, agents: Array.from(agents) };
1530
+ fs.writeFileSync(archiveFile, JSON.stringify(existing, null, 2));
1531
+ } else {
1532
+ var archive = {
1533
+ date: dateStr,
1534
+ project: projectId,
1535
+ projectDir: dirName,
1536
+ archivedAt: now.toISOString(),
1537
+ tasks: archiveTasks,
1538
+ stats: { total: archiveTasks.length, agents: Array.from(agents) },
1539
+ };
1540
+ fs.writeFileSync(archiveFile, JSON.stringify(archive, null, 2));
1541
+ }
1542
+
1543
+ // Delete task files
1544
+ completedTasks.forEach(function(t) {
1545
+ var fp = findTaskFile(String(t.id));
1546
+ if (fp) {
1547
+ try { fs.unlinkSync(fp); } catch {}
1548
+ taskSnapshot.delete(String(t.id));
1549
+ }
1550
+ });
1551
+
1552
+ // Activity log
1553
+ logActivity({
1554
+ type: "archived",
1555
+ taskId: completedTasks.map(function(t) { return t.id; }).join(","),
1556
+ subject: "Archived " + completedTasks.length + " tasks",
1557
+ agent: "",
1558
+ detail: dateStr + " → " + archiveFile,
1559
+ });
1560
+
1561
+ // Generate HTML report
1562
+ var finalData = existing || archive;
1563
+ generateArchiveHTML(finalData, archiveDir);
1564
+ generateArchiveIndex(archiveDir, dirName, projectId);
1565
+
1566
+ return { ok: true, date: dateStr, count: completedTasks.length };
1567
+ }
1568
+
1569
+ function generateArchiveHTML(data, archiveDir) {
1570
+ var tasks = data.tasks || [];
1571
+ var dateStr = data.date;
1572
+ var taskMap = {};
1573
+ tasks.forEach(function(t) { taskMap[t.id] = t; });
1574
+
1575
+ var parents = [], childrenMap = {}, orphans = [], standalone = [];
1576
+ tasks.forEach(function(t) {
1577
+ if (t.children && t.children.length > 0) {
1578
+ parents.push(t);
1579
+ childrenMap[t.id] = t.children.map(function(cid) { return taskMap[cid]; }).filter(Boolean);
1580
+ } else if (t.parentId) {
1581
+ if (!taskMap[t.parentId]) orphans.push(t);
1582
+ } else {
1583
+ standalone.push(t);
1584
+ }
1585
+ });
1586
+
1587
+ function esc(s) { return String(s || "").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); }
1588
+ function nl2br(s) { return esc(s).replace(/\n/g, "<br>"); }
1589
+ function fmtDur(sec) {
1590
+ if (sec == null) return "-";
1591
+ if (sec < 60) return sec + "s";
1592
+ if (sec < 3600) return Math.floor(sec/60) + "m " + (sec%60) + "s";
1593
+ return Math.floor(sec/3600) + "h " + Math.floor((sec%3600)/60) + "m";
1594
+ }
1595
+ function fmtDateTime(iso) {
1596
+ if (!iso) return "-";
1597
+ var d = new Date(iso);
1598
+ return d.toLocaleDateString("ko-KR") + " " + d.toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit" });
1599
+ }
1600
+
1601
+ function renderTask(t, indent) {
1602
+ var cls = indent ? "child" : (t.children && t.children.length > 0 ? "parent" : "");
1603
+ var h = '<div class="task ' + cls + '">';
1604
+ h += '<div class="task-head">';
1605
+ h += '<span class="tid">#' + esc(t.id) + '</span>';
1606
+ h += '<span class="subj">' + esc(t.subject) + '</span>';
1607
+ if (t.priority === "high" || t.priority === "critical") h += '<span class="pri pri-' + t.priority + '">' + esc(t.priority) + '</span>';
1608
+ h += '</div>';
1609
+ h += '<div class="task-meta">';
1610
+ if (t.agent) h += '<span class="meta-tag agent">' + esc(t.agent) + '</span>';
1611
+ if (t.owner && t.owner !== t.agent) h += '<span class="meta-tag">owner: ' + esc(t.owner) + '</span>';
1612
+ if (t.createdAt) h += '<span>created: ' + fmtDateTime(t.createdAt) + '</span>';
1613
+ if (t.completedAt) h += '<span>done: ' + fmtDateTime(t.completedAt) + '</span>';
1614
+ if (t.elapsedSec != null) h += '<span>' + fmtDur(t.elapsedSec) + '</span>';
1615
+ if (indent && t.parentId) h += '<span class="pref">&larr; #' + esc(t.parentId) + '</span>';
1616
+ h += '</div>';
1617
+ if (t.reportSummary) h += '<div class="task-report"><div class="report-label">Report</div>' + nl2br(t.reportSummary) + '</div>';
1618
+ if (t.description) h += '<div class="task-detail"><div class="detail-label">Description</div>' + nl2br(t.description) + '</div>';
1619
+ if (t.children && t.children.length > 0) h += '<div class="task-children">' + t.children.length + ' subtasks</div>';
1620
+ h += '</div>';
1621
+ return h;
1622
+ }
1623
+
1624
+ var body = '';
1625
+ parents.forEach(function(p) {
1626
+ body += renderTask(p, false);
1627
+ (childrenMap[p.id] || []).forEach(function(c) { body += renderTask(c, true); });
1628
+ });
1629
+ if (standalone.length > 0) {
1630
+ if (parents.length > 0) body += '<div class="section-label">Standalone</div>';
1631
+ standalone.forEach(function(t) { body += renderTask(t, false); });
1632
+ }
1633
+ if (orphans.length > 0) {
1634
+ body += '<div class="section-label">Orphan subtasks</div>';
1635
+ orphans.forEach(function(t) { body += renderTask(t, true); });
1636
+ }
1637
+
1638
+ var agentList = (data.stats && data.stats.agents) ? data.stats.agents.join(", ") : "";
1639
+ var css = '*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}\n';
1640
+ css += 'body{font-family:-apple-system,BlinkMacSystemFont,"Pretendard Variable",sans-serif;background:#050A12;color:#DDE5F0;font-size:13px;line-height:1.6}\n';
1641
+ css += '.wrap{max-width:900px;margin:0 auto;padding:32px 24px 60px}\n';
1642
+ css += 'h1{font-size:22px;font-weight:700;margin-bottom:4px;color:#fff}\n';
1643
+ css += '.subtitle{font-size:12px;color:#607088;margin-bottom:24px}\n';
1644
+ css += '.stats-bar{display:flex;gap:16px;padding:12px 16px;background:#0B1220;border:1px solid #1E2D45;border-radius:6px;margin-bottom:28px;font-size:12px;color:#8899B0;flex-wrap:wrap}\n';
1645
+ css += '.stats-bar strong{color:#DDE5F0}\n';
1646
+ css += '.section-label{font-size:11px;font-weight:600;color:#607088;text-transform:uppercase;letter-spacing:.5px;margin:28px 0 10px;padding-bottom:6px;border-bottom:1px solid #1E2D45}\n';
1647
+ css += '.task{padding:14px 16px;border:1px solid #1E2D45;border-radius:6px;margin-bottom:8px;background:#0B1220;transition:background .15s}\n';
1648
+ css += '.task:hover{background:#101828}\n';
1649
+ css += '.task.parent{border-left:3px solid #1A6FEF}\n';
1650
+ css += '.task.child{margin-left:28px;border-left:2px dashed #2A3F5F}\n';
1651
+ css += '.task-head{display:flex;align-items:center;gap:8px;margin-bottom:6px}\n';
1652
+ css += '.tid{font-size:11px;font-family:monospace;color:#1A6FEF;font-weight:700;flex-shrink:0}\n';
1653
+ css += '.subj{font-size:14px;font-weight:600;color:#fff;flex:1}\n';
1654
+ css += '.pri{font-size:9px;font-weight:700;padding:2px 8px;border-radius:3px;text-transform:uppercase}\n';
1655
+ css += '.pri-high{background:rgba(239,68,68,.15);color:#EF4444}\n';
1656
+ css += '.pri-critical{background:rgba(239,68,68,.25);color:#EF4444}\n';
1657
+ css += '.task-meta{display:flex;gap:10px;font-size:10px;color:#607088;flex-wrap:wrap;margin-bottom:6px;align-items:center}\n';
1658
+ css += '.meta-tag{padding:1px 7px;background:#162035;border-radius:3px;color:#8899B0;font-weight:500}\n';
1659
+ css += '.meta-tag.agent{color:#1A6FEF;background:rgba(26,111,239,.1)}\n';
1660
+ css += '.task-meta .pref{font-family:monospace;color:#C8A24A}\n';
1661
+ css += '.task-report{margin-top:8px;padding:10px 12px;background:#0F1B30;border:1px solid #1E2D45;border-left:3px solid #22C55E;border-radius:4px;font-size:12px;color:#8899B0;line-height:1.7}\n';
1662
+ css += '.report-label{font-size:9px;font-weight:700;color:#22C55E;text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px}\n';
1663
+ css += '.task-detail{margin-top:6px;padding:10px 12px;background:#0A0F1A;border:1px solid #1E2D45;border-radius:4px;font-size:12px;color:#607088;line-height:1.7}\n';
1664
+ css += '.detail-label{font-size:9px;font-weight:700;color:#455570;text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px}\n';
1665
+ css += '.task-children{margin-top:6px;font-size:10px;color:#1A6FEF;font-weight:600}\n';
1666
+ css += '.nav{margin-top:32px;padding-top:16px;border-top:1px solid #1E2D45;font-size:11px;color:#607088}\n';
1667
+ css += '.nav a{color:#1A6FEF;text-decoration:none}\n';
1668
+ css += '.nav a:hover{text-decoration:underline}\n';
1669
+
1670
+ var html = '<!DOCTYPE html>\n<html lang="ko"><head><meta charset="UTF-8">\n';
1671
+ html += '<meta name="viewport" content="width=device-width, initial-scale=1.0">\n';
1672
+ html += '<title>Archive ' + esc(dateStr) + ' — ' + esc(data.project || "") + '</title>\n';
1673
+ html += '<style>' + css + '</style></head><body>\n';
1674
+ html += '<div class="wrap">\n';
1675
+ html += '<h1>' + esc(dateStr) + '</h1>\n';
1676
+ html += '<div class="subtitle">' + esc(data.project || "") + ' archive &middot; ' + fmtDateTime(data.archivedAt) + '</div>\n';
1677
+ html += '<div class="stats-bar"><span><strong>' + tasks.length + '</strong> tasks</span>';
1678
+ if (agentList) html += '<span>agents: ' + esc(agentList) + '</span>';
1679
+ html += '</div>\n';
1680
+ html += body;
1681
+ html += '<div class="nav"><a href="../index.html">&larr; All archives</a></div>\n';
1682
+ html += '</div></body></html>';
1683
+
1684
+ fs.writeFileSync(path.join(archiveDir, dateStr + ".html"), html);
1685
+ }
1686
+
1687
+ function generateArchiveIndex(archiveDir, dirName, projectId) {
1688
+ // Ensure per-date HTML exists for all JSONs in this project
1689
+ try {
1690
+ var jsonFiles = fs.readdirSync(archiveDir).filter(function(f) { return f.endsWith(".json"); });
1691
+ jsonFiles.forEach(function(f) {
1692
+ var htmlF = f.replace(".json", ".html");
1693
+ if (!fs.existsSync(path.join(archiveDir, htmlF))) {
1694
+ try {
1695
+ var d = JSON.parse(fs.readFileSync(path.join(archiveDir, f), "utf-8"));
1696
+ generateArchiveHTML(d, archiveDir);
1697
+ } catch {}
1698
+ }
1699
+ });
1700
+ } catch {}
1701
+ generateUnifiedArchiveIndex();
1702
+ }
1703
+
1704
+ function generateUnifiedArchiveIndex() {
1705
+ var projects = readProjects();
1706
+ function esc(s) { return String(s || "").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); }
1707
+
1708
+ var projectData = [];
1709
+ projects.forEach(function(p) {
1710
+ var dirName = p.dir || p.id;
1711
+ var archDir = path.join(ARCHIVES_DIR, dirName);
1712
+ var entries = [];
1713
+ if (fs.existsSync(archDir)) {
1714
+ try {
1715
+ var files = fs.readdirSync(archDir).filter(function(f) { return f.endsWith(".json"); }).sort().reverse();
1716
+ entries = files.map(function(f) {
1717
+ try {
1718
+ var data = JSON.parse(fs.readFileSync(path.join(archDir, f), "utf-8"));
1719
+ return { date: data.date || f.replace(".json",""), count: (data.tasks||[]).length, agents: (data.stats&&data.stats.agents)||[], dir: dirName };
1720
+ } catch { return { date: f.replace(".json",""), count: 0, agents: [], dir: dirName }; }
1721
+ });
1722
+ } catch {}
1723
+ }
1724
+ projectData.push({ id: p.id, name: p.name, color: p.color || "#3B82F6", dir: dirName, entries: entries });
1725
+ });
1726
+
1727
+ var totalDays = projectData.reduce(function(s, p) { return s + p.entries.length; }, 0);
1728
+
1729
+ var css = '*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}\n';
1730
+ css += 'body{font-family:-apple-system,BlinkMacSystemFont,"Pretendard Variable",sans-serif;background:#050A12;color:#DDE5F0;font-size:13px;line-height:1.6}\n';
1731
+ css += '.wrap{max-width:900px;margin:0 auto;padding:32px 24px 60px}\n';
1732
+ css += 'h1{font-size:22px;font-weight:700;margin-bottom:4px;color:#fff}\n';
1733
+ css += '.subtitle{font-size:12px;color:#607088;margin-bottom:20px}\n';
1734
+ css += '.tabs{display:flex;gap:0;border-bottom:1px solid #1E2D45;margin-bottom:24px}\n';
1735
+ css += '.tab{padding:10px 20px;cursor:pointer;font-size:13px;font-weight:600;color:#607088;border:none;background:none;font-family:inherit;border-bottom:2px solid transparent;transition:all .15s}\n';
1736
+ css += '.tab:hover{color:#8899B0}\n';
1737
+ css += '.tab.active{color:#DDE5F0;border-bottom-color:var(--tc,#1A6FEF)}\n';
1738
+ css += '.tab .dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;vertical-align:middle}\n';
1739
+ css += '.tab .cnt{font-size:10px;color:#455570;margin-left:4px;font-weight:400;font-family:monospace}\n';
1740
+ css += '.panel{display:none}\n';
1741
+ css += '.panel.active{display:block}\n';
1742
+ css += '.empty-msg{text-align:center;padding:40px;color:#455570;font-size:12px}\n';
1743
+ css += 'table{width:100%;border-collapse:collapse}\n';
1744
+ css += 'th{text-align:left;padding:10px 14px;background:#0B1220;border:1px solid #1E2D45;font-size:10px;font-weight:600;color:#607088;text-transform:uppercase;letter-spacing:.5px}\n';
1745
+ css += 'td{padding:10px 14px;border:1px solid #1E2D45;font-size:13px}\n';
1746
+ css += 'tr:hover td{background:#0B1220}\n';
1747
+ css += 'a{color:#1A6FEF;text-decoration:none}\n';
1748
+ css += 'a:hover{text-decoration:underline}\n';
1749
+ css += '.agent-tags{display:flex;gap:4px;flex-wrap:wrap}\n';
1750
+ css += '.agent-tag{font-size:10px;padding:1px 6px;background:#162035;border-radius:3px;color:#8899B0}\n';
1751
+
1752
+ var tabsHtml = '';
1753
+ var panelsHtml = '';
1754
+ projectData.forEach(function(p, i) {
1755
+ var activeClass = i === 0 ? " active" : "";
1756
+ tabsHtml += '<button class="tab' + activeClass + '" style="--tc:' + esc(p.color) + '" onclick="switchTab(\'' + esc(p.id) + '\')">';
1757
+ tabsHtml += '<span class="dot" style="background:' + esc(p.color) + '"></span>';
1758
+ tabsHtml += esc(p.name);
1759
+ tabsHtml += '<span class="cnt">' + p.entries.length + '</span>';
1760
+ tabsHtml += '</button>';
1761
+
1762
+ panelsHtml += '<div class="panel' + activeClass + '" data-project="' + esc(p.id) + '">';
1763
+ if (p.entries.length === 0) {
1764
+ panelsHtml += '<div class="empty-msg">No archives yet</div>';
1765
+ } else {
1766
+ panelsHtml += '<table><thead><tr><th>Date</th><th>Tasks</th><th>Agents</th></tr></thead><tbody>';
1767
+ p.entries.forEach(function(e) {
1768
+ panelsHtml += '<tr>';
1769
+ panelsHtml += '<td><a href="' + esc(e.dir) + '/' + esc(e.date) + '.html">' + esc(e.date) + '</a></td>';
1770
+ panelsHtml += '<td>' + e.count + '</td>';
1771
+ panelsHtml += '<td><div class="agent-tags">';
1772
+ e.agents.forEach(function(a) { panelsHtml += '<span class="agent-tag">' + esc(a) + '</span>'; });
1773
+ panelsHtml += '</div></td>';
1774
+ panelsHtml += '</tr>';
1775
+ });
1776
+ panelsHtml += '</tbody></table>';
1777
+ }
1778
+ panelsHtml += '</div>';
1779
+ });
1780
+
1781
+ var js = 'function switchTab(id){';
1782
+ js += 'document.querySelectorAll(".tab").forEach(function(t){t.classList.remove("active")});';
1783
+ js += 'document.querySelectorAll(".panel").forEach(function(p){p.classList.remove("active")});';
1784
+ js += 'event.currentTarget.classList.add("active");';
1785
+ js += 'document.querySelector(\'[data-project="\'+id+\'"]\').classList.add("active");';
1786
+ js += '}';
1787
+
1788
+ var html = '<!DOCTYPE html>\n<html lang="ko"><head><meta charset="UTF-8">\n';
1789
+ html += '<meta name="viewport" content="width=device-width, initial-scale=1.0">\n';
1790
+ html += '<title>Archives</title>\n';
1791
+ html += '<style>' + css + '</style></head><body>\n';
1792
+ html += '<div class="wrap">\n';
1793
+ html += '<h1>Archives</h1>\n';
1794
+ html += '<div class="subtitle">' + projects.length + ' projects &middot; ' + totalDays + ' days</div>\n';
1795
+ html += '<div class="tabs">' + tabsHtml + '</div>\n';
1796
+ html += panelsHtml;
1797
+ html += '</div>\n';
1798
+ html += '<script>' + js + '</script>\n';
1799
+ html += '</body></html>';
1800
+
1801
+ fs.writeFileSync(path.join(ARCHIVES_DIR, "index.html"), html);
1802
+
1803
+ // Per-project index.html → redirect to unified
1804
+ projectData.forEach(function(p) {
1805
+ var perDir = path.join(ARCHIVES_DIR, p.dir);
1806
+ if (fs.existsSync(perDir)) {
1807
+ fs.writeFileSync(path.join(perDir, "index.html"),
1808
+ '<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0;url=../index.html"></head><body><a href="../index.html">Archives</a></body></html>');
1809
+ }
1810
+ });
1811
+ }
1812
+
1510
1813
  // ── Request body parser ──
1511
1814
  function parseBody(req) {
1512
1815
  return new Promise((resolve, reject) => {
@@ -1601,94 +1904,6 @@ const server = http.createServer(async (req, res) => {
1601
1904
  return;
1602
1905
  }
1603
1906
 
1604
- // GET /api/metrics — progress metrics
1605
- if (req.url.split("?")[0] === "/api/metrics" && req.method === "GET") {
1606
- var metricsParams = new URL(req.url, "http://localhost").searchParams;
1607
- var mProject = metricsParams.get("project") || null;
1608
- var allT = readAllTasks(mProject);
1609
- var total = allT.length, pending = 0, active = 0, review = 0, completed = 0, blocked = 0, stale = 0;
1610
- var byAgent = {}, byPriority = { high: 0, medium: 0, low: 0 };
1611
- var dailyCompleted = {}; // date -> count
1612
- var dailyCreated = {};
1613
- allT.forEach(function (t) {
1614
- var st = t.status || "pending";
1615
- if (st === "pending") pending++;
1616
- else if (st === "in_progress") active++;
1617
- else if (st === "in_review") review++;
1618
- else if (st === "completed") completed++;
1619
- if (t.blockedBy && t.blockedBy.length > 0 && st !== "completed") blocked++;
1620
- if (t._stale) stale++;
1621
- var ag = t.agent || "unassigned";
1622
- if (!byAgent[ag]) byAgent[ag] = { total: 0, completed: 0, in_progress: 0, pending: 0 };
1623
- byAgent[ag].total++;
1624
- byAgent[ag][st] = (byAgent[ag][st] || 0) + 1;
1625
- byPriority[t.priority || "medium"]++;
1626
- if (t.completedAt) {
1627
- var d = t.completedAt.slice(0, 10);
1628
- dailyCompleted[d] = (dailyCompleted[d] || 0) + 1;
1629
- }
1630
- if (t.createdAt) {
1631
- var d2 = t.createdAt.slice(0, 10);
1632
- dailyCreated[d2] = (dailyCreated[d2] || 0) + 1;
1633
- }
1634
- });
1635
- // Build burndown: remaining tasks over time
1636
- var burndown = [];
1637
- var dates = Object.keys(Object.assign({}, dailyCompleted, dailyCreated)).sort();
1638
- if (dates.length > 0) {
1639
- var remaining = total;
1640
- for (var di = dates.length - 1; di >= 0; di--) {
1641
- burndown.unshift({ date: dates[di], remaining: remaining });
1642
- remaining = remaining + (dailyCompleted[dates[di]] || 0) - (dailyCreated[dates[di]] || 0);
1643
- }
1644
- }
1645
- res.writeHead(200, { "Content-Type": "application/json" });
1646
- res.end(JSON.stringify({
1647
- total: total, pending: pending, active: active, review: review, completed: completed,
1648
- blocked: blocked, stale: stale,
1649
- completionRate: total > 0 ? Math.round(completed / total * 100) : 0,
1650
- byAgent: byAgent, byPriority: byPriority,
1651
- dailyCompleted: dailyCompleted, dailyCreated: dailyCreated,
1652
- burndown: burndown
1653
- }));
1654
- return;
1655
- }
1656
-
1657
- // GET /api/agents/status — agent tracker + zombie info
1658
- if (req.url === "/api/agents/status" && req.method === "GET") {
1659
- var agents = {};
1660
- var allT = readAllTasks();
1661
- // Collect agent stats
1662
- allT.forEach(function(t) {
1663
- var a = t.agent || "unassigned";
1664
- if (!agents[a]) agents[a] = { total: 0, completed: 0, in_progress: 0, pending: 0, in_review: 0 };
1665
- agents[a].total++;
1666
- agents[a][t.status] = (agents[a][t.status] || 0) + 1;
1667
- });
1668
- // Merge live tracking
1669
- var tracked = {};
1670
- Object.keys(agentTracker).forEach(function(tid) {
1671
- var info = agentTracker[tid];
1672
- tracked[tid] = { pid: info.pid, alive: isProcessAlive(info.pid), startedAt: info.startedAt, lastActivity: info.lastActivity, agent: info.agent, subject: info.subject };
1673
- });
1674
- res.writeHead(200, { "Content-Type": "application/json" });
1675
- res.end(JSON.stringify({ agents: agents, tracked: tracked, activeExec: activeExec ? { taskId: activeExec.taskId } : null }));
1676
- return;
1677
- }
1678
-
1679
- // POST /api/tasks/:id/recover — recover zombie task
1680
- var recoverMatch = req.url.match(/^\/api\/tasks\/(\d+)\/recover$/);
1681
- if (recoverMatch && req.method === "POST") {
1682
- var rid = recoverMatch[1];
1683
- untrackAgent(rid);
1684
- updateTask(rid, { status: "pending", _stale: false, _staleReason: null, _staleAt: null, activeForm: null });
1685
- logActivity({ type: "updated", taskId: rid, subject: "", detail: "Recovered from zombie state" });
1686
- broadcastRaw({ type: "tasks_updated" });
1687
- res.writeHead(200, { "Content-Type": "application/json" });
1688
- res.end(JSON.stringify({ ok: true, taskId: rid }));
1689
- return;
1690
- }
1691
-
1692
1907
  // GET /api/tasks
1693
1908
  if (req.url.split("?")[0] === "/api/tasks" && req.method === "GET") {
1694
1909
  var taskParams = new URL(req.url, "http://localhost").searchParams;
@@ -1770,39 +1985,6 @@ const server = http.createServer(async (req, res) => {
1770
1985
  return;
1771
1986
  }
1772
1987
 
1773
- // GET /api/tasks/:id/comments — list comments
1774
- var commentsGetMatch = req.url.match(/^\/api\/tasks\/(\d+)\/comments$/);
1775
- if (commentsGetMatch && req.method === "GET") {
1776
- var cid = commentsGetMatch[1];
1777
- var ct = readTask(cid);
1778
- res.writeHead(200, { "Content-Type": "application/json" });
1779
- res.end(JSON.stringify(ct && ct.comments ? ct.comments : []));
1780
- return;
1781
- }
1782
-
1783
- // POST /api/tasks/:id/comments — add comment
1784
- var commentsPostMatch = req.url.match(/^\/api\/tasks\/(\d+)\/comments$/);
1785
- if (commentsPostMatch && req.method === "POST") {
1786
- var cid2 = commentsPostMatch[1];
1787
- collectBody(req, function (body) {
1788
- try {
1789
- var cdata = JSON.parse(body);
1790
- var filePath = findTaskFile(cid2);
1791
- if (!filePath) { res.writeHead(404); res.end(JSON.stringify({ error: "not found" })); return; }
1792
- var task = JSON.parse(fs.readFileSync(filePath, "utf-8"));
1793
- if (!task.comments) task.comments = [];
1794
- var comment = { author: cdata.author || "user", text: cdata.text || "", createdAt: new Date().toISOString() };
1795
- task.comments.push(comment);
1796
- fs.writeFileSync(filePath, JSON.stringify(task, null, 2));
1797
- broadcastRaw({ type: "tasks_updated" });
1798
- logActivity({ type: "updated", taskId: cid2, subject: task.subject || "", detail: "Comment by " + comment.author });
1799
- res.writeHead(200, { "Content-Type": "application/json" });
1800
- res.end(JSON.stringify({ ok: true, comment: comment }));
1801
- } catch (e) { res.writeHead(400); res.end(JSON.stringify({ error: String(e) })); }
1802
- });
1803
- return;
1804
- }
1805
-
1806
1988
  // POST /api/tasks/:id/slack — Agent progress reporting via thread
1807
1989
  const slackMatch = req.url.match(/^\/api\/tasks\/([\w.-]+)\/slack$/);
1808
1990
  if (slackMatch && req.method === "POST") {
@@ -1844,6 +2026,71 @@ const server = http.createServer(async (req, res) => {
1844
2026
  return;
1845
2027
  }
1846
2028
 
2029
+ // POST /api/archive
2030
+ if (req.url === "/api/archive" && req.method === "POST") {
2031
+ try {
2032
+ var archBody = await parseBody(req);
2033
+ var archProject = archBody.project || (readProjects()[0] || {}).id || "apex";
2034
+ var result = archiveTasks(archProject, archBody.taskIds || null);
2035
+ res.writeHead(200, { "Content-Type": "application/json" });
2036
+ res.end(JSON.stringify(result));
2037
+ } catch (e) {
2038
+ res.writeHead(400, { "Content-Type": "application/json" });
2039
+ res.end(JSON.stringify({ error: e.message }));
2040
+ }
2041
+ return;
2042
+ }
2043
+
2044
+ // GET /api/archives?project=<id>
2045
+ if (req.url.split("?")[0] === "/api/archives" && !req.url.match(/\/api\/archives\//) && req.method === "GET") {
2046
+ var archParams = new URL(req.url, "http://localhost").searchParams;
2047
+ var archProjId = archParams.get("project") || (readProjects()[0] || {}).id || "apex";
2048
+ var archProjects = readProjects();
2049
+ var archProj = archProjects.find(function(p) { return p.id === archProjId; });
2050
+ var archDirName = archProj ? (archProj.dir || archProj.id) : archProjId;
2051
+ var archDir = path.join(ARCHIVES_DIR, archDirName);
2052
+ var archList = [];
2053
+ if (fs.existsSync(archDir)) {
2054
+ try {
2055
+ var archFiles = fs.readdirSync(archDir).filter(function(f) { return f.endsWith(".json"); }).sort().reverse();
2056
+ archList = archFiles.map(function(f) {
2057
+ try {
2058
+ var data = JSON.parse(fs.readFileSync(path.join(archDir, f), "utf-8"));
2059
+ return { date: data.date, count: (data.tasks || []).length, agents: (data.stats || {}).agents || [] };
2060
+ } catch { return { date: f.replace(".json", ""), count: 0, agents: [] }; }
2061
+ });
2062
+ } catch {}
2063
+ }
2064
+ res.writeHead(200, { "Content-Type": "application/json" });
2065
+ res.end(JSON.stringify(archList));
2066
+ return;
2067
+ }
2068
+
2069
+ // GET /api/archives/:date?project=<id>
2070
+ var archDateMatch = req.url.match(/^\/api\/archives\/([\d-]+)/);
2071
+ if (archDateMatch && req.method === "GET") {
2072
+ var archParams2 = new URL(req.url, "http://localhost").searchParams;
2073
+ var archProjId2 = archParams2.get("project") || (readProjects()[0] || {}).id || "apex";
2074
+ var archProjects2 = readProjects();
2075
+ var archProj2 = archProjects2.find(function(p) { return p.id === archProjId2; });
2076
+ var archDirName2 = archProj2 ? (archProj2.dir || archProj2.id) : archProjId2;
2077
+ var archFile2 = path.join(ARCHIVES_DIR, archDirName2, archDateMatch[1] + ".json");
2078
+ if (!fs.existsSync(archFile2)) {
2079
+ res.writeHead(404, { "Content-Type": "application/json" });
2080
+ res.end('{"error":"Archive not found"}');
2081
+ return;
2082
+ }
2083
+ try {
2084
+ var archData = JSON.parse(fs.readFileSync(archFile2, "utf-8"));
2085
+ res.writeHead(200, { "Content-Type": "application/json" });
2086
+ res.end(JSON.stringify(archData));
2087
+ } catch (e) {
2088
+ res.writeHead(500, { "Content-Type": "application/json" });
2089
+ res.end(JSON.stringify({ error: e.message }));
2090
+ }
2091
+ return;
2092
+ }
2093
+
1847
2094
  // DELETE /api/tasks/:id
1848
2095
  const delMatch = req.url.match(/^\/api\/tasks\/([\w.-]+)$/);
1849
2096
  if (delMatch && req.method === "DELETE") {
@@ -1882,19 +2129,58 @@ const server = http.createServer(async (req, res) => {
1882
2129
  }
1883
2130
 
1884
2131
  // GET /api/agents — list all agents with prompts
1885
- if (req.url === "/api/agents" && req.method === "GET") {
1886
- var agentsDir = path.join(__dirname, "..", ".claude", "agents");
2132
+ // Supports ?project=<id> to return project-specific agents
2133
+ if (req.url.split("?")[0] === "/api/agents" && req.method === "GET") {
2134
+ var qs = (req.url.split("?")[1] || "");
2135
+ var projectFilter = null;
2136
+ qs.split("&").forEach(function(kv) {
2137
+ var parts = kv.split("=");
2138
+ if (parts[0] === "project") projectFilter = decodeURIComponent(parts[1]);
2139
+ });
2140
+
1887
2141
  var agentList = [];
1888
- try {
1889
- var files = fs.readdirSync(agentsDir).filter(function (f) { return f.endsWith(".md") && !f.startsWith("_"); });
1890
- files.forEach(function (f) {
1891
- var name = f.replace(".md", "");
1892
- var content = "";
1893
- try { content = fs.readFileSync(path.join(agentsDir, f), "utf8"); } catch (e) {}
1894
- var model = getModelForAgent(resolveAgentName(name) || name);
1895
- agentList.push({ name: name, model: model, prompt: content });
1896
- });
1897
- } catch (e) {}
2142
+
2143
+ // Check if the filtered project has an agentsSource (e.g. pipeline.json)
2144
+ var projectHasSource = false;
2145
+ if (projectFilter) {
2146
+ var prjs = readProjects();
2147
+ var prj = prjs.find(function(p) { return p.id === projectFilter; });
2148
+ if (prj && prj.agentsSource) {
2149
+ projectHasSource = true;
2150
+ try {
2151
+ var pipeData = JSON.parse(fs.readFileSync(prj.agentsSource, "utf8"));
2152
+ var pipelineArr = pipeData.pipeline || pipeData.agents || [];
2153
+ pipelineArr.forEach(function(agent) {
2154
+ agentList.push({
2155
+ name: agent.id || agent.name,
2156
+ model: agent.model || "sonnet",
2157
+ prompt: agent.prompt || "",
2158
+ role: agent.role || "",
2159
+ order: agent.order || 0,
2160
+ project: projectFilter
2161
+ });
2162
+ });
2163
+ // Sort by order
2164
+ agentList.sort(function(a, b) { return (a.order || 0) - (b.order || 0); });
2165
+ } catch (e) { /* pipeline.json read error */ }
2166
+ }
2167
+ }
2168
+
2169
+ // Default: read from .claude/agents/*.md
2170
+ if (!projectHasSource) {
2171
+ var agentsDir = path.join(__dirname, "..", ".claude", "agents");
2172
+ try {
2173
+ var files = fs.readdirSync(agentsDir).filter(function (f) { return f.endsWith(".md") && !f.startsWith("_"); });
2174
+ files.forEach(function (f) {
2175
+ var name = f.replace(".md", "");
2176
+ var content = "";
2177
+ try { content = fs.readFileSync(path.join(agentsDir, f), "utf8"); } catch (e) {}
2178
+ var model = getModelForAgent(resolveAgentName(name) || name);
2179
+ agentList.push({ name: name, model: model, prompt: content });
2180
+ });
2181
+ } catch (e) {}
2182
+ }
2183
+
1898
2184
  // Also include project context
1899
2185
  var projectCtx = loadProjectContext();
1900
2186
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -1995,7 +2281,7 @@ const server = http.createServer(async (req, res) => {
1995
2281
  var chatBody = await parseBody(req);
1996
2282
  var chatMessage = chatBody.message || "";
1997
2283
  var chatModel = chatBody.model || "sonnet";
1998
- var chatProject = chatBody.project || readProjects()[0].id || "default";
2284
+ var chatProject = chatBody.project || readProjects()[0].id || "apex";
1999
2285
  var chatHistory = chatBody.history || []; // [{role,content}, ...]
2000
2286
 
2001
2287
  var tasks = readAllTasks(chatProject);
@@ -2006,13 +2292,14 @@ const server = http.createServer(async (req, res) => {
2006
2292
  var chatArgs = ["-p", "--verbose", "--output-format", "stream-json",
2007
2293
  "--model", chatModel, "--dangerously-skip-permissions"];
2008
2294
 
2009
- // Session continuity: resume if same model, otherwise start fresh
2010
- if (chatSessionId && chatSessionModel === chatModel) {
2295
+ // Session continuity: resume if same model AND same project, otherwise start fresh
2296
+ if (chatSessionId && chatSessionModel === chatModel && chatSessionProject === chatProject) {
2011
2297
  chatArgs.push("--resume", chatSessionId);
2012
2298
  } else {
2013
- // Don't use --no-session-persistencelet CLI persist session so --resume works next time
2299
+ // Project or model changed start fresh session so new CLAUDE.md context is loaded
2014
2300
  chatSessionId = null;
2015
2301
  chatSessionModel = chatModel;
2302
+ chatSessionProject = chatProject;
2016
2303
  }
2017
2304
 
2018
2305
  // Build conversation history string for new sessions
@@ -2040,8 +2327,9 @@ const server = http.createServer(async (req, res) => {
2040
2327
 
2041
2328
  var chatEnv = Object.assign({}, process.env);
2042
2329
  delete chatEnv.ANTHROPIC_API_KEY; // Max 구독 OAuth 사용
2330
+ var chatCwd = getProjectSourceDir(chatProject) || path.join(__dirname, "..");
2043
2331
  var chatProc = spawn("claude", chatArgs, {
2044
- cwd: path.join(__dirname, ".."),
2332
+ cwd: chatCwd,
2045
2333
  env: chatEnv,
2046
2334
  stdio: ["pipe", "pipe", "pipe"],
2047
2335
  });
@@ -2286,6 +2574,7 @@ server.listen(PORT, () => {
2286
2574
  console.log(" N : new task");
2287
2575
  console.log(" C : chat panel");
2288
2576
  console.log(" A : activity panel");
2577
+ console.log(" R : archive panel");
2289
2578
  console.log(" Drag : move between columns");
2290
2579
  console.log(" Ctrl+Shift+K : stop execution");
2291
2580
  console.log(" Ctrl+C : quit");