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