From de925ded14ad1b64d726425399ebd3bd8a141747 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Tue, 5 May 2026 12:37:12 +0700 Subject: [PATCH 01/34] Preserve truthful approval semantics for Claude plan bypass Thread a clear-context reminder flag through approval decisions, expose a Claude Code-only approval entry that requests bypass mode, and keep the hook response honest by emitting a reminder instead of claiming context was cleared. Constraint: Claude Code PermissionRequest hooks have no documented clearContext response field and bypassPermissions is only a request when the mode is available. Rejected: adding a permission-mode enum or undocumented clearContext field | those would misrepresent hook capabilities and broaden the contract. Confidence: high Scope-risk: moderate Directive: Do not claim Plannotator clears context until Claude Code documents a hook field for that behavior; keep reminder copy truthful. Tested: bun test packages/server apps/hook; bun test packages/editor/wideMode.test.ts packages/ui/hooks/useAgentSettings.test.ts; bun test; bun run build:review; bun run build:hook; bun run typecheck; git diff --check --cached. Not-tested: Browser warning-dialog replay and interactive Claude Code smoke test. Co-authored-by: OmX --- apps/hook/server/index.ts | 4 + packages/server/index.ts | 12 +- packages/ui/components/ApproveDropdown.tsx | 149 ++++++++++++++------- 3 files changed, 119 insertions(+), 46 deletions(-) diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 9287d2fee..b1e687974 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -1277,6 +1277,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: { diff --git a/packages/server/index.ts b/packages/server/index.ts index 8b1356a49..c71677346 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -101,6 +101,7 @@ export interface ServerResult { savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; }>; /** Wait for user to close (archive mode only) */ waitForDone?: () => Promise; @@ -172,6 +173,7 @@ export async function startPlannotatorServer( savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; }) => void; let decisionPromise: Promise<{ approved: boolean; @@ -179,6 +181,7 @@ export async function startPlannotatorServer( savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; }>; if (mode !== "archive") { @@ -455,6 +458,7 @@ export async function startPlannotatorServer( let feedback: string | undefined; let agentSwitch: string | undefined; let requestedPermissionMode: string | undefined; + let clearContextNudge: boolean | undefined; let planSaveEnabled = true; // default to enabled for backwards compat let planSaveCustomPath: string | undefined; try { @@ -466,6 +470,7 @@ export async function startPlannotatorServer( agentSwitch?: string; planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string; + clearContextNudge?: boolean; }; // Capture feedback if provided (for "approve with notes") @@ -483,6 +488,11 @@ export async function startPlannotatorServer( requestedPermissionMode = body.permissionMode; } + // Capture optional /clear reminder request for Claude Code approval flow + if (body.clearContextNudge === true) { + clearContextNudge = true; + } + // Capture plan save settings if (body.planSave !== undefined) { planSaveEnabled = body.planSave.enabled; @@ -528,7 +538,7 @@ export async function startPlannotatorServer( // Use permission mode from client request if provided, otherwise fall back to hook input const effectivePermissionMode = requestedPermissionMode || permissionMode; - resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode }); + resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode, clearContextNudge }); return Response.json({ ok: true, savedPath }); } diff --git a/packages/ui/components/ApproveDropdown.tsx b/packages/ui/components/ApproveDropdown.tsx index ab9c48c7f..b669f76e2 100644 --- a/packages/ui/components/ApproveDropdown.tsx +++ b/packages/ui/components/ApproveDropdown.tsx @@ -2,11 +2,21 @@ import React, { useState, useRef, useEffect } from 'react'; import type { Agent } from '../hooks/useAgents'; import { getAgentSwitchSettings, saveAgentSwitchSettings, type AgentSwitchSettings } from '../utils/agentSwitch'; +export interface ApproveExtraEntry { + id: string; + label: string; + description?: string; + onSelect: () => void; + disabled?: boolean; +} + interface ApproveDropdownProps { onApprove: () => void; agents: Agent[]; disabled?: boolean; isLoading?: boolean; + extraEntries?: ApproveExtraEntry[]; + showAgentSwitch?: boolean; } function getSelectedLabel(setting: AgentSwitchSettings, agents: Agent[]): string | null { @@ -35,6 +45,8 @@ export const ApproveDropdown: React.FC = ({ agents, disabled = false, isLoading = false, + extraEntries = [], + showAgentSwitch, }) => { const [setting, setSetting] = useState(() => getAgentSwitchSettings()); const [isOpen, setIsOpen] = useState(false); @@ -57,6 +69,10 @@ export const ApproveDropdown: React.FC = ({ }; }, []); + const hasExtraEntries = extraEntries.length > 0; + const shouldShowAgentSwitch = showAgentSwitch ?? agents.length > 0; + const hasDropdownContent = hasExtraEntries || shouldShowAgentSwitch; + const handleSelect = (newSetting: AgentSwitchSettings) => { setSetting(newSetting); saveAgentSwitchSettings(newSetting); @@ -78,16 +94,36 @@ export const ApproveDropdown: React.FC = ({ onApprove(); }; + const handleExtraSelect = (entry: ApproveExtraEntry) => { + if (entry.disabled) return; + setIsOpen(false); + entry.onSelect(); + }; + return (
- {/* Mobile: simple button */} - + {/* Mobile: simple button, with menu when extra actions exist */} +
+ + {hasDropdownContent && ( + + )} +
{/* Desktop: split button */}
@@ -109,8 +145,9 @@ export const ApproveDropdown: React.FC = ({
{/* Dropdown */} - {isOpen && ( -
-
- Switch to agent -
- {agents.map((agent) => { - const selected = isSelected(agent.id, setting); - return ( + {isOpen && hasDropdownContent && ( +
+ {hasExtraEntries && ( + <> + {extraEntries.map((entry) => ( + + ))} + {shouldShowAgentSwitch &&
} + + )} + {shouldShowAgentSwitch && ( + <> +
+ Switch to agent +
+ {agents.map((agent) => { + const selected = isSelected(agent.id, setting); + return ( + + ); + })} + {isCustom && setting.customName && ( + + )} +
- ); - })} - {isCustom && setting.customName && ( - + )} -
-
)}
From c92abe2c290761a0153ee8d92049747264a71eb3 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Tue, 5 May 2026 15:05:47 +0700 Subject: [PATCH 02/34] Keep approval payloads truthful across Claude Code and Pi Constraint: Claude Code hooks cannot clear context directly; the new action remains a truthful reminder plus bypass-mode approval. Rejected: Sending OpenCode agent-switch state for Claude Code | it leaked build (?) UI state and misleading payload fields. Confidence: high Scope-risk: narrow Directive: Keep OpenCode agent switching gated to opencode-origin approval payloads. Tested: bun run typecheck; bun test; bun run build:review; bun run build:hook Not-tested: Manual browser click-through of the Claude Code dropdown. --- apps/pi-extension/plannotator-browser.ts | 1 + apps/pi-extension/plannotator-events.ts | 2 + apps/pi-extension/server.test.ts | 40 ++++++++++++++++ apps/pi-extension/server/serverPlan.ts | 4 ++ packages/editor/App.tsx | 33 +++++++------ packages/editor/approvalBody.test.ts | 33 +++++++++++++ packages/editor/approvalBody.ts | 47 +++++++++++++++++++ .../ui/components/ApproveDropdown.test.tsx | 24 ++++++++++ packages/ui/components/ApproveDropdown.tsx | 8 ++-- 9 files changed, 171 insertions(+), 21 deletions(-) create mode 100644 packages/editor/approvalBody.test.ts create mode 100644 packages/editor/approvalBody.ts create mode 100644 packages/ui/components/ApproveDropdown.test.tsx diff --git a/apps/pi-extension/plannotator-browser.ts b/apps/pi-extension/plannotator-browser.ts index c1d390edc..99fdc52b7 100644 --- a/apps/pi-extension/plannotator-browser.ts +++ b/apps/pi-extension/plannotator-browser.ts @@ -35,6 +35,7 @@ export interface PlanReviewDecision { savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; } export interface BrowserDecisionSession { diff --git a/apps/pi-extension/plannotator-events.ts b/apps/pi-extension/plannotator-events.ts index 12845ae70..f2754a6de 100644 --- a/apps/pi-extension/plannotator-events.ts +++ b/apps/pi-extension/plannotator-events.ts @@ -73,6 +73,7 @@ export interface PlannotatorReviewResultEvent { savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; } export interface PlannotatorReviewStatusPayload { @@ -248,6 +249,7 @@ export function registerPlannotatorEventListeners(pi: ExtensionAPI): void { savedPath: result.savedPath, agentSwitch: result.agentSwitch, permissionMode: result.permissionMode, + clearContextNudge: result.clearContextNudge, } satisfies PlannotatorReviewResultEvent; setStoredReviewStatus(session.reviewId, { status: "completed", ...reviewResult }); pi.events.emit(PLANNOTATOR_REVIEW_RESULT_CHANNEL, reviewResult); diff --git a/apps/pi-extension/server.test.ts b/apps/pi-extension/server.test.ts index bbfafaff9..a0dc5215d 100644 --- a/apps/pi-extension/server.test.ts +++ b/apps/pi-extension/server.test.ts @@ -9,6 +9,7 @@ import { getVcsContext, prepareLocalReviewDiff, runGitDiff, + startPlanReviewServer, startReviewServer, } from "./server"; @@ -134,6 +135,45 @@ afterEach(() => { describe("pi review server", () => { const testIfJj = hasJj() ? test : test.skip; + test("plan approve preserves clear context nudge decisions", async () => { + const homeDir = makeTempDir("plannotator-pi-home-"); + const repoDir = makeTempDir("plannotator-pi-plan-"); + process.env.HOME = homeDir; + process.chdir(repoDir); + process.env.PLANNOTATOR_PORT = String(await reservePort()); + + const server = await startPlanReviewServer({ + plan: "# Plan\n\nShip it.", + htmlContent: "plan", + origin: "pi", + permissionMode: "acceptEdits", + }); + + try { + const approveResponse = await fetch(`${server.url}/api/approve`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + permissionMode: "bypassPermissions", + clearContextNudge: true, + planSave: { enabled: false }, + }), + }); + expect(approveResponse.status).toBe(200); + + await expect(server.waitForDecision()).resolves.toEqual({ + approved: true, + feedback: undefined, + savedPath: undefined, + agentSwitch: undefined, + permissionMode: "bypassPermissions", + clearContextNudge: true, + }); + } finally { + server.stop(); + } + }); + test("serves review diff parity endpoints including drafts, uploads, and editor annotations", async () => { const homeDir = makeTempDir("plannotator-pi-home-"); const repoDir = initRepo(); diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index 06ba52754..9df014487 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -56,6 +56,7 @@ export interface PlanReviewDecision { savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; } export interface PlanServerResult { @@ -369,6 +370,7 @@ export async function startPlanReviewServer(options: { let feedback: string | undefined; let agentSwitch: string | undefined; let requestedPermissionMode: string | undefined; + let clearContextNudge: boolean | undefined; let planSaveEnabled = true; let planSaveCustomPath: string | undefined; try { @@ -377,6 +379,7 @@ export async function startPlanReviewServer(options: { if (body.agentSwitch) agentSwitch = body.agentSwitch as string; if (body.permissionMode) requestedPermissionMode = body.permissionMode as string; + if (body.clearContextNudge === true) clearContextNudge = true; if (body.planSave !== undefined) { const ps = body.planSave as { enabled: boolean; customPath?: string }; planSaveEnabled = ps.enabled; @@ -438,6 +441,7 @@ export async function startPlanReviewServer(options: { savedPath, agentSwitch, permissionMode: effectivePermissionMode, + clearContextNudge, }); json(res, { ok: true, savedPath }); } else if (url.pathname === "/api/deny" && req.method === "POST") { diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index c48b7eb2f..4f2fc2e1a 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -75,6 +75,7 @@ import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiff import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from './demoPlan'; import { DIFF_DEMO_PLAN_CONTENT } from './demoPlanDiffDemo'; import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode'; +import { buildApprovalRequestBody, type ApprovalOverride } from './approvalBody'; const USE_DIFF_DEMO = import.meta.env.VITE_DIFF_DEMO === '1' || import.meta.env.VITE_DIFF_DEMO === 'true'; @@ -952,24 +953,22 @@ const App: React.FC = () => { ? await autoSavePromiseRef.current : autoSaveResultsRef.current; - // Build request body - include integrations if enabled - const body: { obsidian?: object; bear?: object; octarine?: object; feedback?: string; agentSwitch?: string; planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string } = {}; - - // Include permission mode for Claude Code - if (origin === 'claude-code') { - body.permissionMode = permissionMode; - } - const effectiveAgent = getEffectiveAgentName(getAgentSwitchSettings()); - if (effectiveAgent) { - body.agentSwitch = effectiveAgent; - } - - // Include plan save settings - body.planSave = { - enabled: planSaveSettings.enabled, - ...(planSaveSettings.customPath && { customPath: planSaveSettings.customPath }), - }; + const body = buildApprovalRequestBody({ + origin, + permissionMode, + override, + effectiveAgent, + planSaveSettings, + }); + const effectiveAgent = getEffectiveAgentName(getAgentSwitchSettings()); + const body = buildApprovalRequestBody({ + origin, + permissionMode, + override, + effectiveAgent, + planSaveSettings, + }); const effectiveVaultPath = getEffectiveVaultPath(obsidianSettings); if (obsidianSettings.enabled && effectiveVaultPath) { diff --git a/packages/editor/approvalBody.test.ts b/packages/editor/approvalBody.test.ts new file mode 100644 index 000000000..c1f024e74 --- /dev/null +++ b/packages/editor/approvalBody.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'bun:test'; +import { buildApprovalRequestBody } from './approvalBody'; + +describe('buildApprovalRequestBody', () => { + test('omits agentSwitch for Claude Code approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'acceptEdits', + effectiveAgent: 'build', + override: { + permissionMode: 'bypassPermissions', + clearContextNudge: true, + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + clearContextNudge: true, + planSave: { enabled: true }, + }); + }); + + test('keeps agentSwitch for OpenCode approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'opencode', + permissionMode: 'acceptEdits', + effectiveAgent: 'build', + planSaveSettings: { enabled: true }, + })).toEqual({ + agentSwitch: 'build', + planSave: { enabled: true }, + }); + }); +}); diff --git a/packages/editor/approvalBody.ts b/packages/editor/approvalBody.ts new file mode 100644 index 000000000..7ebb4b834 --- /dev/null +++ b/packages/editor/approvalBody.ts @@ -0,0 +1,47 @@ +import type { Origin } from '@plannotator/shared/agents'; +import type { PermissionMode } from '@plannotator/ui/utils/permissionMode'; + +export type ApprovalOverride = { + permissionMode?: PermissionMode; + clearContextNudge?: boolean; +}; + +export interface ApprovalRequestBody { + obsidian?: object; + bear?: object; + octarine?: object; + feedback?: string; + agentSwitch?: string; + planSave?: { enabled: boolean; customPath?: string }; + permissionMode?: string; + clearContextNudge?: boolean; +} + +export function buildApprovalRequestBody(options: { + origin: Origin | null; + permissionMode: PermissionMode; + override?: ApprovalOverride; + effectiveAgent?: string; + planSaveSettings: { enabled: boolean; customPath?: string | null }; +}): ApprovalRequestBody { + const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings } = options; + const body: ApprovalRequestBody = {}; + + if (origin === 'claude-code') { + body.permissionMode = override.permissionMode ?? permissionMode; + if (override.clearContextNudge) { + body.clearContextNudge = true; + } + } + + if (origin === 'opencode' && effectiveAgent) { + body.agentSwitch = effectiveAgent; + } + + body.planSave = { + enabled: planSaveSettings.enabled, + ...(planSaveSettings.customPath && { customPath: planSaveSettings.customPath }), + }; + + return body; +} diff --git a/packages/ui/components/ApproveDropdown.test.tsx b/packages/ui/components/ApproveDropdown.test.tsx new file mode 100644 index 000000000..967f8e4be --- /dev/null +++ b/packages/ui/components/ApproveDropdown.test.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { describe, expect, test } from 'bun:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { ApproveDropdown } from './ApproveDropdown'; + +describe('ApproveDropdown', () => { + test('does not show agent-switch label when only extra approval entries are enabled', () => { + const html = renderToStaticMarkup( + {}} + agents={[]} + extraEntries={[{ + id: 'approve-bypass-clear-reminder', + label: 'Approve + Bypass + /clear Reminder', + onSelect: () => {}, + }]} + />, + ); + + expect(html).toContain('Approve'); + expect(html).not.toContain('build'); + expect(html).not.toContain('(?)'); + }); +}); diff --git a/packages/ui/components/ApproveDropdown.tsx b/packages/ui/components/ApproveDropdown.tsx index b669f76e2..9424696d3 100644 --- a/packages/ui/components/ApproveDropdown.tsx +++ b/packages/ui/components/ApproveDropdown.tsx @@ -79,10 +79,10 @@ export const ApproveDropdown: React.FC = ({ setIsOpen(false); }; - const agentLabel = getSelectedLabel(setting, agents); - const isNoSwitch = setting.switchTo === 'disabled'; - const isCustom = setting.switchTo === 'custom'; - const notFound = agentLabel && !isNoSwitch && !isCustom + const agentLabel = shouldShowAgentSwitch ? getSelectedLabel(setting, agents) : null; + const isNoSwitch = shouldShowAgentSwitch && setting.switchTo === 'disabled'; + const isCustom = shouldShowAgentSwitch && setting.switchTo === 'custom'; + const notFound = shouldShowAgentSwitch && agentLabel && !isNoSwitch && !isCustom && !agents.some(a => a.id.toLowerCase() === setting.switchTo.toLowerCase()); const baseClasses = disabled From 566173160b96f0002093461c314677c870a7632a Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Thu, 7 May 2026 02:20:25 +0700 Subject: [PATCH 03/34] Keep Plannotator approvals actionable for bypass clear context Route Claude Code plan approvals through explicit bypass payloads, consent-gated native clear-context deferral, and a fallback /clear nudge so selecting the clear-context mode no longer collapses into a silent approval no-op. The active ignored hook bundle was rebuilt locally after this source change. Constraint: Claude Code hooks cannot directly clear context; native clear requires showClearContextOnPlanAccept and user consent.\nRejected: Treating bypassPermissionsClearReminder as a raw permissionMode | Claude Code only accepts bypassPermissions on the wire and would ignore the local UI-only value.\nConfidence: high\nScope-risk: moderate\nDirective: Rebuild apps/hook/dist/index.html after changing plan-review UI because the local plannotator launcher imports the ignored dist bundle at runtime.\nTested: git diff --check; bun run typecheck; bun test; bun run build:review; bun run build:hook; fixed-port hook smoke for /api/settings-status and native-clear /api/approve.\nNot-tested: Manual click-through in Claude Code native plan-accept dialog. --- apps/hook/server/clearContextSetting.test.ts | 146 +++++++++++++++++++ apps/hook/server/clearContextSetting.ts | 105 +++++++++++++ apps/hook/server/index.ts | 21 +++ apps/pi-extension/server.test.ts | 33 +++++ apps/pi-extension/server/serverPlan.ts | 10 ++ packages/editor/App.tsx | 133 +++++++++++++++-- packages/editor/approvalBody.test.ts | 113 ++++++++++++++ packages/editor/approvalBody.ts | 16 +- packages/server/index.ts | 104 ++++++++++++- packages/ui/components/Settings.tsx | 4 +- packages/ui/utils/permissionMode.ts | 16 +- 11 files changed, 678 insertions(+), 23 deletions(-) create mode 100644 apps/hook/server/clearContextSetting.test.ts create mode 100644 apps/hook/server/clearContextSetting.ts diff --git a/apps/hook/server/clearContextSetting.test.ts b/apps/hook/server/clearContextSetting.test.ts new file mode 100644 index 000000000..6e427893b --- /dev/null +++ b/apps/hook/server/clearContextSetting.test.ts @@ -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); + }); +}); diff --git a/apps/hook/server/clearContextSetting.ts b/apps/hook/server/clearContextSetting.ts new file mode 100644 index 000000000..7cb1f62f2 --- /dev/null +++ b/apps/hook/server/clearContextSetting.ts @@ -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): 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 { + if (!hasConsent()) { + console.error( + "[plannotator] clearContextSetting: no consent recorded; skipping settings mutation", + ); + return isClearContextSettingEnabled(); + } + + let settings: Record; + 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; +} diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index b1e687974..1bbdb6c7f 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -110,6 +110,7 @@ import { isTopLevelHelpInvocation, isVersionInvocation, } from "./cli"; +import { ensureClearContextSettingEnabled } from "./clearContextSetting"; import path from "path"; import { tmpdir } from "os"; @@ -1202,6 +1203,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"); @@ -1215,6 +1222,7 @@ if (args[0] === "sessions") { plan: planContent, origin: isGemini ? "gemini-cli" : detectedOrigin, permissionMode, + toolName, sharingEnabled, shareBaseUrl, pasteApiUrl, @@ -1265,6 +1273,19 @@ if (args[0] === "sessions") { } } else { // Claude Code: PermissionRequest hook decision + if ( + result.approved && + result.deferToNativeForClear && + toolName === "ExitPlanMode" + ) { + const nativeClearEnabled = await ensureClearContextSettingEnabled(); + if (nativeClearEnabled) { + process.exit(0); + } + result.clearContextNudge = true; + result.permissionMode ||= "bypassPermissions"; + } + if (result.approved) { const updatedPermissions = []; if (result.permissionMode) { diff --git a/apps/pi-extension/server.test.ts b/apps/pi-extension/server.test.ts index a0dc5215d..97d38117c 100644 --- a/apps/pi-extension/server.test.ts +++ b/apps/pi-extension/server.test.ts @@ -174,6 +174,39 @@ describe("pi review server", () => { } }); + test("plan clear-context setting endpoints are explicit unsupported fallbacks", async () => { + const homeDir = makeTempDir("plannotator-pi-home-"); + const repoDir = makeTempDir("plannotator-pi-plan-"); + process.env.HOME = homeDir; + process.chdir(repoDir); + process.env.PLANNOTATOR_PORT = String(await reservePort()); + + const server = await startPlanReviewServer({ + plan: "# Plan\n\nShip it.", + htmlContent: "plan", + origin: "pi", + permissionMode: "acceptEdits", + }); + + try { + const statusResponse = await fetch(`${server.url}/api/settings-status`); + await expect(statusResponse.json()).resolves.toEqual({ + settingEnabled: false, + consentGiven: false, + }); + + const enableResponse = await fetch(`${server.url}/api/enable-clear-context`, { + method: "POST", + }); + await expect(enableResponse.json()).resolves.toEqual({ + ok: false, + reason: "not-supported-in-pi-extension", + }); + } finally { + server.stop(); + } + }); + test("serves review diff parity endpoints including drafts, uploads, and editor annotations", async () => { const homeDir = makeTempDir("plannotator-pi-home-"); const repoDir = initRepo(); diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index 9df014487..5b95e74ed 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -444,6 +444,16 @@ export async function startPlanReviewServer(options: { clearContextNudge, }); json(res, { ok: true, savedPath }); + } else if ( + url.pathname === "/api/settings-status" && + req.method === "GET" + ) { + json(res, { settingEnabled: false, consentGiven: false }); + } else if ( + url.pathname === "/api/enable-clear-context" && + req.method === "POST" + ) { + json(res, { ok: false, reason: "not-supported-in-pi-extension" }); } else if (url.pathname === "/api/deny" && req.method === "POST") { if (decisionSettled) { json(res, { ok: true, duplicate: true }); diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 4f2fc2e1a..4c321364f 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -76,6 +76,7 @@ import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from './demoPlan'; import { DIFF_DEMO_PLAN_CONTENT } from './demoPlanDiffDemo'; import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode'; import { buildApprovalRequestBody, type ApprovalOverride } from './approvalBody'; +import type { ApproveExtraEntry } from '@plannotator/ui/components/ApproveDropdown'; const USE_DIFF_DEMO = import.meta.env.VITE_DIFF_DEMO === '1' || import.meta.env.VITE_DIFF_DEMO === 'true'; @@ -149,6 +150,9 @@ const App: React.FC = () => { }, []); const [isApiMode, setIsApiMode] = useState(false); const [origin, setOrigin] = useState(null); + const [pendingToolName, setPendingToolName] = useState(); + const [showClearContextBanner, setShowClearContextBanner] = useState(false); + const [pendingApprovalOverride, setPendingApprovalOverride] = useState({}); const [gitUser, setGitUser] = useState(); const [isWSL, setIsWSL] = useState(false); const [globalAttachments, setGlobalAttachments] = useState([]); @@ -778,6 +782,9 @@ const App: React.FC = () => { if (data.versionInfo) { setVersionInfo(data.versionInfo); } + if (data.toolName) { + setPendingToolName(data.toolName); + } if (data.origin) { setOrigin(data.origin); // For Claude Code, check if user needs to configure permission mode @@ -785,7 +792,8 @@ const App: React.FC = () => { setShowPermissionModeSetup(true); } // Load saved permission mode preference - setPermissionMode(getPermissionModeSettings().mode); + const savedPermissionMode = getPermissionModeSettings().mode; + setPermissionMode(savedPermissionMode); } if (data.isWSL) { setIsWSL(true); @@ -942,7 +950,7 @@ const App: React.FC = () => { }; // API mode handlers - const handleApprove = async () => { + const handleApprove = async (override: ApprovalOverride = {}) => { setIsSubmitting(true); try { const obsidianSettings = getObsidianSettings(); @@ -953,6 +961,19 @@ const App: React.FC = () => { ? await autoSavePromiseRef.current : autoSaveResultsRef.current; + const shouldUseNativeClear = + origin === 'claude-code' && + pendingToolName === 'ExitPlanMode' && + (override.deferToNativeForClear || (override.permissionMode ?? permissionMode) === 'bypassPermissionsClearReminder'); + if (shouldUseNativeClear) { + try { + const response = await fetch('/api/enable-clear-context', { method: 'POST' }); + if (response.ok) setShowClearContextBanner(false); + } catch { + setShowClearContextBanner(true); + } + } + const effectiveAgent = getEffectiveAgentName(getAgentSwitchSettings()); const body = buildApprovalRequestBody({ origin, @@ -960,14 +981,7 @@ const App: React.FC = () => { override, effectiveAgent, planSaveSettings, - }); - const effectiveAgent = getEffectiveAgentName(getAgentSwitchSettings()); - const body = buildApprovalRequestBody({ - origin, - permissionMode, - override, - effectiveAgent, - planSaveSettings, + toolName: pendingToolName, }); const effectiveVaultPath = getEffectiveVaultPath(obsidianSettings); @@ -1039,6 +1053,39 @@ const App: React.FC = () => { } }; + const approveWithClaudeCodeWarning = useCallback((override: ApprovalOverride = {}) => { + setPendingApprovalOverride(override); + if (origin === 'claude-code' && (allAnnotations.length > 0 || codeAnnotations.length > 0)) { + setShowClaudeCodeWarning(true); + return; + } + handleApprove(override); + }, [allAnnotations.length, codeAnnotations.length, origin, handleApprove]); + + const claudeCodeExtraEntries = useMemo(() => { + if (origin !== 'claude-code') return []; + if (pendingToolName === 'ExitPlanMode') { + return [{ + id: 'approve-bypass-native-clear', + label: 'Approve + Bypass + Clear Context (native)', + description: "Defers to Claude Code's native plan-accept dialog so it can clear context and set bypass permissions.", + onSelect: () => approveWithClaudeCodeWarning({ + permissionMode: 'bypassPermissions', + deferToNativeForClear: true, + }), + }]; + } + return [{ + id: 'approve-bypass-clear-reminder', + label: 'Approve + Bypass + /clear Reminder', + description: 'Requests bypass mode and reminds you to run /clear. Hooks cannot clear context directly outside plan acceptance.', + onSelect: () => approveWithClaudeCodeWarning({ + permissionMode: 'bypassPermissions', + clearContextNudge: true, + }), + }]; + }, [approveWithClaudeCodeWarning, origin, pendingToolName]); + // Annotate mode handler — sends feedback via /api/feedback const handleAnnotateFeedback = async () => { setIsSubmitting(true); @@ -1635,6 +1682,7 @@ const App: React.FC = () => { canShareCurrentSession={canShareCurrentSession} agentName={agentName} availableAgents={availableAgents} + approveExtraEntries={claudeCodeExtraEntries} showAnnotationsWarning={allAnnotations.length > 0 || codeAnnotations.length > 0} callbackConfig={callbackConfig} taterMode={taterMode} @@ -1670,7 +1718,6 @@ const App: React.FC = () => { bearConfigured={getBearSettings().enabled} octarineConfigured={isOctarineConfigured()} /> - {/* Linked document error banner */} {linkedDocHook.error && (
@@ -2015,7 +2062,9 @@ const App: React.FC = () => { onClose={() => setShowClaudeCodeWarning(false)} onConfirm={() => { setShowClaudeCodeWarning(false); - handleApprove(); + const override = pendingApprovalOverride; + setPendingApprovalOverride({}); + handleApprove(override); }} title="Annotations Won't Be Sent" message={<>{agentName} doesn't yet support feedback on approval. Your {allAnnotations.length + codeAnnotations.length} annotation{(allAnnotations.length + codeAnnotations.length) !== 1 ? 's' : ''} will be lost.} @@ -2133,6 +2182,66 @@ const App: React.FC = () => { {/* Update notification */} + {showClearContextBanner && ( +
+
+ Enable native clear-on-accept? +
+
+ Plannotator will write{' '} + showClearContextOnPlanAccept: true to your Claude + Code settings so Claude Code can clear planning context through + its native approval flow. +
+
+ + +
+
+ )} + {/* Image Annotator for pasted images */} { + test('maps bypass clear reminder mode to native Claude Code clear on ExitPlanMode', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'bypassPermissionsClearReminder', + toolName: 'ExitPlanMode', + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + deferToNativeForClear: true, + planSave: { enabled: true }, + }); + }); + + test('maps bypass clear reminder mode to reminder fallback outside ExitPlanMode', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'bypassPermissionsClearReminder', + toolName: 'OtherTool', + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + clearContextNudge: true, + planSave: { enabled: true }, + }); + }); + test('omits agentSwitch for Claude Code approvals', () => { expect(buildApprovalRequestBody({ origin: 'claude-code', @@ -19,6 +45,37 @@ describe('buildApprovalRequestBody', () => { }); }); + test('keeps bypass clear reminder override fallback fields for Claude Code approvals without ExitPlanMode', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'acceptEdits', + override: { + permissionMode: 'bypassPermissionsClearReminder', + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + clearContextNudge: true, + planSave: { enabled: true }, + }); + }); + + test('uses native clear for bypass clear reminder override when ExitPlanMode is known', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'acceptEdits', + toolName: 'ExitPlanMode', + override: { + permissionMode: 'bypassPermissionsClearReminder', + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + deferToNativeForClear: true, + planSave: { enabled: true }, + }); + }); + test('keeps agentSwitch for OpenCode approvals', () => { expect(buildApprovalRequestBody({ origin: 'opencode', @@ -30,4 +87,60 @@ describe('buildApprovalRequestBody', () => { planSave: { enabled: true }, }); }); + + test('ignores bypass clear reminder mode for OpenCode approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'opencode', + permissionMode: 'bypassPermissionsClearReminder', + effectiveAgent: 'build', + planSaveSettings: { enabled: true }, + })).toEqual({ + agentSwitch: 'build', + planSave: { enabled: true }, + }); + }); + + test('forwards deferToNativeForClear for Claude Code bypass approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'acceptEdits', + override: { + permissionMode: 'bypassPermissions', + deferToNativeForClear: true, + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + deferToNativeForClear: true, + planSave: { enabled: true }, + }); + }); + + test('does not forward deferToNativeForClear for OpenCode approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'opencode', + permissionMode: 'acceptEdits', + effectiveAgent: 'build', + override: { + deferToNativeForClear: true, + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + agentSwitch: 'build', + planSave: { enabled: true }, + }); + }); + + test('does not forward deferToNativeForClear for Gemini origin', () => { + expect(buildApprovalRequestBody({ + origin: 'gemini-cli', + permissionMode: 'acceptEdits', + override: { + deferToNativeForClear: true, + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + planSave: { enabled: true }, + }); + }); }); diff --git a/packages/editor/approvalBody.ts b/packages/editor/approvalBody.ts index 7ebb4b834..a5e1ba7b4 100644 --- a/packages/editor/approvalBody.ts +++ b/packages/editor/approvalBody.ts @@ -4,6 +4,7 @@ import type { PermissionMode } from '@plannotator/ui/utils/permissionMode'; export type ApprovalOverride = { permissionMode?: PermissionMode; clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }; export interface ApprovalRequestBody { @@ -15,6 +16,7 @@ export interface ApprovalRequestBody { planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string; clearContextNudge?: boolean; + deferToNativeForClear?: boolean; } export function buildApprovalRequestBody(options: { @@ -23,13 +25,21 @@ export function buildApprovalRequestBody(options: { override?: ApprovalOverride; effectiveAgent?: string; planSaveSettings: { enabled: boolean; customPath?: string | null }; + toolName?: string; }): ApprovalRequestBody { - const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings } = options; + const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings, toolName } = options; const body: ApprovalRequestBody = {}; if (origin === 'claude-code') { - body.permissionMode = override.permissionMode ?? permissionMode; - if (override.clearContextNudge) { + const effectivePermissionMode = override.permissionMode ?? permissionMode; + const wantsClearContext = effectivePermissionMode === 'bypassPermissionsClearReminder'; + const useNativeClear = override.deferToNativeForClear || (wantsClearContext && toolName === 'ExitPlanMode'); + + body.permissionMode = wantsClearContext ? 'bypassPermissions' : effectivePermissionMode; + + if (useNativeClear) { + body.deferToNativeForClear = true; + } else if (override.clearContextNudge || wantsClearContext) { body.clearContextNudge = true; } } diff --git a/packages/server/index.ts b/packages/server/index.ts index c71677346..7a354bd0f 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -13,7 +13,10 @@ */ import type { Origin } from "@plannotator/shared/agents"; -import { resolve } from "path"; +import { randomBytes } from "crypto"; +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs"; +import { homedir } from "os"; +import { dirname, join, resolve } from "path"; import { isRemoteSession, getServerHostname, getServerPort } from "./remote"; import { openEditorDiff } from "./ide"; import { @@ -71,6 +74,8 @@ export interface ServerOptions { htmlContent: string; /** Current permission mode to preserve (Claude Code only) */ permissionMode?: string; + /** Tool name from the permission request, e.g. ExitPlanMode */ + toolName?: string; /** Whether URL sharing is enabled (default: true) */ sharingEnabled?: boolean; /** Custom base URL for share links (default: https://share.plannotator.ai) */ @@ -102,6 +107,7 @@ export interface ServerResult { agentSwitch?: string; permissionMode?: string; clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }>; /** Wait for user to close (archive mode only) */ waitForDone?: () => Promise; @@ -113,6 +119,64 @@ export interface ServerResult { const MAX_RETRIES = 5; const RETRY_DELAY_MS = 500; +const CLEAR_CONTEXT_SETTING_KEY = "showClearContextOnPlanAccept"; + +function clearContextConsentPath(): string { + return join(homedir(), ".plannotator", "consent", "clear-context-setting.json"); +} + +function clearContextSettingsPath(): string { + return join(homedir(), ".claude", "settings.json"); +} + +function readClearContextSettings(): Record | null { + if (!existsSync(clearContextSettingsPath())) return {}; + try { + const parsed = JSON.parse(readFileSync(clearContextSettingsPath(), "utf8")); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed + : {}; + } catch { + return null; + } +} + +function hasClearContextConsent(): boolean { + try { + if (!existsSync(clearContextConsentPath())) return false; + const parsed = JSON.parse(readFileSync(clearContextConsentPath(), "utf8")); + return parsed?.consented === true; + } catch { + return false; + } +} + +function writeJsonAtomic(path: string, data: Record): 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); +} + +function recordClearContextConsent(): void { + mkdirSync(join(homedir(), ".plannotator", "consent"), { recursive: true }); + writeJsonAtomic(clearContextConsentPath(), { + consented: true, + recordedAt: new Date().toISOString(), + }); +} + +function enableClearContextSetting(): "ok" | "malformed" { + const settings = readClearContextSettings(); + if (settings === null) return "malformed"; + settings[CLEAR_CONTEXT_SETTING_KEY] = true; + mkdirSync(join(homedir(), ".claude"), { recursive: true }); + recordClearContextConsent(); + writeJsonAtomic(clearContextSettingsPath(), settings); + return "ok"; +} /** * Start the Plannotator server @@ -126,7 +190,7 @@ const RETRY_DELAY_MS = 500; export async function startPlannotatorServer( options: ServerOptions ): Promise { - const { plan, origin, htmlContent, permissionMode, sharingEnabled = true, shareBaseUrl, pasteApiUrl, onReady, mode, customPlanPath } = options; + const { plan, origin, htmlContent, permissionMode, toolName, sharingEnabled = true, shareBaseUrl, pasteApiUrl, onReady, mode, customPlanPath } = options; const isRemote = isRemoteSession(); const configuredPort = getServerPort(); @@ -174,6 +238,7 @@ export async function startPlannotatorServer( agentSwitch?: string; permissionMode?: string; clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }) => void; let decisionPromise: Promise<{ approved: boolean; @@ -182,6 +247,7 @@ export async function startPlannotatorServer( agentSwitch?: string; permissionMode?: string; clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }>; if (mode !== "archive") { @@ -287,7 +353,7 @@ export async function startPlannotatorServer( serverConfig: getServerConfig(gitUser), }); } - return Response.json({ plan, origin, permissionMode, sharingEnabled, shareBaseUrl, pasteApiUrl, repoInfo, previousPlan, versionInfo, projectRoot: process.cwd(), isWSL: wslFlag, serverConfig: getServerConfig(gitUser) }); + return Response.json({ plan, origin, permissionMode, toolName, sharingEnabled, shareBaseUrl, pasteApiUrl, repoInfo, previousPlan, versionInfo, projectRoot: process.cwd(), isWSL: wslFlag, serverConfig: getServerConfig(gitUser) }); } // API: Serve a linked markdown document @@ -459,6 +525,7 @@ export async function startPlannotatorServer( let agentSwitch: string | undefined; let requestedPermissionMode: string | undefined; let clearContextNudge: boolean | undefined; + let deferToNativeForClear: boolean | undefined; let planSaveEnabled = true; // default to enabled for backwards compat let planSaveCustomPath: string | undefined; try { @@ -471,6 +538,7 @@ export async function startPlannotatorServer( planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string; clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }; // Capture feedback if provided (for "approve with notes") @@ -492,6 +560,9 @@ export async function startPlannotatorServer( if (body.clearContextNudge === true) { clearContextNudge = true; } + if (body.deferToNativeForClear === true) { + deferToNativeForClear = true; + } // Capture plan save settings if (body.planSave !== undefined) { @@ -538,10 +609,35 @@ export async function startPlannotatorServer( // Use permission mode from client request if provided, otherwise fall back to hook input const effectivePermissionMode = requestedPermissionMode || permissionMode; - resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode, clearContextNudge }); + resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode, clearContextNudge, deferToNativeForClear }); return Response.json({ ok: true, savedPath }); } + if (url.pathname === "/api/settings-status" && req.method === "GET") { + if (origin !== "claude-code" || toolName !== "ExitPlanMode") { + return Response.json({ error: "Unsupported clear-context flow" }, { status: 404 }); + } + const settings = readClearContextSettings(); + return Response.json({ + settingEnabled: settings?.[CLEAR_CONTEXT_SETTING_KEY] === true, + consentGiven: hasClearContextConsent(), + }); + } + + if (url.pathname === "/api/enable-clear-context" && req.method === "POST") { + if (origin !== "claude-code" || toolName !== "ExitPlanMode") { + return Response.json({ error: "Unsupported clear-context flow" }, { status: 404 }); + } + const result = enableClearContextSetting(); + if (result === "malformed") { + return Response.json( + { ok: false, error: "Malformed Claude Code settings JSON" }, + { status: 400 }, + ); + } + return Response.json({ ok: true }); + } + // API: Deny with feedback if (url.pathname === "/api/deny" && req.method === "POST") { let feedback = "Plan rejected by user"; diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index e2982315b..48a4bdb6f 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -80,6 +80,7 @@ interface SettingsProps { origin?: Origin | null; mode?: 'plan' | 'review'; onUIPreferencesChange?: (prefs: UIPreferences) => void; + onPermissionModeChange?: (mode: PermissionMode) => void; /** Externally controlled open state (for mobile menu integration) */ externalOpen?: boolean; onExternalClose?: () => void; @@ -599,7 +600,7 @@ const CommentsTab: React.FC = () => { ); }; -export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser }) => { +export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, onPermissionModeChange, externalOpen, onExternalClose, aiProviders = [], gitUser }) => { const [showDialog, setShowDialog] = useState(false); const [themePreview, setThemePreview] = useState(false); @@ -803,6 +804,7 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange const handlePermissionModeChange = (mode: PermissionMode) => { setPermissionMode(mode); savePermissionModeSettings(mode); + onPermissionModeChange?.(mode); }; const handleDefaultNotesAppChange = (app: DefaultNotesApp) => { diff --git a/packages/ui/utils/permissionMode.ts b/packages/ui/utils/permissionMode.ts index 809995377..ed72dc4f1 100644 --- a/packages/ui/utils/permissionMode.ts +++ b/packages/ui/utils/permissionMode.ts @@ -6,6 +6,7 @@ * * Available modes: * - bypassPermissions: Auto-approve all tool calls + * - bypassPermissionsClearReminder: Persisted UI mode that uses native clear-on-accept for Claude Code plan approvals and a /clear reminder fallback otherwise * - acceptEdits: Auto-approve file edits only * - default: Manually approve each tool call */ @@ -15,7 +16,7 @@ import { storage } from './storage'; const STORAGE_KEY_MODE = 'plannotator-permission-mode'; const STORAGE_KEY_CONFIGURED = 'plannotator-permission-mode-configured'; -export type PermissionMode = 'bypassPermissions' | 'acceptEdits' | 'default'; +export type PermissionMode = 'bypassPermissions' | 'bypassPermissionsClearReminder' | 'acceptEdits' | 'default'; export interface PermissionModeSettings { mode: PermissionMode; @@ -33,6 +34,11 @@ export const PERMISSION_MODE_OPTIONS: { value: PermissionMode; label: string; de label: 'Bypass Permissions', description: 'Auto-approve all tool calls (equivalent to --dangerously-skip-permissions)', }, + { + value: 'bypassPermissionsClearReminder', + label: 'Bypass + Clear Context', + description: 'For Claude Code plan approvals, defer to the native clear-context flow and bypass permissions; otherwise emit a /clear reminder.', + }, { value: 'default', label: 'Manual Approval', @@ -42,15 +48,19 @@ export const PERMISSION_MODE_OPTIONS: { value: PermissionMode; label: string; de const DEFAULT_MODE: PermissionMode = 'acceptEdits'; +function isPermissionMode(value: string | null): value is PermissionMode { + return PERMISSION_MODE_OPTIONS.some((option) => option.value === value); +} + /** * Get current permission mode settings from storage */ export function getPermissionModeSettings(): PermissionModeSettings { - const mode = storage.getItem(STORAGE_KEY_MODE) as PermissionMode | null; + const mode = storage.getItem(STORAGE_KEY_MODE); const configured = storage.getItem(STORAGE_KEY_CONFIGURED) === 'true'; return { - mode: mode || DEFAULT_MODE, + mode: isPermissionMode(mode) ? mode : DEFAULT_MODE, configured, }; } From 02117770ef5bf3e8d54931a40d34b0e88e318769 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Mon, 11 May 2026 10:49:46 +0700 Subject: [PATCH 04/34] feat(hook): auto-confirm native plan-accept dialog via keystroke injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When user clicks "Approve + Bypass + Clear Context (native)" in plannotator UI, the hook now spawns a detached background process before exiting 0 (native passthrough). The process injects "1\n" into the CC terminal after a 600ms delay, auto-selecting "Yes, clear context and bypass permissions" without a manual keypress. Detection priority: 1. tmux ($TMUX_PANE) → tmux send-keys (no accessibility permissions needed) 2. macOS → osascript iterating {warp, iTerm2, Terminal} 3. Linux/Windows without tmux → no-op (falls back to manual press) spawnKeystrokeInjector() detaches via Bun.spawn + .unref() — parent exits immediately, child fires after delay. 7 unit tests added (265/265 pass). Build clean. Smoke-tested on WarpTerminal. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/hook/server/index.ts | 498 +++++++++++++++------ apps/hook/server/keystrokeInjector.test.ts | 118 +++++ apps/hook/server/keystrokeInjector.ts | 78 ++++ 3 files changed, 568 insertions(+), 126 deletions(-) create mode 100644 apps/hook/server/keystrokeInjector.test.ts create mode 100644 apps/hook/server/keystrokeInjector.ts diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 1bbdb6c7f..90ceba02b 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -52,10 +52,7 @@ * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) */ -import { - startPlannotatorServer, - handleServerReady, -} from "@plannotator/server"; +import { startPlannotatorServer, handleServerReady } from "@plannotator/server"; import { startReviewServer, handleReviewServerReady, @@ -69,12 +66,36 @@ import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator import { parseReviewArgs } from "@plannotator/shared/review-args"; import { stripAtPrefix, resolveAtReference } from "@plannotator/shared/at-reference"; import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown"; -import { urlToMarkdown, isConvertedSource } from "@plannotator/shared/url-to-markdown"; -import { fetchRef, createWorktree, removeWorktree, ensureObjectAvailable } from "@plannotator/shared/worktree"; -import { createWorktreePool, type WorktreePool } from "@plannotator/shared/worktree-pool"; -import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getCliInstallUrl, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr"; +import { + urlToMarkdown, + isConvertedSource, +} from "@plannotator/shared/url-to-markdown"; +import { + fetchRef, + createWorktree, + removeWorktree, + ensureObjectAvailable, +} from "@plannotator/shared/worktree"; +import { + createWorktreePool, + type WorktreePool, +} from "@plannotator/shared/worktree-pool"; +import { + parsePRUrl, + checkPRAuth, + fetchPR, + getCliName, + getCliInstallUrl, + getMRLabel, + getMRNumberLabel, + getDisplayRepo, +} from "@plannotator/server/pr"; import { writeRemoteShareLink } from "@plannotator/server/share-url"; -import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannotator/shared/resolve-file"; +import { + resolveMarkdownFile, + resolveUserPath, + hasMarkdownFiles, +} from "@plannotator/shared/resolve-file"; import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; import { statSync, rmSync, realpathSync, existsSync } from "fs"; import { parseRemoteUrl } from "@plannotator/shared/repo"; @@ -85,7 +106,11 @@ import { getPlanToolName, buildPlanFileRule, } from "@plannotator/shared/prompts"; -import { registerSession, unregisterSession, listSessions } from "@plannotator/server/sessions"; +import { + registerSession, + unregisterSession, + listSessions, +} from "@plannotator/server/sessions"; import { openBrowser } from "@plannotator/server/browser"; import { detectProjectName } from "@plannotator/server/project"; import { hostnameOrFallback } from "@plannotator/shared/project"; @@ -100,8 +125,16 @@ import { resolveSessionLogByCwdScan, type RenderedMessage, } from "./session-log"; -import { findCodexRolloutByThreadId, getLastCodexMessage, getLatestCodexPlan } from "./codex-session"; -import { findCopilotPlanContent, findCopilotSessionForCwd, getLastCopilotMessage } from "./copilot-session"; +import { + findCodexRolloutByThreadId, + getLastCodexMessage, + getLatestCodexPlan, +} from "./codex-session"; +import { + findCopilotPlanContent, + findCopilotSessionForCwd, + getLastCopilotMessage, +} from "./copilot-session"; import { formatInteractiveNoArgClarification, formatTopLevelHelp, @@ -111,6 +144,7 @@ import { isVersionInvocation, } from "./cli"; import { ensureClearContextSettingEnabled } from "./clearContextSetting"; +import { spawnKeystrokeInjector } from "./keystrokeInjector"; import path from "path"; import { tmpdir } from "os"; @@ -184,7 +218,9 @@ function emitAnnotateOutcome(result: { if (hookFlag) { if (result.approved || result.exit) return; if (result.feedback) { - console.log(JSON.stringify({ decision: "block", reason: result.feedback })); + console.log( + JSON.stringify({ decision: "block", reason: result.feedback }), + ); } return; } @@ -194,7 +230,12 @@ function emitAnnotateOutcome(result: { } else if (result.exit) { console.log(JSON.stringify({ decision: "dismissed" })); } else { - console.log(JSON.stringify({ decision: "annotated", feedback: result.feedback || "" })); + console.log( + JSON.stringify({ + decision: "annotated", + feedback: result.feedback || "", + }), + ); } return; } @@ -246,12 +287,17 @@ const pasteApiUrl = process.env.PLANNOTATOR_PASTE_URL || undefined; // packages/shared/agents.ts (see header comment there). const originOverride = process.env.PLANNOTATOR_ORIGIN as Origin | undefined; const detectedOrigin: Origin = - (originOverride && originOverride in AGENT_CONFIG) ? originOverride : - process.env.CODEX_THREAD_ID ? "codex" : - process.env.COPILOT_CLI ? "copilot-cli" : - process.env.OPENCODE ? "opencode" : - process.env.GEMINI_CLI ? "gemini-cli" : - "claude-code"; + originOverride && originOverride in AGENT_CONFIG + ? originOverride + : process.env.CODEX_THREAD_ID + ? "codex" + : process.env.COPILOT_CLI + ? "copilot-cli" + : process.env.OPENCODE + ? "opencode" + : process.env.GEMINI_CLI + ? "gemini-cli" + : "claude-code"; if (args[0] === "sessions") { // ============================================ @@ -261,7 +307,9 @@ if (args[0] === "sessions") { if (args.includes("--clean")) { // Force cleanup: list sessions (which auto-removes stale entries) const sessions = listSessions(); - console.error(`Cleaned up stale sessions. ${sessions.length} active session(s) remain.`); + console.error( + `Cleaned up stale sessions. ${sessions.length} active session(s) remain.`, + ); process.exit(0); } @@ -279,7 +327,9 @@ if (args[0] === "sessions") { const n = nArg ? parseInt(nArg, 10) : 1; const session = sessions[n - 1]; if (!session) { - console.error(`Session #${n} not found. ${sessions.length} active session(s).`); + console.error( + `Session #${n} not found. ${sessions.length} active session(s).`, + ); process.exit(1); } await openBrowser(session.url); @@ -291,13 +341,17 @@ if (args[0] === "sessions") { console.error("Active Plannotator sessions:\n"); for (let i = 0; i < sessions.length; i++) { const s = sessions[i]; - const age = Math.round((Date.now() - new Date(s.startedAt).getTime()) / 60000); - const ageStr = age < 60 ? `${age}m` : `${Math.floor(age / 60)}h ${age % 60}m`; - console.error(` #${i + 1} ${s.mode.padEnd(9)} ${s.project.padEnd(20)} ${s.url.padEnd(28)} ${ageStr} ago`); + const age = Math.round( + (Date.now() - new Date(s.startedAt).getTime()) / 60000, + ); + const ageStr = + age < 60 ? `${age}m` : `${Math.floor(age / 60)}h ${age % 60}m`; + console.error( + ` #${i + 1} ${s.mode.padEnd(9)} ${s.project.padEnd(20)} ${s.url.padEnd(28)} ${ageStr} ago`, + ); } console.error(`\nReopen with: plannotator sessions --open [N]`); process.exit(0); - } else if (args[0] === "review") { // ============================================ // CODE REVIEW MODE @@ -325,7 +379,9 @@ if (args[0] === "sessions") { console.error(`Invalid PR/MR URL: ${urlArg}`); console.error("Supported formats:"); console.error(" GitHub: https://github.com/owner/repo/pull/123"); - console.error(" GitLab: https://gitlab.com/group/project/-/merge_requests/42"); + console.error( + " GitLab: https://gitlab.com/group/project/-/merge_requests/42", + ); process.exit(1); } @@ -337,7 +393,9 @@ if (args[0] === "sessions") { } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes("not found") || msg.includes("ENOENT")) { - console.error(`${cliName === "gh" ? "GitHub" : "GitLab"} CLI (${cliName}) is not installed.`); + console.error( + `${cliName === "gh" ? "GitHub" : "GitLab"} CLI (${cliName}) is not installed.`, + ); console.error(`Install it from ${cliUrl}`); } else { console.error(msg); @@ -345,7 +403,9 @@ if (args[0] === "sessions") { process.exit(1); } - console.error(`Fetching ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)} from ${getDisplayRepo(prRef)}...`); + console.error( + `Fetching ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)} from ${getDisplayRepo(prRef)}...`, + ); try { const pr = await fetchPR(prRef); rawPatch = pr.rawPatch; @@ -363,42 +423,68 @@ if (args[0] === "sessions") { let sessionDir: string | undefined; try { const repoDir = process.cwd(); - const identifier = prMetadata.platform === "github" - ? `${prMetadata.owner}-${prMetadata.repo}-${prMetadata.number}` - : `${prMetadata.projectPath.replace(/\//g, "-")}-${prMetadata.iid}`; + const identifier = + prMetadata.platform === "github" + ? `${prMetadata.owner}-${prMetadata.repo}-${prMetadata.number}` + : `${prMetadata.projectPath.replace(/\//g, "-")}-${prMetadata.iid}`; const suffix = Math.random().toString(36).slice(2, 8); // Resolve tmpdir to its real path — on macOS, tmpdir() returns /var/folders/... // but processes report /private/var/folders/... which breaks path stripping. - sessionDir = path.join(realpathSync(tmpdir()), `plannotator-pr-${identifier}-${suffix}`); - const prNumber = prMetadata.platform === "github" ? prMetadata.number : prMetadata.iid; + sessionDir = path.join( + realpathSync(tmpdir()), + `plannotator-pr-${identifier}-${suffix}`, + ); + const prNumber = + prMetadata.platform === "github" ? prMetadata.number : prMetadata.iid; localPath = path.join(sessionDir, "pool", `pr-${prNumber}`); - const fetchRefStr = prMetadata.platform === "github" - ? `refs/pull/${prMetadata.number}/head` - : `refs/merge-requests/${prMetadata.iid}/head`; + const fetchRefStr = + prMetadata.platform === "github" + ? `refs/pull/${prMetadata.number}/head` + : `refs/merge-requests/${prMetadata.iid}/head`; // Validate inputs from platform API to prevent git flag/path injection - if (prMetadata.baseBranch.includes('..') || prMetadata.baseBranch.startsWith('-')) throw new Error(`Invalid base branch: ${prMetadata.baseBranch}`); - if (!/^[0-9a-f]{40,64}$/i.test(prMetadata.baseSha)) throw new Error(`Invalid base SHA: ${prMetadata.baseSha}`); + if ( + prMetadata.baseBranch.includes("..") || + prMetadata.baseBranch.startsWith("-") + ) + throw new Error(`Invalid base branch: ${prMetadata.baseBranch}`); + if (!/^[0-9a-f]{40,64}$/i.test(prMetadata.baseSha)) + throw new Error(`Invalid base SHA: ${prMetadata.baseSha}`); // Detect same-repo vs cross-repo (must match both owner/repo AND host) let isSameRepo = false; try { - const remoteResult = await gitRuntime.runGit(["remote", "get-url", "origin"]); + const remoteResult = await gitRuntime.runGit([ + "remote", + "get-url", + "origin", + ]); if (remoteResult.exitCode === 0) { const remoteUrl = remoteResult.stdout.trim(); const currentRepo = parseRemoteUrl(remoteUrl); - const prRepo = prMetadata.platform === "github" - ? `${prMetadata.owner}/${prMetadata.repo}` - : prMetadata.projectPath; - const repoMatches = !!currentRepo && currentRepo.toLowerCase() === prRepo.toLowerCase(); + const prRepo = + prMetadata.platform === "github" + ? `${prMetadata.owner}/${prMetadata.repo}` + : prMetadata.projectPath; + const repoMatches = + !!currentRepo && + currentRepo.toLowerCase() === prRepo.toLowerCase(); // Extract host from remote URL to avoid cross-instance false positives (GHE) const sshHost = remoteUrl.match(/^[^@]+@([^:]+):/)?.[1]; - const httpsHost = (() => { try { return new URL(remoteUrl).hostname; } catch { return null; } })(); + const httpsHost = (() => { + try { + return new URL(remoteUrl).hostname; + } catch { + return null; + } + })(); const remoteHost = (sshHost || httpsHost || "").toLowerCase(); const prHost = prMetadata.host.toLowerCase(); isSameRepo = repoMatches && remoteHost === prHost; } - } catch { /* not in a git repo — cross-repo path */ } + } catch { + /* not in a git repo — cross-repo path */ + } if (isSameRepo) { // ── Same-repo: fast worktree path ── @@ -408,7 +494,9 @@ if (args[0] === "sessions") { // Both MUST happen before the PR head fetch since FETCH_HEAD is what // createWorktree uses — the PR head fetch must be last. await fetchRef(gitRuntime, prMetadata.baseBranch, { cwd: repoDir }); - await ensureObjectAvailable(gitRuntime, prMetadata.baseSha, { cwd: repoDir }); + await ensureObjectAvailable(gitRuntime, prMetadata.baseSha, { + cwd: repoDir, + }); // Fetch PR head LAST — sets FETCH_HEAD to the PR tip for createWorktree. await fetchRef(gitRuntime, fetchRefStr, { cwd: repoDir }); @@ -421,41 +509,65 @@ if (args[0] === "sessions") { worktreeCleanup = async () => { if (worktreePool) await worktreePool.cleanup(gitRuntime); - try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} + try { + rmSync(sessionDir, { recursive: true, force: true }); + } catch {} }; process.once("exit", () => { // Best-effort sync cleanup: remove each pool worktree from git, then rm session dir try { for (const entry of worktreePool?.entries() ?? []) { - Bun.spawnSync(["git", "worktree", "remove", "--force", entry.path], { cwd: repoDir }); + Bun.spawnSync( + ["git", "worktree", "remove", "--force", entry.path], + { cwd: repoDir }, + ); } } catch {} - try { Bun.spawnSync(["rm", "-rf", sessionDir]); } catch {} + try { + Bun.spawnSync(["rm", "-rf", sessionDir]); + } catch {} }); } else { // ── Cross-repo: shallow clone + fetch PR head ── - const prRepo = prMetadata.platform === "github" - ? `${prMetadata.owner}/${prMetadata.repo}` - : prMetadata.projectPath; + const prRepo = + prMetadata.platform === "github" + ? `${prMetadata.owner}/${prMetadata.repo}` + : prMetadata.projectPath; // Validate repo identifier to prevent flag injection via crafted URLs - if (/^-/.test(prRepo)) throw new Error(`Invalid repository identifier: ${prRepo}`); + if (/^-/.test(prRepo)) + throw new Error(`Invalid repository identifier: ${prRepo}`); const cli = prMetadata.platform === "github" ? "gh" : "glab"; const host = prMetadata.host; // gh/glab repo clone doesn't accept --hostname; set GH_HOST/GITLAB_HOST env instead const isDefaultHost = host === "github.com" || host === "gitlab.com"; - const cloneEnv = isDefaultHost ? undefined : { - ...process.env, - ...(prMetadata.platform === "github" ? { GH_HOST: host } : { GITLAB_HOST: host }), - }; + const cloneEnv = isDefaultHost + ? undefined + : { + ...process.env, + ...(prMetadata.platform === "github" + ? { GH_HOST: host } + : { GITLAB_HOST: host }), + }; // Step 1: Fast skeleton clone (no checkout, depth 1 — minimal data transfer) console.error(`Cloning ${prRepo} (shallow)...`); const cloneResult = Bun.spawnSync( - [cli, "repo", "clone", prRepo, localPath, "--", "--depth=1", "--no-checkout"], + [ + cli, + "repo", + "clone", + prRepo, + localPath, + "--", + "--depth=1", + "--no-checkout", + ], { stderr: "pipe", env: cloneEnv }, ); if (cloneResult.exitCode !== 0) { - throw new Error(`${cli} repo clone failed: ${new TextDecoder().decode(cloneResult.stderr).trim()}`); + throw new Error( + `${cli} repo clone failed: ${new TextDecoder().decode(cloneResult.stderr).trim()}`, + ); } // Step 2: Fetch only the PR head ref (targeted, much faster than full fetch) @@ -464,23 +576,54 @@ if (args[0] === "sessions") { ["git", "fetch", "--depth=200", "origin", fetchRefStr], { cwd: localPath, stderr: "pipe" }, ); - if (fetchResult.exitCode !== 0) throw new Error(`Failed to fetch PR head ref: ${new TextDecoder().decode(fetchResult.stderr).trim()}`); + if (fetchResult.exitCode !== 0) + throw new Error( + `Failed to fetch PR head ref: ${new TextDecoder().decode(fetchResult.stderr).trim()}`, + ); // Step 3: Checkout PR head (critical — if this fails, worktree is empty) - const checkoutResult = Bun.spawnSync(["git", "checkout", "FETCH_HEAD"], { cwd: localPath, stderr: "pipe" }); + const checkoutResult = Bun.spawnSync( + ["git", "checkout", "FETCH_HEAD"], + { cwd: localPath, stderr: "pipe" }, + ); if (checkoutResult.exitCode !== 0) { - throw new Error(`git checkout FETCH_HEAD failed: ${new TextDecoder().decode(checkoutResult.stderr).trim()}`); + throw new Error( + `git checkout FETCH_HEAD failed: ${new TextDecoder().decode(checkoutResult.stderr).trim()}`, + ); } // Best-effort: create base refs so `git diff main...HEAD` and `git diff origin/main...HEAD` work - const baseFetch = Bun.spawnSync(["git", "fetch", "--depth=200", "origin", prMetadata.baseSha], { cwd: localPath, stderr: "pipe" }); - if (baseFetch.exitCode !== 0) console.error("Warning: failed to fetch baseSha, agent diffs may be inaccurate"); - Bun.spawnSync(["git", "branch", "--", prMetadata.baseBranch, prMetadata.baseSha], { cwd: localPath, stderr: "pipe" }); - Bun.spawnSync(["git", "update-ref", `refs/remotes/origin/${prMetadata.baseBranch}`, prMetadata.baseSha], { cwd: localPath, stderr: "pipe" }); + const baseFetch = Bun.spawnSync( + ["git", "fetch", "--depth=200", "origin", prMetadata.baseSha], + { cwd: localPath, stderr: "pipe" }, + ); + if (baseFetch.exitCode !== 0) + console.error( + "Warning: failed to fetch baseSha, agent diffs may be inaccurate", + ); + Bun.spawnSync( + ["git", "branch", "--", prMetadata.baseBranch, prMetadata.baseSha], + { cwd: localPath, stderr: "pipe" }, + ); + Bun.spawnSync( + [ + "git", + "update-ref", + `refs/remotes/origin/${prMetadata.baseBranch}`, + prMetadata.baseSha, + ], + { cwd: localPath, stderr: "pipe" }, + ); - worktreeCleanup = () => { try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} }; + worktreeCleanup = () => { + try { + rmSync(sessionDir, { recursive: true, force: true }); + } catch {} + }; process.once("exit", () => { - try { Bun.spawnSync(["rm", "-rf", sessionDir]); } catch {} + try { + Bun.spawnSync(["rm", "-rf", sessionDir]); + } catch {} }); } @@ -491,14 +634,22 @@ if (args[0] === "sessions") { // Create worktree pool with the initial PR as the first entry worktreePool = createWorktreePool( { sessionDir, repoDir, isSameRepo }, - { path: localPath, prUrl: prMetadata.url, number: prNumber, ready: true }, + { + path: localPath, + prUrl: prMetadata.url, + number: prNumber, + ready: true, + }, ); console.error(`Local checkout ready at ${localPath}`); } catch (err) { console.error(`Warning: --local failed, falling back to remote diff`); console.error(err instanceof Error ? err.message : String(err)); - if (sessionDir) try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} + if (sessionDir) + try { + rmSync(sessionDir, { recursive: true, force: true }); + } catch {} agentCwd = undefined; worktreePool = undefined; worktreeCleanup = undefined; @@ -540,7 +691,12 @@ if (args[0] === "sessions") { handleReviewServerReady(url, isRemote, port); if (isRemote && sharingEnabled && rawPatch) { - await writeRemoteShareLink(rawPatch, shareBaseUrl, "review changes", "diff only").catch(() => {}); + await writeRemoteShareLink( + rawPatch, + shareBaseUrl, + "review changes", + "diff only", + ).catch(() => {}); } }, }); @@ -552,7 +708,9 @@ if (args[0] === "sessions") { mode: "review", project: reviewProject, startedAt: new Date().toISOString(), - label: isPRMode ? `${getMRLabel(prMetadata!).toLowerCase()}-review-${getDisplayRepo(prMetadata!)}${getMRNumberLabel(prMetadata!)}` : `review-${reviewProject}`, + label: isPRMode + ? `${getMRLabel(prMetadata!).toLowerCase()}-review-${getDisplayRepo(prMetadata!)}${getMRNumberLabel(prMetadata!)}` + : `review-${reviewProject}`, }); // Wait for user feedback @@ -576,7 +734,6 @@ if (args[0] === "sessions") { } } process.exit(0); - } else if (args[0] === "annotate") { // ============================================ // ANNOTATE MODE @@ -584,7 +741,9 @@ if (args[0] === "sessions") { const rawFilePath = args[1]; if (!rawFilePath) { - console.error("Usage: plannotator annotate [--no-jina] [--gate] [--json] [--hook]"); + console.error( + "Usage: plannotator annotate [--no-jina] [--gate] [--json] [--hook]", + ); process.exit(1); } @@ -614,31 +773,46 @@ if (args[0] === "sessions") { if (isUrl) { const useJina = resolveUseJina(cliNoJina, loadConfig()); - console.error(`Fetching: ${filePath}${useJina ? " (via Jina Reader)" : " (via fetch+Turndown)"}`); + console.error( + `Fetching: ${filePath}${useJina ? " (via Jina Reader)" : " (via fetch+Turndown)"}`, + ); try { const result = await urlToMarkdown(filePath, { useJina }); markdown = result.markdown; sourceConverted = isConvertedSource(result.source); if (process.env.PLANNOTATOR_DEBUG) { - console.error(`[DEBUG] Fetched via ${result.source} (${markdown.length} chars)`); + console.error( + `[DEBUG] Fetched via ${result.source} (${markdown.length} chars)`, + ); } } catch (err) { - console.error(`Failed to fetch URL: ${err instanceof Error ? err.message : String(err)}`); + console.error( + `Failed to fetch URL: ${err instanceof Error ? err.message : String(err)}`, + ); process.exit(1); } absolutePath = filePath; // Use URL as the "path" for display - sourceInfo = filePath; // Full URL for source attribution + sourceInfo = filePath; // Full URL for source attribution } else { // Folder check with literal-@ fallback for scoped-package-style names. const folderCandidate = resolveAtReference(rawFilePath, (c) => { - try { return statSync(resolveUserPath(c, projectRoot)).isDirectory(); } - catch { return false; } + try { + return statSync(resolveUserPath(c, projectRoot)).isDirectory(); + } catch { + return false; + } }); if (folderCandidate !== null) { const resolvedArg = resolveUserPath(folderCandidate, projectRoot); // Folder annotation mode (markdown + HTML files) - if (!hasMarkdownFiles(resolvedArg, FILE_BROWSER_EXCLUDED, /\.(mdx?|html?)$/i)) { + if ( + !hasMarkdownFiles( + resolvedArg, + FILE_BROWSER_EXCLUDED, + /\.(mdx?|html?)$/i, + ) + ) { console.error(`No markdown or HTML files found in ${resolvedArg}`); process.exit(1); } @@ -658,7 +832,9 @@ if (args[0] === "sessions") { const resolvedArg = resolveUserPath(htmlCandidate, projectRoot); const htmlFile = Bun.file(resolvedArg); if (htmlFile.size > 10 * 1024 * 1024) { - console.error(`File too large (${Math.round(htmlFile.size / 1024 / 1024)}MB, max 10MB): ${resolvedArg}`); + console.error( + `File too large (${Math.round(htmlFile.size / 1024 / 1024)}MB, max 10MB): ${resolvedArg}`, + ); process.exit(1); } const html = await htmlFile.text(); @@ -681,7 +857,9 @@ if (args[0] === "sessions") { } if (resolved.kind === "ambiguous") { - console.error(`Ambiguous filename "${resolved.input}" — found ${resolved.matches.length} matches:`); + console.error( + `Ambiguous filename "${resolved.input}" — found ${resolved.matches.length} matches:`, + ); for (const match of resolved.matches) { console.error(` ${match}`); } @@ -721,7 +899,12 @@ if (args[0] === "sessions") { handleAnnotateServerReady(url, isRemote, port); if (isRemote && sharingEnabled && markdown) { - await writeRemoteShareLink(markdown, shareBaseUrl, "annotate", "document only").catch(() => {}); + await writeRemoteShareLink( + markdown, + shareBaseUrl, + "annotate", + "document only", + ).catch(() => {}); } }, }); @@ -750,7 +933,6 @@ if (args[0] === "sessions") { // Output feedback (captured by slash command) emitAnnotateOutcome(result); process.exit(0); - } else if (args[0] === "annotate-last" || args[0] === "last") { // ============================================ // ANNOTATE LAST MESSAGE MODE @@ -774,7 +956,11 @@ if (args[0] === "sessions") { } const msg = getLastCodexMessage(rolloutPath); if (msg) { - lastMessage = { messageId: codexThreadId, text: msg.text, lineNumbers: [] }; + lastMessage = { + messageId: codexThreadId, + text: msg.text, + lineNumbers: [], + }; } } } else { @@ -804,7 +990,9 @@ if (args[0] === "sessions") { if (lastMessage) return; const paths = getPaths(); if (process.env.PLANNOTATOR_DEBUG) { - console.error(`[DEBUG] ${label}: ${paths.length ? paths.join(", ") : "(none)"}`); + console.error( + `[DEBUG] ${label}: ${paths.length ? paths.join(", ") : "(none)"}`, + ); } for (const logPath of paths) { lastMessage = getLastRenderedMessage(logPath); @@ -814,17 +1002,25 @@ if (args[0] === "sessions") { // 1. Walk ancestor PIDs for a matching session metadata file const ancestorLog = resolveSessionLogByAncestorPids(); - tryLogCandidates("Ancestor PID session metadata", () => ancestorLog ? [ancestorLog] : []); + tryLogCandidates("Ancestor PID session metadata", () => + ancestorLog ? [ancestorLog] : [], + ); // 2. Scan all session metadata files for one whose cwd matches const cwdScanLog = resolveSessionLogByCwdScan({ cwd: projectRoot }); - tryLogCandidates("Cwd-scan session metadata", () => cwdScanLog ? [cwdScanLog] : []); + tryLogCandidates("Cwd-scan session metadata", () => + cwdScanLog ? [cwdScanLog] : [], + ); // 3. Fall back to CWD slug match (mtime-based) - tryLogCandidates("CWD slug match (mtime)", () => findSessionLogsForCwd(projectRoot)); + tryLogCandidates("CWD slug match (mtime)", () => + findSessionLogsForCwd(projectRoot), + ); // 4. Fall back to ancestor directory walk - tryLogCandidates("Directory ancestor walk", () => findSessionLogsByAncestorWalk(projectRoot)); + tryLogCandidates("Directory ancestor walk", () => + findSessionLogsByAncestorWalk(projectRoot), + ); } if (!lastMessage) { @@ -833,7 +1029,9 @@ if (args[0] === "sessions") { } if (process.env.PLANNOTATOR_DEBUG) { - console.error(`[DEBUG] Found message ${lastMessage.messageId} (${lastMessage.text.length} chars)`); + console.error( + `[DEBUG] Found message ${lastMessage.messageId} (${lastMessage.text.length} chars)`, + ); } const annotateProject = (await detectProjectName()) ?? "_unknown"; @@ -852,7 +1050,12 @@ if (args[0] === "sessions") { handleAnnotateServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink(lastMessage.text, shareBaseUrl, "annotate", "message only").catch(() => {}); + await writeRemoteShareLink( + lastMessage.text, + shareBaseUrl, + "annotate", + "message only", + ).catch(() => {}); } }, }); @@ -875,7 +1078,6 @@ if (args[0] === "sessions") { emitAnnotateOutcome(result); process.exit(0); - } else if (args[0] === "archive") { // ============================================ // ARCHIVE BROWSER MODE @@ -910,7 +1112,6 @@ if (args[0] === "sessions") { await Bun.sleep(500); server.stop(); process.exit(0); - } else if (args[0] === "copilot-plan") { // ============================================ // COPILOT CLI PLAN INTERCEPTION MODE @@ -921,7 +1122,13 @@ if (args[0] === "sessions") { // No output = allow the tool call to proceed. const eventJson = await Bun.stdin.text(); - let event: { toolName: string; toolArgs: string; cwd: string; timestamp: number; sessionId?: string }; + let event: { + toolName: string; + toolArgs: string; + cwd: string; + timestamp: number; + sessionId?: string; + }; try { event = JSON.parse(eventJson); @@ -956,7 +1163,12 @@ if (args[0] === "sessions") { handleServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink(planContent, shareBaseUrl, "review the plan", "plan only").catch(() => {}); + await writeRemoteShareLink( + planContent, + shareBaseUrl, + "review the plan", + "plan only", + ).catch(() => {}); } }, }); @@ -977,23 +1189,26 @@ if (args[0] === "sessions") { // Output Copilot CLI permission decision format if (result.approved) { - console.log(JSON.stringify({ - permissionDecision: "allow", - })); + console.log( + JSON.stringify({ + permissionDecision: "allow", + }), + ); } else { const feedback = getPlanDeniedPrompt("copilot-cli", undefined, { toolName: getPlanToolName("copilot-cli"), planFileRule: "", feedback: result.feedback || "Plan changes requested", }); - console.log(JSON.stringify({ - permissionDecision: "deny", - permissionDecisionReason: feedback, - })); + console.log( + JSON.stringify({ + permissionDecision: "deny", + permissionDecisionReason: feedback, + }), + ); } process.exit(0); - } else if (args[0] === "copilot-last") { // ============================================ // COPILOT CLI ANNOTATE LAST MESSAGE MODE @@ -1002,7 +1217,9 @@ if (args[0] === "sessions") { const projectRoot = process.env.PLANNOTATOR_CWD || process.cwd(); if (process.env.PLANNOTATOR_DEBUG) { - console.error(`[DEBUG] Copilot CLI detected, finding session for CWD: ${projectRoot}`); + console.error( + `[DEBUG] Copilot CLI detected, finding session for CWD: ${projectRoot}`, + ); } const sessionDir = findCopilotSessionForCwd(projectRoot); @@ -1041,7 +1258,12 @@ if (args[0] === "sessions") { handleAnnotateServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink(msg.text, shareBaseUrl, "annotate", "message only").catch(() => {}); + await writeRemoteShareLink( + msg.text, + shareBaseUrl, + "annotate", + "message only", + ).catch(() => {}); } }, }); @@ -1062,7 +1284,6 @@ if (args[0] === "sessions") { emitAnnotateOutcome(result); process.exit(0); - } else if (args[0] === "improve-context") { // ============================================ // IMPROVEMENT HOOK CONTEXT INJECTION MODE @@ -1085,15 +1306,16 @@ if (args[0] === "sessions") { if (context === null) process.exit(0); - console.log(JSON.stringify({ - hookSpecificOutput: { - hookEventName: "PreToolUse", - additionalContext: context, - }, - })); + console.log( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + additionalContext: context, + }, + }), + ); process.exit(0); - } else { // ============================================ // PLAN REVIEW MODE (default) @@ -1145,7 +1367,12 @@ if (args[0] === "sessions") { handleServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink(latestPlan.text, shareBaseUrl, "review the plan", "plan only").catch(() => {}); + await writeRemoteShareLink( + latestPlan.text, + shareBaseUrl, + "review the plan", + "plan only", + ).catch(() => {}); } }, }); @@ -1175,7 +1402,7 @@ if (args[0] === "sessions") { planFileRule: "", feedback: result.feedback || "Plan changes requested", }), - }) + }), ); } @@ -1188,7 +1415,8 @@ if (args[0] === "sessions") { let planFilename = ""; // Detect harness: Gemini sends plan_filename (file on disk), Claude Code sends plan (inline) - planFilename = event.tool_input?.plan_filename || event.tool_input?.plan_path || ""; + planFilename = + event.tool_input?.plan_filename || event.tool_input?.plan_path || ""; isGemini = !!planFilename; if (isGemini) { @@ -1196,7 +1424,12 @@ if (args[0] === "sessions") { // transcript_path = /chats/session-...json // plan lives at = //plans/ const projectTempDir = path.dirname(path.dirname(event.transcript_path)); - const planFilePath = path.join(projectTempDir, event.session_id, "plans", planFilename); + const planFilePath = path.join( + projectTempDir, + event.session_id, + "plans", + planFilename, + ); planContent = await Bun.file(planFilePath).text(); } else { planContent = event.tool_input?.plan || ""; @@ -1231,7 +1464,12 @@ if (args[0] === "sessions") { handleServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink(planContent, shareBaseUrl, "review the plan", "plan only").catch(() => {}); + await writeRemoteShareLink( + planContent, + shareBaseUrl, + "review the plan", + "plan only", + ).catch(() => {}); } }, }); @@ -1258,17 +1496,24 @@ if (args[0] === "sessions") { // Output decision in the appropriate format for the harness if (isGemini) { if (result.approved) { - console.log(result.feedback ? JSON.stringify({ systemMessage: result.feedback }) : "{}"); + console.log( + result.feedback + ? JSON.stringify({ systemMessage: result.feedback }) + : "{}", + ); } else { console.log( JSON.stringify({ decision: "deny", reason: getPlanDeniedPrompt("gemini-cli", undefined, { toolName: getPlanToolName("gemini-cli"), - planFileRule: buildPlanFileRule(getPlanToolName("gemini-cli"), planFilename), + planFileRule: buildPlanFileRule( + getPlanToolName("gemini-cli"), + planFilename, + ), feedback: result.feedback || "Plan changes requested", }), - }) + }), ); } } else { @@ -1280,6 +1525,7 @@ if (args[0] === "sessions") { ) { const nativeClearEnabled = await ensureClearContextSettingEnabled(); if (nativeClearEnabled) { + spawnKeystrokeInjector(); process.exit(0); } result.clearContextNudge = true; @@ -1309,7 +1555,7 @@ if (args[0] === "sessions") { ...(updatedPermissions.length > 0 && { updatedPermissions }), }, }, - }) + }), ); } else { console.log( @@ -1325,7 +1571,7 @@ if (args[0] === "sessions") { }), }, }, - }) + }), ); } } diff --git a/apps/hook/server/keystrokeInjector.test.ts b/apps/hook/server/keystrokeInjector.test.ts new file mode 100644 index 000000000..8d6063c45 --- /dev/null +++ b/apps/hook/server/keystrokeInjector.test.ts @@ -0,0 +1,118 @@ +import { + describe, + it, + expect, + spyOn, + mock, + beforeEach, + afterEach, +} from "bun:test"; +import { spawnKeystrokeInjector } from "./keystrokeInjector"; + +describe("spawnKeystrokeInjector", () => { + let spawnCalls: { cmd: string[]; opts: Record }[] = []; + let originalEnv: NodeJS.ProcessEnv; + let originalPlatform: string; + + beforeEach(() => { + spawnCalls = []; + originalEnv = { ...process.env }; + originalPlatform = process.platform; + delete process.env["TMUX_PANE"]; + + const mockChild = { unref: mock(() => {}) }; + spyOn(Bun, "spawn").mockImplementation( + (cmd: string[], opts: Record) => { + spawnCalls.push({ cmd, opts }); + return mockChild as ReturnType; + }, + ); + }); + + afterEach(() => { + process.env = originalEnv; + Object.defineProperty(process, "platform", { + value: originalPlatform, + writable: true, + }); + mock.restore(); + }); + + it("uses tmux send-keys when TMUX_PANE is set", () => { + process.env["TMUX_PANE"] = "%3"; + spawnKeystrokeInjector(100); + + expect(spawnCalls).toHaveLength(1); + expect(spawnCalls[0].cmd[0]).toBe("bash"); + const script = spawnCalls[0].cmd[2] as string; + expect(script).toContain("tmux send-keys"); + expect(script).toContain("%3"); + expect(script).toContain("1 Enter"); + expect(script).not.toContain("osascript"); + }); + + it("uses osascript on macOS when no tmux pane", () => { + Object.defineProperty(process, "platform", { + value: "darwin", + writable: true, + }); + spawnKeystrokeInjector(100); + + expect(spawnCalls).toHaveLength(1); + const script = spawnCalls[0].cmd[2] as string; + expect(script).toContain("osascript"); + expect(script).toContain("warp"); + expect(script).toContain("iTerm2"); + expect(script).toContain("Terminal"); + expect(script).toContain('keystroke "1"'); + expect(script).not.toContain("tmux send-keys"); + }); + + it("no-op on linux without tmux", () => { + Object.defineProperty(process, "platform", { + value: "linux", + writable: true, + }); + spawnKeystrokeInjector(); + expect(spawnCalls).toHaveLength(0); + }); + + it("no-op on windows without tmux", () => { + Object.defineProperty(process, "platform", { + value: "win32", + writable: true, + }); + spawnKeystrokeInjector(); + expect(spawnCalls).toHaveLength(0); + }); + + it("spawn is detached and unreffed (does not block caller)", () => { + Object.defineProperty(process, "platform", { + value: "darwin", + writable: true, + }); + spawnKeystrokeInjector(); + + expect(spawnCalls).toHaveLength(1); + const opts = spawnCalls[0].opts as { detached: boolean; stdio: string[] }; + expect(opts.detached).toBe(true); + expect(opts.stdio).toEqual(["ignore", "ignore", "ignore"]); + }); + + it("embeds delay in tmux script via sleep", () => { + process.env["TMUX_PANE"] = "%0"; + spawnKeystrokeInjector(1200); + const script = spawnCalls[0].cmd[2] as string; + expect(script).toContain("sleep 1.20"); + }); + + it("embeds delay in osascript via 'delay' statement", () => { + Object.defineProperty(process, "platform", { + value: "darwin", + writable: true, + }); + spawnKeystrokeInjector(800); + const script = spawnCalls[0].cmd[2] as string; + expect(script).toContain("delay 0.80"); + }); +}); diff --git a/apps/hook/server/keystrokeInjector.ts b/apps/hook/server/keystrokeInjector.ts new file mode 100644 index 000000000..7ecfa31aa --- /dev/null +++ b/apps/hook/server/keystrokeInjector.ts @@ -0,0 +1,78 @@ +/** + * Detached keystroke injector for auto-confirming CC's native plan-accept dialog. + * + * CC renders the plan-accept dialog in the terminal ~200–500ms after the hook + * process exits. This module spawns a background process that fires "1\n" into + * the active CC terminal window after a configurable delay, selecting + * "Yes, clear context and bypass permissions" without user interaction. + * + * Platform strategy: + * 1. tmux ($TMUX_PANE) → tmux send-keys (no accessibility permissions needed) + * 2. macOS → osascript targeting WarpTerminal, iTerm2, or Terminal + * 3. everything else → no-op (user must press 1 manually) + * + * Silent-fail contract: any error (accessibility denied, no terminal found, etc.) + * exits the child process non-zero without affecting the hook's exit or logging noise. + */ + +const KNOWN_MACOS_TERMINALS = ["warp", "iTerm2", "Terminal"] as const; + +function buildTmuxScript(pane: string, delayMs: number): string { + const delaySec = (delayMs / 1000).toFixed(2); + return `sleep ${delaySec} && tmux send-keys -t ${JSON.stringify(pane)} 1 Enter`; +} + +function buildOsascriptScript(delayMs: number): string { + const delaySec = (delayMs / 1000).toFixed(2); + const appList = KNOWN_MACOS_TERMINALS.map((a) => `"${a}"`).join(", "); + // prettier-ignore + return [ + `osascript <<'APPLESCRIPT'`, + `delay ${delaySec}`, + `tell application "System Events"`, + ` repeat with appName in {${appList}}`, + ` if exists (application process (appName as string)) then`, + ` set frontmost of application process (appName as string) to true`, + ` delay 0.05`, + ` keystroke "1"`, + ` key code 36`, + ` exit repeat`, + ` end if`, + ` end repeat`, + `end tell`, + `APPLESCRIPT`, + ].join("\n"); +} + +/** + * Spawn a detached process that injects "1\n" into the CC terminal window. + * Returns immediately; the child continues running after the parent exits. + * + * @param delayMs Milliseconds to wait before sending the keystroke (default: 600). + * Should be longer than CC's dialog render time (~200–500ms). + */ +export function spawnKeystrokeInjector(delayMs = 600): void { + const tmuxPane = process.env["TMUX_PANE"]; + + if (tmuxPane) { + const script = buildTmuxScript(tmuxPane, delayMs); + const child = Bun.spawn(["bash", "-c", script], { + detached: true, + stdio: ["ignore", "ignore", "ignore"], + }); + child.unref(); + return; + } + + if (process.platform === "darwin") { + const script = buildOsascriptScript(delayMs); + const child = Bun.spawn(["bash", "-c", script], { + detached: true, + stdio: ["ignore", "ignore", "ignore"], + }); + child.unref(); + return; + } + + // Linux/Windows without tmux: no-op; user must press 1 manually. +} From 2f1d5a96b1a28a7b4866608c03d645c7af88ea25 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Mon, 11 May 2026 10:58:24 +0700 Subject: [PATCH 05/34] =?UTF-8?q?refactor(hook):=20deslop=20keystrokeInjec?= =?UTF-8?q?tor=20=E2=80=94=20collapse=20builders,=20unify=20spawn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Structural cleanup of the keystroke injector without behavior changes: - Delete 16-line module docblock + 5-line JSDoc (over-commented for 78 lines) - Inline buildTmuxScript() and buildOsascriptScript() — single-use helpers - Unify the duplicated Bun.spawn+.unref() into one call at the end: both branches now set `script: string | null`, then a single spawn runs if set - `KNOWN_MACOS_TERMINALS as const` → plain string[] (not a readonly tuple) Test cleanup: - Extract setPlatform(v) helper — replaces 5× Object.defineProperty blocks 78→39 lines (implementation), 118→107 lines (tests). 7/7 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/hook/server/keystrokeInjector.test.ts | 29 ++----- apps/hook/server/keystrokeInjector.ts | 99 +++++++--------------- 2 files changed, 39 insertions(+), 89 deletions(-) diff --git a/apps/hook/server/keystrokeInjector.test.ts b/apps/hook/server/keystrokeInjector.test.ts index 8d6063c45..d3cc9b94d 100644 --- a/apps/hook/server/keystrokeInjector.test.ts +++ b/apps/hook/server/keystrokeInjector.test.ts @@ -14,6 +14,10 @@ describe("spawnKeystrokeInjector", () => { let originalEnv: NodeJS.ProcessEnv; let originalPlatform: string; + function setPlatform(v: string) { + Object.defineProperty(process, "platform", { value: v, writable: true }); + } + beforeEach(() => { spawnCalls = []; originalEnv = { ...process.env }; @@ -52,10 +56,7 @@ describe("spawnKeystrokeInjector", () => { }); it("uses osascript on macOS when no tmux pane", () => { - Object.defineProperty(process, "platform", { - value: "darwin", - writable: true, - }); + setPlatform("darwin"); spawnKeystrokeInjector(100); expect(spawnCalls).toHaveLength(1); @@ -69,28 +70,19 @@ describe("spawnKeystrokeInjector", () => { }); it("no-op on linux without tmux", () => { - Object.defineProperty(process, "platform", { - value: "linux", - writable: true, - }); + setPlatform("linux"); spawnKeystrokeInjector(); expect(spawnCalls).toHaveLength(0); }); it("no-op on windows without tmux", () => { - Object.defineProperty(process, "platform", { - value: "win32", - writable: true, - }); + setPlatform("win32"); spawnKeystrokeInjector(); expect(spawnCalls).toHaveLength(0); }); it("spawn is detached and unreffed (does not block caller)", () => { - Object.defineProperty(process, "platform", { - value: "darwin", - writable: true, - }); + setPlatform("darwin"); spawnKeystrokeInjector(); expect(spawnCalls).toHaveLength(1); @@ -107,10 +99,7 @@ describe("spawnKeystrokeInjector", () => { }); it("embeds delay in osascript via 'delay' statement", () => { - Object.defineProperty(process, "platform", { - value: "darwin", - writable: true, - }); + setPlatform("darwin"); spawnKeystrokeInjector(800); const script = spawnCalls[0].cmd[2] as string; expect(script).toContain("delay 0.80"); diff --git a/apps/hook/server/keystrokeInjector.ts b/apps/hook/server/keystrokeInjector.ts index 7ecfa31aa..907ffebd7 100644 --- a/apps/hook/server/keystrokeInjector.ts +++ b/apps/hook/server/keystrokeInjector.ts @@ -1,78 +1,39 @@ -/** - * Detached keystroke injector for auto-confirming CC's native plan-accept dialog. - * - * CC renders the plan-accept dialog in the terminal ~200–500ms after the hook - * process exits. This module spawns a background process that fires "1\n" into - * the active CC terminal window after a configurable delay, selecting - * "Yes, clear context and bypass permissions" without user interaction. - * - * Platform strategy: - * 1. tmux ($TMUX_PANE) → tmux send-keys (no accessibility permissions needed) - * 2. macOS → osascript targeting WarpTerminal, iTerm2, or Terminal - * 3. everything else → no-op (user must press 1 manually) - * - * Silent-fail contract: any error (accessibility denied, no terminal found, etc.) - * exits the child process non-zero without affecting the hook's exit or logging noise. - */ +// Injects "1\n" into CC terminal to auto-select the plan-accept "clear + bypass" option. +const MACOS_TERMINALS = ["warp", "iTerm2", "Terminal"]; -const KNOWN_MACOS_TERMINALS = ["warp", "iTerm2", "Terminal"] as const; - -function buildTmuxScript(pane: string, delayMs: number): string { - const delaySec = (delayMs / 1000).toFixed(2); - return `sleep ${delaySec} && tmux send-keys -t ${JSON.stringify(pane)} 1 Enter`; -} - -function buildOsascriptScript(delayMs: number): string { - const delaySec = (delayMs / 1000).toFixed(2); - const appList = KNOWN_MACOS_TERMINALS.map((a) => `"${a}"`).join(", "); - // prettier-ignore - return [ - `osascript <<'APPLESCRIPT'`, - `delay ${delaySec}`, - `tell application "System Events"`, - ` repeat with appName in {${appList}}`, - ` if exists (application process (appName as string)) then`, - ` set frontmost of application process (appName as string) to true`, - ` delay 0.05`, - ` keystroke "1"`, - ` key code 36`, - ` exit repeat`, - ` end if`, - ` end repeat`, - `end tell`, - `APPLESCRIPT`, - ].join("\n"); -} - -/** - * Spawn a detached process that injects "1\n" into the CC terminal window. - * Returns immediately; the child continues running after the parent exits. - * - * @param delayMs Milliseconds to wait before sending the keystroke (default: 600). - * Should be longer than CC's dialog render time (~200–500ms). - */ export function spawnKeystrokeInjector(delayMs = 600): void { + const delaySec = (delayMs / 1000).toFixed(2); const tmuxPane = process.env["TMUX_PANE"]; + let script: string | null = null; + if (tmuxPane) { - const script = buildTmuxScript(tmuxPane, delayMs); - const child = Bun.spawn(["bash", "-c", script], { - detached: true, - stdio: ["ignore", "ignore", "ignore"], - }); - child.unref(); - return; + script = `sleep ${delaySec} && tmux send-keys -t ${JSON.stringify(tmuxPane)} 1 Enter`; + } else if (process.platform === "darwin") { + const apps = MACOS_TERMINALS.map((a) => `"${a}"`).join(", "); + script = [ + `osascript <<'APPLESCRIPT'`, + `delay ${delaySec}`, + `tell application "System Events"`, + ` repeat with appName in {${apps}}`, + ` if exists (application process (appName as string)) then`, + ` set frontmost of application process (appName as string) to true`, + ` delay 0.05`, + ` keystroke "1"`, + ` key code 36`, + ` exit repeat`, + ` end if`, + ` end repeat`, + `end tell`, + `APPLESCRIPT`, + ].join("\n"); } - if (process.platform === "darwin") { - const script = buildOsascriptScript(delayMs); - const child = Bun.spawn(["bash", "-c", script], { - detached: true, - stdio: ["ignore", "ignore", "ignore"], - }); - child.unref(); - return; - } + if (!script) return; // Linux/Windows without tmux: user must press 1 manually - // Linux/Windows without tmux: no-op; user must press 1 manually. + const child = Bun.spawn(["bash", "-c", script], { + detached: true, + stdio: ["ignore", "ignore", "ignore"], + }); + child.unref(); } From cdd0e834418ba0ca711e7f9c52c8c2e50f9ad984 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Mon, 11 May 2026 13:13:24 +0700 Subject: [PATCH 06/34] fix(hook): detect WarpTerminal via bundle name, not process name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Warp ships as Warp.app/Contents/MacOS/stable, so its macOS process name is "stable" — not "warp". The previous code checked `exists (application process "warp")` via System Events, which always returned false on Warp, leaving the keystroke never injected and the CC plan-accept TUI unattended. Fix: check `application "Warp" is running` (bundle-name lookup, works regardless of the binary name) and activate Warp directly before sending keystrokes. Fall through to process-name search for iTerm2 and Terminal unchanged. Test: update the osascript assertion to match the new Warp check. 7/7 tests pass. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- apps/hook/server/keystrokeInjector.test.ts | 2 +- apps/hook/server/keystrokeInjector.ts | 37 ++++++++++++++-------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/apps/hook/server/keystrokeInjector.test.ts b/apps/hook/server/keystrokeInjector.test.ts index d3cc9b94d..3a43ae94d 100644 --- a/apps/hook/server/keystrokeInjector.test.ts +++ b/apps/hook/server/keystrokeInjector.test.ts @@ -62,7 +62,7 @@ describe("spawnKeystrokeInjector", () => { expect(spawnCalls).toHaveLength(1); const script = spawnCalls[0].cmd[2] as string; expect(script).toContain("osascript"); - expect(script).toContain("warp"); + expect(script).toContain('application "Warp" is running'); expect(script).toContain("iTerm2"); expect(script).toContain("Terminal"); expect(script).toContain('keystroke "1"'); diff --git a/apps/hook/server/keystrokeInjector.ts b/apps/hook/server/keystrokeInjector.ts index 907ffebd7..1db86ab95 100644 --- a/apps/hook/server/keystrokeInjector.ts +++ b/apps/hook/server/keystrokeInjector.ts @@ -1,5 +1,5 @@ // Injects "1\n" into CC terminal to auto-select the plan-accept "clear + bypass" option. -const MACOS_TERMINALS = ["warp", "iTerm2", "Terminal"]; +const MACOS_PROCESS_TERMINALS = ["iTerm2", "Terminal"]; export function spawnKeystrokeInjector(delayMs = 600): void { const delaySec = (delayMs / 1000).toFixed(2); @@ -10,21 +10,32 @@ export function spawnKeystrokeInjector(delayMs = 600): void { if (tmuxPane) { script = `sleep ${delaySec} && tmux send-keys -t ${JSON.stringify(tmuxPane)} 1 Enter`; } else if (process.platform === "darwin") { - const apps = MACOS_TERMINALS.map((a) => `"${a}"`).join(", "); + const apps = MACOS_PROCESS_TERMINALS.map((a) => `"${a}"`).join(", "); + // Warp ships as Warp.app/MacOS/stable so its process name is "stable", not "warp". + // Check by bundle name first, then fall back to process-name search for other terminals. script = [ `osascript <<'APPLESCRIPT'`, `delay ${delaySec}`, - `tell application "System Events"`, - ` repeat with appName in {${apps}}`, - ` if exists (application process (appName as string)) then`, - ` set frontmost of application process (appName as string) to true`, - ` delay 0.05`, - ` keystroke "1"`, - ` key code 36`, - ` exit repeat`, - ` end if`, - ` end repeat`, - `end tell`, + `if application "Warp" is running then`, + ` tell application "Warp" to activate`, + ` delay 0.05`, + ` tell application "System Events"`, + ` keystroke "1"`, + ` key code 36`, + ` end tell`, + `else`, + ` tell application "System Events"`, + ` repeat with appName in {${apps}}`, + ` if exists (application process (appName as string)) then`, + ` set frontmost of application process (appName as string) to true`, + ` delay 0.05`, + ` keystroke "1"`, + ` key code 36`, + ` exit repeat`, + ` end if`, + ` end repeat`, + ` end tell`, + `end if`, `APPLESCRIPT`, ].join("\n"); } From 08261185fcbee8f4a458e29125785155ee43ca3c Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Mon, 11 May 2026 18:23:49 +0700 Subject: [PATCH 07/34] Keep plan approvals in the current session by default Constraint: Approved Ralph plan required saved bypassPermissionsClearReminder to nudge /clear without native fresh-thread deferral.\nRejected: Reusing native clear as the default | it can restart or open a fresh thread unexpectedly.\nConfidence: high\nScope-risk: moderate\nDirective: Treat deferToNativeForClear as an explicit native/fresh-thread escape hatch only; do not wire it to saved reminder mode.\nTested: bun test packages/editor/approvalBody.test.ts apps/hook/server/keystrokeInjector.test.ts; git diff --check scoped files; bun run typecheck; bun test; bun run build:hook; architect verification approved.\nNot-tested: Interactive manual browser smoke of Claude Code native dialog selection. --- apps/hook/server/index.ts | 6 ++- apps/hook/server/keystrokeInjector.test.ts | 21 ++++++++- apps/hook/server/keystrokeInjector.ts | 6 ++- packages/editor/approvalBody.test.ts | 51 +++++++++++++++++++--- packages/editor/approvalBody.ts | 14 +++++- packages/ui/utils/permissionMode.ts | 6 +-- 6 files changed, 90 insertions(+), 14 deletions(-) diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 90ceba02b..66e377816 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -144,7 +144,7 @@ import { isVersionInvocation, } from "./cli"; import { ensureClearContextSettingEnabled } from "./clearContextSetting"; -import { spawnKeystrokeInjector } from "./keystrokeInjector"; +import { shouldAutoSelectNativeClear, spawnKeystrokeInjector } from "./keystrokeInjector"; import path from "path"; import { tmpdir } from "os"; @@ -1525,7 +1525,9 @@ if (args[0] === "sessions") { ) { const nativeClearEnabled = await ensureClearContextSettingEnabled(); if (nativeClearEnabled) { - spawnKeystrokeInjector(); + if (shouldAutoSelectNativeClear()) { + spawnKeystrokeInjector(); + } process.exit(0); } result.clearContextNudge = true; diff --git a/apps/hook/server/keystrokeInjector.test.ts b/apps/hook/server/keystrokeInjector.test.ts index 3a43ae94d..5391a6d94 100644 --- a/apps/hook/server/keystrokeInjector.test.ts +++ b/apps/hook/server/keystrokeInjector.test.ts @@ -7,7 +7,26 @@ import { beforeEach, afterEach, } from "bun:test"; -import { spawnKeystrokeInjector } from "./keystrokeInjector"; +import { shouldAutoSelectNativeClear, spawnKeystrokeInjector } from "./keystrokeInjector"; + +describe("shouldAutoSelectNativeClear", () => { + it("defaults to false", () => { + expect(shouldAutoSelectNativeClear({} as NodeJS.ProcessEnv)).toBe(false); + }); + + it("is true only for PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR=1", () => { + expect( + shouldAutoSelectNativeClear({ + PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR: "1", + } as NodeJS.ProcessEnv), + ).toBe(true); + expect( + shouldAutoSelectNativeClear({ + PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR: "true", + } as NodeJS.ProcessEnv), + ).toBe(false); + }); +}); describe("spawnKeystrokeInjector", () => { let spawnCalls: { cmd: string[]; opts: Record }[] = []; diff --git a/apps/hook/server/keystrokeInjector.ts b/apps/hook/server/keystrokeInjector.ts index 1db86ab95..81bc816f8 100644 --- a/apps/hook/server/keystrokeInjector.ts +++ b/apps/hook/server/keystrokeInjector.ts @@ -1,6 +1,10 @@ -// Injects "1\n" into CC terminal to auto-select the plan-accept "clear + bypass" option. +// Opt-in helper that injects "1\n" into CC terminal to auto-select native clear + bypass. const MACOS_PROCESS_TERMINALS = ["iTerm2", "Terminal"]; +export function shouldAutoSelectNativeClear(env: NodeJS.ProcessEnv = process.env): boolean { + return env["PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR"] === "1"; +} + export function spawnKeystrokeInjector(delayMs = 600): void { const delaySec = (delayMs / 1000).toFixed(2); const tmuxPane = process.env["TMUX_PANE"]; diff --git a/packages/editor/approvalBody.test.ts b/packages/editor/approvalBody.test.ts index 3d1977074..4c6b974c3 100644 --- a/packages/editor/approvalBody.test.ts +++ b/packages/editor/approvalBody.test.ts @@ -1,8 +1,30 @@ import { describe, expect, test } from 'bun:test'; -import { buildApprovalRequestBody } from './approvalBody'; +import { buildApprovalRequestBody, shouldEnableNativeClearBeforeApprove } from './approvalBody'; + +describe('shouldEnableNativeClearBeforeApprove', () => { + test('enables native clear only for explicit Claude Code ExitPlanMode overrides', () => { + expect(shouldEnableNativeClearBeforeApprove({ + origin: 'claude-code', + toolName: 'ExitPlanMode', + override: { deferToNativeForClear: true }, + })).toBe(true); + + expect(shouldEnableNativeClearBeforeApprove({ + origin: 'claude-code', + toolName: 'ExitPlanMode', + override: { permissionMode: 'bypassPermissionsClearReminder' }, + })).toBe(false); + + expect(shouldEnableNativeClearBeforeApprove({ + origin: 'claude-code', + toolName: 'OtherTool', + override: { deferToNativeForClear: true }, + })).toBe(false); + }); +}); describe('buildApprovalRequestBody', () => { - test('maps bypass clear reminder mode to native Claude Code clear on ExitPlanMode', () => { + test('maps bypass clear reminder mode to reminder fallback on ExitPlanMode', () => { expect(buildApprovalRequestBody({ origin: 'claude-code', permissionMode: 'bypassPermissionsClearReminder', @@ -10,7 +32,7 @@ describe('buildApprovalRequestBody', () => { planSaveSettings: { enabled: true }, })).toEqual({ permissionMode: 'bypassPermissions', - deferToNativeForClear: true, + clearContextNudge: true, planSave: { enabled: true }, }); }); @@ -60,7 +82,7 @@ describe('buildApprovalRequestBody', () => { }); }); - test('uses native clear for bypass clear reminder override when ExitPlanMode is known', () => { + test('keeps bypass clear reminder override as reminder fallback when ExitPlanMode is known', () => { expect(buildApprovalRequestBody({ origin: 'claude-code', permissionMode: 'acceptEdits', @@ -71,7 +93,7 @@ describe('buildApprovalRequestBody', () => { planSaveSettings: { enabled: true }, })).toEqual({ permissionMode: 'bypassPermissions', - deferToNativeForClear: true, + clearContextNudge: true, planSave: { enabled: true }, }); }); @@ -100,10 +122,11 @@ describe('buildApprovalRequestBody', () => { }); }); - test('forwards deferToNativeForClear for Claude Code bypass approvals', () => { + test('forwards deferToNativeForClear only for explicit Claude Code ExitPlanMode bypass approvals', () => { expect(buildApprovalRequestBody({ origin: 'claude-code', permissionMode: 'acceptEdits', + toolName: 'ExitPlanMode', override: { permissionMode: 'bypassPermissions', deferToNativeForClear: true, @@ -116,6 +139,22 @@ describe('buildApprovalRequestBody', () => { }); }); + test('does not forward deferToNativeForClear without ExitPlanMode', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'acceptEdits', + toolName: 'OtherTool', + override: { + permissionMode: 'bypassPermissions', + deferToNativeForClear: true, + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + planSave: { enabled: true }, + }); + }); + test('does not forward deferToNativeForClear for OpenCode approvals', () => { expect(buildApprovalRequestBody({ origin: 'opencode', diff --git a/packages/editor/approvalBody.ts b/packages/editor/approvalBody.ts index a5e1ba7b4..55a905fcc 100644 --- a/packages/editor/approvalBody.ts +++ b/packages/editor/approvalBody.ts @@ -19,6 +19,18 @@ export interface ApprovalRequestBody { deferToNativeForClear?: boolean; } +export function shouldEnableNativeClearBeforeApprove(options: { + origin: Origin | null; + toolName?: string; + override?: ApprovalOverride; +}): boolean { + return ( + options.origin === 'claude-code' && + options.toolName === 'ExitPlanMode' && + options.override?.deferToNativeForClear === true + ); +} + export function buildApprovalRequestBody(options: { origin: Origin | null; permissionMode: PermissionMode; @@ -33,7 +45,7 @@ export function buildApprovalRequestBody(options: { if (origin === 'claude-code') { const effectivePermissionMode = override.permissionMode ?? permissionMode; const wantsClearContext = effectivePermissionMode === 'bypassPermissionsClearReminder'; - const useNativeClear = override.deferToNativeForClear || (wantsClearContext && toolName === 'ExitPlanMode'); + const useNativeClear = shouldEnableNativeClearBeforeApprove({ origin, toolName, override }); body.permissionMode = wantsClearContext ? 'bypassPermissions' : effectivePermissionMode; diff --git a/packages/ui/utils/permissionMode.ts b/packages/ui/utils/permissionMode.ts index ed72dc4f1..fa016be2a 100644 --- a/packages/ui/utils/permissionMode.ts +++ b/packages/ui/utils/permissionMode.ts @@ -6,7 +6,7 @@ * * Available modes: * - bypassPermissions: Auto-approve all tool calls - * - bypassPermissionsClearReminder: Persisted UI mode that uses native clear-on-accept for Claude Code plan approvals and a /clear reminder fallback otherwise + * - bypassPermissionsClearReminder: Persisted UI mode that bypasses permissions and emits a /clear reminder after plan approval * - acceptEdits: Auto-approve file edits only * - default: Manually approve each tool call */ @@ -36,8 +36,8 @@ export const PERMISSION_MODE_OPTIONS: { value: PermissionMode; label: string; de }, { value: 'bypassPermissionsClearReminder', - label: 'Bypass + Clear Context', - description: 'For Claude Code plan approvals, defer to the native clear-context flow and bypass permissions; otherwise emit a /clear reminder.', + label: 'Bypass + /clear Reminder', + description: 'Bypass permissions after plan approval and emit a /clear reminder without invoking the native fresh-thread flow.', }, { value: 'default', From 78243feb653d9b72f39e9dee75caa515300c954b Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Thu, 14 May 2026 15:58:06 +0700 Subject: [PATCH 08/34] omx(team): auto-checkpoint worker-4 [unknown] --- apps/hook/server/index.ts | 500 ++++++--------------- apps/hook/server/keystrokeInjector.test.ts | 126 ------ apps/hook/server/keystrokeInjector.ts | 54 --- 3 files changed, 126 insertions(+), 554 deletions(-) delete mode 100644 apps/hook/server/keystrokeInjector.test.ts delete mode 100644 apps/hook/server/keystrokeInjector.ts diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 66e377816..1bbdb6c7f 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -52,7 +52,10 @@ * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) */ -import { startPlannotatorServer, handleServerReady } from "@plannotator/server"; +import { + startPlannotatorServer, + handleServerReady, +} from "@plannotator/server"; import { startReviewServer, handleReviewServerReady, @@ -66,36 +69,12 @@ import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator import { parseReviewArgs } from "@plannotator/shared/review-args"; import { stripAtPrefix, resolveAtReference } from "@plannotator/shared/at-reference"; import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown"; -import { - urlToMarkdown, - isConvertedSource, -} from "@plannotator/shared/url-to-markdown"; -import { - fetchRef, - createWorktree, - removeWorktree, - ensureObjectAvailable, -} from "@plannotator/shared/worktree"; -import { - createWorktreePool, - type WorktreePool, -} from "@plannotator/shared/worktree-pool"; -import { - parsePRUrl, - checkPRAuth, - fetchPR, - getCliName, - getCliInstallUrl, - getMRLabel, - getMRNumberLabel, - getDisplayRepo, -} from "@plannotator/server/pr"; +import { urlToMarkdown, isConvertedSource } from "@plannotator/shared/url-to-markdown"; +import { fetchRef, createWorktree, removeWorktree, ensureObjectAvailable } from "@plannotator/shared/worktree"; +import { createWorktreePool, type WorktreePool } from "@plannotator/shared/worktree-pool"; +import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getCliInstallUrl, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr"; import { writeRemoteShareLink } from "@plannotator/server/share-url"; -import { - resolveMarkdownFile, - resolveUserPath, - hasMarkdownFiles, -} from "@plannotator/shared/resolve-file"; +import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannotator/shared/resolve-file"; import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; import { statSync, rmSync, realpathSync, existsSync } from "fs"; import { parseRemoteUrl } from "@plannotator/shared/repo"; @@ -106,11 +85,7 @@ import { getPlanToolName, buildPlanFileRule, } from "@plannotator/shared/prompts"; -import { - registerSession, - unregisterSession, - listSessions, -} from "@plannotator/server/sessions"; +import { registerSession, unregisterSession, listSessions } from "@plannotator/server/sessions"; import { openBrowser } from "@plannotator/server/browser"; import { detectProjectName } from "@plannotator/server/project"; import { hostnameOrFallback } from "@plannotator/shared/project"; @@ -125,16 +100,8 @@ import { resolveSessionLogByCwdScan, type RenderedMessage, } from "./session-log"; -import { - findCodexRolloutByThreadId, - getLastCodexMessage, - getLatestCodexPlan, -} from "./codex-session"; -import { - findCopilotPlanContent, - findCopilotSessionForCwd, - getLastCopilotMessage, -} from "./copilot-session"; +import { findCodexRolloutByThreadId, getLastCodexMessage, getLatestCodexPlan } from "./codex-session"; +import { findCopilotPlanContent, findCopilotSessionForCwd, getLastCopilotMessage } from "./copilot-session"; import { formatInteractiveNoArgClarification, formatTopLevelHelp, @@ -144,7 +111,6 @@ import { isVersionInvocation, } from "./cli"; import { ensureClearContextSettingEnabled } from "./clearContextSetting"; -import { shouldAutoSelectNativeClear, spawnKeystrokeInjector } from "./keystrokeInjector"; import path from "path"; import { tmpdir } from "os"; @@ -218,9 +184,7 @@ function emitAnnotateOutcome(result: { if (hookFlag) { if (result.approved || result.exit) return; if (result.feedback) { - console.log( - JSON.stringify({ decision: "block", reason: result.feedback }), - ); + console.log(JSON.stringify({ decision: "block", reason: result.feedback })); } return; } @@ -230,12 +194,7 @@ function emitAnnotateOutcome(result: { } else if (result.exit) { console.log(JSON.stringify({ decision: "dismissed" })); } else { - console.log( - JSON.stringify({ - decision: "annotated", - feedback: result.feedback || "", - }), - ); + console.log(JSON.stringify({ decision: "annotated", feedback: result.feedback || "" })); } return; } @@ -287,17 +246,12 @@ const pasteApiUrl = process.env.PLANNOTATOR_PASTE_URL || undefined; // packages/shared/agents.ts (see header comment there). const originOverride = process.env.PLANNOTATOR_ORIGIN as Origin | undefined; const detectedOrigin: Origin = - originOverride && originOverride in AGENT_CONFIG - ? originOverride - : process.env.CODEX_THREAD_ID - ? "codex" - : process.env.COPILOT_CLI - ? "copilot-cli" - : process.env.OPENCODE - ? "opencode" - : process.env.GEMINI_CLI - ? "gemini-cli" - : "claude-code"; + (originOverride && originOverride in AGENT_CONFIG) ? originOverride : + process.env.CODEX_THREAD_ID ? "codex" : + process.env.COPILOT_CLI ? "copilot-cli" : + process.env.OPENCODE ? "opencode" : + process.env.GEMINI_CLI ? "gemini-cli" : + "claude-code"; if (args[0] === "sessions") { // ============================================ @@ -307,9 +261,7 @@ if (args[0] === "sessions") { if (args.includes("--clean")) { // Force cleanup: list sessions (which auto-removes stale entries) const sessions = listSessions(); - console.error( - `Cleaned up stale sessions. ${sessions.length} active session(s) remain.`, - ); + console.error(`Cleaned up stale sessions. ${sessions.length} active session(s) remain.`); process.exit(0); } @@ -327,9 +279,7 @@ if (args[0] === "sessions") { const n = nArg ? parseInt(nArg, 10) : 1; const session = sessions[n - 1]; if (!session) { - console.error( - `Session #${n} not found. ${sessions.length} active session(s).`, - ); + console.error(`Session #${n} not found. ${sessions.length} active session(s).`); process.exit(1); } await openBrowser(session.url); @@ -341,17 +291,13 @@ if (args[0] === "sessions") { console.error("Active Plannotator sessions:\n"); for (let i = 0; i < sessions.length; i++) { const s = sessions[i]; - const age = Math.round( - (Date.now() - new Date(s.startedAt).getTime()) / 60000, - ); - const ageStr = - age < 60 ? `${age}m` : `${Math.floor(age / 60)}h ${age % 60}m`; - console.error( - ` #${i + 1} ${s.mode.padEnd(9)} ${s.project.padEnd(20)} ${s.url.padEnd(28)} ${ageStr} ago`, - ); + const age = Math.round((Date.now() - new Date(s.startedAt).getTime()) / 60000); + const ageStr = age < 60 ? `${age}m` : `${Math.floor(age / 60)}h ${age % 60}m`; + console.error(` #${i + 1} ${s.mode.padEnd(9)} ${s.project.padEnd(20)} ${s.url.padEnd(28)} ${ageStr} ago`); } console.error(`\nReopen with: plannotator sessions --open [N]`); process.exit(0); + } else if (args[0] === "review") { // ============================================ // CODE REVIEW MODE @@ -379,9 +325,7 @@ if (args[0] === "sessions") { console.error(`Invalid PR/MR URL: ${urlArg}`); console.error("Supported formats:"); console.error(" GitHub: https://github.com/owner/repo/pull/123"); - console.error( - " GitLab: https://gitlab.com/group/project/-/merge_requests/42", - ); + console.error(" GitLab: https://gitlab.com/group/project/-/merge_requests/42"); process.exit(1); } @@ -393,9 +337,7 @@ if (args[0] === "sessions") { } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes("not found") || msg.includes("ENOENT")) { - console.error( - `${cliName === "gh" ? "GitHub" : "GitLab"} CLI (${cliName}) is not installed.`, - ); + console.error(`${cliName === "gh" ? "GitHub" : "GitLab"} CLI (${cliName}) is not installed.`); console.error(`Install it from ${cliUrl}`); } else { console.error(msg); @@ -403,9 +345,7 @@ if (args[0] === "sessions") { process.exit(1); } - console.error( - `Fetching ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)} from ${getDisplayRepo(prRef)}...`, - ); + console.error(`Fetching ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)} from ${getDisplayRepo(prRef)}...`); try { const pr = await fetchPR(prRef); rawPatch = pr.rawPatch; @@ -423,68 +363,42 @@ if (args[0] === "sessions") { let sessionDir: string | undefined; try { const repoDir = process.cwd(); - const identifier = - prMetadata.platform === "github" - ? `${prMetadata.owner}-${prMetadata.repo}-${prMetadata.number}` - : `${prMetadata.projectPath.replace(/\//g, "-")}-${prMetadata.iid}`; + const identifier = prMetadata.platform === "github" + ? `${prMetadata.owner}-${prMetadata.repo}-${prMetadata.number}` + : `${prMetadata.projectPath.replace(/\//g, "-")}-${prMetadata.iid}`; const suffix = Math.random().toString(36).slice(2, 8); // Resolve tmpdir to its real path — on macOS, tmpdir() returns /var/folders/... // but processes report /private/var/folders/... which breaks path stripping. - sessionDir = path.join( - realpathSync(tmpdir()), - `plannotator-pr-${identifier}-${suffix}`, - ); - const prNumber = - prMetadata.platform === "github" ? prMetadata.number : prMetadata.iid; + sessionDir = path.join(realpathSync(tmpdir()), `plannotator-pr-${identifier}-${suffix}`); + const prNumber = prMetadata.platform === "github" ? prMetadata.number : prMetadata.iid; localPath = path.join(sessionDir, "pool", `pr-${prNumber}`); - const fetchRefStr = - prMetadata.platform === "github" - ? `refs/pull/${prMetadata.number}/head` - : `refs/merge-requests/${prMetadata.iid}/head`; + const fetchRefStr = prMetadata.platform === "github" + ? `refs/pull/${prMetadata.number}/head` + : `refs/merge-requests/${prMetadata.iid}/head`; // Validate inputs from platform API to prevent git flag/path injection - if ( - prMetadata.baseBranch.includes("..") || - prMetadata.baseBranch.startsWith("-") - ) - throw new Error(`Invalid base branch: ${prMetadata.baseBranch}`); - if (!/^[0-9a-f]{40,64}$/i.test(prMetadata.baseSha)) - throw new Error(`Invalid base SHA: ${prMetadata.baseSha}`); + if (prMetadata.baseBranch.includes('..') || prMetadata.baseBranch.startsWith('-')) throw new Error(`Invalid base branch: ${prMetadata.baseBranch}`); + if (!/^[0-9a-f]{40,64}$/i.test(prMetadata.baseSha)) throw new Error(`Invalid base SHA: ${prMetadata.baseSha}`); // Detect same-repo vs cross-repo (must match both owner/repo AND host) let isSameRepo = false; try { - const remoteResult = await gitRuntime.runGit([ - "remote", - "get-url", - "origin", - ]); + const remoteResult = await gitRuntime.runGit(["remote", "get-url", "origin"]); if (remoteResult.exitCode === 0) { const remoteUrl = remoteResult.stdout.trim(); const currentRepo = parseRemoteUrl(remoteUrl); - const prRepo = - prMetadata.platform === "github" - ? `${prMetadata.owner}/${prMetadata.repo}` - : prMetadata.projectPath; - const repoMatches = - !!currentRepo && - currentRepo.toLowerCase() === prRepo.toLowerCase(); + const prRepo = prMetadata.platform === "github" + ? `${prMetadata.owner}/${prMetadata.repo}` + : prMetadata.projectPath; + const repoMatches = !!currentRepo && currentRepo.toLowerCase() === prRepo.toLowerCase(); // Extract host from remote URL to avoid cross-instance false positives (GHE) const sshHost = remoteUrl.match(/^[^@]+@([^:]+):/)?.[1]; - const httpsHost = (() => { - try { - return new URL(remoteUrl).hostname; - } catch { - return null; - } - })(); + const httpsHost = (() => { try { return new URL(remoteUrl).hostname; } catch { return null; } })(); const remoteHost = (sshHost || httpsHost || "").toLowerCase(); const prHost = prMetadata.host.toLowerCase(); isSameRepo = repoMatches && remoteHost === prHost; } - } catch { - /* not in a git repo — cross-repo path */ - } + } catch { /* not in a git repo — cross-repo path */ } if (isSameRepo) { // ── Same-repo: fast worktree path ── @@ -494,9 +408,7 @@ if (args[0] === "sessions") { // Both MUST happen before the PR head fetch since FETCH_HEAD is what // createWorktree uses — the PR head fetch must be last. await fetchRef(gitRuntime, prMetadata.baseBranch, { cwd: repoDir }); - await ensureObjectAvailable(gitRuntime, prMetadata.baseSha, { - cwd: repoDir, - }); + await ensureObjectAvailable(gitRuntime, prMetadata.baseSha, { cwd: repoDir }); // Fetch PR head LAST — sets FETCH_HEAD to the PR tip for createWorktree. await fetchRef(gitRuntime, fetchRefStr, { cwd: repoDir }); @@ -509,65 +421,41 @@ if (args[0] === "sessions") { worktreeCleanup = async () => { if (worktreePool) await worktreePool.cleanup(gitRuntime); - try { - rmSync(sessionDir, { recursive: true, force: true }); - } catch {} + try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} }; process.once("exit", () => { // Best-effort sync cleanup: remove each pool worktree from git, then rm session dir try { for (const entry of worktreePool?.entries() ?? []) { - Bun.spawnSync( - ["git", "worktree", "remove", "--force", entry.path], - { cwd: repoDir }, - ); + Bun.spawnSync(["git", "worktree", "remove", "--force", entry.path], { cwd: repoDir }); } } catch {} - try { - Bun.spawnSync(["rm", "-rf", sessionDir]); - } catch {} + try { Bun.spawnSync(["rm", "-rf", sessionDir]); } catch {} }); } else { // ── Cross-repo: shallow clone + fetch PR head ── - const prRepo = - prMetadata.platform === "github" - ? `${prMetadata.owner}/${prMetadata.repo}` - : prMetadata.projectPath; + const prRepo = prMetadata.platform === "github" + ? `${prMetadata.owner}/${prMetadata.repo}` + : prMetadata.projectPath; // Validate repo identifier to prevent flag injection via crafted URLs - if (/^-/.test(prRepo)) - throw new Error(`Invalid repository identifier: ${prRepo}`); + if (/^-/.test(prRepo)) throw new Error(`Invalid repository identifier: ${prRepo}`); const cli = prMetadata.platform === "github" ? "gh" : "glab"; const host = prMetadata.host; // gh/glab repo clone doesn't accept --hostname; set GH_HOST/GITLAB_HOST env instead const isDefaultHost = host === "github.com" || host === "gitlab.com"; - const cloneEnv = isDefaultHost - ? undefined - : { - ...process.env, - ...(prMetadata.platform === "github" - ? { GH_HOST: host } - : { GITLAB_HOST: host }), - }; + const cloneEnv = isDefaultHost ? undefined : { + ...process.env, + ...(prMetadata.platform === "github" ? { GH_HOST: host } : { GITLAB_HOST: host }), + }; // Step 1: Fast skeleton clone (no checkout, depth 1 — minimal data transfer) console.error(`Cloning ${prRepo} (shallow)...`); const cloneResult = Bun.spawnSync( - [ - cli, - "repo", - "clone", - prRepo, - localPath, - "--", - "--depth=1", - "--no-checkout", - ], + [cli, "repo", "clone", prRepo, localPath, "--", "--depth=1", "--no-checkout"], { stderr: "pipe", env: cloneEnv }, ); if (cloneResult.exitCode !== 0) { - throw new Error( - `${cli} repo clone failed: ${new TextDecoder().decode(cloneResult.stderr).trim()}`, - ); + throw new Error(`${cli} repo clone failed: ${new TextDecoder().decode(cloneResult.stderr).trim()}`); } // Step 2: Fetch only the PR head ref (targeted, much faster than full fetch) @@ -576,54 +464,23 @@ if (args[0] === "sessions") { ["git", "fetch", "--depth=200", "origin", fetchRefStr], { cwd: localPath, stderr: "pipe" }, ); - if (fetchResult.exitCode !== 0) - throw new Error( - `Failed to fetch PR head ref: ${new TextDecoder().decode(fetchResult.stderr).trim()}`, - ); + if (fetchResult.exitCode !== 0) throw new Error(`Failed to fetch PR head ref: ${new TextDecoder().decode(fetchResult.stderr).trim()}`); // Step 3: Checkout PR head (critical — if this fails, worktree is empty) - const checkoutResult = Bun.spawnSync( - ["git", "checkout", "FETCH_HEAD"], - { cwd: localPath, stderr: "pipe" }, - ); + const checkoutResult = Bun.spawnSync(["git", "checkout", "FETCH_HEAD"], { cwd: localPath, stderr: "pipe" }); if (checkoutResult.exitCode !== 0) { - throw new Error( - `git checkout FETCH_HEAD failed: ${new TextDecoder().decode(checkoutResult.stderr).trim()}`, - ); + throw new Error(`git checkout FETCH_HEAD failed: ${new TextDecoder().decode(checkoutResult.stderr).trim()}`); } // Best-effort: create base refs so `git diff main...HEAD` and `git diff origin/main...HEAD` work - const baseFetch = Bun.spawnSync( - ["git", "fetch", "--depth=200", "origin", prMetadata.baseSha], - { cwd: localPath, stderr: "pipe" }, - ); - if (baseFetch.exitCode !== 0) - console.error( - "Warning: failed to fetch baseSha, agent diffs may be inaccurate", - ); - Bun.spawnSync( - ["git", "branch", "--", prMetadata.baseBranch, prMetadata.baseSha], - { cwd: localPath, stderr: "pipe" }, - ); - Bun.spawnSync( - [ - "git", - "update-ref", - `refs/remotes/origin/${prMetadata.baseBranch}`, - prMetadata.baseSha, - ], - { cwd: localPath, stderr: "pipe" }, - ); + const baseFetch = Bun.spawnSync(["git", "fetch", "--depth=200", "origin", prMetadata.baseSha], { cwd: localPath, stderr: "pipe" }); + if (baseFetch.exitCode !== 0) console.error("Warning: failed to fetch baseSha, agent diffs may be inaccurate"); + Bun.spawnSync(["git", "branch", "--", prMetadata.baseBranch, prMetadata.baseSha], { cwd: localPath, stderr: "pipe" }); + Bun.spawnSync(["git", "update-ref", `refs/remotes/origin/${prMetadata.baseBranch}`, prMetadata.baseSha], { cwd: localPath, stderr: "pipe" }); - worktreeCleanup = () => { - try { - rmSync(sessionDir, { recursive: true, force: true }); - } catch {} - }; + worktreeCleanup = () => { try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} }; process.once("exit", () => { - try { - Bun.spawnSync(["rm", "-rf", sessionDir]); - } catch {} + try { Bun.spawnSync(["rm", "-rf", sessionDir]); } catch {} }); } @@ -634,22 +491,14 @@ if (args[0] === "sessions") { // Create worktree pool with the initial PR as the first entry worktreePool = createWorktreePool( { sessionDir, repoDir, isSameRepo }, - { - path: localPath, - prUrl: prMetadata.url, - number: prNumber, - ready: true, - }, + { path: localPath, prUrl: prMetadata.url, number: prNumber, ready: true }, ); console.error(`Local checkout ready at ${localPath}`); } catch (err) { console.error(`Warning: --local failed, falling back to remote diff`); console.error(err instanceof Error ? err.message : String(err)); - if (sessionDir) - try { - rmSync(sessionDir, { recursive: true, force: true }); - } catch {} + if (sessionDir) try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} agentCwd = undefined; worktreePool = undefined; worktreeCleanup = undefined; @@ -691,12 +540,7 @@ if (args[0] === "sessions") { handleReviewServerReady(url, isRemote, port); if (isRemote && sharingEnabled && rawPatch) { - await writeRemoteShareLink( - rawPatch, - shareBaseUrl, - "review changes", - "diff only", - ).catch(() => {}); + await writeRemoteShareLink(rawPatch, shareBaseUrl, "review changes", "diff only").catch(() => {}); } }, }); @@ -708,9 +552,7 @@ if (args[0] === "sessions") { mode: "review", project: reviewProject, startedAt: new Date().toISOString(), - label: isPRMode - ? `${getMRLabel(prMetadata!).toLowerCase()}-review-${getDisplayRepo(prMetadata!)}${getMRNumberLabel(prMetadata!)}` - : `review-${reviewProject}`, + label: isPRMode ? `${getMRLabel(prMetadata!).toLowerCase()}-review-${getDisplayRepo(prMetadata!)}${getMRNumberLabel(prMetadata!)}` : `review-${reviewProject}`, }); // Wait for user feedback @@ -734,6 +576,7 @@ if (args[0] === "sessions") { } } process.exit(0); + } else if (args[0] === "annotate") { // ============================================ // ANNOTATE MODE @@ -741,9 +584,7 @@ if (args[0] === "sessions") { const rawFilePath = args[1]; if (!rawFilePath) { - console.error( - "Usage: plannotator annotate [--no-jina] [--gate] [--json] [--hook]", - ); + console.error("Usage: plannotator annotate [--no-jina] [--gate] [--json] [--hook]"); process.exit(1); } @@ -773,46 +614,31 @@ if (args[0] === "sessions") { if (isUrl) { const useJina = resolveUseJina(cliNoJina, loadConfig()); - console.error( - `Fetching: ${filePath}${useJina ? " (via Jina Reader)" : " (via fetch+Turndown)"}`, - ); + console.error(`Fetching: ${filePath}${useJina ? " (via Jina Reader)" : " (via fetch+Turndown)"}`); try { const result = await urlToMarkdown(filePath, { useJina }); markdown = result.markdown; sourceConverted = isConvertedSource(result.source); if (process.env.PLANNOTATOR_DEBUG) { - console.error( - `[DEBUG] Fetched via ${result.source} (${markdown.length} chars)`, - ); + console.error(`[DEBUG] Fetched via ${result.source} (${markdown.length} chars)`); } } catch (err) { - console.error( - `Failed to fetch URL: ${err instanceof Error ? err.message : String(err)}`, - ); + console.error(`Failed to fetch URL: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); } absolutePath = filePath; // Use URL as the "path" for display - sourceInfo = filePath; // Full URL for source attribution + sourceInfo = filePath; // Full URL for source attribution } else { // Folder check with literal-@ fallback for scoped-package-style names. const folderCandidate = resolveAtReference(rawFilePath, (c) => { - try { - return statSync(resolveUserPath(c, projectRoot)).isDirectory(); - } catch { - return false; - } + try { return statSync(resolveUserPath(c, projectRoot)).isDirectory(); } + catch { return false; } }); if (folderCandidate !== null) { const resolvedArg = resolveUserPath(folderCandidate, projectRoot); // Folder annotation mode (markdown + HTML files) - if ( - !hasMarkdownFiles( - resolvedArg, - FILE_BROWSER_EXCLUDED, - /\.(mdx?|html?)$/i, - ) - ) { + if (!hasMarkdownFiles(resolvedArg, FILE_BROWSER_EXCLUDED, /\.(mdx?|html?)$/i)) { console.error(`No markdown or HTML files found in ${resolvedArg}`); process.exit(1); } @@ -832,9 +658,7 @@ if (args[0] === "sessions") { const resolvedArg = resolveUserPath(htmlCandidate, projectRoot); const htmlFile = Bun.file(resolvedArg); if (htmlFile.size > 10 * 1024 * 1024) { - console.error( - `File too large (${Math.round(htmlFile.size / 1024 / 1024)}MB, max 10MB): ${resolvedArg}`, - ); + console.error(`File too large (${Math.round(htmlFile.size / 1024 / 1024)}MB, max 10MB): ${resolvedArg}`); process.exit(1); } const html = await htmlFile.text(); @@ -857,9 +681,7 @@ if (args[0] === "sessions") { } if (resolved.kind === "ambiguous") { - console.error( - `Ambiguous filename "${resolved.input}" — found ${resolved.matches.length} matches:`, - ); + console.error(`Ambiguous filename "${resolved.input}" — found ${resolved.matches.length} matches:`); for (const match of resolved.matches) { console.error(` ${match}`); } @@ -899,12 +721,7 @@ if (args[0] === "sessions") { handleAnnotateServerReady(url, isRemote, port); if (isRemote && sharingEnabled && markdown) { - await writeRemoteShareLink( - markdown, - shareBaseUrl, - "annotate", - "document only", - ).catch(() => {}); + await writeRemoteShareLink(markdown, shareBaseUrl, "annotate", "document only").catch(() => {}); } }, }); @@ -933,6 +750,7 @@ if (args[0] === "sessions") { // Output feedback (captured by slash command) emitAnnotateOutcome(result); process.exit(0); + } else if (args[0] === "annotate-last" || args[0] === "last") { // ============================================ // ANNOTATE LAST MESSAGE MODE @@ -956,11 +774,7 @@ if (args[0] === "sessions") { } const msg = getLastCodexMessage(rolloutPath); if (msg) { - lastMessage = { - messageId: codexThreadId, - text: msg.text, - lineNumbers: [], - }; + lastMessage = { messageId: codexThreadId, text: msg.text, lineNumbers: [] }; } } } else { @@ -990,9 +804,7 @@ if (args[0] === "sessions") { if (lastMessage) return; const paths = getPaths(); if (process.env.PLANNOTATOR_DEBUG) { - console.error( - `[DEBUG] ${label}: ${paths.length ? paths.join(", ") : "(none)"}`, - ); + console.error(`[DEBUG] ${label}: ${paths.length ? paths.join(", ") : "(none)"}`); } for (const logPath of paths) { lastMessage = getLastRenderedMessage(logPath); @@ -1002,25 +814,17 @@ if (args[0] === "sessions") { // 1. Walk ancestor PIDs for a matching session metadata file const ancestorLog = resolveSessionLogByAncestorPids(); - tryLogCandidates("Ancestor PID session metadata", () => - ancestorLog ? [ancestorLog] : [], - ); + tryLogCandidates("Ancestor PID session metadata", () => ancestorLog ? [ancestorLog] : []); // 2. Scan all session metadata files for one whose cwd matches const cwdScanLog = resolveSessionLogByCwdScan({ cwd: projectRoot }); - tryLogCandidates("Cwd-scan session metadata", () => - cwdScanLog ? [cwdScanLog] : [], - ); + tryLogCandidates("Cwd-scan session metadata", () => cwdScanLog ? [cwdScanLog] : []); // 3. Fall back to CWD slug match (mtime-based) - tryLogCandidates("CWD slug match (mtime)", () => - findSessionLogsForCwd(projectRoot), - ); + tryLogCandidates("CWD slug match (mtime)", () => findSessionLogsForCwd(projectRoot)); // 4. Fall back to ancestor directory walk - tryLogCandidates("Directory ancestor walk", () => - findSessionLogsByAncestorWalk(projectRoot), - ); + tryLogCandidates("Directory ancestor walk", () => findSessionLogsByAncestorWalk(projectRoot)); } if (!lastMessage) { @@ -1029,9 +833,7 @@ if (args[0] === "sessions") { } if (process.env.PLANNOTATOR_DEBUG) { - console.error( - `[DEBUG] Found message ${lastMessage.messageId} (${lastMessage.text.length} chars)`, - ); + console.error(`[DEBUG] Found message ${lastMessage.messageId} (${lastMessage.text.length} chars)`); } const annotateProject = (await detectProjectName()) ?? "_unknown"; @@ -1050,12 +852,7 @@ if (args[0] === "sessions") { handleAnnotateServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink( - lastMessage.text, - shareBaseUrl, - "annotate", - "message only", - ).catch(() => {}); + await writeRemoteShareLink(lastMessage.text, shareBaseUrl, "annotate", "message only").catch(() => {}); } }, }); @@ -1078,6 +875,7 @@ if (args[0] === "sessions") { emitAnnotateOutcome(result); process.exit(0); + } else if (args[0] === "archive") { // ============================================ // ARCHIVE BROWSER MODE @@ -1112,6 +910,7 @@ if (args[0] === "sessions") { await Bun.sleep(500); server.stop(); process.exit(0); + } else if (args[0] === "copilot-plan") { // ============================================ // COPILOT CLI PLAN INTERCEPTION MODE @@ -1122,13 +921,7 @@ if (args[0] === "sessions") { // No output = allow the tool call to proceed. const eventJson = await Bun.stdin.text(); - let event: { - toolName: string; - toolArgs: string; - cwd: string; - timestamp: number; - sessionId?: string; - }; + let event: { toolName: string; toolArgs: string; cwd: string; timestamp: number; sessionId?: string }; try { event = JSON.parse(eventJson); @@ -1163,12 +956,7 @@ if (args[0] === "sessions") { handleServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink( - planContent, - shareBaseUrl, - "review the plan", - "plan only", - ).catch(() => {}); + await writeRemoteShareLink(planContent, shareBaseUrl, "review the plan", "plan only").catch(() => {}); } }, }); @@ -1189,26 +977,23 @@ if (args[0] === "sessions") { // Output Copilot CLI permission decision format if (result.approved) { - console.log( - JSON.stringify({ - permissionDecision: "allow", - }), - ); + console.log(JSON.stringify({ + permissionDecision: "allow", + })); } else { const feedback = getPlanDeniedPrompt("copilot-cli", undefined, { toolName: getPlanToolName("copilot-cli"), planFileRule: "", feedback: result.feedback || "Plan changes requested", }); - console.log( - JSON.stringify({ - permissionDecision: "deny", - permissionDecisionReason: feedback, - }), - ); + console.log(JSON.stringify({ + permissionDecision: "deny", + permissionDecisionReason: feedback, + })); } process.exit(0); + } else if (args[0] === "copilot-last") { // ============================================ // COPILOT CLI ANNOTATE LAST MESSAGE MODE @@ -1217,9 +1002,7 @@ if (args[0] === "sessions") { const projectRoot = process.env.PLANNOTATOR_CWD || process.cwd(); if (process.env.PLANNOTATOR_DEBUG) { - console.error( - `[DEBUG] Copilot CLI detected, finding session for CWD: ${projectRoot}`, - ); + console.error(`[DEBUG] Copilot CLI detected, finding session for CWD: ${projectRoot}`); } const sessionDir = findCopilotSessionForCwd(projectRoot); @@ -1258,12 +1041,7 @@ if (args[0] === "sessions") { handleAnnotateServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink( - msg.text, - shareBaseUrl, - "annotate", - "message only", - ).catch(() => {}); + await writeRemoteShareLink(msg.text, shareBaseUrl, "annotate", "message only").catch(() => {}); } }, }); @@ -1284,6 +1062,7 @@ if (args[0] === "sessions") { emitAnnotateOutcome(result); process.exit(0); + } else if (args[0] === "improve-context") { // ============================================ // IMPROVEMENT HOOK CONTEXT INJECTION MODE @@ -1306,16 +1085,15 @@ if (args[0] === "sessions") { if (context === null) process.exit(0); - console.log( - JSON.stringify({ - hookSpecificOutput: { - hookEventName: "PreToolUse", - additionalContext: context, - }, - }), - ); + console.log(JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + additionalContext: context, + }, + })); process.exit(0); + } else { // ============================================ // PLAN REVIEW MODE (default) @@ -1367,12 +1145,7 @@ if (args[0] === "sessions") { handleServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink( - latestPlan.text, - shareBaseUrl, - "review the plan", - "plan only", - ).catch(() => {}); + await writeRemoteShareLink(latestPlan.text, shareBaseUrl, "review the plan", "plan only").catch(() => {}); } }, }); @@ -1402,7 +1175,7 @@ if (args[0] === "sessions") { planFileRule: "", feedback: result.feedback || "Plan changes requested", }), - }), + }) ); } @@ -1415,8 +1188,7 @@ if (args[0] === "sessions") { let planFilename = ""; // Detect harness: Gemini sends plan_filename (file on disk), Claude Code sends plan (inline) - planFilename = - event.tool_input?.plan_filename || event.tool_input?.plan_path || ""; + planFilename = event.tool_input?.plan_filename || event.tool_input?.plan_path || ""; isGemini = !!planFilename; if (isGemini) { @@ -1424,12 +1196,7 @@ if (args[0] === "sessions") { // transcript_path = /chats/session-...json // plan lives at = //plans/ const projectTempDir = path.dirname(path.dirname(event.transcript_path)); - const planFilePath = path.join( - projectTempDir, - event.session_id, - "plans", - planFilename, - ); + const planFilePath = path.join(projectTempDir, event.session_id, "plans", planFilename); planContent = await Bun.file(planFilePath).text(); } else { planContent = event.tool_input?.plan || ""; @@ -1464,12 +1231,7 @@ if (args[0] === "sessions") { handleServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink( - planContent, - shareBaseUrl, - "review the plan", - "plan only", - ).catch(() => {}); + await writeRemoteShareLink(planContent, shareBaseUrl, "review the plan", "plan only").catch(() => {}); } }, }); @@ -1496,24 +1258,17 @@ if (args[0] === "sessions") { // Output decision in the appropriate format for the harness if (isGemini) { if (result.approved) { - console.log( - result.feedback - ? JSON.stringify({ systemMessage: result.feedback }) - : "{}", - ); + console.log(result.feedback ? JSON.stringify({ systemMessage: result.feedback }) : "{}"); } else { console.log( JSON.stringify({ decision: "deny", reason: getPlanDeniedPrompt("gemini-cli", undefined, { toolName: getPlanToolName("gemini-cli"), - planFileRule: buildPlanFileRule( - getPlanToolName("gemini-cli"), - planFilename, - ), + planFileRule: buildPlanFileRule(getPlanToolName("gemini-cli"), planFilename), feedback: result.feedback || "Plan changes requested", }), - }), + }) ); } } else { @@ -1525,9 +1280,6 @@ if (args[0] === "sessions") { ) { const nativeClearEnabled = await ensureClearContextSettingEnabled(); if (nativeClearEnabled) { - if (shouldAutoSelectNativeClear()) { - spawnKeystrokeInjector(); - } process.exit(0); } result.clearContextNudge = true; @@ -1557,7 +1309,7 @@ if (args[0] === "sessions") { ...(updatedPermissions.length > 0 && { updatedPermissions }), }, }, - }), + }) ); } else { console.log( @@ -1573,7 +1325,7 @@ if (args[0] === "sessions") { }), }, }, - }), + }) ); } } diff --git a/apps/hook/server/keystrokeInjector.test.ts b/apps/hook/server/keystrokeInjector.test.ts deleted file mode 100644 index 5391a6d94..000000000 --- a/apps/hook/server/keystrokeInjector.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { - describe, - it, - expect, - spyOn, - mock, - beforeEach, - afterEach, -} from "bun:test"; -import { shouldAutoSelectNativeClear, spawnKeystrokeInjector } from "./keystrokeInjector"; - -describe("shouldAutoSelectNativeClear", () => { - it("defaults to false", () => { - expect(shouldAutoSelectNativeClear({} as NodeJS.ProcessEnv)).toBe(false); - }); - - it("is true only for PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR=1", () => { - expect( - shouldAutoSelectNativeClear({ - PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR: "1", - } as NodeJS.ProcessEnv), - ).toBe(true); - expect( - shouldAutoSelectNativeClear({ - PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR: "true", - } as NodeJS.ProcessEnv), - ).toBe(false); - }); -}); - -describe("spawnKeystrokeInjector", () => { - let spawnCalls: { cmd: string[]; opts: Record }[] = []; - let originalEnv: NodeJS.ProcessEnv; - let originalPlatform: string; - - function setPlatform(v: string) { - Object.defineProperty(process, "platform", { value: v, writable: true }); - } - - beforeEach(() => { - spawnCalls = []; - originalEnv = { ...process.env }; - originalPlatform = process.platform; - delete process.env["TMUX_PANE"]; - - const mockChild = { unref: mock(() => {}) }; - spyOn(Bun, "spawn").mockImplementation( - (cmd: string[], opts: Record) => { - spawnCalls.push({ cmd, opts }); - return mockChild as ReturnType; - }, - ); - }); - - afterEach(() => { - process.env = originalEnv; - Object.defineProperty(process, "platform", { - value: originalPlatform, - writable: true, - }); - mock.restore(); - }); - - it("uses tmux send-keys when TMUX_PANE is set", () => { - process.env["TMUX_PANE"] = "%3"; - spawnKeystrokeInjector(100); - - expect(spawnCalls).toHaveLength(1); - expect(spawnCalls[0].cmd[0]).toBe("bash"); - const script = spawnCalls[0].cmd[2] as string; - expect(script).toContain("tmux send-keys"); - expect(script).toContain("%3"); - expect(script).toContain("1 Enter"); - expect(script).not.toContain("osascript"); - }); - - it("uses osascript on macOS when no tmux pane", () => { - setPlatform("darwin"); - spawnKeystrokeInjector(100); - - expect(spawnCalls).toHaveLength(1); - const script = spawnCalls[0].cmd[2] as string; - expect(script).toContain("osascript"); - expect(script).toContain('application "Warp" is running'); - expect(script).toContain("iTerm2"); - expect(script).toContain("Terminal"); - expect(script).toContain('keystroke "1"'); - expect(script).not.toContain("tmux send-keys"); - }); - - it("no-op on linux without tmux", () => { - setPlatform("linux"); - spawnKeystrokeInjector(); - expect(spawnCalls).toHaveLength(0); - }); - - it("no-op on windows without tmux", () => { - setPlatform("win32"); - spawnKeystrokeInjector(); - expect(spawnCalls).toHaveLength(0); - }); - - it("spawn is detached and unreffed (does not block caller)", () => { - setPlatform("darwin"); - spawnKeystrokeInjector(); - - expect(spawnCalls).toHaveLength(1); - const opts = spawnCalls[0].opts as { detached: boolean; stdio: string[] }; - expect(opts.detached).toBe(true); - expect(opts.stdio).toEqual(["ignore", "ignore", "ignore"]); - }); - - it("embeds delay in tmux script via sleep", () => { - process.env["TMUX_PANE"] = "%0"; - spawnKeystrokeInjector(1200); - const script = spawnCalls[0].cmd[2] as string; - expect(script).toContain("sleep 1.20"); - }); - - it("embeds delay in osascript via 'delay' statement", () => { - setPlatform("darwin"); - spawnKeystrokeInjector(800); - const script = spawnCalls[0].cmd[2] as string; - expect(script).toContain("delay 0.80"); - }); -}); diff --git a/apps/hook/server/keystrokeInjector.ts b/apps/hook/server/keystrokeInjector.ts deleted file mode 100644 index 81bc816f8..000000000 --- a/apps/hook/server/keystrokeInjector.ts +++ /dev/null @@ -1,54 +0,0 @@ -// Opt-in helper that injects "1\n" into CC terminal to auto-select native clear + bypass. -const MACOS_PROCESS_TERMINALS = ["iTerm2", "Terminal"]; - -export function shouldAutoSelectNativeClear(env: NodeJS.ProcessEnv = process.env): boolean { - return env["PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR"] === "1"; -} - -export function spawnKeystrokeInjector(delayMs = 600): void { - const delaySec = (delayMs / 1000).toFixed(2); - const tmuxPane = process.env["TMUX_PANE"]; - - let script: string | null = null; - - if (tmuxPane) { - script = `sleep ${delaySec} && tmux send-keys -t ${JSON.stringify(tmuxPane)} 1 Enter`; - } else if (process.platform === "darwin") { - const apps = MACOS_PROCESS_TERMINALS.map((a) => `"${a}"`).join(", "); - // Warp ships as Warp.app/MacOS/stable so its process name is "stable", not "warp". - // Check by bundle name first, then fall back to process-name search for other terminals. - script = [ - `osascript <<'APPLESCRIPT'`, - `delay ${delaySec}`, - `if application "Warp" is running then`, - ` tell application "Warp" to activate`, - ` delay 0.05`, - ` tell application "System Events"`, - ` keystroke "1"`, - ` key code 36`, - ` end tell`, - `else`, - ` tell application "System Events"`, - ` repeat with appName in {${apps}}`, - ` if exists (application process (appName as string)) then`, - ` set frontmost of application process (appName as string) to true`, - ` delay 0.05`, - ` keystroke "1"`, - ` key code 36`, - ` exit repeat`, - ` end if`, - ` end repeat`, - ` end tell`, - `end if`, - `APPLESCRIPT`, - ].join("\n"); - } - - if (!script) return; // Linux/Windows without tmux: user must press 1 manually - - const child = Bun.spawn(["bash", "-c", script], { - detached: true, - stdio: ["ignore", "ignore", "ignore"], - }); - child.unref(); -} From ea2ad0fadbf3199ec9f83091b48a914e58a60031 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Thu, 14 May 2026 15:58:52 +0700 Subject: [PATCH 09/34] omx(team): auto-checkpoint worker-4 [unknown] --- apps/hook/server/index.ts | 4 + apps/hook/server/keystrokeInjector.test.ts | 126 +++++++++++++++++++++ apps/hook/server/keystrokeInjector.ts | 54 +++++++++ 3 files changed, 184 insertions(+) create mode 100644 apps/hook/server/keystrokeInjector.test.ts create mode 100644 apps/hook/server/keystrokeInjector.ts diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 1bbdb6c7f..3eb91bda0 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -111,6 +111,7 @@ import { isVersionInvocation, } from "./cli"; import { ensureClearContextSettingEnabled } from "./clearContextSetting"; +import { shouldAutoSelectNativeClear, spawnKeystrokeInjector } from "./keystrokeInjector"; import path from "path"; import { tmpdir } from "os"; @@ -1280,6 +1281,9 @@ if (args[0] === "sessions") { ) { const nativeClearEnabled = await ensureClearContextSettingEnabled(); if (nativeClearEnabled) { + if (shouldAutoSelectNativeClear()) { + spawnKeystrokeInjector(); + } process.exit(0); } result.clearContextNudge = true; diff --git a/apps/hook/server/keystrokeInjector.test.ts b/apps/hook/server/keystrokeInjector.test.ts new file mode 100644 index 000000000..5391a6d94 --- /dev/null +++ b/apps/hook/server/keystrokeInjector.test.ts @@ -0,0 +1,126 @@ +import { + describe, + it, + expect, + spyOn, + mock, + beforeEach, + afterEach, +} from "bun:test"; +import { shouldAutoSelectNativeClear, spawnKeystrokeInjector } from "./keystrokeInjector"; + +describe("shouldAutoSelectNativeClear", () => { + it("defaults to false", () => { + expect(shouldAutoSelectNativeClear({} as NodeJS.ProcessEnv)).toBe(false); + }); + + it("is true only for PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR=1", () => { + expect( + shouldAutoSelectNativeClear({ + PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR: "1", + } as NodeJS.ProcessEnv), + ).toBe(true); + expect( + shouldAutoSelectNativeClear({ + PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR: "true", + } as NodeJS.ProcessEnv), + ).toBe(false); + }); +}); + +describe("spawnKeystrokeInjector", () => { + let spawnCalls: { cmd: string[]; opts: Record }[] = []; + let originalEnv: NodeJS.ProcessEnv; + let originalPlatform: string; + + function setPlatform(v: string) { + Object.defineProperty(process, "platform", { value: v, writable: true }); + } + + beforeEach(() => { + spawnCalls = []; + originalEnv = { ...process.env }; + originalPlatform = process.platform; + delete process.env["TMUX_PANE"]; + + const mockChild = { unref: mock(() => {}) }; + spyOn(Bun, "spawn").mockImplementation( + (cmd: string[], opts: Record) => { + spawnCalls.push({ cmd, opts }); + return mockChild as ReturnType; + }, + ); + }); + + afterEach(() => { + process.env = originalEnv; + Object.defineProperty(process, "platform", { + value: originalPlatform, + writable: true, + }); + mock.restore(); + }); + + it("uses tmux send-keys when TMUX_PANE is set", () => { + process.env["TMUX_PANE"] = "%3"; + spawnKeystrokeInjector(100); + + expect(spawnCalls).toHaveLength(1); + expect(spawnCalls[0].cmd[0]).toBe("bash"); + const script = spawnCalls[0].cmd[2] as string; + expect(script).toContain("tmux send-keys"); + expect(script).toContain("%3"); + expect(script).toContain("1 Enter"); + expect(script).not.toContain("osascript"); + }); + + it("uses osascript on macOS when no tmux pane", () => { + setPlatform("darwin"); + spawnKeystrokeInjector(100); + + expect(spawnCalls).toHaveLength(1); + const script = spawnCalls[0].cmd[2] as string; + expect(script).toContain("osascript"); + expect(script).toContain('application "Warp" is running'); + expect(script).toContain("iTerm2"); + expect(script).toContain("Terminal"); + expect(script).toContain('keystroke "1"'); + expect(script).not.toContain("tmux send-keys"); + }); + + it("no-op on linux without tmux", () => { + setPlatform("linux"); + spawnKeystrokeInjector(); + expect(spawnCalls).toHaveLength(0); + }); + + it("no-op on windows without tmux", () => { + setPlatform("win32"); + spawnKeystrokeInjector(); + expect(spawnCalls).toHaveLength(0); + }); + + it("spawn is detached and unreffed (does not block caller)", () => { + setPlatform("darwin"); + spawnKeystrokeInjector(); + + expect(spawnCalls).toHaveLength(1); + const opts = spawnCalls[0].opts as { detached: boolean; stdio: string[] }; + expect(opts.detached).toBe(true); + expect(opts.stdio).toEqual(["ignore", "ignore", "ignore"]); + }); + + it("embeds delay in tmux script via sleep", () => { + process.env["TMUX_PANE"] = "%0"; + spawnKeystrokeInjector(1200); + const script = spawnCalls[0].cmd[2] as string; + expect(script).toContain("sleep 1.20"); + }); + + it("embeds delay in osascript via 'delay' statement", () => { + setPlatform("darwin"); + spawnKeystrokeInjector(800); + const script = spawnCalls[0].cmd[2] as string; + expect(script).toContain("delay 0.80"); + }); +}); diff --git a/apps/hook/server/keystrokeInjector.ts b/apps/hook/server/keystrokeInjector.ts new file mode 100644 index 000000000..81bc816f8 --- /dev/null +++ b/apps/hook/server/keystrokeInjector.ts @@ -0,0 +1,54 @@ +// Opt-in helper that injects "1\n" into CC terminal to auto-select native clear + bypass. +const MACOS_PROCESS_TERMINALS = ["iTerm2", "Terminal"]; + +export function shouldAutoSelectNativeClear(env: NodeJS.ProcessEnv = process.env): boolean { + return env["PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR"] === "1"; +} + +export function spawnKeystrokeInjector(delayMs = 600): void { + const delaySec = (delayMs / 1000).toFixed(2); + const tmuxPane = process.env["TMUX_PANE"]; + + let script: string | null = null; + + if (tmuxPane) { + script = `sleep ${delaySec} && tmux send-keys -t ${JSON.stringify(tmuxPane)} 1 Enter`; + } else if (process.platform === "darwin") { + const apps = MACOS_PROCESS_TERMINALS.map((a) => `"${a}"`).join(", "); + // Warp ships as Warp.app/MacOS/stable so its process name is "stable", not "warp". + // Check by bundle name first, then fall back to process-name search for other terminals. + script = [ + `osascript <<'APPLESCRIPT'`, + `delay ${delaySec}`, + `if application "Warp" is running then`, + ` tell application "Warp" to activate`, + ` delay 0.05`, + ` tell application "System Events"`, + ` keystroke "1"`, + ` key code 36`, + ` end tell`, + `else`, + ` tell application "System Events"`, + ` repeat with appName in {${apps}}`, + ` if exists (application process (appName as string)) then`, + ` set frontmost of application process (appName as string) to true`, + ` delay 0.05`, + ` keystroke "1"`, + ` key code 36`, + ` exit repeat`, + ` end if`, + ` end repeat`, + ` end tell`, + `end if`, + `APPLESCRIPT`, + ].join("\n"); + } + + if (!script) return; // Linux/Windows without tmux: user must press 1 manually + + const child = Bun.spawn(["bash", "-c", script], { + detached: true, + stdio: ["ignore", "ignore", "ignore"], + }); + child.unref(); +} From 2e1b50e72cb2ae7ad1a3dece1b1bf142344011d1 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Thu, 14 May 2026 16:02:16 +0700 Subject: [PATCH 10/34] omx(team): auto-checkpoint worker-4 [unknown] --- apps/pi-extension/server/serverPlan.ts | 10 ++++++++-- packages/server/index.ts | 8 ++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index 5b95e74ed..5db3c6507 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -1,5 +1,7 @@ import { randomUUID } from "node:crypto"; import { createServer } from "node:http"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { contentHash, deleteDraft } from "../generated/draft.js"; import { @@ -37,7 +39,7 @@ import { import { listenOnPort } from "./network.js"; import { loadConfig, saveConfig, detectGitUser, getServerConfig } from "../generated/config.js"; -import { readImprovementHook, getImprovementHookExpectedPath } from "../generated/improvement-hooks.js"; +import { readImprovementHook } from "../generated/improvement-hooks.js"; import { composeImproveContext } from "../generated/pfm-reminder.js"; import { detectProjectName, getRepoInfo } from "./project.js"; import { @@ -50,6 +52,10 @@ import { } from "./reference.js"; import { warmFileListCache } from "../generated/resolve-file.js"; +function getEnterPlanModeImproveHookExpectedPath(): string { + return join(homedir(), ".plannotator", "hooks", "compound", "enterplanmode-improve-hook.txt"); +} + export interface PlanReviewDecision { approved: boolean; feedback?: string; @@ -239,7 +245,7 @@ export async function startPlanReviewServer(options: { pfmReminder: { enabled: pfmEnabled }, improvementHook: { present: !!hook, - filePath: hook?.filePath ?? getImprovementHookExpectedPath("enterplanmode-improve"), + filePath: hook?.filePath ?? getEnterPlanModeImproveHookExpectedPath(), fileSize: hook?.content?.length ?? null, content: hook?.content ?? null, }, diff --git a/packages/server/index.ts b/packages/server/index.ts index 7a354bd0f..b6a6501e7 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -45,7 +45,7 @@ import { import { getRepoInfo } from "./repo"; import { detectProjectName } from "./project"; import { loadConfig, saveConfig, detectGitUser, getServerConfig } from "./config"; -import { readImprovementHook, getImprovementHookExpectedPath } from "@plannotator/shared/improvement-hooks"; +import { readImprovementHook } from "@plannotator/shared/improvement-hooks"; import { composeImproveContext } from "@plannotator/shared/pfm-reminder"; import { handleImage, handleUpload, handleAgents, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, handleFavicon, type OpencodeClient } from "./shared-handlers"; import { contentHash, deleteDraft } from "./draft"; @@ -63,6 +63,10 @@ export * from "./storage"; export { handleServerReady } from "./shared-handlers"; export { type VaultNode, buildFileTree } from "@plannotator/shared/reference-common"; +function getEnterPlanModeImproveHookExpectedPath(): string { + return join(homedir(), ".plannotator", "hooks", "compound", "enterplanmode-improve-hook.txt"); +} + // --- Types --- export interface ServerOptions { @@ -379,7 +383,7 @@ export async function startPlannotatorServer( pfmReminder: { enabled: pfmEnabled }, improvementHook: { present: !!hook, - filePath: hook?.filePath ?? getImprovementHookExpectedPath("enterplanmode-improve"), + filePath: hook?.filePath ?? getEnterPlanModeImproveHookExpectedPath(), fileSize: hook?.content?.length ?? null, content: hook?.content ?? null, }, From 95b47f6b5390c03b2cde91f23c18d7f0b2c393f9 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 5 May 2026 12:18:18 -0700 Subject: [PATCH 11/34] Install Plannotator command skills under Codex home (#669) * Install Plannotator skills under Codex home * Keep shared Plannotator skills in agent scope * Harden scoped skill migration --- scripts/install.cmd | 1 - scripts/install.ps1 | 1 - scripts/install.sh | 1 - scripts/install.test.ts | 8 ++++---- 4 files changed, 4 insertions(+), 7 deletions(-) diff --git a/scripts/install.cmd b/scripts/install.cmd index a150f7447..0594b5cdf 100644 --- a/scripts/install.cmd +++ b/scripts/install.cmd @@ -531,7 +531,6 @@ if !ERRORLEVEL! equ 0 ( xcopy /s /y /q "apps\skills\*" "!CLAUDE_SKILLS_DIR!\" >nul 2>&1 if exist "apps\skills\plannotator-compound" xcopy /s /i /y /q "apps\skills\plannotator-compound" "!AGENTS_SKILLS_DIR!\plannotator-compound\" >nul 2>&1 if exist "apps\skills\plannotator-setup-goal" xcopy /s /i /y /q "apps\skills\plannotator-setup-goal" "!AGENTS_SKILLS_DIR!\plannotator-setup-goal\" >nul 2>&1 - if exist "apps\skills\plannotator-visual-explainer" xcopy /s /i /y /q "apps\skills\plannotator-visual-explainer" "!AGENTS_SKILLS_DIR!\plannotator-visual-explainer\" >nul 2>&1 if "!CODEX_AVAILABLE!"=="1" ( if not exist "!CODEX_SKILLS_DIR!" mkdir "!CODEX_SKILLS_DIR!" if exist "apps\skills\plannotator-review" xcopy /s /i /y /q "apps\skills\plannotator-review" "!CODEX_SKILLS_DIR!\plannotator-review\" >nul 2>&1 diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 843b13780..3cfeca2c6 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -576,7 +576,6 @@ if (Get-Command git -ErrorAction SilentlyContinue) { Copy-Item -Recurse -Force "apps\skills\*" $claudeSkillsDir Copy-SkillIfPresent "apps\skills\plannotator-compound" $agentsSkillsDir Copy-SkillIfPresent "apps\skills\plannotator-setup-goal" $agentsSkillsDir - Copy-SkillIfPresent "apps\skills\plannotator-visual-explainer" $agentsSkillsDir if ($codexAvailable) { New-Item -ItemType Directory -Force -Path $codexSkillsDir | Out-Null Copy-SkillIfPresent "apps\skills\plannotator-review" $codexSkillsDir diff --git a/scripts/install.sh b/scripts/install.sh index bb752556a..e27d0bf16 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -799,7 +799,6 @@ if command -v git &>/dev/null; then cp -r apps/skills/* "$CLAUDE_SKILLS_DIR/" copy_skill_if_present apps/skills/plannotator-compound "$AGENTS_SKILLS_DIR" copy_skill_if_present apps/skills/plannotator-setup-goal "$AGENTS_SKILLS_DIR" - copy_skill_if_present apps/skills/plannotator-visual-explainer "$AGENTS_SKILLS_DIR" if [ "$codex_available" -eq 1 ]; then mkdir -p "$CODEX_SKILLS_DIR" copy_skill_if_present apps/skills/plannotator-review "$CODEX_SKILLS_DIR" diff --git a/scripts/install.test.ts b/scripts/install.test.ts index 86f17a14a..d295ff320 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -218,8 +218,8 @@ describe("install.ps1", () => { expect(script).toContain('Copy-SkillIfPresent "apps\\skills\\plannotator-compound" $agentsSkillsDir'); expect(script).toContain('Copy-SkillIfPresent "apps\\skills\\plannotator-setup-goal" $agentsSkillsDir'); expect(script).toContain("if ($codexAvailable)"); - expect(script).not.toContain('Copy-Item -Recurse -Force "skills\\*" $codexSkillsDir'); - expect(script).not.toContain('Copy-Item -Recurse -Force "skills\\*" $agentsSkillsDir'); + expect(script).not.toContain('Copy-Item -Recurse -Force "apps\\skills\\*" $codexSkillsDir'); + expect(script).not.toContain('Copy-Item -Recurse -Force "apps\\skills\\*" $agentsSkillsDir'); expect(script).not.toContain('Copy-Item -Recurse -Force "apps\\skills\\plannotator-review" $codexSkillsDir'); expect(script).toContain('Skipping skills install (git not found)'); }); @@ -333,8 +333,8 @@ describe("install.cmd", () => { expect(script).toContain('if exist "apps\\skills\\plannotator-last" xcopy /s /i /y /q "apps\\skills\\plannotator-last" "!CODEX_SKILLS_DIR!\\plannotator-last\\"'); expect(script).toContain('if exist "apps\\skills\\plannotator-compound" xcopy /s /i /y /q "apps\\skills\\plannotator-compound" "!AGENTS_SKILLS_DIR!\\plannotator-compound\\"'); expect(script).toContain('if exist "apps\\skills\\plannotator-setup-goal" xcopy /s /i /y /q "apps\\skills\\plannotator-setup-goal" "!AGENTS_SKILLS_DIR!\\plannotator-setup-goal\\"'); - expect(script).not.toContain('xcopy /s /y /q "skills\\*" "!CODEX_SKILLS_DIR!\\"'); - expect(script).not.toContain('xcopy /s /y /q "skills\\*" "!AGENTS_SKILLS_DIR!\\"'); + expect(script).not.toContain('xcopy /s /y /q "apps\\skills\\*" "!CODEX_SKILLS_DIR!\\"'); + expect(script).not.toContain('xcopy /s /y /q "apps\\skills\\*" "!AGENTS_SKILLS_DIR!\\"'); expect(script).toContain("Skipping skills install"); }); From 7f0c8f05ddcde07aff4bcc87c5ddbb2e18055826 Mon Sep 17 00:00:00 2001 From: Nehr <127654909+AgileInnov8tor@users.noreply.github.com> Date: Wed, 6 May 2026 02:27:16 +0700 Subject: [PATCH 12/34] Expose bypass clear reminder permission mode (#668) * Preserve truthful approval semantics for Claude plan bypass Thread a clear-context reminder flag through approval decisions, expose a Claude Code-only approval entry that requests bypass mode, and keep the hook response honest by emitting a reminder instead of claiming context was cleared. Constraint: Claude Code PermissionRequest hooks have no documented clearContext response field and bypassPermissions is only a request when the mode is available. Rejected: adding a permission-mode enum or undocumented clearContext field | those would misrepresent hook capabilities and broaden the contract. Confidence: high Scope-risk: moderate Directive: Do not claim Plannotator clears context until Claude Code documents a hook field for that behavior; keep reminder copy truthful. Tested: bun test packages/server apps/hook; bun test packages/editor/wideMode.test.ts packages/ui/hooks/useAgentSettings.test.ts; bun test; bun run build:review; bun run build:hook; bun run typecheck; git diff --check --cached. Not-tested: Browser warning-dialog replay and interactive Claude Code smoke test. Co-authored-by: OmX * Keep approval payloads truthful across Claude Code and Pi Constraint: Claude Code hooks cannot clear context directly; the new action remains a truthful reminder plus bypass-mode approval. Rejected: Sending OpenCode agent-switch state for Claude Code | it leaked build (?) UI state and misleading payload fields. Confidence: high Scope-risk: narrow Directive: Keep OpenCode agent switching gated to opencode-origin approval payloads. Tested: bun run typecheck; bun test; bun run build:review; bun run build:hook Not-tested: Manual browser click-through of the Claude Code dropdown. * Expose the clear-context reminder permission default Constraint: Claude Code hooks can only emit a systemMessage nudge, not clear context directly. Rejected: Server protocol changes | existing permissionMode plus clearContextNudge wire fields already support the behavior. Confidence: high Scope-risk: narrow Directive: Keep bypassPermissionsClearReminder as a UI/storage-only synthetic mode that decomposes before /api/approve. Tested: bun test packages/editor/approvalBody.test.ts; bun run --cwd apps/review build && bun run build:hook; bun run --cwd packages/ui typecheck; git diff --check Not-tested: Root bun run typecheck could not run because tsc was not on PATH for the root script. * Reject invalid persisted permission modes Constraint: Stored browser values can be stale, corrupt, or from a future Plannotator build. Rejected: Trusting the storage read with a type assertion | invalid values could flow into UI state and approval request construction. Confidence: high Scope-risk: narrow Directive: Keep PermissionMode storage reads validated against PERMISSION_MODE_OPTIONS when adding or renaming modes. Tested: bun test packages/editor/approvalBody.test.ts; bun run --cwd packages/ui typecheck; git diff --check Not-tested: Full repo typecheck/build, outside this review-fix scope. * Ensure approvals use the live permission setting Settings persisted permission mode changes, but App kept the original mode in React state and used that stale value when building approval payloads. Push the Settings change back into App so the selected clear-reminder mode reaches the hook decision. Constraint: Permission mode storage is cookie-backed while approval payload construction reads App state.\nRejected: Read permission cookies during approval | would couple approval construction to browser storage and duplicate Settings state.\nConfidence: high\nScope-risk: narrow\nDirective: Keep permission-mode writes synchronized with approval state when adding or changing modes.\nTested: bun test packages/editor/approvalBody.test.ts packages/ui/components/ApproveDropdown.test.tsx; bun x tsc --noEmit -p packages/ui/tsconfig.json; bun run build:hook; direct Playwright hook smoke verified bypassPermissionsClearReminder UI and hook payload.\nNot-tested: Live interactive Claude CLI session; direct hook/server simulation covered the wire output. --------- Co-authored-by: OmX --- apps/pi-extension/server.test.ts | 44 +--- packages/editor/App.tsx | 341 +++++++++++++++++++-------- packages/editor/approvalBody.test.ts | 119 +--------- packages/editor/approvalBody.ts | 29 +-- packages/server/index.ts | 10 +- packages/ui/utils/permissionMode.ts | 4 +- 6 files changed, 260 insertions(+), 287 deletions(-) diff --git a/apps/pi-extension/server.test.ts b/apps/pi-extension/server.test.ts index 97d38117c..7af6306d1 100644 --- a/apps/pi-extension/server.test.ts +++ b/apps/pi-extension/server.test.ts @@ -4,14 +4,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { createServer as createNetServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { - getGitContext, - getVcsContext, - prepareLocalReviewDiff, - runGitDiff, - startPlanReviewServer, - startReviewServer, -} from "./server"; +import { getGitContext, runGitDiff, startPlanReviewServer, startReviewServer } from "./server"; const tempDirs: string[] = []; const originalCwd = process.cwd(); @@ -133,8 +126,6 @@ afterEach(() => { }); describe("pi review server", () => { - const testIfJj = hasJj() ? test : test.skip; - test("plan approve preserves clear context nudge decisions", async () => { const homeDir = makeTempDir("plannotator-pi-home-"); const repoDir = makeTempDir("plannotator-pi-plan-"); @@ -174,39 +165,6 @@ describe("pi review server", () => { } }); - test("plan clear-context setting endpoints are explicit unsupported fallbacks", async () => { - const homeDir = makeTempDir("plannotator-pi-home-"); - const repoDir = makeTempDir("plannotator-pi-plan-"); - process.env.HOME = homeDir; - process.chdir(repoDir); - process.env.PLANNOTATOR_PORT = String(await reservePort()); - - const server = await startPlanReviewServer({ - plan: "# Plan\n\nShip it.", - htmlContent: "plan", - origin: "pi", - permissionMode: "acceptEdits", - }); - - try { - const statusResponse = await fetch(`${server.url}/api/settings-status`); - await expect(statusResponse.json()).resolves.toEqual({ - settingEnabled: false, - consentGiven: false, - }); - - const enableResponse = await fetch(`${server.url}/api/enable-clear-context`, { - method: "POST", - }); - await expect(enableResponse.json()).resolves.toEqual({ - ok: false, - reason: "not-supported-in-pi-extension", - }); - } finally { - server.stop(); - } - }); - test("serves review diff parity endpoints including drafts, uploads, and editor annotations", async () => { const homeDir = makeTempDir("plannotator-pi-home-"); const repoDir = initRepo(); diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 4c321364f..53e94bc82 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -15,6 +15,9 @@ import { AnnotationToolstrip } from '@plannotator/ui/components/AnnotationToolst import { StickyHeaderLane } from '@plannotator/ui/components/StickyHeaderLane'; import { TaterSpriteRunning } from '@plannotator/ui/components/TaterSpriteRunning'; import { TaterSpritePullup } from '@plannotator/ui/components/TaterSpritePullup'; +import { Settings } from '@plannotator/ui/components/Settings'; +import { FeedbackButton, ApproveButton, ExitButton } from '@plannotator/ui/components/ToolbarButtons'; +import { ApproveDropdown, type ApproveExtraEntry } from '@plannotator/ui/components/ApproveDropdown'; import { useSharing } from '@plannotator/ui/hooks/useSharing'; import { getCallbackConfig, CallbackAction, executeCallback } from '@plannotator/ui/utils/callback'; import { useAgents } from '@plannotator/ui/hooks/useAgents'; @@ -76,7 +79,6 @@ import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from './demoPlan'; import { DIFF_DEMO_PLAN_CONTENT } from './demoPlanDiffDemo'; import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode'; import { buildApprovalRequestBody, type ApprovalOverride } from './approvalBody'; -import type { ApproveExtraEntry } from '@plannotator/ui/components/ApproveDropdown'; const USE_DIFF_DEMO = import.meta.env.VITE_DIFF_DEMO === '1' || import.meta.env.VITE_DIFF_DEMO === 'true'; @@ -104,6 +106,7 @@ const App: React.FC = () => { const [showImport, setShowImport] = useState(false); const [showFeedbackPrompt, setShowFeedbackPrompt] = useState(false); const [showClaudeCodeWarning, setShowClaudeCodeWarning] = useState(false); + const [pendingApprovalOverride, setPendingApprovalOverride] = useState(null); const [showExitWarning, setShowExitWarning] = useState(false); // When the warning dialog confirms, route to the handler matching the button that opened it. const [exitWarningAction, setExitWarningAction] = useState<'close' | 'approve'>('close'); @@ -951,6 +954,7 @@ const App: React.FC = () => { // API mode handlers const handleApprove = async (override: ApprovalOverride = {}) => { + setPendingApprovalOverride(null); setIsSubmitting(true); try { const obsidianSettings = getObsidianSettings(); @@ -961,19 +965,6 @@ const App: React.FC = () => { ? await autoSavePromiseRef.current : autoSaveResultsRef.current; - const shouldUseNativeClear = - origin === 'claude-code' && - pendingToolName === 'ExitPlanMode' && - (override.deferToNativeForClear || (override.permissionMode ?? permissionMode) === 'bypassPermissionsClearReminder'); - if (shouldUseNativeClear) { - try { - const response = await fetch('/api/enable-clear-context', { method: 'POST' }); - if (response.ok) setShowClearContextBanner(false); - } catch { - setShowClearContextBanner(true); - } - } - const effectiveAgent = getEffectiveAgentName(getAgentSwitchSettings()); const body = buildApprovalRequestBody({ origin, @@ -981,7 +972,6 @@ const App: React.FC = () => { override, effectiveAgent, planSaveSettings, - toolName: pendingToolName, }); const effectiveVaultPath = getEffectiveVaultPath(obsidianSettings); @@ -1062,29 +1052,15 @@ const App: React.FC = () => { handleApprove(override); }, [allAnnotations.length, codeAnnotations.length, origin, handleApprove]); - const claudeCodeExtraEntries = useMemo(() => { - if (origin !== 'claude-code') return []; - if (pendingToolName === 'ExitPlanMode') { - return [{ - id: 'approve-bypass-native-clear', - label: 'Approve + Bypass + Clear Context (native)', - description: "Defers to Claude Code's native plan-accept dialog so it can clear context and set bypass permissions.", - onSelect: () => approveWithClaudeCodeWarning({ - permissionMode: 'bypassPermissions', - deferToNativeForClear: true, - }), - }]; - } - return [{ - id: 'approve-bypass-clear-reminder', - label: 'Approve + Bypass + /clear Reminder', - description: 'Requests bypass mode and reminds you to run /clear. Hooks cannot clear context directly outside plan acceptance.', - onSelect: () => approveWithClaudeCodeWarning({ - permissionMode: 'bypassPermissions', - clearContextNudge: true, - }), - }]; - }, [approveWithClaudeCodeWarning, origin, pendingToolName]); + const claudeCodeExtraEntries = useMemo(() => (origin === 'claude-code' ? [{ + id: 'approve-bypass-clear-reminder', + label: 'Approve + Bypass + /clear Reminder', + description: 'Requests bypass mode and reminds you to run /clear. Hooks cannot clear context directly.', + onSelect: () => approveWithClaudeCodeWarning({ + permissionMode: 'bypassPermissions', + clearContextNudge: true, + }), + }] : []), [approveWithClaudeCodeWarning, origin]); // Annotate mode handler — sends feedback via /api/feedback const handleAnnotateFeedback = async () => { @@ -1666,58 +1642,234 @@ const App: React.FC = () => {
- 0 || codeAnnotations.length > 0} - callbackConfig={callbackConfig} - taterMode={taterMode} - mobileSettingsOpen={mobileSettingsOpen} - gitUser={gitUser} - onCallbackFeedback={handleCallbackFeedback} - onCallbackApprove={handleCallbackApprove} - onAnnotateExit={handleHeaderAnnotateExit} - onAnnotateFeedback={handleHeaderAnnotateFeedback} - onAnnotateApprove={handleHeaderAnnotateApprove} - onFeedback={handleHeaderFeedback} - onApprove={handleHeaderApprove} - onAnnotationPanelToggle={handleAnnotationPanelToggle} - onArchiveCopy={archive.copy} - onArchiveDone={archive.done} - onTaterModeChange={handleTaterModeChange} - onIdentityChange={handleIdentityChange} - onUIPreferencesChange={setUiPrefs} - onOpenSettings={handleOpenSettings} - onCloseSettings={handleCloseSettings} - onOpenExport={handleOpenExport} - onCopyAgentInstructions={handleHeaderCopyAgentInstructions} - onDownloadAnnotations={handleHeaderDownloadAnnotations} - onPrint={handlePrint} - onCopyShareLink={handleHeaderCopyShareLink} - onOpenImport={handleOpenImport} - onSaveToObsidian={handleSaveToObsidian} - onSaveToBear={handleSaveToBear} - onSaveToOctarine={handleSaveToOctarine} - appVersion={typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'} - agentInstructionsEnabled={isApiMode && !archive.archiveMode && !annotateMode} - obsidianConfigured={isObsidianConfigured()} - bearConfigured={getBearSettings().enabled} - octarineConfigured={isOctarineConfigured()} - /> + {/* Minimal Header */} +
+ + +
+ {/* Bot callback buttons — only shown when ?cb=&ct= params are present */} + {callbackConfig && !isApiMode && isSharedSession && ( + <> +
+ + + + )} + + {isApiMode && !linkedDocHook.isActive && archive.archiveMode && ( + <> + + + + )} + + {isApiMode && (!linkedDocHook.isActive || annotateMode) && !archive.archiveMode && ( + <> + {annotateMode ? ( + // Annotate mode: Close always visible, Send Annotations when annotations exist, + // Approve only when gate (review) mode is enabled (#570). + <> + { + if (hasAnyAnnotations) { + setExitWarningAction('close'); + setShowExitWarning(true); + } else { + handleAnnotateExit(); + } + }} + disabled={isSubmitting || isExiting} + isLoading={isExiting} + /> + {hasAnyAnnotations && ( + + )} + + ) : ( + // Plan mode: Send Feedback + { + const docAnnotations = linkedDocHook.getDocAnnotations(); + const hasDocAnnotations = Array.from(docAnnotations.values()).some( + (d) => d.annotations.length > 0 || d.globalAttachments.length > 0 + ); + if (allAnnotations.length === 0 && codeAnnotations.length === 0 && editorAnnotations.length === 0 && !hasDocAnnotations) { + setShowFeedbackPrompt(true); + } else { + handleDeny(); + } + }} + disabled={isSubmitting} + isLoading={isSubmitting} + label="Send Feedback" + title="Send Feedback" + /> + )} + + {(!annotateMode || gate) && ( + !annotateMode && ( + (origin === 'opencode' && availableAgents.length > 0) || + (origin === 'claude-code' && claudeCodeExtraEntries.length > 0) + ) ? ( + { + if (origin === 'opencode') { + const warning = getAgentWarning(); + if (warning) { + setAgentWarningMessage(warning); + setShowAgentWarning(true); + return; + } + } + approveWithClaudeCodeWarning(); + }} + agents={origin === 'opencode' ? availableAgents : []} + extraEntries={claudeCodeExtraEntries} + disabled={isSubmitting} + isLoading={isSubmitting} + /> + ) : ( +
+ { + if (annotateMode) { + if (hasAnyAnnotations) { + setExitWarningAction('approve'); + setShowExitWarning(true); + return; + } + handleAnnotateApprove(); + return; + } + if (origin === 'claude-code' && (allAnnotations.length > 0 || codeAnnotations.length > 0)) { + setPendingApprovalOverride({}); + setShowClaudeCodeWarning(true); + return; + } + if (origin === 'opencode') { + const warning = getAgentWarning(); + if (warning) { + setAgentWarningMessage(warning); + setShowAgentWarning(true); + return; + } + } + handleApprove(); + }} + disabled={isSubmitting || (annotateMode && isExiting)} + isLoading={isSubmitting} + dimmed={!annotateMode && (origin === 'claude-code' || origin === 'gemini-cli') && (allAnnotations.length > 0 || codeAnnotations.length > 0)} + title={annotateMode ? 'Approve — no changes requested' : undefined} + /> + {!annotateMode && (origin === 'claude-code' || origin === 'gemini-cli') && (allAnnotations.length > 0 || codeAnnotations.length > 0) && ( +
+
+
+ {agentName} doesn't support feedback on approval. Your annotations won't be seen. +
+ )} +
+ ) + )} + +
+ + )} + + {/* Annotations panel toggle — top-level header button */} + + + {/* Settings dialog (controlled, button hidden — opened from PlanHeaderMenu) */} +
+ setMobileSettingsOpen(false)} + gitUser={gitUser} + /> +
+ + { + setMobileSettingsOpen(true); + }} + onOpenExport={() => { setInitialExportTab(undefined); setShowExport(true); }} + onCopyAgentInstructions={handleCopyAgentInstructions} + onDownloadAnnotations={handleDownloadAnnotations} + onPrint={() => window.print()} + onCopyShareLink={handleCopyShareLink} + onOpenImport={() => setShowImport(true)} + onSaveToObsidian={() => handleQuickSaveToNotes('obsidian')} + onSaveToBear={() => handleQuickSaveToNotes('bear')} + onSaveToOctarine={() => handleQuickSaveToNotes('octarine')} + sharingEnabled={canShareCurrentSession} + isApiMode={isApiMode} + agentInstructionsEnabled={isApiMode && !archive.archiveMode && !annotateMode} + obsidianConfigured={isObsidianConfigured()} + bearConfigured={getBearSettings().enabled} + octarineConfigured={isOctarineConfigured()} + /> +
+
+ {/* Linked document error banner */} {linkedDocHook.error && (
@@ -2059,11 +2211,14 @@ const App: React.FC = () => { {/* Claude Code annotation warning dialog */} setShowClaudeCodeWarning(false)} + onClose={() => { + setShowClaudeCodeWarning(false); + setPendingApprovalOverride(null); + }} onConfirm={() => { + const override = pendingApprovalOverride ?? {}; setShowClaudeCodeWarning(false); - const override = pendingApprovalOverride; - setPendingApprovalOverride({}); + setPendingApprovalOverride(null); handleApprove(override); }} title="Annotations Won't Be Sent" diff --git a/packages/editor/approvalBody.test.ts b/packages/editor/approvalBody.test.ts index 4c6b974c3..dcb9a2451 100644 --- a/packages/editor/approvalBody.test.ts +++ b/packages/editor/approvalBody.test.ts @@ -1,47 +1,11 @@ import { describe, expect, test } from 'bun:test'; -import { buildApprovalRequestBody, shouldEnableNativeClearBeforeApprove } from './approvalBody'; - -describe('shouldEnableNativeClearBeforeApprove', () => { - test('enables native clear only for explicit Claude Code ExitPlanMode overrides', () => { - expect(shouldEnableNativeClearBeforeApprove({ - origin: 'claude-code', - toolName: 'ExitPlanMode', - override: { deferToNativeForClear: true }, - })).toBe(true); - - expect(shouldEnableNativeClearBeforeApprove({ - origin: 'claude-code', - toolName: 'ExitPlanMode', - override: { permissionMode: 'bypassPermissionsClearReminder' }, - })).toBe(false); - - expect(shouldEnableNativeClearBeforeApprove({ - origin: 'claude-code', - toolName: 'OtherTool', - override: { deferToNativeForClear: true }, - })).toBe(false); - }); -}); +import { buildApprovalRequestBody } from './approvalBody'; describe('buildApprovalRequestBody', () => { - test('maps bypass clear reminder mode to reminder fallback on ExitPlanMode', () => { + test('maps bypass clear reminder mode to Claude Code wire fields', () => { expect(buildApprovalRequestBody({ origin: 'claude-code', permissionMode: 'bypassPermissionsClearReminder', - toolName: 'ExitPlanMode', - planSaveSettings: { enabled: true }, - })).toEqual({ - permissionMode: 'bypassPermissions', - clearContextNudge: true, - planSave: { enabled: true }, - }); - }); - - test('maps bypass clear reminder mode to reminder fallback outside ExitPlanMode', () => { - expect(buildApprovalRequestBody({ - origin: 'claude-code', - permissionMode: 'bypassPermissionsClearReminder', - toolName: 'OtherTool', planSaveSettings: { enabled: true }, })).toEqual({ permissionMode: 'bypassPermissions', @@ -67,7 +31,7 @@ describe('buildApprovalRequestBody', () => { }); }); - test('keeps bypass clear reminder override fallback fields for Claude Code approvals without ExitPlanMode', () => { + test('keeps bypass clear reminder override wire fields for Claude Code approvals', () => { expect(buildApprovalRequestBody({ origin: 'claude-code', permissionMode: 'acceptEdits', @@ -82,22 +46,6 @@ describe('buildApprovalRequestBody', () => { }); }); - test('keeps bypass clear reminder override as reminder fallback when ExitPlanMode is known', () => { - expect(buildApprovalRequestBody({ - origin: 'claude-code', - permissionMode: 'acceptEdits', - toolName: 'ExitPlanMode', - override: { - permissionMode: 'bypassPermissionsClearReminder', - }, - planSaveSettings: { enabled: true }, - })).toEqual({ - permissionMode: 'bypassPermissions', - clearContextNudge: true, - planSave: { enabled: true }, - }); - }); - test('keeps agentSwitch for OpenCode approvals', () => { expect(buildApprovalRequestBody({ origin: 'opencode', @@ -121,65 +69,4 @@ describe('buildApprovalRequestBody', () => { planSave: { enabled: true }, }); }); - - test('forwards deferToNativeForClear only for explicit Claude Code ExitPlanMode bypass approvals', () => { - expect(buildApprovalRequestBody({ - origin: 'claude-code', - permissionMode: 'acceptEdits', - toolName: 'ExitPlanMode', - override: { - permissionMode: 'bypassPermissions', - deferToNativeForClear: true, - }, - planSaveSettings: { enabled: true }, - })).toEqual({ - permissionMode: 'bypassPermissions', - deferToNativeForClear: true, - planSave: { enabled: true }, - }); - }); - - test('does not forward deferToNativeForClear without ExitPlanMode', () => { - expect(buildApprovalRequestBody({ - origin: 'claude-code', - permissionMode: 'acceptEdits', - toolName: 'OtherTool', - override: { - permissionMode: 'bypassPermissions', - deferToNativeForClear: true, - }, - planSaveSettings: { enabled: true }, - })).toEqual({ - permissionMode: 'bypassPermissions', - planSave: { enabled: true }, - }); - }); - - test('does not forward deferToNativeForClear for OpenCode approvals', () => { - expect(buildApprovalRequestBody({ - origin: 'opencode', - permissionMode: 'acceptEdits', - effectiveAgent: 'build', - override: { - deferToNativeForClear: true, - }, - planSaveSettings: { enabled: true }, - })).toEqual({ - agentSwitch: 'build', - planSave: { enabled: true }, - }); - }); - - test('does not forward deferToNativeForClear for Gemini origin', () => { - expect(buildApprovalRequestBody({ - origin: 'gemini-cli', - permissionMode: 'acceptEdits', - override: { - deferToNativeForClear: true, - }, - planSaveSettings: { enabled: true }, - })).toEqual({ - planSave: { enabled: true }, - }); - }); }); diff --git a/packages/editor/approvalBody.ts b/packages/editor/approvalBody.ts index 55a905fcc..5d8e92703 100644 --- a/packages/editor/approvalBody.ts +++ b/packages/editor/approvalBody.ts @@ -4,7 +4,6 @@ import type { PermissionMode } from '@plannotator/ui/utils/permissionMode'; export type ApprovalOverride = { permissionMode?: PermissionMode; clearContextNudge?: boolean; - deferToNativeForClear?: boolean; }; export interface ApprovalRequestBody { @@ -16,19 +15,6 @@ export interface ApprovalRequestBody { planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string; clearContextNudge?: boolean; - deferToNativeForClear?: boolean; -} - -export function shouldEnableNativeClearBeforeApprove(options: { - origin: Origin | null; - toolName?: string; - override?: ApprovalOverride; -}): boolean { - return ( - options.origin === 'claude-code' && - options.toolName === 'ExitPlanMode' && - options.override?.deferToNativeForClear === true - ); } export function buildApprovalRequestBody(options: { @@ -37,21 +23,16 @@ export function buildApprovalRequestBody(options: { override?: ApprovalOverride; effectiveAgent?: string; planSaveSettings: { enabled: boolean; customPath?: string | null }; - toolName?: string; }): ApprovalRequestBody { - const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings, toolName } = options; + const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings } = options; const body: ApprovalRequestBody = {}; if (origin === 'claude-code') { const effectivePermissionMode = override.permissionMode ?? permissionMode; - const wantsClearContext = effectivePermissionMode === 'bypassPermissionsClearReminder'; - const useNativeClear = shouldEnableNativeClearBeforeApprove({ origin, toolName, override }); - - body.permissionMode = wantsClearContext ? 'bypassPermissions' : effectivePermissionMode; - - if (useNativeClear) { - body.deferToNativeForClear = true; - } else if (override.clearContextNudge || wantsClearContext) { + body.permissionMode = effectivePermissionMode === 'bypassPermissionsClearReminder' + ? 'bypassPermissions' + : effectivePermissionMode; + if (override.clearContextNudge || effectivePermissionMode === 'bypassPermissionsClearReminder') { body.clearContextNudge = true; } } diff --git a/packages/server/index.ts b/packages/server/index.ts index b6a6501e7..0654d90d5 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -111,7 +111,6 @@ export interface ServerResult { agentSwitch?: string; permissionMode?: string; clearContextNudge?: boolean; - deferToNativeForClear?: boolean; }>; /** Wait for user to close (archive mode only) */ waitForDone?: () => Promise; @@ -242,7 +241,6 @@ export async function startPlannotatorServer( agentSwitch?: string; permissionMode?: string; clearContextNudge?: boolean; - deferToNativeForClear?: boolean; }) => void; let decisionPromise: Promise<{ approved: boolean; @@ -251,7 +249,6 @@ export async function startPlannotatorServer( agentSwitch?: string; permissionMode?: string; clearContextNudge?: boolean; - deferToNativeForClear?: boolean; }>; if (mode !== "archive") { @@ -529,7 +526,6 @@ export async function startPlannotatorServer( let agentSwitch: string | undefined; let requestedPermissionMode: string | undefined; let clearContextNudge: boolean | undefined; - let deferToNativeForClear: boolean | undefined; let planSaveEnabled = true; // default to enabled for backwards compat let planSaveCustomPath: string | undefined; try { @@ -542,7 +538,6 @@ export async function startPlannotatorServer( planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string; clearContextNudge?: boolean; - deferToNativeForClear?: boolean; }; // Capture feedback if provided (for "approve with notes") @@ -564,9 +559,6 @@ export async function startPlannotatorServer( if (body.clearContextNudge === true) { clearContextNudge = true; } - if (body.deferToNativeForClear === true) { - deferToNativeForClear = true; - } // Capture plan save settings if (body.planSave !== undefined) { @@ -613,7 +605,7 @@ export async function startPlannotatorServer( // Use permission mode from client request if provided, otherwise fall back to hook input const effectivePermissionMode = requestedPermissionMode || permissionMode; - resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode, clearContextNudge, deferToNativeForClear }); + resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode, clearContextNudge }); return Response.json({ ok: true, savedPath }); } diff --git a/packages/ui/utils/permissionMode.ts b/packages/ui/utils/permissionMode.ts index fa016be2a..4143c1d75 100644 --- a/packages/ui/utils/permissionMode.ts +++ b/packages/ui/utils/permissionMode.ts @@ -6,7 +6,7 @@ * * Available modes: * - bypassPermissions: Auto-approve all tool calls - * - bypassPermissionsClearReminder: Persisted UI mode that bypasses permissions and emits a /clear reminder after plan approval + * - bypassPermissionsClearReminder: Persisted UI mode that sends bypassPermissions plus a /clear reminder nudge * - acceptEdits: Auto-approve file edits only * - default: Manually approve each tool call */ @@ -37,7 +37,7 @@ export const PERMISSION_MODE_OPTIONS: { value: PermissionMode; label: string; de { value: 'bypassPermissionsClearReminder', label: 'Bypass + /clear Reminder', - description: 'Bypass permissions after plan approval and emit a /clear reminder without invoking the native fresh-thread flow.', + description: 'Auto-approve all tool calls and emit a system message reminding you to run /clear (hooks cannot clear context directly).', }, { value: 'default', From e2441cc60b06dde377e96e0a1fbb5c41a75a59e9 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Thu, 14 May 2026 16:04:19 +0700 Subject: [PATCH 13/34] task: resolve hook server conflict surfaces --- apps/pi-extension/plannotator-browser.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/pi-extension/plannotator-browser.ts b/apps/pi-extension/plannotator-browser.ts index 99fdc52b7..a47e89a8f 100644 --- a/apps/pi-extension/plannotator-browser.ts +++ b/apps/pi-extension/plannotator-browser.ts @@ -472,15 +472,13 @@ export async function startMarkdownAnnotationSession( sourceInfo?: string, sourceConverted?: boolean, gate?: boolean, - rawHtml?: string, - renderHtml?: boolean, ): Promise> { if (!ctx.hasUI || !planHtmlContent) { throw new Error("Plannotator annotation browser is unavailable in this session."); } let resolvedMarkdown = markdown; - if (!renderHtml && !resolvedMarkdown.trim() && existsSync(filePath)) { + if (!resolvedMarkdown.trim() && existsSync(filePath)) { try { const fileStat = statSync(filePath); if (!fileStat.isDirectory()) { @@ -500,8 +498,6 @@ export async function startMarkdownAnnotationSession( sourceInfo, sourceConverted, gate, - rawHtml, - renderHtml, htmlContent: planHtmlContent, sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled", shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined, From b341b3ab64b319e6ca4efa8e205c2365063a95b8 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Tue, 5 May 2026 12:37:12 +0700 Subject: [PATCH 14/34] Preserve truthful approval semantics for Claude plan bypass Thread a clear-context reminder flag through approval decisions, expose a Claude Code-only approval entry that requests bypass mode, and keep the hook response honest by emitting a reminder instead of claiming context was cleared. Constraint: Claude Code PermissionRequest hooks have no documented clearContext response field and bypassPermissions is only a request when the mode is available. Rejected: adding a permission-mode enum or undocumented clearContext field | those would misrepresent hook capabilities and broaden the contract. Confidence: high Scope-risk: moderate Directive: Do not claim Plannotator clears context until Claude Code documents a hook field for that behavior; keep reminder copy truthful. Tested: bun test packages/server apps/hook; bun test packages/editor/wideMode.test.ts packages/ui/hooks/useAgentSettings.test.ts; bun test; bun run build:review; bun run build:hook; bun run typecheck; git diff --check --cached. Not-tested: Browser warning-dialog replay and interactive Claude Code smoke test. Co-authored-by: OmX --- packages/editor/App.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 53e94bc82..6f1cfa642 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -94,6 +94,11 @@ type NoteAutoSaveResults = { octarine?: boolean; }; +type ApprovalOverride = { + permissionMode?: PermissionMode; + clearContextNudge?: boolean; +}; + const App: React.FC = () => { const [markdown, setMarkdown] = useState(DEMO_PLAN_CONTENT); const [annotations, setAnnotations] = useState([]); From ef9640a2c62154f1090c0eb87e274da64d789e0c Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Tue, 5 May 2026 15:05:47 +0700 Subject: [PATCH 15/34] Keep approval payloads truthful across Claude Code and Pi Constraint: Claude Code hooks cannot clear context directly; the new action remains a truthful reminder plus bypass-mode approval. Rejected: Sending OpenCode agent-switch state for Claude Code | it leaked build (?) UI state and misleading payload fields. Confidence: high Scope-risk: narrow Directive: Keep OpenCode agent switching gated to opencode-origin approval payloads. Tested: bun run typecheck; bun test; bun run build:review; bun run build:hook Not-tested: Manual browser click-through of the Claude Code dropdown. --- packages/editor/App.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 6f1cfa642..53e94bc82 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -94,11 +94,6 @@ type NoteAutoSaveResults = { octarine?: boolean; }; -type ApprovalOverride = { - permissionMode?: PermissionMode; - clearContextNudge?: boolean; -}; - const App: React.FC = () => { const [markdown, setMarkdown] = useState(DEMO_PLAN_CONTENT); const [annotations, setAnnotations] = useState([]); From 77ac25126bc230b7241fe36f3fcbf59f0af56289 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Thu, 7 May 2026 02:20:25 +0700 Subject: [PATCH 16/34] Keep Plannotator approvals actionable for bypass clear context Route Claude Code plan approvals through explicit bypass payloads, consent-gated native clear-context deferral, and a fallback /clear nudge so selecting the clear-context mode no longer collapses into a silent approval no-op. The active ignored hook bundle was rebuilt locally after this source change. Constraint: Claude Code hooks cannot directly clear context; native clear requires showClearContextOnPlanAccept and user consent.\nRejected: Treating bypassPermissionsClearReminder as a raw permissionMode | Claude Code only accepts bypassPermissions on the wire and would ignore the local UI-only value.\nConfidence: high\nScope-risk: moderate\nDirective: Rebuild apps/hook/dist/index.html after changing plan-review UI because the local plannotator launcher imports the ignored dist bundle at runtime.\nTested: git diff --check; bun run typecheck; bun test; bun run build:review; bun run build:hook; fixed-port hook smoke for /api/settings-status and native-clear /api/approve.\nNot-tested: Manual click-through in Claude Code native plan-accept dialog. --- apps/pi-extension/server.test.ts | 33 +++++++++++++++++++++++ packages/editor/App.tsx | 46 +++++++++++++++++++++++++------- packages/editor/approvalBody.ts | 5 +++- packages/server/index.ts | 10 ++++++- 4 files changed, 83 insertions(+), 11 deletions(-) diff --git a/apps/pi-extension/server.test.ts b/apps/pi-extension/server.test.ts index 7af6306d1..0f8dd33f2 100644 --- a/apps/pi-extension/server.test.ts +++ b/apps/pi-extension/server.test.ts @@ -165,6 +165,39 @@ describe("pi review server", () => { } }); + test("plan clear-context setting endpoints are explicit unsupported fallbacks", async () => { + const homeDir = makeTempDir("plannotator-pi-home-"); + const repoDir = makeTempDir("plannotator-pi-plan-"); + process.env.HOME = homeDir; + process.chdir(repoDir); + process.env.PLANNOTATOR_PORT = String(await reservePort()); + + const server = await startPlanReviewServer({ + plan: "# Plan\n\nShip it.", + htmlContent: "plan", + origin: "pi", + permissionMode: "acceptEdits", + }); + + try { + const statusResponse = await fetch(`${server.url}/api/settings-status`); + await expect(statusResponse.json()).resolves.toEqual({ + settingEnabled: false, + consentGiven: false, + }); + + const enableResponse = await fetch(`${server.url}/api/enable-clear-context`, { + method: "POST", + }); + await expect(enableResponse.json()).resolves.toEqual({ + ok: false, + reason: "not-supported-in-pi-extension", + }); + } finally { + server.stop(); + } + }); + test("serves review diff parity endpoints including drafts, uploads, and editor annotations", async () => { const homeDir = makeTempDir("plannotator-pi-home-"); const repoDir = initRepo(); diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 53e94bc82..8710cc6ae 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -965,6 +965,19 @@ const App: React.FC = () => { ? await autoSavePromiseRef.current : autoSaveResultsRef.current; + const shouldUseNativeClear = + origin === 'claude-code' && + pendingToolName === 'ExitPlanMode' && + (override.deferToNativeForClear || (override.permissionMode ?? permissionMode) === 'bypassPermissionsClearReminder'); + if (shouldUseNativeClear) { + try { + const response = await fetch('/api/enable-clear-context', { method: 'POST' }); + if (response.ok) setShowClearContextBanner(false); + } catch { + setShowClearContextBanner(true); + } + } + const effectiveAgent = getEffectiveAgentName(getAgentSwitchSettings()); const body = buildApprovalRequestBody({ origin, @@ -972,6 +985,7 @@ const App: React.FC = () => { override, effectiveAgent, planSaveSettings, + toolName: pendingToolName, }); const effectiveVaultPath = getEffectiveVaultPath(obsidianSettings); @@ -1052,15 +1066,29 @@ const App: React.FC = () => { handleApprove(override); }, [allAnnotations.length, codeAnnotations.length, origin, handleApprove]); - const claudeCodeExtraEntries = useMemo(() => (origin === 'claude-code' ? [{ - id: 'approve-bypass-clear-reminder', - label: 'Approve + Bypass + /clear Reminder', - description: 'Requests bypass mode and reminds you to run /clear. Hooks cannot clear context directly.', - onSelect: () => approveWithClaudeCodeWarning({ - permissionMode: 'bypassPermissions', - clearContextNudge: true, - }), - }] : []), [approveWithClaudeCodeWarning, origin]); + const claudeCodeExtraEntries = useMemo(() => { + if (origin !== 'claude-code') return []; + if (pendingToolName === 'ExitPlanMode') { + return [{ + id: 'approve-bypass-native-clear', + label: 'Approve + Bypass + Clear Context (native)', + description: "Defers to Claude Code's native plan-accept dialog so it can clear context and set bypass permissions.", + onSelect: () => approveWithClaudeCodeWarning({ + permissionMode: 'bypassPermissions', + deferToNativeForClear: true, + }), + }]; + } + return [{ + id: 'approve-bypass-clear-reminder', + label: 'Approve + Bypass + /clear Reminder', + description: 'Requests bypass mode and reminds you to run /clear. Hooks cannot clear context directly outside plan acceptance.', + onSelect: () => approveWithClaudeCodeWarning({ + permissionMode: 'bypassPermissions', + clearContextNudge: true, + }), + }]; + }, [approveWithClaudeCodeWarning, origin, pendingToolName]); // Annotate mode handler — sends feedback via /api/feedback const handleAnnotateFeedback = async () => { diff --git a/packages/editor/approvalBody.ts b/packages/editor/approvalBody.ts index 5d8e92703..31180df9d 100644 --- a/packages/editor/approvalBody.ts +++ b/packages/editor/approvalBody.ts @@ -4,6 +4,7 @@ import type { PermissionMode } from '@plannotator/ui/utils/permissionMode'; export type ApprovalOverride = { permissionMode?: PermissionMode; clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }; export interface ApprovalRequestBody { @@ -15,6 +16,7 @@ export interface ApprovalRequestBody { planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string; clearContextNudge?: boolean; + deferToNativeForClear?: boolean; } export function buildApprovalRequestBody(options: { @@ -23,8 +25,9 @@ export function buildApprovalRequestBody(options: { override?: ApprovalOverride; effectiveAgent?: string; planSaveSettings: { enabled: boolean; customPath?: string | null }; + toolName?: string; }): ApprovalRequestBody { - const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings } = options; + const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings, toolName } = options; const body: ApprovalRequestBody = {}; if (origin === 'claude-code') { diff --git a/packages/server/index.ts b/packages/server/index.ts index 0654d90d5..b6a6501e7 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -111,6 +111,7 @@ export interface ServerResult { agentSwitch?: string; permissionMode?: string; clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }>; /** Wait for user to close (archive mode only) */ waitForDone?: () => Promise; @@ -241,6 +242,7 @@ export async function startPlannotatorServer( agentSwitch?: string; permissionMode?: string; clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }) => void; let decisionPromise: Promise<{ approved: boolean; @@ -249,6 +251,7 @@ export async function startPlannotatorServer( agentSwitch?: string; permissionMode?: string; clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }>; if (mode !== "archive") { @@ -526,6 +529,7 @@ export async function startPlannotatorServer( let agentSwitch: string | undefined; let requestedPermissionMode: string | undefined; let clearContextNudge: boolean | undefined; + let deferToNativeForClear: boolean | undefined; let planSaveEnabled = true; // default to enabled for backwards compat let planSaveCustomPath: string | undefined; try { @@ -538,6 +542,7 @@ export async function startPlannotatorServer( planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string; clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }; // Capture feedback if provided (for "approve with notes") @@ -559,6 +564,9 @@ export async function startPlannotatorServer( if (body.clearContextNudge === true) { clearContextNudge = true; } + if (body.deferToNativeForClear === true) { + deferToNativeForClear = true; + } // Capture plan save settings if (body.planSave !== undefined) { @@ -605,7 +613,7 @@ export async function startPlannotatorServer( // Use permission mode from client request if provided, otherwise fall back to hook input const effectivePermissionMode = requestedPermissionMode || permissionMode; - resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode, clearContextNudge }); + resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode, clearContextNudge, deferToNativeForClear }); return Response.json({ ok: true, savedPath }); } From fb38d31fad880a10d5e44bcf53b58bbdc46a120d Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Mon, 11 May 2026 18:23:49 +0700 Subject: [PATCH 17/34] Keep plan approvals in the current session by default Constraint: Approved Ralph plan required saved bypassPermissionsClearReminder to nudge /clear without native fresh-thread deferral.\nRejected: Reusing native clear as the default | it can restart or open a fresh thread unexpectedly.\nConfidence: high\nScope-risk: moderate\nDirective: Treat deferToNativeForClear as an explicit native/fresh-thread escape hatch only; do not wire it to saved reminder mode.\nTested: bun test packages/editor/approvalBody.test.ts apps/hook/server/keystrokeInjector.test.ts; git diff --check scoped files; bun run typecheck; bun test; bun run build:hook; architect verification approved.\nNot-tested: Interactive manual browser smoke of Claude Code native dialog selection. --- packages/editor/App.tsx | 12 ++++-------- packages/editor/approvalBody.test.ts | 24 +++++++++++++++++++++++- packages/editor/approvalBody.ts | 12 ++++++++++++ 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 8710cc6ae..85a06ce60 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -78,7 +78,7 @@ import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiff import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from './demoPlan'; import { DIFF_DEMO_PLAN_CONTENT } from './demoPlanDiffDemo'; import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode'; -import { buildApprovalRequestBody, type ApprovalOverride } from './approvalBody'; +import { buildApprovalRequestBody, shouldEnableNativeClearBeforeApprove, type ApprovalOverride } from './approvalBody'; const USE_DIFF_DEMO = import.meta.env.VITE_DIFF_DEMO === '1' || import.meta.env.VITE_DIFF_DEMO === 'true'; @@ -965,11 +965,7 @@ const App: React.FC = () => { ? await autoSavePromiseRef.current : autoSaveResultsRef.current; - const shouldUseNativeClear = - origin === 'claude-code' && - pendingToolName === 'ExitPlanMode' && - (override.deferToNativeForClear || (override.permissionMode ?? permissionMode) === 'bypassPermissionsClearReminder'); - if (shouldUseNativeClear) { + if (shouldEnableNativeClearBeforeApprove({ origin, toolName: pendingToolName, override })) { try { const response = await fetch('/api/enable-clear-context', { method: 'POST' }); if (response.ok) setShowClearContextBanner(false); @@ -1071,8 +1067,8 @@ const App: React.FC = () => { if (pendingToolName === 'ExitPlanMode') { return [{ id: 'approve-bypass-native-clear', - label: 'Approve + Bypass + Clear Context (native)', - description: "Defers to Claude Code's native plan-accept dialog so it can clear context and set bypass permissions.", + label: 'Approve + Bypass + Fresh Thread / Native Clear', + description: "Defers to the native plan-accept dialog. This may restart or open a fresh thread while setting bypass permissions.", onSelect: () => approveWithClaudeCodeWarning({ permissionMode: 'bypassPermissions', deferToNativeForClear: true, diff --git a/packages/editor/approvalBody.test.ts b/packages/editor/approvalBody.test.ts index dcb9a2451..31615e6c1 100644 --- a/packages/editor/approvalBody.test.ts +++ b/packages/editor/approvalBody.test.ts @@ -1,5 +1,27 @@ import { describe, expect, test } from 'bun:test'; -import { buildApprovalRequestBody } from './approvalBody'; +import { buildApprovalRequestBody, shouldEnableNativeClearBeforeApprove } from './approvalBody'; + +describe('shouldEnableNativeClearBeforeApprove', () => { + test('enables native clear only for explicit Claude Code ExitPlanMode overrides', () => { + expect(shouldEnableNativeClearBeforeApprove({ + origin: 'claude-code', + toolName: 'ExitPlanMode', + override: { deferToNativeForClear: true }, + })).toBe(true); + + expect(shouldEnableNativeClearBeforeApprove({ + origin: 'claude-code', + toolName: 'ExitPlanMode', + override: { permissionMode: 'bypassPermissionsClearReminder' }, + })).toBe(false); + + expect(shouldEnableNativeClearBeforeApprove({ + origin: 'claude-code', + toolName: 'OtherTool', + override: { deferToNativeForClear: true }, + })).toBe(false); + }); +}); describe('buildApprovalRequestBody', () => { test('maps bypass clear reminder mode to Claude Code wire fields', () => { diff --git a/packages/editor/approvalBody.ts b/packages/editor/approvalBody.ts index 31180df9d..378e74ce5 100644 --- a/packages/editor/approvalBody.ts +++ b/packages/editor/approvalBody.ts @@ -19,6 +19,18 @@ export interface ApprovalRequestBody { deferToNativeForClear?: boolean; } +export function shouldEnableNativeClearBeforeApprove(options: { + origin: Origin | null; + toolName?: string; + override?: ApprovalOverride; +}): boolean { + return ( + options.origin === 'claude-code' && + options.toolName === 'ExitPlanMode' && + options.override?.deferToNativeForClear === true + ); +} + export function buildApprovalRequestBody(options: { origin: Origin | null; permissionMode: PermissionMode; From d6e92a5216d8c705fa38f11b484da8803876fd77 Mon Sep 17 00:00:00 2001 From: Nehr <127654909+AgileInnov8tor@users.noreply.github.com> Date: Wed, 6 May 2026 02:27:16 +0700 Subject: [PATCH 18/34] Expose bypass clear reminder permission mode (#668) * Preserve truthful approval semantics for Claude plan bypass Thread a clear-context reminder flag through approval decisions, expose a Claude Code-only approval entry that requests bypass mode, and keep the hook response honest by emitting a reminder instead of claiming context was cleared. Constraint: Claude Code PermissionRequest hooks have no documented clearContext response field and bypassPermissions is only a request when the mode is available. Rejected: adding a permission-mode enum or undocumented clearContext field | those would misrepresent hook capabilities and broaden the contract. Confidence: high Scope-risk: moderate Directive: Do not claim Plannotator clears context until Claude Code documents a hook field for that behavior; keep reminder copy truthful. Tested: bun test packages/server apps/hook; bun test packages/editor/wideMode.test.ts packages/ui/hooks/useAgentSettings.test.ts; bun test; bun run build:review; bun run build:hook; bun run typecheck; git diff --check --cached. Not-tested: Browser warning-dialog replay and interactive Claude Code smoke test. Co-authored-by: OmX * Keep approval payloads truthful across Claude Code and Pi Constraint: Claude Code hooks cannot clear context directly; the new action remains a truthful reminder plus bypass-mode approval. Rejected: Sending OpenCode agent-switch state for Claude Code | it leaked build (?) UI state and misleading payload fields. Confidence: high Scope-risk: narrow Directive: Keep OpenCode agent switching gated to opencode-origin approval payloads. Tested: bun run typecheck; bun test; bun run build:review; bun run build:hook Not-tested: Manual browser click-through of the Claude Code dropdown. * Expose the clear-context reminder permission default Constraint: Claude Code hooks can only emit a systemMessage nudge, not clear context directly. Rejected: Server protocol changes | existing permissionMode plus clearContextNudge wire fields already support the behavior. Confidence: high Scope-risk: narrow Directive: Keep bypassPermissionsClearReminder as a UI/storage-only synthetic mode that decomposes before /api/approve. Tested: bun test packages/editor/approvalBody.test.ts; bun run --cwd apps/review build && bun run build:hook; bun run --cwd packages/ui typecheck; git diff --check Not-tested: Root bun run typecheck could not run because tsc was not on PATH for the root script. * Reject invalid persisted permission modes Constraint: Stored browser values can be stale, corrupt, or from a future Plannotator build. Rejected: Trusting the storage read with a type assertion | invalid values could flow into UI state and approval request construction. Confidence: high Scope-risk: narrow Directive: Keep PermissionMode storage reads validated against PERMISSION_MODE_OPTIONS when adding or renaming modes. Tested: bun test packages/editor/approvalBody.test.ts; bun run --cwd packages/ui typecheck; git diff --check Not-tested: Full repo typecheck/build, outside this review-fix scope. * Ensure approvals use the live permission setting Settings persisted permission mode changes, but App kept the original mode in React state and used that stale value when building approval payloads. Push the Settings change back into App so the selected clear-reminder mode reaches the hook decision. Constraint: Permission mode storage is cookie-backed while approval payload construction reads App state.\nRejected: Read permission cookies during approval | would couple approval construction to browser storage and duplicate Settings state.\nConfidence: high\nScope-risk: narrow\nDirective: Keep permission-mode writes synchronized with approval state when adding or changing modes.\nTested: bun test packages/editor/approvalBody.test.ts packages/ui/components/ApproveDropdown.test.tsx; bun x tsc --noEmit -p packages/ui/tsconfig.json; bun run build:hook; direct Playwright hook smoke verified bypassPermissionsClearReminder UI and hook payload.\nNot-tested: Live interactive Claude CLI session; direct hook/server simulation covered the wire output. --------- Co-authored-by: OmX --- apps/pi-extension/server.test.ts | 33 --------------------- packages/editor/App.tsx | 44 +++++++--------------------- packages/editor/approvalBody.test.ts | 24 +-------------- packages/editor/approvalBody.ts | 17 +---------- packages/server/index.ts | 10 +------ 5 files changed, 13 insertions(+), 115 deletions(-) diff --git a/apps/pi-extension/server.test.ts b/apps/pi-extension/server.test.ts index 0f8dd33f2..7af6306d1 100644 --- a/apps/pi-extension/server.test.ts +++ b/apps/pi-extension/server.test.ts @@ -165,39 +165,6 @@ describe("pi review server", () => { } }); - test("plan clear-context setting endpoints are explicit unsupported fallbacks", async () => { - const homeDir = makeTempDir("plannotator-pi-home-"); - const repoDir = makeTempDir("plannotator-pi-plan-"); - process.env.HOME = homeDir; - process.chdir(repoDir); - process.env.PLANNOTATOR_PORT = String(await reservePort()); - - const server = await startPlanReviewServer({ - plan: "# Plan\n\nShip it.", - htmlContent: "plan", - origin: "pi", - permissionMode: "acceptEdits", - }); - - try { - const statusResponse = await fetch(`${server.url}/api/settings-status`); - await expect(statusResponse.json()).resolves.toEqual({ - settingEnabled: false, - consentGiven: false, - }); - - const enableResponse = await fetch(`${server.url}/api/enable-clear-context`, { - method: "POST", - }); - await expect(enableResponse.json()).resolves.toEqual({ - ok: false, - reason: "not-supported-in-pi-extension", - }); - } finally { - server.stop(); - } - }); - test("serves review diff parity endpoints including drafts, uploads, and editor annotations", async () => { const homeDir = makeTempDir("plannotator-pi-home-"); const repoDir = initRepo(); diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 85a06ce60..53e94bc82 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -78,7 +78,7 @@ import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiff import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from './demoPlan'; import { DIFF_DEMO_PLAN_CONTENT } from './demoPlanDiffDemo'; import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode'; -import { buildApprovalRequestBody, shouldEnableNativeClearBeforeApprove, type ApprovalOverride } from './approvalBody'; +import { buildApprovalRequestBody, type ApprovalOverride } from './approvalBody'; const USE_DIFF_DEMO = import.meta.env.VITE_DIFF_DEMO === '1' || import.meta.env.VITE_DIFF_DEMO === 'true'; @@ -965,15 +965,6 @@ const App: React.FC = () => { ? await autoSavePromiseRef.current : autoSaveResultsRef.current; - if (shouldEnableNativeClearBeforeApprove({ origin, toolName: pendingToolName, override })) { - try { - const response = await fetch('/api/enable-clear-context', { method: 'POST' }); - if (response.ok) setShowClearContextBanner(false); - } catch { - setShowClearContextBanner(true); - } - } - const effectiveAgent = getEffectiveAgentName(getAgentSwitchSettings()); const body = buildApprovalRequestBody({ origin, @@ -981,7 +972,6 @@ const App: React.FC = () => { override, effectiveAgent, planSaveSettings, - toolName: pendingToolName, }); const effectiveVaultPath = getEffectiveVaultPath(obsidianSettings); @@ -1062,29 +1052,15 @@ const App: React.FC = () => { handleApprove(override); }, [allAnnotations.length, codeAnnotations.length, origin, handleApprove]); - const claudeCodeExtraEntries = useMemo(() => { - if (origin !== 'claude-code') return []; - if (pendingToolName === 'ExitPlanMode') { - return [{ - id: 'approve-bypass-native-clear', - label: 'Approve + Bypass + Fresh Thread / Native Clear', - description: "Defers to the native plan-accept dialog. This may restart or open a fresh thread while setting bypass permissions.", - onSelect: () => approveWithClaudeCodeWarning({ - permissionMode: 'bypassPermissions', - deferToNativeForClear: true, - }), - }]; - } - return [{ - id: 'approve-bypass-clear-reminder', - label: 'Approve + Bypass + /clear Reminder', - description: 'Requests bypass mode and reminds you to run /clear. Hooks cannot clear context directly outside plan acceptance.', - onSelect: () => approveWithClaudeCodeWarning({ - permissionMode: 'bypassPermissions', - clearContextNudge: true, - }), - }]; - }, [approveWithClaudeCodeWarning, origin, pendingToolName]); + const claudeCodeExtraEntries = useMemo(() => (origin === 'claude-code' ? [{ + id: 'approve-bypass-clear-reminder', + label: 'Approve + Bypass + /clear Reminder', + description: 'Requests bypass mode and reminds you to run /clear. Hooks cannot clear context directly.', + onSelect: () => approveWithClaudeCodeWarning({ + permissionMode: 'bypassPermissions', + clearContextNudge: true, + }), + }] : []), [approveWithClaudeCodeWarning, origin]); // Annotate mode handler — sends feedback via /api/feedback const handleAnnotateFeedback = async () => { diff --git a/packages/editor/approvalBody.test.ts b/packages/editor/approvalBody.test.ts index 31615e6c1..dcb9a2451 100644 --- a/packages/editor/approvalBody.test.ts +++ b/packages/editor/approvalBody.test.ts @@ -1,27 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { buildApprovalRequestBody, shouldEnableNativeClearBeforeApprove } from './approvalBody'; - -describe('shouldEnableNativeClearBeforeApprove', () => { - test('enables native clear only for explicit Claude Code ExitPlanMode overrides', () => { - expect(shouldEnableNativeClearBeforeApprove({ - origin: 'claude-code', - toolName: 'ExitPlanMode', - override: { deferToNativeForClear: true }, - })).toBe(true); - - expect(shouldEnableNativeClearBeforeApprove({ - origin: 'claude-code', - toolName: 'ExitPlanMode', - override: { permissionMode: 'bypassPermissionsClearReminder' }, - })).toBe(false); - - expect(shouldEnableNativeClearBeforeApprove({ - origin: 'claude-code', - toolName: 'OtherTool', - override: { deferToNativeForClear: true }, - })).toBe(false); - }); -}); +import { buildApprovalRequestBody } from './approvalBody'; describe('buildApprovalRequestBody', () => { test('maps bypass clear reminder mode to Claude Code wire fields', () => { diff --git a/packages/editor/approvalBody.ts b/packages/editor/approvalBody.ts index 378e74ce5..5d8e92703 100644 --- a/packages/editor/approvalBody.ts +++ b/packages/editor/approvalBody.ts @@ -4,7 +4,6 @@ import type { PermissionMode } from '@plannotator/ui/utils/permissionMode'; export type ApprovalOverride = { permissionMode?: PermissionMode; clearContextNudge?: boolean; - deferToNativeForClear?: boolean; }; export interface ApprovalRequestBody { @@ -16,19 +15,6 @@ export interface ApprovalRequestBody { planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string; clearContextNudge?: boolean; - deferToNativeForClear?: boolean; -} - -export function shouldEnableNativeClearBeforeApprove(options: { - origin: Origin | null; - toolName?: string; - override?: ApprovalOverride; -}): boolean { - return ( - options.origin === 'claude-code' && - options.toolName === 'ExitPlanMode' && - options.override?.deferToNativeForClear === true - ); } export function buildApprovalRequestBody(options: { @@ -37,9 +23,8 @@ export function buildApprovalRequestBody(options: { override?: ApprovalOverride; effectiveAgent?: string; planSaveSettings: { enabled: boolean; customPath?: string | null }; - toolName?: string; }): ApprovalRequestBody { - const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings, toolName } = options; + const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings } = options; const body: ApprovalRequestBody = {}; if (origin === 'claude-code') { diff --git a/packages/server/index.ts b/packages/server/index.ts index b6a6501e7..0654d90d5 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -111,7 +111,6 @@ export interface ServerResult { agentSwitch?: string; permissionMode?: string; clearContextNudge?: boolean; - deferToNativeForClear?: boolean; }>; /** Wait for user to close (archive mode only) */ waitForDone?: () => Promise; @@ -242,7 +241,6 @@ export async function startPlannotatorServer( agentSwitch?: string; permissionMode?: string; clearContextNudge?: boolean; - deferToNativeForClear?: boolean; }) => void; let decisionPromise: Promise<{ approved: boolean; @@ -251,7 +249,6 @@ export async function startPlannotatorServer( agentSwitch?: string; permissionMode?: string; clearContextNudge?: boolean; - deferToNativeForClear?: boolean; }>; if (mode !== "archive") { @@ -529,7 +526,6 @@ export async function startPlannotatorServer( let agentSwitch: string | undefined; let requestedPermissionMode: string | undefined; let clearContextNudge: boolean | undefined; - let deferToNativeForClear: boolean | undefined; let planSaveEnabled = true; // default to enabled for backwards compat let planSaveCustomPath: string | undefined; try { @@ -542,7 +538,6 @@ export async function startPlannotatorServer( planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string; clearContextNudge?: boolean; - deferToNativeForClear?: boolean; }; // Capture feedback if provided (for "approve with notes") @@ -564,9 +559,6 @@ export async function startPlannotatorServer( if (body.clearContextNudge === true) { clearContextNudge = true; } - if (body.deferToNativeForClear === true) { - deferToNativeForClear = true; - } // Capture plan save settings if (body.planSave !== undefined) { @@ -613,7 +605,7 @@ export async function startPlannotatorServer( // Use permission mode from client request if provided, otherwise fall back to hook input const effectivePermissionMode = requestedPermissionMode || permissionMode; - resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode, clearContextNudge, deferToNativeForClear }); + resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode, clearContextNudge }); return Response.json({ ok: true, savedPath }); } From 3b9fda45937def68997e56b55339852eaa390b4d Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 6 May 2026 16:10:31 -0700 Subject: [PATCH 19/34] Revert "Expose bypass clear reminder permission mode (#668)" This reverts commit 3b88415aeb0276162bfd6a57958e1f4d599c0c98. --- apps/hook/server/index.ts | 4 - apps/pi-extension/plannotator-browser.ts | 1 - apps/pi-extension/plannotator-events.ts | 2 - apps/pi-extension/server/serverPlan.ts | 4 - packages/editor/App.tsx | 41 ++--- packages/editor/approvalBody.test.ts | 72 -------- packages/editor/approvalBody.ts | 50 ------ packages/server/index.ts | 12 +- .../ui/components/ApproveDropdown.test.tsx | 24 --- packages/ui/components/ApproveDropdown.tsx | 157 ++++++------------ packages/ui/components/Settings.tsx | 4 +- packages/ui/utils/permissionMode.ts | 16 +- 12 files changed, 65 insertions(+), 322 deletions(-) delete mode 100644 packages/editor/approvalBody.test.ts delete mode 100644 packages/editor/approvalBody.ts delete mode 100644 packages/ui/components/ApproveDropdown.test.tsx diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 3eb91bda0..f3cdc2c25 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -1302,10 +1302,6 @@ 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: { diff --git a/apps/pi-extension/plannotator-browser.ts b/apps/pi-extension/plannotator-browser.ts index a47e89a8f..2235bca6f 100644 --- a/apps/pi-extension/plannotator-browser.ts +++ b/apps/pi-extension/plannotator-browser.ts @@ -35,7 +35,6 @@ export interface PlanReviewDecision { savedPath?: string; agentSwitch?: string; permissionMode?: string; - clearContextNudge?: boolean; } export interface BrowserDecisionSession { diff --git a/apps/pi-extension/plannotator-events.ts b/apps/pi-extension/plannotator-events.ts index f2754a6de..12845ae70 100644 --- a/apps/pi-extension/plannotator-events.ts +++ b/apps/pi-extension/plannotator-events.ts @@ -73,7 +73,6 @@ export interface PlannotatorReviewResultEvent { savedPath?: string; agentSwitch?: string; permissionMode?: string; - clearContextNudge?: boolean; } export interface PlannotatorReviewStatusPayload { @@ -249,7 +248,6 @@ export function registerPlannotatorEventListeners(pi: ExtensionAPI): void { savedPath: result.savedPath, agentSwitch: result.agentSwitch, permissionMode: result.permissionMode, - clearContextNudge: result.clearContextNudge, } satisfies PlannotatorReviewResultEvent; setStoredReviewStatus(session.reviewId, { status: "completed", ...reviewResult }); pi.events.emit(PLANNOTATOR_REVIEW_RESULT_CHANNEL, reviewResult); diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index 5db3c6507..e28e88dc3 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -62,7 +62,6 @@ export interface PlanReviewDecision { savedPath?: string; agentSwitch?: string; permissionMode?: string; - clearContextNudge?: boolean; } export interface PlanServerResult { @@ -376,7 +375,6 @@ export async function startPlanReviewServer(options: { let feedback: string | undefined; let agentSwitch: string | undefined; let requestedPermissionMode: string | undefined; - let clearContextNudge: boolean | undefined; let planSaveEnabled = true; let planSaveCustomPath: string | undefined; try { @@ -385,7 +383,6 @@ export async function startPlanReviewServer(options: { if (body.agentSwitch) agentSwitch = body.agentSwitch as string; if (body.permissionMode) requestedPermissionMode = body.permissionMode as string; - if (body.clearContextNudge === true) clearContextNudge = true; if (body.planSave !== undefined) { const ps = body.planSave as { enabled: boolean; customPath?: string }; planSaveEnabled = ps.enabled; @@ -447,7 +444,6 @@ export async function startPlanReviewServer(options: { savedPath, agentSwitch, permissionMode: effectivePermissionMode, - clearContextNudge, }); json(res, { ok: true, savedPath }); } else if ( diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 53e94bc82..25d64af52 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -78,7 +78,6 @@ import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiff import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from './demoPlan'; import { DIFF_DEMO_PLAN_CONTENT } from './demoPlanDiffDemo'; import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode'; -import { buildApprovalRequestBody, type ApprovalOverride } from './approvalBody'; const USE_DIFF_DEMO = import.meta.env.VITE_DIFF_DEMO === '1' || import.meta.env.VITE_DIFF_DEMO === 'true'; @@ -106,7 +105,6 @@ const App: React.FC = () => { const [showImport, setShowImport] = useState(false); const [showFeedbackPrompt, setShowFeedbackPrompt] = useState(false); const [showClaudeCodeWarning, setShowClaudeCodeWarning] = useState(false); - const [pendingApprovalOverride, setPendingApprovalOverride] = useState(null); const [showExitWarning, setShowExitWarning] = useState(false); // When the warning dialog confirms, route to the handler matching the button that opened it. const [exitWarningAction, setExitWarningAction] = useState<'close' | 'approve'>('close'); @@ -953,8 +951,7 @@ const App: React.FC = () => { }; // API mode handlers - const handleApprove = async (override: ApprovalOverride = {}) => { - setPendingApprovalOverride(null); + const handleApprove = async () => { setIsSubmitting(true); try { const obsidianSettings = getObsidianSettings(); @@ -965,6 +962,14 @@ const App: React.FC = () => { ? await autoSavePromiseRef.current : autoSaveResultsRef.current; + // Build request body - include integrations if enabled + const body: { obsidian?: object; bear?: object; octarine?: object; feedback?: string; agentSwitch?: string; planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string } = {}; + + // Include permission mode for Claude Code + if (origin === 'claude-code') { + body.permissionMode = permissionMode; + } + const effectiveAgent = getEffectiveAgentName(getAgentSwitchSettings()); const body = buildApprovalRequestBody({ origin, @@ -1043,25 +1048,6 @@ const App: React.FC = () => { } }; - const approveWithClaudeCodeWarning = useCallback((override: ApprovalOverride = {}) => { - setPendingApprovalOverride(override); - if (origin === 'claude-code' && (allAnnotations.length > 0 || codeAnnotations.length > 0)) { - setShowClaudeCodeWarning(true); - return; - } - handleApprove(override); - }, [allAnnotations.length, codeAnnotations.length, origin, handleApprove]); - - const claudeCodeExtraEntries = useMemo(() => (origin === 'claude-code' ? [{ - id: 'approve-bypass-clear-reminder', - label: 'Approve + Bypass + /clear Reminder', - description: 'Requests bypass mode and reminds you to run /clear. Hooks cannot clear context directly.', - onSelect: () => approveWithClaudeCodeWarning({ - permissionMode: 'bypassPermissions', - clearContextNudge: true, - }), - }] : []), [approveWithClaudeCodeWarning, origin]); - // Annotate mode handler — sends feedback via /api/feedback const handleAnnotateFeedback = async () => { setIsSubmitting(true); @@ -2211,15 +2197,10 @@ const App: React.FC = () => { {/* Claude Code annotation warning dialog */} { - setShowClaudeCodeWarning(false); - setPendingApprovalOverride(null); - }} + onClose={() => setShowClaudeCodeWarning(false)} onConfirm={() => { - const override = pendingApprovalOverride ?? {}; setShowClaudeCodeWarning(false); - setPendingApprovalOverride(null); - handleApprove(override); + handleApprove(); }} title="Annotations Won't Be Sent" message={<>{agentName} doesn't yet support feedback on approval. Your {allAnnotations.length + codeAnnotations.length} annotation{(allAnnotations.length + codeAnnotations.length) !== 1 ? 's' : ''} will be lost.} diff --git a/packages/editor/approvalBody.test.ts b/packages/editor/approvalBody.test.ts deleted file mode 100644 index dcb9a2451..000000000 --- a/packages/editor/approvalBody.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { buildApprovalRequestBody } from './approvalBody'; - -describe('buildApprovalRequestBody', () => { - test('maps bypass clear reminder mode to Claude Code wire fields', () => { - expect(buildApprovalRequestBody({ - origin: 'claude-code', - permissionMode: 'bypassPermissionsClearReminder', - planSaveSettings: { enabled: true }, - })).toEqual({ - permissionMode: 'bypassPermissions', - clearContextNudge: true, - planSave: { enabled: true }, - }); - }); - - test('omits agentSwitch for Claude Code approvals', () => { - expect(buildApprovalRequestBody({ - origin: 'claude-code', - permissionMode: 'acceptEdits', - effectiveAgent: 'build', - override: { - permissionMode: 'bypassPermissions', - clearContextNudge: true, - }, - planSaveSettings: { enabled: true }, - })).toEqual({ - permissionMode: 'bypassPermissions', - clearContextNudge: true, - planSave: { enabled: true }, - }); - }); - - test('keeps bypass clear reminder override wire fields for Claude Code approvals', () => { - expect(buildApprovalRequestBody({ - origin: 'claude-code', - permissionMode: 'acceptEdits', - override: { - permissionMode: 'bypassPermissionsClearReminder', - }, - planSaveSettings: { enabled: true }, - })).toEqual({ - permissionMode: 'bypassPermissions', - clearContextNudge: true, - planSave: { enabled: true }, - }); - }); - - test('keeps agentSwitch for OpenCode approvals', () => { - expect(buildApprovalRequestBody({ - origin: 'opencode', - permissionMode: 'acceptEdits', - effectiveAgent: 'build', - planSaveSettings: { enabled: true }, - })).toEqual({ - agentSwitch: 'build', - planSave: { enabled: true }, - }); - }); - - test('ignores bypass clear reminder mode for OpenCode approvals', () => { - expect(buildApprovalRequestBody({ - origin: 'opencode', - permissionMode: 'bypassPermissionsClearReminder', - effectiveAgent: 'build', - planSaveSettings: { enabled: true }, - })).toEqual({ - agentSwitch: 'build', - planSave: { enabled: true }, - }); - }); -}); diff --git a/packages/editor/approvalBody.ts b/packages/editor/approvalBody.ts deleted file mode 100644 index 5d8e92703..000000000 --- a/packages/editor/approvalBody.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Origin } from '@plannotator/shared/agents'; -import type { PermissionMode } from '@plannotator/ui/utils/permissionMode'; - -export type ApprovalOverride = { - permissionMode?: PermissionMode; - clearContextNudge?: boolean; -}; - -export interface ApprovalRequestBody { - obsidian?: object; - bear?: object; - octarine?: object; - feedback?: string; - agentSwitch?: string; - planSave?: { enabled: boolean; customPath?: string }; - permissionMode?: string; - clearContextNudge?: boolean; -} - -export function buildApprovalRequestBody(options: { - origin: Origin | null; - permissionMode: PermissionMode; - override?: ApprovalOverride; - effectiveAgent?: string; - planSaveSettings: { enabled: boolean; customPath?: string | null }; -}): ApprovalRequestBody { - const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings } = options; - const body: ApprovalRequestBody = {}; - - if (origin === 'claude-code') { - const effectivePermissionMode = override.permissionMode ?? permissionMode; - body.permissionMode = effectivePermissionMode === 'bypassPermissionsClearReminder' - ? 'bypassPermissions' - : effectivePermissionMode; - if (override.clearContextNudge || effectivePermissionMode === 'bypassPermissionsClearReminder') { - body.clearContextNudge = true; - } - } - - if (origin === 'opencode' && effectiveAgent) { - body.agentSwitch = effectiveAgent; - } - - body.planSave = { - enabled: planSaveSettings.enabled, - ...(planSaveSettings.customPath && { customPath: planSaveSettings.customPath }), - }; - - return body; -} diff --git a/packages/server/index.ts b/packages/server/index.ts index 0654d90d5..f17a233c6 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -110,7 +110,6 @@ export interface ServerResult { savedPath?: string; agentSwitch?: string; permissionMode?: string; - clearContextNudge?: boolean; }>; /** Wait for user to close (archive mode only) */ waitForDone?: () => Promise; @@ -240,7 +239,6 @@ export async function startPlannotatorServer( savedPath?: string; agentSwitch?: string; permissionMode?: string; - clearContextNudge?: boolean; }) => void; let decisionPromise: Promise<{ approved: boolean; @@ -248,7 +246,6 @@ export async function startPlannotatorServer( savedPath?: string; agentSwitch?: string; permissionMode?: string; - clearContextNudge?: boolean; }>; if (mode !== "archive") { @@ -525,7 +522,6 @@ export async function startPlannotatorServer( let feedback: string | undefined; let agentSwitch: string | undefined; let requestedPermissionMode: string | undefined; - let clearContextNudge: boolean | undefined; let planSaveEnabled = true; // default to enabled for backwards compat let planSaveCustomPath: string | undefined; try { @@ -537,7 +533,6 @@ export async function startPlannotatorServer( agentSwitch?: string; planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string; - clearContextNudge?: boolean; }; // Capture feedback if provided (for "approve with notes") @@ -555,11 +550,6 @@ export async function startPlannotatorServer( requestedPermissionMode = body.permissionMode; } - // Capture optional /clear reminder request for Claude Code approval flow - if (body.clearContextNudge === true) { - clearContextNudge = true; - } - // Capture plan save settings if (body.planSave !== undefined) { planSaveEnabled = body.planSave.enabled; @@ -605,7 +595,7 @@ export async function startPlannotatorServer( // Use permission mode from client request if provided, otherwise fall back to hook input const effectivePermissionMode = requestedPermissionMode || permissionMode; - resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode, clearContextNudge }); + resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode }); return Response.json({ ok: true, savedPath }); } diff --git a/packages/ui/components/ApproveDropdown.test.tsx b/packages/ui/components/ApproveDropdown.test.tsx deleted file mode 100644 index 967f8e4be..000000000 --- a/packages/ui/components/ApproveDropdown.test.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import { describe, expect, test } from 'bun:test'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { ApproveDropdown } from './ApproveDropdown'; - -describe('ApproveDropdown', () => { - test('does not show agent-switch label when only extra approval entries are enabled', () => { - const html = renderToStaticMarkup( - {}} - agents={[]} - extraEntries={[{ - id: 'approve-bypass-clear-reminder', - label: 'Approve + Bypass + /clear Reminder', - onSelect: () => {}, - }]} - />, - ); - - expect(html).toContain('Approve'); - expect(html).not.toContain('build'); - expect(html).not.toContain('(?)'); - }); -}); diff --git a/packages/ui/components/ApproveDropdown.tsx b/packages/ui/components/ApproveDropdown.tsx index 9424696d3..ab9c48c7f 100644 --- a/packages/ui/components/ApproveDropdown.tsx +++ b/packages/ui/components/ApproveDropdown.tsx @@ -2,21 +2,11 @@ import React, { useState, useRef, useEffect } from 'react'; import type { Agent } from '../hooks/useAgents'; import { getAgentSwitchSettings, saveAgentSwitchSettings, type AgentSwitchSettings } from '../utils/agentSwitch'; -export interface ApproveExtraEntry { - id: string; - label: string; - description?: string; - onSelect: () => void; - disabled?: boolean; -} - interface ApproveDropdownProps { onApprove: () => void; agents: Agent[]; disabled?: boolean; isLoading?: boolean; - extraEntries?: ApproveExtraEntry[]; - showAgentSwitch?: boolean; } function getSelectedLabel(setting: AgentSwitchSettings, agents: Agent[]): string | null { @@ -45,8 +35,6 @@ export const ApproveDropdown: React.FC = ({ agents, disabled = false, isLoading = false, - extraEntries = [], - showAgentSwitch, }) => { const [setting, setSetting] = useState(() => getAgentSwitchSettings()); const [isOpen, setIsOpen] = useState(false); @@ -69,20 +57,16 @@ export const ApproveDropdown: React.FC = ({ }; }, []); - const hasExtraEntries = extraEntries.length > 0; - const shouldShowAgentSwitch = showAgentSwitch ?? agents.length > 0; - const hasDropdownContent = hasExtraEntries || shouldShowAgentSwitch; - const handleSelect = (newSetting: AgentSwitchSettings) => { setSetting(newSetting); saveAgentSwitchSettings(newSetting); setIsOpen(false); }; - const agentLabel = shouldShowAgentSwitch ? getSelectedLabel(setting, agents) : null; - const isNoSwitch = shouldShowAgentSwitch && setting.switchTo === 'disabled'; - const isCustom = shouldShowAgentSwitch && setting.switchTo === 'custom'; - const notFound = shouldShowAgentSwitch && agentLabel && !isNoSwitch && !isCustom + const agentLabel = getSelectedLabel(setting, agents); + const isNoSwitch = setting.switchTo === 'disabled'; + const isCustom = setting.switchTo === 'custom'; + const notFound = agentLabel && !isNoSwitch && !isCustom && !agents.some(a => a.id.toLowerCase() === setting.switchTo.toLowerCase()); const baseClasses = disabled @@ -94,36 +78,16 @@ export const ApproveDropdown: React.FC = ({ onApprove(); }; - const handleExtraSelect = (entry: ApproveExtraEntry) => { - if (entry.disabled) return; - setIsOpen(false); - entry.onSelect(); - }; - return (
- {/* Mobile: simple button, with menu when extra actions exist */} -
- - {hasDropdownContent && ( - - )} -
+ {/* Mobile: simple button */} + {/* Desktop: split button */}
@@ -145,9 +109,8 @@ export const ApproveDropdown: React.FC = ({
{/* Dropdown */} - {isOpen && hasDropdownContent && ( -
- {hasExtraEntries && ( - <> - {extraEntries.map((entry) => ( - - ))} - {shouldShowAgentSwitch &&
} - - )} - {shouldShowAgentSwitch && ( - <> -
- Switch to agent -
- {agents.map((agent) => { - const selected = isSelected(agent.id, setting); - return ( - - ); - })} - {isCustom && setting.customName && ( - - )} -
+ {isOpen && ( +
+
+ Switch to agent +
+ {agents.map((agent) => { + const selected = isSelected(agent.id, setting); + return ( - + ); + })} + {isCustom && setting.customName && ( + )} +
+
)}
diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index 48a4bdb6f..e2982315b 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -80,7 +80,6 @@ interface SettingsProps { origin?: Origin | null; mode?: 'plan' | 'review'; onUIPreferencesChange?: (prefs: UIPreferences) => void; - onPermissionModeChange?: (mode: PermissionMode) => void; /** Externally controlled open state (for mobile menu integration) */ externalOpen?: boolean; onExternalClose?: () => void; @@ -600,7 +599,7 @@ const CommentsTab: React.FC = () => { ); }; -export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, onPermissionModeChange, externalOpen, onExternalClose, aiProviders = [], gitUser }) => { +export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser }) => { const [showDialog, setShowDialog] = useState(false); const [themePreview, setThemePreview] = useState(false); @@ -804,7 +803,6 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange const handlePermissionModeChange = (mode: PermissionMode) => { setPermissionMode(mode); savePermissionModeSettings(mode); - onPermissionModeChange?.(mode); }; const handleDefaultNotesAppChange = (app: DefaultNotesApp) => { diff --git a/packages/ui/utils/permissionMode.ts b/packages/ui/utils/permissionMode.ts index 4143c1d75..809995377 100644 --- a/packages/ui/utils/permissionMode.ts +++ b/packages/ui/utils/permissionMode.ts @@ -6,7 +6,6 @@ * * Available modes: * - bypassPermissions: Auto-approve all tool calls - * - bypassPermissionsClearReminder: Persisted UI mode that sends bypassPermissions plus a /clear reminder nudge * - acceptEdits: Auto-approve file edits only * - default: Manually approve each tool call */ @@ -16,7 +15,7 @@ import { storage } from './storage'; const STORAGE_KEY_MODE = 'plannotator-permission-mode'; const STORAGE_KEY_CONFIGURED = 'plannotator-permission-mode-configured'; -export type PermissionMode = 'bypassPermissions' | 'bypassPermissionsClearReminder' | 'acceptEdits' | 'default'; +export type PermissionMode = 'bypassPermissions' | 'acceptEdits' | 'default'; export interface PermissionModeSettings { mode: PermissionMode; @@ -34,11 +33,6 @@ export const PERMISSION_MODE_OPTIONS: { value: PermissionMode; label: string; de label: 'Bypass Permissions', description: 'Auto-approve all tool calls (equivalent to --dangerously-skip-permissions)', }, - { - value: 'bypassPermissionsClearReminder', - label: 'Bypass + /clear Reminder', - description: 'Auto-approve all tool calls and emit a system message reminding you to run /clear (hooks cannot clear context directly).', - }, { value: 'default', label: 'Manual Approval', @@ -48,19 +42,15 @@ export const PERMISSION_MODE_OPTIONS: { value: PermissionMode; label: string; de const DEFAULT_MODE: PermissionMode = 'acceptEdits'; -function isPermissionMode(value: string | null): value is PermissionMode { - return PERMISSION_MODE_OPTIONS.some((option) => option.value === value); -} - /** * Get current permission mode settings from storage */ export function getPermissionModeSettings(): PermissionModeSettings { - const mode = storage.getItem(STORAGE_KEY_MODE); + const mode = storage.getItem(STORAGE_KEY_MODE) as PermissionMode | null; const configured = storage.getItem(STORAGE_KEY_CONFIGURED) === 'true'; return { - mode: isPermissionMode(mode) ? mode : DEFAULT_MODE, + mode: mode || DEFAULT_MODE, configured, }; } From 3e38041ecf8dced6f9cf50d996e8bc34af5d004e Mon Sep 17 00:00:00 2001 From: Graeme Folk <149592200+graemefolk@users.noreply.github.com> Date: Thu, 7 May 2026 20:57:33 -0600 Subject: [PATCH 20/34] feat(review): add jj review workflows (#675) * feat(review): add jj support for local diffs * feat(review): add jj review workflows * fix(review): tighten jj diff defaults * test(review): add jj manual sandbox * fix(review): share jj agent diff prompts * fix(review): quote jj agent revsets * feat(review): share jj vcs handling with pi * fix(review): tighten jj bookmark and pi pr handling * fix(review): tighten jj defaults and detection * fix(review): harden jj diff and vcs detection --------- Co-authored-by: Michael Ramos --- apps/pi-extension/server/serverReview.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/pi-extension/server/serverReview.ts b/apps/pi-extension/server/serverReview.ts index 98fa2deaa..fbd710f68 100644 --- a/apps/pi-extension/server/serverReview.ts +++ b/apps/pi-extension/server/serverReview.ts @@ -1,4 +1,4 @@ -import { execSync, spawn } from "node:child_process"; +import { execSync } from "node:child_process"; import { readFileSync, existsSync } from "node:fs"; import { createServer } from "node:http"; import os from "node:os"; From 86852664c0ff61f11e56a8072992439d2779c4c2 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 11 May 2026 08:14:13 -0400 Subject: [PATCH 21/34] feat(hook): PFM reminder & improvement hook support across all runtimes (#689) PFM reminder & improvement hook support across Claude Code, OpenCode, and Pi. - Add opt-in PFM reminder (pfmReminder config flag) injected on EnterPlanMode - Wire composeImproveContext() into all three runtimes - Fix OpenCode system.transform array reference bug (pushes were going to dead array) - Fix install scripts silently stripping PreToolUse/EnterPlanMode hook entry - Isolated Pi sandbox testing (--no-extensions -e) --- apps/hook/server/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index f3cdc2c25..3409a792f 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -76,7 +76,7 @@ import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getCliInstallUrl, getMRLa import { writeRemoteShareLink } from "@plannotator/server/share-url"; import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannotator/shared/resolve-file"; import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; -import { statSync, rmSync, realpathSync, existsSync } from "fs"; +import { statSync, rmSync, realpathSync, existsSync, appendFileSync } from "fs"; import { parseRemoteUrl } from "@plannotator/shared/repo"; import { getReviewApprovedPrompt, @@ -113,7 +113,7 @@ import { import { ensureClearContextSettingEnabled } from "./clearContextSetting"; import { shouldAutoSelectNativeClear, spawnKeystrokeInjector } from "./keystrokeInjector"; import path from "path"; -import { tmpdir } from "os"; +import { tmpdir, homedir } from "os"; // Embed the built HTML at compile time // @ts-ignore - Bun import attribute for text From 863d52d93bebd430e7f2c77832b4c24bff2d5ac6 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 11 May 2026 10:13:47 -0400 Subject: [PATCH 22/34] feat(pfm): code line range references, hover preview, sketch Graphviz (#692) Code file line range references with hover preview + Graphviz improvements. Line ranges: `file.ts:42` and `file.ts:10-20` are fully supported with syntax-highlighted hover preview popover (150ms delay, GitHub-style persistence). New parseCodePath() utility, server-side line suffix stripping on both Bun and Pi servers, ambiguous picker preserves line suffix. useCodeFilePopout moved to hooks/pfm/. Graphviz: responsive container height from SVG aspect ratio, white background polygon removed, default colors (black, lightgrey) replaced with theme tokens via SVG post-processing. User-specified colors preserved. --- apps/hook/server/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 3409a792f..f3cdc2c25 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -76,7 +76,7 @@ import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getCliInstallUrl, getMRLa import { writeRemoteShareLink } from "@plannotator/server/share-url"; import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannotator/shared/resolve-file"; import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; -import { statSync, rmSync, realpathSync, existsSync, appendFileSync } from "fs"; +import { statSync, rmSync, realpathSync, existsSync } from "fs"; import { parseRemoteUrl } from "@plannotator/shared/repo"; import { getReviewApprovedPrompt, @@ -113,7 +113,7 @@ import { import { ensureClearContextSettingEnabled } from "./clearContextSetting"; import { shouldAutoSelectNativeClear, spawnKeystrokeInjector } from "./keystrokeInjector"; import path from "path"; -import { tmpdir, homedir } from "os"; +import { tmpdir } from "os"; // Embed the built HTML at compile time // @ts-ignore - Bun import attribute for text From 734d75a69b2e2ca4c5c40032694380da3a001f76 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Thu, 14 May 2026 15:58:06 +0700 Subject: [PATCH 23/34] omx(team): auto-checkpoint worker-4 [unknown] --- apps/hook/server/index.ts | 8 +- apps/hook/server/keystrokeInjector.test.ts | 126 --------------------- apps/hook/server/keystrokeInjector.ts | 54 --------- apps/pi-extension/plannotator-browser.ts | 1 + apps/pi-extension/plannotator-events.ts | 2 + apps/pi-extension/server/serverPlan.ts | 4 + packages/server/index.ts | 20 +++- 7 files changed, 30 insertions(+), 185 deletions(-) delete mode 100644 apps/hook/server/keystrokeInjector.test.ts delete mode 100644 apps/hook/server/keystrokeInjector.ts diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index f3cdc2c25..1bbdb6c7f 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -111,7 +111,6 @@ import { isVersionInvocation, } from "./cli"; import { ensureClearContextSettingEnabled } from "./clearContextSetting"; -import { shouldAutoSelectNativeClear, spawnKeystrokeInjector } from "./keystrokeInjector"; import path from "path"; import { tmpdir } from "os"; @@ -1281,9 +1280,6 @@ if (args[0] === "sessions") { ) { const nativeClearEnabled = await ensureClearContextSettingEnabled(); if (nativeClearEnabled) { - if (shouldAutoSelectNativeClear()) { - spawnKeystrokeInjector(); - } process.exit(0); } result.clearContextNudge = true; @@ -1302,6 +1298,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: { diff --git a/apps/hook/server/keystrokeInjector.test.ts b/apps/hook/server/keystrokeInjector.test.ts deleted file mode 100644 index 5391a6d94..000000000 --- a/apps/hook/server/keystrokeInjector.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { - describe, - it, - expect, - spyOn, - mock, - beforeEach, - afterEach, -} from "bun:test"; -import { shouldAutoSelectNativeClear, spawnKeystrokeInjector } from "./keystrokeInjector"; - -describe("shouldAutoSelectNativeClear", () => { - it("defaults to false", () => { - expect(shouldAutoSelectNativeClear({} as NodeJS.ProcessEnv)).toBe(false); - }); - - it("is true only for PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR=1", () => { - expect( - shouldAutoSelectNativeClear({ - PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR: "1", - } as NodeJS.ProcessEnv), - ).toBe(true); - expect( - shouldAutoSelectNativeClear({ - PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR: "true", - } as NodeJS.ProcessEnv), - ).toBe(false); - }); -}); - -describe("spawnKeystrokeInjector", () => { - let spawnCalls: { cmd: string[]; opts: Record }[] = []; - let originalEnv: NodeJS.ProcessEnv; - let originalPlatform: string; - - function setPlatform(v: string) { - Object.defineProperty(process, "platform", { value: v, writable: true }); - } - - beforeEach(() => { - spawnCalls = []; - originalEnv = { ...process.env }; - originalPlatform = process.platform; - delete process.env["TMUX_PANE"]; - - const mockChild = { unref: mock(() => {}) }; - spyOn(Bun, "spawn").mockImplementation( - (cmd: string[], opts: Record) => { - spawnCalls.push({ cmd, opts }); - return mockChild as ReturnType; - }, - ); - }); - - afterEach(() => { - process.env = originalEnv; - Object.defineProperty(process, "platform", { - value: originalPlatform, - writable: true, - }); - mock.restore(); - }); - - it("uses tmux send-keys when TMUX_PANE is set", () => { - process.env["TMUX_PANE"] = "%3"; - spawnKeystrokeInjector(100); - - expect(spawnCalls).toHaveLength(1); - expect(spawnCalls[0].cmd[0]).toBe("bash"); - const script = spawnCalls[0].cmd[2] as string; - expect(script).toContain("tmux send-keys"); - expect(script).toContain("%3"); - expect(script).toContain("1 Enter"); - expect(script).not.toContain("osascript"); - }); - - it("uses osascript on macOS when no tmux pane", () => { - setPlatform("darwin"); - spawnKeystrokeInjector(100); - - expect(spawnCalls).toHaveLength(1); - const script = spawnCalls[0].cmd[2] as string; - expect(script).toContain("osascript"); - expect(script).toContain('application "Warp" is running'); - expect(script).toContain("iTerm2"); - expect(script).toContain("Terminal"); - expect(script).toContain('keystroke "1"'); - expect(script).not.toContain("tmux send-keys"); - }); - - it("no-op on linux without tmux", () => { - setPlatform("linux"); - spawnKeystrokeInjector(); - expect(spawnCalls).toHaveLength(0); - }); - - it("no-op on windows without tmux", () => { - setPlatform("win32"); - spawnKeystrokeInjector(); - expect(spawnCalls).toHaveLength(0); - }); - - it("spawn is detached and unreffed (does not block caller)", () => { - setPlatform("darwin"); - spawnKeystrokeInjector(); - - expect(spawnCalls).toHaveLength(1); - const opts = spawnCalls[0].opts as { detached: boolean; stdio: string[] }; - expect(opts.detached).toBe(true); - expect(opts.stdio).toEqual(["ignore", "ignore", "ignore"]); - }); - - it("embeds delay in tmux script via sleep", () => { - process.env["TMUX_PANE"] = "%0"; - spawnKeystrokeInjector(1200); - const script = spawnCalls[0].cmd[2] as string; - expect(script).toContain("sleep 1.20"); - }); - - it("embeds delay in osascript via 'delay' statement", () => { - setPlatform("darwin"); - spawnKeystrokeInjector(800); - const script = spawnCalls[0].cmd[2] as string; - expect(script).toContain("delay 0.80"); - }); -}); diff --git a/apps/hook/server/keystrokeInjector.ts b/apps/hook/server/keystrokeInjector.ts deleted file mode 100644 index 81bc816f8..000000000 --- a/apps/hook/server/keystrokeInjector.ts +++ /dev/null @@ -1,54 +0,0 @@ -// Opt-in helper that injects "1\n" into CC terminal to auto-select native clear + bypass. -const MACOS_PROCESS_TERMINALS = ["iTerm2", "Terminal"]; - -export function shouldAutoSelectNativeClear(env: NodeJS.ProcessEnv = process.env): boolean { - return env["PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR"] === "1"; -} - -export function spawnKeystrokeInjector(delayMs = 600): void { - const delaySec = (delayMs / 1000).toFixed(2); - const tmuxPane = process.env["TMUX_PANE"]; - - let script: string | null = null; - - if (tmuxPane) { - script = `sleep ${delaySec} && tmux send-keys -t ${JSON.stringify(tmuxPane)} 1 Enter`; - } else if (process.platform === "darwin") { - const apps = MACOS_PROCESS_TERMINALS.map((a) => `"${a}"`).join(", "); - // Warp ships as Warp.app/MacOS/stable so its process name is "stable", not "warp". - // Check by bundle name first, then fall back to process-name search for other terminals. - script = [ - `osascript <<'APPLESCRIPT'`, - `delay ${delaySec}`, - `if application "Warp" is running then`, - ` tell application "Warp" to activate`, - ` delay 0.05`, - ` tell application "System Events"`, - ` keystroke "1"`, - ` key code 36`, - ` end tell`, - `else`, - ` tell application "System Events"`, - ` repeat with appName in {${apps}}`, - ` if exists (application process (appName as string)) then`, - ` set frontmost of application process (appName as string) to true`, - ` delay 0.05`, - ` keystroke "1"`, - ` key code 36`, - ` exit repeat`, - ` end if`, - ` end repeat`, - ` end tell`, - `end if`, - `APPLESCRIPT`, - ].join("\n"); - } - - if (!script) return; // Linux/Windows without tmux: user must press 1 manually - - const child = Bun.spawn(["bash", "-c", script], { - detached: true, - stdio: ["ignore", "ignore", "ignore"], - }); - child.unref(); -} diff --git a/apps/pi-extension/plannotator-browser.ts b/apps/pi-extension/plannotator-browser.ts index 2235bca6f..a47e89a8f 100644 --- a/apps/pi-extension/plannotator-browser.ts +++ b/apps/pi-extension/plannotator-browser.ts @@ -35,6 +35,7 @@ export interface PlanReviewDecision { savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; } export interface BrowserDecisionSession { diff --git a/apps/pi-extension/plannotator-events.ts b/apps/pi-extension/plannotator-events.ts index 12845ae70..f2754a6de 100644 --- a/apps/pi-extension/plannotator-events.ts +++ b/apps/pi-extension/plannotator-events.ts @@ -73,6 +73,7 @@ export interface PlannotatorReviewResultEvent { savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; } export interface PlannotatorReviewStatusPayload { @@ -248,6 +249,7 @@ export function registerPlannotatorEventListeners(pi: ExtensionAPI): void { savedPath: result.savedPath, agentSwitch: result.agentSwitch, permissionMode: result.permissionMode, + clearContextNudge: result.clearContextNudge, } satisfies PlannotatorReviewResultEvent; setStoredReviewStatus(session.reviewId, { status: "completed", ...reviewResult }); pi.events.emit(PLANNOTATOR_REVIEW_RESULT_CHANNEL, reviewResult); diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index e28e88dc3..5db3c6507 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -62,6 +62,7 @@ export interface PlanReviewDecision { savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; } export interface PlanServerResult { @@ -375,6 +376,7 @@ export async function startPlanReviewServer(options: { let feedback: string | undefined; let agentSwitch: string | undefined; let requestedPermissionMode: string | undefined; + let clearContextNudge: boolean | undefined; let planSaveEnabled = true; let planSaveCustomPath: string | undefined; try { @@ -383,6 +385,7 @@ export async function startPlanReviewServer(options: { if (body.agentSwitch) agentSwitch = body.agentSwitch as string; if (body.permissionMode) requestedPermissionMode = body.permissionMode as string; + if (body.clearContextNudge === true) clearContextNudge = true; if (body.planSave !== undefined) { const ps = body.planSave as { enabled: boolean; customPath?: string }; planSaveEnabled = ps.enabled; @@ -444,6 +447,7 @@ export async function startPlanReviewServer(options: { savedPath, agentSwitch, permissionMode: effectivePermissionMode, + clearContextNudge, }); json(res, { ok: true, savedPath }); } else if ( diff --git a/packages/server/index.ts b/packages/server/index.ts index f17a233c6..b6a6501e7 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -110,6 +110,8 @@ export interface ServerResult { savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }>; /** Wait for user to close (archive mode only) */ waitForDone?: () => Promise; @@ -239,6 +241,8 @@ export async function startPlannotatorServer( savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }) => void; let decisionPromise: Promise<{ approved: boolean; @@ -246,6 +250,8 @@ export async function startPlannotatorServer( savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }>; if (mode !== "archive") { @@ -522,6 +528,8 @@ export async function startPlannotatorServer( let feedback: string | undefined; let agentSwitch: string | undefined; let requestedPermissionMode: string | undefined; + let clearContextNudge: boolean | undefined; + let deferToNativeForClear: boolean | undefined; let planSaveEnabled = true; // default to enabled for backwards compat let planSaveCustomPath: string | undefined; try { @@ -533,6 +541,8 @@ export async function startPlannotatorServer( agentSwitch?: string; planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string; + clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }; // Capture feedback if provided (for "approve with notes") @@ -550,6 +560,14 @@ export async function startPlannotatorServer( requestedPermissionMode = body.permissionMode; } + // Capture optional /clear reminder request for Claude Code approval flow + if (body.clearContextNudge === true) { + clearContextNudge = true; + } + if (body.deferToNativeForClear === true) { + deferToNativeForClear = true; + } + // Capture plan save settings if (body.planSave !== undefined) { planSaveEnabled = body.planSave.enabled; @@ -595,7 +613,7 @@ export async function startPlannotatorServer( // Use permission mode from client request if provided, otherwise fall back to hook input const effectivePermissionMode = requestedPermissionMode || permissionMode; - resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode }); + resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode, clearContextNudge, deferToNativeForClear }); return Response.json({ ok: true, savedPath }); } From 933544a27f6aa06db551ba8cc5a80cbcbadffadb Mon Sep 17 00:00:00 2001 From: Nehr <127654909+AgileInnov8tor@users.noreply.github.com> Date: Wed, 6 May 2026 02:27:16 +0700 Subject: [PATCH 24/34] Expose bypass clear reminder permission mode (#668) * Preserve truthful approval semantics for Claude plan bypass Thread a clear-context reminder flag through approval decisions, expose a Claude Code-only approval entry that requests bypass mode, and keep the hook response honest by emitting a reminder instead of claiming context was cleared. Constraint: Claude Code PermissionRequest hooks have no documented clearContext response field and bypassPermissions is only a request when the mode is available. Rejected: adding a permission-mode enum or undocumented clearContext field | those would misrepresent hook capabilities and broaden the contract. Confidence: high Scope-risk: moderate Directive: Do not claim Plannotator clears context until Claude Code documents a hook field for that behavior; keep reminder copy truthful. Tested: bun test packages/server apps/hook; bun test packages/editor/wideMode.test.ts packages/ui/hooks/useAgentSettings.test.ts; bun test; bun run build:review; bun run build:hook; bun run typecheck; git diff --check --cached. Not-tested: Browser warning-dialog replay and interactive Claude Code smoke test. Co-authored-by: OmX * Keep approval payloads truthful across Claude Code and Pi Constraint: Claude Code hooks cannot clear context directly; the new action remains a truthful reminder plus bypass-mode approval. Rejected: Sending OpenCode agent-switch state for Claude Code | it leaked build (?) UI state and misleading payload fields. Confidence: high Scope-risk: narrow Directive: Keep OpenCode agent switching gated to opencode-origin approval payloads. Tested: bun run typecheck; bun test; bun run build:review; bun run build:hook Not-tested: Manual browser click-through of the Claude Code dropdown. * Expose the clear-context reminder permission default Constraint: Claude Code hooks can only emit a systemMessage nudge, not clear context directly. Rejected: Server protocol changes | existing permissionMode plus clearContextNudge wire fields already support the behavior. Confidence: high Scope-risk: narrow Directive: Keep bypassPermissionsClearReminder as a UI/storage-only synthetic mode that decomposes before /api/approve. Tested: bun test packages/editor/approvalBody.test.ts; bun run --cwd apps/review build && bun run build:hook; bun run --cwd packages/ui typecheck; git diff --check Not-tested: Root bun run typecheck could not run because tsc was not on PATH for the root script. * Reject invalid persisted permission modes Constraint: Stored browser values can be stale, corrupt, or from a future Plannotator build. Rejected: Trusting the storage read with a type assertion | invalid values could flow into UI state and approval request construction. Confidence: high Scope-risk: narrow Directive: Keep PermissionMode storage reads validated against PERMISSION_MODE_OPTIONS when adding or renaming modes. Tested: bun test packages/editor/approvalBody.test.ts; bun run --cwd packages/ui typecheck; git diff --check Not-tested: Full repo typecheck/build, outside this review-fix scope. * Ensure approvals use the live permission setting Settings persisted permission mode changes, but App kept the original mode in React state and used that stale value when building approval payloads. Push the Settings change back into App so the selected clear-reminder mode reaches the hook decision. Constraint: Permission mode storage is cookie-backed while approval payload construction reads App state.\nRejected: Read permission cookies during approval | would couple approval construction to browser storage and duplicate Settings state.\nConfidence: high\nScope-risk: narrow\nDirective: Keep permission-mode writes synchronized with approval state when adding or changing modes.\nTested: bun test packages/editor/approvalBody.test.ts packages/ui/components/ApproveDropdown.test.tsx; bun x tsc --noEmit -p packages/ui/tsconfig.json; bun run build:hook; direct Playwright hook smoke verified bypassPermissionsClearReminder UI and hook payload.\nNot-tested: Live interactive Claude CLI session; direct hook/server simulation covered the wire output. --------- Co-authored-by: OmX --- packages/editor/App.tsx | 41 +++-- packages/editor/approvalBody.test.ts | 72 ++++++++ packages/editor/approvalBody.ts | 50 ++++++ .../ui/components/ApproveDropdown.test.tsx | 24 +++ packages/ui/components/ApproveDropdown.tsx | 157 ++++++++++++------ packages/ui/components/Settings.tsx | 4 +- packages/ui/utils/permissionMode.ts | 16 +- 7 files changed, 300 insertions(+), 64 deletions(-) create mode 100644 packages/editor/approvalBody.test.ts create mode 100644 packages/editor/approvalBody.ts create mode 100644 packages/ui/components/ApproveDropdown.test.tsx diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 25d64af52..53e94bc82 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -78,6 +78,7 @@ import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiff import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from './demoPlan'; import { DIFF_DEMO_PLAN_CONTENT } from './demoPlanDiffDemo'; import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode'; +import { buildApprovalRequestBody, type ApprovalOverride } from './approvalBody'; const USE_DIFF_DEMO = import.meta.env.VITE_DIFF_DEMO === '1' || import.meta.env.VITE_DIFF_DEMO === 'true'; @@ -105,6 +106,7 @@ const App: React.FC = () => { const [showImport, setShowImport] = useState(false); const [showFeedbackPrompt, setShowFeedbackPrompt] = useState(false); const [showClaudeCodeWarning, setShowClaudeCodeWarning] = useState(false); + const [pendingApprovalOverride, setPendingApprovalOverride] = useState(null); const [showExitWarning, setShowExitWarning] = useState(false); // When the warning dialog confirms, route to the handler matching the button that opened it. const [exitWarningAction, setExitWarningAction] = useState<'close' | 'approve'>('close'); @@ -951,7 +953,8 @@ const App: React.FC = () => { }; // API mode handlers - const handleApprove = async () => { + const handleApprove = async (override: ApprovalOverride = {}) => { + setPendingApprovalOverride(null); setIsSubmitting(true); try { const obsidianSettings = getObsidianSettings(); @@ -962,14 +965,6 @@ const App: React.FC = () => { ? await autoSavePromiseRef.current : autoSaveResultsRef.current; - // Build request body - include integrations if enabled - const body: { obsidian?: object; bear?: object; octarine?: object; feedback?: string; agentSwitch?: string; planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string } = {}; - - // Include permission mode for Claude Code - if (origin === 'claude-code') { - body.permissionMode = permissionMode; - } - const effectiveAgent = getEffectiveAgentName(getAgentSwitchSettings()); const body = buildApprovalRequestBody({ origin, @@ -1048,6 +1043,25 @@ const App: React.FC = () => { } }; + const approveWithClaudeCodeWarning = useCallback((override: ApprovalOverride = {}) => { + setPendingApprovalOverride(override); + if (origin === 'claude-code' && (allAnnotations.length > 0 || codeAnnotations.length > 0)) { + setShowClaudeCodeWarning(true); + return; + } + handleApprove(override); + }, [allAnnotations.length, codeAnnotations.length, origin, handleApprove]); + + const claudeCodeExtraEntries = useMemo(() => (origin === 'claude-code' ? [{ + id: 'approve-bypass-clear-reminder', + label: 'Approve + Bypass + /clear Reminder', + description: 'Requests bypass mode and reminds you to run /clear. Hooks cannot clear context directly.', + onSelect: () => approveWithClaudeCodeWarning({ + permissionMode: 'bypassPermissions', + clearContextNudge: true, + }), + }] : []), [approveWithClaudeCodeWarning, origin]); + // Annotate mode handler — sends feedback via /api/feedback const handleAnnotateFeedback = async () => { setIsSubmitting(true); @@ -2197,10 +2211,15 @@ const App: React.FC = () => { {/* Claude Code annotation warning dialog */} setShowClaudeCodeWarning(false)} + onClose={() => { + setShowClaudeCodeWarning(false); + setPendingApprovalOverride(null); + }} onConfirm={() => { + const override = pendingApprovalOverride ?? {}; setShowClaudeCodeWarning(false); - handleApprove(); + setPendingApprovalOverride(null); + handleApprove(override); }} title="Annotations Won't Be Sent" message={<>{agentName} doesn't yet support feedback on approval. Your {allAnnotations.length + codeAnnotations.length} annotation{(allAnnotations.length + codeAnnotations.length) !== 1 ? 's' : ''} will be lost.} diff --git a/packages/editor/approvalBody.test.ts b/packages/editor/approvalBody.test.ts new file mode 100644 index 000000000..dcb9a2451 --- /dev/null +++ b/packages/editor/approvalBody.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from 'bun:test'; +import { buildApprovalRequestBody } from './approvalBody'; + +describe('buildApprovalRequestBody', () => { + test('maps bypass clear reminder mode to Claude Code wire fields', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'bypassPermissionsClearReminder', + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + clearContextNudge: true, + planSave: { enabled: true }, + }); + }); + + test('omits agentSwitch for Claude Code approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'acceptEdits', + effectiveAgent: 'build', + override: { + permissionMode: 'bypassPermissions', + clearContextNudge: true, + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + clearContextNudge: true, + planSave: { enabled: true }, + }); + }); + + test('keeps bypass clear reminder override wire fields for Claude Code approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'acceptEdits', + override: { + permissionMode: 'bypassPermissionsClearReminder', + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + clearContextNudge: true, + planSave: { enabled: true }, + }); + }); + + test('keeps agentSwitch for OpenCode approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'opencode', + permissionMode: 'acceptEdits', + effectiveAgent: 'build', + planSaveSettings: { enabled: true }, + })).toEqual({ + agentSwitch: 'build', + planSave: { enabled: true }, + }); + }); + + test('ignores bypass clear reminder mode for OpenCode approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'opencode', + permissionMode: 'bypassPermissionsClearReminder', + effectiveAgent: 'build', + planSaveSettings: { enabled: true }, + })).toEqual({ + agentSwitch: 'build', + planSave: { enabled: true }, + }); + }); +}); diff --git a/packages/editor/approvalBody.ts b/packages/editor/approvalBody.ts new file mode 100644 index 000000000..5d8e92703 --- /dev/null +++ b/packages/editor/approvalBody.ts @@ -0,0 +1,50 @@ +import type { Origin } from '@plannotator/shared/agents'; +import type { PermissionMode } from '@plannotator/ui/utils/permissionMode'; + +export type ApprovalOverride = { + permissionMode?: PermissionMode; + clearContextNudge?: boolean; +}; + +export interface ApprovalRequestBody { + obsidian?: object; + bear?: object; + octarine?: object; + feedback?: string; + agentSwitch?: string; + planSave?: { enabled: boolean; customPath?: string }; + permissionMode?: string; + clearContextNudge?: boolean; +} + +export function buildApprovalRequestBody(options: { + origin: Origin | null; + permissionMode: PermissionMode; + override?: ApprovalOverride; + effectiveAgent?: string; + planSaveSettings: { enabled: boolean; customPath?: string | null }; +}): ApprovalRequestBody { + const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings } = options; + const body: ApprovalRequestBody = {}; + + if (origin === 'claude-code') { + const effectivePermissionMode = override.permissionMode ?? permissionMode; + body.permissionMode = effectivePermissionMode === 'bypassPermissionsClearReminder' + ? 'bypassPermissions' + : effectivePermissionMode; + if (override.clearContextNudge || effectivePermissionMode === 'bypassPermissionsClearReminder') { + body.clearContextNudge = true; + } + } + + if (origin === 'opencode' && effectiveAgent) { + body.agentSwitch = effectiveAgent; + } + + body.planSave = { + enabled: planSaveSettings.enabled, + ...(planSaveSettings.customPath && { customPath: planSaveSettings.customPath }), + }; + + return body; +} diff --git a/packages/ui/components/ApproveDropdown.test.tsx b/packages/ui/components/ApproveDropdown.test.tsx new file mode 100644 index 000000000..967f8e4be --- /dev/null +++ b/packages/ui/components/ApproveDropdown.test.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { describe, expect, test } from 'bun:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { ApproveDropdown } from './ApproveDropdown'; + +describe('ApproveDropdown', () => { + test('does not show agent-switch label when only extra approval entries are enabled', () => { + const html = renderToStaticMarkup( + {}} + agents={[]} + extraEntries={[{ + id: 'approve-bypass-clear-reminder', + label: 'Approve + Bypass + /clear Reminder', + onSelect: () => {}, + }]} + />, + ); + + expect(html).toContain('Approve'); + expect(html).not.toContain('build'); + expect(html).not.toContain('(?)'); + }); +}); diff --git a/packages/ui/components/ApproveDropdown.tsx b/packages/ui/components/ApproveDropdown.tsx index ab9c48c7f..9424696d3 100644 --- a/packages/ui/components/ApproveDropdown.tsx +++ b/packages/ui/components/ApproveDropdown.tsx @@ -2,11 +2,21 @@ import React, { useState, useRef, useEffect } from 'react'; import type { Agent } from '../hooks/useAgents'; import { getAgentSwitchSettings, saveAgentSwitchSettings, type AgentSwitchSettings } from '../utils/agentSwitch'; +export interface ApproveExtraEntry { + id: string; + label: string; + description?: string; + onSelect: () => void; + disabled?: boolean; +} + interface ApproveDropdownProps { onApprove: () => void; agents: Agent[]; disabled?: boolean; isLoading?: boolean; + extraEntries?: ApproveExtraEntry[]; + showAgentSwitch?: boolean; } function getSelectedLabel(setting: AgentSwitchSettings, agents: Agent[]): string | null { @@ -35,6 +45,8 @@ export const ApproveDropdown: React.FC = ({ agents, disabled = false, isLoading = false, + extraEntries = [], + showAgentSwitch, }) => { const [setting, setSetting] = useState(() => getAgentSwitchSettings()); const [isOpen, setIsOpen] = useState(false); @@ -57,16 +69,20 @@ export const ApproveDropdown: React.FC = ({ }; }, []); + const hasExtraEntries = extraEntries.length > 0; + const shouldShowAgentSwitch = showAgentSwitch ?? agents.length > 0; + const hasDropdownContent = hasExtraEntries || shouldShowAgentSwitch; + const handleSelect = (newSetting: AgentSwitchSettings) => { setSetting(newSetting); saveAgentSwitchSettings(newSetting); setIsOpen(false); }; - const agentLabel = getSelectedLabel(setting, agents); - const isNoSwitch = setting.switchTo === 'disabled'; - const isCustom = setting.switchTo === 'custom'; - const notFound = agentLabel && !isNoSwitch && !isCustom + const agentLabel = shouldShowAgentSwitch ? getSelectedLabel(setting, agents) : null; + const isNoSwitch = shouldShowAgentSwitch && setting.switchTo === 'disabled'; + const isCustom = shouldShowAgentSwitch && setting.switchTo === 'custom'; + const notFound = shouldShowAgentSwitch && agentLabel && !isNoSwitch && !isCustom && !agents.some(a => a.id.toLowerCase() === setting.switchTo.toLowerCase()); const baseClasses = disabled @@ -78,16 +94,36 @@ export const ApproveDropdown: React.FC = ({ onApprove(); }; + const handleExtraSelect = (entry: ApproveExtraEntry) => { + if (entry.disabled) return; + setIsOpen(false); + entry.onSelect(); + }; + return (
- {/* Mobile: simple button */} - + {/* Mobile: simple button, with menu when extra actions exist */} +
+ + {hasDropdownContent && ( + + )} +
{/* Desktop: split button */}
@@ -109,8 +145,9 @@ export const ApproveDropdown: React.FC = ({
{/* Dropdown */} - {isOpen && ( -
-
- Switch to agent -
- {agents.map((agent) => { - const selected = isSelected(agent.id, setting); - return ( + {isOpen && hasDropdownContent && ( +
+ {hasExtraEntries && ( + <> + {extraEntries.map((entry) => ( + + ))} + {shouldShowAgentSwitch &&
} + + )} + {shouldShowAgentSwitch && ( + <> +
+ Switch to agent +
+ {agents.map((agent) => { + const selected = isSelected(agent.id, setting); + return ( + + ); + })} + {isCustom && setting.customName && ( + + )} +
- ); - })} - {isCustom && setting.customName && ( - + )} -
-
)}
diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index e2982315b..48a4bdb6f 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -80,6 +80,7 @@ interface SettingsProps { origin?: Origin | null; mode?: 'plan' | 'review'; onUIPreferencesChange?: (prefs: UIPreferences) => void; + onPermissionModeChange?: (mode: PermissionMode) => void; /** Externally controlled open state (for mobile menu integration) */ externalOpen?: boolean; onExternalClose?: () => void; @@ -599,7 +600,7 @@ const CommentsTab: React.FC = () => { ); }; -export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser }) => { +export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, onPermissionModeChange, externalOpen, onExternalClose, aiProviders = [], gitUser }) => { const [showDialog, setShowDialog] = useState(false); const [themePreview, setThemePreview] = useState(false); @@ -803,6 +804,7 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange const handlePermissionModeChange = (mode: PermissionMode) => { setPermissionMode(mode); savePermissionModeSettings(mode); + onPermissionModeChange?.(mode); }; const handleDefaultNotesAppChange = (app: DefaultNotesApp) => { diff --git a/packages/ui/utils/permissionMode.ts b/packages/ui/utils/permissionMode.ts index 809995377..4143c1d75 100644 --- a/packages/ui/utils/permissionMode.ts +++ b/packages/ui/utils/permissionMode.ts @@ -6,6 +6,7 @@ * * Available modes: * - bypassPermissions: Auto-approve all tool calls + * - bypassPermissionsClearReminder: Persisted UI mode that sends bypassPermissions plus a /clear reminder nudge * - acceptEdits: Auto-approve file edits only * - default: Manually approve each tool call */ @@ -15,7 +16,7 @@ import { storage } from './storage'; const STORAGE_KEY_MODE = 'plannotator-permission-mode'; const STORAGE_KEY_CONFIGURED = 'plannotator-permission-mode-configured'; -export type PermissionMode = 'bypassPermissions' | 'acceptEdits' | 'default'; +export type PermissionMode = 'bypassPermissions' | 'bypassPermissionsClearReminder' | 'acceptEdits' | 'default'; export interface PermissionModeSettings { mode: PermissionMode; @@ -33,6 +34,11 @@ export const PERMISSION_MODE_OPTIONS: { value: PermissionMode; label: string; de label: 'Bypass Permissions', description: 'Auto-approve all tool calls (equivalent to --dangerously-skip-permissions)', }, + { + value: 'bypassPermissionsClearReminder', + label: 'Bypass + /clear Reminder', + description: 'Auto-approve all tool calls and emit a system message reminding you to run /clear (hooks cannot clear context directly).', + }, { value: 'default', label: 'Manual Approval', @@ -42,15 +48,19 @@ export const PERMISSION_MODE_OPTIONS: { value: PermissionMode; label: string; de const DEFAULT_MODE: PermissionMode = 'acceptEdits'; +function isPermissionMode(value: string | null): value is PermissionMode { + return PERMISSION_MODE_OPTIONS.some((option) => option.value === value); +} + /** * Get current permission mode settings from storage */ export function getPermissionModeSettings(): PermissionModeSettings { - const mode = storage.getItem(STORAGE_KEY_MODE) as PermissionMode | null; + const mode = storage.getItem(STORAGE_KEY_MODE); const configured = storage.getItem(STORAGE_KEY_CONFIGURED) === 'true'; return { - mode: mode || DEFAULT_MODE, + mode: isPermissionMode(mode) ? mode : DEFAULT_MODE, configured, }; } From ba50652c386858ebd9a78575c207db514b0a4a40 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 6 May 2026 16:10:31 -0700 Subject: [PATCH 25/34] Revert "Expose bypass clear reminder permission mode (#668)" This reverts commit 3b88415aeb0276162bfd6a57958e1f4d599c0c98. --- apps/hook/server/index.ts | 4 - apps/pi-extension/plannotator-browser.ts | 1 - apps/pi-extension/plannotator-events.ts | 2 - apps/pi-extension/server/serverPlan.ts | 4 - packages/editor/App.tsx | 41 ++--- packages/editor/approvalBody.test.ts | 72 -------- packages/editor/approvalBody.ts | 50 ------ .../ui/components/ApproveDropdown.test.tsx | 24 --- packages/ui/components/ApproveDropdown.tsx | 157 ++++++------------ packages/ui/components/Settings.tsx | 4 +- packages/ui/utils/permissionMode.ts | 16 +- 11 files changed, 64 insertions(+), 311 deletions(-) delete mode 100644 packages/editor/approvalBody.test.ts delete mode 100644 packages/editor/approvalBody.ts delete mode 100644 packages/ui/components/ApproveDropdown.test.tsx diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 1bbdb6c7f..4e3f4e1fa 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -1298,10 +1298,6 @@ 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: { diff --git a/apps/pi-extension/plannotator-browser.ts b/apps/pi-extension/plannotator-browser.ts index a47e89a8f..2235bca6f 100644 --- a/apps/pi-extension/plannotator-browser.ts +++ b/apps/pi-extension/plannotator-browser.ts @@ -35,7 +35,6 @@ export interface PlanReviewDecision { savedPath?: string; agentSwitch?: string; permissionMode?: string; - clearContextNudge?: boolean; } export interface BrowserDecisionSession { diff --git a/apps/pi-extension/plannotator-events.ts b/apps/pi-extension/plannotator-events.ts index f2754a6de..12845ae70 100644 --- a/apps/pi-extension/plannotator-events.ts +++ b/apps/pi-extension/plannotator-events.ts @@ -73,7 +73,6 @@ export interface PlannotatorReviewResultEvent { savedPath?: string; agentSwitch?: string; permissionMode?: string; - clearContextNudge?: boolean; } export interface PlannotatorReviewStatusPayload { @@ -249,7 +248,6 @@ export function registerPlannotatorEventListeners(pi: ExtensionAPI): void { savedPath: result.savedPath, agentSwitch: result.agentSwitch, permissionMode: result.permissionMode, - clearContextNudge: result.clearContextNudge, } satisfies PlannotatorReviewResultEvent; setStoredReviewStatus(session.reviewId, { status: "completed", ...reviewResult }); pi.events.emit(PLANNOTATOR_REVIEW_RESULT_CHANNEL, reviewResult); diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index 5db3c6507..e28e88dc3 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -62,7 +62,6 @@ export interface PlanReviewDecision { savedPath?: string; agentSwitch?: string; permissionMode?: string; - clearContextNudge?: boolean; } export interface PlanServerResult { @@ -376,7 +375,6 @@ export async function startPlanReviewServer(options: { let feedback: string | undefined; let agentSwitch: string | undefined; let requestedPermissionMode: string | undefined; - let clearContextNudge: boolean | undefined; let planSaveEnabled = true; let planSaveCustomPath: string | undefined; try { @@ -385,7 +383,6 @@ export async function startPlanReviewServer(options: { if (body.agentSwitch) agentSwitch = body.agentSwitch as string; if (body.permissionMode) requestedPermissionMode = body.permissionMode as string; - if (body.clearContextNudge === true) clearContextNudge = true; if (body.planSave !== undefined) { const ps = body.planSave as { enabled: boolean; customPath?: string }; planSaveEnabled = ps.enabled; @@ -447,7 +444,6 @@ export async function startPlanReviewServer(options: { savedPath, agentSwitch, permissionMode: effectivePermissionMode, - clearContextNudge, }); json(res, { ok: true, savedPath }); } else if ( diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 53e94bc82..25d64af52 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -78,7 +78,6 @@ import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiff import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from './demoPlan'; import { DIFF_DEMO_PLAN_CONTENT } from './demoPlanDiffDemo'; import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode'; -import { buildApprovalRequestBody, type ApprovalOverride } from './approvalBody'; const USE_DIFF_DEMO = import.meta.env.VITE_DIFF_DEMO === '1' || import.meta.env.VITE_DIFF_DEMO === 'true'; @@ -106,7 +105,6 @@ const App: React.FC = () => { const [showImport, setShowImport] = useState(false); const [showFeedbackPrompt, setShowFeedbackPrompt] = useState(false); const [showClaudeCodeWarning, setShowClaudeCodeWarning] = useState(false); - const [pendingApprovalOverride, setPendingApprovalOverride] = useState(null); const [showExitWarning, setShowExitWarning] = useState(false); // When the warning dialog confirms, route to the handler matching the button that opened it. const [exitWarningAction, setExitWarningAction] = useState<'close' | 'approve'>('close'); @@ -953,8 +951,7 @@ const App: React.FC = () => { }; // API mode handlers - const handleApprove = async (override: ApprovalOverride = {}) => { - setPendingApprovalOverride(null); + const handleApprove = async () => { setIsSubmitting(true); try { const obsidianSettings = getObsidianSettings(); @@ -965,6 +962,14 @@ const App: React.FC = () => { ? await autoSavePromiseRef.current : autoSaveResultsRef.current; + // Build request body - include integrations if enabled + const body: { obsidian?: object; bear?: object; octarine?: object; feedback?: string; agentSwitch?: string; planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string } = {}; + + // Include permission mode for Claude Code + if (origin === 'claude-code') { + body.permissionMode = permissionMode; + } + const effectiveAgent = getEffectiveAgentName(getAgentSwitchSettings()); const body = buildApprovalRequestBody({ origin, @@ -1043,25 +1048,6 @@ const App: React.FC = () => { } }; - const approveWithClaudeCodeWarning = useCallback((override: ApprovalOverride = {}) => { - setPendingApprovalOverride(override); - if (origin === 'claude-code' && (allAnnotations.length > 0 || codeAnnotations.length > 0)) { - setShowClaudeCodeWarning(true); - return; - } - handleApprove(override); - }, [allAnnotations.length, codeAnnotations.length, origin, handleApprove]); - - const claudeCodeExtraEntries = useMemo(() => (origin === 'claude-code' ? [{ - id: 'approve-bypass-clear-reminder', - label: 'Approve + Bypass + /clear Reminder', - description: 'Requests bypass mode and reminds you to run /clear. Hooks cannot clear context directly.', - onSelect: () => approveWithClaudeCodeWarning({ - permissionMode: 'bypassPermissions', - clearContextNudge: true, - }), - }] : []), [approveWithClaudeCodeWarning, origin]); - // Annotate mode handler — sends feedback via /api/feedback const handleAnnotateFeedback = async () => { setIsSubmitting(true); @@ -2211,15 +2197,10 @@ const App: React.FC = () => { {/* Claude Code annotation warning dialog */} { - setShowClaudeCodeWarning(false); - setPendingApprovalOverride(null); - }} + onClose={() => setShowClaudeCodeWarning(false)} onConfirm={() => { - const override = pendingApprovalOverride ?? {}; setShowClaudeCodeWarning(false); - setPendingApprovalOverride(null); - handleApprove(override); + handleApprove(); }} title="Annotations Won't Be Sent" message={<>{agentName} doesn't yet support feedback on approval. Your {allAnnotations.length + codeAnnotations.length} annotation{(allAnnotations.length + codeAnnotations.length) !== 1 ? 's' : ''} will be lost.} diff --git a/packages/editor/approvalBody.test.ts b/packages/editor/approvalBody.test.ts deleted file mode 100644 index dcb9a2451..000000000 --- a/packages/editor/approvalBody.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { buildApprovalRequestBody } from './approvalBody'; - -describe('buildApprovalRequestBody', () => { - test('maps bypass clear reminder mode to Claude Code wire fields', () => { - expect(buildApprovalRequestBody({ - origin: 'claude-code', - permissionMode: 'bypassPermissionsClearReminder', - planSaveSettings: { enabled: true }, - })).toEqual({ - permissionMode: 'bypassPermissions', - clearContextNudge: true, - planSave: { enabled: true }, - }); - }); - - test('omits agentSwitch for Claude Code approvals', () => { - expect(buildApprovalRequestBody({ - origin: 'claude-code', - permissionMode: 'acceptEdits', - effectiveAgent: 'build', - override: { - permissionMode: 'bypassPermissions', - clearContextNudge: true, - }, - planSaveSettings: { enabled: true }, - })).toEqual({ - permissionMode: 'bypassPermissions', - clearContextNudge: true, - planSave: { enabled: true }, - }); - }); - - test('keeps bypass clear reminder override wire fields for Claude Code approvals', () => { - expect(buildApprovalRequestBody({ - origin: 'claude-code', - permissionMode: 'acceptEdits', - override: { - permissionMode: 'bypassPermissionsClearReminder', - }, - planSaveSettings: { enabled: true }, - })).toEqual({ - permissionMode: 'bypassPermissions', - clearContextNudge: true, - planSave: { enabled: true }, - }); - }); - - test('keeps agentSwitch for OpenCode approvals', () => { - expect(buildApprovalRequestBody({ - origin: 'opencode', - permissionMode: 'acceptEdits', - effectiveAgent: 'build', - planSaveSettings: { enabled: true }, - })).toEqual({ - agentSwitch: 'build', - planSave: { enabled: true }, - }); - }); - - test('ignores bypass clear reminder mode for OpenCode approvals', () => { - expect(buildApprovalRequestBody({ - origin: 'opencode', - permissionMode: 'bypassPermissionsClearReminder', - effectiveAgent: 'build', - planSaveSettings: { enabled: true }, - })).toEqual({ - agentSwitch: 'build', - planSave: { enabled: true }, - }); - }); -}); diff --git a/packages/editor/approvalBody.ts b/packages/editor/approvalBody.ts deleted file mode 100644 index 5d8e92703..000000000 --- a/packages/editor/approvalBody.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Origin } from '@plannotator/shared/agents'; -import type { PermissionMode } from '@plannotator/ui/utils/permissionMode'; - -export type ApprovalOverride = { - permissionMode?: PermissionMode; - clearContextNudge?: boolean; -}; - -export interface ApprovalRequestBody { - obsidian?: object; - bear?: object; - octarine?: object; - feedback?: string; - agentSwitch?: string; - planSave?: { enabled: boolean; customPath?: string }; - permissionMode?: string; - clearContextNudge?: boolean; -} - -export function buildApprovalRequestBody(options: { - origin: Origin | null; - permissionMode: PermissionMode; - override?: ApprovalOverride; - effectiveAgent?: string; - planSaveSettings: { enabled: boolean; customPath?: string | null }; -}): ApprovalRequestBody { - const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings } = options; - const body: ApprovalRequestBody = {}; - - if (origin === 'claude-code') { - const effectivePermissionMode = override.permissionMode ?? permissionMode; - body.permissionMode = effectivePermissionMode === 'bypassPermissionsClearReminder' - ? 'bypassPermissions' - : effectivePermissionMode; - if (override.clearContextNudge || effectivePermissionMode === 'bypassPermissionsClearReminder') { - body.clearContextNudge = true; - } - } - - if (origin === 'opencode' && effectiveAgent) { - body.agentSwitch = effectiveAgent; - } - - body.planSave = { - enabled: planSaveSettings.enabled, - ...(planSaveSettings.customPath && { customPath: planSaveSettings.customPath }), - }; - - return body; -} diff --git a/packages/ui/components/ApproveDropdown.test.tsx b/packages/ui/components/ApproveDropdown.test.tsx deleted file mode 100644 index 967f8e4be..000000000 --- a/packages/ui/components/ApproveDropdown.test.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import { describe, expect, test } from 'bun:test'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { ApproveDropdown } from './ApproveDropdown'; - -describe('ApproveDropdown', () => { - test('does not show agent-switch label when only extra approval entries are enabled', () => { - const html = renderToStaticMarkup( - {}} - agents={[]} - extraEntries={[{ - id: 'approve-bypass-clear-reminder', - label: 'Approve + Bypass + /clear Reminder', - onSelect: () => {}, - }]} - />, - ); - - expect(html).toContain('Approve'); - expect(html).not.toContain('build'); - expect(html).not.toContain('(?)'); - }); -}); diff --git a/packages/ui/components/ApproveDropdown.tsx b/packages/ui/components/ApproveDropdown.tsx index 9424696d3..ab9c48c7f 100644 --- a/packages/ui/components/ApproveDropdown.tsx +++ b/packages/ui/components/ApproveDropdown.tsx @@ -2,21 +2,11 @@ import React, { useState, useRef, useEffect } from 'react'; import type { Agent } from '../hooks/useAgents'; import { getAgentSwitchSettings, saveAgentSwitchSettings, type AgentSwitchSettings } from '../utils/agentSwitch'; -export interface ApproveExtraEntry { - id: string; - label: string; - description?: string; - onSelect: () => void; - disabled?: boolean; -} - interface ApproveDropdownProps { onApprove: () => void; agents: Agent[]; disabled?: boolean; isLoading?: boolean; - extraEntries?: ApproveExtraEntry[]; - showAgentSwitch?: boolean; } function getSelectedLabel(setting: AgentSwitchSettings, agents: Agent[]): string | null { @@ -45,8 +35,6 @@ export const ApproveDropdown: React.FC = ({ agents, disabled = false, isLoading = false, - extraEntries = [], - showAgentSwitch, }) => { const [setting, setSetting] = useState(() => getAgentSwitchSettings()); const [isOpen, setIsOpen] = useState(false); @@ -69,20 +57,16 @@ export const ApproveDropdown: React.FC = ({ }; }, []); - const hasExtraEntries = extraEntries.length > 0; - const shouldShowAgentSwitch = showAgentSwitch ?? agents.length > 0; - const hasDropdownContent = hasExtraEntries || shouldShowAgentSwitch; - const handleSelect = (newSetting: AgentSwitchSettings) => { setSetting(newSetting); saveAgentSwitchSettings(newSetting); setIsOpen(false); }; - const agentLabel = shouldShowAgentSwitch ? getSelectedLabel(setting, agents) : null; - const isNoSwitch = shouldShowAgentSwitch && setting.switchTo === 'disabled'; - const isCustom = shouldShowAgentSwitch && setting.switchTo === 'custom'; - const notFound = shouldShowAgentSwitch && agentLabel && !isNoSwitch && !isCustom + const agentLabel = getSelectedLabel(setting, agents); + const isNoSwitch = setting.switchTo === 'disabled'; + const isCustom = setting.switchTo === 'custom'; + const notFound = agentLabel && !isNoSwitch && !isCustom && !agents.some(a => a.id.toLowerCase() === setting.switchTo.toLowerCase()); const baseClasses = disabled @@ -94,36 +78,16 @@ export const ApproveDropdown: React.FC = ({ onApprove(); }; - const handleExtraSelect = (entry: ApproveExtraEntry) => { - if (entry.disabled) return; - setIsOpen(false); - entry.onSelect(); - }; - return (
- {/* Mobile: simple button, with menu when extra actions exist */} -
- - {hasDropdownContent && ( - - )} -
+ {/* Mobile: simple button */} + {/* Desktop: split button */}
@@ -145,9 +109,8 @@ export const ApproveDropdown: React.FC = ({
{/* Dropdown */} - {isOpen && hasDropdownContent && ( -
- {hasExtraEntries && ( - <> - {extraEntries.map((entry) => ( - - ))} - {shouldShowAgentSwitch &&
} - - )} - {shouldShowAgentSwitch && ( - <> -
- Switch to agent -
- {agents.map((agent) => { - const selected = isSelected(agent.id, setting); - return ( - - ); - })} - {isCustom && setting.customName && ( - - )} -
+ {isOpen && ( +
+
+ Switch to agent +
+ {agents.map((agent) => { + const selected = isSelected(agent.id, setting); + return ( - + ); + })} + {isCustom && setting.customName && ( + )} +
+
)}
diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index 48a4bdb6f..e2982315b 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -80,7 +80,6 @@ interface SettingsProps { origin?: Origin | null; mode?: 'plan' | 'review'; onUIPreferencesChange?: (prefs: UIPreferences) => void; - onPermissionModeChange?: (mode: PermissionMode) => void; /** Externally controlled open state (for mobile menu integration) */ externalOpen?: boolean; onExternalClose?: () => void; @@ -600,7 +599,7 @@ const CommentsTab: React.FC = () => { ); }; -export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, onPermissionModeChange, externalOpen, onExternalClose, aiProviders = [], gitUser }) => { +export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser }) => { const [showDialog, setShowDialog] = useState(false); const [themePreview, setThemePreview] = useState(false); @@ -804,7 +803,6 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange const handlePermissionModeChange = (mode: PermissionMode) => { setPermissionMode(mode); savePermissionModeSettings(mode); - onPermissionModeChange?.(mode); }; const handleDefaultNotesAppChange = (app: DefaultNotesApp) => { diff --git a/packages/ui/utils/permissionMode.ts b/packages/ui/utils/permissionMode.ts index 4143c1d75..809995377 100644 --- a/packages/ui/utils/permissionMode.ts +++ b/packages/ui/utils/permissionMode.ts @@ -6,7 +6,6 @@ * * Available modes: * - bypassPermissions: Auto-approve all tool calls - * - bypassPermissionsClearReminder: Persisted UI mode that sends bypassPermissions plus a /clear reminder nudge * - acceptEdits: Auto-approve file edits only * - default: Manually approve each tool call */ @@ -16,7 +15,7 @@ import { storage } from './storage'; const STORAGE_KEY_MODE = 'plannotator-permission-mode'; const STORAGE_KEY_CONFIGURED = 'plannotator-permission-mode-configured'; -export type PermissionMode = 'bypassPermissions' | 'bypassPermissionsClearReminder' | 'acceptEdits' | 'default'; +export type PermissionMode = 'bypassPermissions' | 'acceptEdits' | 'default'; export interface PermissionModeSettings { mode: PermissionMode; @@ -34,11 +33,6 @@ export const PERMISSION_MODE_OPTIONS: { value: PermissionMode; label: string; de label: 'Bypass Permissions', description: 'Auto-approve all tool calls (equivalent to --dangerously-skip-permissions)', }, - { - value: 'bypassPermissionsClearReminder', - label: 'Bypass + /clear Reminder', - description: 'Auto-approve all tool calls and emit a system message reminding you to run /clear (hooks cannot clear context directly).', - }, { value: 'default', label: 'Manual Approval', @@ -48,19 +42,15 @@ export const PERMISSION_MODE_OPTIONS: { value: PermissionMode; label: string; de const DEFAULT_MODE: PermissionMode = 'acceptEdits'; -function isPermissionMode(value: string | null): value is PermissionMode { - return PERMISSION_MODE_OPTIONS.some((option) => option.value === value); -} - /** * Get current permission mode settings from storage */ export function getPermissionModeSettings(): PermissionModeSettings { - const mode = storage.getItem(STORAGE_KEY_MODE); + const mode = storage.getItem(STORAGE_KEY_MODE) as PermissionMode | null; const configured = storage.getItem(STORAGE_KEY_CONFIGURED) === 'true'; return { - mode: isPermissionMode(mode) ? mode : DEFAULT_MODE, + mode: mode || DEFAULT_MODE, configured, }; } From 8201366a202448296e3baffcb229cb95004618a8 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 11 May 2026 08:14:13 -0400 Subject: [PATCH 26/34] feat(hook): PFM reminder & improvement hook support across all runtimes (#689) PFM reminder & improvement hook support across Claude Code, OpenCode, and Pi. - Add opt-in PFM reminder (pfmReminder config flag) injected on EnterPlanMode - Wire composeImproveContext() into all three runtimes - Fix OpenCode system.transform array reference bug (pushes were going to dead array) - Fix install scripts silently stripping PreToolUse/EnterPlanMode hook entry - Isolated Pi sandbox testing (--no-extensions -e) --- apps/hook/server/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 4e3f4e1fa..61f93b3d7 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -76,7 +76,7 @@ import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getCliInstallUrl, getMRLa import { writeRemoteShareLink } from "@plannotator/server/share-url"; import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannotator/shared/resolve-file"; import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; -import { statSync, rmSync, realpathSync, existsSync } from "fs"; +import { statSync, rmSync, realpathSync, existsSync, appendFileSync } from "fs"; import { parseRemoteUrl } from "@plannotator/shared/repo"; import { getReviewApprovedPrompt, @@ -112,7 +112,7 @@ import { } from "./cli"; import { ensureClearContextSettingEnabled } from "./clearContextSetting"; import path from "path"; -import { tmpdir } from "os"; +import { tmpdir, homedir } from "os"; // Embed the built HTML at compile time // @ts-ignore - Bun import attribute for text From cc843922c1e07c0517eb35b3d041003215fd273a Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 11 May 2026 10:13:47 -0400 Subject: [PATCH 27/34] feat(pfm): code line range references, hover preview, sketch Graphviz (#692) Code file line range references with hover preview + Graphviz improvements. Line ranges: `file.ts:42` and `file.ts:10-20` are fully supported with syntax-highlighted hover preview popover (150ms delay, GitHub-style persistence). New parseCodePath() utility, server-side line suffix stripping on both Bun and Pi servers, ambiguous picker preserves line suffix. useCodeFilePopout moved to hooks/pfm/. Graphviz: responsive container height from SVG aspect ratio, white background polygon removed, default colors (black, lightgrey) replaced with theme tokens via SVG post-processing. User-specified colors preserved. --- apps/hook/server/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 61f93b3d7..4e3f4e1fa 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -76,7 +76,7 @@ import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getCliInstallUrl, getMRLa import { writeRemoteShareLink } from "@plannotator/server/share-url"; import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannotator/shared/resolve-file"; import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; -import { statSync, rmSync, realpathSync, existsSync, appendFileSync } from "fs"; +import { statSync, rmSync, realpathSync, existsSync } from "fs"; import { parseRemoteUrl } from "@plannotator/shared/repo"; import { getReviewApprovedPrompt, @@ -112,7 +112,7 @@ import { } from "./cli"; import { ensureClearContextSettingEnabled } from "./clearContextSetting"; import path from "path"; -import { tmpdir, homedir } from "os"; +import { tmpdir } from "os"; // Embed the built HTML at compile time // @ts-ignore - Bun import attribute for text From fe70c47db049a04ded0e2128dc564d9705b413b1 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 11 May 2026 10:30:49 -0400 Subject: [PATCH 28/34] feat: standalone skills package + HTML render-annotate mode (#687) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add --render-html flag to plannotator annotate that renders HTML files as-is in an iframe instead of converting to markdown. Includes annotation support via postMessage bridge, sharing via paste service, and theme inheritance from Plannotator's 30+ themes. New skill: plannotator-visual-explainer — wraps nicobailon/visual-explainer with Plannotator theme tokens, extended patterns (timelines, SVG diagrams, code blocks, risk tables, Pierre diffs via CDN), and plan/PR-specific guidance. All three servers (Bun, Pi, OpenCode) support the new flag. --- .../references/extended-patterns.md | 629 ++++++++++++++++++ 1 file changed, 629 insertions(+) create mode 100644 apps/skills/plannotator-visual-explainer/references/extended-patterns.md diff --git a/apps/skills/plannotator-visual-explainer/references/extended-patterns.md b/apps/skills/plannotator-visual-explainer/references/extended-patterns.md new file mode 100644 index 000000000..65ecad6fd --- /dev/null +++ b/apps/skills/plannotator-visual-explainer/references/extended-patterns.md @@ -0,0 +1,629 @@ +# Extended Patterns + +Components that complement visual-explainer's toolkit. These use the same Plannotator theme tokens from `theme-override.md` and can be mixed freely with Nico's `.ve-card`, `.kpi-card`, `.pipeline` patterns. + +## Timeline + +Vertical timeline showing phases or sequence — without time estimates. Shows ordering and dependencies, not duration. + +```html +
+
+
Phase 1
+
+
+
+
+
+

Foundation

+

Set up the core infrastructure and initial integrations.

+
+
+ +
+``` + +```css +.timeline { display: flex; flex-direction: column; gap: 0; } + +.timeline-item { + display: grid; + grid-template-columns: 100px 28px 1fr; + gap: 16px; + min-height: 80px; +} + +.timeline-label { + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--muted-foreground); + text-align: right; + padding-top: 4px; +} + +.timeline-dot-col { + display: flex; + flex-direction: column; + align-items: center; +} + +.timeline-dot { + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--card); + border: 3px solid var(--primary); + flex-shrink: 0; +} + +.timeline-dot.active { background: var(--primary); } + +.timeline-line { + width: 2px; + flex: 1; + background: var(--border); +} + +.timeline-content { padding-bottom: 24px; } + +.timeline-content h4 { + font-family: var(--font-display); + font-size: 1rem; + font-weight: 500; + margin-bottom: 4px; +} + +.timeline-content p { + font-size: 0.88rem; + color: var(--muted-foreground); +} +``` + +The last timeline item should hide the line: `style="background: transparent"` on the `.timeline-line`. + +## Code Blocks with Syntax Highlighting + +Dark-themed code panels showing key interfaces, schemas, or API signatures. Use sparingly — show the 5-10 lines that matter, not full files. + +```html +
+ src/api/handler.ts +
interface Config {
+  port: number;
+  host: string;
+}
+
+``` + +```css +.code-panel { + background: var(--code-bg); + border: 1.5px solid var(--border); + border-radius: var(--radius); + padding: 20px 24px; + overflow-x: auto; + margin: 16px 0; +} + +.code-file { + font-family: var(--font-mono); + font-size: 0.7rem; + color: var(--muted-foreground); + display: block; + margin-bottom: 8px; +} + +.code-panel pre { + margin: 0; + font-family: var(--font-mono); + font-size: 0.85rem; + line-height: 1.55; + color: var(--foreground); + white-space: pre-wrap; + word-break: break-word; +} + +.code-panel .kw { color: var(--primary); } +.code-panel .fn { color: var(--accent); } +.code-panel .str { color: var(--success); } +.code-panel .cm { color: var(--muted-foreground); font-style: italic; } +.code-panel .num { color: var(--warning); } +``` + +## Risk Table + +Severity-graded risk assessment with colored badges. + +```html +
+
+
Database migration on live table
+
HIGH
+
Run during off-peak with online DDL
+
+
+``` + +```css +.risk-grid { + border: 1.5px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.risk-row { + display: grid; + grid-template-columns: 1fr auto 1.5fr; + gap: 24px; + padding: 16px 24px; + align-items: center; + border-bottom: 1px solid var(--border); +} + +.risk-row:last-child { border-bottom: none; } +.risk-name { font-weight: 500; } +.risk-mitigation { font-size: 0.9rem; color: var(--muted-foreground); } + +.risk-badge { + font-family: var(--font-mono); + font-size: 0.65rem; + font-weight: 600; + padding: 2px 8px; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.risk-high { + background: color-mix(in oklab, var(--destructive) 15%, transparent); + color: var(--destructive); +} +.risk-med { + background: color-mix(in oklab, var(--warning) 15%, transparent); + color: var(--warning); +} +.risk-low { + background: color-mix(in oklab, var(--success) 15%, transparent); + color: var(--success); +} +``` + +## Open Questions + +Callout cards for unresolved decisions. Each names who can answer. + +```html +
+

Should we use WebSockets or SSE?

+

SSE is simpler but unidirectional. WebSockets add infrastructure complexity.

+ Decide with: infrastructure team +
+``` + +```css +.question { + border-left: 3px solid var(--primary); + padding: 16px 24px; + margin: 16px 0; + background: var(--card); + border-radius: 0 var(--radius) var(--radius) 0; +} + +.question h3 { + font-family: var(--font-display); + font-size: 1.05rem; + font-weight: 500; + margin-bottom: 4px; +} + +.question p { + font-size: 0.9rem; + color: var(--muted-foreground); + line-height: 1.55; +} + +.question-owner { + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--primary); + font-weight: 500; + display: block; + margin-top: 8px; +} +``` + +## Inline SVG Diagrams + +For architecture, data flow, and simple flowcharts where Mermaid is overkill (under 8 nodes, simple topology). These produce crisp, theme-aware vector diagrams drawn directly in the HTML. Use Mermaid for anything with complex edge routing (10+ nodes, many crossing connections). + +### Container + +```html +
+ + + + Request flow through the API gateway +
+``` + +```css +.svg-panel { + border: 1.5px solid var(--border); + border-radius: var(--radius); + padding: 24px; + margin: 24px 0; + background: var(--card); +} + +.svg-caption { + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--muted-foreground); + display: block; + margin-top: 8px; + text-align: center; +} +``` + +### Arrow markers + +Define in ``. Reference via `marker-end="url(#arrow)"`. + +```svg + + + + + + + + + + + + + + +``` + +### Box node + +```svg + + + API Server + Express + middleware + +``` + +### Highlighted box (new or hot-path component) + +```svg + + + New Service + to be created + +``` + +### Connecting arrows + +```svg + + + + + + + + +``` + +### Edge labels + +```svg +REST +``` + +### Flowchart elements + +```svg + + +Valid? + + + + + + + + + + + + +``` + +### Data flow (request/response) + +```svg + + +POST /api/plan + + + +{ plan, status } +``` + +### Fan-out pattern + +```svg + + + +``` + +### Bar chart + +```svg + + + + + + + + + + + + + + 25 + + + Q1 + +``` + +### Using CSS classes in SVG + +For cleaner markup, define reusable classes inside the SVG: + +```svg + + + +``` + +### Positioning rules + +- `viewBox` with fixed coordinates + `style="width:100%;max-width:720px"` for responsive scaling +- Standard node: `120–160px` wide, `48–56px` tall +- Minimum gap: `60px` horizontal, `40px` vertical +- Arrow label offset: `8–12px` above the line + +### Color roles in SVG + +| Element | Token | +|---------|-------| +| Box background | `var(--card)` | +| Box stroke | `var(--border)` | +| Highlighted box | `var(--primary)` stroke, `color-mix(in oklab, var(--primary) 8%, transparent)` fill | +| Arrows / connectors | `var(--muted-foreground)` | +| Title text | `var(--foreground)` | +| Subtitle / labels | `var(--muted-foreground)` | +| Success path | `var(--success)` | +| Error path | `var(--destructive)` | +| Warning | `var(--warning)` | + +## Section Headers + +Numbered sections with display font headings: + +```css +.section-header { + display: flex; + align-items: baseline; + gap: 14px; + margin-bottom: 24px; + padding-bottom: 8px; + border-bottom: 1.5px solid var(--border); +} + +.section-num { + font-family: var(--font-mono); + font-size: 0.75rem; + font-weight: 600; + color: var(--primary); +} + +.section-header h2 { + font-family: var(--font-display); + font-size: 1.35rem; + font-weight: 500; +} +``` + +## Tag Chips + +Small inline labels for categorizing items: + +```css +.tag { + font-family: var(--font-mono); + font-size: 0.68rem; + padding: 2px 8px; + border-radius: 4px; + background: var(--muted); + color: var(--muted-foreground); +} + +.tag-highlight { + background: color-mix(in oklab, var(--primary) 12%, transparent); + color: var(--primary); +} +``` + +## Diff Rendering (for PR reviews) + +Use Pierre diffs via CDN for syntax-highlighted, theme-aware diff rendering. Pierre renders into shadow DOM (no style conflicts) and supports Plannotator theme tokens via CSS variable injection. + +```html + + + +``` + +Pierre handles syntax highlighting (via Shiki), line numbers, add/del coloring, word-level diffs, and split/unified views automatically. The `unsafeCSS` option injects Plannotator theme tokens into the shadow DOM. + +For multiple diffs on one page, create one `` per file and set `fileDiff` + `options` on each. + +## Review Comment Bubbles (for PR reviews) + +Speech bubbles with severity-coded left borders. + +```css +.bubble { + position: relative; + background: var(--card); + border: 1.5px solid var(--border); + border-left-width: 4px; + border-radius: 8px; + padding: 12px 14px 12px 16px; + max-width: 680px; +} + +.bubble.blocking { border-left-color: var(--primary); } +.bubble.nit { border-left-color: var(--border); } +.bubble.suggestion { border-left-color: var(--success); } + +.bubble .severity { + font-family: var(--font-mono); + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.bubble.blocking .severity { color: var(--primary); } +.bubble.nit .severity { color: var(--muted-foreground); } +.bubble.suggestion .severity { color: var(--success); } +``` + +## File Badges (for PR reviews) + +```css +.file-badge { + font-family: var(--font-mono); + font-size: 0.62rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 2px 6px; + border-radius: 4px; +} + +.file-badge.new { background: color-mix(in oklab, var(--success) 15%, transparent); color: var(--success); } +.file-badge.mod { background: color-mix(in oklab, var(--warning) 15%, transparent); color: var(--warning); } +.file-badge.del { background: color-mix(in oklab, var(--destructive) 15%, transparent); color: var(--destructive); } +``` From e5384b109304932099248763d87168dd3fff6012 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 11 May 2026 21:07:58 -0700 Subject: [PATCH 29/34] feat(ui): copyable hook path + guidance in Settings Hooks tab (#707) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): show copyable file path and guidance in Hooks settings tab Always return the improvement hook file path from /api/hooks/status (actual path when present, expected path when absent) so the UI can display it in both states. Add CopyPathButton with tilde-shortened display and full-path clipboard copy. When active, guide users to edit directly or regenerate via /plannotator-compound. When absent, show expected path and both creation options (auto-generate or manual). * fix(ui): anchor displayPath on .plannotator instead of guessing homedir The regex assumed home directories are always two segments deep (/Users/x), which breaks for /root on Linux — it would capture /root/.plannotator as the home prefix and display ~/hooks/... instead of ~/.plannotator/hooks/..., leading users to create the file in the wrong location. --- apps/pi-extension/server/serverPlan.ts | 2 +- packages/server/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index e28e88dc3..af463e5f7 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -39,7 +39,7 @@ import { import { listenOnPort } from "./network.js"; import { loadConfig, saveConfig, detectGitUser, getServerConfig } from "../generated/config.js"; -import { readImprovementHook } from "../generated/improvement-hooks.js"; +import { readImprovementHook, getImprovementHookExpectedPath } from "../generated/improvement-hooks.js"; import { composeImproveContext } from "../generated/pfm-reminder.js"; import { detectProjectName, getRepoInfo } from "./project.js"; import { diff --git a/packages/server/index.ts b/packages/server/index.ts index b6a6501e7..df664bc77 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -45,7 +45,7 @@ import { import { getRepoInfo } from "./repo"; import { detectProjectName } from "./project"; import { loadConfig, saveConfig, detectGitUser, getServerConfig } from "./config"; -import { readImprovementHook } from "@plannotator/shared/improvement-hooks"; +import { readImprovementHook, getImprovementHookExpectedPath } from "@plannotator/shared/improvement-hooks"; import { composeImproveContext } from "@plannotator/shared/pfm-reminder"; import { handleImage, handleUpload, handleAgents, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, handleFavicon, type OpencodeClient } from "./shared-handlers"; import { contentHash, deleteDraft } from "./draft"; From f65e4515aeb3646ed7168e4a9739f5c01ff22282 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Tue, 5 May 2026 12:37:12 +0700 Subject: [PATCH 30/34] Preserve truthful approval semantics for Claude plan bypass Thread a clear-context reminder flag through approval decisions, expose a Claude Code-only approval entry that requests bypass mode, and keep the hook response honest by emitting a reminder instead of claiming context was cleared. Constraint: Claude Code PermissionRequest hooks have no documented clearContext response field and bypassPermissions is only a request when the mode is available. Rejected: adding a permission-mode enum or undocumented clearContext field | those would misrepresent hook capabilities and broaden the contract. Confidence: high Scope-risk: moderate Directive: Do not claim Plannotator clears context until Claude Code documents a hook field for that behavior; keep reminder copy truthful. Tested: bun test packages/server apps/hook; bun test packages/editor/wideMode.test.ts packages/ui/hooks/useAgentSettings.test.ts; bun test; bun run build:review; bun run build:hook; bun run typecheck; git diff --check --cached. Not-tested: Browser warning-dialog replay and interactive Claude Code smoke test. Co-authored-by: OmX --- apps/hook/server/index.ts | 4 + packages/ui/components/ApproveDropdown.tsx | 149 ++++++++++++++------- 2 files changed, 108 insertions(+), 45 deletions(-) diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 4e3f4e1fa..1bbdb6c7f 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -1298,6 +1298,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: { diff --git a/packages/ui/components/ApproveDropdown.tsx b/packages/ui/components/ApproveDropdown.tsx index ab9c48c7f..b669f76e2 100644 --- a/packages/ui/components/ApproveDropdown.tsx +++ b/packages/ui/components/ApproveDropdown.tsx @@ -2,11 +2,21 @@ import React, { useState, useRef, useEffect } from 'react'; import type { Agent } from '../hooks/useAgents'; import { getAgentSwitchSettings, saveAgentSwitchSettings, type AgentSwitchSettings } from '../utils/agentSwitch'; +export interface ApproveExtraEntry { + id: string; + label: string; + description?: string; + onSelect: () => void; + disabled?: boolean; +} + interface ApproveDropdownProps { onApprove: () => void; agents: Agent[]; disabled?: boolean; isLoading?: boolean; + extraEntries?: ApproveExtraEntry[]; + showAgentSwitch?: boolean; } function getSelectedLabel(setting: AgentSwitchSettings, agents: Agent[]): string | null { @@ -35,6 +45,8 @@ export const ApproveDropdown: React.FC = ({ agents, disabled = false, isLoading = false, + extraEntries = [], + showAgentSwitch, }) => { const [setting, setSetting] = useState(() => getAgentSwitchSettings()); const [isOpen, setIsOpen] = useState(false); @@ -57,6 +69,10 @@ export const ApproveDropdown: React.FC = ({ }; }, []); + const hasExtraEntries = extraEntries.length > 0; + const shouldShowAgentSwitch = showAgentSwitch ?? agents.length > 0; + const hasDropdownContent = hasExtraEntries || shouldShowAgentSwitch; + const handleSelect = (newSetting: AgentSwitchSettings) => { setSetting(newSetting); saveAgentSwitchSettings(newSetting); @@ -78,16 +94,36 @@ export const ApproveDropdown: React.FC = ({ onApprove(); }; + const handleExtraSelect = (entry: ApproveExtraEntry) => { + if (entry.disabled) return; + setIsOpen(false); + entry.onSelect(); + }; + return (
- {/* Mobile: simple button */} - + {/* Mobile: simple button, with menu when extra actions exist */} +
+ + {hasDropdownContent && ( + + )} +
{/* Desktop: split button */}
@@ -109,8 +145,9 @@ export const ApproveDropdown: React.FC = ({
{/* Dropdown */} - {isOpen && ( -
-
- Switch to agent -
- {agents.map((agent) => { - const selected = isSelected(agent.id, setting); - return ( + {isOpen && hasDropdownContent && ( +
+ {hasExtraEntries && ( + <> + {extraEntries.map((entry) => ( + + ))} + {shouldShowAgentSwitch &&
} + + )} + {shouldShowAgentSwitch && ( + <> +
+ Switch to agent +
+ {agents.map((agent) => { + const selected = isSelected(agent.id, setting); + return ( + + ); + })} + {isCustom && setting.customName && ( + + )} +
- ); - })} - {isCustom && setting.customName && ( - + )} -
-
)}
From 4a27c9badad48b7d4f359ea5fb0ebdf09355e101 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Tue, 5 May 2026 15:05:47 +0700 Subject: [PATCH 31/34] Keep approval payloads truthful across Claude Code and Pi Constraint: Claude Code hooks cannot clear context directly; the new action remains a truthful reminder plus bypass-mode approval. Rejected: Sending OpenCode agent-switch state for Claude Code | it leaked build (?) UI state and misleading payload fields. Confidence: high Scope-risk: narrow Directive: Keep OpenCode agent switching gated to opencode-origin approval payloads. Tested: bun run typecheck; bun test; bun run build:review; bun run build:hook Not-tested: Manual browser click-through of the Claude Code dropdown. --- apps/pi-extension/plannotator-browser.ts | 1 + apps/pi-extension/plannotator-events.ts | 2 + apps/pi-extension/server/serverPlan.ts | 4 ++ packages/editor/App.tsx | 9 +--- packages/editor/approvalBody.test.ts | 33 +++++++++++++ packages/editor/approvalBody.ts | 47 +++++++++++++++++++ .../ui/components/ApproveDropdown.test.tsx | 24 ++++++++++ packages/ui/components/ApproveDropdown.tsx | 8 ++-- 8 files changed, 116 insertions(+), 12 deletions(-) create mode 100644 packages/editor/approvalBody.test.ts create mode 100644 packages/editor/approvalBody.ts create mode 100644 packages/ui/components/ApproveDropdown.test.tsx diff --git a/apps/pi-extension/plannotator-browser.ts b/apps/pi-extension/plannotator-browser.ts index 2235bca6f..a47e89a8f 100644 --- a/apps/pi-extension/plannotator-browser.ts +++ b/apps/pi-extension/plannotator-browser.ts @@ -35,6 +35,7 @@ export interface PlanReviewDecision { savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; } export interface BrowserDecisionSession { diff --git a/apps/pi-extension/plannotator-events.ts b/apps/pi-extension/plannotator-events.ts index 12845ae70..f2754a6de 100644 --- a/apps/pi-extension/plannotator-events.ts +++ b/apps/pi-extension/plannotator-events.ts @@ -73,6 +73,7 @@ export interface PlannotatorReviewResultEvent { savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; } export interface PlannotatorReviewStatusPayload { @@ -248,6 +249,7 @@ export function registerPlannotatorEventListeners(pi: ExtensionAPI): void { savedPath: result.savedPath, agentSwitch: result.agentSwitch, permissionMode: result.permissionMode, + clearContextNudge: result.clearContextNudge, } satisfies PlannotatorReviewResultEvent; setStoredReviewStatus(session.reviewId, { status: "completed", ...reviewResult }); pi.events.emit(PLANNOTATOR_REVIEW_RESULT_CHANNEL, reviewResult); diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index af463e5f7..a894b21d2 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -62,6 +62,7 @@ export interface PlanReviewDecision { savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; } export interface PlanServerResult { @@ -375,6 +376,7 @@ export async function startPlanReviewServer(options: { let feedback: string | undefined; let agentSwitch: string | undefined; let requestedPermissionMode: string | undefined; + let clearContextNudge: boolean | undefined; let planSaveEnabled = true; let planSaveCustomPath: string | undefined; try { @@ -383,6 +385,7 @@ export async function startPlanReviewServer(options: { if (body.agentSwitch) agentSwitch = body.agentSwitch as string; if (body.permissionMode) requestedPermissionMode = body.permissionMode as string; + if (body.clearContextNudge === true) clearContextNudge = true; if (body.planSave !== undefined) { const ps = body.planSave as { enabled: boolean; customPath?: string }; planSaveEnabled = ps.enabled; @@ -444,6 +447,7 @@ export async function startPlanReviewServer(options: { savedPath, agentSwitch, permissionMode: effectivePermissionMode, + clearContextNudge, }); json(res, { ok: true, savedPath }); } else if ( diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 25d64af52..9c19abdba 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -78,6 +78,7 @@ import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiff import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from './demoPlan'; import { DIFF_DEMO_PLAN_CONTENT } from './demoPlanDiffDemo'; import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode'; +import { buildApprovalRequestBody, type ApprovalOverride } from './approvalBody'; const USE_DIFF_DEMO = import.meta.env.VITE_DIFF_DEMO === '1' || import.meta.env.VITE_DIFF_DEMO === 'true'; @@ -962,14 +963,6 @@ const App: React.FC = () => { ? await autoSavePromiseRef.current : autoSaveResultsRef.current; - // Build request body - include integrations if enabled - const body: { obsidian?: object; bear?: object; octarine?: object; feedback?: string; agentSwitch?: string; planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string } = {}; - - // Include permission mode for Claude Code - if (origin === 'claude-code') { - body.permissionMode = permissionMode; - } - const effectiveAgent = getEffectiveAgentName(getAgentSwitchSettings()); const body = buildApprovalRequestBody({ origin, diff --git a/packages/editor/approvalBody.test.ts b/packages/editor/approvalBody.test.ts new file mode 100644 index 000000000..c1f024e74 --- /dev/null +++ b/packages/editor/approvalBody.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'bun:test'; +import { buildApprovalRequestBody } from './approvalBody'; + +describe('buildApprovalRequestBody', () => { + test('omits agentSwitch for Claude Code approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'acceptEdits', + effectiveAgent: 'build', + override: { + permissionMode: 'bypassPermissions', + clearContextNudge: true, + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + clearContextNudge: true, + planSave: { enabled: true }, + }); + }); + + test('keeps agentSwitch for OpenCode approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'opencode', + permissionMode: 'acceptEdits', + effectiveAgent: 'build', + planSaveSettings: { enabled: true }, + })).toEqual({ + agentSwitch: 'build', + planSave: { enabled: true }, + }); + }); +}); diff --git a/packages/editor/approvalBody.ts b/packages/editor/approvalBody.ts new file mode 100644 index 000000000..7ebb4b834 --- /dev/null +++ b/packages/editor/approvalBody.ts @@ -0,0 +1,47 @@ +import type { Origin } from '@plannotator/shared/agents'; +import type { PermissionMode } from '@plannotator/ui/utils/permissionMode'; + +export type ApprovalOverride = { + permissionMode?: PermissionMode; + clearContextNudge?: boolean; +}; + +export interface ApprovalRequestBody { + obsidian?: object; + bear?: object; + octarine?: object; + feedback?: string; + agentSwitch?: string; + planSave?: { enabled: boolean; customPath?: string }; + permissionMode?: string; + clearContextNudge?: boolean; +} + +export function buildApprovalRequestBody(options: { + origin: Origin | null; + permissionMode: PermissionMode; + override?: ApprovalOverride; + effectiveAgent?: string; + planSaveSettings: { enabled: boolean; customPath?: string | null }; +}): ApprovalRequestBody { + const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings } = options; + const body: ApprovalRequestBody = {}; + + if (origin === 'claude-code') { + body.permissionMode = override.permissionMode ?? permissionMode; + if (override.clearContextNudge) { + body.clearContextNudge = true; + } + } + + if (origin === 'opencode' && effectiveAgent) { + body.agentSwitch = effectiveAgent; + } + + body.planSave = { + enabled: planSaveSettings.enabled, + ...(planSaveSettings.customPath && { customPath: planSaveSettings.customPath }), + }; + + return body; +} diff --git a/packages/ui/components/ApproveDropdown.test.tsx b/packages/ui/components/ApproveDropdown.test.tsx new file mode 100644 index 000000000..967f8e4be --- /dev/null +++ b/packages/ui/components/ApproveDropdown.test.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { describe, expect, test } from 'bun:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { ApproveDropdown } from './ApproveDropdown'; + +describe('ApproveDropdown', () => { + test('does not show agent-switch label when only extra approval entries are enabled', () => { + const html = renderToStaticMarkup( + {}} + agents={[]} + extraEntries={[{ + id: 'approve-bypass-clear-reminder', + label: 'Approve + Bypass + /clear Reminder', + onSelect: () => {}, + }]} + />, + ); + + expect(html).toContain('Approve'); + expect(html).not.toContain('build'); + expect(html).not.toContain('(?)'); + }); +}); diff --git a/packages/ui/components/ApproveDropdown.tsx b/packages/ui/components/ApproveDropdown.tsx index b669f76e2..9424696d3 100644 --- a/packages/ui/components/ApproveDropdown.tsx +++ b/packages/ui/components/ApproveDropdown.tsx @@ -79,10 +79,10 @@ export const ApproveDropdown: React.FC = ({ setIsOpen(false); }; - const agentLabel = getSelectedLabel(setting, agents); - const isNoSwitch = setting.switchTo === 'disabled'; - const isCustom = setting.switchTo === 'custom'; - const notFound = agentLabel && !isNoSwitch && !isCustom + const agentLabel = shouldShowAgentSwitch ? getSelectedLabel(setting, agents) : null; + const isNoSwitch = shouldShowAgentSwitch && setting.switchTo === 'disabled'; + const isCustom = shouldShowAgentSwitch && setting.switchTo === 'custom'; + const notFound = shouldShowAgentSwitch && agentLabel && !isNoSwitch && !isCustom && !agents.some(a => a.id.toLowerCase() === setting.switchTo.toLowerCase()); const baseClasses = disabled From 8647968913f7114dfaca1f00e9a570f1e2e5bfd8 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Thu, 7 May 2026 02:20:25 +0700 Subject: [PATCH 32/34] omx(team): checkpoint worker-1 shutdown changes --- packages/editor/App.tsx | 50 ++++++++++ packages/editor/approvalBody.test.ts | 113 +++++++++++++++++++++++ packages/editor/approvalBody.ts | 16 +++- packages/editor/components/AppHeader.tsx | 10 +- packages/ui/components/Settings.tsx | 4 +- packages/ui/utils/permissionMode.ts | 16 +++- 6 files changed, 199 insertions(+), 10 deletions(-) diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 9c19abdba..2b346bcfb 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -963,6 +963,19 @@ const App: React.FC = () => { ? await autoSavePromiseRef.current : autoSaveResultsRef.current; + const shouldUseNativeClear = + origin === 'claude-code' && + pendingToolName === 'ExitPlanMode' && + (override.deferToNativeForClear || (override.permissionMode ?? permissionMode) === 'bypassPermissionsClearReminder'); + if (shouldUseNativeClear) { + try { + const response = await fetch('/api/enable-clear-context', { method: 'POST' }); + if (response.ok) setShowClearContextBanner(false); + } catch { + setShowClearContextBanner(true); + } + } + const effectiveAgent = getEffectiveAgentName(getAgentSwitchSettings()); const body = buildApprovalRequestBody({ origin, @@ -970,6 +983,7 @@ const App: React.FC = () => { override, effectiveAgent, planSaveSettings, + toolName: pendingToolName, }); const effectiveVaultPath = getEffectiveVaultPath(obsidianSettings); @@ -1041,6 +1055,42 @@ const App: React.FC = () => { } }; +<<<<<<< HEAD +======= + const approveWithClaudeCodeWarning = useCallback((override: ApprovalOverride = {}) => { + setPendingApprovalOverride(override); + if (origin === 'claude-code' && (allAnnotations.length > 0 || codeAnnotations.length > 0)) { + setShowClaudeCodeWarning(true); + return; + } + handleApprove(override); + }, [allAnnotations.length, codeAnnotations.length, origin, handleApprove]); + + const claudeCodeExtraEntries = useMemo(() => { + if (origin !== 'claude-code') return []; + if (pendingToolName === 'ExitPlanMode') { + return [{ + id: 'approve-bypass-native-clear', + label: 'Approve + Bypass + Clear Context (native)', + description: "Defers to Claude Code's native plan-accept dialog so it can clear context and set bypass permissions.", + onSelect: () => approveWithClaudeCodeWarning({ + permissionMode: 'bypassPermissions', + deferToNativeForClear: true, + }), + }]; + } + return [{ + id: 'approve-bypass-clear-reminder', + label: 'Approve + Bypass + /clear Reminder', + description: 'Requests bypass mode and reminds you to run /clear. Hooks cannot clear context directly outside plan acceptance.', + onSelect: () => approveWithClaudeCodeWarning({ + permissionMode: 'bypassPermissions', + clearContextNudge: true, + }), + }]; + }, [approveWithClaudeCodeWarning, origin, pendingToolName]); + +>>>>>>> ff79946 (Keep Plannotator approvals actionable for bypass clear context) // Annotate mode handler — sends feedback via /api/feedback const handleAnnotateFeedback = async () => { setIsSubmitting(true); diff --git a/packages/editor/approvalBody.test.ts b/packages/editor/approvalBody.test.ts index c1f024e74..3d1977074 100644 --- a/packages/editor/approvalBody.test.ts +++ b/packages/editor/approvalBody.test.ts @@ -2,6 +2,32 @@ import { describe, expect, test } from 'bun:test'; import { buildApprovalRequestBody } from './approvalBody'; describe('buildApprovalRequestBody', () => { + test('maps bypass clear reminder mode to native Claude Code clear on ExitPlanMode', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'bypassPermissionsClearReminder', + toolName: 'ExitPlanMode', + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + deferToNativeForClear: true, + planSave: { enabled: true }, + }); + }); + + test('maps bypass clear reminder mode to reminder fallback outside ExitPlanMode', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'bypassPermissionsClearReminder', + toolName: 'OtherTool', + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + clearContextNudge: true, + planSave: { enabled: true }, + }); + }); + test('omits agentSwitch for Claude Code approvals', () => { expect(buildApprovalRequestBody({ origin: 'claude-code', @@ -19,6 +45,37 @@ describe('buildApprovalRequestBody', () => { }); }); + test('keeps bypass clear reminder override fallback fields for Claude Code approvals without ExitPlanMode', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'acceptEdits', + override: { + permissionMode: 'bypassPermissionsClearReminder', + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + clearContextNudge: true, + planSave: { enabled: true }, + }); + }); + + test('uses native clear for bypass clear reminder override when ExitPlanMode is known', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'acceptEdits', + toolName: 'ExitPlanMode', + override: { + permissionMode: 'bypassPermissionsClearReminder', + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + deferToNativeForClear: true, + planSave: { enabled: true }, + }); + }); + test('keeps agentSwitch for OpenCode approvals', () => { expect(buildApprovalRequestBody({ origin: 'opencode', @@ -30,4 +87,60 @@ describe('buildApprovalRequestBody', () => { planSave: { enabled: true }, }); }); + + test('ignores bypass clear reminder mode for OpenCode approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'opencode', + permissionMode: 'bypassPermissionsClearReminder', + effectiveAgent: 'build', + planSaveSettings: { enabled: true }, + })).toEqual({ + agentSwitch: 'build', + planSave: { enabled: true }, + }); + }); + + test('forwards deferToNativeForClear for Claude Code bypass approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'acceptEdits', + override: { + permissionMode: 'bypassPermissions', + deferToNativeForClear: true, + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + deferToNativeForClear: true, + planSave: { enabled: true }, + }); + }); + + test('does not forward deferToNativeForClear for OpenCode approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'opencode', + permissionMode: 'acceptEdits', + effectiveAgent: 'build', + override: { + deferToNativeForClear: true, + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + agentSwitch: 'build', + planSave: { enabled: true }, + }); + }); + + test('does not forward deferToNativeForClear for Gemini origin', () => { + expect(buildApprovalRequestBody({ + origin: 'gemini-cli', + permissionMode: 'acceptEdits', + override: { + deferToNativeForClear: true, + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + planSave: { enabled: true }, + }); + }); }); diff --git a/packages/editor/approvalBody.ts b/packages/editor/approvalBody.ts index 7ebb4b834..a5e1ba7b4 100644 --- a/packages/editor/approvalBody.ts +++ b/packages/editor/approvalBody.ts @@ -4,6 +4,7 @@ import type { PermissionMode } from '@plannotator/ui/utils/permissionMode'; export type ApprovalOverride = { permissionMode?: PermissionMode; clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }; export interface ApprovalRequestBody { @@ -15,6 +16,7 @@ export interface ApprovalRequestBody { planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string; clearContextNudge?: boolean; + deferToNativeForClear?: boolean; } export function buildApprovalRequestBody(options: { @@ -23,13 +25,21 @@ export function buildApprovalRequestBody(options: { override?: ApprovalOverride; effectiveAgent?: string; planSaveSettings: { enabled: boolean; customPath?: string | null }; + toolName?: string; }): ApprovalRequestBody { - const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings } = options; + const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings, toolName } = options; const body: ApprovalRequestBody = {}; if (origin === 'claude-code') { - body.permissionMode = override.permissionMode ?? permissionMode; - if (override.clearContextNudge) { + const effectivePermissionMode = override.permissionMode ?? permissionMode; + const wantsClearContext = effectivePermissionMode === 'bypassPermissionsClearReminder'; + const useNativeClear = override.deferToNativeForClear || (wantsClearContext && toolName === 'ExitPlanMode'); + + body.permissionMode = wantsClearContext ? 'bypassPermissions' : effectivePermissionMode; + + if (useNativeClear) { + body.deferToNativeForClear = true; + } else if (override.clearContextNudge || wantsClearContext) { body.clearContextNudge = true; } } diff --git a/packages/editor/components/AppHeader.tsx b/packages/editor/components/AppHeader.tsx index ad3ca9b74..8c44d7f1b 100644 --- a/packages/editor/components/AppHeader.tsx +++ b/packages/editor/components/AppHeader.tsx @@ -2,7 +2,7 @@ import React from 'react'; import type { Origin } from '@plannotator/shared/agents'; import type { Agent } from '@plannotator/ui/hooks/useAgents'; import { FeedbackButton, ApproveButton, ExitButton } from '@plannotator/ui/components/ToolbarButtons'; -import { ApproveDropdown } from '@plannotator/ui/components/ApproveDropdown'; +import { ApproveDropdown, type ApproveExtraEntry } from '@plannotator/ui/components/ApproveDropdown'; import { Settings } from '@plannotator/ui/components/Settings'; import { PlanHeaderMenu } from '@plannotator/ui/components/PlanHeaderMenu'; import type { CallbackConfig } from '@plannotator/ui/utils/callback'; @@ -27,6 +27,7 @@ interface AppHeaderProps { canShareCurrentSession: boolean; agentName: string; availableAgents: Agent[]; + approveExtraEntries?: ApproveExtraEntry[]; showAnnotationsWarning: boolean; // Callback config (null when no bot callback) @@ -87,6 +88,7 @@ export const AppHeader = React.memo(({ canShareCurrentSession, agentName, availableAgents, + approveExtraEntries = [], showAnnotationsWarning, callbackConfig, taterMode, @@ -198,10 +200,12 @@ export const AppHeader = React.memo(({ )} {(!annotateMode || gate) && ( - origin === 'opencode' && !annotateMode && availableAgents.length > 0 ? ( + !annotateMode && ((origin === 'opencode' && availableAgents.length > 0) || approveExtraEntries.length > 0) ? ( 0} disabled={isSubmitting} isLoading={isSubmitting} /> diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index e2982315b..48a4bdb6f 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -80,6 +80,7 @@ interface SettingsProps { origin?: Origin | null; mode?: 'plan' | 'review'; onUIPreferencesChange?: (prefs: UIPreferences) => void; + onPermissionModeChange?: (mode: PermissionMode) => void; /** Externally controlled open state (for mobile menu integration) */ externalOpen?: boolean; onExternalClose?: () => void; @@ -599,7 +600,7 @@ const CommentsTab: React.FC = () => { ); }; -export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser }) => { +export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, onPermissionModeChange, externalOpen, onExternalClose, aiProviders = [], gitUser }) => { const [showDialog, setShowDialog] = useState(false); const [themePreview, setThemePreview] = useState(false); @@ -803,6 +804,7 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange const handlePermissionModeChange = (mode: PermissionMode) => { setPermissionMode(mode); savePermissionModeSettings(mode); + onPermissionModeChange?.(mode); }; const handleDefaultNotesAppChange = (app: DefaultNotesApp) => { diff --git a/packages/ui/utils/permissionMode.ts b/packages/ui/utils/permissionMode.ts index 809995377..ed72dc4f1 100644 --- a/packages/ui/utils/permissionMode.ts +++ b/packages/ui/utils/permissionMode.ts @@ -6,6 +6,7 @@ * * Available modes: * - bypassPermissions: Auto-approve all tool calls + * - bypassPermissionsClearReminder: Persisted UI mode that uses native clear-on-accept for Claude Code plan approvals and a /clear reminder fallback otherwise * - acceptEdits: Auto-approve file edits only * - default: Manually approve each tool call */ @@ -15,7 +16,7 @@ import { storage } from './storage'; const STORAGE_KEY_MODE = 'plannotator-permission-mode'; const STORAGE_KEY_CONFIGURED = 'plannotator-permission-mode-configured'; -export type PermissionMode = 'bypassPermissions' | 'acceptEdits' | 'default'; +export type PermissionMode = 'bypassPermissions' | 'bypassPermissionsClearReminder' | 'acceptEdits' | 'default'; export interface PermissionModeSettings { mode: PermissionMode; @@ -33,6 +34,11 @@ export const PERMISSION_MODE_OPTIONS: { value: PermissionMode; label: string; de label: 'Bypass Permissions', description: 'Auto-approve all tool calls (equivalent to --dangerously-skip-permissions)', }, + { + value: 'bypassPermissionsClearReminder', + label: 'Bypass + Clear Context', + description: 'For Claude Code plan approvals, defer to the native clear-context flow and bypass permissions; otherwise emit a /clear reminder.', + }, { value: 'default', label: 'Manual Approval', @@ -42,15 +48,19 @@ export const PERMISSION_MODE_OPTIONS: { value: PermissionMode; label: string; de const DEFAULT_MODE: PermissionMode = 'acceptEdits'; +function isPermissionMode(value: string | null): value is PermissionMode { + return PERMISSION_MODE_OPTIONS.some((option) => option.value === value); +} + /** * Get current permission mode settings from storage */ export function getPermissionModeSettings(): PermissionModeSettings { - const mode = storage.getItem(STORAGE_KEY_MODE) as PermissionMode | null; + const mode = storage.getItem(STORAGE_KEY_MODE); const configured = storage.getItem(STORAGE_KEY_CONFIGURED) === 'true'; return { - mode: mode || DEFAULT_MODE, + mode: isPermissionMode(mode) ? mode : DEFAULT_MODE, configured, }; } From 59f089e1d779527b5983c125ec9db31a28c35767 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Thu, 14 May 2026 15:58:05 +0700 Subject: [PATCH 33/34] task: Resolve UI/editor conflict surfaces --- packages/editor/App.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 2b346bcfb..bd36875ba 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -79,6 +79,7 @@ import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from './demoPlan'; import { DIFF_DEMO_PLAN_CONTENT } from './demoPlanDiffDemo'; import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode'; import { buildApprovalRequestBody, type ApprovalOverride } from './approvalBody'; +import type { ApproveExtraEntry } from '@plannotator/ui/components/ApproveDropdown'; const USE_DIFF_DEMO = import.meta.env.VITE_DIFF_DEMO === '1' || import.meta.env.VITE_DIFF_DEMO === 'true'; @@ -952,7 +953,7 @@ const App: React.FC = () => { }; // API mode handlers - const handleApprove = async () => { + const handleApprove = async (override: ApprovalOverride = {}) => { setIsSubmitting(true); try { const obsidianSettings = getObsidianSettings(); @@ -1055,8 +1056,6 @@ const App: React.FC = () => { } }; -<<<<<<< HEAD -======= const approveWithClaudeCodeWarning = useCallback((override: ApprovalOverride = {}) => { setPendingApprovalOverride(override); if (origin === 'claude-code' && (allAnnotations.length > 0 || codeAnnotations.length > 0)) { @@ -1090,7 +1089,6 @@ const App: React.FC = () => { }]; }, [approveWithClaudeCodeWarning, origin, pendingToolName]); ->>>>>>> ff79946 (Keep Plannotator approvals actionable for bypass clear context) // Annotate mode handler — sends feedback via /api/feedback const handleAnnotateFeedback = async () => { setIsSubmitting(true); @@ -2243,7 +2241,9 @@ const App: React.FC = () => { onClose={() => setShowClaudeCodeWarning(false)} onConfirm={() => { setShowClaudeCodeWarning(false); - handleApprove(); + const override = pendingApprovalOverride; + setPendingApprovalOverride({}); + handleApprove(override); }} title="Annotations Won't Be Sent" message={<>{agentName} doesn't yet support feedback on approval. Your {allAnnotations.length + codeAnnotations.length} annotation{(allAnnotations.length + codeAnnotations.length) !== 1 ? 's' : ''} will be lost.} From 718705794c0f6d162907eb43e9db3e4f92ce50f4 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Thu, 14 May 2026 23:06:06 +0700 Subject: [PATCH 34/34] fix(hook): fail closed for clear-context approvals --- apps/hook/server/hookDecision.test.ts | 56 +++++++++ apps/hook/server/hookDecision.ts | 110 ++++++++++++++++++ apps/hook/server/index.ts | 70 +++-------- .../editor/App.clearContextBanner.test.ts | 21 ++++ packages/editor/App.tsx | 82 ++----------- packages/editor/approvalBody.test.ts | 88 ++++++++++++-- packages/editor/approvalBody.ts | 15 ++- packages/ui/utils/permissionMode.ts | 4 +- 8 files changed, 311 insertions(+), 135 deletions(-) create mode 100644 apps/hook/server/hookDecision.test.ts create mode 100644 apps/hook/server/hookDecision.ts create mode 100644 packages/editor/App.clearContextBanner.test.ts diff --git a/apps/hook/server/hookDecision.test.ts b/apps/hook/server/hookDecision.test.ts new file mode 100644 index 000000000..63e678c76 --- /dev/null +++ b/apps/hook/server/hookDecision.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test"; +import { formatClaudePlanHookOutput } from "./hookDecision"; + +describe("formatClaudePlanHookOutput", () => { + test("native handoff emits PreToolUse ask only when native clear was enabled", () => { + expect(formatClaudePlanHookOutput({ + result: { approved: true, permissionMode: "bypassPermissions", deferToNativeForClear: true }, + hookEventName: "PreToolUse", + toolName: "ExitPlanMode", + detectedOrigin: "claude-code", + nativeClearEnabled: true, + })).toEqual({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "ask", + }, + }); + }); + + test("PermissionRequest native defer falls back to explicit allow JSON", () => { + const output = formatClaudePlanHookOutput({ + result: { approved: true, deferToNativeForClear: true }, + hookEventName: "PermissionRequest", + toolName: "ExitPlanMode", + detectedOrigin: "claude-code", + nativeClearEnabled: true, + }) as any; + + expect(output.hookSpecificOutput.hookEventName).toBe("PermissionRequest"); + expect(output.hookSpecificOutput.decision.behavior).toBe("allow"); + expect(output.hookSpecificOutput.decision.updatedPermissions).toEqual([ + { type: "setMode", mode: "bypassPermissions", destination: "session" }, + ]); + expect(output.systemMessage).toContain("/clear"); + }); + + test("normal PermissionRequest approval includes updatedPermissions", () => { + expect(formatClaudePlanHookOutput({ + result: { approved: true, permissionMode: "bypassPermissions", clearContextNudge: true }, + hookEventName: "PermissionRequest", + toolName: "ExitPlanMode", + detectedOrigin: "claude-code", + })).toEqual({ + systemMessage: expect.stringContaining("/clear"), + hookSpecificOutput: { + hookEventName: "PermissionRequest", + decision: { + behavior: "allow", + updatedPermissions: [ + { type: "setMode", mode: "bypassPermissions", destination: "session" }, + ], + }, + }, + }); + }); +}); diff --git a/apps/hook/server/hookDecision.ts b/apps/hook/server/hookDecision.ts new file mode 100644 index 000000000..1f613f1cf --- /dev/null +++ b/apps/hook/server/hookDecision.ts @@ -0,0 +1,110 @@ +import { + buildPlanFileRule, + getPlanDeniedPrompt, + getPlanToolName, +} from "@plannotator/shared/prompts"; +import type { Origin } from "@plannotator/shared/agents"; + +export type PlanDecisionResult = { + approved: boolean; + feedback?: string; + permissionMode?: string; + clearContextNudge?: boolean; + deferToNativeForClear?: boolean; +}; + +export type ClaudeHookEventName = "PreToolUse" | "PermissionRequest"; + +export function normalizeClaudeHookEventName(value: unknown): ClaudeHookEventName { + return value === "PreToolUse" ? "PreToolUse" : "PermissionRequest"; +} + +function clearContextSystemMessage(): string { + return "Plannotator requested bypass mode. Hooks cannot clear context. Run /clear before continuing if you want a fresh implementation session."; +} + +export function formatClaudePlanHookOutput(options: { + result: PlanDecisionResult; + hookEventName: ClaudeHookEventName; + toolName: string; + detectedOrigin: Origin; + nativeClearEnabled?: boolean; + planFilename?: string; +}): Record { + const { result, hookEventName, toolName, detectedOrigin, nativeClearEnabled, planFilename } = options; + const isExitPlanMode = toolName === "ExitPlanMode"; + + if (result.approved && result.deferToNativeForClear && isExitPlanMode) { + if (hookEventName === "PreToolUse" && nativeClearEnabled) { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "ask", + }, + }; + } + + result.clearContextNudge = true; + result.permissionMode ||= "bypassPermissions"; + } + + if (hookEventName === "PreToolUse") { + if (result.approved) { + return { + ...(result.clearContextNudge && { systemMessage: clearContextSystemMessage() }), + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow", + }, + }; + } + + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: getPlanDeniedPrompt(detectedOrigin, undefined, { + toolName: getPlanToolName(detectedOrigin), + planFileRule: buildPlanFileRule(getPlanToolName(detectedOrigin), planFilename), + feedback: result.feedback || "Plan changes requested", + }), + }, + }; + } + + if (result.approved) { + const updatedPermissions = []; + if (result.permissionMode) { + updatedPermissions.push({ + type: "setMode", + mode: result.permissionMode, + destination: "session", + }); + } + + return { + ...(result.clearContextNudge && { systemMessage: clearContextSystemMessage() }), + hookSpecificOutput: { + hookEventName: "PermissionRequest", + decision: { + behavior: "allow", + ...(updatedPermissions.length > 0 && { updatedPermissions }), + }, + }, + }; + } + + return { + hookSpecificOutput: { + hookEventName: "PermissionRequest", + decision: { + behavior: "deny", + message: getPlanDeniedPrompt(detectedOrigin, undefined, { + toolName: getPlanToolName(detectedOrigin), + planFileRule: "", + feedback: result.feedback || "Plan changes requested", + }), + }, + }, + }; +} diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 1bbdb6c7f..83bc711c5 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -111,6 +111,7 @@ import { isVersionInvocation, } from "./cli"; import { ensureClearContextSettingEnabled } from "./clearContextSetting"; +import { formatClaudePlanHookOutput, normalizeClaudeHookEventName } from "./hookDecision"; import path from "path"; import { tmpdir } from "os"; @@ -1272,62 +1273,27 @@ if (args[0] === "sessions") { ); } } else { - // Claude Code: PermissionRequest hook decision - if ( + const hookEventName = normalizeClaudeHookEventName(event.hook_event_name); + const nativeClearEnabled = result.approved && result.deferToNativeForClear && + hookEventName === "PreToolUse" && toolName === "ExitPlanMode" - ) { - const nativeClearEnabled = await ensureClearContextSettingEnabled(); - if (nativeClearEnabled) { - process.exit(0); - } - result.clearContextNudge = true; - result.permissionMode ||= "bypassPermissions"; - } - - if (result.approved) { - const updatedPermissions = []; - if (result.permissionMode) { - updatedPermissions.push({ - type: "setMode", - mode: result.permissionMode, - destination: "session", - }); - } - - 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: { - behavior: "allow", - ...(updatedPermissions.length > 0 && { updatedPermissions }), - }, - }, - }) - ); - } else { - console.log( - JSON.stringify({ - hookSpecificOutput: { - hookEventName: "PermissionRequest", - decision: { - behavior: "deny", - message: getPlanDeniedPrompt(detectedOrigin, undefined, { - toolName: getPlanToolName(detectedOrigin), - planFileRule: "", - feedback: result.feedback || "Plan changes requested", - }), - }, - }, + ? await ensureClearContextSettingEnabled() + : false; + + console.log( + JSON.stringify( + formatClaudePlanHookOutput({ + result, + hookEventName, + toolName, + detectedOrigin, + nativeClearEnabled, + planFilename, }) - ); - } + ) + ); } process.exit(0); diff --git a/packages/editor/App.clearContextBanner.test.ts b/packages/editor/App.clearContextBanner.test.ts new file mode 100644 index 000000000..443eb1776 --- /dev/null +++ b/packages/editor/App.clearContextBanner.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +describe('App clear-context approval UI', () => { + test('does not render the blocking native clear-on-accept prompt', () => { + const source = readFileSync(join(import.meta.dir, 'App.tsx'), 'utf8'); + + expect(source).not.toContain('showClearContextBanner'); + expect(source).not.toContain('Enable native clear-on-accept?'); + expect(source).not.toContain('aria-label="Enable native clear-on-accept"'); + }); + + test('gates the native clear setup API behind the shared native-clear predicate', () => { + const source = readFileSync(join(import.meta.dir, 'App.tsx'), 'utf8'); + + expect(source.match(/\/api\/enable-clear-context/g)?.length).toBe(1); + expect(source).toContain('shouldEnableNativeClearBeforeApprove({ origin, permissionMode, toolName: pendingToolName, override })'); + expect(source).toContain("clearContextNudge: true"); + }); +}); diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index bd36875ba..5cf5cb906 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -78,8 +78,7 @@ import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiff import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from './demoPlan'; import { DIFF_DEMO_PLAN_CONTENT } from './demoPlanDiffDemo'; import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode'; -import { buildApprovalRequestBody, type ApprovalOverride } from './approvalBody'; -import type { ApproveExtraEntry } from '@plannotator/ui/components/ApproveDropdown'; +import { buildApprovalRequestBody, shouldEnableNativeClearBeforeApprove, type ApprovalOverride } from './approvalBody'; const USE_DIFF_DEMO = import.meta.env.VITE_DIFF_DEMO === '1' || import.meta.env.VITE_DIFF_DEMO === 'true'; @@ -154,7 +153,6 @@ const App: React.FC = () => { const [isApiMode, setIsApiMode] = useState(false); const [origin, setOrigin] = useState(null); const [pendingToolName, setPendingToolName] = useState(); - const [showClearContextBanner, setShowClearContextBanner] = useState(false); const [pendingApprovalOverride, setPendingApprovalOverride] = useState({}); const [gitUser, setGitUser] = useState(); const [isWSL, setIsWSL] = useState(false); @@ -964,16 +962,19 @@ const App: React.FC = () => { ? await autoSavePromiseRef.current : autoSaveResultsRef.current; - const shouldUseNativeClear = - origin === 'claude-code' && - pendingToolName === 'ExitPlanMode' && - (override.deferToNativeForClear || (override.permissionMode ?? permissionMode) === 'bypassPermissionsClearReminder'); - if (shouldUseNativeClear) { + let approvalOverride = override; + if (shouldEnableNativeClearBeforeApprove({ origin, permissionMode, toolName: pendingToolName, override })) { try { const response = await fetch('/api/enable-clear-context', { method: 'POST' }); - if (response.ok) setShowClearContextBanner(false); + if (!response.ok) { + throw new Error(`Unable to enable native clear-context: ${response.status}`); + } } catch { - setShowClearContextBanner(true); + approvalOverride = { + permissionMode: 'bypassPermissions', + clearContextNudge: true, + }; + toast.warning('Native clear-on-accept unavailable; approving with a /clear reminder instead.'); } } @@ -981,7 +982,7 @@ const App: React.FC = () => { const body = buildApprovalRequestBody({ origin, permissionMode, - override, + override: approvalOverride, effectiveAgent, planSaveSettings, toolName: pendingToolName, @@ -2361,65 +2362,6 @@ const App: React.FC = () => { {/* Update notification */} - {showClearContextBanner && ( -
-
- Enable native clear-on-accept? -
-
- Plannotator will write{' '} - showClearContextOnPlanAccept: true to your Claude - Code settings so Claude Code can clear planning context through - its native approval flow. -
-
- - -
-
- )} {/* Image Annotator for pasted images */} { + test('enables native clear for explicit Claude Code ExitPlanMode overrides', () => { + expect(shouldEnableNativeClearBeforeApprove({ + origin: 'claude-code', + permissionMode: 'acceptEdits', + toolName: 'ExitPlanMode', + override: { deferToNativeForClear: true }, + })).toBe(true); + }); + + test('does not enable native clear for saved bypass clear mode', () => { + expect(shouldEnableNativeClearBeforeApprove({ + origin: 'claude-code', + permissionMode: 'bypassPermissionsClearReminder', + toolName: 'ExitPlanMode', + })).toBe(false); + + expect(shouldEnableNativeClearBeforeApprove({ + origin: 'claude-code', + permissionMode: 'acceptEdits', + toolName: 'ExitPlanMode', + override: { permissionMode: 'bypassPermissionsClearReminder' }, + })).toBe(false); + }); + + test('does not enable native clear outside Claude Code ExitPlanMode', () => { + expect(shouldEnableNativeClearBeforeApprove({ + origin: 'claude-code', + permissionMode: 'bypassPermissionsClearReminder', + toolName: 'OtherTool', + })).toBe(false); + + expect(shouldEnableNativeClearBeforeApprove({ + origin: 'opencode', + permissionMode: 'bypassPermissionsClearReminder', + toolName: 'ExitPlanMode', + override: { deferToNativeForClear: true }, + })).toBe(false); + + expect(shouldEnableNativeClearBeforeApprove({ + origin: 'gemini-cli', + permissionMode: 'bypassPermissionsClearReminder', + toolName: 'ExitPlanMode', + override: { deferToNativeForClear: true }, + })).toBe(false); + }); +}); describe('buildApprovalRequestBody', () => { - test('maps bypass clear reminder mode to native Claude Code clear on ExitPlanMode', () => { + test('maps saved bypass clear reminder mode to hook approval with clear reminder on Claude Code ExitPlanMode', () => { expect(buildApprovalRequestBody({ origin: 'claude-code', permissionMode: 'bypassPermissionsClearReminder', @@ -10,7 +58,7 @@ describe('buildApprovalRequestBody', () => { planSaveSettings: { enabled: true }, })).toEqual({ permissionMode: 'bypassPermissions', - deferToNativeForClear: true, + clearContextNudge: true, planSave: { enabled: true }, }); }); @@ -60,7 +108,7 @@ describe('buildApprovalRequestBody', () => { }); }); - test('uses native clear for bypass clear reminder override when ExitPlanMode is known', () => { + test('uses reminder fallback for bypass clear reminder override when ExitPlanMode is known', () => { expect(buildApprovalRequestBody({ origin: 'claude-code', permissionMode: 'acceptEdits', @@ -71,7 +119,7 @@ describe('buildApprovalRequestBody', () => { planSaveSettings: { enabled: true }, })).toEqual({ permissionMode: 'bypassPermissions', - deferToNativeForClear: true, + clearContextNudge: true, planSave: { enabled: true }, }); }); @@ -93,6 +141,7 @@ describe('buildApprovalRequestBody', () => { origin: 'opencode', permissionMode: 'bypassPermissionsClearReminder', effectiveAgent: 'build', + toolName: 'ExitPlanMode', planSaveSettings: { enabled: true }, })).toEqual({ agentSwitch: 'build', @@ -100,10 +149,11 @@ describe('buildApprovalRequestBody', () => { }); }); - test('forwards deferToNativeForClear for Claude Code bypass approvals', () => { + test('forwards deferToNativeForClear for explicit Claude Code ExitPlanMode bypass approvals', () => { expect(buildApprovalRequestBody({ origin: 'claude-code', permissionMode: 'acceptEdits', + toolName: 'ExitPlanMode', override: { permissionMode: 'bypassPermissions', deferToNativeForClear: true, @@ -116,11 +166,28 @@ describe('buildApprovalRequestBody', () => { }); }); - test('does not forward deferToNativeForClear for OpenCode approvals', () => { + test('does not forward deferToNativeForClear without ExitPlanMode', () => { expect(buildApprovalRequestBody({ - origin: 'opencode', + origin: 'claude-code', permissionMode: 'acceptEdits', + toolName: 'OtherTool', + override: { + permissionMode: 'bypassPermissions', + deferToNativeForClear: true, + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + planSave: { enabled: true }, + }); + }); + + test('does not forward deferToNativeForClear or native clear for OpenCode approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'opencode', + permissionMode: 'bypassPermissionsClearReminder', effectiveAgent: 'build', + toolName: 'ExitPlanMode', override: { deferToNativeForClear: true, }, @@ -131,10 +198,11 @@ describe('buildApprovalRequestBody', () => { }); }); - test('does not forward deferToNativeForClear for Gemini origin', () => { + test('does not forward deferToNativeForClear or native clear for Gemini origin', () => { expect(buildApprovalRequestBody({ origin: 'gemini-cli', - permissionMode: 'acceptEdits', + permissionMode: 'bypassPermissionsClearReminder', + toolName: 'ExitPlanMode', override: { deferToNativeForClear: true, }, diff --git a/packages/editor/approvalBody.ts b/packages/editor/approvalBody.ts index a5e1ba7b4..ed4737930 100644 --- a/packages/editor/approvalBody.ts +++ b/packages/editor/approvalBody.ts @@ -19,6 +19,19 @@ export interface ApprovalRequestBody { deferToNativeForClear?: boolean; } +export function shouldEnableNativeClearBeforeApprove(options: { + origin: Origin | null; + permissionMode: PermissionMode; + toolName?: string; + override?: ApprovalOverride; +}): boolean { + return ( + options.origin === 'claude-code' && + options.toolName === 'ExitPlanMode' && + options.override?.deferToNativeForClear === true + ); +} + export function buildApprovalRequestBody(options: { origin: Origin | null; permissionMode: PermissionMode; @@ -33,7 +46,7 @@ export function buildApprovalRequestBody(options: { if (origin === 'claude-code') { const effectivePermissionMode = override.permissionMode ?? permissionMode; const wantsClearContext = effectivePermissionMode === 'bypassPermissionsClearReminder'; - const useNativeClear = override.deferToNativeForClear || (wantsClearContext && toolName === 'ExitPlanMode'); + const useNativeClear = shouldEnableNativeClearBeforeApprove({ origin, permissionMode, toolName, override }); body.permissionMode = wantsClearContext ? 'bypassPermissions' : effectivePermissionMode; diff --git a/packages/ui/utils/permissionMode.ts b/packages/ui/utils/permissionMode.ts index ed72dc4f1..f35711b9f 100644 --- a/packages/ui/utils/permissionMode.ts +++ b/packages/ui/utils/permissionMode.ts @@ -6,7 +6,7 @@ * * Available modes: * - bypassPermissions: Auto-approve all tool calls - * - bypassPermissionsClearReminder: Persisted UI mode that uses native clear-on-accept for Claude Code plan approvals and a /clear reminder fallback otherwise + * - bypassPermissionsClearReminder: Persisted UI mode that requests bypassPermissions and emits a /clear reminder * - acceptEdits: Auto-approve file edits only * - default: Manually approve each tool call */ @@ -37,7 +37,7 @@ export const PERMISSION_MODE_OPTIONS: { value: PermissionMode; label: string; de { value: 'bypassPermissionsClearReminder', label: 'Bypass + Clear Context', - description: 'For Claude Code plan approvals, defer to the native clear-context flow and bypass permissions; otherwise emit a /clear reminder.', + description: 'Approve with bypass permissions and show a /clear reminder. Use the explicit native option when available for Claude Code’s own clear-context prompt.', }, { value: 'default',