From 109449f25051af93380bb21ef203e197178bdb0c Mon Sep 17 00:00:00 2001 From: joelazar Date: Sun, 7 Jun 2026 00:32:03 +0200 Subject: [PATCH] :recycle: refactor gondolin and export-md extensions with formatting and code organization --- dot_claude/settings.json | 7 +- dot_config/ghostty/config | 2 + .../{session-md => export-md}/index.ts | 31 +- dot_pi/agent/extensions/gondolin/index.ts | 973 ++++++++++-------- 4 files changed, 558 insertions(+), 455 deletions(-) rename dot_pi/agent/extensions/{session-md => export-md}/index.ts (86%) diff --git a/dot_claude/settings.json b/dot_claude/settings.json index b394d87d..5dc42bad 100644 --- a/dot_claude/settings.json +++ b/dot_claude/settings.json @@ -104,7 +104,8 @@ "enabledPlugins": { "frontend-design@claude-plugins-official": true, "code-review@claude-plugins-official": true, - "code-simplifier@claude-plugins-official": true + "code-simplifier@claude-plugins-official": true, + "playwright@claude-plugins-official": true }, "extraKnownMarketplaces": { "anthropic-agent-skills": { @@ -119,8 +120,8 @@ "autoUpdatesChannel": "stable", "showThinkingSummaries": true, "skipDangerousModePermissionPrompt": true, - "voiceEnabled": false, "theme": "dark-ansi", "preferredNotifChannel": "ghostty", - "fileCheckpointingEnabled": false + "fileCheckpointingEnabled": false, + "voiceEnabled": false } diff --git a/dot_config/ghostty/config b/dot_config/ghostty/config index 86700825..d352e2e6 100644 --- a/dot_config/ghostty/config +++ b/dot_config/ghostty/config @@ -117,6 +117,8 @@ keybind = super+shift+g=text:\x1b[103;6u keybind = super+shift+c=text:\x1b[99;6u # remap cmd+shift+e to ctrl+shift+e (CSI u: 101='e', 6=1+shift+ctrl) keybind = super+shift+e=text:\x1b[101;6u +# remap cmd+shift+m to ctrl+shift+m (CSI u: 109='m', 6=1+shift+ctrl) +keybind = super+shift+m=text:\x1b[109;6u # search entire scrollback history in default text editor keybind = super+shift+f=write_screen_file:open diff --git a/dot_pi/agent/extensions/session-md/index.ts b/dot_pi/agent/extensions/export-md/index.ts similarity index 86% rename from dot_pi/agent/extensions/session-md/index.ts rename to dot_pi/agent/extensions/export-md/index.ts index e83b7d79..aa0d8104 100644 --- a/dot_pi/agent/extensions/session-md/index.ts +++ b/dot_pi/agent/extensions/export-md/index.ts @@ -8,19 +8,27 @@ type Block = | { type: "text"; text: string } | { type: "thinking"; thinking: string } | { type: "image"; mimeType: string } - | { type: "toolCall"; id: string; name: string; arguments: Record }; + | { + type: "toolCall"; + id: string; + name: string; + arguments: Record; + }; type Content = string | Block[]; -const fence = (lang: string, body: string) => "```" + lang + "\n" + body.replace(/\n+$/, "") + "\n```"; +const fence = (lang: string, body: string) => + "```" + lang + "\n" + body.replace(/\n+$/, "") + "\n```"; const details = (summary: string, body: string) => `
\n${summary}\n\n${body}\n\n
`; const time = (ts: number | string | undefined) => { if (ts === undefined) return ""; - const d = typeof ts === "number" ? new Date(ts) : new Date(ts); - return Number.isNaN(d.getTime()) ? "" : d.toISOString().replace("T", " ").slice(0, 19); + const d = new Date(ts); + return Number.isNaN(d.getTime()) + ? "" + : d.toISOString().replace("T", " ").slice(0, 19); }; function renderContent(content: Content): string { @@ -32,7 +40,8 @@ function renderContent(content: Content): string { if (block.text.trim()) parts.push(block.text.trim()); break; case "thinking": - if (block.thinking.trim()) parts.push(details("💭 Thinking", block.thinking.trim())); + if (block.thinking.trim()) + parts.push(details("💭 Thinking", block.thinking.trim())); break; case "image": parts.push(`*(image: ${block.mimeType})*`); @@ -90,7 +99,10 @@ function renderEntry(entry: any): string | null { return null; } if (entry.type === "compaction") { - return details(`📦 Compaction (${entry.tokensBefore} tokens)`, entry.summary ?? ""); + return details( + `📦 Compaction (${entry.tokensBefore} tokens)`, + entry.summary ?? "", + ); } if (entry.type === "branch_summary") { return details("🌿 Branch summary", entry.summary ?? ""); @@ -160,6 +172,13 @@ export default function (pi: ExtensionAPI) { }, }); + pi.registerCommand("export-md", { + description: "Export current session to markdown and open in $EDITOR", + handler: async (_args, ctx) => { + await run(ctx); + }, + }); + pi.registerShortcut("ctrl+shift+m", { description: "Export session to markdown", handler: async (ctx) => { diff --git a/dot_pi/agent/extensions/gondolin/index.ts b/dot_pi/agent/extensions/gondolin/index.ts index ab050df7..3624fab8 100644 --- a/dot_pi/agent/extensions/gondolin/index.ts +++ b/dot_pi/agent/extensions/gondolin/index.ts @@ -21,511 +21,592 @@ import path from "node:path"; import { RealFSProvider, VM } from "@earendil-works/gondolin"; -import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import type { + ExtensionAPI, + ExtensionContext, +} from "@earendil-works/pi-coding-agent"; import { - type BashOperations, - createBashTool, - createEditTool, - createFindTool, - createGrepTool, - createLsTool, - createReadTool, - createWriteTool, - DEFAULT_MAX_BYTES, - type EditOperations, - type FindOperations, - formatSize, - type GrepToolDetails, - type GrepToolInput, - type LsOperations, - type ReadOperations, - truncateHead, - truncateLine, - type WriteOperations, + type BashOperations, + createBashTool, + createEditTool, + createFindTool, + createGrepTool, + createLsTool, + createReadTool, + createWriteTool, + DEFAULT_MAX_BYTES, + type EditOperations, + type FindOperations, + formatSize, + type GrepToolDetails, + type GrepToolInput, + type LsOperations, + type ReadOperations, + truncateHead, + truncateLine, + type WriteOperations, } from "@earendil-works/pi-coding-agent"; const GUEST_WORKSPACE = "/workspace"; const DEFAULT_GREP_LIMIT = 100; type TextToolResult = { - content: Array<{ type: "text"; text: string }>; - details: TDetails | undefined; + content: Array<{ type: "text"; text: string }>; + details: TDetails | undefined; }; function stripAtPrefix(value: string): string { - return value.startsWith("@") ? value.slice(1) : value; + return value.startsWith("@") ? value.slice(1) : value; } function toPosix(value: string): string { - return value.split(path.sep).join(path.posix.sep); + return value.split(path.sep).join(path.posix.sep); } function isInsideHostPath(root: string, value: string): boolean { - const relativePath = path.relative(root, value); - return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); + const relativePath = path.relative(root, value); + return ( + relativePath === "" || + (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)) + ); } function hostPathToGuest(localCwd: string, hostPath: string): string { - const relativePath = path.relative(localCwd, hostPath); - if (!isInsideHostPath(localCwd, hostPath)) return toPosix(hostPath); - return relativePath ? path.posix.join(GUEST_WORKSPACE, toPosix(relativePath)) : GUEST_WORKSPACE; + const relativePath = path.relative(localCwd, hostPath); + if (!isInsideHostPath(localCwd, hostPath)) return toPosix(hostPath); + return relativePath + ? path.posix.join(GUEST_WORKSPACE, toPosix(relativePath)) + : GUEST_WORKSPACE; } function toGuestPath(localCwd: string, inputPath: string): string { - const trimmed = stripAtPrefix(inputPath.trim()); - if (!trimmed) return GUEST_WORKSPACE; - if (path.isAbsolute(trimmed)) { - if (isInsideHostPath(localCwd, trimmed)) return hostPathToGuest(localCwd, trimmed); - return path.posix.resolve("/", toPosix(trimmed)); - } - return path.posix.resolve(GUEST_WORKSPACE, toPosix(trimmed)); + const trimmed = stripAtPrefix(inputPath.trim()); + if (!trimmed) return GUEST_WORKSPACE; + if (path.isAbsolute(trimmed)) { + if (isInsideHostPath(localCwd, trimmed)) + return hostPathToGuest(localCwd, trimmed); + return path.posix.resolve("/", toPosix(trimmed)); + } + return path.posix.resolve(GUEST_WORKSPACE, toPosix(trimmed)); } function createGondolinReadOps(vm: VM, localCwd: string): ReadOperations { - return { - readFile: async (filePath) => vm.fs.readFile(toGuestPath(localCwd, filePath)), - access: async (filePath) => { - await vm.fs.access(toGuestPath(localCwd, filePath)); - }, - detectImageMimeType: async (filePath) => { - const ext = path.posix.extname(toGuestPath(localCwd, filePath)).toLowerCase(); - if (ext === ".png") return "image/png"; - if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg"; - if (ext === ".gif") return "image/gif"; - if (ext === ".webp") return "image/webp"; - return null; - }, - }; + return { + readFile: async (filePath) => + vm.fs.readFile(toGuestPath(localCwd, filePath)), + access: async (filePath) => { + await vm.fs.access(toGuestPath(localCwd, filePath)); + }, + detectImageMimeType: async (filePath) => { + const ext = path.posix + .extname(toGuestPath(localCwd, filePath)) + .toLowerCase(); + if (ext === ".png") return "image/png"; + if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg"; + if (ext === ".gif") return "image/gif"; + if (ext === ".webp") return "image/webp"; + return null; + }, + }; } function createGondolinWriteOps(vm: VM, localCwd: string): WriteOperations { - return { - writeFile: async (filePath, content) => { - await vm.fs.writeFile(toGuestPath(localCwd, filePath), content, { encoding: "utf8" }); - }, - mkdir: async (dirPath) => { - await vm.fs.mkdir(toGuestPath(localCwd, dirPath), { recursive: true }); - }, - }; + return { + writeFile: async (filePath, content) => { + await vm.fs.writeFile(toGuestPath(localCwd, filePath), content, { + encoding: "utf8", + }); + }, + mkdir: async (dirPath) => { + await vm.fs.mkdir(toGuestPath(localCwd, dirPath), { recursive: true }); + }, + }; } function createGondolinEditOps(vm: VM, localCwd: string): EditOperations { - const readOps = createGondolinReadOps(vm, localCwd); - const writeOps = createGondolinWriteOps(vm, localCwd); - return { - readFile: readOps.readFile, - writeFile: writeOps.writeFile, - access: readOps.access, - }; + const readOps = createGondolinReadOps(vm, localCwd); + const writeOps = createGondolinWriteOps(vm, localCwd); + return { + readFile: readOps.readFile, + writeFile: writeOps.writeFile, + access: readOps.access, + }; +} + +async function guestExists( + vm: VM, + localCwd: string, + filePath: string, +): Promise { + try { + await vm.fs.access(toGuestPath(localCwd, filePath)); + return true; + } catch { + return false; + } } function createGondolinLsOps(vm: VM, localCwd: string): LsOperations { - return { - exists: async (filePath) => { - try { - await vm.fs.access(toGuestPath(localCwd, filePath)); - return true; - } catch { - return false; - } - }, - stat: async (filePath) => vm.fs.stat(toGuestPath(localCwd, filePath)), - readdir: async (dirPath) => vm.fs.listDir(toGuestPath(localCwd, dirPath)), - }; + return { + exists: async (filePath) => guestExists(vm, localCwd, filePath), + stat: async (filePath) => vm.fs.stat(toGuestPath(localCwd, filePath)), + readdir: async (dirPath) => vm.fs.listDir(toGuestPath(localCwd, dirPath)), + }; } async function walkGuestFiles( - vm: VM, - root: string, - visit: (guestPath: string, relativePath: string) => Promise, - signal?: AbortSignal, + vm: VM, + root: string, + visit: (guestPath: string, relativePath: string) => Promise, + signal?: AbortSignal, ): Promise { - if (signal?.aborted) throw new Error("Operation aborted"); - const stat = await vm.fs.stat(root, { signal }); - if (!stat.isDirectory()) return visit(root, path.posix.basename(root)); - - const walkDirectory = async (dir: string, relativeDir: string): Promise => { - if (signal?.aborted) throw new Error("Operation aborted"); - const entries = await vm.fs.listDir(dir, { signal }); - for (const entry of entries) { - if (entry === ".git" || entry === "node_modules") continue; - const guestPath = path.posix.join(dir, entry); - const relativePath = relativeDir ? path.posix.join(relativeDir, entry) : entry; - let entryStat: Awaited>; - try { - entryStat = await vm.fs.stat(guestPath, { signal }); - } catch { - continue; - } - if (entryStat.isDirectory()) { - if (!(await walkDirectory(guestPath, relativePath))) return false; - } else if (!(await visit(guestPath, relativePath))) { - return false; - } - } - return true; - }; - - return walkDirectory(root, ""); + if (signal?.aborted) throw new Error("Operation aborted"); + const stat = await vm.fs.stat(root, { signal }); + if (!stat.isDirectory()) return visit(root, path.posix.basename(root)); + + const walkDirectory = async ( + dir: string, + relativeDir: string, + ): Promise => { + if (signal?.aborted) throw new Error("Operation aborted"); + const entries = await vm.fs.listDir(dir, { signal }); + for (const entry of entries) { + if (entry === ".git" || entry === "node_modules") continue; + const guestPath = path.posix.join(dir, entry); + const relativePath = relativeDir + ? path.posix.join(relativeDir, entry) + : entry; + let entryStat: Awaited>; + try { + entryStat = await vm.fs.stat(guestPath, { signal }); + } catch { + continue; + } + if (entryStat.isDirectory()) { + if (!(await walkDirectory(guestPath, relativePath))) return false; + } else if (!(await visit(guestPath, relativePath))) { + return false; + } + } + return true; + }; + + return walkDirectory(root, ""); } function matchesToolGlob(relativePath: string, pattern: string): boolean { - const normalizedPattern = toPosix(pattern); - if (normalizedPattern.includes("/")) { - return ( - path.posix.matchesGlob(relativePath, normalizedPattern) || - path.posix.matchesGlob(relativePath, `**/${normalizedPattern}`) - ); - } - return path.posix.matchesGlob(path.posix.basename(relativePath), normalizedPattern); + const normalizedPattern = toPosix(pattern); + if (normalizedPattern.includes("/")) { + return ( + path.posix.matchesGlob(relativePath, normalizedPattern) || + path.posix.matchesGlob(relativePath, `**/${normalizedPattern}`) + ); + } + return path.posix.matchesGlob( + path.posix.basename(relativePath), + normalizedPattern, + ); } function createGondolinFindOps(vm: VM, localCwd: string): FindOperations { - return { - exists: async (filePath) => { - try { - await vm.fs.access(toGuestPath(localCwd, filePath)); - return true; - } catch { - return false; - } - }, - glob: async (pattern, cwd, options) => { - const root = toGuestPath(localCwd, cwd); - const results: string[] = []; - await walkGuestFiles(vm, root, async (guestPath, relativePath) => { - if (results.length >= options.limit) return false; - if (matchesToolGlob(relativePath, pattern)) results.push(guestPath); - return results.length < options.limit; - }); - return results; - }, - }; + return { + exists: async (filePath) => guestExists(vm, localCwd, filePath), + glob: async (pattern, cwd, options) => { + const root = toGuestPath(localCwd, cwd); + const results: string[] = []; + await walkGuestFiles(vm, root, async (guestPath, relativePath) => { + if (results.length >= options.limit) return false; + if (matchesToolGlob(relativePath, pattern)) results.push(guestPath); + return results.length < options.limit; + }); + return results; + }, + }; } -function createLineMatcher(pattern: string, literal: boolean | undefined, ignoreCase: boolean | undefined) { - if (literal) { - const needle = ignoreCase ? pattern.toLowerCase() : pattern; - return (line: string) => (ignoreCase ? line.toLowerCase() : line).includes(needle); - } - const regex = new RegExp(pattern, ignoreCase ? "i" : undefined); - return (line: string) => regex.test(line); +function createLineMatcher( + pattern: string, + literal: boolean | undefined, + ignoreCase: boolean | undefined, +) { + if (literal) { + const needle = ignoreCase ? pattern.toLowerCase() : pattern; + return (line: string) => + (ignoreCase ? line.toLowerCase() : line).includes(needle); + } + const regex = new RegExp(pattern, ignoreCase ? "i" : undefined); + return (line: string) => regex.test(line); } function appendGrepBlock(params: { - outputLines: string[]; - lines: string[]; - relativePath: string; - lineIndex: number; - contextLines: number; + outputLines: string[]; + lines: string[]; + relativePath: string; + lineIndex: number; + contextLines: number; }): boolean { - let linesTruncated = false; - const start = params.contextLines > 0 ? Math.max(0, params.lineIndex - params.contextLines) : params.lineIndex; - const end = - params.contextLines > 0 - ? Math.min(params.lines.length - 1, params.lineIndex + params.contextLines) - : params.lineIndex; - - for (let index = start; index <= end; index++) { - const rawLine = params.lines[index] ?? ""; - const { text, wasTruncated } = truncateLine(rawLine.replace(/\r/g, "")); - if (wasTruncated) linesTruncated = true; - const separator = index === params.lineIndex ? ":" : "-"; - params.outputLines.push(`${params.relativePath}${separator}${index + 1}${separator} ${text}`); - } - return linesTruncated; + let linesTruncated = false; + const start = + params.contextLines > 0 + ? Math.max(0, params.lineIndex - params.contextLines) + : params.lineIndex; + const end = + params.contextLines > 0 + ? Math.min( + params.lines.length - 1, + params.lineIndex + params.contextLines, + ) + : params.lineIndex; + + for (let index = start; index <= end; index++) { + const rawLine = params.lines[index] ?? ""; + const { text, wasTruncated } = truncateLine(rawLine.replace(/\r/g, "")); + if (wasTruncated) linesTruncated = true; + const separator = index === params.lineIndex ? ":" : "-"; + params.outputLines.push( + `${params.relativePath}${separator}${index + 1}${separator} ${text}`, + ); + } + return linesTruncated; } async function executeGondolinGrep( - vm: VM, - localCwd: string, - params: GrepToolInput, - signal?: AbortSignal, + vm: VM, + localCwd: string, + params: GrepToolInput, + signal?: AbortSignal, ): Promise> { - const root = toGuestPath(localCwd, params.path ?? "."); - const rootStat = await vm.fs.stat(root, { signal }); - const rootIsDirectory = rootStat.isDirectory(); - const matcher = createLineMatcher(params.pattern, params.literal, params.ignoreCase); - const contextLines = params.context && params.context > 0 ? params.context : 0; - const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT); - const outputLines: string[] = []; - const details: GrepToolDetails = {}; - let matchCount = 0; - let matchLimitReached = false; - let linesTruncated = false; - - await walkGuestFiles( - vm, - root, - async (guestPath, relativePath) => { - if (matchCount >= effectiveLimit) return false; - if (params.glob && !matchesToolGlob(relativePath, params.glob)) return true; - let content: string; - try { - content = await vm.fs.readFile(guestPath, { encoding: "utf8", signal }); - } catch { - return true; - } - const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); - const displayPath = rootIsDirectory ? relativePath : path.posix.basename(guestPath); - for (let index = 0; index < lines.length; index++) { - if (signal?.aborted) throw new Error("Operation aborted"); - if (!matcher(lines[index] ?? "")) continue; - matchCount++; - if (appendGrepBlock({ outputLines, lines, relativePath: displayPath, lineIndex: index, contextLines })) { - linesTruncated = true; - } - if (matchCount >= effectiveLimit) { - matchLimitReached = true; - return false; - } - } - return true; - }, - signal, - ); - - if (matchCount === 0) return { content: [{ type: "text", text: "No matches found" }], details: undefined }; - - const rawOutput = outputLines.join("\n"); - const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); - const notices: string[] = []; - let output = truncation.content; - - if (matchLimitReached) { - details.matchLimitReached = effectiveLimit; - notices.push(`${effectiveLimit} matches limit reached`); - } - if (linesTruncated) { - details.linesTruncated = true; - notices.push("long lines truncated"); - } - if (truncation.truncated) { - details.truncation = truncation; - notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); - } - if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`; - - return { - content: [{ type: "text", text: output }], - details: Object.keys(details).length > 0 ? details : undefined, - }; + const root = toGuestPath(localCwd, params.path ?? "."); + const rootStat = await vm.fs.stat(root, { signal }); + const rootIsDirectory = rootStat.isDirectory(); + const matcher = createLineMatcher( + params.pattern, + params.literal, + params.ignoreCase, + ); + const contextLines = + params.context && params.context > 0 ? params.context : 0; + const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT); + const outputLines: string[] = []; + const details: GrepToolDetails = {}; + let matchCount = 0; + let matchLimitReached = false; + let linesTruncated = false; + + await walkGuestFiles( + vm, + root, + async (guestPath, relativePath) => { + if (matchCount >= effectiveLimit) return false; + if (params.glob && !matchesToolGlob(relativePath, params.glob)) + return true; + let content: string; + try { + content = await vm.fs.readFile(guestPath, { encoding: "utf8", signal }); + } catch { + return true; + } + const lines = content + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .split("\n"); + const displayPath = rootIsDirectory + ? relativePath + : path.posix.basename(guestPath); + for (let index = 0; index < lines.length; index++) { + if (signal?.aborted) throw new Error("Operation aborted"); + if (!matcher(lines[index] ?? "")) continue; + matchCount++; + if ( + appendGrepBlock({ + outputLines, + lines, + relativePath: displayPath, + lineIndex: index, + contextLines, + }) + ) { + linesTruncated = true; + } + if (matchCount >= effectiveLimit) { + matchLimitReached = true; + return false; + } + } + return true; + }, + signal, + ); + + if (matchCount === 0) + return { + content: [{ type: "text", text: "No matches found" }], + details: undefined, + }; + + const rawOutput = outputLines.join("\n"); + const truncation = truncateHead(rawOutput, { + maxLines: Number.MAX_SAFE_INTEGER, + }); + const notices: string[] = []; + let output = truncation.content; + + if (matchLimitReached) { + details.matchLimitReached = effectiveLimit; + notices.push(`${effectiveLimit} matches limit reached`); + } + if (linesTruncated) { + details.linesTruncated = true; + notices.push("long lines truncated"); + } + if (truncation.truncated) { + details.truncation = truncation; + notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); + } + if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`; + + return { + content: [{ type: "text", text: output }], + details: Object.keys(details).length > 0 ? details : undefined, + }; } -function sanitizeEnv(env: NodeJS.ProcessEnv | undefined): Record | undefined { - if (!env) return undefined; - const result: Record = {}; - for (const [key, value] of Object.entries(env)) { - if (typeof value === "string") result[key] = value; - } - return result; +function sanitizeEnv( + env: NodeJS.ProcessEnv | undefined, +): Record | undefined { + if (!env) return undefined; + const result: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (typeof value === "string") result[key] = value; + } + return result; } -function createGondolinBashOps(vm: VM, localCwd: string, shellPath: string): BashOperations { - return { - exec: async (command, cwd, { onData, signal, timeout, env }) => { - if (signal?.aborted) throw new Error("aborted"); - const guestCwd = toGuestPath(localCwd, cwd); - const controller = new AbortController(); - const onAbort = () => controller.abort(); - signal?.addEventListener("abort", onAbort, { once: true }); - - let timedOut = false; - const timer = - timeout && timeout > 0 - ? setTimeout(() => { - timedOut = true; - controller.abort(); - }, timeout * 1000) - : undefined; - - try { - const proc = vm.exec([shellPath, "-lc", command], { - cwd: guestCwd, - env: sanitizeEnv(env), - signal: controller.signal, - stdout: "pipe", - stderr: "pipe", - }); - for await (const chunk of proc.output()) onData(chunk.data); - const result = await proc; - return { exitCode: result.exitCode }; - } catch (error) { - if (signal?.aborted) throw new Error("aborted"); - if (timedOut) throw new Error(`timeout:${timeout}`); - throw error; - } finally { - if (timer) clearTimeout(timer); - signal?.removeEventListener("abort", onAbort); - } - }, - }; +function createGondolinBashOps( + vm: VM, + localCwd: string, + shellPath: string, +): BashOperations { + return { + exec: async (command, cwd, { onData, signal, timeout, env }) => { + if (signal?.aborted) throw new Error("aborted"); + const guestCwd = toGuestPath(localCwd, cwd); + const controller = new AbortController(); + const onAbort = () => controller.abort(); + signal?.addEventListener("abort", onAbort, { once: true }); + + let timedOut = false; + const timer = + timeout && timeout > 0 + ? setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeout * 1000) + : undefined; + + try { + const proc = vm.exec([shellPath, "-lc", command], { + cwd: guestCwd, + env: sanitizeEnv(env), + signal: controller.signal, + stdout: "pipe", + stderr: "pipe", + }); + for await (const chunk of proc.output()) onData(chunk.data); + const result = await proc; + return { exitCode: result.exitCode }; + } catch (error) { + if (signal?.aborted) throw new Error("aborted"); + if (timedOut) throw new Error(`timeout:${timeout}`); + throw error; + } finally { + if (timer) clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + } + }, + }; } export default function (pi: ExtensionAPI) { - const localCwd = process.cwd(); - const localRead = createReadTool(localCwd); - const localWrite = createWriteTool(localCwd); - const localEdit = createEditTool(localCwd); - const localBash = createBashTool(localCwd); - const localGrep = createGrepTool(localCwd); - const localFind = createFindTool(localCwd); - const localLs = createLsTool(localCwd); - - let vm: VM | undefined; - let vmStarting: Promise | undefined; - let shellPath = "/bin/sh"; - - async function startVm(ctx?: ExtensionContext): Promise { - ctx?.ui.setStatus("gondolin", ctx.ui.theme.fg("accent", `Gondolin: starting ${GUEST_WORKSPACE}`)); - const created = await VM.create({ - sessionLabel: `pi ${path.basename(localCwd)}`, - vfs: { - mounts: { - [GUEST_WORKSPACE]: new RealFSProvider(localCwd), - }, - }, - }); - const bashProbe = await created.exec(["/bin/sh", "-lc", "command -v bash || true"]); - shellPath = bashProbe.stdout.trim() || "/bin/sh"; - vm = created; - ctx?.ui.setStatus( - "gondolin", - ctx.ui.theme.fg("accent", `Gondolin: ${created.id.slice(0, 8)} (${GUEST_WORKSPACE})`), - ); - ctx?.ui.notify(`Gondolin VM ready. ${localCwd} is mounted at ${GUEST_WORKSPACE}.`, "info"); - return created; - } - - async function ensureVm(ctx?: ExtensionContext): Promise { - if (vm) return vm; - if (!vmStarting) { - vmStarting = startVm(ctx).finally(() => { - vmStarting = undefined; - }); - } - return vmStarting; - } - - pi.on("session_start", async (_event, ctx) => { - await ensureVm(ctx); - }); - - pi.on("session_shutdown", async (_event, ctx) => { - const activeVm = vm; - vm = undefined; - vmStarting = undefined; - if (!activeVm) return; - ctx.ui.setStatus("gondolin", ctx.ui.theme.fg("muted", "Gondolin: stopping")); - try { - await activeVm.close(); - } finally { - ctx.ui.setStatus("gondolin", undefined); - } - }); - - pi.registerCommand("gondolin", { - description: "Show Gondolin VM status", - handler: async (_args, ctx) => { - const activeVm = await ensureVm(ctx); - ctx.ui.notify( - [ - `Gondolin VM: ${activeVm.id}`, - `Host workspace: ${localCwd}`, - `Guest workspace: ${GUEST_WORKSPACE}`, - `Shell: ${shellPath}`, - ].join("\n"), - "info", - ); - }, - }); - - pi.registerTool({ - ...localRead, - async execute(id, params, signal, onUpdate, ctx) { - const activeVm = await ensureVm(ctx); - const tool = createReadTool(GUEST_WORKSPACE, { - operations: createGondolinReadOps(activeVm, localCwd), - }); - return tool.execute(id, params, signal, onUpdate); - }, - }); - - pi.registerTool({ - ...localWrite, - async execute(id, params, signal, onUpdate, ctx) { - const activeVm = await ensureVm(ctx); - const tool = createWriteTool(GUEST_WORKSPACE, { - operations: createGondolinWriteOps(activeVm, localCwd), - }); - return tool.execute(id, params, signal, onUpdate); - }, - }); - - pi.registerTool({ - ...localEdit, - async execute(id, params, signal, onUpdate, ctx) { - const activeVm = await ensureVm(ctx); - const tool = createEditTool(GUEST_WORKSPACE, { - operations: createGondolinEditOps(activeVm, localCwd), - }); - return tool.execute(id, params, signal, onUpdate); - }, - }); - - pi.registerTool({ - ...localBash, - async execute(id, params, signal, onUpdate, ctx) { - const activeVm = await ensureVm(ctx); - const tool = createBashTool(GUEST_WORKSPACE, { - operations: createGondolinBashOps(activeVm, localCwd, shellPath), - }); - return tool.execute(id, params, signal, onUpdate); - }, - }); - - pi.registerTool({ - ...localLs, - async execute(id, params, signal, onUpdate, ctx) { - const activeVm = await ensureVm(ctx); - const tool = createLsTool(GUEST_WORKSPACE, { - operations: createGondolinLsOps(activeVm, localCwd), - }); - return tool.execute(id, params, signal, onUpdate); - }, - }); - - pi.registerTool({ - ...localFind, - async execute(id, params, signal, onUpdate, ctx) { - const activeVm = await ensureVm(ctx); - const tool = createFindTool(GUEST_WORKSPACE, { - operations: createGondolinFindOps(activeVm, localCwd), - }); - return tool.execute(id, params, signal, onUpdate); - }, - }); - - pi.registerTool({ - ...localGrep, - async execute(_id, params, signal, _onUpdate, ctx) { - const activeVm = await ensureVm(ctx); - return executeGondolinGrep(activeVm, localCwd, params, signal); - }, - }); - - pi.on("user_bash", async (_event, ctx) => { - const activeVm = await ensureVm(ctx); - return { operations: createGondolinBashOps(activeVm, localCwd, shellPath) }; - }); - - pi.on("before_agent_start", async (event, ctx) => { - await ensureVm(ctx); - const localLine = `Current working directory: ${localCwd}`; - const guestLine = `Current working directory: ${GUEST_WORKSPACE} (Gondolin VM; host workspace mounted from ${localCwd})`; - const systemPrompt = event.systemPrompt.includes(localLine) - ? event.systemPrompt.replace(localLine, guestLine) - : `${event.systemPrompt}\n\n${guestLine}`; - return { systemPrompt }; - }); + const localCwd = process.cwd(); + const localRead = createReadTool(localCwd); + const localWrite = createWriteTool(localCwd); + const localEdit = createEditTool(localCwd); + const localBash = createBashTool(localCwd); + const localGrep = createGrepTool(localCwd); + const localFind = createFindTool(localCwd); + const localLs = createLsTool(localCwd); + + let vm: VM | undefined; + let vmStarting: Promise | undefined; + let shellPath = "/bin/sh"; + + async function startVm(ctx?: ExtensionContext): Promise { + ctx?.ui.setStatus( + "gondolin", + ctx.ui.theme.fg("accent", `Gondolin: starting ${GUEST_WORKSPACE}`), + ); + const created = await VM.create({ + sessionLabel: `pi ${path.basename(localCwd)}`, + vfs: { + mounts: { + [GUEST_WORKSPACE]: new RealFSProvider(localCwd), + }, + }, + }); + const bashProbe = await created.exec([ + "/bin/sh", + "-lc", + "command -v bash || true", + ]); + shellPath = bashProbe.stdout.trim() || "/bin/sh"; + vm = created; + ctx?.ui.setStatus( + "gondolin", + ctx.ui.theme.fg( + "accent", + `Gondolin: ${created.id.slice(0, 8)} (${GUEST_WORKSPACE})`, + ), + ); + ctx?.ui.notify( + `Gondolin VM ready. ${localCwd} is mounted at ${GUEST_WORKSPACE}.`, + "info", + ); + return created; + } + + async function ensureVm(ctx?: ExtensionContext): Promise { + if (vm) return vm; + if (!vmStarting) { + vmStarting = startVm(ctx).finally(() => { + vmStarting = undefined; + }); + } + return vmStarting; + } + + pi.on("session_start", async (_event, ctx) => { + await ensureVm(ctx); + }); + + pi.on("session_shutdown", async (_event, ctx) => { + const activeVm = vm; + vm = undefined; + vmStarting = undefined; + if (!activeVm) return; + ctx.ui.setStatus( + "gondolin", + ctx.ui.theme.fg("muted", "Gondolin: stopping"), + ); + try { + await activeVm.close(); + } finally { + ctx.ui.setStatus("gondolin", undefined); + } + }); + + pi.registerCommand("gondolin", { + description: "Show Gondolin VM status", + handler: async (_args, ctx) => { + const activeVm = await ensureVm(ctx); + ctx.ui.notify( + [ + `Gondolin VM: ${activeVm.id}`, + `Host workspace: ${localCwd}`, + `Guest workspace: ${GUEST_WORKSPACE}`, + `Shell: ${shellPath}`, + ].join("\n"), + "info", + ); + }, + }); + + pi.registerTool({ + ...localRead, + async execute(id, params, signal, onUpdate, ctx) { + const activeVm = await ensureVm(ctx); + const tool = createReadTool(GUEST_WORKSPACE, { + operations: createGondolinReadOps(activeVm, localCwd), + }); + return tool.execute(id, params, signal, onUpdate); + }, + }); + + pi.registerTool({ + ...localWrite, + async execute(id, params, signal, onUpdate, ctx) { + const activeVm = await ensureVm(ctx); + const tool = createWriteTool(GUEST_WORKSPACE, { + operations: createGondolinWriteOps(activeVm, localCwd), + }); + return tool.execute(id, params, signal, onUpdate); + }, + }); + + pi.registerTool({ + ...localEdit, + async execute(id, params, signal, onUpdate, ctx) { + const activeVm = await ensureVm(ctx); + const tool = createEditTool(GUEST_WORKSPACE, { + operations: createGondolinEditOps(activeVm, localCwd), + }); + return tool.execute(id, params, signal, onUpdate); + }, + }); + + pi.registerTool({ + ...localBash, + async execute(id, params, signal, onUpdate, ctx) { + const activeVm = await ensureVm(ctx); + const tool = createBashTool(GUEST_WORKSPACE, { + operations: createGondolinBashOps(activeVm, localCwd, shellPath), + }); + return tool.execute(id, params, signal, onUpdate); + }, + }); + + pi.registerTool({ + ...localLs, + async execute(id, params, signal, onUpdate, ctx) { + const activeVm = await ensureVm(ctx); + const tool = createLsTool(GUEST_WORKSPACE, { + operations: createGondolinLsOps(activeVm, localCwd), + }); + return tool.execute(id, params, signal, onUpdate); + }, + }); + + pi.registerTool({ + ...localFind, + async execute(id, params, signal, onUpdate, ctx) { + const activeVm = await ensureVm(ctx); + const tool = createFindTool(GUEST_WORKSPACE, { + operations: createGondolinFindOps(activeVm, localCwd), + }); + return tool.execute(id, params, signal, onUpdate); + }, + }); + + pi.registerTool({ + ...localGrep, + async execute(_id, params, signal, _onUpdate, ctx) { + const activeVm = await ensureVm(ctx); + return executeGondolinGrep(activeVm, localCwd, params, signal); + }, + }); + + pi.on("user_bash", async (_event, ctx) => { + const activeVm = await ensureVm(ctx); + return { operations: createGondolinBashOps(activeVm, localCwd, shellPath) }; + }); + + pi.on("before_agent_start", async (event, ctx) => { + await ensureVm(ctx); + const localLine = `Current working directory: ${localCwd}`; + const guestLine = `Current working directory: ${GUEST_WORKSPACE} (Gondolin VM; host workspace mounted from ${localCwd})`; + const systemPrompt = event.systemPrompt.includes(localLine) + ? event.systemPrompt.replace(localLine, guestLine) + : `${event.systemPrompt}\n\n${guestLine}`; + return { systemPrompt }; + }); } -- 2.51.2