@pi-unipi/kanboard 2.20.4 → 3.0.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +184 -66
- package/index.ts +206 -63
- package/package.json +18 -23
- package/skills/kanboard/SKILL.md +106 -0
- package/src/bin.ts +210 -0
- package/src/commands.ts +1371 -0
- package/src/guard.ts +143 -0
- package/src/runner.ts +817 -0
- package/src/settings.ts +158 -0
- package/src/shapes.ts +220 -0
- package/commands.ts +0 -83
- package/parser/checkbox-parser.ts +0 -165
- package/parser/frontmatter.ts +0 -26
- package/parser/index.ts +0 -94
- package/parser/milestones.ts +0 -110
- package/parser/plans.ts +0 -111
- package/server/index.ts +0 -266
- package/server/routes/milestone.ts +0 -38
- package/server/routes/workflow.ts +0 -41
- package/skills/kanboard-doctor/SKILL.md +0 -71
- package/types.ts +0 -67
- package/ui/components/checklist.ts +0 -58
- package/ui/components/copy-button.ts +0 -23
- package/ui/components/status-badge.ts +0 -21
- package/ui/layouts/base.ts +0 -68
- package/ui/milestone/page.ts +0 -140
- package/ui/static/app.js +0 -73
- package/ui/static/style.css +0 -729
- package/ui/workflow/page.ts +0 -182
package/src/commands.ts
ADDED
|
@@ -0,0 +1,1371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/kanboard — `/unipi:kanboard` (help/open/close/onboard/status/doctor),
|
|
3
|
+
* `/unipi:kanboard-add`, `/unipi:kanboard-do`, `/unipi:kanboard-autowork`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execFile, spawn } from "node:child_process";
|
|
7
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { homedir, hostname as osHostname, networkInterfaces, tmpdir, type NetworkInterfaceInfo } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
|
|
12
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { getSettings } from "@pi-unipi/core";
|
|
14
|
+
|
|
15
|
+
import { KanboardCliError, type KanboardCli } from "./bin.js";
|
|
16
|
+
import { asProject, asStopResult, asTask, asTaskList, type KanboardTask } from "./shapes.js";
|
|
17
|
+
import { applyLimitEnv, readKanboardSettings, type KanboardSettings } from "./settings.js";
|
|
18
|
+
|
|
19
|
+
export const KANBOARD_COMMAND = "kanboard";
|
|
20
|
+
export const SUBCOMMANDS = ["open", "close", "onboard", "status", "doctor", "show"] as const;
|
|
21
|
+
export type Subcommand = (typeof SUBCOMMANDS)[number];
|
|
22
|
+
|
|
23
|
+
/** Custom message types that are display-only and never enter the LLM context. */
|
|
24
|
+
export const HELP_CUSTOM_TYPE = "unipi:kanboard-help";
|
|
25
|
+
export const DOCTOR_CUSTOM_TYPE = "unipi:kanboard-doctor";
|
|
26
|
+
export const SHOW_CUSTOM_TYPE = "unipi:kanboard-show";
|
|
27
|
+
|
|
28
|
+
export const HELP = `Kanboard commands
|
|
29
|
+
/unipi:kanboard this list
|
|
30
|
+
/unipi:kanboard open [--host 127.0.0.1|0.0.0.0|tailscale] [--port N]
|
|
31
|
+
start the board and print its link
|
|
32
|
+
/unipi:kanboard close shut the board down
|
|
33
|
+
/unipi:kanboard onboard register this project
|
|
34
|
+
/unipi:kanboard status board, claims and queue
|
|
35
|
+
/unipi:kanboard show [--all] the board, in chat (lanes + claim order)
|
|
36
|
+
/unipi:kanboard doctor check the setup
|
|
37
|
+
/unipi:kanboard-add [-p 1-5] <title> add a task; lines below the title are the description (paste images there)
|
|
38
|
+
/unipi:kanboard-do <request> let the agent use the board for this turn
|
|
39
|
+
/unipi:kanboard-autowork start|stop work ready tasks chain by chain; stop finishes the current one first
|
|
40
|
+
Priority -p: 1 none · 2 low · 3 medium · 4 high · 5 urgent`;
|
|
41
|
+
|
|
42
|
+
export function doText(slug: string, cli: string, request: string, queueMax = 10): string {
|
|
43
|
+
const limit = queueMax === 0 ? "Todo tasks" : `Todo tasks, at most ${queueMax}`;
|
|
44
|
+
return `[kanboard] For this request you may use the kanboard skill on project ${slug} (CLI: \`${cli} --actor agent --project ${slug} …\`). Board writes are allowed until this turn ends. You can add tasks, move them between backlog and todo, link, order, note, and edit tasks you created. To have tasks worked, queue them in order with \`queue <IDs>\` (${limit}); the runner starts them one by one after this turn, so do not start the work yourself. If the request is unclear, ask me instead of guessing.
|
|
45
|
+
|
|
46
|
+
Request: ${request}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Split a command line into words the way a shell would: whitespace separates,
|
|
51
|
+
* a token may start with a quoted span, and a quote inside a word is literal
|
|
52
|
+
* (so `it's fine` keeps its apostrophe).
|
|
53
|
+
*/
|
|
54
|
+
export function tokenizeArgs(text: string): string[] {
|
|
55
|
+
const words: string[] = [];
|
|
56
|
+
let current = "";
|
|
57
|
+
let started = false;
|
|
58
|
+
let index = 0;
|
|
59
|
+
while (index < text.length) {
|
|
60
|
+
const char = text[index]!;
|
|
61
|
+
if (/\s/.test(char)) {
|
|
62
|
+
if (started) words.push(current);
|
|
63
|
+
current = "";
|
|
64
|
+
started = false;
|
|
65
|
+
index += 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if ((char === '"' || char === "'") && !started) {
|
|
69
|
+
const quote = char;
|
|
70
|
+
index += 1;
|
|
71
|
+
started = true;
|
|
72
|
+
while (index < text.length && text[index] !== quote) {
|
|
73
|
+
if (quote === '"' && text[index] === "\\" && index + 1 < text.length) {
|
|
74
|
+
index += 1;
|
|
75
|
+
current += text[index]!;
|
|
76
|
+
} else {
|
|
77
|
+
current += text[index]!;
|
|
78
|
+
}
|
|
79
|
+
index += 1;
|
|
80
|
+
}
|
|
81
|
+
index += 1;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
current += char;
|
|
85
|
+
started = true;
|
|
86
|
+
index += 1;
|
|
87
|
+
}
|
|
88
|
+
if (started) words.push(current);
|
|
89
|
+
return words;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface AddArgs {
|
|
93
|
+
title: string;
|
|
94
|
+
/** CLI argv after the title (`--after`, `--priority`, `--status`). */
|
|
95
|
+
flags: string[];
|
|
96
|
+
/** Lines below the first — the task body. */
|
|
97
|
+
description: string;
|
|
98
|
+
/** Existing files detected in the description (passed as --attach). */
|
|
99
|
+
attaches: string[];
|
|
100
|
+
/** Set when the line was malformed, e.g. a flag without its value. */
|
|
101
|
+
error: string | null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const PRIORITY_BY_NUMBER: Record<string, string> = {
|
|
105
|
+
"1": "none",
|
|
106
|
+
"2": "low",
|
|
107
|
+
"3": "medium",
|
|
108
|
+
"4": "high",
|
|
109
|
+
"5": "urgent",
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Parse `/unipi:kanboard-add [-p 1-5 | --priority P] [--after ID]…
|
|
114
|
+
* [--status backlog|todo] <title>`: flags come first on the first line,
|
|
115
|
+
* everything after them is the title, and every later line is the body.
|
|
116
|
+
* Whitespace-free tokens in the body that are absolute or `~/` paths to
|
|
117
|
+
* existing files become `--attach` arguments.
|
|
118
|
+
*/
|
|
119
|
+
export function parseAddArgs(text: string): AddArgs {
|
|
120
|
+
const lines = (text ?? "").split("\n");
|
|
121
|
+
const words = tokenizeArgs(lines[0] ?? "");
|
|
122
|
+
const flags: string[] = [];
|
|
123
|
+
let index = 0;
|
|
124
|
+
const takeValue = (word: string, inline: string | undefined): string | undefined => {
|
|
125
|
+
if (inline !== undefined) return inline;
|
|
126
|
+
const value = words[index + 1];
|
|
127
|
+
if (value === undefined) return undefined;
|
|
128
|
+
index += 1;
|
|
129
|
+
return value;
|
|
130
|
+
};
|
|
131
|
+
while (index < words.length) {
|
|
132
|
+
const word = words[index]!;
|
|
133
|
+
const eq = word.indexOf("=");
|
|
134
|
+
const name = eq > 0 ? word.slice(0, eq) : word;
|
|
135
|
+
const inline = eq > 0 ? word.slice(eq + 1) : undefined;
|
|
136
|
+
if (name === "-p" || /^-p\d$/.test(word)) {
|
|
137
|
+
const raw = /^-p\d$/.test(word) ? word.slice(2) : takeValue(word, inline);
|
|
138
|
+
const priority = raw !== undefined ? PRIORITY_BY_NUMBER[raw] : undefined;
|
|
139
|
+
if (priority === undefined) return { title: "", flags, description: "", attaches: [], error: "-p takes 1-5 (1 none · 5 urgent)" };
|
|
140
|
+
flags.push("--priority", priority);
|
|
141
|
+
index += 1;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (name === "--priority" || name === "--status" || name === "--after") {
|
|
145
|
+
const value = takeValue(word, inline);
|
|
146
|
+
if (value === undefined || value === "") return { title: "", flags, description: "", attaches: [], error: `${name} needs a value` };
|
|
147
|
+
flags.push(name, value);
|
|
148
|
+
index += 1;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
const title = words.slice(index).join(" ").trim();
|
|
154
|
+
const description = lines.slice(1).join("\n").trim();
|
|
155
|
+
return { title, flags, description, attaches: detectFilePaths(description), error: null };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Whitespace-free tokens that are absolute (`/…`) or `~/` paths to files that
|
|
160
|
+
* exist — pasted screenshots and logs the user dropped into the description.
|
|
161
|
+
*/
|
|
162
|
+
export function detectFilePaths(text: string): string[] {
|
|
163
|
+
const found: string[] = [];
|
|
164
|
+
for (const raw of text.split(/\s+/)) {
|
|
165
|
+
let token = raw;
|
|
166
|
+
// Strip markdown/link wrappers so `` still matches.
|
|
167
|
+
const wrapped = token.match(/^!?\[[^\]]*\]\((.+)\)[.,;:]?$/) ?? token.match(/^\((.+)\)[.,;:]?$/);
|
|
168
|
+
if (wrapped) token = wrapped[1]!;
|
|
169
|
+
token = token.replace(/[.,;:]+$/, "");
|
|
170
|
+
const expanded = token.startsWith("~/") ? join(homedir(), token.slice(2)) : token;
|
|
171
|
+
if (!(token.startsWith("/") || token.startsWith("~/"))) continue;
|
|
172
|
+
if (!existsSync(expanded)) continue;
|
|
173
|
+
if (!found.includes(expanded)) found.push(expanded);
|
|
174
|
+
}
|
|
175
|
+
return found;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface CommandDeps {
|
|
179
|
+
/** null when no binary was found (then `unavailable` explains why). */
|
|
180
|
+
cli: KanboardCli | null;
|
|
181
|
+
unavailable: string | null;
|
|
182
|
+
/** Command runner for `tailscale ip -4` / `command -v` (injectable in tests). */
|
|
183
|
+
exec?: (cmd: string, args: string[]) => Promise<string>;
|
|
184
|
+
settings: () => KanboardSettings;
|
|
185
|
+
/** Reveal the kanboard skill for this session (append-only). */
|
|
186
|
+
revealSkill: (ctx: ExtensionContext | ExtensionCommandContext) => void;
|
|
187
|
+
/** Autowork: claim-and-run loop (queue first, then claim-next). */
|
|
188
|
+
work: (ctx: ExtensionContext) => Promise<void>;
|
|
189
|
+
/** Finish the current task, then stop the loop. */
|
|
190
|
+
stop: (ctx: ExtensionContext) => void;
|
|
191
|
+
/** Drain this session's queue after a -do turn (no-op when empty/running). */
|
|
192
|
+
drainQueue: (ctx: ExtensionContext) => Promise<void>;
|
|
193
|
+
status: () => { taskId: string | null; mode: string | null; phase: string };
|
|
194
|
+
/** The write window opened by -do (read by the tool_call gate). */
|
|
195
|
+
guard: import("./guard.js").WriteGuard;
|
|
196
|
+
/** This session's id (UNIPI_KANBOARD_SESSION / pi-<pid>). */
|
|
197
|
+
session: () => string;
|
|
198
|
+
debug: (line: string) => void;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface DaemonInfo {
|
|
202
|
+
pid: number;
|
|
203
|
+
port: number;
|
|
204
|
+
version: string;
|
|
205
|
+
startedAt: string;
|
|
206
|
+
host?: string;
|
|
207
|
+
token?: string;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const run = promisify(execFile);
|
|
211
|
+
|
|
212
|
+
/** `open` flags: override the settings for this invocation only. */
|
|
213
|
+
export interface OpenFlags {
|
|
214
|
+
host?: string;
|
|
215
|
+
port?: number;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Split `open`'s arguments; unknown words are reported so the user can fix them. */
|
|
219
|
+
export function parseOpenArgs(
|
|
220
|
+
args: string,
|
|
221
|
+
settings: { host: string; port: number },
|
|
222
|
+
): { host: string; port: number; unknown: string[] } {
|
|
223
|
+
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
224
|
+
const unknown: string[] = [];
|
|
225
|
+
let host = settings.host;
|
|
226
|
+
let port = settings.port;
|
|
227
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
228
|
+
const part = parts[index]!;
|
|
229
|
+
const value = (inline?: string): string | undefined => inline ?? parts[++index];
|
|
230
|
+
if (part === "--host" || part.startsWith("--host=")) {
|
|
231
|
+
const given = value(part.includes("=") ? part.slice("--host=".length) : undefined);
|
|
232
|
+
if (given) host = given;
|
|
233
|
+
else unknown.push(part);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (part === "--port" || part.startsWith("--port=")) {
|
|
237
|
+
const given = value(part.includes("=") ? part.slice("--port=".length) : undefined);
|
|
238
|
+
const parsed = Number(given);
|
|
239
|
+
if (Number.isInteger(parsed) && parsed >= 0 && parsed <= 65535) port = parsed;
|
|
240
|
+
else unknown.push(`${part} ${given ?? ""}`.trim());
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
unknown.push(part);
|
|
244
|
+
}
|
|
245
|
+
return { host, port, unknown };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function isLoopbackHost(host: string): boolean {
|
|
249
|
+
const bare = host.trim().replace(/^\[|\]$/g, "");
|
|
250
|
+
return bare === "localhost" || bare === "::1" || /^127\./.test(bare) || bare === "0:0:0:0:0:0:0:1";
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Local IPv4s that other machines can reach (non-internal). */
|
|
254
|
+
export function reachableAddresses(): string[] {
|
|
255
|
+
const out: string[] = [];
|
|
256
|
+
const interfaces = networkInterfaces() as Record<string, NetworkInterfaceInfo[] | undefined>;
|
|
257
|
+
for (const entries of Object.values(interfaces)) {
|
|
258
|
+
for (const entry of entries ?? []) {
|
|
259
|
+
if (entry.family === "IPv4" && !entry.internal) out.push(entry.address);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** `tailscale` → the machine's tailnet IPv4 (first line of `tailscale ip -4`). */
|
|
266
|
+
export async function resolveHost(
|
|
267
|
+
host: string,
|
|
268
|
+
exec: (cmd: string, args: string[]) => Promise<string> = async (cmd, args) =>
|
|
269
|
+
(await run(cmd, args)).stdout,
|
|
270
|
+
): Promise<{ host: string; error?: string }> {
|
|
271
|
+
if (host.trim() !== "tailscale") return { host: host.trim() };
|
|
272
|
+
try {
|
|
273
|
+
const output = await exec("tailscale", ["ip", "-4"]);
|
|
274
|
+
const first = output.split("\n").map((line) => line.trim()).find((line) => line.length > 0);
|
|
275
|
+
if (!first) return { host, error: "tailscale ip -4 returned nothing — is this machine on a tailnet?" };
|
|
276
|
+
return { host: first };
|
|
277
|
+
} catch (error) {
|
|
278
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
279
|
+
return { host, error: `tailscale is not available (${detail}) — install it or pass --host <addr>` };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Every URL to hand the user: the bound host plus, for a wildcard bind, the
|
|
285
|
+
* hostname, each reachable IPv4 and the tailnet address.
|
|
286
|
+
*/
|
|
287
|
+
export function formatBoardUrls(options: {
|
|
288
|
+
host: string;
|
|
289
|
+
port: number;
|
|
290
|
+
slug: string | null;
|
|
291
|
+
token?: string | null;
|
|
292
|
+
hostname?: string;
|
|
293
|
+
addresses?: string[];
|
|
294
|
+
tailscale?: string | null;
|
|
295
|
+
}): { urls: string[]; warnings: string[] } {
|
|
296
|
+
const { host, port, slug, token } = options;
|
|
297
|
+
const path = slug ? `/p/${slug}` : "/";
|
|
298
|
+
const suffix = token ? `?t=${token}` : "";
|
|
299
|
+
const local = `http://127.0.0.1:${port}${path}${suffix}`;
|
|
300
|
+
|
|
301
|
+
if (isLoopbackHost(host)) {
|
|
302
|
+
return {
|
|
303
|
+
urls: [local],
|
|
304
|
+
warnings: [`from another machine, tunnel it: ssh -N -L ${port}:127.0.0.1:${port} ${options.hostname ?? "$(hostname)"}`],
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const hosts: string[] = [];
|
|
309
|
+
const wildcard = host === "0.0.0.0" || host === "::";
|
|
310
|
+
if (wildcard) {
|
|
311
|
+
if (options.hostname) hosts.push(options.hostname);
|
|
312
|
+
for (const address of options.addresses ?? []) hosts.push(address);
|
|
313
|
+
if (options.tailscale) hosts.push(options.tailscale);
|
|
314
|
+
} else {
|
|
315
|
+
hosts.push(host);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// A wildcard bind with no known address still reports the binding itself
|
|
319
|
+
// rather than pretending the board is local.
|
|
320
|
+
const candidates = hosts.length > 0 ? hosts : [host];
|
|
321
|
+
const urls = candidates.map((candidate) => `http://${candidate}:${port}${path}${suffix}`);
|
|
322
|
+
return {
|
|
323
|
+
urls,
|
|
324
|
+
warnings: ["board is reachable from the network; anyone with the link can edit it"],
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function kanboardHome(): string {
|
|
329
|
+
return process.env.UNIPI_KANBOARD_HOME?.trim() || join(homedir(), ".unipi", "kanboard");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function readDaemonInfo(): DaemonInfo | null {
|
|
333
|
+
try {
|
|
334
|
+
const raw = readFileSync(join(kanboardHome(), "daemon.json"), "utf-8");
|
|
335
|
+
const parsed = JSON.parse(raw) as DaemonInfo;
|
|
336
|
+
return typeof parsed?.port === "number" ? parsed : null;
|
|
337
|
+
} catch {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export async function healthy(port: number, timeoutMs = 700, token?: string | null): Promise<boolean> {
|
|
343
|
+
const controller = new AbortController();
|
|
344
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
345
|
+
const headers: Record<string, string> = token ? { authorization: `Bearer ${token}` } : {};
|
|
346
|
+
try {
|
|
347
|
+
const response = await fetch(`http://127.0.0.1:${port}/api/health`, { signal: controller.signal, headers });
|
|
348
|
+
if (!response.ok) return false;
|
|
349
|
+
const payload = (await response.json()) as { ok?: boolean };
|
|
350
|
+
return payload.ok === true;
|
|
351
|
+
} catch {
|
|
352
|
+
return false;
|
|
353
|
+
} finally {
|
|
354
|
+
clearTimeout(timer);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Ensure the daemon runs on the requested binding. A running daemon on a
|
|
360
|
+
* different host/port is restarted (the daemon runs no jobs, so this is safe).
|
|
361
|
+
*/
|
|
362
|
+
export async function ensureDaemon(
|
|
363
|
+
deps: CommandDeps,
|
|
364
|
+
ctx: ExtensionCommandContext | ExtensionContext,
|
|
365
|
+
binding?: { host: string; port: number },
|
|
366
|
+
onRestart?: (info: { host: string; port: number }) => void,
|
|
367
|
+
): Promise<number | null> {
|
|
368
|
+
const client = deps.cli;
|
|
369
|
+
if (!client) {
|
|
370
|
+
ctx.ui.notify(`kanboard: ${deps.unavailable}`, "warning");
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
const settings = deps.settings();
|
|
374
|
+
const host = binding?.host ?? settings.host;
|
|
375
|
+
const port = binding?.port ?? settings.port;
|
|
376
|
+
const existing = readDaemonInfo();
|
|
377
|
+
if (existing && (await healthy(existing.port, 700, existing.token))) {
|
|
378
|
+
const sameBinding = (existing.host ?? "127.0.0.1") === host && (port === 0 || existing.port === port);
|
|
379
|
+
if (sameBinding) {
|
|
380
|
+
// Same binding but different auth wishes: reusing would silently ignore
|
|
381
|
+
// the settings — say so instead of restarting unprompted.
|
|
382
|
+
const wantsToken = settings.requireAuth || !isLoopbackHost(host);
|
|
383
|
+
const hasToken = typeof existing.token === "string" && existing.token.length > 0;
|
|
384
|
+
if (wantsToken !== hasToken) {
|
|
385
|
+
const mode = hasToken ? "token-gated" : "open (no token)";
|
|
386
|
+
ctx.ui.notify(
|
|
387
|
+
`kanboard: the board is running ${mode} on ${existing.host}:${existing.port} — /unipi:kanboard close then open to apply`,
|
|
388
|
+
"warning",
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
return existing.port;
|
|
392
|
+
}
|
|
393
|
+
deps.debug(`rebinding: running ${existing.host}:${existing.port} → ${host}:${port}`);
|
|
394
|
+
await client.run(["stop"], {}).catch(() => undefined);
|
|
395
|
+
onRestart?.({ host, port });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const args = ["serve", "--host", host, "--port", String(port), "--idle-min", String(settings.idleMin)];
|
|
399
|
+
if (settings.requireAuth) args.push("--require-auth");
|
|
400
|
+
if (settings.keepToken) args.push("--keep-token");
|
|
401
|
+
try {
|
|
402
|
+
// Fire and forget: the daemon detaches itself (single instance via flock).
|
|
403
|
+
const child = spawn(client.binary.path, args, {
|
|
404
|
+
detached: true,
|
|
405
|
+
stdio: "ignore",
|
|
406
|
+
env: {
|
|
407
|
+
...process.env,
|
|
408
|
+
UNIPI_KANBOARD_ACTOR: "user",
|
|
409
|
+
UNIPI_KANBOARD_CHAIN_GATE: settings.chainGate,
|
|
410
|
+
UNIPI_KANBOARD_QUEUE_MAX: String(settings.queueMax),
|
|
411
|
+
UNIPI_KANBOARD_MAX_SESSIONS: String(settings.maxSessions),
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
child.unref();
|
|
415
|
+
} catch (error) {
|
|
416
|
+
ctx.ui.notify(`kanboard: could not start the daemon — ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const deadline = Date.now() + 3000;
|
|
421
|
+
while (Date.now() < deadline) {
|
|
422
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
423
|
+
const info = readDaemonInfo();
|
|
424
|
+
if (info && (await healthy(info.port, 700, info.token))) return info.port;
|
|
425
|
+
}
|
|
426
|
+
ctx.ui.notify("kanboard: the daemon did not answer within 3s", "warning");
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function currentSlug(): string | null {
|
|
431
|
+
const fromEnv = process.env.UNIPI_KANBOARD_PROJECT?.trim();
|
|
432
|
+
if (fromEnv) return fromEnv;
|
|
433
|
+
try {
|
|
434
|
+
const settings = getSettings("kanboard", process.cwd()) as { slug?: string };
|
|
435
|
+
return typeof settings.slug === "string" && settings.slug.length > 0 ? settings.slug : null;
|
|
436
|
+
} catch {
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** `open [--host H] [--port N]`: ensure the daemon and print every usable URL. */
|
|
442
|
+
export async function runOpen(
|
|
443
|
+
deps: CommandDeps,
|
|
444
|
+
ctx: ExtensionCommandContext | ExtensionContext,
|
|
445
|
+
args = "",
|
|
446
|
+
): Promise<string[]> {
|
|
447
|
+
const settings = deps.settings();
|
|
448
|
+
const flags = parseOpenArgs(args, {
|
|
449
|
+
host: settings.host ?? "127.0.0.1",
|
|
450
|
+
port: settings.port ?? 0,
|
|
451
|
+
});
|
|
452
|
+
if (flags.unknown.length > 0) {
|
|
453
|
+
ctx.ui.notify(`kanboard: ignoring unknown option(s) ${flags.unknown.join(" ")}`, "warning");
|
|
454
|
+
}
|
|
455
|
+
const resolved = await resolveHost(flags.host, deps.exec);
|
|
456
|
+
if (resolved.error) {
|
|
457
|
+
ctx.ui.notify(`kanboard: ${resolved.error}`, "error");
|
|
458
|
+
return [];
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
let restarted = false;
|
|
462
|
+
const port = await ensureDaemon(deps, ctx, { host: resolved.host, port: flags.port }, () => {
|
|
463
|
+
restarted = true;
|
|
464
|
+
});
|
|
465
|
+
if (port === null) return [];
|
|
466
|
+
if (restarted) {
|
|
467
|
+
ctx.ui.notify(`kanboard: restarted kanboard on ${resolved.host}:${port}`, "info");
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
await syncPiRuntime(deps, ctx);
|
|
471
|
+
const info = readDaemonInfo();
|
|
472
|
+
const tailscale = resolved.host === "0.0.0.0" ? await tailscaleAddress(deps) : undefined;
|
|
473
|
+
const { urls, warnings } = formatBoardUrls({
|
|
474
|
+
host: resolved.host,
|
|
475
|
+
port,
|
|
476
|
+
slug: currentSlug(),
|
|
477
|
+
token: info?.token ?? null,
|
|
478
|
+
hostname: osHostname(),
|
|
479
|
+
addresses: reachableAddresses(),
|
|
480
|
+
tailscale,
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
for (const url of urls) ctx.ui.notify(`kanboard: ${url}`, "info");
|
|
484
|
+
for (const warning of warnings) ctx.ui.notify(`kanboard: ⚠ ${warning}`, "warning");
|
|
485
|
+
if (settings.openBrowser) openBrowser(urls[0]!);
|
|
486
|
+
return urls;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** The tailnet address, when tailscale is installed (wildcard binds list it). */
|
|
490
|
+
async function tailscaleAddress(deps: CommandDeps): Promise<string | undefined> {
|
|
491
|
+
try {
|
|
492
|
+
const resolved = await resolveHost("tailscale", deps.exec);
|
|
493
|
+
return resolved.error ? undefined : resolved.host;
|
|
494
|
+
} catch {
|
|
495
|
+
return undefined;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function openBrowser(url: string): void {
|
|
500
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
501
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
502
|
+
try {
|
|
503
|
+
spawn(command, args, { detached: true, stdio: "ignore" }).unref();
|
|
504
|
+
} catch {
|
|
505
|
+
// Opening a browser is a convenience; the URL is already on screen.
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export async function runOnboard(deps: CommandDeps, ctx: ExtensionCommandContext | ExtensionContext): Promise<string | null> {
|
|
510
|
+
const client = deps.cli;
|
|
511
|
+
if (!client) {
|
|
512
|
+
ctx.ui.notify(`kanboard: ${deps.unavailable}`, "warning");
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
const before = currentSlug();
|
|
516
|
+
try {
|
|
517
|
+
const project = asProject("project add", await client.run<unknown>(["project", "add"], {
|
|
518
|
+
cwd: ctx.cwd,
|
|
519
|
+
}));
|
|
520
|
+
// Remember which project this workspace maps to, so later commands resolve
|
|
521
|
+
// it without a git-root lookup. Best-effort: the board works regardless.
|
|
522
|
+
try {
|
|
523
|
+
const { setSettings } = await import("@pi-unipi/core");
|
|
524
|
+
setSettings("kanboard", { slug: project.slug, root: ctx.cwd }, "project", ctx.cwd);
|
|
525
|
+
} catch (error) {
|
|
526
|
+
deps.debug(`could not cache the slug: ${error instanceof Error ? error.message : String(error)}`);
|
|
527
|
+
}
|
|
528
|
+
ctx.ui.notify(
|
|
529
|
+
[
|
|
530
|
+
`kanboard: ${project.name} (${project.slug}) registered`,
|
|
531
|
+
` /unipi:kanboard-add <title> — capture a task into Backlog`,
|
|
532
|
+
` /unipi:kanboard-do <request> — let the agent use the board for a turn`,
|
|
533
|
+
` /unipi:kanboard-autowork start — work ready tasks one by one`,
|
|
534
|
+
` /unipi:kanboard open — open the board in a browser`,
|
|
535
|
+
].join("\n"),
|
|
536
|
+
"info",
|
|
537
|
+
);
|
|
538
|
+
deps.debug(`onboard ${project.slug} (was ${before ?? "none"})`);
|
|
539
|
+
return project.slug;
|
|
540
|
+
} catch (error) {
|
|
541
|
+
ctx.ui.notify(
|
|
542
|
+
`kanboard: onboarding failed — ${error instanceof KanboardCliError ? error.message : String(error)}`,
|
|
543
|
+
"error",
|
|
544
|
+
);
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Ensure this folder maps to a board project, offering to onboard if not. */
|
|
550
|
+
async function ensureOnboarded(deps: CommandDeps, ctx: ExtensionCommandContext): Promise<boolean> {
|
|
551
|
+
if (currentSlug()) return true;
|
|
552
|
+
const proceed = await ctx.ui.confirm(
|
|
553
|
+
"Kanboard is not set up here",
|
|
554
|
+
"Register this project on the board first?",
|
|
555
|
+
);
|
|
556
|
+
if (!proceed) return false;
|
|
557
|
+
return (await runOnboard(deps, ctx)) !== null;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* `/unipi:kanboard-add` — the first line is flags + title, the rest is the
|
|
562
|
+
* body. File paths in the body are attached; the body goes via --body-file so
|
|
563
|
+
* nothing is re-escaped through argv.
|
|
564
|
+
*/
|
|
565
|
+
export async function runAdd(deps: CommandDeps, ctx: ExtensionCommandContext, text: string): Promise<void> {
|
|
566
|
+
const client = deps.cli;
|
|
567
|
+
if (!client) {
|
|
568
|
+
ctx.ui.notify(`kanboard: ${deps.unavailable}`, "warning");
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const parsed = parseAddArgs(text);
|
|
572
|
+
if (parsed.error) {
|
|
573
|
+
ctx.ui.notify(`kanboard: ${parsed.error} — /unipi:kanboard-add [-p 1-5] [--after ID] [--status backlog|todo] <title>`, "warning");
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (!parsed.title) {
|
|
577
|
+
ctx.ui.notify("kanboard: add needs a title — /unipi:kanboard-add <title>", "warning");
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (!(await ensureOnboarded(deps, ctx))) return;
|
|
581
|
+
|
|
582
|
+
let bodyFile: string | null = null;
|
|
583
|
+
try {
|
|
584
|
+
const argv = ["add", parsed.title, ...parsed.flags];
|
|
585
|
+
if (parsed.description) {
|
|
586
|
+
bodyFile = join(mkdtempSync(join(tmpdir(), "kb-add-")), "body.md");
|
|
587
|
+
writeFileSync(bodyFile, parsed.description);
|
|
588
|
+
argv.push("--body-file", bodyFile);
|
|
589
|
+
}
|
|
590
|
+
for (const file of parsed.attaches) argv.push("--attach", file);
|
|
591
|
+
const task = asTask("add", await client.run<unknown>(argv, { cwd: ctx.cwd }));
|
|
592
|
+
const lane = task.status === "todo" ? "Todo" : "Backlog";
|
|
593
|
+
const attachments = parsed.attaches.length > 0 ? ` (${parsed.attaches.length} attachment${parsed.attaches.length === 1 ? "" : "s"})` : "";
|
|
594
|
+
ctx.ui.notify(`✓ ${task.id} added to ${lane}${attachments}`, "info");
|
|
595
|
+
deps.debug(`add ${task.id} [${task.status}]: ${parsed.title} (${parsed.attaches.length} attachments)`);
|
|
596
|
+
} catch (error) {
|
|
597
|
+
ctx.ui.notify(
|
|
598
|
+
`kanboard: could not add the task — ${error instanceof KanboardCliError ? error.message : String(error)}`,
|
|
599
|
+
"error",
|
|
600
|
+
);
|
|
601
|
+
} finally {
|
|
602
|
+
if (bodyFile) rmSync(join(bodyFile, ".."), { recursive: true, force: true });
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
export async function runStatus(deps: CommandDeps, ctx: ExtensionCommandContext | ExtensionContext): Promise<void> {
|
|
607
|
+
const client = deps.cli;
|
|
608
|
+
const lines: string[] = [];
|
|
609
|
+
const info = readDaemonInfo();
|
|
610
|
+
if (info && (await healthy(info.port))) {
|
|
611
|
+
lines.push(`daemon: pid ${info.pid} · http://${info.host ?? "127.0.0.1"}:${info.port} · v${info.version}`);
|
|
612
|
+
} else {
|
|
613
|
+
lines.push("daemon: not running");
|
|
614
|
+
}
|
|
615
|
+
const slug = currentSlug();
|
|
616
|
+
if (slug) {
|
|
617
|
+
try {
|
|
618
|
+
const { tasks, problems } = asTaskList(await client!.run<unknown>(["list"], {}));
|
|
619
|
+
const counts = tasks.reduce<Record<string, number>>((acc, task) => {
|
|
620
|
+
acc[task.status] = (acc[task.status] ?? 0) + 1;
|
|
621
|
+
return acc;
|
|
622
|
+
}, {});
|
|
623
|
+
lines.push(
|
|
624
|
+
`project: ${slug} · ${tasks.length} tasks (${Object.entries(counts)
|
|
625
|
+
.map(([status, count]) => `${status} ${count}`)
|
|
626
|
+
.join(", ") || "empty"})`,
|
|
627
|
+
);
|
|
628
|
+
if (problems && problems.length > 0) {
|
|
629
|
+
lines.push(`⚠ ${problems.length} task file(s) need repair — unipi-kanboard validate --fix`);
|
|
630
|
+
}
|
|
631
|
+
// Active claims and this session's queue.
|
|
632
|
+
const running = tasks.filter((task) => task.status === "in_progress");
|
|
633
|
+
if (running.length > 0) {
|
|
634
|
+
lines.push("claims:");
|
|
635
|
+
for (const task of running) {
|
|
636
|
+
const run = (task as KanboardTask & { run?: { session?: string; pid?: number; host?: string } }).run;
|
|
637
|
+
const staleness = (task as KanboardTask & { staleness?: string }).staleness ?? "?";
|
|
638
|
+
lines.push(
|
|
639
|
+
` ${task.id} — session ${run?.session ?? "?"} pid ${run?.pid ?? "?"} on ${run?.host ?? "?"} (${staleness})`,
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
try {
|
|
644
|
+
const queue = (await client!.run<unknown>(["queue", "--list"], {
|
|
645
|
+
extraEnv: { UNIPI_KANBOARD_SESSION: deps.session() },
|
|
646
|
+
})) as { queue?: string[] };
|
|
647
|
+
lines.push(`queue (${deps.session()}): ${(queue.queue ?? []).join(" ") || "empty"}`);
|
|
648
|
+
} catch {
|
|
649
|
+
lines.push("queue: (unreadable)");
|
|
650
|
+
}
|
|
651
|
+
} catch {
|
|
652
|
+
lines.push(`project: ${slug} (unreadable board)`);
|
|
653
|
+
}
|
|
654
|
+
} else {
|
|
655
|
+
lines.push("project: not registered here — /unipi:kanboard onboard");
|
|
656
|
+
}
|
|
657
|
+
const run = deps.status();
|
|
658
|
+
lines.push(run.taskId ? `runner: ${run.taskId} · ${run.mode} · ${run.phase}` : "runner: idle");
|
|
659
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* After a -do window closed (the first real agent_end), start draining this
|
|
664
|
+
* session's queue when it is non-empty. Returns true when draining started.
|
|
665
|
+
*/
|
|
666
|
+
export async function drainQueueAfterDo(deps: CommandDeps, ctx: ExtensionContext): Promise<boolean> {
|
|
667
|
+
const client = deps.cli;
|
|
668
|
+
const slug = currentSlug();
|
|
669
|
+
if (!client || !slug) return false;
|
|
670
|
+
const queued = await client
|
|
671
|
+
.run<{ queue?: string[] }>(["queue", "--list"], {
|
|
672
|
+
extraEnv: { UNIPI_KANBOARD_PROJECT: slug, UNIPI_KANBOARD_SESSION: deps.session() },
|
|
673
|
+
})
|
|
674
|
+
.then((payload) => payload.queue ?? [])
|
|
675
|
+
.catch(() => [] as string[]);
|
|
676
|
+
if (queued.length === 0) return false;
|
|
677
|
+
await deps.drainQueue(ctx);
|
|
678
|
+
return true;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
/** `doctor`: ✓/✗ lines for the whole setup, posted as a display-only message. */
|
|
683
|
+
export async function runDoctor(deps: CommandDeps, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
|
|
684
|
+
const check = (ok: boolean | null, label: string): string => `${ok === null ? "–" : ok ? "✓" : "✗"} ${label}`;
|
|
685
|
+
const lines: string[] = ["kanboard doctor"];
|
|
686
|
+
const client = deps.cli;
|
|
687
|
+
lines.push(check(client !== null, client ? `binary: ${client.binary.path} (${client.binary.source})` : `binary: ${deps.unavailable}`));
|
|
688
|
+
|
|
689
|
+
const info = readDaemonInfo();
|
|
690
|
+
const alive = info ? await healthy(info.port, 700, info.token) : false;
|
|
691
|
+
lines.push(check(alive, info ? `daemon: http://${info.host ?? "127.0.0.1"}:${info.port} alive (pid ${info.pid})` : "daemon: not running"));
|
|
692
|
+
if (info) {
|
|
693
|
+
const loopback = isLoopbackHost(info.host ?? "127.0.0.1");
|
|
694
|
+
const url = `http://${loopback ? "127.0.0.1" : osHostname()}:${info.port}`;
|
|
695
|
+
lines.push(check(true, `bind: ${info.host ?? "127.0.0.1"}:${info.port} ${loopback ? "(loopback — local only)" : `(remote — token-gated, e.g. ${url}?t=…)`}`));
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const slug = currentSlug();
|
|
699
|
+
if (!slug) {
|
|
700
|
+
lines.push(check(false, "project: not registered for this folder — /unipi:kanboard onboard"));
|
|
701
|
+
} else if (client) {
|
|
702
|
+
try {
|
|
703
|
+
const shown = (await client.run<unknown>(["project", "show"], {})) as { project?: { name?: string }; total?: number };
|
|
704
|
+
lines.push(check(true, `project: ${shown.project?.name ?? slug} (${slug}) · ${shown.total ?? "?"} tasks`));
|
|
705
|
+
} catch (error) {
|
|
706
|
+
lines.push(check(false, `project: ${slug} unreadable — ${error instanceof Error ? error.message : String(error)}`));
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Summarize runs pi itself: piCommand argv (written on session start /
|
|
711
|
+
// open) must exist and its executable must resolve; summaryModel must be one
|
|
712
|
+
// of the reported models when set.
|
|
713
|
+
try {
|
|
714
|
+
const settingsPath = join(kanboardHome(), "settings.json");
|
|
715
|
+
const raw = JSON.parse(readFileSync(settingsPath, "utf-8")) as {
|
|
716
|
+
piCommand?: string[];
|
|
717
|
+
models?: string[];
|
|
718
|
+
summaryModel?: string;
|
|
719
|
+
};
|
|
720
|
+
const argv = Array.isArray(raw.piCommand) ? raw.piCommand : [];
|
|
721
|
+
if (argv.length === 0) {
|
|
722
|
+
lines.push(check(null, "summarize via pi: not configured (open the board from pi once)"));
|
|
723
|
+
} else {
|
|
724
|
+
const exe = argv[0]!;
|
|
725
|
+
const exists = existsSync(exe);
|
|
726
|
+
lines.push(
|
|
727
|
+
check(exists, `summarize via pi: ${argv.join(" ")}${exists ? "" : ` — ${exe} not found`}`),
|
|
728
|
+
);
|
|
729
|
+
const model = (raw.summaryModel ?? "").trim();
|
|
730
|
+
if (model) {
|
|
731
|
+
const listed = (raw.models ?? []).includes(model);
|
|
732
|
+
lines.push(check(listed, `summary model: ${model}${listed ? "" : " — not in the reported model list"}`));
|
|
733
|
+
} else {
|
|
734
|
+
lines.push(check(true, "summary model: pi default"));
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
} catch {
|
|
738
|
+
lines.push(check(null, "summarize via pi: not configured (open the board from pi once)"));
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// The workflow permission mode lives in the "permission" settings namespace.
|
|
742
|
+
try {
|
|
743
|
+
const raw = getSettings("permission", ctx.cwd) as { mode?: string };
|
|
744
|
+
lines.push(check(true, `permission mode: ${raw.mode ?? "auto"}`));
|
|
745
|
+
} catch {
|
|
746
|
+
// not trivially available — skipped
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
if (slug && client) {
|
|
750
|
+
try {
|
|
751
|
+
const dry = (await client.run<unknown>(["reap", "--dry-run"], {
|
|
752
|
+
extraEnv: { UNIPI_KANBOARD_PROJECT: slug },
|
|
753
|
+
})) as { released?: string[]; unknown?: string[] };
|
|
754
|
+
for (const id of dry.released ?? []) lines.push(check(false, `claim: ${id} — dead session, will return to Todo on next claim`));
|
|
755
|
+
for (const id of dry.unknown ?? []) lines.push(check(null, `claim: ${id} — running on another host`));
|
|
756
|
+
if ((dry.released ?? []).length === 0 && (dry.unknown ?? []).length === 0) lines.push(check(true, "claims: none stale"));
|
|
757
|
+
} catch {
|
|
758
|
+
lines.push(check(null, "claims: could not probe (reap --dry-run failed)"));
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
pi.sendMessage({ customType: DOCTOR_CUSTOM_TYPE, content: lines.join("\n"), display: true }, { triggerTurn: false });
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
export async function runStopDaemon(deps: CommandDeps, ctx: ExtensionCommandContext | ExtensionContext): Promise<void> {
|
|
766
|
+
const client = deps.cli;
|
|
767
|
+
if (!client) {
|
|
768
|
+
ctx.ui.notify(`kanboard: ${deps.unavailable}`, "warning");
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
try {
|
|
772
|
+
const payload = asStopResult(await client.run<unknown>(["stop"]));
|
|
773
|
+
ctx.ui.notify(
|
|
774
|
+
payload.stopped ? `kanboard daemon stopped (pid ${payload.pid})` : `kanboard: ${payload.reason ?? "not running"}`,
|
|
775
|
+
"info",
|
|
776
|
+
);
|
|
777
|
+
} catch (error) {
|
|
778
|
+
ctx.ui.notify(`kanboard: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Tell the daemon how to run pi for summaries: `piCommand` (this process's
|
|
784
|
+
* argv minus flags) and `models` (the user's available list, the whitelist the
|
|
785
|
+
* Settings dialog may pick from). Runs on session start and on `open`.
|
|
786
|
+
*/
|
|
787
|
+
export async function syncPiRuntime(
|
|
788
|
+
deps: CommandDeps,
|
|
789
|
+
ctx: ExtensionCommandContext | ExtensionContext,
|
|
790
|
+
): Promise<void> {
|
|
791
|
+
const client = deps.cli;
|
|
792
|
+
if (!client) return;
|
|
793
|
+
// argv[1] is the pi script when node runs it; a compiled binary has none.
|
|
794
|
+
// Keep it when it exists on disk (the coffee bin/pi shim has no extension).
|
|
795
|
+
const script = process.argv[1];
|
|
796
|
+
const argv = [process.execPath, ...(script && existsSync(script) ? [script] : [])];
|
|
797
|
+
const models =
|
|
798
|
+
(ctx as { modelRegistry?: { getAvailable?: () => Array<{ provider: string; id: string }> } })
|
|
799
|
+
.modelRegistry?.getAvailable?.()
|
|
800
|
+
.map((model) => `${model.provider}/${model.id}`) ?? [];
|
|
801
|
+
try {
|
|
802
|
+
await client.run<unknown>(["settings", "set", "pi-command", JSON.stringify(argv)], {});
|
|
803
|
+
await client.run<unknown>(["settings", "set", "models", JSON.stringify(models)], {});
|
|
804
|
+
} catch (error) {
|
|
805
|
+
deps.debug(`pi runtime sync failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/** Hub action "Rotate access token": the CLI drops <home>/token. */
|
|
810
|
+
export async function runRotateTokenAction(
|
|
811
|
+
deps: CommandDeps,
|
|
812
|
+
ctx: ExtensionCommandContext | ExtensionContext,
|
|
813
|
+
): Promise<void> {
|
|
814
|
+
const client = deps.cli;
|
|
815
|
+
if (!client) {
|
|
816
|
+
ctx.ui.notify(`kanboard: ${deps.unavailable}`, "warning");
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
try {
|
|
820
|
+
await client.run<unknown>(["rotate-token"], {});
|
|
821
|
+
ctx.ui.notify("kanboard: new token on the next daemon start — /unipi:kanboard close, then open", "info");
|
|
822
|
+
} catch (error) {
|
|
823
|
+
ctx.ui.notify(
|
|
824
|
+
`kanboard: rotate-token failed — ${error instanceof KanboardCliError ? error.message : String(error)}`,
|
|
825
|
+
"error",
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
// ─── show: the board as a themed chat message ──────────────────────────────
|
|
832
|
+
|
|
833
|
+
interface ShowTask {
|
|
834
|
+
id: string;
|
|
835
|
+
title: string;
|
|
836
|
+
status: string;
|
|
837
|
+
priority?: string;
|
|
838
|
+
order?: number;
|
|
839
|
+
deps?: string[];
|
|
840
|
+
ready?: boolean;
|
|
841
|
+
waitingFor?: string[];
|
|
842
|
+
run?: { session?: string } | null;
|
|
843
|
+
blockedReason?: { text?: string } | null;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const SHOW_LANES: Array<{ id: string; label: string }> = [
|
|
847
|
+
{ id: "in_progress", label: "In Progress" },
|
|
848
|
+
{ id: "blocked", label: "Blocked" },
|
|
849
|
+
{ id: "todo", label: "Todo" },
|
|
850
|
+
{ id: "in_review", label: "In Review" },
|
|
851
|
+
{ id: "backlog", label: "Backlog" },
|
|
852
|
+
{ id: "done", label: "Done" },
|
|
853
|
+
];
|
|
854
|
+
const SHOW_EXTRA_LANES: Array<{ id: string; label: string }> = [
|
|
855
|
+
{ id: "cancelled", label: "Cancelled" },
|
|
856
|
+
{ id: "archived", label: "Archived" },
|
|
857
|
+
];
|
|
858
|
+
const PRIO_GLYPH: Record<string, string> = { urgent: "⇈", high: "↑", medium: "·", low: "↓", none: " " };
|
|
859
|
+
const PRIO_RANK: Record<string, number> = { urgent: 0, high: 1, medium: 2, low: 3, none: 4 };
|
|
860
|
+
|
|
861
|
+
/** Same ordering as the CLI's claim_sort: priority, then lane order, then id. */
|
|
862
|
+
export function claimOrder(tasks: ShowTask[]): ShowTask[] {
|
|
863
|
+
return [...tasks].sort(
|
|
864
|
+
(a, b) =>
|
|
865
|
+
(PRIO_RANK[a.priority ?? "none"] ?? 4) - (PRIO_RANK[b.priority ?? "none"] ?? 4) ||
|
|
866
|
+
(a.order ?? 0) - (b.order ?? 0) ||
|
|
867
|
+
a.id.localeCompare(b.id),
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** One renderable line's parts, before theming. */
|
|
872
|
+
interface ShowRow {
|
|
873
|
+
/** tree prefix: "", "├ " or "└ " (children sit under their dep) */
|
|
874
|
+
branch: "" | "├ " | "└ ";
|
|
875
|
+
number?: string;
|
|
876
|
+
id: string;
|
|
877
|
+
prio: string;
|
|
878
|
+
title: string;
|
|
879
|
+
/** e.g. "after KBL-3", "· pi-123", the blocked reason */
|
|
880
|
+
extra?: string;
|
|
881
|
+
tone?: "dim" | "error";
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function laneRows(tasks: ShowTask[], lane: string): ShowRow[] {
|
|
885
|
+
const rows: ShowRow[] = [];
|
|
886
|
+
if (lane === "todo") {
|
|
887
|
+
const ordered = claimOrder(tasks);
|
|
888
|
+
const byId = new Map(ordered.map((task) => [task.id, task]));
|
|
889
|
+
// children share a dep with a later sibling → ├, otherwise └
|
|
890
|
+
const kids = new Map<string, ShowTask[]>();
|
|
891
|
+
for (const task of ordered) {
|
|
892
|
+
for (const dep of task.deps ?? []) {
|
|
893
|
+
if (byId.has(dep)) kids.set(dep, [...(kids.get(dep) ?? []), task]);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
const emitted = new Set<string>();
|
|
897
|
+
const emit = (task: ShowTask, branch: "" | "├ " | "└ ", number: number): void => {
|
|
898
|
+
emitted.add(task.id);
|
|
899
|
+
const waits = (task.waitingFor ?? []).filter((id) => id !== "");
|
|
900
|
+
rows.push({
|
|
901
|
+
branch,
|
|
902
|
+
number: `${number}.`,
|
|
903
|
+
id: task.id,
|
|
904
|
+
prio: PRIO_GLYPH[task.priority ?? "none"] ?? " ",
|
|
905
|
+
title: task.title,
|
|
906
|
+
extra:
|
|
907
|
+
task.ready === false || waits.length > 0
|
|
908
|
+
? `waits for ${waits.join(", ")}`
|
|
909
|
+
: "ready",
|
|
910
|
+
tone: task.ready === false || waits.length > 0 ? "dim" : undefined,
|
|
911
|
+
});
|
|
912
|
+
const children = kids.get(task.id) ?? [];
|
|
913
|
+
children.forEach((child, index) => emit(child, index === children.length - 1 ? "└ " : "├ ", number));
|
|
914
|
+
};
|
|
915
|
+
let n = 0;
|
|
916
|
+
for (const task of ordered) {
|
|
917
|
+
if (emitted.has(task.id)) continue;
|
|
918
|
+
n += 1;
|
|
919
|
+
emit(task, "", n);
|
|
920
|
+
}
|
|
921
|
+
return rows;
|
|
922
|
+
}
|
|
923
|
+
for (const task of tasks) {
|
|
924
|
+
rows.push({
|
|
925
|
+
branch: "",
|
|
926
|
+
id: task.id,
|
|
927
|
+
prio: PRIO_GLYPH[task.priority ?? "none"] ?? " ",
|
|
928
|
+
title: task.title,
|
|
929
|
+
extra:
|
|
930
|
+
lane === "in_progress" && task.run?.session
|
|
931
|
+
? `· ${task.run.session}`
|
|
932
|
+
: lane === "blocked" && task.blockedReason?.text
|
|
933
|
+
? task.blockedReason.text
|
|
934
|
+
: (task.deps ?? []).length > 0
|
|
935
|
+
? `after ${(task.deps ?? []).join(", ")}`
|
|
936
|
+
: undefined,
|
|
937
|
+
tone: lane === "blocked" && task.blockedReason?.text ? "error" : undefined,
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
return rows;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/** Plain-text fallback (also what the message stores as content). */
|
|
944
|
+
export function renderShowPlain(project: string, tasks: ShowTask[], all: boolean): string {
|
|
945
|
+
const lanes = all ? [...SHOW_LANES, ...SHOW_EXTRA_LANES] : SHOW_LANES;
|
|
946
|
+
const lines = [`${project} · ${tasks.length} tasks`];
|
|
947
|
+
for (const lane of lanes) {
|
|
948
|
+
const inLane = tasks.filter((task) => task.status === lane.id);
|
|
949
|
+
if (inLane.length === 0) {
|
|
950
|
+
lines.push(`${lane.label} — empty`);
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
lines.push(lane.label);
|
|
954
|
+
for (const row of laneRows(inLane, lane.id)) {
|
|
955
|
+
lines.push(` ${row.branch}${row.number ? `${row.number} ` : ""}${row.id} ${row.prio} ${row.title}${row.extra ? ` ${row.extra}` : ""}`);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
return lines.join("\n");
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/** The themed renderer registered for SHOW_CUSTOM_TYPE. */
|
|
962
|
+
export function showRenderer(
|
|
963
|
+
message: { content: unknown; details?: { project?: string; tasks?: ShowTask[]; all?: boolean } },
|
|
964
|
+
_options: unknown,
|
|
965
|
+
theme: Theme,
|
|
966
|
+
): { render: (width: number) => string[]; invalidate: () => void } {
|
|
967
|
+
return {
|
|
968
|
+
render(width: number): string[] {
|
|
969
|
+
const details = message.details;
|
|
970
|
+
if (!details?.tasks) return [typeof message.content === "string" ? message.content : ""];
|
|
971
|
+
const lanes = details.all ? [...SHOW_LANES, ...SHOW_EXTRA_LANES] : SHOW_LANES;
|
|
972
|
+
const lines: string[] = [theme.bold(`${details.project ?? "board"} · ${details.tasks.length} tasks`)];
|
|
973
|
+
for (const lane of lanes) {
|
|
974
|
+
const inLane = details.tasks.filter((task) => task.status === lane.id);
|
|
975
|
+
if (inLane.length === 0) {
|
|
976
|
+
lines.push(` ${theme.fg("dim", `${lane.label} — empty`)}`);
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
lines.push(theme.fg("accent", lane.label));
|
|
980
|
+
for (const row of laneRows(inLane, lane.id)) {
|
|
981
|
+
const head = ` ${row.branch}${row.number ? `${row.number} ` : ""}${theme.fg("muted", row.id)} ${row.prio} `;
|
|
982
|
+
// visible length so far, then truncate the title to fit.
|
|
983
|
+
const prefixLen = 2 + row.branch.length + (row.number ? row.number.length + 1 : 0) + row.id.length + 2;
|
|
984
|
+
const budget = Math.max(10, width - prefixLen - (row.extra ? row.extra.length + 2 : 0));
|
|
985
|
+
const title = row.title.length > budget ? `${row.title.slice(0, Math.max(1, budget - 1))}…` : row.title;
|
|
986
|
+
const extra = row.extra ? ` ${row.tone === "error" ? theme.fg("error", row.extra) : theme.fg("dim", row.extra)}` : "";
|
|
987
|
+
lines.push(`${head}${title}${extra}`);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
return lines;
|
|
991
|
+
},
|
|
992
|
+
invalidate: () => undefined,
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
async function runShow(deps: CommandDeps, ctx: ExtensionCommandContext, pi: ExtensionAPI, all: boolean): Promise<void> {
|
|
997
|
+
const client = deps.cli;
|
|
998
|
+
if (!client) {
|
|
999
|
+
ctx.ui.notify(`kanboard: ${deps.unavailable}`, "warning");
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
if (!(await ensureOnboarded(deps, ctx))) return;
|
|
1003
|
+
const slug = currentSlug()!;
|
|
1004
|
+
let tasks: ShowTask[] = [];
|
|
1005
|
+
try {
|
|
1006
|
+
const payload = (await client.run(["list", "--json"], { cwd: ctx.cwd })) as { tasks?: ShowTask[] };
|
|
1007
|
+
tasks = payload.tasks ?? [];
|
|
1008
|
+
} catch (error) {
|
|
1009
|
+
ctx.ui.notify(`kanboard: show failed — ${error instanceof KanboardCliError ? error.message : String(error)}`, "error");
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
pi.sendMessage(
|
|
1013
|
+
{
|
|
1014
|
+
customType: SHOW_CUSTOM_TYPE,
|
|
1015
|
+
content: renderShowPlain(slug, tasks, all),
|
|
1016
|
+
display: true,
|
|
1017
|
+
details: { project: slug, tasks, all },
|
|
1018
|
+
},
|
|
1019
|
+
{ triggerTurn: false },
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
// ─── autocomplete ───────────────────────────────────────────────────────────
|
|
1025
|
+
|
|
1026
|
+
interface CompletionItem {
|
|
1027
|
+
value: string;
|
|
1028
|
+
label: string;
|
|
1029
|
+
description?: string;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* pi-tui's applyCompletion replaces the WHOLE argument prefix with the item's
|
|
1034
|
+
* `value` — so every value must be the full argument text, earlier tokens
|
|
1035
|
+
* included, or accepting a completion would silently delete them.
|
|
1036
|
+
*/
|
|
1037
|
+
function fullArgs(before: string, token: string): string {
|
|
1038
|
+
return before + token;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const SUB_DESCRIPTIONS: Record<Subcommand, string> = {
|
|
1042
|
+
open: "Start the daemon and print the board URL (--host 0.0.0.0|tailscale, --port N)",
|
|
1043
|
+
close: "Shut down the board daemon (the web UI goes offline)",
|
|
1044
|
+
onboard: "Register this project on the board",
|
|
1045
|
+
status: "Daemon, project counts, claims, queue and the runner state",
|
|
1046
|
+
doctor: "Check the whole setup (binary, daemon, project, agent, claims)",
|
|
1047
|
+
show: "The board in chat: lanes + claim order (--all adds cancelled/archived)",
|
|
1048
|
+
};
|
|
1049
|
+
|
|
1050
|
+
/** /unipi:kanboard … */
|
|
1051
|
+
export function kanboardCompletions(prefix: string): CompletionItem[] | null {
|
|
1052
|
+
const raw = prefix.replace(/^\s+/, "");
|
|
1053
|
+
// `open --host <TAB>` → bind addresses (value keeps "open --host ").
|
|
1054
|
+
const hostMatch = /^(open\s+--host\s+)(\S*)$/.exec(raw);
|
|
1055
|
+
if (hostMatch) {
|
|
1056
|
+
const [, before, partial] = hostMatch;
|
|
1057
|
+
return [
|
|
1058
|
+
{ value: "127.0.0.1", label: "127.0.0.1", description: "local only (default)" },
|
|
1059
|
+
{ value: "0.0.0.0", label: "0.0.0.0", description: "every interface, token-gated" },
|
|
1060
|
+
{ value: "tailscale", label: "tailscale", description: "the tailnet IPv4" },
|
|
1061
|
+
]
|
|
1062
|
+
.filter((item) => item.value.startsWith(partial))
|
|
1063
|
+
.map((item) => ({ ...item, value: fullArgs(before!, item.value) }));
|
|
1064
|
+
}
|
|
1065
|
+
// `open --<TAB>` → the flags.
|
|
1066
|
+
const openFlags = /^(open\s+)(\S*)$/.exec(raw) ?? /^(open\s+.*\s)(\S*)$/.exec(raw);
|
|
1067
|
+
if (openFlags && openFlags[2]!.startsWith("--")) {
|
|
1068
|
+
const [, before, partial] = openFlags;
|
|
1069
|
+
return [
|
|
1070
|
+
{ value: "--host", label: "--host", description: "Bind address: 127.0.0.1 · 0.0.0.0 · tailscale" },
|
|
1071
|
+
{ value: "--port", label: "--port", description: "Port to bind (0 = auto)" },
|
|
1072
|
+
]
|
|
1073
|
+
.filter((item) => item.value.startsWith(partial))
|
|
1074
|
+
.map((item) => ({ ...item, value: fullArgs(before!, item.value) }));
|
|
1075
|
+
}
|
|
1076
|
+
// `show --<TAB>` → --all.
|
|
1077
|
+
const showFlags = /^(show\s+)(\S*)$/.exec(raw);
|
|
1078
|
+
if (showFlags) {
|
|
1079
|
+
const [, before, partial] = showFlags;
|
|
1080
|
+
return [{ value: "--all", label: "--all", description: "Include cancelled and archived lanes" }]
|
|
1081
|
+
.filter((item) => item.value.startsWith(partial))
|
|
1082
|
+
.map((item) => ({ ...item, value: fullArgs(before!, item.value) }));
|
|
1083
|
+
}
|
|
1084
|
+
const needle = raw.toLowerCase();
|
|
1085
|
+
const items = SUBCOMMANDS.map((sub) => ({ value: sub, label: sub, description: SUB_DESCRIPTIONS[sub] })).filter((item) =>
|
|
1086
|
+
item.value.startsWith(needle),
|
|
1087
|
+
);
|
|
1088
|
+
return items.length > 0 ? items : null;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/** Live task list for `--after` / `-do` completions, cached ~5s per client. */
|
|
1092
|
+
const taskCache = new WeakMap<KanboardCli, { at: number; tasks: ShowTask[] }>();
|
|
1093
|
+
|
|
1094
|
+
export async function taskCompletions(deps: CommandDeps, prefix: string, before: string): Promise<CompletionItem[] | null> {
|
|
1095
|
+
const client = deps.cli;
|
|
1096
|
+
if (!client) return null;
|
|
1097
|
+
let cache = taskCache.get(client);
|
|
1098
|
+
if (!cache || Date.now() - cache.at > 5_000) {
|
|
1099
|
+
try {
|
|
1100
|
+
const payload = (await client.run(["list", "--json"], { cwd: process.cwd() })) as { tasks?: ShowTask[] };
|
|
1101
|
+
cache = { at: Date.now(), tasks: payload.tasks ?? [] };
|
|
1102
|
+
taskCache.set(client, cache);
|
|
1103
|
+
} catch {
|
|
1104
|
+
return null;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
const needle = prefix.toLowerCase();
|
|
1108
|
+
const items = cache.tasks
|
|
1109
|
+
.filter((task) => !["cancelled", "archived"].includes(task.status))
|
|
1110
|
+
.filter((task) => task.id.toLowerCase().startsWith(needle) || task.title.toLowerCase().includes(needle))
|
|
1111
|
+
.slice(0, 12)
|
|
1112
|
+
.map((task) => ({
|
|
1113
|
+
value: fullArgs(before, task.id),
|
|
1114
|
+
label: task.id,
|
|
1115
|
+
description: `${task.status} — ${task.title}`,
|
|
1116
|
+
}));
|
|
1117
|
+
return items.length > 0 ? items : null;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const PRIORITY_FLAGS: Array<[string, string]> = [
|
|
1121
|
+
["1", "none"],
|
|
1122
|
+
["2", "low"],
|
|
1123
|
+
["3", "medium"],
|
|
1124
|
+
["4", "high"],
|
|
1125
|
+
["5", "urgent"],
|
|
1126
|
+
];
|
|
1127
|
+
|
|
1128
|
+
/** /unipi:kanboard-add … */
|
|
1129
|
+
export async function kanboardAddCompletions(deps: CommandDeps, prefix: string): Promise<CompletionItem[] | null> {
|
|
1130
|
+
const raw = prefix ?? "";
|
|
1131
|
+
// The last whitespace-separated token is being completed; keep the rest.
|
|
1132
|
+
const match = /^(.*?)(\S*)$/.exec(raw.replace(/\s+$/, " "))!;
|
|
1133
|
+
const before = match[1]!;
|
|
1134
|
+
const token = match[2]!;
|
|
1135
|
+
|
|
1136
|
+
// `--after <ID>` → live tasks.
|
|
1137
|
+
if (/(^|\s)--after\s+$/.test(`${before} `) || /(^|\s)--after\s+\S*$/.test(raw)) {
|
|
1138
|
+
const m = /^(.*?--after\s+)(\S*)$/.exec(raw);
|
|
1139
|
+
if (m) return taskCompletions(deps, m[2]!, m[1]!);
|
|
1140
|
+
if (raw.endsWith("--after ") || raw.endsWith("--after")) {
|
|
1141
|
+
return taskCompletions(deps, "", raw.endsWith(" ") ? raw : `${raw} `);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
// `-p <n>` → 1..5 with labels.
|
|
1145
|
+
const pMatch = /^(.*?-p\s+)(\S*)$/.exec(raw) ?? /^(.*?)(-p)$/.exec(raw);
|
|
1146
|
+
if (pMatch && !token.startsWith("--")) {
|
|
1147
|
+
const [, pbefore, ptoken] = pMatch;
|
|
1148
|
+
const digits = ptoken === "-p" ? "" : ptoken;
|
|
1149
|
+
return PRIORITY_FLAGS.filter(([n]) => n.startsWith(digits)).map(([n, name]) => ({
|
|
1150
|
+
value: fullArgs(pbefore!, n),
|
|
1151
|
+
label: `-p ${n}`,
|
|
1152
|
+
description: name,
|
|
1153
|
+
}));
|
|
1154
|
+
}
|
|
1155
|
+
// `--priority <name>`.
|
|
1156
|
+
const priMatch = /^(.*?--priority\s+)(\S*)$/.exec(raw) ?? /^(.*?)(--priority)$/.exec(raw);
|
|
1157
|
+
if (priMatch) {
|
|
1158
|
+
const [, pbefore, ptoken] = priMatch;
|
|
1159
|
+
const part = ptoken === "--priority" ? "" : ptoken;
|
|
1160
|
+
return PRIORITY_FLAGS.map(([, name]) => name)
|
|
1161
|
+
.filter((name) => name.startsWith(part))
|
|
1162
|
+
.map((name) => ({ value: fullArgs(pbefore!, name), label: name, description: "priority" }));
|
|
1163
|
+
}
|
|
1164
|
+
// `--status backlog|todo`.
|
|
1165
|
+
const stMatch = /^(.*?--status\s+)(\S*)$/.exec(raw) ?? /^(.*?)(--status)$/.exec(raw);
|
|
1166
|
+
if (stMatch) {
|
|
1167
|
+
const [, sbefore, stoken] = stMatch;
|
|
1168
|
+
const part = stoken === "--status" ? "" : stoken;
|
|
1169
|
+
return ["backlog", "todo"]
|
|
1170
|
+
.filter((name) => name.startsWith(part))
|
|
1171
|
+
.map((name) => ({ value: fullArgs(sbefore!, name), label: name, description: "lane" }));
|
|
1172
|
+
}
|
|
1173
|
+
// Bare token → the flags themselves.
|
|
1174
|
+
if (token.startsWith("-")) {
|
|
1175
|
+
const flags = ["-p", "--priority", "--status", "--after"];
|
|
1176
|
+
return flags
|
|
1177
|
+
.filter((flag) => flag.startsWith(token))
|
|
1178
|
+
.map((flag) => ({
|
|
1179
|
+
value: fullArgs(before, flag),
|
|
1180
|
+
label: flag,
|
|
1181
|
+
description:
|
|
1182
|
+
flag === "-p" || flag === "--priority"
|
|
1183
|
+
? "Priority: none · low · medium · high · urgent"
|
|
1184
|
+
: flag === "--status"
|
|
1185
|
+
? "Lane: backlog|todo"
|
|
1186
|
+
: "Runs after <task id>",
|
|
1187
|
+
}));
|
|
1188
|
+
}
|
|
1189
|
+
return null;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/** /unipi:kanboard-do … — complete a trailing task-id prefix. */
|
|
1193
|
+
export async function kanboardDoCompletions(deps: CommandDeps, prefix: string): Promise<CompletionItem[] | null> {
|
|
1194
|
+
const raw = prefix ?? "";
|
|
1195
|
+
const match = /^(.*?)(\S*)$/.exec(raw)!;
|
|
1196
|
+
const before = match[1]!;
|
|
1197
|
+
const token = match[2]!;
|
|
1198
|
+
// Only when the last token looks like a task id prefix (ABC-…).
|
|
1199
|
+
if (!/^[A-Za-z]{2,6}-?[0-9]*$/.test(token) || token.length < 2) return null;
|
|
1200
|
+
return taskCompletions(deps, token, before);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
export function registerKanboardCommands(pi: ExtensionAPI, deps: CommandDeps): void {
|
|
1204
|
+
// Help and doctor output is display-only: it must never enter the LLM's
|
|
1205
|
+
// context, so a `context` hook drops these custom types every turn.
|
|
1206
|
+
// The board view renders itself (theme + terminal width); the stored content
|
|
1207
|
+
// stays plain text as a fallback and the context filter drops it.
|
|
1208
|
+
if (typeof pi.registerMessageRenderer === "function") {
|
|
1209
|
+
pi.registerMessageRenderer(SHOW_CUSTOM_TYPE, (message, options, theme) =>
|
|
1210
|
+
showRenderer(message as { content: unknown; details?: { project?: string; tasks?: ShowTask[]; all?: boolean } }, options, theme),
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
pi.on("context", (event) => {
|
|
1215
|
+
const hidden = new Set([HELP_CUSTOM_TYPE, DOCTOR_CUSTOM_TYPE, SHOW_CUSTOM_TYPE]);
|
|
1216
|
+
const filtered = event.messages.filter(
|
|
1217
|
+
(message) => !hidden.has((message as { customType?: string }).customType ?? ""),
|
|
1218
|
+
);
|
|
1219
|
+
if (filtered.length !== event.messages.length) return { messages: filtered };
|
|
1220
|
+
return undefined;
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
// Board writes through bash are gated by the write window: read-only
|
|
1224
|
+
// subcommands always pass, everything else needs a -do turn or a running task.
|
|
1225
|
+
pi.on("tool_call", async (event) => {
|
|
1226
|
+
const toolName = (event as { toolName?: string }).toolName;
|
|
1227
|
+
if (toolName !== "bash" && toolName !== "powershell") return undefined;
|
|
1228
|
+
const command = String((event as { input?: { command?: unknown } }).input?.command ?? "");
|
|
1229
|
+
// Limits live in the environment; refresh so this bash call inherits the
|
|
1230
|
+
// current settings (the spawned CLI reads them).
|
|
1231
|
+
applyLimitEnv(deps.settings());
|
|
1232
|
+
const reason = deps.guard.check(command);
|
|
1233
|
+
return reason ? { block: true, reason } : undefined;
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
pi.registerCommand("unipi:kanboard", {
|
|
1237
|
+
description: "Kanboard — the board: open, onboard, status, doctor (bare = help)",
|
|
1238
|
+
getArgumentCompletions: (prefix: string) => kanboardCompletions(prefix ?? ""),
|
|
1239
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
1240
|
+
const parts = (args ?? "").trim().split(/\s+/).filter(Boolean);
|
|
1241
|
+
const sub = (parts[0] ?? "").toLowerCase();
|
|
1242
|
+
const rest = args?.trim().slice(parts[0]?.length ?? 0).trim() ?? "";
|
|
1243
|
+
|
|
1244
|
+
// Bare `/unipi:kanboard` lists the commands (display-only, never LLM context).
|
|
1245
|
+
if (sub === "") {
|
|
1246
|
+
pi.sendMessage({ customType: HELP_CUSTOM_TYPE, content: HELP, display: true }, { triggerTurn: false });
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// Old entry points now just point at the new commands.
|
|
1251
|
+
if (sub === "add") {
|
|
1252
|
+
ctx.ui.notify("kanboard: use /unipi:kanboard-add <title> — lines below the title become the description", "info");
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
if (sub === "work") {
|
|
1256
|
+
ctx.ui.notify("kanboard: use /unipi:kanboard-autowork start to work the queue", "info");
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
if (sub === "stop") {
|
|
1260
|
+
ctx.ui.notify("kanboard: use /unipi:kanboard-autowork stop to finish the current task and stop", "info");
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
if (!SUBCOMMANDS.includes(sub as Subcommand)) {
|
|
1265
|
+
ctx.ui.notify(`kanboard: unknown subcommand "${sub}" — /unipi:kanboard lists the commands`, "warning");
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
if (!deps.cli && sub !== "open") {
|
|
1269
|
+
ctx.ui.notify(`kanboard: ${deps.unavailable}`, "warning");
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
switch (sub as Subcommand) {
|
|
1274
|
+
case "onboard":
|
|
1275
|
+
await runOnboard(deps, ctx);
|
|
1276
|
+
return;
|
|
1277
|
+
case "close":
|
|
1278
|
+
await runStopDaemon(deps, ctx);
|
|
1279
|
+
return;
|
|
1280
|
+
case "status":
|
|
1281
|
+
await runStatus(deps, ctx);
|
|
1282
|
+
return;
|
|
1283
|
+
case "doctor":
|
|
1284
|
+
await runDoctor(deps, ctx, pi);
|
|
1285
|
+
return;
|
|
1286
|
+
case "show":
|
|
1287
|
+
await runShow(deps, ctx, pi, parts.includes("--all"));
|
|
1288
|
+
return;
|
|
1289
|
+
case "open":
|
|
1290
|
+
if (!deps.cli) {
|
|
1291
|
+
ctx.ui.notify(`kanboard: ${deps.unavailable}`, "warning");
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
await runOpen(deps, ctx, rest);
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
},
|
|
1298
|
+
});
|
|
1299
|
+
|
|
1300
|
+
pi.registerCommand("unipi:kanboard-add", {
|
|
1301
|
+
description: "Add a board task — [-p 1-5] [--after ID] [--status backlog|todo] <title>; lines below are the description (paste file paths to attach)",
|
|
1302
|
+
getArgumentCompletions: (prefix: string) => kanboardAddCompletions(deps, prefix ?? ""),
|
|
1303
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
1304
|
+
await runAdd(deps, ctx, args ?? "");
|
|
1305
|
+
},
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
pi.registerCommand("unipi:kanboard-do", {
|
|
1309
|
+
description: "Let the agent use the kanboard for this turn — writes are allowed until the turn ends",
|
|
1310
|
+
getArgumentCompletions: (prefix: string) => kanboardDoCompletions(deps, prefix ?? ""),
|
|
1311
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
1312
|
+
const request = (args ?? "").trim();
|
|
1313
|
+
if (!request) {
|
|
1314
|
+
ctx.ui.notify("kanboard: -do needs a request — /unipi:kanboard-do <what the agent may do on the board>", "warning");
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
const client = deps.cli;
|
|
1318
|
+
if (!client) {
|
|
1319
|
+
ctx.ui.notify(`kanboard: ${deps.unavailable}`, "warning");
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
if (!(await ensureOnboarded(deps, ctx))) return;
|
|
1323
|
+
const slug = currentSlug()!;
|
|
1324
|
+
deps.revealSkill(ctx);
|
|
1325
|
+
deps.guard.open();
|
|
1326
|
+
const busy = typeof (ctx as unknown as { isIdle?: () => boolean }).isIdle === "function"
|
|
1327
|
+
? !(ctx as unknown as { isIdle: () => boolean }).isIdle!()
|
|
1328
|
+
: false;
|
|
1329
|
+
pi.sendUserMessage(doText(slug, client.binary.path, request, deps.settings().queueMax), busy ? { deliverAs: "followUp" } : undefined);
|
|
1330
|
+
deps.guard.noteSent();
|
|
1331
|
+
deps.debug(`do window open (${slug})`);
|
|
1332
|
+
},
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1335
|
+
pi.registerCommand("unipi:kanboard-autowork", {
|
|
1336
|
+
description: "Work ready board tasks one by one — start | stop (stop finishes the current task first)",
|
|
1337
|
+
getArgumentCompletions: (prefix: string) =>
|
|
1338
|
+
(["start", "stop"] as const)
|
|
1339
|
+
.filter((word) => word.startsWith((prefix ?? "").trim().toLowerCase()))
|
|
1340
|
+
.map((word) => ({
|
|
1341
|
+
value: word,
|
|
1342
|
+
label: word,
|
|
1343
|
+
description: word === "start" ? "Work ready tasks one by one" : "Finish the current task, then stop",
|
|
1344
|
+
})),
|
|
1345
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
1346
|
+
const sub = (args ?? "").trim().toLowerCase();
|
|
1347
|
+
if (sub === "start") {
|
|
1348
|
+
if (!(await ensureOnboarded(deps, ctx))) return;
|
|
1349
|
+
await deps.work(ctx as unknown as ExtensionContext);
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
if (sub === "stop") {
|
|
1353
|
+
deps.stop(ctx as unknown as ExtensionContext);
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
ctx.ui.notify("kanboard: /unipi:kanboard-autowork start|stop", "warning");
|
|
1357
|
+
},
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
/** Back-compat name for the single-command registration. */
|
|
1362
|
+
export const registerKanboardCommand = registerKanboardCommands;
|
|
1363
|
+
|
|
1364
|
+
/** CLI presence probe used at session start (cheap, no spawn). */
|
|
1365
|
+
export function binaryLooksReady(path: string): boolean {
|
|
1366
|
+
try {
|
|
1367
|
+
return existsSync(path);
|
|
1368
|
+
} catch {
|
|
1369
|
+
return false;
|
|
1370
|
+
}
|
|
1371
|
+
}
|