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:
commit
67f0a6253f
8 changed files with 1764 additions and 1645 deletions
File diff suppressed because it is too large
Load diff
198
src/resources/extensions/bg-shell/interaction.ts
Normal file
198
src/resources/extensions/bg-shell/interaction.ts
Normal 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 };
|
||||
}
|
||||
259
src/resources/extensions/bg-shell/output-formatter.ts
Normal file
259
src/resources/extensions/bg-shell/output-formatter.ts
Normal 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;
|
||||
}
|
||||
432
src/resources/extensions/bg-shell/overlay.ts
Normal file
432
src/resources/extensions/bg-shell/overlay.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
404
src/resources/extensions/bg-shell/process-manager.ts
Normal file
404
src/resources/extensions/bg-shell/process-manager.ts
Normal 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 [];
|
||||
}
|
||||
126
src/resources/extensions/bg-shell/readiness-detector.ts
Normal file
126
src/resources/extensions/bg-shell/readiness-detector.ts
Normal 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}` };
|
||||
}
|
||||
251
src/resources/extensions/bg-shell/types.ts
Normal file
251
src/resources/extensions/bg-shell/types.ts
Normal 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,
|
||||
];
|
||||
55
src/resources/extensions/bg-shell/utilities.ts
Normal file
55
src/resources/extensions/bg-shell/utilities.ts
Normal 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`;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue