pi-kanban 0.3.1 → 2.0.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 (129) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +61 -21
  3. package/cli.js +1586 -0
  4. package/extensions/kanban.ts +330 -367
  5. package/extensions/package.json +1 -0
  6. package/extensions/tools/ask.ts +85 -0
  7. package/extensions/tools/board-api.ts +142 -0
  8. package/extensions/tools/cli-path.ts +34 -0
  9. package/extensions/tools/dispatch.ts +56 -0
  10. package/extensions/tools/inbox.ts +86 -0
  11. package/extensions/tools/kanban-command.ts +27 -0
  12. package/extensions/tools/scratchpad.ts +70 -0
  13. package/extensions/tsconfig.json +16 -0
  14. package/lib/agent-dir.js +20 -0
  15. package/lib/agent-log.js +64 -0
  16. package/lib/ask.js +252 -0
  17. package/lib/atomic-write.js +30 -0
  18. package/lib/board-dir.js +63 -0
  19. package/lib/claude-settings.js +21 -0
  20. package/lib/companion-host.js +142 -0
  21. package/lib/companions.js +136 -0
  22. package/lib/contain.js +78 -0
  23. package/lib/dispatch-reports.js +86 -0
  24. package/lib/file-url.js +38 -0
  25. package/lib/folder-dialog.js +91 -0
  26. package/lib/frame-policy.js +79 -0
  27. package/lib/git-branch.js +37 -102
  28. package/lib/http-error.js +7 -0
  29. package/lib/inline-assets.js +238 -0
  30. package/lib/linked-docs.js +56 -0
  31. package/lib/live-sessions.js +86 -0
  32. package/lib/model-windows.js +48 -0
  33. package/lib/net-guard.js +277 -0
  34. package/lib/one-line.js +6 -0
  35. package/lib/open-editor.js +173 -0
  36. package/lib/panes.js +213 -0
  37. package/lib/parsers.js +1434 -0
  38. package/lib/pi-agents.js +178 -0
  39. package/lib/pi-git-branch.js +112 -0
  40. package/lib/pi-models.js +34 -0
  41. package/lib/pi-sessions.js +240 -0
  42. package/lib/pi-transcript.js +354 -0
  43. package/lib/preview-action.js +55 -0
  44. package/lib/priority.js +45 -0
  45. package/lib/proc-stats.js +82 -0
  46. package/lib/retention.js +191 -0
  47. package/lib/scratch-files.js +47 -0
  48. package/lib/scratchpad-dir.js +95 -0
  49. package/lib/session-cache.js +32 -0
  50. package/lib/session-events.js +275 -0
  51. package/lib/session-pins.js +99 -0
  52. package/lib/show.js +279 -0
  53. package/lib/stamped-json-file.js +49 -0
  54. package/lib/task-counts.js +90 -0
  55. package/lib/task-dir.js +26 -0
  56. package/lib/task-store.js +44 -47
  57. package/lib/terminal-client.js +248 -0
  58. package/lib/terminal-host.js +113 -0
  59. package/lib/terminal.js +1116 -0
  60. package/lib/user-groups.js +384 -0
  61. package/lib/worktrees.js +132 -0
  62. package/package.json +39 -15
  63. package/public/app.js +14349 -3448
  64. package/public/fonts/OFL-IBM-Plex.txt +93 -0
  65. package/public/fonts/OFL-Playfair-Display.txt +93 -0
  66. package/public/fonts/fonts.css +244 -0
  67. package/public/fonts/ibm-plex-mono-cyrillic-400.woff2 +0 -0
  68. package/public/fonts/ibm-plex-mono-cyrillic-500.woff2 +0 -0
  69. package/public/fonts/ibm-plex-mono-cyrillic-600.woff2 +0 -0
  70. package/public/fonts/ibm-plex-mono-cyrillic-ext-400.woff2 +0 -0
  71. package/public/fonts/ibm-plex-mono-cyrillic-ext-500.woff2 +0 -0
  72. package/public/fonts/ibm-plex-mono-cyrillic-ext-600.woff2 +0 -0
  73. package/public/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
  74. package/public/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
  75. package/public/fonts/ibm-plex-mono-latin-600.woff2 +0 -0
  76. package/public/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
  77. package/public/fonts/ibm-plex-mono-latin-ext-500.woff2 +0 -0
  78. package/public/fonts/ibm-plex-mono-latin-ext-600.woff2 +0 -0
  79. package/public/fonts/ibm-plex-mono-vietnamese-400.woff2 +0 -0
  80. package/public/fonts/ibm-plex-mono-vietnamese-500.woff2 +0 -0
  81. package/public/fonts/ibm-plex-mono-vietnamese-600.woff2 +0 -0
  82. package/public/fonts/playfair-display-cyrillic-400.woff2 +0 -0
  83. package/public/fonts/playfair-display-latin-400.woff2 +0 -0
  84. package/public/fonts/playfair-display-latin-ext-400.woff2 +0 -0
  85. package/public/fonts/playfair-display-vietnamese-400.woff2 +0 -0
  86. package/public/group-tree.js +21 -0
  87. package/public/icons/icon-192.png +0 -0
  88. package/public/icons/icon-512.png +0 -0
  89. package/public/icons/icon-svg.svg +7 -5
  90. package/public/index.html +493 -328
  91. package/public/link-url.js +18 -0
  92. package/public/manifest.json +7 -7
  93. package/public/project-match.js +34 -0
  94. package/public/style.css +8301 -2251
  95. package/public/sw.js +4 -3
  96. package/public/terminal-frame.js +437 -0
  97. package/public/terminal.html +26 -0
  98. package/public/themes.css +876 -0
  99. package/public/tree-highlight.js +196 -0
  100. package/server.js +3368 -467
  101. package/skills/ask/SKILL.md +29 -0
  102. package/skills/ask/references/playground.md +25 -0
  103. package/skills/dispatch/SKILL.md +51 -0
  104. package/skills/dispatch/references/orchestration-patterns.md +50 -0
  105. package/skills/kanban/SKILL.md +46 -0
  106. package/skills/show/SKILL.md +41 -0
  107. package/skills/show/references/NOTICE.md +27 -0
  108. package/skills/show/references/chart.md +58 -0
  109. package/skills/show/references/diagram/architecture.md +18 -0
  110. package/skills/show/references/diagram/er.md +10 -0
  111. package/skills/show/references/diagram/flow.md +13 -0
  112. package/skills/show/references/diagram/layers.md +10 -0
  113. package/skills/show/references/diagram/sequence.md +19 -0
  114. package/skills/show/references/diagram/state.md +12 -0
  115. package/skills/show/references/diagram/swimlane.md +10 -0
  116. package/skills/show/references/diagram/timeline.md +9 -0
  117. package/skills/show/references/diagram/tree.md +10 -0
  118. package/skills/show/references/diagram.md +87 -0
  119. package/skills/show/references/explain.md +100 -0
  120. package/skills/show/references/html-contract.md +18 -0
  121. package/skills/show/references/mockup.md +30 -0
  122. package/skills/visualize/SKILL.md +73 -0
  123. package/skills/visualize/blocks.html +68 -0
  124. package/skills/visualize/template.html +179 -0
  125. package/lib/pi-parsers.js +0 -1000
  126. package/themes/kanban-dark-default.json +0 -21
  127. package/themes/kanban-default.json +0 -21
  128. package/themes/pi-dark.json +0 -21
  129. package/themes/pi-light.json +0 -21
@@ -1,184 +1,259 @@
1
- import { spawn, spawnSync, type ChildProcess } from "node:child_process";
2
- import { createRequire } from "node:module";
3
- import { readFileSync, statSync } 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 } from "typebox";
9
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
10
-
11
- const extDir = fileURLToPath(new URL(".", import.meta.url));
12
- const taskStore = createRequire(import.meta.url)(
13
- resolvePath(extDir, "..", "lib", "task-store.js"),
14
- ) as {
15
- reconcileFromSnapshot: (sessionId: string, snapshot: unknown[]) => void;
16
- cleanupIfAllCompleted: (sessionId: string) => boolean;
1
+ import { dirname, join as joinPath } from "node:path";
2
+ import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
3
+ import { boardFetch, boardTerminalId, boardUrl, errorText, kanbanDir, lib, okJson, postJson, resolveTarget, terminalToken } from "./tools/board-api.js";
4
+ import { linkDoc, openCompanion, previewDoc } from "./tools/kanban-command.js";
5
+ import { addCliToPath } from "./tools/cli-path.js";
6
+ import { registerDispatch } from "./tools/dispatch.js";
7
+ import { registerInbox } from "./tools/inbox.js";
8
+ import { registerScratchpad } from "./tools/scratchpad.js";
9
+ import { ASK_CLOSE, ASK_OPEN, ASK_TOOL, type AskOpen, registerAsk } from "./tools/ask.js";
10
+
11
+ type Line = Record<string, unknown>;
12
+
13
+ type SessionLines = { sessionId: string; lines: Line[] } | null;
14
+
15
+ const taskStore = lib("task-store.js") as {
16
+ reconcileFromSnapshot: (sessionId: string, snapshot: unknown[]) => Promise<void>;
17
+ };
18
+ const liveSessions = lib("live-sessions.js") as {
19
+ writeLiveSession: (dir: string, entry: Line) => Promise<void>;
20
+ removeLiveSession: (dir: string, pid: number) => Promise<void>;
21
+ removeLiveSessionSync: (dir: string, pid: number) => void;
22
+ sweepDeadLiveSessions: (dir: string) => Promise<void>;
23
+ };
24
+ const agentLog = lib("agent-log.js") as {
25
+ appendAgentLines: (dir: string, records: Line[]) => Promise<void>;
26
+ promptMarker: (prompt: { kind: string; title?: string }, tool: { toolName: string; args: unknown } | undefined, now: string) => Line;
27
+ writeWaiting: (dir: string, marker: Line) => Promise<void>;
28
+ clearWaiting: (dir: string) => Promise<void>;
29
+ markStopped: (dir: string) => Promise<void>;
30
+ clearStopped: (dir: string) => Promise<void>;
31
+ };
32
+ const piAgents = lib("pi-agents.js") as {
33
+ createSubagentTracker: () => {
34
+ start: (sessionId: string, toolCallId: string, args: unknown, now: string) => Line[];
35
+ update: (toolCallId: string, partial: unknown, now: string) => SessionLines;
36
+ end: (toolCallId: string, result: unknown, isError: boolean, now: string) => SessionLines;
37
+ asyncComplete: (payload: unknown, now: string) => Promise<SessionLines>;
38
+ };
17
39
  };
18
40
 
19
- let child: ChildProcess | null = null;
20
- let lastStderr = "";
21
- const port = 3460;
41
+ const modelWindows = lib("model-windows.js") as {
42
+ MODEL_WINDOWS_FILE: string;
43
+ recordModelWindows: (file: string, models: { provider: string; id: string; contextWindow: number }[]) => Promise<boolean>;
44
+ };
22
45
 
23
- const KANBAN_DIR = joinPath(homedir(), ".pi", "agent", "kanban");
24
- const SETTINGS_PATH = joinPath(KANBAN_DIR, "settings.json");
25
- const DEFAULT_THEME_DIR = joinPath(KANBAN_DIR, "themes");
46
+ const { SESSION_ID_ENV } = lib("board-dir.js") as { SESSION_ID_ENV: string };
26
47
 
27
- type KanbanSettings = {
28
- themes?: {
29
- light?: string;
30
- dark?: string;
31
- dir?: string;
32
- };
33
- };
48
+ const LIVE_DIR = joinPath(kanbanDir(), "live");
49
+ const MODEL_WINDOWS_FILE = joinPath(kanbanDir(), modelWindows.MODEL_WINDOWS_FILE);
50
+ const ACTIVITY_DIR = joinPath(kanbanDir(), "agent-activity");
51
+
52
+ // pi's default dir is <agentDir>/sessions/<encoded cwd>, which the board scans already. Only a
53
+ // dir pi was told to use (--session-dir, env, settings) goes in the live entry.
54
+ function ownSessionDir(dir: string): string | null {
55
+ return dir && dirname(dir) !== joinPath(getAgentDir(), "sessions") ? dir : null;
56
+ }
57
+
58
+ // A pi in the board terminal already runs inside the board page.
59
+ const CAN_OPEN = !boardTerminalId();
60
+ const { PANE_COMPANIONS } = lib("companions.js") as { PANE_COMPANIONS: { id: string; verb: string; shows: string }[] };
61
+ const companionOf = (verb: string) => PANE_COMPANIONS.find((c) => c.verb === verb);
62
+ const COMPANION_VERBS = PANE_COMPANIONS.map((c) => c.verb);
63
+ const SUBCOMMANDS = [...(CAN_OPEN ? ["open"] : []), "session", ...COMPANION_VERBS];
64
+
65
+ const SESSION_SUBCOMMANDS = ["open", "pin", "sticky-pin", "unpin", "view-doc", "link-doc", ...COMPANION_VERBS];
66
+
67
+ // Whole commands, never an argument: Enter on a completion must not replace a typed path.
68
+ const COMPLETIONS = [...SUBCOMMANDS, ...SESSION_SUBCOMMANDS.map((s) => `session ${s}`)];
69
+
70
+ // pi's first Enter takes an offered completion, so a command typed in full offers none and runs.
71
+ export function completionsFor(prefix: string) {
72
+ const typed = prefix.trimStart();
73
+ const items = COMPLETIONS.filter((c) => c.startsWith(typed) && c !== typed).map((c) => ({ value: c, label: c }));
74
+ return items.length ? items : null;
75
+ }
76
+
77
+ function splitArgs(args: string): string[] {
78
+ return args.trim().split(/\s+/).filter(Boolean);
79
+ }
34
80
 
35
- function readSettings(): KanbanSettings {
81
+ // The board reads the token from the hash and drops it from the address bar. A board started
82
+ // without the terminal has no token, and opens read-only.
83
+ async function boardPageUrl(): Promise<string> {
84
+ const res = await boardFetch("/", { method: "HEAD" });
85
+ if (!res.ok) throw new Error(await errorText(res));
86
+ const url = boardUrl()!;
36
87
  try {
37
- return JSON.parse(readFileSync(SETTINGS_PATH, "utf8")) as KanbanSettings;
38
- } catch (e: any) {
39
- if (e?.code !== "ENOENT") console.warn(`pi-kanban: cannot read ${SETTINGS_PATH}: ${e.message}`);
40
- return {};
88
+ return `${url}/#t=${terminalToken()}`;
89
+ } catch {
90
+ return url;
41
91
  }
42
92
  }
43
93
 
44
- function buildServerEnv(): NodeJS.ProcessEnv {
45
- const settings = readSettings();
46
- const t = settings.themes ?? {};
47
- const env: NodeJS.ProcessEnv = { ...process.env, PORT: String(port) };
48
- env.KANBAN_THEME_DIR = process.env.KANBAN_THEME_DIR ?? t.dir ?? DEFAULT_THEME_DIR;
49
- if (process.env.KANBAN_LIGHT_THEME ?? t.light) {
50
- env.KANBAN_LIGHT_THEME = process.env.KANBAN_LIGHT_THEME ?? t.light;
94
+ const PROCESS_STARTED_AT = Date.now() - Math.round(process.uptime() * 1000);
95
+ const EXIT_GUARD = Symbol.for("pi-kanban.live-exit-guard");
96
+
97
+ // A subagent child is shown on its parent's card, so it does not register as a live session.
98
+ function registerLiveState(pi: ExtensionAPI) {
99
+ if (process.env.PI_SUBAGENT_CHILD) return;
100
+
101
+ let session: { sessionId: string; cwd: string; sessionDir: string | null; name: string | null } | null = null;
102
+ let status: "busy" | "idle" | "waiting" = "idle";
103
+ let statusUpdatedAt = Date.now();
104
+ let running = false;
105
+ let openPrompts = 0;
106
+ let queue: Promise<unknown> = Promise.resolve();
107
+ let published = "";
108
+ const toolsInFlight = new Map<string, { toolName: string; args: unknown }>();
109
+ const subagents = piAgents.createSubagentTracker();
110
+
111
+ const now = () => new Date().toISOString();
112
+ const activityDir = (sessionId: string) => joinPath(ACTIVITY_DIR, sessionId);
113
+
114
+ // Writes run one at a time, so the files change in the order the events came.
115
+ function enqueue(label: string, job: () => Promise<unknown>) {
116
+ queue = queue.then(job).catch((e: any) => console.warn(`pi-kanban: ${label} failed: ${e?.message ?? e}`));
51
117
  }
52
- if (process.env.KANBAN_DARK_THEME ?? t.dark) {
53
- env.KANBAN_DARK_THEME = process.env.KANBAN_DARK_THEME ?? t.dark;
118
+
119
+ function inActivityDir(label: string, job: (dir: string) => Promise<unknown>) {
120
+ if (!session) return;
121
+ const dir = activityDir(session.sessionId);
122
+ enqueue(label, () => job(dir));
54
123
  }
55
- return env;
56
- }
57
124
 
58
- const SUBCOMMANDS = [
59
- "start",
60
- "stop",
61
- "restart",
62
- "status",
63
- "open",
64
- "web",
65
- "app",
66
- "pin",
67
- "sticky-pin",
68
- "unpin",
69
- "preview",
70
- "link",
71
- ] as const;
72
- type Sub = (typeof SUBCOMMANDS)[number];
73
-
74
- function probePort(p: number, timeoutMs = 250): Promise<boolean> {
75
- return new Promise((resolve) => {
76
- const sock = createConnection({ port: p, host: "127.0.0.1" });
77
- const done = (ok: boolean) => {
78
- sock.destroy();
79
- resolve(ok);
80
- };
81
- sock.setTimeout(timeoutMs);
82
- sock.once("connect", () => done(true));
83
- sock.once("timeout", () => done(false));
84
- sock.once("error", () => done(false));
85
- });
86
- }
125
+ function appendLines(batch: SessionLines) {
126
+ if (!batch?.lines.length) return;
127
+ const dir = activityDir(batch.sessionId);
128
+ enqueue("agent activity", () => agentLog.appendAgentLines(dir, batch.lines));
129
+ }
87
130
 
88
- async function waitForPort(p: number, totalMs = 5000): Promise<boolean> {
89
- const start = Date.now();
90
- while (Date.now() - start < totalMs) {
91
- if (await probePort(p)) return true;
92
- await new Promise((r) => setTimeout(r, 150));
131
+ function publish(next?: typeof status) {
132
+ if (next && next !== status) {
133
+ status = next;
134
+ statusUpdatedAt = Date.now();
135
+ }
136
+ if (!session) return;
137
+ const entry = { sessionId: session.sessionId, pid: process.pid, cwd: session.cwd, sessionDir: session.sessionDir, startedAt: PROCESS_STARTED_AT, status, statusUpdatedAt, name: session.name };
138
+ const text = JSON.stringify(entry);
139
+ if (text === published) return;
140
+ published = text;
141
+ enqueue("live session", () => liveSessions.writeLiveSession(LIVE_DIR, entry));
93
142
  }
94
- return false;
95
- }
96
143
 
97
- async function api(path: string, init?: RequestInit): Promise<Response> {
98
- return fetch(`http://127.0.0.1:${port}${path}`, init);
99
- }
144
+ const g = globalThis as Record<symbol, unknown>;
145
+ if (!g[EXIT_GUARD]) {
146
+ g[EXIT_GUARD] = true;
147
+ process.once("exit", () => {
148
+ try { liveSessions.removeLiveSessionSync(LIVE_DIR, process.pid); } catch {}
149
+ });
150
+ }
151
+
152
+ pi.on("session_start", async (_event, ctx) => {
153
+ const sessionId = ctx.sessionManager.getSessionId();
154
+ if (!sessionId) return;
155
+ session = { sessionId, cwd: ctx.sessionManager.getCwd(), sessionDir: ownSessionDir(ctx.sessionManager.getSessionDir()), name: ctx.sessionManager.getSessionName() ?? null };
156
+ running = false;
157
+ openPrompts = 0;
158
+ toolsInFlight.clear();
159
+ enqueue("sweep", () => liveSessions.sweepDeadLiveSessions(LIVE_DIR));
160
+ inActivityDir("waiting", agentLog.clearWaiting);
161
+ publish("idle");
162
+ });
100
163
 
101
- async function postPin(id: string, state: "pinned" | "sticky" | "none"): Promise<Response> {
102
- return api("/api/session/pin", {
103
- method: "POST",
104
- headers: { "Content-Type": "application/json" },
105
- body: JSON.stringify({ id, state }),
164
+ pi.on("session_info_changed", async (event) => {
165
+ if (!session) return;
166
+ session.name = event.name ?? null;
167
+ publish();
106
168
  });
107
- }
108
169
 
109
- async function postSessionOpen(id: string): Promise<Response> {
110
- return api("/api/session/open", {
111
- method: "POST",
112
- headers: { "Content-Type": "application/json" },
113
- body: JSON.stringify({ id }),
170
+ // /new, /resume and /fork keep the process, and the next session_start rewrites its file.
171
+ pi.on("session_shutdown", async (event) => {
172
+ if (event.reason !== "quit" || !session) return;
173
+ session = null;
174
+ published = "";
175
+ enqueue("live session", () => liveSessions.removeLiveSession(LIVE_DIR, process.pid));
114
176
  });
115
- }
116
177
 
117
- async function postPreview(filePath: string, sessionId: string | null, link = false): Promise<Response> {
118
- return api("/api/preview", {
119
- method: "POST",
120
- headers: { "Content-Type": "application/json" },
121
- body: JSON.stringify({ path: filePath, sessionId, link }),
178
+ pi.on("agent_start", async () => {
179
+ if (!session) return;
180
+ running = true;
181
+ inActivityDir("stop marker", agentLog.clearStopped);
182
+ publish("busy");
122
183
  });
123
- }
124
184
 
125
- async function postPlan(id: string, planPath: string, title: string | null): Promise<Response> {
126
- return api("/api/session/plan", {
127
- method: "POST",
128
- headers: { "Content-Type": "application/json" },
129
- body: JSON.stringify({ id, path: planPath, title }),
185
+ pi.on("agent_settled", async (event) => {
186
+ if (!session) return;
187
+ running = false;
188
+ if (!event.aborted) inActivityDir("stop marker", agentLog.markStopped);
189
+ publish(openPrompts ? "waiting" : "idle");
130
190
  });
131
- }
132
191
 
133
- const PLAN_MAX_BYTES = 256 * 1024;
192
+ function openPrompt(prompt: { kind: string; title?: string }, tool?: { toolName: string; args: unknown }) {
193
+ if (!session) return;
194
+ openPrompts++;
195
+ const marker = agentLog.promptMarker(prompt, tool, now());
196
+ inActivityDir("waiting", (dir) => agentLog.writeWaiting(dir, marker));
197
+ publish("waiting");
198
+ }
134
199
 
135
- function extractFirstHeading(markdown: string): string | null {
136
- const m = markdown.match(/^#\s+(.+?)\s*$/m);
137
- return m ? m[1].trim() : null;
138
- }
200
+ function closePrompt() {
201
+ if (!session || openPrompts === 0 || --openPrompts > 0) return;
202
+ inActivityDir("waiting", agentLog.clearWaiting);
203
+ publish(running ? "busy" : "idle");
204
+ }
139
205
 
140
- function splitArgs(args: string): string[] {
141
- return args.trim().split(/\s+/).filter(Boolean);
142
- }
206
+ pi.on("ui_prompt_start", async (event) => openPrompt(event, [...toolsInFlight.values()].at(-1)));
207
+ pi.on("ui_prompt_end", async () => closePrompt());
143
208
 
144
- function findPidsOnPort(p: number): number[] {
145
- if (process.platform === "win32") {
146
- const r = spawnSync("netstat", ["-ano", "-p", "tcp"], { encoding: "utf8" });
147
- if (r.status !== 0) return [];
148
- const pids = new Set<number>();
149
- for (const line of r.stdout.split(/\r?\n/)) {
150
- const m = line.match(/^\s*TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)/i);
151
- if (m && Number(m[1]) === p) pids.add(Number(m[2]));
152
- }
153
- return [...pids];
154
- }
155
- const r = spawnSync("lsof", ["-tiTCP:" + p, "-sTCP:LISTEN"], { encoding: "utf8" });
156
- if (r.status !== 0) return [];
157
- return r.stdout
158
- .split(/\s+/)
159
- .map((s) => Number(s))
160
- .filter((n) => Number.isFinite(n) && n > 0);
161
- }
209
+ pi.events.on(ASK_OPEN, (data) => {
210
+ const { title, args } = data as AskOpen;
211
+ openPrompt({ kind: "input", title }, { toolName: ASK_TOOL, args });
212
+ });
213
+ pi.events.on(ASK_CLOSE, () => closePrompt());
162
214
 
163
- function killPid(pid: number): void {
164
- if (process.platform === "win32") {
165
- spawnSync("taskkill", ["/F", "/PID", String(pid)], { stdio: "ignore" });
166
- } else {
167
- try {
168
- process.kill(pid, "SIGKILL");
169
- } catch {}
170
- }
215
+ pi.on("tool_execution_start", async (event) => {
216
+ toolsInFlight.set(event.toolCallId, { toolName: event.toolName, args: event.args });
217
+ if (!session || event.toolName !== "subagent") return;
218
+ const { sessionId } = session;
219
+ appendLines({ sessionId, lines: subagents.start(sessionId, event.toolCallId, event.args, now()) });
220
+ });
221
+
222
+ pi.on("tool_execution_update", async (event) => {
223
+ appendLines(subagents.update(event.toolCallId, event.partialResult, now()));
224
+ });
225
+
226
+ pi.on("tool_execution_end", async (event) => {
227
+ toolsInFlight.delete(event.toolCallId);
228
+ appendLines(subagents.end(event.toolCallId, event.result, event.isError, now()));
229
+ });
230
+
231
+ // pi-subagents emits this in the parent process when an async run's result file appears.
232
+ pi.events.on("subagent:async-complete", (data: unknown) => {
233
+ enqueue("agent activity", async () => {
234
+ const batch = await subagents.asyncComplete(data, now());
235
+ if (batch?.lines.length) await agentLog.appendAgentLines(activityDir(batch.sessionId), batch.lines);
236
+ });
237
+ });
171
238
  }
172
239
 
173
- async function ensureRunning(notify: (msg: string, level: "info" | "error") => void): Promise<boolean> {
174
- if (await probePort(port)) return true;
175
- notify(`pi-kanban not running. Start it with /kanban start`, "error");
176
- return false;
240
+ function registerModelWindows(pi: ExtensionAPI) {
241
+ const record = (ctx: ExtensionContext) => {
242
+ modelWindows.recordModelWindows(MODEL_WINDOWS_FILE, ctx.modelRegistry.getAvailable())
243
+ .catch((e: any) => console.warn(`pi-kanban: model windows failed: ${e?.message ?? e}`));
244
+ };
245
+ pi.on("session_start", async (_event, ctx) => record(ctx));
246
+ pi.on("model_select", async (_event, ctx) => record(ctx));
177
247
  }
178
248
 
179
249
  export default function kanbanExtension(pi: ExtensionAPI) {
180
- const serverPath = resolvePath(extDir, "..", "server.js");
181
- const url = `http://localhost:${port}`;
250
+ addCliToPath();
251
+ registerLiveState(pi);
252
+ registerModelWindows(pi);
253
+ registerScratchpad(pi);
254
+ registerAsk(pi);
255
+ registerDispatch(pi);
256
+ registerInbox(pi);
182
257
 
183
258
  pi.on("tool_result", async (event, ctx) => {
184
259
  if (event.toolName !== "todo") return;
@@ -186,8 +261,7 @@ export default function kanbanExtension(pi: ExtensionAPI) {
186
261
  const tasks = (event.details as any)?.tasks;
187
262
  if (sessionId && Array.isArray(tasks)) {
188
263
  try {
189
- taskStore.reconcileFromSnapshot(sessionId, tasks);
190
- taskStore.cleanupIfAllCompleted(sessionId);
264
+ await taskStore.reconcileFromSnapshot(sessionId, tasks);
191
265
  } catch (e: any) { console.warn(`pi-kanban: reconcile failed: ${e?.message ?? e}`); }
192
266
  }
193
267
  });
@@ -195,6 +269,7 @@ export default function kanbanExtension(pi: ExtensionAPI) {
195
269
  pi.on("session_start", async (_event, ctx) => {
196
270
  const sessionId = ctx.sessionManager.getSessionId();
197
271
  if (!sessionId) return;
272
+ process.env[SESSION_ID_ENV] = sessionId;
198
273
  const branch = ctx.sessionManager.getBranch();
199
274
  for (let i = branch.length - 1; i >= 0; i--) {
200
275
  const entry = branch[i];
@@ -204,262 +279,150 @@ export default function kanbanExtension(pi: ExtensionAPI) {
204
279
  const tasks = msg?.details?.tasks;
205
280
  if (!Array.isArray(tasks)) continue;
206
281
  try {
207
- taskStore.reconcileFromSnapshot(sessionId, tasks);
208
- taskStore.cleanupIfAllCompleted(sessionId);
282
+ await taskStore.reconcileFromSnapshot(sessionId, tasks);
209
283
  } catch (e: any) { console.warn(`pi-kanban: backfill failed: ${e?.message ?? e}`); }
210
284
  return;
211
285
  }
212
286
  });
213
287
 
214
- pi.registerTool({
215
- name: "bind_plan",
216
- label: "Bind Plan",
217
- description:
218
- "Attach a markdown plan file (e.g. plan.md) to the current session so the dashboard can render it.",
219
- promptSnippet:
220
- "Bind a markdown plan file to the current session — call after writing plan file",
221
- promptGuidelines: [
222
- "Use bind_plan once a planning markdown file exists so it surfaces in pi-kanban.",
223
- ],
224
- parameters: Type.Object({
225
- path: Type.String({
226
- description: "Path to the plan markdown file. Absolute, or relative to the session cwd.",
227
- }),
228
- }),
229
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
230
- const sessionId = ctx?.sessionManager?.getSessionId?.() ?? null;
231
- const cwd = ctx?.sessionManager?.getCwd?.() ?? process.cwd();
232
- const inputPath = String(params.path ?? "").trim();
233
- if (!inputPath) {
234
- return { content: [{ type: "text", text: "Error: path is required." }], isError: true };
235
- }
236
- const absPath = isAbsolute(inputPath) ? inputPath : resolvePath(cwd, inputPath);
237
-
238
- let title: string | null = null;
239
- try {
240
- const stat = statSync(absPath);
241
- if (!stat.isFile()) {
242
- return { content: [{ type: "text", text: `Error: ${absPath} is not a file.` }], isError: true };
243
- }
244
- const buf = readFileSync(absPath, { encoding: "utf8" });
245
- const head = buf.slice(0, PLAN_MAX_BYTES);
246
- title = extractFirstHeading(head);
247
- } catch (e: any) {
248
- return {
249
- content: [{ type: "text", text: `Error reading ${absPath}: ${e?.message ?? e}` }],
250
- isError: true,
251
- };
252
- }
253
-
254
- if (!sessionId) {
255
- return {
256
- content: [{ type: "text", text: "Error: no active session id; cannot bind plan." }],
257
- isError: true,
258
- };
259
- }
260
- try {
261
- const res = await postPlan(sessionId, absPath, title);
262
- if (!res.ok) {
263
- const msg = `pi-kanban server rejected bind_plan: HTTP ${res.status}. Is /kanban running?`;
264
- console.warn(msg);
265
- return { content: [{ type: "text", text: msg }], isError: true };
266
- }
267
- } catch (e: any) {
268
- const msg = `pi-kanban: bind_plan POST failed (server may be stopped): ${e?.message ?? e}`;
269
- console.warn(msg);
270
- return { content: [{ type: "text", text: msg }], isError: true };
271
- }
272
-
273
- const summary = title ? `Bound plan "${title}" → ${absPath}` : `Bound plan → ${absPath}`;
274
- return {
275
- content: [{ type: "text", text: summary }],
276
- details: { path: absPath, title, sessionId },
277
- };
278
- },
279
- });
280
-
281
- type Notify = (m: string, l?: "info" | "error") => void;
282
-
283
- async function startServer(notify: Notify, opts: { silentSuccess?: boolean } = {}): Promise<boolean> {
284
- if (await probePort(port)) {
285
- notify(`pi-kanban already listening on ${url} — run /kanban open or /kanban web`);
286
- return true;
287
- }
288
- lastStderr = "";
289
- child = spawn(process.execPath, [serverPath], {
290
- env: buildServerEnv(),
291
- stdio: ["ignore", "ignore", "pipe"],
292
- detached: true,
293
- windowsHide: true,
294
- });
295
- child.stderr?.on("data", (b) => {
296
- lastStderr += b.toString();
297
- });
298
- child.on("exit", () => {
299
- child = null;
300
- });
301
-
302
- if (!(await waitForPort(port))) {
303
- notify(
304
- `pi-kanban failed to start.\n${lastStderr.slice(-500) || "(no stderr)"}`,
305
- "error",
306
- );
307
- return false;
308
- }
309
- // Detach: stop holding the child to the parent's lifetime so /kanban survives
310
- // when the pi process that ran /kanban start exits.
311
- child.stderr?.removeAllListeners("data");
312
- child.stderr?.resume();
313
- child.unref();
314
- if (!opts.silentSuccess) {
315
- notify(`pi-kanban started → ${url} — run /kanban open or /kanban web to launch it`);
316
- }
317
- return true;
318
- }
319
-
320
- async function stopServer(notify: Notify, opts: { silentSuccess?: boolean } = {}): Promise<boolean> {
321
- if (child) child.kill("SIGINT");
322
- child = null;
323
-
324
- if (await probePort(port)) {
325
- const pids = findPidsOnPort(port);
326
- for (const pid of pids) killPid(pid);
327
- await new Promise((r) => setTimeout(r, 300));
328
- const stillUp = await probePort(port);
329
- if (stillUp) {
330
- notify(
331
- `port ${port} still in use after killing pids ${pids.join(",") || "?"}`,
332
- "error",
333
- );
334
- return false;
335
- }
336
- if (!opts.silentSuccess) {
337
- notify(`pi-kanban stopped (killed orphan pid${pids.length > 1 ? "s" : ""} ${pids.join(",")})`);
338
- }
339
- return true;
340
- }
341
- if (!opts.silentSuccess) notify("pi-kanban stopped");
342
- return true;
343
- }
344
-
345
288
  pi.registerCommand("kanban", {
346
- description:
347
- "pi-kanban dashboard: start | stop | restart | status | open | web | app | pin | sticky-pin | unpin | preview | link",
348
- getArgumentCompletions: (prefix) =>
349
- SUBCOMMANDS.filter((s) => s.startsWith(prefix)).map((s) => ({ value: s, label: s })),
289
+ description: `pi-kanban board: ${CAN_OPEN ? "open | " : ""}session <verb> | ${COMPANION_VERBS.join(" | ")}`,
290
+ getArgumentCompletions: completionsFor,
350
291
  handler: async (args, ctx) => {
351
292
  const tokens = splitArgs(args);
352
- const sub = (tokens[0] || "start") as Sub;
293
+ const sub = tokens[0] || (CAN_OPEN ? "open" : "help");
353
294
  const rest = tokens.slice(1);
354
295
  const notify = (m: string, l: "info" | "error" = "info") => ctx.ui.notify(m, l);
355
296
 
356
- if (sub === "start") {
357
- await startServer(notify);
358
- return;
359
- }
360
-
361
- if (sub === "stop") {
362
- await stopServer(notify);
363
- return;
364
- }
365
-
366
- if (sub === "restart") {
367
- if (!(await stopServer(notify, { silentSuccess: true }))) return;
368
- if (!(await startServer(notify, { silentSuccess: true }))) return;
369
- notify(`pi-kanban restarted → ${url}`);
370
- return;
371
- }
372
-
373
- if (sub === "status") {
374
- const up = await probePort(port);
375
- const owned = child ? ` (pid ${child.pid})` : " (external)";
376
- notify(up ? `running on ${url}${owned}` : "not running");
377
- return;
297
+ function showHelp() {
298
+ const lines = [
299
+ "Usage: /kanban <command>",
300
+ "",
301
+ ...(CAN_OPEN ? [" open Open the board in the browser, with the terminal token", ""] : []),
302
+ "Session commands (default to current session):",
303
+ " session open [<id>]",
304
+ " session pin [<id>]",
305
+ " session sticky-pin [<id>]",
306
+ " session unpin [<id>]",
307
+ " session view-doc <file> [<id>]",
308
+ " session link-doc <file> [<id>]",
309
+ ...PANE_COMPANIONS.map((c) => ` ${`${c.verb} [<id>]`.padEnd(17)} Open ${c.id}: ${c.shows} (also session ${c.verb})`),
310
+ ];
311
+ notify(lines.join("\n"));
378
312
  }
379
313
 
380
- if (sub === "open") {
381
- if (!(await ensureRunning(notify))) return;
382
- const id = rest[0] ?? ctx.sessionManager.getSessionId() ?? null;
314
+ // In the board terminal the board is on screen, so the pane opens there; elsewhere it waits as a tab.
315
+ async function companionCommand({ verb, id: companion }: { verb: string; id: string }, idArg: string | undefined) {
316
+ const id = idArg ?? ctx.sessionManager.getSessionId();
383
317
  if (!id) {
384
- notify("Usage: /kanban open <session-id> (no current session to default to)", "error");
318
+ notify(`Usage: /kanban ${verb} <session-id> (no current session to default to)`, "error");
385
319
  return;
386
320
  }
387
- const res = await postSessionOpen(id);
388
- if (!res.ok) {
389
- notify(`open failed (${res.status}): ${await res.text()}`, "error");
390
- return;
321
+ try {
322
+ const { pane, added } = await openCompanion(id, companion, boardTerminalId() ?? undefined);
323
+ notify(
324
+ CAN_OPEN
325
+ ? `${added ? "Added" : "Already there"}: ${pane.id} ${pane.title}. Open its tab on the board.`
326
+ : `Opened ${pane.title} on the board.`,
327
+ );
328
+ } catch (e: any) {
329
+ notify(`${verb} failed: ${e?.message ?? e}`, "error");
391
330
  }
392
- const usedCurrent = !rest[0];
393
- notify(`opened: ${id}${usedCurrent ? " (current)" : ""}`);
394
- return;
395
331
  }
396
332
 
397
- if (sub === "web") {
398
- const { default: open } = await import("open");
399
- await open(url);
333
+ const subCompanion = companionOf(sub);
334
+ if (subCompanion) {
335
+ await companionCommand(subCompanion, rest[0]);
400
336
  return;
401
337
  }
402
338
 
403
- if (sub === "app") {
404
- const { default: open, apps } = await import("open");
405
- for (const name of [apps.chrome, apps.edge, apps.browser]) {
406
- try {
407
- await open(url, { app: { name, arguments: [`--app=${url}`] } });
408
- return;
409
- } catch {}
339
+ if (sub === "open" && CAN_OPEN) {
340
+ try {
341
+ const { default: open } = await import("open");
342
+ await open(await boardPageUrl());
343
+ } catch (e: any) {
344
+ notify(`open failed: ${e?.message ?? e}`, "error");
410
345
  }
411
- notify("Could not find Chrome/Edge for PWA window mode", "error");
412
346
  return;
413
347
  }
414
348
 
415
- if (sub === "pin" || sub === "sticky-pin" || sub === "unpin") {
416
- if (!(await ensureRunning(notify))) return;
417
- const id = rest[0] ?? ctx.sessionManager.getSessionId() ?? null;
418
- if (!id) {
419
- notify(`Usage: /kanban ${sub} <session-id> (no current session to default to)`, "error");
349
+ if (sub === "session") {
350
+ const verb = rest[0];
351
+ const verbRest = rest.slice(1);
352
+
353
+ if (!verb) {
354
+ showHelp();
420
355
  return;
421
356
  }
422
- const PIN_STATE = { "sticky-pin": "sticky", "unpin": "none", "pin": "pinned" } as const;
423
- const state = PIN_STATE[sub];
424
- const res = await postPin(id, state);
425
- if (!res.ok) {
426
- notify(`${sub} failed (${res.status}): ${await res.text()}`, "error");
357
+
358
+ if (!SESSION_SUBCOMMANDS.includes(verb)) {
359
+ notify(`Unknown session verb: ${verb}. Try: ${SESSION_SUBCOMMANDS.join(", ")}`, "error");
427
360
  return;
428
361
  }
429
- const usedCurrent = !rest[0];
430
- const verb = sub === "unpin" ? "unpinned" : state;
431
- notify(`${verb}: ${id}${usedCurrent ? " (current)" : ""}`);
432
- return;
433
- }
434
362
 
435
- if (sub === "preview" || sub === "link") {
436
- if (!(await ensureRunning(notify))) return;
437
- const file = rest[0];
438
- if (!file) {
439
- notify(`Usage: /kanban ${sub} <file.md> [session-id]`, "error");
363
+ if (verb === "open") {
364
+ const id = verbRest[0] ?? ctx.sessionManager.getSessionId() ?? null;
365
+ if (!id) {
366
+ notify("Usage: /kanban session open <session-id> (no current session to default to)", "error");
367
+ return;
368
+ }
369
+ try {
370
+ await okJson(postJson("/api/session/open", { id }));
371
+ } catch (e: any) {
372
+ notify(`open failed: ${e?.message ?? e}`, "error");
373
+ return;
374
+ }
375
+ notify(`opened: ${id}${!verbRest[0] ? " (current)" : ""}`);
440
376
  return;
441
377
  }
442
- const sessionId = rest[1] ?? ctx.sessionManager.getSessionId() ?? null;
443
- if (sub === "link" && !sessionId) {
444
- notify("Usage: /kanban link <file.md> <session-id> (no current session to default to)", "error");
378
+
379
+ if (verb === "pin" || verb === "sticky-pin" || verb === "unpin") {
380
+ const id = verbRest[0] ?? ctx.sessionManager.getSessionId() ?? null;
381
+ if (!id) {
382
+ notify(`Usage: /kanban session ${verb} <session-id> (no current session to default to)`, "error");
383
+ return;
384
+ }
385
+ const PIN_STATE = { "sticky-pin": "sticky", "unpin": "none", "pin": "pinned" } as const;
386
+ const state = PIN_STATE[verb];
387
+ try {
388
+ await okJson(postJson("/api/session/pin", { ids: [id], state }));
389
+ } catch (e: any) {
390
+ notify(`${verb} failed: ${e?.message ?? e}`, "error");
391
+ return;
392
+ }
393
+ const pinVerb = verb === "unpin" ? "unpinned" : state;
394
+ notify(`${pinVerb}: ${id}${!verbRest[0] ? " (current)" : ""}`);
445
395
  return;
446
396
  }
447
- const expanded =
448
- file === "~" || file.startsWith("~/") || file.startsWith("~\\")
449
- ? joinPath(homedir(), file.slice(1))
450
- : file;
451
- const abs = isAbsolute(expanded) ? expanded : resolvePath(process.cwd(), expanded);
452
- const res = await postPreview(abs, sessionId, sub === "link");
453
- if (!res.ok) {
454
- notify(`${sub} failed (${res.status}): ${await res.text()}`, "error");
397
+
398
+ if (verb === "view-doc" || verb === "link-doc") {
399
+ const file = verbRest[0];
400
+ if (!file) {
401
+ notify(`Usage: /kanban session ${verb} <file.md> [session-id]`, "error");
402
+ return;
403
+ }
404
+ const sessionId = verbRest[1] ?? ctx.sessionManager.getSessionId() ?? null;
405
+ if (verb === "link-doc" && !sessionId) {
406
+ notify("Usage: /kanban session link-doc <file.md> <session-id> (no current session to default to)", "error");
407
+ return;
408
+ }
409
+ try {
410
+ const target = resolveTarget(ctx.cwd, file);
411
+ notify(verb === "link-doc" ? await linkDoc(sessionId!, target) : await previewDoc(target, sessionId));
412
+ } catch (e: any) {
413
+ notify(`${verb} failed: ${e?.message ?? e}`, "error");
414
+ }
415
+ return;
416
+ }
417
+
418
+ const verbCompanion = companionOf(verb);
419
+ if (verbCompanion) {
420
+ await companionCommand(verbCompanion, verbRest[0]);
455
421
  return;
456
422
  }
457
- const usedCurrent = !rest[1] && sessionId;
458
- notify(`${sub}: ${abs}${sessionId ? ` → session ${sessionId}${usedCurrent ? " (current)" : ""}` : ""}`);
459
- return;
460
423
  }
461
424
 
462
- notify(`Unknown subcommand: ${sub}. Try: ${SUBCOMMANDS.join(", ")}`, "error");
425
+ showHelp();
463
426
  },
464
427
  });
465
428
  }