/** * Background Terminals Extension * * Lets the LLM agent manage background shell processes via tools and * provides a /ps TUI overlay for the user. * * Tools: * bg_start — start a background terminal (command, title?, working_dir?) * bg_status — get stdout/stderr for one terminal * bg_list — list all terminals with status * bg_kill — kill a terminal tree * * Command: * /ps — interactive overlay with live list → detail view * * Constraints: * - max 8 running terminals * - completed terminals retained up to 32, oldest pruned on overflow * - IDs are bt-N (sequential counter) * - statuses: running | done | failed | killed * - one persistent WriteStream per stream with error handler (not per chunk) * - memory buffer limited to 64 KiB per stream (tail-only) * - full logs spilled to session-scoped directory under tmp * - auto-deliver output exactly once via pi.sendMessage on terminal exit * - bg_status / bg_kill consume pending to avoid duplicate delivery * - widget only shown while terminals are running * - flush and deliver pending on agent_settled * - cleanup on session_shutdown: kill trees, close streams, remove dir * - Windows: PowerShell + taskkill; POSIX: process group signals */ import { mkdirSync, existsSync, createWriteStream, unlinkSync, readFileSync, rmSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext, } from "@earendil-works/pi-coding-agent"; import type { Component, TUI } from "@earendil-works/pi-tui"; import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui"; import { killProcessTree } from "../shared/process-tree.ts"; import { spawnBackground } from "../shared/shell-command.ts"; import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import { Type } from "typebox"; import type { WriteStream } from "node:fs"; // ── Constants ────────────────────────────────────────────────────────────────── const MAX_RUNNING = 8; const MAX_COMPLETED = 32; const MEMORY_BUFFER_LIMIT = 64 * 1024; // 64 KiB per stream const STATUS_TRUNCATE = 4_000; // chars returned to LLM per stream // ── Types ────────────────────────────────────────────────────────────────────── type TerminalStatus = "running" | "done" | "failed" | "killed"; interface BgTerminal { id: string; // bt-N title?: string; command: string; status: TerminalStatus; startedAt: number; exitedAt: number | null; exitCode: number | null; stdoutBuffer: string; stderrBuffer: string; stdoutFile: string; stderrFile: string; stdoutStream: WriteStream; stderrStream: WriteStream; pid: number; /** True when terminal exited and output hasn't been delivered to the LLM yet. */ pending: boolean; workingDir?: string; } // ── Helpers ──────────────────────────────────────────────────────────────────── function asContent(s: string): AgentToolResult["content"] { return [{ type: "text", text: s }]; } // ── ID counter ───────────────────────────────────────────────────────────────── let nextId = 1; // ── Session-scoped spill directory ───────────────────────────────────────────── let sessionSpillDir: string | null = null; function getSpillDir(): string { if (!sessionSpillDir) { // Fallback in case session_start hasn't fired yet (shouldn't happen). sessionSpillDir = join(tmpdir(), "pi-bg-terminals", `session-${Date.now()}`); } if (!existsSync(sessionSpillDir)) mkdirSync(sessionSpillDir, { recursive: true }); return sessionSpillDir; } function spillPath(id: string, stream: "stdout" | "stderr"): string { return join(getSpillDir(), `${id}-${stream}.log`); } function cleanupSpillDir(): void { if (!sessionSpillDir || !existsSync(sessionSpillDir)) return; try { rmSync(sessionSpillDir, { recursive: true, force: true }); } catch { // Best effort. } } // ── Stream helpers ──────────────────────────────────────────────────────────── function openStream(path: string): WriteStream { const stream = createWriteStream(path, { flags: "a" }); stream.on("error", () => { // Swallow — memory buffer is the primary store. }); return stream; } function closeStream(stream: WriteStream | undefined): void { if (!stream || stream.destroyed) return; try { stream.end(); } catch { try { stream.destroy(); } catch { // Best effort. } } } function flushStream(stream: WriteStream | undefined): void { if (!stream || stream.destroyed) return; // WriteStream has internal buffering; write an empty string to trigger a // drain opportunity (best-effort flush). try { stream.write(""); } catch { // Best effort. } } function cleanupTerminalFiles(t: BgTerminal): void { try { if (existsSync(t.stdoutFile)) unlinkSync(t.stdoutFile); if (existsSync(t.stderrFile)) unlinkSync(t.stderrFile); } catch { // Best effort. } } // ── Buffer helpers ───────────────────────────────────────────────────────────── function appendToBuffer(current: string, chunk: string, limit: number): string { const next = current + chunk; if (next.length <= limit) return next; // Keep tail (most recent output) within limit. const drop = next.length - limit; return `…[${drop} older bytes dropped]…\n${next.slice(drop)}`; } function truncateForLlm(text: string, max = STATUS_TRUNCATE): string { if (text.length <= max) return text || "(empty)"; return `…[${text.length - max} bytes truncated]…\n${text.slice(-max)}`; } // ── Full log reader ──────────────────────────────────────────────────────────── function readFullOutput(filePath: string, memoryBuffer: string): string { try { return readFileSync(filePath, "utf-8"); } catch { return memoryBuffer; } } // ── Helpers for terminals map ────────────────────────────────────────────────── function runningCount(terminals: Map): number { let count = 0; for (const t of terminals.values()) { if (t.status === "running") count++; } return count; } function uptimeSeconds(t: BgTerminal): number { const end = t.exitedAt ?? Date.now(); return Math.round((end - t.startedAt) / 1000); } function truncateCommand(cmd: string, max = 50): string { return cmd.length > max ? cmd.slice(0, max - 1) + "…" : cmd; } function findTerminal( terminals: Map, query: string, ): BgTerminal | undefined { if (!query) return undefined; // Exact match (full bt-N ID). if (terminals.has(query)) return terminals.get(query); // Prefix match. for (const t of terminals.values()) { if (t.id.startsWith(query)) return t; } return undefined; } function statusIcon(status: TerminalStatus): string { switch (status) { case "running": return "●"; case "done": return "✓"; case "failed": return "✗"; case "killed": return "✕"; } } // ── Prune completed terminals ────────────────────────────────────────────────── function pruneCompleted(terminals: Map): void { const completed: BgTerminal[] = []; for (const t of terminals.values()) { if (t.status !== "running") completed.push(t); } if (completed.length <= MAX_COMPLETED) return; // Sort by exitedAt ascending (oldest first), then startedAt. completed.sort( (a, b) => (a.exitedAt ?? a.startedAt) - (b.exitedAt ?? b.startedAt), ); const toRemove = completed.slice(0, completed.length - MAX_COMPLETED); for (const t of toRemove) { closeStream(t.stdoutStream); closeStream(t.stderrStream); cleanupTerminalFiles(t); terminals.delete(t.id); } } // ── Tool parameter schemas (TypeBox) ─────────────────────────────────────────── const BgStartParams = Type.Object({ command: Type.String({ description: "Shell command to run in background" }), title: Type.Optional( Type.String({ description: "Human-readable label for this terminal" }), ), working_dir: Type.Optional( Type.String({ description: "Working directory. Defaults to the agent's current directory.", }), ), }); const BgIdParams = Type.Object({ id: Type.String({ description: "Terminal ID returned by bg_start or bg_list" }), }); // ── /ps overlay component ───────────────────────────────────────────────────── /** * Interactive overlay component for /ps. * * List mode: shows all terminals with live status. Up/Down to navigate, * Enter to inspect, K to kill, Q/Esc to close. * * Detail mode: shows selected terminal's stdout or stderr. Tab to toggle * stream, Up/Down/PgUp/PgDown to scroll, Esc back to list, K to kill. */ class PsOverlayComponent implements Component { private mode: "list" | "detail" = "list"; private listIndex = 0; private scrollOffset = 0; private showStderr = false; private detailId: string | null = null; constructor( private terminals: Map, private tui: TUI, private done: () => void, private killFn: (t: BgTerminal) => Promise, ) {} invalidate(): void {} render(width: number): string[] { if (this.mode === "detail") return this.renderDetail(width); return this.renderList(width); } // ── List render ─────────────────────────────────────────────────────── private renderList(width: number): string[] { const lines: string[] = []; const items = Array.from(this.terminals.values()); const headerBg = `╔══ Background Terminals ${items.length > 0 ? `(${runningCount(this.terminals)} running)` : ""} ══`; const headerPad = Math.max(0, width - headerBg.length - 2); lines.push(`${headerBg}${"═".repeat(headerPad)}╗`); if (items.length === 0) { lines.push("║ No terminals. Use bg_start to create one.".padEnd(width - 1) + "║"); } else { for (let i = 0; i < items.length; i++) { const t = items[i]!; const icon = statusIcon(t.status); const uptime = String(uptimeSeconds(t)).padStart(5); const label = t.title ? ` [${truncateCommand(t.title, 20)}]` : ""; const prefix = i === this.listIndex ? "▶" : " "; const cmd = truncateCommand(t.command, Math.max(10, width - 35 - label.length)); const line = `║ ${prefix} ${icon} ${t.id.padEnd(6)} ${t.status.padEnd(7)} ${uptime}s ${cmd}${label}`; lines.push(line.padEnd(width - 1) + "║"); } } lines.push("║" + "─".repeat(width - 2) + "║"); lines.push( truncateToWidth( `║ ↑↓:navigate Enter:inspect K:kill Q/Esc:close`, width, "…", ).padEnd(width - 1) + "║", ); const footerPad = Math.max(0, width - 3); lines.push(`╚${"═".repeat(footerPad)}╝`); return lines; } // ── Detail render ───────────────────────────────────────────────────── private renderDetail(width: number): string[] { const t = this.detailId ? this.terminals.get(this.detailId) : undefined; if (!t) { this.mode = "list"; return this.renderList(width); } const label = t.title ? `${t.title} (${t.id})` : t.id; const uptime = uptimeSeconds(t); const streamLabel = this.showStderr ? "STDERR" : "STDOUT"; const buffer = this.showStderr ? t.stderrBuffer : t.stdoutBuffer; const filePath = this.showStderr ? t.stderrFile : t.stdoutFile; const lines: string[] = []; const headerBg = `╔══ ${label} ══`; const headerPad = Math.max(0, width - headerBg.length - 2); lines.push(`${headerBg}${"═".repeat(headerPad)}╗`); lines.push( truncateToWidth( `║ Status: ${t.status} Exit: ${t.exitCode ?? "N/A"} Uptime: ${uptime}s PID: ${t.pid || "N/A"}`, width, "…", ).padEnd(width - 1) + "║", ); lines.push( truncateToWidth( `║ Cmd: ${t.command}`, width, "…", ).padEnd(width - 1) + "║", ); lines.push("║" + "─".repeat(width - 2) + "║"); // Stream content area. const maxContentLines = Math.max(3, width > 60 ? 20 : 10); const contentLines = (buffer || "(empty)").split("\n"); const totalContentLines = contentLines.length; // Clamp scroll. const maxScroll = Math.max(0, totalContentLines - maxContentLines); if (this.scrollOffset > maxScroll) this.scrollOffset = maxScroll; if (this.scrollOffset < 0) this.scrollOffset = 0; const visibleLines = contentLines.slice( this.scrollOffset, this.scrollOffset + maxContentLines, ); for (const cl of visibleLines) { const trimmed = cl.length > width - 4 ? cl.slice(0, width - 5) + "…" : cl; lines.push(`║ ${trimmed}`.padEnd(width - 1) + "║"); } // Fill remaining content lines. for (let i = visibleLines.length; i < maxContentLines; i++) { lines.push("║".padEnd(width - 1) + "║"); } lines.push("║" + "─".repeat(width - 2) + "║"); const scrollInfo = totalContentLines > 0 ? `L${this.scrollOffset + 1}-${Math.min(this.scrollOffset + maxContentLines, totalContentLines)}/${totalContentLines}` : "0/0"; lines.push( truncateToWidth( `║ ${streamLabel} ${scrollInfo} Tab:toggle ↑↓:scroll PgUp/PgDn Esc:back K:kill Full: ${filePath}`, width, "…", ).padEnd(width - 1) + "║", ); const footerPad = Math.max(0, width - 3); lines.push(`╚${"═".repeat(footerPad)}╝`); return lines; } // ── Input handling ──────────────────────────────────────────────────── handleInput(data: string): void { if (this.mode === "detail") { this.handleDetailInput(data); } else { this.handleListInput(data); } } private handleListInput(data: string): void { const items = Array.from(this.terminals.values()); if (matchesKey(data, Key.up)) { if (items.length > 0) { this.listIndex = (this.listIndex - 1 + items.length) % items.length; } } else if (matchesKey(data, Key.down)) { if (items.length > 0) this.listIndex = (this.listIndex + 1) % items.length; } else if (matchesKey(data, Key.enter)) { const t = items[this.listIndex]; if (t) { this.detailId = t.id; this.mode = "detail"; this.scrollOffset = 0; this.showStderr = false; } } else if (data.toLowerCase() === "k") { const t = items[this.listIndex]; if (t) void this.killFn(t); } else if (data.toLowerCase() === "q" || matchesKey(data, Key.escape)) { this.done(); } this.tui.requestRender(); } private handleDetailInput(data: string): void { const t = this.detailId ? this.terminals.get(this.detailId) : undefined; const buffer = t ? this.showStderr ? t.stderrBuffer : t.stdoutBuffer : ""; const contentLines = buffer.split("\n"); const maxContentLines = 20; // reasonable default const maxScroll = Math.max(0, contentLines.length - maxContentLines); if (matchesKey(data, Key.tab)) { this.showStderr = !this.showStderr; this.scrollOffset = 0; } else if (matchesKey(data, Key.up)) { this.scrollOffset = Math.max(0, this.scrollOffset - 1); } else if (matchesKey(data, Key.down)) { this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1); } else if (matchesKey(data, Key.pageUp)) { this.scrollOffset = Math.max(0, this.scrollOffset - maxContentLines); } else if (matchesKey(data, Key.pageDown)) { this.scrollOffset = Math.min(maxScroll, this.scrollOffset + maxContentLines); } else if (matchesKey(data, Key.escape)) { this.mode = "list"; this.detailId = null; } else if (data.toLowerCase() === "k") { if (t) { void this.killFn(t); this.mode = "list"; this.detailId = null; } } else if (data.toLowerCase() === "q") { this.done(); } this.tui.requestRender(); } } // ── Extension ────────────────────────────────────────────────────────────────── export default function backgroundTerminalsExtension(pi: ExtensionAPI) { // ── State ────────────────────────────────────────────────────────────── const terminals = new Map(); let widgetRequestRender: (() => void) | undefined; let overlayRequestRender: (() => void) | undefined; let latestCtx: ExtensionContext | undefined; // ── Widget ───────────────────────────────────────────────────────────── function installWidget(ctx: any): void { if (!ctx.hasUI) return; ctx.ui.setWidget( "bg-terminals", (_tui: any, theme: any) => { widgetRequestRender = () => _tui.requestRender(); return { render(width: number) { const count = runningCount(terminals); // Only show widget when there are running terminals. if (count === 0) return []; const total = terminals.size; return [ truncateToWidth( `${theme.fg("accent", " bg")} ${count} running / ${total} total`, width, "…", ), ]; }, invalidate() { _tui.requestRender(); }, } satisfies Component; }, { placement: "aboveEditor" }, ); } function refreshWidget(): void { widgetRequestRender?.(); } // ── Kill helpers ────────────────────────────────────────────────────── async function killTerminal(t: BgTerminal): Promise { if (t.status !== "running") return; t.status = "killed"; t.exitedAt = Date.now(); t.exitCode = null; t.pending = false; // consumed by kill action closeStream(t.stdoutStream); closeStream(t.stderrStream); try { await killProcessTree(t.pid, { force: true }); } catch { // Process may already be gone. } pruneCompleted(terminals); refreshWidget(); overlayRequestRender?.(); } async function killAll(): Promise { const promises: Promise[] = []; for (const t of terminals.values()) { promises.push(killTerminal(t)); } await Promise.allSettled(promises); } // ── Pending auto-delivery ───────────────────────────────────────────── function deliverPending(): void { for (const t of terminals.values()) { if (!t.pending) continue; t.pending = false; const stdout = truncateForLlm(t.stdoutBuffer); const stderr = truncateForLlm(t.stderrBuffer); const uptime = uptimeSeconds(t); const titleStr = t.title ? ` [${t.title}]` : ""; const body = [ `**Background terminal ${t.id}${titleStr} finished**`, `Status: ${t.status} | Exit code: ${t.exitCode ?? "N/A"} | Uptime: ${uptime}s`, `Command: \`${t.command}\``, ``, `**STDOUT** (${t.stdoutBuffer.length} bytes, full log: ${t.stdoutFile})`, stdout || "(empty)", ``, `**STDERR** (${t.stderrBuffer.length} bytes, full log: ${t.stderrFile})`, stderr || "(empty)", ].join("\n"); pi.sendMessage( { customType: "bg_terminal_output", content: body, display: true, details: { terminalId: t.id, title: t.title, status: t.status, }, }, { deliverAs: "followUp", triggerTurn: true }, ); } } // ── Tools ────────────────────────────────────────────────────────────── pi.registerTool({ name: "bg_start", label: "Start Background Terminal", description: "Start a shell command in a background terminal. Max 8 running terminals. " + "Completed terminals are retained up to 32. " + "Use bg_status to check output, bg_list to see all terminals, and bg_kill to stop one. " + "When a terminal finishes, its output is automatically delivered.", promptSnippet: "bg_start(command: str, title?: str, working_dir?: str) → starts a background terminal", promptGuidelines: [ "Use bg_start for long-running commands (servers, watchers, builds).", "Max 8 running terminals. Check with bg_list before starting new ones.", "Provide an optional title to label the terminal for easier tracking.", "Use bg_status to check output; bg_kill to stop a terminal.", "When a terminal exits, output is auto-delivered. No need to poll.", ], parameters: BgStartParams, async execute( _toolCallId: string, params: { command: string; title?: string; working_dir?: string }, _signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext, ): Promise> { const command = (params.command ?? "").trim(); if (!command) { return { content: asContent("Error: command must be non-empty."), details: { kind: "error" as const }, }; } if (runningCount(terminals) >= MAX_RUNNING) { return { content: asContent( `Error: max ${MAX_RUNNING} running background terminals reached. ` + `Kill one with bg_kill or wait for one to exit.`, ), details: { kind: "error" as const }, }; } const id = `bt-${nextId++}`; const stdoutFile = spillPath(id, "stdout"); const stderrFile = spillPath(id, "stderr"); const stdoutStream = openStream(stdoutFile); const stderrStream = openStream(stderrFile); const terminal: BgTerminal = { id, title: params.title?.trim() || undefined, command, status: "running", startedAt: Date.now(), exitedAt: null, exitCode: null, stdoutBuffer: "", stderrBuffer: "", stdoutFile, stderrFile, stdoutStream, stderrStream, pid: 0, pending: false, workingDir: params.working_dir?.trim() || undefined, }; terminals.set(id, terminal); const cwd = terminal.workingDir ?? ctx.cwd ?? process.cwd(); const spawned = spawnBackground(command, { cwd, onStdout(data) { terminal.stdoutBuffer = appendToBuffer( terminal.stdoutBuffer, data, MEMORY_BUFFER_LIMIT, ); try { terminal.stdoutStream.write(data); } catch { // Best effort. } }, onStderr(data) { terminal.stderrBuffer = appendToBuffer( terminal.stderrBuffer, data, MEMORY_BUFFER_LIMIT, ); try { terminal.stderrStream.write(data); } catch { // Best effort. } }, onExit(code, _sig) { // A kill or spawn error may already have finalized this terminal. if (terminal.status !== "running") return; terminal.status = code === 0 ? "done" : "failed"; terminal.exitedAt = Date.now(); terminal.exitCode = code; terminal.pending = true; closeStream(terminal.stdoutStream); closeStream(terminal.stderrStream); pruneCompleted(terminals); refreshWidget(); overlayRequestRender?.(); if (latestCtx?.isIdle()) deliverPending(); }, onError(err) { if (terminal.status !== "running") return; terminal.status = "failed"; terminal.exitedAt = Date.now(); terminal.exitCode = -1; terminal.pending = true; const errMsg = `Spawn error: ${err.message}`; terminal.stderrBuffer = appendToBuffer( terminal.stderrBuffer, errMsg, MEMORY_BUFFER_LIMIT, ); try { terminal.stderrStream.write(errMsg); } catch { // Best effort. } closeStream(terminal.stdoutStream); closeStream(terminal.stderrStream); pruneCompleted(terminals); refreshWidget(); overlayRequestRender?.(); if (latestCtx?.isIdle()) deliverPending(); }, }); terminal.pid = spawned.pid; refreshWidget(); const titleInfo = terminal.title ? `\nTitle: ${terminal.title}` : ""; return { content: asContent( `Terminal started. ID: ${id}\nCommand: ${command}${titleInfo}\nWorking dir: ${cwd}`, ), details: { kind: "bg_start_result" as const, terminalId: id, status: "running", }, }; }, }); pi.registerTool({ name: "bg_status", label: "Background Terminal Status", description: "Get stdout and stderr from a background terminal. " + "Output is truncated to the last ~4000 chars per stream; full logs are on disk. " + "After the terminal exits, the first status call consumes the pending delivery " + "(so automatic delivery is not duplicated). Subsequent calls will report " + "that the terminal has already been followed up.", promptSnippet: "bg_status(id: str) → terminal output and status", promptGuidelines: [ "Call bg_status to check on a terminal's output.", "After the terminal exits, you can call bg_status exactly once to get the final output.", "Full logs are available on disk — use the file paths returned, or read directly.", ], parameters: BgIdParams, async execute( _toolCallId: string, params: { id: string }, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: ExtensionContext, ): Promise> { const id = params.id?.trim() ?? ""; const terminal = findTerminal(terminals, id); if (!terminal) { return { content: asContent( `Error: no terminal found matching "${id}". Use bg_list to see IDs.`, ), details: { kind: "error" as const }, }; } // If terminal is not running, consume pending to avoid duplicate auto-delivery. const wasPending = terminal.pending; if (terminal.status !== "running") { if (wasPending) { terminal.pending = false; } else { return { content: asContent( `Terminal ${terminal.id} has already been followed up. ` + `Status: ${terminal.status}, exit code: ${terminal.exitCode ?? "N/A"}\n` + `Full logs: ${terminal.stdoutFile} / ${terminal.stderrFile}`, ), details: { kind: "bg_status_result" as const, terminalId: terminal.id, status: terminal.status, }, }; } } const stdout = truncateForLlm(terminal.stdoutBuffer); const stderr = truncateForLlm(terminal.stderrBuffer); const uptime = uptimeSeconds(terminal); const titleStr = terminal.title ? ` [${terminal.title}]` : ""; const info = [ `Terminal: ${terminal.id}${titleStr}`, `Status: ${terminal.status}`, `Uptime: ${uptime}s`, `Exit code: ${terminal.exitCode ?? "N/A"}`, `PID: ${terminal.pid || "N/A"}`, `Command: ${terminal.command}`, `Full logs: ${terminal.stdoutFile} / ${terminal.stderrFile}`, ]; return { content: asContent( `${info.join("\n")}\n\n--- STDOUT ---\n${stdout}\n\n--- STDERR ---\n${stderr}`, ), details: { kind: "bg_status_result" as const, terminalId: terminal.id, status: terminal.status, exitCode: terminal.exitCode, uptime, }, }; }, }); pi.registerTool({ name: "bg_list", label: "List Background Terminals", description: "List all background terminals with ID, status, uptime, title, and command.", promptSnippet: "bg_list() → list of all background terminals", promptGuidelines: [ "Call bg_list to see all running and completed terminals and their IDs.", ], parameters: Type.Object({}), async execute(): Promise> { if (terminals.size === 0) { return { content: asContent("No background terminals."), details: { kind: "bg_list_result" as const, terminals: [] }, }; } const lines: string[] = []; const list: Array<{ id: string; status: string; uptime: number; command: string; title?: string; }> = []; for (const t of terminals.values()) { const uptime = uptimeSeconds(t); list.push({ id: t.id, status: t.status, uptime, command: t.command, title: t.title, }); const titlePart = t.title ? ` [${truncateCommand(t.title, 15)}]` : ""; lines.push( `${t.id.padEnd(6)} ${statusIcon(t.status)} ${t.status.padEnd(7)} ${String(uptime).padStart(5)}s ${truncateCommand(t.command)}${titlePart}`, ); } return { content: asContent( `ID ST STATUS UPTIME COMMAND\n${"-".repeat(60)}\n${lines.join("\n")}`, ), details: { kind: "bg_list_result" as const, terminals: list }, }; }, }); pi.registerTool({ name: "bg_kill", label: "Kill Background Terminal", description: "Kill a background terminal by ID. Consumes any pending auto-delivery for that terminal.", promptSnippet: "bg_kill(id: str) → kills a terminal", promptGuidelines: [ "Call bg_kill to stop a terminal you started with bg_start.", "Killing consumes any pending delivery — no duplicate output.", ], parameters: BgIdParams, async execute( _toolCallId: string, params: { id: string }, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: ExtensionContext, ): Promise> { const id = params.id?.trim() ?? ""; const terminal = findTerminal(terminals, id); if (!terminal) { return { content: asContent( `Error: no terminal found matching "${id}". Use bg_list to see IDs.`, ), details: { kind: "error" as const }, }; } if (terminal.status !== "running") { return { content: asContent( `Terminal ${terminal.id} is already ${terminal.status}.`, ), details: { kind: "bg_kill_result" as const, terminalId: terminal.id, status: terminal.status, }, }; } await killTerminal(terminal); refreshWidget(); return { content: asContent(`Terminal ${terminal.id} killed.`), details: { kind: "bg_kill_result" as const, terminalId: terminal.id, status: "killed", }, }; }, }); // ── Command: /ps ─────────────────────────────────────────────────────── pi.registerCommand("ps", { description: "List and inspect background terminals", handler: async (args: string, ctx: ExtensionCommandContext) => { const trimmed = args?.trim() ?? ""; // /ps killall — kill all terminals if (trimmed === "killall" || trimmed === "ka") { if (terminals.size === 0) { ctx.ui.notify("No background terminals to kill.", "info"); return; } await killAll(); refreshWidget(); ctx.ui.notify("Killed all background terminals.", "info"); return; } // /ps kill — kill a specific terminal if (trimmed.startsWith("kill ") || trimmed.startsWith("k ")) { const qid = trimmed.replace(/^k(?:ill)?\s+/, "").trim(); const terminal = findTerminal(terminals, qid); if (!terminal) { ctx.ui.notify(`No terminal matching "${qid}".`, "warning"); return; } await killTerminal(terminal); refreshWidget(); ctx.ui.notify(`Killed terminal ${terminal.id}.`, "info"); return; } // /ps — interactive overlay if (!ctx.hasUI) { // Fallback: print list if (terminals.size === 0) { ctx.ui.notify("No background terminals.", "info"); return; } const lines: string[] = []; for (const t of terminals.values()) { lines.push( `${statusIcon(t.status)} ${t.id} ${t.status} ${uptimeSeconds(t)}s ${t.command}`, ); } ctx.ui.notify(lines.join("\n"), "info"); return; } if (terminals.size === 0) { ctx.ui.notify( "No background terminals. Use bg_start tool to start one.", "info", ); return; } // Interactive overlay. await ctx.ui.custom( (tui: any, _theme: any, _kb: any, done: (result: unknown) => void) => { overlayRequestRender = () => tui.requestRender(); const overlay = new PsOverlayComponent( terminals, tui, () => { overlayRequestRender = undefined; done(undefined); }, killTerminal, ); return overlay; }, { overlay: true }, ); }, }); // ── Lifecycle ────────────────────────────────────────────────────────── pi.on("session_start", async (_event: any, ctx: ExtensionContext) => { latestCtx = ctx; nextId = 1; // Create session-scoped spill directory. Use timestamp + random suffix // to avoid collisions. sessionSpillDir = join( tmpdir(), "pi-bg-terminals", `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, ); mkdirSync(sessionSpillDir, { recursive: true }); installWidget(ctx); refreshWidget(); }); pi.on("agent_settled", async () => { // Flush all streams. for (const t of terminals.values()) { flushStream(t.stdoutStream); flushStream(t.stderrStream); } // Deliver pending terminal outputs. deliverPending(); }); pi.on("session_shutdown", async () => { await killAll(); // Close any remaining streams and clean up. for (const t of terminals.values()) { closeStream(t.stdoutStream); closeStream(t.stderrStream); cleanupTerminalFiles(t); } terminals.clear(); latestCtx = undefined; cleanupSpillDir(); }); }