open-plan-annotator 1.4.2 → 1.5.0

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.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "Interactive plan annotation plugin for Claude Code",
8
- "version": "1.4.2"
8
+ "version": "1.5.0"
9
9
  },
10
10
  "plugins": [
11
11
  {
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": "1.4.2",
15
+ "version": "1.5.0",
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": "1.4.2",
4
+ "version": "1.5.0",
5
5
  "author": {
6
6
  "name": "ndom91"
7
7
  },
package/README.md CHANGED
@@ -61,6 +61,16 @@ To update, refresh the plugin through OpenCode and restart the app so it reloads
61
61
  > [!NOTE]
62
62
  > The update mechanism changed significantly in `1.0.20+`: OpenCode now loads the npm package plus a platform runtime package instead of using the old in-place binary updater. If OpenCode appears to be stuck on an older plugin build, clear the cached `open-plan-annotator` entries under `~/.cache/opencode/node_modules/` and restart OpenCode.
63
63
 
64
+ ### Pi
65
+
66
+ Install the dedicated Pi package:
67
+
68
+ ```sh
69
+ pi install npm:@open-plan-annotator/pi-extension
70
+ ```
71
+
72
+ The extension package registers a `submit_plan` tool and an `/annotate-plan` command, both of which open the same browser-based plan review UI.
73
+
64
74
  #### Implementation Handoff
65
75
 
66
76
  By default, after a plan is approved the plugin sends "Proceed with implementation." to a `build` agent. To customize or disable this, create `open-plan-annotator.json` in your project's `.opencode/` directory or globally in `~/.config/opencode/`:
@@ -1,201 +1,27 @@
1
- import { spawn } from "node:child_process";
2
- import { randomUUID } from "node:crypto";
3
- import { existsSync, statSync } from "node:fs";
4
- import { dirname } from "node:path";
5
1
  import { fileURLToPath } from "node:url";
6
2
  import { detectPackageManager } from "../shared/packageManager.mjs";
3
+ import { runPlanReviewBinary } from "../shared/planReview.mjs";
7
4
  import { resolveRuntimeBinary } from "../shared/runtimeResolver.mjs";
8
5
 
9
- const PKG_ROOT = fileURLToPath(new URL("..", import.meta.url));
10
-
11
- /**
12
- * @typedef {{
13
- * hookSpecificOutput: {
14
- * hookEventName: "PermissionRequest",
15
- * decision: { behavior: "allow" } | { behavior: "deny", message: string }
16
- * }
17
- * }} HookOutput
18
- */
19
-
20
- /**
21
- * @param {{ plan: string, sessionId?: string, cwd?: string }} options
22
- */
23
- function buildHookPayload(options) {
24
- return {
25
- session_id: options.sessionId ?? randomUUID(),
26
- transcript_path: "",
27
- cwd: options.cwd ?? process.cwd(),
28
- permission_mode: "default",
29
- hook_event_name: "PermissionRequest",
30
- tool_name: "ExitPlanMode",
31
- tool_use_id: randomUUID(),
32
- tool_input: {
33
- plan: options.plan,
34
- },
35
- };
36
- }
37
-
38
- /**
39
- * @param {string} stdoutText
40
- * @param {string} stderrText
41
- * @returns {HookOutput}
42
- */
43
- function parseHookOutput(stdoutText, stderrText) {
44
- const trimmed = stdoutText.trim();
45
- if (!trimmed) {
46
- const stderr = stderrText.trim();
47
- throw new Error(
48
- stderr
49
- ? `open-plan-annotator returned empty stdout; stderr: ${stderr}`
50
- : "open-plan-annotator returned empty stdout",
51
- );
52
- }
53
-
54
- try {
55
- return validateHookOutput(JSON.parse(trimmed));
56
- } catch {
57
- const lines = trimmed
58
- .split(/\r?\n/)
59
- .map((line) => line.trim())
60
- .filter(Boolean)
61
- .reverse();
62
-
63
- for (const line of lines) {
64
- try {
65
- return validateHookOutput(JSON.parse(line));
66
- } catch {
67
- // ignore and keep searching
68
- }
69
- }
70
-
71
- throw new Error("open-plan-annotator returned invalid hook JSON");
72
- }
73
- }
74
-
75
- /**
76
- * @param {unknown} value
77
- * @returns {HookOutput}
78
- */
79
- function validateHookOutput(value) {
80
- if (!value || typeof value !== "object") {
81
- throw new Error("invalid hook output shape");
82
- }
83
-
84
- const output = /** @type {HookOutput} */ (value);
85
- const decision = output?.hookSpecificOutput?.decision;
86
-
87
- if (!decision || typeof decision !== "object" || typeof decision.behavior !== "string") {
88
- throw new Error("missing decision in hook output");
89
- }
90
-
91
- if (decision.behavior === "allow") {
92
- return output;
93
- }
94
-
95
- if (decision.behavior === "deny" && typeof decision.message === "string") {
96
- return output;
97
- }
98
-
99
- throw new Error("unsupported decision payload");
100
- }
101
-
102
6
  /**
103
7
  * @param {{ plan: string, sessionId?: string, cwd?: string }} options
104
8
  */
105
9
  export async function runPlanReview(options) {
106
10
  const runtime = resolveRuntimeBinary({ parentUrl: import.meta.url });
107
11
 
108
- const payload = buildHookPayload(options);
109
-
110
- const output = await new Promise((resolve, reject) => {
111
- let cwd = options.cwd ?? process.cwd();
112
-
113
- // Guard: ensure cwd is a directory, not a file
114
- try {
115
- if (existsSync(cwd) && !statSync(cwd).isDirectory()) {
116
- cwd = dirname(cwd);
117
- }
118
- } catch {
119
- cwd = PKG_ROOT;
120
- }
121
-
122
- // Spawn detached so the binary can outlive this call — it keeps its
123
- // HTTP server alive for ~10s after emitting the JSON hook response.
124
- const child = spawn(runtime.binaryPath, [], {
125
- cwd,
126
- stdio: ["pipe", "pipe", "pipe"],
127
- env: {
128
- ...process.env,
129
- OPEN_PLAN_HOST: "opencode",
130
- OPEN_PLAN_PKG_MANAGER:
131
- process.env.OPEN_PLAN_PKG_MANAGER || detectPackageManager({ installPath: fileURLToPath(import.meta.url) }),
132
- },
133
- detached: true,
134
- });
135
-
136
- let stdout = "";
137
- let stderr = "";
138
- let resolved = false;
139
-
140
- child.stdout.on("data", (chunk) => {
141
- stdout += String(chunk);
142
- if (resolved) return;
143
-
144
- // Scan for a complete JSON hook-output line. Once found, resolve
145
- // immediately and let the binary keep running in the background.
146
- const lines = stdout.split("\n");
147
- for (const line of lines) {
148
- const trimmed = line.trim();
149
- if (!trimmed) continue;
150
- try {
151
- const parsed = validateHookOutput(JSON.parse(trimmed));
152
- resolved = true;
153
- child.unref();
154
- resolve(parsed);
155
- return;
156
- } catch {
157
- // Not valid hook JSON yet, keep buffering
158
- }
159
- }
160
- });
161
-
162
- child.stderr.on("data", (chunk) => {
163
- stderr += String(chunk);
164
- });
165
-
166
- child.on("error", (error) => {
167
- if (!resolved) reject(error);
168
- });
169
-
170
- child.on("close", (code, signal) => {
171
- if (resolved) return;
172
- // Binary exited without producing valid JSON
173
- if (signal) {
174
- reject(
175
- new Error(
176
- stderr.trim()
177
- ? `open-plan-annotator was terminated by signal ${signal}: ${stderr.trim()}`
178
- : `open-plan-annotator was terminated by signal ${signal}`,
179
- ),
180
- );
181
- } else if (code !== 0) {
182
- reject(
183
- new Error(
184
- stderr.trim()
185
- ? `open-plan-annotator exited with code ${code}: ${stderr.trim()}`
186
- : `open-plan-annotator exited with code ${code}`,
187
- ),
188
- );
189
- } else {
190
- reject(new Error("open-plan-annotator exited without producing hook output"));
191
- }
192
- });
193
-
194
- child.stdin.write(`${JSON.stringify(payload)}\n`);
195
- child.stdin.end();
12
+ const output = await runPlanReviewBinary({
13
+ binaryPath: runtime.binaryPath,
14
+ plan: options.plan,
15
+ sessionId: options.sessionId,
16
+ cwd: options.cwd,
17
+ env: {
18
+ OPEN_PLAN_HOST: "opencode",
19
+ OPEN_PLAN_PKG_MANAGER: process.env.OPEN_PLAN_PKG_MANAGER || detectPackageManager({ installPath: fileURLToPath(import.meta.url) }),
20
+ },
21
+ detached: true,
196
22
  });
197
23
 
198
- const decision = /** @type {HookOutput} */ (output).hookSpecificOutput.decision;
24
+ const decision = output.hookSpecificOutput.decision;
199
25
 
200
26
  if (decision.behavior === "allow") {
201
27
  return { approved: true };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-plan-annotator",
3
- "version": "1.4.2",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
5
  "description": "Fully local plugin for interactive plan annotation from your Agentic assistants",
6
6
  "author": "ndom91",
@@ -34,6 +34,8 @@
34
34
  "shared/runtimeResolver.mjs",
35
35
  "shared/updateHints.mjs",
36
36
  "shared/versionInfo.mjs",
37
+ "shared/planReview.mjs",
38
+ "shared/piExtension.mjs",
37
39
  ".claude-plugin/",
38
40
  "opencode/bridge.js",
39
41
  "opencode/config.js",
@@ -65,16 +67,25 @@
65
67
  "@biomejs/biome": "^2.4.13",
66
68
  "@types/node": "^25.6.0",
67
69
  "@types/bun": "^1.3.13",
68
- "@typescript/native-preview": "^7.0.0-dev.20260430.1"
70
+ "@typescript/native-preview": "^7.0.0-dev.20260430.1",
71
+ "typebox": "^1.1.37"
69
72
  },
70
73
  "dependencies": {
71
74
  "@opencode-ai/plugin": "^1.14.30"
72
75
  },
76
+ "peerDependencies": {
77
+ "typebox": "*"
78
+ },
79
+ "peerDependenciesMeta": {
80
+ "typebox": {
81
+ "optional": true
82
+ }
83
+ },
73
84
  "optionalDependencies": {
74
- "@open-plan-annotator/runtime-darwin-arm64": "1.4.2",
75
- "@open-plan-annotator/runtime-darwin-x64": "1.4.2",
76
- "@open-plan-annotator/runtime-linux-arm64": "1.4.2",
77
- "@open-plan-annotator/runtime-linux-x64": "1.4.2"
85
+ "@open-plan-annotator/runtime-darwin-arm64": "1.5.0",
86
+ "@open-plan-annotator/runtime-darwin-x64": "1.5.0",
87
+ "@open-plan-annotator/runtime-linux-arm64": "1.5.0",
88
+ "@open-plan-annotator/runtime-linux-x64": "1.5.0"
78
89
  },
79
90
  "overrides": {
80
91
  "uuid": "^14.0.0"
@@ -0,0 +1,134 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { Type } from "typebox";
3
+ import { detectPackageManager } from "./packageManager.mjs";
4
+ import { runPlanReviewBinary } from "./planReview.mjs";
5
+ import { resolveRuntimeBinary } from "./runtimeResolver.mjs";
6
+
7
+ const TOOL_NAME = "submit_plan";
8
+
9
+ function getSessionId(ctx) {
10
+ try {
11
+ return ctx.sessionManager.getSessionId();
12
+ } catch {
13
+ return undefined;
14
+ }
15
+ }
16
+
17
+ function getLastAssistantText(ctx) {
18
+ const branch = ctx.sessionManager.getBranch();
19
+
20
+ for (let i = branch.length - 1; i >= 0; i--) {
21
+ const entry = branch[i];
22
+ if (entry.type !== "message") continue;
23
+
24
+ const message = entry.message;
25
+ if (!message || message.role !== "assistant" || !Array.isArray(message.content)) continue;
26
+
27
+ const text = message.content
28
+ .filter((block) => block.type === "text")
29
+ .map((block) => block.text)
30
+ .join("\n")
31
+ .trim();
32
+
33
+ if (text) return text;
34
+ }
35
+
36
+ return undefined;
37
+ }
38
+
39
+ async function reviewPlan(plan, ctx) {
40
+ const runtime = resolveRuntimeBinary({ parentUrl: import.meta.url });
41
+ const result = await runPlanReviewBinary({
42
+ binaryPath: runtime.binaryPath,
43
+ plan,
44
+ sessionId: getSessionId(ctx),
45
+ cwd: ctx.cwd,
46
+ env: {
47
+ OPEN_PLAN_HOST: "pi",
48
+ OPEN_PLAN_PKG_MANAGER:
49
+ process.env.OPEN_PLAN_PKG_MANAGER || detectPackageManager({ installPath: fileURLToPath(import.meta.url) }),
50
+ },
51
+ detached: true,
52
+ });
53
+
54
+ const decision = result.hookSpecificOutput.decision;
55
+ return {
56
+ approved: decision.behavior === "allow",
57
+ feedback: decision.behavior === "deny" ? decision.message : undefined,
58
+ };
59
+ }
60
+
61
+ export function registerPiExtension(pi) {
62
+ pi.registerTool({
63
+ name: TOOL_NAME,
64
+ label: "Annotate Plan",
65
+ description: "Open the browser-based plan annotation UI and return approval or revision feedback.",
66
+ promptSnippet: "Use this tool after drafting a plan that needs human review before implementation.",
67
+ promptGuidelines: [
68
+ "Call submit_plan when you have a concrete plan in markdown form.",
69
+ "Include the full plan text, including numbered steps if available.",
70
+ "Wait for the review result before proceeding with implementation.",
71
+ ],
72
+ parameters: Type.Object({
73
+ plan: Type.String({ description: "The plan markdown to review" }),
74
+ }),
75
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
76
+ const review = await reviewPlan(params.plan, ctx);
77
+
78
+ if (review.approved) {
79
+ return {
80
+ content: [{ type: "text", text: "Plan approved. Continue with implementation." }],
81
+ details: { approved: true },
82
+ };
83
+ }
84
+
85
+ return {
86
+ content: [
87
+ {
88
+ type: "text",
89
+ text: `Plan changes requested:\n\n${review.feedback ?? "No feedback provided."}`,
90
+ },
91
+ ],
92
+ details: { approved: false, feedback: review.feedback ?? null },
93
+ };
94
+ },
95
+ });
96
+
97
+ pi.registerCommand("annotate-plan", {
98
+ description: "Open the plan annotation UI for the latest plan or provided plan text.",
99
+ handler: async (args, ctx) => {
100
+ let plan = args.trim();
101
+
102
+ if (!plan) {
103
+ plan = getLastAssistantText(ctx) ?? "";
104
+ }
105
+
106
+ if (!plan && !ctx.hasUI) {
107
+ ctx.ui.notify("Usage: /annotate-plan <plan markdown> or run it after a plan message.", "warning");
108
+ return;
109
+ }
110
+
111
+ if (!plan) {
112
+ const edited = await ctx.ui.editor("Paste plan markdown", "# Plan\n\n1. ");
113
+ if (!edited?.trim()) {
114
+ ctx.ui.notify("Plan review cancelled", "info");
115
+ return;
116
+ }
117
+ plan = edited;
118
+ }
119
+
120
+ try {
121
+ const review = await reviewPlan(plan, ctx);
122
+ if (review.approved) {
123
+ ctx.ui.notify("Plan approved.", "success");
124
+ return;
125
+ }
126
+
127
+ ctx.ui.notify(`Changes requested:\n${review.feedback ?? "No feedback provided."}`, "warning");
128
+ } catch (error) {
129
+ const message = error instanceof Error ? error.message : String(error);
130
+ ctx.ui.notify(`Plan review failed: ${message}`, "error");
131
+ }
132
+ },
133
+ });
134
+ }
@@ -0,0 +1,203 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, statSync } from "node:fs";
3
+ import { spawn } from "node:child_process";
4
+ import { dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const PKG_ROOT = fileURLToPath(new URL("..", import.meta.url));
8
+
9
+ /**
10
+ * @typedef {{
11
+ * hookSpecificOutput: {
12
+ * hookEventName: "PermissionRequest",
13
+ * decision: { behavior: "allow" } | { behavior: "deny", message: string }
14
+ * }
15
+ * }} HookOutput
16
+ */
17
+
18
+ /**
19
+ * @param {{ plan: string, sessionId?: string, cwd?: string }} options
20
+ */
21
+ export function buildHookPayload(options) {
22
+ return {
23
+ session_id: options.sessionId ?? randomUUID(),
24
+ transcript_path: "",
25
+ cwd: options.cwd ?? process.cwd(),
26
+ permission_mode: "default",
27
+ hook_event_name: "PermissionRequest",
28
+ tool_name: "ExitPlanMode",
29
+ tool_use_id: randomUUID(),
30
+ tool_input: {
31
+ plan: options.plan,
32
+ },
33
+ };
34
+ }
35
+
36
+ /**
37
+ * @param {unknown} value
38
+ * @returns {HookOutput}
39
+ */
40
+ export function validateHookOutput(value) {
41
+ if (!value || typeof value !== "object") {
42
+ throw new Error("invalid hook output shape");
43
+ }
44
+
45
+ const output = /** @type {HookOutput} */ (value);
46
+ const decision = output?.hookSpecificOutput?.decision;
47
+
48
+ if (!decision || typeof decision !== "object" || typeof decision.behavior !== "string") {
49
+ throw new Error("missing decision in hook output");
50
+ }
51
+
52
+ if (decision.behavior === "allow") {
53
+ return output;
54
+ }
55
+
56
+ if (decision.behavior === "deny" && typeof decision.message === "string") {
57
+ return output;
58
+ }
59
+
60
+ throw new Error("unsupported decision payload");
61
+ }
62
+
63
+ /**
64
+ * @param {string} stdoutText
65
+ * @param {string} stderrText
66
+ * @returns {HookOutput}
67
+ */
68
+ export function parseHookOutput(stdoutText, stderrText) {
69
+ const trimmed = stdoutText.trim();
70
+ if (!trimmed) {
71
+ const stderr = stderrText.trim();
72
+ throw new Error(
73
+ stderr
74
+ ? `open-plan-annotator returned empty stdout; stderr: ${stderr}`
75
+ : "open-plan-annotator returned empty stdout",
76
+ );
77
+ }
78
+
79
+ try {
80
+ return validateHookOutput(JSON.parse(trimmed));
81
+ } catch {
82
+ const lines = trimmed
83
+ .split(/\r?\n/)
84
+ .map((line) => line.trim())
85
+ .filter(Boolean)
86
+ .reverse();
87
+
88
+ for (const line of lines) {
89
+ try {
90
+ return validateHookOutput(JSON.parse(line));
91
+ } catch {
92
+ // keep searching
93
+ }
94
+ }
95
+
96
+ throw new Error("open-plan-annotator returned invalid hook JSON");
97
+ }
98
+ }
99
+
100
+ /**
101
+ * @param {string | undefined} cwd
102
+ */
103
+ function resolveSpawnCwd(cwd) {
104
+ let resolvedCwd = cwd ?? process.cwd();
105
+
106
+ try {
107
+ if (existsSync(resolvedCwd) && !statSync(resolvedCwd).isDirectory()) {
108
+ resolvedCwd = dirname(resolvedCwd);
109
+ }
110
+ } catch {
111
+ resolvedCwd = PKG_ROOT;
112
+ }
113
+
114
+ return resolvedCwd;
115
+ }
116
+
117
+ /**
118
+ * @param {{
119
+ * binaryPath: string,
120
+ * plan: string,
121
+ * sessionId?: string,
122
+ * cwd?: string,
123
+ * env?: Record<string, string | undefined>,
124
+ * detached?: boolean,
125
+ * }} options
126
+ * @returns {Promise<HookOutput>}
127
+ */
128
+ export async function runPlanReviewBinary(options) {
129
+ const payload = buildHookPayload({ plan: options.plan, sessionId: options.sessionId, cwd: options.cwd });
130
+ const cwd = resolveSpawnCwd(options.cwd);
131
+
132
+ return await new Promise((resolve, reject) => {
133
+ const child = spawn(options.binaryPath, [], {
134
+ cwd,
135
+ stdio: ["pipe", "pipe", "pipe"],
136
+ env: {
137
+ ...process.env,
138
+ ...options.env,
139
+ },
140
+ detached: options.detached ?? true,
141
+ });
142
+
143
+ let stdout = "";
144
+ let stderr = "";
145
+ let resolved = false;
146
+
147
+ child.stdout.on("data", (chunk) => {
148
+ stdout += String(chunk);
149
+ if (resolved) return;
150
+
151
+ const lines = stdout.split("\n");
152
+ for (const line of lines) {
153
+ const trimmed = line.trim();
154
+ if (!trimmed) continue;
155
+
156
+ try {
157
+ const parsed = validateHookOutput(JSON.parse(trimmed));
158
+ resolved = true;
159
+ child.unref();
160
+ resolve(parsed);
161
+ return;
162
+ } catch {
163
+ // Not JSON yet
164
+ }
165
+ }
166
+ });
167
+
168
+ child.stderr.on("data", (chunk) => {
169
+ stderr += String(chunk);
170
+ });
171
+
172
+ child.on("error", (error) => {
173
+ if (!resolved) reject(error);
174
+ });
175
+
176
+ child.on("close", (code, signal) => {
177
+ if (resolved) return;
178
+
179
+ if (signal) {
180
+ reject(
181
+ new Error(
182
+ stderr.trim()
183
+ ? `open-plan-annotator was terminated by signal ${signal}: ${stderr.trim()}`
184
+ : `open-plan-annotator was terminated by signal ${signal}`,
185
+ ),
186
+ );
187
+ } else if (code !== 0) {
188
+ reject(
189
+ new Error(
190
+ stderr.trim()
191
+ ? `open-plan-annotator exited with code ${code}: ${stderr.trim()}`
192
+ : `open-plan-annotator exited with code ${code}`,
193
+ ),
194
+ );
195
+ } else {
196
+ reject(new Error("open-plan-annotator exited without producing hook output"));
197
+ }
198
+ });
199
+
200
+ child.stdin.write(`${JSON.stringify(payload)}\n`);
201
+ child.stdin.end();
202
+ });
203
+ }
@@ -7,7 +7,8 @@ export async function buildUpdateMessage(options = {}) {
7
7
  const host = options.host ?? process.env.OPEN_PLAN_HOST;
8
8
 
9
9
  try {
10
- const latestVersion = await fetchLatestVersion();
10
+ const fetchVersion = options.fetchLatestVersion ?? fetchLatestVersion;
11
+ const latestVersion = await fetchVersion();
11
12
  if (currentVersion && isNewerVersion(currentVersion, latestVersion)) {
12
13
  return `latest v${latestVersion}; ${buildUpdateInstructions({ host, packageManager, version: latestVersion })}`;
13
14
  }