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 的配置。
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 expectsstring[]names such as"read"and"bash"instead ofTool[],--toolsnow allowlists built-in, extension, and custom tools by name, and--no-toolsnow disables all tools by default rather than only built-ins. Migrate SDK code fromtools: [readTool, bashTool]totools: ["read", "bash"](#2835, #3452)- Removed prebuilt cwd-bound tool and tool-definition exports from
@mariozechner/pi-coding-agent, includingreadTool,bashTool,editTool,writeTool,grepTool,findTool,lsTool,readOnlyTools,codingTools, and the corresponding*ToolDefinitionvalues. Use the explicit factory exports instead, for examplecreateReadTool(cwd),createBashTool(cwd),createCodingTools(cwd), andcreateReadToolDefinition(cwd)(#3452)- Removed ambient
process.cwd()/ default agent-dir fallback behavior from public resource helpers.DefaultResourceLoader,loadProjectContextFiles(), andloadSkills()now require explicit cwd/agent-dir style inputs, and exported system-prompt option types now require an explicitcwd. 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 都是很好的设计模板。
answer.ts: https://github.com/mitsuhiko/agent-stuff/blob/main/extensions/answer.ts
这个 extension 可以把 agent 的问题整理输出成一个统一的问答回复。
Claude Code 和 OpenCode 都内置了这个功能。
prompt-editor.ts: https://github.com/mitsuhiko/agent-stuff/blob/main/extensions/prompt-editor.ts
这个 extension 可以快速切换模型配置。
比如需要更多逻辑思考的时候用 gpt-5.4/thinking high,简单问答 gpt-5.4-nano/thinking off,编辑代码的时候 gpt-5.3-codex/thinking high。
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."))))))