@plannotator/pi-extension 0.28.48 → 0.28.49

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,47 @@
1
+ /**
2
+ * Code navigation's node runtime for Pi (the Bun one is packages/server/code-nav.ts):
3
+ * the review server's /api/code-nav/* and Shape's code routes (serverShape.ts).
4
+ */
5
+ import { spawn } from "node:child_process";
6
+ import { resolve as resolvePath } from "node:path";
7
+ import { type CodeNavRuntime, CODE_NAV_MAX_FILE_BYTES } from "../generated/code-nav.ts";
8
+ import { readFileCapped } from "../generated/repo-file.ts";
9
+
10
+ export const piCodeNavRuntime: CodeNavRuntime = {
11
+ runCommand(command, args, options) {
12
+ return new Promise((resolve) => {
13
+ const proc = spawn(command, args, {
14
+ cwd: options?.cwd,
15
+ stdio: ["ignore", "pipe", "pipe"],
16
+ });
17
+ let timer: ReturnType<typeof setTimeout> | undefined;
18
+ if (options?.timeoutMs) {
19
+ timer = setTimeout(() => proc.kill(), options.timeoutMs);
20
+ }
21
+ const stdoutChunks: Buffer[] = [];
22
+ const stderrChunks: Buffer[] = [];
23
+ proc.stdout!.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
24
+ proc.stderr!.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
25
+ proc.on("close", (code: number | null) => {
26
+ if (timer) clearTimeout(timer);
27
+ resolve({
28
+ stdout: Buffer.concat(stdoutChunks).toString("utf-8"),
29
+ stderr: Buffer.concat(stderrChunks).toString("utf-8"),
30
+ exitCode: code ?? 1,
31
+ });
32
+ });
33
+ proc.on("error", () => {
34
+ if (timer) clearTimeout(timer);
35
+ resolve({ stdout: "", stderr: "command not found", exitCode: 1 });
36
+ });
37
+ });
38
+ },
39
+
40
+ async readFile(path, options) {
41
+ // Capped at what rg searches, non-blocking (repo-file.ts).
42
+ const read = await readFileCapped(options?.cwd ? resolvePath(options.cwd, path) : path, {
43
+ maxBytes: CODE_NAV_MAX_FILE_BYTES,
44
+ });
45
+ return read.ok ? read.content : null;
46
+ },
47
+ };
@@ -3,7 +3,6 @@ import { schedulePiDailyPing } from "./daily-ping.ts";
3
3
  import { createPackageChangesService, MAX_PACKAGE_FILE_BYTES, PACKAGE_CHECKOUT_WAIT_MS, prSettingsSource, REMOTE_PACKAGE_READS, waitAtMost } from "../generated/package-changes-service.ts";
4
4
  import { parseDiffToFiles } from "../generated/diff-files.ts";
5
5
  import { isSameOriginOrNoOrigin } from "../generated/request-origin.ts";
6
- import { spawn } from "node:child_process";
7
6
  import { likelyAppHtmlEncoding, prewarmAppHtml } from "../generated/app-html.ts";
8
7
  import { openPackedApp } from "../generated/packed-app.ts";
9
8
  import {
@@ -232,14 +231,13 @@ import {
232
231
  import { resolveWorkspaceFilePath } from "../generated/review-workspace-node.ts";
233
232
  import {
234
233
  type CodeNavRequest,
235
- type CodeNavRuntime,
236
- CODE_NAV_MAX_FILE_BYTES,
237
234
  resolveCodeNav,
238
235
  resolveCodeNavHover,
239
236
  validateCodeNavRequest,
240
237
  extractChangedFiles,
241
238
  } from "../generated/code-nav.ts";
242
- import { readFileCapped, readRepoFile, repoFileErrorBody, REPO_FILE_ERROR_STATUS } from "../generated/repo-file.ts";
239
+ import { readRepoFile, repoFileErrorBody, REPO_FILE_ERROR_STATUS } from "../generated/repo-file.ts";
240
+ import { piCodeNavRuntime } from "./code-nav-runtime.ts";
243
241
  import { readRepoWorkingTreeFile } from "../generated/repo-files-node.ts";
244
242
  import {
245
243
  createDefaultSemanticDiffRuntime,
@@ -282,44 +280,6 @@ import {
282
280
  vcsSupportsSnapshot,
283
281
  } from "./vcs.ts";
284
282
 
285
- const piCodeNavRuntime: CodeNavRuntime = {
286
- runCommand(command, args, options) {
287
- return new Promise((resolve) => {
288
- const proc = spawn(command, args, {
289
- cwd: options?.cwd,
290
- stdio: ["ignore", "pipe", "pipe"],
291
- });
292
- let timer: ReturnType<typeof setTimeout> | undefined;
293
- if (options?.timeoutMs) {
294
- timer = setTimeout(() => proc.kill(), options.timeoutMs);
295
- }
296
- const stdoutChunks: Buffer[] = [];
297
- const stderrChunks: Buffer[] = [];
298
- proc.stdout!.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
299
- proc.stderr!.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
300
- proc.on("close", (code: number | null) => {
301
- if (timer) clearTimeout(timer);
302
- resolve({
303
- stdout: Buffer.concat(stdoutChunks).toString("utf-8"),
304
- stderr: Buffer.concat(stderrChunks).toString("utf-8"),
305
- exitCode: code ?? 1,
306
- });
307
- });
308
- proc.on("error", () => {
309
- if (timer) clearTimeout(timer);
310
- resolve({ stdout: "", stderr: "command not found", exitCode: 1 });
311
- });
312
- });
313
- },
314
-
315
- async readFile(path, options) {
316
- // Capped at what rg searches, non-blocking (repo-file.ts).
317
- const read = await readFileCapped(options?.cwd ? resolvePath(options.cwd, path) : path, {
318
- maxBytes: CODE_NAV_MAX_FILE_BYTES,
319
- });
320
- return read.ok ? read.content : null;
321
- },
322
- };
323
283
 
324
284
  // Review ingestion completion semantics (REVIEW_OUTPUT_FAILED,
325
285
  // markJobReviewFailed) now live in the shared agent-jobs module.
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Shape's routes on both runtimes: the same requests on the same store answer
3
+ * the same on Pi (node:http, serverShape.ts) and on Bun
4
+ * (packages/server/shape-routes.ts), so the mirror cannot drift. Also: a
5
+ * cross-site read is refused, an image is served with a sandbox, and the event
6
+ * feed names a shape when an agent picks up its waiting change.
7
+ */
8
+ import { afterEach, expect, test } from "bun:test";
9
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
10
+ import { createServer, type Server } from "node:http";
11
+ import type { AddressInfo } from "node:net";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { createBunShapeRoutes } from "../../../packages/server/shape-routes.ts";
15
+ import { createShapeStore } from "../../../packages/shared/shape/store.ts";
16
+ import { createPiShapeRoutes } from "./serverShape.ts";
17
+
18
+ const cleanups: (() => void)[] = [];
19
+ afterEach(() => {
20
+ for (const done of cleanups.splice(0)) done();
21
+ });
22
+
23
+ function scratch(): string {
24
+ const dir = mkdtempSync(join(tmpdir(), "shape-pi-"));
25
+ cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
26
+ return dir;
27
+ }
28
+
29
+ async function startPi(dataDir: string): Promise<string> {
30
+ const routes = createPiShapeRoutes({ dataDir, watchMs: 20 });
31
+ const server: Server = createServer((req, res) => {
32
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
33
+ void routes.handle(req, res, url).then((done) => {
34
+ if (!done) {
35
+ res.writeHead(404);
36
+ res.end();
37
+ }
38
+ });
39
+ });
40
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
41
+ cleanups.push(() => {
42
+ server.closeAllConnections();
43
+ server.close();
44
+ });
45
+ return `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
46
+ }
47
+
48
+ function startBun(dataDir: string): string {
49
+ const routes = createBunShapeRoutes({ dataDir, watchMs: 20 });
50
+ const server = Bun.serve({ hostname: "127.0.0.1", port: 0, idleTimeout: 0, fetch: async (req) => (await routes.route(req, new URL(req.url))) ?? new Response("", { status: 404 }) });
51
+ cleanups.push(() => server.stop(true));
52
+ return `http://127.0.0.1:${server.port}`;
53
+ }
54
+
55
+ function seed(dataDir: string) {
56
+ const store = createShapeStore({ dataDir });
57
+ const source = scratch();
58
+ writeFileSync(join(source, "shot.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
59
+ const made = store.update({
60
+ board: {
61
+ title: "The kettle whistles once",
62
+ claim: "Built.",
63
+ views: { picture: { html: '<p data-mark="p-a">once</p>' } },
64
+ refs: [{ n: 1, kind: "image", title: "Shot", target: "shot.png" }],
65
+ },
66
+ project: { name: "kettle", root: source },
67
+ sourceDir: source,
68
+ });
69
+ if (!made.ok) throw new Error(JSON.stringify(made));
70
+ return { store, id: made.id, file: store.read(made.id)!.refs![0]!.target.replace(/^files\//, "") };
71
+ }
72
+
73
+ /** Status and JSON (ids and times blanked: the two stores write their own notes). */
74
+ async function call(base: string, method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
75
+ const res = await fetch(base + path, { method, headers: { "Content-Type": "application/json", ...headers }, body: body === undefined ? undefined : JSON.stringify(body) });
76
+ const type = res.headers.get("content-type") ?? "";
77
+ const text = type.includes("json") ? JSON.stringify(await res.json()).replace(/(shp_|shn_)[0-9A-HJKMNP-TV-Z]{26}/g, "$1ID").replace(/"root":"[^"]*"/g, '"root":"R"').replace(/"\d{4}-\d\d-\d\dT[^"]+"/g, '"AT"') : `${type}:${(await res.arrayBuffer()).byteLength}:${res.headers.get("content-security-policy")}`;
78
+ return `${res.status} ${text}`;
79
+ }
80
+
81
+ test("Pi answers every Shape route as Bun does", async () => {
82
+ const answers: string[][] = [];
83
+ for (const start of [startPi, async (d: string) => startBun(d)]) {
84
+ const dataDir = scratch();
85
+ const { id, file } = seed(dataDir);
86
+ const base = await start(dataDir);
87
+ const out: string[] = [];
88
+ out.push(await call(base, "GET", "/api/shapes"));
89
+ out.push(await call(base, "GET", `/api/shapes/${id}`));
90
+ out.push(await call(base, "GET", `/api/shapes/${id}/versions`));
91
+ out.push(await call(base, "GET", `/api/shapes/${id}?n=7`));
92
+ out.push(await call(base, "POST", `/api/shapes/${id}/notes`, { kind: "change", v: 1, view: "picture", part: "p-a", text: "Twice" }));
93
+ out.push(await call(base, "POST", `/api/shapes/${id}/notes`, { kind: "nope" }));
94
+ out.push(await call(base, "POST", `/api/shapes/${id}/seen`, { n: 1 }));
95
+ out.push(await call(base, "POST", `/api/shapes/${id}/state`, { state: "finished" }));
96
+ out.push(await call(base, "GET", `/api/shapes/${id}/files/${file}`));
97
+ out.push(await call(base, "GET", `/api/shapes/${id}/files/..%2Fshape.json`));
98
+ out.push(await call(base, "GET", `/api/shapes/${id}/code/file?path=../../etc/passwd`));
99
+ out.push(await call(base, "GET", `/api/shapes/${id}/code/file?path=shot.png`));
100
+ answers.push(out);
101
+ }
102
+ expect(answers[0]).toEqual(answers[1]!);
103
+ // The answers themselves (so "both equally wrong" fails too).
104
+ const [pi] = answers;
105
+ expect(pi![4]).toStartWith("200 ");
106
+ expect(pi![5]).toStartWith("400 ");
107
+ expect(pi![8]).toBe("200 image/png:8:sandbox");
108
+ expect(pi![9]).toStartWith("404 ");
109
+ expect(pi![10]).toStartWith("400 ");
110
+ });
111
+
112
+ test("Pi refuses another site's read and write", async () => {
113
+ const dataDir = scratch();
114
+ const { id } = seed(dataDir);
115
+ const base = await startPi(dataDir);
116
+ const other = { Origin: "https://evil.invalid", "Sec-Fetch-Site": "cross-site" };
117
+ expect(await call(base, "GET", `/api/shapes/${id}`, undefined, other)).toStartWith("403 ");
118
+ expect(await call(base, "POST", `/api/shapes/${id}/seen`, { n: 1 }, other)).toStartWith("403 ");
119
+ });
120
+
121
+ test("both feeds name the shape when an agent picks up its waiting change", async () => {
122
+ for (const start of [startPi, async (d: string) => startBun(d)]) {
123
+ const dataDir = scratch();
124
+ const { store, id } = seed(dataDir);
125
+ store.addNote(id, { kind: "change", v: 1, text: "Twice" });
126
+ const base = await start(dataDir);
127
+ const controller = new AbortController();
128
+ const res = await fetch(`${base}/api/shapes/events`, { signal: controller.signal });
129
+ const reader = res.body!.getReader();
130
+ let text = "";
131
+ const read = (async () => {
132
+ for (;;) {
133
+ const { value, done } = await reader.read();
134
+ if (done) break;
135
+ text += new TextDecoder().decode(value);
136
+ if (text.includes("event: shapes")) break;
137
+ }
138
+ })();
139
+ await Bun.sleep(60);
140
+ // The agent's `shape read --notes`: another process on the same folder.
141
+ const agent = createShapeStore({ dataDir });
142
+ agent.markDelivered(id, agent.waiting(id).map((n) => n.id));
143
+ await Promise.race([read, Bun.sleep(1500)]);
144
+ controller.abort();
145
+ expect(text).toContain(`event: shapes\ndata: {"ids":["${id}"]}`);
146
+ }
147
+ });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Shape's routes on node:http, for Pi (the Bun mirror is
3
+ * packages/server/shape-routes.ts; the route core is
4
+ * packages/shared/shape/routes.ts, vendored to generated/shape/). Same paths,
5
+ * same answers, same event feed: `GET /api/shapes/events` sends
6
+ * `event: shapes` `{ ids }` when a board changes, including when an agent's
7
+ * `plannotator shape read --notes` picks up a waiting change.
8
+ *
9
+ * Mount it like the other Pi routes: after the Host allowlist and the
10
+ * cross-origin write guard (request-origin-guard.ts). A cross-site read is
11
+ * refused here too, because a board names files and folders.
12
+ */
13
+ import { createReadStream } from "node:fs";
14
+ import type { IncomingMessage, ServerResponse } from "node:http";
15
+ import { isSameOriginOrNoOrigin } from "../generated/request-origin.ts";
16
+ import { readRepoFile, repoFileErrorBody, REPO_FILE_ERROR_STATUS } from "../generated/repo-file.ts";
17
+ import { resolveCodeNavHover, validateCodeNavRequest, type CodeNavRequest } from "../generated/code-nav.ts";
18
+ import { createShapeStore, type ShapeStore } from "../generated/shape/store.ts";
19
+ import { createShapeWatch, handleShapeRoute, SHAPE_EVENTS_PATH, shapeEventFrame, type ShapeRouteDeps } from "../generated/shape/routes.ts";
20
+ import { piCodeNavRuntime } from "./code-nav-runtime.ts";
21
+ import { readBody } from "./helpers.ts";
22
+
23
+ const NO_STORE = { "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" } as const;
24
+
25
+ const piShapeCode: NonNullable<ShapeRouteDeps["code"]> = {
26
+ async file(root, path) {
27
+ const read = await readRepoFile(root, path);
28
+ return read.ok ? { status: 200, body: { content: read.content } } : { status: REPO_FILE_ERROR_STATUS[read.reason], body: repoFileErrorBody(read) };
29
+ },
30
+ async hover(root, body) {
31
+ const error = validateCodeNavRequest(body);
32
+ if (error) return { status: 400, body: { error } };
33
+ try {
34
+ return { status: 200, body: await resolveCodeNavHover(piCodeNavRuntime, body as CodeNavRequest, root, []) };
35
+ } catch (err) {
36
+ return { status: 500, body: { error: err instanceof Error ? err.message : "Code navigation failed" } };
37
+ }
38
+ },
39
+ };
40
+
41
+ /**
42
+ * NOT MOUNTED, and must not be until it has what the Bun Inbox has around it: Pi has no Shape switch (the Inbox's
43
+ * Settings → Capabilities → Shape, config `inboxShape`: off answers 404), no owner check (a tailnet guest is
44
+ * refused there) and no `serverSession` check on writes (409 for a page of a restarted server). Whoever mounts
45
+ * this adds all three first.
46
+ */
47
+ export function createPiShapeRoutes(options: { dataDir: string; heartbeatMs?: number; watchMs?: number; sessionOf?: ShapeRouteDeps["sessionOf"] }) {
48
+ const store: ShapeStore = createShapeStore({ dataDir: options.dataDir });
49
+ const watch = createShapeWatch(store, { intervalMs: options.watchMs });
50
+ const deps: ShapeRouteDeps = { store, sessionOf: options.sessionOf, changed: () => watch.poke(), code: piShapeCode };
51
+
52
+ const sendJson = (res: ServerResponse, status: number, body: unknown) => {
53
+ res.writeHead(status, { "Content-Type": "application/json", ...NO_STORE });
54
+ res.end(JSON.stringify(body));
55
+ };
56
+
57
+ function events(req: IncomingMessage, res: ServerResponse) {
58
+ res.writeHead(200, { "Content-Type": "text/event-stream", Connection: "keep-alive", ...NO_STORE });
59
+ res.write("retry: 2000\nevent: hello\ndata: {}\n\n");
60
+ const unsubscribe = watch.subscribe((ids) => res.write(shapeEventFrame(ids)));
61
+ const beat = setInterval(() => res.write(": ping\n\n"), options.heartbeatMs ?? 25_000);
62
+ const stop = () => {
63
+ clearInterval(beat);
64
+ unsubscribe();
65
+ };
66
+ req.on("close", stop);
67
+ res.on("close", stop);
68
+ }
69
+
70
+ return {
71
+ store,
72
+ watch,
73
+ /** Answers a Shape path and returns true; false when the path is not Shape's. */
74
+ async handle(req: IncomingMessage, res: ServerResponse, url: URL): Promise<boolean> {
75
+ if (url.pathname !== "/api/shapes" && !url.pathname.startsWith("/api/shapes/")) return false;
76
+ const origin = typeof req.headers.origin === "string" ? req.headers.origin : null;
77
+ if (!isSameOriginOrNoOrigin(origin, req.headers.host ?? "", req.headers["sec-fetch-site"])) {
78
+ sendJson(res, 403, { error: "Cross-origin requests are not accepted.", code: "cross_origin" });
79
+ return true;
80
+ }
81
+ if (url.pathname === SHAPE_EVENTS_PATH) {
82
+ if (req.method !== "GET") sendJson(res, 405, { error: "method not allowed" });
83
+ else events(req, res);
84
+ return true;
85
+ }
86
+ let text: string | null = null;
87
+ const answer = await handleShapeRoute(
88
+ {
89
+ method: req.method ?? "GET",
90
+ pathname: url.pathname,
91
+ params: url.searchParams,
92
+ json: async () => JSON.parse((text ??= await readBody(req))),
93
+ },
94
+ deps,
95
+ );
96
+ if (!answer) {
97
+ sendJson(res, 404, { error: "Not found", code: "not_found" });
98
+ return true;
99
+ }
100
+ if ("file" in answer) {
101
+ // Opened before the head is written: a file gone between the check and the read is a 404, and an error
102
+ // mid-read ends the response instead of an unhandled 'error' event taking the process down.
103
+ const file = createReadStream(answer.file.path);
104
+ file.on("error", () => {
105
+ if (!res.headersSent) sendJson(res, 404, { error: "Not found", code: "not_found" });
106
+ else res.destroy();
107
+ });
108
+ file.once("open", () => {
109
+ res.writeHead(200, { "Content-Type": answer.file.contentType, "Cross-Origin-Resource-Policy": "same-origin", "Content-Security-Policy": "sandbox", ...NO_STORE });
110
+ file.pipe(res);
111
+ });
112
+ return true;
113
+ }
114
+ sendJson(res, answer.status, answer.body);
115
+ return true;
116
+ },
117
+ };
118
+ }
@@ -190,6 +190,15 @@ The Plannotator Inbox is one local window per machine where you leave the person
190
190
  - Never run `plannotator inbox` just to send: calls start a stopped Inbox in the background, and bare `plannotator inbox` opens the window for the person. `--tailscale` is the person's choice; never add it. `plannotator inbox status` only reads; never run `plannotator inbox stop` or `restart` unless the person asks.
191
191
  - When the person asks where their Inbox is, or to open it (a closed tab), run `plannotator inbox --open`: it opens the window in their browser (starting the Inbox in the background if needed), prints its address on stdout and returns at once. Tell them the address.
192
192
 
193
+ ## plannotator t3
194
+
195
+ ```bash
196
+ plannotator t3 login --url <T3 MCP URL>
197
+ plannotator t3 logout --url <T3 MCP URL>
198
+ ```
199
+
200
+ If the person uses T3 Code, set it up once so their Inbox replies reach Claude threads in T3 as new turns: run `plannotator t3 --help` and follow its two steps. The person approves T3's sign-in page themselves.
201
+
193
202
  ## plannotator card
194
203
 
195
204
  ```bash