@polderlabs/openkan 0.4.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.
Files changed (114) hide show
  1. package/CHANGELOG.md +226 -0
  2. package/LICENSE +21 -0
  3. package/README.md +318 -0
  4. package/agents/openkan.md +254 -0
  5. package/bin/install-agent.mjs +63 -0
  6. package/bin/ok.mjs +17 -0
  7. package/bin/openkan.mjs +10 -0
  8. package/dist/.claude/skills/ok-planning/SKILL.md +285 -0
  9. package/dist/.claude/skills/ok-planning/references/integration.md +153 -0
  10. package/dist/.claude/skills/ok-planning/references/schemas.md +270 -0
  11. package/dist/.claude/skills/ok-planning/references/workflows.md +185 -0
  12. package/dist/.claude/skills/ok-planning/scripts/ok-init.sh +14 -0
  13. package/dist/.claude/skills/ok-planning/scripts/ok-resume.sh +38 -0
  14. package/dist/.claude/skills/ok-planning/scripts/ok-status.sh +24 -0
  15. package/dist/agents/openkan.md +254 -0
  16. package/dist/bin/install-agent.mjs +76 -0
  17. package/dist/bin/ok-install.js +58 -0
  18. package/dist/bin/ok.js +138 -0
  19. package/dist/bin/openkan.js +804 -0
  20. package/dist/commands/organize.md +15 -0
  21. package/dist/kanban/agent-profile.js +8 -0
  22. package/dist/kanban/archive.js +49 -0
  23. package/dist/kanban/bizar.js +242 -0
  24. package/dist/kanban/board.js +367 -0
  25. package/dist/kanban/bulk.js +139 -0
  26. package/dist/kanban/changelog.js +186 -0
  27. package/dist/kanban/chat.js +1280 -0
  28. package/dist/kanban/claude-state.js +974 -0
  29. package/dist/kanban/comments.js +80 -0
  30. package/dist/kanban/docs.js +144 -0
  31. package/dist/kanban/fs.js +163 -0
  32. package/dist/kanban/git.js +196 -0
  33. package/dist/kanban/images.js +140 -0
  34. package/dist/kanban/import.js +295 -0
  35. package/dist/kanban/inputs.js +94 -0
  36. package/dist/kanban/insights.js +140 -0
  37. package/dist/kanban/io.js +75 -0
  38. package/dist/kanban/mdx-render.js +348 -0
  39. package/dist/kanban/mdx.js +231 -0
  40. package/dist/kanban/projects.js +545 -0
  41. package/dist/kanban/search.js +121 -0
  42. package/dist/kanban/server.js +3296 -0
  43. package/dist/kanban/tags.js +124 -0
  44. package/dist/kanban/template.js +145 -0
  45. package/dist/kanban/tsx-sandbox.js +187 -0
  46. package/dist/kanban/watcher.js +270 -0
  47. package/dist/ok/commands/goal.js +65 -0
  48. package/dist/ok/commands/index.js +87 -0
  49. package/dist/ok/commands/init.js +15 -0
  50. package/dist/ok/commands/plan.js +155 -0
  51. package/dist/ok/commands/prd.js +202 -0
  52. package/dist/ok/commands/progress.js +31 -0
  53. package/dist/ok/commands/task.js +377 -0
  54. package/dist/ok/ids.js +98 -0
  55. package/dist/ok/lock.js +156 -0
  56. package/dist/ok/migrate.js +197 -0
  57. package/dist/ok/schemas.js +402 -0
  58. package/dist/ok/storage.js +222 -0
  59. package/dist/skills/openkan/SKILL.md +111 -0
  60. package/dist/skills/openkan/agents/openai.yaml +4 -0
  61. package/dist/skills/openkan/examples/simple-task.mdx +34 -0
  62. package/dist/skills/openkan/examples/with-ask.mdx +32 -0
  63. package/dist/skills/openkan/examples/with-choice.mdx +51 -0
  64. package/dist/skills/openkan/examples/with-preview.mdx +54 -0
  65. package/dist/skills/openkan/references/api.md +169 -0
  66. package/dist/skills/openkan/templates/task.mdx +46 -0
  67. package/dist/web/api.js +257 -0
  68. package/dist/web/app.js +4251 -0
  69. package/dist/web/bizar.js +39 -0
  70. package/dist/web/brand/agent-activity-sprite.svg +1 -0
  71. package/dist/web/brand/banner-docs.svg +24 -0
  72. package/dist/web/brand/banner.svg +32 -0
  73. package/dist/web/brand/empty-sessions.svg +17 -0
  74. package/dist/web/brand/empty-tasks.svg +17 -0
  75. package/dist/web/brand/favicon.svg +9 -0
  76. package/dist/web/brand/infinity-loader-animated.svg +220 -0
  77. package/dist/web/brand/infinity-loader-spritesheet.svg +230 -0
  78. package/dist/web/brand/logo-wordmark.svg +10 -0
  79. package/dist/web/brand/logo.svg +9 -0
  80. package/dist/web/brand/pixel-infinity-track.svg +1 -0
  81. package/dist/web/brand/social-card.svg +26 -0
  82. package/dist/web/changelog-view.js +456 -0
  83. package/dist/web/charts.js +269 -0
  84. package/dist/web/chat-sidebar.js +2397 -0
  85. package/dist/web/chat-status-motion.js +154 -0
  86. package/dist/web/claude-pane.js +820 -0
  87. package/dist/web/command-palette.js +381 -0
  88. package/dist/web/contributors-view.js +317 -0
  89. package/dist/web/cross-tab.js +102 -0
  90. package/dist/web/docs-view.js +168 -0
  91. package/dist/web/experience.css +165 -0
  92. package/dist/web/goals-view.js +45 -0
  93. package/dist/web/home-view.js +113 -0
  94. package/dist/web/images.js +311 -0
  95. package/dist/web/index.html +485 -0
  96. package/dist/web/insights.js +217 -0
  97. package/dist/web/keyboard.js +446 -0
  98. package/dist/web/mdx-viewer.js +600 -0
  99. package/dist/web/path-picker.js +787 -0
  100. package/dist/web/preview-frame.html +187 -0
  101. package/dist/web/settings.js +582 -0
  102. package/dist/web/style.css +8545 -0
  103. package/dist/web/task-view.js +1759 -0
  104. package/dist/web/vendor/gsap.min.js +11 -0
  105. package/dist/web/workspace.css +1513 -0
  106. package/package.json +71 -0
  107. package/skills/openkan/SKILL.md +111 -0
  108. package/skills/openkan/agents/openai.yaml +4 -0
  109. package/skills/openkan/examples/simple-task.mdx +34 -0
  110. package/skills/openkan/examples/with-ask.mdx +32 -0
  111. package/skills/openkan/examples/with-choice.mdx +51 -0
  112. package/skills/openkan/examples/with-preview.mdx +54 -0
  113. package/skills/openkan/references/api.md +169 -0
  114. package/skills/openkan/templates/task.mdx +46 -0
@@ -0,0 +1,3296 @@
1
+ // OpenKan — HTTP API server.
2
+ import { readFileSync, existsSync, statSync, writeFileSync, unlinkSync, mkdirSync, rmSync, cpSync } from "fs";
3
+ import { createHash } from "node:crypto";
4
+ import { createServer } from "node:http";
5
+ import { Readable } from "node:stream";
6
+ import { join, extname, resolve } from "path";
7
+ import { openSync, closeSync } from "node:fs";
8
+ import { spawnSync } from "node:child_process";
9
+ import { DEFAULT_COLUMNS, withWrite, getBoard, renormalizeOrder, newId, nowIso, KANBAN_DIR, taskArtifacts, ensureBoardForProject, reconcileOkTask, } from "./board.js";
10
+ import { extractMetadata } from "./tags.js";
11
+ import { writeTaskMdx, writeBoardMdx, writeSessionMdx, extractDescription, statMdxMtime, } from "./mdx.js";
12
+ import { listInputs, getPendingInput, addInput, respondInput } from "./inputs.js";
13
+ import { listComments, addComment, deleteComment, resolveComment } from "./comments.js";
14
+ import { renderMdx, stripMdxFrontmatter } from "./mdx-render.js";
15
+ import { buildPreview } from "./tsx-sandbox.js";
16
+ import { writeFileAtomic, ensureDir, removeDir } from "./io.js";
17
+ import { recordEvent, readEvents, readSummary } from "./changelog.js";
18
+ import { computeVelocity } from "./insights.js";
19
+ import { listContributors, attributeCommitsToTasks, isGitRepo } from "./git.js";
20
+ import { archiveTask, restoreTask } from "./archive.js";
21
+ import { listProjects, activeProject, setActiveProject, addProject, removeProject, getActiveProjectRoot, autoDetectProjects, findProject, resolveKanbanDir, } from "./projects.js";
22
+ import { listDocs, readDoc } from "./docs.js";
23
+ import { saveImage, listImages, deleteImage, readImage } from "./images.js";
24
+ import { search } from "./search.js";
25
+ import { applyBulk } from "./bulk.js";
26
+ import { TASK_MDX_TEMPLATE, TEMPLATE_PARSE_HINTS } from "./template.js";
27
+ import { watch, sourcePathOfTask } from "./watcher.js";
28
+ import { listFs, readHome, parents, isDenyListed, realPathIfAllowed } from "./fs.js";
29
+ import { attachBizarWebSocket, executeBizarCommand, getBizarSnapshot, handleBizarRequest, } from "./bizar.js";
30
+ import * as claudeState from "./claude-state.js";
31
+ import { handleClaudeRequest } from "./claude-state.js";
32
+ import { validateAgentsConfig, } from "../ok/schemas.js";
33
+ import { handleChatRequest, sendTurn } from "./chat.js";
34
+ import { initIfMissing as initOkIfMissing, listPrds as listOkPrds, readPrd as readOkPrd, writePrd as writeOkPrd, rebuildIndex as rebuildOkIndex } from "../ok/storage.js";
35
+ import { WebSocketServer, WebSocket } from "ws";
36
+ import { runImport } from "./import.js";
37
+ // ─── Module-level server state ────────────────────────────────────────────────
38
+ let watcherHandle = null;
39
+ /** Timestamp up to which filesystem-change events should be suppressed (self-write guard). */
40
+ let selfWriteUntil = 0;
41
+ // ─── Self-write guard ─────────────────────────────────────────────────────────
42
+ /**
43
+ * Wrap a server-initiated write so the file-watcher ignores the resulting
44
+ * fs-watch events. The suppression window is 250 ms which covers the
45
+ * synchronous fs.watch delivery on Linux/macOS and any microtask deferral.
46
+ */
47
+ async function suppressSelfWrite(fn) {
48
+ selfWriteUntil = Date.now() + 250;
49
+ try {
50
+ return await fn();
51
+ }
52
+ finally { /* keep flag active for 250 ms to flush queued watch events */ }
53
+ }
54
+ let runningServer = null;
55
+ let webRoot = null;
56
+ let bizarSocketBridge = null;
57
+ let claudeSocketBridge = null;
58
+ /**
59
+ * Read the current git user from local config (same logic as git.ts currentUser
60
+ * but inlined here so server.ts stays self-contained; kanban/git.ts is the
61
+ * canonical source of truth).
62
+ */
63
+ function currentGitUser(cwd) {
64
+ try {
65
+ // Without `--local` / `--global` flags, `git config` reads the merged
66
+ // config: local repo wins, then global, then system. This way users with
67
+ // only a global `user.name` (very common on dev machines) are picked up
68
+ // too, instead of falling through to the literal "user" placeholder.
69
+ const name = spawnSync("git", ["config", "user.name"], { cwd, encoding: "utf-8" }).stdout?.trim();
70
+ const email = spawnSync("git", ["config", "user.email"], { cwd, encoding: "utf-8" }).stdout?.trim();
71
+ if (!name)
72
+ return null;
73
+ return { name, email: email ?? "" };
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }
79
+ // ─── Renderer ─────────────────────────────────────────────────────────────────
80
+ const ALLOWED_TAGS = [
81
+ "h1", "h2", "h3", "h4", "h5", "h6", "p", "ul", "ol", "li",
82
+ "code", "pre", "blockquote", "a", "strong", "em", "hr", "br",
83
+ "table", "thead", "tbody", "tr", "th", "td",
84
+ ];
85
+ const ALLOWED_ATTRS = {
86
+ a: ["href", "title"],
87
+ code: ["class"],
88
+ };
89
+ function urlFilter(url) {
90
+ try {
91
+ const u = new URL(url);
92
+ if (u.protocol === "http:" || u.protocol === "https:" ||
93
+ u.protocol === "mailto:" || u.pathname.startsWith("/artifacts/"))
94
+ return true;
95
+ if (u.protocol === "data:")
96
+ return url.startsWith("data:image/");
97
+ return false;
98
+ }
99
+ catch {
100
+ return !url.startsWith("javascript:") && !url.startsWith("data:");
101
+ }
102
+ }
103
+ async function renderMarkdown(raw) {
104
+ const { marked } = await import("marked");
105
+ const html = await marked(stripMdxFrontmatter(raw));
106
+ return html;
107
+ }
108
+ async function renderArtifact(markdownPath, rawFlag, theme) {
109
+ if (!existsSync(markdownPath))
110
+ throw new Error(`Artifact not found: ${markdownPath}`);
111
+ try {
112
+ if (!statSync(markdownPath).isFile())
113
+ throw new Error("not a file");
114
+ }
115
+ catch (e) {
116
+ throw new Error(`Artifact unavailable: ${markdownPath} (${e.message})`);
117
+ }
118
+ if (rawFlag)
119
+ return { body: readFileSync(markdownPath, "utf-8"), contentType: "text/markdown" };
120
+ const raw = readFileSync(markdownPath, "utf-8");
121
+ const html = await renderMarkdown(raw);
122
+ const sanitizeHtml = (await import("sanitize-html")).default;
123
+ const clean = sanitizeHtml(html, {
124
+ allowedTags: ALLOWED_TAGS,
125
+ allowedAttributes: ALLOWED_ATTRS,
126
+ });
127
+ return {
128
+ body: `<!DOCTYPE html>
129
+ <html>
130
+ <head>
131
+ <meta charset="utf-8">
132
+ <meta name="color-scheme" content="light dark">
133
+ <title>Kanban Artifact</title>
134
+ <script>
135
+ // Read parent window theme (same origin only: same 127.0.0.1:7777)
136
+ var theme = "${theme ?? ""}";
137
+ if (!theme && window.parent && window.parent.location) {
138
+ try {
139
+ var stored = window.parent.localStorage.getItem("openkan:theme");
140
+ if (stored) theme = stored;
141
+ } catch(e) { /* cross-origin, ignore */ }
142
+ }
143
+ if (!theme) theme = "system";
144
+ if (theme === "system") {
145
+ theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
146
+ }
147
+ document.documentElement.dataset.theme = theme;
148
+ </script>
149
+ <style>
150
+ :root[data-theme="light"] {
151
+ --text: #111;
152
+ --text-dim: #555;
153
+ --bg: #fff;
154
+ --accent: #2563eb;
155
+ --rule: #e5e5e5;
156
+ --code-bg: #f4f4f4;
157
+ --row-alt: #f9fafb;
158
+ }
159
+ :root[data-theme="dark"] {
160
+ --text: #e5e5e5;
161
+ --text-dim: #9ca3af;
162
+ --bg: #111827;
163
+ --accent: #60a5fa;
164
+ --rule: #374151;
165
+ --code-bg: #1f2937;
166
+ --row-alt: #1f2937;
167
+ }
168
+ body {
169
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
170
+ padding: 2rem;
171
+ max-width: 860px;
172
+ margin: 0 auto;
173
+ line-height: 1.6;
174
+ overflow-y: auto;
175
+ min-height: 100vh;
176
+ color: var(--text);
177
+ background: var(--bg);
178
+ }
179
+ body > :first-child { margin-top: 0; }
180
+ body h1, body h2, body h3, body h4, body h5, body h6 {
181
+ font-weight: 600;
182
+ line-height: 1.25;
183
+ margin-top: 1.5em;
184
+ margin-bottom: 0.5em;
185
+ }
186
+ body h1 { font-size: 1.875rem; border-bottom: 1px solid var(--rule); padding-bottom: 0.3em; }
187
+ body h2 { font-size: 1.5rem; border-bottom: 1px solid var(--rule); padding-bottom: 0.3em; }
188
+ body p { margin: 0 0 1em; }
189
+ body a { color: var(--accent); text-decoration: none; }
190
+ body a:hover { text-decoration: underline; }
191
+ body code { background: var(--code-bg); padding: 0.15em 0.4em; border-radius: 4px; font-size: 0.9em; }
192
+ body pre { background: var(--code-bg); padding: 1rem; border-radius: 8px; overflow-x: auto; line-height: 1.5; }
193
+ body pre code { padding: 0; background: transparent; }
194
+ body blockquote { border-left: 3px solid var(--accent); padding: 0.2em 1em; color: var(--text-dim); font-style: italic; margin: 0 0 1em; }
195
+ body ul, body ol { padding-left: 1.5em; margin: 0 0 1em; }
196
+ body table { border-collapse: collapse; width: 100%; margin: 0 0 1em; }
197
+ body th, body td { border: 1px solid var(--rule); padding: 0.5em 0.75em; text-align: left; }
198
+ body tr:nth-child(odd) { background: var(--row-alt); }
199
+ body img { max-width: 100%; border-radius: 8px; border: 1px solid var(--rule); }
200
+ .back-link { display: inline-block; margin-bottom: 1.5rem; font-size: 0.875rem; }
201
+ </style>
202
+ </head>
203
+ <body>
204
+ <a href="/" class="back-link">← Back to board</a>
205
+ ${clean}
206
+ </body>
207
+ </html>`,
208
+ contentType: "text/html",
209
+ };
210
+ }
211
+ // ─── Session status cache ────────────────────────────────────────────────────
212
+ const sessionStatusCache = new Map();
213
+ // ─── SSE broadcaster ─────────────────────────────────────────────────────────
214
+ const sseControllers = new Set();
215
+ function broadcast(event, data) {
216
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
217
+ for (const ctrl of sseControllers) {
218
+ try {
219
+ ctrl.enqueue(new TextEncoder().encode(payload));
220
+ }
221
+ catch (_) { /* client gone */ }
222
+ }
223
+ }
224
+ // ─── Drift detection ───────────────────────────────────────────────────────────
225
+ /**
226
+ * Returns true if the source file for `task` has changed since import.
227
+ * A missing file is considered stale.
228
+ */
229
+ async function checkSourceDrift(task, kanbanDir) {
230
+ if (!task.source)
231
+ return false;
232
+ const absPath = join(kanbanDir, "..", task.source.path);
233
+ if (!existsSync(absPath))
234
+ return true; // file gone = stale
235
+ let content;
236
+ try {
237
+ content = readFileSync(absPath, "utf-8");
238
+ }
239
+ catch {
240
+ return false;
241
+ }
242
+ const newHash = createHash("sha256").update(content).digest("hex").slice(0, 16);
243
+ return newHash !== task.sourceHash;
244
+ }
245
+ /**
246
+ * Re-check stale status for all tasks that have a source file.
247
+ * Updates the board and broadcasts task.updated events for any changed tasks.
248
+ * Debounced via the `sweepLock` promise chain.
249
+ */
250
+ let _sweepLock = Promise.resolve();
251
+ async function sweepSourceDrift(kanbanDir) {
252
+ _sweepLock = _sweepLock.then(async () => {
253
+ const board = await getBoard();
254
+ const updates = [];
255
+ for (const task of board.tasks) {
256
+ if (!task.source)
257
+ continue;
258
+ const isStale = await checkSourceDrift(task, kanbanDir);
259
+ if (isStale !== task.stale) {
260
+ task.stale = isStale;
261
+ task.lastSourceCheck = nowIso();
262
+ updates.push(task);
263
+ }
264
+ }
265
+ if (updates.length > 0) {
266
+ await withWrite(async (b) => {
267
+ for (const updated of updates) {
268
+ const t = b.tasks.find(t => t.id === updated.id);
269
+ if (t) {
270
+ t.stale = updated.stale;
271
+ t.lastSourceCheck = updated.lastSourceCheck;
272
+ }
273
+ }
274
+ });
275
+ for (const updated of updates) {
276
+ broadcast("task.updated", updated);
277
+ await writeTaskMdx(updated, kanbanDir, await getBoard());
278
+ }
279
+ }
280
+ });
281
+ await _sweepLock;
282
+ }
283
+ // ─── HTTP helpers ─────────────────────────────────────────────────────────────
284
+ async function toRequest(req) {
285
+ const url = `http://${req.headers.host ?? "127.0.0.1"}${req.url ?? "/"}`;
286
+ const method = req.method ?? "GET";
287
+ const headers = new Headers();
288
+ for (const [key, value] of Object.entries(req.headers)) {
289
+ if (Array.isArray(value)) {
290
+ for (const item of value)
291
+ headers.append(key, item);
292
+ }
293
+ else if (value !== undefined)
294
+ headers.set(key, value);
295
+ }
296
+ if (method === "GET" || method === "HEAD")
297
+ return new Request(url, { method, headers });
298
+ const init = {
299
+ method,
300
+ headers,
301
+ body: Readable.toWeb(req),
302
+ duplex: "half",
303
+ };
304
+ return new Request(url, init);
305
+ }
306
+ async function writeResponse(res, response) {
307
+ res.statusCode = response.status;
308
+ response.headers.forEach((value, key) => { res.setHeader(key, value); });
309
+ if (!response.body) {
310
+ res.end();
311
+ return;
312
+ }
313
+ const body = Readable.fromWeb(response.body);
314
+ await new Promise((resolve, reject) => {
315
+ body.on("error", reject);
316
+ res.on("error", reject);
317
+ res.on("finish", resolve);
318
+ body.pipe(res);
319
+ });
320
+ }
321
+ function serveStatic(root, urlPath) {
322
+ const fileName = urlPath.replace(/^\//, "");
323
+ const filePath = join(root, fileName);
324
+ if (filePath !== root && !filePath.startsWith(root + "/"))
325
+ return null;
326
+ if (!existsSync(filePath))
327
+ return null;
328
+ let st;
329
+ try {
330
+ st = statSync(filePath);
331
+ }
332
+ catch {
333
+ return null;
334
+ }
335
+ if (!st.isFile())
336
+ return null;
337
+ const ext = extname(fileName);
338
+ const ctMap = {
339
+ ".html": "text/html", ".css": "text/css", ".js": "application/javascript",
340
+ ".json": "application/json", ".md": "text/markdown",
341
+ ".svg": "image/svg+xml",
342
+ };
343
+ return { body: readFileSync(filePath), contentType: ctMap[ext] ?? "application/octet-stream" };
344
+ }
345
+ function jsonResponse(data, status = 200, extraHeaders) {
346
+ const headers = { "Content-Type": "application/json", ...extraHeaders };
347
+ return new Response(JSON.stringify(data), { status, headers });
348
+ }
349
+ function errorResponse(message, status = 400) {
350
+ return jsonResponse({ error: message }, status);
351
+ }
352
+ function escapeHtml(value) {
353
+ return value.replace(/[&<>'"]/g, (character) => ({
354
+ "&": "&amp;",
355
+ "<": "&lt;",
356
+ ">": "&gt;",
357
+ "'": "&#39;",
358
+ '"': "&quot;",
359
+ })[character] ?? character);
360
+ }
361
+ /** A self-contained browser error page; API clients continue to receive JSON. */
362
+ export function browserErrorPage(status, pathname = "/") {
363
+ const notFound = status === 404;
364
+ const title = notFound ? "Page not found" : "Workspace error";
365
+ const eyebrow = notFound ? "Route unavailable" : "Something went wrong";
366
+ const message = notFound
367
+ ? "This workspace page does not exist, or it may have moved."
368
+ : "OpenKan could not complete this page request. Your project files are still safe.";
369
+ const route = escapeHtml(pathname || "/");
370
+ const body = `<!doctype html>
371
+ <html lang="en">
372
+ <head>
373
+ <meta charset="utf-8">
374
+ <meta name="viewport" content="width=device-width, initial-scale=1">
375
+ <meta name="color-scheme" content="dark">
376
+ <title>${status} — ${title} · OpenKan</title>
377
+ <style>
378
+ :root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
379
+ * { box-sizing: border-box; }
380
+ body { min-height: 100vh; margin: 0; display: grid; place-items: center; overflow: hidden; color: #e8edf7; background: #090d14; }
381
+ body::before, body::after { content: ""; position: fixed; width: 48rem; height: 48rem; border-radius: 50%; pointer-events: none; filter: blur(20px); opacity: .18; }
382
+ body::before { top: -32rem; left: -26rem; background: #6c63ff; }
383
+ body::after { right: -30rem; bottom: -35rem; background: #2fba91; }
384
+ main { position: relative; width: min(42rem, calc(100vw - 2rem)); padding: clamp(1.5rem, 6vw, 4rem); border: 1px solid #263247; border-radius: 1.5rem; background: rgba(16, 23, 35, .9); box-shadow: 0 1.5rem 5rem rgba(0, 0, 0, .38); }
385
+ .brand { display: flex; align-items: center; gap: .65rem; color: #b9c4d7; font-size: .85rem; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
386
+ .mark { display: grid; place-items: center; width: 1.75rem; height: 1.75rem; border-radius: .55rem; color: #fff; background: linear-gradient(135deg, #766dff, #4850db); font-size: 1.1rem; font-weight: 800; letter-spacing: 0; text-transform: none; box-shadow: 0 .35rem 1.25rem rgba(105, 96, 255, .3); }
387
+ .code { margin: clamp(2.5rem, 7vw, 4.5rem) 0 .6rem; color: #f1f4fb; font-size: clamp(4.75rem, 18vw, 8.5rem); font-weight: 800; letter-spacing: -.09em; line-height: .82; }
388
+ .eyebrow { margin: 0 0 .7rem; color: #8f87ff; font-size: .76rem; font-weight: 800; letter-spacing: .13em; text-transform: uppercase; }
389
+ h1 { max-width: 28rem; margin: 0; font-size: clamp(1.65rem, 4vw, 2.35rem); letter-spacing: -.045em; line-height: 1.05; }
390
+ p { max-width: 32rem; margin: 1rem 0 0; color: #aeb9cb; font-size: 1rem; line-height: 1.65; }
391
+ code { padding: .16rem .38rem; border: 1px solid #2c3850; border-radius: .36rem; color: #d4dbeb; background: #111a29; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85em; overflow-wrap: anywhere; }
392
+ .actions { display: flex; flex-wrap: wrap; gap: .7rem; margin-top: 2rem; }
393
+ a { display: inline-flex; align-items: center; justify-content: center; min-height: 2.7rem; padding: .65rem 1rem; border: 1px solid #34435e; border-radius: .7rem; color: #d7e0ef; background: #172236; font-weight: 700; text-decoration: none; transition: transform .16s ease, border-color .16s ease, background .16s ease; }
394
+ a:hover { transform: translateY(-1px); border-color: #837cff; background: #202e48; }
395
+ a.primary { border-color: transparent; color: #fff; background: #665cf6; box-shadow: 0 .55rem 1.4rem rgba(94, 83, 237, .28); }
396
+ a.primary:hover { background: #756cff; }
397
+ a:focus-visible { outline: 3px solid #a39cff; outline-offset: 3px; }
398
+ .route { margin-top: 2.1rem; color: #728099; font-size: .8rem; }
399
+ @media (max-width: 34rem) { main { border-radius: 1.1rem; } .actions { display: grid; } a { width: 100%; } }
400
+ </style>
401
+ </head>
402
+ <body>
403
+ <main>
404
+ <div class="brand"><span class="mark" aria-hidden="true">K</span> OpenKan</div>
405
+ <div class="code" aria-label="Error ${status}">${status}</div>
406
+ <p class="eyebrow">${eyebrow}</p>
407
+ <h1>${title}</h1>
408
+ <p>${message}</p>
409
+ <div class="actions"><a class="primary" href="/">Open workspace</a></div>
410
+ <p class="route">Requested route: <code>${route}</code></p>
411
+ </main>
412
+ </body>
413
+ </html>`;
414
+ return new Response(body, {
415
+ status,
416
+ headers: {
417
+ "Content-Type": "text/html; charset=utf-8",
418
+ "Cache-Control": "no-store",
419
+ "X-Content-Type-Options": "nosniff",
420
+ },
421
+ });
422
+ }
423
+ function isBrowserNavigation(req) {
424
+ if (req.method !== "GET" && req.method !== "HEAD")
425
+ return false;
426
+ return req.headers.get("accept")?.includes("text/html") ?? false;
427
+ }
428
+ export function requestErrorResponse(req, status, message) {
429
+ if (isBrowserNavigation(req) && !new URL(req.url).pathname.startsWith("/api/")) {
430
+ return browserErrorPage(status, new URL(req.url).pathname);
431
+ }
432
+ return errorResponse(message, status);
433
+ }
434
+ // ─── Tasks index ───────────────────────────────────────────────────────────────
435
+ // TaskIndexEntry and taskToIndexEntry are defined in the new handlers section below
436
+ // ─── API handlers ─────────────────────────────────────────────────────────────
437
+ async function apiGetBoard() {
438
+ return jsonResponse(await getBoard());
439
+ }
440
+ // ─── Goals (PRDs stored in the canonical .ok/ planning workspace) ──────────
441
+ async function apiGetGoals(projectRoot) {
442
+ const paths = await initOkIfMissing(projectRoot);
443
+ const prds = await listOkPrds(paths);
444
+ prds.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
445
+ return jsonResponse({ prds });
446
+ }
447
+ async function apiPatchGoal(projectRoot, prdId, goalId, req) {
448
+ let body;
449
+ try {
450
+ body = await req.json();
451
+ }
452
+ catch {
453
+ return errorResponse("invalid JSON body");
454
+ }
455
+ const statuses = ["open", "in_progress", "met", "dropped"];
456
+ if (!statuses.includes(body.status)) {
457
+ return errorResponse("status must be open, in_progress, met, or dropped");
458
+ }
459
+ const paths = await initOkIfMissing(projectRoot);
460
+ const prd = await readOkPrd(paths, prdId);
461
+ if (!prd)
462
+ return errorResponse("PRD not found", 404);
463
+ const goal = prd.goals.find((item) => item.id === goalId);
464
+ if (!goal)
465
+ return errorResponse("goal not found", 404);
466
+ goal.status = body.status;
467
+ prd.updatedAt = new Date().toISOString();
468
+ await writeOkPrd(paths, prd);
469
+ await rebuildOkIndex(paths);
470
+ return jsonResponse({ prd });
471
+ }
472
+ // ─── Search endpoint ─────────────────────────────────────────────────────────
473
+ async function apiSearch(req) {
474
+ const url = new URL(req.url);
475
+ const query = url.searchParams.get("q") ?? undefined;
476
+ const column = url.searchParams.get("column") ?? undefined;
477
+ const tags = url.searchParams.getAll("tags");
478
+ const assignee = url.searchParams.get("assignee") ?? undefined;
479
+ const priority = url.searchParams.get("priority") ?? undefined;
480
+ const category = url.searchParams.get("category") ?? undefined;
481
+ const includeArchived = url.searchParams.get("includeArchived") === "true";
482
+ const limit = parseInt(url.searchParams.get("limit") ?? "50", 10);
483
+ const offset = parseInt(url.searchParams.get("offset") ?? "0", 10);
484
+ const opts = {
485
+ kanbanDir: KANBAN_DIR,
486
+ query,
487
+ column: column || undefined,
488
+ tags: tags.length > 0 ? tags : undefined,
489
+ assignee,
490
+ priority: priority ?? undefined,
491
+ category: category ?? undefined,
492
+ archived: includeArchived,
493
+ limit,
494
+ offset,
495
+ };
496
+ const result = await search(opts);
497
+ return jsonResponse(result);
498
+ }
499
+ // ─── Bulk operations endpoint ───────────────────────────────────────────────
500
+ async function apiBulk(_ctx, req) {
501
+ let body;
502
+ try {
503
+ body = await req.json();
504
+ }
505
+ catch {
506
+ return errorResponse("Invalid JSON");
507
+ }
508
+ if (!body.operation)
509
+ return errorResponse("operation is required", 422);
510
+ const board = await getBoard();
511
+ selfWriteUntil = Date.now() + 250;
512
+ const result = await applyBulk(board, body.operation);
513
+ await writeBoardMdx(await getBoard(), KANBAN_DIR);
514
+ broadcast("board.updated", {});
515
+ return jsonResponse(result);
516
+ }
517
+ // ─── Template endpoint ──────────────────────────────────────────────────────
518
+ async function apiGetTemplate() {
519
+ return jsonResponse({ template: TASK_MDX_TEMPLATE, parseHints: TEMPLATE_PARSE_HINTS });
520
+ }
521
+ export async function apiCreateTask(_ctx, req) {
522
+ let body;
523
+ try {
524
+ body = await req.json();
525
+ }
526
+ catch {
527
+ return errorResponse("Invalid JSON");
528
+ }
529
+ if (!body.title?.trim())
530
+ return errorResponse("title is required", 422);
531
+ // Validate parentId if provided
532
+ if (body.parentId !== undefined) {
533
+ const board = await getBoard();
534
+ const parent = board.tasks.find(t => t.id === body.parentId);
535
+ if (!parent)
536
+ return errorResponse("Parent task not found", 404);
537
+ // No transitive nesting in v1
538
+ if (parent.parentId !== null)
539
+ return errorResponse("Cannot nest a subtask under another subtask (v1)", 422);
540
+ }
541
+ const id = newId("tsk");
542
+ const arts = taskArtifacts(id);
543
+ const now = nowIso();
544
+ // Derive metadata from title + description
545
+ const derived = extractMetadata({ title: body.title, description: body.description ?? "" });
546
+ // Merge: explicit tags/category from body override derived values
547
+ const tags = (body.tags && body.tags.length > 0)
548
+ ? [...new Set([...derived.tags, ...body.tags])]
549
+ : derived.tags;
550
+ const category = body.category ?? derived.category;
551
+ // Auto-assign: use body.assignee if provided, otherwise current git user, else "user"
552
+ // (Agent-created tasks via kanban_add tool also flow through here; the tool calls
553
+ // POST /api/tasks with a body that may include assignee, which gets processed below.)
554
+ const projectRoot = join(KANBAN_DIR, "..");
555
+ const gitUser = currentGitUser(projectRoot);
556
+ const assigneeName = (body.assignee ?? gitUser?.name ?? "user");
557
+ const task = {
558
+ id,
559
+ title: body.title.trim(),
560
+ description: body.description ?? "",
561
+ column: body.column ?? "todo",
562
+ order: 0,
563
+ sessionId: null,
564
+ agent: body.agent ?? "",
565
+ model: body.model ?? null,
566
+ status: "idle",
567
+ state: "idle",
568
+ lastError: null,
569
+ createdAt: now,
570
+ updatedAt: now,
571
+ artifact: arts.mdxPath,
572
+ sessionArtifact: null,
573
+ pendingInputs: [],
574
+ artifacts: arts,
575
+ tags,
576
+ category,
577
+ priority: derived.priority,
578
+ effort: derived.effort,
579
+ archived: false,
580
+ assignees: [assigneeName],
581
+ images: [],
582
+ parentId: body.parentId ?? null,
583
+ subtaskIds: [],
584
+ };
585
+ let created;
586
+ selfWriteUntil = Date.now() + 250;
587
+ await withWrite(async (board) => {
588
+ const colTasks = board.tasks.filter(t => t.column === task.column);
589
+ task.order = colTasks.length;
590
+ board.tasks.push(task);
591
+ // Add child id to parent's subtaskIds
592
+ if (task.parentId) {
593
+ const parent = board.tasks.find(t => t.id === task.parentId);
594
+ if (parent) {
595
+ parent.subtaskIds = [...new Set([...parent.subtaskIds, task.id])];
596
+ }
597
+ }
598
+ created = task;
599
+ });
600
+ // Create per-task directory
601
+ const taskDir = join(KANBAN_DIR, "tasks", id);
602
+ ensureDir(taskDir);
603
+ await writeTaskMdx(created, KANBAN_DIR, await getBoard());
604
+ await writeBoardMdx(await getBoard(), KANBAN_DIR);
605
+ broadcast("task.created", created);
606
+ recordEvent(KANBAN_DIR, "task.created", {
607
+ taskId: id,
608
+ author: "user",
609
+ summary: `created '${created.title}'`,
610
+ payload: { column: created.column, parentId: created.parentId },
611
+ });
612
+ return jsonResponse(created, 201);
613
+ }
614
+ export async function apiUpdateTask(_ctx, taskId, req) {
615
+ let patch;
616
+ try {
617
+ patch = await req.json();
618
+ }
619
+ catch {
620
+ return errorResponse("Invalid JSON");
621
+ }
622
+ // Validate empty title
623
+ if (patch.title !== undefined && patch.title.trim().length === 0) {
624
+ return errorResponse("title cannot be empty", 422);
625
+ }
626
+ let updated;
627
+ let columnChanged = false;
628
+ let isEdit = false; // true if title or description changed
629
+ selfWriteUntil = Date.now() + 250;
630
+ await withWrite(async (board) => {
631
+ const idx = board.tasks.findIndex(t => t.id === taskId);
632
+ if (idx === -1)
633
+ return;
634
+ const task = board.tasks[idx];
635
+ // Handle re-parenting
636
+ if (patch.parentId !== undefined) {
637
+ const oldParentId = task.parentId;
638
+ const newParentId = patch.parentId;
639
+ // Check new parent exists and is not a subtask itself
640
+ if (newParentId !== null) {
641
+ const newParent = board.tasks.find(t => t.id === newParentId);
642
+ if (!newParent)
643
+ return; // will be caught after withWrite
644
+ if (newParent.parentId !== null)
645
+ return; // cannot re-parent under a subtask
646
+ if (newParentId === taskId)
647
+ return; // cannot parent to self
648
+ }
649
+ // Remove from old parent's subtaskIds
650
+ if (oldParentId !== null) {
651
+ const oldParent = board.tasks.find(t => t.id === oldParentId);
652
+ if (oldParent) {
653
+ oldParent.subtaskIds = oldParent.subtaskIds.filter(id => id !== taskId);
654
+ }
655
+ }
656
+ // Add to new parent's subtaskIds
657
+ if (newParentId !== null) {
658
+ const newParent = board.tasks.find(t => t.id === newParentId);
659
+ if (newParent) {
660
+ newParent.subtaskIds = [...new Set([...newParent.subtaskIds, taskId])];
661
+ }
662
+ }
663
+ task.parentId = newParentId;
664
+ }
665
+ if (patch.column !== undefined && patch.column !== task.column)
666
+ columnChanged = true;
667
+ if (patch.title !== undefined) {
668
+ task.title = patch.title;
669
+ isEdit = true;
670
+ }
671
+ if (patch.description !== undefined) {
672
+ task.description = patch.description;
673
+ isEdit = true;
674
+ }
675
+ if (patch.column !== undefined)
676
+ task.column = patch.column;
677
+ if (patch.agent !== undefined)
678
+ task.agent = patch.agent;
679
+ if (patch.model !== undefined)
680
+ task.model = patch.model;
681
+ if (patch.state !== undefined)
682
+ task.state = patch.state;
683
+ if (patch.order !== undefined)
684
+ task.order = patch.order;
685
+ if (patch.archived !== undefined)
686
+ task.archived = patch.archived;
687
+ if (patch.stale !== undefined)
688
+ task.stale = patch.stale;
689
+ // Merge assignees (add-only, not destructive)
690
+ if (patch.assignees !== undefined) {
691
+ task.assignees = [...new Set([...task.assignees, ...patch.assignees])];
692
+ }
693
+ // Re-derive metadata if title or description changed
694
+ if (patch.title !== undefined || patch.description !== undefined) {
695
+ const derived = extractMetadata({ title: task.title, description: task.description });
696
+ task.tags = (patch.tags && patch.tags.length > 0)
697
+ ? [...new Set([...derived.tags, ...patch.tags])]
698
+ : derived.tags;
699
+ task.category = patch.category ?? derived.category;
700
+ task.priority = derived.priority;
701
+ task.effort = derived.effort;
702
+ }
703
+ else {
704
+ // Apply explicit overrides even without re-derivation
705
+ if (patch.tags !== undefined) {
706
+ task.tags = patch.tags;
707
+ }
708
+ if (patch.category !== undefined) {
709
+ task.category = patch.category;
710
+ }
711
+ }
712
+ task.updatedAt = nowIso();
713
+ if (columnChanged)
714
+ board.tasks = renormalizeOrder(board.tasks);
715
+ updated = { ...task };
716
+ });
717
+ if (!updated)
718
+ return errorResponse("Task not found", 404);
719
+ await writeTaskMdx(updated, KANBAN_DIR, await getBoard());
720
+ await writeBoardMdx(await getBoard(), KANBAN_DIR);
721
+ broadcast("task.updated", updated);
722
+ if (columnChanged) {
723
+ recordEvent(KANBAN_DIR, "task.moved", {
724
+ taskId,
725
+ author: "user",
726
+ summary: `moved '${updated.title}' to ${updated.column}`,
727
+ payload: { from: patch.column },
728
+ });
729
+ }
730
+ else if (isEdit) {
731
+ recordEvent(KANBAN_DIR, "task.updated", {
732
+ taskId,
733
+ author: "user",
734
+ summary: `edited '${updated.title}'`,
735
+ payload: { changes: Object.keys(patch) },
736
+ });
737
+ }
738
+ else {
739
+ recordEvent(KANBAN_DIR, "task.updated", {
740
+ taskId,
741
+ author: "user",
742
+ summary: `updated '${updated.title}'`,
743
+ payload: { changes: Object.keys(patch) },
744
+ });
745
+ }
746
+ return jsonResponse(updated);
747
+ }
748
+ // ─── Stale recheck endpoint ─────────────────────────────────────────────────
749
+ /**
750
+ * POST /api/tasks/recheck-stale
751
+ * Body: { taskId: string }
752
+ * Returns: { stale: boolean, sourceHash: string }
753
+ *
754
+ * Checks whether the source file for a task has changed since import.
755
+ */
756
+ export async function apiRecheckStale(_ctx, req) {
757
+ let body;
758
+ try {
759
+ body = await req.json();
760
+ }
761
+ catch {
762
+ return errorResponse("Invalid JSON");
763
+ }
764
+ if (!body.taskId)
765
+ return errorResponse("taskId is required", 422);
766
+ const board = await getBoard();
767
+ const task = board.tasks.find(t => t.id === body.taskId);
768
+ if (!task)
769
+ return errorResponse("Task not found", 404);
770
+ // No source → never stale
771
+ if (!task.source) {
772
+ return jsonResponse({ stale: false, sourceHash: "" });
773
+ }
774
+ const absPath = join(KANBAN_DIR, "..", task.source.path);
775
+ let content;
776
+ try {
777
+ content = readFileSync(absPath, "utf-8");
778
+ }
779
+ catch {
780
+ return errorResponse("Source file not readable", 422);
781
+ }
782
+ const newHash = createHash("sha256").update(content).digest("hex");
783
+ const isStale = newHash !== task.sourceHash;
784
+ if (isStale !== task.stale || task.lastSourceCheck === undefined) {
785
+ task.stale = isStale;
786
+ task.lastSourceCheck = nowIso();
787
+ if (isStale)
788
+ task.sourceHash = newHash; // update hash so subsequent recheck is consistent
789
+ selfWriteUntil = Date.now() + 250;
790
+ await withWrite(async (b) => {
791
+ const t = b.tasks.find(t => t.id === body.taskId);
792
+ if (t) {
793
+ t.stale = isStale;
794
+ t.lastSourceCheck = task.lastSourceCheck;
795
+ if (isStale)
796
+ t.sourceHash = newHash;
797
+ }
798
+ });
799
+ const updated = (await getBoard()).tasks.find(t => t.id === body.taskId);
800
+ await writeTaskMdx(updated, KANBAN_DIR, await getBoard());
801
+ broadcast("task.updated", updated);
802
+ return jsonResponse({ stale: isStale, sourceHash: newHash });
803
+ }
804
+ return jsonResponse({ stale: isStale, sourceHash: task.sourceHash ?? newHash });
805
+ }
806
+ export async function apiDeleteTask(_ctx, taskId) {
807
+ let removedId = "";
808
+ const subtaskIds = [];
809
+ selfWriteUntil = Date.now() + 250;
810
+ await withWrite(async (board) => {
811
+ const idx = board.tasks.findIndex(t => t.id === taskId);
812
+ if (idx === -1)
813
+ return;
814
+ removedId = board.tasks[idx].id;
815
+ subtaskIds.push(...board.tasks[idx].subtaskIds);
816
+ // Remove child's id from parent's subtaskIds
817
+ const parentId = board.tasks[idx].parentId;
818
+ if (parentId) {
819
+ const parent = board.tasks.find(t => t.id === parentId);
820
+ if (parent) {
821
+ parent.subtaskIds = parent.subtaskIds.filter(id => id !== taskId);
822
+ }
823
+ }
824
+ board.tasks.splice(idx, 1);
825
+ board.tasks = renormalizeOrder(board.tasks);
826
+ });
827
+ if (!removedId)
828
+ return errorResponse("Task not found", 404);
829
+ // Cascade delete all subtasks (collect grandchildren first, then delete leaves)
830
+ async function deleteSubtasks(ids) {
831
+ for (const id of ids) {
832
+ const taskDir = join(KANBAN_DIR, "tasks", id);
833
+ removeDir(taskDir);
834
+ // Record event before board modification
835
+ recordEvent(KANBAN_DIR, "task.deleted", {
836
+ taskId: id,
837
+ author: "user",
838
+ summary: `deleted subtask '${id}'`,
839
+ payload: {},
840
+ });
841
+ broadcast("task.deleted", { id });
842
+ await withWrite(async (board) => {
843
+ const idx = board.tasks.findIndex(t => t.id === id);
844
+ if (idx === -1)
845
+ return;
846
+ // Remove from parent's subtaskIds
847
+ const pId = board.tasks[idx].parentId;
848
+ if (pId) {
849
+ const parent = board.tasks.find(t => t.id === pId);
850
+ if (parent)
851
+ parent.subtaskIds = parent.subtaskIds.filter(x => x !== id);
852
+ }
853
+ board.tasks.splice(idx, 1);
854
+ board.tasks = renormalizeOrder(board.tasks);
855
+ });
856
+ }
857
+ }
858
+ await deleteSubtasks(subtaskIds);
859
+ // Delete task directory
860
+ const taskDir = join(KANBAN_DIR, "tasks", taskId);
861
+ removeDir(taskDir);
862
+ await writeBoardMdx(await getBoard(), KANBAN_DIR);
863
+ broadcast("task.deleted", { id: taskId });
864
+ recordEvent(KANBAN_DIR, "task.deleted", {
865
+ taskId,
866
+ author: "user",
867
+ summary: `deleted task '${taskId}'`,
868
+ payload: {},
869
+ });
870
+ return jsonResponse({ ok: true });
871
+ }
872
+ // ─── Tags endpoint ────────────────────────────────────────────────────────────
873
+ async function apiGetTags() {
874
+ const board = await getBoard();
875
+ const categorySet = new Set();
876
+ const tagCounts = new Map();
877
+ const priorityCounts = new Map();
878
+ const effortCounts = new Map();
879
+ for (const task of board.tasks) {
880
+ const cat = (task.category ?? "task");
881
+ categorySet.add(cat);
882
+ priorityCounts.set(task.priority ?? "normal", (priorityCounts.get(task.priority ?? "normal") ?? 0) + 1);
883
+ if (task.effort !== null && task.effort !== undefined) {
884
+ effortCounts.set(task.effort, (effortCounts.get(task.effort) ?? 0) + 1);
885
+ }
886
+ for (const tag of (task.tags ?? [])) {
887
+ tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
888
+ }
889
+ }
890
+ const tagCountObj = {};
891
+ for (const [tag, count] of tagCounts)
892
+ tagCountObj[tag] = count;
893
+ const priorityCountObj = {};
894
+ for (const [p, count] of priorityCounts)
895
+ priorityCountObj[p] = count;
896
+ const effortCountObj = {};
897
+ for (const [e, count] of effortCounts)
898
+ effortCountObj[e] = count;
899
+ return jsonResponse({
900
+ categories: [...categorySet].sort(),
901
+ tagCounts: tagCountObj,
902
+ priorityCounts: priorityCountObj,
903
+ effortCounts: effortCountObj,
904
+ });
905
+ }
906
+ // ─── Input endpoints ──────────────────────────────────────────────────────────
907
+ async function apiAskInput(_ctx, taskId, req) {
908
+ let body;
909
+ try {
910
+ body = await req.json();
911
+ }
912
+ catch {
913
+ return errorResponse("Invalid JSON");
914
+ }
915
+ if (!body.question)
916
+ return errorResponse("question is required", 422);
917
+ const board = await getBoard();
918
+ const task = board.tasks.find(t => t.id === taskId);
919
+ if (!task)
920
+ return errorResponse("Task not found", 404);
921
+ // Check for existing pending input
922
+ const existing = getPendingInput(taskId, KANBAN_DIR);
923
+ if (existing)
924
+ return errorResponse("A pending input already exists for this task", 409);
925
+ const inputType = body.type ?? "ask";
926
+ const input = addInput(taskId, KANBAN_DIR, {
927
+ type: inputType,
928
+ question: body.question,
929
+ options: body.options,
930
+ placeholder: body.placeholder,
931
+ blockId: body.blockId,
932
+ });
933
+ // Update task state to waiting-for-input
934
+ selfWriteUntil = Date.now() + 250;
935
+ await withWrite(async (b) => {
936
+ const t = b.tasks.find(t => t.id === taskId);
937
+ if (t) {
938
+ t.state = "waiting-for-input";
939
+ t.pendingInputs = [...(t.pendingInputs ?? []), input.id];
940
+ t.updatedAt = nowIso();
941
+ }
942
+ });
943
+ const updated = (await getBoard()).tasks.find(t => t.id === taskId);
944
+ await writeTaskMdx(updated, KANBAN_DIR, await getBoard());
945
+ broadcast("task.updated", updated);
946
+ broadcast("task.input.asked", { taskId, input });
947
+ recordEvent(KANBAN_DIR, "task.input.asked", {
948
+ taskId,
949
+ author: "user",
950
+ summary: `asked '${body.question}' on '${task.title}'`,
951
+ payload: { inputId: input.id, type: input.type },
952
+ });
953
+ return jsonResponse(input, 201);
954
+ }
955
+ async function apiRespondInput(_ctx, taskId, req) {
956
+ let body;
957
+ try {
958
+ body = await req.json();
959
+ }
960
+ catch {
961
+ return errorResponse("Invalid JSON");
962
+ }
963
+ if (!body.inputId)
964
+ return errorResponse("inputId is required", 422);
965
+ const board = await getBoard();
966
+ const task = board.tasks.find(t => t.id === taskId);
967
+ if (!task)
968
+ return errorResponse("Task not found", 404);
969
+ let updatedInput;
970
+ try {
971
+ updatedInput = respondInput(taskId, KANBAN_DIR, body.inputId, { value: body.value, optionId: body.optionId });
972
+ }
973
+ catch (e) {
974
+ return errorResponse("respondInput failed: " + String(e?.message ?? e), 422);
975
+ }
976
+ // Restore task to prior state (default running)
977
+ const priorState = task.sessionId ? "running" : "idle";
978
+ selfWriteUntil = Date.now() + 250;
979
+ await withWrite(async (b) => {
980
+ const t = b.tasks.find(t => t.id === taskId);
981
+ if (t) {
982
+ t.state = priorState;
983
+ t.pendingInputs = (t.pendingInputs ?? []).filter(id => id !== body.inputId);
984
+ t.updatedAt = nowIso();
985
+ }
986
+ });
987
+ const updated = (await getBoard()).tasks.find(t => t.id === taskId);
988
+ await writeTaskMdx(updated, KANBAN_DIR, await getBoard());
989
+ broadcast("task.updated", updated);
990
+ broadcast("task.input.responded", { taskId, input: updatedInput });
991
+ recordEvent(KANBAN_DIR, "task.input.responded", {
992
+ taskId,
993
+ author: "user",
994
+ summary: `responded to input on '${task.title}'`,
995
+ payload: { inputId: updatedInput.id },
996
+ });
997
+ return jsonResponse(updatedInput);
998
+ }
999
+ // ─── Comment endpoints ────────────────────────────────────────────────────────
1000
+ async function apiGetComments(_ctx, taskId) {
1001
+ const comments = listComments(taskId, KANBAN_DIR);
1002
+ return jsonResponse(comments);
1003
+ }
1004
+ async function apiAddComment(_ctx, taskId, req) {
1005
+ let body;
1006
+ try {
1007
+ body = await req.json();
1008
+ }
1009
+ catch {
1010
+ return errorResponse("Invalid JSON");
1011
+ }
1012
+ if (!body.blockId || !body.text)
1013
+ return errorResponse("blockId and text are required", 422);
1014
+ // Resolve author: explicit "agent:<name>" wins, else body.author, else git user, else "user"
1015
+ let author = body.author ?? "user";
1016
+ const projectRoot = getActiveProjectRoot();
1017
+ if (author === "user" || !author) {
1018
+ const gitUser = currentGitUser(projectRoot);
1019
+ author = gitUser?.name ?? "user";
1020
+ }
1021
+ const comment = addComment(taskId, KANBAN_DIR, {
1022
+ blockId: body.blockId,
1023
+ line: body.line ?? 1,
1024
+ text: body.text,
1025
+ author,
1026
+ });
1027
+ broadcast("task.comment.added", { taskId, comment });
1028
+ recordEvent(KANBAN_DIR, "task.commented", {
1029
+ taskId,
1030
+ author: comment.author,
1031
+ summary: `commented on task`,
1032
+ payload: { commentId: comment.id, text: comment.text.slice(0, 80) },
1033
+ });
1034
+ return jsonResponse(comment, 201);
1035
+ }
1036
+ async function apiResolveComment(_ctx, taskId, commentId, req) {
1037
+ let body;
1038
+ try {
1039
+ body = await req.json();
1040
+ }
1041
+ catch {
1042
+ return errorResponse("Invalid JSON");
1043
+ }
1044
+ // Resolve resolvedBy: explicit author wins, else git user, else "user"
1045
+ let resolvedBy = body.author ?? "user";
1046
+ if (!resolvedBy || resolvedBy === "user") {
1047
+ const projectRoot = getActiveProjectRoot();
1048
+ const gitUser = currentGitUser(projectRoot);
1049
+ resolvedBy = gitUser?.name ?? "user";
1050
+ }
1051
+ const updated = resolveComment(taskId, KANBAN_DIR, commentId, body.resolved, resolvedBy, undefined, body.reason);
1052
+ if (!updated)
1053
+ return errorResponse("Comment not found", 404);
1054
+ broadcast("task.comment.resolved", { taskId, comment: updated });
1055
+ recordEvent(KANBAN_DIR, "task.comment.resolved", {
1056
+ taskId,
1057
+ author: resolvedBy,
1058
+ summary: `${body.resolved ? "resolved" : "unresolved"} comment on task`,
1059
+ payload: { commentId },
1060
+ });
1061
+ return jsonResponse(updated);
1062
+ }
1063
+ async function apiDeleteComment(_ctx, taskId, commentId) {
1064
+ const ok = deleteComment(taskId, KANBAN_DIR, commentId);
1065
+ if (!ok)
1066
+ return errorResponse("Comment not found", 404);
1067
+ broadcast("task.comment.deleted", { taskId, commentId });
1068
+ recordEvent(KANBAN_DIR, "task.comment.deleted", {
1069
+ taskId,
1070
+ author: "user",
1071
+ summary: `deleted comment on task`,
1072
+ payload: { commentId },
1073
+ });
1074
+ return jsonResponse({ ok: true });
1075
+ }
1076
+ // ─── MDX rendered ────────────────────────────────────────────────────────────
1077
+ async function apiGetMdxRendered(taskId) {
1078
+ const board = await getBoard();
1079
+ const task = board.tasks.find(t => t.id === taskId);
1080
+ if (!task)
1081
+ return errorResponse("Task not found", 404);
1082
+ const mdxPath = join(KANBAN_DIR, task.artifacts.mdxPath);
1083
+ let mdx = "";
1084
+ if (existsSync(mdxPath)) {
1085
+ try {
1086
+ if (statSync(mdxPath).isFile())
1087
+ mdx = readFileSync(mdxPath, "utf-8");
1088
+ }
1089
+ catch { /* ignore */ }
1090
+ }
1091
+ const result = await renderMdx(mdx);
1092
+ return jsonResponse({ html: result.html, blocks: result.blocks });
1093
+ }
1094
+ // ─── TSX preview ─────────────────────────────────────────────────────────────
1095
+ async function apiPreview(req) {
1096
+ let body;
1097
+ try {
1098
+ body = await req.json();
1099
+ }
1100
+ catch {
1101
+ return errorResponse("Invalid JSON");
1102
+ }
1103
+ if (!body.tsx)
1104
+ return errorResponse("tsx is required", 422);
1105
+ const result = await buildPreview(body.tsx, body.props);
1106
+ if (result.error)
1107
+ return jsonResponse({ error: result.error }, 422);
1108
+ return jsonResponse({ js: result.js, sandboxHtml: result.sandboxHtml });
1109
+ }
1110
+ // ─── Session/status handlers (reuse existing) ─────────────────────────────────
1111
+ export async function apiStartTask(projectRoot, taskId, req) {
1112
+ let body;
1113
+ try {
1114
+ body = await req.json();
1115
+ }
1116
+ catch {
1117
+ body = {};
1118
+ }
1119
+ const board = await getBoard();
1120
+ const task = board.tasks.find(t => t.id === taskId);
1121
+ if (!task)
1122
+ return errorResponse("Task not found", 404);
1123
+ if (task.sessionId)
1124
+ return errorResponse("Task already has an active session", 409);
1125
+ let knownAgents = [];
1126
+ try {
1127
+ const snapshot = await getBizarSnapshot(projectRoot);
1128
+ knownAgents = Array.isArray(snapshot?.agents)
1129
+ ? snapshot.agents.map((candidate) => candidate.id ?? candidate.name).filter(Boolean)
1130
+ : [];
1131
+ }
1132
+ catch (e) {
1133
+ return errorResponse("Unable to load Bizar agents: " + String(e?.message ?? e), 502);
1134
+ }
1135
+ if (body.agent && knownAgents.length > 0 && !knownAgents.includes(body.agent)) {
1136
+ return errorResponse(`Unknown agent "${body.agent}". Available: ${knownAgents.join(", ")}`, 400);
1137
+ }
1138
+ // Read agents.active from .ok/openkan.json if present
1139
+ let agentsActive;
1140
+ try {
1141
+ const agentsConfigPath = join(projectRoot, ".ok", "openkan.json");
1142
+ if (existsSync(agentsConfigPath)) {
1143
+ const raw = JSON.parse(readFileSync(agentsConfigPath, "utf-8"));
1144
+ const agentsBlock = raw["agents"];
1145
+ if (agentsBlock && typeof agentsBlock["active"] === "string" && agentsBlock["active"]) {
1146
+ agentsActive = agentsBlock["active"];
1147
+ }
1148
+ }
1149
+ }
1150
+ catch { /* ignore config read errors */ }
1151
+ const preferred = body.agent || task.agent;
1152
+ const agent = (preferred && knownAgents.includes(preferred) ? preferred : "")
1153
+ || (agentsActive && knownAgents.includes(agentsActive) ? agentsActive : "")
1154
+ || (knownAgents.includes("mike") ? "mike" : knownAgents[0]);
1155
+ if (!agent)
1156
+ return errorResponse("No Bizar agents are available", 503);
1157
+ let sessionId;
1158
+ try {
1159
+ const started = executeBizarCommand(projectRoot, "start-session", {
1160
+ agent,
1161
+ name: `OpenKan: ${task.title}`,
1162
+ prompt: [
1163
+ `Work on OpenKan task ${task.id}: ${task.title}`,
1164
+ task.description,
1165
+ `Keep the task workspace at .ok/tasks/${task.id}/task.mdx synchronized with progress.`,
1166
+ ].filter(Boolean).join("\n\n"),
1167
+ });
1168
+ sessionId = started?.session?.sessionId ?? started?.session?.id ?? "";
1169
+ if (!sessionId)
1170
+ throw new Error("Bizar did not return a session ID");
1171
+ }
1172
+ catch (e) {
1173
+ return errorResponse("Bizar session start failed: " + String(e?.message ?? e), 502);
1174
+ }
1175
+ const startedAt = nowIso();
1176
+ selfWriteUntil = Date.now() + 250;
1177
+ await withWrite(async (b) => {
1178
+ const t = b.tasks.find(t => t.id === taskId);
1179
+ if (!t)
1180
+ return;
1181
+ t.sessionId = sessionId;
1182
+ t.agent = agent;
1183
+ if (body.model)
1184
+ t.model = body.model;
1185
+ t.state = "running";
1186
+ t.updatedAt = nowIso();
1187
+ b.sessions[sessionId] = { taskId, status: "running", startedAt, endedAt: null };
1188
+ });
1189
+ const updatedTask = (await getBoard()).tasks.find(t => t.id === taskId);
1190
+ await writeTaskMdx(updatedTask, KANBAN_DIR, await getBoard());
1191
+ await writeBoardMdx(await getBoard(), KANBAN_DIR);
1192
+ broadcast("task.updated", updatedTask);
1193
+ recordEvent(KANBAN_DIR, "agent.started", {
1194
+ taskId,
1195
+ author: `agent:${agent}`,
1196
+ summary: `started on '${task.title}'`,
1197
+ payload: { sessionId, agent },
1198
+ });
1199
+ return jsonResponse(updatedTask);
1200
+ }
1201
+ export async function apiAbortTask(projectRoot, taskId) {
1202
+ const board = await getBoard();
1203
+ const task = board.tasks.find(t => t.id === taskId);
1204
+ if (!task)
1205
+ return errorResponse("Task not found", 404);
1206
+ const sessionId = task.sessionId;
1207
+ if (!sessionId)
1208
+ return errorResponse("Task has no active session", 409);
1209
+ try {
1210
+ executeBizarCommand(projectRoot, "stop-session", { sessionId });
1211
+ }
1212
+ catch (e) {
1213
+ return errorResponse("Bizar session stop failed: " + String(e?.message ?? e), 502);
1214
+ }
1215
+ selfWriteUntil = Date.now() + 250;
1216
+ await withWrite(async (b) => {
1217
+ const t = b.tasks.find(t => t.id === taskId);
1218
+ if (!t)
1219
+ return;
1220
+ t.state = "cancelled";
1221
+ t.updatedAt = nowIso();
1222
+ const r = b.sessions[sessionId];
1223
+ if (r) {
1224
+ r.status = "cancelled";
1225
+ r.endedAt = nowIso();
1226
+ }
1227
+ });
1228
+ const updated = (await getBoard()).tasks.find(t => t.id === taskId);
1229
+ await writeTaskMdx(updated, KANBAN_DIR, await getBoard());
1230
+ await writeBoardMdx(await getBoard(), KANBAN_DIR);
1231
+ broadcast("task.updated", updated);
1232
+ return jsonResponse(updated);
1233
+ }
1234
+ async function apiSessionStatus(sessionId) {
1235
+ return jsonResponse({ status: sessionStatusCache.get(sessionId) ?? "unknown" });
1236
+ }
1237
+ // ─── Archive / restore ──────────────────────────────────────────────────────
1238
+ export async function apiArchiveTask(_ctx, taskId) {
1239
+ const board = await getBoard();
1240
+ const task = board.tasks.find(t => t.id === taskId);
1241
+ if (!task)
1242
+ return errorResponse("Task not found", 404);
1243
+ const author = "user";
1244
+ selfWriteUntil = Date.now() + 250;
1245
+ // Cascade archive subtasks
1246
+ async function archiveSubtasks(ids) {
1247
+ for (const id of ids) {
1248
+ const subtask = (await getBoard()).tasks.find(t => t.id === id);
1249
+ if (!subtask || subtask.archived)
1250
+ continue;
1251
+ const updated = await archiveTask(subtask, KANBAN_DIR, author);
1252
+ await writeTaskMdx(updated, KANBAN_DIR, await getBoard());
1253
+ broadcast("task.updated", updated);
1254
+ }
1255
+ }
1256
+ const updated = await archiveTask(task, KANBAN_DIR, author);
1257
+ await writeTaskMdx(updated, KANBAN_DIR, await getBoard());
1258
+ await writeBoardMdx(await getBoard(), KANBAN_DIR);
1259
+ broadcast("task.updated", updated);
1260
+ // Cascade to subtasks
1261
+ await archiveSubtasks(task.subtaskIds);
1262
+ return jsonResponse(updated);
1263
+ }
1264
+ export async function apiRestoreTask(_ctx, taskId) {
1265
+ const board = await getBoard();
1266
+ const task = board.tasks.find(t => t.id === taskId);
1267
+ if (!task)
1268
+ return errorResponse("Task not found", 404);
1269
+ const author = "user";
1270
+ selfWriteUntil = Date.now() + 250;
1271
+ // Cascade restore subtasks
1272
+ async function restoreSubtasks(ids) {
1273
+ for (const id of ids) {
1274
+ const subtask = (await getBoard()).tasks.find(t => t.id === id);
1275
+ if (!subtask || !subtask.archived)
1276
+ continue;
1277
+ const updated = await restoreTask(subtask, KANBAN_DIR, author);
1278
+ await writeTaskMdx(updated, KANBAN_DIR, await getBoard());
1279
+ broadcast("task.updated", updated);
1280
+ }
1281
+ }
1282
+ const updated = await restoreTask(task, KANBAN_DIR, author);
1283
+ await writeTaskMdx(updated, KANBAN_DIR, await getBoard());
1284
+ await writeBoardMdx(await getBoard(), KANBAN_DIR);
1285
+ broadcast("task.updated", updated);
1286
+ // Cascade to subtasks
1287
+ await restoreSubtasks(task.subtaskIds);
1288
+ return jsonResponse(updated);
1289
+ }
1290
+ // ─── Changelog ─────────────────────────────────────────────────────────────
1291
+ export async function apiGetChangelog(req) {
1292
+ const url = new URL(req.url);
1293
+ const since = url.searchParams.get("since") ?? undefined;
1294
+ const until = url.searchParams.get("until") ?? undefined;
1295
+ const kindStr = url.searchParams.get("kind");
1296
+ const kind = kindStr;
1297
+ const taskId = url.searchParams.get("taskId") ?? undefined;
1298
+ const author = url.searchParams.get("author") ?? undefined;
1299
+ const limit = parseInt(url.searchParams.get("limit") ?? "200", 10);
1300
+ const offset = parseInt(url.searchParams.get("offset") ?? "0", 10);
1301
+ const completedOnly = url.searchParams.get("completedOnly") === "true" || url.searchParams.get("completedOnly") === "1";
1302
+ const reset = url.searchParams.get("reset") === "true";
1303
+ const result = readEvents(KANBAN_DIR, { since, until, kind, taskId, author, limit, offset, completedOnly, reset, kanbanDirForCompletedOnly: KANBAN_DIR });
1304
+ return jsonResponse(result, 200, { "Cache-Control": "no-cache, no-transform" });
1305
+ }
1306
+ async function apiGetChangelogSummary(req) {
1307
+ const url = new URL(req.url);
1308
+ const days = parseInt(url.searchParams.get("days") ?? "30", 10);
1309
+ const summary = readSummary(KANBAN_DIR, { days });
1310
+ return jsonResponse(summary);
1311
+ }
1312
+ async function apiGetInsightsVelocity(req) {
1313
+ const url = new URL(req.url);
1314
+ const rawDays = parseInt(url.searchParams.get("days") ?? "30", 10);
1315
+ const days = Math.max(1, Math.min(365, isFinite(rawDays) ? rawDays : 30));
1316
+ const buckets = computeVelocity(KANBAN_DIR, days);
1317
+ return jsonResponse({
1318
+ days: buckets.days,
1319
+ columns: {
1320
+ backlog: buckets.backlog,
1321
+ todo: buckets.todo,
1322
+ doing: buckets.doing,
1323
+ review: buckets.review,
1324
+ done: buckets.done,
1325
+ },
1326
+ windowDays: buckets.windowDays,
1327
+ generatedAt: buckets.generatedAt,
1328
+ });
1329
+ }
1330
+ // ─── Contributors & git attribution ────────────────────────────────────────
1331
+ async function apiGetContributors(req) {
1332
+ const url = new URL(req.url);
1333
+ const since = url.searchParams.get("since") ?? undefined;
1334
+ const until = url.searchParams.get("until") ?? undefined;
1335
+ const maxCount = parseInt(url.searchParams.get("maxCount") ?? "1000", 10);
1336
+ const projectRoot = join(KANBAN_DIR, "..");
1337
+ if (!isGitRepo(projectRoot))
1338
+ return jsonResponse([]);
1339
+ const contributors = listContributors(projectRoot, { since, until, maxCount });
1340
+ return jsonResponse(contributors);
1341
+ }
1342
+ async function apiGetTaskContributors(_ctx, taskId) {
1343
+ const board = await getBoard();
1344
+ const task = board.tasks.find(t => t.id === taskId);
1345
+ if (!task)
1346
+ return errorResponse("Task not found", 404);
1347
+ const projectRoot = join(KANBAN_DIR, "..");
1348
+ const attributed = attributeCommitsToTasks(projectRoot, [{ id: task.id, title: task.title, source: task.source }], {});
1349
+ const commits = attributed.get(task.id) ?? [];
1350
+ return jsonResponse(commits);
1351
+ }
1352
+ export async function apiOrganize(_ctx, req) {
1353
+ let body;
1354
+ try {
1355
+ body = await req.json();
1356
+ }
1357
+ catch {
1358
+ return errorResponse("Invalid JSON");
1359
+ }
1360
+ if (!Array.isArray(body.operations))
1361
+ return errorResponse("operations must be an array", 422);
1362
+ const applied = [];
1363
+ const skipped = [];
1364
+ selfWriteUntil = Date.now() + 1000;
1365
+ await withWrite(async (board) => {
1366
+ for (const op of body.operations) {
1367
+ const t = board.tasks.find(x => x.id === op.taskId);
1368
+ if (!t) {
1369
+ skipped.push({ taskId: op.taskId, kind: op.kind, reason: `Task not found` });
1370
+ continue;
1371
+ }
1372
+ const before = { column: t.column, tags: [...t.tags], priority: t.priority, effort: t.effort, category: t.category, archived: t.archived };
1373
+ try {
1374
+ switch (op.kind) {
1375
+ case "rederive": {
1376
+ const derived = extractMetadata({ title: t.title, description: t.description });
1377
+ t.tags = derived.tags;
1378
+ t.category = derived.category;
1379
+ t.priority = derived.priority;
1380
+ t.effort = derived.effort;
1381
+ break;
1382
+ }
1383
+ case "set-tags":
1384
+ t.tags = op.tags;
1385
+ break;
1386
+ case "add-tags":
1387
+ t.tags = [...new Set([...t.tags, ...op.tags])];
1388
+ break;
1389
+ case "remove-tag":
1390
+ t.tags = t.tags.filter(tag => tag !== op.tag);
1391
+ break;
1392
+ case "set-priority":
1393
+ t.priority = op.priority;
1394
+ break;
1395
+ case "set-effort":
1396
+ t.effort = op.effort;
1397
+ break;
1398
+ case "set-category":
1399
+ t.category = op.category;
1400
+ break;
1401
+ case "move":
1402
+ t.column = op.column;
1403
+ break;
1404
+ case "archive":
1405
+ t.archived = true;
1406
+ break;
1407
+ case "restore":
1408
+ t.archived = false;
1409
+ break;
1410
+ case "add-area":
1411
+ if (!t.tags.includes(`area:${op.area}`))
1412
+ t.tags = [...t.tags, `area:${op.area}`];
1413
+ break;
1414
+ default:
1415
+ skipped.push({
1416
+ taskId: op.taskId,
1417
+ kind: op.kind,
1418
+ reason: "Unknown operation kind",
1419
+ });
1420
+ continue;
1421
+ }
1422
+ t.updatedAt = nowIso();
1423
+ const after = { column: t.column, tags: [...t.tags], priority: t.priority, effort: t.effort, category: t.category, archived: t.archived };
1424
+ applied.push({ taskId: op.taskId, kind: op.kind, before, after });
1425
+ }
1426
+ catch (e) {
1427
+ skipped.push({ taskId: op.taskId, kind: op.kind, reason: String(e?.message ?? e) });
1428
+ }
1429
+ }
1430
+ // Renormalize after any move ops
1431
+ if (applied.some(a => a.kind === "move")) {
1432
+ board.tasks = renormalizeOrder(board.tasks);
1433
+ }
1434
+ });
1435
+ const summary = {
1436
+ moved: applied.filter(a => a.kind === "move").length,
1437
+ retagged: applied.filter(a => ["set-tags", "add-tags", "remove-tag", "add-area", "set-category", "rederive"].includes(a.kind)).length,
1438
+ archived: applied.filter(a => a.kind === "archive").length,
1439
+ errors: skipped.length,
1440
+ };
1441
+ if (applied.length > 0) {
1442
+ const board = await getBoard();
1443
+ for (const a of applied) {
1444
+ const t = board.tasks.find(x => x.id === a.taskId);
1445
+ if (t) {
1446
+ await writeTaskMdx(t, KANBAN_DIR, board);
1447
+ broadcast("task.updated", t);
1448
+ }
1449
+ }
1450
+ await writeBoardMdx(board, KANBAN_DIR);
1451
+ }
1452
+ recordEvent(KANBAN_DIR, "kanban.organized", {
1453
+ author: "user",
1454
+ summary: `organized ${applied.length} task(s)`,
1455
+ payload: { operations: applied, skipped },
1456
+ });
1457
+ return jsonResponse({ applied, skipped, summary });
1458
+ }
1459
+ /**
1460
+ * Move one or more selected tasks from the active project into another
1461
+ * registered project. Each task is cloned into the target `.ok/`
1462
+ * directory with a freshly-minted id, its source-side copy is removed,
1463
+ * and parent/child links are re-established inside the target board.
1464
+ *
1465
+ * Body: { taskIds: string[] }
1466
+ * Returns: 200 with { moved, skipped }.
1467
+ */
1468
+ export async function apiMoveTasksToProject(targetProjectId, req) {
1469
+ let body;
1470
+ try {
1471
+ body = await req.json();
1472
+ }
1473
+ catch {
1474
+ return errorResponse("invalid JSON body");
1475
+ }
1476
+ if (!Array.isArray(body.taskIds) || body.taskIds.length === 0) {
1477
+ return errorResponse("taskIds must be a non-empty array", 400);
1478
+ }
1479
+ const taskIds = body.taskIds.map((id) => String(id)).filter((s) => s.length > 0);
1480
+ if (taskIds.length === 0)
1481
+ return errorResponse("taskIds must be a non-empty array", 400);
1482
+ // Resolve target via the project registry.
1483
+ const target = findProject(targetProjectId);
1484
+ if (!target)
1485
+ return errorResponse(`Unknown project: ${targetProjectId}`, 404);
1486
+ // Compute the target's `.ok/` directory. Falls back to `<root>/.ok`
1487
+ // when the registry entry does not yet have a `.ok/` on disk.
1488
+ const targetKanbanDir = (() => {
1489
+ const resolved = resolveKanbanDir(target.root);
1490
+ return resolved ?? join(target.root, ".ok");
1491
+ })();
1492
+ ensureDir(targetKanbanDir);
1493
+ ensureDir(join(targetKanbanDir, "tasks"));
1494
+ // Load the active board from memory; this is the source of truth for
1495
+ // what is being moved.
1496
+ const sourceBoard = await getBoard();
1497
+ const sourceKanbanDir = KANBAN_DIR;
1498
+ // Load the target board directly from disk so we don't disturb the
1499
+ // server's in-memory `_board` cache. The server stays bound to the
1500
+ // active project; the target is just an external file we mutate.
1501
+ const targetBoardFile = join(targetKanbanDir, "board.json");
1502
+ const targetBoard = (() => {
1503
+ const fallback = { version: 1, columns: [...DEFAULT_COLUMNS], tasks: [], sessions: {} };
1504
+ if (!existsSync(targetBoardFile))
1505
+ return fallback;
1506
+ try {
1507
+ const parsed = JSON.parse(readFileSync(targetBoardFile, "utf-8"));
1508
+ return {
1509
+ version: 1,
1510
+ columns: Array.isArray(parsed.columns) && parsed.columns.length ? parsed.columns : [...DEFAULT_COLUMNS],
1511
+ tasks: Array.isArray(parsed.tasks) ? parsed.tasks : [],
1512
+ sessions: parsed.sessions ?? {},
1513
+ };
1514
+ }
1515
+ catch {
1516
+ return fallback;
1517
+ }
1518
+ })();
1519
+ const movingIdSet = new Set(taskIds);
1520
+ // Resolve the target column for a (sourceColumnId, sourceColumnTitle)
1521
+ // pair. Tries id, then case-insensitive title, then the first column.
1522
+ const resolveTargetColumn = (sourceColumnId, sourceColumnTitle) => {
1523
+ if (sourceColumnId) {
1524
+ const byId = targetBoard.columns.find((c) => c.id === sourceColumnId);
1525
+ if (byId)
1526
+ return byId.id;
1527
+ }
1528
+ if (sourceColumnTitle) {
1529
+ const lower = sourceColumnTitle.toLowerCase();
1530
+ const byTitle = targetBoard.columns.find((c) => c.title && c.title.toLowerCase() === lower);
1531
+ if (byTitle)
1532
+ return byTitle.id;
1533
+ }
1534
+ return targetBoard.columns[0].id;
1535
+ };
1536
+ const moved = [];
1537
+ const skipped = [];
1538
+ const stages = [];
1539
+ for (const taskId of taskIds) {
1540
+ const src = sourceBoard.tasks.find((t) => t.id === taskId);
1541
+ if (!src) {
1542
+ skipped.push({ id: taskId, reason: "not found" });
1543
+ continue;
1544
+ }
1545
+ const freshId = newId("tsk");
1546
+ const srcCol = sourceBoard.columns?.find((c) => c.id === src.column);
1547
+ const newColumn = resolveTargetColumn(src.column, srcCol?.title);
1548
+ const orderCount = targetBoard.tasks.filter((t) => t.column === newColumn && !t.archived).length;
1549
+ const now = nowIso();
1550
+ const arts = taskArtifacts(freshId);
1551
+ const cloned = {
1552
+ ...src,
1553
+ id: freshId,
1554
+ column: newColumn,
1555
+ sessionId: null,
1556
+ sessionArtifact: null,
1557
+ subtaskIds: [],
1558
+ parentId: null, // resolved in stage 2
1559
+ artifacts: arts,
1560
+ order: orderCount,
1561
+ createdAt: now,
1562
+ updatedAt: now,
1563
+ };
1564
+ stages.push({ sourceId: src.id, newId: freshId, newColumn, newTask: cloned });
1565
+ targetBoard.tasks.push(cloned);
1566
+ }
1567
+ // Stage 2: re-establish parent/child links using the source->new id
1568
+ // mapping. Only relink when both ends are being moved; orphaned
1569
+ // references fall back to null/undefined so the rest of the system
1570
+ // treats the task as a top-level entry.
1571
+ for (const stage of stages) {
1572
+ const src = sourceBoard.tasks.find((t) => t.id === stage.sourceId);
1573
+ if (!src)
1574
+ continue;
1575
+ // parent: if the source parent is moving, use the new parent id.
1576
+ if (src.parentId && movingIdSet.has(src.parentId)) {
1577
+ const parentStage = stages.find((s) => s.sourceId === src.parentId);
1578
+ if (parentStage) {
1579
+ stage.newTask.parentId = parentStage.newId;
1580
+ const parentCopy = targetBoard.tasks.find((t) => t.id === parentStage.newId);
1581
+ if (parentCopy && !parentCopy.subtaskIds.includes(stage.newId)) {
1582
+ parentCopy.subtaskIds = [...parentCopy.subtaskIds, stage.newId];
1583
+ }
1584
+ }
1585
+ }
1586
+ // subtasks: map each source subtask id to its new id (when moving)
1587
+ // and register it on the cloned parent.
1588
+ const childNewIds = [];
1589
+ for (const childSrcId of src.subtaskIds ?? []) {
1590
+ const childStage = stages.find((s) => s.sourceId === childSrcId);
1591
+ if (childStage)
1592
+ childNewIds.push(childStage.newId);
1593
+ }
1594
+ if (childNewIds.length) {
1595
+ const parentCopy = targetBoard.tasks.find((t) => t.id === stage.newId);
1596
+ if (parentCopy) {
1597
+ const seen = new Set(parentCopy.subtaskIds);
1598
+ for (const c of childNewIds)
1599
+ seen.add(c);
1600
+ parentCopy.subtaskIds = [...seen];
1601
+ }
1602
+ }
1603
+ }
1604
+ // Stage 3: copy per-task artifact directories from source to target.
1605
+ // If a copy fails we roll the cloned record back out of the target
1606
+ // board and surface it as a skipped entry so the response reflects
1607
+ // the true outcome.
1608
+ const successful = [];
1609
+ for (const stage of stages) {
1610
+ const srcDir = join(sourceKanbanDir, "tasks", stage.sourceId);
1611
+ const destDir = join(targetKanbanDir, "tasks", stage.newId);
1612
+ ensureDir(destDir);
1613
+ try {
1614
+ cpSync(srcDir, destDir, { recursive: true });
1615
+ successful.push(stage);
1616
+ }
1617
+ catch (e) {
1618
+ skipped.push({ id: stage.sourceId, reason: `copy failed: ${e?.message ?? e}` });
1619
+ targetBoard.tasks = targetBoard.tasks.filter((t) => t.id !== stage.newId);
1620
+ // Also drop this id from any parent's subtaskIds on the cloned
1621
+ // target so the on-disk board stays self-consistent.
1622
+ for (const t of targetBoard.tasks) {
1623
+ if (t.subtaskIds.includes(stage.newId))
1624
+ t.subtaskIds = t.subtaskIds.filter((x) => x !== stage.newId);
1625
+ }
1626
+ }
1627
+ }
1628
+ // Build the public `moved` summary from the records that survived
1629
+ // every stage.
1630
+ for (const stage of successful) {
1631
+ moved.push({
1632
+ sourceId: stage.sourceId,
1633
+ id: stage.newId,
1634
+ title: stage.newTask.title,
1635
+ column: stage.newTask.column,
1636
+ });
1637
+ }
1638
+ // Persist the target board atomically and re-render its MDX mirror.
1639
+ ensureDir(targetKanbanDir);
1640
+ writeFileAtomic(targetBoardFile, JSON.stringify(targetBoard, null, 2));
1641
+ writeBoardMdx(targetBoard, targetKanbanDir);
1642
+ // Finally, remove the source tasks from the active board and clean
1643
+ // their per-task directories. We do this last so a target-write
1644
+ // failure above doesn't leave the source board in a half-moved state.
1645
+ const successfulSourceIds = new Set(successful.map((s) => s.sourceId));
1646
+ if (successfulSourceIds.size > 0) {
1647
+ selfWriteUntil = Date.now() + 250;
1648
+ await withWrite(async (board) => {
1649
+ for (const sid of successfulSourceIds) {
1650
+ const idx = board.tasks.findIndex((t) => t.id === sid);
1651
+ if (idx === -1)
1652
+ continue;
1653
+ const removed = board.tasks[idx];
1654
+ if (removed.parentId) {
1655
+ const parent = board.tasks.find((t) => t.id === removed.parentId);
1656
+ if (parent)
1657
+ parent.subtaskIds = parent.subtaskIds.filter((x) => x !== sid);
1658
+ }
1659
+ board.tasks.splice(idx, 1);
1660
+ }
1661
+ board.tasks = renormalizeOrder(board.tasks);
1662
+ });
1663
+ for (const sid of successfulSourceIds) {
1664
+ removeDir(join(sourceKanbanDir, "tasks", sid));
1665
+ }
1666
+ const refreshed = await getBoard();
1667
+ await writeBoardMdx(refreshed, sourceKanbanDir);
1668
+ broadcast("board.updated", {});
1669
+ recordEvent(sourceKanbanDir, "task.deleted", {
1670
+ author: "user",
1671
+ summary: `moved ${successfulSourceIds.size} task(s) to project '${targetProjectId}'`,
1672
+ payload: { taskIds: [...successfulSourceIds], targetProjectId },
1673
+ });
1674
+ }
1675
+ return jsonResponse({ moved, skipped });
1676
+ }
1677
+ // ─── Import ──────────────────────────────────────────────────────────────────
1678
+ export async function apiImport(_ctx, req) {
1679
+ let body;
1680
+ try {
1681
+ body = await req.json();
1682
+ }
1683
+ catch {
1684
+ return errorResponse("Invalid JSON");
1685
+ }
1686
+ // Read defaults from config
1687
+ const configPath = join(KANBAN_DIR, "config.json");
1688
+ let config = {};
1689
+ try {
1690
+ if (existsSync(configPath))
1691
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
1692
+ }
1693
+ catch { /* ignore */ }
1694
+ const importConfig = config["import"] ?? {};
1695
+ const include = body.include ?? importConfig["include"] ?? ["docs/**", "*.md", "*.mdx"];
1696
+ const exclude = body.exclude ?? importConfig["exclude"] ?? [];
1697
+ const importCtx = {
1698
+ directory: KANBAN_DIR,
1699
+ client: null,
1700
+ log: async () => { },
1701
+ };
1702
+ const result = await runImport(importCtx, { include, exclude });
1703
+ return jsonResponse({ ok: true, imported: result.imported }, 201);
1704
+ }
1705
+ // ─── Settings ──────────────────────────────────────────────────────────────
1706
+ const DEFAULT_SETTINGS = {
1707
+ columns: ["backlog", "todo", "doing", "review", "done"],
1708
+ defaultAgent: "",
1709
+ defaultModel: null,
1710
+ };
1711
+ async function apiGetSettings() {
1712
+ const configPath = join(KANBAN_DIR, "openkan.json");
1713
+ let config = {};
1714
+ try {
1715
+ if (existsSync(configPath))
1716
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
1717
+ }
1718
+ catch { /* ignore */ }
1719
+ const projectRoot = join(KANBAN_DIR, "..");
1720
+ const gitUser = isGitRepo(projectRoot) ? { name: "git", email: "" } : null;
1721
+ const merged = { ...DEFAULT_SETTINGS, ...config, gitUser };
1722
+ return jsonResponse(merged);
1723
+ }
1724
+ async function apiPatchSettings(_ctx, req) {
1725
+ let patch;
1726
+ try {
1727
+ patch = await req.json();
1728
+ }
1729
+ catch {
1730
+ return errorResponse("Invalid JSON");
1731
+ }
1732
+ const configPath = join(KANBAN_DIR, "openkan.json");
1733
+ let existing = {};
1734
+ try {
1735
+ if (existsSync(configPath))
1736
+ existing = JSON.parse(readFileSync(configPath, "utf-8"));
1737
+ }
1738
+ catch { /* ignore */ }
1739
+ const merged = { ...existing, ...patch };
1740
+ writeFileAtomic(configPath, JSON.stringify(merged, null, 2));
1741
+ recordEvent(KANBAN_DIR, "settings.changed", {
1742
+ author: "user",
1743
+ summary: "changed settings",
1744
+ payload: { changes: Object.keys(patch) },
1745
+ });
1746
+ return jsonResponse({ ok: true, settings: merged });
1747
+ }
1748
+ function loadConfig() {
1749
+ const configPath = join(KANBAN_DIR, "openkan.json");
1750
+ try {
1751
+ if (existsSync(configPath)) {
1752
+ return JSON.parse(readFileSync(configPath, "utf-8"));
1753
+ }
1754
+ }
1755
+ catch { /* ignore */ }
1756
+ return {};
1757
+ }
1758
+ export async function apiGetConfigSections() {
1759
+ const config = loadConfig();
1760
+ const sections = [
1761
+ {
1762
+ id: "project",
1763
+ label: "Project",
1764
+ fields: [
1765
+ { key: "defaultAgent", label: "Default agent", type: "text", value: config["project"]?.["defaultAgent"] ?? "" },
1766
+ { key: "defaultModel", label: "Default model", type: "text", value: config["project"]?.["defaultModel"] ?? "" },
1767
+ { key: "defaultColumn", label: "Default column", type: "select", value: config["project"]?.["defaultColumn"] ?? "backlog", options: [{ label: "Backlog", value: "backlog" }, { label: "To Do", value: "todo" }, { label: "In Progress", value: "doing" }, { label: "Review", value: "review" }, { label: "Done", value: "done" }], description: "Where newly created tasks start." },
1768
+ { key: "autoArchiveDays", label: "Auto-archive days", type: "number", value: config["project"]?.["autoArchiveDays"] ?? 0, description: "Use 0 to keep completed tasks indefinitely." },
1769
+ ],
1770
+ },
1771
+ {
1772
+ id: "server",
1773
+ label: "Server",
1774
+ fields: [
1775
+ { key: "port", label: "Port", type: "number", value: config["port"] ?? 7777 },
1776
+ { key: "host", label: "Host", type: "text", value: config["host"] ?? "127.0.0.1" },
1777
+ ],
1778
+ },
1779
+ {
1780
+ id: "ui",
1781
+ label: "UI",
1782
+ fields: [
1783
+ {
1784
+ key: "theme", label: "Theme", type: "select", description: "Applied immediately and remembered for this browser.",
1785
+ value: config["theme"] ?? "dark",
1786
+ options: [
1787
+ { label: "Light", value: "light" },
1788
+ { label: "Dark", value: "dark" },
1789
+ { label: "System", value: "system" },
1790
+ ],
1791
+ },
1792
+ ],
1793
+ },
1794
+ {
1795
+ id: "sandbox",
1796
+ label: "Sandbox",
1797
+ fields: [
1798
+ { key: "tsxMaxBytes", label: "TSX max bytes", type: "number", value: config["sandbox"]?.["tsxMaxBytes"] ?? 32768 },
1799
+ ],
1800
+ },
1801
+ {
1802
+ id: "bizar",
1803
+ label: "Agent runtime",
1804
+ fields: [
1805
+ {
1806
+ key: "enabled",
1807
+ label: "Enabled",
1808
+ type: "boolean",
1809
+ value: config["bizar"]?.["enabled"] ?? true,
1810
+ description: "Expose this project's configured Bizar control plane.",
1811
+ },
1812
+ {
1813
+ key: "projectRoot",
1814
+ label: "Bizar project root",
1815
+ type: "text",
1816
+ value: config["bizar"]?.["projectRoot"] ?? ".",
1817
+ description: "Absolute path or path relative to the OpenKan project.",
1818
+ },
1819
+ {
1820
+ key: "command",
1821
+ label: "Bizar command",
1822
+ type: "text",
1823
+ value: config["bizar"]?.["command"] ?? "bizar",
1824
+ description: "Executable or local cli/bin.mjs path. Never evaluated through a shell.",
1825
+ },
1826
+ ],
1827
+ },
1828
+ {
1829
+ id: "import",
1830
+ label: "Import",
1831
+ fields: [
1832
+ { key: "include", label: "Include paths", type: "text", value: JSON.stringify(config["import"]?.["include"] ?? []) },
1833
+ { key: "exclude", label: "Exclude paths", type: "text", value: JSON.stringify(config["import"]?.["exclude"] ?? []) },
1834
+ ],
1835
+ },
1836
+ {
1837
+ id: "chat",
1838
+ label: "Chat",
1839
+ fields: [
1840
+ { key: "defaultModel", label: "Default model", type: "text", value: config["chat"]?.["defaultModel"] ?? "", description: "Leave empty to use the model router's configured default." },
1841
+ { key: "defaultEffort", label: "Default effort", type: "select", value: config["chat"]?.["defaultEffort"] ?? "high", options: [{ label: "Low", value: "low" }, { label: "Medium", value: "medium" }, { label: "High", value: "high" }] },
1842
+ { key: "permissionMode", label: "Permission mode", type: "select", value: config["chat"]?.["permissionMode"] ?? "default", options: [{ label: "Default", value: "default" }, { label: "Accept edits", value: "acceptEdits" }, { label: "Plan", value: "plan" }] },
1843
+ ],
1844
+ },
1845
+ {
1846
+ id: "notifications",
1847
+ label: "Notifications",
1848
+ fields: [
1849
+ { key: "desktop", label: "Desktop notifications", type: "boolean", value: config["notifications"]?.["desktop"] ?? false },
1850
+ { key: "sound", label: "Completion sound", type: "boolean", value: config["notifications"]?.["sound"] ?? false },
1851
+ ],
1852
+ },
1853
+ {
1854
+ id: "advanced",
1855
+ label: "Advanced",
1856
+ fields: [
1857
+ { key: "autoArchiveAfterDays", label: "Auto-archive after days", type: "number", value: config["autoArchiveAfterDays"] ?? 0 },
1858
+ ],
1859
+ },
1860
+ {
1861
+ id: "agents",
1862
+ label: "Agents",
1863
+ fields: [
1864
+ {
1865
+ key: "active",
1866
+ label: "Active profile",
1867
+ type: "text",
1868
+ value: config["agents"]?.["active"] ?? "claude-code",
1869
+ },
1870
+ {
1871
+ key: "profiles",
1872
+ label: "Profiles (read-only)",
1873
+ type: "text",
1874
+ value: JSON.stringify(config["agents"]?.["profiles"] ?? []),
1875
+ },
1876
+ ],
1877
+ },
1878
+ {
1879
+ id: "contributors",
1880
+ label: "Contributors",
1881
+ fields: [],
1882
+ },
1883
+ ];
1884
+ return jsonResponse({ sections });
1885
+ }
1886
+ export async function apiPatchConfigSection(_ctx, sectionId, req) {
1887
+ let body;
1888
+ try {
1889
+ body = await req.json();
1890
+ }
1891
+ catch {
1892
+ return errorResponse("Invalid JSON");
1893
+ }
1894
+ if (!Array.isArray(body))
1895
+ return errorResponse("body must be an array of { key, value }", 422);
1896
+ const config = loadConfig();
1897
+ // Map sectionId to config path
1898
+ for (const { key, value } of body) {
1899
+ switch (sectionId) {
1900
+ case "project":
1901
+ if (!config["project"])
1902
+ config["project"] = {};
1903
+ config["project"][key] = value;
1904
+ break;
1905
+ case "server":
1906
+ config[key] = value;
1907
+ break;
1908
+ case "ui":
1909
+ config[key] = value;
1910
+ break;
1911
+ case "sandbox":
1912
+ if (!config["sandbox"])
1913
+ config["sandbox"] = {};
1914
+ config["sandbox"][key] = value;
1915
+ break;
1916
+ case "bizar":
1917
+ if (!config["bizar"])
1918
+ config["bizar"] = {};
1919
+ config["bizar"][key] = value;
1920
+ break;
1921
+ case "import":
1922
+ if (!config["import"])
1923
+ config["import"] = {};
1924
+ if (key === "include" || key === "exclude") {
1925
+ // Stored as JSON arrays
1926
+ config["import"][key] = typeof value === "string" ? JSON.parse(value) : value;
1927
+ }
1928
+ else {
1929
+ config["import"][key] = value;
1930
+ }
1931
+ break;
1932
+ case "chat":
1933
+ case "notifications":
1934
+ if (!config[sectionId])
1935
+ config[sectionId] = {};
1936
+ config[sectionId][key] = value;
1937
+ break;
1938
+ case "advanced":
1939
+ config[key] = value;
1940
+ break;
1941
+ case "agents":
1942
+ if (!config["agents"])
1943
+ config["agents"] = { active: "claude-code", profiles: [] };
1944
+ config["agents"][key] = value;
1945
+ break;
1946
+ default:
1947
+ return errorResponse(`Unknown section: ${sectionId}`, 404);
1948
+ }
1949
+ }
1950
+ const configPath = join(KANBAN_DIR, "openkan.json");
1951
+ // Validate agents section before persisting
1952
+ if (sectionId === "agents") {
1953
+ const agents = config["agents"];
1954
+ if (agents) {
1955
+ const err = validateAgentsConfig(agents);
1956
+ if (err)
1957
+ return errorResponse(`Invalid agents config at ${err.path}: ${err.reason}`, 422);
1958
+ }
1959
+ }
1960
+ writeFileAtomic(configPath, JSON.stringify(config, null, 2));
1961
+ // Return the updated section
1962
+ return apiGetConfigSections();
1963
+ }
1964
+ // ─── Updated tasks index (includes archived) ────────────────────────────────
1965
+ function taskToIndexEntry(task, _includeArchived) {
1966
+ return {
1967
+ id: task.id,
1968
+ title: task.title,
1969
+ column: task.column,
1970
+ order: task.order,
1971
+ state: task.state,
1972
+ mdxPath: task.artifacts?.mdxPath ?? task.artifact,
1973
+ agent: task.agent,
1974
+ model: task.model,
1975
+ createdAt: task.createdAt,
1976
+ updatedAt: task.updatedAt,
1977
+ source: task.source,
1978
+ tags: task.tags ?? [],
1979
+ category: task.category ?? "task",
1980
+ priority: task.priority ?? "normal",
1981
+ effort: task.effort ?? null,
1982
+ archived: task.archived,
1983
+ contributors: [],
1984
+ };
1985
+ }
1986
+ async function apiGetTasksIndex(req) {
1987
+ const url = new URL(req.url);
1988
+ const includeArchived = url.searchParams.get("includeArchived") === "true";
1989
+ const board = await getBoard();
1990
+ let tasks = board.tasks.map(t => taskToIndexEntry(t, includeArchived));
1991
+ if (!includeArchived)
1992
+ tasks = tasks.filter(t => !t.archived);
1993
+ // Add top-5 contributors per task
1994
+ const projectRoot = join(KANBAN_DIR, "..");
1995
+ if (isGitRepo(projectRoot)) {
1996
+ const allContributors = listContributors(projectRoot, {});
1997
+ for (const t of tasks) {
1998
+ const attributed = attributeCommitsToTasks(projectRoot, [{ id: t.id, title: t.title, source: t.source }], {});
1999
+ const taskCommits = attributed.get(t.id) ?? [];
2000
+ const topEmails = [...new Set(taskCommits.map(c => c.email))].slice(0, 5);
2001
+ t.contributors = topEmails.map(email => {
2002
+ const c = allContributors.find(x => x.email === email);
2003
+ return c ? { name: c.name, email: c.email, lastSeen: c.lastSeen } : { name: email, email, lastSeen: "" };
2004
+ });
2005
+ }
2006
+ }
2007
+ return jsonResponse({ tasks });
2008
+ }
2009
+ // ─── Updated task detail (adds contributors) ───────────────────────────────
2010
+ export async function apiGetTask(taskId) {
2011
+ const board = await getBoard();
2012
+ const task = board.tasks.find(t => t.id === taskId);
2013
+ if (!task)
2014
+ return errorResponse("Task not found", 404);
2015
+ let mdx = "";
2016
+ const mdxPath = join(KANBAN_DIR, task.artifacts.mdxPath);
2017
+ if (existsSync(mdxPath)) {
2018
+ try {
2019
+ if (statSync(mdxPath).isFile())
2020
+ mdx = readFileSync(mdxPath, "utf-8");
2021
+ }
2022
+ catch { /* ignore */ }
2023
+ }
2024
+ const blocks = await renderMdx(mdx);
2025
+ const renderedHtml = await renderMarkdown(mdx);
2026
+ const comments = listComments(taskId, KANBAN_DIR);
2027
+ const inputs = listInputs(taskId, KANBAN_DIR);
2028
+ // Fetch immediate subtasks
2029
+ const subtasks = task.subtaskIds
2030
+ .map(id => board.tasks.find(t => t.id === id))
2031
+ .filter((t) => t !== undefined);
2032
+ const projectRoot = join(KANBAN_DIR, "..");
2033
+ let commits = [];
2034
+ if (isGitRepo(projectRoot)) {
2035
+ const attributed = attributeCommitsToTasks(projectRoot, [{ id: task.id, title: task.title, source: task.source }], {});
2036
+ commits = attributed.get(task.id) ?? [];
2037
+ }
2038
+ return jsonResponse({
2039
+ task,
2040
+ mdx,
2041
+ metadata: {
2042
+ title: task.title,
2043
+ description: extractDescription(mdx),
2044
+ tags: task.tags || [],
2045
+ category: task.category,
2046
+ priority: task.priority,
2047
+ effort: task.effort,
2048
+ assignees: task.assignees || [],
2049
+ updatedAt: task.updatedAt,
2050
+ mtime: statMdxMtime(taskId, KANBAN_DIR) || null,
2051
+ },
2052
+ html: renderedHtml,
2053
+ blocks: blocks.blocks,
2054
+ comments,
2055
+ inputs,
2056
+ subtasks,
2057
+ attributions: commits,
2058
+ });
2059
+ }
2060
+ export async function apiGetSubtasks(taskId) {
2061
+ const board = await getBoard();
2062
+ const task = board.tasks.find(t => t.id === taskId);
2063
+ if (!task)
2064
+ return errorResponse("Task not found", 404);
2065
+ const subtasks = task.subtaskIds
2066
+ .map(id => board.tasks.find(t => t.id === id))
2067
+ .filter((t) => t !== undefined);
2068
+ return jsonResponse({ subtasks });
2069
+ }
2070
+ // ─── Image endpoints ──────────────────────────────────────────────────────────
2071
+ /**
2072
+ * POST /api/tasks/:id/images
2073
+ * Body (v1 JSON): { data: base64, filename?: string, contentType?: string }
2074
+ * Frontend sends files as base64 via FileReader.readAsDataURL().
2075
+ * Alternative (not implemented in v1): multipart/form-data with raw bytes.
2076
+ */
2077
+ async function apiUploadImage(_ctx, taskId, req) {
2078
+ let body;
2079
+ try {
2080
+ body = await req.json();
2081
+ }
2082
+ catch {
2083
+ return errorResponse("Invalid JSON");
2084
+ }
2085
+ if (!body.data)
2086
+ return errorResponse("data (base64) is required", 422);
2087
+ // Decode base64
2088
+ let buffer;
2089
+ try {
2090
+ const base64Data = body.data.replace(/^data:[^;]+;base64,/, ""); // strip optional mime prefix
2091
+ buffer = Buffer.from(base64Data, "base64");
2092
+ }
2093
+ catch {
2094
+ return errorResponse("Invalid base64 data", 422);
2095
+ }
2096
+ // Determine extension from contentType or filename
2097
+ let ext = "";
2098
+ if (body.filename) {
2099
+ const m = body.filename.match(/\.([^.]+)$/);
2100
+ if (m)
2101
+ ext = m[1];
2102
+ }
2103
+ if (!ext && body.contentType) {
2104
+ const map = {
2105
+ "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif",
2106
+ "image/webp": "webp", "image/svg+xml": "svg",
2107
+ };
2108
+ ext = map[body.contentType] ?? "";
2109
+ }
2110
+ if (!ext)
2111
+ ext = "png"; // default
2112
+ const author = "user";
2113
+ let meta;
2114
+ try {
2115
+ meta = saveImage(taskId, KANBAN_DIR, buffer, ext, body.contentType ?? `image/${ext}`, author);
2116
+ }
2117
+ catch (e) {
2118
+ return errorResponse(e?.message ?? "Failed to save image", 422);
2119
+ }
2120
+ // Add image filename to task.images list
2121
+ selfWriteUntil = Date.now() + 250;
2122
+ await withWrite(async (board) => {
2123
+ const t = board.tasks.find(t => t.id === taskId);
2124
+ if (t) {
2125
+ if (!t.images)
2126
+ t.images = [];
2127
+ if (!t.images.includes(meta.name))
2128
+ t.images.push(meta.name);
2129
+ t.updatedAt = nowIso();
2130
+ }
2131
+ });
2132
+ broadcast("task.image-added", { taskId, image: meta });
2133
+ recordEvent(KANBAN_DIR, "task.image-added", {
2134
+ taskId,
2135
+ author,
2136
+ summary: `uploaded image '${meta.name}'`,
2137
+ payload: { imageName: meta.name, size: meta.size },
2138
+ });
2139
+ return jsonResponse(meta, 201);
2140
+ }
2141
+ async function apiListImages(_ctx, taskId) {
2142
+ const images = listImages(taskId, KANBAN_DIR);
2143
+ return jsonResponse({ images });
2144
+ }
2145
+ async function apiGetImage(_ctx, taskId, name) {
2146
+ const result = readImage(taskId, KANBAN_DIR, name);
2147
+ if (!result)
2148
+ return errorResponse("Image not found", 404);
2149
+ return new Response(result.buffer, {
2150
+ headers: {
2151
+ "Content-Type": result.contentType,
2152
+ "Cache-Control": "max-age=3600",
2153
+ },
2154
+ });
2155
+ }
2156
+ async function apiDeleteImage(_ctx, taskId, name) {
2157
+ const ok = deleteImage(taskId, KANBAN_DIR, name);
2158
+ if (!ok)
2159
+ return errorResponse("Image not found", 404);
2160
+ // Remove from task.images list
2161
+ selfWriteUntil = Date.now() + 250;
2162
+ await withWrite(async (board) => {
2163
+ const t = board.tasks.find(t => t.id === taskId);
2164
+ if (t && t.images) {
2165
+ t.images = t.images.filter(n => n !== name);
2166
+ t.updatedAt = nowIso();
2167
+ }
2168
+ });
2169
+ broadcast("task.image-deleted", { taskId, imageName: name });
2170
+ recordEvent(KANBAN_DIR, "task.image-deleted", {
2171
+ taskId,
2172
+ author: "user",
2173
+ summary: `deleted image '${name}'`,
2174
+ payload: { imageName: name },
2175
+ });
2176
+ return jsonResponse({ ok: true });
2177
+ }
2178
+ // ─── /api/me ─────────────────────────────────────────────────────────────────
2179
+ async function apiGetMe(_ctx) {
2180
+ const projectRoot = join(KANBAN_DIR, "..");
2181
+ const gitUser = currentGitUser(projectRoot);
2182
+ const name = gitUser?.name ?? "user";
2183
+ const email = gitUser?.email ?? "";
2184
+ // Count tasks assigned to this user
2185
+ const board = await getBoard();
2186
+ const currentTasks = board.tasks.filter(t => t.assignees?.includes(name) && !t.archived).length;
2187
+ return jsonResponse({ name, email, currentTasks });
2188
+ }
2189
+ // ─── Project registry endpoints ───────────────────────────────────────────────
2190
+ async function apiGetProjects() {
2191
+ return jsonResponse({ active: activeProject(), projects: listProjects() });
2192
+ }
2193
+ async function apiCreateProject(req) {
2194
+ let body;
2195
+ try {
2196
+ body = await req.json();
2197
+ }
2198
+ catch {
2199
+ return errorResponse("Invalid JSON");
2200
+ }
2201
+ if (!body.root)
2202
+ return errorResponse("root is required", 422);
2203
+ const entry = addProject({ name: body.name ?? body.root, root: body.root });
2204
+ await ensureBoardForProject(projectBoardContext(entry.root));
2205
+ return jsonResponse(entry, 201);
2206
+ }
2207
+ async function apiAutoDetectProjects(req) {
2208
+ let body;
2209
+ try {
2210
+ body = await req.json().catch(() => ({}));
2211
+ }
2212
+ catch {
2213
+ body = {};
2214
+ }
2215
+ const result = await autoDetectProjects({
2216
+ homes: body.homes,
2217
+ suffixes: body.suffixes,
2218
+ maxResults: body.maxResults,
2219
+ });
2220
+ return jsonResponse(result);
2221
+ }
2222
+ async function apiDeleteProject(id) {
2223
+ const wasActive = activeProject()?.id === id;
2224
+ const ok = removeProject(id);
2225
+ if (!ok)
2226
+ return errorResponse("Project not found", 404);
2227
+ // If the deleted project was active, the registry already switched to the next one
2228
+ if (wasActive) {
2229
+ const newActive = activeProject();
2230
+ if (newActive) {
2231
+ await ensureBoardForProject(projectBoardContext(newActive.root));
2232
+ }
2233
+ }
2234
+ return jsonResponse({ ok: true });
2235
+ }
2236
+ async function apiActivateProject(id) {
2237
+ const prev = setActiveProject(id);
2238
+ if (prev === null)
2239
+ return errorResponse("Project not found", 404);
2240
+ const entry = activeProject();
2241
+ if (entry)
2242
+ await ensureBoardForProject(projectBoardContext(entry.root));
2243
+ return jsonResponse(entry);
2244
+ }
2245
+ function projectBoardContext(directory) {
2246
+ return { directory, client: null, log: async () => undefined };
2247
+ }
2248
+ // ─── Active project info endpoint ─────────────────────────────────────────────
2249
+ async function apiGetProject() {
2250
+ return jsonResponse({ active: activeProject() });
2251
+ }
2252
+ // ─── Docs endpoints ───────────────────────────────────────────────────────────
2253
+ async function apiGetDocs() {
2254
+ const root = getActiveProjectRoot();
2255
+ const { entries } = listDocs({ root });
2256
+ return jsonResponse({ entries });
2257
+ }
2258
+ async function apiGetDoc(req, path) {
2259
+ const root = getActiveProjectRoot();
2260
+ const url = new URL(req.url);
2261
+ const rawFlag = url.searchParams.get("raw") === "1";
2262
+ try {
2263
+ const doc = await readDoc({ root, relPath: path, render: !rawFlag });
2264
+ if (rawFlag) {
2265
+ return new Response(doc.raw, { headers: { "Content-Type": "text/markdown" } });
2266
+ }
2267
+ return jsonResponse(doc);
2268
+ }
2269
+ catch (e) {
2270
+ return errorResponse(e.message ?? "Not found", 404);
2271
+ }
2272
+ }
2273
+ async function apiWriteDoc(req, relPath) {
2274
+ const root = getActiveProjectRoot();
2275
+ if (!relPath || relPath.includes("..") || !/\.(md|mdx|txt)$/i.test(relPath))
2276
+ return errorResponse("Use a safe .md, .mdx, or .txt path", 422);
2277
+ let body;
2278
+ try {
2279
+ body = await req.json();
2280
+ }
2281
+ catch {
2282
+ return errorResponse("Invalid JSON");
2283
+ }
2284
+ if (typeof body.content !== "string")
2285
+ return errorResponse("content is required", 422);
2286
+ const docsRoot = resolve(root, "docs");
2287
+ const target = resolve(docsRoot, relPath);
2288
+ if (!target.startsWith(`${docsRoot}/`))
2289
+ return errorResponse("Unsafe path", 422);
2290
+ mkdirSync(join(target, ".."), { recursive: true });
2291
+ writeFileSync(target, body.content, "utf8");
2292
+ return apiGetDoc(new Request(`http://local/api/docs/${encodeURI(relPath)}`), relPath);
2293
+ }
2294
+ async function apiDeleteDoc(relPath) {
2295
+ const root = getActiveProjectRoot();
2296
+ const docsRoot = resolve(root, "docs");
2297
+ const target = resolve(docsRoot, relPath);
2298
+ if (!relPath || relPath.includes("..") || !target.startsWith(`${docsRoot}/`) || !existsSync(target))
2299
+ return errorResponse("Document not found", 404);
2300
+ rmSync(target);
2301
+ return jsonResponse({ ok: true, path: relPath });
2302
+ }
2303
+ async function apiRenderDoc(req) {
2304
+ let body;
2305
+ try {
2306
+ body = await req.json();
2307
+ }
2308
+ catch {
2309
+ return errorResponse("Invalid JSON");
2310
+ }
2311
+ if (typeof body.content !== "string")
2312
+ return errorResponse("content is required", 422);
2313
+ const rendered = await renderMdx(body.content);
2314
+ return jsonResponse({ html: rendered.html, rendered: rendered.html, blocks: rendered.blocks });
2315
+ }
2316
+ async function apiGenerateDoc(req) {
2317
+ const root = getActiveProjectRoot();
2318
+ let body;
2319
+ try {
2320
+ body = await req.json();
2321
+ }
2322
+ catch {
2323
+ return errorResponse("Invalid JSON");
2324
+ }
2325
+ const path = typeof body.path === "string" ? body.path : "";
2326
+ const prompt = typeof body.prompt === "string" ? body.prompt : "";
2327
+ if (!path || !prompt)
2328
+ return errorResponse("path and prompt are required", 422);
2329
+ const result = await sendTurn(root, { message: `Write a complete Markdown document for docs/${path}. ${prompt}. Return only the document content.`, model: typeof body.model === "string" ? body.model : "default", effort: typeof body.effort === "string" ? body.effort : "high", permissionMode: typeof body.permissionMode === "string" ? body.permissionMode : "bypassPermissions" });
2330
+ const content = result.assistantTurn.content;
2331
+ const writeReq = new Request("http://local/api/docs", { method: "PUT", body: JSON.stringify({ content }), headers: { "content-type": "application/json" } });
2332
+ return apiWriteDoc(writeReq, path);
2333
+ }
2334
+ // ─── File-system browser endpoints ─────────────────────────────────────────────
2335
+ /**
2336
+ * GET /api/fs?path=/abs/path&depth=2&includeHidden=0
2337
+ * Returns FsEntry for that path with children up to depth.
2338
+ * 400 if path is outside allowed roots.
2339
+ */
2340
+ async function apiFs(req) {
2341
+ const url = new URL(req.url);
2342
+ const rawPath = url.searchParams.get("path");
2343
+ if (!rawPath)
2344
+ return errorResponse("path is required", 400);
2345
+ // Must be absolute
2346
+ if (!rawPath.startsWith("/"))
2347
+ return errorResponse("path must be absolute", 400);
2348
+ // Deny-list check
2349
+ if (isDenyListed(rawPath))
2350
+ return errorResponse("Access denied: deny-listed path", 403);
2351
+ // Symlink check: canonicalize and verify allowed
2352
+ const { realPath, allowed } = realPathIfAllowed(rawPath);
2353
+ if (!allowed)
2354
+ return errorResponse("Access denied: symlink resolves outside allowed tree", 403);
2355
+ // Cap depth at 5, maxEntries at 1000
2356
+ const depth = Math.min(parseInt(url.searchParams.get("depth") ?? "1", 10), 5);
2357
+ const includeHidden = url.searchParams.get("includeHidden") === "1" || url.searchParams.get("includeHidden") === "true";
2358
+ const maxEntries = Math.min(parseInt(url.searchParams.get("maxEntries") ?? "500", 10), 1000);
2359
+ const result = await listFs({ root: realPath, depth, includeHidden, followSymlinks: false, maxEntries });
2360
+ return jsonResponse(result);
2361
+ }
2362
+ /**
2363
+ * GET /api/home
2364
+ * Returns { home, entries } for the user's home directory.
2365
+ */
2366
+ async function apiHome() {
2367
+ const result = await readHome();
2368
+ return jsonResponse(result);
2369
+ }
2370
+ /**
2371
+ * GET /api/parents?path=/abs/path&maxDepth=8
2372
+ * Returns array of FsEntry for ancestor directories (each depth 0, no children).
2373
+ * Used for breadcrumbs.
2374
+ */
2375
+ async function apiParents(req) {
2376
+ const url = new URL(req.url);
2377
+ const rawPath = url.searchParams.get("path");
2378
+ if (!rawPath)
2379
+ return errorResponse("path is required", 400);
2380
+ if (!rawPath.startsWith("/"))
2381
+ return errorResponse("path must be absolute", 400);
2382
+ if (isDenyListed(rawPath))
2383
+ return errorResponse("Access denied: deny-listed path", 403);
2384
+ const { allowed } = realPathIfAllowed(rawPath);
2385
+ if (!allowed)
2386
+ return errorResponse("Access denied: symlink resolves outside allowed tree", 403);
2387
+ const maxDepth = Math.min(parseInt(url.searchParams.get("maxDepth") ?? "8", 10), 8);
2388
+ const result = parents(rawPath, maxDepth);
2389
+ return jsonResponse(result);
2390
+ }
2391
+ // ─── Event handler ───────────────────────────────────────────────────────────
2392
+ export async function handleEvent(ctx, event) {
2393
+ if (!event?.type)
2394
+ return;
2395
+ const sid = event.properties?.sessionID ?? event.properties?.sessionId ?? "";
2396
+ switch (event.type) {
2397
+ case "session.idle": {
2398
+ if (!sid)
2399
+ break;
2400
+ const board = await getBoard();
2401
+ const rec = board.sessions[sid];
2402
+ if (!rec)
2403
+ break;
2404
+ const task = board.tasks.find(t => t.id === rec.taskId);
2405
+ const endedAt = nowIso();
2406
+ await withWrite(async (b) => {
2407
+ const r = b.sessions[sid];
2408
+ if (!r)
2409
+ return;
2410
+ r.status = "done";
2411
+ r.endedAt = endedAt;
2412
+ const t = b.tasks.find(t => t.id === r.taskId);
2413
+ if (t) {
2414
+ t.state = "done";
2415
+ t.updatedAt = nowIso();
2416
+ }
2417
+ });
2418
+ const updated = (await getBoard()).tasks.find(t => t.id === rec.taskId);
2419
+ if (updated) {
2420
+ await writeTaskMdx(updated, KANBAN_DIR, await getBoard());
2421
+ broadcast("task.updated", updated);
2422
+ }
2423
+ await writeSessionMdx(sid, (await getBoard()).sessions[sid], updated, KANBAN_DIR, ctx.client);
2424
+ await writeBoardMdx(await getBoard(), KANBAN_DIR);
2425
+ broadcast("session.ended", { sessionId: sid, status: "done" });
2426
+ if (rec) {
2427
+ recordEvent(KANBAN_DIR, "agent.ended", {
2428
+ taskId: rec.taskId,
2429
+ author: "agent",
2430
+ summary: `agent ended (done) on '${task?.title ?? rec.taskId}'`,
2431
+ payload: { sessionId: sid, status: "done" },
2432
+ });
2433
+ }
2434
+ break;
2435
+ }
2436
+ case "session.error": {
2437
+ if (!sid)
2438
+ break;
2439
+ const board = await getBoard();
2440
+ const rec = board.sessions[sid];
2441
+ if (!rec)
2442
+ break;
2443
+ const errMsg = event.properties?.error ?? event.properties?.message ?? "Unknown error";
2444
+ const endedAt = nowIso();
2445
+ await withWrite(async (b) => {
2446
+ const r = b.sessions[sid];
2447
+ if (!r)
2448
+ return;
2449
+ r.status = "failed";
2450
+ r.endedAt = endedAt;
2451
+ const t = b.tasks.find(t => t.id === r.taskId);
2452
+ if (t) {
2453
+ t.state = "failed";
2454
+ t.lastError = errMsg;
2455
+ t.updatedAt = nowIso();
2456
+ }
2457
+ });
2458
+ const updated = (await getBoard()).tasks.find(t => t.id === rec.taskId);
2459
+ if (updated) {
2460
+ await writeTaskMdx(updated, KANBAN_DIR, await getBoard());
2461
+ broadcast("task.updated", updated);
2462
+ }
2463
+ await writeSessionMdx(sid, (await getBoard()).sessions[sid], updated, KANBAN_DIR, ctx.client);
2464
+ await writeBoardMdx(await getBoard(), KANBAN_DIR);
2465
+ broadcast("session.ended", { sessionId: sid, status: "failed", error: errMsg });
2466
+ if (rec) {
2467
+ recordEvent(KANBAN_DIR, "agent.ended", {
2468
+ taskId: rec.taskId,
2469
+ author: "agent",
2470
+ summary: `agent ended (failed) on '${updated?.title ?? rec.taskId}'`,
2471
+ payload: { sessionId: sid, status: "failed", error: errMsg },
2472
+ });
2473
+ }
2474
+ break;
2475
+ }
2476
+ case "session.status": {
2477
+ if (sid)
2478
+ sessionStatusCache.set(sid, event.properties?.status ?? "unknown");
2479
+ break;
2480
+ }
2481
+ default: break;
2482
+ }
2483
+ }
2484
+ // ─── Server start (private helper) ──────────────────────────────────────────
2485
+ async function _startServer(ctx, opts, extraServerOpts) {
2486
+ const host = opts.host ?? "127.0.0.1";
2487
+ const basePort = opts.port ?? 7777;
2488
+ const maxTries = opts.maxPortTries ?? 10;
2489
+ const webRoot = opts.webRoot ?? join(ctx.directory, "..", "..", "web");
2490
+ let port = basePort;
2491
+ let server = null;
2492
+ let lastErr = null;
2493
+ for (let attempt = 0; attempt < maxTries; attempt++) {
2494
+ try {
2495
+ server = createServer();
2496
+ await new Promise((resolve, reject) => {
2497
+ const onError = (err) => {
2498
+ server?.off("listening", onListening);
2499
+ reject(err);
2500
+ };
2501
+ const onListening = () => { server?.off("error", onError); resolve(); };
2502
+ server?.once("error", onError);
2503
+ server?.once("listening", onListening);
2504
+ server?.listen(port, host);
2505
+ });
2506
+ break;
2507
+ }
2508
+ catch (e) {
2509
+ if (e?.code === "EADDRINUSE" || String(e?.message).includes("EADDRINUSE")) {
2510
+ lastErr = e;
2511
+ server?.close();
2512
+ port++;
2513
+ continue;
2514
+ }
2515
+ throw e;
2516
+ }
2517
+ }
2518
+ if (!server)
2519
+ throw lastErr ?? new Error(`Could not bind server after ${maxTries} attempts`);
2520
+ return { server, port, hostname: host };
2521
+ }
2522
+ // ─── PID + lock helpers ──────────────────────────────────────────────────────
2523
+ const PID_FILE = "server.pid";
2524
+ const LOCK_FILE = "server.lock";
2525
+ function readPidFile(dir) {
2526
+ const pidPath = join(dir, PID_FILE);
2527
+ if (!existsSync(pidPath))
2528
+ return null;
2529
+ try {
2530
+ const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
2531
+ return isNaN(pid) ? null : pid;
2532
+ }
2533
+ catch {
2534
+ return null;
2535
+ }
2536
+ }
2537
+ function writePidFile(dir, pid) {
2538
+ const pidPath = join(dir, PID_FILE);
2539
+ writeFileSync(pidPath, String(pid), "utf-8");
2540
+ }
2541
+ function deletePidFile(dir) {
2542
+ try {
2543
+ const pidPath = join(dir, PID_FILE);
2544
+ if (existsSync(pidPath))
2545
+ unlinkSync(pidPath);
2546
+ }
2547
+ catch { /* ignore */ }
2548
+ }
2549
+ function isPidAlive(pid) {
2550
+ try {
2551
+ process.kill(pid, 0);
2552
+ return true;
2553
+ }
2554
+ catch {
2555
+ return false;
2556
+ }
2557
+ }
2558
+ async function probeServer(host, port) {
2559
+ try {
2560
+ const controller = new AbortController();
2561
+ const timeout = setTimeout(() => controller.abort(), 1000);
2562
+ try {
2563
+ const res = await fetch(`http://${host}:${port}/api/board`, { signal: controller.signal });
2564
+ clearTimeout(timeout);
2565
+ if (!res.ok)
2566
+ return false;
2567
+ const json = await res.json();
2568
+ return !!json.version; // basic validity check
2569
+ }
2570
+ catch {
2571
+ clearTimeout(timeout);
2572
+ return false;
2573
+ }
2574
+ }
2575
+ catch {
2576
+ return false;
2577
+ }
2578
+ }
2579
+ export async function startOrAttach(ctx, opts = {}) {
2580
+ const host = opts.host ?? "127.0.0.1";
2581
+ const basePort = opts.port ?? 7777;
2582
+ const maxTries = opts.maxPortTries ?? 10;
2583
+ const dir = join(ctx.directory, ".ok");
2584
+ // 1. Check if existing server is alive
2585
+ const existingPid = readPidFile(dir);
2586
+ if (existingPid && isPidAlive(existingPid)) {
2587
+ const alive = await probeServer(host, basePort);
2588
+ if (alive) {
2589
+ // Attach to existing server (not primary)
2590
+ runningServer = {
2591
+ port: basePort,
2592
+ hostname: host,
2593
+ url: `http://${host}:${basePort}`,
2594
+ pid: existingPid,
2595
+ isPrimary: false,
2596
+ broadcast,
2597
+ async stop() { },
2598
+ };
2599
+ return runningServer;
2600
+ }
2601
+ }
2602
+ // 2. Try to acquire the lock. We use a "create-if-not-exists" file lock:
2603
+ // writeFileSync with { flag: "wx" } fails if the file already exists.
2604
+ // This is sufficient for local single-user, single-server operation;
2605
+ // not race-free under heavy concurrent access (rare in practice), and
2606
+ // a stale lock is auto-cleared on the next start if the PID is dead.
2607
+ // On systems with `flock(2)` available, we layer it on top for safety.
2608
+ // TODO: switch to flock(2) on Node versions that expose `node:fs.flock`.
2609
+ const lockPath = join(dir, LOCK_FILE);
2610
+ ensureDir(dir);
2611
+ let hasLock = false;
2612
+ let lockFd = null;
2613
+ try {
2614
+ // Clean up a stale lock from a dead PID.
2615
+ const existingPid = readPidFile(dir);
2616
+ if (!existingPid || !isPidAlive(existingPid)) {
2617
+ try {
2618
+ if (existsSync(lockPath))
2619
+ unlinkSync(lockPath);
2620
+ }
2621
+ catch { }
2622
+ }
2623
+ lockFd = openSync(lockPath, "wx"); // "wx" = O_CREAT | O_EXCL, fails if exists
2624
+ hasLock = true;
2625
+ }
2626
+ catch {
2627
+ hasLock = false;
2628
+ }
2629
+ if (!hasLock) {
2630
+ // Could not acquire lock — wait and retry, then give up
2631
+ for (let retry = 0; retry < 10; retry++) {
2632
+ await new Promise(r => setTimeout(r, 200));
2633
+ const pid2 = readPidFile(dir);
2634
+ if (pid2 && isPidAlive(pid2)) {
2635
+ const alive = await probeServer(host, basePort);
2636
+ if (alive) {
2637
+ runningServer = {
2638
+ port: basePort, hostname: host,
2639
+ url: `http://${host}:${basePort}`, pid: pid2, isPrimary: false,
2640
+ broadcast,
2641
+ async stop() { },
2642
+ };
2643
+ return runningServer;
2644
+ }
2645
+ }
2646
+ // Re-attempt the lock; the holder may have died.
2647
+ try {
2648
+ if (existsSync(lockPath))
2649
+ unlinkSync(lockPath);
2650
+ lockFd = openSync(lockPath, "wx");
2651
+ hasLock = true;
2652
+ break;
2653
+ }
2654
+ catch {
2655
+ // still held
2656
+ }
2657
+ }
2658
+ if (!hasLock) {
2659
+ throw new Error("Could not acquire server lock; another process may be starting the server");
2660
+ }
2661
+ }
2662
+ // 3. We have the lock — bind the server
2663
+ const { server, port, hostname } = await _startServer(ctx, { ...opts, host, port: basePort, maxPortTries: maxTries }, { writePidFile: true, lockFd: lockFd ?? undefined });
2664
+ const pid = process.pid;
2665
+ writePidFile(dir, pid);
2666
+ // Resolve and cache webRoot so the request handler can serve static files.
2667
+ // Default: <project>/web (one level up from .ok).
2668
+ webRoot = opts.webRoot ?? join(ctx.directory, "web");
2669
+ // Set up HTTP request handler (full routing)
2670
+ server.on("request", async (req, res) => {
2671
+ try {
2672
+ const request = await toRequest(req);
2673
+ const response = await handleRequest(request);
2674
+ await writeResponse(res, response);
2675
+ }
2676
+ catch (e) {
2677
+ const message = e instanceof Error ? e.message : "Internal server error";
2678
+ const response = requestErrorResponse(await toRequest(req), 500, message);
2679
+ await writeResponse(res, response);
2680
+ }
2681
+ });
2682
+ bizarSocketBridge?.close();
2683
+ bizarSocketBridge = attachBizarWebSocket(server, getActiveProjectRoot);
2684
+ claudeSocketBridge?.close();
2685
+ claudeSocketBridge = attachClaudeWebSocket(server, getActiveProjectRoot);
2686
+ runningServer = {
2687
+ port,
2688
+ hostname,
2689
+ url: `http://${hostname}:${port}`,
2690
+ pid,
2691
+ isPrimary: true,
2692
+ broadcast,
2693
+ async stop() {
2694
+ clearInterval(driftSweepInterval);
2695
+ bizarSocketBridge?.close();
2696
+ bizarSocketBridge = null;
2697
+ claudeSocketBridge?.close();
2698
+ claudeSocketBridge = null;
2699
+ await new Promise((resolve, reject) => {
2700
+ server.close((err) => (err ? reject(err) : resolve()));
2701
+ });
2702
+ deletePidFile(dir);
2703
+ if (lockFd !== null) {
2704
+ try {
2705
+ closeSync(lockFd);
2706
+ }
2707
+ catch { /* ignore */ }
2708
+ try {
2709
+ unlinkSync(lockPath);
2710
+ }
2711
+ catch { /* ignore */ }
2712
+ }
2713
+ watcherHandle?.close();
2714
+ watcherHandle = null;
2715
+ runningServer = null;
2716
+ },
2717
+ };
2718
+ // ─── File watcher (SSE broadcaster) ───────────────────────────────────────
2719
+ // Watch the project root so we also catch changes to source files (e.g. docs/*.mdx)
2720
+ // that are tracked as task sources. Inside .ok/ we filter the engine's own mirror
2721
+ // writes (board.json, board.mdx, tasks.json, per-task task.mdx + comments/inputs/
2722
+ // state.json) to avoid double-broadcasts, but `.ok/tasks/<id>.json` (the
2723
+ // planning-system store populated by the ok CLI) MUST pass through so an
2724
+ // agent's `ok task add` is visible to the dashboard without a restart.
2725
+ const projectRoot = join(dir, "..");
2726
+ watcherHandle = watch({
2727
+ root: projectRoot,
2728
+ ignore: (p) => {
2729
+ const norm = p.replace(/\\/g, "/");
2730
+ if (/server\.(lock|log|pid)$/.test(norm))
2731
+ return true;
2732
+ if (norm.endsWith(".tmp"))
2733
+ return true;
2734
+ if (norm.includes("/.ok/")) {
2735
+ // Engine-owned mirror writes — suppress; selfWriteUntil already
2736
+ // covers them but explicit ignore here removes cross-talk noise.
2737
+ if (norm.endsWith("/.ok/board.json"))
2738
+ return true;
2739
+ if (norm.endsWith("/.ok/board.mdx"))
2740
+ return true;
2741
+ if (norm.endsWith("/.ok/tasks.json"))
2742
+ return true;
2743
+ if (/\/\.ok\/tasks\/[^/]+\/task\.mdx$/.test(norm))
2744
+ return true;
2745
+ if (/\/\.ok\/tasks\/[^/]+\/(comments|inputs|state)\.json$/.test(norm))
2746
+ return true;
2747
+ if (norm.endsWith("/.ok/changelog.jsonl"))
2748
+ return true;
2749
+ return false; // per-task JSONs and config/index files pass through
2750
+ }
2751
+ return false;
2752
+ },
2753
+ });
2754
+ // Periodic drift sweep — every 60 s, re-check all source hashes.
2755
+ const driftSweepInterval = setInterval(() => {
2756
+ sweepSourceDrift(dir).catch(() => { });
2757
+ }, 60_000);
2758
+ (async () => {
2759
+ for await (const ev of watcherHandle.events) {
2760
+ // Skip events caused by server's own writes.
2761
+ if (Date.now() < selfWriteUntil)
2762
+ continue;
2763
+ // Check if this event matches any task's source.path → drift detection
2764
+ let driftedTaskId = null;
2765
+ {
2766
+ const board = await getBoard();
2767
+ for (const task of board.tasks) {
2768
+ if (!task.source)
2769
+ continue;
2770
+ const srcAbs = sourcePathOfTask(task, dir);
2771
+ if (srcAbs === ev.absPath) {
2772
+ driftedTaskId = task.id;
2773
+ break;
2774
+ }
2775
+ }
2776
+ }
2777
+ if (ev.path.endsWith("board.json") || ev.path.replace(/\\/g, "/").endsWith(".ok/board.json")) {
2778
+ broadcast("board.changed", { path: ev.path });
2779
+ }
2780
+ else if (ev.path.endsWith("changelog.jsonl")) {
2781
+ broadcast("changelog.appended", { path: ev.path });
2782
+ }
2783
+ else if (ev.path.includes("/tasks/") && ev.path.endsWith("task.mdx")) {
2784
+ const taskId = ev.path.match(/\/tasks\/([^/]+)\//)?.[1];
2785
+ if (taskId)
2786
+ broadcast("task.mdx.changed", { taskId, path: ev.path });
2787
+ }
2788
+ else if (ev.path.includes("/tasks/") && ev.path.endsWith("comments.json")) {
2789
+ const taskId = ev.path.match(/\/tasks\/([^/]+)\//)?.[1];
2790
+ if (taskId)
2791
+ broadcast("task.comment.added", { taskId });
2792
+ }
2793
+ else if (ev.path.includes("/tasks/") && ev.path.endsWith("inputs.json")) {
2794
+ const taskId = ev.path.match(/\/tasks\/([^/]+)\//)?.[1];
2795
+ if (taskId)
2796
+ broadcast("task.input.asked", { taskId });
2797
+ }
2798
+ else if (ev.path.includes("/tasks/") && ev.path.endsWith("state.json")) {
2799
+ const taskId = ev.path.match(/\/tasks\/([^/]+)\//)?.[1];
2800
+ if (taskId)
2801
+ broadcast("task.state.changed", { taskId });
2802
+ }
2803
+ else if ((ev.path.includes("/.ok/tasks/") || ev.path.startsWith(".ok/tasks/")) && ev.path.endsWith(".json")) {
2804
+ // Planning-system per-task write (e.g. `ok task add`). Reconcile into
2805
+ // the in-memory board when the id is new; mirror-engine no-op when
2806
+ // the task already lives here. Event paths emitted by the watcher
2807
+ // are project-root-relative, so they can start with ".ok/" directly.
2808
+ const okIdMatch = ev.path.match(/(?:\/|^)\.ok\/tasks\/([^/]+)\.json$/);
2809
+ const okTaskId = okIdMatch?.[1];
2810
+ if (okTaskId && /^tsk-[A-Za-z0-9_-]+$/.test(okTaskId)) {
2811
+ try {
2812
+ const inserted = await reconcileOkTask(okTaskId, dir);
2813
+ if (inserted) {
2814
+ broadcast("task.created", inserted);
2815
+ await writeTaskMdx(inserted, dir, await getBoard());
2816
+ }
2817
+ else {
2818
+ // Existing task — treat the planning write as a metadata
2819
+ // nudge. The HTTP PATCH path stays the canonical owner.
2820
+ broadcast("ok.task.synced", { taskId: okTaskId, path: ev.path });
2821
+ }
2822
+ }
2823
+ catch (e) {
2824
+ process.stderr.write(`reconcileOkTask(${okTaskId}) failed: ${e?.message ?? e}\n`);
2825
+ }
2826
+ }
2827
+ }
2828
+ else if (ev.path.match(/\/tasks\/([^/]+)\/images\//)) {
2829
+ const taskId = ev.path.match(/\/tasks\/([^/]+)\/images\//)?.[1];
2830
+ if (taskId)
2831
+ broadcast("task.image.changed", { taskId });
2832
+ }
2833
+ else if (driftedTaskId) {
2834
+ // A tracked source file changed — run drift check for the affected task
2835
+ const task = (await getBoard()).tasks.find(t => t.id === driftedTaskId);
2836
+ if (task) {
2837
+ const isStale = await checkSourceDrift(task, dir);
2838
+ if (isStale !== task.stale) {
2839
+ task.stale = isStale;
2840
+ task.lastSourceCheck = nowIso();
2841
+ await withWrite(async (b) => {
2842
+ const t2 = b.tasks.find(t => t.id === driftedTaskId);
2843
+ if (t2) {
2844
+ t2.stale = isStale;
2845
+ t2.lastSourceCheck = nowIso();
2846
+ }
2847
+ });
2848
+ broadcast("task.updated", task);
2849
+ await writeTaskMdx(task, dir, await getBoard());
2850
+ }
2851
+ }
2852
+ broadcast("task.source-changed", { taskId: driftedTaskId, path: ev.path });
2853
+ }
2854
+ else {
2855
+ // Generic file-change event for any other watched files.
2856
+ broadcast("file.changed", { path: ev.path });
2857
+ }
2858
+ }
2859
+ })();
2860
+ ctx.log("info", `Kanban server started at ${runningServer.url} (primary)`);
2861
+ // Auto-detect projects in the background if no active project is set
2862
+ if (opts._autoDetect !== false && !activeProject()) {
2863
+ (async () => {
2864
+ try {
2865
+ const result = await autoDetectProjects();
2866
+ if (result.discovered.length) {
2867
+ ctx.log("info", `auto-detect: found ${result.discovered.length} new project(s): ${result.discovered.map(p => p.name).join(", ")}`);
2868
+ }
2869
+ else {
2870
+ ctx.log("info", "auto-detect: no new projects found");
2871
+ }
2872
+ }
2873
+ catch (e) {
2874
+ ctx.log("warn", `auto-detect failed: ${e?.message ?? e}`);
2875
+ }
2876
+ })();
2877
+ }
2878
+ return runningServer;
2879
+ }
2880
+ // ─── Public startServer (back-compat, delegates to startOrAttach) ─────────────
2881
+ export function getServer() {
2882
+ return runningServer;
2883
+ }
2884
+ /** @deprecated Use startOrAttach instead. */
2885
+ export async function startServer(ctx, opts = {}) {
2886
+ return startOrAttach(ctx, opts);
2887
+ }
2888
+ // ─── Request router ──────────────────────────────────────────────────────────
2889
+ /**
2890
+ * Attach a small WebSocket bridge to `ws://…/api/claude/ws`. Mirrors
2891
+ * `attachBizarWebSocket` (the legacy `/api/bizar/ws` endpoint) but sources
2892
+ * the snapshot and live updates from `kanban/claude-state.ts` so callers
2893
+ * no longer need the external `bizar` CLI. SSE on `/api/claude/events` is the
2894
+ * preferred transport; this bridge exists for clients that prefer WS.
2895
+ */
2896
+ export function attachClaudeWebSocket(server, projectRoot) {
2897
+ const wss = new WebSocketServer({ noServer: true });
2898
+ function send(socket, value) {
2899
+ if (socket.readyState === WebSocket.OPEN)
2900
+ socket.send(JSON.stringify(value));
2901
+ }
2902
+ async function snapshot(socket) {
2903
+ try {
2904
+ const data = await claudeState.readSnapshot(projectRoot());
2905
+ send(socket, { type: "snapshot", data });
2906
+ }
2907
+ catch (error) {
2908
+ send(socket, { type: "error", error: error?.message || String(error) });
2909
+ }
2910
+ }
2911
+ const upgrade = (req, socket, head) => {
2912
+ let pathname = "";
2913
+ try {
2914
+ pathname = new URL(req.url || "/", "http://localhost").pathname;
2915
+ }
2916
+ catch { /* invalid */ }
2917
+ if (pathname !== "/api/claude/ws")
2918
+ return;
2919
+ if (!isLoopback(req)) {
2920
+ socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
2921
+ socket.destroy();
2922
+ return;
2923
+ }
2924
+ wss.handleUpgrade(req, socket, head, (client) => wss.emit("connection", client, req));
2925
+ };
2926
+ server.on("upgrade", upgrade);
2927
+ wss.on("connection", (socket) => {
2928
+ void snapshot(socket);
2929
+ socket.on("message", (raw) => {
2930
+ let message;
2931
+ try {
2932
+ message = JSON.parse(raw.toString());
2933
+ }
2934
+ catch {
2935
+ send(socket, { type: "error", error: "Invalid JSON" });
2936
+ return;
2937
+ }
2938
+ if (message.type === "refresh") {
2939
+ void snapshot(socket);
2940
+ return;
2941
+ }
2942
+ send(socket, {
2943
+ type: "error",
2944
+ requestId: message.requestId,
2945
+ error: "Unknown message type",
2946
+ });
2947
+ });
2948
+ });
2949
+ const interval = setInterval(() => {
2950
+ if (wss.clients.size > 0) {
2951
+ for (const client of wss.clients)
2952
+ void snapshot(client);
2953
+ }
2954
+ }, 5_000);
2955
+ interval.unref?.();
2956
+ return {
2957
+ close() {
2958
+ clearInterval(interval);
2959
+ server.off("upgrade", upgrade);
2960
+ for (const client of wss.clients)
2961
+ client.close();
2962
+ wss.close();
2963
+ },
2964
+ };
2965
+ }
2966
+ function isLoopback(req) {
2967
+ const address = req.socket.remoteAddress || "";
2968
+ return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
2969
+ }
2970
+ async function handleRequest(req) {
2971
+ // Resolve and load the active project for this request. The board is cached
2972
+ // in process, so changing a directory alone would leak the previous
2973
+ // project's tasks into the selected project.
2974
+ const projectRoot = getActiveProjectRoot();
2975
+ await ensureBoardForProject(projectBoardContext(projectRoot));
2976
+ const url = new URL(req.url);
2977
+ const path = url.pathname;
2978
+ const rawFlag = url.searchParams.get("raw") === "1";
2979
+ // SSE
2980
+ if (path === "/api/events") {
2981
+ const stream = new ReadableStream({
2982
+ start(ctrl) { sseControllers.add(ctrl); ctrl.enqueue(new TextEncoder().encode("event: server.connected\ndata: {}\n\n")); },
2983
+ cancel(ctrl) { sseControllers.delete(ctrl); },
2984
+ });
2985
+ return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive" } });
2986
+ }
2987
+ // Static: any file under webRoot with an allowed extension. webRoot
2988
+ // defaults to `<project>/web`; the caller can override. The whitelist
2989
+ // includes svg so the brand assets (logo, favicon, banners, empty-state
2990
+ // illustrations) can be served directly.
2991
+ if (path === "/" || /\.(html|css|js|json|md|txt|svg)$/.test(path)) {
2992
+ const root = webRoot ?? join(KANBAN_DIR, "..", "web");
2993
+ const pathForStatic = path === "/" ? "/index.html" : path;
2994
+ const sf = serveStatic(root, pathForStatic);
2995
+ if (sf)
2996
+ return new Response(sf.body, { headers: { "Content-Type": sf.contentType } });
2997
+ // If the explicit static match fails, fall through to other handlers (don't 404 here)
2998
+ }
2999
+ // GET /api/board
3000
+ if (path === "/api/board" && req.method === "GET")
3001
+ return apiGetBoard();
3002
+ if (path.startsWith("/api/bizar/")) {
3003
+ return handleBizarRequest(projectRoot, req, path);
3004
+ }
3005
+ if (path.startsWith("/api/claude/")) {
3006
+ return handleClaudeRequest(projectRoot, req, path);
3007
+ }
3008
+ if (path.startsWith("/api/chat/")) {
3009
+ return handleChatRequest(projectRoot, req, path);
3010
+ }
3011
+ // GET /api/goals — PRDs and their durable goals from .ok/prds.
3012
+ if (path === "/api/goals" && req.method === "GET")
3013
+ return apiGetGoals(projectRoot);
3014
+ const goalMatch = path.match(/^\/api\/goals\/(prd-[A-Za-z0-9_-]+)\/(g[0-9]+)$/);
3015
+ if (goalMatch && req.method === "PATCH")
3016
+ return apiPatchGoal(projectRoot, goalMatch[1], goalMatch[2], req);
3017
+ // GET /api/tasks-index
3018
+ if (path === "/api/tasks-index" && req.method === "GET")
3019
+ return apiGetTasksIndex(req);
3020
+ // GET /api/tags
3021
+ if (path === "/api/tags" && req.method === "GET")
3022
+ return apiGetTags();
3023
+ // GET /api/changelog
3024
+ if (path === "/api/changelog" && req.method === "GET")
3025
+ return apiGetChangelog(req);
3026
+ // GET /api/changelog/summary
3027
+ if (path === "/api/changelog/summary" && req.method === "GET")
3028
+ return apiGetChangelogSummary(req);
3029
+ // GET /api/insights/velocity
3030
+ if (path === "/api/insights/velocity" && req.method === "GET")
3031
+ return apiGetInsightsVelocity(req);
3032
+ // GET /api/contributors
3033
+ if (path === "/api/contributors" && req.method === "GET")
3034
+ return apiGetContributors(req);
3035
+ // GET /api/settings
3036
+ if (path === "/api/settings" && req.method === "GET")
3037
+ return apiGetSettings();
3038
+ // PATCH /api/settings
3039
+ if (path === "/api/settings" && req.method === "PATCH")
3040
+ return apiPatchSettings({ directory: KANBAN_DIR, client: null, log: async () => { } }, req);
3041
+ // GET /api/config-sections
3042
+ if (path === "/api/config-sections" && req.method === "GET")
3043
+ return apiGetConfigSections();
3044
+ // PATCH /api/config-sections/:sectionId
3045
+ const configSectionMatch = path.match(/^\/api\/config-sections\/([^/]+)$/);
3046
+ if (configSectionMatch && req.method === "PATCH") {
3047
+ return apiPatchConfigSection({ directory: KANBAN_DIR, client: null, log: async () => { } }, configSectionMatch[1], req);
3048
+ }
3049
+ // POST /api/organize
3050
+ if (path === "/api/organize" && req.method === "POST")
3051
+ return apiOrganize({ directory: KANBAN_DIR, client: null, log: async () => { } }, req);
3052
+ // POST /api/import
3053
+ if (path === "/api/import" && req.method === "POST")
3054
+ return apiImport({ directory: KANBAN_DIR, client: null, log: async () => { } }, req);
3055
+ // GET /api/search
3056
+ if (path === "/api/search" && req.method === "GET")
3057
+ return apiSearch(req);
3058
+ // POST /api/tasks/bulk
3059
+ if (path === "/api/tasks/bulk" && req.method === "POST")
3060
+ return apiBulk({ directory: KANBAN_DIR, client: null, log: async () => { } }, req);
3061
+ // POST /api/projects/:projectId/tasks/move
3062
+ const moveTasksMatch = path.match(/^\/api\/projects\/([^/]+)\/tasks\/move$/);
3063
+ if (moveTasksMatch && req.method === "POST") {
3064
+ return apiMoveTasksToProject(decodeURIComponent(moveTasksMatch[1]), req);
3065
+ }
3066
+ // GET /api/template
3067
+ if (path === "/api/template" && req.method === "GET")
3068
+ return apiGetTemplate();
3069
+ // GET /api/tasks/:id
3070
+ const taskMatch = path.match(/^\/api\/tasks\/([^/]+)$/);
3071
+ if (taskMatch) {
3072
+ const [_, id] = taskMatch;
3073
+ if (req.method === "GET")
3074
+ return apiGetTask(id);
3075
+ if (req.method === "POST")
3076
+ return apiCreateTask({ directory: KANBAN_DIR, client: null, log: async () => { } }, req);
3077
+ if (req.method === "PATCH")
3078
+ return apiUpdateTask({ directory: KANBAN_DIR, client: null, log: async () => { } }, id, req);
3079
+ if (req.method === "DELETE")
3080
+ return apiDeleteTask({ directory: KANBAN_DIR, client: null, log: async () => { } }, id);
3081
+ }
3082
+ // POST /api/tasks/:id/archive
3083
+ const archiveMatch = path.match(/^\/api\/tasks\/([^/]+)\/archive$/);
3084
+ if (archiveMatch && req.method === "POST") {
3085
+ return apiArchiveTask({ directory: KANBAN_DIR, client: null, log: async () => { } }, archiveMatch[1]);
3086
+ }
3087
+ // POST /api/tasks/:id/restore
3088
+ const restoreMatch = path.match(/^\/api\/tasks\/([^/]+)\/restore$/);
3089
+ if (restoreMatch && req.method === "POST") {
3090
+ return apiRestoreTask({ directory: KANBAN_DIR, client: null, log: async () => { } }, restoreMatch[1]);
3091
+ }
3092
+ // GET /api/tasks/:id/contributors
3093
+ const contribMatch = path.match(/^\/api\/tasks\/([^/]+)\/contributors$/);
3094
+ if (contribMatch && req.method === "GET") {
3095
+ return apiGetTaskContributors({ directory: KANBAN_DIR, client: null, log: async () => { } }, contribMatch[1]);
3096
+ }
3097
+ // GET /api/tasks/:id/subtasks
3098
+ const subtasksMatch = path.match(/^\/api\/tasks\/([^/]+)\/subtasks$/);
3099
+ if (subtasksMatch && req.method === "GET") {
3100
+ return apiGetSubtasks(subtasksMatch[1]);
3101
+ }
3102
+ // POST /api/tasks (legacy)
3103
+ if (path === "/api/tasks" && req.method === "POST")
3104
+ return apiCreateTask({ directory: KANBAN_DIR, client: null, log: async () => { } }, req);
3105
+ // /api/tasks/:id/ask
3106
+ const askMatch = path.match(/^\/api\/tasks\/([^/]+)\/ask$/);
3107
+ if (askMatch && req.method === "POST") {
3108
+ const [_, id] = askMatch;
3109
+ return apiAskInput({ directory: KANBAN_DIR, client: null, log: async () => { } }, id, req);
3110
+ }
3111
+ // POST /api/tasks/recheck-stale
3112
+ if (path === "/api/tasks/recheck-stale" && req.method === "POST") {
3113
+ return apiRecheckStale({ directory: KANBAN_DIR, client: null, log: async () => { } }, req);
3114
+ }
3115
+ // /api/tasks/:id/respond
3116
+ const respondMatch = path.match(/^\/api\/tasks\/([^/]+)\/respond$/);
3117
+ if (respondMatch && req.method === "POST") {
3118
+ const [_, id] = respondMatch;
3119
+ return apiRespondInput({ directory: KANBAN_DIR, client: null, log: async () => { } }, id, req);
3120
+ }
3121
+ // /api/tasks/:id/comments
3122
+ const commentsMatch = path.match(/^\/api\/tasks\/([^/]+)\/comments$/);
3123
+ if (commentsMatch) {
3124
+ const [_, id] = commentsMatch;
3125
+ if (req.method === "GET")
3126
+ return apiGetComments({ directory: KANBAN_DIR, client: null, log: async () => { } }, id);
3127
+ if (req.method === "POST")
3128
+ return apiAddComment({ directory: KANBAN_DIR, client: null, log: async () => { } }, id, req);
3129
+ }
3130
+ // /api/tasks/:id/comments/:cid
3131
+ const commentMatch = path.match(/^\/api\/tasks\/([^/]+)\/comments\/([^/]+)$/);
3132
+ if (commentMatch) {
3133
+ const [_, id, cid] = commentMatch;
3134
+ if (req.method === "PATCH")
3135
+ return apiResolveComment({ directory: KANBAN_DIR, client: null, log: async () => { } }, id, cid, req);
3136
+ if (req.method === "DELETE")
3137
+ return apiDeleteComment({ directory: KANBAN_DIR, client: null, log: async () => { } }, id, cid);
3138
+ }
3139
+ // /api/tasks/:id/images
3140
+ const imgCollectionMatch = path.match(/^\/api\/tasks\/([^/]+)\/images$/);
3141
+ if (imgCollectionMatch) {
3142
+ const [_, id] = imgCollectionMatch;
3143
+ if (req.method === "GET")
3144
+ return apiListImages({ directory: KANBAN_DIR, client: null, log: async () => { } }, id);
3145
+ if (req.method === "POST")
3146
+ return apiUploadImage({ directory: KANBAN_DIR, client: null, log: async () => { } }, id, req);
3147
+ }
3148
+ // /api/tasks/:id/images/:name
3149
+ const imgMatch = path.match(/^\/api\/tasks\/([^/]+)\/images\/([^/]+)$/);
3150
+ if (imgMatch) {
3151
+ const [_, id, name] = imgMatch;
3152
+ if (req.method === "GET")
3153
+ return apiGetImage({ directory: KANBAN_DIR, client: null, log: async () => { } }, id, name);
3154
+ if (req.method === "DELETE")
3155
+ return apiDeleteImage({ directory: KANBAN_DIR, client: null, log: async () => { } }, id, name);
3156
+ }
3157
+ // GET /api/me
3158
+ if (path === "/api/me" && req.method === "GET") {
3159
+ return apiGetMe({ directory: KANBAN_DIR, client: null, log: async () => { } });
3160
+ }
3161
+ // GET /api/project
3162
+ if (path === "/api/project" && req.method === "GET") {
3163
+ return apiGetProject();
3164
+ }
3165
+ // GET /api/projects
3166
+ if (path === "/api/projects" && req.method === "GET") {
3167
+ return apiGetProjects();
3168
+ }
3169
+ // POST /api/projects
3170
+ if (path === "/api/projects" && req.method === "POST") {
3171
+ return apiCreateProject(req);
3172
+ }
3173
+ // DELETE /api/projects/:id
3174
+ const deleteProjectMatch = path.match(/^\/api\/projects\/([^/]+)$/);
3175
+ if (deleteProjectMatch && req.method === "DELETE") {
3176
+ return apiDeleteProject(deleteProjectMatch[1]);
3177
+ }
3178
+ // POST /api/projects/auto-detect
3179
+ if (path === "/api/projects/auto-detect" && req.method === "POST") {
3180
+ return apiAutoDetectProjects(req);
3181
+ }
3182
+ // PATCH /api/projects/:id/active
3183
+ const activateProjectMatch = path.match(/^\/api\/projects\/([^/]+)\/active$/);
3184
+ if (activateProjectMatch && req.method === "PATCH") {
3185
+ return apiActivateProject(activateProjectMatch[1]);
3186
+ }
3187
+ // Docs workspace CRUD + configured-agent generation.
3188
+ if (path === "/api/docs/generate" && req.method === "POST")
3189
+ return apiGenerateDoc(req);
3190
+ if (path === "/api/docs/render" && req.method === "POST")
3191
+ return apiRenderDoc(req);
3192
+ const docListMatch = path.match(/^\/api\/docs\/?$/);
3193
+ if (docListMatch && req.method === "GET")
3194
+ return apiGetDocs();
3195
+ const docFileMatch = path.match(/^\/api\/docs\/(.+)$/);
3196
+ if (docFileMatch && req.method === "GET")
3197
+ return apiGetDoc(req, docFileMatch[1]);
3198
+ if (docFileMatch && req.method === "PUT")
3199
+ return apiWriteDoc(req, decodeURIComponent(docFileMatch[1]));
3200
+ if (docFileMatch && req.method === "DELETE")
3201
+ return apiDeleteDoc(decodeURIComponent(docFileMatch[1]));
3202
+ // GET /api/fs — browse filesystem
3203
+ if (path === "/api/fs" && req.method === "GET")
3204
+ return apiFs(req);
3205
+ // GET /api/home — user's home directory
3206
+ if (path === "/api/home" && req.method === "GET")
3207
+ return apiHome();
3208
+ // GET /api/parents — ancestor directories for breadcrumbs
3209
+ if (path === "/api/parents" && req.method === "GET")
3210
+ return apiParents(req);
3211
+ // /api/tasks/:id/mdx-rendered
3212
+ const mdxRenderedMatch = path.match(/^\/api\/tasks\/([^/]+)\/mdx-rendered$/);
3213
+ if (mdxRenderedMatch && req.method === "GET") {
3214
+ return apiGetMdxRendered(mdxRenderedMatch[1]);
3215
+ }
3216
+ // /api/tasks/:id/start | /abort
3217
+ const actionMatch = path.match(/^\/api\/tasks\/([^/]+)\/(start|abort)$/);
3218
+ if (actionMatch && req.method === "POST") {
3219
+ const [_, id, action] = actionMatch;
3220
+ if (action === "start")
3221
+ return apiStartTask(projectRoot, id, req);
3222
+ if (action === "abort")
3223
+ return apiAbortTask(projectRoot, id);
3224
+ }
3225
+ // /api/sessions/:sid/status
3226
+ const sessMatch = path.match(/^\/api\/sessions\/([^/]+)\/status$/);
3227
+ if (sessMatch && req.method === "GET")
3228
+ return apiSessionStatus(sessMatch[1]);
3229
+ // POST /api/preview
3230
+ if (path === "/api/preview" && req.method === "POST")
3231
+ return apiPreview(req);
3232
+ // Artifacts
3233
+ // Theme: ?theme=dark|light|system wins; otherwise script reads localStorage
3234
+ const themeParam = url.searchParams.get("theme") ?? undefined;
3235
+ const cspHeaders = {
3236
+ // unsafe-inline needed for the theme-init script and inline CSS vars
3237
+ "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; form-action 'none'",
3238
+ };
3239
+ if (path === "/artifacts/board") {
3240
+ try {
3241
+ const { body, contentType } = await renderArtifact(join(KANBAN_DIR, "board.mdx"), rawFlag, themeParam);
3242
+ return new Response(body, { headers: { "Content-Type": contentType, ...cspHeaders } });
3243
+ }
3244
+ catch (e) {
3245
+ const m = e?.message || "Render error";
3246
+ return errorResponse(m, 500);
3247
+ }
3248
+ }
3249
+ const taskArtMatch = path.match(/^\/artifacts\/tasks\/([^/]+)$/);
3250
+ if (taskArtMatch) {
3251
+ const [_, id] = taskArtMatch;
3252
+ try {
3253
+ // Support both flat and per-task layout
3254
+ const flatPath = join(KANBAN_DIR, "tasks", `${id}.mdx`);
3255
+ const perPath = join(KANBAN_DIR, "tasks", id, "task.mdx");
3256
+ const mdxPath = existsSync(perPath) ? perPath : existsSync(flatPath) ? flatPath : null;
3257
+ if (!mdxPath)
3258
+ throw new Error(`Task artifact not found for ${id}`);
3259
+ const { body, contentType } = await renderArtifact(mdxPath, rawFlag, themeParam);
3260
+ return new Response(body, { headers: { "Content-Type": contentType, ...cspHeaders } });
3261
+ }
3262
+ catch (e) {
3263
+ const m = e?.message || "Render error";
3264
+ return errorResponse(m, 500);
3265
+ }
3266
+ }
3267
+ const sessArtMatch = path.match(/^\/artifacts\/sessions\/([^/]+)$/);
3268
+ if (sessArtMatch) {
3269
+ const [_, sid] = sessArtMatch;
3270
+ try {
3271
+ const { body, contentType } = await renderArtifact(join(KANBAN_DIR, "sessions", `${sid}.mdx`), rawFlag, themeParam);
3272
+ return new Response(body, { headers: { "Content-Type": contentType, ...cspHeaders } });
3273
+ }
3274
+ catch (e) {
3275
+ const m = e?.message || "Render error";
3276
+ return errorResponse(m, 500);
3277
+ }
3278
+ }
3279
+ return requestErrorResponse(req, 404, "Not found");
3280
+ }
3281
+ // ─── Event subscription ───────────────────────────────────────────────────────
3282
+ export async function subscribeEvents(ctx, signal) {
3283
+ if (!ctx.client?.event?.subscribe)
3284
+ return;
3285
+ try {
3286
+ for await (const event of ctx.client.event.subscribe({ signal })) {
3287
+ await handleEvent(ctx, event);
3288
+ }
3289
+ }
3290
+ catch (e) {
3291
+ if (e?.name === "AbortError" || signal.aborted)
3292
+ return;
3293
+ const errMsg = e?.message ?? String(e);
3294
+ ctx.log("error", "Event subscription error: " + errMsg);
3295
+ }
3296
+ }