@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.
- package/CHANGELOG.md +226 -0
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/agents/openkan.md +254 -0
- package/bin/install-agent.mjs +63 -0
- package/bin/ok.mjs +17 -0
- package/bin/openkan.mjs +10 -0
- package/dist/.claude/skills/ok-planning/SKILL.md +285 -0
- package/dist/.claude/skills/ok-planning/references/integration.md +153 -0
- package/dist/.claude/skills/ok-planning/references/schemas.md +270 -0
- package/dist/.claude/skills/ok-planning/references/workflows.md +185 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-init.sh +14 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-resume.sh +38 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-status.sh +24 -0
- package/dist/agents/openkan.md +254 -0
- package/dist/bin/install-agent.mjs +76 -0
- package/dist/bin/ok-install.js +58 -0
- package/dist/bin/ok.js +138 -0
- package/dist/bin/openkan.js +804 -0
- package/dist/commands/organize.md +15 -0
- package/dist/kanban/agent-profile.js +8 -0
- package/dist/kanban/archive.js +49 -0
- package/dist/kanban/bizar.js +242 -0
- package/dist/kanban/board.js +367 -0
- package/dist/kanban/bulk.js +139 -0
- package/dist/kanban/changelog.js +186 -0
- package/dist/kanban/chat.js +1280 -0
- package/dist/kanban/claude-state.js +974 -0
- package/dist/kanban/comments.js +80 -0
- package/dist/kanban/docs.js +144 -0
- package/dist/kanban/fs.js +163 -0
- package/dist/kanban/git.js +196 -0
- package/dist/kanban/images.js +140 -0
- package/dist/kanban/import.js +295 -0
- package/dist/kanban/inputs.js +94 -0
- package/dist/kanban/insights.js +140 -0
- package/dist/kanban/io.js +75 -0
- package/dist/kanban/mdx-render.js +348 -0
- package/dist/kanban/mdx.js +231 -0
- package/dist/kanban/projects.js +545 -0
- package/dist/kanban/search.js +121 -0
- package/dist/kanban/server.js +3296 -0
- package/dist/kanban/tags.js +124 -0
- package/dist/kanban/template.js +145 -0
- package/dist/kanban/tsx-sandbox.js +187 -0
- package/dist/kanban/watcher.js +270 -0
- package/dist/ok/commands/goal.js +65 -0
- package/dist/ok/commands/index.js +87 -0
- package/dist/ok/commands/init.js +15 -0
- package/dist/ok/commands/plan.js +155 -0
- package/dist/ok/commands/prd.js +202 -0
- package/dist/ok/commands/progress.js +31 -0
- package/dist/ok/commands/task.js +377 -0
- package/dist/ok/ids.js +98 -0
- package/dist/ok/lock.js +156 -0
- package/dist/ok/migrate.js +197 -0
- package/dist/ok/schemas.js +402 -0
- package/dist/ok/storage.js +222 -0
- package/dist/skills/openkan/SKILL.md +111 -0
- package/dist/skills/openkan/agents/openai.yaml +4 -0
- package/dist/skills/openkan/examples/simple-task.mdx +34 -0
- package/dist/skills/openkan/examples/with-ask.mdx +32 -0
- package/dist/skills/openkan/examples/with-choice.mdx +51 -0
- package/dist/skills/openkan/examples/with-preview.mdx +54 -0
- package/dist/skills/openkan/references/api.md +169 -0
- package/dist/skills/openkan/templates/task.mdx +46 -0
- package/dist/web/api.js +257 -0
- package/dist/web/app.js +4251 -0
- package/dist/web/bizar.js +39 -0
- package/dist/web/brand/agent-activity-sprite.svg +1 -0
- package/dist/web/brand/banner-docs.svg +24 -0
- package/dist/web/brand/banner.svg +32 -0
- package/dist/web/brand/empty-sessions.svg +17 -0
- package/dist/web/brand/empty-tasks.svg +17 -0
- package/dist/web/brand/favicon.svg +9 -0
- package/dist/web/brand/infinity-loader-animated.svg +220 -0
- package/dist/web/brand/infinity-loader-spritesheet.svg +230 -0
- package/dist/web/brand/logo-wordmark.svg +10 -0
- package/dist/web/brand/logo.svg +9 -0
- package/dist/web/brand/pixel-infinity-track.svg +1 -0
- package/dist/web/brand/social-card.svg +26 -0
- package/dist/web/changelog-view.js +456 -0
- package/dist/web/charts.js +269 -0
- package/dist/web/chat-sidebar.js +2397 -0
- package/dist/web/chat-status-motion.js +154 -0
- package/dist/web/claude-pane.js +820 -0
- package/dist/web/command-palette.js +381 -0
- package/dist/web/contributors-view.js +317 -0
- package/dist/web/cross-tab.js +102 -0
- package/dist/web/docs-view.js +168 -0
- package/dist/web/experience.css +165 -0
- package/dist/web/goals-view.js +45 -0
- package/dist/web/home-view.js +113 -0
- package/dist/web/images.js +311 -0
- package/dist/web/index.html +485 -0
- package/dist/web/insights.js +217 -0
- package/dist/web/keyboard.js +446 -0
- package/dist/web/mdx-viewer.js +600 -0
- package/dist/web/path-picker.js +787 -0
- package/dist/web/preview-frame.html +187 -0
- package/dist/web/settings.js +582 -0
- package/dist/web/style.css +8545 -0
- package/dist/web/task-view.js +1759 -0
- package/dist/web/vendor/gsap.min.js +11 -0
- package/dist/web/workspace.css +1513 -0
- package/package.json +71 -0
- package/skills/openkan/SKILL.md +111 -0
- package/skills/openkan/agents/openai.yaml +4 -0
- package/skills/openkan/examples/simple-task.mdx +34 -0
- package/skills/openkan/examples/with-ask.mdx +32 -0
- package/skills/openkan/examples/with-choice.mdx +51 -0
- package/skills/openkan/examples/with-preview.mdx +54 -0
- package/skills/openkan/references/api.md +169 -0
- 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
|
+
}
|