Merge pull request #548 from gsd-build/refactor/519-decompose-bg-shell

refactor: decompose bg-shell/index.ts into focused modules
This commit is contained in:
TÂCHES 2026-03-15 17:30:52 -06:00 committed by GitHub
commit 67f0a6253f
8 changed files with 1764 additions and 1645 deletions

File diff suppressed because it is too large Load diff

View file

@ -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<string, string>; 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<string, string> = {};
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 };
}

View file

@ -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;
}

View file

@ -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<typeof setInterval>;
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;
}
}

View file

@ -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<string, BgProcess>();
/** 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<ProcessEvent, "timestamp">): 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<BgProcess | null> {
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 [];
}

View file

@ -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<boolean> {
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}` };
}

View file

@ -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<string, unknown>;
}
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<string, number>;
/** 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<string, string>;
}
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,
];

View file

@ -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`;
}