@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.
- package/CHANGELOG.md +15 -0
- package/INTEGRATION.md +31 -0
- package/LICENSE +21 -0
- package/README.md +80 -2
- package/changelog.ts +337 -0
- package/dist/viewer/archive.js +185 -0
- package/dist/viewer/config.js +85 -0
- package/dist/viewer/contracts.js +1 -0
- package/dist/viewer/server.js +172 -0
- package/dist/viewer/source.js +29 -0
- package/dist/viewer/web/app.js +485 -0
- package/dist/viewer/web/index.html +39 -0
- package/dist/viewer/web/style.css +481 -0
- package/index.ts +63 -0
- package/package.json +65 -5
- package/src/activity.ts +273 -0
- package/src/archive.ts +103 -0
- package/src/awareness.ts +181 -0
- package/src/briefing.ts +19 -0
- package/src/config.ts +104 -0
- package/src/context-briefing.ts +227 -0
- package/src/contract.ts +189 -0
- package/src/delivery.ts +76 -0
- package/src/identity.ts +174 -0
- package/src/integration.ts +54 -0
- package/src/paging.ts +55 -0
- package/src/protocol.ts +78 -0
- package/src/queries.ts +176 -0
- package/src/response.ts +56 -0
- package/src/runtime.ts +561 -0
- package/src/store.ts +327 -0
- package/src/tool.ts +22 -0
- package/src/turns.ts +101 -0
- package/src/viewer/archive.ts +227 -0
- package/src/viewer/config.ts +111 -0
- package/src/viewer/contracts.ts +82 -0
- package/src/viewer/index.ts +158 -0
- package/src/viewer/server.ts +199 -0
- package/src/viewer/source.ts +33 -0
- package/src/viewer/web/app.js +485 -0
- package/src/viewer/web/index.html +39 -0
- package/src/viewer/web/style.css +481 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
export function stateDirectory() {
|
|
6
|
+
return join(process.env["PI_CODING_AGENT_DIR"] || join(homedir(), ".pi", "agent"), "board-viewer");
|
|
7
|
+
}
|
|
8
|
+
function configPath() {
|
|
9
|
+
return join(stateDirectory(), "config.json");
|
|
10
|
+
}
|
|
11
|
+
export async function loadConfig() {
|
|
12
|
+
let value;
|
|
13
|
+
try {
|
|
14
|
+
value = JSON.parse(await readFile(configPath(), "utf8"));
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
|
|
18
|
+
throw error;
|
|
19
|
+
value = {};
|
|
20
|
+
}
|
|
21
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
22
|
+
throw new Error("Viewer config must be an object");
|
|
23
|
+
const config = value;
|
|
24
|
+
const host = config["host"] ?? "127.0.0.1";
|
|
25
|
+
const port = config["port"] ?? 47984;
|
|
26
|
+
if (typeof host !== "string" || !["127.0.0.1", "0.0.0.0"].includes(host))
|
|
27
|
+
throw new Error("Viewer host must be 127.0.0.1 or 0.0.0.0");
|
|
28
|
+
if (typeof port !== "number" ||
|
|
29
|
+
!Number.isInteger(port) ||
|
|
30
|
+
port < 1024 ||
|
|
31
|
+
port > 65535)
|
|
32
|
+
throw new Error("Viewer port must be an integer from 1024 to 65535");
|
|
33
|
+
const publicUrl = config["publicUrl"] ?? `http://127.0.0.1:${port}`;
|
|
34
|
+
if (typeof publicUrl !== "string")
|
|
35
|
+
throw new Error("Viewer publicUrl must be an HTTP or HTTPS origin");
|
|
36
|
+
const url = new URL(publicUrl);
|
|
37
|
+
if (!["http:", "https:"].includes(url.protocol) ||
|
|
38
|
+
url.username ||
|
|
39
|
+
url.password ||
|
|
40
|
+
url.pathname !== "/" ||
|
|
41
|
+
url.search ||
|
|
42
|
+
url.hash)
|
|
43
|
+
throw new Error("Viewer publicUrl must be an HTTP or HTTPS origin without a path");
|
|
44
|
+
const command = config["openCommand"];
|
|
45
|
+
if (command !== undefined &&
|
|
46
|
+
command !== null &&
|
|
47
|
+
(!Array.isArray(command) ||
|
|
48
|
+
!command.length ||
|
|
49
|
+
!command.every((part) => typeof part === "string" && part.length > 0)))
|
|
50
|
+
throw new Error("Viewer openCommand must be an argument array, or null for link only");
|
|
51
|
+
const defaultCommand = process.platform === "darwin"
|
|
52
|
+
? ["open"]
|
|
53
|
+
: process.platform === "win32"
|
|
54
|
+
? ["rundll32", "url.dll,FileProtocolHandler"]
|
|
55
|
+
: ["xdg-open"];
|
|
56
|
+
return {
|
|
57
|
+
host,
|
|
58
|
+
port,
|
|
59
|
+
publicUrl: url.origin,
|
|
60
|
+
openCommand: command === null
|
|
61
|
+
? null
|
|
62
|
+
: command === undefined
|
|
63
|
+
? defaultCommand
|
|
64
|
+
: command,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export async function accessKey() {
|
|
68
|
+
const directory = stateDirectory();
|
|
69
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
70
|
+
const path = join(directory, "access-key");
|
|
71
|
+
try {
|
|
72
|
+
await writeFile(path, randomBytes(32).toString("hex"), {
|
|
73
|
+
flag: "wx",
|
|
74
|
+
mode: 0o600,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (!(error instanceof Error && "code" in error && error.code === "EEXIST"))
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
const key = (await readFile(path, "utf8")).trim();
|
|
82
|
+
if (!/^[a-f0-9]{64}$/.test(key))
|
|
83
|
+
throw new Error("Invalid viewer access key; remove the access-key file and restart the viewer");
|
|
84
|
+
return key;
|
|
85
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { createServer, } from "node:http";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { Archive } from "./archive.js";
|
|
6
|
+
import { accessKey, loadConfig } from "./config.js";
|
|
7
|
+
import { parseSource } from "./source.js";
|
|
8
|
+
export const PROTOCOL = "pi-board-viewer/1";
|
|
9
|
+
const assets = new Map([
|
|
10
|
+
["/", ["web/index.html", "text/html; charset=utf-8"]],
|
|
11
|
+
["/app.js", ["web/app.js", "text/javascript; charset=utf-8"]],
|
|
12
|
+
["/style.css", ["web/style.css", "text/css; charset=utf-8"]],
|
|
13
|
+
[
|
|
14
|
+
"/vendor/marked.js",
|
|
15
|
+
[import.meta.resolve("marked"), "text/javascript; charset=utf-8"],
|
|
16
|
+
],
|
|
17
|
+
[
|
|
18
|
+
"/vendor/purify.js",
|
|
19
|
+
[import.meta.resolve("dompurify"), "text/javascript; charset=utf-8"],
|
|
20
|
+
],
|
|
21
|
+
]);
|
|
22
|
+
function authenticated(req, secret) {
|
|
23
|
+
const value = req.headers.authorization;
|
|
24
|
+
if (!value?.startsWith("Bearer "))
|
|
25
|
+
return false;
|
|
26
|
+
const provided = Buffer.from(value.slice(7));
|
|
27
|
+
const expected = Buffer.from(secret);
|
|
28
|
+
return (provided.length === expected.length && timingSafeEqual(provided, expected));
|
|
29
|
+
}
|
|
30
|
+
async function body(req) {
|
|
31
|
+
const chunks = [];
|
|
32
|
+
let length = 0;
|
|
33
|
+
for await (const chunk of req) {
|
|
34
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
35
|
+
length += bytes.length;
|
|
36
|
+
if (length > 16_384)
|
|
37
|
+
throw new Error("Viewer request is too large");
|
|
38
|
+
chunks.push(bytes);
|
|
39
|
+
}
|
|
40
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
41
|
+
}
|
|
42
|
+
function json(res, status, value) {
|
|
43
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
44
|
+
res.end(JSON.stringify(value));
|
|
45
|
+
}
|
|
46
|
+
export async function startServer(config, key) {
|
|
47
|
+
const views = new Map();
|
|
48
|
+
const registered = new Map();
|
|
49
|
+
const server = createServer((req, res) => {
|
|
50
|
+
res.setHeader("Cache-Control", "no-store");
|
|
51
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
52
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
53
|
+
res.setHeader("Content-Security-Policy", "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'");
|
|
54
|
+
void handle(req, res).catch((error) => {
|
|
55
|
+
if (res.headersSent) {
|
|
56
|
+
res.destroy();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
json(res, 400, {
|
|
60
|
+
error: error instanceof Error ? error.message : "Viewer request failed",
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
server.headersTimeout = 10_000;
|
|
65
|
+
server.requestTimeout = 10_000;
|
|
66
|
+
server.maxHeadersCount = 50;
|
|
67
|
+
async function handle(req, res) {
|
|
68
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
69
|
+
if (req.method === "GET" && url.pathname === "/api/health") {
|
|
70
|
+
json(res, 200, {
|
|
71
|
+
protocol: PROTOCOL,
|
|
72
|
+
host: config.host,
|
|
73
|
+
publicUrl: config.publicUrl,
|
|
74
|
+
pid: process.pid,
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (req.method === "POST" && url.pathname === "/api/open") {
|
|
79
|
+
// Only the local extension may register filesystem sources. View links cannot.
|
|
80
|
+
if (req.socket.remoteAddress !== "127.0.0.1" ||
|
|
81
|
+
!authenticated(req, key)) {
|
|
82
|
+
json(res, 403, {
|
|
83
|
+
error: "Only the local Pi extension can open a board archive",
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const source = parseSource(await body(req));
|
|
88
|
+
const archive = new Archive(source);
|
|
89
|
+
try {
|
|
90
|
+
archive.index();
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
archive.close();
|
|
94
|
+
}
|
|
95
|
+
const identity = JSON.stringify(source);
|
|
96
|
+
const token = registered.get(identity) ?? randomBytes(32).toString("hex");
|
|
97
|
+
registered.set(identity, token);
|
|
98
|
+
views.set(token, source);
|
|
99
|
+
json(res, 200, { url: `${config.publicUrl}/#view=${token}` });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (req.method !== "GET") {
|
|
103
|
+
json(res, 405, { error: "The board viewer is read-only" });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (url.pathname.startsWith("/api/")) {
|
|
107
|
+
const token = req.headers.authorization?.replace(/^Bearer /, "") ?? "";
|
|
108
|
+
const source = views.get(token);
|
|
109
|
+
if (!source) {
|
|
110
|
+
json(res, 401, {
|
|
111
|
+
error: "This viewer link has expired. Run /board in Pi for a new link.",
|
|
112
|
+
});
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const archive = new Archive(source);
|
|
116
|
+
try {
|
|
117
|
+
const board = url.searchParams.get("boardId") ?? source.boardId;
|
|
118
|
+
let result;
|
|
119
|
+
switch (url.pathname) {
|
|
120
|
+
case "/api/boards":
|
|
121
|
+
result = archive.index();
|
|
122
|
+
break;
|
|
123
|
+
case "/api/board":
|
|
124
|
+
result = archive.detail(board);
|
|
125
|
+
break;
|
|
126
|
+
case "/api/threads":
|
|
127
|
+
result = archive.threads(board, url.searchParams);
|
|
128
|
+
break;
|
|
129
|
+
case "/api/thread":
|
|
130
|
+
result = archive.thread(board, url.searchParams.get("id") ?? "", url.searchParams);
|
|
131
|
+
break;
|
|
132
|
+
default:
|
|
133
|
+
json(res, 404, { error: "Unknown viewer request" });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
json(res, 200, result);
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
archive.close();
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const asset = assets.get(url.pathname);
|
|
144
|
+
if (!asset) {
|
|
145
|
+
json(res, 404, { error: "Page not found" });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const content = await readFile(new URL(asset[0], import.meta.url));
|
|
149
|
+
res.writeHead(200, { "Content-Type": asset[1] });
|
|
150
|
+
res.end(content);
|
|
151
|
+
}
|
|
152
|
+
await new Promise((resolve, reject) => {
|
|
153
|
+
server.once("error", reject);
|
|
154
|
+
server.listen(config.port, config.host, () => {
|
|
155
|
+
server.removeListener("error", reject);
|
|
156
|
+
resolve();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
return server;
|
|
160
|
+
}
|
|
161
|
+
if (process.argv[1] &&
|
|
162
|
+
import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
163
|
+
const config = await loadConfig();
|
|
164
|
+
const server = await startServer(config, await accessKey());
|
|
165
|
+
console.log(`Board viewer listening on ${config.host}:${config.port} (PID ${process.pid})`);
|
|
166
|
+
const stop = () => {
|
|
167
|
+
server.close();
|
|
168
|
+
server.closeAllConnections();
|
|
169
|
+
};
|
|
170
|
+
process.once("SIGTERM", stop);
|
|
171
|
+
process.once("SIGINT", stop);
|
|
172
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { isAbsolute } from "node:path";
|
|
2
|
+
export function parseSource(value) {
|
|
3
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
4
|
+
throw new Error("Invalid viewer source");
|
|
5
|
+
const fields = value;
|
|
6
|
+
const required = (key) => {
|
|
7
|
+
const field = fields[key];
|
|
8
|
+
if (typeof field !== "string" ||
|
|
9
|
+
!field.length ||
|
|
10
|
+
field.length > 4096 ||
|
|
11
|
+
field.includes("\0"))
|
|
12
|
+
throw new Error(`Invalid viewer ${key}`);
|
|
13
|
+
return field;
|
|
14
|
+
};
|
|
15
|
+
const folder = required("folder");
|
|
16
|
+
const databasePath = required("databasePath");
|
|
17
|
+
if (!isAbsolute(folder) || !isAbsolute(databasePath))
|
|
18
|
+
throw new Error("Viewer source paths must be absolute");
|
|
19
|
+
const sessionName = fields["sessionName"];
|
|
20
|
+
if (sessionName !== null && typeof sessionName !== "string")
|
|
21
|
+
throw new Error("Invalid viewer session name");
|
|
22
|
+
return {
|
|
23
|
+
sessionId: required("sessionId"),
|
|
24
|
+
boardId: required("boardId"),
|
|
25
|
+
sessionName,
|
|
26
|
+
folder,
|
|
27
|
+
databasePath,
|
|
28
|
+
};
|
|
29
|
+
}
|