@browser-annotations/pi 1.6.2

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 ADDED
@@ -0,0 +1,9 @@
1
+ # @browser-annotations/pi
2
+
3
+ A pi extension that listens to the Browser Annotations Chrome DevTools extension.
4
+
5
+ ## Install
6
+
7
+ 1. `pi install npm:@browser-annotations/pi`
8
+ 2. Start pi with `pi`
9
+ 3. Run `/browser-annotations`
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@browser-annotations/pi",
3
+ "version": "1.6.2",
4
+ "description": "Receive feedback from the Browser Annotations Chrome extension.",
5
+ "keywords": [
6
+ "annotations",
7
+ "browser",
8
+ "browser-annotations",
9
+ "chrome",
10
+ "pi-package"
11
+ ],
12
+ "homepage": "https://browser-annotations.dev/",
13
+ "author": {
14
+ "name": "Wiebe Kaai"
15
+ },
16
+ "files": [
17
+ "src",
18
+ "README.md",
19
+ "tsconfig.json"
20
+ ],
21
+ "type": "module",
22
+ "devDependencies": {
23
+ "@mariozechner/pi-coding-agent": "0.66.1",
24
+ "@types/node": "25.5.0",
25
+ "@typescript/native-preview": "7.0.0-dev.20251207.1"
26
+ },
27
+ "peerDependencies": {
28
+ "@mariozechner/pi-coding-agent": "*"
29
+ },
30
+ "engines": {
31
+ "node": ">=20.6.0"
32
+ },
33
+ "pi": {
34
+ "extensions": [
35
+ "./src/index.ts"
36
+ ],
37
+ "image": "https://raw.githubusercontent.com/wiebekaai/browser-annotations/main/docs/screenshot.png"
38
+ },
39
+ "scripts": {
40
+ "dev": "cd ../../ && pi -e ./packages/pi/src/index.ts",
41
+ "check": "pnpm run check:types",
42
+ "check:types": "tsgo -b ./tsconfig.json"
43
+ }
44
+ }
package/src/index.ts ADDED
@@ -0,0 +1,344 @@
1
+ import { createServer, type IncomingMessage, type Server as HttpServer } from "node:http";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
+
7
+ const CUSTOM_TYPE = "browser-annotation";
8
+ const STATUS_KEY = "browser-annotations";
9
+ const HOST = "127.0.0.1";
10
+ const PORT = Number.parseInt(process.env.BROWSER_ANNOTATIONS_PORT || "3330", 10) || 3330;
11
+ const BROWSER_PROMPT = "Process feedback from the Browser Annotations Chrome DevTools extension.";
12
+ const COMMAND_NAME = "browser-annotations";
13
+
14
+ type RuntimeState = {
15
+ server?: HttpServer;
16
+ tmpDir?: string;
17
+ hasInjectedPrompt: boolean;
18
+ host: string;
19
+ port: number;
20
+ };
21
+
22
+ async function readBody(req: IncomingMessage): Promise<string> {
23
+ const chunks: Buffer[] = [];
24
+ for await (const chunk of req) chunks.push(chunk);
25
+ return Buffer.concat(chunks).toString();
26
+ }
27
+
28
+ async function saveScreenshot(
29
+ dataUrl: string,
30
+ tmpDir: string,
31
+ id: string = crypto.randomUUID(),
32
+ ): Promise<string> {
33
+ const base64 = dataUrl.replace(/^data:image\/\w+;base64,/, "");
34
+ const extension = dataUrl.match(/^data:image\/(\w+)/)?.[1] ?? "png";
35
+ const path = join(tmpDir, `${id}.${extension}`);
36
+ await writeFile(path, Buffer.from(base64, "base64"));
37
+ return path;
38
+ }
39
+
40
+ async function saveJsonScreenshots(body: Record<string, unknown>, tmpDir: string) {
41
+ const targets = [body, ...(Array.isArray(body.annotations) ? body.annotations : [])].filter(
42
+ (target): target is { id?: unknown; screenshot: string } =>
43
+ Boolean(target) &&
44
+ typeof target === "object" &&
45
+ typeof target.screenshot === "string" &&
46
+ target.screenshot.startsWith("data:"),
47
+ );
48
+
49
+ await Promise.all(
50
+ targets.map(async (target) => {
51
+ target.screenshot =
52
+ typeof target.id === "string"
53
+ ? await saveScreenshot(target.screenshot, tmpDir, target.id)
54
+ : await saveScreenshot(target.screenshot, tmpDir);
55
+ }),
56
+ );
57
+ }
58
+
59
+ const SCREENSHOT_DATA_URL_REGEX = /!\[([^\]]*)\]\((data:image\/\w+;base64,[A-Za-z0-9+/=]+)\)/g;
60
+
61
+ async function saveMarkdownScreenshots(markdown: string, tmpDir: string): Promise<string> {
62
+ const replacements: { match: string; replacement: string }[] = [];
63
+
64
+ for (const match of markdown.matchAll(SCREENSHOT_DATA_URL_REGEX)) {
65
+ const filePath = await saveScreenshot(match[2]!, tmpDir);
66
+ replacements.push({ match: match[0], replacement: `![${match[1]}](${filePath})` });
67
+ }
68
+
69
+ let result = markdown;
70
+ for (const { match, replacement } of replacements) {
71
+ result = result.replace(match, replacement);
72
+ }
73
+
74
+ return result;
75
+ }
76
+
77
+ function getServerUrl(host: string, port: number): string {
78
+ return `http://${host}:${port}/`;
79
+ }
80
+
81
+ function closeServer(server: HttpServer | undefined) {
82
+ if (!server) return Promise.resolve();
83
+
84
+ return new Promise<void>((resolve, reject) => {
85
+ server.close((error) => {
86
+ if (error) reject(error);
87
+ else resolve();
88
+ });
89
+ });
90
+ }
91
+
92
+ function listen(
93
+ server: HttpServer,
94
+ host: string,
95
+ port: number,
96
+ maxRetries = 10,
97
+ ): Promise<{ address: string; port: number }> {
98
+ return new Promise((resolve, reject) => {
99
+ let currentPort = port;
100
+ let attempts = 0;
101
+
102
+ const onError = (err: NodeJS.ErrnoException) => {
103
+ if (err.code === "EADDRINUSE" && ++attempts < maxRetries) {
104
+ server.once("error", onError);
105
+ server.listen(++currentPort, host);
106
+ } else {
107
+ reject(err);
108
+ }
109
+ };
110
+
111
+ server.once("error", onError);
112
+ server.once("listening", () => {
113
+ resolve(server.address() as { address: string; port: number });
114
+ });
115
+
116
+ server.listen(currentPort, host);
117
+ });
118
+ }
119
+
120
+ async function ensureTmpDir(state: RuntimeState) {
121
+ if (!state.tmpDir) {
122
+ state.tmpDir = await mkdtemp(join(tmpdir(), "browser-annotations-"));
123
+ }
124
+
125
+ return state.tmpDir;
126
+ }
127
+
128
+ async function cleanupTmpDir(state: RuntimeState) {
129
+ if (!state.tmpDir) return;
130
+
131
+ await rm(state.tmpDir, { recursive: true, force: true });
132
+ state.tmpDir = undefined;
133
+ }
134
+
135
+ function updateStatus(
136
+ ctx: { hasUI: boolean; ui: { setStatus(key: string, value?: string): void } },
137
+ state: RuntimeState,
138
+ ) {
139
+ if (!ctx.hasUI) return;
140
+
141
+ const value = state.server ? `→ Listening on ${getServerUrl(state.host, state.port)}` : undefined;
142
+ ctx.ui.setStatus(STATUS_KEY, value);
143
+ }
144
+
145
+ function parsePort(value: string) {
146
+ const port = Number.parseInt(value, 10);
147
+
148
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
149
+ throw new Error(`Invalid port: ${value}`);
150
+ }
151
+
152
+ return port;
153
+ }
154
+
155
+ export default function browserAnnotationsExtension(pi: ExtensionAPI) {
156
+ const state: RuntimeState = {
157
+ hasInjectedPrompt: false,
158
+ host: HOST,
159
+ port: PORT,
160
+ };
161
+
162
+ pi.on("before_agent_start", async (event) => {
163
+ if (state.hasInjectedPrompt || !state.server) {
164
+ return;
165
+ }
166
+
167
+ state.hasInjectedPrompt = true;
168
+
169
+ return {
170
+ systemPrompt: `${event.systemPrompt}\n\n${BROWSER_PROMPT}`,
171
+ };
172
+ });
173
+
174
+ async function stopServer() {
175
+ await closeServer(state.server);
176
+ state.server = undefined;
177
+ await cleanupTmpDir(state);
178
+ }
179
+
180
+ function createAnnotationServer() {
181
+ return createServer(async (req, res) => {
182
+ res.setHeader("Access-Control-Allow-Origin", "*");
183
+ res.setHeader("Access-Control-Allow-Methods", "POST, GET");
184
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
185
+
186
+ if (req.method === "OPTIONS") {
187
+ res.writeHead(204).end();
188
+ return;
189
+ }
190
+
191
+ if (req.method === "GET") {
192
+ res.writeHead(200).end("OK");
193
+ return;
194
+ }
195
+
196
+ if (req.method !== "POST") {
197
+ res.writeHead(405).end();
198
+ return;
199
+ }
200
+
201
+ try {
202
+ const contentType = req.headers["content-type"] || "";
203
+ const rawBody = await readBody(req);
204
+ const tmpDir = await ensureTmpDir(state);
205
+
206
+ let content: string;
207
+ let details: unknown;
208
+
209
+ if (contentType.includes("text/markdown")) {
210
+ content = await saveMarkdownScreenshots(rawBody, tmpDir);
211
+ details = { markdown: content };
212
+ } else {
213
+ const body = JSON.parse(rawBody) as Record<string, unknown>;
214
+ await saveJsonScreenshots(body, tmpDir);
215
+ content = JSON.stringify(body);
216
+ details = body;
217
+ }
218
+
219
+ pi.sendMessage(
220
+ {
221
+ customType: CUSTOM_TYPE,
222
+ content,
223
+ display: true,
224
+ details,
225
+ },
226
+ { triggerTurn: true },
227
+ );
228
+
229
+ res.writeHead(200).end();
230
+ } catch {
231
+ res.writeHead(400).end();
232
+ }
233
+ });
234
+ }
235
+
236
+ async function startServer(
237
+ ctx: {
238
+ hasUI: boolean;
239
+ ui: {
240
+ notify(message: string, level?: "info" | "warning" | "error"): void;
241
+ setStatus(key: string, value?: string): void;
242
+ };
243
+ },
244
+ nextPort: number,
245
+ ) {
246
+ if (state.server && state.port === nextPort) {
247
+ updateStatus(ctx, state);
248
+
249
+ if (ctx.hasUI) {
250
+ ctx.ui.notify(
251
+ `Browser annotations already listening on ${getServerUrl(state.host, state.port)}`,
252
+ "info",
253
+ );
254
+ }
255
+
256
+ return;
257
+ }
258
+
259
+ await stopServer();
260
+ state.port = nextPort;
261
+ state.hasInjectedPrompt = false;
262
+ await ensureTmpDir(state);
263
+
264
+ const server = createAnnotationServer();
265
+
266
+ try {
267
+ const address = await listen(server, state.host, state.port);
268
+ state.port = address.port;
269
+ state.server = server;
270
+ updateStatus(ctx, state);
271
+
272
+ if (ctx.hasUI) {
273
+ const portChanged = address.port !== nextPort;
274
+ const prefix = portChanged ? `Port ${nextPort} in use. ` : "";
275
+ ctx.ui.notify(
276
+ `${prefix}Browser annotations listening on ${getServerUrl(state.host, state.port)}`,
277
+ "info",
278
+ );
279
+ }
280
+ } catch (error) {
281
+ const message = error instanceof Error ? error.message : String(error);
282
+ await closeServer(server);
283
+ state.server = undefined;
284
+ await cleanupTmpDir(state);
285
+ updateStatus(ctx, state);
286
+
287
+ if (ctx.hasUI) {
288
+ ctx.ui.notify(`Could not start browser annotations server: ${message}`, "error");
289
+ }
290
+ }
291
+ }
292
+
293
+ pi.registerCommand(COMMAND_NAME, {
294
+ description: "Start, stop, or inspect the browser annotations webhook server",
295
+ handler: async (args, ctx) => {
296
+ const input = args.trim();
297
+
298
+ if (!input) {
299
+ if (state.server) {
300
+ ctx.ui.notify(
301
+ `Browser annotations listening on ${getServerUrl(state.host, state.port)}`,
302
+ "info",
303
+ );
304
+ return;
305
+ }
306
+
307
+ await startServer(ctx, state.port);
308
+ return;
309
+ }
310
+
311
+ if (input === "status") {
312
+ const message = state.server
313
+ ? `Browser annotations listening on ${getServerUrl(state.host, state.port)}`
314
+ : "Browser annotations server is stopped";
315
+ ctx.ui.notify(message, "info");
316
+ return;
317
+ }
318
+
319
+ if (input === "stop") {
320
+ if (!state.server) {
321
+ ctx.ui.notify("Browser annotations server is already stopped", "info");
322
+ return;
323
+ }
324
+
325
+ await stopServer();
326
+ updateStatus(ctx, state);
327
+ ctx.ui.notify("Browser annotations server stopped", "info");
328
+ return;
329
+ }
330
+
331
+ try {
332
+ await startServer(ctx, parsePort(input));
333
+ } catch (error) {
334
+ const message = error instanceof Error ? error.message : String(error);
335
+ ctx.ui.notify(`${message}. Usage: /${COMMAND_NAME} [port|status|stop]`, "error");
336
+ }
337
+ },
338
+ });
339
+
340
+ pi.on("session_shutdown", async (_event, ctx) => {
341
+ await stopServer();
342
+ updateStatus(ctx, state);
343
+ });
344
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "composite": true,
4
+ "lib": ["ESNext"],
5
+ "target": "ESNext",
6
+ "module": "Preserve",
7
+ "moduleDetection": "force",
8
+ "allowJs": true,
9
+ "moduleResolution": "bundler",
10
+ "allowImportingTsExtensions": true,
11
+ "verbatimModuleSyntax": true,
12
+ "noEmit": true,
13
+ "strict": true,
14
+ "skipLibCheck": true,
15
+ "noFallthroughCasesInSwitch": true,
16
+ "noUncheckedIndexedAccess": true,
17
+ "noImplicitOverride": true,
18
+ "types": ["node"]
19
+ },
20
+ "include": ["src/**/*.ts"]
21
+ }