@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,419 @@
|
|
|
1
|
+
// @generated — DO NOT EDIT. Source: packages/shared/live-proxy-node.ts
|
|
2
|
+
/**
|
|
3
|
+
* Loopback reverse proxy for live local app annotation — Node transport.
|
|
4
|
+
*
|
|
5
|
+
* The node:http mirror of packages/server/live-proxy.ts, run by the Pi
|
|
6
|
+
* extension (vendored to apps/pi-extension/generated/). Every DECISION —
|
|
7
|
+
* Host/Origin validation, the streaming injector state machine, the
|
|
8
|
+
* CSP/X-Frame-Options policy, the redirect rewrite, the WS origin gate — is
|
|
9
|
+
* imported from ./live-proxy-core and therefore byte-identical to the Bun
|
|
10
|
+
* transport; this file is only the node:http plumbing around them.
|
|
11
|
+
*
|
|
12
|
+
* Transport notes:
|
|
13
|
+
* - Request/response bodies are PIPED, never buffered: the upstream response
|
|
14
|
+
* streams through a Transform running the shared injector for HTML, and
|
|
15
|
+
* verbatim otherwise, with backpressure handled by stream.pipeline.
|
|
16
|
+
* - WebSocket passthrough (HMR) rides node's 'upgrade' event: the client's
|
|
17
|
+
* own handshake (its Sec-WebSocket-Key et al., raw header casing intact)
|
|
18
|
+
* is replayed upstream over a raw node:net connection, the upstream's 101
|
|
19
|
+
* is relayed back verbatim, and from then on the two sockets are piped
|
|
20
|
+
* byte-for-byte — no WS framing library, no pending-message queue (TCP
|
|
21
|
+
* holds early client bytes until the 101 lands; the 'upgrade' head buffer
|
|
22
|
+
* is forwarded explicitly). Raw TCP rather than an http.request client on
|
|
23
|
+
* purpose: 101 handling differs between Node's http client and other
|
|
24
|
+
* runtimes' node:http shims, while a socket is a socket everywhere. The
|
|
25
|
+
* upstream connect carries no Origin header, which is exactly why the
|
|
26
|
+
* Origin gate refuses foreign browser origins BEFORE any upstream contact
|
|
27
|
+
* (Vite CVE-2025-24010 class).
|
|
28
|
+
*
|
|
29
|
+
* Security posture (identical to the Bun transport; binding is the
|
|
30
|
+
* contract): binds 127.0.0.1 UNCONDITIONALLY — never the shared
|
|
31
|
+
* env-dependent hostname helper — validates Host before any URL parsing or
|
|
32
|
+
* upstream contact (a Host-less HTTP/1.0 request answers the plain 403,
|
|
33
|
+
* never a runtime error), and PLANNOTATOR_URL_HOST is never applied to the
|
|
34
|
+
* proxy origin.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { createServer, request as httpRequest } from "node:http";
|
|
38
|
+
import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from "node:http";
|
|
39
|
+
import { connect as netConnect, type Socket } from "node:net";
|
|
40
|
+
import { Transform, pipeline } from "node:stream";
|
|
41
|
+
import {
|
|
42
|
+
HOP_BY_HOP_HEADERS,
|
|
43
|
+
LIVE_PROXY_BRIDGE_PATH,
|
|
44
|
+
LIVE_PROXY_RESERVED_PREFIX,
|
|
45
|
+
applyHtmlFramingHeaders,
|
|
46
|
+
buildFrameAncestorsPolicy,
|
|
47
|
+
createHtmlInjector,
|
|
48
|
+
isAllowedBridgeFetchSite,
|
|
49
|
+
isAllowedProxyHost,
|
|
50
|
+
isAllowedWsUpgradeOrigin,
|
|
51
|
+
isDocumentIntentRequest,
|
|
52
|
+
isHtmlContentType,
|
|
53
|
+
rewriteLoopbackLocation,
|
|
54
|
+
type HeaderReader,
|
|
55
|
+
type LiveAppProxy,
|
|
56
|
+
type LiveAppProxyOptions,
|
|
57
|
+
} from "./live-proxy-core.ts";
|
|
58
|
+
|
|
59
|
+
// The literal loopback address is the security contract (see header).
|
|
60
|
+
const LOOPBACK_HOST = "127.0.0.1";
|
|
61
|
+
|
|
62
|
+
/** Read adapter over node's lowercased incoming header object. */
|
|
63
|
+
function nodeHeaderReader(headers: IncomingHttpHeaders): HeaderReader {
|
|
64
|
+
return {
|
|
65
|
+
get(name: string): string | null {
|
|
66
|
+
const value = headers[name.toLowerCase()];
|
|
67
|
+
if (value === undefined) return null;
|
|
68
|
+
return Array.isArray(value) ? value.join(", ") : value;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function firstHeaderValue(value: string | string[] | undefined): string | null {
|
|
74
|
+
if (value === undefined) return null;
|
|
75
|
+
return Array.isArray(value) ? value[0] ?? null : value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Outgoing header map (lowercased names; set-cookie may repeat). */
|
|
79
|
+
type HeaderRecord = Record<string, string | string[]>;
|
|
80
|
+
|
|
81
|
+
function copyWithoutHopByHop(headers: IncomingHttpHeaders): HeaderRecord {
|
|
82
|
+
const out: HeaderRecord = {};
|
|
83
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
84
|
+
if (value === undefined) continue;
|
|
85
|
+
if (HOP_BY_HOP_HEADERS.includes(name)) continue; // node keys are lowercased
|
|
86
|
+
out[name] = value;
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function writeRawResponse(socket: Socket, statusLine: string, body: string): void {
|
|
92
|
+
try {
|
|
93
|
+
// end(), not write()+destroy(): destroy discards buffered bytes, and the
|
|
94
|
+
// refusal must actually reach the client before the FIN.
|
|
95
|
+
socket.end(
|
|
96
|
+
`HTTP/1.1 ${statusLine}\r\n`
|
|
97
|
+
+ "Content-Type: text/plain\r\n"
|
|
98
|
+
+ `Content-Length: ${Buffer.byteLength(body)}\r\n`
|
|
99
|
+
+ "Connection: close\r\n\r\n"
|
|
100
|
+
+ body,
|
|
101
|
+
);
|
|
102
|
+
} catch {
|
|
103
|
+
// Socket already gone: nothing to refuse.
|
|
104
|
+
socket.destroy();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function sendForbidden(res: ServerResponse): void {
|
|
109
|
+
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
110
|
+
res.end("Forbidden");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Start the loopback reverse proxy for one live annotate session on
|
|
115
|
+
* node:http. The caller owns its lifecycle: stop() destroys tracked upgrade
|
|
116
|
+
* sockets and closes the listener (plus its remaining keep-alive
|
|
117
|
+
* connections).
|
|
118
|
+
*/
|
|
119
|
+
export function startLiveAppProxyNode(opts: LiveAppProxyOptions): Promise<LiveAppProxy> {
|
|
120
|
+
const target = new URL(opts.targetUrl);
|
|
121
|
+
if (target.protocol !== "http:") {
|
|
122
|
+
throw new Error("Live app proxy supports http upstreams only.");
|
|
123
|
+
}
|
|
124
|
+
const targetHost = target.host;
|
|
125
|
+
const targetHostname = target.hostname;
|
|
126
|
+
const targetPort = Number(target.port || "80");
|
|
127
|
+
const frameAncestors = buildFrameAncestorsPolicy(opts.editorOrigins);
|
|
128
|
+
/** Client+upstream sockets of live WS upgrades, destroyed on stop(). */
|
|
129
|
+
const upgradeSockets = new Set<Socket>();
|
|
130
|
+
let warnedEncodedHtml = false;
|
|
131
|
+
let port = 0;
|
|
132
|
+
|
|
133
|
+
const server = createServer((req, res) => {
|
|
134
|
+
// Host validation FIRST, before any URL construction: the invariant is
|
|
135
|
+
// "Host validation runs before any upstream contact" for ALL inputs,
|
|
136
|
+
// well-formed or not — a Host-less HTTP/1.0 request answers the plain
|
|
137
|
+
// 403, never a runtime error page.
|
|
138
|
+
const hostHeader = firstHeaderValue(req.headers.host);
|
|
139
|
+
if (!isAllowedProxyHost(hostHeader, port)) {
|
|
140
|
+
sendForbidden(res);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// A Host that passed the check should always yield a parseable URL, but
|
|
145
|
+
// parsing must not be the thing that decides: an unparseable request
|
|
146
|
+
// takes the same refusal path rather than any error page.
|
|
147
|
+
let url: URL;
|
|
148
|
+
try {
|
|
149
|
+
url = new URL(req.url ?? "", `http://${hostHeader}`);
|
|
150
|
+
} catch {
|
|
151
|
+
sendForbidden(res);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Reserved namespace: never forwarded upstream.
|
|
156
|
+
if (url.pathname.startsWith(LIVE_PROXY_RESERVED_PREFIX)) {
|
|
157
|
+
if (url.pathname === LIVE_PROXY_BRIDGE_PATH && (req.method === "GET" || req.method === "HEAD")) {
|
|
158
|
+
// The bridge body embeds the per-session token; only same-origin
|
|
159
|
+
// includes and direct navigation may fetch it (see
|
|
160
|
+
// isAllowedBridgeFetchSite in live-proxy-core).
|
|
161
|
+
if (!isAllowedBridgeFetchSite(firstHeaderValue(req.headers["sec-fetch-site"]))) {
|
|
162
|
+
sendForbidden(res);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
res.writeHead(200, {
|
|
166
|
+
"Content-Type": "text/javascript; charset=utf-8",
|
|
167
|
+
"Cache-Control": "no-store",
|
|
168
|
+
});
|
|
169
|
+
res.end(req.method === "HEAD" ? undefined : opts.bridgeJs);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
173
|
+
res.end("Not found");
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Whole-origin forward, streaming both directions.
|
|
178
|
+
const upstreamHeaders = copyWithoutHopByHop(req.headers);
|
|
179
|
+
upstreamHeaders["host"] = targetHost;
|
|
180
|
+
upstreamHeaders["x-forwarded-host"] = hostHeader ?? "";
|
|
181
|
+
upstreamHeaders["x-forwarded-proto"] = "http";
|
|
182
|
+
if (isDocumentIntentRequest(nodeHeaderReader(req.headers))) {
|
|
183
|
+
// HTML must arrive decodable for injection; assets keep encoding.
|
|
184
|
+
upstreamHeaders["accept-encoding"] = "identity";
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const upstreamReq = httpRequest(
|
|
188
|
+
{
|
|
189
|
+
host: targetHostname,
|
|
190
|
+
port: targetPort,
|
|
191
|
+
method: req.method,
|
|
192
|
+
path: url.pathname + url.search,
|
|
193
|
+
headers: upstreamHeaders,
|
|
194
|
+
},
|
|
195
|
+
(upstreamRes) => {
|
|
196
|
+
const responseHeaders = copyWithoutHopByHop(upstreamRes.headers);
|
|
197
|
+
|
|
198
|
+
const location = firstHeaderValue(upstreamRes.headers.location);
|
|
199
|
+
if (location) {
|
|
200
|
+
const rewritten = rewriteLoopbackLocation(
|
|
201
|
+
location,
|
|
202
|
+
target,
|
|
203
|
+
`http://${LOOPBACK_HOST}:${port}`,
|
|
204
|
+
);
|
|
205
|
+
if (rewritten !== null) responseHeaders["location"] = rewritten;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const isHtml = isHtmlContentType(firstHeaderValue(upstreamRes.headers["content-type"]));
|
|
209
|
+
if (isHtml) {
|
|
210
|
+
// Decided posture: drop any app CSP on HTML and replace it with our
|
|
211
|
+
// frame-ancestors policy; non-HTML responses keep their CSP and
|
|
212
|
+
// X-Frame-Options (see applyHtmlFramingHeaders in live-proxy-core).
|
|
213
|
+
applyHtmlFramingHeaders(
|
|
214
|
+
{
|
|
215
|
+
delete: (name) => {
|
|
216
|
+
delete responseHeaders[name];
|
|
217
|
+
},
|
|
218
|
+
set: (name, value) => {
|
|
219
|
+
responseHeaders[name] = value;
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
frameAncestors,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const hasEncoding = !!responseHeaders["content-encoding"];
|
|
227
|
+
if (!isHtml || hasEncoding) {
|
|
228
|
+
if (isHtml && hasEncoding && !warnedEncodedHtml) {
|
|
229
|
+
// Fail open on rendering, closed on injection: the page renders,
|
|
230
|
+
// annotation does not attach on it.
|
|
231
|
+
warnedEncodedHtml = true;
|
|
232
|
+
console.error(
|
|
233
|
+
"[plannotator] Live app proxy: upstream returned content-encoded HTML despite the Accept-Encoding strip; the annotation bridge was not injected.",
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
res.writeHead(upstreamRes.statusCode ?? 502, responseHeaders);
|
|
237
|
+
pipeline(upstreamRes, res, () => {});
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Streaming injection of the bridge script tag.
|
|
242
|
+
delete responseHeaders["content-length"];
|
|
243
|
+
const injector = createHtmlInjector(
|
|
244
|
+
`<script src="${LIVE_PROXY_BRIDGE_PATH}"></script>`,
|
|
245
|
+
);
|
|
246
|
+
const inject = new Transform({
|
|
247
|
+
transform(chunk: Buffer, _encoding, callback) {
|
|
248
|
+
for (const part of injector.push(chunk)) {
|
|
249
|
+
if (part.length) this.push(Buffer.from(part));
|
|
250
|
+
}
|
|
251
|
+
callback();
|
|
252
|
+
},
|
|
253
|
+
flush(callback) {
|
|
254
|
+
for (const part of injector.flush()) {
|
|
255
|
+
if (part.length) this.push(Buffer.from(part));
|
|
256
|
+
}
|
|
257
|
+
callback();
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
res.writeHead(upstreamRes.statusCode ?? 502, responseHeaders);
|
|
261
|
+
pipeline(upstreamRes, inject, res, () => {});
|
|
262
|
+
},
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
upstreamReq.on("error", (err) => {
|
|
266
|
+
if (res.headersSent) {
|
|
267
|
+
res.destroy();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
res.writeHead(502, { "Content-Type": "text/plain" });
|
|
271
|
+
res.end(`Live app upstream unreachable: ${err instanceof Error ? err.message : String(err)}`);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// Stream the request body upstream; GET/HEAD have none by contract.
|
|
275
|
+
if (req.method === "GET" || req.method === "HEAD") {
|
|
276
|
+
upstreamReq.end();
|
|
277
|
+
req.resume();
|
|
278
|
+
} else {
|
|
279
|
+
pipeline(req, upstreamReq, () => {});
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// WebSocket passthrough (HMR): raw duplex piping after the same gate
|
|
284
|
+
// sequence as plain requests — Host first, then the Origin gate, both
|
|
285
|
+
// before any upstream contact.
|
|
286
|
+
server.on("upgrade", (req: IncomingMessage, socket: Socket, head: Buffer) => {
|
|
287
|
+
const hostHeader = firstHeaderValue(req.headers.host);
|
|
288
|
+
if (!isAllowedProxyHost(hostHeader, port)) {
|
|
289
|
+
writeRawResponse(socket, "403 Forbidden", "Forbidden");
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
let url: URL;
|
|
293
|
+
try {
|
|
294
|
+
url = new URL(req.url ?? "", `http://${hostHeader}`);
|
|
295
|
+
} catch {
|
|
296
|
+
writeRawResponse(socket, "403 Forbidden", "Forbidden");
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (url.pathname.startsWith(LIVE_PROXY_RESERVED_PREFIX)) {
|
|
300
|
+
// Reserved namespace is never forwarded upstream, upgrades included.
|
|
301
|
+
writeRawResponse(socket, "404 Not Found", "Not found");
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (!isAllowedWsUpgradeOrigin(firstHeaderValue(req.headers.origin), port)) {
|
|
305
|
+
writeRawResponse(socket, "403 Forbidden", "Forbidden");
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Replay the client's own handshake upstream over raw TCP so the
|
|
310
|
+
// upstream's Sec-WebSocket-Accept matches the client's key. rawHeaders
|
|
311
|
+
// keeps original casing and repeats; Host is rewritten to the target and
|
|
312
|
+
// Origin is dropped to match the Bun transport's origin-less upstream
|
|
313
|
+
// connect (safe because the gate above already ran).
|
|
314
|
+
let handshake = `GET ${url.pathname + url.search} HTTP/1.1\r\n`;
|
|
315
|
+
for (let i = 0; i + 1 < req.rawHeaders.length; i += 2) {
|
|
316
|
+
const name = req.rawHeaders[i]!;
|
|
317
|
+
const lower = name.toLowerCase();
|
|
318
|
+
if (lower === "host") {
|
|
319
|
+
handshake += `Host: ${targetHost}\r\n`;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (lower === "origin") continue;
|
|
323
|
+
handshake += `${name}: ${req.rawHeaders[i + 1]}\r\n`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const upstreamSocket = netConnect(targetPort, targetHostname);
|
|
327
|
+
upgradeSockets.add(socket);
|
|
328
|
+
upgradeSockets.add(upstreamSocket);
|
|
329
|
+
|
|
330
|
+
const teardown = () => {
|
|
331
|
+
upgradeSockets.delete(socket);
|
|
332
|
+
upgradeSockets.delete(upstreamSocket);
|
|
333
|
+
socket.destroy();
|
|
334
|
+
upstreamSocket.destroy();
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
upstreamSocket.on("connect", () => {
|
|
338
|
+
upstreamSocket.write(handshake + "\r\n");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// Accumulate the upstream's response head; once complete, relay it
|
|
342
|
+
// verbatim and decide by status line: 101 pipes both ways, anything else
|
|
343
|
+
// is a refusal relayed to the client before closing. Capped so a
|
|
344
|
+
// malformed upstream cannot buffer unboundedly.
|
|
345
|
+
const MAX_UPGRADE_HEAD_BYTES = 64 * 1024;
|
|
346
|
+
let received = Buffer.alloc(0);
|
|
347
|
+
let established = false;
|
|
348
|
+
const onUpstreamData = (chunk: Buffer) => {
|
|
349
|
+
received = Buffer.concat([received, chunk]);
|
|
350
|
+
const headerEnd = received.indexOf("\r\n\r\n");
|
|
351
|
+
if (headerEnd === -1) {
|
|
352
|
+
if (received.length > MAX_UPGRADE_HEAD_BYTES) teardown();
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
upstreamSocket.off("data", onUpstreamData);
|
|
356
|
+
const statusLine = received.subarray(0, received.indexOf("\r\n")).toString("latin1");
|
|
357
|
+
const is101 = /^HTTP\/1\.1 101 /i.test(statusLine);
|
|
358
|
+
socket.write(received);
|
|
359
|
+
if (!is101) {
|
|
360
|
+
// Refusal relayed; nothing further to pipe.
|
|
361
|
+
teardown();
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
established = true;
|
|
365
|
+
if (head.length) upstreamSocket.write(head);
|
|
366
|
+
upstreamSocket.pipe(socket);
|
|
367
|
+
socket.pipe(upstreamSocket);
|
|
368
|
+
};
|
|
369
|
+
upstreamSocket.on("data", onUpstreamData);
|
|
370
|
+
|
|
371
|
+
upstreamSocket.on("close", teardown);
|
|
372
|
+
upstreamSocket.on("error", () => {
|
|
373
|
+
if (!established) {
|
|
374
|
+
writeRawResponse(socket, "502 Bad Gateway", "Live app upstream unreachable");
|
|
375
|
+
upgradeSockets.delete(socket);
|
|
376
|
+
upgradeSockets.delete(upstreamSocket);
|
|
377
|
+
upstreamSocket.destroy();
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
teardown();
|
|
381
|
+
});
|
|
382
|
+
socket.on("close", teardown);
|
|
383
|
+
socket.on("error", teardown);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// Long-lived streams (SSE) must not be killed by a server-side timer;
|
|
387
|
+
// mirrors the Bun transport's idleTimeout: 0.
|
|
388
|
+
server.requestTimeout = 0;
|
|
389
|
+
|
|
390
|
+
return new Promise((resolve, reject) => {
|
|
391
|
+
server.once("error", reject);
|
|
392
|
+
// The literal loopback address is the security contract (see header).
|
|
393
|
+
server.listen(0, LOOPBACK_HOST, () => {
|
|
394
|
+
const address = server.address();
|
|
395
|
+
if (!address || typeof address === "string") {
|
|
396
|
+
server.close();
|
|
397
|
+
reject(new Error("Live app proxy failed to bind a loopback port"));
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
port = address.port;
|
|
401
|
+
resolve({
|
|
402
|
+
port,
|
|
403
|
+
// Always the literal loopback origin: PLANNOTATOR_URL_HOST and
|
|
404
|
+
// buildAdvertisedUrl are never applied here.
|
|
405
|
+
origin: `http://${LOOPBACK_HOST}:${port}`,
|
|
406
|
+
stop() {
|
|
407
|
+
for (const s of upgradeSockets) s.destroy();
|
|
408
|
+
upgradeSockets.clear();
|
|
409
|
+
server.close();
|
|
410
|
+
// close() only stops the listener; drain keep-alive connections so
|
|
411
|
+
// a stopped session's sockets die immediately (parity with Bun's
|
|
412
|
+
// server.stop(true)). Guarded: jiti can run under hosts whose
|
|
413
|
+
// node:http lacks closeAllConnections.
|
|
414
|
+
(server as { closeAllConnections?: () => void }).closeAllConnections?.();
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
}
|
package/generated/prompts.ts
CHANGED
|
@@ -25,6 +25,7 @@ export const PLAN_TOOL_NAMES: Record<PromptRuntime, string> = {
|
|
|
25
25
|
pi: "plannotator_submit_plan",
|
|
26
26
|
codex: "ExitPlanMode",
|
|
27
27
|
"gemini-cli": "exit_plan_mode",
|
|
28
|
+
"oh-my-pi": "ExitPlanMode",
|
|
28
29
|
};
|
|
29
30
|
|
|
30
31
|
export function getPlanToolName(runtime?: PromptRuntime | null): string {
|
package/generated/review-core.ts
CHANGED
|
@@ -315,12 +315,23 @@ export function parseRemoteBookmark(target: string): { name: string; remote: str
|
|
|
315
315
|
return { name: target.slice(0, at), remote: target.slice(at + 1) };
|
|
316
316
|
}
|
|
317
317
|
|
|
318
|
+
// A full `commit_id`: 40 hex digits for a SHA-1 repo, 64 for SHA-256. Matching
|
|
319
|
+
// the full length only is deliberate, so an ordinary bookmark whose name
|
|
320
|
+
// happens to be hex (`cafebabe`) is still treated as a bookmark.
|
|
321
|
+
const JJ_FULL_COMMIT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
322
|
+
|
|
318
323
|
export function jjCompareTargetRevset(target: string): string {
|
|
319
324
|
const remoteBookmark = parseRemoteBookmark(target);
|
|
320
325
|
if (remoteBookmark) {
|
|
321
326
|
return `remote_bookmarks(exact:${quoteJjString(remoteBookmark.name)}, exact:${quoteJjString(remoteBookmark.remote)})`;
|
|
322
327
|
}
|
|
323
328
|
|
|
329
|
+
// The resolved line base is a bare commit id whenever its fork point carries
|
|
330
|
+
// no usable bookmark. It has no separators, so it would otherwise read as a
|
|
331
|
+
// local bookmark name and build `bookmarks(exact:"<sha>")`, which resolves to
|
|
332
|
+
// no revisions at all and makes the whole Line of work diff fail.
|
|
333
|
+
if (JJ_FULL_COMMIT_ID.test(target)) return target;
|
|
334
|
+
|
|
324
335
|
const localBookmark = parseJjBookmarkName(target);
|
|
325
336
|
return localBookmark ? `bookmarks(exact:${quoteJjString(localBookmark)})` : target;
|
|
326
337
|
}
|
package/index.ts
CHANGED
|
@@ -733,10 +733,16 @@ export default function plannotator(pi: ExtensionAPI): void {
|
|
|
733
733
|
// Split known annotate flags from the path. --json is silently
|
|
734
734
|
// accepted (Pi writes back via sendUserMessage, not stdout).
|
|
735
735
|
// `rawFilePath` keeps any leading `@` for the literal-@ fallback
|
|
736
|
-
// (scoped-package-style names).
|
|
737
|
-
|
|
736
|
+
// (scoped-package-style names). liveFlags: Pi supports live app
|
|
737
|
+
// sessions, so --app / --static are recognized here.
|
|
738
|
+
let { filePath, rawFilePath, gate, renderHtml: renderHtmlFlag, renderMarkdown: renderMarkdownFlag, noJina, app: appFlag, static: staticFlag } = parseAnnotateArgs(args ?? "", { liveFlags: true });
|
|
739
|
+
// Same flag-conflict-first ordering as the Bun CLI.
|
|
740
|
+
if (appFlag && staticFlag) {
|
|
741
|
+
ctx.ui.notify("--app and --static are mutually exclusive", "error");
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
738
744
|
if (!filePath) {
|
|
739
|
-
ctx.ui.notify("Usage: /plannotator-annotate <file.md | file.txt | file.html | https://... | folder/> [--markdown] [--no-jina] [--gate] [--json]", "error");
|
|
745
|
+
ctx.ui.notify("Usage: /plannotator-annotate <file.md | file.txt | file.html | https://... | folder/> [--markdown] [--no-jina] [--app] [--static] [--gate] [--json]", "error");
|
|
740
746
|
return;
|
|
741
747
|
}
|
|
742
748
|
|
|
@@ -784,28 +790,91 @@ export default function plannotator(pi: ExtensionAPI): void {
|
|
|
784
790
|
let rawHtml: string | undefined;
|
|
785
791
|
let absolutePath: string;
|
|
786
792
|
let folderPath: string | undefined;
|
|
787
|
-
let mode: "annotate" | "annotate-folder" | undefined;
|
|
793
|
+
let mode: "annotate" | "annotate-folder" | "annotate-app" | undefined;
|
|
788
794
|
let sourceInfo: string | undefined;
|
|
789
795
|
let sourceConverted = false;
|
|
790
796
|
let isFolder = false;
|
|
797
|
+
let liveTargetUrl: string | undefined;
|
|
791
798
|
|
|
792
799
|
// --- URL annotation ---
|
|
793
800
|
const isUrl = /^https?:\/\//i.test(filePath);
|
|
794
801
|
|
|
802
|
+
// --app is contracted to fail loudly whenever it cannot apply; a
|
|
803
|
+
// file or folder target silently swallowing it would hide the
|
|
804
|
+
// flag's typo'd use (same contract as the Bun CLI).
|
|
805
|
+
if (!isUrl && appFlag) {
|
|
806
|
+
const { LIVE_APP_REQUIRES_URL_MESSAGE } = await import("./generated/live-probe.ts");
|
|
807
|
+
ctx.ui.notify(LIVE_APP_REQUIRES_URL_MESSAGE, "error");
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
|
|
795
811
|
if (isUrl) {
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
812
|
+
// --- Live app detection (shared probe: same 3s timeout, same
|
|
813
|
+
// "< 500 + HTML + same loopback origin" gate as the Bun CLI) ---
|
|
814
|
+
const {
|
|
815
|
+
LIVE_APP_REMOTE_MESSAGE,
|
|
816
|
+
LIVE_APP_REQUIRES_HTTP_MESSAGE,
|
|
817
|
+
LIVE_APP_REQUIRES_LOOPBACK_MESSAGE,
|
|
818
|
+
buildForceAppFailureMessage,
|
|
819
|
+
buildLiveProbeFallbackNotice,
|
|
820
|
+
classifyLiveAppCandidate,
|
|
821
|
+
probeLiveAppTarget,
|
|
822
|
+
} = await import("./generated/live-probe.ts");
|
|
823
|
+
const { parsed: parsedUrl, loopback } = classifyLiveAppCandidate(filePath);
|
|
824
|
+
|
|
825
|
+
if (appFlag && !loopback) {
|
|
826
|
+
ctx.ui.notify(LIVE_APP_REQUIRES_LOOPBACK_MESSAGE, "error");
|
|
805
827
|
return;
|
|
806
828
|
}
|
|
807
|
-
|
|
808
|
-
|
|
829
|
+
if (appFlag && parsedUrl?.protocol === "https:") {
|
|
830
|
+
// The live proxy is http-only.
|
|
831
|
+
ctx.ui.notify(LIVE_APP_REQUIRES_HTTP_MESSAGE, "error");
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (loopback && parsedUrl?.protocol === "http:" && !staticFlag) {
|
|
836
|
+
const probe = await probeLiveAppTarget(filePath, parsedUrl);
|
|
837
|
+
if (probe.liveEligible) {
|
|
838
|
+
// Remote hard-off (layer 1 of 2; the server throw in
|
|
839
|
+
// serverAnnotate.ts backstops it): a live proxy relays
|
|
840
|
+
// the user's authenticated dev app, and a remote Pi
|
|
841
|
+
// session is reachable beyond loopback.
|
|
842
|
+
if (isRemoteSession()) {
|
|
843
|
+
ctx.ui.notify(LIVE_APP_REMOTE_MESSAGE, "error");
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
liveTargetUrl = filePath;
|
|
847
|
+
mode = "annotate-app";
|
|
848
|
+
ctx.ui.notify(`Live app: ${filePath}`, "info");
|
|
849
|
+
} else if (appFlag) {
|
|
850
|
+
ctx.ui.notify(buildForceAppFailureMessage(filePath, probe), "error");
|
|
851
|
+
return;
|
|
852
|
+
} else if (probe.probeError !== null) {
|
|
853
|
+
// A dev server still starting up probes as unreachable;
|
|
854
|
+
// say so instead of silently downgrading to static.
|
|
855
|
+
ctx.ui.notify(buildLiveProbeFallbackNotice(filePath, probe.probeError), "info");
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
if (liveTargetUrl) {
|
|
860
|
+
markdown = "";
|
|
861
|
+
absolutePath = filePath;
|
|
862
|
+
sourceInfo = filePath;
|
|
863
|
+
} else {
|
|
864
|
+
const useJina = resolveUseJina(noJina, loadConfig());
|
|
865
|
+
ctx.ui.notify(`Fetching: ${filePath}${useJina ? " (via Jina Reader)" : " (via fetch+Turndown)"}...`, "info");
|
|
866
|
+
try {
|
|
867
|
+
const { isConvertedSource, urlToMarkdown } = await import("./generated/url-to-markdown.ts");
|
|
868
|
+
const result = await urlToMarkdown(filePath, { useJina });
|
|
869
|
+
markdown = result.markdown;
|
|
870
|
+
sourceConverted = isConvertedSource(result.source);
|
|
871
|
+
} catch (err) {
|
|
872
|
+
ctx.ui.notify(`Failed to fetch URL: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
absolutePath = filePath;
|
|
876
|
+
sourceInfo = filePath;
|
|
877
|
+
}
|
|
809
878
|
} else {
|
|
810
879
|
// Pick the interpretation of the user input that actually exists:
|
|
811
880
|
// stripped form first (reference-mode primary), literal as fallback
|
|
@@ -881,6 +950,8 @@ export default function plannotator(pi: ExtensionAPI): void {
|
|
|
881
950
|
rawHtml,
|
|
882
951
|
!!rawHtml,
|
|
883
952
|
renderMarkdownFlag,
|
|
953
|
+
undefined,
|
|
954
|
+
liveTargetUrl,
|
|
884
955
|
);
|
|
885
956
|
ctx.ui.notify(sessionOpenedMessage("Annotation opened", session.url), "info");
|
|
886
957
|
void session
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plannotator/pi-extension",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Plannotator Pi extension - interactive plan review with annotations, annotate agent messages, and review code/PRs",
|
|
6
6
|
"author": "backnotprop",
|
|
@@ -21,9 +21,13 @@
|
|
|
21
21
|
"ai-agent",
|
|
22
22
|
"coding-agent"
|
|
23
23
|
],
|
|
24
|
+
"//pi": "skills/ is vendored by vendor.sh from apps/skills/core/plannotator and is gitignored, so the shipped copy cannot drift from the single source.",
|
|
24
25
|
"pi": {
|
|
25
26
|
"extensions": [
|
|
26
27
|
"./"
|
|
28
|
+
],
|
|
29
|
+
"skills": [
|
|
30
|
+
"skills/plannotator/SKILL.md"
|
|
27
31
|
]
|
|
28
32
|
},
|
|
29
33
|
"files": [
|
|
@@ -42,6 +46,7 @@
|
|
|
42
46
|
"server/",
|
|
43
47
|
"todo-providers/",
|
|
44
48
|
"generated/",
|
|
49
|
+
"skills/",
|
|
45
50
|
"README.md",
|
|
46
51
|
"plannotator.html",
|
|
47
52
|
"review-editor.html"
|
package/plannotator-browser.ts
CHANGED
|
@@ -53,7 +53,7 @@ export {
|
|
|
53
53
|
hasReviewBrowserHtml,
|
|
54
54
|
} from "./plannotator-browser-runtime.ts";
|
|
55
55
|
|
|
56
|
-
export type AnnotateMode = "annotate" | "annotate-folder" | "annotate-last";
|
|
56
|
+
export type AnnotateMode = "annotate" | "annotate-folder" | "annotate-last" | "annotate-app";
|
|
57
57
|
export interface PlanReviewDecision {
|
|
58
58
|
approved: boolean;
|
|
59
59
|
feedback?: string;
|
|
@@ -661,6 +661,10 @@ export async function startMarkdownAnnotationSession(
|
|
|
661
661
|
renderHtml?: boolean,
|
|
662
662
|
convertHtml?: boolean,
|
|
663
663
|
recentMessages?: { messageId: string; text: string; timestamp?: string }[],
|
|
664
|
+
/** Live app session (mode "annotate-app"): the loopback dev-server URL to
|
|
665
|
+
* proxy. The bridge sources are loaded here from the vendored
|
|
666
|
+
* bridge-script module, mirroring how the Bun CLI supplies them. */
|
|
667
|
+
liveTargetUrl?: string,
|
|
664
668
|
): Promise<BrowserDecisionSession<{ feedback: string; exit?: boolean; approved?: boolean; selectedMessageId?: string; feedbackScope?: "message" | "messages" }>> {
|
|
665
669
|
if (!ctx.hasUI) {
|
|
666
670
|
throw new Error("Plannotator annotation browser is unavailable in this session.");
|
|
@@ -670,6 +674,22 @@ export async function startMarkdownAnnotationSession(
|
|
|
670
674
|
throw new Error("Plannotator annotation browser is unavailable in this session.");
|
|
671
675
|
}
|
|
672
676
|
|
|
677
|
+
// Live sessions serve the page through the proxy; the bridge constants are
|
|
678
|
+
// lazy-imported so plain annotate sessions never pay for the ~large
|
|
679
|
+
// string module.
|
|
680
|
+
let liveApp:
|
|
681
|
+
| { targetUrl: string; bridgeScript: string; bridgeBootstrap: string; annotationCss: string }
|
|
682
|
+
| undefined;
|
|
683
|
+
if (mode === "annotate-app" && liveTargetUrl) {
|
|
684
|
+
const bridge = await import("./generated/bridge-script.ts");
|
|
685
|
+
liveApp = {
|
|
686
|
+
targetUrl: liveTargetUrl,
|
|
687
|
+
bridgeScript: bridge.BRIDGE_SCRIPT,
|
|
688
|
+
bridgeBootstrap: bridge.LIVE_BRIDGE_BOOTSTRAP,
|
|
689
|
+
annotationCss: bridge.ANNOTATION_HIGHLIGHT_CSS,
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
|
|
673
693
|
let resolvedMarkdown = markdown;
|
|
674
694
|
if (!renderHtml && !resolvedMarkdown.trim() && existsSync(filePath)) {
|
|
675
695
|
try {
|
|
@@ -687,6 +707,7 @@ export async function startMarkdownAnnotationSession(
|
|
|
687
707
|
filePath,
|
|
688
708
|
origin: "pi",
|
|
689
709
|
mode,
|
|
710
|
+
liveApp,
|
|
690
711
|
folderPath,
|
|
691
712
|
recentMessages,
|
|
692
713
|
sourceInfo,
|