From 47e10edcaf948351f72a56508ced54b0fb239dcd Mon Sep 17 00:00:00 2001 From: utpal singh Date: Tue, 27 Oct 2026 07:46:48 +0530 Subject: [PATCH 1/6] fix: close uninstall host recovery gaps --- packages/server/inbox-agents.ts | 9 +- packages/server/uninstall-hooks.ts | 185 ++++++++ packages/server/uninstall-host-references.ts | 123 +++++ packages/server/uninstall-inbox-references.ts | 45 ++ packages/server/uninstall.test.ts | 419 ++++++++++++++++++ packages/server/uninstall.ts | 315 ++++++------- 6 files changed, 914 insertions(+), 182 deletions(-) create mode 100644 packages/server/uninstall-hooks.ts create mode 100644 packages/server/uninstall-host-references.ts create mode 100644 packages/server/uninstall-inbox-references.ts diff --git a/packages/server/inbox-agents.ts b/packages/server/inbox-agents.ts index 0a4359a4c1..bae3af8c0d 100644 --- a/packages/server/inbox-agents.ts +++ b/packages/server/inbox-agents.ts @@ -1386,10 +1386,13 @@ export interface InboxAgentReachState { * `~/.claude.json`. Read only, never written: Claude Code is connected through * its plugin, and this only says that one added by hand already reaches the Inbox. */ -function claudeCodeMcpEntry(e: AgentEnvironment, ours: readonly (readonly string[])[]): InboxEntryRead { - const dir = envDir(e, "CLAUDE_CONFIG_DIR"); +export function claudeCodeMcpPath(e: AgentEnvironment): string { + return join(envDir(e, "CLAUDE_CONFIG_DIR") ?? e.homeDir, ".claude.json"); +} + +export function claudeCodeMcpEntry(e: AgentEnvironment, ours: readonly (readonly string[])[]): InboxEntryRead { // .claude.json holds Claude Code's per-project history and often passes 1 MiB; this read only looks. - return classify(rawJsonEntry(join(dir ?? e.homeDir, ".claude.json"), ["mcpServers"], false, CLAUDE_JSON_MAX_BYTES), ours, true); + return classify(rawJsonEntry(claudeCodeMcpPath(e), ["mcpServers"], false, CLAUDE_JSON_MAX_BYTES), ours, true); } /** The most `.claude.json` the read-only MCP lookup reads. */ diff --git a/packages/server/uninstall-hooks.ts b/packages/server/uninstall-hooks.ts new file mode 100644 index 0000000000..746aee7c9d --- /dev/null +++ b/packages/server/uninstall-hooks.ts @@ -0,0 +1,185 @@ +import { basename, isAbsolute } from "node:path"; +import { simpleShellCommandWords } from "@plannotator/shared/plannotator-tool"; +import { parseT3HookCommand } from "@plannotator/shared/t3-hook-command"; + +type HookSuffix = "" | "improve-context" | "t3-hook"; +export type ManagedHookSpec = { readonly event: string; readonly matcher?: string; readonly suffix: HookSuffix }; + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : null; +} + +function sameCommand(left: string, right: string, platform: NodeJS.Platform): boolean { + const normalizedLeft = left.replace(/\\/g, "/"); + const normalizedRight = right.replace(/\\/g, "/"); + return platform === "win32" + ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() + : normalizedLeft === normalizedRight; +} + +/** Read a literal executable, never shell wrappers or commands that merely mention it. */ +function commandParts(command: string) { + const match = /^(?:"([^"$`]+)"|'([^']+)'|([^\s"';&|<>()$`*?\[{}]+))(?=\s|[;&|<>()]|$)([\s\S]*)$/.exec(command.trim()); + if (!match) return null; + const executable = match[1] ?? match[2] ?? match[3]; + if (!executable) return null; + const tail = match[4] ?? ""; + // Trimming the tail must not turn a shell command separator into whitespace. + return { executable, args: /[\r\n]/.test(tail) ? null : simpleShellCommandWords(tail) }; +} + +function isPlannotatorExecutable( + executable: string, + binaryPaths: readonly string[], + platform: NodeJS.Platform, + allowRelocatedBinary: boolean, +): boolean { + if (executable === "plannotator") return true; + if (platform === "win32" && /^(?:plannotator|plannotator\.exe)$/i.test(executable)) return true; + if (binaryPaths.some((path) => sameCommand(executable, path, platform))) return true; + if (!allowRelocatedBinary || !isAbsolute(executable)) return false; + const name = basename(executable).toLowerCase(); + return name === "plannotator" || name === "plannotator.exe"; +} + +/** Only supported hook arguments are safe to remove automatically. */ +export function isManagedHook( + value: unknown, + binaryPaths: readonly string[], + suffix: HookSuffix, + platform: NodeJS.Platform, + allowRelocatedBinary = false, +): boolean { + const hook = asRecord(value); + if (!hook || hook.type !== "command" || typeof hook.command !== "string") return false; + if (suffix === "t3-hook") { + const parsed = parseT3HookCommand(hook.command); + return parsed !== undefined && isPlannotatorExecutable(parsed.executable, binaryPaths, platform, false); + } + const parsed = commandParts(hook.command); + if (!parsed || !parsed.args || !isPlannotatorExecutable(parsed.executable, binaryPaths, platform, allowRelocatedBinary)) return false; + + const remaining: string[] = []; + let browserSeen = false; + for (let index = 0; index < parsed.args.length; index++) { + const arg = parsed.args[index]; + if (arg !== "--browser") { + if (arg !== undefined) remaining.push(arg); + continue; + } + const browser = parsed.args[++index]; + if (browserSeen || !browser || browser.startsWith("-")) return false; + browserSeen = true; + } + return suffix === "" + ? remaining.length === 0 + : remaining.length === 1 && remaining[0] === suffix; +} + +/** Split only outside quotes/comments. These segments are inspected, never executed or auto-removed. */ +function commandSegments(command: string): string[] { + const segments: string[] = []; + let start = 0; + let quote: "'" | '"' | null = null; + let comment = false; + for (let index = 0; index < command.length; index++) { + const char = command[index]; + if (comment) { + if (char !== "\n" && char !== "\r") continue; + comment = false; + start = index + 1; + continue; + } + if (char === "\\" && quote !== "'") { + index++; + continue; + } + if (quote) { + if (char === quote) quote = null; + continue; + } + if (char === "'" || char === '"') { + quote = char; + continue; + } + if (char === "#" && (index === start || /\s/.test(command[index - 1] ?? ""))) { + segments.push(command.slice(start, index)); + comment = true; + continue; + } + if (char && ";&|()\r\n".includes(char)) { + segments.push(command.slice(start, index)); + start = index + 1; + } + } + if (!comment) segments.push(command.slice(start)); + return segments; +} + +/** Skip literal shell assignments, keeping quoted values intact. */ +function withoutAssignments(segment: string): string { + let rest = segment.trimStart(); + while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(rest)) { + let quote: "'" | '"' | null = null; + let index = 0; + for (; index < rest.length; index++) { + const char = rest[index]; + if (char === "\\" && quote !== "'") { index++; continue; } + if (quote) { if (char === quote) quote = null; continue; } + if (char === "'" || char === '"') { quote = char; continue; } + if (char && /\s/.test(char)) break; + } + rest = rest.slice(index).trimStart(); + } + return rest; +} + +/** Find surviving invocations in any event/matcher, including commands we must not edit. */ +export function remainingHookReferences( + config: unknown, + binaryPaths: readonly string[], + platform: NodeJS.Platform, + allowRelocatedBinary: boolean, + projectedRemovals: readonly ManagedHookSpec[] = [], + removedVibeIndices: ReadonlySet = new Set(), +): string[] { + const root = asRecord(config); + const invokesPlannotator = (command: unknown): boolean => { + if (typeof command !== "string") return false; + const gated = parseT3HookCommand(command)?.executable; + if (gated && isPlannotatorExecutable(gated, binaryPaths, platform, allowRelocatedBinary)) return true; + return commandSegments(command).some((segment) => { + const executable = commandParts(withoutAssignments(segment))?.executable; + return executable !== undefined && isPlannotatorExecutable(executable, binaryPaths, platform, allowRelocatedBinary); + }); + }; + // Vibe stores commands directly in [[hooks]], rather than nested event entries. + if (Array.isArray(root?.hooks)) { + return root.hooks.flatMap((value: unknown, index) => + !removedVibeIndices.has(index) && invokesPlannotator(asRecord(value)?.command) ? [`hooks[${index}].command`] : [], + ); + } + const hooks = root ? asRecord(root.hooks) : null; + if (!hooks) return []; + const references: string[] = []; + for (const [event, entries] of Object.entries(hooks)) { + if (!Array.isArray(entries)) continue; + entries.forEach((value: unknown, entryIndex) => { + const entry = asRecord(value); + if (!entry || !Array.isArray(entry.hooks)) return; + entry.hooks.forEach((value: unknown, hookIndex: number) => { + const hook = asRecord(value); + if (!hook || hook.type !== "command" || typeof hook.command !== "string") return; + const willRemove = projectedRemovals.some((spec) => spec.event === event + && (spec.matcher === undefined || spec.matcher === entry.matcher) + && isManagedHook(hook, binaryPaths, spec.suffix, platform, allowRelocatedBinary)); + if (!willRemove && invokesPlannotator(hook.command)) { + references.push(`hooks.${event}[${entryIndex}].hooks[${hookIndex}].command`); + } + }); + }); + } + return references; +} diff --git a/packages/server/uninstall-host-references.ts b/packages/server/uninstall-host-references.ts new file mode 100644 index 0000000000..02fa02fa74 --- /dev/null +++ b/packages/server/uninstall-host-references.ts @@ -0,0 +1,123 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +export type HostReferences = { references: string[]; errors: string[] }; + +type JsonRead = + | { kind: "missing" } + | { kind: "value"; value: unknown } + | { kind: "error"; message: string }; + +function record(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : null; +} + +function missing(error: unknown): boolean { + return error instanceof Error && "code" in error && error.code === "ENOENT"; +} + +function readJson(file: string, field: string): JsonRead { + let text: string; + try { + text = readFileSync(file, "utf8"); + } catch (error) { + if (missing(error)) return { kind: "missing" }; + return { kind: "error", message: `Could not inspect ${file}, ${field}; make the file readable before retrying.` }; + } + try { + return { kind: "value", value: JSON.parse(text) }; + } catch { + return { kind: "error", message: `Could not inspect ${file}, ${field}; repair its invalid JSON before retrying.` }; + } +} + +function objectFile(file: string, field: string): HostReferences & { value: Record | null } { + const read = readJson(file, field); + if (read.kind === "missing") return { references: [], errors: [], value: null }; + if (read.kind === "error") return { references: [], errors: [read.message], value: null }; + const value = record(read.value); + return { + references: [], + errors: value ? [] : [`Could not inspect ${file}, ${field}; expected a JSON object. Repair it before retrying.`], + value, + }; +} + +export function enabledPluginReferences(file: string, id: string): HostReferences { + const field = `enabledPlugins[${JSON.stringify(id)}]`; + const read = objectFile(file, field); + const enabled = record(read.value?.enabledPlugins); + return { references: enabled?.[id] === true ? [`${file}, ${field}`] : [], errors: read.errors }; +} + +export function pluginReferences(directory: string, id: string): HostReferences { + const enabled = enabledPluginReferences(join(directory, "settings.json"), id); + const file = join(directory, "plugins", "installed_plugins.json"); + const field = `plugins[${JSON.stringify(id)}]`; + const installed = objectFile(file, field); + const entries = record(installed.value?.plugins)?.[id]; + return { + references: [...enabled.references, ...(Array.isArray(entries) && entries.length ? [`${file}, ${field}`] : [])], + errors: [...enabled.errors, ...installed.errors], + }; +} + +export function piPackageReferences(file: string): HostReferences { + const read = objectFile(file, "packages"); + const packages = read.value?.packages; + return { + errors: read.errors, + references: Array.isArray(packages) ? packages.flatMap((entry: unknown, index) => { + const source = typeof entry === "string" ? entry : record(entry)?.source; + return typeof source === "string" && /^npm:@plannotator\/pi-extension(?:@|$)/.test(source) + ? [`${file}, packages[${index}]${typeof entry === "string" ? "" : ".source"}`] + : []; + }) : [], + }; +} + +export function directoryReferences(directory: string, prefix: string): HostReferences { + try { + return { + references: readdirSync(directory).filter((entry) => entry.startsWith(prefix)).map((entry) => join(directory, entry)), + errors: [], + }; + } catch (error) { + return { + references: [], + errors: missing(error) ? [] : [`Could not inspect ${directory}; make the directory readable before retrying.`], + }; + } +} + +/** VS Code can unregister an extension while leaving its files for later cleanup. */ +export function vscodeReferences(directory: string): HostReferences { + const id = "backnotprop.plannotator-webview"; + const file = join(directory, "extensions.json"); + const registry = readJson(file, "extension registrations"); + if (registry.kind === "error") return { references: [], errors: [registry.message] }; + if (registry.kind === "value") { + if (!Array.isArray(registry.value)) { + return { references: [], errors: [`Could not inspect ${file}; expected an extension registration array. Repair it before retrying.`] }; + } + return { + references: registry.value.flatMap((entry: unknown, index) => { + const identifier = record(record(entry)?.identifier)?.id; + return typeof identifier === "string" && identifier.toLowerCase() === id + ? [`${file}, [${index}].identifier.id (${id})`] + : []; + }), + errors: [], + }; + } + // Older installations may not have a registration index. Honor their removal markers. + const directories = directoryReferences(directory, `${id}-`); + if (directories.references.length === 0) return directories; + const obsolete = objectFile(join(directory, ".obsolete"), "removed extensions"); + return { + references: directories.references.filter((path) => obsolete.value?.[path.slice(directory.length + 1)] !== true), + errors: [...directories.errors, ...obsolete.errors], + }; +} diff --git a/packages/server/uninstall-inbox-references.ts b/packages/server/uninstall-inbox-references.ts new file mode 100644 index 0000000000..21cb91a8cf --- /dev/null +++ b/packages/server/uninstall-inbox-references.ts @@ -0,0 +1,45 @@ +import type { InboxAgentId } from "@plannotator/core/inbox-types"; +import { + AGENT_SPECS, agentHomeAllowed, claudeCodeMcpEntry, claudeCodeMcpPath, + entryArgv, isPlannotatorInboxCommand, type AgentEnvironment, +} from "./inbox-agents"; + +/** Inspect executable references independently of who owns the MCP entry. */ +export function remainingInboxReferences( + environment: AgentEnvironment, + binaryPaths: readonly string[], + projectedRemovals: ReadonlySet = new Set(), +): string[] { + if (!agentHomeAllowed(environment)) return []; + const normalize = (path: string) => { + const normalized = path.replace(/\\/g, "/"); + return environment.platform === "win32" ? normalized.toLowerCase() : normalized; + }; + const executables = new Set([...binaryPaths, "plannotator", ...(environment.platform === "win32" ? ["plannotator.exe"] : [])].map(normalize)); + const references: string[] = []; + for (const spec of AGENT_SPECS) { + if (projectedRemovals.has(spec.id)) continue; + const path = spec.id === "claude-code" ? claudeCodeMcpPath(environment) : spec.file(environment); + if (!path) continue; + let container = "mcpServers"; + if (spec.id === "codex") container = "mcp_servers"; + if (spec.id === "amp") container = '["amp.mcpServers"]'; + const field = `${container}["plannotator-inbox"].command`; + const raw = spec.raw(environment); + const claude = spec.id === "claude-code" ? claudeCodeMcpEntry(environment, []) : null; + const read = claude ?? raw; + if (!read || read.kind === "absent") continue; + if (read.kind === "unreadable") { + references.push(`Could not inspect ${path}, ${field}: ${read.reason}. Repair the file and remove only a Plannotator entry if present`); + continue; + } + const argv = read.kind === "value" ? entryArgv(read.value) : read.argv; + const executable = argv?.[0]; + // Source execution uses Bun/Node; the interpreter alone is not Plannotator. + const sourceCommand = argv?.length === 4 && isPlannotatorInboxCommand(argv); + if (executable && (executables.has(normalize(executable)) || sourceCommand)) { + references.push(`${path}, ${field} still invokes Plannotator. Remove only that entry; preserve unrelated MCP servers`); + } + } + return references; +} diff --git a/packages/server/uninstall.test.ts b/packages/server/uninstall.test.ts index c46ee29df4..1902bf3079 100644 --- a/packages/server/uninstall.test.ts +++ b/packages/server/uninstall.test.ts @@ -1594,6 +1594,184 @@ describe("purge uninstall", () => { }); describe("host and platform integrations", () => { + test.each([ + [".claude", "settings.json", "PermissionRequest", "ExitPlanMode"], + [".codex", "hooks.json", "Stop", ""], + [".gemini", "settings.json", "BeforeTool", "exit_plan_mode"], + ])("cleans customized %s hooks in dry-run and real uninstall", async (directory, file, event, matcher) => { + const fixture = createFixture(); + const binary = join(fixture.homeDir, ".local", "bin", "plannotator"); + const settings = join(fixture.homeDir, directory, file); + const custom = { type: "command", command: "my-custom-hook" }; + writeText(binary); + writeJson(settings, { hooks: { [event]: [{ matcher, hooks: [ + { type: "command", command: "plannotator --browser Safari" }, custom, + ] }] } }); + const before = snapshotTree(fixture.homeDir); + const dryRun = await runPlannotatorUninstall( + { purge: false, dryRun: true }, fixture.environment, + ); + expect(dryRun.ok).toBe(true); + expect(dryRun.planned.join("\n")).toContain(settings); + expect(snapshotTree(fixture.homeDir)).toEqual(before); + + const result = await runPlannotatorUninstall( + { purge: false, dryRun: false }, fixture.environment, + ); + expect(result.ok).toBe(true); + expect(existsSync(binary)).toBe(false); + expect(readJson(settings)).toEqual({ hooks: { [event]: [{ matcher, hooks: [custom] }] } }); + }); + + test("cleans browser overrides on improve-context and Windows executable paths", async () => { + const fixture = createFixture({ + platform: "win32", + which: (command) => command === "powershell.exe" ? "C:\\Windows\\powershell.exe" : null, + }); + const binary = join(fixture.homeDir, "AppData", "Local", "plannotator", "plannotator.exe"); + const settings = join(fixture.homeDir, ".claude", "settings.json"); + writeText(binary); + writeJson(settings, { theme: "dark", hooks: { PreToolUse: [{ + matcher: "EnterPlanMode", hooks: [ + { type: "command", command: `"${binary.replaceAll("/", "\\")}" improve-context --browser "Google Chrome"` }, + { type: "command", command: "plannotator --browser Safari improve-context" }, + ], + }] } }); + const result = await runPlannotatorUninstall( + { purge: false, dryRun: false }, fixture.environment, + ); + expect(result.ok).toBe(true); + expect(readJson(settings)).toEqual({ theme: "dark" }); + expect(existsSync(binary)).toBe(false); + }); + + test.each([ + "plannotator --custom-option", + "plannotator --browser Safari && my-custom-hook", + "plannotator&&my-custom-hook", + "plannotator review", + ])("preserves and blocks on unsupported or composite hook %s", async (command) => { + const fixture = createFixture(); + const binary = join(fixture.homeDir, ".local", "bin", "plannotator"); + const settings = join(fixture.homeDir, ".claude", "settings.json"); + writeText(binary); + const config = { hooks: { PermissionRequest: [{ matcher: "ExitPlanMode", hooks: [ + { type: "command", command }, + ] }] } }; + writeJson(settings, config); + const result = await runPlannotatorUninstall( + { purge: false, dryRun: false }, fixture.environment, + ); + expect(result.ok).toBe(false); + expect(existsSync(binary)).toBe(true); + expect(readJson(settings)).toEqual(config); + expect(result.errors.join("\n")).toContain("hooks.PermissionRequest[0].hooks[0].command"); + }); + + test("blocks on a Vibe command outside the managed block without deleting custom hooks", async () => { + const fixture = createFixture(); + const binary = join(fixture.homeDir, ".local", "bin", "plannotator"); + const settings = join(fixture.homeDir, ".vibe", "hooks.toml"); + const content = '[[hooks]]\nname = "custom-plan"\ncommand = "plannotator --browser Safari"\n'; + writeText(binary); + writeText(settings, content); + const result = await runPlannotatorUninstall( + { purge: false, dryRun: false }, fixture.environment, + ); + expect(result.ok).toBe(false); + expect(existsSync(binary)).toBe(true); + expect(readFileSync(settings, "utf8")).toBe(content); + expect(result.errors.join("\n")).toContain(settings); + expect(result.errors.join("\n")).toContain("hooks[0].command"); + }); + + test("does not trust a host manager's success while its configuration survives", async () => { + const fixture = createFixture({ which: () => "/fake/droid" }); + const binary = join(fixture.homeDir, ".local", "bin", "plannotator"); + const settings = join(fixture.homeDir, ".factory", "settings.json"); + writeText(binary); + writeJson(settings, { enabledPlugins: { "plannotator@plannotator": true, "user-plugin": true } }); + const blocked = await runPlannotatorUninstall( + { purge: false, dryRun: false }, fixture.environment, + ); + expect(blocked.ok).toBe(false); + expect(existsSync(binary)).toBe(true); + expect(blocked.errors.join("\n")).toContain('enabledPlugins["plannotator@plannotator"]'); + expect(blocked.errors.join("\n")).toContain(settings); + const retry = await runPlannotatorUninstall( + { purge: false, dryRun: false }, { + ...fixture.environment, + runCommand: async () => { + writeJson(settings, { enabledPlugins: { "user-plugin": true } }); + return { exitCode: 0, timedOut: false }; + }, + }, + ); + expect(retry.ok).toBe(true); + expect(existsSync(binary)).toBe(false); + expect(readJson(settings)).toEqual({ enabledPlugins: { "user-plugin": true } }); + }); + + test("removes browser-customized hooks but preserves unrelated commands", async () => { + const fixture = createFixture(); + const binary = join(fixture.homeDir, ".local", "bin", "plannotator"); + const settings = join(fixture.homeDir, ".claude", "settings.json"); + const custom = [ + { type: "command", command: "my-custom-hook" }, + { type: "command", command: "echo plannotator --browser Safari" }, + { type: "command", command: "plannotator-helper --browser Safari" }, + ]; + writeText(binary); + writeJson(settings, { theme: "dark", hooks: { PermissionRequest: [{ + matcher: "ExitPlanMode", hooks: [ + { type: "command", command: "plannotator --browser Safari" }, + { type: "command", command: `"${binary}" --browser "Google Chrome"` }, + ...custom, + ], + }] } }); + + const result = await runPlannotatorUninstall( + { purge: false, dryRun: false }, fixture.environment, + ); + + expect(result.ok).toBe(true); + expect(existsSync(binary)).toBe(false); + expect(readJson(settings)).toEqual({ theme: "dark", hooks: { + PermissionRequest: [{ matcher: "ExitPlanMode", hooks: custom }], + } }); + }); + + test("blocks binary removal for a surviving recognized command until manual cleanup", async () => { + const fixture = createFixture(); + const binary = join(fixture.homeDir, ".local", "bin", "plannotator"); + const settings = join(fixture.homeDir, ".claude", "settings.json"); + const hooks = [{ matcher: "custom-tool", hooks: [ + { type: "command", command: "plannotator --browser Safari" }, + { type: "command", command: "my-custom-hook" }, + ] }]; + writeText(binary); + writeJson(settings, { hooks: { PreToolUse: hooks } }); + + const blocked = await runPlannotatorUninstall( + { purge: false, dryRun: false }, fixture.environment, + ); + expect(blocked.ok).toBe(false); + expect(existsSync(binary)).toBe(true); + expect(readJson(settings)).toEqual({ hooks: { PreToolUse: hooks } }); + expect(blocked.errors.join("\n")).toContain(settings); + expect(blocked.errors.join("\n")).toContain("hooks.PreToolUse[0].hooks[0].command"); + expect(blocked.errors.join("\n")).toContain("plannotator uninstall"); + + writeJson(settings, { hooks: { PreToolUse: [{ matcher: "custom-tool", hooks: [ + { type: "command", command: "my-custom-hook" }, + ] }] } }); + const retry = await runPlannotatorUninstall( + { purge: false, dryRun: false }, fixture.environment, + ); + expect(retry.ok).toBe(true); + expect(existsSync(binary)).toBe(false); + }); + test("keeps the binary and reports the exact command when a host is unavailable", async () => { const fixture = createFixture(); const binary = join( @@ -1623,6 +1801,8 @@ describe("host and platform integrations", () => { expect(result.errors[0]).toContain( "After it succeeds, rerun `plannotator uninstall`.", ); + expect(result.errors[0]).toContain(join(fixture.homeDir, ".factory", "settings.json")); + expect(result.errors[0]).toContain('enabledPlugins["plannotator@plannotator"]'); expect(existsSync(binary)).toBe(true); expect(result.warnings).toContain( "Preserved the Plannotator CLI and its Windows PATH entry so you can resolve the errors and retry uninstall.", @@ -2261,3 +2441,242 @@ describe("the Inbox's agent connections (inbox-agents.ts)", () => { expect(readJson(cursorFile(fixture)).mcpServers).toEqual({ "plannotator-inbox": { command: "theirs" } }); }); }); + + +describe("uninstall review regressions", () => { + test.each([ + "plannotator\nimprove-context", + "plannotator\rimprove-context", + "PLANNOTATOR_BROWSER=Safari plannotator", + "PLANNOTATOR_BROWSER=\"Google Chrome\" plannotator", + "echo preflight && plannotator --browser Safari", + "echo preflight;plannotator", + ])("preserves composite hook and blocks removal: %s", async (command) => { + const fixture = createFixture(); + const binary = join(fixture.homeDir, ".local", "bin", "plannotator"); + const file = join(fixture.homeDir, ".claude", "settings.json"); + writeText(binary); + const config = { hooks: { PreToolUse: [{ matcher: "EnterPlanMode", hooks: [{ type: "command", command }] }] } }; + writeJson(file, config); + for (const dryRun of [true, false]) { + const result = await runPlannotatorUninstall({ purge: false, dryRun }, fixture.environment); + expect(result.ok).toBe(false); + expect(existsSync(binary)).toBe(true); + expect(readJson(file)).toEqual(config); + expect(result.errors.join("\n")).toContain(file); + expect(result.errors.join("\n")).toContain("hooks.PreToolUse[0].hooks[0].command"); + expect(result.planned).not.toContain(binary); + } + }); + + test.each([ + "echo plannotator", + "echo 'preflight && plannotator'", + 'echo "preflight; plannotator"', + "echo preflight # plannotator; plannotator", + ])("does not claim a command that only mentions Plannotator: %s", async (command) => { + const fixture = createFixture(); + const binary = join(fixture.homeDir, ".local", "bin", "plannotator"); + const file = join(fixture.homeDir, ".claude", "settings.json"); + writeText(binary); + const config = { hooks: { PermissionRequest: [{ matcher: "ExitPlanMode", hooks: [{ type: "command", command }] }] } }; + writeJson(file, config); + const result = await runPlannotatorUninstall({ purge: false, dryRun: false }, fixture.environment); + expect(result.ok).toBe(true); + expect(existsSync(binary)).toBe(false); + expect(readJson(file)).toEqual(config); + }); + + test("rejects a successful host cleanup whose formerly detected config is now malformed", async () => { + const fixture = createFixture({ which: () => "/fake/droid" }); + const binary = join(fixture.homeDir, ".local", "bin", "plannotator"); + const file = join(fixture.homeDir, ".factory", "settings.json"); + writeText(binary); + writeJson(file, { enabledPlugins: { "plannotator@plannotator": true } }); + const result = await runPlannotatorUninstall({ purge: false, dryRun: false }, { + ...fixture.environment, + runCommand: async () => { + writeText(file, "{broken"); + return { exitCode: 0, timedOut: false }; + }, + }); + expect(result.ok).toBe(false); + expect(existsSync(binary)).toBe(true); + expect(result.errors.join("\n")).toContain(file); + expect(result.errors.join("\n")).toContain("enabledPlugins"); + expect(readFileSync(file, "utf8")).toBe("{broken"); + }); + + test("accepts VS Code removal when registration is gone but directory remains", async () => { + const fixture = createFixture({ which: (name) => name === "code" ? "/fake/code" : null }); + const binary = join(fixture.homeDir, ".local", "bin", "plannotator"); + const directory = join(fixture.homeDir, ".vscode", "extensions", "backnotprop.plannotator-webview-0.1.0"); + const registry = join(fixture.homeDir, ".vscode", "extensions", "extensions.json"); + writeText(binary); + writeText(join(directory, "package.json")); + writeJson(registry, [{ identifier: { id: "backnotprop.plannotator-webview" }, version: "0.1.0" }]); + const result = await runPlannotatorUninstall({ purge: false, dryRun: false }, { + ...fixture.environment, + runCommand: async () => { + writeJson(registry, []); + return { exitCode: 0, timedOut: false }; + }, + }); + expect(result.ok).toBe(true); + expect(existsSync(binary)).toBe(false); + expect(existsSync(directory)).toBe(true); + const remainingRegistry: unknown = JSON.parse(readFileSync(registry, "utf8")); + expect(remainingRegistry).toEqual([]); + }); + + test.each(["registry", "obsolete"])("does not invoke VS Code for unregistered leftovers: %s", async (kind) => { + const fixture = createFixture(); + const binary = join(fixture.homeDir, ".local", "bin", "plannotator"); + const parent = join(fixture.homeDir, ".vscode", "extensions"); + const name = "backnotprop.plannotator-webview-0.1.0"; + writeText(binary); + writeText(join(parent, name, "package.json")); + if (kind === "registry") writeJson(join(parent, "extensions.json"), []); + else writeJson(join(parent, ".obsolete"), { [name]: true }); + const result = await runPlannotatorUninstall({ purge: false, dryRun: false }, fixture.environment); + expect(result.ok).toBe(true); + expect(fixture.commandCalls).toEqual([]); + expect(existsSync(binary)).toBe(false); + expect(existsSync(join(parent, name))).toBe(true); + }); + + test.each(["plannotator review", "plannotator --custom-option"])("dry-run reports preserved hook blocker: %s", async (command) => { + const fixture = createFixture(); + const binary = join(fixture.homeDir, ".local", "bin", "plannotator"); + const file = join(fixture.homeDir, ".gemini", "settings.json"); + writeText(binary); + writeJson(file, { hooks: { BeforeTool: [{ matcher: "exit_plan_mode", hooks: [ + { type: "command", command: "plannotator --browser Safari" }, + { type: "command", command }, + ] }] } }); + const before = snapshotTree(fixture.homeDir); + const result = await runPlannotatorUninstall({ purge: false, dryRun: true }, fixture.environment); + expect(result.ok).toBe(false); + expect(result.errors.join("\n")).toContain(file); + expect(result.errors.join("\n")).toContain("hooks.BeforeTool[0].hooks[1].command"); + expect(result.planned).not.toContain(binary); + expect(snapshotTree(fixture.homeDir)).toEqual(before); + }); + + test.each([true, false])("unrecorded MCP reference blocks binary removal, dryRun=%s", async (dryRun) => { + const fixture = createFixture(); + const binary = join(fixture.homeDir, ".local", "bin", "plannotator"); + const file = join(fixture.homeDir, ".cursor", "mcp.json"); + const entry = { command: binary, args: ["inbox", "mcp"] }; + writeText(binary); + writeJson(file, { mcpServers: { "plannotator-inbox": entry, mine: { command: "user-server" } } }); + const result = await runPlannotatorUninstall({ purge: false, dryRun }, fixture.environment); + expect(result.ok).toBe(false); + expect(existsSync(binary)).toBe(true); + expect(result.errors.join("\n")).toContain(file); + expect(result.errors.join("\n")).toContain('mcpServers["plannotator-inbox"].command'); + expect(readJson(file)).toEqual({ mcpServers: { "plannotator-inbox": entry, mine: { command: "user-server" } } }); + writeJson(file, { mcpServers: { mine: { command: "user-server" } } }); + const retry = await runPlannotatorUninstall({ purge: false, dryRun: false }, fixture.environment); + expect(retry.ok).toBe(true); + expect(existsSync(binary)).toBe(false); + expect(readJson(file)).toEqual({ mcpServers: { mine: { command: "user-server" } } }); + }); +}); + + +describe("uninstall second-review regressions", () => { + test.each([false, true])("Claude user MCP entry blocks removal with config override=%s", async (override) => { + const fixture = createFixture(); + const binary = join(fixture.homeDir, ".local", "bin", "plannotator"); + const directory = override ? join(fixture.root, "claude-custom") : fixture.homeDir; + const file = join(directory, ".claude.json"); + const entry = { command: binary, args: ["inbox", "mcp"] }; + const config = { mcpServers: { "plannotator-inbox": entry }, projects: { mine: {} } }; + writeText(binary); writeJson(file, config); + const environment = { ...fixture.environment, env: override ? { CLAUDE_CONFIG_DIR: directory } : {} }; + for (const dryRun of [true, false]) { + const result = await runPlannotatorUninstall({ purge: false, dryRun }, environment); + expect(result.ok).toBe(false); + expect(existsSync(binary)).toBe(true); + expect(result.errors.join("\n")).toContain(file); + expect(result.errors.join("\n")).toContain('mcpServers["plannotator-inbox"].command'); + expect(readJson(file)).toEqual(config); + } + writeJson(file, { mcpServers: {}, projects: { mine: {} } }); + const retry = await runPlannotatorUninstall({ purge: false, dryRun: false }, environment); + expect(retry.ok).toBe(true); expect(existsSync(binary)).toBe(false); + }); + + test("source-run uninstall does not claim a user-owned Bun MCP entry", async () => { + const fixture = createFixture({ execPath: process.execPath }); + const binary = join(fixture.homeDir, ".local", "bin", "plannotator"); + const file = join(fixture.homeDir, ".cursor", "mcp.json"); + const config = { mcpServers: { "plannotator-inbox": { command: process.execPath, args: ["unrelated-server.ts"] } } }; + writeText(binary); writeJson(file, config); + const result = await runPlannotatorUninstall({ purge: false, dryRun: false }, fixture.environment); + expect(result.ok).toBe(true); expect(existsSync(binary)).toBe(false); + expect(readJson(file)).toEqual(config); + }); + + test("dry-run reports missing Droid CLI with exact recovery entries", async () => { + const fixture = createFixture(); + const binary = join(fixture.homeDir, ".local", "bin", "plannotator"); + const file = join(fixture.homeDir, ".factory", "settings.json"); + writeText(binary); writeJson(file, { enabledPlugins: { "plannotator@plannotator": true } }); + const before = snapshotTree(fixture.homeDir); + const result = await runPlannotatorUninstall({ purge: false, dryRun: true }, fixture.environment); + expect(result.ok).toBe(false); + expect(result.errors.join("\n")).toContain("restore droid on PATH"); + expect(result.errors.join("\n")).toContain(file); + expect(result.errors.join("\n")).toContain('enabledPlugins["plannotator@plannotator"]'); + expect(result.planned).not.toContain(binary); + expect(snapshotTree(fixture.homeDir)).toEqual(before); + }); + + test("dry-run reports original entry indices after other matcher entries are removed", async () => { + const fixture = createFixture(); + const file = join(fixture.homeDir, ".claude", "settings.json"); + const config = { hooks: { PreToolUse: [ + { matcher: "EnterPlanMode", hooks: [{ type: "command", command: "plannotator improve-context" }] }, + { matcher: "Bash", hooks: [{ type: "command", command: "plannotator review" }] }, + ] } }; + writeJson(file, config); + const result = await runPlannotatorUninstall({ purge: false, dryRun: true }, fixture.environment); + expect(result.ok).toBe(false); + expect(result.errors.join("\n")).toContain("hooks.PreToolUse[1].hooks[0].command"); + expect(readJson(file)).toEqual(config); + }); + + test("Vibe dry-run reports original index of a surviving hook beside the managed block", async () => { + const fixture = createFixture(); + const file = join(fixture.homeDir, ".vibe", "hooks.toml"); + const content = '# >>> plannotator-managed-vibe-hooks\n[[hooks]]\ncommand = "plannotator"\n# <<< plannotator-managed-vibe-hooks\n[[hooks]]\ncommand = "plannotator review"\n'; + writeText(file, content); + const result = await runPlannotatorUninstall({ purge: false, dryRun: true }, fixture.environment); + expect(result.ok).toBe(false); + expect(result.errors.join("\n")).toContain("hooks[1].command"); + expect(readFileSync(file, "utf8")).toBe(content); + }); + + test.skipIf(process.platform === "win32" || process.getuid?.() === 0)("inaccessible hook target blocks removal instead of appearing missing", async () => { + const fixture = createFixture(); + const binary = join(fixture.homeDir, ".local", "bin", "plannotator"); + const targetDirectory = join(fixture.root, "inaccessible"); + const target = join(targetDirectory, "hooks.toml"); + const file = join(fixture.homeDir, ".vibe", "hooks.toml"); + writeText(binary); writeText(target, '[[hooks]]\ncommand = "plannotator"\n'); + mkdirSync(dirname(file), { recursive: true }); symlinkSync(target, file); + chmodSync(targetDirectory, 0o000); + try { + expect(existsSync(file)).toBe(false); + for (const dryRun of [true, false]) { + const result = await runPlannotatorUninstall({ purge: false, dryRun }, fixture.environment); + expect(result.ok).toBe(false); + expect(existsSync(binary)).toBe(true); + expect(result.errors.join("\n")).toContain(file); + expect(result.planned).not.toContain(binary); + } + } finally { chmodSync(targetDirectory, 0o755); } + }); +}); diff --git a/packages/server/uninstall.ts b/packages/server/uninstall.ts index f8d881fe86..050ccc148a 100644 --- a/packages/server/uninstall.ts +++ b/packages/server/uninstall.ts @@ -19,7 +19,6 @@ import { } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { - basename, dirname, isAbsolute, join, @@ -29,7 +28,10 @@ import { resolve, } from "node:path"; import { getPlannotatorDataDir } from "@plannotator/shared/data-dir"; -import { parseT3HookCommand, T3_SETTINGS_BACKUP_SUFFIX } from "@plannotator/shared/t3-hook-command"; +import { T3_SETTINGS_BACKUP_SUFFIX } from "@plannotator/shared/t3-hook-command"; +import { isManagedHook, remainingHookReferences, type ManagedHookSpec } from "./uninstall-hooks"; +import { remainingInboxReferences } from "./uninstall-inbox-references"; +import { pluginReferences, enabledPluginReferences, piPackageReferences, directoryReferences, vscodeReferences } from "./uninstall-host-references"; import { runningT3Workers, stopT3Workers } from "@plannotator/shared/t3-workers"; import { inboxStatus, readInboxRegistry, stopInbox } from "@plannotator/shared/inbox/registry"; import { INBOX_TAILNET_HTTPS_PORT, takeDownInboxTailnet } from "./inbox-devices"; @@ -305,11 +307,7 @@ type MutableUninstallResult = { errors: string[]; }; -type HookCleanupSpec = { - readonly event: string; - readonly matcher?: string; - readonly suffix: "" | "improve-context" | "t3-hook"; -}; +type HookCleanupSpec = ManagedHookSpec; type HookCleanupPolicy = { readonly allowRelocatedBinary: boolean; @@ -464,7 +462,7 @@ export async function runPlannotatorUninstall( removeHostConfigEntries(request, environment, paths, state); // Plain uninstall too: with the binary gone, an agent's plannotator-inbox // entry would fail to start at every launch. - await removeInboxAgentConnections(request, environment, state); + await removeInboxAgentConnections(request, environment, paths.binaryPaths, state); removeInstalledFiles(request, environment, paths, state); if (dataDirSafetyIssue) { state.warnings.push( @@ -490,6 +488,37 @@ export async function runPlannotatorUninstall( } } + if (!request.dryRun && state.errors.length === 0) { + for (const [filePath, allowRelocatedBinary] of [ + [join(paths.claudeDir, "settings.json"), false], + [join(paths.codexDir, "hooks.json"), true], + [join(environment.homeDir, ".gemini", "settings.json"), false], + [join(paths.vibeDir, "hooks.toml"), false], + ] as const) { + let config: unknown; + if (filePath.endsWith(".toml")) { + try { + config = Bun.TOML.parse(readFileSync(filePath, "utf8")); + } catch (error) { + if (isMissingPathError(error)) continue; + reportHostCleanupFailure( + `Could not inspect ${filePath}`, formatError(error), + { manualCleanup: `Make ${filePath} readable, valid TOML and remove only hooks that invoke Plannotator; preserve unrelated hooks.` }, + request, state, + ); + continue; + } + } else { + config = readJsonRecord( + filePath, "remaining host hooks", + { manualCleanup: `Make ${filePath} a readable, writable strict JSON object and remove only hooks that invoke Plannotator; preserve unrelated settings.` }, + request, state, + ); + } + reportRemainingHookReferences(config, filePath, paths.binaryPaths, environment.platform, allowRelocatedBinary, request, state); + } + } + if (state.errors.length === 0) { await removeBinaries(request, environment, paths, state); } @@ -560,12 +589,13 @@ function agentEnvironmentOf(environment: UninstallEnvironment): AgentEnvironment * agents on this computer (inbox-agents.ts): only the recorded ones that still * run Plannotator. An entry the person changed to run something else is theirs * and is named; so is one that runs this binary but has no record (agents.json - * lost), which the person may have added by hand. A failure is a warning, - * never a reason to keep the CLI. + * lost), which the person may have added by hand. A surviving reference to + * the CLI blocks binary removal; unrelated notes remain warning-only. */ async function removeInboxAgentConnections( request: UninstallRequest, environment: UninstallEnvironment, + binaryPaths: readonly string[], state: MutableUninstallResult, ): Promise { const agents = agentEnvironmentOf(environment); @@ -577,6 +607,11 @@ async function removeInboxAgentConnections( if (request.dryRun) { for (const item of plan.items) state.planned.push(item.describe); for (const note of notes.items) state.planned.push(note.describe); + if (state.errors.length === 0) { + for (const reference of remainingInboxReferences(agents, binaryPaths, new Set(plan.items.map((item) => item.agent)))) { + state.errors.push(`${reference}. Then rerun \`${formatUninstallRetryCommand(request)}\`.`); + } + } return; } for (const note of notes.items) { @@ -594,6 +629,11 @@ async function removeInboxAgentConnections( state.removed.push(item.describe); removed.push(item.agent); } + if (state.errors.length === 0) { + for (const reference of remainingInboxReferences(agents, binaryPaths)) { + state.errors.push(`${reference}. Then rerun \`${formatUninstallRetryCommand(request)}\`.`); + } + } // A plain uninstall keeps the data: forget these records so a reinstall connects them again. if (!request.purge) forgetInboxAgents(state.dataDir, removed); } @@ -616,6 +656,26 @@ function reportHostCleanupFailure( ); } +function reportRemainingHookReferences( + config: unknown, + filePath: string, + binaryPaths: readonly string[], + platform: NodeJS.Platform, + allowRelocatedBinary: boolean, + request: UninstallRequest, + state: MutableUninstallResult, + projectedRemovals: readonly ManagedHookSpec[] = [], + removedVibeIndices: ReadonlySet = new Set(), +): void { + for (const field of remainingHookReferences(config, binaryPaths, platform, allowRelocatedBinary, projectedRemovals, removedVibeIndices)) { + reportHostCleanupFailure( + `Preserved Plannotator reference in ${filePath}`, `${field} still invokes Plannotator`, + { manualCleanup: `Remove only the Plannotator hook at ${field} in ${filePath}; preserve unrelated hooks and settings.` }, + request, state, + ); + } +} + function resolveOwnedPaths(environment: UninstallEnvironment) { const { homeDir, env } = environment; const claudeDir = env.CLAUDE_CONFIG_DIR || join(homeDir, ".claude"); @@ -672,29 +732,17 @@ async function removeHostPlugins( ...(request.purge ? [] : ["--keep-data"]), "--yes", ], - installed: - hasEnabledPlugin( - join(paths.claudeDir, "settings.json"), - "plannotator@plannotator", - ) || - hasInstalledPlugin( - join(paths.claudeDir, "plugins", "installed_plugins.json"), - "plannotator@plannotator", - ), + references: () => pluginReferences(paths.claudeDir, "plannotator@plannotator"), }, { label: "GitHub Copilot CLI plugin plannotator-copilot@plannotator", command: "copilot", args: ["plugins", "remove", "plannotator-copilot@plannotator", "--plugin"], - installed: - hasEnabledPlugin( - join(paths.copilotDir, "settings.json"), - "plannotator-copilot@plannotator", - ) || - hasDirectoryWithPrefix( - join(paths.copilotDir, "installed-plugins", "plannotator"), - "plannotator-copilot", - ), + references: () => { + const enabled = enabledPluginReferences(join(paths.copilotDir, "settings.json"), "plannotator-copilot@plannotator"); + const installed = directoryReferences(join(paths.copilotDir, "installed-plugins", "plannotator"), "plannotator-copilot"); + return { references: [...enabled.references, ...installed.references], errors: [...enabled.errors, ...installed.errors] }; + }, }, { label: "Droid plugin plannotator@plannotator", @@ -706,57 +754,57 @@ async function removeHostPlugins( "--scope", "user", ], - installed: - hasEnabledPlugin( - join(paths.factoryDir, "settings.json"), - "plannotator@plannotator", - ) || - hasInstalledPlugin( - join(paths.factoryDir, "plugins", "installed_plugins.json"), - "plannotator@plannotator", - ), + references: () => pluginReferences(paths.factoryDir, "plannotator@plannotator"), }, { label: "Pi extension npm:@plannotator/pi-extension", command: "pi", args: ["remove", "npm:@plannotator/pi-extension"], - installed: hasPiPackage( - join( - environment.env.PI_CODING_AGENT_DIR || join(environment.homeDir, ".pi", "agent"), - "settings.json", - ), - ), + references: () => { + const filePath = join(environment.env.PI_CODING_AGENT_DIR || join(environment.homeDir, ".pi", "agent"), "settings.json"); + return piPackageReferences(filePath); + }, }, { label: "VS Code extension backnotprop.plannotator-webview", command: "code", args: ["--uninstall-extension", "backnotprop.plannotator-webview"], - installed: hasDirectoryWithPrefix( - join(environment.homeDir, ".vscode", "extensions"), - "backnotprop.plannotator-webview-", - ), + references: () => vscodeReferences(join(environment.homeDir, ".vscode", "extensions")), }, ] as const; for (const action of actions) { - if (!action.installed) continue; - if (request.dryRun) { - state.planned.push(action.label); + const scan = action.references(); + if (scan.errors.length > 0) { + state.errors.push(...scan.errors.map((error) => `${error} Then rerun \`${formatUninstallRetryCommand(request)}\`.`)); continue; } - + const references = scan.references; + if (references.length === 0) continue; const executable = environment.which(action.command); const manualCommand = [action.command, ...action.args].join(" "); + const manualRemoval = `If the host was permanently uninstalled, remove only these detected Plannotator entries: ${references.join("; ")}. Preserve unrelated configuration, then rerun \`${formatUninstallRetryCommand(request)}\`.`; if (!executable) { state.errors.push( - `${action.label} was detected but ${action.command} is unavailable; it was not removed. Manual cleanup: restore ${action.command} on PATH and run \`${manualCommand}\`. After it succeeds, rerun \`${formatUninstallRetryCommand(request)}\`.`, + `${action.label} was detected but ${action.command} is unavailable; it was not removed. Manual cleanup: restore ${action.command} on PATH and run \`${manualCommand}\`. After it succeeds, rerun \`${formatUninstallRetryCommand(request)}\`. ${manualRemoval}`, ); continue; } + if (request.dryRun) { + state.planned.push(action.label); + continue; + } const result = await environment.runCommand(executable, action.args); if (result.exitCode === 0) { - state.removed.push(action.label); + const remaining = action.references(); + if (remaining.errors.length > 0) { + state.errors.push(...remaining.errors.map((error) => `${error} Then rerun \`${formatUninstallRetryCommand(request)}\`.`)); + } else if (remaining.references.length > 0) { + state.errors.push(`${action.label} still has recognized references after ${action.command} reported success. Manual cleanup: remove only ${remaining.references.join("; ")}; preserve unrelated configuration. Then rerun \`${formatUninstallRetryCommand(request)}\`.`); + } else { + state.removed.push(action.label); + } } else { state.errors.push( `${action.label} was not removed automatically (${result.timedOut ? "command timed out" : `exit ${result.exitCode}`}). Manual cleanup: run \`${manualCommand}\` directly and resolve the host error until it succeeds. Then rerun \`${formatUninstallRetryCommand(request)}\`.`, @@ -821,7 +869,7 @@ function removeHostConfigEntries( state, ); - cleanupVibeHooks(join(paths.vibeDir, "hooks.toml"), request, state); + cleanupVibeHooks(join(paths.vibeDir, "hooks.toml"), paths.binaryPaths, environment.platform, request, state); for (const configDir of paths.configDirs) { for (const name of ["opencode.json", "opencode.jsonc"]) { @@ -1318,6 +1366,7 @@ function cleanupHooksJson( state, ); if (!parsed) return; + if (request.dryRun) reportRemainingHookReferences(parsed, filePath, binaryPaths, platform, policy.allowRelocatedBinary, request, state, specs); const hooks = asRecord(parsed.hooks); if (!hooks) return; @@ -1373,7 +1422,9 @@ function cleanupHooksJson( else hooks[spec.event] = nextEntries; } - if (!changed) return; + if (!changed) { + return; + } if (Object.keys(hooks).length === 0) delete parsed.hooks; else parsed.hooks = hooks; @@ -1441,10 +1492,11 @@ const VIBE_MANAGED_END = "# <<< plannotator-managed-vibe-hooks"; */ function cleanupVibeHooks( filePath: string, + binaryPaths: readonly string[], + platform: NodeJS.Platform, request: UninstallRequest, state: MutableUninstallResult, ): void { - if (!existsSync(filePath)) return; const recovery = { manualCleanup: `Make ${filePath} readable and writable. Remove the lines between and including the "${VIBE_MANAGED_START}" and "${VIBE_MANAGED_END}" markers; keep every other hook. Delete the file only if nothing remains.`, }; @@ -1452,6 +1504,7 @@ function cleanupVibeHooks( try { content = readFileSync(filePath, "utf8"); } catch (error) { + if (isMissingPathError(error)) return; reportHostCleanupFailure( `Could not inspect ${filePath}`, formatError(error), @@ -1466,8 +1519,14 @@ function cleanupVibeHooks( const nextLines: string[] = []; let skipping = false; let sawStart = false; + let hookIndex = -1; + const removedHookIndices = new Set(); for (const line of lines) { const trimmed = line.trim(); + if (/^\[\[\s*(?:hooks|"hooks"|'hooks')\s*\]\]/.test(trimmed)) { + hookIndex++; + if (skipping) removedHookIndices.add(hookIndex); + } if (!skipping && trimmed.startsWith(VIBE_MANAGED_START)) { skipping = true; sawStart = true; @@ -1482,6 +1541,14 @@ function cleanupVibeHooks( nextLines.push(line); } + if (request.dryRun) { + try { + const original: unknown = Bun.TOML.parse(content); + reportRemainingHookReferences(original, filePath, binaryPaths, platform, false, request, state, [], removedHookIndices); + } catch (error) { + reportHostCleanupFailure(`Could not inspect ${filePath}`, formatError(error), recovery, request, state); + } + } if (!sawStart) return; const remaining = nextLines.filter((line) => line.trim().length > 0); @@ -1533,6 +1600,8 @@ function cleanupGeminiSettings( state, ); if (!parsed) return; + if (request.dryRun) reportRemainingHookReferences(parsed, filePath, binaryPaths, platform, false, request, state, + [{ event: "BeforeTool", matcher: "exit_plan_mode", suffix: "" }]); const wasInstallerTemplate = isGeminiInstallerTemplate( parsed, binaryPaths, @@ -1541,7 +1610,9 @@ function cleanupGeminiSettings( const hooks = asRecord(parsed.hooks); const beforeTool = hooks?.BeforeTool; - if (!hooks || !Array.isArray(beforeTool)) return; + if (!hooks || !Array.isArray(beforeTool)) { + return; + } let changed = false; const nextEntries: unknown[] = []; @@ -1571,7 +1642,13 @@ function cleanupGeminiSettings( nextEntries.push({ ...entry, hooks: nextHooks }); } - if (!changed) return; + if (!changed) { + return; + } + if (nextEntries.length === 0) delete hooks.BeforeTool; + else hooks.BeforeTool = nextEntries; + if (Object.keys(hooks).length === 0) delete parsed.hooks; + else parsed.hooks = hooks; if (request.dryRun) { state.planned.push(`managed Gemini hook in ${filePath}`); return; @@ -1582,10 +1659,6 @@ function cleanupGeminiSettings( return; } - if (nextEntries.length === 0) delete hooks.BeforeTool; - else hooks.BeforeTool = nextEntries; - if (Object.keys(hooks).length === 0) delete parsed.hooks; - else parsed.hooks = hooks; writeJson( filePath, parsed, @@ -1800,11 +1873,11 @@ function readJsonRecord( request: UninstallRequest, state: MutableUninstallResult, ): JsonRecord | null { - if (!existsSync(filePath)) return null; let content: string; try { content = readFileSync(filePath, "utf8"); } catch (error) { + if (isMissingPathError(error)) return null; reportHostCleanupFailure( `Could not inspect ${filePath}`, formatError(error), @@ -2076,59 +2149,6 @@ function removePath( } } -function isManagedHook( - value: unknown, - binaryPaths: readonly string[], - suffix: "" | "improve-context" | "t3-hook", - platform: NodeJS.Platform, - allowRelocatedBinary = false, -): boolean { - const hook = asRecord(value); - if (!hook || hook.type !== "command" || typeof hook.command !== "string") { - return false; - } - - const command = hook.command.trim(); - if (suffix === "t3-hook") { - const parsed = parseT3HookCommand(command); - return parsed !== undefined && (parsed.executable === "plannotator" || binaryPaths.some((path) => sameCommand(parsed.executable, path, platform))); - } - const expectedBare = suffix ? `plannotator ${suffix}` : "plannotator"; - if (command === expectedBare) return true; - - for (const binaryPath of binaryPaths) { - const candidates = suffix - ? [`${binaryPath} ${suffix}`, `"${binaryPath}" ${suffix}`] - : [binaryPath, `"${binaryPath}"`]; - if (candidates.some((candidate) => sameCommand(command, candidate, platform))) { - return true; - } - } - return allowRelocatedBinary && isRelocatedPlannotatorCommand(command, suffix); -} - -function isRelocatedPlannotatorCommand( - command: string, - suffix: "" | "improve-context", -): boolean { - const commandSuffix = suffix ? ` ${suffix}` : ""; - if (commandSuffix && !command.endsWith(commandSuffix)) return false; - - const executable = commandSuffix - ? command.slice(0, -commandSuffix.length).trim() - : command; - const unquoted = - executable.length >= 2 && - executable.startsWith('"') && - executable.endsWith('"') - ? executable.slice(1, -1) - : executable; - if (!isAbsolute(unquoted)) return false; - - const executableName = basename(unquoted).toLowerCase(); - return executableName === "plannotator" || executableName === "plannotator.exe"; -} - function isGeminiInstallerTemplate( value: JsonRecord, binaryPaths: readonly string[], @@ -2178,57 +2198,6 @@ function isPlannotatorOpenCodePlugin(value: unknown): boolean { return spec !== null && /^@plannotator\/opencode(?:@|$)/.test(spec); } -function hasEnabledPlugin(filePath: string, pluginId: string): boolean { - const parsed = tryReadJsonRecord(filePath); - const enabled = parsed ? asRecord(parsed.enabledPlugins) : null; - return enabled?.[pluginId] === true; -} - -function hasInstalledPlugin(filePath: string, pluginId: string): boolean { - const parsed = tryReadJsonRecord(filePath); - const plugins = parsed ? asRecord(parsed.plugins) : null; - const installs = plugins?.[pluginId]; - return Array.isArray(installs) && installs.length > 0; -} - -function hasPiPackage(filePath: string): boolean { - const parsed = tryReadJsonRecord(filePath); - const packages = parsed?.packages; - if (!Array.isArray(packages)) return false; - - return packages.some((entry) => { - const record = asRecord(entry); - const source = - typeof entry === "string" - ? entry - : typeof record?.source === "string" - ? record.source - : null; - return ( - typeof source === "string" && - /^npm:@plannotator\/pi-extension(?:@|$)/.test(source) - ); - }); -} - -function tryReadJsonRecord(filePath: string): JsonRecord | null { - if (!existsSync(filePath)) return null; - try { - const parsed: unknown = JSON.parse(readFileSync(filePath, "utf8")); - return asRecord(parsed); - } catch { - return null; - } -} - -function hasDirectoryWithPrefix(directory: string, prefix: string): boolean { - try { - return readdirSync(directory).some((entry) => entry.startsWith(prefix)); - } catch { - return false; - } -} - function asRecord(value: unknown): JsonRecord | null { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as JsonRecord @@ -2243,18 +2212,6 @@ function hasOnlyKeys( return Object.keys(value).every((key) => allowedSet.has(key)); } -function sameCommand( - left: string, - right: string, - platform: NodeJS.Platform, -): boolean { - const normalizedLeft = left.replace(/\\/g, "/"); - const normalizedRight = right.replace(/\\/g, "/"); - return platform === "win32" - ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() - : normalizedLeft === normalizedRight; -} - function sameLexicalPath( left: string, right: string, From 78115a5fa77e53b8eec003bd1499804d78d257c6 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Fri, 30 Oct 2026 23:09:37 -0400 Subject: [PATCH 2/6] uninstall: review fixes for the host recovery checks (#2226) Follows up on utpal singh's fix for #1179. - A config uninstall can't read still keeps the CLI, but the error names the file, the tool that owns it, why it couldn't be read, that Plannotator did not change it, and what to do next (over the size cap: quit the tool, move the file aside, rerun, move it back). Each file is reported once. - A plannotator-inbox entry no record says Plannotator added is a warning again, never a reason to keep the CLI, with how to remove it (`claude mcp remove plannotator-inbox -s user` for ~/.claude.json). - Claude Code and Droid: only the user scope decides. A project's or a managed install is named with its own uninstall command, never waited for, so a retry never asks the host to remove a user install that is gone. - An unquoted install path with spaces (install.sh's Codex hook) matches exactly again. - Vibe's hooks.toml parse errors say what is wrong; surviving hooks are one error per file. - The dry-run test writes its Vibe path as a TOML string, which fixes the Windows job. - Test groups are named after what they test; docs describe the new blocking cases. --- README.md | 5 + .../docs/getting-started/installation.md | 5 + .../src/content/docs/reference/inbox.md | 4 +- docs/agent-guides/inbox-foundation.md | 4 +- packages/server/inbox-agents.ts | 21 +- packages/server/uninstall-hooks.ts | 10 + packages/server/uninstall-host-references.ts | 99 +++--- packages/server/uninstall-inbox-references.ts | 50 +-- packages/server/uninstall.test.ts | 294 +++++++++++++----- packages/server/uninstall.ts | 212 ++++++++----- 10 files changed, 496 insertions(+), 208 deletions(-) diff --git a/README.md b/README.md index f14ddd98fa..40917a9995 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,11 @@ Host integrations are always part of uninstall. If a broken or unavailable host prevents safe cleanup, the command names the blocking plugin manager or configuration, gives exact manual cleanup instructions, and stops before deleting the binary. Complete that cleanup and rerun uninstall. +It also keeps the binary when an agent's config file can't be read (another +tool's too), or when a hook it did not install still runs Plannotator. The +error names the file, the tool, and what to do. A `plannotator-inbox` entry +you added yourself, or a Claude Code or Droid plugin installed for one project, +is named with how to remove it and does not keep the binary. These mechanics keep the ordinary confirmation default-negative, make the irreversible outcome require a stronger explicit word, and still give package managers and scripts a conventional non-interactive flag. diff --git a/apps/marketing/src/content/docs/getting-started/installation.md b/apps/marketing/src/content/docs/getting-started/installation.md index 7f05c62717..adec2b2f64 100644 --- a/apps/marketing/src/content/docs/getting-started/installation.md +++ b/apps/marketing/src/content/docs/getting-started/installation.md @@ -201,6 +201,11 @@ Host integrations are always part of uninstall. If a broken or unavailable host prevents safe cleanup, the command names the blocking plugin manager or configuration, gives exact manual cleanup instructions, and stops before deleting the binary. Complete that cleanup and rerun uninstall. +It also keeps the binary when an agent's config file can't be read (another +tool's too), or when a hook it did not install still runs Plannotator. The +error names the file, the tool, and what to do. A `plannotator-inbox` entry +you added yourself, or a Claude Code or Droid plugin installed for one project, +is named with how to remove it and does not keep the binary. The purge removes only known Plannotator entries from the configured data directory. Unknown top-level files are preserved rather than guessed at, and diff --git a/apps/marketing/src/content/docs/reference/inbox.md b/apps/marketing/src/content/docs/reference/inbox.md index 6f02864dd5..efc9f9e271 100644 --- a/apps/marketing/src/content/docs/reference/inbox.md +++ b/apps/marketing/src/content/docs/reference/inbox.md @@ -88,7 +88,7 @@ Each entry runs ` inbox mcp`. The agent uses it from it - Editors (VS Code, Zed, JetBrains, Goose) are never changed. Settings has a one-click link or a snippet for each. - Qwen Code's `settings.json` may hold comments: Plannotator keeps every one, and adds only its own entry. It never uses `qwen mcp add`, which writes your other servers back with their `${VAR}` values filled in (a token kept in your environment would end up in the file). A file that is not valid JSON or is read-only is left as it was, and the terminal says why. A relative `QWEN_HOME` names a different folder in each project, so you connect Qwen Code by hand then. If the Inbox starts with another `QWEN_HOME` than the one Qwen Code was connected in, Plannotator leaves both folders as they are and tells you once. **Settings → Agents** shows Qwen Code off there, with the folder it is connected in; turn it on to move it. If the new folder's settings.json can't take it, nothing changes and the reason is shown. - `PLANNOTATOR_INBOX_CONNECT=0` skips this step. -- `plannotator uninstall` removes every entry it recorded adding (never one you added yourself). `--dry-run` lists them first. If its record (`~/.plannotator/inbox/agents.json`) is gone, it removes none: an entry that runs this Plannotator is named with how to remove it, since you may have added it by hand. +- `plannotator uninstall` removes every entry it recorded adding (never one you added yourself). `--dry-run` lists them first. If its record (`~/.plannotator/inbox/agents.json`) is gone, it removes none. Any `plannotator-inbox` entry that still runs Plannotator and that it did not add is named with how to remove it (for Claude Code's `~/.claude.json`, `claude mcp remove plannotator-inbox -s user`), and the `plannotator` command is still removed. If an agent's config file can't be read (not valid JSON or TOML, not UTF-8, permission denied, or too large), uninstall leaves the file as it is and keeps the `plannotator` command. It names the file, the agent and the reason, and says what to do before you run it again. ### A note in each agent's instructions @@ -395,7 +395,7 @@ The Inbox stores the files agents attached as they were when sent, the messages plannotator uninstall --purge ``` -Both forms remove the `plannotator-inbox` entries `plannotator inbox` added to your agents (an entry you changed to run something else is left, and named). `--purge` also stops a running Inbox first, then deletes `~/.plannotator/inbox/` with the rest of the Plannotator data. If the Inbox does not stop, uninstall still removes the plugins, skills, hooks and config entries, but keeps your Plannotator data (`~/.plannotator`) and the `plannotator` binary. Quit the Inbox and run the command again to finish. Add `--dry-run` to see what would be removed. +Both forms remove the `plannotator-inbox` entries `plannotator inbox` added to your agents (an entry you changed to run something else is left, and named; so is one you added yourself). An agent config uninstall can't read keeps the `plannotator` binary until you fix it (see [Your agents are connected for you](#your-agents-are-connected-for-you)). `--purge` also stops a running Inbox first, then deletes `~/.plannotator/inbox/` with the rest of the Plannotator data. If the Inbox does not stop, uninstall still removes the plugins, skills, hooks and config entries, but keeps your Plannotator data (`~/.plannotator`) and the `plannotator` binary. Quit the Inbox and run the command again to finish. Add `--dry-run` to see what would be removed. ## Troubleshooting diff --git a/docs/agent-guides/inbox-foundation.md b/docs/agent-guides/inbox-foundation.md index 2acc3785ea..2dc1a9bfc0 100644 --- a/docs/agent-guides/inbox-foundation.md +++ b/docs/agent-guides/inbox-foundation.md @@ -63,7 +63,7 @@ One long-lived local server per machine. Agents leave messages, `:::question` bl - When: a person's `plannotator inbox` (with or without `--no-open`), never `--background`, the shim, a connection that starts a stopped Inbox, or the window's Restart (`spawnDetachedInbox` marks those `PLANNOTATOR_INBOX_DETACHED=1`, scrubbed at start; `PLANNOTATOR_INBOX_LOCK_HELD=1` alone counts as detached too, because an Inbox of 0.28.17 or older spawns its Restart into an updated binary, and every other detached start, with only that marker). Starting the Inbox: `inbox.connectAgents()` once it listens, off the request path, its lines on stderr when done. An Inbox already running (an agent started it): the command opens the window, then `POST /api/inbox/control/connect` (connection-route guards: loopback Host, no Origin, the registry token; an older Inbox answers 404 and nothing happens); the step then runs in that Inbox's process, with the environment (PATH, home) the agent started it with. `PLANNOTATOR_INBOX_CONNECT=0` in either environment skips it. One run at a time (`connectAgents` is single-flight); stdout is unchanged. - What: every agent found (the install script's rules: its command on PATH, or its folder; Cursor also by `cursor` / `cursor-agent`, Kiro by `kiro-cli`, Codex by `$CODEX_HOME` holding config, a relative one resolved against the working folder as Codex does) with no record in `inbox/agents.json` gets a `plannotator-inbox` MCP server running ` inbox mcp`, through its own `mcp add` where it has one (Codex `codex mcp add … --`, Gemini `gemini mcp add -s user` (`$GEMINI_CLI_HOME` honored), Windsurf/Devin `devin mcp add -s user … --`, Amp, Pi 0.99+ without the extension `pi mcp add`), each only after its `--version` line has that CLI's own shape (`VERSION_SHAPES`: a program that merely shares the name, cln's `pi`, amp.rs, is never handed it; a build older than its `mcp add` is left to the person: Pi before 0.99, which would read the words as a chat prompt, Codex CLI before 0.35.0 and Gemini CLI before 0.1.19), else one key in a JSON file (`mcpServers` in `~/.cursor/mcp.json`, `~/.kiro/settings/mcp.json`, the Claude desktop app's `claude_desktop_config.json`, and Qwen Code's `$QWEN_HOME/settings.json`; created when missing). JSON edits are lossless (`packages/server/json-edit.ts`: only that member's text is inserted or cut, in the file's own style and dominant line ending, BOM kept; a file that is not strict JSON or has a duplicate key on the path is refused with the reason; Qwen's settings.json is read as JSON with comments, as Qwen reads it, and every comment stays: a new member goes after a comment that ends the last member's line, a removal cuts the member and one comma only). Add then remove gives the file back byte for byte: the record keeps the add's exact text change (`undo: { before, after }` on the agent's record in `agents.json`), and removal puts `before` back while `after` is still in the file untouched, so an empty file stays empty, a final newline or its absence stays, and a container Plannotator made goes; a file Plannotator created (`created`) is deleted once only the empty containers are left; an entry the person edited is cut alone; written at a symlink's target, atomic, a no-op when nothing changes (agent-files.ts). Codex's config.toml is read with `Bun.TOML.parse` (every TOML form). Pi with Plannotator's extension (Pi's `settings.json` `packages`) and OpenCode with its plugin (the global `opencode.json(c)` `plugin`, or `plugins` strings or `{ package }`): `inboxTool. = true` in config.json, unless config.json already decides it (false is recorded `off`) or `PLANNOTATOR_INBOX_TOOL` does (nothing written). Claude Code: nothing (said "already on" when the plugin is enabled). Left to the person (`manual`): Codex or Gemini found without its CLI, Qwen with a relative `QWEN_HOME`, Pi before 0.99, a CLI whose version line is not its own, Cline (its `mcp add` is not verified), OpenCode without the plugin. Editors (VS Code, Zed, JetBrains, Goose) are never written. Qwen Code is never connected through `qwen mcp add` / `remove`: Qwen writes the whole `mcpServers` back as it resolved it, `$VAR` / `${VAR}` filled in from its environment, so a person's token would land in the file. Plannotator edits the file itself (`qwenWay`, `QWEN_SETTINGS_RULES`): a file that does not parse or is read-only is left as it is (said with the reason, tried again next run). An entry Plannotator added is checked and removed in the file its record names (`recordedFile`), not the one the environment names now. Places are compared as the file system names them (`samePlace`: the real path of the deepest existing folder, the rest as written), so a symlinked `~/.qwen`, `/tmp` and `/private/tmp`, a trailing slash, or another case on a volume that ignores case (the real path has the case on disk) are one place. Case is never folded by Plannotator: on a case-sensitive volume (Linux, or one so formatted on macOS) `qwen` and `Qwen` are two folders. When `QWEN_HOME` names another place than the add (set in one shell, not in another: an accident of how the Inbox was started), a run reads the recorded file only: an entry gone from it is removed outside, as anywhere; one still there is left with both files untouched, and said once per pair of places (`elsewhere` in the report, `elsewhere_said`, kept when a run from the matching shell comes between, cleared when Settings acts; a run whose `QWEN_HOME` matches drops the other shell's line from the band, and so does any Settings switch of that agent). Settings → Agents shows it off, state `elsewhere`: "Connected in . Turn on to move it here." Uninstall cleans the recorded file. Only the person moves it, by turning it on: the entry is added in the new home first (a relative `QWEN_HOME`, another program named `qwen`, a new settings.json that does not parse or is read-only: refused, nothing changed), then taken out of the recorded file; when that cannot be done (read-only, no longer parses), the new entry is taken back out, so only the recorded one is ever left (if even that fails, both stay, said, and the new one is kept on the record as a `stray` that uninstall removes too). Strays stay recorded through later Settings switches: turning it on where a stray is records that entry as added (the old one moved out), turning it off removes the strays too (one that cannot go stays recorded, said), and uninstall forgets an agent's record only when every entry of it went. A stray is checked as the agent's own entry is: one gone is nothing to do, one the person replaced with their own is theirs (named by uninstall, never touched, no longer recorded). A `qwen` on PATH is still asked its `--version` (any Qwen Code version will do; another program named `qwen` is left to the person), run in the home folder (`cwd`), so Qwen never migrates or resets the Inbox's working folder's `.qwen/settings.json`. The agent CLIs run with no stdin in their own process group; the time limit kills the group, and the answer comes at exit even if a grandchild keeps the output open. - Rules: an agent with a record is never added again by itself (turned off, removed outside Plannotator: `removed_outside_at`, checked read-only each run; foreign). An entry is ours when it runs exactly this CLI's command or the one its record says Plannotator wrote; for "already connected", also any `plannotator` binary or checkout running `inbox mcp` (`isPlannotatorInboxCommand`: the program's name is exactly `plannotator`, not `my-plannotator-proxy`). Anything else named `plannotator-inbox` is never replaced and is recorded `foreign`. A refused file or a failing or hung `mcp add` is tried again next run and said again only when its reason changes (`failures` in agents.json). Limits: 10 s per agent, 30 s in all (agents still going are `unfinished`). A test run (PLANNOTATOR_TEST_SANDBOX) whose home is not a scratch folder connects nothing (`agentHomeAllowed`). -- Said once: the first run "Connected Codex, Gemini CLI and Cursor to the Inbox (Claude Code was already on)." plus "Agents use it from their next session. PLANNOTATOR_INBOX_CONNECT=0 skips this step; plannotator uninstall removes what it added."; a later run "Connected Amp to the Inbox (new on this computer)."; failures and foreign entries name the file and reason (`formatConnectReport`); an agent only a person can connect (no CLI on PATH, Pi before 0.99, Cline) is named with its reason ("Pi needs connecting by hand: …"), once per reason (`manual_said` in `agents.json`, `manual_why` on the report); time limits are said as they are (`formatSeconds`: "1.5 s"). The run's report is kept in `agents.json` (`last_report`, `seen: false` when it has news) and served at `GET /api/inbox/agents` (local only; records with `~` paths). Uninstall removes only entries Plannotator recorded as `added` that still run the command it wrote; with no record at all (`agents.json` lost) nothing is removed, and an entry that runs exactly the binary being uninstalled is named in a warning with how to remove it, since the person may have added it by hand; one the person added (`found`) is left. +- Said once: the first run "Connected Codex, Gemini CLI and Cursor to the Inbox (Claude Code was already on)." plus "Agents use it from their next session. PLANNOTATOR_INBOX_CONNECT=0 skips this step; plannotator uninstall removes what it added."; a later run "Connected Amp to the Inbox (new on this computer)."; failures and foreign entries name the file and reason (`formatConnectReport`); an agent only a person can connect (no CLI on PATH, Pi before 0.99, Cline) is named with its reason ("Pi needs connecting by hand: …"), once per reason (`manual_said` in `agents.json`, `manual_why` on the report); time limits are said as they are (`formatSeconds`: "1.5 s"). The run's report is kept in `agents.json` (`last_report`, `seen: false` when it has news) and served at `GET /api/inbox/agents` (local only; records with `~` paths). Uninstall removes only entries Plannotator recorded as `added` that still run the command it wrote; with no record at all (`agents.json` lost) nothing is removed, and an entry that runs exactly the binary being uninstalled is named in a warning with how to remove it, since the person may have added it by hand; one the person added (`found`) is left. After that, every agent's file is read once more (`remainingInboxReferences`, `packages/server/uninstall-inbox-references.ts`, skipping the agents the plan already removed or named): any other `plannotator-inbox` that runs an install path, bare `plannotator`, or a source checkout's `inbox mcp` is named in a warning with how to remove it (`inboxEntryRemovalHint`; Claude Code's `~/.claude.json`: `claude mcp remove plannotator-inbox -s user`, never a hand edit of a file Claude Code rewrites while it runs). These are warnings, never a reason to keep the CLI. An agent's file that cannot be read (not JSON / TOML, not UTF-8, permission denied, over the size cap: 32 MiB for `~/.claude.json`, 1 MiB for the others) does keep it: uninstall cannot tell what it runs. The error names the file, the agent, the reason, that Plannotator did not change it, and the next step (over the cap: quit the agent, move the file aside, rerun, move it back). Each file is said once per run. - Tests: `packages/server/inbox-agents.test.ts` (fake agent CLIs on a scratch PATH and HOME, `tests/helpers/fake-agents.ts`; the real CLIs are never run), `packages/server/inbox-agents-qwen.test.ts` (Qwen: comments and a person's `${VAR}` kept byte for byte, `qwen --version` run in the home folder, `QWEN_HOME` from the environment or Qwen's `.env` files, a relative one, a foreign `qwen`, turned off, a malformed, trailing-comma or read-only settings.json, removal), `packages/server/inbox-connect-route.test.ts`, `apps/hook/server/inbox-connect-cli.test.ts` (the CLI as processes), the uninstall block in `packages/server/uninstall.test.ts`. The guard test also fails on a path from the real home to an agent's folder and on connecting agents with no agent environment (`agentHomeFindings`, `agentConnectFindings`). **The note in each agent's instructions** (`packages/server/inbox-agent-notes.ts`; decision 5). @@ -102,7 +102,7 @@ One long-lived local server per machine. Agents leave messages, `:::question` bl - **The release page's redirect** on github.com is read whatever the case of the owner and repository in its path; a mirror (`PLANNOTATOR_UPDATE_LATEST_URL`) is read exactly. - **The words.** Settings → General and About say "Up to date" only after a successful check in the last 24 h with checks on (`checkStatusWords`): otherwise "Update checks are off", "Can't check for updates since … · last tried …", "Last check failed