@plannotator/pi-extension 0.27.6 → 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.
@@ -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. */
@@ -161,7 +161,7 @@ export interface ParsedCallDiffWorkerResult {
161
161
  diagnostics: CallFlowDiagnostic[];
162
162
  }
163
163
 
164
- const MAX_TREES = 100;
164
+ const MAX_TREES = 2_000;
165
165
  const MAX_NODES = 5_000;
166
166
  const MAX_TREE_DEPTH = 32;
167
167
  const MAX_DIAGNOSTICS = 100;
@@ -218,9 +218,16 @@ export function parseCallDiffWorkerResult(value: unknown): ParsedCallDiffWorkerR
218
218
  if (!isRecord(value.result) || !Array.isArray(value.result.trees)) {
219
219
  throw new Error("CallDiff worker response is missing its diff trees.");
220
220
  }
221
- if (value.result.trees.length > MAX_TREES) {
222
- throw new Error("CallDiff result exceeded Plannotator's tree limits.");
223
- }
221
+ // A large-but-valid result (many small entry trees — common in Swift, TS,
222
+ // and other per-function languages) degrades instead of failing: keep the
223
+ // first MAX_TREES trees and say so in diagnostics (#1351). The caps that
224
+ // guard against genuinely unbounded worker output (total nodes, depth, raw
225
+ // length) still throw below.
226
+ const totalTreeCount = value.result.trees.length;
227
+ const truncatedTrees = totalTreeCount > MAX_TREES;
228
+ const boundedTreeCandidates = truncatedTrees
229
+ ? value.result.trees.slice(0, MAX_TREES)
230
+ : value.result.trees;
224
231
 
225
232
  let nodeCount = 0;
226
233
  const parseNode = (candidate: unknown, depth: number): CallFlowNode => {
@@ -254,7 +261,7 @@ export function parseCallDiffWorkerResult(value: unknown): ParsedCallDiffWorkerR
254
261
  };
255
262
 
256
263
  const raw = boundedRaw(value.result.ascii);
257
- const trees = value.result.trees.map((candidate): CallFlowTree => {
264
+ const trees = boundedTreeCandidates.map((candidate): CallFlowTree => {
258
265
  if (!isRecord(candidate)) throw new Error("CallDiff worker returned an invalid tree.");
259
266
  const entry = boundedString(candidate.entry, "tree entry");
260
267
  const tree = parseNode(candidate.tree, 0);
@@ -287,6 +294,12 @@ export function parseCallDiffWorkerResult(value: unknown): ParsedCallDiffWorkerR
287
294
  });
288
295
  }
289
296
  }
297
+ if (truncatedTrees) {
298
+ diagnostics.push({
299
+ level: "warning",
300
+ message: `Showing the first ${MAX_TREES.toLocaleString("en-US")} of ${totalTreeCount.toLocaleString("en-US")} call trees; the rest were truncated to bound the result.`,
301
+ });
302
+ }
290
303
 
291
304
  return {
292
305
  version: boundedString(value.version, "version", 100),
@@ -46,7 +46,8 @@ export type PromptRuntime =
46
46
  | "copilot-cli"
47
47
  | "pi"
48
48
  | "codex"
49
- | "gemini-cli";
49
+ | "gemini-cli"
50
+ | "oh-my-pi";
50
51
 
51
52
  interface PromptSectionConfig {
52
53
  [key: string]: string | Partial<Record<PromptRuntime, PromptSectionOverrides>> | undefined;
@@ -9,6 +9,7 @@ import {
9
9
  type JjEvoLogEntry,
10
10
  JJ_TRUNK_REVSET,
11
11
  jjLineBaseRevset,
12
+ parseRemoteBookmark,
12
13
  validateFilePath,
13
14
  } from "./review-core.ts";
14
15
 
@@ -27,6 +28,31 @@ export interface ReviewJjRuntime {
27
28
  ) => Promise<GitCommandResult>;
28
29
  }
29
30
 
31
+ // `reachable(@, mutable())` is JJ's definition of the stack being worked on.
32
+ // Its root parents are where that line diverged from immutable history.
33
+ //
34
+ // `latest(..., 1)` is what keeps the query single-record. A criss-cross history
35
+ // can leave several fork points, and the parser below reads one record only, so
36
+ // the tie-break belongs in the revset where it is deliberate and testable
37
+ // rather than in a silent "first row wins" slice. It also matters for
38
+ // correctness: bookmark preference (remote before local) is only meaningful
39
+ // within one commit, so a multi-row answer could otherwise pick a remote
40
+ // bookmark from one commit over a local bookmark on a nearer one.
41
+ const JJ_LINE_BASE_REVSET = "latest(fork_point(roots(reachable(@, mutable()))-), 1)";
42
+
43
+ // `jj git push --change` mints bookmarks under `git.push-bookmark-prefix`
44
+ // (default `push-`). They name one change, not a line of work, so they are
45
+ // never a useful review base. They reach the fork point in practice: a
46
+ // colleague's pushed change bookmark arrives as an untracked remote bookmark,
47
+ // which makes its commit immutable and therefore a candidate base, and the
48
+ // reviewer would be told they are comparing against `push-vmopwunwxopv@origin`.
49
+ const JJ_GENERATED_PUSH_BOOKMARK_PREFIX = "push-";
50
+
51
+ // `commit_id` renders in full, so an all-zero id is the virtual root commit.
52
+ // A repo with no immutable history forks there, and a 40-zero hash means
53
+ // nothing to a reviewer, so keep the `trunk()` sentinel for that case.
54
+ const JJ_ROOT_COMMIT_ID = /^0+$/;
55
+
30
56
  export async function detectJjWorkspace(
31
57
  runtime: ReviewJjRuntime,
32
58
  cwd?: string,
@@ -369,13 +395,35 @@ export async function selectDefaultJjCompareTarget(
369
395
  "log",
370
396
  "--no-graph",
371
397
  "-r",
372
- JJ_TRUNK_REVSET,
398
+ JJ_LINE_BASE_REVSET,
373
399
  "-T",
374
- "json(bookmarks)",
400
+ 'json(bookmarks) ++ "\\t" ++ commit_id ++ "\\n"',
375
401
  ], { cwd });
402
+ // Every unresolvable case falls back to `trunk()`, which is what this
403
+ // returned before the line-of-work base was inferred at all. The only live
404
+ // caller is `getJjContext`, which runs on the review startup path with no
405
+ // handler above it, so throwing here does not report a problem: it aborts
406
+ // `plannotator review` with a stack trace before the server is built. That
407
+ // also covers a `jj` too old for `fork_point`/`reachable`, where the revset
408
+ // itself fails and the previous default is still perfectly serviceable.
376
409
  if (result.exitCode !== 0) return JJ_TRUNK_REVSET;
377
410
 
378
- return parseJjResolvedBookmarks(result.stdout)[0] ?? JJ_TRUNK_REVSET;
411
+ const [record] = splitJjTemplateRecords(result.stdout);
412
+ if (!record) return JJ_TRUNK_REVSET;
413
+
414
+ const fields = splitJjTemplateFields(record);
415
+ const bookmark = parseJjResolvedBookmarks(fields?.[0] ?? record)
416
+ .find((name) => !isGeneratedPushBookmark(name));
417
+ if (bookmark) return bookmark;
418
+
419
+ const commitId = fields?.[1]?.trim();
420
+ if (commitId && !JJ_ROOT_COMMIT_ID.test(commitId)) return commitId;
421
+ return JJ_TRUNK_REVSET;
422
+ }
423
+
424
+ function isGeneratedPushBookmark(target: string): boolean {
425
+ const name = parseRemoteBookmark(target)?.name ?? target;
426
+ return name.startsWith(JJ_GENERATED_PUSH_BOOKMARK_PREFIX);
379
427
  }
380
428
 
381
429
  function parseJjResolvedBookmarks(value: string): string[] {
@@ -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 {
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/pi-extension",
3
- "version": "0.27.6",
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"