open-plan-annotator 0.2.15 → 0.2.17

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.
@@ -12,7 +12,7 @@
12
12
  "name": "open-plan-annotator",
13
13
  "source": "./",
14
14
  "description": "Interactive plan annotation UI: review, strikethrough, and comment on Claude's plans before approving. Fully local, no external services.",
15
- "version": "0.2.15",
15
+ "version": "0.2.17",
16
16
  "author": {
17
17
  "name": "ndom91"
18
18
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "open-plan-annotator",
3
3
  "description": "Interactive plan annotation UI: review, strikethrough, and comment on Claude's plans before approving. Fully local, no external services.",
4
- "version": "0.2.15",
4
+ "version": "0.2.17",
5
5
  "author": {
6
6
  "name": "ndom91"
7
7
  },
@@ -5,9 +5,12 @@ import { dirname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
7
7
  const PKG_ROOT = fileURLToPath(new URL("..", import.meta.url));
8
- const BINARY_PATH = join(PKG_ROOT, "bin", "open-plan-annotator-binary");
8
+ const LOCAL_BINARY_PATH = join(PKG_ROOT, "bin", "open-plan-annotator-binary");
9
9
  const INSTALL_SCRIPT = join(PKG_ROOT, "install.cjs");
10
10
 
11
+ /** Resolved path to the binary (may differ from LOCAL_BINARY_PATH if found on PATH). */
12
+ let BINARY_PATH = LOCAL_BINARY_PATH;
13
+
11
14
  /**
12
15
  * @typedef {{
13
16
  * hookSpecificOutput: {
@@ -99,9 +102,33 @@ function validateHookOutput(value) {
99
102
  throw new Error("unsupported decision payload");
100
103
  }
101
104
 
105
+ /**
106
+ * Check if `open-plan-annotator` is available on PATH.
107
+ * The CLI wrapper handles binary discovery/download and stdin/stdout
108
+ * forwarding, so we can spawn it directly as a fallback when the local
109
+ * binary isn't available (e.g. OpenCode loads the plugin from its own
110
+ * node_modules but the binary only exists in a global pnpm install).
111
+ * @returns {string | undefined}
112
+ */
113
+ function findWrapperOnPath() {
114
+ const cmd = process.platform === "win32" ? "where" : "which";
115
+ try {
116
+ const result = execFileSync(cmd, ["open-plan-annotator"], {
117
+ encoding: "utf-8",
118
+ stdio: ["ignore", "pipe", "ignore"],
119
+ }).trim().split("\n")[0];
120
+ return result || undefined;
121
+ } catch {
122
+ return undefined;
123
+ }
124
+ }
125
+
102
126
  /** Ensure the compiled binary exists, downloading if necessary. */
103
127
  function ensureBinary() {
104
- if (existsSync(BINARY_PATH)) return;
128
+ if (existsSync(LOCAL_BINARY_PATH)) {
129
+ BINARY_PATH = LOCAL_BINARY_PATH;
130
+ return;
131
+ }
105
132
 
106
133
  // Try to find node on PATH for running the install script
107
134
  try {
@@ -121,12 +148,23 @@ function ensureBinary() {
121
148
  }
122
149
  }
123
150
 
124
- if (!existsSync(BINARY_PATH)) {
125
- throw new Error(
126
- `open-plan-annotator: binary not found at ${BINARY_PATH}. ` +
127
- `Try running: node ${INSTALL_SCRIPT}`,
128
- );
151
+ if (existsSync(LOCAL_BINARY_PATH)) {
152
+ BINARY_PATH = LOCAL_BINARY_PATH;
153
+ return;
154
+ }
155
+
156
+ // Fallback: use the CLI wrapper from PATH (e.g. global pnpm install).
157
+ // The wrapper handles binary discovery/download and stdio forwarding.
158
+ const wrapperPath = findWrapperOnPath();
159
+ if (wrapperPath) {
160
+ BINARY_PATH = wrapperPath;
161
+ return;
129
162
  }
163
+
164
+ throw new Error(
165
+ `open-plan-annotator: binary not found at ${LOCAL_BINARY_PATH}. ` +
166
+ `Try running: node ${INSTALL_SCRIPT}`,
167
+ );
130
168
  }
131
169
 
132
170
  /**
@@ -137,7 +175,7 @@ export async function runPlanReview(options) {
137
175
 
138
176
  const payload = buildHookPayload(options);
139
177
 
140
- const result = await new Promise((resolve, reject) => {
178
+ const output = await new Promise((resolve, reject) => {
141
179
  let cwd = options.cwd ?? process.cwd();
142
180
 
143
181
  // Guard: ensure cwd is a directory, not a file
@@ -149,19 +187,39 @@ export async function runPlanReview(options) {
149
187
  cwd = PKG_ROOT;
150
188
  }
151
189
 
152
- // Spawn the compiled binary directly (skip the Node wrapper).
153
- // This avoids issues with bun vs node runtime differences.
190
+ // Spawn detached so the binary can outlive this call — it keeps its
191
+ // HTTP server alive for ~10s after emitting the JSON hook response.
154
192
  const child = spawn(BINARY_PATH, [], {
155
193
  cwd,
156
194
  stdio: ["pipe", "pipe", "pipe"],
157
195
  env: process.env,
196
+ detached: true,
158
197
  });
159
198
 
160
199
  let stdout = "";
161
200
  let stderr = "";
201
+ let resolved = false;
162
202
 
163
203
  child.stdout.on("data", (chunk) => {
164
204
  stdout += String(chunk);
205
+ if (resolved) return;
206
+
207
+ // Scan for a complete JSON hook-output line. Once found, resolve
208
+ // immediately and let the binary keep running in the background.
209
+ const lines = stdout.split("\n");
210
+ for (const line of lines) {
211
+ const trimmed = line.trim();
212
+ if (!trimmed) continue;
213
+ try {
214
+ const parsed = validateHookOutput(JSON.parse(trimmed));
215
+ resolved = true;
216
+ child.unref();
217
+ resolve(parsed);
218
+ return;
219
+ } catch {
220
+ // Not valid hook JSON yet, keep buffering
221
+ }
222
+ }
165
223
  });
166
224
 
167
225
  child.stderr.on("data", (chunk) => {
@@ -169,40 +227,38 @@ export async function runPlanReview(options) {
169
227
  });
170
228
 
171
229
  child.on("error", (error) => {
172
- reject(error);
230
+ if (!resolved) reject(error);
173
231
  });
174
232
 
175
233
  child.on("close", (code, signal) => {
176
- resolve({ code, signal, stdout, stderr });
234
+ if (resolved) return;
235
+ // Binary exited without producing valid JSON
236
+ if (signal) {
237
+ reject(
238
+ new Error(
239
+ stderr.trim()
240
+ ? `open-plan-annotator was terminated by signal ${signal}: ${stderr.trim()}`
241
+ : `open-plan-annotator was terminated by signal ${signal}`,
242
+ ),
243
+ );
244
+ } else if (code !== 0) {
245
+ reject(
246
+ new Error(
247
+ stderr.trim()
248
+ ? `open-plan-annotator exited with code ${code}: ${stderr.trim()}`
249
+ : `open-plan-annotator exited with code ${code}`,
250
+ ),
251
+ );
252
+ } else {
253
+ reject(new Error("open-plan-annotator exited without producing hook output"));
254
+ }
177
255
  });
178
256
 
179
257
  child.stdin.write(`${JSON.stringify(payload)}\n`);
180
258
  child.stdin.end();
181
259
  });
182
260
 
183
- const settled =
184
- /** @type {{ code: number | null, signal: NodeJS.Signals | null, stdout: string, stderr: string }} */ (result);
185
-
186
- if (settled.signal) {
187
- const errorText = settled.stderr.trim();
188
- throw new Error(
189
- errorText
190
- ? `open-plan-annotator was terminated by signal ${settled.signal}: ${errorText}`
191
- : `open-plan-annotator was terminated by signal ${settled.signal}`,
192
- );
193
- }
194
-
195
- if (settled.code !== 0) {
196
- const errorText = settled.stderr.trim();
197
- throw new Error(
198
- errorText
199
- ? `open-plan-annotator exited with code ${settled.code}: ${errorText}`
200
- : `open-plan-annotator exited with code ${settled.code}`,
201
- );
202
- }
203
-
204
- const output = parseHookOutput(settled.stdout, settled.stderr);
205
- const decision = output.hookSpecificOutput.decision;
261
+ const decision = /** @type {HookOutput} */ (output).hookSpecificOutput.decision;
206
262
 
207
263
  if (decision.behavior === "allow") {
208
264
  return { approved: true };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-plan-annotator",
3
- "version": "0.2.15",
3
+ "version": "0.2.17",
4
4
  "type": "module",
5
5
  "description": "Fully local plugin for interactive plan annotation from your Agentic assistants",
6
6
  "author": "ndom91",