@plannotator/pi-extension 0.27.5 → 0.27.6

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,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/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
- let { filePath, rawFilePath, gate, renderHtml: renderHtmlFlag, renderMarkdown: renderMarkdownFlag, noJina } = parseAnnotateArgs(args ?? "");
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
- const useJina = resolveUseJina(noJina, loadConfig());
797
- ctx.ui.notify(`Fetching: ${filePath}${useJina ? " (via Jina Reader)" : " (via fetch+Turndown)"}...`, "info");
798
- try {
799
- const { isConvertedSource, urlToMarkdown } = await import("./generated/url-to-markdown.ts");
800
- const result = await urlToMarkdown(filePath, { useJina });
801
- markdown = result.markdown;
802
- sourceConverted = isConvertedSource(result.source);
803
- } catch (err) {
804
- ctx.ui.notify(`Failed to fetch URL: ${err instanceof Error ? err.message : String(err)}`, "error");
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
- absolutePath = filePath;
808
- sourceInfo = filePath;
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.5",
3
+ "version": "0.27.6",
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",
@@ -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,