@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,1280 @@
1
+ // OpenKan — chat sidebar backend.
2
+ //
3
+ // This module powers the right-rail chat orchestrator. It owns:
4
+ // 1. JSONL session storage under `.ok/sessions/<sid>.jsonl`
5
+ // 2. A subprocess wrapper that spawns `claude -p --output-format stream-json
6
+ // --verbose` per user turn, parses the line-delimited JSON event stream,
7
+ // and tracks running PIDs in-memory so an abort request can kill the
8
+ // process.
9
+ // 3. An HTTP dispatcher (`handleChatRequest`) registered at `/api/chat/*`.
10
+ //
11
+ // Design choices:
12
+ // - One process per turn (stateless, no daemon). CLI flags `--model`,
13
+ // `--effort`, `--permission-mode` carry the selector values straight
14
+ // through to the Claude Code binary on $PATH (or $CLAUDE_BIN).
15
+ // `--output-format stream-json --verbose` makes Claude Code emit line-
16
+ // delimited JSON events on stdout, which gives the sidebar real-time
17
+ // visibility into tool calls and incremental text.
18
+ // - SSE for live turn events. Two channels:
19
+ // /api/chat/events — every event in the project
20
+ // /api/chat/sessions/<sid>/events — events scoped to a session
21
+ // Both emit typed events (`text_delta`, `tool_use`, `tool_input_delta`,
22
+ // `tool_result`, `message_done`, `chat.turn`).
23
+ // - All persistence is local: `.ok/sessions/` is gitignored already, so
24
+ // user/assistant transcripts never leak into commits.
25
+ import { appendFileSync, existsSync, readdirSync, readFileSync, renameSync, unlinkSync, } from "node:fs";
26
+ import { spawn } from "node:child_process";
27
+ import { join, resolve } from "node:path";
28
+ import { basename } from "node:path";
29
+ import { randomUUID } from "node:crypto";
30
+ import { ensureDir, writeFileAtomic } from "./io.js";
31
+ import { readAgents, readModelRouter } from "./claude-state.js";
32
+ import { OPENKAN_AGENT_ID, openkanAgentDefinition } from "./agent-profile.js";
33
+ // ─── Tool-use label mapper ───────────────────────────────────────────────────
34
+ /** Truncate a string to `max` chars with an ellipsis suffix. */
35
+ function truncate(s, max) {
36
+ if (s.length <= max)
37
+ return s;
38
+ return s.slice(0, Math.max(0, max - 1)) + "…";
39
+ }
40
+ /** Read a possibly-nested string field from an arbitrary input map. */
41
+ function inputString(input, key) {
42
+ const v = input[key];
43
+ return typeof v === "string" ? v : "";
44
+ }
45
+ /**
46
+ * Map a tool call to a short human-readable label, e.g. `Read`,
47
+ * `Write`, `Bash`. Truncates long arguments so the chip width fits
48
+ * the label without overflowing.
49
+ */
50
+ export function toolUseLabel(toolUse) {
51
+ const input = toolUse.input ?? {};
52
+ switch (toolUse.name) {
53
+ case "Read":
54
+ return `Reading ${basename(inputString(input, "file_path")) || "file"}`;
55
+ case "Write":
56
+ return `Writing ${basename(inputString(input, "file_path")) || "file"}`;
57
+ case "Edit": {
58
+ const file = inputString(input, "file_path");
59
+ return `Editing ${basename(file) || "file"}`;
60
+ }
61
+ case "Bash":
62
+ return `Running ${truncate(inputString(input, "command").replace(/\s+/g, " ").trim(), 60)}`;
63
+ case "Grep":
64
+ return `Searching for "${truncate(inputString(input, "query") || inputString(input, "pattern"), 40)}"`;
65
+ case "Glob":
66
+ return `Finding ${truncate(inputString(input, "pattern"), 60)}`;
67
+ case "WebFetch":
68
+ return `Fetching ${truncate(inputString(input, "url"), 60)}`;
69
+ case "WebSearch":
70
+ return `Searching the web for "${truncate(inputString(input, "query"), 40)}"`;
71
+ case "Agent":
72
+ case "Task":
73
+ return `Delegating to ${inputString(input, "subagent_type") || "subagent"}`;
74
+ default:
75
+ return `Using ${toolUse.name}`;
76
+ }
77
+ }
78
+ // ─── Paths ───────────────────────────────────────────────────────────────────
79
+ const SESSIONS_SUBDIR = "sessions";
80
+ const ARCHIVED_SUBDIR = ".archived";
81
+ const SESSION_EXT = ".jsonl";
82
+ /** Resolve the active sessions dir under `<projectRoot>/.ok/sessions`. */
83
+ export function sessionsDir(projectRoot) {
84
+ return join(projectRoot, ".ok", SESSIONS_SUBDIR);
85
+ }
86
+ /** Resolve the archived sessions dir. */
87
+ export function archivedSessionsDir(projectRoot) {
88
+ return join(sessionsDir(projectRoot), ARCHIVED_SUBDIR);
89
+ }
90
+ function sessionPath(projectRoot, sessionId) {
91
+ return join(sessionsDir(projectRoot), `${sessionId}${SESSION_EXT}`);
92
+ }
93
+ function archivedSessionPath(projectRoot, sessionId) {
94
+ return join(archivedSessionsDir(projectRoot), `${sessionId}${SESSION_EXT}`);
95
+ }
96
+ function ensureSessionsDirs(projectRoot) {
97
+ ensureDir(sessionsDir(projectRoot));
98
+ ensureDir(archivedSessionsDir(projectRoot));
99
+ }
100
+ // ─── ID helpers ──────────────────────────────────────────────────────────────
101
+ /** Generate a unique session id (`ses-<uuid>`). */
102
+ export function generateSessionId() {
103
+ return `ses-${randomUUID()}`;
104
+ }
105
+ function newMessageId() {
106
+ return `msg-${randomUUID()}`;
107
+ }
108
+ function nowIso() {
109
+ return new Date().toISOString();
110
+ }
111
+ // ─── JSONL parsing ───────────────────────────────────────────────────────────
112
+ /**
113
+ * Parse a JSONL file into an array of turns. Empty lines and unparseable
114
+ * rows are silently skipped (matches `changelog.ts:parseLine` behaviour but
115
+ * without the stderr warning — chat history is rebuilt incrementally and
116
+ * one bad row should not break a session).
117
+ */
118
+ function parseJsonl(raw) {
119
+ const out = [];
120
+ for (const line of raw.split("\n")) {
121
+ const trimmed = line.trim();
122
+ if (!trimmed)
123
+ continue;
124
+ try {
125
+ const obj = JSON.parse(trimmed);
126
+ if (obj && typeof obj === "object" && typeof obj.role === "string") {
127
+ // Backwards-compat: legacy turns were written before `toolUses`
128
+ // existed. Normalise absent → empty array so downstream code can
129
+ // safely iterate without checking for `undefined`.
130
+ if (obj.toolUses === undefined)
131
+ obj.toolUses = [];
132
+ out.push(obj);
133
+ }
134
+ }
135
+ catch {
136
+ /* skip unparseable row */
137
+ }
138
+ }
139
+ return out;
140
+ }
141
+ function readTurnsFile(path) {
142
+ if (!existsSync(path))
143
+ return [];
144
+ try {
145
+ return parseJsonl(readFileSync(path, "utf-8"));
146
+ }
147
+ catch {
148
+ return [];
149
+ }
150
+ }
151
+ // ─── Storage ─────────────────────────────────────────────────────────────────
152
+ /** Append a turn to the session JSONL file. Atomic on first write. */
153
+ export function appendTurn(projectRoot, sessionId, turn) {
154
+ ensureSessionsDirs(projectRoot);
155
+ const path = sessionPath(projectRoot, sessionId);
156
+ const line = JSON.stringify(turn) + "\n";
157
+ if (!existsSync(path)) {
158
+ writeFileAtomic(path, line);
159
+ }
160
+ else {
161
+ appendFileSync(path, line, "utf-8");
162
+ }
163
+ }
164
+ /** Read every turn for a session, looking in active and archived dirs. */
165
+ export function readSession(projectRoot, sessionId) {
166
+ const active = sessionPath(projectRoot, sessionId);
167
+ if (existsSync(active))
168
+ return readTurnsFile(active);
169
+ const archived = archivedSessionPath(projectRoot, sessionId);
170
+ if (existsSync(archived))
171
+ return readTurnsFile(archived);
172
+ return [];
173
+ }
174
+ /** True when the session is in the active dir. */
175
+ export function isSessionActive(projectRoot, sessionId) {
176
+ return existsSync(sessionPath(projectRoot, sessionId));
177
+ }
178
+ /** True when the session has been archived. */
179
+ export function isSessionArchived(projectRoot, sessionId) {
180
+ return existsSync(archivedSessionPath(projectRoot, sessionId));
181
+ }
182
+ /**
183
+ * Derive a session summary from the transcript. The title is the first user
184
+ * message truncated to 80 chars.
185
+ */
186
+ export function summariseSession(sessionId, turns, archived) {
187
+ const firstUser = turns.find((t) => t.role === "user");
188
+ const last = turns[turns.length - 1];
189
+ const titleRaw = firstUser?.content?.trim() ?? "(empty session)";
190
+ const title = titleRaw.length > 80 ? titleRaw.slice(0, 79) + "…" : titleRaw;
191
+ const selectorTurn = turns.find((t) => t.role === "assistant" && t.model) ?? firstUser;
192
+ return {
193
+ id: sessionId,
194
+ title,
195
+ model: selectorTurn?.model ?? null,
196
+ effort: selectorTurn?.effort ?? null,
197
+ permissionMode: selectorTurn?.permissionMode ?? null,
198
+ createdAt: turns[0]?.ts ?? nowIso(),
199
+ lastActivity: last?.ts ?? turns[0]?.ts ?? nowIso(),
200
+ turnCount: turns.length,
201
+ archived,
202
+ };
203
+ }
204
+ /** List every session (active first, then archived) in last-activity order. */
205
+ export function listSessions(projectRoot) {
206
+ const summaries = [];
207
+ if (!existsSync(sessionsDir(projectRoot)))
208
+ return summaries;
209
+ const activeEntries = readdirSync(sessionsDir(projectRoot))
210
+ .filter((f) => f.endsWith(SESSION_EXT));
211
+ for (const file of activeEntries) {
212
+ const id = file.slice(0, -SESSION_EXT.length);
213
+ if (id.startsWith("."))
214
+ continue; // skip hidden (e.g. .archived marker)
215
+ const turns = readTurnsFile(sessionPath(projectRoot, id));
216
+ summaries.push(summariseSession(id, turns, false));
217
+ }
218
+ const archivedDir = archivedSessionsDir(projectRoot);
219
+ if (existsSync(archivedDir)) {
220
+ const archivedEntries = readdirSync(archivedDir)
221
+ .filter((f) => f.endsWith(SESSION_EXT));
222
+ for (const file of archivedEntries) {
223
+ const id = file.slice(0, -SESSION_EXT.length);
224
+ if (id.startsWith("."))
225
+ continue;
226
+ const turns = readTurnsFile(archivedSessionPath(projectRoot, id));
227
+ summaries.push(summariseSession(id, turns, true));
228
+ }
229
+ }
230
+ // Sort by lastActivity desc. Active before archived ties are not guaranteed
231
+ // (the caller can filter); we sort by timestamp only and rely on the UI to
232
+ // group them.
233
+ summaries.sort((a, b) => (a.lastActivity < b.lastActivity ? 1 : -1));
234
+ return summaries;
235
+ }
236
+ /**
237
+ * Archive an active session by moving its JSONL file to `.archived/`.
238
+ * Returns true on success, false if the session was not active.
239
+ */
240
+ export function archiveSession(projectRoot, sessionId) {
241
+ const active = sessionPath(projectRoot, sessionId);
242
+ if (!existsSync(active))
243
+ return false;
244
+ ensureSessionsDirs(projectRoot);
245
+ const dest = archivedSessionPath(projectRoot, sessionId);
246
+ // renameSync overwrites the destination on POSIX; on Windows it would fail,
247
+ // but OpenKan targets Linux/macOS as the primary platforms.
248
+ renameSync(active, dest);
249
+ return true;
250
+ }
251
+ /**
252
+ * Permanently remove a session (active or archived). Used by tests; the
253
+ * public API uses `archiveSession` for the user-facing DELETE action.
254
+ */
255
+ export function deleteSession(projectRoot, sessionId) {
256
+ const active = sessionPath(projectRoot, sessionId);
257
+ const archived = archivedSessionPath(projectRoot, sessionId);
258
+ let removed = false;
259
+ if (existsSync(active)) {
260
+ try {
261
+ unlinkSync(active);
262
+ removed = true;
263
+ }
264
+ catch { /* ignore */ }
265
+ }
266
+ if (existsSync(archived)) {
267
+ try {
268
+ unlinkSync(archived);
269
+ removed = true;
270
+ }
271
+ catch { /* ignore */ }
272
+ }
273
+ return removed;
274
+ }
275
+ // ─── Subprocess wrapper ──────────────────────────────────────────────────────
276
+ /** Validate a chat selector set. Throws on invalid input. */
277
+ export function validateSelectors(selectors) {
278
+ if (selectors.agent !== undefined && (typeof selectors.agent !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_:./-]{0,127}$/.test(selectors.agent)))
279
+ throw new Error("Invalid agent identifier");
280
+ const eff = selectors.effort;
281
+ const validEffort = new Set(["low", "medium", "high", "max"]);
282
+ if (!validEffort.has(eff)) {
283
+ throw new Error(`Invalid effort: ${eff}. Expected one of ${[...validEffort].join(", ")}.`);
284
+ }
285
+ const validPerm = new Set(ALLOWED_PERMISSION_MODES);
286
+ if (!validPerm.has(selectors.permissionMode)) {
287
+ throw new Error(`Invalid permissionMode: ${selectors.permissionMode}. Expected one of ${[...validPerm].join(", ")}.`);
288
+ }
289
+ if (!selectors.model || typeof selectors.model !== "string") {
290
+ throw new Error("model is required");
291
+ }
292
+ }
293
+ /** Allowed permission modes for the Claude Code CLI. */
294
+ export const ALLOWED_PERMISSION_MODES = [
295
+ "acceptEdits", "auto", "bypassPermissions", "manual", "dontAsk", "plan",
296
+ ];
297
+ /**
298
+ * Derive a picker-style option list (id + label) from a model id. Strips a
299
+ * `provider/` prefix when present so `minimax/MiniMax-M3` displays as
300
+ * `MiniMax-M3` in the UI. Used by both the picker endpoint and tests.
301
+ */
302
+ export function toPickerLabel(id) {
303
+ const slash = id.indexOf("/");
304
+ return slash >= 0 ? id.slice(slash + 1) : id;
305
+ }
306
+ /**
307
+ * Build the picker options payload for `/api/chat/picker-options`. Pulls the
308
+ * model list from `readModelRouter` so the chat UI and the model's routing
309
+ * policy stay in sync without duplicating I/O. Tests can inject a model
310
+ * list via `overrides.models` to avoid filesystem fixture setup.
311
+ */
312
+ export async function pickerOptions(projectRoot, overrides = {}) {
313
+ const router = await readModelRouter(projectRoot);
314
+ // Older router files often specify policy defaults but omit the optional
315
+ // `models` array. Expose those configured model ids in the picker rather
316
+ // than leaving the model menu apparently empty.
317
+ const policyModels = Object.values(router.policies ?? {})
318
+ .filter((value) => typeof value === "string" && value.length > 0);
319
+ const ids = overrides.models ?? [...router.models, ...policyModels];
320
+ const models = [];
321
+ const seen = new Set();
322
+ for (const id of ids) {
323
+ if (typeof id !== "string" || !id || seen.has(id))
324
+ continue;
325
+ seen.add(id);
326
+ models.push({ id, label: toPickerLabel(id) });
327
+ }
328
+ const profiles = await readAgents(projectRoot);
329
+ const agents = [
330
+ { id: OPENKAN_AGENT_ID, label: "OpenKan", description: "Planning, structure, goals, and project management" },
331
+ { id: "default", label: "Claude Code", description: "General-purpose assistant without a custom agent profile" },
332
+ ...profiles.filter(profile => profile.id !== OPENKAN_AGENT_ID && profile.id !== "default").map(profile => ({ id: profile.id, label: profile.id, description: String(profile.frontmatter.description || "Custom Claude agent") })),
333
+ ];
334
+ return {
335
+ agents,
336
+ models,
337
+ efforts: ALLOWED_EFFORT_LEVELS,
338
+ permissionModes: ALLOWED_PERMISSION_MODES,
339
+ };
340
+ }
341
+ /** Allowed effort levels for the Claude Code CLI. */
342
+ export const ALLOWED_EFFORT_LEVELS = ["low", "medium", "high", "max"];
343
+ /** Resolve which Claude binary to invoke. Honours $CLAUDE_BIN. */
344
+ export function resolveClaudeBin(override) {
345
+ if (override && override.trim())
346
+ return resolve(override);
347
+ if (process.env.CLAUDE_BIN && process.env.CLAUDE_BIN.trim()) {
348
+ return resolve(process.env.CLAUDE_BIN);
349
+ }
350
+ return "claude";
351
+ }
352
+ /**
353
+ * Process and live-event identities include the project root. Session IDs are
354
+ * random, but making the scope explicit prevents a project switch from ever
355
+ * sharing an abort target or an SSE event channel with another workspace.
356
+ */
357
+ function projectScope(projectRoot) {
358
+ return resolve(projectRoot);
359
+ }
360
+ function scopedSessionKey(projectRoot, sessionId) {
361
+ return `${projectScope(projectRoot)}\u0000${sessionId}`;
362
+ }
363
+ /** Per-project/session child-process registry. */
364
+ const runningProcs = new Map();
365
+ /** Inspect the running process registry (used by tests and the API layer). */
366
+ export function listRunningSessions(projectRoot) {
367
+ const scope = projectRoot ? projectScope(projectRoot) : null;
368
+ return [...runningProcs.values()]
369
+ .filter((entry) => !scope || entry.projectRoot === scope)
370
+ .map((entry) => entry.sessionId);
371
+ }
372
+ function registerProc(projectRoot, sessionId, child) {
373
+ const root = projectScope(projectRoot);
374
+ const key = scopedSessionKey(root, sessionId);
375
+ const existing = runningProcs.get(key)?.child;
376
+ if (existing && !existing.killed) {
377
+ try {
378
+ existing.kill("SIGTERM");
379
+ }
380
+ catch { /* ignore */ }
381
+ }
382
+ runningProcs.set(key, { projectRoot: root, sessionId, child });
383
+ }
384
+ function clearProc(projectRoot, sessionId, child) {
385
+ const key = scopedSessionKey(projectRoot, sessionId);
386
+ if (runningProcs.get(key)?.child === child)
387
+ runningProcs.delete(key);
388
+ }
389
+ /**
390
+ * Kill the subprocess for a project/session pair. The single-argument form is
391
+ * retained for test/backward compatibility; HTTP calls always pass a project.
392
+ */
393
+ export function abortSession(projectRootOrSessionId, maybeSessionId) {
394
+ const entry = maybeSessionId
395
+ ? runningProcs.get(scopedSessionKey(projectRootOrSessionId, maybeSessionId))
396
+ : [...runningProcs.values()].find((candidate) => candidate.sessionId === projectRootOrSessionId);
397
+ if (!entry)
398
+ return false;
399
+ const { child } = entry;
400
+ try {
401
+ child.kill("SIGTERM");
402
+ }
403
+ catch { /* ignore */ }
404
+ // Force-kill after a grace period if it has not exited.
405
+ setTimeout(() => {
406
+ const key = scopedSessionKey(entry.projectRoot, entry.sessionId);
407
+ if (runningProcs.get(key)?.child === child) {
408
+ try {
409
+ child.kill("SIGKILL");
410
+ }
411
+ catch { /* ignore */ }
412
+ }
413
+ }, 2000).unref?.();
414
+ return true;
415
+ }
416
+ const TURN_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
417
+ // ─── Stream parser ───────────────────────────────────────────────────────────
418
+ /**
419
+ * Parse one NDJSON line into a typed StreamEvent. Returns null when the
420
+ * line is empty / not an object — caller should treat that as "no event
421
+ * for this chunk" and continue. Exported so unit tests can exercise the
422
+ * parser without spawning a child process.
423
+ */
424
+ export function parseStreamLine(line) {
425
+ const trimmed = line.trim();
426
+ if (!trimmed)
427
+ return null;
428
+ let raw;
429
+ try {
430
+ const parsed = JSON.parse(trimmed);
431
+ if (!parsed || typeof parsed !== "object")
432
+ return null;
433
+ raw = parsed;
434
+ }
435
+ catch {
436
+ return null;
437
+ }
438
+ // `--include-partial-messages` wraps Anthropic-style deltas as
439
+ // `{type:"stream_event",event:{...}}`. Normalise that documented envelope
440
+ // before parsing, while retaining its original payload for diagnostics.
441
+ const envelope = raw;
442
+ if (raw.type === "stream_event" && raw.event && typeof raw.event === "object") {
443
+ raw = raw.event;
444
+ }
445
+ let type = typeof raw.type === "string" ? raw.type : "unknown";
446
+ const parentFrom = (value) => {
447
+ if (!value || typeof value !== "object")
448
+ return undefined;
449
+ const record = value;
450
+ const id = record.parent_tool_use_id ?? record.parentToolUseId;
451
+ return typeof id === "string" && id.length > 0 ? id : undefined;
452
+ };
453
+ const event = { type, raw: { ...raw, _envelope: envelope.type === "stream_event" ? envelope : undefined } };
454
+ // `--forward-subagent-text` annotates forwarded assistant/user messages
455
+ // with this id. Claude Code has shipped it both on the outer record and on
456
+ // `message`, so accept either documented shape (and the partial envelope).
457
+ event.parentToolUseId = parentFrom(raw)
458
+ ?? parentFrom(raw.message)
459
+ ?? parentFrom(envelope)
460
+ ?? parentFrom(envelope.message);
461
+ if (typeof raw.index === "number")
462
+ event.index = raw.index;
463
+ // Claude Code's current stream-json format emits completed assistant
464
+ // snapshots (`{type:"assistant", message:{content:[...]}}`) instead of
465
+ // Anthropic content_block_delta records. Normalise visible text into the
466
+ // same delta shape so the UI and persisted turn never go blank.
467
+ if (type === "assistant") {
468
+ const content = raw.message?.content;
469
+ const text = Array.isArray(content)
470
+ ? content.filter((part) => part && typeof part === "object" && part.type === "text")
471
+ .map((part) => String(part.text ?? "")).join("")
472
+ : "";
473
+ if (text) {
474
+ type = "content_block_delta";
475
+ event.type = type;
476
+ event.index = 0;
477
+ event.delta = { type: "text_delta", text };
478
+ return event;
479
+ }
480
+ }
481
+ // The terminal result carries a reliable text fallback for providers that
482
+ // do not emit a separate assistant-text record.
483
+ if (type === "result" && typeof raw.result === "string" && raw.result) {
484
+ event.type = "content_block_delta";
485
+ event.index = 0;
486
+ event.delta = { type: "text_delta", text: raw.result };
487
+ return event;
488
+ }
489
+ // content_block_start carries the block descriptor under `content_block`.
490
+ const cb = raw.content_block;
491
+ if (cb && typeof cb === "object") {
492
+ const block = cb;
493
+ const blockType = typeof block.type === "string" ? block.type : "";
494
+ if (blockType === "text" || blockType === "thinking" || blockType === "tool_use" || blockType === "tool_result") {
495
+ event.contentBlock = {
496
+ type: blockType,
497
+ id: typeof block.id === "string" ? block.id : undefined,
498
+ name: typeof block.name === "string" ? block.name : undefined,
499
+ tool_use_id: typeof block.tool_use_id === "string" ? block.tool_use_id : undefined,
500
+ input: block.input && typeof block.input === "object"
501
+ ? block.input
502
+ : undefined,
503
+ content: typeof block.content === "string"
504
+ ? block.content
505
+ : Array.isArray(block.content)
506
+ ? block.content
507
+ : undefined,
508
+ text: typeof block.text === "string" ? block.text : undefined,
509
+ };
510
+ }
511
+ }
512
+ // content_block_delta / message_delta carry the delta under `delta`.
513
+ const d = raw.delta;
514
+ if (d && typeof d === "object") {
515
+ const delta = d;
516
+ event.delta = {
517
+ type: typeof delta.type === "string" ? delta.type : "",
518
+ text: typeof delta.text === "string" ? delta.text : undefined,
519
+ thinking: typeof delta.thinking === "string" ? delta.thinking : undefined,
520
+ partial_json: typeof delta.partial_json === "string" ? delta.partial_json : undefined,
521
+ stop_reason: typeof delta.stop_reason === "string" ? delta.stop_reason : undefined,
522
+ };
523
+ }
524
+ return event;
525
+ }
526
+ /** Apply a parsed stream event to the in-memory TurnState. Returns the
527
+ * list of chip-friendly events (tool_use, tool_result, text_delta) that
528
+ * were produced, so callers can fan them out over SSE without re-parsing.
529
+ * Exported for unit tests.
530
+ */
531
+ function nextToolSequence(state) {
532
+ state.toolSequence = (state.toolSequence ?? 0) + 1;
533
+ return state.toolSequence;
534
+ }
535
+ /** Preserve subagent file/tool work in the final parent activity audit while
536
+ * deliberately excluding forwarded text and thinking from the parent reply. */
537
+ function applyForwardedToolEvent(state, event) {
538
+ const parentToolUseId = event.parentToolUseId;
539
+ const block = event.contentBlock;
540
+ if (!parentToolUseId || !block)
541
+ return;
542
+ const tools = state.subagentToolUses ??= new Map();
543
+ if (block.type === "tool_use" && block.id) {
544
+ const key = `${parentToolUseId}:${block.id}`;
545
+ tools.set(key, {
546
+ id: `subagent:${key}`,
547
+ name: block.name ?? "unknown",
548
+ input: block.input ?? {},
549
+ status: "started",
550
+ source: "subagent",
551
+ parentToolUseId,
552
+ sequence: nextToolSequence(state),
553
+ });
554
+ return;
555
+ }
556
+ if (block.type !== "tool_result" || !block.tool_use_id)
557
+ return;
558
+ const existing = tools.get(`${parentToolUseId}:${block.tool_use_id}`);
559
+ if (!existing)
560
+ return;
561
+ const text = typeof block.content === "string"
562
+ ? block.content
563
+ : Array.isArray(block.content)
564
+ ? block.content.map((item) => item.text ?? "").join("")
565
+ : "";
566
+ const rawBlock = event.raw.content_block;
567
+ existing.status = rawBlock?.is_error ? "failed" : "completed";
568
+ existing.resultPreview = text.slice(0, 200);
569
+ existing.isError = Boolean(rawBlock?.is_error);
570
+ tools.set(`${parentToolUseId}:${block.tool_use_id}`, existing);
571
+ }
572
+ /**
573
+ * Merge a text event without replaying Claude's assistant snapshots after
574
+ * incremental stream deltas. The return value is exactly the new suffix that
575
+ * belongs in the live SSE transcript.
576
+ */
577
+ function mergeText(state, index, text, snapshot) {
578
+ const previous = state.textByBlock.get(index) ?? "";
579
+ if (!snapshot) {
580
+ state.textByBlock.set(index, previous + text);
581
+ return text;
582
+ }
583
+ if (!previous) {
584
+ state.textByBlock.set(index, text);
585
+ return text;
586
+ }
587
+ if (previous === text || previous.endsWith(text) || previous.startsWith(text))
588
+ return "";
589
+ if (text.startsWith(previous)) {
590
+ state.textByBlock.set(index, text);
591
+ return text.slice(previous.length);
592
+ }
593
+ // An assistant snapshot should supersede a partial stream rather than
594
+ // duplicate it. It is intentionally not emitted as a second live delta.
595
+ state.textByBlock.set(index, text);
596
+ return "";
597
+ }
598
+ export function applyStreamEvent(state, event) {
599
+ // Forwarded child text and thinking stay out of the parent reply, while
600
+ // their real tool effects remain available in the completed audit trail.
601
+ if (event.parentToolUseId) {
602
+ applyForwardedToolEvent(state, event);
603
+ return null;
604
+ }
605
+ // `result` is a fallback for providers that skip assistant text events.
606
+ // Do not append it when a normal assistant snapshot has already supplied text.
607
+ if (event.raw.type === "result" && [...state.textByBlock.values()].some(Boolean))
608
+ return null;
609
+ switch (event.type) {
610
+ case "content_block_start": {
611
+ const cb = event.contentBlock;
612
+ if (!cb)
613
+ return null;
614
+ if (cb.type === "text" && typeof event.index === "number") {
615
+ state.textByBlock.set(event.index, "");
616
+ return null;
617
+ }
618
+ if (cb.type === "thinking" && typeof event.index === "number") {
619
+ state.reasoningByBlock.set(event.index, cb.text ?? "");
620
+ return null;
621
+ }
622
+ if (cb.type === "tool_use" && typeof event.index === "number" && cb.id) {
623
+ const toolUse = {
624
+ id: cb.id,
625
+ name: cb.name ?? "unknown",
626
+ input: cb.input ?? {},
627
+ status: "started",
628
+ sequence: nextToolSequence(state),
629
+ };
630
+ state.toolUses.set(event.index, toolUse);
631
+ state.toolIndexById.set(cb.id, event.index);
632
+ return { toolUseIndex: event.index, toolUse };
633
+ }
634
+ if (cb.type === "tool_result" && cb.tool_use_id) {
635
+ const text = typeof cb.content === "string"
636
+ ? cb.content
637
+ : Array.isArray(cb.content)
638
+ ? cb.content.map((c) => c.text ?? "").join("")
639
+ : "";
640
+ const isError = Boolean(event.raw.content_block?.is_error);
641
+ state.toolResults.set(cb.tool_use_id, { content: text, isError });
642
+ const idx = state.toolIndexById.get(cb.tool_use_id);
643
+ if (idx !== undefined) {
644
+ const existing = state.toolUses.get(idx);
645
+ if (existing) {
646
+ existing.status = isError ? "failed" : "completed";
647
+ existing.resultPreview = text.slice(0, 200);
648
+ existing.isError = isError;
649
+ state.toolUses.set(idx, existing);
650
+ return { toolResult: { ...existing } };
651
+ }
652
+ }
653
+ }
654
+ return null;
655
+ }
656
+ case "content_block_delta": {
657
+ const d = event.delta;
658
+ if (!d)
659
+ return null;
660
+ if (d.type === "text_delta" && typeof event.index === "number") {
661
+ const text = mergeText(state, event.index, d.text ?? "", event.raw.type === "assistant");
662
+ return { textDelta: text };
663
+ }
664
+ if ((d.type === "thinking_delta" || d.type === "reasoning_delta") && typeof event.index === "number") {
665
+ const text = d.thinking ?? d.text ?? "";
666
+ state.reasoningByBlock.set(event.index, (state.reasoningByBlock.get(event.index) ?? "") + text);
667
+ return { reasoningDelta: text };
668
+ }
669
+ if (d.type === "input_json_delta" && typeof event.index === "number") {
670
+ const existing = state.toolUses.get(event.index);
671
+ if (existing) {
672
+ existing.status = "streaming";
673
+ state.toolUses.set(event.index, existing);
674
+ }
675
+ return null;
676
+ }
677
+ return null;
678
+ }
679
+ case "message_delta": {
680
+ const reason = event.delta?.stop_reason;
681
+ if (typeof reason === "string") {
682
+ state.stopReason = reason;
683
+ return { stopReason: reason };
684
+ }
685
+ return null;
686
+ }
687
+ case "message_stop": {
688
+ // Mark any started/streaming tool calls that never received a result
689
+ // as aborted (the upstream was cut short).
690
+ for (const [, tool] of state.toolUses) {
691
+ if (tool.status === "started" || tool.status === "streaming") {
692
+ tool.status = "aborted";
693
+ }
694
+ }
695
+ return null;
696
+ }
697
+ default:
698
+ return null;
699
+ }
700
+ }
701
+ /** Assemble the persisted assistant turn from a finished TurnState. */
702
+ export function assembleAssistantTurn(state, opts, assistantTs, status = "ok", error) {
703
+ // Concatenate text blocks in index order.
704
+ const orderedIndexes = [...state.textByBlock.keys()].sort((a, b) => a - b);
705
+ const content = orderedIndexes.map((i) => state.textByBlock.get(i) ?? "").join("");
706
+ const reasoning = [...state.reasoningByBlock.keys()].sort((a, b) => a - b)
707
+ .map((i) => state.reasoningByBlock.get(i) ?? "").join("");
708
+ // Concatenate tool uses in the order they were opened (index order).
709
+ const toolUses = [
710
+ ...[...state.toolUses.entries()].sort((a, b) => a[0] - b[0]).map(([, tool]) => tool),
711
+ ...[...(state.subagentToolUses ?? new Map()).values()],
712
+ ].sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0));
713
+ const turn = {
714
+ ts: assistantTs,
715
+ role: status === "ok" ? "assistant" : "system",
716
+ content,
717
+ agent: opts.agent ?? OPENKAN_AGENT_ID,
718
+ model: opts.model,
719
+ effort: opts.effort,
720
+ permissionMode: opts.permissionMode,
721
+ messageId: newMessageId(),
722
+ status: status ?? "ok",
723
+ toolUses,
724
+ };
725
+ if (reasoning)
726
+ turn.reasoning = reasoning;
727
+ if (error)
728
+ turn.error = error;
729
+ return turn;
730
+ }
731
+ /**
732
+ * Spawn `claude -p "<message>" --output-format stream-json --verbose` and
733
+ * stream parsed NDJSON events back via the provided callback. Returns once
734
+ * the subprocess exits and the final assistant turn has been persisted.
735
+ */
736
+ export async function sendTurn(projectRoot, opts) {
737
+ validateSelectors(opts);
738
+ const sessionId = opts.sessionId ?? generateSessionId();
739
+ const messageId = newMessageId();
740
+ const startedAtMs = Date.now();
741
+ const ts = nowIso();
742
+ const userTurn = {
743
+ ts,
744
+ role: "user",
745
+ content: opts.displayMessage ?? opts.message,
746
+ taskMentions: opts.taskMentions,
747
+ agent: opts.agent ?? OPENKAN_AGENT_ID,
748
+ model: opts.model,
749
+ effort: opts.effort,
750
+ permissionMode: opts.permissionMode,
751
+ messageId,
752
+ };
753
+ appendTurn(projectRoot, sessionId, userTurn);
754
+ const bin = resolveClaudeBin(opts.claudeBin);
755
+ const args = [
756
+ "-p",
757
+ opts.message,
758
+ "--model", opts.model,
759
+ "--effort", opts.effort,
760
+ "--permission-mode", opts.permissionMode,
761
+ "--output-format", "stream-json",
762
+ "--verbose",
763
+ "--include-partial-messages",
764
+ "--include-hook-events",
765
+ "--forward-subagent-text",
766
+ ];
767
+ const selectedAgent = opts.agent ?? OPENKAN_AGENT_ID;
768
+ if (selectedAgent !== "default") {
769
+ // The bundled default also works with --ignore-scripts or a read-only home.
770
+ // Existing user/project profiles retain their normal Claude precedence.
771
+ if (selectedAgent === OPENKAN_AGENT_ID && !(await readAgents(projectRoot)).some(profile => profile.id === OPENKAN_AGENT_ID)) {
772
+ args.push("--agents", JSON.stringify({ [OPENKAN_AGENT_ID]: openkanAgentDefinition() }));
773
+ }
774
+ args.push("--agent", selectedAgent);
775
+ }
776
+ const child = spawn(bin, args, {
777
+ cwd: opts.cwd ?? projectRoot,
778
+ env: opts.env ?? process.env,
779
+ stdio: ["ignore", "pipe", "pipe"],
780
+ });
781
+ registerProc(projectRoot, sessionId, child);
782
+ const abortHandler = () => {
783
+ try {
784
+ child.kill("SIGTERM");
785
+ }
786
+ catch { /* ignore */ }
787
+ };
788
+ if (opts.signal) {
789
+ if (opts.signal.aborted)
790
+ abortHandler();
791
+ else
792
+ opts.signal.addEventListener("abort", abortHandler, { once: true });
793
+ }
794
+ let stderrBuf = "";
795
+ let timedOut = false;
796
+ const timeout = setTimeout(() => {
797
+ timedOut = true;
798
+ try {
799
+ child.kill("SIGTERM");
800
+ }
801
+ catch { /* ignore */ }
802
+ }, TURN_TIMEOUT_MS);
803
+ timeout.unref?.();
804
+ // Line-buffer for NDJSON. Chunks from stdout are appended to `lineBuf`
805
+ // and split on `\n` so we never lose a partial line straddling chunks.
806
+ let lineBuf = "";
807
+ const state = {
808
+ textByBlock: new Map(),
809
+ reasoningByBlock: new Map(),
810
+ toolUses: new Map(),
811
+ toolIndexById: new Map(),
812
+ toolResults: new Map(),
813
+ subagentToolUses: new Map(),
814
+ toolSequence: 0,
815
+ stopReason: null,
816
+ };
817
+ // Child-exit promise. We capture both `code` and `signal` because aborts
818
+ // via SIGTERM/SIGKILL exit with code `null` and a signal name.
819
+ const exitInfo = await new Promise((resolveP, rejectP) => {
820
+ child.stdout?.on("data", (chunk) => {
821
+ const text = chunk.toString("utf-8");
822
+ lineBuf += text;
823
+ let nl;
824
+ while ((nl = lineBuf.indexOf("\n")) !== -1) {
825
+ const line = lineBuf.slice(0, nl);
826
+ lineBuf = lineBuf.slice(nl + 1);
827
+ const event = parseStreamLine(line);
828
+ if (!event)
829
+ continue;
830
+ const applied = applyStreamEvent(state, event);
831
+ opts.onStreamEvent?.(event, applied);
832
+ }
833
+ });
834
+ child.stderr?.on("data", (chunk) => {
835
+ stderrBuf += chunk.toString("utf-8");
836
+ });
837
+ child.on("error", (err) => rejectP(err));
838
+ child.on("exit", (code, signal) => resolveP({ code, signal }));
839
+ });
840
+ // Drain any trailing line that did not end in \n (e.g. partial write).
841
+ if (lineBuf.trim()) {
842
+ const event = parseStreamLine(lineBuf);
843
+ if (event) {
844
+ const applied = applyStreamEvent(state, event);
845
+ opts.onStreamEvent?.(event, applied);
846
+ }
847
+ }
848
+ const exitCode = exitInfo.code;
849
+ const exitSignal = exitInfo.signal;
850
+ clearTimeout(timeout);
851
+ if (opts.signal)
852
+ opts.signal.removeEventListener("abort", abortHandler);
853
+ clearProc(projectRoot, sessionId, child);
854
+ const assistantTs = nowIso();
855
+ const killedBySignal = exitCode === null && exitSignal !== null;
856
+ const aborted = opts.signal?.aborted || timedOut || killedBySignal;
857
+ let assistantTurn;
858
+ if (aborted) {
859
+ const assembled = assembleAssistantTurn(state, opts, assistantTs, "aborted");
860
+ assembled.role = "system";
861
+ if (!assembled.content)
862
+ assembled.content = "(assistant turn aborted before completion)";
863
+ assistantTurn = assembled;
864
+ }
865
+ else if (exitCode !== 0) {
866
+ assistantTurn = assembleAssistantTurn(state, opts, assistantTs, "error", stderrBuf.trim() || `exit code ${exitCode}`);
867
+ assistantTurn.role = "system";
868
+ if (!assistantTurn.content) {
869
+ assistantTurn.content = stderrBuf.trim() || `claude exited with code ${exitCode}`;
870
+ }
871
+ }
872
+ else {
873
+ assistantTurn = assembleAssistantTurn(state, opts, assistantTs, "ok");
874
+ }
875
+ assistantTurn.durationMs = Date.now() - startedAtMs;
876
+ appendTurn(projectRoot, sessionId, assistantTurn);
877
+ return { sessionId, userTurn, assistantTurn };
878
+ }
879
+ // ─── HTTP dispatcher ─────────────────────────────────────────────────────────
880
+ const SSE_HEADERS = {
881
+ "Content-Type": "text/event-stream",
882
+ "Cache-Control": "no-cache",
883
+ "Connection": "keep-alive",
884
+ "X-Accel-Buffering": "no",
885
+ };
886
+ /** Project-scoped SSE channels; switching workspaces must never fan out activity across projects. */
887
+ const chatSseControllers = new Map();
888
+ /** Per-project/session SSE channel. */
889
+ const sessionChatSseControllers = new Map();
890
+ function controllersForProject(projectRoot) {
891
+ const scope = projectScope(projectRoot);
892
+ let controllers = chatSseControllers.get(scope);
893
+ if (!controllers) {
894
+ controllers = new Set();
895
+ chatSseControllers.set(scope, controllers);
896
+ }
897
+ return controllers;
898
+ }
899
+ function broadcastChat(projectRoot, event, data) {
900
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
901
+ for (const ctrl of chatSseControllers.get(projectScope(projectRoot)) ?? []) {
902
+ try {
903
+ ctrl.enqueue(new TextEncoder().encode(payload));
904
+ }
905
+ catch { /* ignore */ }
906
+ }
907
+ }
908
+ /** Push an event only to subscribers of this project/session pair. */
909
+ function broadcastChatSession(projectRoot, sessionId, event, data) {
910
+ const ctrls = sessionChatSseControllers.get(scopedSessionKey(projectRoot, sessionId));
911
+ if (!ctrls)
912
+ return;
913
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
914
+ for (const ctrl of ctrls) {
915
+ try {
916
+ ctrl.enqueue(new TextEncoder().encode(payload));
917
+ }
918
+ catch { /* ignore */ }
919
+ }
920
+ }
921
+ function isHighSignalActivity(event) {
922
+ const raw = event.raw;
923
+ const hook = raw.hook_event_name ?? raw.hookEventName;
924
+ const subtype = typeof raw.subtype === "string" ? raw.subtype.toLowerCase() : "";
925
+ if (event.parentToolUseId && event.contentBlock?.type === "tool_use")
926
+ return true;
927
+ if (hook || raw.mcp_server_name || raw.mcp_tool_name)
928
+ return true;
929
+ if (subtype.includes("retry") || subtype.includes("team") || subtype.includes("workflow") || subtype.includes("agent"))
930
+ return true;
931
+ return event.type === "error" || raw.type === "error";
932
+ }
933
+ function jsonResponse(body, status = 200) {
934
+ return new Response(JSON.stringify(body), {
935
+ status,
936
+ headers: { "Content-Type": "application/json" },
937
+ });
938
+ }
939
+ function errResponse(message, status = 400) {
940
+ return jsonResponse({ error: message }, status);
941
+ }
942
+ const ALLOWED_TAGS = [
943
+ "p", "br", "strong", "em", "del", "code", "pre",
944
+ "ul", "ol", "li", "blockquote",
945
+ "h1", "h2", "h3", "h4", "h5", "h6",
946
+ "table", "thead", "tbody", "tr", "th", "td",
947
+ "a", "hr", "img", "span", "div",
948
+ ];
949
+ const ALLOWED_ATTRS = {
950
+ a: ["href", "title"],
951
+ code: ["class"],
952
+ img: ["src", "alt", "title"],
953
+ };
954
+ /**
955
+ * Server-side markdown rendering for chat messages. Mirrors the
956
+ * `renderArtifact` pipeline in `server.ts` so the chat sidebar can show
957
+ * sanitised HTML without bundling `marked` into the browser.
958
+ */
959
+ async function renderMarkdown(raw) {
960
+ const [{ marked }, sanitizeHtmlMod] = await Promise.all([
961
+ import("marked"),
962
+ import("sanitize-html"),
963
+ ]);
964
+ const html = await marked(raw);
965
+ const sanitizeHtml = sanitizeHtmlMod.default;
966
+ return sanitizeHtml(html, {
967
+ allowedTags: ALLOWED_TAGS,
968
+ allowedAttributes: ALLOWED_ATTRS,
969
+ });
970
+ }
971
+ async function readJsonBody(req) {
972
+ try {
973
+ const body = await req.json();
974
+ if (body && typeof body === "object")
975
+ return body;
976
+ return null;
977
+ }
978
+ catch {
979
+ return null;
980
+ }
981
+ }
982
+ /**
983
+ * HTTP dispatcher for `/api/chat/*`. Mirrors the shape of `handleBizarRequest`
984
+ * / `handleClaudeRequest`: returns a `Response` for every request.
985
+ */
986
+ export async function handleChatRequest(projectRoot, req, path) {
987
+ void new URL(req.url); // reserved for future query filters; suppress lint
988
+ try {
989
+ // GET /api/chat/sessions — list every session summary
990
+ if (req.method === "GET" && path === "/api/chat/sessions") {
991
+ return jsonResponse({ sessions: listSessions(projectRoot) });
992
+ }
993
+ // GET /api/chat/sessions/:sid — full transcript
994
+ const getOne = path.match(/^\/api\/chat\/sessions\/([^/]+)$/);
995
+ if (getOne && req.method === "GET") {
996
+ const sid = decodeURIComponent(getOne[1]);
997
+ const turns = readSession(projectRoot, sid);
998
+ if (turns.length === 0) {
999
+ return errResponse(`Session not found: ${sid}`, 404);
1000
+ }
1001
+ const archived = isSessionArchived(projectRoot, sid);
1002
+ return jsonResponse({
1003
+ session: summariseSession(sid, turns, archived),
1004
+ running: listRunningSessions(projectRoot).includes(sid),
1005
+ turns,
1006
+ });
1007
+ }
1008
+ // DELETE /api/chat/sessions/:sid — archive
1009
+ const delOne = path.match(/^\/api\/chat\/sessions\/([^/]+)$/);
1010
+ if (delOne && req.method === "DELETE") {
1011
+ const sid = decodeURIComponent(delOne[1]);
1012
+ if (!isSessionActive(projectRoot, sid) && !isSessionArchived(projectRoot, sid)) {
1013
+ return errResponse(`Session not found: ${sid}`, 404);
1014
+ }
1015
+ // If active, move to archived; if already archived, hard-delete.
1016
+ if (isSessionActive(projectRoot, sid)) {
1017
+ archiveSession(projectRoot, sid);
1018
+ }
1019
+ else {
1020
+ deleteSession(projectRoot, sid);
1021
+ }
1022
+ broadcastChat(projectRoot, "chat.session-archived", { sessionId: sid });
1023
+ return jsonResponse({ ok: true, sessionId: sid, archived: true });
1024
+ }
1025
+ // POST /api/chat/sessions/:sid/abort — kill running subprocess
1026
+ const abortMatch = path.match(/^\/api\/chat\/sessions\/([^/]+)\/abort$/);
1027
+ if (abortMatch && req.method === "POST") {
1028
+ const sid = decodeURIComponent(abortMatch[1]);
1029
+ const killed = abortSession(projectRoot, sid);
1030
+ return jsonResponse({ ok: true, sessionId: sid, killed });
1031
+ }
1032
+ // GET /api/chat/sessions/:sid/events — SSE stream scoped to one session
1033
+ const eventsMatch = path.match(/^\/api\/chat\/sessions\/([^/]+)\/events$/);
1034
+ if (eventsMatch && req.method === "GET") {
1035
+ const sid = decodeURIComponent(eventsMatch[1]);
1036
+ const encoder = new TextEncoder();
1037
+ const stream = new ReadableStream({
1038
+ start(ctrl) {
1039
+ // Subscribe to BOTH the global and per-session channel so the
1040
+ // sidebar receives `chat.turn` rollups and per-event tool/text
1041
+ // updates on the same stream.
1042
+ controllersForProject(projectRoot).add(ctrl);
1043
+ const sessionKey = scopedSessionKey(projectRoot, sid);
1044
+ let set = sessionChatSseControllers.get(sessionKey);
1045
+ if (!set) {
1046
+ set = new Set();
1047
+ sessionChatSseControllers.set(sessionKey, set);
1048
+ }
1049
+ set.add(ctrl);
1050
+ ctrl.enqueue(encoder.encode(`event: chat.session-connected\ndata: ${JSON.stringify({ sessionId: sid })}\n\n`));
1051
+ },
1052
+ cancel() {
1053
+ const controllers = chatSseControllers.get(projectScope(projectRoot));
1054
+ controllers?.delete(this);
1055
+ const sessionKey = scopedSessionKey(projectRoot, sid);
1056
+ const set = sessionChatSseControllers.get(sessionKey);
1057
+ if (set) {
1058
+ for (const c of set) {
1059
+ if (c === this)
1060
+ set.delete(c);
1061
+ }
1062
+ if (set.size === 0)
1063
+ sessionChatSseControllers.delete(sessionKey);
1064
+ }
1065
+ },
1066
+ });
1067
+ return new Response(stream, { headers: SSE_HEADERS });
1068
+ }
1069
+ // POST /api/chat/send — send a new turn
1070
+ if (req.method === "POST" && path === "/api/chat/send") {
1071
+ const body = await readJsonBody(req);
1072
+ if (!body)
1073
+ return errResponse("Invalid JSON body", 400);
1074
+ const message = typeof body.message === "string" ? body.message.trim() : "";
1075
+ const taskMentions = Array.isArray(body.taskMentions)
1076
+ ? body.taskMentions
1077
+ .filter((item) => !!item && typeof item === "object")
1078
+ .map((item) => ({
1079
+ id: typeof item.id === "string" ? item.id.trim() : "",
1080
+ title: typeof item.title === "string" ? item.title.trim() : "",
1081
+ }))
1082
+ .filter((item) => item.id.length > 0)
1083
+ .slice(0, 12)
1084
+ : [];
1085
+ if (!message && taskMentions.length === 0)
1086
+ return errResponse("message or task reference is required", 422);
1087
+ const taskContext = taskMentions.length
1088
+ ? `\n\nReferenced OpenKan tasks (use these as project context):\n${taskMentions.map((task) => `- ${task.id}: ${task.title || "Untitled task"}`).join("\n")}`
1089
+ : "";
1090
+ const agentMessage = `${message || "Please help with the referenced task."}${taskContext}`;
1091
+ const legacyPermissionModes = {
1092
+ "accept-edits": "acceptEdits", default: "auto", "bypass-permissions": "bypassPermissions",
1093
+ };
1094
+ const requestedPermissionMode = typeof body.permissionMode === "string" && body.permissionMode
1095
+ ? body.permissionMode : "bypassPermissions";
1096
+ const selectors = {
1097
+ agent: body.agent === undefined ? OPENKAN_AGENT_ID : typeof body.agent === "string" ? body.agent : "",
1098
+ model: typeof body.model === "string" && body.model ? body.model : "default",
1099
+ effort: typeof body.effort === "string" && body.effort ? body.effort : "high",
1100
+ permissionMode: legacyPermissionModes[requestedPermissionMode] ?? requestedPermissionMode,
1101
+ };
1102
+ try {
1103
+ validateSelectors(selectors);
1104
+ if (selectors.agent !== OPENKAN_AGENT_ID && selectors.agent !== "default" && !(await readAgents(projectRoot)).some(profile => profile.id === selectors.agent))
1105
+ throw new Error("Selected agent is unavailable. Choose an installed agent.");
1106
+ }
1107
+ catch (e) {
1108
+ return errResponse(e.message, 422);
1109
+ }
1110
+ const sessionId = typeof body.sessionId === "string" && body.sessionId
1111
+ ? body.sessionId
1112
+ : generateSessionId();
1113
+ try {
1114
+ // Start immediately and return an acknowledgement. The browser can now
1115
+ // subscribe to this session before Claude's first streamed event,
1116
+ // rather than staring at an empty composer until the process exits.
1117
+ broadcastChatSession(projectRoot, sessionId, "chat.status", { sessionId, phase: "thinking", label: "Thinking" });
1118
+ const running = sendTurn(projectRoot, {
1119
+ sessionId,
1120
+ message: agentMessage,
1121
+ displayMessage: message,
1122
+ taskMentions,
1123
+ agent: selectors.agent,
1124
+ model: selectors.model,
1125
+ effort: selectors.effort,
1126
+ permissionMode: selectors.permissionMode,
1127
+ onStreamEvent: (event, applied) => {
1128
+ // Live activity remains intentionally quiet: lifecycle changes and
1129
+ // the current subagent tool are useful while work is running;
1130
+ // individual thinking/text tokens are retained nowhere in the UI.
1131
+ if (isHighSignalActivity(event)) {
1132
+ broadcastChatSession(projectRoot, sessionId, "chat.activity", {
1133
+ sessionId,
1134
+ type: event.type,
1135
+ subtype: typeof event.raw.subtype === "string" ? event.raw.subtype : undefined,
1136
+ parentToolUseId: event.parentToolUseId,
1137
+ raw: event.raw,
1138
+ ts: nowIso(),
1139
+ });
1140
+ }
1141
+ // Subagent output belongs to the nested activity tree. Never
1142
+ // stream it into the parent assistant bubble.
1143
+ if (event.parentToolUseId)
1144
+ return;
1145
+ // Per-session channel: stream typed events for chips + bubbles.
1146
+ switch (event.type) {
1147
+ case "content_block_start": {
1148
+ const cb = event.contentBlock;
1149
+ if (cb?.type === "tool_use" && cb.id && event.index !== undefined) {
1150
+ broadcastChatSession(projectRoot, sessionId, "chat.tool-use", {
1151
+ sessionId,
1152
+ id: cb.id,
1153
+ name: cb.name ?? "unknown",
1154
+ input: cb.input ?? {},
1155
+ status: "started",
1156
+ index: event.index,
1157
+ });
1158
+ }
1159
+ break;
1160
+ }
1161
+ case "content_block_delta": {
1162
+ const d = event.delta;
1163
+ if (d?.type === "text_delta" && applied && "textDelta" in applied && applied.textDelta) {
1164
+ broadcastChatSession(projectRoot, sessionId, "chat.text-delta", {
1165
+ sessionId,
1166
+ text: applied.textDelta,
1167
+ });
1168
+ }
1169
+ else if ((d?.type === "thinking_delta" || d?.type === "reasoning_delta") && typeof (d.thinking ?? d.text) === "string") {
1170
+ broadcastChatSession(projectRoot, sessionId, "chat.reasoning-delta", {
1171
+ sessionId,
1172
+ text: d.thinking ?? d.text,
1173
+ });
1174
+ }
1175
+ else if (d?.type === "input_json_delta" && typeof event.index === "number") {
1176
+ broadcastChatSession(projectRoot, sessionId, "chat.tool-input-delta", {
1177
+ sessionId,
1178
+ index: event.index,
1179
+ partialJson: d.partial_json ?? "",
1180
+ });
1181
+ }
1182
+ break;
1183
+ }
1184
+ case "message_delta": {
1185
+ if (event.delta?.stop_reason) {
1186
+ broadcastChatSession(projectRoot, sessionId, "chat.message-delta", {
1187
+ sessionId,
1188
+ stopReason: event.delta.stop_reason,
1189
+ });
1190
+ }
1191
+ break;
1192
+ }
1193
+ case "message_stop": {
1194
+ broadcastChatSession(projectRoot, sessionId, "chat.message-done", {
1195
+ sessionId,
1196
+ stopReason: event.delta?.stop_reason ?? null,
1197
+ });
1198
+ break;
1199
+ }
1200
+ default:
1201
+ break;
1202
+ }
1203
+ },
1204
+ });
1205
+ void running.then((result) => {
1206
+ broadcastChat(projectRoot, "chat.turn", { sessionId: result.sessionId, userTurn: result.userTurn, assistantTurn: result.assistantTurn });
1207
+ broadcastChatSession(projectRoot, result.sessionId, "chat.turn", { sessionId: result.sessionId, userTurn: result.userTurn, assistantTurn: result.assistantTurn });
1208
+ }).catch((error) => {
1209
+ broadcastChatSession(projectRoot, sessionId, "chat.status", { sessionId, phase: "error", label: "Chat failed", error: error instanceof Error ? error.message : String(error) });
1210
+ });
1211
+ const userTurn = readSession(projectRoot, sessionId).at(-1);
1212
+ return jsonResponse({ sessionId, accepted: true, userTurn }, 202);
1213
+ }
1214
+ catch (e) {
1215
+ const message = e?.message || "sendTurn failed";
1216
+ return errResponse(message, 500);
1217
+ }
1218
+ }
1219
+ // GET /api/chat/events — SSE stream of every chat event in the project
1220
+ if (req.method === "GET" && path === "/api/chat/events") {
1221
+ const encoder = new TextEncoder();
1222
+ const stream = new ReadableStream({
1223
+ start(ctrl) {
1224
+ controllersForProject(projectRoot).add(ctrl);
1225
+ ctrl.enqueue(encoder.encode("event: chat.connected\ndata: {}\n\n"));
1226
+ },
1227
+ cancel(ctrl) {
1228
+ const scope = projectScope(projectRoot);
1229
+ const controllers = chatSseControllers.get(scope);
1230
+ controllers?.delete(ctrl);
1231
+ if (controllers?.size === 0)
1232
+ chatSseControllers.delete(scope);
1233
+ },
1234
+ });
1235
+ return new Response(stream, { headers: SSE_HEADERS });
1236
+ }
1237
+ // GET /api/chat/selectors — server-side allowed values for the UI
1238
+ if (req.method === "GET" && path === "/api/chat/selectors") {
1239
+ return jsonResponse({
1240
+ efforts: ALLOWED_EFFORT_LEVELS,
1241
+ permissionModes: ALLOWED_PERMISSION_MODES,
1242
+ });
1243
+ }
1244
+ // GET /api/chat/picker-options — model list (sourced from the project
1245
+ // model-router) + allowed effort + permission modes. Used by the chat
1246
+ // sidebar's model pill popover.
1247
+ if (req.method === "GET" && path === "/api/chat/picker-options") {
1248
+ return jsonResponse(await pickerOptions(projectRoot));
1249
+ }
1250
+ // POST /api/chat/render-markdown — sanitised HTML for chat messages
1251
+ if (req.method === "POST" && path === "/api/chat/render-markdown") {
1252
+ const body = await readJsonBody(req);
1253
+ if (!body)
1254
+ return errResponse("Invalid JSON body", 400);
1255
+ const md = typeof body.markdown === "string" ? body.markdown : "";
1256
+ const html = await renderMarkdown(md);
1257
+ return new Response(html, {
1258
+ status: 200,
1259
+ headers: { "Content-Type": "text/html; charset=utf-8" },
1260
+ });
1261
+ }
1262
+ return errResponse("Not found", 404);
1263
+ }
1264
+ catch (e) {
1265
+ const message = e?.message || String(e);
1266
+ return errResponse(message, 500);
1267
+ }
1268
+ }
1269
+ // Test-only export so tests can wipe the registry between runs.
1270
+ export function _resetRunningProcsForTests() {
1271
+ for (const { child } of runningProcs.values()) {
1272
+ try {
1273
+ child.kill("SIGKILL");
1274
+ }
1275
+ catch { /* ignore */ }
1276
+ }
1277
+ runningProcs.clear();
1278
+ chatSseControllers.clear();
1279
+ sessionChatSseControllers.clear();
1280
+ }