diff --git a/src/resources/extensions/bg-shell/index.ts b/src/resources/extensions/bg-shell/index.ts index 0f8099009..e126c12f1 100644 --- a/src/resources/extensions/bg-shell/index.ts +++ b/src/resources/extensions/bg-shell/index.ts @@ -29,1231 +29,52 @@ import type { ExtensionContext, Theme, } from "@gsd/pi-coding-agent"; -import { - truncateHead, - DEFAULT_MAX_BYTES, - DEFAULT_MAX_LINES, - getShellConfig, - sanitizeCommand, -} from "@gsd/pi-coding-agent"; import { Text, truncateToWidth, visibleWidth, - matchesKey, Key, } from "@gsd/pi-tui"; import { Type } from "@sinclair/typebox"; -import { spawn, spawnSync, type ChildProcess } from "node:child_process"; -import { createConnection } from "node:net"; -import { randomUUID } from "node:crypto"; -import { writeFileSync, readFileSync, existsSync, mkdirSync } from "node:fs"; -import { join } from "node:path"; import { shortcutDesc } from "../shared/terminal.js"; -import { createRequire } from "node:module"; -// ── Windows VT Input Restoration ──────────────────────────────────────────── -// Child processes (esp. Git Bash / MSYS2) can strip the ENABLE_VIRTUAL_TERMINAL_INPUT -// flag from the shared stdin console handle. Re-enable it after each child exits. - -let _vtHandles: { GetConsoleMode: Function; SetConsoleMode: Function; handle: unknown } | null = null; -function restoreWindowsVTInput(): void { - if (process.platform !== "win32") return; - try { - if (!_vtHandles) { - const cjsRequire = createRequire(import.meta.url); - const koffi = cjsRequire("koffi"); - const k32 = koffi.load("kernel32.dll"); - const GetStdHandle = k32.func("void* __stdcall GetStdHandle(int)"); - const GetConsoleMode = k32.func("bool __stdcall GetConsoleMode(void*, _Out_ uint32_t*)"); - const SetConsoleMode = k32.func("bool __stdcall SetConsoleMode(void*, uint32_t)"); - const handle = GetStdHandle(-10); - _vtHandles = { GetConsoleMode, SetConsoleMode, handle }; - } - const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200; - const mode = new Uint32Array(1); - _vtHandles.GetConsoleMode(_vtHandles.handle, mode); - if (!(mode[0] & ENABLE_VIRTUAL_TERMINAL_INPUT)) { - _vtHandles.SetConsoleMode(_vtHandles.handle, mode[0] | ENABLE_VIRTUAL_TERMINAL_INPUT); - } - } catch { /* koffi not available on non-Windows */ } -} - -// ── Types ────────────────────────────────────────────────────────────────── - -type ProcessStatus = - | "starting" - | "ready" - | "error" - | "exited" - | "crashed"; - -type ProcessType = "server" | "build" | "test" | "watcher" | "generic" | "shell"; - -interface ProcessEvent { - type: - | "started" - | "ready" - | "error_detected" - | "recovered" - | "exited" - | "crashed" - | "output" - | "port_open" - | "pattern_match"; - timestamp: number; - detail: string; - data?: Record; -} - -interface OutputDigest { - status: ProcessStatus; - uptime: string; - errors: string[]; - warnings: string[]; - urls: string[]; - ports: number[]; - lastActivity: string; - outputLines: number; - changeSummary: string; -} - -interface OutputLine { - stream: "stdout" | "stderr"; - line: string; - ts: number; -} - -interface BgProcess { - id: string; - label: string; - command: string; - cwd: string; - startedAt: number; - proc: ChildProcess; - /** Unified chronologically-interleaved output buffer */ - output: OutputLine[]; - exitCode: number | null; - signal: string | null; - alive: boolean; - /** Tracks how many lines in the unified output buffer the LLM has already seen */ - lastReadIndex: number; - /** Process classification */ - processType: ProcessType; - /** Current lifecycle status */ - status: ProcessStatus; - /** Detected ports */ - ports: number[]; - /** Detected URLs */ - urls: string[]; - /** Accumulated errors since last read */ - recentErrors: string[]; - /** Accumulated warnings since last read */ - recentWarnings: string[]; - /** Lifecycle events log */ - events: ProcessEvent[]; - /** Ready pattern (regex string) */ - readyPattern: string | null; - /** Ready port to probe */ - readyPort: number | null; - /** Whether readiness was ever achieved */ - wasReady: boolean; - /** Group membership */ - group: string | null; - /** Last error count snapshot for diff detection */ - lastErrorCount: number; - /** Last warning count snapshot for diff detection */ - lastWarningCount: number; - /** Command history for shell-type sessions */ - commandHistory: string[]; - /** Dedup tracker: hash → count of repeated lines */ - lineDedup: Map; - /** Total raw lines (before dedup) for token savings calc */ - totalRawLines: number; - /** Env snapshot (keys only, no values for security) */ - envKeys: string[]; - /** Restart count */ - restartCount: number; - /** Original start config for restart */ - startConfig: { command: string; cwd: string; label: string; processType: ProcessType; readyPattern: string | null; readyPort: number | null; group: string | null }; -} - -interface BgProcessInfo { - id: string; - label: string; - command: string; - cwd: string; - startedAt: number; - alive: boolean; - exitCode: number | null; - signal: string | null; - outputLines: number; - stdoutLines: number; - stderrLines: number; - status: ProcessStatus; - processType: ProcessType; - ports: number[]; - urls: string[]; - group: string | null; - restartCount: number; - uptime: string; - recentErrorCount: number; - recentWarningCount: number; - eventCount: number; -} - -// ── Constants ────────────────────────────────────────────────────────────── - -const MAX_BUFFER_LINES = 5000; -const MAX_EVENTS = 200; -const DEAD_PROCESS_TTL = 10 * 60 * 1000; -const PORT_PROBE_TIMEOUT = 500; -const READY_POLL_INTERVAL = 250; -const DEFAULT_READY_TIMEOUT = 30000; - -// ── Pattern Databases ────────────────────────────────────────────────────── - -/** Patterns that indicate a process is ready/listening */ -const READINESS_PATTERNS: RegExp[] = [ - // Node/JS servers - /listening\s+on\s+(?:port\s+)?(\d+)/i, - /server\s+(?:is\s+)?(?:running|started|listening)\s+(?:at|on)\s+/i, - /ready\s+(?:in|on|at)\s+/i, - /started\s+(?:server\s+)?on\s+/i, - // Next.js / Vite / etc - /Local:\s*https?:\/\//i, - /➜\s+Local:\s*/i, - /compiled\s+(?:successfully|client\s+and\s+server)/i, - // Python - /running\s+on\s+https?:\/\//i, - /Uvicorn\s+running/i, - /Development\s+server\s+is\s+running/i, - // Generic - /press\s+ctrl[\-+]c\s+to\s+(?:quit|stop)/i, - /watching\s+for\s+(?:file\s+)?changes/i, - /build\s+(?:completed|succeeded|finished)/i, -]; - -/** Patterns that indicate errors */ -const ERROR_PATTERNS: RegExp[] = [ - /\berror\b[\s:[\](]/i, - /\bERROR\b/, - /\bfailed\b/i, - /\bFAILED\b/, - /\bfatal\b/i, - /\bFATAL\b/, - /\bexception\b/i, - /\bpanic\b/i, - /\bsegmentation\s+fault\b/i, - /\bsyntax\s*error\b/i, - /\btype\s*error\b/i, - /\breference\s*error\b/i, - /Cannot\s+find\s+module/i, - /Module\s+not\s+found/i, - /ENOENT/, - /EACCES/, - /EADDRINUSE/, - /TS\d{4,5}:/, // TypeScript errors - /E\d{4,5}:/, // Rust errors - /\[ERROR\]/, - /✖|✗|❌/, // Common error symbols -]; - -/** Patterns that indicate warnings */ -const WARNING_PATTERNS: RegExp[] = [ - /\bwarning\b[\s:[\](]/i, - /\bWARN(?:ING)?\b/, - /\bdeprecated\b/i, - /\bDEPRECATED\b/, - /⚠️?/, - /\[WARN\]/, -]; - -/** Patterns to extract URLs */ -const URL_PATTERN = /https?:\/\/[^\s"'<>)\]]+/gi; - -/** Patterns to extract port numbers from "listening" messages */ -const PORT_PATTERN = /(?:port|listening\s+on|:)\s*(\d{2,5})\b/gi; - -/** Patterns indicating test results */ -const TEST_RESULT_PATTERNS: RegExp[] = [ - /(\d+)\s+(?:tests?\s+)?passed/i, - /(\d+)\s+(?:tests?\s+)?failed/i, - /Tests?:\s+(\d+)\s+passed/i, - /(\d+)\s+passing/i, - /(\d+)\s+failing/i, - /PASS|FAIL/, -]; - -/** Patterns indicating build completion */ -const BUILD_COMPLETE_PATTERNS: RegExp[] = [ - /build\s+(?:completed|succeeded|finished|done)/i, - /compiled\s+(?:successfully|with\s+\d+\s+(?:error|warning))/i, - /✓\s+Built/i, - /webpack\s+\d+\.\d+/i, - /bundle\s+(?:is\s+)?ready/i, -]; - -// ── Process Registry ─────────────────────────────────────────────────────── - -const processes = new Map(); - -/** Pending alerts to inject into the next agent context */ -let pendingAlerts: string[] = []; - -function addOutputLine(bg: BgProcess, stream: "stdout" | "stderr", line: string): void { - bg.output.push({ stream, line, ts: Date.now() }); - if (bg.output.length > MAX_BUFFER_LINES) { - const excess = bg.output.length - MAX_BUFFER_LINES; - bg.output.splice(0, excess); - // Adjust the read cursor so incremental delivery stays correct - bg.lastReadIndex = Math.max(0, bg.lastReadIndex - excess); - } -} - -function addEvent(bg: BgProcess, event: Omit): void { - const ev: ProcessEvent = { ...event, timestamp: Date.now() }; - bg.events.push(ev); - if (bg.events.length > MAX_EVENTS) { - bg.events.splice(0, bg.events.length - MAX_EVENTS); - } -} - -function getInfo(p: BgProcess): BgProcessInfo { - const stdoutLines = p.output.filter(l => l.stream === "stdout").length; - const stderrLines = p.output.filter(l => l.stream === "stderr").length; - return { - id: p.id, - label: p.label, - command: p.command, - cwd: p.cwd, - startedAt: p.startedAt, - alive: p.alive, - exitCode: p.exitCode, - signal: p.signal, - outputLines: p.output.length, - stdoutLines, - stderrLines, - status: p.status, - processType: p.processType, - ports: p.ports, - urls: p.urls, - group: p.group, - restartCount: p.restartCount, - uptime: formatUptime(Date.now() - p.startedAt), - recentErrorCount: p.recentErrors.length, - recentWarningCount: p.recentWarnings.length, - eventCount: p.events.length, - }; -} - -// ── Process Type Detection ───────────────────────────────────────────────── - -function detectProcessType(command: string): ProcessType { - const cmd = command.toLowerCase(); - - // Server patterns - if ( - /\b(serve|server|dev|start)\b/.test(cmd) && - /\b(npm|yarn|pnpm|bun|node|next|vite|nuxt|astro|remix|gatsby|uvicorn|flask|django|rails|cargo)\b/.test(cmd) - ) return "server"; - if (/\b(uvicorn|gunicorn|flask\s+run|manage\.py\s+runserver|rails\s+s)\b/.test(cmd)) return "server"; - if (/\b(http-server|live-server|serve)\b/.test(cmd)) return "server"; - - // Build patterns - if (/\b(build|compile|make|tsc|webpack|rollup|esbuild|swc)\b/.test(cmd)) { - if (/\b(watch|--watch|-w)\b/.test(cmd)) return "watcher"; - return "build"; - } - - // Test patterns - if (/\b(test|jest|vitest|mocha|pytest|cargo\s+test|go\s+test|rspec)\b/.test(cmd)) return "test"; - - // Watcher patterns - if (/\b(watch|nodemon|chokidar|fswatch|inotifywait)\b/.test(cmd)) return "watcher"; - - return "generic"; -} - -// ── Output Analysis ──────────────────────────────────────────────────────── - -function analyzeLine(bg: BgProcess, line: string, stream: "stdout" | "stderr"): void { - // Error detection - if (ERROR_PATTERNS.some(p => p.test(line))) { - bg.recentErrors.push(line.trim().slice(0, 200)); // Cap line length - if (bg.recentErrors.length > 50) bg.recentErrors.splice(0, bg.recentErrors.length - 50); - - if (bg.status === "ready") { - bg.status = "error"; - addEvent(bg, { - type: "error_detected", - detail: line.trim().slice(0, 200), - data: { errorCount: bg.recentErrors.length }, - }); - pushAlert(bg, `error_detected: ${line.trim().slice(0, 120)}`); - } - } - - // Warning detection - if (WARNING_PATTERNS.some(p => p.test(line))) { - bg.recentWarnings.push(line.trim().slice(0, 200)); - if (bg.recentWarnings.length > 50) bg.recentWarnings.splice(0, bg.recentWarnings.length - 50); - } - - // URL extraction - const urlMatches = line.match(URL_PATTERN); - if (urlMatches) { - for (const url of urlMatches) { - if (!bg.urls.includes(url)) { - bg.urls.push(url); - } - } - } - - // Port extraction - let portMatch: RegExpExecArray | null; - const portRe = new RegExp(PORT_PATTERN.source, PORT_PATTERN.flags); - while ((portMatch = portRe.exec(line)) !== null) { - const port = parseInt(portMatch[1], 10); - if (port > 0 && port <= 65535 && !bg.ports.includes(port)) { - bg.ports.push(port); - addEvent(bg, { - type: "port_open", - detail: `Port ${port} detected`, - data: { port }, - }); - } - } - - // Readiness detection - if (bg.status === "starting") { - // Check custom ready pattern first - if (bg.readyPattern) { - try { - if (new RegExp(bg.readyPattern, "i").test(line)) { - transitionToReady(bg, `Custom pattern matched: ${line.trim().slice(0, 100)}`); - } - } catch { /* invalid regex, skip */ } - } - - // Check built-in readiness patterns - if (bg.status === "starting" && READINESS_PATTERNS.some(p => p.test(line))) { - transitionToReady(bg, `Readiness pattern matched: ${line.trim().slice(0, 100)}`); - } - } - - // Recovery detection: if we were in error and see a success pattern - if (bg.status === "error") { - if (READINESS_PATTERNS.some(p => p.test(line)) || BUILD_COMPLETE_PATTERNS.some(p => p.test(line))) { - bg.status = "ready"; - bg.recentErrors = []; - addEvent(bg, { type: "recovered", detail: "Process recovered from error state" }); - pushAlert(bg, "recovered — errors cleared"); - } - } - - // Dedup tracking - bg.totalRawLines++; - const lineHash = line.trim().slice(0, 100); - bg.lineDedup.set(lineHash, (bg.lineDedup.get(lineHash) || 0) + 1); -} - -function transitionToReady(bg: BgProcess, detail: string): void { - bg.status = "ready"; - bg.wasReady = true; - addEvent(bg, { type: "ready", detail }); -} - -function pushAlert(bg: BgProcess, message: string): void { - pendingAlerts.push(`[bg:${bg.id} ${bg.label}] ${message}`); -} - -// ── Port Probing ─────────────────────────────────────────────────────────── - -function probePort(port: number, host: string = "127.0.0.1"): Promise { - return new Promise((resolve) => { - const socket = createConnection({ port, host, timeout: PORT_PROBE_TIMEOUT }, () => { - socket.destroy(); - resolve(true); - }); - socket.on("error", () => { - socket.destroy(); - resolve(false); - }); - socket.on("timeout", () => { - socket.destroy(); - resolve(false); - }); - }); -} - -// ── Digest Generation ────────────────────────────────────────────────────── - -function generateDigest(bg: BgProcess, mutate: boolean = false): OutputDigest { - // Change summary: what's different since last read - const newErrors = bg.recentErrors.length - bg.lastErrorCount; - const newWarnings = bg.recentWarnings.length - bg.lastWarningCount; - const newLines = bg.output.length - bg.lastReadIndex; - - let changeSummary: string; - if (newLines === 0) { - changeSummary = "no new output"; - } else { - const parts: string[] = []; - parts.push(`${newLines} new lines`); - if (newErrors > 0) parts.push(`${newErrors} new errors`); - if (newWarnings > 0) parts.push(`${newWarnings} new warnings`); - changeSummary = parts.join(", "); - } - - // Only mutate snapshot counters when explicitly requested (e.g. from tool calls) - if (mutate) { - bg.lastErrorCount = bg.recentErrors.length; - bg.lastWarningCount = bg.recentWarnings.length; - } - - return { - status: bg.status, - uptime: formatUptime(Date.now() - bg.startedAt), - errors: bg.recentErrors.slice(-5), // Last 5 errors - warnings: bg.recentWarnings.slice(-3), // Last 3 warnings - urls: bg.urls, - ports: bg.ports, - lastActivity: bg.events.length > 0 - ? formatTimeAgo(bg.events[bg.events.length - 1].timestamp) - : "none", - outputLines: bg.output.length, - changeSummary, - }; -} - -// ── Highlight Extraction ─────────────────────────────────────────────────── - -function getHighlights(bg: BgProcess, maxLines: number = 15): string[] { - const lines: string[] = []; - - // Collect significant lines - const significant: { line: string; score: number; idx: number }[] = []; - for (let i = 0; i < bg.output.length; i++) { - const entry = bg.output[i]; - let score = 0; - if (ERROR_PATTERNS.some(p => p.test(entry.line))) score += 10; - if (WARNING_PATTERNS.some(p => p.test(entry.line))) score += 5; - if (URL_PATTERN.test(entry.line)) score += 3; - if (READINESS_PATTERNS.some(p => p.test(entry.line))) score += 8; - if (TEST_RESULT_PATTERNS.some(p => p.test(entry.line))) score += 7; - if (BUILD_COMPLETE_PATTERNS.some(p => p.test(entry.line))) score += 6; - // Boost recent lines so highlights favor fresh output over stale - if (i >= bg.output.length - 50) score += 2; - if (score > 0) { - significant.push({ line: entry.line.trim().slice(0, 300), score, idx: i }); - } - } - - // Sort by significance (tie-break by recency) - significant.sort((a, b) => b.score - a.score || b.idx - a.idx); - const top = significant.slice(0, maxLines); - - if (top.length === 0) { - // If nothing significant, show last few lines - const tail = bg.output.slice(-5); - for (const l of tail) lines.push(l.line.trim().slice(0, 300)); - } else { - for (const entry of top) lines.push(entry.line); - } - - return lines; -} - -// ── Process Start ────────────────────────────────────────────────────────── - -interface StartOptions { - command: string; - cwd: string; - label?: string; - type?: ProcessType; - readyPattern?: string; - readyPort?: number; - readyTimeout?: number; - group?: string; - env?: Record; -} - -function startProcess(opts: StartOptions): BgProcess { - const id = randomUUID().slice(0, 8); - const processType = opts.type || detectProcessType(opts.command); - - const env = { ...process.env, ...(opts.env || {}) }; - - const { shell, args: shellArgs } = getShellConfig(); - // Shell sessions default to the user's shell if no command specified - const command = processType === "shell" && !opts.command ? shell : opts.command; - const proc = spawn(shell, [...shellArgs, sanitizeCommand(command)], { - cwd: opts.cwd, - stdio: ["pipe", "pipe", "pipe"], - env, - detached: process.platform !== "win32", - }); - - const bg: BgProcess = { - id, - label: opts.label || command.slice(0, 60), - command, - cwd: opts.cwd, - startedAt: Date.now(), - proc, - output: [], - exitCode: null, - signal: null, - alive: true, - lastReadIndex: 0, - processType, - status: "starting", - ports: [], - urls: [], - recentErrors: [], - recentWarnings: [], - events: [], - readyPattern: opts.readyPattern || null, - readyPort: opts.readyPort || null, - wasReady: false, - group: opts.group || null, - lastErrorCount: 0, - lastWarningCount: 0, - commandHistory: [], - lineDedup: new Map(), - totalRawLines: 0, - envKeys: Object.keys(opts.env || {}), - restartCount: 0, - startConfig: { - command, - cwd: opts.cwd, - label: opts.label || command.slice(0, 60), - processType, - readyPattern: opts.readyPattern || null, - readyPort: opts.readyPort || null, - group: opts.group || null, - }, - }; - - addEvent(bg, { type: "started", detail: `Process started: ${command.slice(0, 100)}` }); - - proc.stdout?.on("data", (chunk: Buffer) => { - const lines = chunk.toString().split("\n"); - for (const line of lines) { - if (line.length > 0) { - addOutputLine(bg, "stdout", line); - analyzeLine(bg, line, "stdout"); - } - } - }); - - proc.stderr?.on("data", (chunk: Buffer) => { - const lines = chunk.toString().split("\n"); - for (const line of lines) { - if (line.length > 0) { - addOutputLine(bg, "stderr", line); - analyzeLine(bg, line, "stderr"); - } - } - }); - - proc.on("exit", (code, sig) => { - restoreWindowsVTInput(); - bg.alive = false; - bg.exitCode = code; - bg.signal = sig ?? null; - - if (code === 0) { - bg.status = "exited"; - addEvent(bg, { type: "exited", detail: `Exited cleanly (code 0)` }); - } else { - bg.status = "crashed"; - const lastErrors = bg.recentErrors.slice(-3).join("; "); - const detail = `Crashed with code ${code}${sig ? ` (signal ${sig})` : ""}${lastErrors ? ` — ${lastErrors}` : ""}`; - addEvent(bg, { - type: "crashed", - detail, - data: { exitCode: code, signal: sig, lastErrors: bg.recentErrors.slice(-5) }, - }); - pushAlert(bg, `CRASHED (code ${code})${lastErrors ? `: ${lastErrors.slice(0, 120)}` : ""}`); - } - }); - - proc.on("error", (err) => { - bg.alive = false; - bg.status = "crashed"; - addOutputLine(bg, "stderr", `[spawn error] ${err.message}`); - addEvent(bg, { type: "crashed", detail: `Spawn error: ${err.message}` }); - pushAlert(bg, `spawn error: ${err.message}`); - }); - - // Port probing for server-type processes - if (bg.readyPort) { - startPortProbing(bg, bg.readyPort, opts.readyTimeout); - } - - // Shell sessions are ready immediately after spawn - if (bg.processType === "shell") { - setTimeout(() => { - if (bg.alive && bg.status === "starting") { - transitionToReady(bg, "Shell session initialized"); - } - }, 200); - } - - processes.set(id, bg); - return bg; -} - -// ── Port Probing Loop ────────────────────────────────────────────────────── - -function startPortProbing(bg: BgProcess, port: number, customTimeout?: number): void { - const timeout = customTimeout || DEFAULT_READY_TIMEOUT; - const interval = setInterval(async () => { - if (!bg.alive) { - clearInterval(interval); - const stderrLines = bg.output.filter(l => l.stream === "stderr").slice(-10).map(l => l.line); - const detail = `Process exited (code ${bg.exitCode}) before port ${port} opened${stderrLines.length > 0 ? ` — ${stderrLines.join("; ").slice(0, 200)}` : ""}`; - addEvent(bg, { type: "port_timeout", detail, data: { port, exitCode: bg.exitCode } }); - return; - } - if (bg.status !== "starting") { - clearInterval(interval); - return; - } - const open = await probePort(port); - if (open) { - clearInterval(interval); - if (!bg.ports.includes(port)) bg.ports.push(port); - transitionToReady(bg, `Port ${port} is open`); - addEvent(bg, { type: "port_open", detail: `Port ${port} is open`, data: { port } }); - } - }, READY_POLL_INTERVAL); - - // Stop probing after timeout — transition to error state so the process - // doesn't stay in "starting" forever (fixes #428) - setTimeout(() => { - clearInterval(interval); - if (bg.alive && bg.status === "starting") { - const stderrLines = bg.output.filter(l => l.stream === "stderr").slice(-10).map(l => l.line); - const detail = `Port ${port} not open after ${timeout}ms${stderrLines.length > 0 ? ` — ${stderrLines.join("; ").slice(0, 200)}` : ""}`; - bg.status = "error"; - addEvent(bg, { type: "port_timeout", detail, data: { port, timeout } }); - pushAlert(bg, `Port ${port} readiness timeout after ${timeout / 1000}s`); - } - }, timeout); -} - -// ── Process Kill ─────────────────────────────────────────────────────────── - -function killProcess(id: string, sig: NodeJS.Signals = "SIGTERM"): boolean { - const bg = processes.get(id); - if (!bg) return false; - if (!bg.alive) return true; - try { - if (process.platform === "win32") { - // Windows: use taskkill /F /T to force-kill the entire process tree. - // process.kill(-pid) (Unix process groups) does not work on Windows. - if (bg.proc.pid) { - const result = spawnSync("taskkill", ["/F", "/T", "/PID", String(bg.proc.pid)], { - timeout: 5000, - encoding: "utf-8", - }); - if (result.status !== 0 && result.status !== 128) { - // taskkill failed — try the direct kill as fallback - bg.proc.kill(sig); - } - } else { - bg.proc.kill(sig); - } - } else { - // Unix/macOS: kill the process group via negative PID - if (bg.proc.pid) { - try { - process.kill(-bg.proc.pid, sig); - } catch { - bg.proc.kill(sig); - } - } else { - bg.proc.kill(sig); - } - } - return true; - } catch { - return false; - } -} - -// ── Process Restart ──────────────────────────────────────────────────────── - -async function restartProcess(id: string): Promise { - const old = processes.get(id); - if (!old) return null; - - const config = old.startConfig; - const restartCount = old.restartCount + 1; - - // Kill old process - if (old.alive) { - killProcess(id, "SIGTERM"); - await new Promise(r => setTimeout(r, 300)); - if (old.alive) { - killProcess(id, "SIGKILL"); - await new Promise(r => setTimeout(r, 200)); - } - } - processes.delete(id); - - // Start new one - const newBg = startProcess({ - command: config.command, - cwd: config.cwd, - label: config.label, - type: config.processType, - readyPattern: config.readyPattern || undefined, - readyPort: config.readyPort || undefined, - group: config.group || undefined, - }); - newBg.restartCount = restartCount; - - return newBg; -} - -// ── Output Retrieval (multi-tier) ────────────────────────────────────────── - -interface GetOutputOptions { - stream: "stdout" | "stderr" | "both"; - tail?: number; - filter?: string; - incremental?: boolean; -} - -function getOutput(bg: BgProcess, opts: GetOutputOptions): string { - const { stream, tail, filter, incremental } = opts; - - // Get the relevant slice of the unified buffer (already in chronological order) - let entries: OutputLine[]; - if (incremental) { - entries = bg.output.slice(bg.lastReadIndex); - bg.lastReadIndex = bg.output.length; - } else { - entries = [...bg.output]; - } - - // Filter by stream if requested - if (stream !== "both") { - entries = entries.filter(e => e.stream === stream); - } - - // Apply regex filter - if (filter) { - try { - const re = new RegExp(filter, "i"); - entries = entries.filter(e => re.test(e.line)); - } catch { /* invalid regex */ } - } - - // Tail - if (tail && tail > 0 && entries.length > tail) { - entries = entries.slice(-tail); - } - - const lines = entries.map(e => e.line); - const raw = lines.join("\n"); - const truncation = truncateHead(raw, { - maxLines: DEFAULT_MAX_LINES, - maxBytes: DEFAULT_MAX_BYTES, - }); - - let result = truncation.content; - if (truncation.truncated) { - result += `\n\n[Output truncated: showing ${truncation.outputLines}/${truncation.totalLines} lines]`; - } - return result; -} - -// ── Wait for Ready ───────────────────────────────────────────────────────── - -async function waitForReady(bg: BgProcess, timeout: number, signal?: AbortSignal): Promise<{ ready: boolean; detail: string }> { - const start = Date.now(); - - while (Date.now() - start < timeout) { - if (signal?.aborted) { - return { ready: false, detail: "Cancelled" }; - } - if (!bg.alive) { - const stderrLines = bg.output.filter(l => l.stream === "stderr").slice(-5).map(l => l.line); - const stderrContext = stderrLines.length > 0 ? `\nstderr:\n${stderrLines.join("\n").slice(0, 500)}` : ""; - return { - ready: false, - detail: `Process exited before becoming ready (code ${bg.exitCode})${bg.recentErrors.length > 0 ? ` — ${bg.recentErrors.slice(-1)[0]}` : ""}${stderrContext}`, - }; - } - if (bg.status === "error") { - const stderrLines = bg.output.filter(l => l.stream === "stderr").slice(-5).map(l => l.line); - const stderrContext = stderrLines.length > 0 ? `\nstderr:\n${stderrLines.join("\n").slice(0, 500)}` : ""; - return { - ready: false, - detail: `Process entered error state${bg.readyPort ? ` (port ${bg.readyPort} never opened)` : ""}${stderrContext}`, - }; - } - if (bg.status === "ready") { - return { - ready: true, - detail: bg.events.find(e => e.type === "ready")?.detail || "Process is ready", - }; - } - await new Promise(r => setTimeout(r, READY_POLL_INTERVAL)); - } - - // Timeout — try port probe as last resort - if (bg.readyPort) { - const open = await probePort(bg.readyPort); - if (open) { - transitionToReady(bg, `Port ${bg.readyPort} is open (detected at timeout)`); - return { ready: true, detail: `Port ${bg.readyPort} is open` }; - } - } - - const stderrLines = bg.output.filter(l => l.stream === "stderr").slice(-5).map(l => l.line); - const stderrContext = stderrLines.length > 0 ? `\nstderr:\n${stderrLines.join("\n").slice(0, 500)}` : ""; - return { ready: false, detail: `Timed out after ${timeout}ms waiting for ready signal${stderrContext}` }; -} - -// ── Query Shell Environment ──────────────────────────────────────────────── - -async function queryShellEnv( - bg: BgProcess, - timeout: number, - signal?: AbortSignal, -): Promise<{ cwd: string; env: Record; shell: string } | null> { - const sentinel = `__GSD_ENV_${randomUUID().slice(0, 8)}__`; - const startIndex = bg.output.length; - - const cmd = [ - `echo "${sentinel}_START"`, - `echo "CWD=$(pwd)"`, - `echo "SHELL=$SHELL"`, - `echo "PATH=$PATH"`, - `echo "VIRTUAL_ENV=$VIRTUAL_ENV"`, - `echo "NODE_ENV=$NODE_ENV"`, - `echo "HOME=$HOME"`, - `echo "USER=$USER"`, - `echo "NVM_DIR=$NVM_DIR"`, - `echo "GOPATH=$GOPATH"`, - `echo "CARGO_HOME=$CARGO_HOME"`, - `echo "PYTHONPATH=$PYTHONPATH"`, - `echo "${sentinel}_END"`, - ].join(" && "); - - bg.proc.stdin?.write(cmd + "\n"); - - const start = Date.now(); - while (Date.now() - start < timeout) { - if (signal?.aborted) return null; - if (!bg.alive) return null; - - const newEntries = bg.output.slice(startIndex); - const endIdx = newEntries.findIndex(e => e.line.includes(`${sentinel}_END`)); - if (endIdx >= 0) { - const startIdx = newEntries.findIndex(e => e.line.includes(`${sentinel}_START`)); - if (startIdx >= 0) { - const envLines = newEntries.slice(startIdx + 1, endIdx); - const env: Record = {}; - let cwd = ""; - let shell = ""; - - for (const entry of envLines) { - const match = entry.line.match(/^([A-Z_]+)=(.*)$/); - if (match) { - const [, key, value] = match; - if (key === "CWD") { - cwd = value; - } else if (key === "SHELL") { - shell = value; - } else if (value) { - env[key] = value; - } - } - } - - return { cwd, env, shell }; - } - } - - await new Promise(r => setTimeout(r, 100)); - } - - return null; -} - -// ── Send and Wait ────────────────────────────────────────────────────────── - -async function sendAndWait( - bg: BgProcess, - input: string, - waitPattern: string, - timeout: number, - signal?: AbortSignal, -): Promise<{ matched: boolean; output: string }> { - // Snapshot the current position in the unified buffer before sending - const startIndex = bg.output.length; - bg.proc.stdin?.write(input + "\n"); - - let re: RegExp; - try { - re = new RegExp(waitPattern, "i"); - } catch { - return { matched: false, output: "Invalid wait pattern regex" }; - } - - const start = Date.now(); - while (Date.now() - start < timeout) { - if (signal?.aborted) { - const newEntries = bg.output.slice(startIndex); - return { matched: false, output: newEntries.map(e => e.line).join("\n") || "(cancelled)" }; - } - const newEntries = bg.output.slice(startIndex); - for (const entry of newEntries) { - if (re.test(entry.line)) { - return { matched: true, output: newEntries.map(e => e.line).join("\n") }; - } - } - await new Promise(r => setTimeout(r, 100)); - } - - const newEntries = bg.output.slice(startIndex); - return { matched: false, output: newEntries.map(e => e.line).join("\n") || "(no output)" }; -} - -// ── Run on Session ───────────────────────────────────────────────────────── - -async function runOnSession( - bg: BgProcess, - command: string, - timeout: number, - signal?: AbortSignal, -): Promise<{ exitCode: number; output: string; timedOut: boolean }> { - const sentinel = randomUUID().slice(0, 8); - const startMarker = `__GSD_SENTINEL_${sentinel}_START__`; - const endMarker = `__GSD_SENTINEL_${sentinel}_END__`; - const exitVar = `__GSD_EXIT_${sentinel}__`; - - // Snapshot current output buffer position - const startIndex = bg.output.length; - - // Write the sentinel-wrapped command to stdin - const wrappedCommand = [ - `echo ${startMarker}`, - command, - `${exitVar}=$?`, - `echo ${endMarker} $${exitVar}`, - ].join("\n"); - bg.proc.stdin?.write(wrappedCommand + "\n"); - - const start = Date.now(); - while (Date.now() - start < timeout) { - if (signal?.aborted) { - const newEntries = bg.output.slice(startIndex); - return { exitCode: -1, output: newEntries.map(e => e.line).join("\n") || "(cancelled)", timedOut: false }; - } - - // Process died while waiting - if (!bg.alive) { - const newEntries = bg.output.slice(startIndex); - const lines = newEntries.map(e => e.line); - return { exitCode: bg.proc.exitCode ?? -1, output: lines.join("\n") || "(process exited)", timedOut: false }; - } - - const newEntries = bg.output.slice(startIndex); - for (let i = 0; i < newEntries.length; i++) { - if (newEntries[i].line.includes(endMarker)) { - // Parse exit code from the END sentinel line - const endLine = newEntries[i].line; - const exitMatch = endLine.match(new RegExp(`${endMarker}\\s+(\\d+)`)); - const exitCode = exitMatch ? parseInt(exitMatch[1], 10) : -1; - - // Extract output between START and END sentinels - const outputLines: string[] = []; - let capturing = false; - for (let j = 0; j < newEntries.length; j++) { - if (newEntries[j].line.includes(startMarker)) { - capturing = true; - continue; - } - if (newEntries[j].line.includes(endMarker)) { - break; - } - if (capturing) { - outputLines.push(newEntries[j].line); - } - } - - return { exitCode, output: outputLines.join("\n"), timedOut: false }; - } - } - - await new Promise(r => setTimeout(r, 100)); - } - - // Timed out - const newEntries = bg.output.slice(startIndex); - const outputLines: string[] = []; - let capturing = false; - for (const entry of newEntries) { - if (entry.line.includes(startMarker)) { - capturing = true; - continue; - } - if (capturing) { - outputLines.push(entry.line); - } - } - return { exitCode: -1, output: outputLines.join("\n") || "(no output)", timedOut: true }; -} - -// ── Group Operations ─────────────────────────────────────────────────────── - -function getGroupProcesses(group: string): BgProcess[] { - return Array.from(processes.values()).filter(p => p.group === group); -} - -function getGroupStatus(group: string): { - group: string; - healthy: boolean; - processes: { id: string; label: string; status: ProcessStatus; alive: boolean }[]; -} { - const procs = getGroupProcesses(group); - const healthy = procs.length > 0 && procs.every(p => p.alive && (p.status === "ready" || p.status === "starting")); - return { - group, - healthy, - processes: procs.map(p => ({ - id: p.id, - label: p.label, - status: p.status, - alive: p.alive, - })), - }; -} - -// ── Persistence ──────────────────────────────────────────────────────────── - -interface ProcessManifest { - id: string; - label: string; - command: string; - cwd: string; - startedAt: number; - processType: ProcessType; - group: string | null; - readyPattern: string | null; - readyPort: number | null; - pid: number | undefined; -} - -function getManifestPath(cwd: string): string { - const dir = join(cwd, ".bg-shell"); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - return join(dir, "manifest.json"); -} - -function persistManifest(cwd: string): void { - try { - const manifest: ProcessManifest[] = Array.from(processes.values()) - .filter(p => p.alive) - .map(p => ({ - id: p.id, - label: p.label, - command: p.command, - cwd: p.cwd, - startedAt: p.startedAt, - processType: p.processType, - group: p.group, - readyPattern: p.readyPattern, - readyPort: p.readyPort, - pid: p.proc.pid, - })); - writeFileSync(getManifestPath(cwd), JSON.stringify(manifest, null, 2)); - } catch { /* best effort */ } -} - -function loadManifest(cwd: string): ProcessManifest[] { - try { - const path = getManifestPath(cwd); - if (existsSync(path)) { - return JSON.parse(readFileSync(path, "utf-8")); - } - } catch { /* best effort */ } - return []; -} - -// ── Utilities ────────────────────────────────────────────────────────────── - -function formatUptime(ms: number): string { - const seconds = Math.floor(ms / 1000); - if (seconds < 60) return `${seconds}s`; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ${seconds % 60}s`; - const hours = Math.floor(minutes / 60); - return `${hours}h ${minutes % 60}m`; -} - -function formatTimeAgo(timestamp: number): string { - return formatUptime(Date.now() - timestamp) + " ago"; -} - -// ── Cleanup ──────────────────────────────────────────────────────────────── - -function pruneDeadProcesses(): void { - const now = Date.now(); - for (const [id, bg] of processes) { - if (!bg.alive) { - const ttl = bg.processType === "shell" ? DEAD_PROCESS_TTL * 6 : DEAD_PROCESS_TTL; - if (now - bg.startedAt > ttl) { - processes.delete(id); - } - } - } -} - -function cleanupAll(): void { - for (const [id, bg] of processes) { - if (bg.alive) killProcess(id, "SIGKILL"); - } - processes.clear(); -} - -// ── Format Digest for LLM ────────────────────────────────────────────────── - -function formatDigestText(bg: BgProcess, digest: OutputDigest): string { - let text = `Process ${bg.id} (${bg.label}):\n`; - text += ` status: ${digest.status}\n`; - text += ` type: ${bg.processType}\n`; - text += ` uptime: ${digest.uptime}\n`; - - if (digest.ports.length > 0) text += ` ports: ${digest.ports.join(", ")}\n`; - if (digest.urls.length > 0) text += ` urls: ${digest.urls.join(", ")}\n`; - - text += ` output: ${digest.outputLines} lines\n`; - text += ` changes: ${digest.changeSummary}`; - - if (digest.errors.length > 0) { - text += `\n errors (${digest.errors.length}):`; - for (const err of digest.errors) { - text += `\n - ${err}`; - } - } - if (digest.warnings.length > 0) { - text += `\n warnings (${digest.warnings.length}):`; - for (const w of digest.warnings) { - text += `\n - ${w}`; - } - } - - return text; -} +// ── Sub-module imports ───────────────────────────────────────────────────── + +import type { BgProcessInfo, ProcessType, ProcessStatus } from "./types.js"; +import { DEFAULT_READY_TIMEOUT } from "./types.js"; +import { + processes, + pendingAlerts, + startProcess, + killProcess, + restartProcess, + getInfo, + getGroupStatus, + pruneDeadProcesses, + cleanupAll, + persistManifest, + loadManifest, + pushAlert, +} from "./process-manager.js"; +import { + generateDigest, + getHighlights, + getOutput, + formatDigestText, +} from "./output-formatter.js"; +import { waitForReady } from "./readiness-detector.js"; +import { queryShellEnv, sendAndWait, runOnSession } from "./interaction.js"; +import { formatUptime, formatTokenCount } from "./utilities.js"; +import { BgManagerOverlay } from "./overlay.js"; + +// ── Re-exports for consumers ─────────────────────────────────────────────── + +export type { ProcessStatus, ProcessType, BgProcess, BgProcessInfo, OutputDigest, OutputLine, ProcessEvent } from "./types.js"; +export { processes, startProcess, killProcess, restartProcess, cleanupAll } from "./process-manager.js"; +export { generateDigest, getHighlights, getOutput, formatDigestText } from "./output-formatter.js"; +export { waitForReady, probePort } from "./readiness-detector.js"; +export { sendAndWait, runOnSession, queryShellEnv } from "./interaction.js"; +export { BgManagerOverlay } from "./overlay.js"; // ── Extension Entry Point ────────────────────────────────────────────────── @@ -1274,7 +95,7 @@ export default function (pi: ExtensionAPI) { process.on("SIGINT", signalCleanup); process.on("beforeExit", signalCleanup); - // ── Compaction Awareness: Survive Context Resets ─────────────────── + // ── Compaction Awareness: Survive Context Resets ─────────────── /** Build a compact state summary of all alive processes for context re-injection */ function buildProcessStateAlert(reason: string): void { @@ -1353,7 +174,7 @@ export default function (pi: ExtensionAPI) { const manifest = loadManifest(ctx.cwd); if (manifest.length > 0) { // Check which PIDs are still alive - const surviving: ProcessManifest[] = []; + const surviving: typeof manifest = []; for (const entry of manifest) { if (entry.pid) { try { @@ -2515,14 +1336,6 @@ export default function (pi: ExtensionAPI) { return items.join(sep); } - function formatTokenCount(count: number): string { - if (count < 1000) return count.toString(); - if (count < 10000) return `${(count / 1000).toFixed(1)}k`; - if (count < 1000000) return `${Math.round(count / 1000)}k`; - if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`; - return `${Math.round(count / 1000000)}M`; - } - /** Reference to tui for triggering re-renders when footer is active */ let footerTui: { requestRender: () => void } | null = null; @@ -2758,422 +1571,3 @@ export default function (pi: ExtensionAPI) { cleanupAll(); }); } - -// ── TUI: Process Manager Overlay ─────────────────────────────────────────── - -class BgManagerOverlay { - private tui: { requestRender: () => void }; - private theme: Theme; - private onClose: () => void; - private selected = 0; - private mode: "list" | "output" | "events" = "list"; - private viewingProcess: BgProcess | null = null; - private scrollOffset = 0; - private cachedWidth?: number; - private cachedLines?: string[]; - private refreshTimer: ReturnType; - - constructor( - tui: { requestRender: () => void }, - theme: Theme, - onClose: () => void, - ) { - this.tui = tui; - this.theme = theme; - this.onClose = onClose; - this.refreshTimer = setInterval(() => { - this.invalidate(); - this.tui.requestRender(); - }, 1000); - } - - private getProcessList(): BgProcess[] { - return Array.from(processes.values()); - } - - selectAndView(index: number): void { - const procs = this.getProcessList(); - if (index >= 0 && index < procs.length) { - this.selected = index; - this.viewingProcess = procs[index]; - this.mode = "output"; - this.scrollOffset = Math.max(0, procs[index].output.length - 20); - } - } - - handleInput(data: string): void { - if (this.mode === "output") { - this.handleOutputInput(data); - return; - } - if (this.mode === "events") { - this.handleEventsInput(data); - return; - } - this.handleListInput(data); - } - - private handleListInput(data: string): void { - const procs = this.getProcessList(); - - if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || matchesKey(data, Key.ctrlAlt("b"))) { - clearInterval(this.refreshTimer); - this.onClose(); - return; - } - - if (matchesKey(data, Key.up) || matchesKey(data, "k")) { - if (this.selected > 0) { - this.selected--; - this.invalidate(); - this.tui.requestRender(); - } - return; - } - - if (matchesKey(data, Key.down) || matchesKey(data, "j")) { - if (this.selected < procs.length - 1) { - this.selected++; - this.invalidate(); - this.tui.requestRender(); - } - return; - } - - if (matchesKey(data, Key.enter)) { - const proc = procs[this.selected]; - if (proc) { - this.viewingProcess = proc; - this.mode = "output"; - this.scrollOffset = Math.max(0, proc.output.length - 20); - this.invalidate(); - this.tui.requestRender(); - } - return; - } - - // e = view events - if (data === "e") { - const proc = procs[this.selected]; - if (proc) { - this.viewingProcess = proc; - this.mode = "events"; - this.scrollOffset = Math.max(0, proc.events.length - 15); - this.invalidate(); - this.tui.requestRender(); - } - return; - } - - // r = restart - if (data === "r") { - const proc = procs[this.selected]; - if (proc) { - restartProcess(proc.id).then(() => { - this.invalidate(); - this.tui.requestRender(); - }); - } - return; - } - - // x or d = kill selected - if (data === "x" || data === "d") { - const proc = procs[this.selected]; - if (proc && proc.alive) { - killProcess(proc.id, "SIGTERM"); - setTimeout(() => { - if (proc.alive) killProcess(proc.id, "SIGKILL"); - this.invalidate(); - this.tui.requestRender(); - }, 300); - } - return; - } - - // X or D = kill all - if (data === "X" || data === "D") { - cleanupAll(); - this.selected = 0; - this.invalidate(); - this.tui.requestRender(); - return; - } - } - - private handleOutputInput(data: string): void { - if (matchesKey(data, Key.escape) || matchesKey(data, "q")) { - this.mode = "list"; - this.viewingProcess = null; - this.scrollOffset = 0; - this.invalidate(); - this.tui.requestRender(); - return; - } - - // Tab to switch to events view - if (matchesKey(data, Key.tab)) { - this.mode = "events"; - if (this.viewingProcess) { - this.scrollOffset = Math.max(0, this.viewingProcess.events.length - 15); - } - this.invalidate(); - this.tui.requestRender(); - return; - } - - if (matchesKey(data, Key.down) || matchesKey(data, "j")) { - if (this.viewingProcess) { - const total = this.viewingProcess.output.length; - this.scrollOffset = Math.min(this.scrollOffset + 5, Math.max(0, total - 20)); - } - this.invalidate(); - this.tui.requestRender(); - return; - } - - if (matchesKey(data, Key.up) || matchesKey(data, "k")) { - this.scrollOffset = Math.max(0, this.scrollOffset - 5); - this.invalidate(); - this.tui.requestRender(); - return; - } - - if (data === "G") { - if (this.viewingProcess) { - const total = this.viewingProcess.output.length; - this.scrollOffset = Math.max(0, total - 20); - } - this.invalidate(); - this.tui.requestRender(); - return; - } - - if (data === "g") { - this.scrollOffset = 0; - this.invalidate(); - this.tui.requestRender(); - return; - } - } - - private handleEventsInput(data: string): void { - if (matchesKey(data, Key.escape) || matchesKey(data, "q")) { - this.mode = "list"; - this.viewingProcess = null; - this.scrollOffset = 0; - this.invalidate(); - this.tui.requestRender(); - return; - } - - // Tab to switch back to output view - if (matchesKey(data, Key.tab)) { - this.mode = "output"; - if (this.viewingProcess) { - this.scrollOffset = Math.max(0, this.viewingProcess.output.length - 20); - } - this.invalidate(); - this.tui.requestRender(); - return; - } - - if (matchesKey(data, Key.down) || matchesKey(data, "j")) { - if (this.viewingProcess) { - this.scrollOffset = Math.min(this.scrollOffset + 3, Math.max(0, this.viewingProcess.events.length - 10)); - } - this.invalidate(); - this.tui.requestRender(); - return; - } - - if (matchesKey(data, Key.up) || matchesKey(data, "k")) { - this.scrollOffset = Math.max(0, this.scrollOffset - 3); - this.invalidate(); - this.tui.requestRender(); - return; - } - } - - render(width: number): string[] { - if (this.cachedLines && this.cachedWidth === width) { - return this.cachedLines; - } - - let lines: string[]; - if (this.mode === "events") { - lines = this.renderEvents(width); - } else if (this.mode === "output") { - lines = this.renderOutput(width); - } else { - lines = this.renderList(width); - } - - this.cachedWidth = width; - this.cachedLines = lines; - return lines; - } - - private box(inner: string[], width: number): string[] { - const th = this.theme; - const bdr = (s: string) => th.fg("borderMuted", s); - const iw = width - 4; - const lines: string[] = []; - - lines.push(bdr("╭" + "─".repeat(width - 2) + "╮")); - for (const line of inner) { - const truncated = truncateToWidth(line, iw); - const pad = Math.max(0, iw - visibleWidth(truncated)); - lines.push(bdr("│") + " " + truncated + " ".repeat(pad) + " " + bdr("│")); - } - lines.push(bdr("╰" + "─".repeat(width - 2) + "╯")); - return lines; - } - - private renderList(width: number): string[] { - const th = this.theme; - const procs = this.getProcessList(); - const inner: string[] = []; - - if (procs.length === 0) { - inner.push(th.fg("dim", "No background processes.")); - inner.push(""); - inner.push(th.fg("dim", "esc close")); - return this.box(inner, width); - } - - inner.push(th.fg("dim", "Background Processes")); - inner.push(""); - - for (let i = 0; i < procs.length; i++) { - const p = procs[i]; - const sel = i === this.selected; - const pointer = sel ? th.fg("accent", "▸ ") : " "; - - const statusIcon = p.alive - ? (p.status === "ready" ? th.fg("success", "●") - : p.status === "error" ? th.fg("error", "●") - : th.fg("warning", "●")) - : th.fg("dim", "○"); - - const uptime = th.fg("dim", formatUptime(Date.now() - p.startedAt)); - const name = sel ? th.fg("text", p.label) : th.fg("muted", p.label); - const typeTag = th.fg("dim", `[${p.processType}]`); - const portInfo = p.ports.length > 0 ? th.fg("dim", ` :${p.ports.join(",")}`) : ""; - const errBadge = p.recentErrors.length > 0 ? th.fg("error", ` ⚠${p.recentErrors.length}`) : ""; - const groupTag = p.group ? th.fg("dim", ` {${p.group}}`) : ""; - const restartBadge = p.restartCount > 0 ? th.fg("warning", ` ↻${p.restartCount}`) : ""; - - const status = p.alive ? "" : " " + th.fg("dim", `exit ${p.exitCode}`); - - inner.push(`${pointer}${statusIcon} ${name} ${typeTag} ${uptime}${portInfo}${errBadge}${groupTag}${restartBadge}${status}`); - } - - inner.push(""); - inner.push(th.fg("dim", "↑↓ select · enter output · e events · r restart · x kill · esc close")); - - return this.box(inner, width); - } - - private renderOutput(width: number): string[] { - const th = this.theme; - const p = this.viewingProcess; - if (!p) return [""]; - const inner: string[] = []; - - const statusIcon = p.alive - ? (p.status === "ready" ? th.fg("success", "●") - : p.status === "error" ? th.fg("error", "●") - : th.fg("warning", "●")) - : th.fg("dim", "○"); - const name = th.fg("muted", p.label); - const uptime = th.fg("dim", formatUptime(Date.now() - p.startedAt)); - const typeTag = th.fg("dim", `[${p.processType}]`); - const portInfo = p.ports.length > 0 ? th.fg("dim", ` :${p.ports.join(",")}`) : ""; - const tabIndicator = th.fg("accent", "[Output]") + " " + th.fg("dim", "Events"); - - inner.push(`${statusIcon} ${name} ${typeTag} ${uptime}${portInfo} ${tabIndicator}`); - inner.push(""); - - // Unified buffer is already chronologically interleaved - const allOutput = p.output; - - const maxVisible = 18; - const visible = allOutput.slice(this.scrollOffset, this.scrollOffset + maxVisible); - - if (allOutput.length === 0) { - inner.push(th.fg("dim", "(no output)")); - } else { - for (const entry of visible) { - const isError = ERROR_PATTERNS.some(pat => pat.test(entry.line)); - const isWarning = !isError && WARNING_PATTERNS.some(pat => pat.test(entry.line)); - const prefix = entry.stream === "stderr" ? th.fg("error", "⚠ ") : ""; - const color = isError ? "error" : isWarning ? "warning" : "dim"; - inner.push(prefix + th.fg(color, entry.line)); - } - - if (allOutput.length > maxVisible) { - inner.push(""); - const pos = `${this.scrollOffset + 1}–${Math.min(this.scrollOffset + maxVisible, allOutput.length)} of ${allOutput.length}`; - inner.push(th.fg("dim", pos)); - } - } - - inner.push(""); - inner.push(th.fg("dim", "↑↓ scroll · g/G top/end · tab events · q back")); - - return this.box(inner, width); - } - - private renderEvents(width: number): string[] { - const th = this.theme; - const p = this.viewingProcess; - if (!p) return [""]; - const inner: string[] = []; - - const statusIcon = p.alive - ? (p.status === "ready" ? th.fg("success", "●") - : p.status === "error" ? th.fg("error", "●") - : th.fg("warning", "●")) - : th.fg("dim", "○"); - const name = th.fg("muted", p.label); - const uptime = th.fg("dim", formatUptime(Date.now() - p.startedAt)); - const tabIndicator = th.fg("dim", "Output") + " " + th.fg("accent", "[Events]"); - - inner.push(`${statusIcon} ${name} ${uptime} ${tabIndicator}`); - inner.push(""); - - if (p.events.length === 0) { - inner.push(th.fg("dim", "(no events)")); - } else { - const maxVisible = 15; - const visible = p.events.slice(this.scrollOffset, this.scrollOffset + maxVisible); - - for (const ev of visible) { - const time = th.fg("dim", formatTimeAgo(ev.timestamp)); - const typeColor = ev.type === "crashed" || ev.type === "error_detected" ? "error" - : ev.type === "ready" || ev.type === "recovered" ? "success" - : ev.type === "port_open" ? "accent" - : "dim"; - const typeLabel = th.fg(typeColor, ev.type); - inner.push(`${time} ${typeLabel}`); - inner.push(` ${th.fg("dim", ev.detail.slice(0, 80))}`); - } - - if (p.events.length > maxVisible) { - inner.push(""); - inner.push(th.fg("dim", `${this.scrollOffset + 1}–${Math.min(this.scrollOffset + maxVisible, p.events.length)} of ${p.events.length} events`)); - } - } - - inner.push(""); - inner.push(th.fg("dim", "↑↓ scroll · tab output · q back")); - - return this.box(inner, width); - } - - invalidate(): void { - this.cachedWidth = undefined; - this.cachedLines = undefined; - } -} diff --git a/src/resources/extensions/bg-shell/interaction.ts b/src/resources/extensions/bg-shell/interaction.ts new file mode 100644 index 000000000..9fcac657d --- /dev/null +++ b/src/resources/extensions/bg-shell/interaction.ts @@ -0,0 +1,198 @@ +/** + * Expect-style interactions: send_and_wait, run on session, query shell environment. + */ + +import { randomUUID } from "node:crypto"; +import type { BgProcess } from "./types.js"; + +// ── Query Shell Environment ──────────────────────────────────────────────── + +export async function queryShellEnv( + bg: BgProcess, + timeout: number, + signal?: AbortSignal, +): Promise<{ cwd: string; env: Record; shell: string } | null> { + const sentinel = `__GSD_ENV_${randomUUID().slice(0, 8)}__`; + const startIndex = bg.output.length; + + const cmd = [ + `echo "${sentinel}_START"`, + `echo "CWD=$(pwd)"`, + `echo "SHELL=$SHELL"`, + `echo "PATH=$PATH"`, + `echo "VIRTUAL_ENV=$VIRTUAL_ENV"`, + `echo "NODE_ENV=$NODE_ENV"`, + `echo "HOME=$HOME"`, + `echo "USER=$USER"`, + `echo "NVM_DIR=$NVM_DIR"`, + `echo "GOPATH=$GOPATH"`, + `echo "CARGO_HOME=$CARGO_HOME"`, + `echo "PYTHONPATH=$PYTHONPATH"`, + `echo "${sentinel}_END"`, + ].join(" && "); + + bg.proc.stdin?.write(cmd + "\n"); + + const start = Date.now(); + while (Date.now() - start < timeout) { + if (signal?.aborted) return null; + if (!bg.alive) return null; + + const newEntries = bg.output.slice(startIndex); + const endIdx = newEntries.findIndex(e => e.line.includes(`${sentinel}_END`)); + if (endIdx >= 0) { + const startIdx = newEntries.findIndex(e => e.line.includes(`${sentinel}_START`)); + if (startIdx >= 0) { + const envLines = newEntries.slice(startIdx + 1, endIdx); + const env: Record = {}; + let cwd = ""; + let shell = ""; + + for (const entry of envLines) { + const match = entry.line.match(/^([A-Z_]+)=(.*)$/); + if (match) { + const [, key, value] = match; + if (key === "CWD") { + cwd = value; + } else if (key === "SHELL") { + shell = value; + } else if (value) { + env[key] = value; + } + } + } + + return { cwd, env, shell }; + } + } + + await new Promise(r => setTimeout(r, 100)); + } + + return null; +} + +// ── Send and Wait ────────────────────────────────────────────────────────── + +export async function sendAndWait( + bg: BgProcess, + input: string, + waitPattern: string, + timeout: number, + signal?: AbortSignal, +): Promise<{ matched: boolean; output: string }> { + // Snapshot the current position in the unified buffer before sending + const startIndex = bg.output.length; + bg.proc.stdin?.write(input + "\n"); + + let re: RegExp; + try { + re = new RegExp(waitPattern, "i"); + } catch { + return { matched: false, output: "Invalid wait pattern regex" }; + } + + const start = Date.now(); + while (Date.now() - start < timeout) { + if (signal?.aborted) { + const newEntries = bg.output.slice(startIndex); + return { matched: false, output: newEntries.map(e => e.line).join("\n") || "(cancelled)" }; + } + const newEntries = bg.output.slice(startIndex); + for (const entry of newEntries) { + if (re.test(entry.line)) { + return { matched: true, output: newEntries.map(e => e.line).join("\n") }; + } + } + await new Promise(r => setTimeout(r, 100)); + } + + const newEntries = bg.output.slice(startIndex); + return { matched: false, output: newEntries.map(e => e.line).join("\n") || "(no output)" }; +} + +// ── Run on Session ───────────────────────────────────────────────────────── + +export async function runOnSession( + bg: BgProcess, + command: string, + timeout: number, + signal?: AbortSignal, +): Promise<{ exitCode: number; output: string; timedOut: boolean }> { + const sentinel = randomUUID().slice(0, 8); + const startMarker = `__GSD_SENTINEL_${sentinel}_START__`; + const endMarker = `__GSD_SENTINEL_${sentinel}_END__`; + const exitVar = `__GSD_EXIT_${sentinel}__`; + + // Snapshot current output buffer position + const startIndex = bg.output.length; + + // Write the sentinel-wrapped command to stdin + const wrappedCommand = [ + `echo ${startMarker}`, + command, + `${exitVar}=$?`, + `echo ${endMarker} $${exitVar}`, + ].join("\n"); + bg.proc.stdin?.write(wrappedCommand + "\n"); + + const start = Date.now(); + while (Date.now() - start < timeout) { + if (signal?.aborted) { + const newEntries = bg.output.slice(startIndex); + return { exitCode: -1, output: newEntries.map(e => e.line).join("\n") || "(cancelled)", timedOut: false }; + } + + // Process died while waiting + if (!bg.alive) { + const newEntries = bg.output.slice(startIndex); + const lines = newEntries.map(e => e.line); + return { exitCode: bg.proc.exitCode ?? -1, output: lines.join("\n") || "(process exited)", timedOut: false }; + } + + const newEntries = bg.output.slice(startIndex); + for (let i = 0; i < newEntries.length; i++) { + if (newEntries[i].line.includes(endMarker)) { + // Parse exit code from the END sentinel line + const endLine = newEntries[i].line; + const exitMatch = endLine.match(new RegExp(`${endMarker}\\s+(\\d+)`)); + const exitCode = exitMatch ? parseInt(exitMatch[1], 10) : -1; + + // Extract output between START and END sentinels + const outputLines: string[] = []; + let capturing = false; + for (let j = 0; j < newEntries.length; j++) { + if (newEntries[j].line.includes(startMarker)) { + capturing = true; + continue; + } + if (newEntries[j].line.includes(endMarker)) { + break; + } + if (capturing) { + outputLines.push(newEntries[j].line); + } + } + + return { exitCode, output: outputLines.join("\n"), timedOut: false }; + } + } + + await new Promise(r => setTimeout(r, 100)); + } + + // Timed out + const newEntries = bg.output.slice(startIndex); + const outputLines: string[] = []; + let capturing = false; + for (const entry of newEntries) { + if (entry.line.includes(startMarker)) { + capturing = true; + continue; + } + if (capturing) { + outputLines.push(entry.line); + } + } + return { exitCode: -1, output: outputLines.join("\n") || "(no output)", timedOut: true }; +} diff --git a/src/resources/extensions/bg-shell/output-formatter.ts b/src/resources/extensions/bg-shell/output-formatter.ts new file mode 100644 index 000000000..044cf0068 --- /dev/null +++ b/src/resources/extensions/bg-shell/output-formatter.ts @@ -0,0 +1,259 @@ +/** + * Output analysis, digest generation, highlights extraction, and output retrieval. + */ + +import { + truncateHead, + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, +} from "@gsd/pi-coding-agent"; +import type { BgProcess, OutputDigest, OutputLine, GetOutputOptions } from "./types.js"; +import { + ERROR_PATTERNS, + WARNING_PATTERNS, + URL_PATTERN, + PORT_PATTERN, + READINESS_PATTERNS, + BUILD_COMPLETE_PATTERNS, + TEST_RESULT_PATTERNS, +} from "./types.js"; +import { addEvent, pushAlert } from "./process-manager.js"; +import { transitionToReady } from "./readiness-detector.js"; +import { formatUptime, formatTimeAgo } from "./utilities.js"; + +// ── Output Analysis ──────────────────────────────────────────────────────── + +export function analyzeLine(bg: BgProcess, line: string, stream: "stdout" | "stderr"): void { + // Error detection + if (ERROR_PATTERNS.some(p => p.test(line))) { + bg.recentErrors.push(line.trim().slice(0, 200)); // Cap line length + if (bg.recentErrors.length > 50) bg.recentErrors.splice(0, bg.recentErrors.length - 50); + + if (bg.status === "ready") { + bg.status = "error"; + addEvent(bg, { + type: "error_detected", + detail: line.trim().slice(0, 200), + data: { errorCount: bg.recentErrors.length }, + }); + pushAlert(bg, `error_detected: ${line.trim().slice(0, 120)}`); + } + } + + // Warning detection + if (WARNING_PATTERNS.some(p => p.test(line))) { + bg.recentWarnings.push(line.trim().slice(0, 200)); + if (bg.recentWarnings.length > 50) bg.recentWarnings.splice(0, bg.recentWarnings.length - 50); + } + + // URL extraction + const urlMatches = line.match(URL_PATTERN); + if (urlMatches) { + for (const url of urlMatches) { + if (!bg.urls.includes(url)) { + bg.urls.push(url); + } + } + } + + // Port extraction + let portMatch: RegExpExecArray | null; + const portRe = new RegExp(PORT_PATTERN.source, PORT_PATTERN.flags); + while ((portMatch = portRe.exec(line)) !== null) { + const port = parseInt(portMatch[1], 10); + if (port > 0 && port <= 65535 && !bg.ports.includes(port)) { + bg.ports.push(port); + addEvent(bg, { + type: "port_open", + detail: `Port ${port} detected`, + data: { port }, + }); + } + } + + // Readiness detection + if (bg.status === "starting") { + // Check custom ready pattern first + if (bg.readyPattern) { + try { + if (new RegExp(bg.readyPattern, "i").test(line)) { + transitionToReady(bg, `Custom pattern matched: ${line.trim().slice(0, 100)}`); + } + } catch { /* invalid regex, skip */ } + } + + // Check built-in readiness patterns + if (bg.status === "starting" && READINESS_PATTERNS.some(p => p.test(line))) { + transitionToReady(bg, `Readiness pattern matched: ${line.trim().slice(0, 100)}`); + } + } + + // Recovery detection: if we were in error and see a success pattern + if (bg.status === "error") { + if (READINESS_PATTERNS.some(p => p.test(line)) || BUILD_COMPLETE_PATTERNS.some(p => p.test(line))) { + bg.status = "ready"; + bg.recentErrors = []; + addEvent(bg, { type: "recovered", detail: "Process recovered from error state" }); + pushAlert(bg, "recovered — errors cleared"); + } + } + + // Dedup tracking + bg.totalRawLines++; + const lineHash = line.trim().slice(0, 100); + bg.lineDedup.set(lineHash, (bg.lineDedup.get(lineHash) || 0) + 1); +} + +// ── Digest Generation ────────────────────────────────────────────────────── + +export function generateDigest(bg: BgProcess, mutate: boolean = false): OutputDigest { + // Change summary: what's different since last read + const newErrors = bg.recentErrors.length - bg.lastErrorCount; + const newWarnings = bg.recentWarnings.length - bg.lastWarningCount; + const newLines = bg.output.length - bg.lastReadIndex; + + let changeSummary: string; + if (newLines === 0) { + changeSummary = "no new output"; + } else { + const parts: string[] = []; + parts.push(`${newLines} new lines`); + if (newErrors > 0) parts.push(`${newErrors} new errors`); + if (newWarnings > 0) parts.push(`${newWarnings} new warnings`); + changeSummary = parts.join(", "); + } + + // Only mutate snapshot counters when explicitly requested (e.g. from tool calls) + if (mutate) { + bg.lastErrorCount = bg.recentErrors.length; + bg.lastWarningCount = bg.recentWarnings.length; + } + + return { + status: bg.status, + uptime: formatUptime(Date.now() - bg.startedAt), + errors: bg.recentErrors.slice(-5), // Last 5 errors + warnings: bg.recentWarnings.slice(-3), // Last 3 warnings + urls: bg.urls, + ports: bg.ports, + lastActivity: bg.events.length > 0 + ? formatTimeAgo(bg.events[bg.events.length - 1].timestamp) + : "none", + outputLines: bg.output.length, + changeSummary, + }; +} + +// ── Highlight Extraction ─────────────────────────────────────────────────── + +export function getHighlights(bg: BgProcess, maxLines: number = 15): string[] { + const lines: string[] = []; + + // Collect significant lines + const significant: { line: string; score: number; idx: number }[] = []; + for (let i = 0; i < bg.output.length; i++) { + const entry = bg.output[i]; + let score = 0; + if (ERROR_PATTERNS.some(p => p.test(entry.line))) score += 10; + if (WARNING_PATTERNS.some(p => p.test(entry.line))) score += 5; + if (URL_PATTERN.test(entry.line)) score += 3; + if (READINESS_PATTERNS.some(p => p.test(entry.line))) score += 8; + if (TEST_RESULT_PATTERNS.some(p => p.test(entry.line))) score += 7; + if (BUILD_COMPLETE_PATTERNS.some(p => p.test(entry.line))) score += 6; + // Boost recent lines so highlights favor fresh output over stale + if (i >= bg.output.length - 50) score += 2; + if (score > 0) { + significant.push({ line: entry.line.trim().slice(0, 300), score, idx: i }); + } + } + + // Sort by significance (tie-break by recency) + significant.sort((a, b) => b.score - a.score || b.idx - a.idx); + const top = significant.slice(0, maxLines); + + if (top.length === 0) { + // If nothing significant, show last few lines + const tail = bg.output.slice(-5); + for (const l of tail) lines.push(l.line.trim().slice(0, 300)); + } else { + for (const entry of top) lines.push(entry.line); + } + + return lines; +} + +// ── Output Retrieval (multi-tier) ────────────────────────────────────────── + +export function getOutput(bg: BgProcess, opts: GetOutputOptions): string { + const { stream, tail, filter, incremental } = opts; + + // Get the relevant slice of the unified buffer (already in chronological order) + let entries: OutputLine[]; + if (incremental) { + entries = bg.output.slice(bg.lastReadIndex); + bg.lastReadIndex = bg.output.length; + } else { + entries = [...bg.output]; + } + + // Filter by stream if requested + if (stream !== "both") { + entries = entries.filter(e => e.stream === stream); + } + + // Apply regex filter + if (filter) { + try { + const re = new RegExp(filter, "i"); + entries = entries.filter(e => re.test(e.line)); + } catch { /* invalid regex */ } + } + + // Tail + if (tail && tail > 0 && entries.length > tail) { + entries = entries.slice(-tail); + } + + const lines = entries.map(e => e.line); + const raw = lines.join("\n"); + const truncation = truncateHead(raw, { + maxLines: DEFAULT_MAX_LINES, + maxBytes: DEFAULT_MAX_BYTES, + }); + + let result = truncation.content; + if (truncation.truncated) { + result += `\n\n[Output truncated: showing ${truncation.outputLines}/${truncation.totalLines} lines]`; + } + return result; +} + +// ── Format Digest for LLM ────────────────────────────────────────────────── + +export function formatDigestText(bg: BgProcess, digest: OutputDigest): string { + let text = `Process ${bg.id} (${bg.label}):\n`; + text += ` status: ${digest.status}\n`; + text += ` type: ${bg.processType}\n`; + text += ` uptime: ${digest.uptime}\n`; + + if (digest.ports.length > 0) text += ` ports: ${digest.ports.join(", ")}\n`; + if (digest.urls.length > 0) text += ` urls: ${digest.urls.join(", ")}\n`; + + text += ` output: ${digest.outputLines} lines\n`; + text += ` changes: ${digest.changeSummary}`; + + if (digest.errors.length > 0) { + text += `\n errors (${digest.errors.length}):`; + for (const err of digest.errors) { + text += `\n - ${err}`; + } + } + if (digest.warnings.length > 0) { + text += `\n warnings (${digest.warnings.length}):`; + for (const w of digest.warnings) { + text += `\n - ${w}`; + } + } + + return text; +} diff --git a/src/resources/extensions/bg-shell/overlay.ts b/src/resources/extensions/bg-shell/overlay.ts new file mode 100644 index 000000000..ed8c45c74 --- /dev/null +++ b/src/resources/extensions/bg-shell/overlay.ts @@ -0,0 +1,432 @@ +/** + * TUI: Background Process Manager Overlay. + */ + +import type { Theme } from "@gsd/pi-coding-agent"; +import { truncateToWidth, visibleWidth, matchesKey, Key } from "@gsd/pi-tui"; +import type { BgProcess, ProcessStatus } from "./types.js"; +import { ERROR_PATTERNS, WARNING_PATTERNS } from "./types.js"; +import { formatUptime, formatTimeAgo } from "./utilities.js"; +import { + processes, + killProcess, + cleanupAll, + restartProcess, +} from "./process-manager.js"; + +export class BgManagerOverlay { + private tui: { requestRender: () => void }; + private theme: Theme; + private onClose: () => void; + private selected = 0; + private mode: "list" | "output" | "events" = "list"; + private viewingProcess: BgProcess | null = null; + private scrollOffset = 0; + private cachedWidth?: number; + private cachedLines?: string[]; + private refreshTimer: ReturnType; + + constructor( + tui: { requestRender: () => void }, + theme: Theme, + onClose: () => void, + ) { + this.tui = tui; + this.theme = theme; + this.onClose = onClose; + this.refreshTimer = setInterval(() => { + this.invalidate(); + this.tui.requestRender(); + }, 1000); + } + + private getProcessList(): BgProcess[] { + return Array.from(processes.values()); + } + + selectAndView(index: number): void { + const procs = this.getProcessList(); + if (index >= 0 && index < procs.length) { + this.selected = index; + this.viewingProcess = procs[index]; + this.mode = "output"; + this.scrollOffset = Math.max(0, procs[index].output.length - 20); + } + } + + handleInput(data: string): void { + if (this.mode === "output") { + this.handleOutputInput(data); + return; + } + if (this.mode === "events") { + this.handleEventsInput(data); + return; + } + this.handleListInput(data); + } + + private handleListInput(data: string): void { + const procs = this.getProcessList(); + + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || matchesKey(data, Key.ctrlAlt("b"))) { + clearInterval(this.refreshTimer); + this.onClose(); + return; + } + + if (matchesKey(data, Key.up) || matchesKey(data, "k")) { + if (this.selected > 0) { + this.selected--; + this.invalidate(); + this.tui.requestRender(); + } + return; + } + + if (matchesKey(data, Key.down) || matchesKey(data, "j")) { + if (this.selected < procs.length - 1) { + this.selected++; + this.invalidate(); + this.tui.requestRender(); + } + return; + } + + if (matchesKey(data, Key.enter)) { + const proc = procs[this.selected]; + if (proc) { + this.viewingProcess = proc; + this.mode = "output"; + this.scrollOffset = Math.max(0, proc.output.length - 20); + this.invalidate(); + this.tui.requestRender(); + } + return; + } + + // e = view events + if (data === "e") { + const proc = procs[this.selected]; + if (proc) { + this.viewingProcess = proc; + this.mode = "events"; + this.scrollOffset = Math.max(0, proc.events.length - 15); + this.invalidate(); + this.tui.requestRender(); + } + return; + } + + // r = restart + if (data === "r") { + const proc = procs[this.selected]; + if (proc) { + restartProcess(proc.id).then(() => { + this.invalidate(); + this.tui.requestRender(); + }); + } + return; + } + + // x or d = kill selected + if (data === "x" || data === "d") { + const proc = procs[this.selected]; + if (proc && proc.alive) { + killProcess(proc.id, "SIGTERM"); + setTimeout(() => { + if (proc.alive) killProcess(proc.id, "SIGKILL"); + this.invalidate(); + this.tui.requestRender(); + }, 300); + } + return; + } + + // X or D = kill all + if (data === "X" || data === "D") { + cleanupAll(); + this.selected = 0; + this.invalidate(); + this.tui.requestRender(); + return; + } + } + + private handleOutputInput(data: string): void { + if (matchesKey(data, Key.escape) || matchesKey(data, "q")) { + this.mode = "list"; + this.viewingProcess = null; + this.scrollOffset = 0; + this.invalidate(); + this.tui.requestRender(); + return; + } + + // Tab to switch to events view + if (matchesKey(data, Key.tab)) { + this.mode = "events"; + if (this.viewingProcess) { + this.scrollOffset = Math.max(0, this.viewingProcess.events.length - 15); + } + this.invalidate(); + this.tui.requestRender(); + return; + } + + if (matchesKey(data, Key.down) || matchesKey(data, "j")) { + if (this.viewingProcess) { + const total = this.viewingProcess.output.length; + this.scrollOffset = Math.min(this.scrollOffset + 5, Math.max(0, total - 20)); + } + this.invalidate(); + this.tui.requestRender(); + return; + } + + if (matchesKey(data, Key.up) || matchesKey(data, "k")) { + this.scrollOffset = Math.max(0, this.scrollOffset - 5); + this.invalidate(); + this.tui.requestRender(); + return; + } + + if (data === "G") { + if (this.viewingProcess) { + const total = this.viewingProcess.output.length; + this.scrollOffset = Math.max(0, total - 20); + } + this.invalidate(); + this.tui.requestRender(); + return; + } + + if (data === "g") { + this.scrollOffset = 0; + this.invalidate(); + this.tui.requestRender(); + return; + } + } + + private handleEventsInput(data: string): void { + if (matchesKey(data, Key.escape) || matchesKey(data, "q")) { + this.mode = "list"; + this.viewingProcess = null; + this.scrollOffset = 0; + this.invalidate(); + this.tui.requestRender(); + return; + } + + // Tab to switch back to output view + if (matchesKey(data, Key.tab)) { + this.mode = "output"; + if (this.viewingProcess) { + this.scrollOffset = Math.max(0, this.viewingProcess.output.length - 20); + } + this.invalidate(); + this.tui.requestRender(); + return; + } + + if (matchesKey(data, Key.down) || matchesKey(data, "j")) { + if (this.viewingProcess) { + this.scrollOffset = Math.min(this.scrollOffset + 3, Math.max(0, this.viewingProcess.events.length - 10)); + } + this.invalidate(); + this.tui.requestRender(); + return; + } + + if (matchesKey(data, Key.up) || matchesKey(data, "k")) { + this.scrollOffset = Math.max(0, this.scrollOffset - 3); + this.invalidate(); + this.tui.requestRender(); + return; + } + } + + render(width: number): string[] { + if (this.cachedLines && this.cachedWidth === width) { + return this.cachedLines; + } + + let lines: string[]; + if (this.mode === "events") { + lines = this.renderEvents(width); + } else if (this.mode === "output") { + lines = this.renderOutput(width); + } else { + lines = this.renderList(width); + } + + this.cachedWidth = width; + this.cachedLines = lines; + return lines; + } + + private box(inner: string[], width: number): string[] { + const th = this.theme; + const bdr = (s: string) => th.fg("borderMuted", s); + const iw = width - 4; + const lines: string[] = []; + + lines.push(bdr("╭" + "─".repeat(width - 2) + "╮")); + for (const line of inner) { + const truncated = truncateToWidth(line, iw); + const pad = Math.max(0, iw - visibleWidth(truncated)); + lines.push(bdr("│") + " " + truncated + " ".repeat(pad) + " " + bdr("│")); + } + lines.push(bdr("╰" + "─".repeat(width - 2) + "╯")); + return lines; + } + + private renderList(width: number): string[] { + const th = this.theme; + const procs = this.getProcessList(); + const inner: string[] = []; + + if (procs.length === 0) { + inner.push(th.fg("dim", "No background processes.")); + inner.push(""); + inner.push(th.fg("dim", "esc close")); + return this.box(inner, width); + } + + inner.push(th.fg("dim", "Background Processes")); + inner.push(""); + + for (let i = 0; i < procs.length; i++) { + const p = procs[i]; + const sel = i === this.selected; + const pointer = sel ? th.fg("accent", "▸ ") : " "; + + const statusIcon = p.alive + ? (p.status === "ready" ? th.fg("success", "●") + : p.status === "error" ? th.fg("error", "●") + : th.fg("warning", "●")) + : th.fg("dim", "○"); + + const uptime = th.fg("dim", formatUptime(Date.now() - p.startedAt)); + const name = sel ? th.fg("text", p.label) : th.fg("muted", p.label); + const typeTag = th.fg("dim", `[${p.processType}]`); + const portInfo = p.ports.length > 0 ? th.fg("dim", ` :${p.ports.join(",")}`) : ""; + const errBadge = p.recentErrors.length > 0 ? th.fg("error", ` ⚠${p.recentErrors.length}`) : ""; + const groupTag = p.group ? th.fg("dim", ` {${p.group}}`) : ""; + const restartBadge = p.restartCount > 0 ? th.fg("warning", ` ↻${p.restartCount}`) : ""; + + const status = p.alive ? "" : " " + th.fg("dim", `exit ${p.exitCode}`); + + inner.push(`${pointer}${statusIcon} ${name} ${typeTag} ${uptime}${portInfo}${errBadge}${groupTag}${restartBadge}${status}`); + } + + inner.push(""); + inner.push(th.fg("dim", "↑↓ select · enter output · e events · r restart · x kill · esc close")); + + return this.box(inner, width); + } + + private renderOutput(width: number): string[] { + const th = this.theme; + const p = this.viewingProcess; + if (!p) return [""]; + const inner: string[] = []; + + const statusIcon = p.alive + ? (p.status === "ready" ? th.fg("success", "●") + : p.status === "error" ? th.fg("error", "●") + : th.fg("warning", "●")) + : th.fg("dim", "○"); + const name = th.fg("muted", p.label); + const uptime = th.fg("dim", formatUptime(Date.now() - p.startedAt)); + const typeTag = th.fg("dim", `[${p.processType}]`); + const portInfo = p.ports.length > 0 ? th.fg("dim", ` :${p.ports.join(",")}`) : ""; + const tabIndicator = th.fg("accent", "[Output]") + " " + th.fg("dim", "Events"); + + inner.push(`${statusIcon} ${name} ${typeTag} ${uptime}${portInfo} ${tabIndicator}`); + inner.push(""); + + // Unified buffer is already chronologically interleaved + const allOutput = p.output; + + const maxVisible = 18; + const visible = allOutput.slice(this.scrollOffset, this.scrollOffset + maxVisible); + + if (allOutput.length === 0) { + inner.push(th.fg("dim", "(no output)")); + } else { + for (const entry of visible) { + const isError = ERROR_PATTERNS.some(pat => pat.test(entry.line)); + const isWarning = !isError && WARNING_PATTERNS.some(pat => pat.test(entry.line)); + const prefix = entry.stream === "stderr" ? th.fg("error", "⚠ ") : ""; + const color = isError ? "error" : isWarning ? "warning" : "dim"; + inner.push(prefix + th.fg(color, entry.line)); + } + + if (allOutput.length > maxVisible) { + inner.push(""); + const pos = `${this.scrollOffset + 1}–${Math.min(this.scrollOffset + maxVisible, allOutput.length)} of ${allOutput.length}`; + inner.push(th.fg("dim", pos)); + } + } + + inner.push(""); + inner.push(th.fg("dim", "↑↓ scroll · g/G top/end · tab events · q back")); + + return this.box(inner, width); + } + + private renderEvents(width: number): string[] { + const th = this.theme; + const p = this.viewingProcess; + if (!p) return [""]; + const inner: string[] = []; + + const statusIcon = p.alive + ? (p.status === "ready" ? th.fg("success", "●") + : p.status === "error" ? th.fg("error", "●") + : th.fg("warning", "●")) + : th.fg("dim", "○"); + const name = th.fg("muted", p.label); + const uptime = th.fg("dim", formatUptime(Date.now() - p.startedAt)); + const tabIndicator = th.fg("dim", "Output") + " " + th.fg("accent", "[Events]"); + + inner.push(`${statusIcon} ${name} ${uptime} ${tabIndicator}`); + inner.push(""); + + if (p.events.length === 0) { + inner.push(th.fg("dim", "(no events)")); + } else { + const maxVisible = 15; + const visible = p.events.slice(this.scrollOffset, this.scrollOffset + maxVisible); + + for (const ev of visible) { + const time = th.fg("dim", formatTimeAgo(ev.timestamp)); + const typeColor = ev.type === "crashed" || ev.type === "error_detected" ? "error" + : ev.type === "ready" || ev.type === "recovered" ? "success" + : ev.type === "port_open" ? "accent" + : "dim"; + const typeLabel = th.fg(typeColor, ev.type); + inner.push(`${time} ${typeLabel}`); + inner.push(` ${th.fg("dim", ev.detail.slice(0, 80))}`); + } + + if (p.events.length > maxVisible) { + inner.push(""); + inner.push(th.fg("dim", `${this.scrollOffset + 1}–${Math.min(this.scrollOffset + maxVisible, p.events.length)} of ${p.events.length} events`)); + } + } + + inner.push(""); + inner.push(th.fg("dim", "↑↓ scroll · tab output · q back")); + + return this.box(inner, width); + } + + invalidate(): void { + this.cachedWidth = undefined; + this.cachedLines = undefined; + } +} diff --git a/src/resources/extensions/bg-shell/process-manager.ts b/src/resources/extensions/bg-shell/process-manager.ts new file mode 100644 index 000000000..603ddba66 --- /dev/null +++ b/src/resources/extensions/bg-shell/process-manager.ts @@ -0,0 +1,404 @@ +/** + * Process lifecycle management: start, stop, restart, signal, state tracking, + * process registry, and persistence. + */ + +import { spawn, spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { writeFileSync, readFileSync, existsSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { getShellConfig, sanitizeCommand } from "@gsd/pi-coding-agent"; +import type { + BgProcess, + BgProcessInfo, + ProcessEvent, + ProcessManifest, + ProcessType, + StartOptions, +} from "./types.js"; +import { + MAX_BUFFER_LINES, + MAX_EVENTS, + DEAD_PROCESS_TTL, +} from "./types.js"; +import { restoreWindowsVTInput, formatUptime } from "./utilities.js"; +import { analyzeLine } from "./output-formatter.js"; +import { startPortProbing, transitionToReady } from "./readiness-detector.js"; + +// ── Process Registry ─────────────────────────────────────────────────────── + +export const processes = new Map(); + +/** Pending alerts to inject into the next agent context */ +export let pendingAlerts: string[] = []; + +/** Replace the pendingAlerts array (used by the extension entry point) */ +export function setPendingAlerts(alerts: string[]): void { + pendingAlerts = alerts; +} + +export function addOutputLine(bg: BgProcess, stream: "stdout" | "stderr", line: string): void { + bg.output.push({ stream, line, ts: Date.now() }); + if (bg.output.length > MAX_BUFFER_LINES) { + const excess = bg.output.length - MAX_BUFFER_LINES; + bg.output.splice(0, excess); + // Adjust the read cursor so incremental delivery stays correct + bg.lastReadIndex = Math.max(0, bg.lastReadIndex - excess); + } +} + +export function addEvent(bg: BgProcess, event: Omit): void { + const ev: ProcessEvent = { ...event, timestamp: Date.now() }; + bg.events.push(ev); + if (bg.events.length > MAX_EVENTS) { + bg.events.splice(0, bg.events.length - MAX_EVENTS); + } +} + +export function pushAlert(bg: BgProcess, message: string): void { + pendingAlerts.push(`[bg:${bg.id} ${bg.label}] ${message}`); +} + +export function getInfo(p: BgProcess): BgProcessInfo { + const stdoutLines = p.output.filter(l => l.stream === "stdout").length; + const stderrLines = p.output.filter(l => l.stream === "stderr").length; + return { + id: p.id, + label: p.label, + command: p.command, + cwd: p.cwd, + startedAt: p.startedAt, + alive: p.alive, + exitCode: p.exitCode, + signal: p.signal, + outputLines: p.output.length, + stdoutLines, + stderrLines, + status: p.status, + processType: p.processType, + ports: p.ports, + urls: p.urls, + group: p.group, + restartCount: p.restartCount, + uptime: formatUptime(Date.now() - p.startedAt), + recentErrorCount: p.recentErrors.length, + recentWarningCount: p.recentWarnings.length, + eventCount: p.events.length, + }; +} + +// ── Process Type Detection ───────────────────────────────────────────────── + +export function detectProcessType(command: string): ProcessType { + const cmd = command.toLowerCase(); + + // Server patterns + if ( + /\b(serve|server|dev|start)\b/.test(cmd) && + /\b(npm|yarn|pnpm|bun|node|next|vite|nuxt|astro|remix|gatsby|uvicorn|flask|django|rails|cargo)\b/.test(cmd) + ) return "server"; + if (/\b(uvicorn|gunicorn|flask\s+run|manage\.py\s+runserver|rails\s+s)\b/.test(cmd)) return "server"; + if (/\b(http-server|live-server|serve)\b/.test(cmd)) return "server"; + + // Build patterns + if (/\b(build|compile|make|tsc|webpack|rollup|esbuild|swc)\b/.test(cmd)) { + if (/\b(watch|--watch|-w)\b/.test(cmd)) return "watcher"; + return "build"; + } + + // Test patterns + if (/\b(test|jest|vitest|mocha|pytest|cargo\s+test|go\s+test|rspec)\b/.test(cmd)) return "test"; + + // Watcher patterns + if (/\b(watch|nodemon|chokidar|fswatch|inotifywait)\b/.test(cmd)) return "watcher"; + + return "generic"; +} + +// ── Process Start ────────────────────────────────────────────────────────── + +export function startProcess(opts: StartOptions): BgProcess { + const id = randomUUID().slice(0, 8); + const processType = opts.type || detectProcessType(opts.command); + + const env = { ...process.env, ...(opts.env || {}) }; + + const { shell, args: shellArgs } = getShellConfig(); + // Shell sessions default to the user's shell if no command specified + const command = processType === "shell" && !opts.command ? shell : opts.command; + const proc = spawn(shell, [...shellArgs, sanitizeCommand(command)], { + cwd: opts.cwd, + stdio: ["pipe", "pipe", "pipe"], + env, + detached: process.platform !== "win32", + }); + + const bg: BgProcess = { + id, + label: opts.label || command.slice(0, 60), + command, + cwd: opts.cwd, + startedAt: Date.now(), + proc, + output: [], + exitCode: null, + signal: null, + alive: true, + lastReadIndex: 0, + processType, + status: "starting", + ports: [], + urls: [], + recentErrors: [], + recentWarnings: [], + events: [], + readyPattern: opts.readyPattern || null, + readyPort: opts.readyPort || null, + wasReady: false, + group: opts.group || null, + lastErrorCount: 0, + lastWarningCount: 0, + commandHistory: [], + lineDedup: new Map(), + totalRawLines: 0, + envKeys: Object.keys(opts.env || {}), + restartCount: 0, + startConfig: { + command, + cwd: opts.cwd, + label: opts.label || command.slice(0, 60), + processType, + readyPattern: opts.readyPattern || null, + readyPort: opts.readyPort || null, + group: opts.group || null, + }, + }; + + addEvent(bg, { type: "started", detail: `Process started: ${command.slice(0, 100)}` }); + + proc.stdout?.on("data", (chunk: Buffer) => { + const lines = chunk.toString().split("\n"); + for (const line of lines) { + if (line.length > 0) { + addOutputLine(bg, "stdout", line); + analyzeLine(bg, line, "stdout"); + } + } + }); + + proc.stderr?.on("data", (chunk: Buffer) => { + const lines = chunk.toString().split("\n"); + for (const line of lines) { + if (line.length > 0) { + addOutputLine(bg, "stderr", line); + analyzeLine(bg, line, "stderr"); + } + } + }); + + proc.on("exit", (code, sig) => { + restoreWindowsVTInput(); + bg.alive = false; + bg.exitCode = code; + bg.signal = sig ?? null; + + if (code === 0) { + bg.status = "exited"; + addEvent(bg, { type: "exited", detail: `Exited cleanly (code 0)` }); + } else { + bg.status = "crashed"; + const lastErrors = bg.recentErrors.slice(-3).join("; "); + const detail = `Crashed with code ${code}${sig ? ` (signal ${sig})` : ""}${lastErrors ? ` — ${lastErrors}` : ""}`; + addEvent(bg, { + type: "crashed", + detail, + data: { exitCode: code, signal: sig, lastErrors: bg.recentErrors.slice(-5) }, + }); + pushAlert(bg, `CRASHED (code ${code})${lastErrors ? `: ${lastErrors.slice(0, 120)}` : ""}`); + } + }); + + proc.on("error", (err) => { + bg.alive = false; + bg.status = "crashed"; + addOutputLine(bg, "stderr", `[spawn error] ${err.message}`); + addEvent(bg, { type: "crashed", detail: `Spawn error: ${err.message}` }); + pushAlert(bg, `spawn error: ${err.message}`); + }); + + // Port probing for server-type processes + if (bg.readyPort) { + startPortProbing(bg, bg.readyPort, opts.readyTimeout); + } + + // Shell sessions are ready immediately after spawn + if (bg.processType === "shell") { + setTimeout(() => { + if (bg.alive && bg.status === "starting") { + transitionToReady(bg, "Shell session initialized"); + } + }, 200); + } + + processes.set(id, bg); + return bg; +} + +// ── Process Kill ─────────────────────────────────────────────────────────── + +export function killProcess(id: string, sig: NodeJS.Signals = "SIGTERM"): boolean { + const bg = processes.get(id); + if (!bg) return false; + if (!bg.alive) return true; + try { + if (process.platform === "win32") { + // Windows: use taskkill /F /T to force-kill the entire process tree. + // process.kill(-pid) (Unix process groups) does not work on Windows. + if (bg.proc.pid) { + const result = spawnSync("taskkill", ["/F", "/T", "/PID", String(bg.proc.pid)], { + timeout: 5000, + encoding: "utf-8", + }); + if (result.status !== 0 && result.status !== 128) { + // taskkill failed — try the direct kill as fallback + bg.proc.kill(sig); + } + } else { + bg.proc.kill(sig); + } + } else { + // Unix/macOS: kill the process group via negative PID + if (bg.proc.pid) { + try { + process.kill(-bg.proc.pid, sig); + } catch { + bg.proc.kill(sig); + } + } else { + bg.proc.kill(sig); + } + } + return true; + } catch { + return false; + } +} + +// ── Process Restart ──────────────────────────────────────────────────────── + +export async function restartProcess(id: string): Promise { + const old = processes.get(id); + if (!old) return null; + + const config = old.startConfig; + const restartCount = old.restartCount + 1; + + // Kill old process + if (old.alive) { + killProcess(id, "SIGTERM"); + await new Promise(r => setTimeout(r, 300)); + if (old.alive) { + killProcess(id, "SIGKILL"); + await new Promise(r => setTimeout(r, 200)); + } + } + processes.delete(id); + + // Start new one + const newBg = startProcess({ + command: config.command, + cwd: config.cwd, + label: config.label, + type: config.processType, + readyPattern: config.readyPattern || undefined, + readyPort: config.readyPort || undefined, + group: config.group || undefined, + }); + newBg.restartCount = restartCount; + + return newBg; +} + +// ── Group Operations ─────────────────────────────────────────────────────── + +export function getGroupProcesses(group: string): BgProcess[] { + return Array.from(processes.values()).filter(p => p.group === group); +} + +export function getGroupStatus(group: string): { + group: string; + healthy: boolean; + processes: { id: string; label: string; status: import("./types.js").ProcessStatus; alive: boolean }[]; +} { + const procs = getGroupProcesses(group); + const healthy = procs.length > 0 && procs.every(p => p.alive && (p.status === "ready" || p.status === "starting")); + return { + group, + healthy, + processes: procs.map(p => ({ + id: p.id, + label: p.label, + status: p.status, + alive: p.alive, + })), + }; +} + +// ── Cleanup ──────────────────────────────────────────────────────────────── + +export function pruneDeadProcesses(): void { + const now = Date.now(); + for (const [id, bg] of processes) { + if (!bg.alive) { + const ttl = bg.processType === "shell" ? DEAD_PROCESS_TTL * 6 : DEAD_PROCESS_TTL; + if (now - bg.startedAt > ttl) { + processes.delete(id); + } + } + } +} + +export function cleanupAll(): void { + for (const [id, bg] of processes) { + if (bg.alive) killProcess(id, "SIGKILL"); + } + processes.clear(); +} + +// ── Persistence ──────────────────────────────────────────────────────────── + +export function getManifestPath(cwd: string): string { + const dir = join(cwd, ".bg-shell"); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + return join(dir, "manifest.json"); +} + +export function persistManifest(cwd: string): void { + try { + const manifest: ProcessManifest[] = Array.from(processes.values()) + .filter(p => p.alive) + .map(p => ({ + id: p.id, + label: p.label, + command: p.command, + cwd: p.cwd, + startedAt: p.startedAt, + processType: p.processType, + group: p.group, + readyPattern: p.readyPattern, + readyPort: p.readyPort, + pid: p.proc.pid, + })); + writeFileSync(getManifestPath(cwd), JSON.stringify(manifest, null, 2)); + } catch { /* best effort */ } +} + +export function loadManifest(cwd: string): ProcessManifest[] { + try { + const path = getManifestPath(cwd); + if (existsSync(path)) { + return JSON.parse(readFileSync(path, "utf-8")); + } + } catch { /* best effort */ } + return []; +} diff --git a/src/resources/extensions/bg-shell/readiness-detector.ts b/src/resources/extensions/bg-shell/readiness-detector.ts new file mode 100644 index 000000000..e1e923fdc --- /dev/null +++ b/src/resources/extensions/bg-shell/readiness-detector.ts @@ -0,0 +1,126 @@ +/** + * Readiness detection: port probing, pattern matching, wait-for-ready. + */ + +import { createConnection } from "node:net"; +import type { BgProcess } from "./types.js"; +import { + PORT_PROBE_TIMEOUT, + READY_POLL_INTERVAL, + DEFAULT_READY_TIMEOUT, +} from "./types.js"; +import { addEvent, pushAlert } from "./process-manager.js"; + +// ── Readiness Transition ─────────────────────────────────────────────────── + +export function transitionToReady(bg: BgProcess, detail: string): void { + bg.status = "ready"; + bg.wasReady = true; + addEvent(bg, { type: "ready", detail }); +} + +// ── Port Probing ─────────────────────────────────────────────────────────── + +export function probePort(port: number, host: string = "127.0.0.1"): Promise { + return new Promise((resolve) => { + const socket = createConnection({ port, host, timeout: PORT_PROBE_TIMEOUT }, () => { + socket.destroy(); + resolve(true); + }); + socket.on("error", () => { + socket.destroy(); + resolve(false); + }); + socket.on("timeout", () => { + socket.destroy(); + resolve(false); + }); + }); +} + +// ── Port Probing Loop ────────────────────────────────────────────────────── + +export function startPortProbing(bg: BgProcess, port: number, customTimeout?: number): void { + const timeout = customTimeout || DEFAULT_READY_TIMEOUT; + const interval = setInterval(async () => { + if (!bg.alive) { + clearInterval(interval); + const stderrLines = bg.output.filter(l => l.stream === "stderr").slice(-10).map(l => l.line); + const detail = `Process exited (code ${bg.exitCode}) before port ${port} opened${stderrLines.length > 0 ? ` — ${stderrLines.join("; ").slice(0, 200)}` : ""}`; + addEvent(bg, { type: "port_timeout", detail, data: { port, exitCode: bg.exitCode } }); + return; + } + if (bg.status !== "starting") { + clearInterval(interval); + return; + } + const open = await probePort(port); + if (open) { + clearInterval(interval); + if (!bg.ports.includes(port)) bg.ports.push(port); + transitionToReady(bg, `Port ${port} is open`); + addEvent(bg, { type: "port_open", detail: `Port ${port} is open`, data: { port } }); + } + }, READY_POLL_INTERVAL); + + // Stop probing after timeout — transition to error state so the process + // doesn't stay in "starting" forever (fixes #428) + setTimeout(() => { + clearInterval(interval); + if (bg.alive && bg.status === "starting") { + const stderrLines = bg.output.filter(l => l.stream === "stderr").slice(-10).map(l => l.line); + const detail = `Port ${port} not open after ${timeout}ms${stderrLines.length > 0 ? ` — ${stderrLines.join("; ").slice(0, 200)}` : ""}`; + bg.status = "error"; + addEvent(bg, { type: "port_timeout", detail, data: { port, timeout } }); + pushAlert(bg, `Port ${port} readiness timeout after ${timeout / 1000}s`); + } + }, timeout); +} + +// ── Wait for Ready ───────────────────────────────────────────────────────── + +export async function waitForReady(bg: BgProcess, timeout: number, signal?: AbortSignal): Promise<{ ready: boolean; detail: string }> { + const start = Date.now(); + + while (Date.now() - start < timeout) { + if (signal?.aborted) { + return { ready: false, detail: "Cancelled" }; + } + if (!bg.alive) { + const stderrLines = bg.output.filter(l => l.stream === "stderr").slice(-5).map(l => l.line); + const stderrContext = stderrLines.length > 0 ? `\nstderr:\n${stderrLines.join("\n").slice(0, 500)}` : ""; + return { + ready: false, + detail: `Process exited before becoming ready (code ${bg.exitCode})${bg.recentErrors.length > 0 ? ` — ${bg.recentErrors.slice(-1)[0]}` : ""}${stderrContext}`, + }; + } + if (bg.status === "error") { + const stderrLines = bg.output.filter(l => l.stream === "stderr").slice(-5).map(l => l.line); + const stderrContext = stderrLines.length > 0 ? `\nstderr:\n${stderrLines.join("\n").slice(0, 500)}` : ""; + return { + ready: false, + detail: `Process entered error state${bg.readyPort ? ` (port ${bg.readyPort} never opened)` : ""}${stderrContext}`, + }; + } + if (bg.status === "ready") { + return { + ready: true, + detail: bg.events.find(e => e.type === "ready")?.detail || "Process is ready", + }; + } + await new Promise(r => setTimeout(r, READY_POLL_INTERVAL)); + } + + // Timeout — try port probe as last resort + if (bg.readyPort) { + const open = await probePort(bg.readyPort); + if (open) { + transitionToReady(bg, `Port ${bg.readyPort} is open (detected at timeout)`); + return { ready: true, detail: `Port ${bg.readyPort} is open` }; + } + } + + const stderrLines = bg.output.filter(l => l.stream === "stderr").slice(-5).map(l => l.line); + const stderrContext = stderrLines.length > 0 ? `\nstderr:\n${stderrLines.join("\n").slice(0, 500)}` : ""; + return { ready: false, detail: `Timed out after ${timeout}ms waiting for ready signal${stderrContext}` }; +} diff --git a/src/resources/extensions/bg-shell/types.ts b/src/resources/extensions/bg-shell/types.ts new file mode 100644 index 000000000..579e5b09b --- /dev/null +++ b/src/resources/extensions/bg-shell/types.ts @@ -0,0 +1,251 @@ +/** + * Shared types, constants, and pattern databases for the bg-shell extension. + */ + +// ── Types ────────────────────────────────────────────────────────────────── + +export type ProcessStatus = + | "starting" + | "ready" + | "error" + | "exited" + | "crashed"; + +export type ProcessType = "server" | "build" | "test" | "watcher" | "generic" | "shell"; + +export interface ProcessEvent { + type: + | "started" + | "ready" + | "error_detected" + | "recovered" + | "exited" + | "crashed" + | "output" + | "port_open" + | "pattern_match" + | "port_timeout"; + timestamp: number; + detail: string; + data?: Record; +} + +export interface OutputDigest { + status: ProcessStatus; + uptime: string; + errors: string[]; + warnings: string[]; + urls: string[]; + ports: number[]; + lastActivity: string; + outputLines: number; + changeSummary: string; +} + +export interface OutputLine { + stream: "stdout" | "stderr"; + line: string; + ts: number; +} + +export interface BgProcess { + id: string; + label: string; + command: string; + cwd: string; + startedAt: number; + proc: import("node:child_process").ChildProcess; + /** Unified chronologically-interleaved output buffer */ + output: OutputLine[]; + exitCode: number | null; + signal: string | null; + alive: boolean; + /** Tracks how many lines in the unified output buffer the LLM has already seen */ + lastReadIndex: number; + /** Process classification */ + processType: ProcessType; + /** Current lifecycle status */ + status: ProcessStatus; + /** Detected ports */ + ports: number[]; + /** Detected URLs */ + urls: string[]; + /** Accumulated errors since last read */ + recentErrors: string[]; + /** Accumulated warnings since last read */ + recentWarnings: string[]; + /** Lifecycle events log */ + events: ProcessEvent[]; + /** Ready pattern (regex string) */ + readyPattern: string | null; + /** Ready port to probe */ + readyPort: number | null; + /** Whether readiness was ever achieved */ + wasReady: boolean; + /** Group membership */ + group: string | null; + /** Last error count snapshot for diff detection */ + lastErrorCount: number; + /** Last warning count snapshot for diff detection */ + lastWarningCount: number; + /** Command history for shell-type sessions */ + commandHistory: string[]; + /** Dedup tracker: hash → count of repeated lines */ + lineDedup: Map; + /** Total raw lines (before dedup) for token savings calc */ + totalRawLines: number; + /** Env snapshot (keys only, no values for security) */ + envKeys: string[]; + /** Restart count */ + restartCount: number; + /** Original start config for restart */ + startConfig: { command: string; cwd: string; label: string; processType: ProcessType; readyPattern: string | null; readyPort: number | null; group: string | null }; +} + +export interface BgProcessInfo { + id: string; + label: string; + command: string; + cwd: string; + startedAt: number; + alive: boolean; + exitCode: number | null; + signal: string | null; + outputLines: number; + stdoutLines: number; + stderrLines: number; + status: ProcessStatus; + processType: ProcessType; + ports: number[]; + urls: string[]; + group: string | null; + restartCount: number; + uptime: string; + recentErrorCount: number; + recentWarningCount: number; + eventCount: number; +} + +export interface StartOptions { + command: string; + cwd: string; + label?: string; + type?: ProcessType; + readyPattern?: string; + readyPort?: number; + readyTimeout?: number; + group?: string; + env?: Record; +} + +export interface GetOutputOptions { + stream: "stdout" | "stderr" | "both"; + tail?: number; + filter?: string; + incremental?: boolean; +} + +export interface ProcessManifest { + id: string; + label: string; + command: string; + cwd: string; + startedAt: number; + processType: ProcessType; + group: string | null; + readyPattern: string | null; + readyPort: number | null; + pid: number | undefined; +} + +// ── Constants ────────────────────────────────────────────────────────────── + +export const MAX_BUFFER_LINES = 5000; +export const MAX_EVENTS = 200; +export const DEAD_PROCESS_TTL = 10 * 60 * 1000; +export const PORT_PROBE_TIMEOUT = 500; +export const READY_POLL_INTERVAL = 250; +export const DEFAULT_READY_TIMEOUT = 30000; + +// ── Pattern Databases ────────────────────────────────────────────────────── + +/** Patterns that indicate a process is ready/listening */ +export const READINESS_PATTERNS: RegExp[] = [ + // Node/JS servers + /listening\s+on\s+(?:port\s+)?(\d+)/i, + /server\s+(?:is\s+)?(?:running|started|listening)\s+(?:at|on)\s+/i, + /ready\s+(?:in|on|at)\s+/i, + /started\s+(?:server\s+)?on\s+/i, + // Next.js / Vite / etc + /Local:\s*https?:\/\//i, + /➜\s+Local:\s*/i, + /compiled\s+(?:successfully|client\s+and\s+server)/i, + // Python + /running\s+on\s+https?:\/\//i, + /Uvicorn\s+running/i, + /Development\s+server\s+is\s+running/i, + // Generic + /press\s+ctrl[\-+]c\s+to\s+(?:quit|stop)/i, + /watching\s+for\s+(?:file\s+)?changes/i, + /build\s+(?:completed|succeeded|finished)/i, +]; + +/** Patterns that indicate errors */ +export const ERROR_PATTERNS: RegExp[] = [ + /\berror\b[\s:[\](]/i, + /\bERROR\b/, + /\bfailed\b/i, + /\bFAILED\b/, + /\bfatal\b/i, + /\bFATAL\b/, + /\bexception\b/i, + /\bpanic\b/i, + /\bsegmentation\s+fault\b/i, + /\bsyntax\s*error\b/i, + /\btype\s*error\b/i, + /\breference\s*error\b/i, + /Cannot\s+find\s+module/i, + /Module\s+not\s+found/i, + /ENOENT/, + /EACCES/, + /EADDRINUSE/, + /TS\d{4,5}:/, // TypeScript errors + /E\d{4,5}:/, // Rust errors + /\[ERROR\]/, + /✖|✗|❌/, // Common error symbols +]; + +/** Patterns that indicate warnings */ +export const WARNING_PATTERNS: RegExp[] = [ + /\bwarning\b[\s:[\](]/i, + /\bWARN(?:ING)?\b/, + /\bdeprecated\b/i, + /\bDEPRECATED\b/, + /⚠️?/, + /\[WARN\]/, +]; + +/** Patterns to extract URLs */ +export const URL_PATTERN = /https?:\/\/[^\s"'<>)\]]+/gi; + +/** Patterns to extract port numbers from "listening" messages */ +export const PORT_PATTERN = /(?:port|listening\s+on|:)\s*(\d{2,5})\b/gi; + +/** Patterns indicating test results */ +export const TEST_RESULT_PATTERNS: RegExp[] = [ + /(\d+)\s+(?:tests?\s+)?passed/i, + /(\d+)\s+(?:tests?\s+)?failed/i, + /Tests?:\s+(\d+)\s+passed/i, + /(\d+)\s+passing/i, + /(\d+)\s+failing/i, + /PASS|FAIL/, +]; + +/** Patterns indicating build completion */ +export const BUILD_COMPLETE_PATTERNS: RegExp[] = [ + /build\s+(?:completed|succeeded|finished|done)/i, + /compiled\s+(?:successfully|with\s+\d+\s+(?:error|warning))/i, + /✓\s+Built/i, + /webpack\s+\d+\.\d+/i, + /bundle\s+(?:is\s+)?ready/i, +]; diff --git a/src/resources/extensions/bg-shell/utilities.ts b/src/resources/extensions/bg-shell/utilities.ts new file mode 100644 index 000000000..b33c68b50 --- /dev/null +++ b/src/resources/extensions/bg-shell/utilities.ts @@ -0,0 +1,55 @@ +/** + * Utility functions for the bg-shell extension. + */ + +import { createRequire } from "node:module"; + +// ── Windows VT Input Restoration ──────────────────────────────────────────── +// Child processes (esp. Git Bash / MSYS2) can strip the ENABLE_VIRTUAL_TERMINAL_INPUT +// flag from the shared stdin console handle. Re-enable it after each child exits. + +let _vtHandles: { GetConsoleMode: Function; SetConsoleMode: Function; handle: unknown } | null = null; +export function restoreWindowsVTInput(): void { + if (process.platform !== "win32") return; + try { + if (!_vtHandles) { + const cjsRequire = createRequire(import.meta.url); + const koffi = cjsRequire("koffi"); + const k32 = koffi.load("kernel32.dll"); + const GetStdHandle = k32.func("void* __stdcall GetStdHandle(int)"); + const GetConsoleMode = k32.func("bool __stdcall GetConsoleMode(void*, _Out_ uint32_t*)"); + const SetConsoleMode = k32.func("bool __stdcall SetConsoleMode(void*, uint32_t)"); + const handle = GetStdHandle(-10); + _vtHandles = { GetConsoleMode, SetConsoleMode, handle }; + } + const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200; + const mode = new Uint32Array(1); + _vtHandles.GetConsoleMode(_vtHandles.handle, mode); + if (!(mode[0] & ENABLE_VIRTUAL_TERMINAL_INPUT)) { + _vtHandles.SetConsoleMode(_vtHandles.handle, mode[0] | ENABLE_VIRTUAL_TERMINAL_INPUT); + } + } catch { /* koffi not available on non-Windows */ } +} + +// ── Time Formatting ──────────────────────────────────────────────────────── + +export function formatUptime(ms: number): string { + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ${seconds % 60}s`; + const hours = Math.floor(minutes / 60); + return `${hours}h ${minutes % 60}m`; +} + +export function formatTimeAgo(timestamp: number): string { + return formatUptime(Date.now() - timestamp) + " ago"; +} + +export function formatTokenCount(count: number): string { + if (count < 1000) return count.toString(); + if (count < 10000) return `${(count / 1000).toFixed(1)}k`; + if (count < 1000000) return `${Math.round(count / 1000)}k`; + if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`; + return `${Math.round(count / 1000000)}M`; +}