From c45cb2d85cf0edbb91b21fcd43692dc26afb8a44 Mon Sep 17 00:00:00 2001 From: Victor Martin Date: Wed, 11 Feb 2026 12:39:47 +0100 Subject: [PATCH] feat(release): v1.5.0 - Context-aware stack detection Add auto-detection of project technology stack at REPL startup to prevent COCO from suggesting incompatible technologies. Features: - Detect language/runtime (Node.js, Java, Python, Go, Rust) - Extract dependencies from package.json, pom.xml, build.gradle, etc. - Infer frameworks (Spring Boot, React, FastAPI) from dependencies - Detect package manager, build tools, and testing frameworks - Enrich LLM system prompt with stack context - Prevent mismatched technology suggestions Added: - Stack detector service (src/cli/repl/context/stack-detector.ts) - CommandHeartbeat utility for future streaming feature - projectContext field in ReplSession type - Stack info in system prompt formatting Changed: - REPL startup now detects and stores stack context - System prompt enriched with technology stack information This prevents COCO from suggesting npm packages in Java projects and vice versa, significantly improving user experience. --- CHANGELOG.md | 27 +++ package.json | 2 +- src/cli/repl/context/stack-detector.ts | 306 +++++++++++++++++++++++++ src/cli/repl/index.ts | 4 + src/cli/repl/session.ts | 56 +++++ src/cli/repl/types.ts | 3 + src/tools/utils/heartbeat.test.ts | 299 ++++++++++++++++++++++++ src/tools/utils/heartbeat.ts | 106 +++++++++ 8 files changed, 802 insertions(+), 1 deletion(-) create mode 100644 src/cli/repl/context/stack-detector.ts create mode 100644 src/tools/utils/heartbeat.test.ts create mode 100644 src/tools/utils/heartbeat.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a9d2e07..5f07f2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [1.5.0] - 2026-02-11 + +### Added +- **Context-aware stack detection:** COCO now auto-detects project technology stack at startup + - Detects language/runtime: Node.js, Java, Python, Go, Rust + - Extracts dependencies from package.json, pom.xml, build.gradle, pyproject.toml, Cargo.toml, go.mod + - Infers frameworks (Spring Boot, React, FastAPI, etc.) from dependencies + - Detects package manager (npm, pnpm, yarn, maven, gradle, cargo, pip, go) + - Detects build tools and testing frameworks + - Enriches LLM system prompt with stack context to prevent mismatched technology suggestions + - **Prevents COCO from suggesting Node.js packages in Java projects (and vice versa)** +- **CommandHeartbeat utility:** Infrastructure for monitoring long-running commands (foundation for future streaming feature) + - Tracks elapsed time and silence duration + - Configurable callbacks for progress updates and warnings + +### Changed +- REPL startup now includes stack detection phase +- System prompt enriched with project technology context including frameworks, dependencies, and build tools +- `ReplSession` type extended with `projectContext` field +- Stack information displayed during REPL session to help user understand detected environment + +### Fixed +- Prevents COCO from suggesting incompatible technologies for project stack (major UX improvement) +- Type-safe dependency parsing with proper null checks + +--- + ## [1.4.0] - 2026-02-10 ### Added diff --git a/package.json b/package.json index ab0b0b0..9e4889a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@corbat-tech/coco", - "version": "1.4.0", + "version": "1.5.0", "description": "Autonomous Coding Agent with Self-Review, Quality Convergence, and Production-Ready Output", "type": "module", "main": "dist/index.js", diff --git a/src/cli/repl/context/stack-detector.ts b/src/cli/repl/context/stack-detector.ts new file mode 100644 index 0000000..17c7fa4 --- /dev/null +++ b/src/cli/repl/context/stack-detector.ts @@ -0,0 +1,306 @@ +/** + * Stack Detector for REPL Context Enrichment + * + * Detects project technology stack at REPL startup to enrich LLM context. + * Prevents COCO from suggesting incompatible technologies (e.g., npm in Java projects). + */ + +import path from "node:path"; +import fs from "node:fs/promises"; +import { fileExists } from "../../../utils/files.js"; + +export type ProjectStack = "node" | "java" | "python" | "go" | "rust" | "unknown"; + +export interface ProjectStackContext { + /** Primary language/runtime */ + stack: ProjectStack; + /** Package manager (npm, pnpm, yarn, maven, gradle, cargo, pip, go) */ + packageManager: string | null; + /** Key dependencies (name → version) */ + dependencies: Record; + /** Inferred frameworks (e.g., ["Spring Boot", "React", "FastAPI"]) */ + frameworks: string[]; + /** Build tools detected (e.g., ["gradle", "webpack", "vite"]) */ + buildTools: string[]; + /** Testing frameworks (e.g., ["junit", "vitest", "pytest"]) */ + testingFrameworks: string[]; + /** Languages detected (e.g., ["TypeScript", "Java", "Python"]) */ + languages: string[]; +} + +/** + * Detect project stack type based on manifest files + */ +async function detectStack(cwd: string): Promise { + if (await fileExists(path.join(cwd, "package.json"))) return "node"; + if (await fileExists(path.join(cwd, "Cargo.toml"))) return "rust"; + if (await fileExists(path.join(cwd, "pyproject.toml"))) return "python"; + if (await fileExists(path.join(cwd, "go.mod"))) return "go"; + if (await fileExists(path.join(cwd, "pom.xml"))) return "java"; + if (await fileExists(path.join(cwd, "build.gradle"))) return "java"; + if (await fileExists(path.join(cwd, "build.gradle.kts"))) return "java"; + return "unknown"; +} + +/** + * Detect package manager based on lock files + */ +async function detectPackageManager(cwd: string, stack: ProjectStack): Promise { + if (stack === "rust") return "cargo"; + if (stack === "python") return "pip"; + if (stack === "go") return "go"; + + if (stack === "java") { + if ( + (await fileExists(path.join(cwd, "build.gradle"))) || + (await fileExists(path.join(cwd, "build.gradle.kts"))) + ) { + return "gradle"; + } + if (await fileExists(path.join(cwd, "pom.xml"))) { + return "maven"; + } + } + + if (stack === "node") { + if (await fileExists(path.join(cwd, "pnpm-lock.yaml"))) return "pnpm"; + if (await fileExists(path.join(cwd, "yarn.lock"))) return "yarn"; + if (await fileExists(path.join(cwd, "bun.lockb"))) return "bun"; + return "npm"; + } + + return null; +} + +/** + * Parse Node.js package.json and extract dependencies + */ +async function parsePackageJson(cwd: string): Promise<{ + dependencies: Record; + frameworks: string[]; + buildTools: string[]; + testingFrameworks: string[]; + languages: string[]; +}> { + const packageJsonPath = path.join(cwd, "package.json"); + + try { + const content = await fs.readFile(packageJsonPath, "utf-8"); + const pkg = JSON.parse(content); + + const allDeps = { + ...pkg.dependencies, + ...pkg.devDependencies, + }; + + // Infer frameworks from dependencies + const frameworks: string[] = []; + if (allDeps.react) frameworks.push("React"); + if (allDeps.vue) frameworks.push("Vue"); + if (allDeps["@angular/core"]) frameworks.push("Angular"); + if (allDeps.next) frameworks.push("Next.js"); + if (allDeps.nuxt) frameworks.push("Nuxt"); + if (allDeps.express) frameworks.push("Express"); + if (allDeps.fastify) frameworks.push("Fastify"); + if (allDeps.nestjs || allDeps["@nestjs/core"]) frameworks.push("NestJS"); + + // Infer build tools + const buildTools: string[] = []; + if (allDeps.webpack) buildTools.push("webpack"); + if (allDeps.vite) buildTools.push("vite"); + if (allDeps.rollup) buildTools.push("rollup"); + if (allDeps.tsup) buildTools.push("tsup"); + if (allDeps.esbuild) buildTools.push("esbuild"); + if (pkg.scripts?.build) buildTools.push("build"); + + // Infer testing frameworks + const testingFrameworks: string[] = []; + if (allDeps.vitest) testingFrameworks.push("vitest"); + if (allDeps.jest) testingFrameworks.push("jest"); + if (allDeps.mocha) testingFrameworks.push("mocha"); + if (allDeps.chai) testingFrameworks.push("chai"); + if (allDeps["@playwright/test"]) testingFrameworks.push("playwright"); + if (allDeps.cypress) testingFrameworks.push("cypress"); + + // Detect languages + const languages: string[] = ["JavaScript"]; + if (allDeps.typescript || (await fileExists(path.join(cwd, "tsconfig.json")))) { + languages.push("TypeScript"); + } + + return { + dependencies: allDeps, + frameworks, + buildTools, + testingFrameworks, + languages, + }; + } catch { + return { + dependencies: {}, + frameworks: [], + buildTools: [], + testingFrameworks: [], + languages: [], + }; + } +} + +/** + * Parse Java pom.xml and extract dependencies (basic parsing) + */ +async function parsePomXml(cwd: string): Promise<{ + dependencies: Record; + frameworks: string[]; + buildTools: string[]; + testingFrameworks: string[]; +}> { + const pomPath = path.join(cwd, "pom.xml"); + + try { + const content = await fs.readFile(pomPath, "utf-8"); + + const dependencies: Record = {}; + const frameworks: string[] = []; + const buildTools: string[] = ["maven"]; + const testingFrameworks: string[] = []; + + // Simple regex-based parsing (not full XML parser) + const depRegex = /([^<]+)<\/groupId>\s*([^<]+)<\/artifactId>/g; + let match; + + while ((match = depRegex.exec(content)) !== null) { + const groupId = match[1]; + const artifactId = match[2]; + if (!groupId || !artifactId) continue; + + const fullName = `${groupId}:${artifactId}`; + dependencies[fullName] = "unknown"; // Version parsing would require more complex logic + + // Infer frameworks + if (artifactId.includes("spring-boot")) { + if (!frameworks.includes("Spring Boot")) frameworks.push("Spring Boot"); + } + if (artifactId.includes("spring-webmvc") || artifactId.includes("spring-web")) { + if (!frameworks.includes("Spring MVC")) frameworks.push("Spring MVC"); + } + if (artifactId.includes("hibernate")) { + if (!frameworks.includes("Hibernate")) frameworks.push("Hibernate"); + } + + // Infer testing frameworks + if (artifactId === "junit-jupiter" || artifactId === "junit") { + if (!testingFrameworks.includes("JUnit")) testingFrameworks.push("JUnit"); + } + if (artifactId === "mockito-core") { + if (!testingFrameworks.includes("Mockito")) testingFrameworks.push("Mockito"); + } + } + + return { dependencies, frameworks, buildTools, testingFrameworks }; + } catch { + return { dependencies: {}, frameworks: [], buildTools: ["maven"], testingFrameworks: [] }; + } +} + +/** + * Parse Python pyproject.toml and extract dependencies (basic parsing) + */ +async function parsePyprojectToml(cwd: string): Promise<{ + dependencies: Record; + frameworks: string[]; + buildTools: string[]; + testingFrameworks: string[]; +}> { + const pyprojectPath = path.join(cwd, "pyproject.toml"); + + try { + const content = await fs.readFile(pyprojectPath, "utf-8"); + + const dependencies: Record = {}; + const frameworks: string[] = []; + const buildTools: string[] = ["pip"]; + const testingFrameworks: string[] = []; + + // Simple line-based parsing + const lines = content.split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + + // Parse dependencies (very basic) + if (trimmed.match(/^["']?[\w-]+["']?\s*=\s*["'][\^~>=<]+[\d.]+["']/)) { + const depMatch = trimmed.match(/^["']?([\w-]+)["']?\s*=\s*["']([\^~>=<]+[\d.]+)["']/); + if (depMatch && depMatch[1] && depMatch[2]) { + dependencies[depMatch[1]] = depMatch[2]; + } + } + + // Infer frameworks + if (trimmed.includes("fastapi")) frameworks.push("FastAPI"); + if (trimmed.includes("django")) frameworks.push("Django"); + if (trimmed.includes("flask")) frameworks.push("Flask"); + + // Infer testing frameworks + if (trimmed.includes("pytest")) testingFrameworks.push("pytest"); + if (trimmed.includes("unittest")) testingFrameworks.push("unittest"); + } + + return { dependencies, frameworks, buildTools, testingFrameworks }; + } catch { + return { dependencies: {}, frameworks: [], buildTools: ["pip"], testingFrameworks: [] }; + } +} + +/** + * Main entry point: Detect project stack and enrich with dependencies + */ +export async function detectProjectStack(cwd: string): Promise { + const stack = await detectStack(cwd); + const packageManager = await detectPackageManager(cwd, stack); + + let dependencies: Record = {}; + let frameworks: string[] = []; + let buildTools: string[] = []; + let testingFrameworks: string[] = []; + let languages: string[] = []; + + // Parse dependencies based on stack + if (stack === "node") { + const parsed = await parsePackageJson(cwd); + dependencies = parsed.dependencies; + frameworks = parsed.frameworks; + buildTools = parsed.buildTools; + testingFrameworks = parsed.testingFrameworks; + languages = parsed.languages; + } else if (stack === "java") { + const parsed = await parsePomXml(cwd); + dependencies = parsed.dependencies; + frameworks = parsed.frameworks; + buildTools = parsed.buildTools; + testingFrameworks = parsed.testingFrameworks; + languages = ["Java"]; + } else if (stack === "python") { + const parsed = await parsePyprojectToml(cwd); + dependencies = parsed.dependencies; + frameworks = parsed.frameworks; + buildTools = parsed.buildTools; + testingFrameworks = parsed.testingFrameworks; + languages = ["Python"]; + } else if (stack === "go") { + languages = ["Go"]; + buildTools = ["go"]; + } else if (stack === "rust") { + languages = ["Rust"]; + buildTools = ["cargo"]; + } + + return { + stack, + packageManager, + dependencies, + frameworks, + buildTools, + testingFrameworks, + languages, + }; +} diff --git a/src/cli/repl/index.ts b/src/cli/repl/index.ts index 47393ab..0e02e08 100644 --- a/src/cli/repl/index.ts +++ b/src/cli/repl/index.ts @@ -127,6 +127,10 @@ export async function startRepl( // Initialize context manager initializeContextManager(session, provider); + // Detect and enrich project stack context + const { detectProjectStack } = await import("./context/stack-detector.js"); + session.projectContext = await detectProjectStack(projectPath); + // Load persisted allowed paths for this project await loadAllowedPaths(projectPath); diff --git a/src/cli/repl/session.ts b/src/cli/repl/session.ts index cfd06ec..3dc3c31 100644 --- a/src/cli/repl/session.ts +++ b/src/cli/repl/session.ts @@ -244,9 +244,65 @@ export function getConversationContext( systemPrompt = `${systemPrompt}\n\n# Project Instructions (from COCO.md/CLAUDE.md)\n\n${session.memoryContext.combinedContent}`; } + // Append project stack context if available + if (session.projectContext) { + const stackInfo = formatStackContext(session.projectContext); + systemPrompt = `${systemPrompt}\n\n${stackInfo}`; + } + return [{ role: "system", content: systemPrompt }, ...session.messages]; } +/** + * Format project stack context for LLM system prompt + */ +function formatStackContext( + ctx: import("./context/stack-detector.js").ProjectStackContext, +): string { + const parts: string[] = []; + + parts.push("# Project Technology Stack"); + parts.push(""); + parts.push(`**Language/Runtime:** ${ctx.stack}`); + + if (ctx.packageManager) { + parts.push(`**Package Manager:** ${ctx.packageManager}`); + } + + if (ctx.frameworks.length > 0) { + parts.push(`**Frameworks:** ${ctx.frameworks.join(", ")}`); + } + + if (ctx.languages.length > 0) { + parts.push(`**Languages:** ${ctx.languages.join(", ")}`); + } + + if (ctx.testingFrameworks.length > 0) { + parts.push(`**Testing Frameworks:** ${ctx.testingFrameworks.join(", ")}`); + } + + if (ctx.buildTools.length > 0) { + parts.push(`**Build Tools:** ${ctx.buildTools.join(", ")}`); + } + + // Show top 10 dependencies + const keyDeps = Object.entries(ctx.dependencies) + .slice(0, 10) + .map(([name, version]) => `${name}@${version}`) + .join(", "); + + if (keyDeps) { + parts.push(`**Key Dependencies:** ${keyDeps}`); + } + + parts.push(""); + parts.push( + "**IMPORTANT:** When suggesting libraries, frameworks, or dependencies, ONLY recommend technologies compatible with the stack above. Do not suggest installing Node.js packages in a Java project, or Java libraries in a Python project.", + ); + + return parts.join("\n"); +} + /** * Clear session messages */ diff --git a/src/cli/repl/types.ts b/src/cli/repl/types.ts index 14b1bd1..26ee509 100644 --- a/src/cli/repl/types.ts +++ b/src/cli/repl/types.ts @@ -6,6 +6,7 @@ import type { Message, ToolCall, StreamChunk } from "../../providers/types.js"; import type { ContextManager } from "./context/manager.js"; import type { ProgressTracker } from "./progress/tracker.js"; import type { MemoryContext } from "./memory/types.js"; +import type { ProjectStackContext } from "./context/stack-detector.js"; /** * REPL session state @@ -24,6 +25,8 @@ export interface ReplSession { progressTracker?: ProgressTracker; /** Memory context from COCO.md/CLAUDE.md files */ memoryContext?: MemoryContext; + /** Project stack context (detected at startup) */ + projectContext?: ProjectStackContext; } /** diff --git a/src/tools/utils/heartbeat.test.ts b/src/tools/utils/heartbeat.test.ts new file mode 100644 index 0000000..68686ee --- /dev/null +++ b/src/tools/utils/heartbeat.test.ts @@ -0,0 +1,299 @@ +/** + * Tests for CommandHeartbeat utility + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { CommandHeartbeat } from "./heartbeat.js"; + +describe("CommandHeartbeat", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + describe("start() and stop()", () => { + it("should initialize timers correctly", () => { + const heartbeat = new CommandHeartbeat(); + + heartbeat.start(); + const stats = heartbeat.getStats(); + + expect(stats.elapsedSeconds).toBe(0); + expect(stats.silentSeconds).toBe(0); + + heartbeat.stop(); + }); + + it("should clear interval on stop()", () => { + const onUpdate = vi.fn(); + const heartbeat = new CommandHeartbeat({ onUpdate }); + + heartbeat.start(); + vi.advanceTimersByTime(10000); // 10s + expect(onUpdate).toHaveBeenCalledTimes(1); + + heartbeat.stop(); + + // After stop, no more updates + vi.advanceTimersByTime(10000); // Another 10s + expect(onUpdate).toHaveBeenCalledTimes(1); // Still 1 + }); + + it("should handle multiple stop() calls safely", () => { + const heartbeat = new CommandHeartbeat(); + + heartbeat.start(); + heartbeat.stop(); + heartbeat.stop(); // Should not throw + + expect(() => heartbeat.stop()).not.toThrow(); + }); + }); + + describe("onUpdate callback", () => { + it("should call onUpdate every 10 seconds", () => { + const onUpdate = vi.fn(); + const heartbeat = new CommandHeartbeat({ onUpdate }); + + heartbeat.start(); + + vi.advanceTimersByTime(10000); // 10s + expect(onUpdate).toHaveBeenCalledTimes(1); + expect(onUpdate).toHaveBeenCalledWith({ + elapsedSeconds: 10, + silentSeconds: 10, + }); + + vi.advanceTimersByTime(10000); // 20s total + expect(onUpdate).toHaveBeenCalledTimes(2); + expect(onUpdate).toHaveBeenCalledWith({ + elapsedSeconds: 20, + silentSeconds: 20, + }); + + heartbeat.stop(); + }); + + it("should not throw if onUpdate is not provided", () => { + const heartbeat = new CommandHeartbeat(); + + heartbeat.start(); + expect(() => vi.advanceTimersByTime(10000)).not.toThrow(); + + heartbeat.stop(); + }); + }); + + describe("onWarn callback", () => { + it("should call onWarn when silent for 30 seconds", () => { + const onWarn = vi.fn(); + const heartbeat = new CommandHeartbeat({ onWarn }); + + heartbeat.start(); + + // First update at 10s - no warning (silence < 30s) + vi.advanceTimersByTime(10000); + expect(onWarn).not.toHaveBeenCalled(); + + // Second update at 20s - no warning (silence < 30s) + vi.advanceTimersByTime(10000); + expect(onWarn).not.toHaveBeenCalled(); + + // Third update at 30s - warning (silence >= 30s) + vi.advanceTimersByTime(10000); + expect(onWarn).toHaveBeenCalledTimes(1); + expect(onWarn).toHaveBeenCalledWith(expect.stringContaining("30s")); + + // Fourth update at 40s - warning again + vi.advanceTimersByTime(10000); + expect(onWarn).toHaveBeenCalledTimes(2); + expect(onWarn).toHaveBeenCalledWith(expect.stringContaining("40s")); + + heartbeat.stop(); + }); + + it("should not throw if onWarn is not provided", () => { + const heartbeat = new CommandHeartbeat(); + + heartbeat.start(); + expect(() => vi.advanceTimersByTime(30000)).not.toThrow(); + + heartbeat.stop(); + }); + }); + + describe("activity()", () => { + it("should reset silence timer on activity", () => { + const onWarn = vi.fn(); + const heartbeat = new CommandHeartbeat({ onWarn }); + + heartbeat.start(); + + // Advance 20s without activity + vi.advanceTimersByTime(20000); + + // Register activity + heartbeat.activity(); + + // Advance another 20s (total 40s elapsed, but only 20s since activity) + vi.advanceTimersByTime(20000); + + // Should NOT warn because silence is only 20s + expect(onWarn).not.toHaveBeenCalled(); + + heartbeat.stop(); + }); + + it("should update silentSeconds in stats after activity", () => { + const onUpdate = vi.fn(); + const heartbeat = new CommandHeartbeat({ onUpdate }); + + heartbeat.start(); + + // 10s without activity + vi.advanceTimersByTime(10000); + expect(onUpdate).toHaveBeenCalledWith({ + elapsedSeconds: 10, + silentSeconds: 10, + }); + + // Register activity + heartbeat.activity(); + + // Another 10s (total 20s elapsed, but 10s since activity) + vi.advanceTimersByTime(10000); + expect(onUpdate).toHaveBeenCalledWith({ + elapsedSeconds: 20, + silentSeconds: 10, + }); + + heartbeat.stop(); + }); + + it("should prevent warnings with frequent activity", () => { + const onWarn = vi.fn(); + const heartbeat = new CommandHeartbeat({ onWarn }); + + heartbeat.start(); + + // Activity every 5 seconds for 60 seconds + for (let i = 0; i < 12; i++) { + vi.advanceTimersByTime(5000); + heartbeat.activity(); + } + + // Should never warn because silence never reaches 30s + expect(onWarn).not.toHaveBeenCalled(); + + heartbeat.stop(); + }); + }); + + describe("getStats()", () => { + it("should return accurate elapsed time", () => { + const heartbeat = new CommandHeartbeat(); + + heartbeat.start(); + + vi.advanceTimersByTime(5000); // 5s + expect(heartbeat.getStats().elapsedSeconds).toBe(5); + + vi.advanceTimersByTime(5000); // 10s total + expect(heartbeat.getStats().elapsedSeconds).toBe(10); + + vi.advanceTimersByTime(15000); // 25s total + expect(heartbeat.getStats().elapsedSeconds).toBe(25); + + heartbeat.stop(); + }); + + it("should return accurate silent time", () => { + const heartbeat = new CommandHeartbeat(); + + heartbeat.start(); + + vi.advanceTimersByTime(10000); // 10s without activity + expect(heartbeat.getStats().silentSeconds).toBe(10); + + heartbeat.activity(); // Reset + + vi.advanceTimersByTime(5000); // 5s since activity + expect(heartbeat.getStats().silentSeconds).toBe(5); + + heartbeat.stop(); + }); + + it("should work correctly before start()", () => { + const heartbeat = new CommandHeartbeat(); + + // Should not throw + const stats = heartbeat.getStats(); + expect(stats.elapsedSeconds).toBeGreaterThanOrEqual(0); + expect(stats.silentSeconds).toBeGreaterThanOrEqual(0); + }); + }); + + describe("integration scenarios", () => { + it("should handle realistic command execution pattern", () => { + const onUpdate = vi.fn(); + const onWarn = vi.fn(); + const heartbeat = new CommandHeartbeat({ onUpdate, onWarn }); + + heartbeat.start(); + + // Simulate npm install with bursts of activity + // First 5s: active output + for (let i = 0; i < 5; i++) { + vi.advanceTimersByTime(1000); + heartbeat.activity(); + } + + // 10s update - no warning (only 5s silent) + vi.advanceTimersByTime(5000); + expect(onUpdate).toHaveBeenCalledTimes(1); + expect(onWarn).not.toHaveBeenCalled(); + + // Next 10s: silent (downloading) + vi.advanceTimersByTime(10000); + expect(onUpdate).toHaveBeenCalledTimes(2); + expect(onWarn).not.toHaveBeenCalled(); // 15s silent + + // Activity burst + for (let i = 0; i < 3; i++) { + vi.advanceTimersByTime(1000); + heartbeat.activity(); + } + + // Continue + vi.advanceTimersByTime(7000); // Total 30s, but only 10s since last activity + expect(onUpdate).toHaveBeenCalledTimes(3); + expect(onWarn).not.toHaveBeenCalled(); // No warning + + heartbeat.stop(); + }); + + it("should warn for truly stalled command", () => { + const onUpdate = vi.fn(); + const onWarn = vi.fn(); + const heartbeat = new CommandHeartbeat({ onUpdate, onWarn }); + + heartbeat.start(); + + // Command produces output initially + heartbeat.activity(); + + // Then becomes completely silent for 60s + vi.advanceTimersByTime(60000); + + // Should have warned multiple times + expect(onWarn).toHaveBeenCalled(); + expect(onWarn.mock.calls.length).toBeGreaterThan(0); + + heartbeat.stop(); + }); + }); +}); diff --git a/src/tools/utils/heartbeat.ts b/src/tools/utils/heartbeat.ts new file mode 100644 index 0000000..8b65f40 --- /dev/null +++ b/src/tools/utils/heartbeat.ts @@ -0,0 +1,106 @@ +/** + * Command Heartbeat Monitor + * + * Provides real-time feedback for long-running shell commands by: + * - Tracking elapsed time since command started + * - Tracking time since last activity (silence duration) + * - Emitting periodic updates every 10 seconds + * - Warning when command has been silent for >30 seconds + */ + +export interface HeartbeatCallbacks { + /** Called every updateIntervalSeconds with current statistics */ + onUpdate?: (stats: HeartbeatStats) => void; + /** Called when command silent for >= warnThreshold seconds */ + onWarn?: (message: string) => void; +} + +export interface HeartbeatStats { + /** Total seconds since command started */ + elapsedSeconds: number; + /** Seconds since last activity() call */ + silentSeconds: number; +} + +/** + * CommandHeartbeat monitors long-running commands and provides periodic feedback + * + * Usage: + * ```typescript + * const heartbeat = new CommandHeartbeat({ + * onUpdate: (stats) => console.log(`⏱️ ${stats.elapsedSeconds}s elapsed`), + * onWarn: (msg) => console.warn(msg), + * }); + * + * heartbeat.start(); + * + * // In stdout/stderr handlers: + * subprocess.stdout.on('data', (chunk) => { + * heartbeat.activity(); // Reset silence timer + * }); + * + * // When done: + * heartbeat.stop(); + * ``` + */ +export class CommandHeartbeat { + private startTime: number = 0; + private lastActivityTime: number = 0; + private updateInterval: NodeJS.Timeout | null = null; + private readonly warnThreshold: number = 30; // seconds + private readonly updateIntervalSeconds: number = 10; // seconds + private readonly callbacks: HeartbeatCallbacks; + + constructor(callbacks: HeartbeatCallbacks = {}) { + this.callbacks = callbacks; + } + + /** + * Start monitoring - begins periodic updates and silence warnings + */ + start(): void { + this.startTime = Date.now(); + this.lastActivityTime = Date.now(); + + // Start periodic update interval + this.updateInterval = setInterval(() => { + const stats = this.getStats(); + this.callbacks.onUpdate?.(stats); + + // Warn if silent for too long + if (stats.silentSeconds >= this.warnThreshold) { + this.callbacks.onWarn?.(`⚠️ Command silent for ${stats.silentSeconds}s`); + } + }, this.updateIntervalSeconds * 1000); + } + + /** + * Register activity - call this when command produces output + * Resets the silence timer + */ + activity(): void { + this.lastActivityTime = Date.now(); + } + + /** + * Stop monitoring - clears periodic interval + * Should be called in finally{} block to ensure cleanup + */ + stop(): void { + if (this.updateInterval) { + clearInterval(this.updateInterval); + this.updateInterval = null; + } + } + + /** + * Get current heartbeat statistics + */ + getStats(): HeartbeatStats { + const now = Date.now(); + return { + elapsedSeconds: Math.floor((now - this.startTime) / 1000), + silentSeconds: Math.floor((now - this.lastActivityTime) / 1000), + }; + } +}