pi-kanban 0.2.0 → 0.3.1
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 +51 -46
- package/extensions/kanban.ts +465 -465
- package/lib/git-branch.js +112 -112
- package/lib/pi-parsers.js +1000 -901
- package/lib/task-store.js +152 -136
- package/package.json +66 -59
- package/public/app.js +5859 -5750
- package/public/icons/icon-svg.svg +6 -6
- package/public/index.html +674 -674
- package/public/manifest.json +26 -26
- package/public/style.css +3760 -3616
- package/public/sw.js +60 -60
- package/server.js +602 -491
- package/themes/kanban-dark-default.json +21 -21
- package/themes/kanban-default.json +21 -21
- package/themes/pi-dark.json +21 -21
- package/themes/pi-light.json +21 -21
package/extensions/kanban.ts
CHANGED
|
@@ -1,465 +1,465 @@
|
|
|
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;
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
let child: ChildProcess | null = null;
|
|
20
|
-
let lastStderr = "";
|
|
21
|
-
const port = 3460;
|
|
22
|
-
|
|
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");
|
|
26
|
-
|
|
27
|
-
type KanbanSettings = {
|
|
28
|
-
themes?: {
|
|
29
|
-
light?: string;
|
|
30
|
-
dark?: string;
|
|
31
|
-
dir?: string;
|
|
32
|
-
};
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
function readSettings(): KanbanSettings {
|
|
36
|
-
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 {};
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
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;
|
|
51
|
-
}
|
|
52
|
-
if (process.env.KANBAN_DARK_THEME ?? t.dark) {
|
|
53
|
-
env.KANBAN_DARK_THEME = process.env.KANBAN_DARK_THEME ?? t.dark;
|
|
54
|
-
}
|
|
55
|
-
return env;
|
|
56
|
-
}
|
|
57
|
-
|
|
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
|
-
}
|
|
87
|
-
|
|
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));
|
|
93
|
-
}
|
|
94
|
-
return false;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
async function api(path: string, init?: RequestInit): Promise<Response> {
|
|
98
|
-
return fetch(`http://127.0.0.1:${port}${path}`, init);
|
|
99
|
-
}
|
|
100
|
-
|
|
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 }),
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
|
|
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 }),
|
|
114
|
-
});
|
|
115
|
-
}
|
|
116
|
-
|
|
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 }),
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
|
|
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 }),
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const PLAN_MAX_BYTES = 256 * 1024;
|
|
134
|
-
|
|
135
|
-
function extractFirstHeading(markdown: string): string | null {
|
|
136
|
-
const m = markdown.match(/^#\s+(.+?)\s*$/m);
|
|
137
|
-
return m ? m[1].trim() : null;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function splitArgs(args: string): string[] {
|
|
141
|
-
return args.trim().split(/\s+/).filter(Boolean);
|
|
142
|
-
}
|
|
143
|
-
|
|
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
|
-
}
|
|
162
|
-
|
|
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
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
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;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
export default function kanbanExtension(pi: ExtensionAPI) {
|
|
180
|
-
const serverPath = resolvePath(extDir, "..", "server.js");
|
|
181
|
-
const url = `http://localhost:${port}`;
|
|
182
|
-
|
|
183
|
-
pi.on("tool_result", async (event, ctx) => {
|
|
184
|
-
if (event.toolName !== "todo") return;
|
|
185
|
-
const sessionId = ctx.sessionManager.getSessionId();
|
|
186
|
-
const tasks = (event.details as any)?.tasks;
|
|
187
|
-
if (sessionId && Array.isArray(tasks)) {
|
|
188
|
-
try {
|
|
189
|
-
taskStore.reconcileFromSnapshot(sessionId, tasks);
|
|
190
|
-
taskStore.cleanupIfAllCompleted(sessionId);
|
|
191
|
-
} catch (e: any) { console.warn(`pi-kanban: reconcile failed: ${e?.message ?? e}`); }
|
|
192
|
-
}
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
pi.on("session_start", async (_event, ctx) => {
|
|
196
|
-
const sessionId = ctx.sessionManager.getSessionId();
|
|
197
|
-
if (!sessionId) return;
|
|
198
|
-
const branch = ctx.sessionManager.getBranch();
|
|
199
|
-
for (let i = branch.length - 1; i >= 0; i--) {
|
|
200
|
-
const entry = branch[i];
|
|
201
|
-
if (entry.type !== "message") continue;
|
|
202
|
-
const msg: any = (entry as any).message;
|
|
203
|
-
if (msg?.role !== "toolResult" || msg?.toolName !== "todo") continue;
|
|
204
|
-
const tasks = msg?.details?.tasks;
|
|
205
|
-
if (!Array.isArray(tasks)) continue;
|
|
206
|
-
try {
|
|
207
|
-
taskStore.reconcileFromSnapshot(sessionId, tasks);
|
|
208
|
-
taskStore.cleanupIfAllCompleted(sessionId);
|
|
209
|
-
} catch (e: any) { console.warn(`pi-kanban: backfill failed: ${e?.message ?? e}`); }
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
});
|
|
213
|
-
|
|
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
|
-
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 })),
|
|
350
|
-
handler: async (args, ctx) => {
|
|
351
|
-
const tokens = splitArgs(args);
|
|
352
|
-
const sub = (tokens[0] || "start") as Sub;
|
|
353
|
-
const rest = tokens.slice(1);
|
|
354
|
-
const notify = (m: string, l: "info" | "error" = "info") => ctx.ui.notify(m, l);
|
|
355
|
-
|
|
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;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
if (sub === "open") {
|
|
381
|
-
if (!(await ensureRunning(notify))) return;
|
|
382
|
-
const id = rest[0] ?? ctx.sessionManager.getSessionId() ?? null;
|
|
383
|
-
if (!id) {
|
|
384
|
-
notify("Usage: /kanban open <session-id> (no current session to default to)", "error");
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
387
|
-
const res = await postSessionOpen(id);
|
|
388
|
-
if (!res.ok) {
|
|
389
|
-
notify(`open failed (${res.status}): ${await res.text()}`, "error");
|
|
390
|
-
return;
|
|
391
|
-
}
|
|
392
|
-
const usedCurrent = !rest[0];
|
|
393
|
-
notify(`opened: ${id}${usedCurrent ? " (current)" : ""}`);
|
|
394
|
-
return;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
if (sub === "web") {
|
|
398
|
-
const { default: open } = await import("open");
|
|
399
|
-
await open(url);
|
|
400
|
-
return;
|
|
401
|
-
}
|
|
402
|
-
|
|
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 {}
|
|
410
|
-
}
|
|
411
|
-
notify("Could not find Chrome/Edge for PWA window mode", "error");
|
|
412
|
-
return;
|
|
413
|
-
}
|
|
414
|
-
|
|
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");
|
|
420
|
-
return;
|
|
421
|
-
}
|
|
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");
|
|
427
|
-
return;
|
|
428
|
-
}
|
|
429
|
-
const usedCurrent = !rest[0];
|
|
430
|
-
const verb = sub === "unpin" ? "unpinned" : state;
|
|
431
|
-
notify(`${verb}: ${id}${usedCurrent ? " (current)" : ""}`);
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
|
-
|
|
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");
|
|
440
|
-
return;
|
|
441
|
-
}
|
|
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");
|
|
445
|
-
return;
|
|
446
|
-
}
|
|
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");
|
|
455
|
-
return;
|
|
456
|
-
}
|
|
457
|
-
const usedCurrent = !rest[1] && sessionId;
|
|
458
|
-
notify(`${sub}: ${abs}${sessionId ? ` → session ${sessionId}${usedCurrent ? " (current)" : ""}` : ""}`);
|
|
459
|
-
return;
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
notify(`Unknown subcommand: ${sub}. Try: ${SUBCOMMANDS.join(", ")}`, "error");
|
|
463
|
-
},
|
|
464
|
-
});
|
|
465
|
-
}
|
|
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;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
let child: ChildProcess | null = null;
|
|
20
|
+
let lastStderr = "";
|
|
21
|
+
const port = 3460;
|
|
22
|
+
|
|
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");
|
|
26
|
+
|
|
27
|
+
type KanbanSettings = {
|
|
28
|
+
themes?: {
|
|
29
|
+
light?: string;
|
|
30
|
+
dark?: string;
|
|
31
|
+
dir?: string;
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function readSettings(): KanbanSettings {
|
|
36
|
+
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 {};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
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;
|
|
51
|
+
}
|
|
52
|
+
if (process.env.KANBAN_DARK_THEME ?? t.dark) {
|
|
53
|
+
env.KANBAN_DARK_THEME = process.env.KANBAN_DARK_THEME ?? t.dark;
|
|
54
|
+
}
|
|
55
|
+
return env;
|
|
56
|
+
}
|
|
57
|
+
|
|
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
|
+
}
|
|
87
|
+
|
|
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));
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function api(path: string, init?: RequestInit): Promise<Response> {
|
|
98
|
+
return fetch(`http://127.0.0.1:${port}${path}`, init);
|
|
99
|
+
}
|
|
100
|
+
|
|
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 }),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
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 }),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
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 }),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
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 }),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const PLAN_MAX_BYTES = 256 * 1024;
|
|
134
|
+
|
|
135
|
+
function extractFirstHeading(markdown: string): string | null {
|
|
136
|
+
const m = markdown.match(/^#\s+(.+?)\s*$/m);
|
|
137
|
+
return m ? m[1].trim() : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function splitArgs(args: string): string[] {
|
|
141
|
+
return args.trim().split(/\s+/).filter(Boolean);
|
|
142
|
+
}
|
|
143
|
+
|
|
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
|
+
}
|
|
162
|
+
|
|
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
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
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;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export default function kanbanExtension(pi: ExtensionAPI) {
|
|
180
|
+
const serverPath = resolvePath(extDir, "..", "server.js");
|
|
181
|
+
const url = `http://localhost:${port}`;
|
|
182
|
+
|
|
183
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
184
|
+
if (event.toolName !== "todo") return;
|
|
185
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
186
|
+
const tasks = (event.details as any)?.tasks;
|
|
187
|
+
if (sessionId && Array.isArray(tasks)) {
|
|
188
|
+
try {
|
|
189
|
+
taskStore.reconcileFromSnapshot(sessionId, tasks);
|
|
190
|
+
taskStore.cleanupIfAllCompleted(sessionId);
|
|
191
|
+
} catch (e: any) { console.warn(`pi-kanban: reconcile failed: ${e?.message ?? e}`); }
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
196
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
197
|
+
if (!sessionId) return;
|
|
198
|
+
const branch = ctx.sessionManager.getBranch();
|
|
199
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
200
|
+
const entry = branch[i];
|
|
201
|
+
if (entry.type !== "message") continue;
|
|
202
|
+
const msg: any = (entry as any).message;
|
|
203
|
+
if (msg?.role !== "toolResult" || msg?.toolName !== "todo") continue;
|
|
204
|
+
const tasks = msg?.details?.tasks;
|
|
205
|
+
if (!Array.isArray(tasks)) continue;
|
|
206
|
+
try {
|
|
207
|
+
taskStore.reconcileFromSnapshot(sessionId, tasks);
|
|
208
|
+
taskStore.cleanupIfAllCompleted(sessionId);
|
|
209
|
+
} catch (e: any) { console.warn(`pi-kanban: backfill failed: ${e?.message ?? e}`); }
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
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
|
+
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 })),
|
|
350
|
+
handler: async (args, ctx) => {
|
|
351
|
+
const tokens = splitArgs(args);
|
|
352
|
+
const sub = (tokens[0] || "start") as Sub;
|
|
353
|
+
const rest = tokens.slice(1);
|
|
354
|
+
const notify = (m: string, l: "info" | "error" = "info") => ctx.ui.notify(m, l);
|
|
355
|
+
|
|
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;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (sub === "open") {
|
|
381
|
+
if (!(await ensureRunning(notify))) return;
|
|
382
|
+
const id = rest[0] ?? ctx.sessionManager.getSessionId() ?? null;
|
|
383
|
+
if (!id) {
|
|
384
|
+
notify("Usage: /kanban open <session-id> (no current session to default to)", "error");
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const res = await postSessionOpen(id);
|
|
388
|
+
if (!res.ok) {
|
|
389
|
+
notify(`open failed (${res.status}): ${await res.text()}`, "error");
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const usedCurrent = !rest[0];
|
|
393
|
+
notify(`opened: ${id}${usedCurrent ? " (current)" : ""}`);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (sub === "web") {
|
|
398
|
+
const { default: open } = await import("open");
|
|
399
|
+
await open(url);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
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 {}
|
|
410
|
+
}
|
|
411
|
+
notify("Could not find Chrome/Edge for PWA window mode", "error");
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
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");
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
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");
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
const usedCurrent = !rest[0];
|
|
430
|
+
const verb = sub === "unpin" ? "unpinned" : state;
|
|
431
|
+
notify(`${verb}: ${id}${usedCurrent ? " (current)" : ""}`);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
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");
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
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");
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
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");
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
const usedCurrent = !rest[1] && sessionId;
|
|
458
|
+
notify(`${sub}: ${abs}${sessionId ? ` → session ${sessionId}${usedCurrent ? " (current)" : ""}` : ""}`);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
notify(`Unknown subcommand: ${sub}. Try: ${SUBCOMMANDS.join(", ")}`, "error");
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
}
|