From 006c5477cc80468d6b2ea43f01c27c434a6d046c Mon Sep 17 00:00:00 2001 From: Thinh Date: Tue, 6 Oct 2026 01:21:31 +0200 Subject: [PATCH 1/2] Add OPEN_PLAN_ANNOTATOR_SKIP_FILE to bypass the hook While the file named by OPEN_PLAN_ANNOTATOR_SKIP_FILE exists, the ExitPlanMode hook exits 0 with no output, so Claude Code falls back to its native plan approval. The file is checked on every call, so the bypass can be toggled mid session, e.g. while driving a session through Remote Control where the local browser UI is unreachable and the hook would otherwise block plan approval. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 10 ++++++++++ bin/open-plan-annotator.mjs | 5 +++++ bin/open-plan-annotator.test.ts | 15 +++++++++++++-- package.json | 1 + shared/skipHook.mjs | 10 ++++++++++ shared/skipHook.test.ts | 22 ++++++++++++++++++++++ 6 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 shared/skipHook.mjs create mode 100644 shared/skipHook.test.ts diff --git a/README.md b/README.md index 4c160a8..752e4da 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,16 @@ open-plan-annotator agent-setup open-plan-annotator help agent ``` +### Skipping the Annotator + +Set `OPEN_PLAN_ANNOTATOR_SKIP_FILE` to a file path. While that file exists, the Claude Code hook exits without a decision and Claude Code shows its native plan approval instead. The file is checked on every hook call, so you can toggle it mid session, for example while continuing a session through Remote Control where the local browser UI is unreachable: + +```sh +export OPEN_PLAN_ANNOTATOR_SKIP_FILE="$HOME/.open-plan-annotator-skip" +touch "$OPEN_PLAN_ANNOTATOR_SKIP_FILE" # use native approval +rm "$OPEN_PLAN_ANNOTATOR_SKIP_FILE" # back to the annotator +``` + ## Updates - **Claude Code**: navigate to the `/plugin` -> "Installed" -> `open-plan-annotator` entry and select "Update", then restart Claude Code. diff --git a/bin/open-plan-annotator.mjs b/bin/open-plan-annotator.mjs index f62584d..a1b90d5 100755 --- a/bin/open-plan-annotator.mjs +++ b/bin/open-plan-annotator.mjs @@ -11,6 +11,7 @@ import { codesignFixCommand, ensureValidCodeSignature, hasValidCodeSignature } f import { detectPackageManager } from "../shared/packageManager.mjs"; import { resolveRuntimeBinary } from "../shared/runtimeResolver.mjs"; import { buildRuntimeEnv } from "../shared/runtimeEnv.mjs"; +import { shouldSkipHook } from "../shared/skipHook.mjs"; import { buildUpdateMessage } from "../shared/updateMessage.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -51,6 +52,10 @@ if (cliMode === "unknown") { process.exit(1); } +if (cliMode === "hook" && shouldSkipHook()) { + process.exit(0); +} + // Buffer stdin immediately so it's not lost if we need to download first. // Skip when stdin is a TTY (manual invocation) to avoid blocking forever. let stdinBuffer; diff --git a/bin/open-plan-annotator.test.ts b/bin/open-plan-annotator.test.ts index b0dad84..71ad256 100644 --- a/bin/open-plan-annotator.test.ts +++ b/bin/open-plan-annotator.test.ts @@ -36,11 +36,11 @@ interface WrapperResult { stdioClosedAfterMs: number; } -function runHook(root: string): Promise { +function runHook(root: string, env: NodeJS.ProcessEnv = {}): Promise { const start = Date.now(); const child = spawn("node", [path.join(root, "bin", "open-plan-annotator.mjs")], { stdio: ["pipe", "pipe", "pipe"], - env: { ...process.env, OPEN_PLAN_ANNOTATOR_SKIP_INSTALL: "1" }, + env: { ...process.env, OPEN_PLAN_ANNOTATOR_SKIP_INSTALL: "1", ...env }, }); child.stdin.end(JSON.stringify({ hook_event_name: "PreToolUse", tool_name: "ExitPlanMode", tool_input: {} })); @@ -87,4 +87,15 @@ describe.if(process.platform !== "win32")("open-plan-annotator wrapper (hook mod expect(result.stdout).toBe('{"ok":true}\n'); expect(result.stdioClosedAfterMs).toBeLessThan(2000); }); + + test("exits 0 without running the runtime when the skip file exists", async () => { + const root = createPluginTree("#!/bin/sh\necho '{\"ok\":true}'\n"); + const skipFile = path.join(root, "skip"); + fs.writeFileSync(skipFile, ""); + + const result = await runHook(root, { OPEN_PLAN_ANNOTATOR_SKIP_FILE: skipFile }); + + expect(result.code).toBe(0); + expect(result.stdout).toBe(""); + }); }); diff --git a/package.json b/package.json index 1b258b9..e7c0da4 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "shared/runtimeResolver.mjs", "shared/macosCodesign.mjs", "shared/runtimeEnv.mjs", + "shared/skipHook.mjs", "shared/updateHints.mjs", "shared/versionInfo.mjs", "shared/planReview.mjs", diff --git a/shared/skipHook.mjs b/shared/skipHook.mjs new file mode 100644 index 0000000..1b34d36 --- /dev/null +++ b/shared/skipHook.mjs @@ -0,0 +1,10 @@ +import fs from "node:fs"; + +/** + * @param {NodeJS.ProcessEnv} [env] + * @returns {boolean} + */ +export function shouldSkipHook(env = process.env) { + const skipFile = env.OPEN_PLAN_ANNOTATOR_SKIP_FILE; + return Boolean(skipFile) && fs.existsSync(skipFile); +} diff --git a/shared/skipHook.test.ts b/shared/skipHook.test.ts new file mode 100644 index 0000000..79c56d3 --- /dev/null +++ b/shared/skipHook.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { shouldSkipHook } from "./skipHook.mjs"; + +describe("shouldSkipHook", () => { + test("does not skip when the env var is unset", () => { + expect(shouldSkipHook({})).toBe(false); + }); + + test("does not skip when the file is missing", () => { + const skipFile = path.join(os.tmpdir(), "opa-skip-missing"); + expect(shouldSkipHook({ OPEN_PLAN_ANNOTATOR_SKIP_FILE: skipFile })).toBe(false); + }); + + test("skips when the file exists", () => { + const skipFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "opa-skip-")), "skip"); + fs.writeFileSync(skipFile, ""); + expect(shouldSkipHook({ OPEN_PLAN_ANNOTATOR_SKIP_FILE: skipFile })).toBe(true); + }); +}); From 55a0ddf69afe92e21c49039ff61d394c16cf580c Mon Sep 17 00:00:00 2001 From: Thinh Date: Tue, 6 Oct 2026 01:33:01 +0200 Subject: [PATCH 2/2] Ignore EPIPE when the runtime exits before reading stdin When the runtime exits before the wrapper finishes writing the hook payload to its stdin, the write fails with EPIPE. The stream had no error listener, so Node crashed with exit 1 instead of reaching the close handler, which exits 2 with a diagnosis. Exit 1 is a non-blocking hook error, so the plan silently skipped review. This is the Linux CI failure in the wrapper test on main. Co-Authored-By: Claude Opus 5.5 (1M context) --- bin/open-plan-annotator.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/bin/open-plan-annotator.mjs b/bin/open-plan-annotator.mjs index a1b90d5..c4f2b52 100755 --- a/bin/open-plan-annotator.mjs +++ b/bin/open-plan-annotator.mjs @@ -107,6 +107,7 @@ function runRuntime(binaryPath, { allowResign }) { env: childEnv, }); + child.stdin.on("error", () => {}); child.stdin.write(stdinBuffer); child.stdin.end();