import { Type } from "typebox"; import { DEFAULT_MAX_BYTES, formatSize, keyHint, truncateHead, type ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { spawn, type ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; interface Segment { kind: SegmentKind; text: string; prompt: string | undefined; } const enum SegmentKind { GdbOutput, GdbError, GdbCommand, Error, Info, } const TIMEOUT_COMMAND = 2_000; const TIMEOUT_COMMAND_CONTINUE = 100; const TIMEOUT_INTERRUPT = 5_000; const TIMEOUT_LAUNCH = 5_000; class GDB { private readonly promptUuid: string; private promptCurrent: string; private readonly child: ChildProcess; private stdoutBuffer = ""; private stderrBuffer = ""; private outBuffer: Segment[] = []; private ready = false; private readonly events = new EventEmitter; private piUpdate: any = null; private closed = false; private constructor(gdbPath: string) { this.promptUuid = randomUUID(); this.promptCurrent = this.promptUuid; this.child = spawn(gdbPath, [ "--quiet", "-ex", "set pagination off", "-ex", "set confirm off", "-ex", `set prompt ${this.promptUuid}`, ]); this.child.on("close", (code: number | null, signal: string | null) => { this.pushOutput(SegmentKind.GdbOutput, this.stdoutBuffer); this.pushOutput(SegmentKind.GdbError, this.stderrBuffer); if (code !== null) this.pushOutput(code != 0 ? SegmentKind.Error : SegmentKind.Info, `GDB exited with code ${code}.`); else this.pushOutput(SegmentKind.Error, `GDB terminated by signal ${signal}.`); this.closed = true; this.events.emit("readyOrClosed"); }); this.child.stdout.on("data", chunk => { this.stdoutBuffer += chunk.toString(); while (true) { const idx = this.stdoutBuffer.indexOf(this.promptCurrent); if (idx === -1) break; const before = this.stdoutBuffer.slice(0, idx); const after = this.stdoutBuffer.slice(idx + this.promptCurrent.length); this.pushOutput(SegmentKind.GdbOutput, before); this.stdoutBuffer = after; this.ready = true; this.events.emit("readyOrClosed"); } }); this.child.stderr.on("data", chunk => { this.stderrBuffer += chunk.toString(); while (true) { const idx = this.stderrBuffer.indexOf("\n"); if (idx === -1) break; const before = this.stderrBuffer.slice(0, idx); const after = this.stderrBuffer.slice(idx + "\n".length); this.pushOutput(SegmentKind.GdbError, before); this.stderrBuffer = after; } }); } static async spawn(gdbPath: string): Promise { const gdb = new GDB(gdbPath); const { timedOut } = await gdb.waitForPrompt(TIMEOUT_LAUNCH); if (gdb.closed) gdb.criticalError(null); if (timedOut) gdb.criticalError("GDB launch timed out. There is no way to recover, so sent SIGKILL to GDB."); return gdb; } async runToolCall(commands: string[], onUpdate): Promise { this.piUpdate = onUpdate; try { await this.runCommands(commands); } finally { this.piUpdate = null; } return this.popOutput(); } private async runCommands(commands: string[]): Promise { await this.interruptIfNotReady(); if (this.closed) return; for (let i = 0; i < commands.length; i++) { const cmd = commands[i]; this.pushOutput(SegmentKind.GdbCommand, cmd); this.ready = false; await new Promise(resolve => this.child.stdin.write(`${cmd}\n`, resolve)); if (cmd == "commands" && !this.promptCurrent.endsWith(">")) this.promptCurrent = ">"; else if (cmd == "commands") this.promptCurrent = ` ${this.promptCurrent}`; if (cmd == "end" && !this.promptCurrent.startsWith(" ")) this.promptCurrent = this.promptUuid; else if (cmd == "end") this.promptCurrent = this.promptCurrent.slice(1); const { timedOut } = await this.waitForPrompt(cmd != "continue" ? TIMEOUT_COMMAND : TIMEOUT_COMMAND_CONTINUE); if (this.closed) break; if (timedOut && cmd == "continue") break; if (timedOut) { this.pushOutput(SegmentKind.Error, `GDB command kept running for ${TIMEOUT_COMMAND / 1000} seconds.`); break; } if (this.outBuffer.length > 0 && this.outBuffer[this.outBuffer.length - 1].kind === SegmentKind.GdbError) break; } } private async interruptIfNotReady(): Promise { if (this.closed || this.ready) return; this.child!.kill("SIGINT"); const { timedOut } = await this.waitForPrompt(TIMEOUT_INTERRUPT); if (this.closed) return; if (timedOut) this.criticalError("Ctrl+C timed out. There is no way to recover, so sent SIGKILL to GDB."); } private waitForPrompt(timeoutMs: number): Promise<{ timedOut: boolean }> { return new Promise(resolve => { if (this.ready || this.closed) return resolve({ timedOut: false }); this.events.once("readyOrClosed", () => { resolve({ timedOut: false }); }); setTimeout(() => resolve({ timedOut: true }), timeoutMs); }); } private pushOutput(kind: SegmentKind, text: string) { if (text.trimEnd().length === 0) return; this.outBuffer.push({ kind, text, prompt: kind == SegmentKind.GdbCommand ? (this.promptCurrent == this.promptUuid ? "(gdb)" : this.promptCurrent) : undefined}); this.piUpdate?.(outputToPi(this.outBuffer)); } popOutput(): Segment[] { const output = this.outBuffer; this.outBuffer = []; return output; } private criticalError(message: string | null): never { this.kill(); if (message !== null) throw new Error(`${outputToLlmText(this.outBuffer)}\n${message}`); else throw new Error(outputToLlmText(this.outBuffer)); } kill(): void { this.child.kill("SIGKILL"); } isClosed(): boolean { return this.closed; } } let gdb: GDB | null = null; export default function (pi: ExtensionAPI) { pi.registerTool({ name: "gdb", label: "GDB", description: "Run GDB commands in a background session.", promptGuidelines: [ "Use gdb tool instead of direct bash commands when debugging with GDB." ], executionMode: "sequential", parameters: Type.Object({ commands: Type.Array(Type.String()), gdb_path: Type.Optional(Type.String({description: "Path to GDB executable."})), }), async execute(_toolCallId, params: { commands: string[]; gdb_path?: string }, signal, onUpdate) { if (gdb?.isClosed()) { const output = gdb.popOutput(); gdb = null; if (output.length > 0) return await outputToPi(output); } if (gdb === null) gdb = await GDB.spawn(params.gdb_path ?? "gdb"); const output = await gdb!.runToolCall(params.commands, onUpdate); return await outputToPi(output); }, renderCall(args, theme) { return new Text("", 0, 0); }, renderResult(result, {expanded, isPartial}, theme, context) { return outputToUiComponent(result.details, expanded, theme); }, }); pi.on("session_shutdown", () => gdb?.kill()); } function outputToPi(output: Segment[]) { return { content: [{type: "text", text: outputToLlmText(output)}], details: output, }; } function outputToLlmText(output: Segment[]): string { return output.map(segment => { if (segment.kind == SegmentKind.GdbCommand) return `(gdb) ${segment.text}`; let fullText = segment.text.trimEnd(); const truncation = truncateHead(fullText, { maxLines: 50, maxBytes: DEFAULT_MAX_BYTES, }) let shortText = truncation.content; if (truncation.truncated) { const tempDir = mkdtempSync(join(tmpdir(), "pi-rg-")); const tempFile = join(tempDir, "output.txt"); writeFileSync(tempFile, fullText, "utf8"); shortText += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`; shortText += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`; shortText += ` Full output saved to: ${tempFile}]`; } return shortText; }).join("\n"); } function outputToUiComponent(output: Segment[], expanded: boolean, theme) { return new Text(`${output.map(segment => { if (segment.kind == SegmentKind.GdbOutput) { let lineCount = segment.text.trimEnd().split(/\n/).length; if (lineCount > 100 && !expanded) return `${theme.fg("muted", `... (${lineCount} lines, `)}${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`; return theme.fg("muted", segment.text.trimEnd()); } else if (segment.kind == SegmentKind.GdbError) return theme.fg("error", segment.text.trimEnd()); else if (segment.kind == SegmentKind.GdbCommand) { let prompt: string; if (segment.prompt === "(gdb)") prompt = theme.fg("toolTitle", theme.bold("(gdb)")); else { const baseIndent = 4 + segment.prompt!.length; const nonEndIndent = segment.text !== "end" ? 4 : 0; prompt = `${' '.repeat(baseIndent + nonEndIndent)}`; } return `${prompt} ${theme.fg("accent", segment.text.trim())}`; } else if (segment.kind == SegmentKind.Error) return `${theme.fg("error", segment.text.trim())}`; else if (segment.kind == SegmentKind.Info) return `${theme.fg("muted", segment.text.trim())}`; }).join("\n")}`, 0, 0); }