Vandee's Blog

19 Apr 2026

My Agent Practice with Pi in Emacs

Pi 现在加入 earendil 公司了,增加了一个很小的遥测,可以主动关闭。

加速 Pi 启动 offline 三连:

# in ~/.bashrc
export PI_TELEMETRY=0
export PI_OFFLINE=1
export PI_SKIP_VERSION_CHECK=1

https://mariozechner.at/posts/2026-04-08-ive-sold-out/

https://lucumr.pocoo.org/2026/4/8/mario-and-earendil/

开源项目大了之后,似乎都逃不过这个路径。

我看了一下最近的几个版本 release,有一部分是为了 OpenClaw。

TL;DR

记录一下现在在 Emacs 里使用 pi 的配置。

另见:My Agent Practice with OpenCode in Emacs

Pimacs

VandeeFeng/pimacs: Emacs frontend for the pi coding agent forked from https://github.com/dnouri/pi-coding-agent ,后面就叫他 pimacs。

xenodium/agent-shell: A native Emacs buffer to interact with LLM agents powered by ACP 更全面,pimacs 更轻量,专门为 Pi 量身定制。

看了源码,通过 Pi 的 RPC 和 Emacs 连接,Emacs 的 native 特性也保持的很好。和 ACP 比起来,少了一个中间层。

pimacs 在 Emacs 里的体验基本和在终端里一致。我挺喜欢这个项目的设计,也学到了许多,感谢大佬的开源。

我的 fork 里,主要根据我的习惯添加了两个小功能:

  • @agent 调用 agent:

    Pi 的设计原则里是没有 subagent 的,所以没有类似的功能。虽然可以通过 slash command 调用 agent,广义上这些都是 prompt,但是我还是觉得在设计上,还是要区分 agent 和 slash command 分开。这样相同的 slash command 可以调用不同的 agent,区别还是有的。

    输入 @ 之后就会自动补全在 pi 里自定义的 agent 名称和描述,直接调用。

    要实现这个功能,还要依赖后面贴上来的一个 pi extension。这个功能依赖的是 pi 底层的逻辑,在这个 fork 里实现的是和 Emacs 的交互补全逻辑。

  • insert region:

    选中一个区域,直接插入到 pimacs 的输入区。这是我想在 Emacs 里使用 pi 的最大原因。

    这应该是现在 Coding TUI 里最不方便的点了,单独做文件预览又很没有必要。

    用 Emacs 来做这个就太合适了。

    效果就是这样:

     @posts/2026-04-19-my-agent-practice-with-pi-in-emacs.org#L30-L31
     ````org
     选中一个区域,直接插入到 pimacs 的输入区。这是我想在 Emacs 里使用 pi 的最大原因。
     ````
    

    在终端里每次都要重复 Emacs 之间的切换,时间久了就感觉很麻烦。

    大型文件(超过 2k 行)agent 往往要 grep 多次才能精准定位。浪费 token 是其次,在搜索的过程中,会污染上下文。看着 agent 一次次的 grep,等待的过程也很磨叽。

    与其用自然语言描述这段代码,不如直接贴过去。

    再其次,就算是全 vibe,我觉得自己对代码要有最起码的理解,起码得知道一个功能具体在哪里实现的吧。

My Pi Extensions

我的 Agent Memory 极简管理方案 之前在这里已经列出了几个,统一再这里补充一下。

后面有更新就只在这里更新了,自己看起来也方便一点。

Command Rule

pi-mono 没有对 tool 调用的规则设定,参考 OpenCode 的实现 让 pi 生成了一个 extension 来禁止一些 shell 指令的调用,例如 rm,git push,git commit。

/**
 * Command Permissions Extension
 *
 * Ported from opencode command permissions configuration.
 * Supports wildcard patterns and ask/allow/deny actions.
 *
 * Configuration in settings.json:
 * {
 *   "permission": {
 *     "YOLO": true,
 *     "nonInteractiveAsk": "deny",
 *     "read": { ".env": "deny", "*.md": "allow" },
 *     "bash": { "git push *": "ask", "rm -rf *": "deny" }
 *   }
 * }
 *
 * Config files (merged, project takes precedence):
 * - ~/.pi/agent/settings.json (global)
 * - <cwd>/.pi/settings.json (project-local)
 */

import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Key, matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";

// Simple glob-to-regex converter for command matching
// Matches opencode's wildcard implementation
// * matches any characters (including /)
// ? matches any single character
// Pattern ending with " *" makes trailing part optional
function globToRegex(pattern: string): RegExp {
    let escaped = pattern
        .replace(/[.+^${}()|[\]\\]/g, '\\$&') // escape special regex chars
        .replace(/\*/g, '.*') // * becomes .*
        .replace(/\?/g, '.'); // ? becomes .

    // If pattern ends with " *" (space + wildcard), make the trailing part optional
    // This allows "ls *" to match both "ls" and "ls -la"
    if (escaped.endsWith(' .*')) {
        escaped = escaped.slice(0, -3) + '( .*)?';
    }

    return new RegExp(`^${escaped}$`, 's'); // 's' flag: . matches newlines
}

function minimatch(str: string, pattern: string): boolean {
    return globToRegex(pattern).test(str);
}

const DEFAULT_YOLO = true;
const DEFAULT_NON_INTERACTIVE_ASK: "allow" | "deny" = "deny";
const VALID_ACTIONS = ["ask", "allow", "deny"] as const;
type Action = (typeof VALID_ACTIONS)[number];

interface PermissionRule {
    pattern: string;
    action: Action;
    isWildcard: boolean;
}

interface PermissionConfig {
    permission?: {
        YOLO?: boolean;
        nonInteractiveAsk?: "allow" | "deny";
        read?: Record<string, string>;
        bash?: Record<string, string>;
        write?: Record<string, string>;
        edit?: Record<string, string> | string;
        grep?: Record<string, string> | string;
        find?: Record<string, string> | string;
        ls?: Record<string, string> | string;
        glob?: Record<string, string> | string;
        list?: Record<string, string> | string;
        [key: string]: Record<string, string> | string | boolean | undefined;
    };
}

// Tool config mapping (handles aliases)
const TOOL_CONFIGS: Array<{
    tools: string[];
    aliases: string[];
    getInput: (input: any) => string;
}> = [
    { tools: ["read"], aliases: [], getInput: (i) => i.path },
    { tools: ["bash"], aliases: [], getInput: (i) => i.command },
    { tools: ["write"], aliases: [], getInput: (i) => i.path },
    { tools: ["edit"], aliases: [], getInput: (i) => i.path },
    { tools: ["grep"], aliases: ["glob"], getInput: (i) => i.pattern },
    { tools: ["find"], aliases: [], getInput: (i) => i.pattern },
    { tools: ["ls"], aliases: ["list"], getInput: (i) => i.path || "." },
];

function loadSettings(cwd: string): PermissionConfig {
    const loadFile = (path: string) => {
        if (!existsSync(path)) return {};
        try {
            const content = readFileSync(path, "utf-8");
            const parsed = JSON.parse(content);
            return parsed.permission || {};
        } catch (err) {
            console.error(`Failed to load permissions from ${path}: ${err}`);
            return {};
        }
    };

    const globalPerm = loadFile(join(homedir(), ".pi", "agent", "settings.json"));
    const projectPerm = loadFile(join(cwd, ".pi", "settings.json"));

    return { permission: { ...globalPerm, ...projectPerm } };
}

function parseRules(config: Record<string, string> | string | undefined): PermissionRule[] {
    if (typeof config === "string") {
        return VALID_ACTIONS.includes(config as Action)
            ? [{ pattern: "*", action: config as Action, isWildcard: true }]
            : [];
    }

    if (!config || typeof config !== "object") return [];

    return Object.entries(config)
        .filter(([, action]) => VALID_ACTIONS.includes(action as Action))
        .map(([pattern, action]) => ({
            pattern,
            action: action as Action,
            isWildcard: pattern.includes("*") || pattern.includes("?"),
        }));
}

function matchInput(input: string, rules: PermissionRule[]): PermissionRule | null {
    const normalized = input.trim().replace(/\s+/g, " ");

    // Find the LAST matching rule (later rules override earlier ones, like opencode)
    const matchingRule = [...rules].reverse().find((r) => {
        if (!r.isWildcard) {
            return r.pattern === normalized;
        }
        return minimatch(normalized, r.pattern);
    });

    return matchingRule || null;
}

function splitCommands(command: string): string[] {
    const result: string[] = [];
    let current = "";
    let inSingle = false;
    let inDouble = false;
    let escape = false;

    for (let i = 0; i < command.length; i++) {
        const char = command[i];
        const next = command[i + 1];

        if (escape) {
            current += char;
            escape = false;
            continue;
        }

        if (char === "\\") {
            escape = true;
            current += char;
            continue;
        }

        if (char === "'" && !inDouble) { inSingle = !inSingle; current += char; continue; }
        if (char === '"' && !inSingle) { inDouble = !inDouble; current += char; continue; }

        if (!inSingle && !inDouble) {
            if (((char === "&" || char === "|") && next === char) || char === ";" || char === "|") {
                const trimmed = current.trim();
                if (trimmed) result.push(trimmed);
                current = "";
                if (next === char) i++;
                continue;
            }
        }

        current += char;
    }

    if (current.trim()) result.push(current.trim());
    return result;
}

export default function (pi: ExtensionAPI) {
    const rules = new Map<string, PermissionRule[]>();
    let yoloMode = DEFAULT_YOLO;
    let nonInteractiveAsk = DEFAULT_NON_INTERACTIVE_ASK;

    function loadPermissions(cwd: string) {
        const config = loadSettings(cwd).permission || {};
        yoloMode = config.YOLO ?? DEFAULT_YOLO;
        nonInteractiveAsk = config.nonInteractiveAsk ?? DEFAULT_NON_INTERACTIVE_ASK;

        TOOL_CONFIGS.forEach(({ tools, aliases }) => {
            const tool = tools[0];
            const toolConfig = tools.concat(aliases).reduce((acc, t) => ({
                ...acc,
                ...(config[t as keyof typeof config] || {}),
            }), {});

            rules.set(tool, parseRules(toolConfig as Record<string, string> | string));
        });
    }

    async function askPermission(toolName: string, input: string, ctx: any): Promise<boolean> {
        if (!ctx.hasUI) {
            return nonInteractiveAsk === "allow";
        }

        const choice = await ctx.ui.custom<boolean>((tui, theme, _kb, done) => {
            let selected = 0;
            const options = ["Yes", "No"] as const;

            const renderOption = (label: string, isSelected: boolean) => {
                if (isSelected) {
                    return theme.bg("selectedBg", theme.fg("text", ` ${label} `));
                }
                return theme.fg("dim", ` ${label} `);
            };

            const handleChoiceInput = (data: string) => {
                const isLeft =
                    matchesKey(data, Key.left) ||
                    matchesKey(data, Key.up) ||
                    matchesKey(data, Key.shift("tab"));
                const isRight =
                    matchesKey(data, Key.right) ||
                    matchesKey(data, Key.down) ||
                    matchesKey(data, Key.tab);

                if (isLeft) {
                    selected = (selected + options.length - 1) % options.length;
                    tui.requestRender();
                    return;
                }
                if (isRight) {
                    selected = (selected + 1) % options.length;
                    tui.requestRender();
                    return;
                }
                if (matchesKey(data, Key.enter) || data.toLowerCase() === "y") {
                    done(options[selected] === "Yes");
                    return;
                }
                if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || data.toLowerCase() === "n") {
                    done(false);
                }
            };

            return {
                render: (width: number) => {
                    const frameWidth = Math.min(Math.max(12, width), 48);
                    const innerWidth = Math.max(8, frameWidth - 4);
                    const prefix = `⚠️ ${toolName}: `;
                    const inputWidth = Math.max(0, innerWidth - visibleWidth(prefix));
                    const title = theme.bold(
                        theme.fg("warning", `${prefix}${truncateToWidth(input, inputWidth, "…")}`),
                    );
                    const content = [
                        title,
                        truncateToWidth(`${renderOption("Yes", selected === 0)}  ${renderOption("No", selected === 1)}`, innerWidth, ""),
                        truncateToWidth(theme.fg("dim", "←→ / tab navigate  enter select  esc cancel"), innerWidth, "…"),
                    ];
                    const top = theme.fg("dim", `┌${"─".repeat(innerWidth + 2)}┐`);
                    const bottom = theme.fg("dim", `└${"─".repeat(innerWidth + 2)}┘`);
                    const lines = content.map((line) => {
                        const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(line)));
                        return `${theme.fg("dim", "│")} ${line}${padding} ${theme.fg("dim", "│")}`;
                    });

                    return [top, ...lines, bottom].map((line) => truncateToWidth(line, frameWidth, ""));
                },
                invalidate: () => {},
                handleInput: (data: string) => {
                    handleChoiceInput(data);
                },
            };
        });

        return choice;
    }

    async function checkPermission(
        toolName: string,
        input: string,
        toolRules: PermissionRule[],
        ctx: any,
    ): Promise<{ block: true; reason: string } | undefined> {
        const rule = matchInput(input, toolRules);

        if (!rule) {
            if (yoloMode) return undefined;
            const allowed = await askPermission(toolName, input, ctx);
            return allowed ? undefined : { block: true, reason: "Blocked by user" };
        }

        if (rule.action === "allow") return undefined;
        if (rule.action === "deny") return { block: true, reason: `Blocked by permission rules: ${toolName}` };

        const allowed = await askPermission(toolName, input, ctx);
        return allowed ? undefined : { block: true, reason: "Blocked by user" };
    }

    pi.on("session_start", async (_event, ctx) => loadPermissions(ctx.cwd));

    pi.on("tool_call", async (event, ctx) => {
        const { toolName, input } = event;

        const toolConfig = TOOL_CONFIGS.find((t) => t.tools.includes(toolName));
        if (!toolConfig) return undefined;

        const toolRules = rules.get(toolName) || [];
        const inputStr = toolConfig.getInput(input);

        // Special handling for bash compound commands
        if (toolName === "bash" && toolRules.length > 0) {
            const subCommands = splitCommands(inputStr);

            for (const subCmd of subCommands) {
                const rule = matchInput(subCmd, toolRules);
                if (!rule || rule.action === "allow") continue;

                if (rule.action === "deny") {
                    return { block: true, reason: `Blocked by permission rules: ${subCmd}` };
                }

                const allowed = await askPermission("bash", inputStr, ctx);
                if (!allowed) return { block: true, reason: "Blocked by user" };
            }
            return undefined;
        }

        // No rules configured
        if (toolRules.length === 0) {
            if (yoloMode) return undefined;
            const allowed = await askPermission(toolName, inputStr, ctx);
            return allowed ? undefined : { block: true, reason: "Blocked by user" };
        }

        return checkPermission(toolName, inputStr, toolRules, ctx);
    });
}

Subagent

pi 的原始设计理念里,没有 subagent,所以也就没有 @agent 这种快速调用 subagent 在独立上下文里运行再返回结果给主 agent 的设计。

Nico’s subagent extension , interactive-shell 这两个 pi 的插件实现了类似的功能,但是对我来说有些复杂。

现阶段我的方式就是新开一个 tmux session,在完成之后解析 对应 pi session 的 jsonl 文件里最后一个 assistant 的内容,并作为一个 follow-up message 返回给主 session 的 agent。

在 pi 的 TUI 里,通过 @agent 来调用这些 subagent,并实现了两个模式:当前会话和 tmux 后台运行。tmux 后台运行通过 @@agent 调用。支持自动补全 agent 的名称。

tmux 的运行状态会在 TUI 的 footer 部分持续显示。直接用 tmux 指令就可以直接打开这个 subagent pi session 。

agent-buff.ts:

/**
 * Agents Buff Extension
 *
 * Provides direct @agent-name invocation with task input and
 * @@agent-name invocation for background subagent execution.
 */
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { randomUUID } from "node:crypto";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { parseFrontmatter } from "@mariozechner/pi-coding-agent";
import { Box, Editor, CombinedAutocompleteProvider, Text, type AutocompleteItem, type AutocompleteSuggestions } from "@mariozechner/pi-tui";

interface AgentConfig {
  name: string;
  description: string;
  source: "user" | "project";
  filePath: string;
  systemPrompt: string;
}

const USER_AGENTS_DIR = path.join(os.homedir(), ".pi", "agent", "agents");
const PROJECT_AGENTS_DIR_NAME = path.join(".pi", "agents");
const AGENT_FILE_EXTENSION = ".md";
const AGENT_NAME_PATTERN = /^@([a-zA-Z0-9_-]+)\s+(.+)$/s;
const BACKGROUND_AGENT_NAME_PATTERN = /^@@([a-zA-Z0-9_-]+)\s+(.+)$/s;
const STOPWORDS = new Set(["the", "for", "with", "from", "into"]);
const MAX_KEYWORDS = 3;
const MIN_KEYWORD_LENGTH = 3;
const STATUS_CHECK_INTERVAL_MS = 4000;
const BACKGROUND_STATUS_WIDGET_ID = "subagent-status";
const MAX_COMPLETION_CHARS = 14000;
const SESSION_FILE_TAIL_READ_BYTES = 256 * 1024;
const TMUX_STATUS_KEY_PREFIX = "PI_BG_STATUS_";
const TMUX_EXIT_CODE_KEY_PREFIX = "PI_BG_EXIT_";
const BACKGROUND_SESSION_META_ENV_KEY = "PI_BG_SESSION_META_PATH";
const BACKGROUND_SESSION_META_WAIT_MS = 5000;
const BACKGROUND_SESSION_META_POLL_INTERVAL_MS = 120;
const BACKGROUND_SESSION_START_WAIT_MS = 5000;
const BACKGROUND_SESSION_START_POLL_INTERVAL_MS = 120;
const PENDING_FOREGROUND_AGENT_TTL_MS = 5 * 60 * 1000;
const MAX_PENDING_FOREGROUND_AGENTS = 20;
const AGENT_AUTOCOMPLETE_STATE_KEY = "__piAgentsBuffAutocompleteState";

interface AgentAutocompleteState {
  getAgents: () => AgentConfig[];
}

function getAgentAutocompleteState(): AgentAutocompleteState {
  const globalState = globalThis as Record<string, unknown>;
  const existing = globalState[AGENT_AUTOCOMPLETE_STATE_KEY] as AgentAutocompleteState | undefined;
  if (existing) {
    return existing;
  }

  const state: AgentAutocompleteState = { getAgents: () => [] };
  globalState[AGENT_AUTOCOMPLETE_STATE_KEY] = state;
  return state;
}

function normalizeAgentName(name: string): string {
  return name.trim().replace(/^@/, "");
}

function extractAgentAutocompletePrefix(textBeforeCursor: string): string | null {
  if (/(?:^|\s)@@?"[^"]*$/.test(textBeforeCursor)) {
    return null;
  }

  const match = textBeforeCursor.match(/(?:^|\s)(@@?[^\s"']*)$/);
  if (!match || !match[1]) {
    return null;
  }

  const prefix = match[1];
  const candidate = prefix.startsWith("@@") ? prefix.slice(2) : prefix.slice(1);
  if (candidate.includes("/") || candidate.includes("\\") || candidate.startsWith(".") || candidate.startsWith("~")) {
    return null;
  }

  return prefix;
}

function buildAgentAutocompleteItems(prefix: string, agents: AgentConfig[]): AutocompleteItem[] {
  const isBackgroundPrefix = prefix.startsWith("@@");
  const query = prefix.replace(/^@@?/, "").toLowerCase();

  return agents
    .map((agent) => ({ ...agent, normalizedName: normalizeAgentName(agent.name) }))
    .filter((agent) => {
    if (!agent.normalizedName) {
      return false;
    }
    if (!query) {
      return true;
    }
    const lowerName = agent.normalizedName.toLowerCase();
    return lowerName.startsWith(query) || lowerName.includes(query);
  })
    .sort((a, b) => {
    const aLower = a.normalizedName.toLowerCase();
    const bLower = b.normalizedName.toLowerCase();
    if (query) {
      const aStarts = aLower.startsWith(query);
      const bStarts = bLower.startsWith(query);
      if (aStarts !== bStarts) {
        return aStarts ? -1 : 1;
      }
    }
    return aLower.localeCompare(bLower);
  })
    .map((agent) => {
    const invocationPrefix = isBackgroundPrefix ? "@@" : "@";
    return {
      value: `${invocationPrefix}${agent.normalizedName}`,
      label: `${invocationPrefix}${agent.normalizedName}`,
      description: `[agent] ${agent.description}`,
    };
  });
}

function installDoubleAtAutocompleteTriggerPatch(): void {
  const editorPrototype = Editor.prototype as Editor & { __doubleAtAutocompletePatched?: boolean };
  if (editorPrototype.__doubleAtAutocompletePatched) {
    return;
  }

  const originalHandleInput = editorPrototype.handleInput;
  if (typeof originalHandleInput !== "function") {
    return;
  }

  editorPrototype.handleInput = function (data: string): void {
    let shouldForceAutocomplete = false;

    if (data === "@") {
      const cursor = this.getCursor();
      const line = this.getLines()[cursor.line] ?? "";
      const previousChar = cursor.col > 0 ? line[cursor.col - 1] : "";
      const charBeforePrevious = cursor.col > 1 ? line[cursor.col - 2] : "";
      const isSecondAt = previousChar === "@";
      const firstAtHasValidBoundary = cursor.col <= 1 || charBeforePrevious === " " || charBeforePrevious === "\t";
      shouldForceAutocomplete = isSecondAt && firstAtHasValidBoundary;
    }

    originalHandleInput.call(this, data);

    if (!shouldForceAutocomplete) {
      return;
    }

    const triggerAutocomplete = (this as unknown as { tryTriggerAutocomplete?: () => void }).tryTriggerAutocomplete;
    if (typeof triggerAutocomplete === "function") {
      triggerAutocomplete.call(this);
    }
  };

  editorPrototype.__doubleAtAutocompletePatched = true;
}

function installAgentAutocompletePatch(getAgents: () => AgentConfig[]): void {
  const state = getAgentAutocompleteState();
  state.getAgents = getAgents;

  const prototype = CombinedAutocompleteProvider.prototype as CombinedAutocompleteProvider & {
    __agentAutocompletePatched?: boolean;
  };

  if (prototype.__agentAutocompletePatched) {
    return;
  }

  const originalGetSuggestions = prototype.getSuggestions;
  if (typeof originalGetSuggestions !== "function") {
    return;
  }

  prototype.getSuggestions = async function (
    lines: string[],
    cursorLine: number,
    cursorCol: number,
    options: { signal: AbortSignal; force?: boolean }
  ): Promise<AutocompleteSuggestions | null> {
    const baseSuggestions = await originalGetSuggestions.call(this, lines, cursorLine, cursorCol, options);

    const currentLine = lines[cursorLine] ?? "";
    const textBeforeCursor = currentLine.slice(0, cursorCol);
    const agentPrefix = extractAgentAutocompletePrefix(textBeforeCursor);
    if (!agentPrefix) {
      return baseSuggestions;
    }

    const agentItems = buildAgentAutocompleteItems(agentPrefix, state.getAgents());
    if (agentItems.length === 0) {
      return baseSuggestions;
    }

    if (!baseSuggestions) {
      return { items: agentItems, prefix: agentPrefix };
    }

    if (baseSuggestions.prefix !== agentPrefix) {
      return baseSuggestions;
    }

    const existingValues = new Set(baseSuggestions.items.map((item) => item.value));
    const mergedItems = [...agentItems.filter((item) => !existingValues.has(item.value)), ...baseSuggestions.items];
    return { items: mergedItems, prefix: baseSuggestions.prefix };
  };

  prototype.__agentAutocompletePatched = true;
}

function generateSessionName(agentName: string, task: string, sessionId: string): string {
  const cleanAgentName = agentName.replace(/^@/, "").replace(/[^a-zA-Z0-9_-]/g, "");
  const words = task
    .toLowerCase()
    .replace(/[^\w\s]/g, " ")
    .split(/\s+/)
    .filter((w) => w.length > MIN_KEYWORD_LENGTH && !STOPWORDS.has(w));
  const keywordStr = words.slice(0, MAX_KEYWORDS).join("-") || "task";
  const base = `pi-${cleanAgentName}-${keywordStr}-${sessionId}`;
  return base.slice(0, 60);
}

type BackgroundSessionStatus = "starting" | "running" | "completed" | "failed";

interface BackgroundAgentSession {
  id: string;
  agentName: string;
  task: string;
  tmuxSessionName: string;
  originSessionId: string;
  sessionId?: string;
  sessionFile?: string;
  status: BackgroundSessionStatus;
  startedAt: number;
  statusKey: string;
  exitCodeKey: string;
  error?: string;
  notified?: boolean;
  cleanedUp?: boolean;
}

interface BackgroundSessionMetadata {
  sessionId: string;
  sessionFile: string;
}

interface AssistantTextSnapshot {
  text: string;
  entryId?: string;
  timestamp?: string;
}

interface PendingForegroundAgentRequest {
  agent: AgentConfig;
  prompt: string;
  createdAt: number;
}

function createTaskId(): string {
  return randomUUID().slice(0, 8);
}

function shellQuote(value: string): string {
  return `'${value.replace(/'/g, `'"'"'`)}'`;
}

function sleep(ms: number): Promise<void> {
  return new Promise(function (resolve) {
    setTimeout(resolve, ms);
  });
}

function buildBackgroundPiCommand(
  agentName: string,
  task: string,
  statusKey: string,
  exitCodeKey: string,
  sessionMetaPath: string
): string {
  const delegatedPrompt = ` @${agentName} ${task}`;
  const script = [
    "finalized=0",
    "finalize() {",
    "  if [ \"$finalized\" -eq 1 ]; then return; fi",
    "  finalized=1",
    "  exit_code=${1:-$?}",
    `  tmux set-environment -g ${shellQuote(exitCodeKey)} \"$exit_code\"`,
    `  if [ \"$exit_code\" -eq 0 ]; then tmux set-environment -g ${shellQuote(statusKey)} completed; else tmux set-environment -g ${shellQuote(statusKey)} failed; fi`,
    "}",
    "trap 'finalize 130; exit 130' INT",
    "trap 'finalize 143; exit 143' TERM HUP",
    `tmux set-environment -g ${shellQuote(statusKey)} running`,
    `export ${BACKGROUND_SESSION_META_ENV_KEY}=${shellQuote(sessionMetaPath)}`,
    `pi ${shellQuote(delegatedPrompt)}`,
    "exit_code=$?",
    "finalize \"$exit_code\"",
    "exec bash",
  ].join("\n");

  return `bash -lc ${shellQuote(script)}`;
}

function trimToMaxChars(text: string, maxChars: number): string {
  if (text.length <= maxChars) {
    return text;
  }
  return text.slice(text.length - maxChars);
}

function extractCustomMessageText(content: string | Array<{ type: string; text?: string }>): string {
  if (typeof content === "string") {
    return content;
  }
  return content
    .filter((block): block is { type: "text"; text: string } => block.type === "text" && typeof block.text === "string")
    .map((block) => block.text)
    .join("\n")
    .trim();
}

function parseBackgroundSessionMetadata(raw: string): BackgroundSessionMetadata | null {
  try {
    const parsed = JSON.parse(raw) as Record<string, unknown>;
    const sessionId = typeof parsed.sessionId === "string" ? parsed.sessionId.trim() : "";
    const sessionFile = typeof parsed.sessionFile === "string" ? parsed.sessionFile.trim() : "";
    if (!sessionId || !sessionFile) {
      return null;
    }
    return { sessionId, sessionFile };
  } catch {
    return null;
  }
}

function readBackgroundSessionMetadata(metaPath: string): BackgroundSessionMetadata | null {
  if (!fs.existsSync(metaPath)) {
    return null;
  }
  try {
    const raw = fs.readFileSync(metaPath, "utf-8");
    return parseBackgroundSessionMetadata(raw);
  } catch {
    return null;
  }
}

async function waitForBackgroundSessionMetadata(metaPath: string, timeoutMs: number): Promise<BackgroundSessionMetadata | null> {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() <= deadline) {
    const metadata = readBackgroundSessionMetadata(metaPath);
    if (metadata) {
      return metadata;
    }
    await sleep(BACKGROUND_SESSION_META_POLL_INTERVAL_MS);
  }
  return readBackgroundSessionMetadata(metaPath);
}

function findLatestAssistantTextInLines(lines: string[]): AssistantTextSnapshot | null {
  for (let i = lines.length - 1; i >= 0; i -= 1) {
    const line = lines[i].trim();
    if (!line) {
      continue;
    }

    let entry: Record<string, unknown>;
    try {
      entry = JSON.parse(line) as Record<string, unknown>;
    } catch {
      continue;
    }

    if (entry.type !== "message") {
      continue;
    }

    const message = entry.message as Record<string, unknown> | undefined;
    if (!message || message.role !== "assistant") {
      continue;
    }

    const content = Array.isArray(message.content) ? message.content : [];
    const textBlocks = content
      .filter((block): block is Record<string, unknown> => typeof block === "object" && block !== null)
      .filter((block) => block.type === "text" && typeof block.text === "string")
      .map((block) => String(block.text).trim())
      .filter((text) => text.length > 0);

    if (textBlocks.length === 0) {
      continue;
    }

    return {
      text: textBlocks.join("\n\n"),
      entryId: typeof entry.id === "string" ? entry.id : undefined,
      timestamp: typeof entry.timestamp === "string" ? entry.timestamp : undefined,
    };
  }

  return null;
}

function readSessionFileTail(sessionFile: string, maxBytes: number): { raw: string; truncated: boolean } | null {
  try {
    const stat = fs.statSync(sessionFile);
    const size = stat.size;
    const start = Math.max(0, size - maxBytes);
    const bytesToRead = size - start;
    const fd = fs.openSync(sessionFile, "r");
    const buffer = Buffer.alloc(bytesToRead);
    try {
      fs.readSync(fd, buffer, 0, bytesToRead, start);
    } finally {
      fs.closeSync(fd);
    }

    let raw = buffer.toString("utf-8");
    if (start > 0) {
      const firstNewline = raw.indexOf("\n");
      raw = firstNewline === -1 ? "" : raw.slice(firstNewline + 1);
    }
    return { raw, truncated: start > 0 };
  } catch {
    return null;
  }
}

function findLatestAssistantText(sessionFile: string): AssistantTextSnapshot | null {
  if (!fs.existsSync(sessionFile)) {
    return null;
  }

  const tail = readSessionFileTail(sessionFile, SESSION_FILE_TAIL_READ_BYTES);
  if (!tail) {
    return null;
  }

  const tailSnapshot = findLatestAssistantTextInLines(tail.raw.split("\n"));
  if (tailSnapshot || !tail.truncated) {
    return tailSnapshot;
  }

  try {
    const raw = fs.readFileSync(sessionFile, "utf-8");
    return findLatestAssistantTextInLines(raw.split("\n"));
  } catch {
    return null;
  }
}

function buildAssistantHistoryCommands(sessionFile: string): { rg: string; grep: string } {
  const quoted = shellQuote(sessionFile);
  return {
    rg: `rg '\"type\":\"message\".*\"role\":\"assistant\"' ${quoted} | rg '\"type\":\"text\"' | rg -v '\"stopReason\":\"aborted\"'`,
    grep: `grep -E '\"type\":\"message\".*\"role\":\"assistant\"' ${quoted} | grep '\"type\":\"text\"' | grep -v '\"stopReason\":\"aborted\"'`,
  };
}

function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] {
  if (!fs.existsSync(dir)) {
    return [];
  }

  return fs.readdirSync(dir, { withFileTypes: true })
    .filter(function (entry) {
    return entry.name.endsWith(AGENT_FILE_EXTENSION) && (entry.isFile() || entry.isSymbolicLink());
  })
    .map(function (entry) {
    const filePath = path.join(dir, entry.name);

    try {
      const { frontmatter, body } = parseFrontmatter<Record<string, unknown>>(fs.readFileSync(filePath, "utf-8"));
      if (!frontmatter.name || !frontmatter.description) {
        return null;
      }

      const name = normalizeAgentName(String(frontmatter.name));
      if (!name) {
        return null;
      }

      return {
        name,
        description: String(frontmatter.description),
        source,
        filePath,
        systemPrompt: body.trim(),
      };
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      console.warn(`[agents-buff] Failed to load agent file ${filePath}: ${message}`);
      return null;
    }
  })
    .filter((config): config is AgentConfig => config !== null);
}

function findNearestProjectAgentsDir(cwd: string): string | null {
  let currentDir = cwd;

  while (currentDir !== path.dirname(currentDir)) {
    const candidate = path.join(currentDir, PROJECT_AGENTS_DIR_NAME);
    if (fs.existsSync(candidate)) {
      try {
        if (fs.statSync(candidate).isDirectory()) {
          return candidate;
        }
      } catch {
        // Ignore invalid paths and continue walking up.
      }
    }
    currentDir = path.dirname(currentDir);
  }

  return null;
}

function discoverAgents(cwd: string): AgentConfig[] {
  const projectAgentsDir = findNearestProjectAgentsDir(cwd);
  const userAgents = loadAgentsFromDir(USER_AGENTS_DIR, "user");
  const projectAgents = projectAgentsDir ? loadAgentsFromDir(projectAgentsDir, "project") : [];

  const agentMap = new Map(userAgents.map((a) => [a.name, a]));
  projectAgents.forEach((a) => agentMap.set(a.name, a));

  return Array.from(agentMap.values());
}

export default function agentsBuffExtension(pi: ExtensionAPI): void {
  const pendingForegroundAgents: PendingForegroundAgentRequest[] = [];
  let cachedAgents: AgentConfig[] | null = null;
  let sessionCwd = process.cwd();
  let runtimeCtx: any = null;
  const runtimeCtxBySessionId = new Map<string, any>();
  const backgroundSessions = new Map<string, BackgroundAgentSession>();
  let lastBackgroundSessionId: string | null = null;
  let statusTimer: ReturnType<typeof setTimeout> | null = null;

  installDoubleAtAutocompleteTriggerPatch();
  installAgentAutocompletePatch(() => cachedAgents ?? discoverAgents(sessionCwd));

  pi.registerMessageRenderer("agents-buff-background-result", (message, { expanded }, theme) => {
    const text = extractCustomMessageText(message.content);
    const box = new Box(1, 1, (value) => theme.bg("customMessageBg", value));
    const label = theme.fg("customMessageLabel", `\x1b[1m[${message.customType}]\x1b[22m`);

    if (!expanded) {
      const lines = text.split("\n");
      const firstLine = lines.find((line) => line.trim().length > 0) ?? "background result";
      const remainingLines = Math.max(lines.length - 1, 0);
      const summary = remainingLines > 0 ? `... (${remainingLines} more lines, ctrl+o to expand)` : "";
      box.addChild(new Text(`${label} ${theme.fg("customMessageText", firstLine)}`, 0, 0));
      if (summary) {
        box.addChild(new Text(theme.fg("dim", summary), 0, 0));
      }
      return box;
    }

    box.addChild(new Text(label, 0, 0));
    box.addChild(new Text(theme.fg("customMessageText", text || "(no content)"), 0, 0));
    return box;
  });

  function rememberRuntimeCtx(ctx: any): void {
    runtimeCtx = ctx;
    const sessionId = ctx?.sessionManager?.getSessionId?.();
    if (typeof sessionId === "string" && sessionId.length > 0) {
      runtimeCtxBySessionId.set(sessionId, ctx);
    }
  }

  function getRuntimeCtxForSession(sessionId: string): any | null {
    return runtimeCtxBySessionId.get(sessionId) ?? runtimeCtx;
  }

  function prunePendingForegroundAgents(): void {
    const threshold = Date.now() - PENDING_FOREGROUND_AGENT_TTL_MS;
    for (let i = pendingForegroundAgents.length - 1; i >= 0; i -= 1) {
      if (pendingForegroundAgents[i].createdAt < threshold) {
        pendingForegroundAgents.splice(i, 1);
      }
    }
  }

  function enqueuePendingForegroundAgent(agent: AgentConfig, prompt: string): void {
    prunePendingForegroundAgents();
    pendingForegroundAgents.push({ agent, prompt, createdAt: Date.now() });
    if (pendingForegroundAgents.length > MAX_PENDING_FOREGROUND_AGENTS) {
      pendingForegroundAgents.splice(0, pendingForegroundAgents.length - MAX_PENDING_FOREGROUND_AGENTS);
    }
  }

  function dequeuePendingForegroundAgent(prompt: string): AgentConfig | null {
    prunePendingForegroundAgents();
    const index = pendingForegroundAgents.findIndex((entry) => entry.prompt === prompt);
    if (index === -1) {
      return null;
    }
    const [entry] = pendingForegroundAgents.splice(index, 1);
    return entry?.agent ?? null;
  }

  function getLatestActiveBackgroundSession(): BackgroundAgentSession | null {
    let latest: BackgroundAgentSession | null = null;
    for (const session of backgroundSessions.values()) {
      if (!isBackgroundSessionActive(session)) {
        continue;
      }
      if (!latest || session.startedAt > latest.startedAt) {
        latest = session;
      }
    }
    return latest;
  }

  function getMostRecentActiveSessionId(): string | null {
    const latest = getLatestActiveBackgroundSession();
    return latest?.id ?? null;
  }

  function formatElapsed(seconds: number): string {
    if (seconds < 60) {
      return `${seconds}s`;
    }
    const minutes = Math.floor(seconds / 60);
    const remainingSeconds = seconds % 60;
    return `${minutes}m${remainingSeconds}s`;
  }

  function getLastBackgroundSession(): BackgroundAgentSession | null {
    if (lastBackgroundSessionId) {
      const session = backgroundSessions.get(lastBackgroundSessionId);
      if (session) {
        return session;
      }
    }

    lastBackgroundSessionId = getMostRecentActiveSessionId();
    if (!lastBackgroundSessionId) {
      return null;
    }
    return backgroundSessions.get(lastBackgroundSessionId) ?? null;
  }

  async function execTmux(args: string[], cwd?: string): Promise<{ code: number; stdout: string; stderr: string }> {
    const result = await pi.exec("tmux", args, cwd ? { cwd } : undefined);
    return {
      code: result.code,
      stdout: result.stdout ?? "",
      stderr: result.stderr ?? "",
    };
  }

  async function clearTmuxEnvironmentKeys(keys: string[]): Promise<void> {
    for (const key of keys) {
      await execTmux(["set-environment", "-gu", key]);
    }
  }

  async function tmuxSessionExists(sessionName: string): Promise<boolean> {
    const result = await execTmux(["has-session", "-t", sessionName]);
    return result.code === 0;
  }

  async function getTmuxEnvironmentValue(key: string): Promise<string | null> {
    const result = await execTmux(["show-environment", "-g", key]);
    if (result.code !== 0) {
      return null;
    }

    const line = result.stdout.trim();
    const prefix = `${key}=`;
    if (!line.startsWith(prefix)) {
      return null;
    }
    return line.slice(prefix.length);
  }

  function isBackgroundSessionActive(session: BackgroundAgentSession): boolean {
    return session.status === "starting" || session.status === "running";
  }

  function updateBackgroundWidget(): void {
    if (!runtimeCtx) {
      return;
    }

    const latest = getLatestActiveBackgroundSession();
    if (!latest) {
      runtimeCtx.ui.setWidget(BACKGROUND_STATUS_WIDGET_ID, undefined);
      return;
    }

    const activeCount = Array.from(backgroundSessions.values()).filter(isBackgroundSessionActive).length;
    const elapsed = Math.floor((Date.now() - latest.startedAt) / 1000);
    const theme = runtimeCtx.ui.theme;
    const text = `@@${latest.agentName} | id: ${latest.id} | tmux: ${latest.tmuxSessionName} | active: ${activeCount} | ⏱️ ${formatElapsed(elapsed)}`;
    runtimeCtx.ui.setWidget(BACKGROUND_STATUS_WIDGET_ID, [theme.fg("dim", text)], { placement: "belowEditor" });
  }

  async function cleanupFinishedBackgroundSession(session: BackgroundAgentSession): Promise<void> {
    if (session.cleanedUp) {
      return;
    }
    session.cleanedUp = true;

    await execTmux(["kill-session", "-t", session.tmuxSessionName]);
    await clearTmuxEnvironmentKeys([session.statusKey, session.exitCodeKey]);
  }

  function setSessionCompleted(session: BackgroundAgentSession): void {
    session.status = "completed";
    session.error = undefined;
  }

  function setSessionFailed(session: BackgroundAgentSession, error: string): void {
    session.status = "failed";
    session.error = error;
  }

  function resolveSessionFromExitCode(session: BackgroundAgentSession, exitCode: number | null, fallbackError: string): void {
    if (exitCode === 0) {
      setSessionCompleted(session);
      return;
    }
    if (exitCode !== null) {
      setSessionFailed(session, `background pi command returned exit code ${exitCode}`);
      return;
    }
    setSessionFailed(session, fallbackError);
  }

  function parseEnvironmentExitCode(rawExitCode: string | null): number | null {
    if (rawExitCode === null) {
      return null;
    }
    const parsed = Number.parseInt(rawExitCode, 10);
    return Number.isNaN(parsed) ? null : parsed;
  }

  function sendBackgroundCompletionToMainSession(session: BackgroundAgentSession): boolean {
    if (!session.sessionFile) {
      return false;
    }

    const assistantSnapshot = findLatestAssistantText(session.sessionFile);
    const statusText = session.status === "completed" ? "completed" : "failed";
    const commands = buildAssistantHistoryCommands(session.sessionFile);
    const fallbackText =
      session.status === "completed"
        ? "Background session completed, but no assistant text block was found in session JSONL."
        : "Background session failed before assistant text content was available in session JSONL.";
    const outputText = assistantSnapshot?.text ?? fallbackText;
    const trimmedOutput = trimToMaxChars(outputText, MAX_COMPLETION_CHARS);
    const truncated = trimmedOutput.length < outputText.length;
    const truncatedNote = truncated ? " (truncated)" : "";
    const message = [
      `Background @@${session.agentName} ${statusText}.`,
      `sessionId: ${session.sessionId ?? "unknown"}`,
      `sessionFile: ${session.sessionFile}`,
      `task: ${session.task}`,
      session.error ? `error: ${session.error}` : undefined,
      "",
      `Last assistant structured text result${truncatedNote}:`,
      "```text",
      trimmedOutput,
      "```",
      "",
      `For more conversation details, read ${session.sessionFile}.`,
      "Filter other assistant text messages in this session:",
      `rg: ${commands.rg}`,
      `grep: ${commands.grep}`,
    ]
      .filter((line): line is string => typeof line === "string" && line.length > 0)
      .join("\n");

    pi.sendMessage(
      {
        customType: "agents-buff-background-result",
        content: message,
        display: true,
      },
      { triggerTurn: true, deliverAs: "followUp" }
    );
    return true;
  }

  function notifyIfFinished(session: BackgroundAgentSession): void {
    if (session.notified) {
      return;
    }

    session.notified = true;
    void cleanupFinishedBackgroundSession(session);
    const sentToMainSession = sendBackgroundCompletionToMainSession(session);
    backgroundSessions.delete(session.id);
    if (lastBackgroundSessionId === session.id) {
      lastBackgroundSessionId = getMostRecentActiveSessionId();
    }

    const targetCtx = getRuntimeCtxForSession(session.originSessionId);
    if (!targetCtx) {
      return;
    }

    const sentSuffix = sentToMainSession ? " and synced to main session" : "";
    if (session.status === "completed") {
      targetCtx.ui.notify(`✓ Background agent finished: ${session.tmuxSessionName}${sentSuffix}`, "success");
      return;
    }

    const reason = session.error ? ` (${session.error})` : "";
    targetCtx.ui.notify(`✗ Background agent failed: ${session.tmuxSessionName}${reason}${sentSuffix}`, "error");
  }

  async function pollBackgroundSession(session: BackgroundAgentSession): Promise<void> {
    const envStatus = await getTmuxEnvironmentValue(session.statusKey);
    const envExitCode = parseEnvironmentExitCode(await getTmuxEnvironmentValue(session.exitCodeKey));
    const exists = await tmuxSessionExists(session.tmuxSessionName);

    if (envStatus === "completed") {
      setSessionCompleted(session);
      notifyIfFinished(session);
      return;
    }

    if (envStatus === "failed") {
      resolveSessionFromExitCode(session, envExitCode, "background pi command returned non-zero exit code");
      notifyIfFinished(session);
      return;
    }

    if (!exists) {
      const fallbackError =
        envStatus === "running" ? "tmux session ended before completion status update" : "tmux session not found";
      resolveSessionFromExitCode(session, envExitCode, fallbackError);
      notifyIfFinished(session);
      return;
    }

    session.status = envStatus === "running" ? "running" : "starting";
  }

  async function pollBackgroundSessions(): Promise<void> {
    const activeSessions = Array.from(backgroundSessions.values()).filter(isBackgroundSessionActive);
    await Promise.all(activeSessions.map((session) => pollBackgroundSession(session)));

    updateBackgroundWidget();
    if (Array.from(backgroundSessions.values()).some(isBackgroundSessionActive)) {
      scheduleStatusPolling();
      return;
    }
    clearStatusPolling();
  }

  function scheduleStatusPolling(): void {
    if (statusTimer !== null) {
      return;
    }

    statusTimer = setTimeout(() => {
      statusTimer = null;
      void pollBackgroundSessions();
    }, STATUS_CHECK_INTERVAL_MS);
  }

  function clearStatusPolling(): void {
    if (statusTimer === null) {
      return;
    }
    clearTimeout(statusTimer);
    statusTimer = null;
  }

  async function waitForTmuxSessionStartup(sessionName: string, timeoutMs: number): Promise<boolean> {
    const deadline = Date.now() + timeoutMs;
    while (Date.now() <= deadline) {
      if (await tmuxSessionExists(sessionName)) {
        return true;
      }
      await sleep(BACKGROUND_SESSION_START_POLL_INTERVAL_MS);
    }
    return tmuxSessionExists(sessionName);
  }

  async function startBackgroundAgentSession(
    agent: AgentConfig,
    task: string,
    cwd: string,
    originSessionId: string
  ): Promise<BackgroundAgentSession> {
    const id = createTaskId();
    const tmuxSessionName = generateSessionName(agent.name, task, id);
    const statusKey = `${TMUX_STATUS_KEY_PREFIX}${id}`;
    const exitCodeKey = `${TMUX_EXIT_CODE_KEY_PREFIX}${id}`;
    const sessionMetaPath = path.join(os.tmpdir(), `pi-bg-session-meta-${id}.json`);
    const command = buildBackgroundPiCommand(agent.name, task, statusKey, exitCodeKey, sessionMetaPath);

    try {
      fs.unlinkSync(sessionMetaPath);
    } catch {
      // Ignore stale metadata file cleanup errors.
    }

    await clearTmuxEnvironmentKeys([statusKey, exitCodeKey]);

    const result = await execTmux(["new-session", "-d", "-s", tmuxSessionName, "-c", cwd, command], cwd);
    if (result.code !== 0) {
      throw new Error(result.stderr || `failed to start tmux session ${tmuxSessionName}`);
    }

    const exists = await waitForTmuxSessionStartup(tmuxSessionName, BACKGROUND_SESSION_START_WAIT_MS);
    if (!exists) {
      throw new Error(`tmux session did not start: ${tmuxSessionName}`);
    }

    const metadata = await waitForBackgroundSessionMetadata(sessionMetaPath, BACKGROUND_SESSION_META_WAIT_MS);
    try {
      fs.unlinkSync(sessionMetaPath);
    } catch {
      // Ignore metadata cleanup errors.
    }

    return {
      id,
      agentName: agent.name,
      task,
      tmuxSessionName,
      originSessionId,
      sessionId: metadata?.sessionId,
      sessionFile: metadata?.sessionFile,
      status: "starting",
      startedAt: Date.now(),
      statusKey,
      exitCodeKey,
    };
  }

  async function startBackgroundAgentTask(agent: AgentConfig, task: string, ctx: any): Promise<void> {
    try {
      rememberRuntimeCtx(ctx);
      const originSessionId = ctx.sessionManager.getSessionId();
      const session = await startBackgroundAgentSession(agent, task, ctx.cwd, originSessionId);
      backgroundSessions.set(session.id, session);
      lastBackgroundSessionId = session.id;
      updateBackgroundWidget();
      scheduleStatusPolling();
      ctx.ui.notify(`🤖 Started @@${agent.name} in tmux session: ${session.tmuxSessionName}`, "success");
      ctx.ui.notify(`Attach with: tmux attach -t ${session.tmuxSessionName}`, "info");
      if (!session.sessionFile) {
        ctx.ui.notify("Background session metadata not available yet; result sync may be skipped.", "warning");
      }
    } catch (err: unknown) {
      const message = err instanceof Error ? err.message : "unknown error";
      ctx.ui.notify(`✗ Failed to start background agent: ${message}`, "error");
    }
  }

  function findAgentByName(agents: AgentConfig[], agentName: string, ctx: any): AgentConfig | null {
    const agent = agents.find((candidate) => candidate.name === agentName);
    if (agent) {
      return agent;
    }
    ctx.ui.notify(`Unknown agent "@${agentName}". Type @ to autocomplete available agents.`, "error");
    return null;
  }

  pi.on("session_start", async function (_event, ctx) {
    rememberRuntimeCtx(ctx);
    sessionCwd = ctx.cwd;
    cachedAgents = discoverAgents(ctx.cwd);

    const sessionMetaPath = process.env[BACKGROUND_SESSION_META_ENV_KEY];
    if (sessionMetaPath) {
      try {
        const sessionInfo = {
          sessionId: ctx.sessionManager.getSessionId(),
          sessionFile: ctx.sessionManager.getSessionFile(),
        };
        fs.writeFileSync(sessionMetaPath, JSON.stringify(sessionInfo), "utf-8");
      } catch {
        // Ignore metadata write failures in child sessions.
      }
    }

    if (ctx.hasUI) {
      ctx.ui.notify(`Loaded ${cachedAgents.length} agents. Use @agent-name or @@agent-name`, "info");
    }
  });

  pi.on("session_shutdown", async function (_event, ctx) {
    const sessionId = ctx.sessionManager.getSessionId();
    runtimeCtxBySessionId.delete(sessionId);
    if (runtimeCtxBySessionId.size === 0) {
      runtimeCtx = null;
      clearStatusPolling();
    }
  });

  pi.on("before_agent_start", async function (event) {
    const queuedAgent = dequeuePendingForegroundAgent(event.prompt);
    if (!queuedAgent) {
      return {};
    }
    const fullPrompt = event.systemPrompt + "\n\n" + queuedAgent.systemPrompt;
    return { systemPrompt: fullPrompt };
  });

  pi.on("input", async function (event, ctx) {
    rememberRuntimeCtx(ctx);
    if (event.source === "extension") {
      return { action: "continue" };
    }

    const text = event.text.trim();
    const agents = cachedAgents ?? discoverAgents(ctx.cwd);

    const backgroundMatch = text.match(BACKGROUND_AGENT_NAME_PATTERN);
    if (backgroundMatch) {
      const [, agentName, task] = backgroundMatch;
      const agent = findAgentByName(agents, agentName, ctx);
      if (!agent) {
        return { action: "handled" };
      }

      await startBackgroundAgentTask(agent, task, ctx);
      return { action: "handled" };
    }

    const match = text.match(AGENT_NAME_PATTERN);
    if (!match) {
      return { action: "continue" };
    }

    const [, agentName, task] = match;
    const agent = findAgentByName(agents, agentName, ctx);
    if (!agent) {
      return { action: "handled" };
    }

    enqueuePendingForegroundAgent(agent, text);
    ctx.ui.notify(`🤖 Start agent @${agentName}`, "info");

    return { action: "transform", text };
  });
}

Rtk Command Proxy

GitHub - rtk-ai/rtk 这个项目可以简化常用 bash 指令的输出结果,长期使用可以节省许多 token。

原项目可以一键和 Claude Code 的 hook 配合,因为 Claude Code 是直接调用的 bash 指令,rtk 在 shell 层面直接用 bash hook 拦截原指令。

但 pi 里 bash 指令是 工具函数 bash("command") 通过 Node.js child_process 执行,所以这个 extension 用 pi.registerTool() 重新注册 bash tool,用 spawnHook 来修改 bash 指令。

还注册了一个 /rtk-status 指令来查看 bash 指令的 rewrite 情况还有 token 的节省统计。

command-integration.ts:

/**
 * RTK + UV Integration Extension for Pi
 *
 * Combines two integrations:
 * 1. RTK: Automatically rewrites common CLI commands to their RTK equivalents
 *    for token optimization using RTK's built-in `rtk rewrite` command.
 * 2. UV: Redirects Python tooling (pip, poetry, python) to uv equivalents.
 *
 * Based on:
 * - RTK: https://github.com/rtk-ai/rtk
 * - UV: https://github.com/astral-sh/uv
 *
 * This extension replaces the default bash tool with one that uses a spawnHook
 * to intercept and rewrite commands before execution.
 */

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { createBashTool } from "@earendil-works/pi-coding-agent";
import { execSync } from "node:child_process";

const stats = new Map<string, number>();
let rtkAvailable = false;
let uvAvailable = false;

function checkToolAvailable(tool: string): boolean {
  try {
    execSync(`${tool} --version`, { encoding: "utf-8", stdio: "pipe" });
    return true;
  } catch {
    return false;
  }
}

// UV command interception
const pipMessage =
  "Error: pip is disabled. Use uv instead:\n\n" +
    "  To install a package for a script: uv run --with PACKAGE python script.py\n" +
    "  To add a dependency to the project: uv add PACKAGE\n";

const venvMessage =
  "Error: 'python -m venv' is disabled. Use uv instead:\n\n" +
    "  To create a virtual environment: uv venv\n";

function shellQuote(value: string): string {
  return `'${value.replace(/'/g, `'\\''`)}'`;
}

function createUvShellPrefix(): string {
  return `
resolve_uv_python() {
  local candidate

  candidate="$(uv python find --managed-python 2>/dev/null || true)"
  if [ -n "$candidate" ] && [ -x "$candidate" ]; then
    echo "$candidate"
    return 0
  fi

  while IFS= read -r candidate; do
    [ -n "$candidate" ] || continue
    echo "$candidate"
    return 0
  done < <(type -aP python3 2>/dev/null)

  while IFS= read -r candidate; do
    [ -n "$candidate" ] || continue
    echo "$candidate"
    return 0
  done < <(type -aP python 2>/dev/null)

  return 1
}

python() {
  if [ "$1" = "-m" ]; then
    case "$2" in
      pip|pip*) echo ${shellQuote(pipMessage)} >&2; return 1 ;;
      venv|venv*) echo ${shellQuote(venvMessage)} >&2; return 1 ;;
      py_compile|py_compile*) echo "Error: 'python -m py_compile' is disabled because it writes .pyc files to __pycache__." >&2; return 1 ;;
    esac
  fi

  case "$1" in
    -mpip*|-m\\pip) echo ${shellQuote(pipMessage)} >&2; return 1 ;;
    -mvenv*|-m\\venv) echo ${shellQuote(venvMessage)} >&2; return 1 ;;
    -mpy_compile*|-m\\py_compile) echo "Error: 'python -m py_compile' is disabled because it writes .pyc files to __pycache__." >&2; return 1 ;;
  esac

  local uv_python
  uv_python="$(resolve_uv_python)"
  if [ -z "$uv_python" ]; then
    echo "Error: Unable to locate a Python interpreter outside shim directories." >&2
    return 1
  fi

  uv run --python "$uv_python" python "$@"
}
python3() { python "$@"; }
pip() { echo ${shellQuote(pipMessage)} >&2; return 1; }
pip3() { pip "$@"; }
poetry() { echo 'Error: Use uv instead of poetry (uv init, uv add, uv sync, uv run)' >&2; return 1; }
export -f resolve_uv_python python python3 pip pip3 poetry
`;
}

function shouldUseUvShim(command: string): boolean {
  return /(^|[;&|()\s])(python3?|pip3?|poetry)(?=([;&|()\s]|$))/.test(command);
}

// RTK command rewriting
function rewriteCommand(command: string): string {
  const trimmed = command.trim();
  if (!trimmed) {
    return command;
  }

  if (rtkAvailable && !trimmed.startsWith("rtk ") && trimmed !== "rtk") {
    if (/\bfind\b/.test(trimmed)) {
      return trimmed;
    }

    if (!trimmed.includes("<<") && !trimmed.includes("$((")) {
      try {
        const rewritten = execSync(`rtk rewrite ${JSON.stringify(trimmed)}`, {
          encoding: "utf-8",
          stdio: ["pipe", "pipe", "pipe"],
          maxBuffer: 1024 * 1024,
        }).trim();

        if (rewritten && rewritten !== trimmed) {
          return rewritten;
        }
      } catch (error) {
        const stdout = (error as { stdout?: Buffer | string }).stdout;
        const rewritten = Buffer.isBuffer(stdout) ? stdout.toString().trim() : stdout?.trim();

        if (rewritten && rewritten !== trimmed) {
          return rewritten;
        }

        // Command not supported by RTK - continue with original
      }
    }
  }

  return trimmed;
}

export default function (pi: ExtensionAPI): void {
  rtkAvailable = checkToolAvailable("rtk");
  uvAvailable = checkToolAvailable("uv");

  const cwd = process.cwd();

  const bashTool = createBashTool(cwd, {
    spawnHook: ({ command, cwd, env }) => {
      const rewritten = rewriteCommand(command);

      if (rewritten !== command.trim()) {
        const baseCmd = command.trim().split(/\s+/)[0] || "";
        if (baseCmd) {
          const count = (stats.get(baseCmd) || 0) + 1;
          stats.set(baseCmd, count);
        }
      }

      const commandToRun = uvAvailable && shouldUseUvShim(rewritten)
        ? `${createUvShellPrefix()}\n${rewritten}`
        : rewritten;
      return { command: commandToRun, cwd, env };
    },
  });

  pi.registerTool({
    ...bashTool,
    execute: async (id, params, signal, onUpdate, _ctx) => {
      return bashTool.execute(id, params, signal, onUpdate);
    },
  });

  pi.registerCommand("rtk-stats", {
    description: "Show RTK token savings statistics",
    handler: async (_args, ctx) => {
      function formatSessionStats(): string {
        const lines = [
          "📊 RTK Session Statistics",
          "──────────────────────────────────────────────────",
        ];

        if (!rtkAvailable) {
          lines.push("⚠️  RTK is not installed or not available in PATH");
          lines.push("");
          lines.push("Install RTK: https://github.com/rtk-ai/rtk");
          return lines.join("\n");
        }

        if (stats.size > 0) {
          const totalRewrites = Array.from(stats.values()).reduce((sum, count) => sum + count, 0);
          lines.push(`Commands rewritten this session: ${totalRewrites}`);
          lines.push("");
          lines.push("Top commands:");
          Array.from(stats.entries())
            .sort((a, b) => b[1] - a[1])
            .slice(0, 10)
            .forEach(([cmd, count]) => {
            lines.push(`  ${cmd}: ${count}`);
          });
        } else {
          lines.push("(No commands rewritten yet in this session)");
        }

        return lines.join("\n");
      }

      try {
        const globalOutput = execSync("rtk gain", {
          encoding: "utf-8",
          stdio: "pipe",
          maxBuffer: 10 * 1024 * 1024,
        });

        const output = [
          formatSessionStats(),
          "",
          "📈 Global Statistics",
          "──────────────────────────────────────────────────",
          globalOutput.trim(),
        ].join("\n");
        ctx.ui.notify(output, "success");
      } catch {
        ctx.ui.notify(formatSessionStats(), "success");
      }
    },
  });

  pi.on("session_start", async (_event, ctx) => {
    const messages = [];

    if (uvAvailable) {
      messages.push("✓ UV integration: pip/poetry/python redirected to uv");
    } else {
      messages.push("⚠ UV not available. Python tools won't be redirected.");
    }

    if (rtkAvailable) {
      messages.push("✓ RTK integration: Commands auto-rewritten for token savings");
    } else {
      messages.push("⚠ RTK not available. Install from https://github.com/rtk-ai/rtk");
    }

    // ctx.ui.notify(messages.join("\n"), "info");
  });
}

File Explore

原始版本是:https://github.com/mitsuhiko/agent-stuff/blob/main/extensions/files.ts

加上了一些针对 Emacs 的改动。做这些改动是为了能够在终端使用 pi 的时候,快速选择文件内容复制到终端里,现在 pimacs 已经解决了这个问题。

/**
 * Files Extension
 *
 * /files command lists files in the current git tree (plus session-referenced files)
 * and offers quick actions like reveal, open, edit, or diff.
 * /diff is kept as an alias to the same picker.
 */

import { spawnSync } from "node:child_process";
import {
        existsSync,
        mkdtempSync,
        readFileSync,
        realpathSync,
        statSync,
        unlinkSync,
        writeFileSync,
} from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@mariozechner/pi-coding-agent";
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
import {
        Container,
        fuzzyFilter,
        Input,
        matchesKey,
        type SelectItem,
        SelectList,
        Spacer,
        Text,
        type TUI,
} from "@mariozechner/pi-tui";

type ContentBlock = {
        type?: string;
        text?: string;
        arguments?: Record<string, unknown>;
};

type FileReference = {
        path: string;
        display: string;
        exists: boolean;
        isDirectory: boolean;
};

type FileEntry = {
        canonicalPath: string;
        resolvedPath: string;
        displayPath: string;
        exists: boolean;
        isDirectory: boolean;
        status?: string;
        inRepo: boolean;
        isTracked: boolean;
        isReferenced: boolean;
        hasSessionChange: boolean;
        lastTimestamp: number;
};

type GitStatusEntry = {
        status: string;
        exists: boolean;
        isDirectory: boolean;
};

type FileToolName = "write" | "edit";

type SessionFileChange = {
        operations: Set<FileToolName>;
        lastTimestamp: number;
};

const FILE_TAG_REGEX = /<file\s+name=["']([^"']+)["']>/g;
const FILE_URL_REGEX = /file:\/\/[^\s"'<>]+/g;
const PATH_REGEX = /(?:^|[\s"'`([{<])((?:~|\/)[^\s"'`<>)}\]]+)/g;

const MAX_EDIT_BYTES = 40 * 1024 * 1024;

const extractFileReferencesFromText = (text: string): string[] => {
        const refs: string[] = [];

        for (const match of text.matchAll(FILE_TAG_REGEX)) {
                refs.push(match[1]);
        }

        for (const match of text.matchAll(FILE_URL_REGEX)) {
                refs.push(match[0]);
        }

        for (const match of text.matchAll(PATH_REGEX)) {
                refs.push(match[1]);
        }

        return refs;
};

const extractPathsFromToolArgs = (args: unknown): string[] => {
        if (!args || typeof args !== "object") {
                return [];
        }

        const refs: string[] = [];
        const record = args as Record<string, unknown>;
        const directKeys = ["path", "file", "filePath", "filepath", "fileName", "filename"] as const;
        const listKeys = ["paths", "files", "filePaths"] as const;

        for (const key of directKeys) {
                const value = record[key];
                if (typeof value === "string") {
                        refs.push(value);
                }
        }

        for (const key of listKeys) {
                const value = record[key];
                if (Array.isArray(value)) {
                        for (const item of value) {
                                if (typeof item === "string") {
                                        refs.push(item);
                                }
                        }
                }
        }

        return refs;
};

const extractFileReferencesFromContent = (content: unknown): string[] => {
        if (typeof content === "string") {
                return extractFileReferencesFromText(content);
        }

        if (!Array.isArray(content)) {
                return [];
        }

        const refs: string[] = [];
        for (const part of content) {
                if (!part || typeof part !== "object") {
                        continue;
                }

                const block = part as ContentBlock;

                if (block.type === "text" && typeof block.text === "string") {
                        refs.push(...extractFileReferencesFromText(block.text));
                }

                if (block.type === "toolCall") {
                        refs.push(...extractPathsFromToolArgs(block.arguments));
                }
        }

        return refs;
};

const extractFileReferencesFromEntry = (entry: SessionEntry): string[] => {
        if (entry.type === "message") {
                return extractFileReferencesFromContent(entry.message.content);
        }

        if (entry.type === "custom_message") {
                return extractFileReferencesFromContent(entry.content);
        }

        return [];
};

const sanitizeReference = (raw: string): string => {
        let value = raw.trim();
        value = value.replace(/^["'`(<\[]+/, "");
        value = value.replace(/[>"'`,;).\]]+$/, "");
        value = value.replace(/[.,;:]+$/, "");
        return value;
};

const isCommentLikeReference = (value: string): boolean => value.startsWith("//");

const stripLineSuffix = (value: string): string => {
        let result = value.replace(/#L\d+(C\d+)?$/i, "");
        const lastSeparator = Math.max(result.lastIndexOf("/"), result.lastIndexOf("\\"));
        const segmentStart = lastSeparator >= 0 ? lastSeparator + 1 : 0;
        const segment = result.slice(segmentStart);
        const colonIndex = segment.indexOf(":");

        if (colonIndex >= 0 && /\d/.test(segment[colonIndex + 1] ?? "")) {
                return result.slice(0, segmentStart + colonIndex);
        }

        const lastColon = result.lastIndexOf(":");
        if (lastColon > lastSeparator && /^\d+(?::\d+)?$/.test(result.slice(lastColon + 1))) {
                return result.slice(0, lastColon);
        }

        return result;
};

const normalizeReferencePath = (raw: string, cwd: string): string | null => {
        let candidate = sanitizeReference(raw);
        if (!candidate || isCommentLikeReference(candidate)) return null;

        if (candidate.startsWith("file://")) {
                try {
                        candidate = fileURLToPath(candidate);
                } catch {
                        return null;
                }
        }

        candidate = stripLineSuffix(candidate);
        if (!candidate || isCommentLikeReference(candidate)) return null;

        if (candidate.startsWith("~")) {
                candidate = path.join(os.homedir(), candidate.slice(1));
        }

        candidate = path.normalize(path.isAbsolute(candidate) ? candidate : path.resolve(cwd, candidate));
        const root = path.parse(candidate).root;
        return candidate.length > root.length ? candidate.replace(/[\\/]+$/, "") : candidate;
};

const formatDisplayPath = (absolutePath: string, cwd: string): string => {
        const normalizedCwd = path.resolve(cwd);
        if (absolutePath.startsWith(normalizedCwd + path.sep)) {
                return path.relative(normalizedCwd, absolutePath);
        }

        return absolutePath;
};

const collectRecentFileReferences = (entries: SessionEntry[], cwd: string, limit: number): FileReference[] => {
        const results: FileReference[] = [];
        const seen = new Set<string>();

        for (const entry of entries) {
                if (results.length >= limit) break;
                const refs = extractFileReferencesFromEntry(entry);

                for (const ref of refs) {
                        if (results.length >= limit) break;
                        const normalized = normalizeReferencePath(ref, cwd);
                        if (!normalized || seen.has(normalized)) continue;
                        seen.add(normalized);

                        const exists = existsSync(normalized);
                        results.push({
                                path: normalized,
                                display: formatDisplayPath(normalized, cwd),
                                exists,
                                isDirectory: exists && statSync(normalized).isDirectory(),
                        });
                }
        }

        return results;
};

const findLatestFileReference = (entries: SessionEntry[], cwd: string): FileReference | null => {
        const refs = collectRecentFileReferences(entries, cwd, 100);
        return refs.find((ref) => ref.exists) ?? null;
};

const toCanonicalPath = (inputPath: string): { canonicalPath: string; isDirectory: boolean } | null => {
        if (!existsSync(inputPath)) {
                return null;
        }

        try {
                const canonicalPath = realpathSync(inputPath);
                const stats = statSync(canonicalPath);
                return { canonicalPath, isDirectory: stats.isDirectory() };
        } catch {
                return null;
        }
};

const toCanonicalPathMaybeMissing = (
        inputPath: string,
): { canonicalPath: string; isDirectory: boolean; exists: boolean } | null => {
        const resolvedPath = path.resolve(inputPath);
        if (!existsSync(resolvedPath)) {
                return { canonicalPath: path.normalize(resolvedPath), isDirectory: false, exists: false };
        }

        try {
                const canonicalPath = realpathSync(resolvedPath);
                const stats = statSync(canonicalPath);
                return { canonicalPath, isDirectory: stats.isDirectory(), exists: true };
        } catch {
                return { canonicalPath: path.normalize(resolvedPath), isDirectory: false, exists: true };
        }
};

const collectSessionFileChanges = (entries: SessionEntry[], cwd: string): Map<string, SessionFileChange> => {
        const toolCalls = new Map<string, { path: string; name: FileToolName }>();

        for (const entry of entries) {
                if (entry.type !== "message") continue;
                const msg = entry.message;

                if (msg.role === "assistant" && Array.isArray(msg.content)) {
                        for (const block of msg.content) {
                                if (block.type === "toolCall") {
                                        const name = block.name as FileToolName;
                                        if (name === "write" || name === "edit") {
                                                const filePath = block.arguments?.path;
                                                if (filePath && typeof filePath === "string") {
                                                        toolCalls.set(block.id, { path: filePath, name });
                                                }
                                        }
                                }
                        }
                }
        }

        const fileMap = new Map<string, SessionFileChange>();

        for (const entry of entries) {
                if (entry.type !== "message") continue;
                const msg = entry.message;

                if (msg.role === "toolResult") {
                        const toolCall = toolCalls.get(msg.toolCallId);
                        if (!toolCall) continue;

                        const resolvedPath = path.isAbsolute(toolCall.path)
                                ? toolCall.path
                                : path.resolve(cwd, toolCall.path);
                        const canonical = toCanonicalPath(resolvedPath);
                        if (!canonical) {
                                continue;
                        }

                        const existing = fileMap.get(canonical.canonicalPath);
                        if (existing) {
                                existing.operations.add(toolCall.name);
                                if (msg.timestamp > existing.lastTimestamp) {
                                        existing.lastTimestamp = msg.timestamp;
                                }
                        } else {
                                fileMap.set(canonical.canonicalPath, {
                                        operations: new Set([toolCall.name]),
                                        lastTimestamp: msg.timestamp,
                                });
                        }
                }
        }

        return fileMap;
};

const splitNullSeparated = (value: string): string[] => value.split("\0").filter(Boolean);

const getGitRoot = async (pi: ExtensionAPI, cwd: string): Promise<string | null> => {
        const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd });
        return result.code === 0 ? (result.stdout.trim() || null) : null;
};

const getGitStatusMap = async (pi: ExtensionAPI, cwd: string): Promise<Map<string, GitStatusEntry>> => {
        const statusMap = new Map<string, GitStatusEntry>();
        const statusResult = await pi.exec("git", ["status", "--porcelain=1", "-z"], { cwd });
        if (statusResult.code !== 0 || !statusResult.stdout) {
                return statusMap;
        }

        const entries = splitNullSeparated(statusResult.stdout);
        for (let i = 0; i < entries.length; i += 1) {
                const entry = entries[i];
                if (!entry || entry.length < 4) continue;
                const status = entry.slice(0, 2);
                const statusLabel = status.replace(/\s/g, "") || status.trim();
                let filePath = entry.slice(3);
                if ((status.startsWith("R") || status.startsWith("C")) && entries[i + 1]) {
                        filePath = entries[i + 1];
                        i += 1;
                }
                if (!filePath) continue;

                const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
                const canonical = toCanonicalPathMaybeMissing(resolved);
                if (!canonical) continue;
                statusMap.set(canonical.canonicalPath, {
                        status: statusLabel,
                        exists: canonical.exists,
                        isDirectory: canonical.isDirectory,
                });
        }

        return statusMap;
};

const getGitFiles = async (
        pi: ExtensionAPI,
        gitRoot: string,
): Promise<{ tracked: Set<string>; files: Array<{ canonicalPath: string; isDirectory: boolean }> }> => {
        const tracked = new Set<string>();
        const files: Array<{ canonicalPath: string; isDirectory: boolean }> = [];

        const trackedResult = await pi.exec("git", ["ls-files", "-z"], { cwd: gitRoot });
        if (trackedResult.code === 0 && trackedResult.stdout) {
                for (const relativePath of splitNullSeparated(trackedResult.stdout)) {
                        const resolvedPath = path.resolve(gitRoot, relativePath);
                        const canonical = toCanonicalPath(resolvedPath);
                        if (!canonical) continue;
                        tracked.add(canonical.canonicalPath);
                        files.push(canonical);
                }
        }

        const untrackedResult = await pi.exec("git", ["ls-files", "-z", "--others", "--exclude-standard"], { cwd: gitRoot });
        if (untrackedResult.code === 0 && untrackedResult.stdout) {
                for (const relativePath of splitNullSeparated(untrackedResult.stdout)) {
                        const resolvedPath = path.resolve(gitRoot, relativePath);
                        const canonical = toCanonicalPath(resolvedPath);
                        if (!canonical) continue;
                        files.push(canonical);
                }
        }

        return { tracked, files };
};

const isInRepo = (filePath: string, gitRoot: string | null): boolean => {
        if (!gitRoot) return false;
        const relative = path.relative(gitRoot, filePath);
        return !relative.startsWith("..") && !path.isAbsolute(relative);
};

const buildFileEntries = async (pi: ExtensionAPI, ctx: ExtensionContext): Promise<{ files: FileEntry[]; gitRoot: string | null }> => {
        const entries = ctx.sessionManager.getBranch();
        const sessionChanges = collectSessionFileChanges(entries, ctx.cwd);
        const gitRoot = await getGitRoot(pi, ctx.cwd);
        const statusMap = gitRoot ? await getGitStatusMap(pi, gitRoot) : new Map<string, GitStatusEntry>();

        let trackedSet = new Set<string>();
        let gitFiles: Array<{ canonicalPath: string; isDirectory: boolean }> = [];
        if (gitRoot) {
                const gitListing = await getGitFiles(pi, gitRoot);
                trackedSet = gitListing.tracked;
                gitFiles = gitListing.files;
        }

        const fileMap = new Map<string, FileEntry>();

        const upsertFile = (data: Partial<FileEntry> & { canonicalPath: string; isDirectory: boolean }) => {
                const existing = fileMap.get(data.canonicalPath);
                const displayPath = data.displayPath ?? formatDisplayPath(data.canonicalPath, ctx.cwd);

                if (existing) {
                        fileMap.set(data.canonicalPath, {
                                ...existing,
                                ...data,
                                displayPath,
                                exists: data.exists ?? existing.exists,
                                isDirectory: data.isDirectory ?? existing.isDirectory,
                                isReferenced: existing.isReferenced || data.isReferenced === true,
                                inRepo: existing.inRepo || data.inRepo === true,
                                isTracked: existing.isTracked || data.isTracked === true,
                                hasSessionChange: existing.hasSessionChange || data.hasSessionChange === true,
                                lastTimestamp: Math.max(existing.lastTimestamp, data.lastTimestamp ?? 0),
                        });
                        return;
                }

                fileMap.set(data.canonicalPath, {
                        canonicalPath: data.canonicalPath,
                        resolvedPath: data.resolvedPath ?? data.canonicalPath,
                        displayPath,
                        exists: data.exists ?? true,
                        isDirectory: data.isDirectory,
                        status: data.status,
                        inRepo: data.inRepo ?? false,
                        isTracked: data.isTracked ?? false,
                        isReferenced: data.isReferenced ?? false,
                        hasSessionChange: data.hasSessionChange ?? false,
                        lastTimestamp: data.lastTimestamp ?? 0,
                });
        };

        for (const file of gitFiles) {
                upsertFile({
                        canonicalPath: file.canonicalPath,
                        resolvedPath: file.canonicalPath,
                        isDirectory: file.isDirectory,
                        exists: true,
                        status: statusMap.get(file.canonicalPath)?.status,
                        inRepo: true,
                        isTracked: trackedSet.has(file.canonicalPath),
                });
        }

        for (const [canonicalPath, statusEntry] of statusMap.entries()) {
                if (fileMap.has(canonicalPath)) continue;

                upsertFile({
                        canonicalPath,
                        resolvedPath: canonicalPath,
                        isDirectory: statusEntry.isDirectory,
                        exists: statusEntry.exists,
                        status: statusEntry.status,
                        inRepo: isInRepo(canonicalPath, gitRoot),
                        isTracked: trackedSet.has(canonicalPath) || statusEntry.status !== "??",
                });
        }

        for (const ref of collectRecentFileReferences(entries, ctx.cwd, 200).filter((ref) => ref.exists)) {
                const canonical = toCanonicalPath(ref.path);
                if (!canonical) continue;

                upsertFile({
                        canonicalPath: canonical.canonicalPath,
                        resolvedPath: canonical.canonicalPath,
                        isDirectory: canonical.isDirectory,
                        exists: true,
                        status: statusMap.get(canonical.canonicalPath)?.status,
                        inRepo: isInRepo(canonical.canonicalPath, gitRoot),
                        isTracked: trackedSet.has(canonical.canonicalPath),
                        isReferenced: true,
                });
        }

        for (const [canonicalPath, change] of sessionChanges.entries()) {
                const canonical = toCanonicalPath(canonicalPath);
                if (!canonical) continue;

                upsertFile({
                        canonicalPath: canonical.canonicalPath,
                        resolvedPath: canonical.canonicalPath,
                        isDirectory: canonical.isDirectory,
                        exists: true,
                        status: statusMap.get(canonical.canonicalPath)?.status,
                        inRepo: isInRepo(canonical.canonicalPath, gitRoot),
                        isTracked: trackedSet.has(canonical.canonicalPath),
                        hasSessionChange: true,
                        lastTimestamp: change.lastTimestamp,
                });
        }

        const files = Array.from(fileMap.values()).sort((a, b) => {
                if (a.status !== b.status) return (b.status ? 1 : 0) - (a.status ? 1 : 0);
                if (a.inRepo !== b.inRepo) return a.inRepo ? -1 : 1;
                if (a.hasSessionChange !== b.hasSessionChange) return a.hasSessionChange ? -1 : 1;
                if (a.lastTimestamp !== b.lastTimestamp) return b.lastTimestamp - a.lastTimestamp;
                if (a.isReferenced !== b.isReferenced) return a.isReferenced ? -1 : 1;
                return a.displayPath.localeCompare(b.displayPath);
        });

        return { files, gitRoot };
};

type EditCheckResult = {
        allowed: boolean;
        reason?: string;
        content?: string;
};

const getEditableContent = (target: FileEntry): EditCheckResult => {
        if (!existsSync(target.resolvedPath)) return { allowed: false, reason: "File not found" };

        const stats = statSync(target.resolvedPath);
        if (stats.isDirectory()) return { allowed: false, reason: "Directories cannot be edited" };
        if (stats.size >= MAX_EDIT_BYTES) return { allowed: false, reason: "File is too large" };

        const buffer = readFileSync(target.resolvedPath);
        if (buffer.includes(0)) return { allowed: false, reason: "File contains null bytes" };

        return { allowed: true, content: buffer.toString("utf8") };
};

const showActionSelector = async (
        ctx: ExtensionContext,
        options: { canQuickLook: boolean; canEdit: boolean; canDiff: boolean },
): Promise<"reveal" | "quicklook" | "open" | "edit" | "addToPrompt" | "diff" | null> => {
        const actions: SelectItem[] = [
                ...(options.canDiff ? [{ value: "diff", label: "diff in Emacs" }] : []),
                { value: "reveal", label: "Reveal in Finder" },
                { value: "open", label: "Open" },
                { value: "addToPrompt", label: "Add to prompt" },
                ...(options.canQuickLook ? [{ value: "quicklook", label: "Open in Quick Look" }] : []),
                ...(options.canEdit ? [{ value: "edit", label: "Edit" }] : []),
        ];

        return ctx.ui.custom<"reveal" | "quicklook" | "open" | "edit" | "addToPrompt" | "diff" | null>((tui, theme, _kb, done) => {
                const container = new Container();
                container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
                container.addChild(new Text(theme.fg("accent", theme.bold("Choose action"))));

                const selectList = new SelectList(actions, actions.length, {
                        selectedPrefix: (text) => theme.fg("accent", text),
                        selectedText: (text) => theme.fg("accent", text),
                        description: (text) => theme.fg("muted", text),
                        scrollInfo: (text) => theme.fg("dim", text),
                        noMatch: (text) => theme.fg("warning", text),
                });

                selectList.onSelect = (item) => done(item.value as "reveal" | "quicklook" | "open" | "edit" | "addToPrompt" | "diff");
                selectList.onCancel = () => done(null);

                container.addChild(selectList);
                container.addChild(new Text(theme.fg("dim", "Press enter to confirm or esc to cancel")));
                container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));

                return {
                        render(width: number) {
                                return container.render(width);
                        },
                        invalidate() {
                                container.invalidate();
                        },
                        handleInput(data: string) {
                                selectList.handleInput(data);
                                tui.requestRender();
                        },
                };
        });
};

const openPath = async (pi: ExtensionAPI, ctx: ExtensionContext, target: FileEntry): Promise<void> => {
        if (!existsSync(target.resolvedPath)) {
                ctx.ui.notify(`File not found: ${target.displayPath}`, "error");
                return;
        }

        const editorCmd = getEditor();
        if (editorCmd) {
                const result = await pi.exec(editorCmd, [target.resolvedPath]);
                if (result.code !== 0) {
                        ctx.ui.notify(result.stderr?.trim() || `Failed to open ${target.displayPath}`, "error");
                }
                return;
        }

        if (process.platform === "darwin") {
                const result = await pi.exec("open", [target.resolvedPath]);
                if (result.code !== 0) {
                        ctx.ui.notify(result.stderr?.trim() || `Failed to open ${target.displayPath}`, "error");
                }
                return;
        }

        ctx.ui.notify("No editor configured. Set $VISUAL, $EDITOR, or install emacs/emacsclient.", "warning");
};

const openExternalEditor = (tui: TUI, editorCmd: string, content: string): string | null => {
        const tmpFile = path.join(os.tmpdir(), `pi-files-edit-${Date.now()}.txt`);

        try {
                writeFileSync(tmpFile, content, "utf8");
                tui.stop();

                const [editor, ...editorArgs] = editorCmd.split(" ");
                const result = spawnSync(editor, [...editorArgs, tmpFile], { stdio: "inherit" });

                if (result.status === 0) {
                        return readFileSync(tmpFile, "utf8").replace(/\n$/, "");
                }

                return null;
        } finally {
                try {
                        unlinkSync(tmpFile);
                } catch {
                }
                tui.start();
                tui.requestRender(true);
        }
};

const getEditor = (): string | null => {
        const tryWhich = (bin: string): boolean => {
                try {
                        const result = spawnSync("which", [bin], { encoding: "utf8" });
                        return result.status === 0;
                } catch {
                        return false;
                }
        };

        const isEmacsServerRunning = (): boolean => {
                try {
                        const result = spawnSync("ps", ["aux"], { encoding: "utf8" });
                        if (result.status !== 0) return false;
                        return result.stdout.includes("emacs") || result.stdout.includes(" Emacs");
                } catch {
                        return false;
                }
        };

        // Priority: emacsclient > Emacs > visual > editor
        if (process.env.EMACSCLIENT) return process.env.EMACSCLIENT;
        if (tryWhich("emacsclient") && isEmacsServerRunning()) return "emacsclient";

        if (process.env.EMACS) return process.env.EMACS;
        if (tryWhich("emacs")) return "emacs";

        if (process.env.VISUAL) return process.env.VISUAL;
        if (process.env.EDITOR) return process.env.EDITOR;

        return null;
};

const editPath = async (ctx: ExtensionContext, target: FileEntry, content: string): Promise<void> => {
        const editorCmd = getEditor();
        if (!editorCmd) {
                ctx.ui.notify("No editor configured. Set $VISUAL, $EDITOR, or install emacs/emacsclient.", "warning");
                return;
        }

        const updated = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
                const status = new Text(theme.fg("dim", `Opening ${editorCmd}...`));

                queueMicrotask(() => {
                        const result = openExternalEditor(tui, editorCmd, content);
                        done(result);
                });

                return status;
        });

        if (updated === null) {
                ctx.ui.notify("Edit cancelled", "info");
                return;
        }

        try {
                writeFileSync(target.resolvedPath, updated, "utf8");
        } catch {
                ctx.ui.notify(`Failed to save ${target.displayPath}`, "error");
        }
};

const revealPath = async (pi: ExtensionAPI, ctx: ExtensionContext, target: FileEntry): Promise<void> => {
        if (!existsSync(target.resolvedPath)) {
                ctx.ui.notify(`File not found: ${target.displayPath}`, "error");
                return;
        }

        const isDirectory = target.isDirectory || statSync(target.resolvedPath).isDirectory();
        const args = process.platform === "darwin"
                ? (isDirectory ? [target.resolvedPath] : ["-R", target.resolvedPath])
                : [isDirectory ? target.resolvedPath : path.dirname(target.resolvedPath)];

        const result = await pi.exec(process.platform === "darwin" ? "open" : "xdg-open", args);
        if (result.code !== 0) {
                ctx.ui.notify(result.stderr?.trim() || `Failed to reveal ${target.displayPath}`, "error");
        }
};

const quickLookPath = async (pi: ExtensionAPI, ctx: ExtensionContext, target: FileEntry): Promise<void> => {
        if (process.platform !== "darwin") {
                ctx.ui.notify("Quick Look is only available on macOS", "warning");
                return;
        }
        if (!existsSync(target.resolvedPath)) {
                ctx.ui.notify(`File not found: ${target.displayPath}`, "error");
                return;
        }
        if (target.isDirectory || statSync(target.resolvedPath).isDirectory()) {
                ctx.ui.notify("Quick Look only works on files", "warning");
                return;
        }

        const result = await pi.exec("qlmanage", ["-p", target.resolvedPath]);
        if (result.code !== 0) {
                ctx.ui.notify(result.stderr?.trim() || `Failed to Quick Look ${target.displayPath}`, "error");
        }
};

const openDiff = async (pi: ExtensionAPI, ctx: ExtensionContext, target: FileEntry, gitRoot: string | null): Promise<void> => {
        if (!gitRoot) {
                ctx.ui.notify("Git repository not found", "warning");
                return;
        }

        if (!target.isTracked) {
                ctx.ui.notify("File is not tracked by git", "warning");
                return;
        }

        if (!existsSync(target.resolvedPath)) {
                ctx.ui.notify(`File not found: ${target.displayPath}`, "error");
                return;
        }

        const emacsCmd = process.env.EMACSCLIENT || "emacsclient";
        const emacsBin = process.env.EMACS || "emacs";
        const magitCmd = `(progn (find-file "${target.resolvedPath}") (magit-diff-buffer-file))`;

        const fallbacks = [
                [emacsCmd, ["--eval", magitCmd]],
                [emacsCmd, ["--create-frame", "--eval", magitCmd]],
                [emacsCmd, ["--alternate-editor", process.env.ALTERNATE_EDITOR || emacsBin, "--eval", magitCmd]],
                [emacsBin, ["--eval", magitCmd]],
        ];

        let openResult = { code: -1, stderr: "" };
        for (const [cmd, args] of fallbacks) {
                if (cmd === emacsCmd) {
                        const whichResult = await pi.exec("which", [emacsCmd]);
                        if (whichResult.code !== 0) continue;
                }
                openResult = await pi.exec(cmd, args, { cwd: gitRoot });
                if (openResult.code === 0) break;
        }

        if (openResult.code !== 0) {
                ctx.ui.notify(openResult.stderr?.trim() || `Failed to open diff for ${target.displayPath}`, "error");
        }
};

const addFileToPrompt = (ctx: ExtensionContext, target: FileEntry): void => {
        const mentionTarget = target.displayPath || target.resolvedPath;
        const mention = `@${mentionTarget}`;
        const current = ctx.ui.getEditorText();
        const separator = current && !current.endsWith(" ") ? " " : "";
        ctx.ui.setEditorText(`${current}${separator}${mention}`);
        ctx.ui.notify(`Added ${mention} to prompt`, "info");
};

const showFileSelector = async (
        ctx: ExtensionContext,
        files: FileEntry[],
        selectedPath?: string | null,
        gitRoot?: string | null,
): Promise<{ selected: FileEntry | null; quickAction: "diff" | null }> => {
        const items: SelectItem[] = files.map((file) => {
                const directoryLabel = file.isDirectory ? " [directory]" : "";
                const statusSuffix = file.status ? ` [${file.status}]` : "";
                return {
                        value: file.canonicalPath,
                        label: `${file.displayPath}${directoryLabel}${statusSuffix}`,
                };
        });

        let quickAction: "diff" | null = null;
        const selection = await ctx.ui.custom<string | null>((tui, theme, keybindings, done) => {
                const container = new Container();
                container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
                container.addChild(new Text(theme.fg("accent", theme.bold(" Select file")), 0, 0));

                const searchInput = new Input();
                container.addChild(searchInput);
                container.addChild(new Spacer(1));

                const listContainer = new Container();
                container.addChild(listContainer);
                container.addChild(
                        new Text(theme.fg("dim", "Type to filter • enter to select • ctrl+shift+d diff • esc to cancel"), 0, 0),
                );
                container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));

                let filteredItems = items;
                let selectList: SelectList | null = null;

                const updateList = () => {
                        listContainer.clear();
                        if (filteredItems.length === 0) {
                                listContainer.addChild(new Text(theme.fg("warning", "  No matching files"), 0, 0));
                                selectList = null;
                                return;
                        }

                        selectList = new SelectList(filteredItems, Math.min(filteredItems.length, 12), {
                                selectedPrefix: (text) => theme.fg("accent", text),
                                selectedText: (text) => theme.fg("accent", text),
                                description: (text) => theme.fg("muted", text),
                                scrollInfo: (text) => theme.fg("dim", text),
                                noMatch: (text) => theme.fg("warning", text),
                        });

                        if (selectedPath) {
                                const index = filteredItems.findIndex((item) => item.value === selectedPath);
                                if (index >= 0) {
                                        selectList.setSelectedIndex(index);
                                }
                        }

                        selectList.onSelect = (item) => done(item.value as string);
                        selectList.onCancel = () => done(null);

                        listContainer.addChild(selectList);
                };

                const applyFilter = () => {
                        const query = searchInput.getValue();
                        filteredItems = query
                                ? fuzzyFilter(items, query, (item) => `${item.label} ${item.value} ${item.description ?? ""}`)
                                : items;
                        updateList();
                };

                applyFilter();

                return {
                        render(width: number) {
                                return container.render(width);
                        },
                        invalidate() {
                                container.invalidate();
                        },
                        handleInput(data: string) {
                                if (matchesKey(data, "ctrl+shift+d")) {
                                        const selected = selectList?.getSelectedItem();
                                        if (selected) {
                                                const file = files.find((entry) => entry.canonicalPath === selected.value);
                                                const canDiff = file?.isTracked && !file.isDirectory && Boolean(gitRoot);
                                                if (!canDiff) {
                                                        ctx.ui.notify("Diff is only available for tracked files", "warning");
                                                        return;
                                                }
                                                quickAction = "diff";
                                                done(selected.value as string);
                                                return;
                                        }
                                }

                                if (
                                        keybindings.matches(data, "tui.select.up") ||
                                        keybindings.matches(data, "tui.select.down") ||
                                        keybindings.matches(data, "tui.select.confirm") ||
                                        keybindings.matches(data, "tui.select.cancel")
                                ) {
                                        if (selectList) {
                                                selectList.handleInput(data);
                                        } else if (keybindings.matches(data, "tui.select.cancel")) {
                                                done(null);
                                        }
                                        tui.requestRender();
                                        return;
                                }

                                searchInput.handleInput(data);
                                applyFilter();
                                tui.requestRender();
                        },
                };
        });

        const selected = selection ? files.find((file) => file.canonicalPath === selection) ?? null : null;
        return { selected, quickAction };
};

const runFileBrowser = async (pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> => {
        if (!ctx.hasUI) {
                ctx.ui.notify("Files requires interactive mode", "error");
                return;
        }

        const { files, gitRoot } = await buildFileEntries(pi, ctx);
        if (files.length === 0) {
                ctx.ui.notify("No files found", "info");
                return;
        }

        let lastSelectedPath: string | null = null;
        while (true) {
                const { selected, quickAction } = await showFileSelector(ctx, files, lastSelectedPath, gitRoot);
                if (!selected) {
                        ctx.ui.notify("Files cancelled", "info");
                        return;
                }

                lastSelectedPath = selected.canonicalPath;

                const canQuickLook = process.platform === "darwin" && !selected.isDirectory;
                const editCheck = getEditableContent(selected);
                const canDiff = selected.isTracked && !selected.isDirectory && Boolean(gitRoot);

                if (quickAction === "diff") {
                        await openDiff(pi, ctx, selected, gitRoot);
                        continue;
                }

                const action = await showActionSelector(ctx, {
                        canQuickLook,
                        canEdit: editCheck.allowed,
                        canDiff,
                });
                if (!action) {
                        continue;
                }

                switch (action) {
                        case "quicklook":
                                await quickLookPath(pi, ctx, selected);
                                break;
                        case "open":
                                await openPath(pi, ctx, selected);
                                break;
                        case "edit":
                                if (!editCheck.allowed || editCheck.content === undefined) {
                                        ctx.ui.notify(editCheck.reason ?? "File cannot be edited", "warning");
                                        break;
                                }
                                await editPath(ctx, selected, editCheck.content);
                                break;
                        case "addToPrompt":
                                addFileToPrompt(ctx, selected);
                                break;
                        case "diff":
                                await openDiff(pi, ctx, selected, gitRoot);
                                break;
                        default:
                                await revealPath(pi, ctx, selected);
                                break;
                }
        }
};

export default function (pi: ExtensionAPI): void {
        pi.registerCommand("files", {
                description: "Browse files with git status and session references",
                handler: async (_args, ctx) => {
                        await runFileBrowser(pi, ctx);
                },
        });

        pi.registerShortcut("ctrl+shift+o", {
                description: "Browse files mentioned in the session",
                handler: async (ctx) => {
                        await runFileBrowser(pi, ctx);
                },
        });

        pi.registerShortcut("ctrl+shift+f", {
                description: "Reveal the latest file reference in Finder",
                handler: async (ctx) => {
                        const entries = ctx.sessionManager.getBranch();
                        const latest = findLatestFileReference(entries, ctx.cwd);

                        if (!latest) {
                                ctx.ui.notify("No file reference found in the session", "warning");
                                return;
                        }

                        const canonical = toCanonicalPath(latest.path);
                        if (!canonical) {
                                ctx.ui.notify(`File not found: ${latest.display}`, "error");
                                return;
                        }

                        await revealPath(pi, ctx, {
                                canonicalPath: canonical.canonicalPath,
                                resolvedPath: canonical.canonicalPath,
                                displayPath: latest.display,
                                exists: true,
                                isDirectory: canonical.isDirectory,
                                status: undefined,
                                inRepo: false,
                                isTracked: false,
                                isReferenced: true,
                                hasSessionChange: false,
                                lastTimestamp: 0,
                        });
                },
        });

        pi.registerShortcut("ctrl+shift+r", {
                description: "Quick Look the latest file reference",
                handler: async (ctx) => {
                        const entries = ctx.sessionManager.getBranch();
                        const latest = findLatestFileReference(entries, ctx.cwd);

                        if (!latest) {
                                ctx.ui.notify("No file reference found in the session", "warning");
                                return;
                        }

                        const canonical = toCanonicalPath(latest.path);
                        if (!canonical) {
                                ctx.ui.notify(`File not found: ${latest.display}`, "error");
                                return;
                        }

                        await quickLookPath(pi, ctx, {
                                canonicalPath: canonical.canonicalPath,
                                resolvedPath: canonical.canonicalPath,
                                displayPath: latest.display,
                                exists: true,
                                isDirectory: canonical.isDirectory,
                                status: undefined,
                                inRepo: false,
                                isTracked: false,
                                isReferenced: true,
                                hasSessionChange: false,
                                lastTimestamp: 0,
                        });
                },
        });
}

@file Improve

有些模型在 Pi 里会识别不了 @file 的完整路径,这个插件就只是简单的 transform 了完整的路径到 input:

pi 的 v0.68.0 里的这些改动似乎解决了这个问题:

  • Changed SDK and CLI tool selection from cwd-bound built-in tool instances to tool-name allowlists. createAgentSession({ tools }) now expects string[] names such as "read" and "bash" instead of Tool[], --tools now allowlists built-in, extension, and custom tools by name, and --no-tools now disables all tools by default rather than only built-ins. Migrate SDK code from tools: [readTool, bashTool] to tools: ["read", "bash"] (#2835, #3452)
  • Removed prebuilt cwd-bound tool and tool-definition exports from @mariozechner/pi-coding-agent, including readTool, bashTool, editTool, writeTool, grepTool, findTool, lsTool, readOnlyTools, codingTools, and the corresponding *ToolDefinition values. Use the explicit factory exports instead, for example createReadTool(cwd), createBashTool(cwd), createCodingTools(cwd), and createReadToolDefinition(cwd) (#3452)
  • Removed ambient process.cwd() / default agent-dir fallback behavior from public resource helpers. DefaultResourceLoader, loadProjectContextFiles(), and loadSkills() now require explicit cwd/agent-dir style inputs, and exported system-prompt option types now require an explicit cwd . Pass the session or project cwd explicitly instead of relying on process-global defaults (#3452)
import { existsSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";

const MENTION_PATTERN = /@([^\s@]+)/g;

const isGptSeriesModel = (modelId: string | undefined) => Boolean(modelId && /^gpt/i.test(modelId));

const toDisplayPath = (value: string) => value.replace(/\\/g, "/");

const toMentionPath = (absolutePath: string) => {
        const resolvedPath = path.resolve(absolutePath);
        const home = os.homedir();

        if (resolvedPath === home) return "~";
        if (resolvedPath.startsWith(`${home}${path.sep}`)) {
                return `~/${toDisplayPath(resolvedPath.slice(home.length + 1))}`;
        }

        return toDisplayPath(resolvedPath);
};

const resolveToAbsolutePath = (cwd: string, target: string): string | null => {
        const trimmed = target.trim();
        if (!trimmed) return null;

        const expanded = trimmed === "~" ? os.homedir() : trimmed.startsWith("~/") ? path.join(os.homedir(), trimmed.slice(2)) : trimmed;
        const absolutePath = path.isAbsolute(expanded) ? expanded : path.resolve(cwd, expanded);
        if (!existsSync(absolutePath)) return null;

        return path.resolve(absolutePath);
};

const formatMention = (absolutePath: string) => `@${toMentionPath(absolutePath)}`;

function rewriteMentions(text: string, cwd: string) {
        let changed = false;

        const rewritten = text.replace(MENTION_PATTERN, (original, rawTarget: string | undefined) => {
                const absolutePath = resolveToAbsolutePath(cwd, rawTarget ?? "");
                if (!absolutePath) return original;

                const replacement = formatMention(absolutePath);
                changed ||= replacement !== original;
                return replacement;
        });

        return changed ? rewritten : text;
}

export default function (pi: ExtensionAPI) {
        pi.on("input", async (event, ctx) => {
                if (event.source === "extension" || !event.text.includes("@") || !isGptSeriesModel(ctx.model?.id)) {
                        return { action: "continue" };
                }

                const rewritten = rewriteMentions(event.text, ctx.cwd);
                if (rewritten === event.text) {
                        return { action: "continue" };
                }

                return { action: "transform", text: rewritten, images: event.images };
        });
}

Others

https://github.com/mitsuhiko/agent-stuff/ 的作者写的这些 extension 都是很好的设计模板。

Custom Emacs Functions

有时候只是一些简单的小问题,不想打开终端或者 pimacs,就会用到这个在 Emacs 里快速用 pi --no-session -p 提问:

;; simple ai code shell command
(defun my/ai-shell-command (prompt)
  "Execute ai command asynchronously and display the output."
  (interactive "sAI prompt: ")
  (let* ((command-name "pi")
         (output-buffer-name "*AI Output*")
         (output-buffer (get-buffer-create output-buffer-name))
         (shell-program (or (getenv "SHELL") shell-file-name))
         ;; Use shell-quote-argument
         (command-str (format "%s --no-session -p %s" command-name (shell-quote-argument prompt))))
    (with-current-buffer output-buffer
      (setq buffer-read-only nil)
      (erase-buffer)
      (setq-local header-line-format (format "AI Output for prompt: %s" prompt)))
    ;; (display-buffer output-buffer) ; Show the buffer immediately
    (message "AI command running asynchronously...")
    ;; Start the async process
    (let ((process (start-process "ai-process"
                                  output-buffer-name
                                  shell-program
                                  "-lc"
                                  command-str)))
      ;; Store command-name for sentinel
      (process-put process :command-name command-name)
      ;; Set a function to be called when the process finishes
      (set-process-sentinel process #'my/ai-process-sentinel))))

(defun my/ai-process-sentinel (process _event)
  "Sentinel for the ai async process. Handles success and error cases."
  (when (memq (process-status process) '(exit signal))
    (let* ((buffer (process-buffer process))
           (exit-code (process-exit-status process))
           (command-name (process-get process :command-name)))
      (cond
       ;; Case 1: Process failed (non-zero exit code)
       ((/= exit-code 0)
        (kill-buffer buffer)
        (if (= exit-code 127)
            (message "Error: '%s' command not found. Please ensure it's in your shell's PATH." command-name)
          (message "Error: AI command failed with exit code %d." exit-code)))

       ;; Case 2: Process succeeded but produced no output
       ((zerop (with-current-buffer buffer (buffer-size)))
        (kill-buffer buffer)
        (message "AI command finished with no output."))

       ;; Case 3: Success
       (t
        (with-current-buffer buffer
          (setq buffer-read-only t)
          (goto-char (point-min)))
        (display-buffer buffer)
        (message "AI command finished."))))))

Tags: Emacs