@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,974 @@
1
+ // OpenKan — native readers for Claude Code's filesystem state.
2
+ //
3
+ // These readers let OpenKan render the Bizar control plane without shelling
4
+ // out to the external `bizar` CLI. They walk `~/.claude/`, `~/.claude/skills/`,
5
+ // `~/.claude/commands/`, `~/.claude/hooks/`, and `~/.claude/projects/` to
6
+ // surface the same data shape the legacy `bizarJson(..., ["control",
7
+ // "snapshot", "--json"])` shim returned, with two differences:
8
+ //
9
+ // 1. Sources are read directly from disk; no subprocess is spawned.
10
+ // 2. Frontmatter is parsed with `gray-matter` so each field is a real value,
11
+ // not a stringified blob.
12
+ //
13
+ // All readers are pure async functions with no module-level state except a
14
+ // module-private cursor map for `readActivityTail` so tail calls are
15
+ // incremental.
16
+ import { existsSync, readdirSync, readFileSync, statSync, } from "node:fs";
17
+ import { basename, join, sep } from "node:path";
18
+ import { homedir } from "node:os";
19
+ import matter from "gray-matter";
20
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
21
+ const SHARED_DIR = `${sep}_shared${sep}`;
22
+ /** Global Claude configuration plus project-local additions, with local last. */
23
+ function claudeConfigDirs(projectRoot) {
24
+ const globalDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
25
+ const localDir = join(projectRoot, ".claude");
26
+ return [...new Set([globalDir, localDir])];
27
+ }
28
+ function claudeHomeDir() {
29
+ return process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
30
+ }
31
+ function listMarkdownFiles(claudeDir, relPath) {
32
+ const dir = join(claudeDir, relPath);
33
+ if (!existsSync(dir))
34
+ return [];
35
+ const files = [];
36
+ const walk = (current) => {
37
+ try {
38
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
39
+ // Shared prompt fragments are reusable source material, not invocable
40
+ // commands. Keep them out of every command catalogue.
41
+ if (entry.name === "_shared")
42
+ continue;
43
+ const path = join(current, entry.name);
44
+ if (entry.isDirectory()) {
45
+ walk(path);
46
+ continue;
47
+ }
48
+ if (entry.isFile() && (entry.name.endsWith(".md") || entry.name.endsWith(".mdx")))
49
+ files.push(path);
50
+ }
51
+ }
52
+ catch { /* unreadable command folders are absent from the catalogue */ }
53
+ };
54
+ walk(dir);
55
+ return files.sort();
56
+ }
57
+ function safeReadFile(path) {
58
+ try {
59
+ return readFileSync(path, "utf-8");
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
65
+ function asString(value, fallback = "") {
66
+ return typeof value === "string" ? value : fallback;
67
+ }
68
+ function asStringArray(value) {
69
+ if (Array.isArray(value))
70
+ return value.filter((v) => typeof v === "string");
71
+ if (typeof value === "string")
72
+ return value.split(",").map((s) => s.trim()).filter(Boolean);
73
+ return [];
74
+ }
75
+ function isSharedPath(path) {
76
+ return path.includes(SHARED_DIR);
77
+ }
78
+ function parseFrontmatter(path) {
79
+ const raw = safeReadFile(path);
80
+ if (raw === null)
81
+ return { frontmatter: {}, body: "" };
82
+ try {
83
+ const parsed = matter(raw);
84
+ const fm = parsed.data && typeof parsed.data === "object" ? parsed.data : {};
85
+ return { frontmatter: fm, body: parsed.content ?? "" };
86
+ }
87
+ catch {
88
+ return { frontmatter: {}, body: raw };
89
+ }
90
+ }
91
+ // ─── Agents ──────────────────────────────────────────────────────────────────
92
+ export async function readAgents(rootDir) {
93
+ const router = await readModelRouter(rootDir);
94
+ const byId = new Map();
95
+ for (const claudeDir of claudeConfigDirs(rootDir)) {
96
+ const agentsDir = join(claudeDir, "agents");
97
+ if (!existsSync(agentsDir))
98
+ continue;
99
+ const stack = [agentsDir];
100
+ while (stack.length) {
101
+ const dir = stack.pop();
102
+ let entries;
103
+ try {
104
+ entries = readdirSync(dir);
105
+ }
106
+ catch {
107
+ continue;
108
+ }
109
+ for (const name of entries) {
110
+ const full = join(dir, name);
111
+ let st;
112
+ try {
113
+ st = statSync(full);
114
+ }
115
+ catch {
116
+ continue;
117
+ }
118
+ if (st.isDirectory()) {
119
+ stack.push(full);
120
+ continue;
121
+ }
122
+ if (!st.isFile() || !name.endsWith(".md") || isSharedPath(full))
123
+ continue;
124
+ const { frontmatter, body } = parseFrontmatter(full);
125
+ const id = asString(frontmatter.name) || full.replace(agentsDir + sep, "").replace(/\.md$/, "");
126
+ const tools = asStringArray(frontmatter.tools);
127
+ const routerModel = router.tierHints[id];
128
+ const model = asString(frontmatter.model) || (routerModel === "default" ? null : routerModel) || null;
129
+ byId.set(id, { id, path: full, frontmatter, body, tools, model });
130
+ }
131
+ }
132
+ }
133
+ const out = [...byId.values()];
134
+ out.sort((a, b) => a.id.localeCompare(b.id));
135
+ return out;
136
+ }
137
+ // ─── Skills ──────────────────────────────────────────────────────────────────
138
+ export async function readSkills(rootDir) {
139
+ const byId = new Map();
140
+ for (const claudeDir of claudeConfigDirs(rootDir)) {
141
+ const skillsRoot = join(claudeDir, "skills");
142
+ let entries;
143
+ try {
144
+ entries = readdirSync(skillsRoot);
145
+ }
146
+ catch {
147
+ continue;
148
+ }
149
+ for (const entry of entries) {
150
+ const skillMd = join(skillsRoot, entry, "SKILL.md");
151
+ if (!existsSync(skillMd))
152
+ continue;
153
+ const { frontmatter, body } = parseFrontmatter(skillMd);
154
+ const name = asString(frontmatter.name) || entry;
155
+ const description = asString(frontmatter.description) || extractFirstHeading(body) || "";
156
+ byId.set(name, { id: name, path: skillMd, name, description, frontmatter, kind: frontmatter.kind ? asString(frontmatter.kind) : null });
157
+ }
158
+ }
159
+ const out = [...byId.values()];
160
+ out.sort((a, b) => a.name.localeCompare(b.name));
161
+ return out;
162
+ }
163
+ function extractFirstHeading(body) {
164
+ const match = body.match(/^#\s+(.+?)$/m);
165
+ return match ? match[1].trim() : null;
166
+ }
167
+ // ─── Commands ────────────────────────────────────────────────────────────────
168
+ export async function readCommands(rootDir) {
169
+ const byId = new Map();
170
+ for (const claudeDir of claudeConfigDirs(rootDir)) {
171
+ for (const path of listMarkdownFiles(claudeDir, "commands")) {
172
+ const { frontmatter, body } = parseFrontmatter(path);
173
+ const id = asString(frontmatter.name) || path.replace(/^.*\/commands\//, "").replace(/\.md$/, "");
174
+ const description = asString(frontmatter.description) || extractFirstHeading(body) || "";
175
+ byId.set(id, { id, path, description, frontmatter, workflow: frontmatter.workflow === true });
176
+ }
177
+ }
178
+ const out = [...byId.values()];
179
+ out.sort((a, b) => a.id.localeCompare(b.id));
180
+ return out;
181
+ }
182
+ function parseHooksObject(hooks, source, out) {
183
+ if (!hooks || typeof hooks !== "object")
184
+ return;
185
+ for (const [event, matchers] of Object.entries(hooks)) {
186
+ if (!Array.isArray(matchers))
187
+ continue;
188
+ for (const matcherEntry of matchers) {
189
+ const matcher = matcherEntry && typeof matcherEntry === "object" && "matcher" in matcherEntry
190
+ ? asString(matcherEntry.matcher) || null
191
+ : null;
192
+ const inner = matcherEntry && typeof matcherEntry === "object" && "hooks" in matcherEntry
193
+ ? matcherEntry.hooks
194
+ : undefined;
195
+ const list = Array.isArray(inner) ? inner : [matcherEntry];
196
+ for (const h of list) {
197
+ if (!h || typeof h !== "object")
198
+ continue;
199
+ const command = asString(h.command);
200
+ if (!command)
201
+ continue;
202
+ out.push({ event, matcher, command, source });
203
+ }
204
+ }
205
+ }
206
+ }
207
+ export async function readHooks(rootDir) {
208
+ const out = [];
209
+ const globalDir = claudeHomeDir();
210
+ const projectDir = join(rootDir, ".claude");
211
+ // Claude settings precedence is user < project < project-local. Merge only
212
+ // the hook event map into a fresh object so source JSON is never mutated.
213
+ const hookSources = [
214
+ join(globalDir, "settings.json"),
215
+ join(projectDir, "settings.json"),
216
+ join(projectDir, "settings.local.json"),
217
+ ];
218
+ const mergedHooks = {};
219
+ const hookSourceByEvent = new Map();
220
+ for (const settings of [...new Set(hookSources)]) {
221
+ const raw = safeReadFile(settings);
222
+ if (!raw)
223
+ continue;
224
+ try {
225
+ const parsed = JSON.parse(raw);
226
+ if (!parsed.hooks || typeof parsed.hooks !== "object" || Array.isArray(parsed.hooks))
227
+ continue;
228
+ for (const [event, entries] of Object.entries(parsed.hooks)) {
229
+ mergedHooks[event] = entries;
230
+ hookSourceByEvent.set(event, settings);
231
+ }
232
+ }
233
+ catch { /* malformed settings are ignored */ }
234
+ }
235
+ for (const [event, entries] of Object.entries(mergedHooks)) {
236
+ parseHooksObject({ [event]: entries }, hookSourceByEvent.get(event) ?? "settings", out);
237
+ }
238
+ for (const claudeDir of claudeConfigDirs(rootDir)) {
239
+ const hooksDir = join(claudeDir, "hooks");
240
+ if (!existsSync(hooksDir))
241
+ continue;
242
+ let entries;
243
+ try {
244
+ entries = readdirSync(hooksDir);
245
+ }
246
+ catch {
247
+ entries = [];
248
+ }
249
+ for (const entry of entries) {
250
+ if (!entry.endsWith(".mjs") && !entry.endsWith(".js") && !entry.endsWith(".sh"))
251
+ continue;
252
+ const full = join(hooksDir, entry);
253
+ out.push({ event: "filesystem", matcher: null, command: full, source: full });
254
+ }
255
+ }
256
+ return out;
257
+ }
258
+ // ─── Model router ────────────────────────────────────────────────────────────
259
+ const DEFAULT_ROUTER = {
260
+ version: null,
261
+ endpoint: null,
262
+ models: [],
263
+ tierHints: {},
264
+ policies: { mainOrchestrator: null, unknownAgent: "minimax/MiniMax-M3" },
265
+ raw: {},
266
+ };
267
+ export async function readModelRouter(rootDir) {
268
+ let parsed = null;
269
+ for (const claudeDir of claudeConfigDirs(rootDir)) {
270
+ const raw = safeReadFile(join(claudeDir, "model-router.json"));
271
+ if (!raw)
272
+ continue;
273
+ try {
274
+ parsed = JSON.parse(raw);
275
+ }
276
+ catch { /* retain the last valid configuration */ }
277
+ }
278
+ if (!parsed)
279
+ return DEFAULT_ROUTER;
280
+ const userSelected = (parsed.userSelected && typeof parsed.userSelected === "object"
281
+ ? parsed.userSelected
282
+ : {});
283
+ const models = Array.isArray(userSelected.models)
284
+ ? userSelected.models.filter((m) => typeof m === "string")
285
+ : [];
286
+ const tierHints = userSelected.tierHints && typeof userSelected.tierHints === "object"
287
+ ? Object.fromEntries(Object.entries(userSelected.tierHints)
288
+ .filter(([, v]) => typeof v === "string")
289
+ .map(([k, v]) => [k, v]))
290
+ : {};
291
+ const policiesRaw = parsed.policies && typeof parsed.policies === "object"
292
+ ? parsed.policies
293
+ : {};
294
+ const policies = {
295
+ mainOrchestrator: typeof policiesRaw.mainOrchestrator === "string"
296
+ ? policiesRaw.mainOrchestrator
297
+ : null,
298
+ unknownAgent: typeof policiesRaw.unknownAgent === "string"
299
+ ? policiesRaw.unknownAgent
300
+ : DEFAULT_ROUTER.policies.unknownAgent,
301
+ };
302
+ return {
303
+ version: typeof parsed.version === "string" ? parsed.version : null,
304
+ endpoint: typeof parsed.endpoint === "string" ? parsed.endpoint : null,
305
+ models,
306
+ tierHints,
307
+ policies,
308
+ raw: parsed,
309
+ };
310
+ }
311
+ // ─── Teams ───────────────────────────────────────────────────────────────────
312
+ const TEAM_KEYWORDS = [/\bteam\b/i, /\borchestrator\b/i, /\bcoordinator\b/i];
313
+ function isTeamish(description) {
314
+ return TEAM_KEYWORDS.some((re) => re.test(description));
315
+ }
316
+ export async function readTeams(rootDir) {
317
+ const agents = await readAgents(rootDir);
318
+ const router = await readModelRouter(rootDir);
319
+ const mainOrchestrator = router.policies.mainOrchestrator;
320
+ const teamAgents = agents.filter((a) => {
321
+ const desc = asString(a.frontmatter.description);
322
+ return isTeamish(desc);
323
+ });
324
+ const teams = [];
325
+ if (mainOrchestrator) {
326
+ const orchestrator = agents.find((a) => a.id === mainOrchestrator);
327
+ const members = teamAgents
328
+ .filter((a) => a.id !== mainOrchestrator)
329
+ .map((a) => a.id);
330
+ if (orchestrator)
331
+ members.unshift(orchestrator.id);
332
+ teams.push({
333
+ name: mainOrchestrator,
334
+ members,
335
+ model: orchestrator?.model ?? null,
336
+ });
337
+ }
338
+ // Group remaining team-keyword agents under an "ad-hoc" team if any exist
339
+ // that weren't already assigned to the main orchestrator.
340
+ const assigned = new Set(teams.flatMap((t) => t.members));
341
+ const remaining = teamAgents.filter((a) => !assigned.has(a.id));
342
+ if (remaining.length > 0) {
343
+ teams.push({
344
+ name: "ad-hoc",
345
+ members: remaining.map((a) => a.id).sort(),
346
+ model: remaining[0]?.model ?? null,
347
+ });
348
+ }
349
+ return teams;
350
+ }
351
+ // ─── Workflows ───────────────────────────────────────────────────────────────
352
+ function extractPhases(body) {
353
+ // Match either explicit "## Phase: X" or "## Phase X" headings, or "1.", "2." numbered list items.
354
+ const headingPhases = [];
355
+ const headingRe = /^##\s+(?:Phase(?:\s+|:)?\s*)(.+?)$/gim;
356
+ let m;
357
+ while ((m = headingRe.exec(body)) !== null) {
358
+ headingPhases.push(m[1].trim());
359
+ }
360
+ if (headingPhases.length > 0)
361
+ return headingPhases;
362
+ const listRe = /^\d+\.\s+(.+?)$/gm;
363
+ const items = [];
364
+ while ((m = listRe.exec(body)) !== null)
365
+ items.push(m[1].trim());
366
+ return items;
367
+ }
368
+ export async function readWorkflows(rootDir) {
369
+ const out = [];
370
+ const skills = await readSkills(rootDir);
371
+ for (const skill of skills) {
372
+ if (skill.kind !== "workflow")
373
+ continue;
374
+ const { body } = parseFrontmatter(skill.path);
375
+ out.push({
376
+ id: skill.id,
377
+ name: skill.name,
378
+ source: "skill",
379
+ path: skill.path,
380
+ description: skill.description,
381
+ phases: extractPhases(body),
382
+ });
383
+ }
384
+ const commands = await readCommands(rootDir);
385
+ for (const cmd of commands) {
386
+ if (!cmd.workflow)
387
+ continue;
388
+ const { body } = parseFrontmatter(cmd.path);
389
+ out.push({
390
+ id: cmd.id,
391
+ name: cmd.id,
392
+ source: "command",
393
+ path: cmd.path,
394
+ description: cmd.description,
395
+ phases: extractPhases(body),
396
+ });
397
+ }
398
+ out.sort((a, b) => a.name.localeCompare(b.name));
399
+ return out;
400
+ }
401
+ const tailCursors = (() => {
402
+ const m = new Map();
403
+ return m;
404
+ })();
405
+ function cursorKey(rootDir, file) {
406
+ return `${rootDir}::${file}`;
407
+ }
408
+ function mapRowToActivity(row, fallbackAgent, sessionId) {
409
+ const kind = typeof row.type === "string" ? row.type : null;
410
+ if (!kind)
411
+ return null;
412
+ const ts = typeof row.timestamp === "string" ? row.timestamp : new Date().toISOString();
413
+ const agentId = typeof row.agentName === "string"
414
+ ? row.agentName
415
+ : typeof row.agent === "string"
416
+ ? row.agent
417
+ : fallbackAgent;
418
+ const summary = typeof row.message === "string"
419
+ ? row.message
420
+ : typeof row.payload === "object" && row.payload && "summary" in row.payload && typeof row.payload.summary === "string"
421
+ ? row.payload.summary
422
+ : kind;
423
+ const meta = { sessionId };
424
+ if (row.payload && typeof row.payload === "object") {
425
+ Object.assign(meta, row.payload);
426
+ }
427
+ // The ActivityEvent.activityKind allowlist is strict; unknown row types are
428
+ // mapped to "agent.queued" so the UI still shows them, but the original
429
+ // `kind` is preserved in `meta`.
430
+ const allowed = new Set([
431
+ "chat.turn-started",
432
+ "chat.turn-ended",
433
+ "chat.turn-aborted",
434
+ "chat.message-added",
435
+ "task.created",
436
+ "task.moved",
437
+ "task.commented",
438
+ "task.linked",
439
+ "task.deleted",
440
+ "agent.started",
441
+ "agent.ended",
442
+ "agent.queued",
443
+ ]);
444
+ const mappedKind = allowed.has(kind)
445
+ ? kind
446
+ : "agent.queued";
447
+ meta.originalKind = kind;
448
+ return {
449
+ id: `${ts}-${agentId}-${kind}`,
450
+ projectId: sessionId,
451
+ agentId,
452
+ kind: mappedKind,
453
+ status: "info",
454
+ summary,
455
+ meta,
456
+ ts,
457
+ };
458
+ }
459
+ function listSessionFiles(rootDir) {
460
+ const projectsDir = projectTranscriptDir(rootDir);
461
+ let dirs;
462
+ try {
463
+ dirs = readdirSync(projectsDir);
464
+ }
465
+ catch {
466
+ return [];
467
+ }
468
+ const out = [];
469
+ for (const dir of dirs) {
470
+ const subdir = join(projectsDir, dir);
471
+ let st;
472
+ try {
473
+ st = statSync(subdir);
474
+ }
475
+ catch {
476
+ continue;
477
+ }
478
+ if (!st.isDirectory())
479
+ continue;
480
+ const sub = join(subdir, "subagents");
481
+ if (!existsSync(sub))
482
+ continue;
483
+ let files;
484
+ try {
485
+ files = readdirSync(sub);
486
+ }
487
+ catch {
488
+ continue;
489
+ }
490
+ for (const f of files) {
491
+ if (!f.startsWith("agent-") || !f.endsWith(".jsonl"))
492
+ continue;
493
+ out.push({ path: join(sub, f), sessionId: dir });
494
+ }
495
+ }
496
+ return out;
497
+ }
498
+ function tailJsonl(cursor, path, rootDir, fallbackAgent, sessionId, out) {
499
+ let st;
500
+ try {
501
+ st = statSync(path);
502
+ }
503
+ catch {
504
+ return cursor ?? { offset: 0, mtime: 0 };
505
+ }
506
+ const key = cursorKey(rootDir, path);
507
+ let startOffset = cursor?.offset ?? 0;
508
+ // Reset cursor if file shrank (rotation/truncation)
509
+ if (cursor && st.size < cursor.offset)
510
+ startOffset = 0;
511
+ // Reset cursor if mtime went backwards
512
+ if (cursor && st.mtimeMs < cursor.mtime)
513
+ startOffset = 0;
514
+ const fd = (() => {
515
+ try {
516
+ return readFileSync(path, { encoding: "utf-8" });
517
+ }
518
+ catch {
519
+ return null;
520
+ }
521
+ })();
522
+ if (fd === null)
523
+ return cursor ?? { offset: 0, mtime: 0 };
524
+ // Slice from offset
525
+ const slice = fd.slice(startOffset);
526
+ for (const line of slice.split("\n")) {
527
+ const trimmed = line.trim();
528
+ if (!trimmed)
529
+ continue;
530
+ try {
531
+ const row = JSON.parse(trimmed);
532
+ const ev = mapRowToActivity(row, fallbackAgent, sessionId);
533
+ if (ev)
534
+ out.push(ev);
535
+ }
536
+ catch { /* skip malformed line */ }
537
+ }
538
+ const next = { offset: st.size, mtime: st.mtimeMs };
539
+ tailCursors.set(key, next);
540
+ return next;
541
+ }
542
+ export async function readActivityTail(rootDir, sinceMs) {
543
+ const files = listSessionFiles(rootDir);
544
+ const events = [];
545
+ for (const f of files) {
546
+ const cursor = tailCursors.get(cursorKey(rootDir, f.path));
547
+ const fallbackAgent = f.path.split(sep).pop() ?? "agent";
548
+ tailJsonl(cursor, f.path, rootDir, fallbackAgent, f.sessionId, events);
549
+ }
550
+ if (sinceMs !== undefined) {
551
+ const cutoff = new Date(sinceMs).getTime();
552
+ return events.filter((e) => {
553
+ const t = Date.parse(e.ts);
554
+ return Number.isFinite(t) ? t > cutoff : true;
555
+ });
556
+ }
557
+ return events;
558
+ }
559
+ /** Test helper: reset all tail cursors. */
560
+ export function resetActivityTail() {
561
+ tailCursors.clear();
562
+ }
563
+ // ─── In-memory activity ring buffer ─────────────────────────────────────────
564
+ const RING_MAX = 200;
565
+ const ringBuffer = [];
566
+ const ringListeners = new Set();
567
+ /**
568
+ * Append an event to the bounded ring buffer. Oldest events are evicted once
569
+ * the buffer exceeds `RING_MAX` entries. Listeners are notified after the
570
+ * write so SSE/WS subscribers can broadcast the new rows.
571
+ *
572
+ * Mirrors `recordEvent` in `kanban/agent-activity.ts`; when that file lands
573
+ * on this branch the orchestrator will reconcile the duplicate.
574
+ */
575
+ export function recordEvent(event) {
576
+ ringBuffer.push(event);
577
+ while (ringBuffer.length > RING_MAX)
578
+ ringBuffer.shift();
579
+ for (const fn of ringListeners) {
580
+ try {
581
+ fn([event]);
582
+ }
583
+ catch { /* ignore listener errors */ }
584
+ }
585
+ }
586
+ /** Read the most-recent ring buffer entries, newest first. */
587
+ export function readEvents(limit = RING_MAX) {
588
+ const out = ringBuffer.slice(-limit);
589
+ return out.reverse();
590
+ }
591
+ /** Subscribe to live updates; returns an unsubscribe handle. */
592
+ export function subscribe(fn) {
593
+ ringListeners.add(fn);
594
+ return ringListeners.delete.bind(ringListeners, fn);
595
+ }
596
+ /** Test helper: clear the entire ring buffer. */
597
+ export function resetActivityRing() {
598
+ ringBuffer.length = 0;
599
+ }
600
+ // ─── Snapshot ────────────────────────────────────────────────────────────────
601
+ const TRANSCRIPT_RECENT_MS = 15 * 60_000;
602
+ const MAX_TRANSCRIPT_BYTES = 8 * 1024 * 1024;
603
+ function projectTranscriptDir(projectRoot) {
604
+ // Claude Code's documented project transcript directory encodes an absolute
605
+ // cwd by replacing path separators with dashes.
606
+ const id = projectRoot.replace(/[\\/]+/g, "-");
607
+ return join(claudeHomeDir(), "projects", id);
608
+ }
609
+ function readTranscriptRows(path) {
610
+ let st;
611
+ try {
612
+ st = statSync(path);
613
+ }
614
+ catch {
615
+ return [];
616
+ }
617
+ if (!st.isFile() || st.size > MAX_TRANSCRIPT_BYTES)
618
+ return [];
619
+ const raw = safeReadFile(path);
620
+ if (raw === null)
621
+ return [];
622
+ const rows = [];
623
+ for (const line of raw.split("\n")) {
624
+ if (line.length > 128 * 1024)
625
+ continue;
626
+ try {
627
+ const row = JSON.parse(line);
628
+ if (row && typeof row === "object" && !Array.isArray(row))
629
+ rows.push(row);
630
+ }
631
+ catch { /* partial/malformed JSONL rows are not useful for a snapshot */ }
632
+ }
633
+ return rows;
634
+ }
635
+ function rowString(row, ...keys) {
636
+ for (const key of keys)
637
+ if (typeof row[key] === "string" && row[key])
638
+ return row[key];
639
+ return null;
640
+ }
641
+ function sessionState(id, agentId, lastSeenAt, mtimeMs) {
642
+ const events = readEvents().filter((event) => event.projectId === id || event.meta?.relaySessionId === id || (agentId !== null && event.agentId === agentId));
643
+ const newest = events[0]; // readEvents is newest first.
644
+ if (newest && (newest.kind === "agent.started" || newest.kind === "chat.turn-started")) {
645
+ return { state: "active", liveness: "relay" };
646
+ }
647
+ const seenMs = lastSeenAt ? Date.parse(lastSeenAt) : mtimeMs;
648
+ return { state: Number.isFinite(seenMs) && Date.now() - seenMs <= TRANSCRIPT_RECENT_MS ? "recent" : "settled", liveness: "transcript" };
649
+ }
650
+ /**
651
+ * Read parent transcripts and nested subagent transcripts for the active
652
+ * project. This is intentionally observational: JSONL presence/timestamps do
653
+ * not imply a running Claude process; only relay events can mark a session
654
+ * active.
655
+ */
656
+ export async function readNativeSessions(projectRoot) {
657
+ const dir = projectTranscriptDir(projectRoot);
658
+ let entries;
659
+ try {
660
+ entries = readdirSync(dir);
661
+ }
662
+ catch {
663
+ return [];
664
+ }
665
+ const sessions = [];
666
+ const add = (path, kind, parentSessionId) => {
667
+ let st;
668
+ try {
669
+ st = statSync(path);
670
+ }
671
+ catch {
672
+ return;
673
+ }
674
+ if (!st.isFile() || !path.endsWith(".jsonl") || st.size > MAX_TRANSCRIPT_BYTES)
675
+ return;
676
+ const rows = readTranscriptRows(path);
677
+ const fallbackId = basename(path, ".jsonl").replace(/^agent-/, "");
678
+ const id = kind === "subagent"
679
+ ? fallbackId
680
+ : rows.map((row) => rowString(row, "sessionId")).find(Boolean) || fallbackId;
681
+ let agentId = null;
682
+ let taskId = null;
683
+ let title = null;
684
+ let firstSeenAt = null;
685
+ let lastSeenAt = null;
686
+ for (const row of rows) {
687
+ agentId ||= rowString(row, "agentName", "agent", "agentId", "agentSetting");
688
+ taskId ||= rowString(row, "taskId", "task_id");
689
+ title ||= rowString(row, "customTitle", "title");
690
+ const timestamp = rowString(row, "timestamp");
691
+ if (timestamp && Number.isFinite(Date.parse(timestamp))) {
692
+ firstSeenAt ||= timestamp;
693
+ lastSeenAt = timestamp;
694
+ }
695
+ }
696
+ const state = sessionState(id, agentId, lastSeenAt, st.mtimeMs);
697
+ sessions.push({ id, kind, parentSessionId, agentId, taskId, title, firstSeenAt, lastSeenAt, ...state });
698
+ };
699
+ for (const entry of entries) {
700
+ const path = join(dir, entry);
701
+ let st;
702
+ try {
703
+ st = statSync(path);
704
+ }
705
+ catch {
706
+ continue;
707
+ }
708
+ if (st.isFile() && entry.endsWith(".jsonl"))
709
+ add(path, "parent", null);
710
+ if (!st.isDirectory())
711
+ continue;
712
+ const subagents = join(path, "subagents");
713
+ let files;
714
+ try {
715
+ files = readdirSync(subagents);
716
+ }
717
+ catch {
718
+ continue;
719
+ }
720
+ for (const file of files)
721
+ if (file.endsWith(".jsonl"))
722
+ add(join(subagents, file), "subagent", entry);
723
+ }
724
+ return sessions.sort((a, b) => (b.lastSeenAt ?? "").localeCompare(a.lastSeenAt ?? "") || a.id.localeCompare(b.id));
725
+ }
726
+ function listProjectSessions(rootDir) {
727
+ const projectsDir = join(rootDir, ".claude", "projects");
728
+ if (!existsSync(projectsDir))
729
+ return [];
730
+ let dirs;
731
+ try {
732
+ dirs = readdirSync(projectsDir);
733
+ }
734
+ catch {
735
+ return [];
736
+ }
737
+ return dirs.map((d) => {
738
+ const subdir = join(projectsDir, d);
739
+ let count = 0;
740
+ try {
741
+ count = readdirSync(subdir).filter((f) => f.endsWith(".jsonl")).length;
742
+ }
743
+ catch {
744
+ count = 0;
745
+ }
746
+ return { id: d, root: subdir, sessionCount: count };
747
+ });
748
+ }
749
+ export async function readSnapshot(rootDir) {
750
+ const [agents, skills, commands, hooks, modelRouter, teams, workflows, sessions] = await Promise.all([
751
+ readAgents(rootDir),
752
+ readSkills(rootDir),
753
+ readCommands(rootDir),
754
+ readHooks(rootDir),
755
+ readModelRouter(rootDir),
756
+ readTeams(rootDir),
757
+ readWorkflows(rootDir),
758
+ readNativeSessions(rootDir),
759
+ ]);
760
+ return {
761
+ agents,
762
+ skills,
763
+ commands,
764
+ hooks,
765
+ modelRouter,
766
+ teams,
767
+ workflows,
768
+ projects: listProjectSessions(rootDir),
769
+ sessions,
770
+ serverTs: new Date().toISOString(),
771
+ };
772
+ }
773
+ // ─── HTTP request handler ───────────────────────────────────────────────────
774
+ /** Allowed values for `ActivityKind`. Mirrors the union at the top of this file. */
775
+ const ACTIVITY_KINDS = new Set([
776
+ "chat.turn-started",
777
+ "chat.turn-ended",
778
+ "chat.turn-aborted",
779
+ "chat.message-added",
780
+ "task.created",
781
+ "task.moved",
782
+ "task.commented",
783
+ "task.linked",
784
+ "task.deleted",
785
+ "agent.started",
786
+ "agent.ended",
787
+ "agent.queued",
788
+ ]);
789
+ function json(value, status = 200) {
790
+ return new Response(JSON.stringify(value), {
791
+ status,
792
+ headers: { "Content-Type": "application/json" },
793
+ });
794
+ }
795
+ function err(message, status = 400) {
796
+ return json({ error: message }, status);
797
+ }
798
+ function asStringBounded(value, max = 1024) {
799
+ if (typeof value !== "string")
800
+ return null;
801
+ if (value.length === 0 || value.length > max)
802
+ return null;
803
+ return value;
804
+ }
805
+ const SSE_HEADERS = {
806
+ "Content-Type": "text/event-stream",
807
+ "Cache-Control": "no-cache, no-transform",
808
+ "Connection": "keep-alive",
809
+ // Disable proxy buffering so events flush promptly through localhost proxies.
810
+ "X-Accel-Buffering": "no",
811
+ };
812
+ /**
813
+ * Route dispatcher for `/api/claude/*`. Mirrors the shape of `handleBizarRequest`
814
+ * in `kanban/bizar.ts`. The `rootDir` argument is the user's home directory —
815
+ * readers always look under `<rootDir>/.claude/...`.
816
+ */
817
+ export async function handleClaudeRequest(rootDir, req, path) {
818
+ const url = new URL(req.url);
819
+ const relayEnabled = process.env.CLAUDE_OPENKAN_RELAY === "1";
820
+ try {
821
+ if (req.method === "GET") {
822
+ if (path === "/api/claude/snapshot") {
823
+ const payload = await readSnapshot(rootDir);
824
+ return json(payload);
825
+ }
826
+ if (path === "/api/claude/agents")
827
+ return json({ agents: await readAgents(rootDir) });
828
+ if (path === "/api/claude/skills")
829
+ return json({ skills: await readSkills(rootDir) });
830
+ if (path === "/api/claude/commands")
831
+ return json({ commands: await readCommands(rootDir) });
832
+ if (path === "/api/claude/hooks")
833
+ return json({ hooks: await readHooks(rootDir) });
834
+ if (path === "/api/claude/teams")
835
+ return json({ teams: await readTeams(rootDir) });
836
+ if (path === "/api/claude/workflows")
837
+ return json({ workflows: await readWorkflows(rootDir) });
838
+ if (path === "/api/claude/model-router")
839
+ return json(await readModelRouter(rootDir));
840
+ if (path === "/api/claude/activity") {
841
+ const since = url.searchParams.get("since");
842
+ const sinceMs = since ? Date.parse(since) : undefined;
843
+ return json({ events: await readActivityTail(rootDir, Number.isFinite(sinceMs) ? sinceMs : undefined) });
844
+ }
845
+ if (path === "/api/claude/ring")
846
+ return json({ events: readEvents() });
847
+ if (path === "/api/claude/relay-status") {
848
+ return json({ enabled: relayEnabled });
849
+ }
850
+ if (path === "/api/claude/events")
851
+ return handleClaudeSse(rootDir);
852
+ return err("Not found", 404);
853
+ }
854
+ if (req.method === "POST" && path === "/api/claude/events") {
855
+ const body = await req.json().catch(() => null);
856
+ if (!body || typeof body !== "object")
857
+ return err("Invalid JSON body", 400);
858
+ const event = asStringBounded(body.event, 128);
859
+ const sessionId = asStringBounded(body.sessionId, 128);
860
+ const ts = asStringBounded(body.ts, 64);
861
+ if (!event)
862
+ return err("event is required", 422);
863
+ if (!sessionId)
864
+ return err("sessionId is required", 422);
865
+ if (!ts)
866
+ return err("ts is required", 422);
867
+ const kind = ACTIVITY_KINDS.has(event)
868
+ ? event
869
+ : "agent.queued";
870
+ const payload = body.payload && typeof body.payload === "object"
871
+ ? body.payload
872
+ : {};
873
+ const summary = typeof payload.summary === "string"
874
+ ? payload.summary
875
+ : event;
876
+ const agentId = typeof payload.agent === "string" ? payload.agent : "@user";
877
+ const record = {
878
+ id: `${ts}-${agentId}-${event}`,
879
+ projectId: sessionId,
880
+ agentId,
881
+ kind,
882
+ status: "info",
883
+ summary,
884
+ meta: { ...payload, relaySessionId: sessionId, ...(kind === "agent.queued" && event !== "agent.queued" ? { originalKind: event } : {}) },
885
+ ts,
886
+ };
887
+ recordEvent(record);
888
+ return json({ ok: true });
889
+ }
890
+ return err("Not found", 404);
891
+ }
892
+ catch (e) {
893
+ const message = e?.message || String(e);
894
+ return err(message, 500);
895
+ }
896
+ }
897
+ const TAIL_POLL_MS = 500;
898
+ const HEARTBEAT_MS = 15_000;
899
+ function handleClaudeSse(rootDir) {
900
+ const encoder = new TextEncoder();
901
+ // Track the high-water mark (ms) we've already emitted so we only push
902
+ // truly-new rows on each poll tick.
903
+ let lastSeenMs = Date.now();
904
+ let closed = false;
905
+ let pollTimer = null;
906
+ let heartbeatTimer = null;
907
+ let unsubscribe = null;
908
+ const stream = new ReadableStream({
909
+ async start(controller) {
910
+ const safeEnqueue = (chunk) => {
911
+ if (closed)
912
+ return;
913
+ try {
914
+ controller.enqueue(encoder.encode(chunk));
915
+ }
916
+ catch {
917
+ closed = true;
918
+ }
919
+ };
920
+ safeEnqueue(": connected\n\n");
921
+ const flush = async () => {
922
+ if (closed)
923
+ return;
924
+ try {
925
+ const sinceMs = lastSeenMs;
926
+ const fresh = await readActivityTail(rootDir, sinceMs);
927
+ if (fresh.length > 0) {
928
+ lastSeenMs = Math.max(sinceMs, ...fresh
929
+ .map((e) => Date.parse(e.ts))
930
+ .filter((t) => Number.isFinite(t)));
931
+ safeEnqueue(`event: activity\ndata: ${JSON.stringify({ events: fresh })}\n\n`);
932
+ }
933
+ const ring = readEvents();
934
+ if (ring.length > 0) {
935
+ safeEnqueue(`event: ring\ndata: ${JSON.stringify({ events: ring })}\n\n`);
936
+ }
937
+ }
938
+ catch { /* swallow poll errors */ }
939
+ };
940
+ // Push any events that arrived via the relay hook since server start.
941
+ unsubscribe = subscribe((events) => {
942
+ if (closed)
943
+ return;
944
+ try {
945
+ safeEnqueue(`event: ring\ndata: ${JSON.stringify({ events })}\n\n`);
946
+ }
947
+ catch { /* ignore */ }
948
+ });
949
+ // Initial flush, then poll on TAIL_POLL_MS cadence.
950
+ await flush();
951
+ pollTimer = setInterval(() => { void flush(); }, TAIL_POLL_MS);
952
+ pollTimer.unref?.();
953
+ heartbeatTimer = setInterval(() => safeEnqueue(": heartbeat\n\n"), HEARTBEAT_MS);
954
+ heartbeatTimer.unref?.();
955
+ },
956
+ cancel() {
957
+ closed = true;
958
+ if (pollTimer)
959
+ clearInterval(pollTimer);
960
+ if (heartbeatTimer)
961
+ clearInterval(heartbeatTimer);
962
+ if (unsubscribe) {
963
+ try {
964
+ unsubscribe();
965
+ }
966
+ catch { /* ignore */ }
967
+ }
968
+ pollTimer = null;
969
+ heartbeatTimer = null;
970
+ unsubscribe = null;
971
+ },
972
+ });
973
+ return new Response(stream, { headers: SSE_HEADERS });
974
+ }