ai: Rename ai/AI.md to ai/_AGENTS.md, remove old .pi/agent/ tracked files
Underscore prefix distinguishes the user-level agent prompt from
project-level AGENTS.md files that tools auto-detect by convention.

.pi/agent/ tracked files removed — user config now lives at ai/pi/
(committed in previous change). .pi/.gitignore simplified to ignore
all of agent/ since it resolves through the symlink to ai/pi/.

Assisted-by: Claude Opus 4.6 via pi
change qqsoyyuwxnxopvywxkwlqwwuylxtxnms
commit bd864fc049c87742a8777deb640bbb9aec052734
author Alpha Chen <alpha@kejadlen.dev>
date
parent zlvlnkvn
diff --git a/.pi/.gitignore b/.pi/.gitignore
index 2f6af6d4..3ba62490 100644
--- a/.pi/.gitignore
+++ b/.pi/.gitignore
@@ -1,6 +1 @@
-agent/*
-!agent/extensions/
-!agent/keybindings.json
-!agent/pinch.json
-!agent/settings.json
-!agent/skills/
+agent/
diff --git a/.pi/agent/extensions/README.md b/.pi/agent/extensions/README.md
deleted file mode 100644
index e5f6dd7f..00000000
--- a/.pi/agent/extensions/README.md
+++ /dev/null
@@ -1,109 +0,0 @@
-# Permission Gate
-
-Requires explicit user confirmation before every tool call, with
-declarative allowlists for commands that can run without prompting.
-
-## How It Works
-
-**Read tool** — auto-allowed for files tracked by `jj`, files in skills
-directories, and pi documentation.
-
-**Bash tool** — auto-allowed only if the command matches the allowlist
-and contains no shell chaining (`|`, `&&`, `;`, `` ` ``, `$()`).
-
-Everything else prompts for confirmation.
-
-## Base Allowlist
-
-The `BASE_COMMANDS` tree in `permission-gate.ts` defines the global
-allowlist. Each entry maps a command to a rule:
-
-```ts
-const BASE_COMMANDS: CommandRule = {
-  jj: ["diff", "log", "show", "status"],
-};
-```
-
-### Rule Types
-
-| Rule | Meaning | Example |
-|------|---------|---------|
-| `true` | Allow with any arguments | `ls: true` |
-| `string[]` | Allow only these subcommands | `jj: ["diff", "log"]` |
-| `{ ... }` | Recurse into subcommands | `gh: { pr: ["list", "view"] }` |
-| `(args) => boolean` | Custom predicate | `rake: (a) => a.startsWith("-T")` |
-
-Rules are recursive — arrays and objects can nest to any depth:
-
-```ts
-{
-  gh: {
-    pr:    ["list", "view", "checks"],
-    issue: { list: true, view: true },
-    repo:  true,                        // all repo subcommands
-  },
-}
-```
-
-`gh pr list --author me` walks `gh` → `pr` → `list` → `true` → allowed.
-`gh pr create` walks `gh` → `pr` → `create` → undefined → prompted.
-
-## Per-Project Overrides
-
-Projects can extend the allowlist by placing a `.pi/permissions.json`
-in the project root (next to `.pi/`):
-
-```json
-{
-  "allow": {
-    "cargo": ["test", "check", "clippy"],
-    "make": ["test", "lint"],
-    "docker": {
-      "compose": ["up", "down", "ps"]
-    }
-  }
-}
-```
-
-### Trust Model
-
-Project overrides are **not trusted automatically**. On the first tool
-call of a session (or when the file changes), the user sees a
-confirmation prompt:
-
-```
-Project wants to auto-allow:
-  cargo test
-  cargo check
-  cargo clippy
-  make test
-  make lint
-  docker compose up
-  docker compose down
-  docker compose ps
-
-Allow? [y/n]
-```
-
-Approvals are persisted in `~/.pi/agent/approved-permissions.json`,
-keyed by project directory and a SHA-256 hash of the file content.
-If `.pi/permissions.json` is modified, the hash won't match and the
-user is re-prompted.
-
-### Merge Behavior
-
-Project rules are **additive only** — they are deep-merged with the
-base allowlist. A project cannot narrow or remove base permissions.
-If the base already grants `true` at some node, the project's rules
-for that node are ignored (it's already fully allowed).
-
-### JSON-Only
-
-Project permissions use `JsonCommandRule`, which supports `true`,
-`string[]`, and nested objects — but not function predicates (since
-the file is JSON). Predicates are only available in `BASE_COMMANDS`.
-
-### Non-Interactive Mode
-
-If pi is running without a UI (e.g., RPC or print mode), project
-permissions that haven't been previously approved are silently skipped.
diff --git a/.pi/agent/extensions/claude-plugins.ts b/.pi/agent/extensions/claude-plugins.ts
deleted file mode 100644
index 11d475ba..00000000
--- a/.pi/agent/extensions/claude-plugins.ts
+++ /dev/null
@@ -1,218 +0,0 @@
-/**
- * Claude Plugins Bridge
- *
- * Loads skills from Claude Code's plugin cache (~/.claude/plugins/cache)
- * and makes them available in pi. Claude's installed plugins become the
- * source of truth — no duplicate configuration needed.
- *
- * Structure expected:
- *   ~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/
- *     .claude-plugin/plugin.json
- *     skills/<skill-name>/SKILL.md
- *
- * When multiple versions of a plugin exist, the highest version is used.
- * Skills under examples/ directories are skipped.
- */
-
-import * as fs from "node:fs";
-import * as path from "node:path";
-import * as os from "node:os";
-import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
-
-const CACHE_DIR = path.join(os.homedir(), ".claude", "plugins", "cache");
-
-interface PluginSkill {
-  name: string;
-  description: string;
-  path: string; // absolute path to SKILL.md
-  plugin: string; // plugin name from plugin.json
-}
-
-/**
- * Compare version strings. Handles semver (1.2.3) and commit hashes
- * (hex strings treated as equal — we pick the first found).
- */
-function compareVersions(a: string, b: string): number {
-  const semverRe = /^(\d+)\.(\d+)\.(\d+)/;
-  const ma = a.match(semverRe);
-  const mb = b.match(semverRe);
-
-  if (ma && mb) {
-    for (let i = 1; i <= 3; i++) {
-      const diff = parseInt(ma[i]) - parseInt(mb[i]);
-      if (diff !== 0) return diff;
-    }
-    return 0;
-  }
-
-  // Non-semver (commit hashes, etc.) — no meaningful ordering
-  return 0;
-}
-
-/**
- * Parse YAML-style frontmatter from a SKILL.md file.
- */
-function parseFrontmatter(content: string): Record<string, string> {
-  const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
-  if (!match) return {};
-
-  const result: Record<string, string> = {};
-  for (const line of match[1].split("\n")) {
-    const kv = line.match(/^(\w[\w-]*):\s*(.+)/);
-    if (kv) {
-      result[kv[1]] = kv[2].trim();
-    }
-  }
-  return result;
-}
-
-/**
- * Discover skills from the Claude plugins cache.
- */
-function discoverSkills(): PluginSkill[] {
-  if (!fs.existsSync(CACHE_DIR)) return [];
-
-  // Collect: marketplace -> plugin -> version[]
-  const plugins = new Map<string, { versions: string[]; marketplace: string; plugin: string }>();
-
-  for (const marketplace of safeReaddir(CACHE_DIR)) {
-    const marketDir = path.join(CACHE_DIR, marketplace);
-    if (!isDir(marketDir)) continue;
-
-    for (const plugin of safeReaddir(marketDir)) {
-      const pluginDir = path.join(marketDir, plugin);
-      if (!isDir(pluginDir)) continue;
-
-      const key = `${marketplace}/${plugin}`;
-      const entry = plugins.get(key) ?? { versions: [], marketplace, plugin };
-
-      for (const version of safeReaddir(pluginDir)) {
-        const versionDir = path.join(pluginDir, version);
-        if (!isDir(versionDir)) continue;
-
-        // Must have .claude-plugin/plugin.json to be a real plugin
-        const pluginJson = path.join(versionDir, ".claude-plugin", "plugin.json");
-        if (fs.existsSync(pluginJson)) {
-          entry.versions.push(version);
-        }
-      }
-
-      if (entry.versions.length > 0) {
-        plugins.set(key, entry);
-      }
-    }
-  }
-
-  // For each plugin, pick the latest version and collect skills
-  const skills: PluginSkill[] = [];
-
-  for (const [key, { versions, marketplace, plugin }] of plugins) {
-    const latest = versions.sort(compareVersions).at(-1)!;
-    const versionDir = path.join(CACHE_DIR, marketplace, plugin, latest);
-
-    // Read plugin name from plugin.json
-    let pluginName = plugin;
-    try {
-      const meta = JSON.parse(
-        fs.readFileSync(path.join(versionDir, ".claude-plugin", "plugin.json"), "utf-8")
-      );
-      if (meta.name) pluginName = meta.name;
-    } catch {}
-
-    // Find SKILL.md files under skills/ (not under examples/)
-    const skillsDir = path.join(versionDir, "skills");
-    if (!isDir(skillsDir)) continue;
-
-    for (const skillFile of findSkillFiles(skillsDir, versionDir)) {
-      try {
-        const content = fs.readFileSync(skillFile, "utf-8");
-        const fm = parseFrontmatter(content);
-        if (!fm.description) continue; // pi requires a description
-
-        const name = fm.name ?? path.basename(path.dirname(skillFile));
-        skills.push({ name, description: fm.description, path: skillFile, plugin: pluginName });
-      } catch {}
-    }
-  }
-
-  return skills;
-}
-
-/**
- * Recursively find SKILL.md files, skipping examples/ directories.
- */
-function findSkillFiles(dir: string, root: string): string[] {
-  const results: string[] = [];
-
-  for (const entry of safeReaddirEntries(dir)) {
-    const fullPath = path.join(dir, entry.name);
-
-    if (entry.isDirectory()) {
-      // Skip examples directories relative to the plugin root
-      const rel = path.relative(root, fullPath);
-      if (rel.startsWith("examples")) continue;
-
-      results.push(...findSkillFiles(fullPath, root));
-    } else if (entry.name === "SKILL.md") {
-      results.push(fullPath);
-    }
-  }
-
-  return results;
-}
-
-function isDir(p: string): boolean {
-  try {
-    return fs.statSync(p).isDirectory();
-  } catch {
-    return false;
-  }
-}
-
-function safeReaddir(dir: string): string[] {
-  try {
-    return fs.readdirSync(dir);
-  } catch {
-    return [];
-  }
-}
-
-function safeReaddirEntries(dir: string): fs.Dirent[] {
-  try {
-    return fs.readdirSync(dir, { withFileTypes: true });
-  } catch {
-    return [];
-  }
-}
-
-export default function claudePlugins(pi: ExtensionAPI) {
-  let skills: PluginSkill[] = [];
-
-  pi.on("session_start", async (_event, ctx) => {
-    skills = discoverSkills();
-    if (skills.length > 0) {
-      ctx.ui.notify(`Claude plugins: ${skills.length} skill(s) loaded`, "info");
-    }
-  });
-
-  pi.on("before_agent_start", async (event) => {
-    if (skills.length === 0) return;
-
-    const xml = skills
-      .map(
-        (s) =>
-          `  <skill>\n    <name>${s.name}</name>\n    <description>${escapeXml(s.description)}</description>\n    <location>${s.path}</location>\n  </skill>`
-      )
-      .join("\n");
-
-    return {
-      systemPrompt:
-        event.systemPrompt +
-        `\n\nThe following skills are available from Claude Code plugins.\nUse the read tool to load a skill's file when the task matches its description.\n\n<available_skills>\n${xml}\n</available_skills>\n`,
-    };
-  });
-}
-
-function escapeXml(s: string): string {
-  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
-}
diff --git a/.pi/agent/extensions/fork-tmux.ts b/.pi/agent/extensions/fork-tmux.ts
deleted file mode 100644
index 1fe42c90..00000000
--- a/.pi/agent/extensions/fork-tmux.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-/**
- * Fork Tmux - Fork the current session into a new tmux pane, window, or popup.
- *
- * Creates a branched copy of the current session and opens it
- * in a new tmux target, leaving the current session untouched.
- *
- * Usage:
- *   /fork-tmux              Popup (tmux 3.3+)
- *   /fork-tmux 80%x80%     Popup with size
- *   /fork-tmux -h           Horizontal split (right)
- *   /fork-tmux -v           Vertical split (below)
- *   /fork-tmux -w           New window
- */
-
-import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
-
-type Mode = "split-h" | "split-v" | "window" | "popup";
-
-function parseArgs(raw: string): { mode: Mode; extra: string[] } {
-	const parts = raw.trim().split(/\s+/).filter(Boolean);
-	let mode: Mode = "popup";
-	const extra: string[] = [];
-
-	for (const p of parts) {
-		if (p === "-h") mode = "split-h";
-		else if (p === "-v") mode = "split-v";
-		else if (p === "-w") mode = "window";
-		else extra.push(p);
-	}
-
-	return { mode, extra };
-}
-
-function shellEscape(s: string): string {
-	return "'" + s.replace(/'/g, "'\\''") + "'";
-}
-
-function findPi(): string {
-	// Resolve full path to pi so it works in minimal shell environments (e.g. tmux popups)
-	try {
-		const { execSync } = require("node:child_process");
-		return execSync("which pi", { encoding: "utf-8" }).trim();
-	} catch {
-		return "pi";
-	}
-}
-
-function buildShellCommand(mode: Mode, extra: string[], sessionFile: string): string {
-	const piCmd = `${findPi()} --session ${shellEscape(sessionFile)}`;
-
-	switch (mode) {
-		case "split-h":
-			return `tmux split-window -h -- ${piCmd}`;
-		case "split-v":
-			return `tmux split-window -v -- ${piCmd}`;
-		case "window":
-			return `tmux new-window -- ${piCmd}`;
-		case "popup": {
-			const sizeArgs: string[] = [];
-			if (extra[0]) {
-				const [w, h] = extra[0].split("x");
-				if (w) sizeArgs.push(`-w ${w}`);
-				if (h) sizeArgs.push(`-h ${h}`);
-			}
-			return `tmux display-popup -E ${sizeArgs.join(" ")} -- ${piCmd}`;
-		}
-	}
-}
-
-export default function (pi: ExtensionAPI) {
-	pi.registerCommand("fork-tmux", {
-		description: "Fork session into a tmux popup (default), pane (-h, -v), or window (-w)",
-		handler: async (args, ctx) => {
-			if (!ctx.hasUI) {
-				ctx.ui.notify("fork-tmux requires interactive mode", "error");
-				return;
-			}
-
-			if (!process.env.TMUX) {
-				ctx.ui.notify("Not inside a tmux session", "error");
-				return;
-			}
-
-			const sessionFile = ctx.sessionManager.getSessionFile();
-			if (!sessionFile) {
-				ctx.ui.notify("No session file (ephemeral session)", "error");
-				return;
-			}
-
-			const leafId = ctx.sessionManager.getLeafId();
-			if (!leafId) {
-				ctx.ui.notify("No entries to fork from", "error");
-				return;
-			}
-
-			const newSessionFile = ctx.sessionManager.createBranchedSession(leafId);
-			if (!newSessionFile) {
-				ctx.ui.notify("Failed to create branched session", "error");
-				return;
-			}
-
-			const { mode, extra } = parseArgs(args);
-			const cmd = buildShellCommand(mode, extra, newSessionFile);
-
-			const result = await pi.exec("bash", ["-c", cmd]);
-			if (result.code !== 0) {
-				ctx.ui.notify(`tmux failed (exit ${result.code}): ${result.stderr || result.stdout || "(no output)"}`, "error");
-				return;
-			}
-
-			const labels: Record<Mode, string> = {
-				"split-h": "pane (right)",
-				"split-v": "pane (below)",
-				"window": "window",
-				"popup": "popup",
-			};
-			ctx.ui.notify(`Forked into new ${labels[mode]}`, "success");
-		},
-	});
-}
diff --git a/.pi/agent/extensions/jj-status.ts b/.pi/agent/extensions/jj-status.ts
deleted file mode 100644
index c8c49a9d..00000000
--- a/.pi/agent/extensions/jj-status.ts
+++ /dev/null
@@ -1,257 +0,0 @@
-/**
- * JJ Status Extension
- *
- * Replaces the default footer to show jj info instead of the git branch.
- * The built-in footer shows "(detached)" in colocated jj repos because
- * jj's git backend uses a detached HEAD. This extension swaps that for
- * bookmark, distance, and change ID — matching p10k's jj prompt.
- *
- * All other footer behavior (token stats, context %, model, extension
- * statuses) is preserved verbatim from the built-in footer.
- */
-
-import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
-import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
-import { execSync } from "node:child_process";
-
-// --- jj info ---
-
-function getJJBranch(cwd: string): string | null {
-  try {
-    const bookmark = execSync(
-      `jj --ignore-working-copy --no-pager log --no-graph --limit 1 -r '
-        coalesce(
-          heads(::@ & (bookmarks() | remote_bookmarks() | tags())),
-          heads(@:: & (bookmarks() | remote_bookmarks() | tags())),
-          trunk()
-        )' -T 'separate(" ", bookmarks, tags)'`,
-      { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] },
-    ).trim().split(" ")[0]?.replace(/\*$/, "") ?? "";
-
-    let result = "";
-
-    if (bookmark) {
-      const where = bookmark.length > 32
-        ? bookmark.slice(0, 12) + "…" + bookmark.slice(-12)
-        : bookmark;
-      const after = execSync(
-        `jj --ignore-working-copy --no-pager log --no-graph -r '${bookmark}..@ & (~empty() | merges())' -T '"n"'`,
-        { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] },
-      ).length;
-      const before = execSync(
-        `jj --ignore-working-copy --no-pager log --no-graph -r '@..${bookmark} & (~empty() | merges())' -T '"n"'`,
-        { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] },
-      ).length;
-      result += where;
-      if (before > 0) result += `‹${before}`;
-      if (after > 0) result += `›${after}`;
-    }
-
-    const raw = execSync(
-      `jj --ignore-working-copy --no-pager log --no-graph --limit 1 -r @ -T '
-        separate("#",
-          change_id.shortest(4),
-          concat(
-            if(conflict, "!"),
-            if(divergent, "D"),
-            if(hidden, "H"),
-            if(immutable, "I"),
-          ),
-        )'`,
-      { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] },
-    ).trim();
-    const [changeId, flags] = raw.split("#");
-
-    if (changeId) {
-      if (result) result += " ";
-      result += changeId;
-    }
-    if (flags) result += ` ${flags}`;
-
-    return result || null;
-  } catch {
-    return null;
-  }
-}
-
-// --- footer (copied from built-in, replacing getGitBranch with getJJBranch) ---
-
-function sanitizeStatusText(text: string): string {
-  return text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim();
-}
-
-function formatTokens(count: number): string {
-  if (count < 1000) return count.toString();
-  if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
-  if (count < 1000000) return `${Math.round(count / 1000)}k`;
-  if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
-  return `${Math.round(count / 1000000)}M`;
-}
-
-let cachedBranch: string | null = null;
-
-function refreshJJ(cwd: string) {
-  cachedBranch = getJJBranch(cwd);
-}
-
-export default function (pi: ExtensionAPI) {
-  pi.on("session_start", async (_event, ctx) => {
-    refreshJJ(ctx.cwd);
-
-    ctx.ui.setFooter((tui, theme, footerData) => ({
-      invalidate() {},
-      render(width: number): string[] {
-        // --- Line 1: pwd + jj branch + session name ---
-        let pwd = process.cwd();
-        const home = process.env.HOME || process.env.USERPROFILE;
-        if (home && pwd.startsWith(home)) {
-          pwd = `~${pwd.slice(home.length)}`;
-        }
-
-        // jj info instead of git branch
-        if (cachedBranch) {
-          pwd = `${pwd} (${cachedBranch})`;
-        }
-
-        const sessionName = ctx.sessionManager.getSessionName();
-        if (sessionName) {
-          pwd = `${pwd} • ${sessionName}`;
-        }
-
-        if (pwd.length > width) {
-          const half = Math.floor(width / 2) - 2;
-          if (half > 1) {
-            pwd = `${pwd.slice(0, half)}...${pwd.slice(-(half - 1))}`;
-          } else {
-            pwd = pwd.slice(0, Math.max(1, width));
-          }
-        }
-
-        // --- Line 2: token stats (left) + model (right) ---
-        let totalInput = 0;
-        let totalOutput = 0;
-        let totalCacheRead = 0;
-        let totalCacheWrite = 0;
-        let totalCost = 0;
-
-        for (const entry of ctx.sessionManager.getEntries()) {
-          if (entry.type === "message" && entry.message.role === "assistant") {
-            const m = entry.message as any;
-            totalInput += m.usage.input;
-            totalOutput += m.usage.output;
-            totalCacheRead += m.usage.cacheRead;
-            totalCacheWrite += m.usage.cacheWrite;
-            totalCost += m.usage.cost.total;
-          }
-        }
-
-        // Context % from last non-aborted assistant message
-        const branch = ctx.sessionManager.getBranch();
-        let contextTokens = 0;
-        for (let i = branch.length - 1; i >= 0; i--) {
-          const e = branch[i];
-          if (e.type === "message" && e.message.role === "assistant") {
-            const m = e.message as any;
-            if (m.stopReason !== "aborted") {
-              contextTokens = m.usage.input + m.usage.output + m.usage.cacheRead + m.usage.cacheWrite;
-              break;
-            }
-          }
-        }
-
-        const contextWindow = ctx.model?.contextWindow || 0;
-        const contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
-
-        const statsParts: string[] = [];
-        if (totalInput) statsParts.push(`↑${formatTokens(totalInput)}`);
-        if (totalOutput) statsParts.push(`↓${formatTokens(totalOutput)}`);
-        if (totalCacheRead) statsParts.push(`R${formatTokens(totalCacheRead)}`);
-        if (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`);
-
-        const usingSubscription = ctx.model ? ctx.modelRegistry.isUsingOAuth(ctx.model) : false;
-        if (totalCost || usingSubscription) {
-          statsParts.push(`$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`);
-        }
-
-        const contextDisplay = `${contextPercentValue.toFixed(1)}%/${formatTokens(contextWindow)} (auto)`;
-        if (contextPercentValue > 90) {
-          statsParts.push(theme.fg("error", contextDisplay));
-        } else if (contextPercentValue > 70) {
-          statsParts.push(theme.fg("warning", contextDisplay));
-        } else {
-          statsParts.push(contextDisplay);
-        }
-
-        let statsLeft = statsParts.join(" ");
-        let statsLeftWidth = visibleWidth(statsLeft);
-        if (statsLeftWidth > width) {
-          const plain = statsLeft.replace(/\x1b\[[0-9;]*m/g, "");
-          statsLeft = `${plain.substring(0, width - 3)}...`;
-          statsLeftWidth = visibleWidth(statsLeft);
-        }
-
-        const minPadding = 2;
-        const modelName = ctx.model?.id || "no-model";
-        let rightSideWithoutProvider = modelName;
-        if (ctx.model?.reasoning) {
-          const level = pi.getThinkingLevel() || "off";
-          rightSideWithoutProvider =
-            level === "off" ? `${modelName} • thinking off` : `${modelName} • ${level}`;
-        }
-
-        let rightSide = rightSideWithoutProvider;
-        if (footerData.getAvailableProviderCount() > 1 && ctx.model) {
-          rightSide = `(${ctx.model.provider}) ${rightSideWithoutProvider}`;
-          if (statsLeftWidth + minPadding + visibleWidth(rightSide) > width) {
-            rightSide = rightSideWithoutProvider;
-          }
-        }
-
-        const rightSideWidth = visibleWidth(rightSide);
-        const totalNeeded = statsLeftWidth + minPadding + rightSideWidth;
-        let statsLine: string;
-        if (totalNeeded <= width) {
-          const padding = " ".repeat(width - statsLeftWidth - rightSideWidth);
-          statsLine = statsLeft + padding + rightSide;
-        } else {
-          const availableForRight = width - statsLeftWidth - minPadding;
-          if (availableForRight > 3) {
-            const plainRight = rightSide.replace(/\x1b\[[0-9;]*m/g, "");
-            const truncated = plainRight.substring(0, availableForRight);
-            const padding = " ".repeat(width - statsLeftWidth - truncated.length);
-            statsLine = statsLeft + padding + truncated;
-          } else {
-            statsLine = statsLeft;
-          }
-        }
-
-        const dimLeft = theme.fg("dim", statsLeft);
-        const remainder = statsLine.slice(statsLeft.length);
-        const dimRemainder = theme.fg("dim", remainder);
-        const lines = [theme.fg("dim", pwd), dimLeft + dimRemainder];
-
-        // --- Line 3 (optional): extension statuses ---
-        const extensionStatuses = footerData.getExtensionStatuses();
-        if (extensionStatuses.size > 0) {
-          const sorted = Array.from(extensionStatuses.entries())
-            .sort(([a], [b]) => a.localeCompare(b))
-            .map(([, text]) => sanitizeStatusText(text));
-          const statusLine = sorted.join(" ");
-          lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
-        }
-
-        return lines;
-      },
-    }));
-  });
-
-  pi.on("tool_result", async (event, ctx) => {
-    if (event.toolName === "bash") {
-      const cmd = String(event.input.command ?? "").trimStart();
-      if (cmd.startsWith("jj ")) refreshJJ(ctx.cwd);
-    }
-    if (event.toolName === "write" || event.toolName === "edit") {
-      refreshJJ(ctx.cwd);
-    }
-  });
-}
diff --git a/.pi/agent/extensions/model-identity.ts b/.pi/agent/extensions/model-identity.ts
deleted file mode 100644
index 8dbfd9a0..00000000
--- a/.pi/agent/extensions/model-identity.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * Model Identity Extension
- *
- * Replaces the default Claude Code identity in the system prompt and
- * injects the current model name so the model knows what it's running
- * as (useful for Assisted-by footers, etc.)
- */
-
-import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
-
-export default function (pi: ExtensionAPI) {
-  pi.on("before_agent_start", async (event, ctx) => {
-    const model = ctx.model;
-    let prompt = event.systemPrompt;
-
-    // Replace the Claude Code identity preamble
-    prompt = prompt.replace(
-      /You are Claude Code, Anthropic.s official CLI for Claude\.\s*/,
-      "",
-    );
-
-    prompt += `\n\nYou are running as: ${model.name} (${model.provider}/${model.id}) via pi`;
-
-    return { systemPrompt: prompt };
-  });
-}
diff --git a/.pi/agent/extensions/permission-gate.ts b/.pi/agent/extensions/permission-gate.ts
deleted file mode 100644
index ecfe279a..00000000
--- a/.pi/agent/extensions/permission-gate.ts
+++ /dev/null
@@ -1,439 +0,0 @@
-/**
- * Permission Gate Extension
- *
- * Requires explicit user confirmation before every tool call.
- * Auto-allows read-only operations (read tool, non-destructive bash commands).
- * Auto-allows reading from tracked files, skills directories, and pi docs.
- * Shows tool name and a summary of the arguments, then asks to allow or block.
- *
- * Write/edit prompts offer "Allow for session" — once accepted, subsequent
- * writes/edits to tracked files are auto-allowed for the remainder of the
- * session. Writes to untracked files still require confirmation. Resets on
- * session start.
- *
- * Allowed bash commands are configured declaratively in BASE_COMMANDS below.
- * Projects can add to the allowlist via `.pi/permissions.json`, which requires
- * a one-time user confirmation (re-prompted if the file changes).
- */
-
-import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
-import { execSync } from "node:child_process";
-import { createHash } from "node:crypto";
-import * as fs from "node:fs";
-import * as os from "node:os";
-import * as path from "node:path";
-
-// ---------------------------------------------------------------------------
-// Allowed bash commands configuration
-//
-// Each entry maps a command/subcommand to its permission rule, forming a tree:
-//
-//   true                          — allow with any further arguments
-//   string[]                      — shorthand: allow these subcommands (each as true)
-//   Record<string, CommandRule>   — recurse into subcommands
-//   (args: string) => boolean     — custom predicate on remaining args
-//
-// Examples:
-//   jj: ["diff", "log", "show", "status"],
-//   gh: {
-//     pr:    ["list", "view", "checks"],
-//     issue: ["list", "view"],
-//   },
-// ---------------------------------------------------------------------------
-type CommandRule = true | string[] | ((args: string) => boolean) | { [subcommand: string]: CommandRule };
-
-/** JSON-safe subset of CommandRule (no predicates) for .pi/permissions.json */
-type JsonCommandRule = true | string[] | { [subcommand: string]: JsonCommandRule };
-
-const BASE_COMMANDS: CommandRule = {
-  gh: { "issue": ["list", "view"], "project": ["item-list", "list"], "repo": ["list"] },
-  jj: { "bookmark": ["list"], "diff": true, "log": true, "show": true, "st": true, "status": true },
-};
-
-// ---------------------------------------------------------------------------
-// Per-project permission overrides
-//
-// Projects can place a `.pi/permissions.json` in the project root:
-//
-//   { "allow": { "cargo": ["test", "check"], "make": ["test"] } }
-//
-// On first use (or when the file changes), the user is shown a summary
-// and asked to confirm. Approvals are stored in ~/.pi/agent/ keyed by
-// a hash of the file content.
-// ---------------------------------------------------------------------------
-const APPROVALS_PATH = path.join(os.homedir(), ".pi/agent/approved-permissions.json");
-
-interface ApprovalRecord {
-  hash: string;
-}
-
-function loadApprovals(): Record<string, ApprovalRecord> {
-  try {
-    return JSON.parse(fs.readFileSync(APPROVALS_PATH, "utf-8"));
-  } catch {
-    return {};
-  }
-}
-
-function saveApprovals(approvals: Record<string, ApprovalRecord>): void {
-  fs.mkdirSync(path.dirname(APPROVALS_PATH), { recursive: true });
-  fs.writeFileSync(APPROVALS_PATH, JSON.stringify(approvals, null, 2) + "\n");
-}
-
-function hashContent(content: string): string {
-  return createHash("sha256").update(content).digest("hex");
-}
-
-/**
- * Render a command rule tree as a human-readable list of allowed command paths.
- * e.g. { cargo: ["test", "check"] } → ["cargo test", "cargo check"]
- */
-function summarizeRules(rule: JsonCommandRule, prefix = ""): string[] {
-  if (rule === true) return [prefix || "(all commands)"];
-  if (Array.isArray(rule)) return rule.map((s) => `${prefix} ${s}`.trim());
-  const lines: string[] = [];
-  for (const [key, sub] of Object.entries(rule)) {
-    lines.push(...summarizeRules(sub, `${prefix} ${key}`.trim()));
-  }
-  return lines;
-}
-
-/**
- * Deep-merge a JSON rule tree into an existing CommandRule tree.
- * Project rules can only add — they cannot override `true` with something
- * more restrictive, and predicates in the base are preserved.
- */
-function mergeRules(base: CommandRule, override: JsonCommandRule): CommandRule {
-  // base is already fully permissive — nothing to add
-  if (base === true) return true;
-  // override grants blanket access at this level
-  if (override === true) return true;
-
-  // base is a predicate or array — wrap into object form to merge
-  if (typeof base === "function" || Array.isArray(base)) {
-    // Can't cleanly merge a predicate/array with an object override.
-    // Convert base array to object, keep predicates as-is.
-    if (Array.isArray(base) && !Array.isArray(override)) {
-      const obj: { [k: string]: CommandRule } = {};
-      for (const k of base) obj[k] = true;
-      return mergeRules(obj, override);
-    }
-    if (Array.isArray(base) && Array.isArray(override)) {
-      return [...new Set([...base, ...override])];
-    }
-    // predicate base + object override — keep predicate (it's more expressive)
-    return base;
-  }
-
-  // Both are objects (or override is array)
-  if (Array.isArray(override)) {
-    // Convert override array to object and merge
-    const obj: { [k: string]: JsonCommandRule } = {};
-    for (const k of override) obj[k] = true;
-    return mergeRules(base, obj);
-  }
-
-  // Both are objects — recurse
-  const merged: { [k: string]: CommandRule } = { ...base };
-  for (const [key, val] of Object.entries(override)) {
-    if (key in merged) {
-      merged[key] = mergeRules(merged[key], val);
-    } else {
-      merged[key] = val;
-    }
-  }
-  return merged;
-}
-
-/** The effective merged command tree, updated after approval. */
-let allowedCommands: CommandRule = BASE_COMMANDS;
-
-/**
- * Pending project rules awaiting user approval. Set at session start if the
- * project has a .pi/permissions.json that hasn't been approved yet. Cleared
- * after the user accepts or rejects.
- */
-let pendingProjectRules: { raw: string; rules: JsonCommandRule } | null = null;
-
-/** Per-session opt-in: when true, write/edit to tracked files are auto-allowed. */
-let sessionAllowEdits = false;
-
-// ---------------------------------------------------------------------------
-// Helpers for read-tool path checks
-// ---------------------------------------------------------------------------
-const PI_DOCS_PREFIX = path.join(
-  os.homedir(),
-  ".volta/tools/image/packages/@mariozechner/pi-coding-agent/"
-);
-
-let trackedFiles: Set<string> | null = null;
-let skillsDirs: Set<string> | null = null;
-
-function getTrackedFiles(cwd: string): Set<string> {
-  if (trackedFiles) return trackedFiles;
-  try {
-    const output = execSync("jj file list", { cwd, encoding: "utf-8" });
-    trackedFiles = new Set(output.trim().split("\n").map((f) => {
-      const resolved = path.resolve(cwd, f);
-      try { return fs.realpathSync(resolved); } catch { return resolved; }
-    }));
-  } catch {
-    trackedFiles = new Set();
-  }
-  return trackedFiles;
-}
-
-/**
- * Derive skills directories from the system prompt's <available_skills> block.
- * This reflects the actual loaded skills (including any added by extensions via
- * resources_discover), rather than just what's configured in settings.json.
- */
-function getSkillsDirectories(ctx: ExtensionContext): Set<string> {
-  if (skillsDirs) return skillsDirs;
-  const dirs = new Set<string>();
-  const prompt = ctx.getSystemPrompt();
-  const locationRe = /<location>(.*?)<\/location>/g;
-  let match;
-  while ((match = locationRe.exec(prompt)) !== null) {
-    const dir = path.dirname(match[1]);
-    dirs.add(dir);
-    // Also resolve symlinks so that paths through symlinked directories
-    // (e.g. ~/.claude -> ~/.dotfiles/ai/claude/) still match after
-    // realpathSync is applied to the file being read.
-    try { dirs.add(fs.realpathSync(dir)); } catch { /* ignore */ }
-  }
-  skillsDirs = dirs;
-  return skillsDirs;
-}
-
-function isInSkillsDirectory(filePath: string, ctx: ExtensionContext): boolean {
-  const dirs = getSkillsDirectories(ctx);
-  for (const dir of dirs) {
-    if (filePath.startsWith(dir + path.sep) || filePath === dir) return true;
-  }
-  return false;
-}
-
-// ---------------------------------------------------------------------------
-// Bash command parsing
-// ---------------------------------------------------------------------------
-
-/**
- * Strip a leading `cd <cwd> && ` prefix from a command.
- * The agent often wraps commands this way, which is a no-op but obscures the
- * real command from permission checks.
- */
-function stripCdToCwd(cmd: string, cwd: string): string {
-  const prefix = `cd ${cwd} && `;
-  if (cmd.startsWith(prefix)) return cmd.slice(prefix.length);
-  return cmd;
-}
-
-/** Returns true if the command string contains shell chaining, piping, or injection operators. */
-function hasChaining(cmd: string): boolean {
-  // Intentionally conservative — if the agent needs any of these it can
-  // ask for permission on the full command.
-  return /[;|&`\n]/.test(cmd)
-    || cmd.includes("$(")
-    || cmd.includes("<(") || cmd.includes(">(")
-    || cmd.includes("<<");
-}
-
-/**
- * Walk the allowedCommands tree to check if a command is allowed.
- * Arrays are shorthand for an object whose keys all map to `true`.
- */
-function isCommandAllowed(cmd: string): boolean {
-  const tokens = cmd.trimStart().split(/\s+/);
-  let rule: CommandRule = allowedCommands;
-
-  for (let i = 0; i < tokens.length; i++) {
-    if (rule === true) return true;
-    if (typeof rule === "function") return rule(tokens.slice(i).join(" "));
-    if (Array.isArray(rule)) return rule.includes(tokens[i]);
-    // rule is a subcommand tree — look up the next token
-    const next = rule[tokens[i]];
-    if (next === undefined) return false;
-    rule = next;
-  }
-
-  // We consumed all tokens — allow if the final node is `true` or a
-  // predicate that accepts empty input.  A tree node means we stopped at
-  // a branch (e.g. bare `jj` with no subcommand) — don't auto-allow that.
-  if (rule === true) return true;
-  if (typeof rule === "function") return rule("");
-  return false;
-}
-
-// ---------------------------------------------------------------------------
-// Main permission check
-// ---------------------------------------------------------------------------
-
-function isTrackedFile(filePath: string, ctx: ExtensionContext): boolean {
-  let realPath: string;
-  try {
-    realPath = fs.realpathSync(filePath);
-  } catch {
-    return false;
-  }
-  return getTrackedFiles(ctx.cwd).has(realPath);
-}
-
-function isAllowed(toolName: string, input: Record<string, unknown>, ctx: ExtensionContext): boolean {
-  if (toolName === "read") {
-    const filePath = path.resolve(ctx.cwd, String(input.path ?? ""));
-    // Resolve symlinks so a tracked symlink pointing outside the repo
-    // doesn't auto-allow reading its target.
-    let realPath: string;
-    try {
-      realPath = fs.realpathSync(filePath);
-    } catch {
-      return false; // Can't resolve — require prompt
-    }
-    return (
-      getTrackedFiles(ctx.cwd).has(realPath) ||
-      isInSkillsDirectory(realPath, ctx) ||
-      realPath.startsWith(PI_DOCS_PREFIX)
-    );
-  }
-
-  if (toolName === "write" || toolName === "edit") {
-    if (!sessionAllowEdits) return false;
-    const filePath = path.resolve(ctx.cwd, String(input.path ?? ""));
-    return isTrackedFile(filePath, ctx);
-  }
-
-  if (toolName === "bash") {
-    const cmd = stripCdToCwd(String(input.command ?? "").trimStart(), ctx.cwd);
-    if (hasChaining(cmd)) return false;
-    return isCommandAllowed(cmd);
-  }
-
-  return false;
-}
-
-// ---------------------------------------------------------------------------
-// Extension entry point
-// ---------------------------------------------------------------------------
-
-export default function(pi: ExtensionAPI) {
-  pi.on("session_start", async (_event, ctx) => {
-    // Reset state each session
-    allowedCommands = BASE_COMMANDS;
-    pendingProjectRules = null;
-    sessionAllowEdits = false;
-
-    const permPath = path.join(ctx.cwd, ".pi/permissions.json");
-    let raw: string;
-    try {
-      raw = fs.readFileSync(permPath, "utf-8");
-    } catch {
-      return; // No project permissions file
-    }
-
-    let projectRules: JsonCommandRule;
-    try {
-      const parsed = JSON.parse(raw);
-      projectRules = parsed.allow;
-      if (!projectRules) return;
-    } catch {
-      if (ctx.hasUI) ctx.ui.notify("Invalid .pi/permissions.json — ignoring", "warning");
-      return;
-    }
-
-    const hash = hashContent(raw);
-    const approvals = loadApprovals();
-
-    if (approvals[ctx.cwd]?.hash === hash) {
-      // Already approved this exact version
-      allowedCommands = mergeRules(BASE_COMMANDS, projectRules);
-    } else {
-      // Defer approval prompt to first tool_call (UI isn't ready here)
-      pendingProjectRules = { raw, rules: projectRules };
-    }
-  });
-
-  pi.on("tool_result", async (event) => {
-    if (event.toolName === "bash") {
-      const cmd = String(event.input.command ?? "").trimStart();
-      if (cmd.startsWith("jj ")) {
-        trackedFiles = null;
-      }
-    }
-  });
-
-  pi.on("tool_call", async (event, ctx) => {
-    if (!ctx.hasUI) return;
-
-    // Prompt for pending project permissions on first tool_call
-    if (pendingProjectRules) {
-      const pending = pendingProjectRules;
-      pendingProjectRules = null; // Clear so we only prompt once
-
-      const summary = summarizeRules(pending.rules);
-      const message = "Project wants to auto-allow:\n" + summary.map((s) => `  ${s}`).join("\n");
-      const approved = await ctx.ui.confirm("Project permissions", message);
-
-      if (approved) {
-        const approvals = loadApprovals();
-        approvals[ctx.cwd] = { hash: hashContent(pending.raw) };
-        saveApprovals(approvals);
-        allowedCommands = mergeRules(BASE_COMMANDS, pending.rules);
-      }
-    }
-
-    if (isAllowed(event.toolName, event.input, ctx)) return;
-
-    const summary = formatArgs(event.toolName, event.input);
-
-    // For write/edit, offer a "allow for session" option
-    if (event.toolName === "write" || event.toolName === "edit") {
-      const choice = await ctx.ui.select(`${event.toolName}: ${summary}`, [
-        "Allow once",
-        "Allow for session",
-        "Block",
-      ]);
-
-      if (choice === "Allow for session") {
-        sessionAllowEdits = true;
-        ctx.ui.notify("Edits allowed for this session (tracked files only)", "info");
-        return;
-      }
-      if (choice === "Allow once") return;
-      return { block: true, reason: "Blocked by user" };
-    }
-
-    const allowed = await ctx.ui.confirm(event.toolName, summary);
-
-    if (!allowed) {
-      return { block: true, reason: "Blocked by user" };
-    }
-  });
-}
-
-// ---------------------------------------------------------------------------
-// Formatting
-// ---------------------------------------------------------------------------
-
-function formatArgs(toolName: string, input: Record<string, unknown>): string {
-  switch (toolName) {
-    case "bash":
-      return `$ ${input.command}`;
-    case "read":
-      return `${input.path}` + (input.offset ? ` (offset ${input.offset})` : "");
-    case "write":
-      return `${input.path} (${String(input.content ?? "").length} chars)`;
-    case "edit":
-      return `${input.path}`;
-    default: {
-      const keys = Object.keys(input);
-      if (keys.length === 0) return "(no arguments)";
-      return keys.map((k) => `${k}: ${truncate(String(input[k]), 80)}`).join("\n");
-    }
-  }
-}
-
-function truncate(s: string, max: number): string {
-  if (s.length <= max) return s;
-  return s.slice(0, max) + "…";
-}
diff --git a/.pi/agent/extensions/pinch.ts b/.pi/agent/extensions/pinch.ts
deleted file mode 100644
index e7468c60..00000000
--- a/.pi/agent/extensions/pinch.ts
+++ /dev/null
@@ -1,708 +0,0 @@
-/**
- * Pinch — per-project plugin manager for pi
- *
- * Manages Claude plugins from git repos declared in pinch.json manifests.
- * Global manifest: ~/.pi/agent/pinch.json
- * Project manifest: .pi/pinch.json (overrides global by name)
- *
- * Repos are cloned into $XDG_CACHE_HOME/pinch/<source>/ as a shared cache.
- * Only the requested plugins are copied into the project at
- * .pi/pinch/<source>/<plugin>/. This keeps projects lightweight and works
- * in bind-mounted containers (no symlinks pointing outside the mount).
- *
- * Plugin location within a repo is resolved by:
- * 1. Explicit `path` field in pinch.json (e.g. "plugins")
- * 2. marketplace.json `source` field (e.g. "./plugins/playground")
- * 3. Repo root fallback (<repo>/<plugin>/)
- *
- * Manifest format (pinch.json):
- *
- *   {
- *     "anthropic": {
- *       "repo": "https://github.com/anthropics/claude-plugins-official",
- *       "ref": "main",
- *       "plugins": ["playground", "skill-creator"]
- *     },
- *     "my-plugins": {
- *       "repo": "git@github.com:me/plugins.git",
- *       "ref": "v1.2.0",
- *       "path": "plugins",
- *       "plugins": ["my-plugin"]
- *     }
- *   }
- *
- * Plugin structure (Claude plugin convention):
- *
- *   <plugin>/
- *     .claude-plugin/plugin.json
- *     skills/<skill-name>/SKILL.md
- *
- * Commands:
- *   /pinch:install  — clone/fetch all sources, copy plugins, register skills
- *   /pinch:update   — pull latest for unpinned sources, re-copy plugins
- *   /pinch:status   — show installed plugins and their skills
- *
- * Lock file (.pi/pinch-lock.json) tracks exact commits per source.
- * The `ref` field in lock entries is informational only — checkout uses
- * the commit hash directly for reproducibility.
- */
-
-import * as fs from "node:fs";
-import * as path from "node:path";
-import * as os from "node:os";
-import * as child_process from "node:child_process";
-import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
-
-// ── Types ──────────────────────────────────────────────────────────────
-
-interface PinchSource {
-  repo: string;
-  ref?: string;
-  path?: string; // subdirectory containing plugins (e.g. "plugins")
-  plugins: string[];
-}
-
-interface PinchConfig {
-  [name: string]: PinchSource;
-}
-
-interface PinchLockEntry {
-  commit: string;
-  ref?: string; // informational — records which ref produced this commit
-}
-
-interface PinchLock {
-  [name: string]: PinchLockEntry;
-}
-
-// ── Container detection ────────────────────────────────────────────────
-
-function inContainer(): boolean {
-  return fs.existsSync("/.dockerenv") || fs.existsSync("/run/.containerenv");
-}
-
-// ── Paths ──────────────────────────────────────────────────────────────
-
-/** XDG cache dir for repo clones */
-function cacheDir(): string {
-  const xdg = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
-  return path.join(xdg, "pinch");
-}
-
-/** Cached repo clone for a source */
-function cachedRepoDir(name: string): string {
-  return path.join(cacheDir(), name);
-}
-
-/** Project-local plugin install dir */
-function projectPinchDir(cwd: string): string {
-  return path.join(cwd, ".pi", "pinch");
-}
-
-/** Installed plugin dir within the project */
-function installedPluginDir(cwd: string, sourceName: string, pluginName: string): string {
-  return path.join(projectPinchDir(cwd), sourceName, pluginName);
-}
-
-function globalManifestPath(): string {
-  return path.join(os.homedir(), ".pi", "agent", "pinch.json");
-}
-
-function projectManifestPath(cwd: string): string {
-  return path.join(cwd, ".pi", "pinch.json");
-}
-
-function lockPath(cwd: string): string {
-  return path.join(cwd, ".pi", "pinch-lock.json");
-}
-
-// ── Manifest / Lock ───────────────────────────────────────────────────
-
-function readJsonFile(filePath: string): Record<string, any> | null {
-  if (!fs.existsSync(filePath)) return null;
-  try {
-    const raw = JSON.parse(fs.readFileSync(filePath, "utf-8"));
-    if (!raw || typeof raw !== "object") return null;
-    return raw;
-  } catch {
-    return null;
-  }
-}
-
-/**
- * Merge global (~/.pi/agent/pinch.json) and project (.pi/pinch.json)
- * manifests.
- *
- * - Global entries must have `repo` and `plugins`.
- * - Project entries referencing a global key can only set `plugins` —
- *   `repo`, `ref`, and `path` are disallowed (the global definition owns those).
- * - Project entries with a new key are full source definitions (must have `repo`).
- *
- * Returns null if no valid config is found.
- */
-interface ConfigResult {
-  config: PinchConfig;
-  errors: string[];
-}
-
-function readConfig(cwd: string): ConfigResult | null {
-  const globalRaw = readJsonFile(globalManifestPath());
-  const projectRaw = readJsonFile(projectManifestPath(cwd));
-  if (!globalRaw && !projectRaw) return null;
-
-  const config: PinchConfig = {};
-  const errors: string[] = [];
-
-  // Load global entries (must have repo)
-  if (globalRaw) {
-    for (const [name, entry] of Object.entries(globalRaw)) {
-      if (typeof entry !== "object" || entry === null) continue;
-      if (!("repo" in entry) || !("plugins" in entry)) continue;
-      config[name] = entry as PinchSource;
-    }
-  }
-
-  // Merge project entries
-  if (projectRaw) {
-    for (const [name, entry] of Object.entries(projectRaw)) {
-      if (typeof entry !== "object" || entry === null) continue;
-      if (!("plugins" in entry)) continue;
-
-      if (name in config) {
-        // Referencing a global source — only plugins allowed
-        if ("repo" in entry || "ref" in entry || "path" in entry) {
-          errors.push(`${name}: project manifest cannot override repo/ref/path of global source — only plugins allowed`);
-          continue;
-        }
-        config[name] = { ...config[name], plugins: (entry as PinchSource).plugins };
-      } else {
-        // New project-only source — full definition required
-        if (!("repo" in entry)) {
-          errors.push(`${name}: source must have repo`);
-          continue;
-        }
-        config[name] = entry as PinchSource;
-      }
-    }
-  }
-
-  if (Object.keys(config).length === 0 && errors.length === 0) return null;
-  return { config, errors };
-}
-
-function readLock(cwd: string): PinchLock {
-  const p = lockPath(cwd);
-  if (!fs.existsSync(p)) return {};
-  try {
-    return JSON.parse(fs.readFileSync(p, "utf-8")) as PinchLock;
-  } catch {
-    return {};
-  }
-}
-
-function writeLock(cwd: string, lock: PinchLock): void {
-  fs.mkdirSync(path.dirname(lockPath(cwd)), { recursive: true });
-  fs.writeFileSync(lockPath(cwd), JSON.stringify(lock, null, 2) + "\n", "utf-8");
-}
-
-// ── Git operations ─────────────────────────────────────────────────────
-
-function git(args: string[], opts?: { cwd?: string }): { ok: boolean; stdout: string; stderr: string } {
-  try {
-    const result = child_process.execFileSync("git", args, {
-      cwd: opts?.cwd,
-      encoding: "utf-8",
-      stdio: ["pipe", "pipe", "pipe"],
-      timeout: 120_000,
-    });
-    return { ok: true, stdout: result.trim(), stderr: "" };
-  } catch (e: any) {
-    return {
-      ok: false,
-      stdout: (e.stdout ?? "").toString().trim(),
-      stderr: (e.stderr ?? "").toString().trim(),
-    };
-  }
-}
-
-function cloneOrFetch(name: string, repo: string): { ok: boolean; error?: string } {
-  const dir = cachedRepoDir(name);
-
-  if (fs.existsSync(path.join(dir, ".git"))) {
-    const result = git(["fetch", "--all", "--prune"], { cwd: dir });
-    if (!result.ok) return { ok: false, error: `fetch failed: ${result.stderr}` };
-    return { ok: true };
-  }
-
-  fs.mkdirSync(path.dirname(dir), { recursive: true });
-  const result = git(["clone", repo, dir]);
-  if (!result.ok) return { ok: false, error: `clone failed: ${result.stderr}` };
-  return { ok: true };
-}
-
-function checkout(name: string, ref: string): { ok: boolean; commit?: string; error?: string } {
-  const dir = cachedRepoDir(name);
-
-  let target = ref;
-  const originRef = git(["rev-parse", "--verify", `origin/${ref}`], { cwd: dir });
-  if (originRef.ok) {
-    target = `origin/${ref}`;
-  }
-
-  const result = git(["checkout", "--detach", target], { cwd: dir });
-  if (!result.ok) return { ok: false, error: `checkout ${ref} failed: ${result.stderr}` };
-
-  const rev = git(["rev-parse", "HEAD"], { cwd: dir });
-  return { ok: true, commit: rev.stdout };
-}
-
-function getCurrentCommit(name: string): string | undefined {
-  const dir = cachedRepoDir(name);
-  const rev = git(["rev-parse", "HEAD"], { cwd: dir });
-  return rev.ok ? rev.stdout : undefined;
-}
-
-// ── Marketplace ────────────────────────────────────────────────────────
-
-interface MarketplaceEntry {
-  name: string;
-  source: string | { source: string; url?: string; path?: string };
-  [key: string]: any;
-}
-
-interface Marketplace {
-  plugins?: MarketplaceEntry[];
-  [key: string]: any;
-}
-
-/** Per-source cache of marketplace plugin name → source path */
-const marketplaceCache = new Map<string, Map<string, string> | null>();
-
-/**
- * Read .claude-plugin/marketplace.json from a repo.
- * Returns a cached map of plugin name → relative source path.
- */
-function readMarketplace(dir: string): Map<string, string> | null {
-  if (marketplaceCache.has(dir)) return marketplaceCache.get(dir)!;
-
-  const mp = path.join(dir, ".claude-plugin", "marketplace.json");
-  if (!fs.existsSync(mp)) {
-    marketplaceCache.set(dir, null);
-    return null;
-  }
-
-  try {
-    const data = JSON.parse(fs.readFileSync(mp, "utf-8")) as Marketplace;
-    if (!data.plugins) {
-      marketplaceCache.set(dir, null);
-      return null;
-    }
-
-    const map = new Map<string, string>();
-    for (const entry of data.plugins) {
-      if (typeof entry.source === "string") {
-        map.set(entry.name, entry.source);
-      }
-    }
-    const result = map.size > 0 ? map : null;
-    marketplaceCache.set(dir, result);
-    return result;
-  } catch {
-    marketplaceCache.set(dir, null);
-    return null;
-  }
-}
-
-/** Clear the marketplace cache (call after checkout changes the working tree) */
-function clearMarketplaceCache(): void {
-  marketplaceCache.clear();
-}
-
-// ── Plugin resolution ──────────────────────────────────────────────────
-
-/**
- * Resolve the source directory of a plugin within the cached repo.
- *
- * Resolution order:
- * 1. Explicit `path` in pinch.json → <cache>/<path>/<plugin>/
- * 2. marketplace.json `source` field → <cache>/<source>/
- * 3. Repo root fallback → <cache>/<plugin>/
- */
-function cachedPluginDir(name: string, source: PinchSource, plugin: string): string {
-  const dir = cachedRepoDir(name);
-
-  if (source.path) return path.join(dir, source.path, plugin);
-
-  const marketplace = readMarketplace(dir);
-  if (marketplace) {
-    const sourcePath = marketplace.get(plugin);
-    if (sourcePath) return path.join(dir, sourcePath);
-  }
-
-  return path.join(dir, plugin);
-}
-
-// ── Copy plugins ───────────────────────────────────────────────────────
-
-/** Recursively copy a directory, creating parents as needed. Skips symlinks. */
-function copyDir(src: string, dest: string): void {
-  fs.mkdirSync(dest, { recursive: true });
-  for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
-    const srcPath = path.join(src, entry.name);
-    const destPath = path.join(dest, entry.name);
-    if (entry.isSymbolicLink()) {
-      continue; // skip symlinks — they may point outside the repo
-    } else if (entry.isDirectory()) {
-      copyDir(srcPath, destPath);
-    } else if (entry.isFile()) {
-      fs.copyFileSync(srcPath, destPath);
-    }
-  }
-}
-
-/**
- * Copy a plugin from the cache into the project.
- * Removes the old copy first to ensure a clean state.
- */
-function installPlugin(cwd: string, sourceName: string, source: PinchSource, plugin: string): { ok: boolean; error?: string } {
-  const src = cachedPluginDir(sourceName, source, plugin);
-  if (!fs.existsSync(src)) {
-    return { ok: false, error: `plugin "${plugin}" not found in repo` };
-  }
-
-  const dest = installedPluginDir(cwd, sourceName, plugin);
-  fs.rmSync(dest, { recursive: true, force: true });
-  copyDir(src, dest);
-  return { ok: true };
-}
-
-// ── Cleanup ────────────────────────────────────────────────────────────
-
-/**
- * Remove source/plugin dirs under .pi/pinch/ that are no longer in the config.
- * Cleans up empty source directories after pruning individual plugins.
- */
-function pruneStale(config: PinchConfig, cwd: string): string[] {
-  const dir = projectPinchDir(cwd);
-  if (!fs.existsSync(dir)) return [];
-
-  const pruned: string[] = [];
-
-  for (const entry of fs.readdirSync(dir)) {
-    const full = path.join(dir, entry);
-    try {
-      if (!fs.statSync(full).isDirectory()) continue;
-    } catch {
-      continue;
-    }
-
-    if (!config[entry]) {
-      // Entire source removed
-      fs.rmSync(full, { recursive: true, force: true });
-      pruned.push(entry);
-    } else {
-      // Prune plugins no longer in the list
-      const wantedPlugins = new Set(config[entry].plugins);
-      for (const pluginEntry of fs.readdirSync(full)) {
-        const pluginFull = path.join(full, pluginEntry);
-        try {
-          if (!fs.statSync(pluginFull).isDirectory()) continue;
-        } catch {
-          continue;
-        }
-        if (!wantedPlugins.has(pluginEntry)) {
-          fs.rmSync(pluginFull, { recursive: true, force: true });
-          pruned.push(`${entry}/${pluginEntry}`);
-        }
-      }
-
-      // Remove empty source directory
-      try {
-        const remaining = fs.readdirSync(full);
-        if (remaining.length === 0) {
-          fs.rmdirSync(full);
-        }
-      } catch {}
-    }
-  }
-
-  return pruned;
-}
-
-// ── Skill discovery ────────────────────────────────────────────────────
-
-/**
- * Find all SKILL.md files inside a plugin's skills/ directory.
- */
-function findPluginSkills(dir: string): string[] {
-  const skillsDir = path.join(dir, "skills");
-  if (!fs.existsSync(skillsDir)) return [];
-
-  const paths: string[] = [];
-  for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
-    if (!entry.isDirectory()) continue;
-    const skillMd = path.join(skillsDir, entry.name, "SKILL.md");
-    if (fs.existsSync(skillMd)) {
-      paths.push(skillMd);
-    }
-  }
-  return paths;
-}
-
-/**
- * Collect all skill paths from installed plugins (project copies).
- */
-function resolveSkillPaths(config: PinchConfig, cwd: string): string[] {
-  const paths: string[] = [];
-
-  for (const [name, source] of Object.entries(config)) {
-    for (const plugin of source.plugins) {
-      const dir = installedPluginDir(cwd, name, plugin);
-      if (fs.existsSync(dir)) {
-        paths.push(...findPluginSkills(dir));
-      }
-    }
-  }
-
-  return paths;
-}
-
-// ── Install / Update logic ─────────────────────────────────────────────
-
-interface SyncResult {
-  installed: string[];
-  updated: string[];
-  copied: string[];
-  pruned: string[];
-  skills: string[];
-  errors: string[];
-}
-
-type Log = (msg: string) => void;
-
-function installAll(cwd: string, update: boolean, log: Log): SyncResult {
-  const cr = readConfig(cwd);
-  if (!cr) return { installed: [], updated: [], copied: [], pruned: [], errors: ['No pinch.json manifest found'], skills: [] };
-
-  const { config, errors: configErrors } = cr;
-  const lock = readLock(cwd);
-  const newLock: PinchLock = {};
-  const result: SyncResult = { installed: [], updated: [], copied: [], pruned: [], errors: [...configErrors], skills: [] };
-
-  for (const [name, source] of Object.entries(config)) {
-    const repoExists = fs.existsSync(path.join(cachedRepoDir(name), ".git"));
-    const lockedCommit = lock[name]?.commit;
-    const ref = source.ref ?? "HEAD";
-    const isPinned = !!source.ref;
-
-    // Clone or fetch into cache
-    if (!repoExists) {
-      log(`cloning ${name} from ${source.repo}...`);
-      const clone = cloneOrFetch(name, source.repo);
-      if (!clone.ok) {
-        result.errors.push(`${name}: ${clone.error}`);
-        continue;
-      }
-      result.installed.push(name);
-    } else if (update) {
-      log(`fetching ${name}...`);
-      const fetch = cloneOrFetch(name, source.repo);
-      if (!fetch.ok) {
-        result.errors.push(`${name}: ${fetch.error}`);
-        if (lockedCommit) {
-          newLock[name] = { commit: lockedCommit, ...(source.ref ? { ref: source.ref } : {}) };
-        }
-        continue;
-      }
-    }
-
-    // Determine which ref to checkout
-    let targetRef = ref;
-    if (!update && lockedCommit && repoExists) {
-      targetRef = lockedCommit;
-    } else if (update && isPinned) {
-      targetRef = ref;
-    } else if (update) {
-      targetRef = "HEAD";
-    }
-
-    log(`checking out ${name} at ${targetRef === ref ? ref : targetRef.slice(0, 8)}...`);
-    clearMarketplaceCache();
-    const co = checkout(name, targetRef);
-    if (!co.ok) {
-      result.errors.push(`${name}: ${co.error}`);
-      continue;
-    }
-
-    const commit = co.commit ?? getCurrentCommit(name) ?? "unknown";
-
-    if (update && lockedCommit && lockedCommit !== commit) {
-      result.updated.push(name);
-    }
-
-    newLock[name] = { commit, ...(source.ref ? { ref: source.ref } : {}) };
-
-    // Copy plugins from cache into project
-    for (const plugin of source.plugins) {
-      log(`copying ${name}/${plugin}...`);
-      const install = installPlugin(cwd, name, source, plugin);
-      if (!install.ok) {
-        result.errors.push(`${name}: ${install.error}`);
-        continue;
-      }
-      result.copied.push(`${name}/${plugin}`);
-      const dest = installedPluginDir(cwd, name, plugin);
-      const skills = findPluginSkills(dest);
-      result.skills.push(...skills.map((s) => {
-        const rel = path.relative(projectPinchDir(cwd), s);
-        return rel.replace(/\/SKILL\.md$/, "");
-      }));
-    }
-  }
-
-  writeLock(cwd, newLock);
-  result.pruned = pruneStale(config, cwd);
-
-  return result;
-}
-
-// ── Extension ──────────────────────────────────────────────────────────
-
-export default function pinch(pi: ExtensionAPI) {
-  let cwd = "";
-
-  pi.on("resources_discover", (event) => {
-    cwd = event.cwd;
-    const cr = readConfig(cwd);
-    if (!cr) return;
-
-    const skillPaths = resolveSkillPaths(cr.config, cwd);
-    if (skillPaths.length > 0) {
-      return { skillPaths };
-    }
-  });
-
-  pi.on("session_start", async (_event, ctx) => {
-    const cr = readConfig(cwd);
-    if (!cr) return;
-
-    for (const err of cr.errors) {
-      ctx.ui.notify(`pinch: ${err}`, "error");
-    }
-
-    const { config } = cr;
-    const skillPaths = resolveSkillPaths(config, cwd);
-    const totalPlugins = Object.values(config).reduce((n, s) => n + s.plugins.length, 0);
-    const installedPlugins = Object.entries(config).reduce((n, [name, source]) => {
-      return n + source.plugins.filter((p) => fs.existsSync(installedPluginDir(cwd, name, p))).length;
-    }, 0);
-    const missing = totalPlugins - installedPlugins;
-
-    if (missing > 0) {
-      ctx.ui.notify(
-        `pinch: ${missing} plugin(s) not installed — run /pinch:install`,
-        "warning"
-      );
-    } else if (skillPaths.length > 0) {
-      ctx.ui.notify(`pinch: ${skillPaths.length} skill(s) from ${installedPlugins} plugin(s)`, "info");
-    }
-  });
-
-  pi.registerCommand("pinch:install", {
-    description: "Install plugins declared in pinch config",
-    handler: async (_args, ctx) => {
-      if (inContainer()) {
-        ctx.ui.notify("pinch: cannot install inside a container — run on the host", "error");
-        return;
-      }
-      const log = (msg: string) => ctx.ui.notify(`pinch: ${msg}`, "info");
-      const result = installAll(cwd, false, log);
-      reportResult(result, ctx);
-
-      if (result.skills.length > 0 || result.pruned.length > 0) {
-        await ctx.reload();
-      }
-    },
-  });
-
-  pi.registerCommand("pinch:update", {
-    description: "Update plugins to latest (respects pinned refs)",
-    handler: async (_args, ctx) => {
-      if (inContainer()) {
-        ctx.ui.notify("pinch: cannot update inside a container — run on the host", "error");
-        return;
-      }
-      const log = (msg: string) => ctx.ui.notify(`pinch: ${msg}`, "info");
-      const result = installAll(cwd, true, log);
-      reportResult(result, ctx);
-
-      if (result.updated.length > 0 || result.installed.length > 0 || result.pruned.length > 0) {
-        await ctx.reload();
-      }
-    },
-  });
-
-  pi.registerCommand("pinch:status", {
-    description: "Show installed plugins and their skills",
-    handler: async (_args, ctx) => {
-      const cr = readConfig(cwd);
-      if (!cr) {
-        ctx.ui.notify('No pinch.json manifest found', "error");
-        return;
-      }
-
-      for (const err of cr.errors) {
-        ctx.ui.notify(`pinch: ${err}`, "error");
-      }
-
-      const lock = readLock(cwd);
-      const lines: string[] = [];
-
-      for (const [name, source] of Object.entries(cr.config)) {
-        const lockEntry = lock[name];
-        const cached = fs.existsSync(path.join(cachedRepoDir(name), ".git"));
-        const status = cached ? "✓" : "✗";
-        const commit = lockEntry?.commit?.slice(0, 8) ?? "—";
-        const ref = source.ref ?? "HEAD";
-
-        lines.push(`${status} ${name} (${ref} @ ${commit})`);
-
-        for (const plugin of source.plugins) {
-          const pDir = installedPluginDir(cwd, name, plugin);
-          const exists = fs.existsSync(pDir);
-          const skills = exists ? findPluginSkills(pDir) : [];
-          const skillNames = skills.map((s) => path.basename(path.dirname(s)));
-
-          if (skills.length > 0) {
-            lines.push(`  • ${plugin} (${skillNames.join(", ")})`);
-          } else if (exists) {
-            lines.push(`  • ${plugin}`);
-          } else {
-            lines.push(`  ✗ ${plugin} (not installed)`);
-          }
-        }
-      }
-
-      await ctx.ui.select("Pinch Status", lines);
-    },
-  });
-}
-
-function reportResult(result: SyncResult, ctx: ExtensionContext): void {
-  for (const err of result.errors) {
-    ctx.ui.notify(`pinch: ${err}`, "error");
-  }
-
-  const parts: string[] = [];
-  if (result.installed.length > 0) parts.push(`cloned ${result.installed.join(", ")}`);
-  if (result.updated.length > 0) parts.push(`updated ${result.updated.join(", ")}`);
-  if (result.copied.length > 0) parts.push(`copied ${result.copied.length} plugin(s)`);
-  if (result.pruned.length > 0) parts.push(`pruned ${result.pruned.join(", ")}`);
-  if (result.skills.length > 0) parts.push(`${result.skills.length} skill(s)`);
-
-  if (parts.length > 0) {
-    ctx.ui.notify(`pinch: ${parts.join(", ")}`, "info");
-  } else if (result.errors.length === 0) {
-    ctx.ui.notify("pinch: nothing to do", "info");
-  }
-}
diff --git a/.pi/agent/keybindings.json b/.pi/agent/keybindings.json
deleted file mode 100644
index 0ddd327b..00000000
--- a/.pi/agent/keybindings.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
-  "cursorUp": ["up", "ctrl+p"],
-  "cursorDown": ["down", "ctrl+n"],
-  "selectUp": ["up", "ctrl+p", "ctrl+k"],
-  "selectDown": ["down", "ctrl+n", "ctrl+j"],
-  "cycleModelForward": []
-}
diff --git a/.pi/agent/pinch.json b/.pi/agent/pinch.json
deleted file mode 100644
index 2ac9f3f6..00000000
--- a/.pi/agent/pinch.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
-  "learning-opportunities": {
-    "repo": "https://github.com/DrCatHicks/learning-opportunities",
-    "ref": "main",
-    "plugins": ["learning-opportunities"]
-  },
-  "claude-plugins-official": {
-    "repo": "https://github.com/anthropics/claude-plugins-official",
-    "ref": "main",
-    "plugins": ["playground"]
-  }
-}
diff --git a/.pi/agent/settings.json b/.pi/agent/settings.json
deleted file mode 100644
index 83532683..00000000
--- a/.pi/agent/settings.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
-  "lastChangelogVersion": "0.58.1",
-  "defaultProvider": "anthropic",
-  "defaultModel": "claude-opus-4-6",
-  "skills": [
-    "~/.claude/skills"
-  ],
-  "enableSkillCommands": true,
-  "defaultThinkingLevel": "high"
-}
diff --git a/.pi/agent/skills/pinch/SKILL.md b/.pi/agent/skills/pinch/SKILL.md
deleted file mode 100644
index 4452d4c5..00000000
--- a/.pi/agent/skills/pinch/SKILL.md
+++ /dev/null
@@ -1,137 +0,0 @@
----
-name: pinch
-description: Manage pi plugins from git repos via pinch — add/remove sources and plugins in pinch.json, install, update, and troubleshoot. Use when the user wants to add a plugin, configure a marketplace/source, or debug pinch issues.
----
-
-# Pinch
-
-Pinch is a per-project plugin manager for pi, implemented as a pi extension (`~/.pi/agent/extensions/pinch.ts`). It clones git repos containing plugins (with skills, etc.) into `.pi/pinch/` and registers their skills automatically.
-
-## Concepts
-
-- **Source**: A named git repo entry in `pinch.json`. Each source has a `repo` URL, optional `ref` (branch/tag/commit), optional `path` (subdirectory containing plugins), and a `plugins` array listing which plugins to activate.
-- **Plugin**: A directory inside a source repo following Claude plugin conventions — contains `skills/<skill-name>/SKILL.md`.
-- **Lock file**: `.pi/pinch-lock.json` tracks the exact commit per source for reproducible installs.
-
-## Manifest format
-
-Manifests are JSON files named `pinch.json`:
-
-- **Global**: `~/.pi/agent/pinch.json` — applies to all projects
-- **Project**: `.pi/pinch.json` — project-specific, overrides global entries by name
-
-```json
-{
-  "source-name": {
-    "repo": "https://github.com/owner/repo",
-    "ref": "main",
-    "path": "plugins",
-    "plugins": ["plugin-a", "plugin-b"]
-  }
-}
-```
-
-| Field | Required | Description |
-|-------|----------|-------------|
-| `repo` | Yes | Git clone URL (HTTPS or SSH) |
-| `ref` | No | Branch, tag, or commit. Omit for HEAD. Pinned refs are not updated by `/pinch:update`. |
-| `path` | No | Subdirectory within the repo that contains the plugin directories. Auto-detected if omitted (scans one level deep for `.claude-plugin/plugin.json`). |
-| `plugins` | Yes | Array of plugin directory names to activate from this source |
-
-## Commands
-
-| Command | Description |
-|---------|-------------|
-| `/pinch:install` | Clone sources and register skills. Respects lock file for already-installed sources. |
-| `/pinch:update` | Fetch latest and update to newest commits. Pinned refs stay put. |
-| `/pinch:status` | Show installed sources, plugins, and their skills. |
-
-## Manifest rules
-
-- **Global manifest** (`~/.pi/agent/pinch.json`): Full source definitions with `repo`, `ref`, `path`, `plugins`.
-- **Project manifest** (`.pi/pinch.json`): Can define new sources (with `repo`), or reference global sources by name with only `plugins`. If a key exists in the global manifest, `repo`/`ref`/`path` cannot be overridden — only `plugins` is allowed.
-
-```json
-// Project manifest referencing a global source (plugins only)
-{
-  "claude-plugins-official": {
-    "plugins": ["playground", "skill-creator"]
-  }
-}
-
-// Project manifest with its own source (full definition, new key)
-{
-  "my-team-plugins": {
-    "repo": "git@github.com:team/plugins.git",
-    "ref": "main",
-    "plugins": ["our-plugin"]
-  }
-}
-```
-
-## Adding a plugin
-
-1. Identify the repo URL and which plugin(s) you want from it.
-2. Choose a short source name (used as the directory name under `.pi/pinch/`).
-3. Add the entry to the appropriate `pinch.json`:
-
-```json
-{
-  "anthropic": {
-    "repo": "https://github.com/anthropics/claude-plugins-official",
-    "ref": "main",
-    "plugins": ["playgrounds"]
-  }
-}
-```
-
-If the plugins live in a subdirectory of the repo (not at the root), set `path`:
-
-```json
-{
-  "my-source": {
-    "repo": "https://github.com/owner/repo",
-    "ref": "main",
-    "path": "plugins",
-    "plugins": ["the-plugin"]
-  }
-}
-```
-
-4. Run `/pinch:install` to clone and activate.
-
-## Removing a plugin
-
-- To stop using a single plugin from a source, remove it from the `plugins` array.
-- To remove an entire source, delete its key from `pinch.json`. The next `/pinch:install` prunes stale directories.
-
-## File layout
-
-```
-$XDG_CACHE_HOME/pinch/         # Shared repo cache (~/.cache/pinch/)
-└── <source-name>/             # Full repo clone
-
-.pi/
-├── pinch.json                  # Project manifest (optional)
-├── pinch-lock.json             # Lock file (auto-generated)
-└── pinch/
-    └── <source-name>/
-        └── <plugin>/           # Copied from cache (only selected plugins)
-            └── skills/
-                └── <skill>/
-                    └── SKILL.md
-
-~/.pi/agent/
-├── pinch.json                  # Global manifest
-└── extensions/
-    └── pinch.ts                # The extension itself
-```
-
-Repos are cloned once into the XDG cache. Only the requested plugin directories are copied into the project, keeping `.pi/pinch/` lightweight and working correctly in bind-mounted containers.
-
-## Troubleshooting
-
-- **"No pinch.json manifest found"**: Neither `~/.pi/agent/pinch.json` nor `.pi/pinch.json` exists or contains valid entries.
-- **Plugin not found after install**: Check that the plugin name in `plugins` matches the actual directory name in the repo. Use `path` if plugins are nested.
-- **Network errors on clone/fetch**: Pinch uses `git` directly — check that the repo URL is reachable and credentials are configured.
-- **Skills not loading**: Plugin must have `skills/<name>/SKILL.md` structure. Run `/pinch:status` to verify.
diff --git a/ai/AI.md b/ai/_AGENTS.md
similarity index 100%
rename from ai/AI.md
rename to ai/_AGENTS.md
diff --git a/frork/_dotfiles.fnl b/frork/_dotfiles.fnl
index 4b290935..a35fcacb 100644
--- a/frork/_dotfiles.fnl
+++ b/frork/_dotfiles.fnl
@@ -26,7 +26,7 @@
       (ok :symlink dest src))))
 
 (ok :symlink "~/.dotfiles/ai/claude" "~/.claude")
-(ok :symlink "~/.dotfiles/ai/AI.md" "~/.claude/CLAUDE.md")
+(ok :symlink "~/.dotfiles/ai/_AGENTS.md" "~/.claude/CLAUDE.md")
 
 (ok :directory "~/.pi")
 (ok :symlink "~/.dotfiles/ai/pi" "~/.pi/agent")