@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 CHANGED
@@ -270,6 +270,8 @@ Plannotator does not send `Continue with the approved plan`, enter its executing
270
270
 
271
271
  Run `/plannotator-annotate <file.md>` to open any markdown file in the annotation UI. Useful for reviewing documentation or design specs with the agent.
272
272
 
273
+ URL targets work too. A loopback `http` URL that answers with an HTML page (a running dev app, e.g. `http://localhost:5173`) opens **live**: the app is served through a local reverse proxy and annotated in place, with HMR and WebSockets passed through. `--static` forces the classic markdown conversion; `--app` requires a live session and errors instead of falling back. Live sessions are unavailable in remote mode (`PLANNOTATOR_REMOTE`).
274
+
273
275
  ### Annotate last message
274
276
 
275
277
  Run `/plannotator-last` to annotate the agent's most recent response. The message opens in the annotation UI where you can highlight text, add comments, and send structured feedback back to the agent.
@@ -0,0 +1,93 @@
1
+ // @generated — DO NOT EDIT. Source: packages/ai/providers/child-io.ts
2
+ /**
3
+ * Stdio guards for the JSONL/JSON-RPC child processes the AI providers drive.
4
+ *
5
+ * Both `PiProcessNode` (pi-sdk-node.ts) and `CodexAppServerProcess`
6
+ * (codex-app-server.ts) talk to a nested agent over pipes, and a pipe can
7
+ * break at any instant — the child exits, is killed, or closes stdin while a
8
+ * command is in flight. Two Node behaviors turn that ordinary condition into
9
+ * a host-killing crash:
10
+ *
11
+ * 1. `stream.write()` on a broken pipe reports `EPIPE` either synchronously
12
+ * (throw) or asynchronously (an `error` event on the stream), and which
13
+ * one you get is a timing race. A `destroyed` check before the write
14
+ * cannot close that race: the child can close the pipe between the check
15
+ * and the write.
16
+ * 2. An `error` event on a Node stream (or on the `ChildProcess` itself)
17
+ * with NO listener is re-thrown as an `uncaughtException`. Inside an
18
+ * embedded extension that is not "the provider failed" — it terminates
19
+ * the HOST agent process.
20
+ *
21
+ * That is issue #1378: a Plannotator plan review opened from Pi on Windows
22
+ * took the whole Pi host down with `write EPIPE` out of `PiProcessNode.send()`.
23
+ * Windows only made it likelier to land on the async path during teardown;
24
+ * the mechanism is platform-independent.
25
+ *
26
+ * The contract these helpers enforce: a broken pipe is a PROVIDER failure.
27
+ * It never escalates past the provider, it is reported exactly like any other
28
+ * process end (in-flight requests reject, listeners see the process end), and
29
+ * the provider is left dead so the next query re-spawns it.
30
+ */
31
+
32
+ import type { ChildProcess } from "node:child_process";
33
+
34
+ /** Normalize an unknown thrown/emitted value to an Error. */
35
+ export function toChildError(err: unknown): Error {
36
+ return err instanceof Error ? err : new Error(String(err));
37
+ }
38
+
39
+ /**
40
+ * Attach `error` listeners to the child process and every piped stream so a
41
+ * broken pipe can never reach `uncaughtException`.
42
+ *
43
+ * Call this immediately after `spawn()` returns — before awaiting the spawn
44
+ * handshake — so a stream that fails during startup is already covered. The
45
+ * spawn handshake may register its own one-shot `error` listener; Node allows
46
+ * several, and `onFailure` is expected to be idempotent.
47
+ */
48
+ export function guardChildStreams(
49
+ proc: ChildProcess,
50
+ label: string,
51
+ onFailure: (error: Error) => void,
52
+ ): void {
53
+ const report = (stream: string) => (err: unknown) => {
54
+ onFailure(new Error(`${label} ${stream} failed: ${toChildError(err).message}`));
55
+ };
56
+ proc.on("error", report("process"));
57
+ proc.stdin?.on("error", report("stdin"));
58
+ proc.stdout?.on("error", report("stdout"));
59
+ proc.stderr?.on("error", report("stderr"));
60
+ }
61
+
62
+ /**
63
+ * Write one already-newline-terminated line to a child's stdin.
64
+ *
65
+ * Returns the Error the write failed with, or `null` when the bytes were
66
+ * accepted by the stream. Failures that only surface later (the write
67
+ * callback, or an `error` event covered by {@link guardChildStreams}) are
68
+ * reported through `onAsyncFailure` instead, so BOTH the sync-throw and the
69
+ * async-event paths end up at the caller's failure handling.
70
+ */
71
+ export function writeChildLine(
72
+ proc: ChildProcess | null,
73
+ line: string,
74
+ label: string,
75
+ onAsyncFailure: (error: Error) => void,
76
+ ): Error | null {
77
+ const stdin = proc?.stdin;
78
+ if (!stdin || stdin.destroyed || stdin.writableEnded) {
79
+ return new Error(`${label} stdin is closed`);
80
+ }
81
+ try {
82
+ stdin.write(line, (err) => {
83
+ if (err) {
84
+ onAsyncFailure(
85
+ new Error(`${label} stdin write failed: ${toChildError(err).message}`),
86
+ );
87
+ }
88
+ });
89
+ } catch (err) {
90
+ return new Error(`${label} stdin write failed: ${toChildError(err).message}`);
91
+ }
92
+ return null;
93
+ }
@@ -44,8 +44,10 @@ import {
44
44
  killWindowsProcessTree,
45
45
  resolveWindowsCommandShim,
46
46
  } from "./command-path.ts";
47
+ import { guardChildStreams, writeChildLine } from "./child-io.ts";
47
48
 
48
49
  const PROVIDER_NAME = "codex-sdk";
50
+ const CODEX_PROCESS_LABEL = "Codex app-server";
49
51
  const DEFAULT_MODEL = "gpt-5.6-sol";
50
52
  const CLIENT_NAME = "plannotator";
51
53
  /** Kill an idle app-server process after this long with no query. */
@@ -335,6 +337,10 @@ class CodexAppServerProcess {
335
337
  }
336
338
 
337
339
  this.proc = proc;
340
+ // Cover every pipe BEFORE the spawn handshake: an `error` event on a child
341
+ // stream with no listener becomes an uncaughtException and kills the host
342
+ // process, not just this provider (#1378).
343
+ guardChildStreams(proc, CODEX_PROCESS_LABEL, (error) => this.failProcess(error));
338
344
  proc.once("exit", () => {
339
345
  this.handleProcessEnd(new Error("Codex app-server exited unexpectedly"));
340
346
  });
@@ -378,6 +384,23 @@ class CodexAppServerProcess {
378
384
  this.send({ method: "initialized", params: {} });
379
385
  }
380
386
 
387
+ /**
388
+ * A pipe to the child broke: resolve it as a provider failure and reap the
389
+ * child, leaving `alive` false so the next query re-spawns (#1378).
390
+ */
391
+ private failProcess(error: Error): void {
392
+ const proc = this.proc;
393
+ this.startPromise = null;
394
+ this.handleProcessEnd(error);
395
+ if (proc) {
396
+ try {
397
+ if (!killWindowsProcessTree(proc.pid)) proc.kill();
398
+ } catch {
399
+ // Already gone.
400
+ }
401
+ }
402
+ }
403
+
381
404
  private handleProcessEnd(error: Error): void {
382
405
  if (!this.proc && this.pendingRequests.size === 0) return;
383
406
  this._alive = false;
@@ -439,9 +462,22 @@ class CodexAppServerProcess {
439
462
  }
440
463
  }
441
464
 
465
+ /**
466
+ * Send a JSON-RPC message without waiting for a response.
467
+ *
468
+ * A closed or broken stdin is a provider failure, never a throw at the
469
+ * caller and never an unhandled stream error: both the synchronous throw
470
+ * and the asynchronous `error`/write-callback paths land in failProcess,
471
+ * which rejects anything in flight.
472
+ */
442
473
  send(message: RpcMessage): void {
443
- if (!this.proc?.stdin || this.proc.stdin.destroyed) return;
444
- this.proc.stdin.write(`${JSON.stringify(message)}\n`);
474
+ const error = writeChildLine(
475
+ this.proc,
476
+ `${JSON.stringify(message)}\n`,
477
+ CODEX_PROCESS_LABEL,
478
+ (err) => this.failProcess(err),
479
+ );
480
+ if (error) this.failProcess(error);
445
481
  }
446
482
 
447
483
  sendAndWait(message: RpcMessage, timeoutMs = RPC_TIMEOUT_MS): Promise<RpcMessage> {
@@ -25,11 +25,13 @@ import {
25
25
  killWindowsProcessTree,
26
26
  resolveWindowsCommandShim,
27
27
  } from "./command-path.ts";
28
+ import { guardChildStreams, writeChildLine } from "./child-io.ts";
28
29
 
29
30
  // Re-export mapPiEvent from shared (runtime-agnostic)
30
31
  export { mapPiEvent } from "./pi-events.ts";
31
32
 
32
33
  const PROVIDER_NAME = "pi-sdk";
34
+ const PI_PROCESS_LABEL = "Pi process";
33
35
 
34
36
  // ---------------------------------------------------------------------------
35
37
  // JSONL subprocess wrapper (Node.js)
@@ -37,7 +39,8 @@ const PROVIDER_NAME = "pi-sdk";
37
39
 
38
40
  type EventListener = (event: Record<string, unknown>) => void;
39
41
 
40
- class PiProcessNode {
42
+ /** Exported for the stdio-failure regression tests (#1378). */
43
+ export class PiProcessNode {
41
44
  private proc: ChildProcess | null = null;
42
45
  private listeners: EventListener[] = [];
43
46
  private pendingRequests = new Map<
@@ -62,9 +65,12 @@ class PiProcessNode {
62
65
  let proc: ChildProcess;
63
66
  try {
64
67
  const [file, ...args] = command;
68
+ // stderr is "ignore", not "pipe": we never read it, and an
69
+ // un-drained stderr pipe deadlocks the child once its buffer fills
70
+ // (same reasoning as codex-app-server.ts).
65
71
  proc = spawn(file, args, {
66
72
  cwd,
67
- stdio: ["pipe", "pipe", "pipe"],
73
+ stdio: ["pipe", "pipe", "ignore"],
68
74
  });
69
75
  } catch (err) {
70
76
  const error = err instanceof Error ? err : new Error(String(err));
@@ -73,6 +79,10 @@ class PiProcessNode {
73
79
  }
74
80
 
75
81
  this.proc = proc;
82
+ // Cover every pipe BEFORE the spawn handshake: an `error` event on a
83
+ // child stream with no listener becomes an uncaughtException and kills
84
+ // the host agent process, not just this provider (#1378).
85
+ guardChildStreams(proc, PI_PROCESS_LABEL, (error) => this.failProcess(error));
76
86
  proc.once("exit", () => {
77
87
  this.handleProcessEnd(new Error("Pi process exited unexpectedly"));
78
88
  });
@@ -99,6 +109,24 @@ class PiProcessNode {
99
109
  });
100
110
  }
101
111
 
112
+ /**
113
+ * A pipe to the child broke. Resolve it as a provider failure: reject
114
+ * everything in flight, tell listeners the process ended, and reap the
115
+ * child so it cannot linger with an unusable RPC channel. `alive` flips
116
+ * false, so the next query re-spawns a fresh process.
117
+ */
118
+ private failProcess(error: Error): void {
119
+ const proc = this.proc;
120
+ this.handleProcessEnd(error);
121
+ if (proc) {
122
+ try {
123
+ if (!killWindowsProcessTree(proc.pid)) proc.kill();
124
+ } catch {
125
+ // Already gone.
126
+ }
127
+ }
128
+ }
129
+
102
130
  private handleProcessEnd(error: Error): void {
103
131
  if (!this.proc && this.pendingRequests.size === 0) return;
104
132
 
@@ -153,9 +181,23 @@ class PiProcessNode {
153
181
  }
154
182
  }
155
183
 
184
+ /**
185
+ * Send a command without waiting for a response.
186
+ *
187
+ * A closed or broken stdin is a provider failure, never a throw at the
188
+ * caller and never an unhandled stream error: both the synchronous throw
189
+ * and the asynchronous `error`/write-callback paths land in failProcess,
190
+ * which rejects anything in flight (including the `sendAndWait` request
191
+ * this call may be carrying).
192
+ */
156
193
  send(command: Record<string, unknown>): void {
157
- if (!this.proc?.stdin || this.proc.stdin.destroyed) return;
158
- this.proc.stdin.write(`${JSON.stringify(command)}\n`);
194
+ const error = writeChildLine(
195
+ this.proc,
196
+ `${JSON.stringify(command)}\n`,
197
+ PI_PROCESS_LABEL,
198
+ (err) => this.failProcess(err),
199
+ );
200
+ if (error) this.failProcess(error);
159
201
  }
160
202
 
161
203
  sendAndWait(
@@ -58,11 +58,14 @@ class PiProcess {
58
58
  "rpc",
59
59
  ];
60
60
  try {
61
+ // stderr is "ignore", not "pipe": we never read it, and an
62
+ // un-drained stderr pipe deadlocks the child once its buffer fills
63
+ // (same reasoning as codex-app-server.ts).
61
64
  this.proc = Bun.spawn(command, {
62
65
  cwd,
63
66
  stdin: "pipe",
64
67
  stdout: "pipe",
65
- stderr: "pipe",
68
+ stderr: "ignore",
66
69
  });
67
70
  } catch (err) {
68
71
  const error = err instanceof Error ? err : new Error(String(err));
@@ -78,6 +81,22 @@ class PiProcess {
78
81
  });
79
82
  }
80
83
 
84
+ /**
85
+ * A pipe to the child broke: resolve it as a provider failure and reap the
86
+ * child, leaving `alive` false so the next query re-spawns.
87
+ */
88
+ private failProcess(error: Error): void {
89
+ const proc = this.proc;
90
+ this.handleProcessEnd(error);
91
+ if (proc) {
92
+ try {
93
+ if (!killWindowsProcessTree(proc.pid)) proc.kill();
94
+ } catch {
95
+ // Already gone.
96
+ }
97
+ }
98
+ }
99
+
81
100
  private handleProcessEnd(error: Error): void {
82
101
  if (!this.proc && this.pendingRequests.size === 0) return;
83
102
 
@@ -144,13 +163,42 @@ class PiProcess {
144
163
  }
145
164
  }
146
165
 
147
- /** Send a command without waiting for a response. */
166
+ /**
167
+ * Send a command without waiting for a response.
168
+ *
169
+ * The write is guarded for the same reason the Node variant's is (#1378):
170
+ * the child can close the pipe between the liveness check and the write,
171
+ * and a raw EPIPE escaping here would surface as a synchronous throw at
172
+ * the caller (or an unhandled rejection out of flush()) instead of a
173
+ * provider failure. Failing the process rejects anything in flight.
174
+ */
148
175
  send(command: Record<string, unknown>): void {
149
- if (!this.proc?.stdin || typeof this.proc.stdin === "number") return;
176
+ const stdin = this.proc?.stdin;
177
+ if (!stdin || typeof stdin === "number") {
178
+ this.failProcess(new Error("Pi process stdin is closed"));
179
+ return;
180
+ }
150
181
  // Bun.spawn stdin is a FileSink with .write(), not a WritableStream
151
- const sink = this.proc.stdin as { write(data: string): void; flush(): void };
152
- sink.write(`${JSON.stringify(command)}\n`);
153
- sink.flush();
182
+ const sink = stdin as { write(data: string): void; flush(): unknown };
183
+ try {
184
+ sink.write(`${JSON.stringify(command)}\n`);
185
+ const flushed = sink.flush();
186
+ if (flushed && typeof (flushed as Promise<number>).then === "function") {
187
+ (flushed as Promise<number>).catch((err: unknown) => {
188
+ this.failProcess(
189
+ new Error(
190
+ `Pi process stdin write failed: ${err instanceof Error ? err.message : String(err)}`,
191
+ ),
192
+ );
193
+ });
194
+ }
195
+ } catch (err) {
196
+ this.failProcess(
197
+ new Error(
198
+ `Pi process stdin write failed: ${err instanceof Error ? err.message : String(err)}`,
199
+ ),
200
+ );
201
+ }
154
202
  }
155
203
 
156
204
  /** Send a command and wait for the correlated response. */
@@ -47,6 +47,10 @@ export interface ParsedAnnotateArgs {
47
47
  renderHtml: boolean;
48
48
  renderMarkdown: boolean;
49
49
  noJina: boolean;
50
+ /** --app: force a live app session (recognized only with `liveFlags`). */
51
+ app: boolean;
52
+ /** --static: force the classic conversion pipeline (only with `liveFlags`). */
53
+ static: boolean;
50
54
  }
51
55
 
52
56
  type Segment = { type: "ws" | "tok"; text: string };
@@ -60,9 +64,27 @@ const FLAG_MAP = {
60
64
  "--no-jina": "noJina",
61
65
  } as const satisfies Record<string, keyof Omit<ParsedAnnotateArgs, "filePath" | "rawFilePath">>;
62
66
 
63
- export function parseAnnotateArgs(raw: string): ParsedAnnotateArgs {
67
+ /** Live-mode flags, recognized only where the host actually supports live
68
+ * app sessions (`liveFlags: true` — Pi today). A host that cannot act on
69
+ * --app must NOT silently strip it: leaving the token in the path keeps the
70
+ * legacy "File not found: --app ..." error, which is honest about the flag
71
+ * being unsupported there. */
72
+ const LIVE_FLAG_MAP = {
73
+ "--app": "app",
74
+ "--static": "static",
75
+ } as const satisfies Record<string, keyof Omit<ParsedAnnotateArgs, "filePath" | "rawFilePath">>;
76
+
77
+ export interface ParseAnnotateArgsOptions {
78
+ /** Recognize --app / --static (hosts with live app annotation support). */
79
+ liveFlags?: boolean;
80
+ }
81
+
82
+ export function parseAnnotateArgs(raw: string, opts?: ParseAnnotateArgsOptions): ParsedAnnotateArgs {
64
83
  const s = (raw ?? "").trim();
65
- const flags = { gate: false, json: false, hook: false, renderHtml: false, renderMarkdown: false, noJina: false };
84
+ const flags = { gate: false, json: false, hook: false, renderHtml: false, renderMarkdown: false, noJina: false, app: false, static: false };
85
+ const flagMap: Record<string, keyof typeof flags> = opts?.liveFlags
86
+ ? { ...FLAG_MAP, ...LIVE_FLAG_MAP }
87
+ : { ...FLAG_MAP };
66
88
 
67
89
  const segments: Segment[] = [];
68
90
  for (let i = 0; i < s.length;) {
@@ -76,7 +98,7 @@ export function parseAnnotateArgs(raw: string): ParsedAnnotateArgs {
76
98
  for (let j = 0; j < segments.length; j++) {
77
99
  const seg = segments[j];
78
100
  if (seg.type !== "tok") continue;
79
- const key = FLAG_MAP[seg.text as keyof typeof FLAG_MAP];
101
+ const key = flagMap[seg.text];
80
102
  if (!key) continue;
81
103
 
82
104
  flags[key] = true;