@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,820 @@
1
+ // OpenKan — native Claude control plane (Phase 4).
2
+ // Three sub-views: Subagents, Teams, Workflows. Sticky live activity footer
3
+ // streams events from /api/claude/events (SSE primary) with a 5s polling
4
+ // fallback via /api/claude/activity?since=<iso>. Reconnect backoff:
5
+ // 1s → 2s → 5s (capped).
6
+ //
7
+ // Public API: window.OpenKanClaude = { mount(root), unmount() }.
8
+ (() => {
9
+ "use strict";
10
+
11
+ /* ----------------------------------------------------------------------
12
+ * Constants
13
+ * -------------------------------------------------------------------- */
14
+ const MAX_ACTIVITY = 200; // virtual-list cap for activity footer
15
+ const MAX_GRAPH_NODES = 72;
16
+ const MAX_DEFAULT_SESSION_ROOTS = 18;
17
+ const MAX_SESSION_RELATION_NODES = 36;
18
+ const POLL_INTERVAL_MS = 5000; // polling cadence on SSE failure
19
+ const RECONNECT_STEPS_MS = [1000, 2000, 5000];
20
+ const TSK_REGEX = /tsk-[A-Za-z0-9_-]+/g;
21
+
22
+ /* ----------------------------------------------------------------------
23
+ * Small helpers
24
+ * -------------------------------------------------------------------- */
25
+ const esc = (v) => String(v ?? "")
26
+ .replaceAll("&", "&amp;")
27
+ .replaceAll("<", "&lt;")
28
+ .replaceAll(">", "&gt;")
29
+ .replaceAll('"', "&quot;")
30
+ .replaceAll("'", "&#39;");
31
+
32
+ const escAttr = (v) => esc(v);
33
+
34
+ const clampActivity = (items) => (
35
+ items.length <= MAX_ACTIVITY ? items : items.slice(items.length - MAX_ACTIVITY)
36
+ );
37
+
38
+ // Relative time formatter. Falls back to "—" for missing/invalid input.
39
+ function relativeTime(iso) {
40
+ if (!iso) return "—";
41
+ const then = new Date(iso).getTime();
42
+ if (Number.isNaN(then)) return "—";
43
+ const diffMs = Date.now() - then;
44
+ if (diffMs < 1000) return "just now";
45
+ const sec = Math.floor(diffMs / 1000);
46
+ if (sec < 60) return `${sec}s ago`;
47
+ const min = Math.floor(sec / 60);
48
+ if (min < 60) return `${min}m ago`;
49
+ const hr = Math.floor(min / 60);
50
+ if (hr < 24) return `${hr}h ago`;
51
+ const day = Math.floor(hr / 24);
52
+ if (day < 7) return `${day}d ago`;
53
+ const wk = Math.floor(day / 7);
54
+ if (wk < 5) return `${wk}w ago`;
55
+ return new Date(iso).toLocaleDateString();
56
+ }
57
+
58
+ // Status mapping. Reuses --status-red for errored; coral only on active.
59
+ function statusKind(record) {
60
+ if (!record) return "idle";
61
+ if (record.errored || record.state === "errored" || record.state === "failed") return "errored";
62
+ if (record.active || record.state === "active" || record.state === "running") return "active";
63
+ if (record.state === "recent") return "recent";
64
+ const lastSeen = record.lastSeenAt || record.lastSeen || record.ts || record.updatedAt;
65
+ if (lastSeen) {
66
+ const since = Date.now() - new Date(lastSeen).getTime();
67
+ if (!Number.isNaN(since) && since >= 0 && since < 60_000) return "recent";
68
+ }
69
+ return "idle";
70
+ }
71
+
72
+ // Transcript link: tolerate both {transcriptUrl} and {transcriptPath} shapes.
73
+ function transcriptHref(team) {
74
+ if (!team) return null;
75
+ if (typeof team.transcriptUrl === "string" && team.transcriptUrl) {
76
+ return { href: team.transcriptUrl };
77
+ }
78
+ if (typeof team.transcriptPath === "string" && team.transcriptPath) {
79
+ const path = team.transcriptPath;
80
+ const normalized = path.startsWith("/") ? path : `/${path}`;
81
+ return { href: `file://${normalized}` };
82
+ }
83
+ if (typeof team.transcript === "string" && /^((https?|file):\/\/|\/)/.test(team.transcript)) {
84
+ const t = team.transcript;
85
+ return { href: /^([a-z]+:|\/)/.test(t) ? t : `/${t}` };
86
+ }
87
+ return null;
88
+ }
89
+
90
+ /* ----------------------------------------------------------------------
91
+ * State
92
+ * -------------------------------------------------------------------- */
93
+ const state = {
94
+ root: null,
95
+ mounted: false,
96
+ snapshot: null,
97
+ view: "subagents",
98
+ agents: [],
99
+ sessions: [],
100
+ boardTasks: [],
101
+ teams: [],
102
+ workflows: [],
103
+ skills: [],
104
+ commands: [],
105
+ hooks: [],
106
+ modelRouter: null,
107
+ projects: [],
108
+ serverTs: null,
109
+ activity: [],
110
+ lastEventTs: null,
111
+ filter: { agentId: null, kind: null },
112
+ live: { source: "offline", reconnectAttempt: 0 },
113
+ poll: { timer: null, inFlight: false },
114
+ evt: null,
115
+ reconcileTimer: null,
116
+ };
117
+
118
+ /* ----------------------------------------------------------------------
119
+ * Network
120
+ * -------------------------------------------------------------------- */
121
+ function api() {
122
+ return window.OpenKanAPI?.api || null;
123
+ }
124
+
125
+ async function fetchSnapshot() {
126
+ const a = api();
127
+ if (!a) return null;
128
+ const data = await a("GET", "/api/claude/snapshot");
129
+ return data && typeof data === "object" ? data : null;
130
+ }
131
+
132
+ async function fetchBoard() {
133
+ const a = api();
134
+ if (!a) return [];
135
+ const data = await a("GET", "/api/board");
136
+ return Array.isArray(data?.tasks) ? data.tasks : [];
137
+ }
138
+
139
+ async function fetchActivity(since) {
140
+ const a = api();
141
+ if (!a) return [];
142
+ const path = since
143
+ ? `/api/claude/activity?since=${encodeURIComponent(since)}`
144
+ : "/api/claude/activity";
145
+ let data;
146
+ try {
147
+ data = await a("GET", path);
148
+ } catch (_err) {
149
+ return [];
150
+ }
151
+ if (!data) return [];
152
+ if (Array.isArray(data)) return data;
153
+ if (Array.isArray(data.events)) return data.events;
154
+ return [];
155
+ }
156
+
157
+ /* ----------------------------------------------------------------------
158
+ * Live stream
159
+ * -------------------------------------------------------------------- */
160
+ function labelForStatus(kind) {
161
+ if (kind === "live") return "Live";
162
+ if (kind === "polling") return "Polling (5s)";
163
+ if (kind === "connecting") return "Connecting…";
164
+ return "Offline";
165
+ }
166
+
167
+ function setConnectionStatus(kind) {
168
+ state.live.source = kind;
169
+ const node = state.root?.querySelector("#claude-status");
170
+ if (!node) return;
171
+ node.dataset.status = kind;
172
+ node.innerHTML =
173
+ `<span class="claude-pane-status-dot claude-pane-status-${esc(kind)}" aria-hidden="true"></span>` +
174
+ `<span class="claude-pane-status-text">${esc(labelForStatus(kind))}</span>`;
175
+ }
176
+
177
+ function pushEvent(event) {
178
+ if (!event || !event.ts) return;
179
+ if (state.lastEventTs) {
180
+ const incoming = new Date(event.ts).getTime();
181
+ const last = new Date(state.lastEventTs).getTime();
182
+ if (!Number.isNaN(incoming) && !Number.isNaN(last) && incoming <= last) return;
183
+ }
184
+ state.lastEventTs = event.ts;
185
+ state.activity.push(event);
186
+ state.activity = clampActivity(state.activity);
187
+ renderActivity({ fresh: true });
188
+ scheduleGraphReconcile();
189
+ }
190
+
191
+ function handleSsePayload(payload) {
192
+ let data;
193
+ try {
194
+ data = typeof payload.data === "string" ? JSON.parse(payload.data) : payload.data;
195
+ } catch (_err) {
196
+ return;
197
+ }
198
+ if (!data) return;
199
+ if (Array.isArray(data)) {
200
+ for (const e of data) pushEvent(e);
201
+ } else {
202
+ pushEvent(data);
203
+ }
204
+ }
205
+
206
+ function stopEventSource() {
207
+ if (state.evt) {
208
+ try { state.evt.close(); } catch (_err) { /* ignore */ }
209
+ state.evt = null;
210
+ }
211
+ }
212
+
213
+ function stopPolling() {
214
+ if (state.poll.timer) {
215
+ clearInterval(state.poll.timer);
216
+ state.poll.timer = null;
217
+ }
218
+ }
219
+
220
+ async function pollTick() {
221
+ if (state.poll.inFlight) return;
222
+ state.poll.inFlight = true;
223
+ try {
224
+ const events = await fetchActivity(state.lastEventTs || undefined);
225
+ if (Array.isArray(events)) {
226
+ for (const e of events) pushEvent(e);
227
+ }
228
+ } catch (_err) {
229
+ /* swallow; next tick will retry */
230
+ } finally {
231
+ state.poll.inFlight = false;
232
+ }
233
+ }
234
+
235
+ function startPolling() {
236
+ if (state.poll.timer) return;
237
+ setConnectionStatus("polling");
238
+ pollTick();
239
+ state.poll.timer = setInterval(pollTick, POLL_INTERVAL_MS);
240
+ }
241
+
242
+ function scheduleReconnect() {
243
+ const attempt = state.live.reconnectAttempt;
244
+ const delay = RECONNECT_STEPS_MS[Math.min(attempt, RECONNECT_STEPS_MS.length - 1)];
245
+ state.live.reconnectAttempt = attempt + 1;
246
+ setConnectionStatus("connecting");
247
+ setTimeout(() => {
248
+ if (state.mounted) connectLive();
249
+ }, delay);
250
+ }
251
+
252
+ function connectLive() {
253
+ if (!state.mounted) return;
254
+ stopEventSource();
255
+ stopPolling();
256
+ setConnectionStatus("connecting");
257
+ let sse;
258
+ try {
259
+ sse = new EventSource("/api/claude/events");
260
+ } catch (_err) {
261
+ startPolling();
262
+ return;
263
+ }
264
+ state.evt = sse;
265
+ let opened = false;
266
+ sse.addEventListener("open", () => {
267
+ opened = true;
268
+ state.live.reconnectAttempt = 0;
269
+ setConnectionStatus("live");
270
+ // Drain any events that landed while we were disconnected.
271
+ if (state.lastEventTs) {
272
+ fetchActivity(state.lastEventTs).then((events) => {
273
+ if (Array.isArray(events)) for (const e of events) pushEvent(e);
274
+ }).catch(() => { /* ignore */ });
275
+ }
276
+ });
277
+ sse.addEventListener("message", handleSsePayload);
278
+ sse.addEventListener("error", () => {
279
+ if (state.evt !== sse) return;
280
+ stopEventSource();
281
+ if (opened) {
282
+ // Was streaming; backoff then retry.
283
+ scheduleReconnect();
284
+ } else {
285
+ // Never opened; fall back to polling immediately.
286
+ startPolling();
287
+ }
288
+ });
289
+ }
290
+
291
+ /* ----------------------------------------------------------------------
292
+ * Snapshot ingestion
293
+ * -------------------------------------------------------------------- */
294
+ function ingestSnapshot(snap) {
295
+ state.snapshot = snap || {};
296
+ state.agents = Array.isArray(snap?.agents) ? snap.agents : [];
297
+ // sessions is newly normalized by the backend; old snapshots simply omit it.
298
+ state.sessions = Array.isArray(snap?.sessions) ? snap.sessions : [];
299
+ state.teams = Array.isArray(snap?.teams) ? snap.teams : [];
300
+ state.workflows = Array.isArray(snap?.workflows) ? snap.workflows : [];
301
+ state.skills = Array.isArray(snap?.skills) ? snap.skills : [];
302
+ state.commands = Array.isArray(snap?.commands) ? snap.commands : [];
303
+ state.hooks = Array.isArray(snap?.hooks) ? snap.hooks : [];
304
+ state.modelRouter = snap?.modelRouter || null;
305
+ state.projects = Array.isArray(snap?.projects) ? snap.projects : [];
306
+ state.serverTs = snap?.serverTs || null;
307
+ }
308
+
309
+ function scheduleGraphReconcile() {
310
+ if (state.reconcileTimer || !state.mounted) return;
311
+ state.reconcileTimer = setTimeout(async () => {
312
+ state.reconcileTimer = null;
313
+ try {
314
+ const [snap, tasks] = await Promise.all([fetchSnapshot(), fetchBoard()]);
315
+ if (snap) ingestSnapshot(snap);
316
+ state.boardTasks = tasks;
317
+ if (state.view === "subagents") render();
318
+ } catch (_err) { /* activity remains useful when a refresh is unavailable */ }
319
+ }, 250);
320
+ }
321
+
322
+ /* ----------------------------------------------------------------------
323
+ * Renderers
324
+ * -------------------------------------------------------------------- */
325
+ function renderHeader() {
326
+ const agents = state.agents.length;
327
+ const teams = state.teams.length;
328
+ const workflows = state.workflows.length;
329
+ return `
330
+ <header class="claude-pane-header">
331
+ <div class="claude-pane-header-meta">
332
+ <span class="claude-pane-eyebrow">Agent control plane</span>
333
+ <h2 class="claude-pane-title">Agents</h2>
334
+ <p class="claude-pane-subtitle">
335
+ Claude Code · ${agents} agents · ${teams} teams · ${workflows} workflows
336
+ </p>
337
+ </div>
338
+ <div class="claude-pane-header-actions">
339
+ <span id="claude-status" class="claude-pane-status" data-status="offline" role="status" aria-live="polite">
340
+ <span class="claude-pane-status-dot claude-pane-status-offline" aria-hidden="true"></span>
341
+ <span class="claude-pane-status-text">Offline</span>
342
+ </span>
343
+ <button type="button" class="claude-pane-btn" data-claude-action="refresh">Refresh</button>
344
+ </div>
345
+ </header>
346
+ `;
347
+ }
348
+
349
+ function renderSubnav() {
350
+ const counts = {
351
+ subagents: state.sessions.length,
352
+ teams: state.teams.length,
353
+ workflows: state.workflows.length,
354
+ };
355
+ const tab = (key, label) => `
356
+ <button type="button"
357
+ class="claude-pane-subtab${state.view === key ? " active" : ""}"
358
+ data-claude-subtab="${escAttr(key)}"
359
+ role="tab"
360
+ aria-selected="${state.view === key ? "true" : "false"}">
361
+ <span class="claude-pane-subtab-label">${esc(label)}</span>
362
+ <span class="claude-pane-subtab-count">${counts[key]}</span>
363
+ </button>
364
+ `;
365
+ return `
366
+ <nav class="claude-pane-subnav" role="tablist" aria-label="Agent sub-views">
367
+ ${tab("subagents", "Relationship map")}
368
+ ${tab("teams", "Teams")}
369
+ ${tab("workflows", "Workflows")}
370
+ </nav>
371
+ ${renderFilterChips()}
372
+ `;
373
+ }
374
+
375
+ function renderFilterChips() {
376
+ const active = new Set(state.agents.map((a) => a.id || a.name).filter(Boolean));
377
+ const chips = [];
378
+ if (state.filter.agentId) {
379
+ chips.push(
380
+ `<button type="button" class="claude-pane-chip active" data-claude-chip="agentId" data-claude-chip-clear="1">
381
+ <span class="claude-pane-chip-x" aria-hidden="true">×</span>
382
+ <span class="claude-pane-chip-text">Agent: ${esc(state.filter.agentId)}</span>
383
+ </button>`
384
+ );
385
+ }
386
+ if (state.filter.kind) {
387
+ chips.push(
388
+ `<button type="button" class="claude-pane-chip active" data-claude-chip="kind" data-claude-chip-clear="1">
389
+ <span class="claude-pane-chip-x" aria-hidden="true">×</span>
390
+ <span class="claude-pane-chip-text">Kind: ${esc(state.filter.kind)}</span>
391
+ </button>`
392
+ );
393
+ }
394
+ const allKinds = new Set();
395
+ for (const a of state.agents) if (a.kind) allKinds.add(a.kind);
396
+ const kindRow = state.filter.kind ? "" :
397
+ Array.from(allKinds).slice(0, 8).map((k) =>
398
+ `<button type="button" class="claude-pane-chip" data-claude-chip="kind" data-claude-chip-value="${escAttr(k)}">${esc(k)}</button>`
399
+ ).join("");
400
+ const agentRow = state.filter.agentId ? "" :
401
+ Array.from(active).slice(0, 12).map((id) =>
402
+ `<button type="button" class="claude-pane-chip" data-claude-chip="agentId" data-claude-chip-value="${escAttr(id)}">${esc(id)}</button>`
403
+ ).join("");
404
+ const filterRow =
405
+ chips.length === 0 && !kindRow && !agentRow
406
+ ? `<span class="claude-pane-chip-empty-hint">No filters applied.</span>`
407
+ : `
408
+ ${chips.join("")}
409
+ ${kindRow ? `<div class="claude-pane-chip-row" role="group" aria-label="Filter by kind">${kindRow}</div>` : ""}
410
+ ${agentRow ? `<div class="claude-pane-chip-row" role="group" aria-label="Filter by agent">${agentRow}</div>` : ""}
411
+ `;
412
+ return `<div class="claude-pane-filter-bar">${filterRow}</div>`;
413
+ }
414
+
415
+ function renderEmpty(message) {
416
+ return `<div class="claude-pane-empty"><p>${esc(message)}</p></div>`;
417
+ }
418
+
419
+ function graphId(prefix, value) {
420
+ return `${prefix}:${String(value || prefix).replace(/[^A-Za-z0-9_.-]/g, "_")}`;
421
+ }
422
+
423
+ function graphTaskId(record) {
424
+ return record?.id || record?.taskId || record?.key || null;
425
+ }
426
+
427
+ function graphRecency(node) {
428
+ const value = node.raw?.lastSeenAt || node.raw?.lastSeen || node.raw?.updatedAt || node.raw?.ts || node.raw?.startedAt;
429
+ const time = new Date(value || 0).getTime();
430
+ return Number.isNaN(time) ? 0 : time;
431
+ }
432
+
433
+ function compareGraphPriority(a, b) {
434
+ const rank = { active: 0, recent: 1, idle: 2, errored: 3 };
435
+ return (rank[a.status] ?? 4) - (rank[b.status] ?? 4) || graphRecency(b) - graphRecency(a);
436
+ }
437
+
438
+ function selectDefaultGraph(nodes, edges, sessionNodes, catalogAgents) {
439
+ const visibleIds = new Set();
440
+ const related = new Map();
441
+ for (const edge of edges) {
442
+ if (!related.has(edge.from)) related.set(edge.from, []);
443
+ if (!related.has(edge.to)) related.set(edge.to, []);
444
+ related.get(edge.from).push(edge.to);
445
+ related.get(edge.to).push(edge.from);
446
+ }
447
+ const add = (node, limit = MAX_GRAPH_NODES) => {
448
+ if (!node || visibleIds.has(node.id) || visibleIds.size >= limit) return false;
449
+ visibleIds.add(node.id);
450
+ return true;
451
+ };
452
+ const addRelated = (node, limit) => {
453
+ for (const id of related.get(node.id) || []) {
454
+ add(nodesById.get(id), limit);
455
+ }
456
+ };
457
+ const nodesById = new Map(nodes.map((node) => [node.id, node]));
458
+ const prioritizedSessions = [...sessionNodes].sort(compareGraphPriority).slice(0, MAX_DEFAULT_SESSION_ROOTS);
459
+
460
+ // Show current work first, then its immediate agent, task, and parent links.
461
+ for (const node of prioritizedSessions) add(node, MAX_SESSION_RELATION_NODES);
462
+ for (const node of prioritizedSessions) addRelated(node, MAX_SESSION_RELATION_NODES);
463
+
464
+ // Reserve the remaining space for the catalog, expanding each selected
465
+ // agent once so its task/session relationships remain visible.
466
+ for (const node of [...catalogAgents].sort(compareGraphPriority)) {
467
+ if (visibleIds.size >= MAX_GRAPH_NODES) break;
468
+ if (add(node)) addRelated(node, MAX_GRAPH_NODES);
469
+ }
470
+
471
+ const visibleNodes = nodes.filter((node) => visibleIds.has(node.id));
472
+ return {
473
+ nodes: visibleNodes,
474
+ edges: edges.filter((edge) => visibleIds.has(edge.from) && visibleIds.has(edge.to)),
475
+ };
476
+ }
477
+
478
+ function graphModel() {
479
+ const nodes = [];
480
+ const edges = [];
481
+ const known = new Map();
482
+ const add = (type, raw, label, detail) => {
483
+ const id = graphId(type, raw?.id || raw?.sessionId || raw?.name || label);
484
+ if (!known.has(id)) {
485
+ const node = { id, type, raw, label: String(label || type), detail: String(detail || ""), status: statusKind(raw) };
486
+ known.set(id, node); nodes.push(node);
487
+ }
488
+ return known.get(id);
489
+ };
490
+ const link = (from, to, kind) => {
491
+ if (from && to && from.id !== to.id && !edges.some((edge) => edge.from === from.id && edge.to === to.id && edge.kind === kind)) {
492
+ edges.push({ from: from.id, to: to.id, kind });
493
+ }
494
+ };
495
+ const agents = state.agents.filter((agent) => !state.filter.kind || agent.kind === state.filter.kind);
496
+ const agentsByKey = new Map();
497
+ for (const agent of agents) {
498
+ const node = add("agent", agent, agent.name || agent.id, agent.kind || "agent");
499
+ agentsByKey.set(String(agent.id || agent.name), node);
500
+ }
501
+ const sessionsByKey = new Map();
502
+ const sessionNodes = [];
503
+ for (const session of state.sessions) {
504
+ const node = add(session.kind === "subagent" ? "subagent" : "session", session, session.title || session.name || session.id, session.state || session.kind || "session");
505
+ sessionsByKey.set(String(session.id || session.sessionId), node);
506
+ sessionNodes.push(node);
507
+ }
508
+ for (const session of state.sessions) {
509
+ const node = sessionsByKey.get(String(session.id || session.sessionId));
510
+ const parent = sessionsByKey.get(String(session.parentSessionId || session.parentId || ""));
511
+ if (parent) link(parent, node, "delegates");
512
+ const agent = agentsByKey.get(String(session.agentId || session.agent || session.owner || ""));
513
+ if (agent) link(agent, node, "runs");
514
+ }
515
+ for (const team of state.teams) {
516
+ const node = add("team", team, team.name || team.id, "team");
517
+ for (const member of (Array.isArray(team.members) ? team.members : [])) {
518
+ link(node, agentsByKey.get(String(member?.id || member?.agentId || member)), "contains");
519
+ }
520
+ }
521
+ for (const workflow of state.workflows) {
522
+ // WorkflowDef.phases is a string[] (headings), not assignment metadata.
523
+ // Keep the definition visible without inventing workflow-to-agent links.
524
+ add("workflow", workflow, workflow.name || workflow.id, workflow.source || "workflow");
525
+ }
526
+ for (const agent of agents) {
527
+ const node = agentsByKey.get(String(agent.id || agent.name));
528
+ const parent = agentsByKey.get(String(agent.parentAgentId || agent.parentId || agent.parent || ""));
529
+ if (parent) { node.type = "subagent"; link(parent, node, "delegates"); }
530
+ const session = sessionsByKey.get(String(agent.sessionId || ""));
531
+ if (session) link(session, node, "uses");
532
+ }
533
+ const tasksByKey = new Map();
534
+ for (const task of state.boardTasks) {
535
+ const taskId = graphTaskId(task);
536
+ if (!taskId) continue;
537
+ const node = add("task", task, task.title || task.name || taskId, task.column || task.status || "task");
538
+ tasksByKey.set(String(taskId), node);
539
+ }
540
+ const linkTask = (raw, node) => {
541
+ const ids = [raw?.taskId, raw?.task?.id, ...(String(raw?.description || raw?.message || raw?.summary || "").match(TSK_REGEX) || [])];
542
+ for (const taskId of ids) link(node, tasksByKey.get(String(taskId)), "works-on");
543
+ };
544
+ for (const agent of agents) linkTask(agent, agentsByKey.get(String(agent.id || agent.name)));
545
+ for (const session of state.sessions) linkTask(session, sessionsByKey.get(String(session.id || session.sessionId)));
546
+ for (const event of state.activity) {
547
+ const actor = agentsByKey.get(String(event.agentId || ""));
548
+ const sessionId = event.meta && typeof event.meta === "object" ? event.meta.sessionId : null;
549
+ const session = sessionsByKey.get(String(sessionId || ""));
550
+ linkTask(event, actor || session);
551
+ if (actor && session) link(session, actor, "uses");
552
+ }
553
+ if (!state.filter.agentId) {
554
+ return selectDefaultGraph(nodes, edges, sessionNodes, Array.from(agentsByKey.values()));
555
+ }
556
+
557
+ // Project the graph from the selected agent: only it, directly related
558
+ // sessions/subagents/tasks/teams, and edges whose endpoints survive.
559
+ const selected = agentsByKey.get(String(state.filter.agentId));
560
+ if (!selected) return { nodes: [], edges: [] };
561
+ const keptIds = new Set([selected.id]);
562
+ for (const edge of edges) {
563
+ if (edge.from === selected.id) keptIds.add(edge.to);
564
+ if (edge.to === selected.id) keptIds.add(edge.from);
565
+ }
566
+ const visibleNodes = nodes.filter((node) => keptIds.has(node.id)).slice(0, MAX_GRAPH_NODES);
567
+ const visibleIds = new Set(visibleNodes.map((node) => node.id));
568
+ return {
569
+ nodes: visibleNodes,
570
+ edges: edges.filter((edge) => visibleIds.has(edge.from) && visibleIds.has(edge.to)),
571
+ };
572
+ }
573
+
574
+ function renderGraph() {
575
+ const graph = graphModel();
576
+ if (!graph.nodes.length) return `<section class="claude-pane-empty claude-pane-graph-empty"><strong>No agent relationships are available yet.</strong><p>Start an agent session or assign a board task; live activity will appear here as sessions, agents, and task links arrive.</p></section>`;
577
+ const lanes = { session: 0, team: 0, workflow: 0, agent: 1, subagent: 1, task: 2 };
578
+ const groups = [[], [], []];
579
+ graph.nodes.forEach((node) => groups[lanes[node.type] ?? 1].push(node));
580
+ const height = Math.max(330, ...groups.map((group) => group.length * 74 + 42));
581
+ const width = 900;
582
+ const positions = new Map();
583
+ groups.forEach((group, lane) => group.forEach((node, index) => positions.set(node.id, { x: 50 + lane * 300, y: 34 + index * 74 })));
584
+ const edgeHtml = graph.edges.filter((edge) => positions.has(edge.from) && positions.has(edge.to)).map((edge) => {
585
+ const a = positions.get(edge.from), b = positions.get(edge.to);
586
+ return `<path class="claude-pane-graph-edge claude-pane-graph-edge--${escAttr(edge.kind)}" data-graph-edge="${escAttr(edge.kind)}" d="M ${a.x + 196} ${a.y + 25} C ${a.x + 245} ${a.y + 25}, ${b.x - 45} ${b.y + 25}, ${b.x} ${b.y + 25}" />`;
587
+ }).join("");
588
+ const nodeHtml = graph.nodes.map((node) => {
589
+ const pos = positions.get(node.id);
590
+ return `<g class="claude-pane-graph-node claude-pane-graph-node--${escAttr(node.type)} claude-pane-graph-node--${escAttr(node.status)}" data-graph-node="${escAttr(node.id)}" tabindex="0" role="listitem" aria-label="${escAttr(`${node.type}: ${node.label}. ${node.detail}. ${node.status}`)}" transform="translate(${pos.x} ${pos.y})"><rect width="196" height="50" rx="8"/><circle cx="15" cy="16" r="4"/><text x="27" y="20">${esc(node.label).slice(0, 26)}</text><text class="claude-pane-graph-node-detail" x="12" y="39">${esc(node.detail).slice(0, 31)}</text></g>`;
591
+ }).join("");
592
+ const list = graph.nodes.map((node) => `<li><strong>${esc(node.label)}</strong><span>${esc(node.type)} · ${esc(node.detail || node.status)}</span></li>`).join("");
593
+ const relationships = graph.edges.map((edge) => {
594
+ const from = graph.nodes.find((node) => node.id === edge.from);
595
+ const to = graph.nodes.find((node) => node.id === edge.to);
596
+ return from && to ? `<li>${esc(from.label)} <span>${esc(edge.kind)}</span> ${esc(to.label)}</li>` : "";
597
+ }).join("") || "<li>No inferred connections yet.</li>";
598
+ return `<section class="claude-pane-graph" aria-labelledby="claude-pane-graph-title"><header class="claude-pane-graph-header"><div><h3 id="claude-pane-graph-title">Agent relationship map</h3><p>Sessions delegate to agents and subagents; links to current board work are inferred from task IDs and live activity.</p></div><span>${graph.nodes.length} nodes · ${graph.edges.length} links</span></header><div class="claude-pane-graph-viewport"><svg class="claude-pane-graph-svg" viewBox="0 0 ${width} ${height}" role="list" aria-label="Agent relationship graph"><title>Agent relationship graph</title><g class="claude-pane-graph-lanes"><text x="50" y="18">Sessions</text><text x="350" y="18">Agents</text><text x="650" y="18">Board tasks</text></g>${edgeHtml}${nodeHtml}</svg></div><details class="claude-pane-graph-fallback"><summary>Accessible relationship list (${graph.nodes.length})</summary><h4>Nodes</h4><ul>${list}</ul><h4>Connections</h4><ul>${relationships}</ul></details></section>`;
599
+ }
600
+
601
+ function renderSubagents() {
602
+ return renderGraph();
603
+ }
604
+
605
+ function renderTeams() {
606
+ if (!state.teams.length) return renderEmpty("No teams registered yet.");
607
+ const cards = state.teams.map((team) => {
608
+ const id = team.id || team.name || "team";
609
+ const transcript = transcriptHref(team);
610
+ const status = statusKind(team);
611
+ const lastSeen = relativeTime(team.lastSeen || team.ts || team.updatedAt);
612
+ const memberCount = Number.isFinite(team.memberCount)
613
+ ? team.memberCount
614
+ : Array.isArray(team.members) ? team.members.length : null;
615
+ const transcriptBtn = transcript
616
+ ? `<a class="claude-pane-btn claude-pane-btn-primary" href="${escAttr(transcript.href)}" target="_blank" rel="noopener noreferrer">View transcript</a>`
617
+ : `<span class="claude-pane-btn claude-pane-btn-disabled" aria-disabled="true">No transcript</span>`;
618
+ return `
619
+ <article class="claude-pane-team-card">
620
+ <header class="claude-pane-team-card-head">
621
+ <h3 class="claude-pane-team-card-title">${esc(team.name || id)}</h3>
622
+ <span class="claude-pane-pill claude-pane-pill-${escAttr(status)}">
623
+ <span class="claude-pane-status-dot claude-pane-status-${escAttr(status)}" aria-hidden="true"></span>
624
+ <span class="claude-pane-pill-label">${esc(status)}</span>
625
+ </span>
626
+ </header>
627
+ <p class="claude-pane-team-meta">${esc(id)} · ${esc(lastSeen)}</p>
628
+ <p class="claude-pane-team-desc">${esc(team.description || "")}</p>
629
+ <footer class="claude-pane-team-card-foot">
630
+ ${transcriptBtn}
631
+ <span class="claude-pane-team-size">${esc(memberCount ?? "?")} members</span>
632
+ </footer>
633
+ </article>
634
+ `;
635
+ }).join("");
636
+ return `<div class="claude-pane-team-grid">${cards}</div>`;
637
+ }
638
+
639
+ function renderWorkflows() {
640
+ if (!state.workflows.length) return renderEmpty("No workflows registered yet.");
641
+ const cards = state.workflows.map((wf) => {
642
+ const id = wf.id || wf.name || "workflow";
643
+ const status = statusKind(wf);
644
+ const lastSeen = relativeTime(wf.lastSeen || wf.ts || wf.updatedAt);
645
+ const phases = Array.isArray(wf.phases) ? wf.phases : [];
646
+ const phaseList = phases.length
647
+ ? phases.map((p) => {
648
+ const ps = statusKind(p);
649
+ return `
650
+ <li class="claude-pane-workflow-phase">
651
+ <span class="claude-pane-pill claude-pane-pill-${escAttr(ps)}">
652
+ <span class="claude-pane-status-dot claude-pane-status-${escAttr(ps)}" aria-hidden="true"></span>
653
+ <span class="claude-pane-pill-label">${esc(p.name || p.id || "phase")}</span>
654
+ </span>
655
+ ${p.description ? `<span class="claude-pane-workflow-phase-desc">${esc(p.description)}</span>` : ""}
656
+ </li>
657
+ `;
658
+ }).join("")
659
+ : '<li class="claude-pane-workflow-phase claude-pane-workflow-phase-empty">No phases defined.</li>';
660
+ return `
661
+ <article class="claude-pane-workflow-card">
662
+ <header class="claude-pane-workflow-card-head">
663
+ <h3 class="claude-pane-workflow-card-title">${esc(wf.name || id)}</h3>
664
+ <span class="claude-pane-pill claude-pane-pill-${escAttr(status)}">
665
+ <span class="claude-pane-status-dot claude-pane-status-${escAttr(status)}" aria-hidden="true"></span>
666
+ <span class="claude-pane-pill-label">${esc(status)}</span>
667
+ </span>
668
+ </header>
669
+ <p class="claude-pane-workflow-meta">${esc(id)} · ${esc(lastSeen)}</p>
670
+ <ol class="claude-pane-workflow-phases">${phaseList}</ol>
671
+ </article>
672
+ `;
673
+ }).join("");
674
+ return `<div class="claude-pane-workflow-grid">${cards}</div>`;
675
+ }
676
+
677
+ function renderActivity(opts = {}) {
678
+ const node = state.root?.querySelector("#claude-pane-activity-list");
679
+ if (!node) return;
680
+ if (!state.activity.length) {
681
+ node.innerHTML = `<div class="claude-pane-activity-empty">No activity yet.</div>`;
682
+ return;
683
+ }
684
+ const window = 50;
685
+ const slice = state.activity.slice(-window).reverse();
686
+ const lastEvent = state.activity[state.activity.length - 1];
687
+ const html = slice.map((evt) => {
688
+ const fresh = opts.fresh && evt === lastEvent;
689
+ const cls = `claude-pane-activity-row${fresh ? " is-fresh" : ""}`;
690
+ const time = esc(relativeTime(evt.ts));
691
+ const kind = esc(evt.kind || "event");
692
+ const actor = esc(evt.agent || evt.actor || "—");
693
+ const msg = esc(evt.message || evt.summary || "");
694
+ return `
695
+ <div class="${cls}">
696
+ <span class="claude-pane-activity-time">${time}</span>
697
+ <span class="claude-pane-activity-kind">${kind}</span>
698
+ <span class="claude-pane-activity-actor">${actor}</span>
699
+ <span class="claude-pane-activity-msg">${msg}</span>
700
+ </div>
701
+ `;
702
+ }).join("");
703
+ node.innerHTML = html;
704
+ const counter = state.root.querySelector("#claude-pane-activity-count");
705
+ if (counter) counter.textContent = `${state.activity.length} events`;
706
+ }
707
+
708
+ function render() {
709
+ if (!state.root) return;
710
+ let body;
711
+ if (state.view === "teams") body = renderTeams();
712
+ else if (state.view === "workflows") body = renderWorkflows();
713
+ else body = renderSubagents();
714
+
715
+ state.root.innerHTML = `
716
+ <div class="claude-pane">
717
+ ${renderHeader()}
718
+ ${renderSubnav()}
719
+ <main class="claude-pane-main" role="tabpanel" data-claude-view="${escAttr(state.view)}">
720
+ ${body}
721
+ </main>
722
+ <footer class="claude-pane-activity" aria-label="Live activity">
723
+ <header class="claude-pane-activity-header">
724
+ <span class="claude-pane-activity-title">Live activity</span>
725
+ <span id="claude-pane-activity-count" class="claude-pane-activity-meta" aria-live="polite">0 events</span>
726
+ </header>
727
+ <div id="claude-pane-activity-list" class="claude-pane-activity-list"></div>
728
+ </footer>
729
+ </div>
730
+ `;
731
+ setConnectionStatus(state.live.source);
732
+ renderActivity();
733
+ bindEvents();
734
+ }
735
+
736
+ /* ----------------------------------------------------------------------
737
+ * Event bindings
738
+ * -------------------------------------------------------------------- */
739
+ function bindEvents() {
740
+ if (!state.root) return;
741
+ state.root.querySelectorAll("[data-claude-subtab]").forEach((btn) => {
742
+ btn.addEventListener("click", (e) => {
743
+ const next = e.currentTarget.dataset.claudeSubtab;
744
+ if (next && next !== state.view) {
745
+ state.view = next;
746
+ render();
747
+ }
748
+ });
749
+ });
750
+ state.root.querySelectorAll("[data-claude-chip]").forEach((btn) => {
751
+ btn.addEventListener("click", (e) => {
752
+ const node = e.currentTarget;
753
+ const key = node.dataset.claudeChip;
754
+ if (!(key in state.filter)) return;
755
+ if (node.dataset.claudeChipClear) {
756
+ state.filter[key] = null;
757
+ } else {
758
+ state.filter[key] = node.dataset.claudeChipValue || null;
759
+ }
760
+ render();
761
+ });
762
+ });
763
+ state.root.querySelectorAll("[data-claude-action='refresh']").forEach((btn) => {
764
+ btn.addEventListener("click", async () => {
765
+ try {
766
+ const snap = await fetchSnapshot();
767
+ if (snap) ingestSnapshot(snap);
768
+ try { state.boardTasks = await fetchBoard(); } catch (_err) { /* retain previous board */ }
769
+ render();
770
+ } catch (_err) {
771
+ /* status pill already reflects connectivity */
772
+ }
773
+ });
774
+ });
775
+ }
776
+
777
+ /* ----------------------------------------------------------------------
778
+ * Lifecycle
779
+ * -------------------------------------------------------------------- */
780
+ async function mount(root) {
781
+ if (state.root === root && state.mounted) return;
782
+ if (state.root && state.root !== root) unmount();
783
+ state.root = root;
784
+ state.mounted = true;
785
+ root.innerHTML = `<div class="claude-pane-loading">Loading Claude snapshot…</div>`;
786
+ let snap = null;
787
+ try {
788
+ snap = await fetchSnapshot();
789
+ } catch (_err) {
790
+ snap = null;
791
+ }
792
+ ingestSnapshot(snap || {});
793
+ try { state.boardTasks = await fetchBoard(); } catch (_err) { state.boardTasks = []; }
794
+ // Seed activity via REST so the footer isn't blank before SSE connects.
795
+ try {
796
+ const events = await fetchActivity(undefined);
797
+ if (Array.isArray(events) && events.length) {
798
+ for (const e of events) state.activity.push(e);
799
+ state.activity = clampActivity(state.activity);
800
+ const last = state.activity[state.activity.length - 1];
801
+ if (last && last.ts) state.lastEventTs = last.ts;
802
+ }
803
+ } catch (_err) { /* ignore */ }
804
+ render();
805
+ connectLive();
806
+ }
807
+
808
+ function unmount() {
809
+ state.mounted = false;
810
+ stopEventSource();
811
+ stopPolling();
812
+ if (state.reconcileTimer) { clearTimeout(state.reconcileTimer); state.reconcileTimer = null; }
813
+ if (state.root) {
814
+ try { state.root.innerHTML = ""; } catch (_err) { /* ignore */ }
815
+ }
816
+ state.root = null;
817
+ }
818
+
819
+ window.OpenKanClaude = { mount, unmount };
820
+ })();