pi: user config at ai/pi/, symlink ~/.pi/agent
Pi user-level config (extensions, settings, keybindings, pinch
manifest, skills) now lives at ai/pi/ alongside ai/claude/ and
ai/skills/. Frork declares ~/.pi/agent → ~/.dotfiles/ai/pi.

The old ~/.pi → ~/.dotfiles/.pi symlink is replaced with a real
~/.pi directory containing just the agent symlink. Pi writes
runtime files (sessions, auth) into ai/pi/ — gitignored there.

Settings updated to point skills at ~/.dotfiles/ai/skills
instead of reaching through ~/.claude/skills.

Assisted-by: Claude Opus 4.6 via pi
change zlvlnkvnqywxptyyzxpnnxmvxllzsmzn
commit 418629f40d07c62909e9982d37e3d6a28f8ae718
author Alpha Chen <alpha@kejadlen.dev>
date
parent mlkuprlu
diff --git a/ai/pi/.gitignore b/ai/pi/.gitignore
new file mode 100644
index 00000000..71abe84c
--- /dev/null
+++ b/ai/pi/.gitignore
@@ -0,0 +1,3 @@
+sessions/
+auth.json
+approved-permissions.json
diff --git a/ai/pi/extensions/README.md b/ai/pi/extensions/README.md
new file mode 100644
index 00000000..e5f6dd7f
--- /dev/null
+++ b/ai/pi/extensions/README.md
@@ -0,0 +1,109 @@
+# 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/ai/pi/extensions/claude-plugins.ts b/ai/pi/extensions/claude-plugins.ts
new file mode 100644
index 00000000..11d475ba
--- /dev/null
+++ b/ai/pi/extensions/claude-plugins.ts
@@ -0,0 +1,218 @@
+/**
+ * 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/ai/pi/extensions/fork-tmux.ts b/ai/pi/extensions/fork-tmux.ts
new file mode 100644
index 00000000..1fe42c90
--- /dev/null
+++ b/ai/pi/extensions/fork-tmux.ts
@@ -0,0 +1,120 @@
+/**
+ * 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/ai/pi/extensions/jj-status.ts b/ai/pi/extensions/jj-status.ts
new file mode 100644
index 00000000..c8c49a9d
--- /dev/null
+++ b/ai/pi/extensions/jj-status.ts
@@ -0,0 +1,257 @@
+/**
+ * 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/ai/pi/extensions/model-identity.ts b/ai/pi/extensions/model-identity.ts
new file mode 100644
index 00000000..8dbfd9a0
--- /dev/null
+++ b/ai/pi/extensions/model-identity.ts
@@ -0,0 +1,26 @@
+/**
+ * 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/ai/pi/extensions/permission-gate.ts b/ai/pi/extensions/permission-gate.ts
new file mode 100644
index 00000000..ecfe279a
--- /dev/null
+++ b/ai/pi/extensions/permission-gate.ts
@@ -0,0 +1,439 @@
+/**
+ * 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/ai/pi/extensions/pinch.ts b/ai/pi/extensions/pinch.ts
new file mode 100644
index 00000000..e7468c60
--- /dev/null
+++ b/ai/pi/extensions/pinch.ts
@@ -0,0 +1,708 @@
+/**
+ * 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/ai/pi/keybindings.json b/ai/pi/keybindings.json
new file mode 100644
index 00000000..0ddd327b
--- /dev/null
+++ b/ai/pi/keybindings.json
@@ -0,0 +1,7 @@
+{
+  "cursorUp": ["up", "ctrl+p"],
+  "cursorDown": ["down", "ctrl+n"],
+  "selectUp": ["up", "ctrl+p", "ctrl+k"],
+  "selectDown": ["down", "ctrl+n", "ctrl+j"],
+  "cycleModelForward": []
+}
diff --git a/ai/pi/pinch.json b/ai/pi/pinch.json
new file mode 100644
index 00000000..2ac9f3f6
--- /dev/null
+++ b/ai/pi/pinch.json
@@ -0,0 +1,12 @@
+{
+  "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/ai/pi/settings.json b/ai/pi/settings.json
new file mode 100644
index 00000000..0631e22f
--- /dev/null
+++ b/ai/pi/settings.json
@@ -0,0 +1,10 @@
+{
+  "lastChangelogVersion": "0.58.1",
+  "defaultProvider": "anthropic",
+  "defaultModel": "claude-opus-4-6",
+  "skills": [
+    "~/.dotfiles/ai/skills"
+  ],
+  "enableSkillCommands": true,
+  "defaultThinkingLevel": "high"
+}
\ No newline at end of file
diff --git a/ai/pi/skills/pinch/SKILL.md b/ai/pi/skills/pinch/SKILL.md
new file mode 100644
index 00000000..4452d4c5
--- /dev/null
+++ b/ai/pi/skills/pinch/SKILL.md
@@ -0,0 +1,137 @@
+---
+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/frork/_dotfiles.fnl b/frork/_dotfiles.fnl
index d527d959..4b290935 100644
--- a/frork/_dotfiles.fnl
+++ b/frork/_dotfiles.fnl
@@ -28,6 +28,9 @@
 (ok :symlink "~/.dotfiles/ai/claude" "~/.claude")
 (ok :symlink "~/.dotfiles/ai/AI.md" "~/.claude/CLAUDE.md")
 
+(ok :directory "~/.pi")
+(ok :symlink "~/.dotfiles/ai/pi" "~/.pi/agent")
+
 ;; moved to .config/git/config
 (no :symlink :$HOME/.gitconfig :$HOME/.dotfiles/.gitconfig)