@plannotator/pi-extension 0.27.5 → 0.27.7
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 +2 -0
- package/generated/ai/providers/child-io.ts +93 -0
- package/generated/ai/providers/codex-app-server.ts +38 -2
- package/generated/ai/providers/pi-sdk-node.ts +46 -4
- package/generated/ai/providers/pi-sdk.ts +54 -6
- package/generated/annotate-args.ts +25 -3
- package/generated/bridge-script.ts +4590 -0
- package/generated/call-flow-types.ts +18 -5
- package/generated/config.ts +2 -1
- package/generated/jj-core.ts +51 -3
- package/generated/live-probe.ts +133 -0
- package/generated/live-proxy-core.ts +555 -0
- package/generated/live-proxy-node.ts +419 -0
- package/generated/prompts.ts +1 -0
- package/generated/review-core.ts +11 -0
- package/index.ts +86 -15
- package/package.json +6 -1
- package/plannotator-browser.ts +22 -1
- package/plannotator.html +3 -3
- package/review-editor.html +3 -3
- package/server/serverAnnotate-live.test.ts +273 -0
- package/server/serverAnnotate.ts +146 -10
- package/skills/plannotator/SKILL.md +185 -0
- package/skills/plannotator/agents/openai.yaml +9 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Annotate server (Pi/Node): live app mode (annotate-app)
|
|
3
|
+
*
|
|
4
|
+
* Node mirror of packages/server/annotate.test.ts's "live app mode" describe
|
|
5
|
+
* block — the same session contract (live /api/plan payload, composed bridge
|
|
6
|
+
* served by the loopback proxy, per-target draft identity, no version
|
|
7
|
+
* history, guarded shutdown) over apps/pi-extension/server/serverAnnotate.ts
|
|
8
|
+
* and the vendored Node live-proxy transport, plus the remote hard-off that
|
|
9
|
+
* Pi enforces server-side.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
13
|
+
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { startAnnotateServer } from "./serverAnnotate.ts";
|
|
17
|
+
import { liveAppDraftIdentity } from "../generated/live-proxy-core.ts";
|
|
18
|
+
|
|
19
|
+
const MINIMAL_HTML = "<html><body>editor</body></html>";
|
|
20
|
+
|
|
21
|
+
describe("pi annotate server: live app mode (annotate-app)", () => {
|
|
22
|
+
let savedPort: string | undefined;
|
|
23
|
+
let savedRemote: string | undefined;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
savedPort = process.env.PLANNOTATOR_PORT;
|
|
27
|
+
savedRemote = process.env.PLANNOTATOR_REMOTE;
|
|
28
|
+
delete process.env.PLANNOTATOR_PORT;
|
|
29
|
+
process.env.PLANNOTATOR_REMOTE = "0";
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
if (savedPort === undefined) delete process.env.PLANNOTATOR_PORT;
|
|
34
|
+
else process.env.PLANNOTATOR_PORT = savedPort;
|
|
35
|
+
if (savedRemote === undefined) delete process.env.PLANNOTATOR_REMOTE;
|
|
36
|
+
else process.env.PLANNOTATOR_REMOTE = savedRemote;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function startFakeApp() {
|
|
40
|
+
return Bun.serve({
|
|
41
|
+
hostname: "127.0.0.1",
|
|
42
|
+
port: 0,
|
|
43
|
+
fetch: () =>
|
|
44
|
+
new Response("<html><head><title>app</title></head><body>app</body></html>", {
|
|
45
|
+
headers: { "Content-Type": "text/html" },
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function startLiveServer(targetUrl: string) {
|
|
51
|
+
return startAnnotateServer({
|
|
52
|
+
markdown: "",
|
|
53
|
+
filePath: targetUrl,
|
|
54
|
+
htmlContent: MINIMAL_HTML,
|
|
55
|
+
mode: "annotate-app",
|
|
56
|
+
sourceInfo: targetUrl,
|
|
57
|
+
sharingEnabled: false,
|
|
58
|
+
liveApp: {
|
|
59
|
+
targetUrl,
|
|
60
|
+
bridgeScript: "/* bridge body */",
|
|
61
|
+
bridgeBootstrap: "/* bootstrap body */",
|
|
62
|
+
annotationCss: ".pn-live {}",
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
test("/api/plan returns the live payload and the proxy serves the composed bridge", async () => {
|
|
68
|
+
const app = startFakeApp();
|
|
69
|
+
const targetUrl = `http://127.0.0.1:${app.port}`;
|
|
70
|
+
const server = await startLiveServer(targetUrl);
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const plan = (await (await fetch(`${server.url}/api/plan`)).json()) as Record<string, unknown>;
|
|
74
|
+
expect(plan.mode).toBe("annotate-app");
|
|
75
|
+
expect(plan.origin).toBe("pi");
|
|
76
|
+
expect(plan.filePath).toBe(targetUrl);
|
|
77
|
+
expect(plan.targetUrl).toBe(targetUrl);
|
|
78
|
+
expect(plan.liveToken).toMatch(/^[0-9a-f]{32}$/);
|
|
79
|
+
expect(plan.sharingEnabled).toBe(false);
|
|
80
|
+
expect(plan.convertHtml).toBe(false);
|
|
81
|
+
// appUrl is the live loopback proxy under its LOCALHOST spelling (so
|
|
82
|
+
// the framed app is same-site with the editor and shares the dev
|
|
83
|
+
// app's host-only localhost cookies), never an advertised-host URL.
|
|
84
|
+
expect(plan.appUrl).toMatch(/^http:\/\/localhost:\d+\/$/);
|
|
85
|
+
// No srcdoc payloads, no version fields.
|
|
86
|
+
expect(plan.rawHtml).toBeUndefined();
|
|
87
|
+
expect(plan.renderAs).toBeUndefined();
|
|
88
|
+
expect(plan.previousPlan).toBeUndefined();
|
|
89
|
+
expect(plan.versionInfo).toBeUndefined();
|
|
90
|
+
expect(plan.diffCurrent).toBeUndefined();
|
|
91
|
+
// Agent terminal stays unavailable for live sessions.
|
|
92
|
+
expect((plan.agentTerminal as { enabled: boolean }).enabled).toBe(false);
|
|
93
|
+
|
|
94
|
+
// The proxy serves the composed bridge body: config prelude with the
|
|
95
|
+
// session token and both editor origin forms (localhost first), then
|
|
96
|
+
// bootstrap, then bridge.
|
|
97
|
+
const appUrl = plan.appUrl as string;
|
|
98
|
+
const bridge = await (await fetch(`${appUrl}__plannotator__/bridge.js`)).text();
|
|
99
|
+
expect(bridge).toContain(String(plan.liveToken));
|
|
100
|
+
const localhostAt = bridge.indexOf(`http://localhost:${server.port}`);
|
|
101
|
+
const loopbackAt = bridge.indexOf(`http://127.0.0.1:${server.port}`);
|
|
102
|
+
expect(localhostAt).toBeGreaterThanOrEqual(0);
|
|
103
|
+
expect(loopbackAt).toBeGreaterThan(localhostAt);
|
|
104
|
+
expect(bridge).toContain(".pn-live {}");
|
|
105
|
+
expect(bridge.indexOf("/* bootstrap body */")).toBeLessThan(bridge.indexOf("/* bridge body */"));
|
|
106
|
+
|
|
107
|
+
// The proxied page carries the injected bridge script tag.
|
|
108
|
+
const page = await (await fetch(appUrl)).text();
|
|
109
|
+
expect(page).toContain('<script src="/__plannotator__/bridge.js"></script>');
|
|
110
|
+
} finally {
|
|
111
|
+
server.stop();
|
|
112
|
+
app.stop(true);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("a pathful target URL keeps its path and query in appUrl", async () => {
|
|
117
|
+
// Annotating http://localhost:5173/admin/settings must open that page,
|
|
118
|
+
// not the app root.
|
|
119
|
+
const app = startFakeApp();
|
|
120
|
+
const targetUrl = `http://127.0.0.1:${app.port}/admin/settings?tab=2`;
|
|
121
|
+
const server = await startLiveServer(targetUrl);
|
|
122
|
+
try {
|
|
123
|
+
const plan = (await (await fetch(`${server.url}/api/plan`)).json()) as { appUrl: string };
|
|
124
|
+
expect(plan.appUrl).toMatch(/^http:\/\/localhost:\d+\/admin\/settings\?tab=2$/);
|
|
125
|
+
// The advertised page is reachable through the proxy under the
|
|
126
|
+
// localhost Host spelling.
|
|
127
|
+
const res = await fetch(plan.appUrl);
|
|
128
|
+
expect(res.status).toBe(200);
|
|
129
|
+
} finally {
|
|
130
|
+
server.stop();
|
|
131
|
+
app.stop(true);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("version endpoints report no history for live sessions", async () => {
|
|
136
|
+
const app = startFakeApp();
|
|
137
|
+
const server = await startLiveServer(`http://127.0.0.1:${app.port}`);
|
|
138
|
+
try {
|
|
139
|
+
const versions = (await (await fetch(`${server.url}/api/plan/versions`)).json()) as {
|
|
140
|
+
slug: string | null;
|
|
141
|
+
versions: unknown[];
|
|
142
|
+
};
|
|
143
|
+
expect(versions.slug).toBeNull();
|
|
144
|
+
expect(versions.versions).toEqual([]);
|
|
145
|
+
const version = await fetch(`${server.url}/api/plan/version?v=1`);
|
|
146
|
+
expect(version.status).toBe(404);
|
|
147
|
+
} finally {
|
|
148
|
+
server.stop();
|
|
149
|
+
app.stop(true);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("stop() closes the proxy port with the server", async () => {
|
|
154
|
+
const app = startFakeApp();
|
|
155
|
+
const server = await startLiveServer(`http://127.0.0.1:${app.port}`);
|
|
156
|
+
const plan = (await (await fetch(`${server.url}/api/plan`)).json()) as { appUrl: string };
|
|
157
|
+
// Reachable while running.
|
|
158
|
+
expect((await fetch(plan.appUrl)).status).toBe(200);
|
|
159
|
+
server.stop();
|
|
160
|
+
await Bun.sleep(50);
|
|
161
|
+
let closed = false;
|
|
162
|
+
try {
|
|
163
|
+
await fetch(plan.appUrl, { signal: AbortSignal.timeout(1000) });
|
|
164
|
+
} catch {
|
|
165
|
+
closed = true;
|
|
166
|
+
}
|
|
167
|
+
expect(closed).toBe(true);
|
|
168
|
+
app.stop(true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("remote sessions refuse live app mode outright", async () => {
|
|
172
|
+
// Defense in depth behind the command-side check: a live proxy relays
|
|
173
|
+
// the user's authenticated dev app, and a remote Pi session is
|
|
174
|
+
// reachable beyond loopback. No override env var exists on purpose.
|
|
175
|
+
const app = startFakeApp();
|
|
176
|
+
process.env.PLANNOTATOR_REMOTE = "1";
|
|
177
|
+
try {
|
|
178
|
+
await expect(startLiveServer(`http://127.0.0.1:${app.port}`)).rejects.toThrow(
|
|
179
|
+
"Live app annotation is unavailable in remote mode",
|
|
180
|
+
);
|
|
181
|
+
} finally {
|
|
182
|
+
process.env.PLANNOTATOR_REMOTE = "0";
|
|
183
|
+
app.stop(true);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("draft isolation between live sessions", () => {
|
|
188
|
+
// A live session holds no document text (markdown is "" by
|
|
189
|
+
// construction), so keying its draft by content would give every live
|
|
190
|
+
// session on the machine the one hash of the empty string. The target
|
|
191
|
+
// is the identity, shared with the Bun server via liveAppDraftIdentity.
|
|
192
|
+
const savedDataDir = process.env.PLANNOTATOR_DATA_DIR;
|
|
193
|
+
let draftDataDir: string;
|
|
194
|
+
|
|
195
|
+
beforeEach(() => {
|
|
196
|
+
draftDataDir = mkdtempSync(join(tmpdir(), "plannotator-pi-live-draft-"));
|
|
197
|
+
process.env.PLANNOTATOR_DATA_DIR = draftDataDir;
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
afterEach(() => {
|
|
201
|
+
if (savedDataDir === undefined) delete process.env.PLANNOTATOR_DATA_DIR;
|
|
202
|
+
else process.env.PLANNOTATOR_DATA_DIR = savedDataDir;
|
|
203
|
+
rmSync(draftDataDir, { recursive: true, force: true });
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
async function saveDraft(server: { url: string }, feedback: string): Promise<void> {
|
|
207
|
+
const res = await fetch(`${server.url}/api/draft`, {
|
|
208
|
+
method: "POST",
|
|
209
|
+
headers: { "Content-Type": "application/json" },
|
|
210
|
+
body: JSON.stringify({ feedback, annotations: [] }),
|
|
211
|
+
});
|
|
212
|
+
expect(res.status).toBe(200);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function loadDraft(server: { url: string }): Promise<{ feedback?: string } | null> {
|
|
216
|
+
const res = await fetch(`${server.url}/api/draft`);
|
|
217
|
+
if (res.status === 404) return null;
|
|
218
|
+
expect(res.status).toBe(200);
|
|
219
|
+
return (await res.json()) as { feedback?: string };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
test("two live sessions on different targets keep independent drafts", async () => {
|
|
223
|
+
const appX = startFakeApp();
|
|
224
|
+
const appY = startFakeApp();
|
|
225
|
+
const serverX = await startLiveServer(`http://127.0.0.1:${appX.port}`);
|
|
226
|
+
const serverY = await startLiveServer(`http://127.0.0.1:${appY.port}`);
|
|
227
|
+
try {
|
|
228
|
+
await saveDraft(serverX, "notes for X");
|
|
229
|
+
await saveDraft(serverY, "notes for Y");
|
|
230
|
+
|
|
231
|
+
// Neither session sees the other's text, in either direction.
|
|
232
|
+
expect((await loadDraft(serverX))?.feedback).toBe("notes for X");
|
|
233
|
+
expect((await loadDraft(serverY))?.feedback).toBe("notes for Y");
|
|
234
|
+
expect(readdirSync(join(draftDataDir, "drafts")).length).toBe(2);
|
|
235
|
+
} finally {
|
|
236
|
+
serverX.stop();
|
|
237
|
+
serverY.stop();
|
|
238
|
+
appX.stop(true);
|
|
239
|
+
appY.stop(true);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("the same target recovers its draft after a restart", async () => {
|
|
244
|
+
const app = startFakeApp();
|
|
245
|
+
const targetUrl = `http://127.0.0.1:${app.port}`;
|
|
246
|
+
const first = await startLiveServer(targetUrl);
|
|
247
|
+
try {
|
|
248
|
+
await saveDraft(first, "survives the crash");
|
|
249
|
+
} finally {
|
|
250
|
+
first.stop();
|
|
251
|
+
}
|
|
252
|
+
// Same target, spelled with a trailing slash the way a browser would
|
|
253
|
+
// hand it back: the draft is the point of the key, so it must survive.
|
|
254
|
+
const second = await startLiveServer(`${targetUrl}/`);
|
|
255
|
+
try {
|
|
256
|
+
expect((await loadDraft(second))?.feedback).toBe("survives the crash");
|
|
257
|
+
} finally {
|
|
258
|
+
second.stop();
|
|
259
|
+
app.stop(true);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("the vendored identity matches the Bun server's normalization", () => {
|
|
264
|
+
const a = liveAppDraftIdentity("http://127.0.0.1:5173");
|
|
265
|
+
expect(liveAppDraftIdentity("http://127.0.0.1:5173/")).toBe(a);
|
|
266
|
+
expect(liveAppDraftIdentity("http://127.0.0.1:5174")).not.toBe(a);
|
|
267
|
+
expect(liveAppDraftIdentity("http://127.0.0.1:5173/admin/")).toBe(
|
|
268
|
+
liveAppDraftIdentity("http://127.0.0.1:5173/admin"),
|
|
269
|
+
);
|
|
270
|
+
expect(liveAppDraftIdentity("not a url")).toBe("not a url");
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
});
|
package/server/serverAnnotate.ts
CHANGED
|
@@ -2,7 +2,16 @@ import { createServer } from "node:http";
|
|
|
2
2
|
import type { IncomingMessage } from "node:http";
|
|
3
3
|
import { dirname, resolve as resolvePath } from "node:path";
|
|
4
4
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
5
|
-
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
buildLiveAppUrl,
|
|
9
|
+
buildLiveEditorOrigins,
|
|
10
|
+
composeLiveBridgeJs,
|
|
11
|
+
liveAppDraftIdentity,
|
|
12
|
+
} from "../generated/live-proxy-core.ts";
|
|
13
|
+
import { startLiveAppProxyNode } from "../generated/live-proxy-node.ts";
|
|
14
|
+
import type { LiveAppProxy } from "../generated/live-proxy-core.ts";
|
|
6
15
|
|
|
7
16
|
import { contentHash, deleteDraft } from "../generated/draft.ts";
|
|
8
17
|
import { getPlanVersion, getVersionCount, listVersions } from "../generated/storage.ts";
|
|
@@ -224,7 +233,28 @@ export async function startAnnotateServer(options: {
|
|
|
224
233
|
agentCwd?: string;
|
|
225
234
|
/** Project name for keying per-file version history (powers the annotate version diff). */
|
|
226
235
|
project?: string;
|
|
236
|
+
/**
|
|
237
|
+
* Live local app annotation (mode "annotate-app"): the server starts a
|
|
238
|
+
* loopback reverse proxy (node:http transport over the shared core)
|
|
239
|
+
* mirroring targetUrl and serves the composed bridge body from it. The
|
|
240
|
+
* caller supplies the bridge sources; the server owns the per-session
|
|
241
|
+
* token. Refused outright in remote mode.
|
|
242
|
+
*/
|
|
243
|
+
liveApp?: {
|
|
244
|
+
targetUrl: string;
|
|
245
|
+
bridgeScript: string;
|
|
246
|
+
bridgeBootstrap: string;
|
|
247
|
+
annotationCss: string;
|
|
248
|
+
};
|
|
227
249
|
}): Promise<AnnotateServerResult> {
|
|
250
|
+
// Remote hard-off, defense in depth behind the command-side check: a live
|
|
251
|
+
// proxy relays the user's authenticated dev app, so it must never coexist
|
|
252
|
+
// with a beyond-loopback annotate bind. No override env var exists on
|
|
253
|
+
// purpose (mirrors packages/server/annotate.ts).
|
|
254
|
+
if (options.liveApp && isRemoteSession()) {
|
|
255
|
+
throw new Error("Live app annotation is unavailable in remote mode");
|
|
256
|
+
}
|
|
257
|
+
|
|
228
258
|
const gitUser = detectGitUser();
|
|
229
259
|
const sharingEnabled =
|
|
230
260
|
options.sharingEnabled ?? resolveSharingEnabled(loadConfig());
|
|
@@ -275,11 +305,19 @@ export async function startAnnotateServer(options: {
|
|
|
275
305
|
{ graceMs: clientLeaseGraceMs },
|
|
276
306
|
);
|
|
277
307
|
|
|
278
|
-
//
|
|
308
|
+
// Draft identity. Content-derived for the modes that HAVE content, and
|
|
309
|
+
// path-derived for the modes that do not: a live app session resolves
|
|
310
|
+
// `markdown` to "" by construction (the page lives behind the proxy), so
|
|
311
|
+
// its target is its identity — hashing the empty body would give every
|
|
312
|
+
// live session on the machine one shared draft slot. Folder sessions key
|
|
313
|
+
// by folder path for the same reason. liveAppDraftIdentity is the shared
|
|
314
|
+
// normalization, so Bun and Pi sessions key the same target identically.
|
|
279
315
|
const draftSource =
|
|
280
|
-
options.mode === "annotate-
|
|
281
|
-
? `
|
|
282
|
-
: options.
|
|
316
|
+
options.mode === "annotate-app" && options.liveApp
|
|
317
|
+
? `annotate-app\0${liveAppDraftIdentity(options.liveApp.targetUrl)}`
|
|
318
|
+
: options.mode === "annotate-folder" && options.folderPath
|
|
319
|
+
? `folder:${resolvePath(options.folderPath)}`
|
|
320
|
+
: options.renderHtml && options.rawHtml ? options.rawHtml : options.markdown;
|
|
283
321
|
const draftKey = contentHash(draftSource);
|
|
284
322
|
|
|
285
323
|
// Per-file version history → powers the native version diff in annotate mode.
|
|
@@ -290,10 +328,13 @@ export async function startAnnotateServer(options: {
|
|
|
290
328
|
const annotateProjectName = options.project ?? "_unknown";
|
|
291
329
|
const annotateHistoryEnabled = resolveAnnotateHistory(loadConfig());
|
|
292
330
|
// Single local file sessions are the only ones this eager gate covers.
|
|
293
|
-
// URL
|
|
294
|
-
// dir. Folder sessions do participate in per-file version history,
|
|
295
|
-
// lazily through /api/doc (see computeFolderAnnotateHistory below), not
|
|
296
|
-
// here. The durable submit records stay single-local-file only.
|
|
331
|
+
// URL, agent-message, and live-app sessions never write session content to
|
|
332
|
+
// the data dir. Folder sessions do participate in per-file version history,
|
|
333
|
+
// but lazily through /api/doc (see computeFolderAnnotateHistory below), not
|
|
334
|
+
// here. The durable submit records stay single-local-file only. The
|
|
335
|
+
// mode === "annotate" check is deliberate and explicit: "annotate-app"
|
|
336
|
+
// (whose filePath is URL-shaped anyway) must never become history-eligible
|
|
337
|
+
// by accident.
|
|
297
338
|
const singleFileLocalAnnotate =
|
|
298
339
|
(options.mode || "annotate") === "annotate" && !/^https?:\/\//i.test(options.filePath);
|
|
299
340
|
let annotateHistory: AnnotateHistoryResult | null = null;
|
|
@@ -499,6 +540,13 @@ export async function startAnnotateServer(options: {
|
|
|
499
540
|
initialSingleFileSourcePath,
|
|
500
541
|
});
|
|
501
542
|
|
|
543
|
+
// Live app session state, populated after the annotate port is known (the
|
|
544
|
+
// editor origins carry the port) and before the URL is returned to the
|
|
545
|
+
// caller for advertisement.
|
|
546
|
+
let liveProxy: LiveAppProxy | null = null;
|
|
547
|
+
let liveSessionToken = "";
|
|
548
|
+
let liveAppUrl = "";
|
|
549
|
+
|
|
502
550
|
const server = createServer(async (req, res) => {
|
|
503
551
|
const url = requestUrl(req);
|
|
504
552
|
|
|
@@ -541,7 +589,41 @@ export async function startAnnotateServer(options: {
|
|
|
541
589
|
return;
|
|
542
590
|
}
|
|
543
591
|
|
|
544
|
-
if (url.pathname === "/api/plan" && req.method === "GET") {
|
|
592
|
+
if (url.pathname === "/api/plan" && req.method === "GET" && options.mode === "annotate-app" && options.liveApp) {
|
|
593
|
+
// Live app session: no rawHtml, no renderAs, no version fields,
|
|
594
|
+
// sharing off. The client frames appUrl (the loopback proxy) and
|
|
595
|
+
// authenticates the bridge with liveToken. Mirrors the Bun
|
|
596
|
+
// server's annotate-app payload (packages/server/annotate.ts).
|
|
597
|
+
json(res, {
|
|
598
|
+
plan: "",
|
|
599
|
+
origin: options.origin ?? "pi",
|
|
600
|
+
mode: options.mode,
|
|
601
|
+
filePath: options.filePath,
|
|
602
|
+
sourceInfo: options.sourceInfo ?? options.liveApp.targetUrl,
|
|
603
|
+
appUrl: liveAppUrl,
|
|
604
|
+
targetUrl: options.liveApp.targetUrl,
|
|
605
|
+
liveToken: liveSessionToken,
|
|
606
|
+
gate: options.gate ?? false,
|
|
607
|
+
approvalNotesSupported: options.approvalNotesSupported ?? false,
|
|
608
|
+
clientLease: options.clientLeaseSupported
|
|
609
|
+
? { enabled: true as const, reconnectGraceMs: clientLeaseGraceMs }
|
|
610
|
+
: { enabled: false as const },
|
|
611
|
+
sharingEnabled: false,
|
|
612
|
+
convertHtml: false,
|
|
613
|
+
repoInfo,
|
|
614
|
+
projectRoot: process.cwd(),
|
|
615
|
+
serverConfig: getServerConfig(gitUser),
|
|
616
|
+
agentTerminal: agentTerminalCapability,
|
|
617
|
+
feedbackTemplates: {
|
|
618
|
+
fileFeedback: getAnnotateFileFeedbackTemplate(
|
|
619
|
+
(options.origin ?? "pi") as PromptRuntime,
|
|
620
|
+
),
|
|
621
|
+
messageFeedback: getAnnotateMessageFeedbackTemplate(
|
|
622
|
+
(options.origin ?? "pi") as PromptRuntime,
|
|
623
|
+
),
|
|
624
|
+
},
|
|
625
|
+
});
|
|
626
|
+
} else if (url.pathname === "/api/plan" && req.method === "GET") {
|
|
545
627
|
const displayRawHtml = options.renderHtml && options.rawHtml
|
|
546
628
|
? htmlAssets.rewriteHtml(options.rawHtml, options.filePath)
|
|
547
629
|
: undefined;
|
|
@@ -928,6 +1010,55 @@ export async function startAnnotateServer(options: {
|
|
|
928
1010
|
|
|
929
1011
|
const { port, portSource } = await listenOnPort(server);
|
|
930
1012
|
|
|
1013
|
+
if (options.liveApp) {
|
|
1014
|
+
// Compose the proxy-served bridge body via the shared assembly (config
|
|
1015
|
+
// prelude with the token this server owns, both editor origin forms
|
|
1016
|
+
// with the localhost one first to match the advertised URL, then the
|
|
1017
|
+
// bootstrap that installs the CSS, then the bridge itself). A proxy
|
|
1018
|
+
// startup failure must not leave the annotate listener hanging.
|
|
1019
|
+
try {
|
|
1020
|
+
liveSessionToken = randomBytes(16).toString("hex");
|
|
1021
|
+
const editorOrigins = buildLiveEditorOrigins(port);
|
|
1022
|
+
liveProxy = await startLiveAppProxyNode({
|
|
1023
|
+
targetUrl: options.liveApp.targetUrl,
|
|
1024
|
+
editorOrigins,
|
|
1025
|
+
bridgeJs: composeLiveBridgeJs({
|
|
1026
|
+
token: liveSessionToken,
|
|
1027
|
+
editorOrigins,
|
|
1028
|
+
annotationCss: options.liveApp.annotationCss,
|
|
1029
|
+
bridgeBootstrap: options.liveApp.bridgeBootstrap,
|
|
1030
|
+
bridgeScript: options.liveApp.bridgeScript,
|
|
1031
|
+
}),
|
|
1032
|
+
});
|
|
1033
|
+
// Advertise the proxy under the LOCALHOST spelling, carrying the
|
|
1034
|
+
// target URL's own path and query (see buildLiveAppUrl in the
|
|
1035
|
+
// shared core for the same-site/cookie rationale).
|
|
1036
|
+
// PLANNOTATOR_URL_HOST is never applied here.
|
|
1037
|
+
liveAppUrl = buildLiveAppUrl(liveProxy.port, options.liveApp.targetUrl);
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
// Same disposal set the normal stop() runs, each step guarded so
|
|
1040
|
+
// the original startup error is what propagates.
|
|
1041
|
+
for (const dispose of [
|
|
1042
|
+
() => closeAllFileBrowserWatchers(),
|
|
1043
|
+
() => {
|
|
1044
|
+
clientLease.cancel();
|
|
1045
|
+
clientLease.closeSessions();
|
|
1046
|
+
},
|
|
1047
|
+
() => aiRuntime?.dispose(),
|
|
1048
|
+
() => agentTerminal.dispose(),
|
|
1049
|
+
]) {
|
|
1050
|
+
try {
|
|
1051
|
+
dispose();
|
|
1052
|
+
} catch {
|
|
1053
|
+
// startup failure cleanup: best effort
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
server.close();
|
|
1057
|
+
(server as { closeAllConnections?: () => void }).closeAllConnections?.();
|
|
1058
|
+
throw error;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
931
1062
|
// Mirror the Bun server: bind first, then warm through the async shared walk.
|
|
932
1063
|
void warmFileListCache(process.cwd(), "code");
|
|
933
1064
|
|
|
@@ -954,6 +1085,11 @@ export async function startAnnotateServer(options: {
|
|
|
954
1085
|
}],
|
|
955
1086
|
["AI runtime", () => aiRuntime?.dispose()],
|
|
956
1087
|
["agent terminal", () => agentTerminal.dispose()],
|
|
1088
|
+
// Live proxy last, mirroring the Bun server's guarded order: a
|
|
1089
|
+
// throw in the historically fragile agent-terminal teardown
|
|
1090
|
+
// (#1314) must never orphan the proxy's listener or its
|
|
1091
|
+
// upstream sockets.
|
|
1092
|
+
["live proxy", () => liveProxy?.stop()],
|
|
957
1093
|
];
|
|
958
1094
|
try {
|
|
959
1095
|
for (const [name, dispose] of disposals) {
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plannotator
|
|
3
|
+
description: "Reference for using the Plannotator CLI: plan review, code review, annotating files, URLs, folders, and running local apps, annotating the last assistant message, browsing archived plan decisions, and exporting or sharing Guided Reviews. Invoke when asked to use Plannotator for anything not covered by a more specific plannotator-* skill."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Plannotator CLI Reference
|
|
7
|
+
|
|
8
|
+
Plannotator is a local, browser-based review layer for agent workflows: it opens plans, diffs, and documents in an annotation UI, the human marks them up, and the structured feedback comes back to you on stdout. It installs as a single `plannotator` binary plus per-host hooks, so plan review fires automatically when you exit plan mode; every other surface is launched explicitly from the CLI. A session runs on a random localhost port (fixed port 19432 in remote mode) and blocks until the reviewer submits feedback, approves, or closes the tab.
|
|
9
|
+
|
|
10
|
+
This skill is the knowledge layer. The `plannotator-review`, `plannotator-annotate`, and `plannotator-last` skills are thin launchers for the three most common actions; use this reference when you need to pick the right command or flags yourself.
|
|
11
|
+
|
|
12
|
+
## Choose the command
|
|
13
|
+
|
|
14
|
+
| The user wants | Run |
|
|
15
|
+
| --- | --- |
|
|
16
|
+
| Review a plan you produced | Nothing. Plan review opens automatically on plan exit via hooks. Never run bare `plannotator` yourself. |
|
|
17
|
+
| Review current code changes | `plannotator review` |
|
|
18
|
+
| Review a GitHub PR or GitLab MR | `plannotator review <PR_URL>` |
|
|
19
|
+
| Annotate a markdown, text, config, or HTML file | `plannotator annotate <file>` |
|
|
20
|
+
| Annotate a web page | `plannotator annotate <https-url>` |
|
|
21
|
+
| Annotate a running local app (dev server) | `plannotator annotate <http://localhost:PORT/>` |
|
|
22
|
+
| Pick a file to annotate from a folder | `plannotator annotate <folder/>` |
|
|
23
|
+
| Annotate your latest assistant message | `plannotator last` |
|
|
24
|
+
| Browse past plan decisions | `plannotator archive` |
|
|
25
|
+
| Export or share a Guided Review | `plannotator guide export` / `plannotator guide share` |
|
|
26
|
+
| Reopen or list live sessions | `plannotator sessions` |
|
|
27
|
+
|
|
28
|
+
## Session model
|
|
29
|
+
|
|
30
|
+
Every review or annotate command starts a local web server, opens the browser, and blocks until the human decides. That can take minutes. Launch it with a long (or no) command timeout, or in the background, then read stdout when the process exits. Do not kill the process to "finish" a review; a session that ends without a decision reads as no feedback.
|
|
31
|
+
|
|
32
|
+
The stdout contract is the whole interface:
|
|
33
|
+
|
|
34
|
+
- Plaintext (default): empty output on close, `The user approved.` on approve, otherwise the feedback text. Address returned feedback in the same conversation.
|
|
35
|
+
- `--json`: one JSON record, `{"decision":"approved"|"dismissed"|"annotated","feedback":"..."}`. An approval may still carry notes in `feedback`; treat those as guidance, not a change request.
|
|
36
|
+
- `--hook`: hook-native output for real PostToolUse/Stop hook contexts only. Approve/close emits nothing (hook passes); annotations emit `{"decision":"block","reason":"..."}`. `--hook` implies the gate UI. Never use it for a normal interactive invocation.
|
|
37
|
+
|
|
38
|
+
`plannotator <command> --help` prints usage without launching anything. Bare `plannotator` is the hook entry point and expects hook JSON on stdin.
|
|
39
|
+
|
|
40
|
+
## plannotator review
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
plannotator review [--git | --gitbutler] [--local | --no-local] [--tailscale] [PR_URL]
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Reviews local VCS changes, or a pull request when a URL is given. Feedback and annotations come back on stdout when the reviewer submits; an approval comes back as an LGTM-style message.
|
|
47
|
+
|
|
48
|
+
- VCS is auto-detected (JJ, GitButler, Git, and P4 where supported). `--git` forces plain Git; `--gitbutler` forces GitButler (requires the `but` CLI 0.21.0+). Running from a non-VCS parent folder that contains nested repos produces a combined workspace diff.
|
|
49
|
+
- The default diff is "everything a PR would show now": merge-base of the trunk vs the working tree plus untracked files. The reviewer can switch diff types in the UI; you do not control that from the CLI.
|
|
50
|
+
- PR review (`plannotator review https://github.com/owner/repo/pull/123`, GitLab MR URLs too) needs an authenticated `gh` or `glab` CLI. `--local` (the default) builds a local checkout of the PR head in the background for full file access; `--no-local` skips it and reviews the platform diff only.
|
|
51
|
+
- `--tailscale` publishes the loopback session over the user's tailnet via `tailscale serve` (HTTPS, never public) and prints the URL with a QR code. A publish failure exits nonzero instead of leaving the server hanging.
|
|
52
|
+
|
|
53
|
+
## plannotator annotate
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
plannotator annotate <target> [--markdown] [--no-jina] [--app | --static] [--render-html] [--tailscale] [--gate] [--json] [--hook]
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Opens one document, page, or app in the annotation UI and returns the human's annotations on stdout.
|
|
60
|
+
|
|
61
|
+
Targets:
|
|
62
|
+
|
|
63
|
+
- Markdown and text files: `.md`, `.mdx`, `.txt`.
|
|
64
|
+
- Plain-text config and data files, rendered as text: `.yaml`, `.yml`, `.json`, `.jsonc`, `.json5`, `.toml`, `.ini`, `.cfg`, `.conf`, `.properties`, `.csv`, `.tsv`, `.log`, `.xml`, `.env.example`. `.env` itself is deliberately refused (it commonly holds secrets, and annotate history copies file contents). Source-code files belong to `plannotator review`, not annotate.
|
|
65
|
+
- HTML files (`.html`, `.htm`): rendered as the raw page by default; `--markdown` converts to markdown instead. `--render-html` is accepted for compatibility; raw rendering is already the default.
|
|
66
|
+
- URLs (`https://...`): fetched and converted via Jina Reader by default; `--no-jina` uses plain fetch plus Turndown instead.
|
|
67
|
+
- Running local apps: a loopback `http://localhost:PORT/` URL whose probe returns HTML opens in live-app mode (annotate the real running page). `--app` forces live mode and fails loudly when it cannot apply; `--static` forces the classic conversion pipeline. Non-loopback URLs always use the conversion pipeline.
|
|
68
|
+
- Folders: `plannotator annotate docs/` opens a file browser over the folder's supported files.
|
|
69
|
+
|
|
70
|
+
Single files are capped at 2MB. Files are read from disk at stable project paths; keep the reviewed source where it lives.
|
|
71
|
+
|
|
72
|
+
Argument tolerance: extra words are fine (`plannotator annotate look at notes.md please` opens `notes.md`), but two resolvable targets is an error naming both, and an unrecognized dashed token disables the tolerance so flag typos fail loudly. When nothing resolves in a plain multi-word invocation, the CLI prints an agent-addressed handoff on stdout and exits 0: read it, work out the concrete target, and re-run with that exact path or URL.
|
|
73
|
+
|
|
74
|
+
### Strict gates and exit codes
|
|
75
|
+
|
|
76
|
+
For a machine-checkable approval gate, add `--gate --json` plus one or both strict flags:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
plannotator annotate report.md --gate --json --require-approval --result-file /tmp/decision.json
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- `--require-approval`: exit code reports the human outcome.
|
|
83
|
+
- `--result-file <path>`: the stdout decision JSON is also published atomically to `<path>`. The parent directory must exist and the file must not; results resolve from the invocation cwd.
|
|
84
|
+
|
|
85
|
+
Exit codes under a strict flag (grep convention):
|
|
86
|
+
|
|
87
|
+
| Exit | Meaning |
|
|
88
|
+
| --- | --- |
|
|
89
|
+
| 0 | Approved. The only success. |
|
|
90
|
+
| 1 | The reviewer did not approve (annotated or dismissed); the decision record was still published. |
|
|
91
|
+
| 2 | The gate itself failed: bad flag combination, startup failure (missing file, unreachable URL, oversized file), or the result file could not be published. Never treat as a reviewer outcome. |
|
|
92
|
+
| 128+n | Killed by signal n. |
|
|
93
|
+
|
|
94
|
+
Without strict flags, startup failures exit 1 and the exit code carries no decision; parse the output instead. Both strict flags require `--gate --json` and reject `--hook`.
|
|
95
|
+
|
|
96
|
+
## plannotator annotate-last
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
plannotator annotate-last [--stdin] [--tailscale] [--gate] [--json] [--hook]
|
|
100
|
+
plannotator last
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Opens the latest rendered assistant message from the current agent session in the annotation UI (`last` is an alias). The session log is discovered per host automatically; `--stdin` reads the content from stdin instead.
|
|
104
|
+
|
|
105
|
+
Do not print a commentary or status message immediately before running it: the command targets the latest rendered assistant message, so a preamble becomes the thing being annotated.
|
|
106
|
+
|
|
107
|
+
## plannotator copilot-last
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
plannotator copilot-last [--gate] [--json] [--hook]
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The annotate-last variant for live GitHub Copilot CLI sessions (reads Copilot's session-state events). Normally invoked by the Copilot plugin's /plannotator-last command; use it only inside a Copilot CLI session.
|
|
114
|
+
|
|
115
|
+
## plannotator archive
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
plannotator archive
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Opens a read-only browser over saved plan decisions (approved/denied badges) from the Plannotator data directory. No feedback comes back; the session ends when the user clicks Done.
|
|
122
|
+
|
|
123
|
+
## plannotator guide
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
plannotator guide list
|
|
127
|
+
plannotator guide export --id <savedGuideId> [--out <file.html>]
|
|
128
|
+
plannotator guide export --guide <guide.json> --patch <diff.patch> [--out <file.html>]
|
|
129
|
+
plannotator guide export --snapshot <snapshot.json> [--out <file.html>]
|
|
130
|
+
plannotator guide share --id <savedGuideId> [--public] [--ttl <7d|24h|30m|3600>] [--json]
|
|
131
|
+
plannotator guide unshare <id> --token <deleteToken>
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Guided Reviews are AI-generated walkthroughs of a diff, produced inside the code review UI. The CLI works with saved ones:
|
|
135
|
+
|
|
136
|
+
- `list` shows guides Plannotator has persisted for the current repo.
|
|
137
|
+
- `export` writes one portable, self-contained HTML file (the viewer loads from guides.show). `--guide` + `--patch` exports a guide you authored yourself against a unified diff (`--patch -` reads stdin; validation is strict and names any file the guide references that the patch lacks). `--out -` writes to stdout. `--viewer-url` overrides the pinned viewer base.
|
|
138
|
+
- `share` uploads the guide and prints a link. Encrypted by default: the key lives only in the URL fragment and the host stores ciphertext. `--public` stores it unencrypted so chat apps can unfurl a preview. `--ttl` sets an expiry; otherwise the link stays until `unshare`. A saved guide records its link, and a second `share --id` refuses rather than orphaning the first link's delete token.
|
|
139
|
+
- `unshare <id> --token <t>` removes a link using the delete token printed at share time.
|
|
140
|
+
|
|
141
|
+
## plannotator sessions
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
plannotator sessions [--open [N]] [--clean]
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Lists active Plannotator server sessions. `--open` reopens session N (default 1) in the browser, useful when a tab was closed mid-review. `--clean` drops stale entries.
|
|
148
|
+
|
|
149
|
+
## Other subcommands
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
plannotator setup-goal <interview|facts> <bundle.json | -> [--json]
|
|
153
|
+
plannotator uninstall [--purge] [--yes] [--dry-run]
|
|
154
|
+
plannotator improve-context
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
- `setup-goal` opens the interview or facts-acceptance UI for /goal workflows; it is driven by the `plannotator-setup-goal` skill and takes a bundle JSON (`-` reads stdin). Do not hand-build bundles.
|
|
158
|
+
- `uninstall` removes Plannotator-installed components (`--purge` also deletes local data; `--yes` is required without a TTY; `--dry-run` previews).
|
|
159
|
+
- `improve-context` and `install-runtime` are internal integration commands (hook plumbing and managed runtime install). Never run `improve-context` directly; `plannotator install-runtime agent-terminal` exists for reinstalling the optional annotate-terminal runtime and is normally run by the installer.
|
|
160
|
+
- Additional host-internal subcommands (the `opencode-*` and `copilot-plan` family) are invoked by their plugins, not by you.
|
|
161
|
+
|
|
162
|
+
## Environment variables that change behavior
|
|
163
|
+
|
|
164
|
+
| Variable | Use |
|
|
165
|
+
| --- | --- |
|
|
166
|
+
| `PLANNOTATOR_REMOTE=1` | Force remote mode (fixed port 19432, wide bind) for SSH/devcontainer sessions; `0` forces local. Unset means SSH auto-detection. |
|
|
167
|
+
| `PLANNOTATOR_PORT` | Fix the port instead of a random one. |
|
|
168
|
+
| `PLANNOTATOR_ORIGIN` | Override agent-origin detection (`claude-code`, `codex`, `opencode`, `pi`, `oh-my-pi`, `amp`, `droid`, `copilot-cli`, `gemini-cli`, `kiro-cli`). Set it when launching Plannotator from a wrapper the detection cannot see through. |
|
|
169
|
+
| `PLANNOTATOR_AI=disabled` | Disable Ask AI and agent-launched review surfaces in the UI. |
|
|
170
|
+
| `PLANNOTATOR_SHARE=disabled` | Disable URL sharing, including guide share links. |
|
|
171
|
+
| `PLANNOTATOR_DATA_DIR` | Move the data directory (default `~/.plannotator`): plans, history, drafts, config. |
|
|
172
|
+
| `PLANNOTATOR_BROWSER` | Open sessions in a specific browser. |
|
|
173
|
+
|
|
174
|
+
## Posting annotations into a live session
|
|
175
|
+
|
|
176
|
+
A running plan-review session exposes a small HTTP API on its base URL for external annotations: `POST /api/external-annotations` adds inline annotations the reviewer sees immediately, with PATCH/DELETE for updates and an SSE stream at `/api/external-annotations/stream`. The UI's "copy agent instructions" action puts the full API contract for the current session, with the correct base URL, on the clipboard for handing to an agent or script. If the user pastes such instructions, follow them; do not invent endpoints beyond that contract.
|
|
177
|
+
|
|
178
|
+
## Do not
|
|
179
|
+
|
|
180
|
+
- Do not parse or scrape the browser UI's HTML; the CLI's stdout (and the documented HTTP API above) is the whole contract.
|
|
181
|
+
- Do not use `--hook` outside a real hook context; use `--json` when you need structured output.
|
|
182
|
+
- Do not run bare `plannotator` interactively; it is the hook entry point.
|
|
183
|
+
- Do not guess flags. Run `plannotator <command> --help` when unsure; unknown dashed tokens make annotate fail on purpose.
|
|
184
|
+
- Do not point `plannotator annotate` at source-code files or `.env` files; code goes through `plannotator review`, and `.env` is refused.
|
|
185
|
+
- Do not start a strict gate (`--require-approval`) unless a human is actually there to review; the session blocks until they act.
|