pi-kanban 0.1.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/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # pi-kanban
2
+
3
+ Web dashboard for the [pi coding agent](https://pi.dev) — sessions, todos, subagents, observability.
4
+
5
+ ![pi-kanban dashboard](assets/pi-kanban-poster.png)
6
+
7
+ ## Try it now
8
+
9
+ **[→ Open the live demo](https://nikiforovall.blog/pi-kanban/)**
10
+
11
+ Click around a fully interactive dashboard with synthesized sessions, tasks, and subagents — no install. Toggle the theme, browse projects, expand a subagent's review, open the storage manager. Everything works in-browser.
12
+
13
+ When you're ready to run it on your own pi sessions, install below.
14
+
15
+ ## Installation
16
+
17
+ ```sh
18
+ pi install npm:pi-kanban
19
+ ```
20
+
21
+ Then use `/kanban start | stop | restart | status | open | web | app | pin | sticky-pin | unpin | preview | link` from inside pi.
22
+
23
+ Or run standalone:
24
+
25
+ ```sh
26
+ npx pi-kanban
27
+ ```
28
+
29
+ ### Companion pi extensions
30
+
31
+ pi-kanban surfaces data produced by two other pi extensions. Install them alongside for the full experience:
32
+
33
+ ```sh
34
+ pi install npm:pi-subagents
35
+ pi install npm:@juicesharp/rpiv-todo
36
+ ```
37
+
38
+ - [`pi-subagents`](https://www.npmjs.com/package/pi-subagents) — spawns named subagent sessions; pi-kanban renders them under each parent session.
39
+ - [`@juicesharp/rpiv-todo`](https://www.npmjs.com/package/@juicesharp/rpiv-todo) — task tracking that pi-kanban displays as kanban columns.
40
+
41
+ ## Documentation
42
+
43
+ - [User guide](docs/user-guide.md) — slash commands, layout, sessions, subagents, storage manager, keyboard shortcuts.
44
+ - [Theming](docs/theming.md) — built-in themes, configuration, custom theme authoring.
@@ -0,0 +1,382 @@
1
+ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
2
+ import { createRequire } from "node:module";
3
+ import { readFileSync } from "node:fs";
4
+ import { createConnection } from "node:net";
5
+ import { homedir } from "node:os";
6
+ import { isAbsolute, join as joinPath, resolve as resolvePath } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
+
10
+ const extDir = fileURLToPath(new URL(".", import.meta.url));
11
+ const taskStore = createRequire(import.meta.url)(
12
+ resolvePath(extDir, "..", "lib", "task-store.js"),
13
+ ) as {
14
+ reconcileFromSnapshot: (sessionId: string, snapshot: unknown[]) => void;
15
+ cleanupIfAllCompleted: (sessionId: string) => boolean;
16
+ };
17
+
18
+ let child: ChildProcess | null = null;
19
+ let lastStderr = "";
20
+ const port = 3460;
21
+
22
+ const KANBAN_DIR = joinPath(homedir(), ".pi", "agent", "kanban");
23
+ const SETTINGS_PATH = joinPath(KANBAN_DIR, "settings.json");
24
+ const DEFAULT_THEME_DIR = joinPath(KANBAN_DIR, "themes");
25
+
26
+ type KanbanSettings = {
27
+ themes?: {
28
+ light?: string;
29
+ dark?: string;
30
+ dir?: string;
31
+ };
32
+ };
33
+
34
+ function readSettings(): KanbanSettings {
35
+ try {
36
+ return JSON.parse(readFileSync(SETTINGS_PATH, "utf8")) as KanbanSettings;
37
+ } catch (e: any) {
38
+ if (e?.code !== "ENOENT") console.warn(`pi-kanban: cannot read ${SETTINGS_PATH}: ${e.message}`);
39
+ return {};
40
+ }
41
+ }
42
+
43
+ function buildServerEnv(): NodeJS.ProcessEnv {
44
+ const settings = readSettings();
45
+ const t = settings.themes ?? {};
46
+ const env: NodeJS.ProcessEnv = { ...process.env, PORT: String(port) };
47
+ env.KANBAN_THEME_DIR = process.env.KANBAN_THEME_DIR ?? t.dir ?? DEFAULT_THEME_DIR;
48
+ if (process.env.KANBAN_LIGHT_THEME ?? t.light) {
49
+ env.KANBAN_LIGHT_THEME = process.env.KANBAN_LIGHT_THEME ?? t.light;
50
+ }
51
+ if (process.env.KANBAN_DARK_THEME ?? t.dark) {
52
+ env.KANBAN_DARK_THEME = process.env.KANBAN_DARK_THEME ?? t.dark;
53
+ }
54
+ return env;
55
+ }
56
+
57
+ const SUBCOMMANDS = [
58
+ "start",
59
+ "stop",
60
+ "restart",
61
+ "status",
62
+ "open",
63
+ "web",
64
+ "app",
65
+ "pin",
66
+ "sticky-pin",
67
+ "unpin",
68
+ "preview",
69
+ "link",
70
+ ] as const;
71
+ type Sub = (typeof SUBCOMMANDS)[number];
72
+
73
+ function probePort(p: number, timeoutMs = 250): Promise<boolean> {
74
+ return new Promise((resolve) => {
75
+ const sock = createConnection({ port: p, host: "127.0.0.1" });
76
+ const done = (ok: boolean) => {
77
+ sock.destroy();
78
+ resolve(ok);
79
+ };
80
+ sock.setTimeout(timeoutMs);
81
+ sock.once("connect", () => done(true));
82
+ sock.once("timeout", () => done(false));
83
+ sock.once("error", () => done(false));
84
+ });
85
+ }
86
+
87
+ async function waitForPort(p: number, totalMs = 5000): Promise<boolean> {
88
+ const start = Date.now();
89
+ while (Date.now() - start < totalMs) {
90
+ if (await probePort(p)) return true;
91
+ await new Promise((r) => setTimeout(r, 150));
92
+ }
93
+ return false;
94
+ }
95
+
96
+ async function api(path: string, init?: RequestInit): Promise<Response> {
97
+ return fetch(`http://127.0.0.1:${port}${path}`, init);
98
+ }
99
+
100
+ async function postPin(id: string, state: "pinned" | "sticky" | "none"): Promise<Response> {
101
+ return api("/api/session/pin", {
102
+ method: "POST",
103
+ headers: { "Content-Type": "application/json" },
104
+ body: JSON.stringify({ id, state }),
105
+ });
106
+ }
107
+
108
+ async function postSessionOpen(id: string): Promise<Response> {
109
+ return api("/api/session/open", {
110
+ method: "POST",
111
+ headers: { "Content-Type": "application/json" },
112
+ body: JSON.stringify({ id }),
113
+ });
114
+ }
115
+
116
+ async function postPreview(filePath: string, sessionId: string | null, link = false): Promise<Response> {
117
+ return api("/api/preview", {
118
+ method: "POST",
119
+ headers: { "Content-Type": "application/json" },
120
+ body: JSON.stringify({ path: filePath, sessionId, link }),
121
+ });
122
+ }
123
+
124
+ function splitArgs(args: string): string[] {
125
+ return args.trim().split(/\s+/).filter(Boolean);
126
+ }
127
+
128
+ function findPidsOnPort(p: number): number[] {
129
+ if (process.platform === "win32") {
130
+ const r = spawnSync("netstat", ["-ano", "-p", "tcp"], { encoding: "utf8" });
131
+ if (r.status !== 0) return [];
132
+ const pids = new Set<number>();
133
+ for (const line of r.stdout.split(/\r?\n/)) {
134
+ const m = line.match(/^\s*TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)/i);
135
+ if (m && Number(m[1]) === p) pids.add(Number(m[2]));
136
+ }
137
+ return [...pids];
138
+ }
139
+ const r = spawnSync("lsof", ["-tiTCP:" + p, "-sTCP:LISTEN"], { encoding: "utf8" });
140
+ if (r.status !== 0) return [];
141
+ return r.stdout
142
+ .split(/\s+/)
143
+ .map((s) => Number(s))
144
+ .filter((n) => Number.isFinite(n) && n > 0);
145
+ }
146
+
147
+ function killPid(pid: number): void {
148
+ if (process.platform === "win32") {
149
+ spawnSync("taskkill", ["/F", "/PID", String(pid)], { stdio: "ignore" });
150
+ } else {
151
+ try {
152
+ process.kill(pid, "SIGKILL");
153
+ } catch {}
154
+ }
155
+ }
156
+
157
+ async function ensureRunning(notify: (msg: string, level: "info" | "error") => void): Promise<boolean> {
158
+ if (await probePort(port)) return true;
159
+ notify(`pi-kanban not running. Start it with /kanban start`, "error");
160
+ return false;
161
+ }
162
+
163
+ export default function kanbanExtension(pi: ExtensionAPI) {
164
+ const serverPath = resolvePath(extDir, "..", "server.js");
165
+ const url = `http://localhost:${port}`;
166
+
167
+ pi.on("tool_result", async (event, ctx) => {
168
+ if (event.toolName !== "todo") return;
169
+ const sessionId = ctx.sessionManager.getSessionId();
170
+ const tasks = (event.details as any)?.tasks;
171
+ if (sessionId && Array.isArray(tasks)) {
172
+ try {
173
+ taskStore.reconcileFromSnapshot(sessionId, tasks);
174
+ taskStore.cleanupIfAllCompleted(sessionId);
175
+ } catch (e: any) { console.warn(`pi-kanban: reconcile failed: ${e?.message ?? e}`); }
176
+ }
177
+ });
178
+
179
+ pi.on("session_start", async (_event, ctx) => {
180
+ const sessionId = ctx.sessionManager.getSessionId();
181
+ if (!sessionId) return;
182
+ const branch = ctx.sessionManager.getBranch();
183
+ for (let i = branch.length - 1; i >= 0; i--) {
184
+ const entry = branch[i];
185
+ if (entry.type !== "message") continue;
186
+ const msg: any = (entry as any).message;
187
+ if (msg?.role !== "toolResult" || msg?.toolName !== "todo") continue;
188
+ const tasks = msg?.details?.tasks;
189
+ if (!Array.isArray(tasks)) continue;
190
+ try {
191
+ taskStore.reconcileFromSnapshot(sessionId, tasks);
192
+ taskStore.cleanupIfAllCompleted(sessionId);
193
+ } catch (e: any) { console.warn(`pi-kanban: backfill failed: ${e?.message ?? e}`); }
194
+ return;
195
+ }
196
+ });
197
+
198
+ type Notify = (m: string, l?: "info" | "error") => void;
199
+
200
+ async function startServer(notify: Notify, opts: { silentSuccess?: boolean } = {}): Promise<boolean> {
201
+ if (await probePort(port)) {
202
+ notify(`pi-kanban already listening on ${url} — run /kanban open or /kanban web`);
203
+ return true;
204
+ }
205
+ lastStderr = "";
206
+ child = spawn(process.execPath, [serverPath], {
207
+ env: buildServerEnv(),
208
+ stdio: ["ignore", "ignore", "pipe"],
209
+ detached: true,
210
+ windowsHide: true,
211
+ });
212
+ child.stderr?.on("data", (b) => {
213
+ lastStderr += b.toString();
214
+ });
215
+ child.on("exit", () => {
216
+ child = null;
217
+ });
218
+
219
+ if (!(await waitForPort(port))) {
220
+ notify(
221
+ `pi-kanban failed to start.\n${lastStderr.slice(-500) || "(no stderr)"}`,
222
+ "error",
223
+ );
224
+ return false;
225
+ }
226
+ // Detach: stop holding the child to the parent's lifetime so /kanban survives
227
+ // when the pi process that ran /kanban start exits.
228
+ child.stderr?.removeAllListeners("data");
229
+ child.stderr?.resume();
230
+ child.unref();
231
+ if (!opts.silentSuccess) {
232
+ notify(`pi-kanban started → ${url} — run /kanban open or /kanban web to launch it`);
233
+ }
234
+ return true;
235
+ }
236
+
237
+ async function stopServer(notify: Notify, opts: { silentSuccess?: boolean } = {}): Promise<boolean> {
238
+ if (child) child.kill("SIGINT");
239
+ child = null;
240
+
241
+ if (await probePort(port)) {
242
+ const pids = findPidsOnPort(port);
243
+ for (const pid of pids) killPid(pid);
244
+ await new Promise((r) => setTimeout(r, 300));
245
+ const stillUp = await probePort(port);
246
+ if (stillUp) {
247
+ notify(
248
+ `port ${port} still in use after killing pids ${pids.join(",") || "?"}`,
249
+ "error",
250
+ );
251
+ return false;
252
+ }
253
+ if (!opts.silentSuccess) {
254
+ notify(`pi-kanban stopped (killed orphan pid${pids.length > 1 ? "s" : ""} ${pids.join(",")})`);
255
+ }
256
+ return true;
257
+ }
258
+ if (!opts.silentSuccess) notify("pi-kanban stopped");
259
+ return true;
260
+ }
261
+
262
+ pi.registerCommand("kanban", {
263
+ description:
264
+ "pi-kanban dashboard: start | stop | restart | status | open | web | app | pin | sticky-pin | unpin | preview | link",
265
+ getArgumentCompletions: (prefix) =>
266
+ SUBCOMMANDS.filter((s) => s.startsWith(prefix)).map((s) => ({ value: s, label: s })),
267
+ handler: async (args, ctx) => {
268
+ const tokens = splitArgs(args);
269
+ const sub = (tokens[0] || "start") as Sub;
270
+ const rest = tokens.slice(1);
271
+ const notify = (m: string, l: "info" | "error" = "info") => ctx.ui.notify(m, l);
272
+
273
+ if (sub === "start") {
274
+ await startServer(notify);
275
+ return;
276
+ }
277
+
278
+ if (sub === "stop") {
279
+ await stopServer(notify);
280
+ return;
281
+ }
282
+
283
+ if (sub === "restart") {
284
+ if (!(await stopServer(notify, { silentSuccess: true }))) return;
285
+ if (!(await startServer(notify, { silentSuccess: true }))) return;
286
+ notify(`pi-kanban restarted → ${url}`);
287
+ return;
288
+ }
289
+
290
+ if (sub === "status") {
291
+ const up = await probePort(port);
292
+ const owned = child ? ` (pid ${child.pid})` : " (external)";
293
+ notify(up ? `running on ${url}${owned}` : "not running");
294
+ return;
295
+ }
296
+
297
+ if (sub === "open") {
298
+ if (!(await ensureRunning(notify))) return;
299
+ const id = rest[0] ?? ctx.sessionManager.getSessionId() ?? null;
300
+ if (!id) {
301
+ notify("Usage: /kanban open <session-id> (no current session to default to)", "error");
302
+ return;
303
+ }
304
+ const res = await postSessionOpen(id);
305
+ if (!res.ok) {
306
+ notify(`open failed (${res.status}): ${await res.text()}`, "error");
307
+ return;
308
+ }
309
+ const usedCurrent = !rest[0];
310
+ notify(`opened: ${id}${usedCurrent ? " (current)" : ""}`);
311
+ return;
312
+ }
313
+
314
+ if (sub === "web") {
315
+ const { default: open } = await import("open");
316
+ await open(url);
317
+ return;
318
+ }
319
+
320
+ if (sub === "app") {
321
+ const { default: open, apps } = await import("open");
322
+ for (const name of [apps.chrome, apps.edge, apps.browser]) {
323
+ try {
324
+ await open(url, { app: { name, arguments: [`--app=${url}`] } });
325
+ return;
326
+ } catch {}
327
+ }
328
+ notify("Could not find Chrome/Edge for PWA window mode", "error");
329
+ return;
330
+ }
331
+
332
+ if (sub === "pin" || sub === "sticky-pin" || sub === "unpin") {
333
+ if (!(await ensureRunning(notify))) return;
334
+ const id = rest[0] ?? ctx.sessionManager.getSessionId() ?? null;
335
+ if (!id) {
336
+ notify(`Usage: /kanban ${sub} <session-id> (no current session to default to)`, "error");
337
+ return;
338
+ }
339
+ const PIN_STATE = { "sticky-pin": "sticky", "unpin": "none", "pin": "pinned" } as const;
340
+ const state = PIN_STATE[sub];
341
+ const res = await postPin(id, state);
342
+ if (!res.ok) {
343
+ notify(`${sub} failed (${res.status}): ${await res.text()}`, "error");
344
+ return;
345
+ }
346
+ const usedCurrent = !rest[0];
347
+ const verb = sub === "unpin" ? "unpinned" : state;
348
+ notify(`${verb}: ${id}${usedCurrent ? " (current)" : ""}`);
349
+ return;
350
+ }
351
+
352
+ if (sub === "preview" || sub === "link") {
353
+ if (!(await ensureRunning(notify))) return;
354
+ const file = rest[0];
355
+ if (!file) {
356
+ notify(`Usage: /kanban ${sub} <file.md> [session-id]`, "error");
357
+ return;
358
+ }
359
+ const sessionId = rest[1] ?? ctx.sessionManager.getSessionId() ?? null;
360
+ if (sub === "link" && !sessionId) {
361
+ notify("Usage: /kanban link <file.md> <session-id> (no current session to default to)", "error");
362
+ return;
363
+ }
364
+ const expanded =
365
+ file === "~" || file.startsWith("~/") || file.startsWith("~\\")
366
+ ? joinPath(homedir(), file.slice(1))
367
+ : file;
368
+ const abs = isAbsolute(expanded) ? expanded : resolvePath(process.cwd(), expanded);
369
+ const res = await postPreview(abs, sessionId, sub === "link");
370
+ if (!res.ok) {
371
+ notify(`${sub} failed (${res.status}): ${await res.text()}`, "error");
372
+ return;
373
+ }
374
+ const usedCurrent = !rest[1] && sessionId;
375
+ notify(`${sub}: ${abs}${sessionId ? ` → session ${sessionId}${usedCurrent ? " (current)" : ""}` : ""}`);
376
+ return;
377
+ }
378
+
379
+ notify(`Unknown subcommand: ${sub}. Try: ${SUBCOMMANDS.join(", ")}`, "error");
380
+ },
381
+ });
382
+ }
@@ -0,0 +1,112 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const { spawn } = require('node:child_process');
4
+
5
+ // cwd -> { branch: string|null, headMtimeMs: number, isRepo: boolean, checkedAt: number }
6
+ const cache = new Map();
7
+ const inflight = new Map();
8
+
9
+ const MAX_CONCURRENT = 4;
10
+ const SPAWN_TIMEOUT_MS = 500;
11
+ // re-check non-repos every 30s so newly-cloned repos surface without a server restart
12
+ const NEGATIVE_TTL_MS = 30_000;
13
+ // skip statSync within this window of a known-good cache hit; keeps list endpoints from
14
+ // hitting the FS once per session per request when the same cwds repeat
15
+ const POSITIVE_TTL_MS = 2_000;
16
+ let running = 0;
17
+ const queue = [];
18
+
19
+ function pump() {
20
+ while (running < MAX_CONCURRENT && queue.length) {
21
+ const job = queue.shift();
22
+ running++;
23
+ job().finally(() => { running--; pump(); });
24
+ }
25
+ }
26
+
27
+ function headMtime(cwd) {
28
+ try { return fs.statSync(path.join(cwd, '.git', 'HEAD')).mtimeMs; }
29
+ catch { return null; }
30
+ }
31
+
32
+ function spawnGit(cwd) {
33
+ return new Promise((resolve) => {
34
+ let stdout = '';
35
+ let done = false;
36
+ let proc;
37
+ try {
38
+ proc = spawn('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], {
39
+ windowsHide: true,
40
+ });
41
+ } catch { resolve(null); return; }
42
+ const timer = setTimeout(() => {
43
+ if (done) return;
44
+ done = true;
45
+ try { proc.kill(); } catch {}
46
+ resolve(null);
47
+ }, SPAWN_TIMEOUT_MS);
48
+ proc.stdout.on('data', (b) => { stdout += b.toString(); });
49
+ proc.on('error', () => {
50
+ if (done) return;
51
+ done = true; clearTimeout(timer); resolve(null);
52
+ });
53
+ proc.on('close', (code) => {
54
+ if (done) return;
55
+ done = true; clearTimeout(timer);
56
+ if (code !== 0) { resolve(null); return; }
57
+ const branch = stdout.trim();
58
+ resolve(branch || null);
59
+ });
60
+ });
61
+ }
62
+
63
+ function getBranch(cwd, onResolved) {
64
+ if (!cwd) return null;
65
+ const entry = cache.get(cwd);
66
+ const now = Date.now();
67
+
68
+ if (entry && entry.isRepo && now - entry.checkedAt < POSITIVE_TTL_MS) {
69
+ return entry.branch;
70
+ }
71
+
72
+ const mtime = headMtime(cwd);
73
+
74
+ if (mtime === null) {
75
+ if (entry && entry.isRepo === false && now - entry.checkedAt < NEGATIVE_TTL_MS) {
76
+ return null;
77
+ }
78
+ cache.set(cwd, { branch: null, headMtimeMs: 0, isRepo: false, checkedAt: now });
79
+ return null;
80
+ }
81
+
82
+ if (entry && entry.isRepo && entry.headMtimeMs === mtime) {
83
+ entry.checkedAt = now;
84
+ return entry.branch;
85
+ }
86
+
87
+ scheduleResolve(cwd, mtime, entry, onResolved);
88
+ return entry && entry.isRepo ? entry.branch : null;
89
+ }
90
+
91
+ function scheduleResolve(cwd, mtime, prevEntry, onResolved) {
92
+ if (inflight.has(cwd)) {
93
+ if (onResolved) inflight.get(cwd).then((b) => {
94
+ if (!prevEntry || prevEntry.branch !== b) onResolved(b);
95
+ });
96
+ return;
97
+ }
98
+ const prevBranch = prevEntry ? prevEntry.branch : undefined;
99
+ const p = new Promise((resolve) => {
100
+ queue.push(async () => {
101
+ const branch = await spawnGit(cwd);
102
+ cache.set(cwd, { branch, headMtimeMs: mtime, isRepo: branch !== null, checkedAt: Date.now() });
103
+ inflight.delete(cwd);
104
+ resolve(branch);
105
+ });
106
+ pump();
107
+ });
108
+ inflight.set(cwd, p);
109
+ if (onResolved) p.then((b) => { if (b !== prevBranch) onResolved(b); });
110
+ }
111
+
112
+ module.exports = { getBranch };