@howaboua/pi-agent-board 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,111 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ export interface ViewerConfig {
7
+ host: string;
8
+ port: number;
9
+ publicUrl: string;
10
+ openCommand: string[] | null;
11
+ }
12
+
13
+ export function stateDirectory(): string {
14
+ return join(
15
+ process.env["PI_CODING_AGENT_DIR"] || join(homedir(), ".pi", "agent"),
16
+ "board-viewer",
17
+ );
18
+ }
19
+
20
+ function configPath(): string {
21
+ return join(stateDirectory(), "config.json");
22
+ }
23
+
24
+ export async function loadConfig(): Promise<ViewerConfig> {
25
+ let value: unknown;
26
+ try {
27
+ value = JSON.parse(await readFile(configPath(), "utf8"));
28
+ } catch (error) {
29
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
30
+ throw error;
31
+ value = {};
32
+ }
33
+ if (!value || typeof value !== "object" || Array.isArray(value))
34
+ throw new Error("Viewer config must be an object");
35
+ const config = value as Record<string, unknown>;
36
+ const host = config["host"] ?? "127.0.0.1";
37
+ const port = config["port"] ?? 47984;
38
+ if (typeof host !== "string" || !["127.0.0.1", "0.0.0.0"].includes(host))
39
+ throw new Error("Viewer host must be 127.0.0.1 or 0.0.0.0");
40
+ if (
41
+ typeof port !== "number" ||
42
+ !Number.isInteger(port) ||
43
+ port < 1024 ||
44
+ port > 65535
45
+ )
46
+ throw new Error("Viewer port must be an integer from 1024 to 65535");
47
+ const publicUrl = config["publicUrl"] ?? `http://127.0.0.1:${port}`;
48
+ if (typeof publicUrl !== "string")
49
+ throw new Error("Viewer publicUrl must be an HTTP or HTTPS origin");
50
+ const url = new URL(publicUrl);
51
+ if (
52
+ !["http:", "https:"].includes(url.protocol) ||
53
+ url.username ||
54
+ url.password ||
55
+ url.pathname !== "/" ||
56
+ url.search ||
57
+ url.hash
58
+ )
59
+ throw new Error(
60
+ "Viewer publicUrl must be an HTTP or HTTPS origin without a path",
61
+ );
62
+ const command = config["openCommand"];
63
+ if (
64
+ command !== undefined &&
65
+ command !== null &&
66
+ (!Array.isArray(command) ||
67
+ !command.length ||
68
+ !command.every((part) => typeof part === "string" && part.length > 0))
69
+ )
70
+ throw new Error(
71
+ "Viewer openCommand must be an argument array, or null for link only",
72
+ );
73
+ const defaultCommand =
74
+ process.platform === "darwin"
75
+ ? ["open"]
76
+ : process.platform === "win32"
77
+ ? ["rundll32", "url.dll,FileProtocolHandler"]
78
+ : ["xdg-open"];
79
+ return {
80
+ host,
81
+ port,
82
+ publicUrl: url.origin,
83
+ openCommand:
84
+ command === null
85
+ ? null
86
+ : command === undefined
87
+ ? defaultCommand
88
+ : (command as string[]),
89
+ };
90
+ }
91
+
92
+ export async function accessKey(): Promise<string> {
93
+ const directory = stateDirectory();
94
+ await mkdir(directory, { recursive: true, mode: 0o700 });
95
+ const path = join(directory, "access-key");
96
+ try {
97
+ await writeFile(path, randomBytes(32).toString("hex"), {
98
+ flag: "wx",
99
+ mode: 0o600,
100
+ });
101
+ } catch (error) {
102
+ if (!(error instanceof Error && "code" in error && error.code === "EEXIST"))
103
+ throw error;
104
+ }
105
+ const key = (await readFile(path, "utf8")).trim();
106
+ if (!/^[a-f0-9]{64}$/.test(key))
107
+ throw new Error(
108
+ "Invalid viewer access key; remove the access-key file and restart the viewer",
109
+ );
110
+ return key;
111
+ }
@@ -0,0 +1,82 @@
1
+ interface ViewContext {
2
+ sessionId: string;
3
+ sessionName: string | null;
4
+ boardId: string;
5
+ folder: string;
6
+ }
7
+
8
+ export interface ViewSource extends ViewContext {
9
+ databasePath: string;
10
+ }
11
+
12
+ export interface BoardSummary {
13
+ id: string;
14
+ rootSessionId: string;
15
+ createdAt: string | null;
16
+ lastActivity: string | null;
17
+ channels: number;
18
+ posts: number;
19
+ agents: number;
20
+ }
21
+
22
+ export interface BoardIndex {
23
+ context: ViewContext;
24
+ boards: BoardSummary[];
25
+ }
26
+
27
+ interface Channel {
28
+ name: string;
29
+ author: string;
30
+ createdAt: string;
31
+ posts: number;
32
+ threads: number;
33
+ lastActivity: string | null;
34
+ }
35
+
36
+ interface Agent {
37
+ name: string;
38
+ posts: number;
39
+ lastActivity: string;
40
+ }
41
+
42
+ interface Subscription {
43
+ target: string;
44
+ agent: string;
45
+ enabled: boolean;
46
+ }
47
+
48
+ export interface BoardDetail {
49
+ board: BoardSummary;
50
+ channels: Channel[];
51
+ agents: Agent[];
52
+ subscriptions: Subscription[];
53
+ }
54
+
55
+ interface ThreadSummary {
56
+ id: string;
57
+ channel: string;
58
+ author: string;
59
+ preview: string;
60
+ createdAt: string;
61
+ lastActivity: string;
62
+ replies: number;
63
+ }
64
+
65
+ export interface ThreadPage {
66
+ threads: ThreadSummary[];
67
+ nextCursor: string | null;
68
+ }
69
+
70
+ export interface Post {
71
+ id: string;
72
+ channel: string;
73
+ author: string;
74
+ createdAt: string;
75
+ text: string;
76
+ }
77
+
78
+ export interface ThreadDetail {
79
+ root: Post;
80
+ replies: Post[];
81
+ nextCursor: string | null;
82
+ }
@@ -0,0 +1,158 @@
1
+ import { spawn } from "node:child_process";
2
+ import { closeSync, openSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import type {
6
+ ExtensionAPI,
7
+ ExtensionContext,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import {
10
+ accessKey,
11
+ loadConfig,
12
+ stateDirectory,
13
+ type ViewerConfig,
14
+ } from "./config.ts";
15
+ import type { ViewSource } from "./contracts.ts";
16
+ import { parseSource } from "./source.ts";
17
+
18
+ export type ViewerSource = (
19
+ ctx: ExtensionContext,
20
+ sessionName: string | undefined,
21
+ ) => ViewSource | Promise<ViewSource>;
22
+
23
+ const PROTOCOL = "pi-board-viewer/1";
24
+ async function available(
25
+ origin: string,
26
+ config: ViewerConfig,
27
+ ): Promise<boolean> {
28
+ let response: Response;
29
+ try {
30
+ response = await fetch(`${origin}/api/health`, {
31
+ signal: AbortSignal.timeout(700),
32
+ });
33
+ } catch {
34
+ return false;
35
+ }
36
+ let value: unknown;
37
+ try {
38
+ value = await response.json();
39
+ } catch {
40
+ throw new Error(
41
+ "The viewer port is occupied by another application; change the viewer port",
42
+ );
43
+ }
44
+ if (
45
+ !response.ok ||
46
+ !value ||
47
+ typeof value !== "object" ||
48
+ !("protocol" in value) ||
49
+ value.protocol !== PROTOCOL
50
+ )
51
+ throw new Error(
52
+ "The viewer port is occupied by another application or viewer version",
53
+ );
54
+ if (
55
+ !("host" in value) ||
56
+ value.host !== config.host ||
57
+ !("publicUrl" in value) ||
58
+ value.publicUrl !== config.publicUrl
59
+ ) {
60
+ const process =
61
+ "pid" in value && typeof value.pid === "number"
62
+ ? ` process ${value.pid}`
63
+ : " server";
64
+ throw new Error(
65
+ `Viewer configuration changed. Restart the viewer${process}, then run /board again`,
66
+ );
67
+ }
68
+ return true;
69
+ }
70
+
71
+ async function ensureServer(config: ViewerConfig): Promise<string> {
72
+ const origin = `http://127.0.0.1:${config.port}`;
73
+ if (await available(origin, config)) return origin;
74
+ const log = openSync(join(stateDirectory(), "server.log"), "a", 0o600);
75
+ let failure: Error | undefined;
76
+ try {
77
+ const child = spawn(
78
+ process.execPath,
79
+ [fileURLToPath(new URL("../../dist/viewer/server.js", import.meta.url))],
80
+ { detached: true, stdio: ["ignore", log, log], windowsHide: true },
81
+ );
82
+ child.once("error", (error) => {
83
+ failure = error;
84
+ });
85
+ child.unref();
86
+ } finally {
87
+ closeSync(log);
88
+ }
89
+ for (let attempt = 0; attempt < 30; attempt++) {
90
+ if (failure) throw failure;
91
+ if (await available(origin, config)) return origin;
92
+ await new Promise((resolve) => setTimeout(resolve, 100));
93
+ }
94
+ throw new Error(
95
+ `Board viewer did not start. Check ${join(stateDirectory(), "server.log")}`,
96
+ );
97
+ }
98
+
99
+ export async function openBoardViewer(
100
+ pi: ExtensionAPI,
101
+ ctx: ExtensionContext,
102
+ source: ViewerSource,
103
+ ): Promise<void> {
104
+ try {
105
+ const view = parseSource(await source(ctx, pi.getSessionName()));
106
+ const config = await loadConfig();
107
+ const key = await accessKey();
108
+ const origin = await ensureServer(config);
109
+ const response = await fetch(`${origin}/api/open`, {
110
+ method: "POST",
111
+ headers: {
112
+ Authorization: `Bearer ${key}`,
113
+ "Content-Type": "application/json",
114
+ },
115
+ body: JSON.stringify(view),
116
+ signal: AbortSignal.timeout(10_000),
117
+ });
118
+ const value: unknown = await response.json();
119
+ if (
120
+ !response.ok ||
121
+ !value ||
122
+ typeof value !== "object" ||
123
+ !("url" in value) ||
124
+ typeof value.url !== "string"
125
+ ) {
126
+ throw new Error(
127
+ value &&
128
+ typeof value === "object" &&
129
+ "error" in value &&
130
+ typeof value.error === "string"
131
+ ? value.error
132
+ : "Could not open the board viewer",
133
+ );
134
+ }
135
+ const url = value.url;
136
+ ctx.ui.notify(url, "info");
137
+ if (config.openCommand) {
138
+ const [command, ...commandArgs] = config.openCommand;
139
+ if (!command) return;
140
+ try {
141
+ const result = await pi.exec(command, [...commandArgs, url], {
142
+ timeout: 5_000,
143
+ });
144
+ if (result.code !== 0) throw new Error("Browser opener failed");
145
+ } catch {
146
+ ctx.ui.notify(
147
+ "The viewer is ready, but the browser did not open. Open the link above.",
148
+ "warning",
149
+ );
150
+ }
151
+ }
152
+ } catch (error) {
153
+ ctx.ui.notify(
154
+ `Board viewer: ${error instanceof Error ? error.message : String(error)}`,
155
+ "error",
156
+ );
157
+ }
158
+ }
@@ -0,0 +1,199 @@
1
+ import { randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import {
4
+ createServer,
5
+ type IncomingMessage,
6
+ type ServerResponse,
7
+ } from "node:http";
8
+ import { pathToFileURL } from "node:url";
9
+ import { Archive } from "./archive.ts";
10
+ import { accessKey, loadConfig, type ViewerConfig } from "./config.ts";
11
+ import type { ViewSource } from "./contracts.ts";
12
+ import { parseSource } from "./source.ts";
13
+
14
+ export const PROTOCOL = "pi-board-viewer/1";
15
+ const assets = new Map<string, readonly [string, string]>([
16
+ ["/", ["web/index.html", "text/html; charset=utf-8"]],
17
+ ["/app.js", ["web/app.js", "text/javascript; charset=utf-8"]],
18
+ ["/style.css", ["web/style.css", "text/css; charset=utf-8"]],
19
+ [
20
+ "/vendor/marked.js",
21
+ [import.meta.resolve("marked"), "text/javascript; charset=utf-8"],
22
+ ],
23
+ [
24
+ "/vendor/purify.js",
25
+ [import.meta.resolve("dompurify"), "text/javascript; charset=utf-8"],
26
+ ],
27
+ ] as const);
28
+
29
+ function authenticated(req: IncomingMessage, secret: string): boolean {
30
+ const value = req.headers.authorization;
31
+ if (!value?.startsWith("Bearer ")) return false;
32
+ const provided = Buffer.from(value.slice(7));
33
+ const expected = Buffer.from(secret);
34
+ return (
35
+ provided.length === expected.length && timingSafeEqual(provided, expected)
36
+ );
37
+ }
38
+
39
+ async function body(req: IncomingMessage): Promise<unknown> {
40
+ const chunks: Buffer[] = [];
41
+ let length = 0;
42
+ for await (const chunk of req) {
43
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
44
+ length += bytes.length;
45
+ if (length > 16_384) throw new Error("Viewer request is too large");
46
+ chunks.push(bytes);
47
+ }
48
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
49
+ }
50
+
51
+ function json(res: ServerResponse, status: number, value: unknown): void {
52
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
53
+ res.end(JSON.stringify(value));
54
+ }
55
+
56
+ export async function startServer(config: ViewerConfig, key: string) {
57
+ const views = new Map<string, ViewSource>();
58
+ const registered = new Map<string, string>();
59
+ const server = createServer((req, res) => {
60
+ res.setHeader("Cache-Control", "no-store");
61
+ res.setHeader("Referrer-Policy", "no-referrer");
62
+ res.setHeader("X-Content-Type-Options", "nosniff");
63
+ res.setHeader(
64
+ "Content-Security-Policy",
65
+ "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; font-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'",
66
+ );
67
+ void handle(req, res).catch((error: unknown) => {
68
+ if (res.headersSent) {
69
+ res.destroy();
70
+ return;
71
+ }
72
+ json(res, 400, {
73
+ error: error instanceof Error ? error.message : "Viewer request failed",
74
+ });
75
+ });
76
+ });
77
+ server.headersTimeout = 10_000;
78
+ server.requestTimeout = 10_000;
79
+ server.maxHeadersCount = 50;
80
+
81
+ async function handle(
82
+ req: IncomingMessage,
83
+ res: ServerResponse,
84
+ ): Promise<void> {
85
+ const url = new URL(req.url ?? "/", "http://localhost");
86
+ if (req.method === "GET" && url.pathname === "/api/health") {
87
+ json(res, 200, {
88
+ protocol: PROTOCOL,
89
+ host: config.host,
90
+ publicUrl: config.publicUrl,
91
+ pid: process.pid,
92
+ });
93
+ return;
94
+ }
95
+ if (req.method === "POST" && url.pathname === "/api/open") {
96
+ // Only the local extension may register filesystem sources. View links cannot.
97
+ if (
98
+ req.socket.remoteAddress !== "127.0.0.1" ||
99
+ !authenticated(req, key)
100
+ ) {
101
+ json(res, 403, {
102
+ error: "Only the local Pi extension can open a board archive",
103
+ });
104
+ return;
105
+ }
106
+ const source = parseSource(await body(req));
107
+ const archive = new Archive(source);
108
+ try {
109
+ archive.index();
110
+ } finally {
111
+ archive.close();
112
+ }
113
+ const identity = JSON.stringify(source);
114
+ const token = registered.get(identity) ?? randomBytes(32).toString("hex");
115
+ registered.set(identity, token);
116
+ views.set(token, source);
117
+ json(res, 200, { url: `${config.publicUrl}/#view=${token}` });
118
+ return;
119
+ }
120
+ if (req.method !== "GET") {
121
+ json(res, 405, { error: "The board viewer is read-only" });
122
+ return;
123
+ }
124
+ if (url.pathname.startsWith("/api/")) {
125
+ const token = req.headers.authorization?.replace(/^Bearer /, "") ?? "";
126
+ const source = views.get(token);
127
+ if (!source) {
128
+ json(res, 401, {
129
+ error:
130
+ "This viewer link has expired. Run /board in Pi for a new link.",
131
+ });
132
+ return;
133
+ }
134
+ const archive = new Archive(source);
135
+ try {
136
+ const board = url.searchParams.get("boardId") ?? source.boardId;
137
+ let result: unknown;
138
+ switch (url.pathname) {
139
+ case "/api/boards":
140
+ result = archive.index();
141
+ break;
142
+ case "/api/board":
143
+ result = archive.detail(board);
144
+ break;
145
+ case "/api/threads":
146
+ result = archive.threads(board, url.searchParams);
147
+ break;
148
+ case "/api/thread":
149
+ result = archive.thread(
150
+ board,
151
+ url.searchParams.get("id") ?? "",
152
+ url.searchParams,
153
+ );
154
+ break;
155
+ default:
156
+ json(res, 404, { error: "Unknown viewer request" });
157
+ return;
158
+ }
159
+ json(res, 200, result);
160
+ } finally {
161
+ archive.close();
162
+ }
163
+ return;
164
+ }
165
+ const asset = assets.get(url.pathname);
166
+ if (!asset) {
167
+ json(res, 404, { error: "Page not found" });
168
+ return;
169
+ }
170
+ const content = await readFile(new URL(asset[0], import.meta.url));
171
+ res.writeHead(200, { "Content-Type": asset[1] });
172
+ res.end(content);
173
+ }
174
+ await new Promise<void>((resolve, reject) => {
175
+ server.once("error", reject);
176
+ server.listen(config.port, config.host, () => {
177
+ server.removeListener("error", reject);
178
+ resolve();
179
+ });
180
+ });
181
+ return server;
182
+ }
183
+
184
+ if (
185
+ process.argv[1] &&
186
+ import.meta.url === pathToFileURL(process.argv[1]).href
187
+ ) {
188
+ const config = await loadConfig();
189
+ const server = await startServer(config, await accessKey());
190
+ console.log(
191
+ `Board viewer listening on ${config.host}:${config.port} (PID ${process.pid})`,
192
+ );
193
+ const stop = () => {
194
+ server.close();
195
+ server.closeAllConnections();
196
+ };
197
+ process.once("SIGTERM", stop);
198
+ process.once("SIGINT", stop);
199
+ }
@@ -0,0 +1,33 @@
1
+ import { isAbsolute } from "node:path";
2
+ import type { ViewSource } from "./contracts.ts";
3
+
4
+ export function parseSource(value: unknown): ViewSource {
5
+ if (!value || typeof value !== "object" || Array.isArray(value))
6
+ throw new Error("Invalid viewer source");
7
+ const fields = value as Record<string, unknown>;
8
+ const required = (key: string) => {
9
+ const field = fields[key];
10
+ if (
11
+ typeof field !== "string" ||
12
+ !field.length ||
13
+ field.length > 4096 ||
14
+ field.includes("\0")
15
+ )
16
+ throw new Error(`Invalid viewer ${key}`);
17
+ return field;
18
+ };
19
+ const folder = required("folder");
20
+ const databasePath = required("databasePath");
21
+ if (!isAbsolute(folder) || !isAbsolute(databasePath))
22
+ throw new Error("Viewer source paths must be absolute");
23
+ const sessionName = fields["sessionName"];
24
+ if (sessionName !== null && typeof sessionName !== "string")
25
+ throw new Error("Invalid viewer session name");
26
+ return {
27
+ sessionId: required("sessionId"),
28
+ boardId: required("boardId"),
29
+ sessionName,
30
+ folder,
31
+ databasePath,
32
+ };
33
+ }