Update pi extensions and settings
Bridge Claude Code plugin skills into pi via new claude-plugins
extension. Add session-scoped write/edit auto-allow for tracked
files in permission-gate. Enable high thinking level.

Assisted-by: Claude Opus 4.6 via Claude Code
change xlnmlwnuvnnxopmnyztpwtkuyzqlxmwk
commit 45809c1c0a51fefc0158f6a5719fff077df7f81e
author Alpha Chen <alpha@kejadlen.dev>
date
parent wxxvrlzm
diff --git a/.pi/agent/extensions/claude-plugins.ts b/.pi/agent/extensions/claude-plugins.ts
new file mode 100644
index 00000000..11d475ba
--- /dev/null
+++ b/.pi/agent/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/.pi/agent/extensions/permission-gate.ts b/.pi/agent/extensions/permission-gate.ts
index 261e6d3e..ecfe279a 100644
--- a/.pi/agent/extensions/permission-gate.ts
+++ b/.pi/agent/extensions/permission-gate.ts
@@ -6,6 +6,11 @@
  * 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).
@@ -150,6 +155,9 @@ let allowedCommands: CommandRule = BASE_COMMANDS;
  */
 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
 // ---------------------------------------------------------------------------
@@ -261,6 +269,16 @@ function isCommandAllowed(cmd: string): boolean {
 // 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 ?? ""));
@@ -279,6 +297,12 @@ function isAllowed(toolName: string, input: Record<string, unknown>, ctx: Extens
     );
   }
 
+  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;
@@ -297,6 +321,7 @@ export default function(pi: ExtensionAPI) {
     // Reset state each session
     allowedCommands = BASE_COMMANDS;
     pendingProjectRules = null;
+    sessionAllowEdits = false;
 
     const permPath = path.join(ctx.cwd, ".pi/permissions.json");
     let raw: string;
@@ -360,6 +385,24 @@ export default function(pi: ExtensionAPI) {
     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) {
diff --git a/.pi/agent/settings.json b/.pi/agent/settings.json
index 36e43037..02581e30 100644
--- a/.pi/agent/settings.json
+++ b/.pi/agent/settings.json
@@ -1,10 +1,10 @@
 {
-  "lastChangelogVersion": "0.52.6",
+  "lastChangelogVersion": "0.54.0",
   "defaultProvider": "anthropic",
   "defaultModel": "claude-opus-4-6",
   "skills": [
     "~/.claude/skills"
   ],
   "enableSkillCommands": true,
-  "defaultThinkingLevel": "off"
+  "defaultThinkingLevel": "high"
 }
\ No newline at end of file