/** * tmux-fork — Fork 当前会话并在 tmux 中打开 * * /tmux-fork fork 当前会话,打开新 pi * /tmux-fork-gt 同上,但额外在 git worktree 中打开子 agent * /tmux-fork-gt-clean 列出并清理 gittree worktree * * 缓存复用:fork 会完整复制父会话的对话历史(system prompt + 历史轮次), * 保证发送给 LLM 的 prompt 前缀字节级一致。对于 GLM-5.2 等隐式缓存模型, * 这意味着子会话能命中父会话已建立的 prompt 缓存,避免重复 prefill。 * 注意:worktree 场景下 cwd 变化会破坏缓存,由 cwd-cache-fixer 扩展修复。 * * "tmuxFork": { * "mode": "new-window", // "new-window" = 整页新窗口(默认),"split-window" = 当前窗口分屏 * "closeOnExit": false // pi 退出后是否关闭窗口(默认 false,保留 shell) * } */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { SessionManager } from "@earendil-works/pi-coding-agent"; import { execSync, spawnSync } from "child_process"; import { existsSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { homedir } from "node:os"; import { fileURLToPath } from "node:url"; import { shEscape, parseWorktreePorcelain, parseLsofCwds, classifyLsofResult } from "./lib.mjs"; import type { WorktreeInfo } from "./lib.mjs"; // 本扩展所在目录,用于定位同目录的 gittree-bootstrap.mjs。 // 用 import.meta.url 定位,与安装位置无关(npm / git / 本地路径均可)。 const EXT_DIR = dirname(fileURLToPath(import.meta.url)); // ── 共享工具 ─────────────────────────────────────────────────────────── interface TmuxForkConfig { closeOnExit: boolean; mode: "new-window" | "split-window"; } /** 读取 ~/.pi/agent/settings.json 中的 tmuxFork 配置 */ function loadConfig(): TmuxForkConfig { const defaults: TmuxForkConfig = { closeOnExit: false, mode: "new-window" }; const path = join(homedir(), ".pi", "agent", "settings.json"); if (existsSync(path)) { try { const s = JSON.parse(readFileSync(path, "utf-8")); if (s.tmuxFork && typeof s.tmuxFork === "object") { return { closeOnExit: s.tmuxFork.closeOnExit === true, mode: s.tmuxFork.mode === "split-window" ? "split-window" : defaults.mode, }; } } catch (e) { // settings.json 解析失败时回退默认值,但打印警告让用户知道配置没生效 console.warn(`[tmux-fork] settings.json parse failed, using defaults: ${e}`); } } return defaults; } // shEscape、parseWorktreePorcelain、WorktreeInfo 类型见 ./lib.mjs(纯函数,便于单测) /** 列出当前 tmux 窗口的所有 pane id */ function listPanes(): string[] { const out = execSync("tmux list-panes -F '#{pane_id}'", { timeout: 2000 }) .toString() .trim(); return out ? out.split("\n") : []; } /** 开整页新窗口并执行命令(类似 cmd+t)。返回新窗口的 pane id。 */ function tmuxNewWindow(cwd: string, cmd: string): string { const escCwd = shEscape(cwd); const escCmd = shEscape(cmd); // -P 打印新 pane 信息,-F 指定格式,只取 pane id return execSync( `tmux new-window -P -F '#{pane_id}' -c "${escCwd}" bash -c "${escCmd}"`, { timeout: 3000 }, ) .toString() .trim(); } /** 在当前窗口分屏并执行命令。返回新 pane 的 pane id。 * - 仅一个 pane 时:右侧水平分屏 * - 多个 pane 时:在最后一个 pane 下方垂直分屏 */ function tmuxSplitPane(cwd: string, cmd: string): string { const panes = listPanes(); const escCwd = shEscape(cwd); const escCmd = shEscape(cmd); if (panes.length <= 1) { return execSync( `tmux split-window -P -F '#{pane_id}' -h -p 50 -c "${escCwd}" bash -c "${escCmd}"`, { timeout: 3000 }, ) .toString() .trim(); } const target = panes[panes.length - 1]; return execSync( `tmux split-window -P -F '#{pane_id}' -v -p 50 -t "${shEscape(target)}" -c "${escCwd}" bash -c "${escCmd}"`, { timeout: 3000 }, ) .toString() .trim(); } /** 根据 config.mode 决定开整页窗口或分屏。返回新 pane 的 id。 */ function tmuxOpenAndRun(cwd: string, cmd: string, config: TmuxForkConfig): string { if (config.mode === "new-window") { return tmuxNewWindow(cwd, cmd); } return tmuxSplitPane(cwd, cmd); } // ── 命令实现 ─────────────────────────────────────────────────────────── /** /tmux-fork:fork 当前会话,在 tmux 分屏打开 */ function registerTmuxFork(pi: ExtensionAPI): void { pi.registerCommand("tmux-fork", { description: "Fork current session and open in tmux split pane", handler: async (_args, ctx) => { if (!process.env.TMUX) { return ctx.ui.notify("Not inside tmux.", "error"); } const src = ctx.sessionManager.getSessionFile(); if (!src) { return ctx.ui.notify("No session file (ephemeral).", "error"); } const fork = SessionManager.forkFrom(src, ctx.cwd); const file = fork.getSessionFile(); ctx.ui.notify("Forked! Opening in tmux...", "info"); const config = loadConfig(); // 当前会话是 fork-gt 后代时(有 PI_CACHE_CWD),孙会话需接力该变量 // 并加载 cwd-cache-fixer,否则 system prompt cwd 会是 worktree 路径, // 与父会话缓存不一致。tmux new-window 不继承调用进程环境,需显式注入。 const cacheCwd = process.env.PI_CACHE_CWD; let piCmd: string; if (cacheCwd) { const fixerPath = join(EXT_DIR, "cwd-cache-fixer.ts"); piCmd = `env PI_CACHE_CWD="${shEscape(cacheCwd)}" ` + `pi --session "${file}" --extension "${shEscape(fixerPath)}"`; } else { piCmd = `pi --session "${file}"`; } const cmd = config.closeOnExit ? piCmd : `${piCmd}; exec $SHELL`; tmuxOpenAndRun(ctx.cwd, cmd, config); // pane id 此处不需要 }, }); } /** /tmux-fork-gt:fork 会话 + git worktree 隔离的子 agent */ function registerTmuxForkGt(pi: ExtensionAPI): void { pi.registerCommand("tmux-fork-gt", { description: "Fork + git worktree: opens a child agent that creates its own worktree", handler: async (desc, ctx) => { if (!desc?.trim()) { return ctx.ui.notify( "Usage: /tmux-fork-gt ", "error", ); } if (!process.env.TMUX) { return ctx.ui.notify("Not inside tmux.", "error"); } const src = ctx.sessionManager.getSessionFile(); if (!src) { return ctx.ui.notify("No session file (ephemeral).", "error"); } const taskDesc = desc.trim(); // 解析 git 根目录(用于设置 tmux pane 的初始 cwd) const root = execSync("git rev-parse --show-toplevel", { cwd: ctx.cwd, timeout: 3000, }) .toString() .trim(); // fork 当前会话,子 agent 继承完整对话前缀以复用 prompt 缓存 const sm = SessionManager.forkFrom(src, ctx.cwd); const file = sm.getSessionFile(); if (!file) { return ctx.ui.notify("Failed to create session", "error"); } // bootstrap 与本扩展同目录,用模块自身位置定位,与安装路径无关。 const bootstrap = join(EXT_DIR, "gittree-bootstrap.mjs"); const config = loadConfig(); ctx.ui.notify("Forking session to gittree workspace...", "info"); // bootstrap 负责:建 worktree、patch session cwd、加载 cwd-cache-fixer // 扩展、启动 pi。session 文件路径让 pi 继承历史。 const shCmd = `node "${shEscape(bootstrap)}" "${shEscape(file)}" ` + `"${shEscape(taskDesc)}"` + (config.closeOnExit ? "" : "; exec $SHELL"); // 在 tmux 中执行 bootstrap 脚本,拿到新 pane 的 id const paneId = tmuxOpenAndRun(ctx.cwd, shCmd, config); // best-effort:等待 pi 加载后向【新 pane】发 G 键滚动到底部,跳过历史回放。 // 精确发到新 pane id,避免 {last}(上一个活动 pane)指向用户已切走的窗口, // 把 G 键误发到用户正在输入的编辑器里。重试几次覆盖冷启动延迟;失败不影响功能。 const sendG = () => { try { execSync(`tmux send-keys -t "${shEscape(paneId)}" G`, { timeout: 1000, }); return true; } catch { return false; } }; setTimeout(() => { if (!sendG()) setTimeout(sendG, 1000); }, 1500); }, }); } /** 检测哪些 worktree 路径下有进程占用(cwd 在该路径下的进程)。 * 返回被占用的路径集合,以及 lsof 是否可用。 */ interface DetectResult { inUse: Set; /** false 表示 lsof 不可用,无法检测占用,调用方应提醒用户 */ lsofAvailable: boolean; } function detectInUseWorktrees(paths: string[]): DetectResult { if (paths.length === 0) return { inUse: new Set(), lsofAvailable: true }; const inUse = new Set(); // lsof 查 cwd 在任一 worktree 下的进程。 // -c 按进程名前缀匹配,多列几个常见开发工具,避免漏检导致误删。 const lsofArgs = [ "-a", "-d", "cwd", "-c", "pi", "-c", "node", "-c", "bash", "-c", "zsh", "-c", "python", "-c", "python3", "-c", "rg", "-c", "rg.exe", "-c", "code", "-c", "vim", "-c", "nvim", "-c", "emacs", "-Fn", ]; // 用 spawnSync 而非 execSync:lsof 退出码语义特殊,只要任一 -c 没匹配 // 到进程,整体 exit 1,但这不代表 lsof 不可用。用 classifyLsofResult // 按 stdout 是否有内容来判断可用性,不依赖退出码。 const res = spawnSync("lsof", lsofArgs, { timeout: 3000, stdio: ["ignore", "pipe", "ignore"], }); if (!classifyLsofResult(res)) { return { inUse, lsofAvailable: false }; } const stdout = typeof res.stdout === "string" ? res.stdout : (res.stdout ?? Buffer.alloc(0)).toString(); for (const cwd of parseLsofCwds(stdout)) { for (const p of paths) { if (cwd === p || cwd.startsWith(p + "/") || cwd.startsWith(p + "\\")) { inUse.add(p); break; } } } return { inUse, lsofAvailable: true }; } // WorktreeInfo 类型与 parseWorktreePorcelain 实现见 ./lib.mjs(纯函数,便于单测) interface GittreeList { trees: WorktreeInfo[]; /** false 表示 lsof 不可用,占用检测无效,调用方应提醒用户 */ lsofAvailable: boolean; } function listGittrees(root: string): GittreeList { let out: string; try { out = execSync(`git -C "${shEscape(root)}" worktree list --porcelain`, { timeout: 3000, }).toString(); } catch { return { trees: [], lsofAvailable: true }; } const trees = parseWorktreePorcelain(out).filter((t) => // 仅列出 .worktrees/gittree-[-task] /[/\\]\.worktrees[/\\]gittree-/.test(t.path), ); // 从路径回推 name:gittree--task 或 gittree- 中的 for (const t of trees) { const m = t.path.match(/gittree-(.+?)(?:-task)?$/); t.name = m ? m[1] : t.branch.replace(/^gittree-/, ""); } // 批量检测占用状态 const { inUse, lsofAvailable } = detectInUseWorktrees(trees.map((t) => t.path)); for (const t of trees) t.inUse = inUse.has(t.path); return { trees: trees.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })), lsofAvailable, }; } /** 删除单个 worktree 并返回是否成功 */ function removeWorktree(root: string, t: WorktreeInfo): boolean { try { execSync( `git -C "${shEscape(root)}" worktree remove --force "${shEscape(t.path)}"`, { timeout: 5000 }, ); return true; } catch { return false; } } /** 清理已失效的 worktree 元数据(git worktree prune)。失败忽略。 */ function pruneWorktrees(root: string): void { try { execSync(`git -C "${shEscape(root)}" worktree prune`, { timeout: 3000 }); } catch { /* prune 失败不影响主流程 */ } } /** /tmux-fork-gt-clean:交互式列出并清理 gittree worktree * * /tmux-fork-gt-clean 弹窗选择要删除的 worktree * /tmux-fork-gt-clean all 清理全部空闲 worktree(跳过 in-use) * /tmux-fork-gt-clean 直接清理指定名称(跳过弹窗) */ function registerTmuxForkGtClean(pi: ExtensionAPI): void { pi.registerCommand("tmux-fork-gt-clean", { description: "List or clean up gittree worktrees", handler: async (args, ctx) => { const root = execSync("git rev-parse --show-toplevel", { cwd: ctx.cwd, timeout: 3000, }).toString().trim(); const { trees, lsofAvailable } = listGittrees(root); if (trees.length === 0) { return ctx.ui.notify("No gittree worktrees.", "info"); } if (!lsofAvailable) { ctx.ui.notify( "lsof 不可用,无法检测 worktree 占用,清理时不会拦截正在使用的目录。", "warning", ); } const arg = args?.trim(); // all:批量清理所有空闲 worktree if (arg.toLowerCase() === "all") { const idle = trees.filter((t) => !t.inUse); const inUse = trees.filter((t) => t.inUse); if (idle.length === 0) { return ctx.ui.notify( `All ${trees.length} worktree(s) in use, nothing to clean.`, "warning", ); } const labels = idle.map((t) => t.branch).join(", "); const confirmed = await ctx.ui.confirm( "Clean all idle gittree worktrees", `Remove ${idle.length} idle worktree(s): ${labels}?` + (inUse.length > 0 ? `\n(${inUse.length} in use: ${inUse.map((t) => t.branch).join(", ")})` : ""), ); if (!confirmed) return ctx.ui.notify("Cancelled.", "info"); let removed = 0; let failed = 0; for (const t of idle) { if (removeWorktree(root, t)) removed++; else failed++; } pruneWorktrees(root); const msg = `Removed ${removed} worktree(s)` + (failed > 0 ? `, ${failed} failed` : ""); return ctx.ui.notify(msg, failed > 0 ? "warning" : "info"); } // 直接指定 name:跳过弹窗 if (arg) { const target = trees.find((t) => t.name.toLowerCase() === arg.toLowerCase()); if (!target) { return ctx.ui.notify(`No gittree worktree matching "${arg}".`, "error"); } if (target.inUse) { return ctx.ui.notify( `${target.branch} is in use, cannot remove.`, "warning", ); } const ok = await ctx.ui.confirm( "Clean gittree worktree", `Remove ${target.branch}?`, ); if (!ok) return ctx.ui.notify("Cancelled.", "info"); const success = removeWorktree(root, target); pruneWorktrees(root); return ctx.ui.notify( success ? `Removed ${target.branch}.` : `Failed to remove ${target.branch}.`, success ? "info" : "error", ); } const options = trees.map((t) => { const tags: string[] = []; if (t.inUse) tags.push("in use"); if (t.prunable) tags.push("prunable"); if (t.locked) tags.push("locked"); const tag = tags.length > 0 ? ` [${tags.join(", ")}]` : ""; return `${t.branch}${tag}`; }); options.push("[cancel]"); const choice = await ctx.ui.select( "Select worktree to remove", options, ); if (!choice || choice === "[cancel]") { return ctx.ui.notify("Cancelled.", "info"); } // 拦截:选到 in-use 的 const selected = trees.find((t) => choice.startsWith(t.branch)); if (selected?.inUse) { return ctx.ui.notify( `${selected.branch} is in use, cannot remove.`, "warning", ); } if (!selected) { return ctx.ui.notify("Invalid selection.", "error"); } const ok = await ctx.ui.confirm( "Clean gittree worktree", `Remove ${selected.branch}?`, ); if (!ok) return ctx.ui.notify("Cancelled.", "info"); const success = removeWorktree(root, selected); pruneWorktrees(root); ctx.ui.notify( success ? `Removed ${selected.branch}.` : `Failed to remove ${selected.branch}.`, success ? "info" : "error", ); }, }); } // ── 入口 ─────────────────────────────────────────────────────────────── // fork-gt 出的子 agent 会由 gittree-bootstrap.mjs 注入 PI_CACHE_CWD 环境变量。 // 检测到它即说明当前是 worktree 子 agent,禁用 /tmux-fork-gt 与 // /tmux-fork-gt-clean:子 agent 再 fork worktree 会嵌套混乱,且 clean // 按 git root 扫描所有 .worktrees/gittree-*,可能误删兄弟/父会话的 worktree。 // /tmux-fork(普通 fork,不开 worktree)保留,其 fork 出的会话无 PI_CACHE_CWD, // 仍可正常使用全部命令。 export default function (pi: ExtensionAPI): void { registerTmuxFork(pi); const isGittreeChild = !!process.env.PI_CACHE_CWD; if (isGittreeChild) { // 子 agent 不注册 gt / gt-clean,命令列表里也不会出现 return; } registerTmuxForkGt(pi); registerTmuxForkGtClean(pi); }