pi-kanban 0.3.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +61 -21
  3. package/cli.js +1586 -0
  4. package/extensions/kanban.ts +330 -367
  5. package/extensions/package.json +1 -0
  6. package/extensions/tools/ask.ts +85 -0
  7. package/extensions/tools/board-api.ts +142 -0
  8. package/extensions/tools/cli-path.ts +34 -0
  9. package/extensions/tools/dispatch.ts +56 -0
  10. package/extensions/tools/inbox.ts +86 -0
  11. package/extensions/tools/kanban-command.ts +27 -0
  12. package/extensions/tools/scratchpad.ts +70 -0
  13. package/extensions/tsconfig.json +16 -0
  14. package/lib/agent-dir.js +20 -0
  15. package/lib/agent-log.js +64 -0
  16. package/lib/ask.js +252 -0
  17. package/lib/atomic-write.js +30 -0
  18. package/lib/board-dir.js +63 -0
  19. package/lib/claude-settings.js +21 -0
  20. package/lib/companion-host.js +142 -0
  21. package/lib/companions.js +136 -0
  22. package/lib/contain.js +78 -0
  23. package/lib/dispatch-reports.js +86 -0
  24. package/lib/file-url.js +38 -0
  25. package/lib/folder-dialog.js +91 -0
  26. package/lib/frame-policy.js +79 -0
  27. package/lib/git-branch.js +37 -102
  28. package/lib/http-error.js +7 -0
  29. package/lib/inline-assets.js +238 -0
  30. package/lib/linked-docs.js +56 -0
  31. package/lib/live-sessions.js +86 -0
  32. package/lib/model-windows.js +48 -0
  33. package/lib/net-guard.js +277 -0
  34. package/lib/one-line.js +6 -0
  35. package/lib/open-editor.js +173 -0
  36. package/lib/panes.js +213 -0
  37. package/lib/parsers.js +1434 -0
  38. package/lib/pi-agents.js +178 -0
  39. package/lib/pi-git-branch.js +112 -0
  40. package/lib/pi-models.js +34 -0
  41. package/lib/pi-sessions.js +240 -0
  42. package/lib/pi-transcript.js +354 -0
  43. package/lib/preview-action.js +55 -0
  44. package/lib/priority.js +45 -0
  45. package/lib/proc-stats.js +82 -0
  46. package/lib/retention.js +191 -0
  47. package/lib/scratch-files.js +47 -0
  48. package/lib/scratchpad-dir.js +95 -0
  49. package/lib/session-cache.js +32 -0
  50. package/lib/session-events.js +275 -0
  51. package/lib/session-pins.js +99 -0
  52. package/lib/show.js +279 -0
  53. package/lib/stamped-json-file.js +49 -0
  54. package/lib/task-counts.js +90 -0
  55. package/lib/task-dir.js +26 -0
  56. package/lib/task-store.js +44 -47
  57. package/lib/terminal-client.js +248 -0
  58. package/lib/terminal-host.js +113 -0
  59. package/lib/terminal.js +1116 -0
  60. package/lib/user-groups.js +384 -0
  61. package/lib/worktrees.js +132 -0
  62. package/package.json +39 -15
  63. package/public/app.js +14349 -3448
  64. package/public/fonts/OFL-IBM-Plex.txt +93 -0
  65. package/public/fonts/OFL-Playfair-Display.txt +93 -0
  66. package/public/fonts/fonts.css +244 -0
  67. package/public/fonts/ibm-plex-mono-cyrillic-400.woff2 +0 -0
  68. package/public/fonts/ibm-plex-mono-cyrillic-500.woff2 +0 -0
  69. package/public/fonts/ibm-plex-mono-cyrillic-600.woff2 +0 -0
  70. package/public/fonts/ibm-plex-mono-cyrillic-ext-400.woff2 +0 -0
  71. package/public/fonts/ibm-plex-mono-cyrillic-ext-500.woff2 +0 -0
  72. package/public/fonts/ibm-plex-mono-cyrillic-ext-600.woff2 +0 -0
  73. package/public/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
  74. package/public/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
  75. package/public/fonts/ibm-plex-mono-latin-600.woff2 +0 -0
  76. package/public/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
  77. package/public/fonts/ibm-plex-mono-latin-ext-500.woff2 +0 -0
  78. package/public/fonts/ibm-plex-mono-latin-ext-600.woff2 +0 -0
  79. package/public/fonts/ibm-plex-mono-vietnamese-400.woff2 +0 -0
  80. package/public/fonts/ibm-plex-mono-vietnamese-500.woff2 +0 -0
  81. package/public/fonts/ibm-plex-mono-vietnamese-600.woff2 +0 -0
  82. package/public/fonts/playfair-display-cyrillic-400.woff2 +0 -0
  83. package/public/fonts/playfair-display-latin-400.woff2 +0 -0
  84. package/public/fonts/playfair-display-latin-ext-400.woff2 +0 -0
  85. package/public/fonts/playfair-display-vietnamese-400.woff2 +0 -0
  86. package/public/group-tree.js +21 -0
  87. package/public/icons/icon-192.png +0 -0
  88. package/public/icons/icon-512.png +0 -0
  89. package/public/icons/icon-svg.svg +7 -5
  90. package/public/index.html +493 -328
  91. package/public/link-url.js +18 -0
  92. package/public/manifest.json +7 -7
  93. package/public/project-match.js +34 -0
  94. package/public/style.css +8301 -2251
  95. package/public/sw.js +4 -3
  96. package/public/terminal-frame.js +437 -0
  97. package/public/terminal.html +26 -0
  98. package/public/themes.css +876 -0
  99. package/public/tree-highlight.js +196 -0
  100. package/server.js +3368 -467
  101. package/skills/ask/SKILL.md +29 -0
  102. package/skills/ask/references/playground.md +25 -0
  103. package/skills/dispatch/SKILL.md +51 -0
  104. package/skills/dispatch/references/orchestration-patterns.md +50 -0
  105. package/skills/kanban/SKILL.md +46 -0
  106. package/skills/show/SKILL.md +41 -0
  107. package/skills/show/references/NOTICE.md +27 -0
  108. package/skills/show/references/chart.md +58 -0
  109. package/skills/show/references/diagram/architecture.md +18 -0
  110. package/skills/show/references/diagram/er.md +10 -0
  111. package/skills/show/references/diagram/flow.md +13 -0
  112. package/skills/show/references/diagram/layers.md +10 -0
  113. package/skills/show/references/diagram/sequence.md +19 -0
  114. package/skills/show/references/diagram/state.md +12 -0
  115. package/skills/show/references/diagram/swimlane.md +10 -0
  116. package/skills/show/references/diagram/timeline.md +9 -0
  117. package/skills/show/references/diagram/tree.md +10 -0
  118. package/skills/show/references/diagram.md +87 -0
  119. package/skills/show/references/explain.md +100 -0
  120. package/skills/show/references/html-contract.md +18 -0
  121. package/skills/show/references/mockup.md +30 -0
  122. package/skills/visualize/SKILL.md +73 -0
  123. package/skills/visualize/blocks.html +68 -0
  124. package/skills/visualize/template.html +179 -0
  125. package/lib/pi-parsers.js +0 -1000
  126. package/themes/kanban-dark-default.json +0 -21
  127. package/themes/kanban-default.json +0 -21
  128. package/themes/pi-dark.json +0 -21
  129. package/themes/pi-light.json +0 -21
@@ -0,0 +1 @@
1
+ { "type": "module" }
@@ -0,0 +1,85 @@
1
+ import { Type } from "typebox";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { BoardDownError, boardFetch, boardTerminalId, okJson, pollSettled, POLL_MS, postJson, resolveTarget, sessionIdOf, terminalToken, toolText as text } from "./board-api.js";
4
+
5
+ type Answer = { status: "answered"; data: unknown } | { status: "dismissed" | "cancelled" | "gone" | "pending"; reason?: string };
6
+ export type AskOpen = { title: string; args: unknown };
7
+
8
+ function answerText(title: string, a: Answer): string {
9
+ if (a.status === "answered") return `The user answered "${title}":\n${JSON.stringify(a.data, null, 2)}`;
10
+ if (a.status === "dismissed" && a.reason === "replaced") return `"${title}" was replaced by a newer question.`;
11
+ if (a.status === "dismissed") return `The user skipped "${title}" without answering. Go on with your best default, and ask in chat only when no default works.`;
12
+ return `"${title}" is no longer open on the board; the user did not answer it.`;
13
+ }
14
+
15
+ const ASK_PARAMS = Type.Object({
16
+ title: Type.String({ description: "The question, short: it titles the modal and the chip that reopens it" }),
17
+ file: Type.String({ description: "Path of the HTML card: absolute, or relative to the session cwd" }),
18
+ size: Type.Optional(
19
+ Type.Union([Type.Literal("md"), Type.Literal("lg"), Type.Literal("full")], {
20
+ description: "Modal size: md for a short form, lg (default) for most cards, full for a playground or a quiz sheet",
21
+ }),
22
+ ),
23
+ });
24
+
25
+ const INTERRUPTED = "Interrupted; the question was closed.";
26
+
27
+ export const ASK_TOOL = "ask";
28
+
29
+ // A board ask fires no UI prompt event, so the live state learns of it from these.
30
+ export const ASK_OPEN = "kanban:ask-open";
31
+ export const ASK_CLOSE = "kanban:ask-close";
32
+
33
+ // The call waits until the user answers or skips. An abort closes the modal, because the run went on without it.
34
+ async function askOnBoard(pi: ExtensionAPI, base: string, headers: Record<string, string>, body: object, open: AskOpen, signal?: AbortSignal): Promise<string> {
35
+ const { id } = await okJson(postJson(base, body, { headers, signal }));
36
+ const at = `${base}/${encodeURIComponent(id)}`;
37
+ pi.events.emit(ASK_OPEN, open);
38
+ try {
39
+ return answerText(open.title, await pollSettled<Answer>(`${at}/wait?ms=${POLL_MS}`, { headers, signal }));
40
+ } catch (e) {
41
+ if (!signal?.aborted) throw e;
42
+ await boardFetch(at, { method: "DELETE", headers }).catch(() => {});
43
+ throw new Error(INTERRUPTED);
44
+ } finally {
45
+ pi.events.emit(ASK_CLOSE, open);
46
+ }
47
+ }
48
+
49
+ // Without a board the TUI asks in a line of text: the card cannot render there.
50
+ async function askInTui(ctx: ExtensionContext, title: string, file: string, signal?: AbortSignal): Promise<string> {
51
+ ctx.ui.notify(`The pi-kanban board is not running, so the card ${file} cannot open. Answer in text.`, "warning");
52
+ const answer = await ctx.ui.input(title, "Type the answer, or press Esc to skip", { signal });
53
+ if (signal?.aborted) throw new Error(INTERRUPTED);
54
+ return answer === undefined ? answerText(title, { status: "dismissed" }) : answerText(title, { status: "answered", data: answer });
55
+ }
56
+
57
+ export function registerAsk(pi: ExtensionAPI) {
58
+ const terminal = boardTerminalId();
59
+
60
+ pi.registerTool({
61
+ name: ASK_TOOL,
62
+ label: "Ask",
63
+ description:
64
+ "Ask the user through an HTML card in a modal on the pi-kanban board, and wait. The result is the data the card sent, as JSON, or a line that says the user skipped. Write the card to an HTML file in your scratchpad and pass it as file. The card sends the answer with pk.send(data), a <form data-pk-submit> or a <button data-pk-action>.",
65
+ promptSnippet:
66
+ "Ask the user on an HTML card on the pi-kanban board and wait for the answer, when it needs something to see or control: swatches, a diagram, sliders, a long form, a quiz sheet, a playground",
67
+ promptGuidelines: ["Before you write an ask card, read the ask skill: it has the card's HTML contract and theme variables."],
68
+ parameters: ASK_PARAMS,
69
+ annotations: { destructiveHint: false, openWorldHint: false },
70
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
71
+ const sessionId = sessionIdOf(ctx);
72
+ const file = resolveTarget(ctx.cwd, params.file);
73
+ const body = { sessionId, title: params.title, file, size: params.size };
74
+ try {
75
+ const [base, headers] = terminal
76
+ ? [`/api/terminals/${encodeURIComponent(terminal)}/ask`, { "x-terminal-token": terminalToken() }]
77
+ : [`/api/sessions/${encodeURIComponent(sessionId)}/ask`, {}];
78
+ return text(await askOnBoard(pi, base, headers, body, { title: params.title, args: params }, signal));
79
+ } catch (e) {
80
+ if (e instanceof BoardDownError && ctx.hasUI) return text(await askInTui(ctx, params.title, file, signal));
81
+ throw e;
82
+ }
83
+ },
84
+ });
85
+ }
@@ -0,0 +1,142 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { homedir } from "node:os";
4
+ import { isAbsolute, join as joinPath, resolve as resolvePath } from "node:path";
5
+ import { getAgentDir, type ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+
7
+ const DEFAULT_PORT = 3460;
8
+
9
+ const requireHere = createRequire(import.meta.url);
10
+ // The board's CommonJS modules, shared with the extension.
11
+ export const lib = (name: string) => requireHere(`../../lib/${name}`);
12
+
13
+ const { linkUrl } = requireHere("../../public/link-url.js") as { linkUrl: (v: unknown) => string | null };
14
+ export const isUrl = (target: string) => linkUrl(target) !== null;
15
+
16
+ // The board resolves a path against its own cwd, so a relative path is made absolute here.
17
+ // A file:// URL is left to the board, which converts it.
18
+ export function resolveTarget(cwd: string, input: string): string {
19
+ const target = input.trim();
20
+ if (!target) throw new Error("A path or URL is required.");
21
+ if (isUrl(target) || /^file:/i.test(target)) return target;
22
+ const expanded = target === "~" || /^~[\\/]/.test(target) ? joinPath(homedir(), target.slice(1)) : target;
23
+ // Git Bash hands out `/c/x` for `C:\x`, as cliPath in cli.js also handles.
24
+ const native = process.platform === "win32" ? expanded.replace(/^\/([a-zA-Z])(?=\/|$)/, "$1:/") : expanded;
25
+ return isAbsolute(native) ? resolvePath(native) : resolvePath(cwd, native);
26
+ }
27
+
28
+ const BOARD_DOWN ='The pi-kanban board is not running. Ask the user to start it with "pi-kanban".';
29
+
30
+ export function kanbanDir(): string {
31
+ return joinPath(getAgentDir(), "kanban");
32
+ }
33
+
34
+ // EPERM means the process exists but belongs to someone else.
35
+ function isPidAlive(pid: number): boolean {
36
+ try {
37
+ process.kill(pid, 0);
38
+ return true;
39
+ } catch (e: any) {
40
+ return e?.code === "EPERM";
41
+ }
42
+ }
43
+
44
+ export function readText(file: string): string | null {
45
+ try {
46
+ return readFileSync(file, "utf8");
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ function readJson(file: string): any {
53
+ try {
54
+ return JSON.parse(readText(file) ?? "null");
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ // The same rule as cli.js: PK_URL, then this agent dir's server.json beacon, then the default port.
61
+ // Read on every call, so a board that restarted on another port is found. Null means the beacon
62
+ // names a server that is gone, so the default port could be another agent dir's board.
63
+ export function boardUrl(): string | null {
64
+ if (process.env.PK_URL) return process.env.PK_URL.replace(/\/+$/, "");
65
+ const beacon = readJson(joinPath(kanbanDir(), "server.json"));
66
+ if (!beacon) return `http://127.0.0.1:${DEFAULT_PORT}`;
67
+ return beacon.port && beacon.pid && isPidAlive(beacon.pid) ? `http://127.0.0.1:${beacon.port}` : null;
68
+ }
69
+
70
+ export class BoardDownError extends Error {
71
+ constructor(cause?: unknown) {
72
+ super(BOARD_DOWN, { cause });
73
+ this.name = "BoardDownError";
74
+ }
75
+ }
76
+
77
+ // Rejects with BoardDownError when no board answers, so a caller can tell "board down" from an HTTP error.
78
+ export async function boardFetch(path: string, init?: RequestInit): Promise<Response> {
79
+ const url = boardUrl();
80
+ if (!url) throw new BoardDownError();
81
+ try {
82
+ return await fetch(`${url}${path}`, init);
83
+ } catch (e: any) {
84
+ if (e?.name === "AbortError") throw e;
85
+ throw new BoardDownError(e);
86
+ }
87
+ }
88
+
89
+ // Read on every call: a restarted board writes a new token.
90
+ export function terminalToken(): string {
91
+ const url = boardUrl();
92
+ if (!url) throw new BoardDownError();
93
+ const port = new URL(url).port;
94
+ const token = readJson(joinPath(kanbanDir(), "terminal-tokens", `${port}.json`))?.token;
95
+ if (typeof token === "string" && token) return token;
96
+ throw new Error(`No terminal token for the pi-kanban board on port ${port}. It must run with the terminal enabled.`);
97
+ }
98
+
99
+ export const { boardTerminalId } = lib("board-dir.js") as { boardTerminalId: () => string | null };
100
+
101
+ // Under the board's 25 s cap on a long-poll, so each poll answers before the board gives up on it.
102
+ export const POLL_MS = 20_000;
103
+
104
+ export async function pollSettled<T extends { status: string }>(path: string, init: RequestInit = {}): Promise<T> {
105
+ for (;;) {
106
+ const got: T = await okJson(boardFetch(path, { cache: "no-store", ...init }));
107
+ if (got.status !== "pending") return got;
108
+ }
109
+ }
110
+
111
+ export function postJson(path: string, body: unknown, init: RequestInit = {}): Promise<Response> {
112
+ return boardFetch(path, {
113
+ ...init,
114
+ method: init.method ?? "POST",
115
+ headers: { "Content-Type": "application/json", ...(init.headers as Record<string, string> | undefined) },
116
+ body: JSON.stringify(body),
117
+ });
118
+ }
119
+
120
+ // The board answers errors as { error }.
121
+ export async function errorText(res: Response): Promise<string> {
122
+ const text = await res.text().catch(() => "");
123
+ try {
124
+ const { error } = JSON.parse(text);
125
+ if (typeof error === "string" && error) return error;
126
+ } catch {}
127
+ return `pi-kanban answered HTTP ${res.status}`;
128
+ }
129
+
130
+ export async function okJson(pending: Promise<Response>): Promise<any> {
131
+ const res = await pending;
132
+ if (!res.ok) throw new Error(await errorText(res));
133
+ return res.status === 204 ? null : res.json();
134
+ }
135
+
136
+ export function sessionIdOf(ctx: ExtensionContext): string {
137
+ const id = ctx.sessionManager.getSessionId();
138
+ if (!id) throw new Error("This pi session has no id yet.");
139
+ return id;
140
+ }
141
+
142
+ export const toolText = (t: string) => ({ content: [{ type: "text" as const, text: t }], details: undefined });
@@ -0,0 +1,34 @@
1
+ import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { delimiter, join as joinPath } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { kanbanDir, readText } from "./board-api.js";
5
+
6
+ const SERVER_ENV = "PI_KANBAN_SERVER";
7
+
8
+ // The shims read the server from the env, so every pi process runs the server.js of the package
9
+ // that loaded it, whatever copy last wrote the shared shims.
10
+ const SHIMS: Record<string, string> = {
11
+ "pi-kanban": `#!/bin/sh\nexec node "$${SERVER_ENV}" "$@"\n`,
12
+ "pi-kanban.cmd": `@node "%${SERVER_ENV}%" %*\r\n`,
13
+ };
14
+
15
+ // The skills run `pi-kanban`. The shims go first on PATH, so the CLI is the version of the skills
16
+ // pi loaded, also when another version or none is installed globally. They are written here, not
17
+ // shipped, because an npm pack made on Windows drops the executable bit.
18
+ export function addCliToPath(dir = joinPath(kanbanDir(), "bin")): void {
19
+ process.env[SERVER_ENV] = fileURLToPath(new URL("../../server.js", import.meta.url));
20
+ try {
21
+ mkdirSync(dir, { recursive: true });
22
+ for (const [name, text] of Object.entries(SHIMS)) {
23
+ const file = joinPath(dir, name);
24
+ if (readText(file) === text) continue;
25
+ writeFileSync(file, text);
26
+ chmodSync(file, 0o755);
27
+ }
28
+ } catch (e: any) {
29
+ console.warn(`pi-kanban: CLI shims failed: ${e?.message ?? e}`);
30
+ return;
31
+ }
32
+ const parts = (process.env.PATH ?? "").split(delimiter).filter((p) => p && p !== dir);
33
+ process.env.PATH = [dir, ...parts].join(delimiter);
34
+ }
@@ -0,0 +1,56 @@
1
+ import { Type } from "typebox";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { boardFetch, boardTerminalId, okJson, postJson, sessionIdOf, toolText as text } from "./board-api.js";
4
+
5
+ export const REPORT_TOOL = "dispatch_report";
6
+
7
+ const PROBE_TIMEOUT_MS = 5_000;
8
+
9
+ const WORKER_PROMPT = [
10
+ "pi-kanban started this session for another pi session, which waits for your result.",
11
+ `When the task is done, or you cannot finish it, call ${REPORT_TOOL} once with a short report: what you did, the outcome, and the paths of files with details.`,
12
+ "Keep the report under 2000 characters; it is all the other session gets.",
13
+ ].join("\n");
14
+
15
+ async function isDispatched(sessionId: string): Promise<boolean> {
16
+ try {
17
+ const res = await boardFetch(`/api/dispatch/${encodeURIComponent(sessionId)}/report`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
18
+ return res.ok;
19
+ } catch (e: any) {
20
+ console.warn(`pi-kanban: could not ask the board whether this session is a dispatch: ${e?.message ?? e}`);
21
+ return false;
22
+ }
23
+ }
24
+
25
+ export function registerDispatch(pi: ExtensionAPI) {
26
+ let worker = false;
27
+
28
+ // Only a session in the board's terminal can be a dispatch, and the board knows which ones are.
29
+ // It asks on every start, so a dispatch the board resumed after a restart still reports.
30
+ pi.on("session_start", async (_event, ctx) => {
31
+ const sessionId = ctx.sessionManager.getSessionId();
32
+ if (!sessionId) return;
33
+ worker = !!boardTerminalId() && (await isDispatched(sessionId));
34
+ const active = pi.getActiveTools();
35
+ if (active.includes(REPORT_TOOL) === worker) return;
36
+ pi.setActiveTools(worker ? [...active, REPORT_TOOL] : active.filter((t) => t !== REPORT_TOOL));
37
+ });
38
+
39
+ pi.on("before_agent_start", async (event) => {
40
+ if (worker) event.systemPromptOptions.sections.dispatch = WORKER_PROMPT;
41
+ else delete event.systemPromptOptions.sections.dispatch;
42
+ });
43
+
44
+ pi.registerTool({
45
+ name: REPORT_TOOL,
46
+ label: "Dispatch report",
47
+ description: "Send your report to the pi session that dispatched you. Call it once, when the task is done or you cannot finish it.",
48
+ parameters: Type.Object({ text: Type.String({ description: "The report: what you did, the outcome, paths of files with details" }) }),
49
+ annotations: { destructiveHint: false, openWorldHint: false },
50
+ defaultActive: false,
51
+ async execute(_toolCallId, p, signal, _onUpdate, ctx) {
52
+ await okJson(postJson(`/api/dispatch/${encodeURIComponent(sessionIdOf(ctx))}/report`, { text: p.text }, { signal }));
53
+ return text("Report sent. Tell the user it was sent, then stop.");
54
+ },
55
+ });
56
+ }
@@ -0,0 +1,86 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join as joinPath } from "node:path";
3
+ import { setTimeout as sleep } from "node:timers/promises";
4
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import { boardFetch, errorText, kanbanDir, lib } from "./board-api.js";
6
+
7
+ const { isContained } = lib("contain.js") as { isContained: (child: string, root: string) => boolean };
8
+
9
+ type Events = { events: string[]; seqs: number[]; board: string };
10
+
11
+ // The board caps a wait at 120 s.
12
+ const WAIT_SEC = 110;
13
+ const RETRY_MS = [2_000, 5_000, 15_000, 30_000];
14
+
15
+ // pi names a session file <ISO timestamp>_<id>.jsonl.
16
+ function sessionIdOf(file: string | undefined): string | undefined {
17
+ return file ? /_([^_\\/]+)\.jsonl$/.exec(file)?.[1] : undefined;
18
+ }
19
+
20
+ // A review or answer line ends with the path of the file that holds the batch (lib/session-events.js).
21
+ const FILE_RE = /: (.+\.md)$/;
22
+
23
+ async function withBatch(line: string): Promise<string> {
24
+ const file = FILE_RE.exec(line)?.[1];
25
+ if (!file) return line;
26
+ if (!isContained(file, joinPath(kanbanDir(), "reviews"))) return line;
27
+ const batch = await readFile(file, "utf8").catch(() => "");
28
+ return batch ? `${line}\n\n${batch}` : line;
29
+ }
30
+
31
+ // Drains the board's queue for this session (reviews, preview answers, task moves) into the
32
+ // conversation. A line stays on the board until its ack, which rides on the next poll.
33
+ // `prev` on the first poll makes the board deliver to this session what it still sends to the
34
+ // old id (lib/session-events.js).
35
+ async function drain(pi: ExtensionAPI, ctx: ExtensionContext, sessionId: string, prev: string | undefined, signal: AbortSignal) {
36
+ let board = "";
37
+ let seq = 0;
38
+ let first = true;
39
+ let fails = 0;
40
+ while (!signal.aborted) {
41
+ try {
42
+ const q = new URLSearchParams({ wait: String(WAIT_SEC), got: String(seq), ack: String(seq), board });
43
+ if (first) {
44
+ q.set("first", "1");
45
+ if (prev) q.set("prev", prev);
46
+ }
47
+ const res = await boardFetch(`/api/sessions/${encodeURIComponent(sessionId)}/events?${q}`, { cache: "no-store", signal });
48
+ if (!res.ok) throw new Error(await errorText(res));
49
+ const got = (await res.json()) as Events;
50
+ first = false;
51
+ fails = 0;
52
+ if (got.board !== board) seq = 0;
53
+ board = got.board;
54
+ const texts = await Promise.all(got.events.map(withBatch));
55
+ if (signal.aborted) return;
56
+ for (const text of texts) pi.sendUserMessage(text, ctx.isIdle() ? undefined : { deliverAs: "followUp" });
57
+ if (got.seqs.length) seq = Math.max(seq, ...got.seqs);
58
+ } catch (e: any) {
59
+ if (signal.aborted) return;
60
+ if (fails === 0 && e?.name !== "BoardDownError") console.warn(`pi-kanban: board inbox: ${e?.message ?? e}`);
61
+ await sleep(RETRY_MS[Math.min(fails++, RETRY_MS.length - 1)], undefined, { signal }).catch(() => {});
62
+ }
63
+ }
64
+ }
65
+
66
+ export function registerInbox(pi: ExtensionAPI) {
67
+ if (process.env.PI_SUBAGENT_CHILD) return;
68
+ let stop: AbortController | null = null;
69
+
70
+ pi.on("session_start", async (event, ctx) => {
71
+ stop?.abort();
72
+ stop = null;
73
+ const sessionId = ctx.sessionManager.getSessionId();
74
+ if (!sessionId || !ctx.hasUI) return;
75
+ // pi loads the extension again for each session, so only the event knows the one before.
76
+ // A resumed session is not its successor.
77
+ const prev = event.reason === "new" || event.reason === "fork" ? sessionIdOf(event.previousSessionFile) : undefined;
78
+ stop = new AbortController();
79
+ void drain(pi, ctx, sessionId, prev, stop.signal);
80
+ });
81
+
82
+ pi.on("session_shutdown", async () => {
83
+ stop?.abort();
84
+ stop = null;
85
+ });
86
+ }
@@ -0,0 +1,27 @@
1
+ import { isUrl, okJson, postJson, terminalToken } from "./board-api.js";
2
+
3
+ const postBoardJson = (path: string, body: unknown, headers?: Record<string, string>) => okJson(postJson(path, body, { headers }));
4
+
5
+ export async function linkDoc(sessionId: string, target: string, opts: { unlink?: boolean; open?: boolean } = {}): Promise<string> {
6
+ const out = await postBoardJson("/api/document/link", { path: target, sessionId, unlink: !!opts.unlink, open: !!opts.open });
7
+ const what = isUrl(target) ? "URL" : "Document";
8
+ const line = `${what} ${opts.unlink ? "unlinked from" : "linked to"} the session: ${out.path}`;
9
+ return !opts.unlink && out.tabs === 0 ? `${line}\nNo board tab is open; the board shows it when one opens.` : line;
10
+ }
11
+
12
+ // A URL cannot open in the preview modal, so it is linked and the board tab on screen offers an Open button.
13
+ // A sessionId also moves the board to that session.
14
+ export async function previewDoc(target: string, sessionId: string | null): Promise<string> {
15
+ if (isUrl(target)) {
16
+ if (!sessionId) throw new Error("A URL is linked to a session, and this pi session has no id yet.");
17
+ return linkDoc(sessionId, target, { open: true });
18
+ }
19
+ await postBoardJson("/api/preview", { path: target, sessionId });
20
+ return `Preview opened on the board: ${target}`;
21
+ }
22
+
23
+ // The board starts the tool, which runs package code, so a companion pane takes the terminal token.
24
+ // With a board terminal id, the board tab that shows that terminal switches to the pane.
25
+ export async function openCompanion(sessionId: string, companion: string, terminal?: string) {
26
+ return postBoardJson(`/api/panes/${encodeURIComponent(sessionId)}`, { companion, terminal }, { "x-terminal-token": terminalToken() });
27
+ }
@@ -0,0 +1,70 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { join as joinPath } from "node:path";
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { kanbanDir, lib } from "./board-api.js";
5
+
6
+ const pads = lib("scratchpad-dir.js") as {
7
+ defaultScratchpadRoot: () => string;
8
+ scratchpadPath: (root: string, cwd: string, sessionId: string) => string;
9
+ recordedPadDir: (entries: unknown[]) => string | null;
10
+ prunePads: (o: { root: string; isLive: (sessionId: string) => boolean }) => Promise<string[]>;
11
+ SCRATCHPAD_ENTRY: string;
12
+ PAD_MAX_AGE_MS: number;
13
+ };
14
+ const liveSessions = lib("live-sessions.js") as {
15
+ readLiveSessions: (dir: string) => { sessionId: string; pid: number | null }[];
16
+ isSessionLive: (sessions: unknown[], sessionId: string) => boolean;
17
+ };
18
+
19
+ export const SCRATCHPAD_ENV = "PI_SCRATCHPAD_DIR";
20
+
21
+ const DAY_MS = 24 * 60 * 60 * 1000;
22
+
23
+ function promptSection(dir: string): string {
24
+ return [
25
+ `Use this session's scratchpad dir for temporary files, such as notes, scripts, logs and command output, in place of /tmp or the project: ${dir}`,
26
+ `It is also in $${SCRATCHPAD_ENV}, and the pi-kanban board lists its files.`,
27
+ `It is deleted ${Math.round(pads.PAD_MAX_AGE_MS / DAY_MS)} days after the session ends, so put files the user keeps in the project or where the user says.`,
28
+ ].join("\n");
29
+ }
30
+
31
+ async function prune(root: string, sessionId: string): Promise<void> {
32
+ const live = liveSessions.readLiveSessions(joinPath(kanbanDir(), "live"));
33
+ await pads.prunePads({ root, isLive: (id) => id === sessionId || liveSessions.isSessionLive(live, id) });
34
+ }
35
+
36
+ // A subagent child reuses the pad its parent put in the environment, so both write to one dir.
37
+ export function registerScratchpad(pi: ExtensionAPI) {
38
+ const inherited = process.env.PI_SUBAGENT_CHILD ? process.env[SCRATCHPAD_ENV] : undefined;
39
+ const root = pads.defaultScratchpadRoot();
40
+ let dir: string | null = inherited ?? null;
41
+ let pruned = false;
42
+
43
+ pi.on("session_start", async (_event, ctx) => {
44
+ const sessionId = ctx.sessionManager.getSessionId();
45
+ if (!sessionId) return;
46
+ if (!inherited) {
47
+ const next = pads.scratchpadPath(root, ctx.sessionManager.getCwd(), sessionId);
48
+ try {
49
+ mkdirSync(next, { recursive: true });
50
+ } catch (e: any) {
51
+ console.warn(`pi-kanban: cannot create scratchpad ${next}: ${e?.message ?? e}`);
52
+ dir = null;
53
+ delete process.env[SCRATCHPAD_ENV];
54
+ return;
55
+ }
56
+ dir = next;
57
+ process.env[SCRATCHPAD_ENV] = dir;
58
+ }
59
+ if (pads.recordedPadDir(ctx.sessionManager.getEntries()) !== dir) pi.appendEntry(pads.SCRATCHPAD_ENTRY, { dir });
60
+
61
+ if (inherited || pruned) return;
62
+ pruned = true;
63
+ setImmediate(() => prune(root, sessionId).catch((e: any) => console.warn(`pi-kanban: scratchpad prune failed: ${e?.message ?? e}`)));
64
+ });
65
+
66
+ pi.on("before_agent_start", async (event) => {
67
+ if (dir) event.systemPromptOptions.sections.scratchpad = promptSection(dir);
68
+ else delete event.systemPromptOptions.sections.scratchpad;
69
+ });
70
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2024",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "esModuleInterop": true,
7
+ "skipLibCheck": true,
8
+ "strict": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "noEmit": true,
11
+ "resolveJsonModule": true,
12
+ "lib": ["es2024"],
13
+ "types": ["node"]
14
+ },
15
+ "include": ["./**/*.ts"]
16
+ }
@@ -0,0 +1,20 @@
1
+ const os = require('node:os');
2
+ const path = require('node:path');
3
+ const { realpathDeepest } = require('./contain');
4
+
5
+ // libuv aborts the process when a watched Windows path holds an 8.3 short name (NIKIFO~1),
6
+ // because the events come back with the long name, so every dir the board watches gets its long form.
7
+ const longPath = (dir) => realpathDeepest(path.resolve(dir));
8
+
9
+ // Mirrors getAgentDir() in @earendil-works/pi-coding-agent. PI_DIR is pi-kanban's own
10
+ // override (demo, tests) and points at the parent of the agent dir.
11
+ function rawAgentDir() {
12
+ if (process.env.PI_DIR) return path.join(process.env.PI_DIR, 'agent');
13
+ const envDir = process.env.PI_CODING_AGENT_DIR;
14
+ if (envDir) return envDir.replace(/^~(?=$|[\\/])/, os.homedir());
15
+ return path.join(os.homedir(), '.pi', 'agent');
16
+ }
17
+
18
+ const getAgentDir = () => longPath(rawAgentDir());
19
+
20
+ module.exports = { getAgentDir, longPath };
@@ -0,0 +1,64 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs').promises;
4
+ const path = require('node:path');
5
+ const { writeJsonAtomic } = require('./atomic-write');
6
+
7
+ // A message to a stopped subagent spawns it again under the same id, and the plugin appends
8
+ // another start line. Each start line begins a new run, so nothing of the run before it shows.
9
+ function foldAgentLines(raw) {
10
+ let merged = {};
11
+ for (const line of raw.split(/\r?\n/)) {
12
+ if (!line.trim()) continue;
13
+ let obj;
14
+ try { obj = JSON.parse(line); } catch (_) { continue; }
15
+ if (obj.event === 'start') merged = {};
16
+ Object.assign(merged, obj);
17
+ }
18
+ return merged;
19
+ }
20
+
21
+ // An agent id is a file name in the activity dir; the server strips request ids to the same alphabet.
22
+ function sanitizeAgentId(raw) {
23
+ return path.basename(String(raw)).replace(/[^a-zA-Z0-9_-]/g, '');
24
+ }
25
+
26
+ // The pi extension writes a session's activity dir: <agentId>.jsonl lines, _waiting.json and
27
+ // _stop.json. The server folds a line last-key-wins, so a line leaves out a field it does not know.
28
+ async function appendAgentLines(dir, records) {
29
+ if (!records.length) return;
30
+ const byAgent = new Map();
31
+ for (const r of records) byAgent.set(r.agentId, (byAgent.get(r.agentId) || '') + `${JSON.stringify(r)}\n`);
32
+ await fs.mkdir(dir, { recursive: true });
33
+ await Promise.all([...byAgent].map(([agentId, text]) => fs.appendFile(path.join(dir, `${agentId}.jsonl`), text, 'utf8')));
34
+ }
35
+
36
+ // pi does not say which tool asked, so the marker names the newest tool still running.
37
+ function promptMarker(prompt, tool, now) {
38
+ return {
39
+ kind: prompt.kind === 'confirm' ? 'permission' : 'question',
40
+ toolName: tool?.toolName ?? prompt.title ?? prompt.kind,
41
+ toolInput: JSON.stringify(tool?.args ?? { title: prompt.title ?? null }),
42
+ timestamp: now,
43
+ };
44
+ }
45
+
46
+ async function writeWaiting(dir, marker) {
47
+ await writeJsonAtomic(path.join(dir, '_waiting.json'), { status: 'waiting', ...marker });
48
+ }
49
+
50
+ async function clearWaiting(dir) {
51
+ await fs.rm(path.join(dir, '_waiting.json'), { force: true });
52
+ }
53
+
54
+ // Only the marker's mtime is read: the session is unread until opened or written again.
55
+ async function markStopped(dir) {
56
+ await fs.mkdir(dir, { recursive: true });
57
+ await fs.writeFile(path.join(dir, '_stop.json'), '');
58
+ }
59
+
60
+ async function clearStopped(dir) {
61
+ await fs.rm(path.join(dir, '_stop.json'), { force: true });
62
+ }
63
+
64
+ module.exports = { foldAgentLines, sanitizeAgentId, appendAgentLines, promptMarker, writeWaiting, clearWaiting, markStopped, clearStopped };