67e5e456f0f164c9e313cea50b1201e74701ae6e

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Add pi extensions

Diff

This diff is truncated to protect this page.

   1diff --git a/.chezmoiignore b/.chezmoiignore
   2index c5daf737a7d988b3733c5e6584203994a0fd4fa7..804a18de318406560eee6894bfbd5cf9335e3c3f 100644
   3--- a/.chezmoiignore
   4+++ b/.chezmoiignore
   5@@ -6,6 +6,10 @@ us_colemak.patch
   6 config_template.yaml
   7 .config/nvim/lazy-lock.json
   8 
   9+**/node_modules/
  10+
  11+.pi/agent/extensions/pi-policy-engine
  12+
  13 {{ if not (and (eq .chezmoi.os "linux") (hasKey .chezmoi.osRelease "id") (eq .chezmoi.osRelease.id "arch")) }}
  14 .local/bin/pkgbuild-review
  15 .config/llama-server/
  16diff --git a/dot_pi/agent/extensions/continue-after-compaction.ts b/dot_pi/agent/extensions/continue-after-compaction.ts
  17new file mode 100644
  18index 0000000000000000000000000000000000000000..1e42059fe49c7e8aec044a2bf67d43df96107e65
  19--- /dev/null
  20+++ b/dot_pi/agent/extensions/continue-after-compaction.ts
  21@@ -0,0 +1,55 @@
  22+import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
  23+
  24+const buildContinuationPrompt = (sessionFile: string | undefined, compactionEntryId: string): string => {
  25+	const sessionSource =
  26+		sessionFile === undefined
  27+			? "This session is ephemeral, so no persisted session file is available."
  28+			: [
  29+					`The persisted session JSONL is ${JSON.stringify(sessionFile)}.`,
  30+					"Inspect it directly with the read and bash tools.",
  31+					"Do not launch a nested Pi process or open the session with `pi --session`.",
  32+				].join(" ");
  33+
  34+	return `Compaction has just completed. Resume the existing task rather than waiting for another user prompt.
  35+
  36+${sessionSource}
  37+The new compaction entry ID is ${JSON.stringify(compactionEntryId)}.
  38+
  39+Before continuing:
  40+
  41diff --git a/dot_pi/agent/extensions/dot_chezmoiignore b/dot_pi/agent/extensions/dot_chezmoiignore
  42new file mode 100644
  43index 0000000000000000000000000000000000000000..e84c3fbaf386e0bf2aeeb183c7b5352fe3d76895
  44--- /dev/null
  45+++ b/dot_pi/agent/extensions/dot_chezmoiignore
  46@@ -0,0 +1 @@
  47+*/node_modules
  48diff --git a/dot_pi/agent/extensions/git-interceptor.ts b/dot_pi/agent/extensions/git-interceptor.ts
  49new file mode 100644
  50index 0000000000000000000000000000000000000000..4acb3fdeed27b8c01b125793854f89012f333b44
  51--- /dev/null
  52+++ b/dot_pi/agent/extensions/git-interceptor.ts
  53@@ -0,0 +1,39 @@
  54+/**
  55+ * Git Interceptor
  56+ *
  57+ * Two guards for agent-driven git commands:
  58+ *
  59+ * 1. Editor hang prevention — Sets GIT_EDITOR, GIT_SEQUENCE_EDITOR to `true`
  60+ *    (no-op) and GIT_MERGE_AUTOEDIT to `no` so git never spawns an interactive
  61+ *    editor (nvim, vim, etc.) that would hang the bash process.
  62+ *
  63+ * 2. Hook bypass prevention — Blocks any command containing `--no-verify` so
  64+ *    the agent cannot circumvent git hooks (pre-commit, commit-msg, etc.).
  65+ *    The agent should fix hook failures or ask the human for help instead.
  66+ */
  67+
  68+import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
  69+import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
  70+
  71+const GIT_ENV_PREFIX =
  72+	"export GIT_EDITOR=true GIT_SEQUENCE_EDITOR=true GIT_MERGE_AUTOEDIT=no\n";
  73+
  74+const NO_VERIFY_RE = /--no-verify\b/;
  75+
  76+const BLOCK_REASON =
  77+	"BLOCKED: --no-verify is not allowed. Git hooks exist for a reason. " +
  78+	"Do not attempt to bypass them. Instead: fix the underlying issue that " +
  79+	"is causing the hook to fail, or ask the user for help.";
  80+
  81+export default function (pi: ExtensionAPI) {
  82+	pi.on("tool_call", (event) => {
  83+		if (!isToolCallEventType("bash", event)) return;
  84+		if (!event.input.command.includes("git")) return;
  85+
  86+		if (NO_VERIFY_RE.test(event.input.command)) {
  87+			return { block: true, reason: BLOCK_REASON };
  88+		}
  89+
  90+		event.input.command = GIT_ENV_PREFIX + event.input.command;
  91+	});
  92+}
  93diff --git a/dot_pi/agent/extensions/handoff/index.ts b/dot_pi/agent/extensions/handoff/index.ts
  94new file mode 100644
  95index 0000000000000000000000000000000000000000..00bc368ec171a327191b097e457cc859c4df76d6
  96--- /dev/null
  97+++ b/dot_pi/agent/extensions/handoff/index.ts
  98@@ -0,0 +1,133 @@
  99+import type { ExtensionAPI, SessionEntry } from "@earendil-works/pi-coding-agent";
 100+
 101+const HANDOFF_AGENT_START_TIMEOUT_MS = 30_000;
 102+function buildContinueFromHandoffPrompt(
 103+	sessionFile: string | undefined,
 104+	sourceLeafEntryId: string,
 105+): string {
 106+	const sourceBranchInstructions =
 107+		sessionFile === undefined
 108+			? `The source branch ends at session tree entry ${JSON.stringify(sourceLeafEntryId)}. If the handoff leaves a blocking ambiguity, use available session or tree inspection capabilities to recover the needed context from that branch, then resume.`
 109diff --git a/dot_pi/agent/extensions/handoff/package.json b/dot_pi/agent/extensions/handoff/package.json
 110new file mode 100644
 111index 0000000000000000000000000000000000000000..8e9126e6e0b286c43ee054f08faae323cfc58d42
 112--- /dev/null
 113+++ b/dot_pi/agent/extensions/handoff/package.json
 114@@ -0,0 +1,24 @@
 115+{
 116+  "name": "pi-handoff-extension",
 117+  "private": true,
 118+  "version": "0.1.0",
 119+  "type": "module",
 120+  "scripts": {
 121+    "check": "npm run typecheck && npm test",
 122+    "test": "node --import tsx --test \"test/**/*.test.ts\"",
 123+    "typecheck": "tsc --noEmit"
 124+  },
 125+  "pi": {
 126+    "extensions": [
 127+      "./index.ts"
 128+    ]
 129+  },
 130+  "peerDependencies": {
 131+    "@earendil-works/pi-coding-agent": "*"
 132+  },
 133+  "devDependencies": {
 134+    "@types/node": "^24.5.2",
 135+    "tsx": "^4.20.5",
 136+    "typescript": "^5.9.2"
 137+  }
 138+}
 139diff --git a/dot_pi/agent/extensions/handoff/test/handoff.test.ts b/dot_pi/agent/extensions/handoff/test/handoff.test.ts
 140new file mode 100644
 141index 0000000000000000000000000000000000000000..c3d45bcc852a4608baf204cc622105341b6f11e5
 142--- /dev/null
 143+++ b/dot_pi/agent/extensions/handoff/test/handoff.test.ts
 144@@ -0,0 +1,202 @@
 145+import assert from "node:assert/strict";
 146+import { mkdtemp, rm } from "node:fs/promises";
 147+import { tmpdir } from "node:os";
 148+import { dirname, join } from "node:path";
 149+import { fileURLToPath } from "node:url";
 150+import test from "node:test";
 151+
 152+import { InMemoryCredentialStore, type UserMessage } from "@earendil-works/pi-ai";
 153+import {
 154+	discoverAndLoadExtensions,
 155+	ExtensionRunner,
 156+	ModelRegistry,
 157+	ModelRuntime,
 158+	SessionManager,
 159+	type ExtensionCommandContextActions,
 160+} from "@earendil-works/pi-coding-agent";
 161+
 162+const extensionPath = join(dirname(fileURLToPath(import.meta.url)), "..", "index.ts");
 163+
 164+function userMessage(text: string): UserMessage {
 165+	return {
 166+		role: "user",
 167+		content: [{ type: "text", text }],
 168+		timestamp: Date.now(),
 169+	};
 170+}
 171+
 172+async function createHandoffHarness(cwd: string) {
 173+	const credentials = new InMemoryCredentialStore();
 174+	const modelRuntime = await ModelRuntime.create({
 175+		credentials,
 176+		modelsPath: null,
 177+		allowModelNetwork: false,
 178+	});
 179+	const model = modelRuntime.getModels()[0];
 180+	assert.ok(model, "the bundled model catalog should not be empty");
 181+	await credentials.modify(model.provider, async () => ({ type: "api_key", key: "test-key" }));
 182+
 183+	const sessionManager = SessionManager.create(cwd, join(cwd, ".sessions"));
 184+	const loaded = await discoverAndLoadExtensions([extensionPath], cwd, join(cwd, ".agent"));
 185+	assert.deepEqual(loaded.errors, []);
 186+
 187+	const modelRegistry = new ModelRegistry(modelRuntime);
 188+	const runner = new ExtensionRunner(
 189+		loaded.extensions,
 190+		loaded.runtime,
 191+		cwd,
 192+		sessionManager,
 193+		modelRegistry,
 194+	);
 195+	const sentUserMessages: Array<{
 196+		readonly content: string;
 197+		readonly options?: {
 198+			readonly deliverAs?: "steer" | "followUp";
 199+			readonly expandPromptTemplates?: boolean;
 200+		};
 201+	}> = [];
 202+	loaded.runtime.sendUserMessage = (content, options) => {
 203+		if (typeof content !== "string") {
 204+			throw new Error("Handoff test received unexpected image content");
 205+		}
 206+		sentUserMessages.push({ content, ...(options ? { options } : {}) });
 207+		if (content.startsWith("/skill:handoff")) {
 208+			queueMicrotask(() => void runner.emit({ type: "agent_start" }));
 209+		}
 210+	};
 211+
 212+	runner.bindCore(loaded.runtime, {
 213+		getModel: () => model,
 214+		getScopedModels: () => [],
 215+		isIdle: () => true,
 216+		isProjectTrusted: () => true,
 217+		getSignal: () => undefined,
 218+		abort: () => undefined,
 219+		hasPendingMessages: () => false,
 220+		shutdown: () => undefined,
 221+		getContextUsage: () => undefined,
 222+		compact: () => undefined,
 223+		getSystemPrompt: () => "",
 224+	});
 225+
 226+	const navigations: Array<{
 227+		readonly targetId: string;
 228+		readonly options?: {
 229+			readonly summarize?: boolean;
 230+			readonly customInstructions?: string;
 231+			readonly replaceInstructions?: boolean;
 232+			readonly label?: string;
 233+		};
 234+	}> = [];
 235+	let waitForIdleCalls = 0;
 236+	const commandActions: ExtensionCommandContextActions = {
 237+		waitForIdle: async () => {
 238+			waitForIdleCalls += 1;
 239+		},
 240+		newSession: async () => ({ cancelled: false }),
 241+		fork: async () => ({ cancelled: false }),
 242+		navigateTree: async (targetId, options) => {
 243+			navigations.push({ targetId, ...(options ? { options } : {}) });
 244diff --git a/dot_pi/agent/extensions/handoff/tsconfig.json b/dot_pi/agent/extensions/handoff/tsconfig.json
 245new file mode 100644
 246index 0000000000000000000000000000000000000000..4a1c8070805e97432f99eb11246752e790f28738
 247--- /dev/null
 248+++ b/dot_pi/agent/extensions/handoff/tsconfig.json
 249@@ -0,0 +1,18 @@
 250+{
 251+  "compilerOptions": {
 252+    "target": "ESNext",
 253+    "module": "NodeNext",
 254+    "moduleResolution": "NodeNext",
 255+    "lib": ["ESNext"],
 256+    "allowImportingTsExtensions": true,
 257+    "noEmit": true,
 258+    "strict": true,
 259+    "exactOptionalPropertyTypes": true,
 260+    "noUncheckedIndexedAccess": true,
 261+    "noImplicitOverride": true,
 262+    "noFallthroughCasesInSwitch": true,
 263+    "skipLibCheck": true,
 264+    "types": ["node"]
 265+  },
 266+  "include": ["./index.ts", "./test/**/*.ts"]
 267+}
 268diff --git a/dot_pi/agent/extensions/herdr-agent-state.ts b/dot_pi/agent/extensions/herdr-agent-state.ts
 269new file mode 100644
 270index 0000000000000000000000000000000000000000..67bc41889abc2791d6dcdfe8279bdc24186cc6fe
 271--- /dev/null
 272+++ b/dot_pi/agent/extensions/herdr-agent-state.ts
 273@@ -0,0 +1,257 @@
 274+// installed by herdr
 275+// managed by herdr; reinstalling or updating the integration overwrites this file.
 276+// add custom hooks/plugins beside this file instead of editing it.
 277+// HERDR_INTEGRATION_ID=pi
 278+// HERDR_INTEGRATION_VERSION=8
 279+// @ts-nocheck
 280+
 281+import net from "node:net";
 282+
 283+const HERDR_ENV = process.env.HERDR_ENV;
 284+const socketPath = process.env.HERDR_SOCKET_PATH;
 285+const socketEndpoint =
 286+  process.platform === "win32" && socketPath ? `\\\\.\\pipe\\${socketPath}` : socketPath;
 287+const paneId = process.env.HERDR_PANE_ID;
 288+const source = "herdr:pi";
 289+
 290+function enabled() {
 291+  return HERDR_ENV === "1" && !!socketPath && !!paneId;
 292+}
 293+
 294+function sendRequestAttempt(request: unknown, timeoutMs: number): Promise<boolean> {
 295+  if (!enabled()) {
 296+    return Promise.resolve(true);
 297+  }
 298+
 299+  return new Promise((resolve) => {
 300+    let done = false;
 301+    let timeout: ReturnType<typeof setTimeout> | undefined;
 302+    const finish = (delivered: boolean) => {
 303+      if (done) return;
 304+      done = true;
 305+      if (timeout) {
 306+        clearTimeout(timeout);
 307+      }
 308+      socket.destroy();
 309+      resolve(delivered);
 310+    };
 311+
 312+    const socket = net.createConnection(socketEndpoint!);
 313+    socket.on("error", () => finish(false));
 314+    socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`));
 315+    socket.on("data", () => finish(true));
 316+    socket.on("end", () => finish(false));
 317+    timeout = setTimeout(() => finish(false), timeoutMs);
 318+    timeout.unref?.();
 319+  });
 320+}
 321+
 322+async function sendRequest(request: unknown): Promise<void> {
 323+  if (await sendRequestAttempt(request, 500)) {
 324+    return;
 325+  }
 326+  await sendRequestAttempt(request, 1500);
 327+}
 328+
 329+type AgentState = "working" | "blocked" | "idle";
 330+
 331+type QueuedState = {
 332+  state: AgentState;
 333+  message?: string;
 334+  seq: number;
 335+};
 336+
 337+let reportSeq = Date.now() * 1000;
 338+let currentAgentSessionId: string | undefined;
 339+let currentAgentSessionPath: string | undefined;
 340+
 341+function nextReportSeq(): number {
 342+  reportSeq += 1;
 343+  return reportSeq;
 344+}
 345+
 346+function updateSessionRef(ctx: any): void {
 347+  try {
 348+    const file = ctx?.sessionManager?.getSessionFile?.();
 349+    currentAgentSessionPath =
 350+      typeof file === "string" && file.startsWith("/") ? file : undefined;
 351+  } catch {
 352+    currentAgentSessionPath = undefined;
 353+  }
 354+
 355+  try {
 356+    const id = ctx?.sessionManager?.getSessionId?.();
 357+    currentAgentSessionId = typeof id === "string" && id.length > 0 ? id : undefined;
 358+  } catch {
 359+    currentAgentSessionId = undefined;
 360+  }
 361+}
 362+
 363+function withSessionRef(params: Record<string, unknown>): Record<string, unknown> {
 364+  if (currentAgentSessionPath) {
 365+    return { ...params, agent_session_path: currentAgentSessionPath };
 366+  }
 367+  if (currentAgentSessionId) {
 368+    return { ...params, agent_session_id: currentAgentSessionId };
 369+  }
 370+  return params;
 371+}
 372+
 373diff --git a/dot_pi/agent/extensions/pi-skill-toggle/package.json b/dot_pi/agent/extensions/pi-skill-toggle/package.json
 374new file mode 100644
 375index 0000000000000000000000000000000000000000..1ae907d1bb18e90cb96a9b59911f1523eec7183f
 376--- /dev/null
 377+++ b/dot_pi/agent/extensions/pi-skill-toggle/package.json
 378@@ -0,0 +1,23 @@
 379+{
 380+  "name": "pi-skill-toggle",
 381+  "private": true,
 382+  "version": "0.1.0",
 383+  "type": "module",
 384+  "scripts": {
 385+    "test": "node --import tsx --test \"src/**/*.test.ts\"",
 386+    "typecheck": "tsc --noEmit"
 387+  },
 388+  "pi": {
 389+    "extensions": [
 390+      "./src/index.ts"
 391+    ]
 392+  },
 393+  "peerDependencies": {
 394+    "@earendil-works/pi-coding-agent": "*",
 395+    "@earendil-works/pi-tui": "*"
 396+  },
 397+  "devDependencies": {
 398+    "@types/node": "^24.5.2",
 399+    "typescript": "^5.9.2"
 400+  }
 401+}
 402diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/apply/planner.test.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/apply/planner.test.ts
 403new file mode 100644
 404index 0000000000000000000000000000000000000000..93d967a76a06691197f983866adcb8834f520729
 405--- /dev/null
 406+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/apply/planner.test.ts
 407@@ -0,0 +1,78 @@
 408+import { describe, it } from "node:test";
 409+import assert from "node:assert/strict";
 410+import { DefaultSkillTogglePlanner } from "./planner.ts";
 411+import { SimpleFrontmatterCodec } from "../frontmatter/parser.ts";
 412+import { MinimalFrontmatterPatcher } from "../frontmatter/patcher.ts";
 413+import type { FileSystem } from "../ports/fs.ts";
 414+import type { SkillRecord } from "../types.ts";
 415+
 416+const codec = new SimpleFrontmatterCodec();
 417+const patcher = new MinimalFrontmatterPatcher();
 418+
 419+describe("DefaultSkillTogglePlanner", () => {
 420+  it("plans a normalization change for duplicated disable-model-invocation keys even if the mode is unchanged", async () => {
 421+    const filePath = "/skills/handoff/SKILL.md";
 422+    const raw = [
 423+      "---",
 424+      "name: handoff",
 425+      "description: Compact the conversation.",
 426+      "disable-model-invocation: true",
 427+      "argument-hint: What next?",
 428+      "disable-model-invocation: true",
 429+      "---",
 430+      "",
 431+      "# Handoff",
 432+      "",
 433+    ].join("\n");
 434+    const fs = new MemoryFileSystem(new Map([[filePath, raw]]));
 435+    const planner = new DefaultSkillTogglePlanner(fs, codec, patcher);
 436+    const record = skillRecord(filePath, "manual-only");
 437+
 438+    const changes = await planner.plan([record], [{ skill: record, desiredMode: "manual-only" }]);
 439+
 440+    assert.equal(changes.length, 1);
 441+    assert.equal(changes[0]?.from, "manual-only");
 442+    assert.equal(changes[0]?.to, "manual-only");
 443+    assert.equal((changes[0]?.patch.newText.match(/^disable-model-invocation\s*:/gm) ?? []).length, 1);
 444+  });
 445+});
 446+
 447+function skillRecord(filePath: string, mode: SkillRecord["mode"]): SkillRecord {
 448+  return {
 449+    id: filePath,
 450+    name: "handoff",
 451+    description: "Compact the conversation.",
 452+    filePath,
 453+    baseDir: "/skills/handoff",
 454+    source: { kind: "user", root: "/skills" },
 455+    editable: true,
 456+    mode,
 457+    diagnostics: [],
 458+  };
 459+}
 460+
 461+class MemoryFileSystem implements FileSystem {
 462+  constructor(private readonly files: Map<string, string>) {}
 463+
 464+  async readFile(path: string): Promise<string> {
 465+    const value = this.files.get(path);
 466+    if (value === undefined) throw new Error(`missing file: ${path}`);
 467+    return value;
 468+  }
 469+
 470+  async writeFileAtomic(path: string, content: string): Promise<void> {
 471+    this.files.set(path, content);
 472+  }
 473+
 474+  async access(path: string): Promise<boolean> {
 475+    return this.files.has(path);
 476+  }
 477+
 478+  async readdir(): Promise<Array<{ name: string; isDirectory: boolean; isFile: boolean; isSymbolicLink: boolean }>> {
 479+    return [];
 480+  }
 481+
 482+  async stat(): Promise<{ isDirectory: boolean; isFile: boolean; mode: number }> {
 483+    return { isDirectory: false, isFile: true, mode: 0o644 };
 484+  }
 485+}
 486diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/apply/planner.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/apply/planner.ts
 487new file mode 100644
 488index 0000000000000000000000000000000000000000..64661d9ad208606cf8cdec694fd9c2730f7391b1
 489--- /dev/null
 490+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/apply/planner.ts
 491@@ -0,0 +1,49 @@
 492+import type { FileSystem } from "../ports/fs.ts";
 493+import type { FrontmatterCodec } from "../frontmatter/parser.ts";
 494+import type { FrontmatterPatcher } from "../frontmatter/patcher.ts";
 495+import type { SkillChange, SkillDraft, SkillRecord } from "../types.ts";
 496+import { hasDuplicateDisableModelInvocation } from "../frontmatter/validation.ts";
 497+import { classifyInvocationMode } from "../inventory/classifier.ts";
 498+
 499+export interface SkillTogglePlanner {
 500+  plan(records: SkillRecord[], drafts: SkillDraft[]): Promise<SkillChange[]>;
 501+}
 502+
 503+export class DefaultSkillTogglePlanner implements SkillTogglePlanner {
 504+  constructor(
 505+    private readonly fs: FileSystem,
 506+    private readonly codec: FrontmatterCodec,
 507+    private readonly patcher: FrontmatterPatcher,
 508+  ) {}
 509+
 510+  async plan(records: SkillRecord[], drafts: SkillDraft[]): Promise<SkillChange[]> {
 511+    const recordById = new Map(records.map((record) => [record.id, record]));
 512+    const changes: SkillChange[] = [];
 513+
 514+    for (const draft of drafts) {
 515+      const record = recordById.get(draft.skill.id);
 516+      if (!record || !record.editable) continue;
 517+
 518+      const raw = await this.fs.readFile(record.filePath);
 519+      const doc = this.codec.parse(raw);
 520+      if (!doc.hasFrontmatter) continue;
 521+
 522+      const currentMode = classifyInvocationMode(doc);
 523+      const needsNormalization = hasDuplicateDisableModelInvocation(doc);
 524+      if (currentMode === draft.desiredMode && !needsNormalization) continue;
 525+
 526+      const patch = this.patcher.patchInvocationMode(doc, draft.desiredMode);
 527+      if (patch.oldText === patch.newText) continue;
 528+
 529+      changes.push({
 530+        skill: { ...record, mode: currentMode },
 531+        filePath: record.filePath,
 532+        from: currentMode,
 533+        to: draft.desiredMode,
 534+        patch,
 535+      });
 536+    }
 537+
 538+    return changes;
 539+  }
 540+}
 541diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/apply/writer.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/apply/writer.ts
 542new file mode 100644
 543index 0000000000000000000000000000000000000000..6b933e4225a7891213d6e94d02c05b1425f12f8b
 544--- /dev/null
 545+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/apply/writer.ts
 546@@ -0,0 +1,36 @@
 547+import type { FileSystem } from "../ports/fs.ts";
 548+import type { ApplyResult, SkillChange } from "../types.ts";
 549+
 550+export interface SkillChangeWriter {
 551+  apply(changes: SkillChange[]): Promise<ApplyResult>;
 552+}
 553+
 554+export class AtomicSkillChangeWriter implements SkillChangeWriter {
 555+  constructor(private readonly fs: FileSystem) {}
 556+
 557+  async apply(changes: SkillChange[]): Promise<ApplyResult> {
 558+    const result: ApplyResult = { applied: [], skipped: [], errors: [] };
 559+
 560+    for (const change of changes) {
 561+      try {
 562+        const current = await this.fs.readFile(change.filePath);
 563+        if (current !== change.patch.oldText) {
 564+          result.errors.push({
 565+            skill: change.skill,
 566+            message: `${change.skill.name}: file changed while dialog was open; skipped`,
 567+          });
 568+          continue;
 569+        }
 570+        await this.fs.writeFileAtomic(change.filePath, change.patch.newText);
 571+        result.applied.push(change);
 572+      } catch (error) {
 573+        result.errors.push({
 574+          skill: change.skill,
 575+          message: `${change.skill.name}: ${error instanceof Error ? error.message : String(error)}`,
 576+        });
 577+      }
 578+    }
 579+
 580+    return result;
 581+  }
 582+}
 583diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/command.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/command.ts
 584new file mode 100644
 585index 0000000000000000000000000000000000000000..e40a51b2bee1fb1bd842d95d264bf7495eecf41a
 586--- /dev/null
 587+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/command.ts
 588@@ -0,0 +1,75 @@
 589+import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
 590+import type { SkillInventory } from "./inventory/loader.ts";
 591+import type { SkillTogglePlanner } from "./apply/planner.ts";
 592+import type { SkillChangeWriter } from "./apply/writer.ts";
 593+import { showSkillToggleUi } from "./ui/overlay.ts";
 594+import type { ApplyResult } from "./types.ts";
 595+
 596+export interface ToggleSkillsCommandDeps {
 597+  inventory: SkillInventory;
 598+  planner: SkillTogglePlanner;
 599+  writer: SkillChangeWriter;
 600+}
 601+
 602+export async function runToggleSkillsCommand(ctx: ExtensionCommandContext, deps: ToggleSkillsCommandDeps): Promise<void> {
 603+  if (!ctx.hasUI) {
 604+    ctx.ui.notify("/toggle-skills requires interactive mode", "error");
 605+    return;
 606+  }
 607+
 608+  let skills;
 609+  try {
 610+    skills = await deps.inventory.load(ctx.cwd);
 611+  } catch (error) {
 612+    ctx.ui.notify(`Pi Skill Toggle failed to scan skills: ${error instanceof Error ? error.message : String(error)}`, "error");
 613+    return;
 614+  }
 615+
 616+  if (skills.length === 0) {
 617+    ctx.ui.notify("Pi Skill Toggle: no skills found in global, user, or project skill directories", "info");
 618+    return;
 619+  }
 620+
 621+  const result = await showSkillToggleUi(ctx, skills);
 622+  if (result.action !== "apply") return;
 623+
 624+  let changes;
 625+  try {
 626+    changes = await deps.planner.plan(skills, result.drafts);
 627+  } catch (error) {
 628+    ctx.ui.notify(`Pi Skill Toggle failed to plan changes: ${error instanceof Error ? error.message : String(error)}`, "error");
 629+    return;
 630+  }
 631+
 632+  if (changes.length === 0) {
 633+    ctx.ui.notify("Pi Skill Toggle: no changes to apply", "info");
 634+    return;
 635+  }
 636+
 637+  const applied = await deps.writer.apply(changes);
 638+  ctx.ui.notify(formatApplyResult(applied), applied.errors.length > 0 ? "warning" : "info");
 639+
 640+  if (applied.applied.length > 0) {
 641+    await ctx.reload();
 642+  }
 643+}
 644+
 645+function formatApplyResult(result: ApplyResult): string {
 646+  const lines = [`Pi Skill Toggle applied ${result.applied.length} change${result.applied.length === 1 ? "" : "s"}.`];
 647+  for (const change of result.applied.slice(0, 6)) {
 648+    lines.push(`- ${change.skill.name}: ${change.from} → ${change.to}`);
 649+  }
 650+  if (result.applied.length > 6) {
 651+    lines.push(`- … ${result.applied.length - 6} more`);
 652+  }
 653+  if (result.errors.length > 0) {
 654+    lines.push(`Errors/skipped: ${result.errors.length}`);
 655+    for (const error of result.errors.slice(0, 4)) {
 656+      lines.push(`- ${error.message}`);
 657+    }
 658+  }
 659+  if (result.applied.length > 0) {
 660+    lines.push("Reloaded skills, prompts, extensions, and themes.");
 661+  }
 662+  return lines.join("\n");
 663+}
 664diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/discovery/pi-paths.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/discovery/pi-paths.ts
 665new file mode 100644
 666index 0000000000000000000000000000000000000000..558b89f0bb0de79028ee07390c01b1388821ede1
 667--- /dev/null
 668+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/discovery/pi-paths.ts
 669@@ -0,0 +1,65 @@
 670+import { homedir } from "node:os";
 671+import { join, resolve } from "node:path";
 672+import type { SkillSource } from "../types.ts";
 673+
 674+export interface SkillRoot {
 675+  path: string;
 676+  source: SkillSource;
 677+  includeRootMarkdownFiles: boolean;
 678+}
 679+
 680+export function getAgentDir(): string {
 681+  const configured = process.env.PI_CODING_AGENT_DIR?.trim();
 682+  if (configured) return expandHome(configured);
 683+  return join(homedir(), ".pi", "agent");
 684+}
 685+
 686+export function getGlobalAgentsSkillDir(): string {
 687+  return join(homedir(), ".agents", "skills");
 688+}
 689+
 690+export function getSkillRoots(cwd: string): SkillRoot[] {
 691+  const resolvedCwd = resolve(cwd);
 692+  const userSkillRoot = join(getAgentDir(), "skills");
 693+  const globalSkillRoot = getGlobalAgentsSkillDir();
 694+  const projectSkillRoot = resolve(resolvedCwd, ".pi", "skills");
 695+  const projectLegacySkillRoot = resolve(resolvedCwd, ".agents", "skills");
 696+  const roots: SkillRoot[] = [
 697+    {
 698+      path: userSkillRoot,
 699+      source: { kind: "user", root: userSkillRoot },
 700+      includeRootMarkdownFiles: true,
 701+    },
 702+    {
 703+      path: globalSkillRoot,
 704+      source: { kind: "global", root: globalSkillRoot },
 705+      includeRootMarkdownFiles: false,
 706+    },
 707+    {
 708+      path: projectSkillRoot,
 709+      source: { kind: "project", root: projectSkillRoot },
 710+      includeRootMarkdownFiles: true,
 711+    },
 712+    // Pi also loads .agents/skills as a project skill directory. Root markdown
 713+    // files are ignored there; directories containing SKILL.md are discovered.
 714+    {
 715+      path: projectLegacySkillRoot,
 716+      source: { kind: "project-legacy", root: projectLegacySkillRoot },
 717+      includeRootMarkdownFiles: false,
 718+    },
 719+  ];
 720+
 721+  const seen = new Set<string>();
 722+  return roots.filter((root) => {
 723+    const key = resolve(root.path);
 724+    if (seen.has(key)) return false;
 725+    seen.add(key);
 726+    return true;
 727+  });
 728+}
 729+
 730+function expandHome(input: string): string {
 731+  if (input === "~") return homedir();
 732+  if (input.startsWith("~/")) return join(homedir(), input.slice(2));
 733+  return input;
 734+}
 735diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/discovery/skill-locator.test.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/discovery/skill-locator.test.ts
 736new file mode 100644
 737index 0000000000000000000000000000000000000000..b9b91dbb1c62e26c24b55113da5fd6c241aeb49d
 738--- /dev/null
 739+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/discovery/skill-locator.test.ts
 740@@ -0,0 +1,110 @@
 741+import { describe, it, beforeEach, afterEach } from "node:test";
 742+import assert from "node:assert/strict";
 743+import type { FileSystem } from "../ports/fs.ts";
 744+import { DefaultSkillLocator } from "./skill-locator.ts";
 745+
 746+const ORIGINAL_HOME = process.env.HOME;
 747+const ORIGINAL_PI_CODING_AGENT_DIR = process.env.PI_CODING_AGENT_DIR;
 748+
 749+describe("DefaultSkillLocator", () => {
 750+  beforeEach(() => {
 751+    process.env.HOME = "/home/tester";
 752+    process.env.PI_CODING_AGENT_DIR = "/home/tester/.pi/agent";
 753+  });
 754+
 755+  afterEach(() => {
 756+    restoreEnv("HOME", ORIGINAL_HOME);
 757+    restoreEnv("PI_CODING_AGENT_DIR", ORIGINAL_PI_CODING_AGENT_DIR);
 758+  });
 759+
 760+  it("finds global, user, and project skills with Pi root markdown discovery rules", async () => {
 761+    const fs = new MemoryTreeFileSystem([
 762+      "/home/tester/.pi/agent/skills/user-root.md",
 763+      "/home/tester/.agents/skills/ignored-global-root.md",
 764+      "/home/tester/.agents/skills/global-skill/SKILL.md",
 765+      "/repo/.pi/skills/project-root.md",
 766+      "/repo/.agents/skills/ignored-project-legacy-root.md",
 767+      "/repo/.agents/skills/project-legacy-skill/SKILL.md",
 768+    ]);
 769+    const locator = new DefaultSkillLocator(fs);
 770+
 771+    const files = await locator.findSkillFiles("/repo");
 772+    const byPath = new Map(files.map((file) => [file.filePath, file]));
 773+
 774+    assert.equal(byPath.get("/home/tester/.pi/agent/skills/user-root.md")?.source.kind, "user");
 775+    assert.equal(byPath.get("/home/tester/.agents/skills/global-skill/SKILL.md")?.source.kind, "global");
 776+    assert.equal(byPath.get("/repo/.pi/skills/project-root.md")?.source.kind, "project");
 777+    assert.equal(byPath.get("/repo/.agents/skills/project-legacy-skill/SKILL.md")?.source.kind, "project-legacy");
 778+    assert.equal(byPath.has("/home/tester/.agents/skills/ignored-global-root.md"), false);
 779+    assert.equal(byPath.has("/repo/.agents/skills/ignored-project-legacy-root.md"), false);
 780+  });
 781+});
 782+
 783+function restoreEnv(name: string, value: string | undefined): void {
 784+  if (value === undefined) {
 785+    delete process.env[name];
 786+  } else {
 787+    process.env[name] = value;
 788+  }
 789+}
 790+
 791+class MemoryTreeFileSystem implements FileSystem {
 792+  private readonly files = new Set<string>();
 793+  private readonly dirs = new Set<string>(["/"]);
 794+
 795+  constructor(paths: string[]) {
 796+    for (const path of paths) this.addFile(path);
 797+  }
 798+
 799+  async readFile(path: string): Promise<string> {
 800+    if (!this.files.has(path)) throw new Error(`missing file: ${path}`);
 801+    return "---\nname: test\ndescription: Test skill.\n---\n";
 802+  }
 803+
 804+  async writeFileAtomic(): Promise<void> {}
 805+
 806+  async access(path: string): Promise<boolean> {
 807+    return this.files.has(path) || this.dirs.has(path);
 808+  }
 809+
 810+  async readdir(path: string): Promise<Array<{ name: string; isDirectory: boolean; isFile: boolean; isSymbolicLink: boolean }>> {
 811+    if (!this.dirs.has(path)) throw new Error(`missing dir: ${path}`);
 812+    const prefix = path === "/" ? "/" : `${path}/`;
 813+    const names = new Set<string>();
 814+    for (const dir of this.dirs) {
 815+      if (dir === path || !dir.startsWith(prefix)) continue;
 816+      const rest = dir.slice(prefix.length);
 817+      const [name] = rest.split("/");
 818+      if (name) names.add(name);
 819+    }
 820+    for (const file of this.files) {
 821+      if (!file.startsWith(prefix)) continue;
 822+      const rest = file.slice(prefix.length);
 823+      const [name] = rest.split("/");
 824+      if (name) names.add(name);
 825+    }
 826+    return [...names].sort().map((name) => {
 827+      const fullPath = path === "/" ? `/${name}` : `${path}/${name}`;
 828+      return {
 829+        name,
 830+        isDirectory: this.dirs.has(fullPath),
 831+        isFile: this.files.has(fullPath),
 832+        isSymbolicLink: false,
 833+      };
 834+    });
 835+  }
 836+
 837+  async stat(path: string): Promise<{ isDirectory: boolean; isFile: boolean; mode: number }> {
 838+    return { isDirectory: this.dirs.has(path), isFile: this.files.has(path), mode: 0o644 };
 839+  }
 840diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/discovery/skill-locator.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/discovery/skill-locator.ts
 841new file mode 100644
 842index 0000000000000000000000000000000000000000..56adfd8b8e9012b9ce0a2f3678e2d2a12d998555
 843--- /dev/null
 844+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/discovery/skill-locator.ts
 845@@ -0,0 +1,96 @@
 846+import { constants } from "node:fs";
 847+import { join } from "node:path";
 848+import type { FileSystem } from "../ports/fs.ts";
 849+import type { LocatedSkillFile, SkillSource } from "../types.ts";
 850+import { getSkillRoots } from "./pi-paths.ts";
 851+
 852+export interface SkillLocator {
 853+  findSkillFiles(cwd: string): Promise<LocatedSkillFile[]>;
 854+}
 855+
 856+export class DefaultSkillLocator implements SkillLocator {
 857+  constructor(private readonly fs: FileSystem) {}
 858+
 859+  async findSkillFiles(cwd: string): Promise<LocatedSkillFile[]> {
 860+    const roots = getSkillRoots(cwd);
 861+    const files: LocatedSkillFile[] = [];
 862+    const seen = new Set<string>();
 863+
 864+    for (const root of roots) {
 865+      if (!(await this.fs.access(root.path))) continue;
 866+      const found = await this.scanSkillDir(root.path, root.path, root.source, root.includeRootMarkdownFiles);
 867+      for (const file of found) {
 868+        if (seen.has(file.filePath)) continue;
 869+        seen.add(file.filePath);
 870+        files.push(file);
 871+      }
 872+    }
 873+
 874+    return files.sort((a, b) => a.filePath.localeCompare(b.filePath));
 875+  }
 876+
 877+  private async scanSkillDir(
 878+    dir: string,
 879+    root: string,
 880+    source: SkillSource,
 881+    includeRootMarkdownFiles: boolean,
 882+  ): Promise<LocatedSkillFile[]> {
 883+    const out: LocatedSkillFile[] = [];
 884+    let entries: Awaited<ReturnType<FileSystem["readdir"]>>;
 885+    try {
 886+      entries = await this.fs.readdir(dir);
 887+    } catch {
 888+      return out;
 889+    }
 890+
 891+    const skillEntry = entries.find((entry) => entry.name === "SKILL.md");
 892+    if (skillEntry) {
 893+      const filePath = join(dir, "SKILL.md");
 894+      if (await this.isFile(filePath, skillEntry)) {
 895+        out.push({ filePath, source, editable: await this.isWritable(filePath) });
 896+      }
 897+      return out;
 898+    }
 899+
 900+    for (const entry of entries) {
 901+      if (entry.name.startsWith(".")) continue;
 902+      if (entry.name === "node_modules") continue;
 903+
 904+      const fullPath = join(dir, entry.name);
 905+      if (await this.isDirectory(fullPath, entry)) {
 906+        out.push(...(await this.scanSkillDir(fullPath, root, source, false)));
 907+        continue;
 908+      }
 909+
 910+      if (includeRootMarkdownFiles && entry.name.endsWith(".md") && (await this.isFile(fullPath, entry))) {
 911+        out.push({ filePath: fullPath, source, editable: await this.isWritable(fullPath) });
 912+      }
 913+    }
 914+
 915+    return out;
 916+  }
 917+
 918+  private async isDirectory(path: string, entry: { isDirectory: boolean; isSymbolicLink: boolean }): Promise<boolean> {
 919+    if (entry.isDirectory) return true;
 920+    if (!entry.isSymbolicLink) return false;
 921+    try {
 922+      return (await this.fs.stat(path)).isDirectory;
 923+    } catch {
 924+      return false;
 925+    }
 926+  }
 927+
 928+  private async isFile(path: string, entry: { isFile: boolean; isSymbolicLink: boolean }): Promise<boolean> {
 929+    if (entry.isFile) return true;
 930+    if (!entry.isSymbolicLink) return false;
 931+    try {
 932+      return (await this.fs.stat(path)).isFile;
 933+    } catch {
 934+      return false;
 935+    }
 936+  }
 937+
 938+  private async isWritable(path: string): Promise<boolean> {
 939+    return this.fs.access(path, constants.R_OK | constants.W_OK);
 940+  }
 941+}
 942diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/frontmatter/parser.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/frontmatter/parser.ts
 943new file mode 100644
 944index 0000000000000000000000000000000000000000..d77d4af24c8323524810bdf4f6a74eee2f17b926
 945--- /dev/null
 946+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/frontmatter/parser.ts
 947@@ -0,0 +1,83 @@
 948+import type { FrontmatterDocument } from "../types.ts";
 949+
 950+export interface FrontmatterCodec {
 951+  parse(raw: string): FrontmatterDocument;
 952+}
 953+
 954+export class SimpleFrontmatterCodec implements FrontmatterCodec {
 955+  parse(raw: string): FrontmatterDocument {
 956+    const lineEnding: "\n" | "\r\n" = raw.includes("\r\n") ? "\r\n" : "\n";
 957+    const opening = raw.match(/^---[ \t]*(\r?\n)/);
 958+    if (!opening) {
 959+      return {
 960+        raw,
 961+        hasFrontmatter: false,
 962+        frontmatterStart: 0,
 963+        frontmatterEnd: 0,
 964+        contentStart: 0,
 965+        frontmatterText: "",
 966+        bodyText: raw,
 967+        fields: {},
 968+        lineEnding,
 969+      };
 970+    }
 971+
 972+    const frontmatterStart = opening[0].length;
 973+    const rest = raw.slice(frontmatterStart);
 974+    const closing = /^---[ \t]*(?:\r?\n|$)/m.exec(rest);
 975+    if (!closing || closing.index === undefined) {
 976+      return {
 977+        raw,
 978+        hasFrontmatter: false,
 979+        frontmatterStart: 0,
 980+        frontmatterEnd: 0,
 981+        contentStart: 0,
 982+        frontmatterText: "",
 983+        bodyText: raw,
 984+        fields: {},
 985+        lineEnding,
 986+      };
 987+    }
 988+
 989+    const frontmatterEnd = frontmatterStart + closing.index;
 990+    const contentStart = frontmatterEnd + closing[0].length;
 991+    const frontmatterText = raw.slice(frontmatterStart, frontmatterEnd);
 992+
 993+    return {
 994+      raw,
 995+      hasFrontmatter: true,
 996+      frontmatterStart,
 997+      frontmatterEnd,
 998+      contentStart,
 999+      frontmatterText,
1000+      bodyText: raw.slice(contentStart),
1001+      fields: parseYamlLikeFields(frontmatterText),
1002+      lineEnding,
1003+    };
1004+  }
1005+}
1006+
1007+function parseYamlLikeFields(frontmatterText: string): Record<string, unknown> {
1008+  const fields: Record<string, unknown> = {};
1009+  for (const rawLine of frontmatterText.split(/\r?\n/)) {
1010+    const line = rawLine.trim();
1011+    if (!line || line.startsWith("#")) continue;
1012+    const match = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(rawLine);
1013+    if (!match) continue;
1014+    const key = match[1];
1015+    if (!key) continue;
1016+    fields[key] = parseScalar(match[2] ?? "");
1017+  }
1018+  return fields;
1019+}
1020+
1021+function parseScalar(raw: string): unknown {
1022+  const value = raw.trim();
1023+  if (value === "true") return true;
1024+  if (value === "false") return false;
1025+  if (value === "null" || value === "~") return null;
1026+  if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
1027+    return value.slice(1, -1);
1028+  }
1029+  return value;
1030+}
1031diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/frontmatter/patcher.test.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/frontmatter/patcher.test.ts
1032new file mode 100644
1033index 0000000000000000000000000000000000000000..ff451111d74a1d1853d2b9ace3ebd0d4b337903a
1034--- /dev/null
1035+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/frontmatter/patcher.test.ts
1036@@ -0,0 +1,75 @@
1037+import { describe, it } from "node:test";
1038+import assert from "node:assert/strict";
1039+import { SimpleFrontmatterCodec } from "./parser.ts";
1040+import { MinimalFrontmatterPatcher } from "./patcher.ts";
1041+import { getDuplicateFrontmatterKeys } from "./validation.ts";
1042+
1043+const codec = new SimpleFrontmatterCodec();
1044+const patcher = new MinimalFrontmatterPatcher();
1045+
1046+describe("MinimalFrontmatterPatcher", () => {
1047+  it("does not add a second disable-model-invocation key when a skill is already manual-only", () => {
1048+    const raw = [
1049+      "---",
1050+      "name: handoff",
1051+      "description: Compact the conversation.",
1052+      "disable-model-invocation: true",
1053+      "argument-hint: What next?",
1054+      "---",
1055+      "",
1056+      "# Handoff",
1057+      "",
1058+    ].join("\n");
1059+
1060+    const patch = patcher.patchInvocationMode(codec.parse(raw), "manual-only");
1061+
1062+    assert.equal(patch.newText, raw);
1063+    assert.equal(countDisableKeys(patch.newText), 1);
1064+  });
1065+
1066+  it("collapses duplicated disable-model-invocation keys when setting manual-only", () => {
1067+    const raw = [
1068+      "---",
1069+      "name: handoff",
1070+      "description: Compact the conversation.",
1071+      "disable-model-invocation: true",
1072+      "argument-hint: What next?",
1073+      "disable-model-invocation: true",
1074+      "---",
1075+      "",
1076+      "# Handoff",
1077+      "",
1078+    ].join("\n");
1079+
1080+    const patch = patcher.patchInvocationMode(codec.parse(raw), "manual-only");
1081+
1082+    assert.equal(countDisableKeys(patch.newText), 1);
1083+    assert.deepEqual(getDuplicateFrontmatterKeys(codec.parse(patch.newText)), []);
1084+    assert.ok(patch.newText.includes("disable-model-invocation: true\nargument-hint: What next?\n---"));
1085+  });
1086+
1087+  it("removes all disable-model-invocation keys when setting agent-invocable", () => {
1088+    const raw = [
1089+      "---",
1090+      "name: handoff",
1091+      "description: Compact the conversation.",
1092+      "disable-model-invocation: true",
1093+      "argument-hint: What next?",
1094+      "disable-model-invocation: true",
1095+      "---",
1096+      "",
1097+      "# Handoff",
1098+      "",
1099+    ].join("\n");
1100+
1101+    const patch = patcher.patchInvocationMode(codec.parse(raw), "agent-invocable");
1102+
1103+    assert.equal(countDisableKeys(patch.newText), 0);
1104+    assert.deepEqual(getDuplicateFrontmatterKeys(codec.parse(patch.newText)), []);
1105+    assert.ok(patch.newText.includes("argument-hint: What next?\n---"));
1106+  });
1107+});
1108+
1109+function countDisableKeys(raw: string): number {
1110+  return (raw.match(/^disable-model-invocation\s*:/gm) ?? []).length;
1111+}
1112diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/frontmatter/patcher.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/frontmatter/patcher.ts
1113new file mode 100644
1114index 0000000000000000000000000000000000000000..b759e0d100a9f8fe24d9257e94c6e96bcd0ca437
1115--- /dev/null
1116+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/frontmatter/patcher.ts
1117@@ -0,0 +1,79 @@
1118+import type { FrontmatterDocument, FrontmatterPatch, SkillInvocationMode } from "../types.ts";
1119+
1120+const DISABLE_KEY = "disable-model-invocation";
1121+const DISABLE_KEY_RE = /^\s*disable-model-invocation\s*:/;
1122+
1123+export interface FrontmatterPatcher {
1124+  patchInvocationMode(doc: FrontmatterDocument, desiredMode: SkillInvocationMode): FrontmatterPatch;
1125+}
1126+
1127+export class MinimalFrontmatterPatcher implements FrontmatterPatcher {
1128+  patchInvocationMode(doc: FrontmatterDocument, desiredMode: SkillInvocationMode): FrontmatterPatch {
1129+    if (!doc.hasFrontmatter) {
1130+      const newFrontmatter = desiredMode === "manual-only" ? `${DISABLE_KEY}: true${doc.lineEnding}` : "";
1131+      const newText = newFrontmatter
1132+        ? `---${doc.lineEnding}${newFrontmatter}---${doc.lineEnding}${doc.raw}`
1133+        : doc.raw;
1134+      return { oldText: doc.raw, newText };
1135+    }
1136+
1137+    const newFrontmatter = desiredMode === "manual-only"
1138+      ? ensureManualOnly(doc.frontmatterText, doc.lineEnding)
1139+      : ensureAgentInvocable(doc.frontmatterText);
1140+
1141+    const newText = doc.raw.slice(0, doc.frontmatterStart) + newFrontmatter + doc.raw.slice(doc.frontmatterEnd);
1142+    return { oldText: doc.raw, newText };
1143+  }
1144+}
1145+
1146+function ensureManualOnly(frontmatterText: string, lineEnding: "\n" | "\r\n"): string {
1147+  const lines = splitLinesPreserve(frontmatterText, lineEnding);
1148+  let replaced = false;
1149+  const next: string[] = [];
1150+
1151+  for (const line of lines) {
1152+    if (DISABLE_KEY_RE.test(stripEol(line))) {
1153+      if (!replaced) {
1154+        next.push(`${DISABLE_KEY}: true${getEol(line) || lineEnding}`);
1155+        replaced = true;
1156+      }
1157+      continue;
1158+    }
1159+    next.push(line);
1160+  }
1161+
1162+  if (!replaced) {
1163+    if (next.length > 0 && !endsWithEol(next[next.length - 1]!)) {
1164+      next[next.length - 1] = `${next[next.length - 1]}${lineEnding}`;
1165+    }
1166+    next.push(`${DISABLE_KEY}: true${lineEnding}`);
1167+  }
1168+
1169+  return next.join("");
1170+}
1171+
1172+function ensureAgentInvocable(frontmatterText: string): string {
1173+  return splitLinesPreserve(frontmatterText, frontmatterText.includes("\r\n") ? "\r\n" : "\n")
1174+    .filter((line) => !DISABLE_KEY_RE.test(stripEol(line)))
1175+    .join("");
1176+}
1177+
1178+function splitLinesPreserve(text: string, fallbackEol: "\n" | "\r\n"): string[] {
1179+  if (text.length === 0) return [];
1180+  const lines = text.match(/.*(?:\r?\n|$)/g)?.filter((line) => line.length > 0) ?? [text];
1181+  return lines.length > 0 ? lines : [fallbackEol];
1182+}
1183+
1184+function getEol(line: string): string {
1185+  if (line.endsWith("\r\n")) return "\r\n";
1186+  if (line.endsWith("\n")) return "\n";
1187+  return "";
1188+}
1189+
1190+function stripEol(line: string): string {
1191+  return line.replace(/\r?\n$/, "");
1192+}
1193+
1194+function endsWithEol(line: string): boolean {
1195+  return line.endsWith("\n");
1196+}
1197diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/frontmatter/validation.test.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/frontmatter/validation.test.ts
1198new file mode 100644
1199index 0000000000000000000000000000000000000000..94f13c956448462b184e49d2b898f2b6188ab8c6
1200--- /dev/null
1201+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/frontmatter/validation.test.ts
1202@@ -0,0 +1,27 @@
1203+import { describe, it } from "node:test";
1204+import assert from "node:assert/strict";
1205+import { SimpleFrontmatterCodec } from "./parser.ts";
1206+import { deriveSkillMetadata, getDuplicateFrontmatterKeys, hasDuplicateDisableModelInvocation } from "./validation.ts";
1207+
1208+const codec = new SimpleFrontmatterCodec();
1209+
1210+describe("frontmatter validation", () => {
1211+  it("reports duplicate top-level frontmatter keys", () => {
1212+    const doc = codec.parse([
1213+      "---",
1214+      "name: handoff",
1215+      "description: Compact the conversation.",
1216+      "disable-model-invocation: true",
1217+      "argument-hint: What next?",
1218+      "disable-model-invocation: true",
1219+      "---",
1220+      "",
1221+    ].join("\n"));
1222+
1223+    assert.deepEqual(getDuplicateFrontmatterKeys(doc), ["disable-model-invocation"]);
1224+    assert.equal(hasDuplicateDisableModelInvocation(doc), true);
1225+    assert.deepEqual(deriveSkillMetadata("/skills/handoff/SKILL.md", doc).diagnostics, [
1226+      { severity: "warning", message: "Duplicate frontmatter key: disable-model-invocation" },
1227+    ]);
1228+  });
1229+});
1230diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/frontmatter/validation.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/frontmatter/validation.ts
1231new file mode 100644
1232index 0000000000000000000000000000000000000000..11fa2fe5dbbbadb955932ec59de2af04d64e34ab
1233--- /dev/null
1234+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/frontmatter/validation.ts
1235@@ -0,0 +1,70 @@
1236+import { basename, dirname } from "node:path";
1237+import type { FrontmatterDocument, SkillDiagnostic } from "../types.ts";
1238+
1239+const FRONTMATTER_KEY_RE = /^([A-Za-z0-9_-]+)\s*:/;
1240+
1241+export function deriveSkillMetadata(filePath: string, doc: FrontmatterDocument): {
1242+  name: string;
1243+  description: string;
1244+  diagnostics: SkillDiagnostic[];
1245+} {
1246+  const diagnostics: SkillDiagnostic[] = [];
1247+  const parentDirName = basename(dirname(filePath));
1248+  const name = stringField(doc.fields.name) || parentDirName;
1249+  const description = stringField(doc.fields.description);
1250+
1251+  if (!doc.hasFrontmatter) {
1252+    diagnostics.push({ severity: "warning", message: "Missing YAML front matter" });
1253+  }
1254+
1255+  const duplicateKeys = getDuplicateFrontmatterKeys(doc);
1256+  if (duplicateKeys.length > 0) {
1257+    diagnostics.push({
1258+      severity: "warning",
1259+      message: `Duplicate frontmatter key${duplicateKeys.length === 1 ? "" : "s"}: ${duplicateKeys.join(", ")}`,
1260+    });
1261+  }
1262+
1263+  if (!description) {
1264+    diagnostics.push({ severity: "error", message: "Missing required description; Pi will not load this skill" });
1265+  }
1266+  if (name !== parentDirName && basename(filePath) === "SKILL.md") {
1267+    diagnostics.push({ severity: "warning", message: `Name does not match parent directory (${parentDirName})` });
1268+  }
1269+
1270+  return { name, description: description || "", diagnostics };
1271+}
1272+
1273+export function getDisableModelInvocation(doc: FrontmatterDocument): boolean {
1274+  return doc.fields["disable-model-invocation"] === true;
1275+}
1276+
1277+export function hasDuplicateDisableModelInvocation(doc: FrontmatterDocument): boolean {
1278+  return (getTopLevelFrontmatterKeyCounts(doc).get("disable-model-invocation") ?? 0) > 1;
1279+}
1280+
1281+export function getDuplicateFrontmatterKeys(doc: FrontmatterDocument): string[] {
1282+  return [...getTopLevelFrontmatterKeyCounts(doc)]
1283+    .filter(([, count]) => count > 1)
1284+    .map(([key]) => key)
1285+    .sort();
1286+}
1287+
1288+function getTopLevelFrontmatterKeyCounts(doc: FrontmatterDocument): Map<string, number> {
1289+  const counts = new Map<string, number>();
1290+  if (!doc.hasFrontmatter) return counts;
1291+
1292+  for (const rawLine of doc.frontmatterText.split(/\r?\n/)) {
1293+    const trimmed = rawLine.trim();
1294+    if (!trimmed || trimmed.startsWith("#")) continue;
1295+    const match = FRONTMATTER_KEY_RE.exec(rawLine);
1296+    if (!match?.[1]) continue;
1297+    counts.set(match[1], (counts.get(match[1]) ?? 0) + 1);
1298+  }
1299+
1300+  return counts;
1301+}
1302+
1303+function stringField(value: unknown): string {
1304+  return typeof value === "string" ? value.trim() : "";
1305+}
1306diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/index.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/index.ts
1307new file mode 100644
1308index 0000000000000000000000000000000000000000..eb7c46378235b433e709786061544dc72362f3be
1309--- /dev/null
1310+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/index.ts
1311@@ -0,0 +1,26 @@
1312+import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1313+import { AtomicSkillChangeWriter } from "./apply/writer.ts";
1314+import { DefaultSkillTogglePlanner } from "./apply/planner.ts";
1315+import { DefaultSkillLocator } from "./discovery/skill-locator.ts";
1316+import { MinimalFrontmatterPatcher } from "./frontmatter/patcher.ts";
1317+import { SimpleFrontmatterCodec } from "./frontmatter/parser.ts";
1318+import { DefaultSkillInventory } from "./inventory/loader.ts";
1319+import { NodeFileSystem } from "./ports/fs.ts";
1320+import { runToggleSkillsCommand } from "./command.ts";
1321+
1322+export default function piSkillToggle(pi: ExtensionAPI) {
1323+  const fs = new NodeFileSystem();
1324+  const codec = new SimpleFrontmatterCodec();
1325+  const patcher = new MinimalFrontmatterPatcher();
1326+  const locator = new DefaultSkillLocator(fs);
1327+  const inventory = new DefaultSkillInventory(locator, fs, codec);
1328+  const planner = new DefaultSkillTogglePlanner(fs, codec, patcher);
1329+  const writer = new AtomicSkillChangeWriter(fs);
1330+
1331+  pi.registerCommand("toggle-skills", {
1332+    description: "Toggle whether skills are agent-invocable or manual-only",
1333+    handler: async (_args, ctx) => {
1334+      await runToggleSkillsCommand(ctx, { inventory, planner, writer });
1335+    },
1336+  });
1337+}
1338diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/inventory/classifier.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/inventory/classifier.ts
1339new file mode 100644
1340index 0000000000000000000000000000000000000000..12b100b50a072defa6d8a541c4e6c88f6acabb0a
1341--- /dev/null
1342+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/inventory/classifier.ts
1343@@ -0,0 +1,31 @@
1344+import type { FrontmatterDocument, SkillInvocationMode, SkillSource } from "../types.ts";
1345+import { getDisableModelInvocation } from "../frontmatter/validation.ts";
1346+
1347+export function classifyInvocationMode(doc: FrontmatterDocument): SkillInvocationMode {
1348+  return getDisableModelInvocation(doc) ? "manual-only" : "agent-invocable";
1349+}
1350+
1351+export function formatSourceKind(kind: SkillSource["kind"] | string): string {
1352+  switch (kind) {
1353+    case "global":
1354+      return "Global";
1355+    case "user":
1356+      return "User";
1357+    case "project":
1358+      return "Project";
1359+    case "project-legacy":
1360+      return "Project (.agents)";
1361+    default:
1362+      return "Unknown";
1363+  }
1364+}
1365+
1366+export function sourceCategory(source: SkillSource): "global" | "user" | "project" | "unknown" {
1367+  if (source.kind === "project-legacy") return "project";
1368+  if (source.kind === "global" || source.kind === "user" || source.kind === "project") return source.kind;
1369+  return "unknown";
1370+}
1371+
1372+export function sourceBadge(source: SkillSource): string {
1373+  return sourceCategory(source);
1374+}
1375diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/inventory/loader.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/inventory/loader.ts
1376new file mode 100644
1377index 0000000000000000000000000000000000000000..2a8f0aace794e8019be2b564311fd1fb0f4948d8
1378--- /dev/null
1379+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/inventory/loader.ts
1380@@ -0,0 +1,57 @@
1381+import { dirname } from "node:path";
1382+import type { FileSystem } from "../ports/fs.ts";
1383+import type { FrontmatterCodec } from "../frontmatter/parser.ts";
1384+import type { SkillLocator } from "../discovery/skill-locator.ts";
1385+import type { SkillRecord } from "../types.ts";
1386+import { deriveSkillMetadata } from "../frontmatter/validation.ts";
1387+import { classifyInvocationMode } from "./classifier.ts";
1388+
1389+export interface SkillInventory {
1390+  load(cwd: string): Promise<SkillRecord[]>;
1391+}
1392+
1393+export class DefaultSkillInventory implements SkillInventory {
1394+  constructor(
1395+    private readonly locator: SkillLocator,
1396+    private readonly fs: FileSystem,
1397+    private readonly codec: FrontmatterCodec,
1398+  ) {}
1399+
1400+  async load(cwd: string): Promise<SkillRecord[]> {
1401+    const located = await this.locator.findSkillFiles(cwd);
1402+    const records: SkillRecord[] = [];
1403+
1404+    for (const file of located) {
1405+      try {
1406+        const raw = await this.fs.readFile(file.filePath);
1407+        const doc = this.codec.parse(raw);
1408+        const metadata = deriveSkillMetadata(file.filePath, doc);
1409+        records.push({
1410+          id: file.filePath,
1411+          name: metadata.name,
1412+          description: metadata.description,
1413+          filePath: file.filePath,
1414+          baseDir: dirname(file.filePath),
1415+          source: file.source,
1416+          editable: file.editable && doc.hasFrontmatter && !metadata.diagnostics.some((d) => d.severity === "error"),
1417+          mode: classifyInvocationMode(doc),
1418+          diagnostics: metadata.diagnostics,
1419+        });
1420+      } catch (error) {
1421+        records.push({
1422+          id: file.filePath,
1423+          name: file.filePath.split("/").at(-2) ?? file.filePath,
1424+          description: "",
1425+          filePath: file.filePath,
1426+          baseDir: dirname(file.filePath),
1427+          source: file.source,
1428+          editable: false,
1429+          mode: "agent-invocable",
1430+          diagnostics: [{ severity: "error", message: error instanceof Error ? error.message : String(error) }],
1431+        });
1432+      }
1433+    }
1434+
1435+    return records.sort((a, b) => a.name.localeCompare(b.name) || a.filePath.localeCompare(b.filePath));
1436+  }
1437+}
1438diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/ports/fs.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/ports/fs.ts
1439new file mode 100644
1440index 0000000000000000000000000000000000000000..b2d0d1a22ba7a7fbdb48b1c3d193a21f7bfc6dcf
1441--- /dev/null
1442+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/ports/fs.ts
1443@@ -0,0 +1,58 @@
1444+import { constants } from "node:fs";
1445+import fs from "node:fs/promises";
1446+import { dirname, join } from "node:path";
1447+
1448+export interface FileSystem {
1449+  readFile(path: string): Promise<string>;
1450+  writeFileAtomic(path: string, content: string): Promise<void>;
1451+  access(path: string, mode?: number): Promise<boolean>;
1452+  readdir(path: string): Promise<Array<{ name: string; isDirectory: boolean; isFile: boolean; isSymbolicLink: boolean }>>;
1453+  stat(path: string): Promise<{ isDirectory: boolean; isFile: boolean; mode: number }>;
1454+}
1455+
1456+export class NodeFileSystem implements FileSystem {
1457+  async readFile(path: string): Promise<string> {
1458+    return fs.readFile(path, "utf8");
1459+  }
1460+
1461+  async writeFileAtomic(path: string, content: string): Promise<void> {
1462+    const dir = dirname(path);
1463+    const tmp = join(dir, `.pi-skill-toggle-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`);
1464+    let mode: number | undefined;
1465+    try {
1466+      mode = (await fs.stat(path)).mode;
1467+    } catch {
1468+      mode = undefined;
1469+    }
1470+
1471+    await fs.writeFile(tmp, content, "utf8");
1472+    if (mode !== undefined) {
1473+      await fs.chmod(tmp, mode);
1474+    }
1475+    await fs.rename(tmp, path);
1476+  }
1477+
1478+  async access(path: string, mode = constants.F_OK): Promise<boolean> {
1479+    try {
1480+      await fs.access(path, mode);
1481+      return true;
1482+    } catch {
1483+      return false;
1484+    }
1485+  }
1486+
1487+  async readdir(path: string): Promise<Array<{ name: string; isDirectory: boolean; isFile: boolean; isSymbolicLink: boolean }>> {
1488+    const entries = await fs.readdir(path, { withFileTypes: true });
1489+    return entries.map((entry) => ({
1490+      name: entry.name,
1491+      isDirectory: entry.isDirectory(),
1492+      isFile: entry.isFile(),
1493+      isSymbolicLink: entry.isSymbolicLink(),
1494+    }));
1495+  }
1496+
1497+  async stat(path: string): Promise<{ isDirectory: boolean; isFile: boolean; mode: number }> {
1498+    const stats = await fs.stat(path);
1499+    return { isDirectory: stats.isDirectory(), isFile: stats.isFile(), mode: stats.mode };
1500+  }
1501+}
1502diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/types.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/types.ts
1503new file mode 100644
1504index 0000000000000000000000000000000000000000..09e91ba91d1bcf336dbf819175b0735406eaa968
1505--- /dev/null
1506+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/types.ts
1507@@ -0,0 +1,74 @@
1508+export type SkillInvocationMode = "agent-invocable" | "manual-only";
1509+
1510+export type SkillSource =
1511+  | { kind: "global"; root: string }
1512+  | { kind: "user"; root: string }
1513+  | { kind: "project"; root: string }
1514+  | { kind: "project-legacy"; root: string }
1515+  | { kind: "unknown"; root: string };
1516+
1517+export interface LocatedSkillFile {
1518+  filePath: string;
1519+  source: SkillSource;
1520+  editable: boolean;
1521+}
1522+
1523+export type SkillDiagnosticSeverity = "info" | "warning" | "error";
1524+
1525+export interface SkillDiagnostic {
1526+  severity: SkillDiagnosticSeverity;
1527+  message: string;
1528+}
1529+
1530+export interface SkillRecord {
1531+  id: string;
1532+  name: string;
1533+  description: string;
1534+  filePath: string;
1535+  baseDir: string;
1536+  source: SkillSource;
1537+  editable: boolean;
1538+  mode: SkillInvocationMode;
1539+  diagnostics: SkillDiagnostic[];
1540+}
1541+
1542+export interface SkillDraft {
1543+  skill: SkillRecord;
1544+  desiredMode: SkillInvocationMode;
1545+}
1546+
1547+export interface FrontmatterDocument {
1548+  raw: string;
1549+  hasFrontmatter: boolean;
1550+  frontmatterStart: number;
1551+  frontmatterEnd: number;
1552+  contentStart: number;
1553+  frontmatterText: string;
1554+  bodyText: string;
1555+  fields: Record<string, unknown>;
1556+  lineEnding: "\n" | "\r\n";
1557+}
1558+
1559+export interface FrontmatterPatch {
1560+  oldText: string;
1561+  newText: string;
1562+}
1563+
1564+export interface SkillChange {
1565+  skill: SkillRecord;
1566+  filePath: string;
1567+  from: SkillInvocationMode;
1568+  to: SkillInvocationMode;
1569+  patch: FrontmatterPatch;
1570+}
1571+
1572+export interface ApplyResult {
1573+  applied: SkillChange[];
1574+  skipped: Array<{ skill: SkillRecord; reason: string }>;
1575+  errors: Array<{ skill?: SkillRecord; message: string }>;
1576+}
1577+
1578+export interface SkillToggleUiResult {
1579+  action: "apply" | "cancel";
1580+  drafts: SkillDraft[];
1581+}
1582diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/ui/overlay.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/ui/overlay.ts
1583new file mode 100644
1584index 0000000000000000000000000000000000000000..2f980c6a9c083d93906dc08466a5ff7c6b6d2cc3
1585--- /dev/null
1586+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/ui/overlay.ts
1587@@ -0,0 +1,262 @@
1588+import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
1589+import { Key, matchesKey, type TUI } from "@earendil-works/pi-tui";
1590+import type { SkillInvocationMode, SkillRecord, SkillToggleUiResult } from "../types.ts";
1591+import { formatSourceKind } from "../inventory/classifier.ts";
1592+import { bottomBorder, combineColumns, divider, fit, frameLine, topBorder } from "./render.ts";
1593+import { filterSkills, modeLabel, toggleMode } from "./view-model.ts";
1594+
1595+export async function showSkillToggleUi(ctx: ExtensionContext, skills: SkillRecord[]): Promise<SkillToggleUiResult> {
1596+  return ctx.ui.custom<SkillToggleUiResult>(
1597+    (tui, theme, _keybindings, done) => new SkillToggleOverlay(tui, theme, skills, done),
1598+    {
1599+      overlay: true,
1600+      overlayOptions: {
1601+        anchor: "center",
1602+        width: "92%",
1603+        maxHeight: "88%",
1604+        minWidth: 86,
1605+      },
1606+    },
1607+  );
1608+}
1609+
1610+class SkillToggleOverlay {
1611+  private readonly desired = new Map<string, SkillInvocationMode>();
1612+  private search = "";
1613+  private selectedIndex = 0;
1614+
1615+  constructor(
1616+    private readonly tui: TUI,
1617+    private readonly theme: Theme,
1618+    private readonly skills: SkillRecord[],
1619+    private readonly done: (result: SkillToggleUiResult) => void,
1620+  ) {
1621+    for (const skill of skills) this.desired.set(skill.id, skill.mode);
1622+  }
1623+
1624+  handleInput(data: string): void {
1625+    if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
1626+      this.done({ action: "cancel", drafts: this.getDrafts() });
1627+      return;
1628+    }
1629+
1630+    if (matchesKey(data, Key.ctrl("s"))) {
1631+      this.done({ action: "apply", drafts: this.getDrafts() });
1632+      return;
1633+    }
1634+
1635+    if (matchesKey(data, Key.up)) {
1636+      this.moveSelection(-1);
1637+      return;
1638+    }
1639+
1640+    if (matchesKey(data, Key.down)) {
1641+      this.moveSelection(1);
1642+      return;
1643+    }
1644+
1645+    if (matchesKey(data, Key.space)) {
1646+      const selected = this.getSelectedSkill();
1647+      if (selected?.editable) {
1648+        this.desired.set(selected.id, toggleMode(this.desired.get(selected.id) ?? selected.mode));
1649+        this.tui.requestRender();
1650+      }
1651+      return;
1652+    }
1653+
1654+    if (matchesKey(data, Key.backspace)) {
1655+      if (this.search.length > 0) {
1656+        this.search = Array.from(this.search).slice(0, -1).join("");
1657+        this.selectedIndex = 0;
1658+        this.tui.requestRender();
1659+      }
1660+      return;
1661+    }
1662+
1663+    if (isPrintableInput(data)) {
1664+      this.search += data;
1665+      this.selectedIndex = 0;
1666+      this.tui.requestRender();
1667+    }
1668+  }
1669+
1670+  render(width: number): string[] {
1671+    const innerWidth = Math.max(20, width - 2);
1672+    const panelHeight = this.getPanelHeight();
1673+    const bodyHeight = Math.max(10, panelHeight - 8);
1674+    const leftWidth = Math.max(32, Math.floor((innerWidth - 1) * 0.48));
1675+    const rightWidth = Math.max(28, innerWidth - leftWidth - 1);
1676+
1677+    const header = this.renderHeader(innerWidth);
1678+    const search = frameLine(this.theme, this.theme.fg("muted", `Search: ${this.search || "(type to filter)"}`), innerWidth);
1679+    const body = combineColumns(
1680+      this.renderList(leftWidth, bodyHeight),
1681+      this.renderDetails(rightWidth, bodyHeight),
1682+      leftWidth,
1683+      rightWidth,
1684+      this.theme.fg("borderMuted", "│"),
1685+    ).map((line) => frameLine(this.theme, line, innerWidth));
1686+
1687diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/ui/render.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/ui/render.ts
1688new file mode 100644
1689index 0000000000000000000000000000000000000000..afe7823eb6130867a3054b0a52a4728c59049806
1690--- /dev/null
1691+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/ui/render.ts
1692@@ -0,0 +1,33 @@
1693+import type { Theme } from "@earendil-works/pi-coding-agent";
1694+import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
1695+
1696+export function fit(text: string, width: number): string {
1697+  const truncated = truncateToWidth(text, Math.max(0, width));
1698+  const padding = Math.max(0, width - visibleWidth(truncated));
1699+  return `${truncated}${" ".repeat(padding)}`;
1700+}
1701+
1702+export function frameLine(theme: Theme, content: string, innerWidth: number): string {
1703+  return `${theme.fg("borderAccent", "│")}${fit(content, innerWidth)}${theme.fg("borderAccent", "│")}`;
1704+}
1705+
1706+export function divider(theme: Theme, innerWidth: number): string {
1707+  return theme.fg("borderMuted", `├${"─".repeat(innerWidth)}┤`);
1708+}
1709+
1710+export function topBorder(theme: Theme, innerWidth: number): string {
1711+  return theme.fg("borderAccent", `┌${"─".repeat(innerWidth)}┐`);
1712+}
1713+
1714+export function bottomBorder(theme: Theme, innerWidth: number): string {
1715+  return theme.fg("borderAccent", `└${"─".repeat(innerWidth)}┘`);
1716+}
1717+
1718+export function combineColumns(left: string[], right: string[], leftWidth: number, rightWidth: number, sep: string): string[] {
1719+  const rows = Math.max(left.length, right.length);
1720+  const lines: string[] = [];
1721+  for (let i = 0; i < rows; i += 1) {
1722+    lines.push(`${fit(left[i] ?? "", leftWidth)}${sep}${fit(right[i] ?? "", rightWidth)}`);
1723+  }
1724+  return lines;
1725+}
1726diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/ui/view-model.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/ui/view-model.ts
1727new file mode 100644
1728index 0000000000000000000000000000000000000000..8210d53012199728d1ad0862f0ea791ae6613f44
1729--- /dev/null
1730+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/ui/view-model.ts
1731@@ -0,0 +1,33 @@
1732+import { formatSourceKind, sourceBadge } from "../inventory/classifier.ts";
1733+import type { SkillInvocationMode, SkillRecord } from "../types.ts";
1734+
1735+export function modeLabel(mode: SkillInvocationMode): string {
1736+  return mode === "manual-only" ? "Manual-only" : "Agent-invocable";
1737+}
1738+
1739+export function toggleMode(mode: SkillInvocationMode): SkillInvocationMode {
1740+  return mode === "manual-only" ? "agent-invocable" : "manual-only";
1741+}
1742+
1743+export function skillSearchText(skill: SkillRecord): string {
1744+  return [
1745+    skill.name,
1746+    skill.description,
1747+    skill.filePath,
1748+    skill.source.kind,
1749+    sourceBadge(skill.source),
1750+    formatSourceKind(skill.source.kind),
1751+    modeLabel(skill.mode),
1752+  ]
1753+    .join(" ")
1754+    .toLowerCase();
1755+}
1756+
1757+export function filterSkills(skills: SkillRecord[], query: string): SkillRecord[] {
1758+  const tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
1759+  if (tokens.length === 0) return skills;
1760+  return skills.filter((skill) => {
1761+    const haystack = skillSearchText(skill);
1762+    return tokens.every((token) => haystack.includes(token));
1763+  });
1764+}
1765diff --git a/dot_pi/agent/extensions/pi-skill-toggle/tsconfig.json b/dot_pi/agent/extensions/pi-skill-toggle/tsconfig.json
1766new file mode 100644
1767index 0000000000000000000000000000000000000000..b6a89d12238110f9ee68f67b9f15eee3b4f05c6c
1768--- /dev/null
1769+++ b/dot_pi/agent/extensions/pi-skill-toggle/tsconfig.json
1770@@ -0,0 +1,14 @@
1771+{
1772+  "compilerOptions": {
1773+    "target": "ESNext",
1774+    "module": "NodeNext",
1775+    "moduleResolution": "NodeNext",
1776+    "lib": ["ESNext"],
1777+    "allowImportingTsExtensions": true,
1778+    "noEmit": true,
1779+    "strict": true,
1780+    "skipLibCheck": true,
1781+    "types": ["node"]
1782+  },
1783+  "include": ["./src/**/*.ts"]
1784+}
1785diff --git a/dot_pi/agent/extensions/pi-tree-pruning/index.ts b/dot_pi/agent/extensions/pi-tree-pruning/index.ts
1786new file mode 100644
1787index 0000000000000000000000000000000000000000..4a95e8325e9b1634fb628d27e4e8b7734d5d1f85
1788--- /dev/null
1789+++ b/dot_pi/agent/extensions/pi-tree-pruning/index.ts
1790@@ -0,0 +1,181 @@
1791+import { basename } from "node:path";
1792+import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
1793+import {
1794+  captureSessionFile,
1795+  createBackupPath,
1796+  listSessionBackups,
1797+  pruneSessionFile,
1798+  restoreSessionBackup,
1799+} from "./session-file.ts";
1800+import { selectPruneCandidate } from "./prune-selector.ts";
1801+import { findInactiveBranchCandidates } from "./tree-model.ts";
1802+import { buildPruneTreeItems, formatEntryCount } from "./tree-view.ts";
1803+
1804+const COMMAND_NAME = "tree-prune";
1805+
1806+export default function registerTreePruning(pi: ExtensionAPI): void {
1807+  pi.registerCommand(COMMAND_NAME, {
1808+    description: "Delete an inactive session-tree branch with a backup; use /tree-prune undo to restore",
1809+    handler: async (args, ctx) => {
1810+      await runTreePruneCommand(args, ctx);
1811+    },
1812+  });
1813+}
1814+
1815+export async function runTreePruneCommand(
1816+  args: string,
1817+  ctx: ExtensionCommandContext,
1818+): Promise<void> {
1819+  const mode = args.trim();
1820+
1821+  if (!ctx.hasUI) {
1822+    ctx.ui.notify("/tree-prune requires a user interface", "error");
1823+    return;
1824+  }
1825+
1826+  if (mode === "") {
1827+    await runPrune(ctx);
1828+    return;
1829+  }
1830+
1831+  if (mode === "undo") {
1832+    await runUndo(ctx);
1833+    return;
1834+  }
1835+
1836+  ctx.ui.notify("Usage: /tree-prune [undo]", "error");
1837+}
1838+
1839+async function runPrune(ctx: ExtensionCommandContext): Promise<void> {
1840+  try {
1841+    await ctx.waitForIdle();
1842+
1843+    const sessionFile = requireSessionFile(ctx);
1844+    const activeLeafId = ctx.sessionManager.getLeafId();
1845+    if (!activeLeafId) {
1846+      ctx.ui.notify("Tree Prune needs a current active leaf", "warning");
1847+      return;
1848+    }
1849+
1850+    const entries = ctx.sessionManager.getEntries();
1851+    if (!entries.some((entry) => entry.id === activeLeafId)) {
1852+      ctx.ui.notify("Tree Prune cannot find the active leaf in this session", "error");
1853+      return;
1854+    }
1855+
1856+    const snapshot = await captureSessionFile(sessionFile);
1857+    const candidates = findInactiveBranchCandidates(entries, activeLeafId);
1858+    if (candidates.length === 0) {
1859+      ctx.ui.notify("Tree Prune found no inactive branches", "info");
1860+      return;
1861+    }
1862+
1863+    const treeItems = buildPruneTreeItems(candidates, (entryId) => ctx.sessionManager.getLabel(entryId));
1864+    const candidate = await selectPruneCandidate(ctx, treeItems);
1865+    if (!candidate) return;
1866+
1867+    const backupPath = createBackupPath(sessionFile);
1868+    const confirmed = await ctx.ui.confirm(
1869+      "Delete inactive branch?",
1870+      [
1871+        `Branch: ${candidate.preview}`,
1872+        `Entries to delete: ${candidate.entryCount}`,
1873+        `Backup: ${backupPath}`,
1874+        "The branch will be removed from this session file.",
1875+      ].join("\n"),
1876+    );
1877+    if (!confirmed) return;
1878+
1879+    const result = await pruneSessionFile(snapshot, candidate, backupPath);
1880+    const switchResult = await ctx.switchSession(sessionFile, {
1881+      withSession: async (replacement) => {
1882+        replacement.ui.notify(
1883+          `Tree Prune removed ${formatEntryCount(result.removedEntryCount)}. Backup: ${result.backupPath}`,
1884+          "info",
1885+        );
1886+      },
1887+    });
1888+
1889+    if (switchResult.cancelled) {
1890diff --git a/dot_pi/agent/extensions/pi-tree-pruning/package.json b/dot_pi/agent/extensions/pi-tree-pruning/package.json
1891new file mode 100644
1892index 0000000000000000000000000000000000000000..bbcf59847315555fa7bb05f4482ed9c159ff9815
1893--- /dev/null
1894+++ b/dot_pi/agent/extensions/pi-tree-pruning/package.json
1895@@ -0,0 +1,24 @@
1896+{
1897+  "name": "pi-tree-pruning",
1898+  "private": true,
1899+  "version": "0.1.0",
1900+  "type": "module",
1901+  "scripts": {
1902+    "test": "node --test test/*.test.ts",
1903+    "typecheck": "tsc --noEmit",
1904+    "check": "npm run typecheck && npm test"
1905+  },
1906+  "pi": {
1907+    "extensions": [
1908+      "./index.ts"
1909+    ]
1910+  },
1911+  "peerDependencies": {
1912+    "@earendil-works/pi-coding-agent": "*",
1913+    "@earendil-works/pi-tui": "*"
1914+  },
1915+  "devDependencies": {
1916+    "@types/node": "^24.5.2",
1917+    "typescript": "^5.9.2"
1918+  }
1919+}
1920diff --git a/dot_pi/agent/extensions/pi-tree-pruning/prune-selector.ts b/dot_pi/agent/extensions/pi-tree-pruning/prune-selector.ts
1921new file mode 100644
1922index 0000000000000000000000000000000000000000..abdacaf07d2e58f793c1d5973796c403177357d4
1923--- /dev/null
1924+++ b/dot_pi/agent/extensions/pi-tree-pruning/prune-selector.ts
1925@@ -0,0 +1,103 @@
1926+import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
1927+import { type BranchCandidate } from "./tree-model.ts";
1928+import { formatPruneTreeItem, type PruneTreeItem } from "./tree-view.ts";
1929+
1930+export async function selectPruneCandidate(
1931+  ctx: ExtensionCommandContext,
1932+  items: readonly PruneTreeItem[],
1933+): Promise<BranchCandidate | undefined> {
1934+  if (ctx.mode === "tui") {
1935+    return selectWithTreeUi(ctx, items);
1936+  }
1937+
1938+  const choices = makeUniqueChoices(items);
1939+  const selected = await ctx.ui.select("Prune inactive branch:", choices.map((choice) => choice.label));
1940+  return choices.find((choice) => choice.label === selected)?.item.candidate;
1941+}
1942+
1943+async function selectWithTreeUi(
1944+  ctx: ExtensionCommandContext,
1945+  items: readonly PruneTreeItem[],
1946+): Promise<BranchCandidate | undefined> {
1947+  const { Key, matchesKey, truncateToWidth } = await import("@earendil-works/pi-tui");
1948+  const selectedRootId = await ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
1949+    let selectedIndex = 0;
1950+    let scrollOffset = 0;
1951+
1952+    const visibleItemCount = (): number => Math.max(1, (tui.terminal.rows ?? 24) - 5);
1953+    const keepSelectionVisible = (): void => {
1954+      const visibleCount = visibleItemCount();
1955+      if (selectedIndex < scrollOffset) scrollOffset = selectedIndex;
1956+      if (selectedIndex >= scrollOffset + visibleCount) {
1957+        scrollOffset = selectedIndex - visibleCount + 1;
1958+      }
1959+    };
1960+    const moveSelection = (offset: number): void => {
1961+      selectedIndex = Math.max(0, Math.min(items.length - 1, selectedIndex + offset));
1962+      keepSelectionVisible();
1963+      tui.requestRender();
1964+    };
1965+
1966+    return {
1967+      handleInput(data: string): void {
1968+        if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
1969+          done(undefined);
1970+          return;
1971+        }
1972+        if (matchesKey(data, Key.up)) {
1973+          moveSelection(-1);
1974+          return;
1975+        }
1976+        if (matchesKey(data, Key.down)) {
1977+          moveSelection(1);
1978+          return;
1979+        }
1980+        if (matchesKey(data, Key.enter)) {
1981+          done(items[selectedIndex]?.candidate.rootId);
1982+        }
1983+      },
1984+      render(width: number): string[] {
1985+        const visibleCount = visibleItemCount();
1986+        const visibleItems = items.slice(scrollOffset, scrollOffset + visibleCount);
1987+        const lines = [
1988+          theme.fg("accent", theme.bold("Prune inactive branch:")),
1989+          theme.fg("dim", "↑↓ move • enter select • esc cancel"),
1990+        ];
1991+
1992+        if (scrollOffset > 0) lines.push(theme.fg("dim", `… ${scrollOffset} earlier branches`));
1993+
1994+        for (let index = 0; index < visibleItems.length; index += 1) {
1995+          const item = visibleItems[index];
1996+          if (!item) continue;
1997+
1998+          const itemIndex = scrollOffset + index;
1999+          const row = `${itemIndex === selectedIndex ? "→" : " "} ${item.treePrefix}${formatPruneTreeItem(item)}`;
2000+          const rendered = itemIndex === selectedIndex ? theme.fg("accent", theme.bold(row)) : row;
2001+          lines.push(rendered);
2002+        }
2003+
2004+        const hiddenAfter = items.length - scrollOffset - visibleItems.length;
2005+        if (hiddenAfter > 0) lines.push(theme.fg("dim", `… ${hiddenAfter} later branches`));
2006+
2007+        return lines.map((line) => truncateToWidth(line, width));
2008+      },
2009+      invalidate(): void {},
2010+    };
2011+  });
2012+
2013+  return items.find((item) => item.candidate.rootId === selectedRootId)?.candidate;
2014+}
2015+
2016+function makeUniqueChoices(items: readonly PruneTreeItem[]): Array<{ label: string; item: PruneTreeItem }> {
2017+  const counts = new Map<string, number>();
2018+
2019+  return items.map((item) => {
2020+    const baseLabel = `${item.treePrefix}${formatPruneTreeItem(item)}`;
2021+    const count = (counts.get(baseLabel) ?? 0) + 1;
2022+    counts.set(baseLabel, count);
2023+    return {
2024+      label: count === 1 ? baseLabel : `${baseLabel} (${count})`,
2025diff --git a/dot_pi/agent/extensions/pi-tree-pruning/session-file.ts b/dot_pi/agent/extensions/pi-tree-pruning/session-file.ts
2026new file mode 100644
2027index 0000000000000000000000000000000000000000..6017175b3d145c0ced784043f1884abe4466dc4b
2028--- /dev/null
2029+++ b/dot_pi/agent/extensions/pi-tree-pruning/session-file.ts
2030@@ -0,0 +1,268 @@
2031+import { randomUUID } from "node:crypto";
2032+import { chmod, link, lstat, open, readdir, readFile, rename, unlink } from "node:fs/promises";
2033+import { basename, dirname, join, resolve } from "node:path";
2034+import type { BranchCandidate, TreeEntry } from "./tree-model.ts";
2035+
2036+export interface FileIdentity {
2037+  readonly dev: number;
2038+  readonly ino: number;
2039+  readonly mode: number;
2040+  readonly mtimeMs: number;
2041+  readonly size: number;
2042+}
2043+
2044+export interface SessionFileSnapshot {
2045+  readonly path: string;
2046+  readonly content: string;
2047+  readonly identity: FileIdentity;
2048+}
2049+
2050+interface RawLine {
2051+  readonly content: string;
2052+  readonly record?: Record<string, unknown>;
2053+}
2054+
2055+export async function captureSessionFile(sessionFile: string): Promise<SessionFileSnapshot> {
2056+  const path = resolve(sessionFile);
2057+
2058+  for (let attempt = 0; attempt < 2; attempt += 1) {
2059+    const before = await getFileIdentity(path);
2060+    const content = await readFile(path, "utf8");
2061+    const after = await getFileIdentity(path);
2062+
2063+    if (sameIdentity(before, after) && Buffer.byteLength(content) === after.size) {
2064+      return { path, content, identity: after };
2065+    }
2066+  }
2067+
2068+  throw new Error("Session file changed while it was read");
2069+}
2070+
2071+export async function assertSessionFileUnchanged(snapshot: SessionFileSnapshot): Promise<void> {
2072+  const current = await captureSessionFile(snapshot.path);
2073+  if (!sameIdentity(snapshot.identity, current.identity) || snapshot.content !== current.content) {
2074+    throw new Error("Session file changed while the dialog was open");
2075+  }
2076+}
2077+
2078+export function buildPrunedSessionContent(
2079+  snapshot: SessionFileSnapshot,
2080+  candidate: BranchCandidate,
2081+): string {
2082+  const expectedEntries = new Map<string, TreeEntry>();
2083+  for (const entry of candidate.entries) {
2084+    if (expectedEntries.has(entry.id)) {
2085+      throw new Error(`Branch has duplicate entry ID ${entry.id}`);
2086+    }
2087+    expectedEntries.set(entry.id, entry);
2088+  }
2089+
2090+  if (expectedEntries.size === 0) throw new Error("Branch has no entries");
2091+
2092+  const rawLines = splitRawLines(snapshot.content);
2093+  const linesByEntryId = new Map<string, number[]>();
2094+
2095+  for (let index = 0; index < rawLines.length; index += 1) {
2096+    const rawLine = rawLines[index];
2097+    if (!rawLine?.record) continue;
2098+
2099+    const id = rawLine.record.id;
2100+    if (typeof id !== "string" || !expectedEntries.has(id)) continue;
2101+
2102+    const indexes = linesByEntryId.get(id) ?? [];
2103+    indexes.push(index);
2104+    linesByEntryId.set(id, indexes);
2105+  }
2106+
2107+  const lineIndexesToRemove = new Set<number>();
2108+  for (const [id, entry] of expectedEntries) {
2109+    const indexes = linesByEntryId.get(id) ?? [];
2110+    if (indexes.length !== 1) {
2111+      throw new Error(`Could not safely locate exactly one JSONL line for entry ${id}`);
2112+    }
2113+
2114+    const lineIndex = indexes[0];
2115+    if (lineIndex === undefined) {
2116+      throw new Error(`Could not safely locate JSONL line for entry ${id}`);
2117+    }
2118+
2119+    const rawLine = rawLines[lineIndex];
2120+    if (!rawLine?.record || !matchesEntry(rawLine.record, entry)) {
2121+      throw new Error(`JSONL line for entry ${id} does not match the loaded session`);
2122+    }
2123+
2124+    lineIndexesToRemove.add(lineIndex);
2125+  }
2126+
2127+  return rawLines
2128+    .filter((_line, index) => !lineIndexesToRemove.has(index))
2129+    .map((line) => line.content)
2130diff --git a/dot_pi/agent/extensions/pi-tree-pruning/test/command.test.ts b/dot_pi/agent/extensions/pi-tree-pruning/test/command.test.ts
2131new file mode 100644
2132index 0000000000000000000000000000000000000000..60c6f31aafed54975c0e0acbc474b869f7d96988
2133--- /dev/null
2134+++ b/dot_pi/agent/extensions/pi-tree-pruning/test/command.test.ts
2135@@ -0,0 +1,75 @@
2136+import assert from "node:assert/strict";
2137+import { mkdtemp, readdir, readFile, writeFile } from "node:fs/promises";
2138+import { tmpdir } from "node:os";
2139+import { join } from "node:path";
2140+import test from "node:test";
2141+import type {
2142+  ExtensionAPI,
2143+  ExtensionCommandContext,
2144+  RegisteredCommand,
2145+} from "@earendil-works/pi-coding-agent";
2146+import registerTreePruning from "../index.ts";
2147+
2148+const originalContent = [
2149+  '{"type":"session","version":3,"id":"session-id","timestamp":"now","cwd":"/tmp"}\n',
2150+  '{"type":"message","id":"root","parentId":null,"timestamp":"now","message":{"role":"user","content":"root"}}\n',
2151+  '{"type":"message","id":"active","parentId":"root","timestamp":"now","message":{"role":"user","content":"active"}}\n',
2152+  '{"type":"message","id":"inactive","parentId":"root","timestamp":"now","message":{"role":"user","content":"inactive"}}\n',
2153+].join("");
2154+
2155+const entries = [
2156+  { id: "root", parentId: null, type: "message", message: { role: "user", content: "root" } },
2157+  { id: "active", parentId: "root", type: "message", message: { role: "user", content: "active" } },
2158+  { id: "inactive", parentId: "root", type: "message", message: { role: "user", content: "inactive" } },
2159+];
2160+
2161+test("prunes a selected branch, creates a backup, and reloads the session", async () => {
2162+  const directory = await mkdtemp(join(tmpdir(), "pi-tree-pruning-command-"));
2163+  const sessionPath = join(directory, "session.jsonl");
2164+  await writeFile(sessionPath, originalContent, "utf8");
2165+
2166+  let handler: ((args: string, ctx: ExtensionCommandContext) => Promise<void>) | undefined;
2167+  registerTreePruning({
2168+    registerCommand(
2169+      _name: string,
2170+      command: Omit<RegisteredCommand, "name" | "sourceInfo">,
2171+    ) {
2172+      handler = command.handler;
2173+    },
2174+  } as unknown as ExtensionAPI);
2175+
2176+  const notifications: string[] = [];
2177+  let reloadedSession: string | undefined;
2178+  const context = {
2179+    hasUI: true,
2180+    waitForIdle: async () => {},
2181+    sessionManager: {
2182+      getSessionFile: () => sessionPath,
2183+      getLeafId: () => "active",
2184+      getEntries: () => entries,
2185+      getLabel: () => undefined,
2186+    },
2187+    ui: {
2188+      select: async (_title: string, options: string[]) => options[0],
2189+      confirm: async () => true,
2190+      notify: (message: string) => notifications.push(message),
2191+    },
2192+    switchSession: async (path: string, options?: { withSession?: (ctx: { ui: { notify: (message: string) => void } }) => Promise<void> }) => {
2193+      reloadedSession = path;
2194+      await options?.withSession?.({ ui: { notify: (message) => notifications.push(message) } });
2195+      return { cancelled: false };
2196+    },
2197+  } as unknown as ExtensionCommandContext;
2198+
2199+  assert.ok(handler);
2200+  await handler("", context);
2201+
2202+  assert.equal(reloadedSession, sessionPath);
2203+  assert.doesNotMatch(await readFile(sessionPath, "utf8"), /"inactive"/);
2204+  assert.match(notifications.join("\n"), /Tree Prune removed 1 entry/);
2205+
2206+  const files = await readdir(directory);
2207+  const backupName = files.find((file) => file.startsWith("session.jsonl.tree-prune-backup-"));
2208+  assert.ok(backupName);
2209+  assert.equal(await readFile(join(directory, backupName), "utf8"), originalContent);
2210+});
2211diff --git a/dot_pi/agent/extensions/pi-tree-pruning/test/session-file.test.ts b/dot_pi/agent/extensions/pi-tree-pruning/test/session-file.test.ts
2212new file mode 100644
2213index 0000000000000000000000000000000000000000..3cf76bfcdc7e98b8c525f78c17fa90d41d4a5b0b
2214--- /dev/null
2215+++ b/dot_pi/agent/extensions/pi-tree-pruning/test/session-file.test.ts
2216@@ -0,0 +1,110 @@
2217+import assert from "node:assert/strict";
2218+import { mkdtemp, readFile, writeFile } from "node:fs/promises";
2219+import { tmpdir } from "node:os";
2220+import { join } from "node:path";
2221+import test from "node:test";
2222+import {
2223+  assertSessionFileUnchanged,
2224+  buildPrunedSessionContent,
2225+  captureSessionFile,
2226+  createBackupPath,
2227+  createSessionBackup,
2228+  pruneSessionFile,
2229+  restoreSessionBackup,
2230+} from "../session-file.ts";
2231+import { findInactiveBranchCandidates, type TreeEntry } from "../tree-model.ts";
2232+
2233+const sessionContent = [
2234+  '{"type":"session","version":3,"id":"session-id","timestamp":"now","cwd":"/tmp"}\n',
2235+  '{"type":"message","id":"root","parentId":null,"timestamp":"now","message":{"role":"user","content":"root"}}\n',
2236+  'this is not json\n',
2237+  '{"type":"message","id":"active","parentId":"root","timestamp":"now","message":{"role":"user","content":"active"}}\n',
2238+  '{"type":"message","id":"inactive","parentId":"root","timestamp":"now","message":{"role":"user","content":"inactive"}}\n',
2239+  '{"type":"custom","id":"metadata","parentId":"inactive","timestamp":"now","customType":"example"}\n',
2240+].join("");
2241+
2242+const prunedContent = [
2243+  '{"type":"session","version":3,"id":"session-id","timestamp":"now","cwd":"/tmp"}\n',
2244+  '{"type":"message","id":"root","parentId":null,"timestamp":"now","message":{"role":"user","content":"root"}}\n',
2245+  'this is not json\n',
2246+  '{"type":"message","id":"active","parentId":"root","timestamp":"now","message":{"role":"user","content":"active"}}\n',
2247+].join("");
2248+
2249+function treeEntries(): TreeEntry[] {
2250+  return [
2251+    { id: "root", parentId: null, type: "message", message: { role: "user", content: "root" } },
2252+    { id: "active", parentId: "root", type: "message", message: { role: "user", content: "active" } },
2253+    { id: "inactive", parentId: "root", type: "message", message: { role: "user", content: "inactive" } },
2254+    { id: "metadata", parentId: "inactive", type: "custom" },
2255+  ];
2256+}
2257+
2258+async function createFixture(): Promise<string> {
2259+  const directory = await mkdtemp(join(tmpdir(), "pi-tree-pruning-"));
2260+  const sessionPath = join(directory, "session.jsonl");
2261+  await writeFile(sessionPath, sessionContent, "utf8");
2262+  return sessionPath;
2263+}
2264+
2265+function inactiveCandidate() {
2266+  const candidate = findInactiveBranchCandidates(treeEntries(), "active")[0];
2267+  assert.ok(candidate);
2268+  return candidate;
2269+}
2270+
2271+test("removes only the selected JSONL lines and retains unrelated raw lines", async () => {
2272+  const sessionPath = await createFixture();
2273+  const snapshot = await captureSessionFile(sessionPath);
2274+
2275+  assert.equal(buildPrunedSessionContent(snapshot, inactiveCandidate()), prunedContent);
2276+});
2277+
2278+test("creates a same-directory backup without changing the session", async () => {
2279+  const sessionPath = await createFixture();
2280+  const snapshot = await captureSessionFile(sessionPath);
2281+  const backupPath = createBackupPath(sessionPath);
2282+
2283+  const createdBackupPath = await createSessionBackup(snapshot, backupPath);
2284+
2285+  assert.equal(createdBackupPath, backupPath);
2286+  assert.match(backupPath, /session\.jsonl\.tree-prune-backup-/);
2287+  assert.equal(await readFile(backupPath, "utf8"), sessionContent);
2288+  assert.equal(await readFile(sessionPath, "utf8"), sessionContent);
2289+});
2290+
2291+test("detects a changed session file before mutation", async () => {
2292+  const sessionPath = await createFixture();
2293+  const snapshot = await captureSessionFile(sessionPath);
2294+  await writeFile(sessionPath, `${sessionContent}changed\n`, "utf8");
2295+
2296+  await assert.rejects(assertSessionFileUnchanged(snapshot), /changed while the dialog was open/);
2297+});
2298+
2299+test("prunes with a backup and can restore that backup with a backup of the pruned session", async () => {
2300+  const sessionPath = await createFixture();
2301+  const originalSnapshot = await captureSessionFile(sessionPath);
2302+  const originalBackupPath = createBackupPath(sessionPath);
2303+
2304+  const pruneResult = await pruneSessionFile(
2305+    originalSnapshot,
2306+    inactiveCandidate(),
2307+    originalBackupPath,
2308+  );
2309+
2310+  assert.equal(pruneResult.removedEntryCount, 2);
2311+  assert.equal(await readFile(sessionPath, "utf8"), prunedContent);
2312+  assert.equal(await readFile(originalBackupPath, "utf8"), sessionContent);
2313+
2314+  const prunedSnapshot = await captureSessionFile(sessionPath);
2315+  const originalBackupSnapshot = await captureSessionFile(originalBackupPath);
2316diff --git a/dot_pi/agent/extensions/pi-tree-pruning/test/tree-model.test.ts b/dot_pi/agent/extensions/pi-tree-pruning/test/tree-model.test.ts
2317new file mode 100644
2318index 0000000000000000000000000000000000000000..f81e4524a301d64febd9c3da6181fd27c787fb92
2319--- /dev/null
2320+++ b/dot_pi/agent/extensions/pi-tree-pruning/test/tree-model.test.ts
2321@@ -0,0 +1,83 @@
2322+import assert from "node:assert/strict";
2323+import test from "node:test";
2324+import { findInactiveBranchCandidates, type TreeEntry } from "../tree-model.ts";
2325+
2326+function entry(id: string, parentId: string | null, type = "message"): TreeEntry {
2327+  if (type !== "message") return { id, parentId, type };
2328+  return { id, parentId, type, message: { role: "user", content: `message ${id}` } };
2329+}
2330+
2331+test("lists complete inactive subtrees and protects the active subtree", () => {
2332+  const entries = [
2333+    entry("root", null),
2334+    entry("active", "root"),
2335+    entry("active-leaf", "active"),
2336+    entry("inactive", "root"),
2337+    entry("inactive-child", "inactive"),
2338+  ];
2339+
2340+  const candidates = findInactiveBranchCandidates(entries, "active-leaf");
2341+
2342+  assert.equal(candidates.length, 1);
2343+  assert.equal(candidates[0]?.rootId, "inactive");
2344+  assert.deepEqual(candidates[0]?.entryIds, ["inactive", "inactive-child"]);
2345+  assert.equal(candidates[0]?.entryCount, 2);
2346+  assert.match(candidates[0]?.preview ?? "", /^user: message inactive$/);
2347+});
2348+
2349+test("keeps metadata in a removable subtree and supports multiple roots", () => {
2350+  const entries = [
2351+    entry("root-a", null),
2352+    entry("active", "root-a"),
2353+    entry("inactive", "root-a"),
2354+    entry("label", "inactive", "label"),
2355+    entry("root-b", null),
2356+    entry("branch-b1", "root-b"),
2357+    entry("branch-b2", "root-b"),
2358+  ];
2359+
2360+  const candidates = findInactiveBranchCandidates(entries, "active");
2361+
2362+  assert.deepEqual(
2363+    candidates.map((candidate) => [candidate.rootId, candidate.entryIds]),
2364+    [
2365+      ["inactive", ["inactive", "label"]],
2366+      ["branch-b1", ["branch-b1"]],
2367+      ["branch-b2", ["branch-b2"]],
2368+    ],
2369+  );
2370+});
2371+
2372+test("rejects ambiguous children, cycles, duplicates, and a missing active leaf", () => {
2373+  const ambiguousChild = { id: "broken", parentId: "root", type: 42 };
2374+  const entries = [
2375+    entry("root", null),
2376+    entry("active", "root"),
2377+    entry("inactive", "root"),
2378+    ambiguousChild,
2379+    entry("duplicate", "inactive"),
2380+    entry("duplicate", "inactive"),
2381+    entry("cycle-a", "cycle-b"),
2382+    entry("cycle-b", "cycle-a"),
2383+  ] as unknown as TreeEntry[];
2384+
2385+  assert.deepEqual(findInactiveBranchCandidates(entries, "active"), []);
2386+  assert.deepEqual(findInactiveBranchCandidates(entries, "missing"), []);
2387+});
2388+
2389+test("does not offer a nested candidate that reaches a cycle", () => {
2390+  const entries = [
2391+    entry("root", null),
2392+    entry("active", "root"),
2393+    entry("inactive", "root"),
2394+    entry("fork", "inactive"),
2395+    entry("safe", "fork"),
2396+    entry("cycle-a", "fork"),
2397+    entry("cycle-b", "cycle-a"),
2398+    entry("cycle-a", "cycle-b"),
2399+  ] as unknown as TreeEntry[];
2400+
2401+  const candidates = findInactiveBranchCandidates(entries, "active");
2402+
2403+  assert.deepEqual(candidates, []);
2404+});
2405diff --git a/dot_pi/agent/extensions/pi-tree-pruning/test/tree-view.test.ts b/dot_pi/agent/extensions/pi-tree-pruning/test/tree-view.test.ts
2406new file mode 100644
2407index 0000000000000000000000000000000000000000..67fe657929ac3f9c83c9529e3e878bfae6a4af8e
2408--- /dev/null
2409+++ b/dot_pi/agent/extensions/pi-tree-pruning/test/tree-view.test.ts
2410@@ -0,0 +1,42 @@
2411+import assert from "node:assert/strict";
2412+import test from "node:test";
2413+import type { BranchCandidate } from "../tree-model.ts";
2414+import {
2415+  buildPruneTreeItems,
2416+  formatEntryCount,
2417+  formatPruneTreeItem,
2418+} from "../tree-view.ts";
2419+
2420+function candidate(
2421+  rootId: string,
2422+  forkPointId: string,
2423+  entryIds: string[],
2424+  preview: string,
2425+): BranchCandidate {
2426+  return {
2427+    rootId,
2428+    forkPointId,
2429+    entries: entryIds.map((id) => ({ id, parentId: null, type: "message" })),
2430+    entryIds,
2431+    entryCount: entryIds.length,
2432+    preview,
2433+  };
2434+}
2435+
2436+test("renders nested branches with labels and correct entry counts", () => {
2437+  const main = candidate("main", "active-fork", ["main", "nested-fork", "quick"], "user: main work");
2438+  const quick = candidate("quick", "nested-fork", ["quick", "quick-reply"], "user: check implementation");
2439+
2440+  const items = buildPruneTreeItems([main, quick], (entryId) =>
2441+    entryId === "quick" ? "quick check" : undefined,
2442+  );
2443+
2444+  assert.deepEqual(
2445+    items.map((item) => item.treePrefix),
2446+    ["", "   └─ "],
2447+  );
2448+  assert.equal(formatPruneTreeItem(items[0]!), "user: main work · 3 entries");
2449+  assert.equal(formatPruneTreeItem(items[1]!), "[quick check] user: check implementation · 2 entries");
2450+  assert.equal(formatEntryCount(1), "1 entry");
2451+  assert.equal(formatEntryCount(2), "2 entries");
2452+});
2453diff --git a/dot_pi/agent/extensions/pi-tree-pruning/tree-model.ts b/dot_pi/agent/extensions/pi-tree-pruning/tree-model.ts
2454new file mode 100644
2455index 0000000000000000000000000000000000000000..a73d083dd3080d2846508ab78445db2f75ab6c0b
2456--- /dev/null
2457+++ b/dot_pi/agent/extensions/pi-tree-pruning/tree-model.ts
2458@@ -0,0 +1,196 @@
2459+export interface TreeEntry {
2460+  readonly id: string;
2461+  readonly parentId: string | null;
2462+  readonly type: string;
2463+  readonly message?: unknown;
2464+  readonly summary?: unknown;
2465+}
2466+
2467+export interface BranchCandidate {
2468+  readonly rootId: string;
2469+  readonly forkPointId: string;
2470+  readonly entries: readonly TreeEntry[];
2471+  readonly entryIds: readonly string[];
2472+  readonly entryCount: number;
2473+  readonly preview: string;
2474+}
2475+
2476+interface TreeIndex {
2477+  readonly entriesById: ReadonlyMap<string, TreeEntry>;
2478+  readonly childrenByParentId: ReadonlyMap<string, readonly string[]>;
2479+  readonly uncertainChildrenByParentId: ReadonlySet<string>;
2480+}
2481+
2482+const PREVIEW_LIMIT = 72;
2483+
2484+export function findInactiveBranchCandidates(
2485+  entries: readonly TreeEntry[],
2486+  activeLeafId: string,
2487+): BranchCandidate[] {
2488+  const tree = buildTreeIndex(entries);
2489+  if (!tree.entriesById.has(activeLeafId)) return [];
2490+
2491+  const candidates: BranchCandidate[] = [];
2492+  for (const [forkPointId, childIds] of tree.childrenByParentId) {
2493+    if (childIds.length < 2 || tree.uncertainChildrenByParentId.has(forkPointId)) continue;
2494+
2495+    for (const rootId of childIds) {
2496+      const branchEntries = collectSubtree(rootId, tree);
2497+      if (!branchEntries) continue;
2498+      if (branchEntries.some((entry) => entry.id === activeLeafId)) continue;
2499+
2500+      const root = tree.entriesById.get(rootId);
2501+      if (!root) continue;
2502+
2503+      candidates.push({
2504+        rootId,
2505+        forkPointId,
2506+        entries: branchEntries,
2507+        entryIds: branchEntries.map((entry) => entry.id),
2508+        entryCount: branchEntries.length,
2509+        preview: previewEntry(root),
2510+      });
2511+    }
2512+  }
2513+
2514+  return candidates;
2515+}
2516+
2517+function buildTreeIndex(entries: readonly TreeEntry[]): TreeIndex {
2518+  const validEntries = entries.filter(isTreeEntry);
2519+  const occurrences = new Map<string, number>();
2520+  const uncertainChildrenByParentId = new Set<string>();
2521+
2522+  for (const entry of validEntries) {
2523+    occurrences.set(entry.id, (occurrences.get(entry.id) ?? 0) + 1);
2524+  }
2525+
2526+  for (const entry of entries) {
2527+    if (isTreeEntry(entry) && occurrences.get(entry.id) === 1) continue;
2528+    if (hasStringParentId(entry)) uncertainChildrenByParentId.add(entry.parentId);
2529+  }
2530+
2531+  const entriesById = new Map<string, TreeEntry>();
2532+  for (const entry of validEntries) {
2533+    if (occurrences.get(entry.id) === 1) entriesById.set(entry.id, entry);
2534+  }
2535+
2536+  const childrenByParentId = new Map<string, string[]>();
2537+  for (const entry of entriesById.values()) {
2538+    if (entry.parentId === null || !entriesById.has(entry.parentId)) continue;
2539+    const children = childrenByParentId.get(entry.parentId) ?? [];
2540+    children.push(entry.id);
2541+    childrenByParentId.set(entry.parentId, children);
2542+  }
2543+
2544+  return { entriesById, childrenByParentId, uncertainChildrenByParentId };
2545+}
2546+
2547+function collectSubtree(rootId: string, tree: TreeIndex): TreeEntry[] | undefined {
2548+  const entries: TreeEntry[] = [];
2549+  const visitedIds = new Set<string>();
2550+  const pendingIds = [rootId];
2551+
2552+  while (pendingIds.length > 0) {
2553+    const entryId = pendingIds.pop();
2554+    if (!entryId || visitedIds.has(entryId)) return undefined;
2555+    if (tree.uncertainChildrenByParentId.has(entryId)) return undefined;
2556+
2557+    const entry = tree.entriesById.get(entryId);
2558diff --git a/dot_pi/agent/extensions/pi-tree-pruning/tree-view.ts b/dot_pi/agent/extensions/pi-tree-pruning/tree-view.ts
2559new file mode 100644
2560index 0000000000000000000000000000000000000000..acf6db3e7c8dc38338135e8db22a0fb6e20d3ddb
2561--- /dev/null
2562+++ b/dot_pi/agent/extensions/pi-tree-pruning/tree-view.ts
2563@@ -0,0 +1,76 @@
2564+import type { BranchCandidate } from "./tree-model.ts";
2565+
2566+export interface PruneTreeItem {
2567+  readonly candidate: BranchCandidate;
2568+  readonly label: string | undefined;
2569+  readonly treePrefix: string;
2570+}
2571+
2572+export function buildPruneTreeItems(
2573+  candidates: readonly BranchCandidate[],
2574+  getLabel: (entryId: string) => string | undefined,
2575+): PruneTreeItem[] {
2576+  const parentByRootId = new Map<string, string>();
2577+
2578+  for (const candidate of candidates) {
2579+    const parent = findParentCandidate(candidate, candidates);
2580+    if (parent) parentByRootId.set(candidate.rootId, parent.rootId);
2581+  }
2582+
2583+  const childrenByRootId = new Map<string | undefined, BranchCandidate[]>();
2584+  for (const candidate of candidates) {
2585+    const parentRootId = parentByRootId.get(candidate.rootId);
2586+    const children = childrenByRootId.get(parentRootId) ?? [];
2587+    children.push(candidate);
2588+    childrenByRootId.set(parentRootId, children);
2589+  }
2590+
2591+  const items: PruneTreeItem[] = [];
2592+  const visit = (candidate: BranchCandidate, ancestorIsLast: readonly boolean[], isLast: boolean): void => {
2593+    const parentRootId = parentByRootId.get(candidate.rootId);
2594+    const treePrefix = parentRootId
2595+      ? `${ancestorIsLast.map((last) => (last ? "   " : "│  ")).join("")}${isLast ? "└─ " : "├─ "}`
2596+      : "";
2597+
2598+    items.push({ candidate, label: getLabel(candidate.rootId), treePrefix });
2599+
2600+    const children = childrenByRootId.get(candidate.rootId) ?? [];
2601+    for (let index = 0; index < children.length; index += 1) {
2602+      const child = children[index];
2603+      if (!child) continue;
2604+      visit(child, [...ancestorIsLast, isLast], index === children.length - 1);
2605+    }
2606+  };
2607+
2608+  const roots = childrenByRootId.get(undefined) ?? [];
2609+  for (let index = 0; index < roots.length; index += 1) {
2610+    const root = roots[index];
2611+    if (!root) continue;
2612+    visit(root, [], index === roots.length - 1);
2613+  }
2614+
2615+  return items;
2616+}
2617+
2618+export function formatPruneTreeItem(item: PruneTreeItem): string {
2619+  const label = item.label?.replace(/\s+/g, " ").trim();
2620+  const preview = label ? `[${label}] ${item.candidate.preview}` : item.candidate.preview;
2621+  return `${preview} · ${formatEntryCount(item.candidate.entryCount)}`;
2622+}
2623+
2624+export function formatEntryCount(count: number): string {
2625+  return `${count} ${count === 1 ? "entry" : "entries"}`;
2626+}
2627+
2628+function findParentCandidate(
2629+  candidate: BranchCandidate,
2630+  candidates: readonly BranchCandidate[],
2631+): BranchCandidate | undefined {
2632+  return candidates
2633+    .filter(
2634+      (possibleParent) =>
2635+        possibleParent.rootId !== candidate.rootId &&
2636+        possibleParent.entryIds.includes(candidate.forkPointId),
2637+    )
2638+    .sort((left, right) => left.entryCount - right.entryCount)[0];
2639+}
2640diff --git a/dot_pi/agent/extensions/pi-tree-pruning/tsconfig.json b/dot_pi/agent/extensions/pi-tree-pruning/tsconfig.json
2641new file mode 100644
2642index 0000000000000000000000000000000000000000..86e5d35b67366531cab4135639e3eef377e6945a
2643--- /dev/null
2644+++ b/dot_pi/agent/extensions/pi-tree-pruning/tsconfig.json
2645@@ -0,0 +1,18 @@
2646+{
2647+  "compilerOptions": {
2648+    "target": "ESNext",
2649+    "module": "NodeNext",
2650+    "moduleResolution": "NodeNext",
2651+    "lib": ["ESNext"],
2652+    "allowImportingTsExtensions": true,
2653+    "noEmit": true,
2654+    "strict": true,
2655+    "exactOptionalPropertyTypes": true,
2656+    "noUncheckedIndexedAccess": true,
2657+    "noImplicitOverride": true,
2658+    "noFallthroughCasesInSwitch": true,
2659+    "skipLibCheck": true,
2660+    "types": ["node"]
2661+  },
2662+  "include": ["./*.ts", "./test/**/*.ts"]
2663+}
2664diff --git a/dot_pi/agent/policy-engine.json b/dot_pi/agent/policy-engine.json
2665index ed92c53b436019c1c9b4a625408925af79fc1d2f..194b402f907a20c562e88c02ce92ec9c1757857b 100644
2666--- a/dot_pi/agent/policy-engine.json
2667+++ b/dot_pi/agent/policy-engine.json
2668@@ -5,7 +5,8 @@
2669     "model": "reviewer"
2670   },
2671   "permission": {
2672-    "edit": "allow",
2673+    "read": "allow",
2674+    "write": "allow",
2675     "bash": {
2676       "*sudo*": "deny",
2677       "terraform apply*": "deny"