Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions apps/hook/server/clearContextSetting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "fs";
import { tmpdir } from "os";
import { join } from "path";

let tmpHome: string;

beforeEach(() => {
tmpHome = mkdtempSync(join(tmpdir(), "plannotator-clear-context-test-"));
mock.module("os", () => {
const realOs = require("node:os");
return { ...realOs, homedir: () => tmpHome };
});
});

afterEach(() => {
mock.restore();
rmSync(tmpHome, { recursive: true, force: true });
});

async function freshImport() {
return (await import(
`./clearContextSetting?t=${Date.now()}-${Math.random()}`
)) as typeof import("./clearContextSetting");
}

function writeConsent() {
mkdirSync(join(tmpHome, ".plannotator", "consent"), { recursive: true });
writeFileSync(
join(tmpHome, ".plannotator", "consent", "clear-context-setting.json"),
JSON.stringify({ consented: true }),
"utf8",
);
}

describe("clearContextSetting", () => {
test("does not create settings without consent", async () => {
const { ensureClearContextSettingEnabled } = await freshImport();
await ensureClearContextSettingEnabled();
expect(existsSync(join(tmpHome, ".claude", "settings.json"))).toBe(false);
});

test("creates settings with showClearContextOnPlanAccept when consent exists", async () => {
writeConsent();
const { ensureClearContextSettingEnabled } = await freshImport();
await ensureClearContextSettingEnabled();

const settings = JSON.parse(
readFileSync(join(tmpHome, ".claude", "settings.json"), "utf8"),
);
expect(settings.showClearContextOnPlanAccept).toBe(true);
});

test("preserves existing settings keys", async () => {
writeConsent();
mkdirSync(join(tmpHome, ".claude"), { recursive: true });
writeFileSync(
join(tmpHome, ".claude", "settings.json"),
JSON.stringify({ theme: "dark", env: { A: "B" } }),
"utf8",
);

const { ensureClearContextSettingEnabled } = await freshImport();
await ensureClearContextSettingEnabled();

const settings = JSON.parse(
readFileSync(join(tmpHome, ".claude", "settings.json"), "utf8"),
);
expect(settings.theme).toBe("dark");
expect(settings.env).toEqual({ A: "B" });
expect(settings.showClearContextOnPlanAccept).toBe(true);
});

test("is idempotent when setting is already enabled", async () => {
writeConsent();
mkdirSync(join(tmpHome, ".claude"), { recursive: true });
writeFileSync(
join(tmpHome, ".claude", "settings.json"),
JSON.stringify({ showClearContextOnPlanAccept: true }),
"utf8",
);

const { ensureClearContextSettingEnabled } = await freshImport();
await ensureClearContextSettingEnabled();
await ensureClearContextSettingEnabled();

const settings = JSON.parse(
readFileSync(join(tmpHome, ".claude", "settings.json"), "utf8"),
);
expect(settings.showClearContextOnPlanAccept).toBe(true);
});

test("leaves malformed settings JSON untouched", async () => {
writeConsent();
mkdirSync(join(tmpHome, ".claude"), { recursive: true });
const malformed = "{ this is not valid json";
writeFileSync(join(tmpHome, ".claude", "settings.json"), malformed, "utf8");

const { ensureClearContextSettingEnabled } = await freshImport();
await ensureClearContextSettingEnabled();

expect(readFileSync(join(tmpHome, ".claude", "settings.json"), "utf8")).toBe(
malformed,
);
});

test("records consent atomically", async () => {
const { recordConsent } = await freshImport();
recordConsent();

const consentPath = join(
tmpHome,
".plannotator",
"consent",
"clear-context-setting.json",
);
expect(existsSync(consentPath)).toBe(true);
const consent = JSON.parse(readFileSync(consentPath, "utf8"));
expect(consent.consented).toBe(true);
expect(typeof consent.recordedAt).toBe("string");
});

test("reports disabled when settings are missing", async () => {
const { isClearContextSettingEnabled } = await freshImport();
expect(isClearContextSettingEnabled()).toBe(false);
});

test("reports enabled when setting is true", async () => {
mkdirSync(join(tmpHome, ".claude"), { recursive: true });
writeFileSync(
join(tmpHome, ".claude", "settings.json"),
JSON.stringify({ showClearContextOnPlanAccept: true }),
"utf8",
);

const { isClearContextSettingEnabled } = await freshImport();
expect(isClearContextSettingEnabled()).toBe(true);
});
});
105 changes: 105 additions & 0 deletions apps/hook/server/clearContextSetting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import {
existsSync,
mkdirSync,
readFileSync,
renameSync,
writeFileSync,
} from "fs";
import { randomBytes } from "crypto";
import { homedir } from "os";
import { dirname, join } from "path";

const SETTING_KEY = "showClearContextOnPlanAccept";

function consentPath(): string {
return join(
homedir(),
".plannotator",
"consent",
"clear-context-setting.json",
);
}

function settingsPath(): string {
return join(homedir(), ".claude", "settings.json");
}

function hasConsent(): boolean {
try {
if (!existsSync(consentPath())) return false;
const data = JSON.parse(readFileSync(consentPath(), "utf8"));
return data?.consented === true;
} catch {
return false;
}
}

function writeJsonAtomic(path: string, data: Record<string, unknown>): void {
const tmp = join(
dirname(path),
`plannotator-settings-${randomBytes(4).toString("hex")}.json`,
);
writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", "utf8");
renameSync(tmp, path);
}

export function recordConsent(): void {
const dir = join(homedir(), ".plannotator", "consent");
mkdirSync(dir, { recursive: true });
writeJsonAtomic(consentPath(), {
consented: true,
recordedAt: new Date().toISOString(),
});
}

export function isClearContextSettingEnabled(): boolean {
try {
if (!existsSync(settingsPath())) return false;
const settings = JSON.parse(readFileSync(settingsPath(), "utf8"));
return settings?.[SETTING_KEY] === true;
} catch {
return false;
}
}

export async function ensureClearContextSettingEnabled(): Promise<boolean> {
if (!hasConsent()) {
console.error(
"[plannotator] clearContextSetting: no consent recorded; skipping settings mutation",
);
return isClearContextSettingEnabled();
}

let settings: Record<string, unknown>;
try {
settings = existsSync(settingsPath())
? JSON.parse(readFileSync(settingsPath(), "utf8"))
: {};
} catch (error: any) {
console.error(
`[plannotator] clearContextSetting: malformed settings JSON; skipping mutation: ${error?.message}`,
);
return false;
}

if (settings[SETTING_KEY] === true) return true;

settings[SETTING_KEY] = true;
mkdirSync(join(homedir(), ".claude"), { recursive: true });

try {
writeJsonAtomic(settingsPath(), settings);
} catch (error: any) {
try {
await Bun.sleep(50);
writeJsonAtomic(settingsPath(), settings);
} catch (retryError: any) {
console.error(
`[plannotator] clearContextSetting: write failed after retry; skipping: ${retryError?.message}`,
);
return false;
}
}

return true;
}
29 changes: 29 additions & 0 deletions apps/hook/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ import {
isTopLevelHelpInvocation,
isVersionInvocation,
} from "./cli";
import { ensureClearContextSettingEnabled } from "./clearContextSetting";
import { shouldAutoSelectNativeClear, spawnKeystrokeInjector } from "./keystrokeInjector";
import path from "path";
import { tmpdir } from "os";

Expand Down Expand Up @@ -1202,6 +1204,12 @@ if (args[0] === "sessions") {
}

permissionMode = event.permission_mode || "default";
const toolName: string =
typeof event.tool_name === "string"
? event.tool_name
: typeof event.toolName === "string"
? event.toolName
: "";

if (!planContent) {
console.error("No plan content in hook event");
Expand All @@ -1215,6 +1223,7 @@ if (args[0] === "sessions") {
plan: planContent,
origin: isGemini ? "gemini-cli" : detectedOrigin,
permissionMode,
toolName,
sharingEnabled,
shareBaseUrl,
pasteApiUrl,
Expand Down Expand Up @@ -1265,6 +1274,22 @@ if (args[0] === "sessions") {
}
} else {
// Claude Code: PermissionRequest hook decision
if (
result.approved &&
result.deferToNativeForClear &&
toolName === "ExitPlanMode"
) {
const nativeClearEnabled = await ensureClearContextSettingEnabled();
if (nativeClearEnabled) {
if (shouldAutoSelectNativeClear()) {
spawnKeystrokeInjector();
}
process.exit(0);
}
result.clearContextNudge = true;
result.permissionMode ||= "bypassPermissions";
}

if (result.approved) {
const updatedPermissions = [];
if (result.permissionMode) {
Expand All @@ -1277,6 +1302,10 @@ if (args[0] === "sessions") {

console.log(
JSON.stringify({
...(result.clearContextNudge && {
systemMessage:
"Plannotator requested bypass mode. Hooks cannot clear context. Run /clear before continuing if you want a fresh implementation session.",
}),
hookSpecificOutput: {
hookEventName: "PermissionRequest",
decision: {
Expand Down
Loading
Loading