diff --git a/package-lock.json b/package-lock.json index 2c4bcc68..274ba367 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dreb", - "version": "2.19.0", + "version": "2.19.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dreb", - "version": "2.19.0", + "version": "2.19.1", "workspaces": [ "packages/*", "packages/coding-agent/examples/extensions/with-deps", @@ -8763,7 +8763,7 @@ }, "packages/agent": { "name": "@dreb/agent-core", - "version": "2.19.0", + "version": "2.19.1", "license": "MIT", "dependencies": { "@dreb/ai": "*" @@ -8792,7 +8792,7 @@ }, "packages/ai": { "name": "@dreb/ai", - "version": "2.19.0", + "version": "2.19.1", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.73.0", @@ -8848,7 +8848,7 @@ }, "packages/coding-agent": { "name": "@dreb/coding-agent", - "version": "2.19.0", + "version": "2.19.1", "license": "MIT", "dependencies": { "@dreb/agent-core": "*", @@ -8963,7 +8963,7 @@ }, "packages/semantic-search": { "name": "@dreb/semantic-search", - "version": "2.19.0", + "version": "2.19.1", "license": "MIT", "dependencies": { "@huggingface/transformers": "^4.0.1", @@ -9012,7 +9012,7 @@ }, "packages/telegram": { "name": "@dreb/telegram", - "version": "2.19.0", + "version": "2.19.1", "dependencies": { "@dreb/coding-agent": "*", "grammy": "^1.35.0" @@ -9044,7 +9044,7 @@ }, "packages/tui": { "name": "@dreb/tui", - "version": "2.19.0", + "version": "2.19.1", "license": "MIT", "dependencies": { "@types/mime-types": "^2.1.4", diff --git a/package.json b/package.json index 2f510430..445ef446 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "engines": { "node": ">=20.0.0" }, - "version": "2.19.0", + "version": "2.19.1", "dependencies": { "@mariozechner/jiti": "^2.6.5", "@dreb/coding-agent": "*", diff --git a/packages/agent/package.json b/packages/agent/package.json index b3f44093..051292cc 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/agent-core", - "version": "2.19.0", + "version": "2.19.1", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", diff --git a/packages/ai/package.json b/packages/ai/package.json index 69b0a5f0..871c9557 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/ai", - "version": "2.19.0", + "version": "2.19.1", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 13c73c18..00ade7ea 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -674,6 +674,7 @@ dreb --thinking high "Solve this complex problem" | `DREB_SEARXNG_URL` | Base URL for SearXNG backend (default: `http://localhost:8888`) | | `DREB_BRAVE_API_KEY` | API key for Brave search backend | | `DREB_WEB_SEARCH_RATE_LIMIT_MS` | Minimum delay between web searches in milliseconds (default: `10000`) | +| `DREB_DEBUG` | Show debug-level messages in the TUI chat feed (default: suppressed) | | `VISUAL`, `EDITOR` | External editor for Ctrl+G | --- diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index b8f3868c..6fa40d77 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/coding-agent", - "version": "2.19.0", + "version": "2.19.1", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "drebConfig": { diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 311a5da0..02a6b446 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -5,6 +5,7 @@ import type { ThinkingLevel } from "@dreb/agent-core"; import chalk from "chalk"; import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR } from "../config.js"; +import { log } from "../core/logger.js"; import { allTools, type ToolName } from "../core/tools/index.js"; export type Mode = "text" | "json" | "rpc"; @@ -109,7 +110,7 @@ export function parseArgs(args: string[], extensionFlags?: Map\n${content}\n\n`; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); - console.error(chalk.red(`Error: Could not read file ${absolutePath}: ${message}`)); + log.error(chalk.red(`Error: Could not read file ${absolutePath}: ${message}`)); process.exit(1); } } diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index e92b16d0..d65faf9e 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -64,6 +64,7 @@ import { } from "./extensions/index.js"; import { checkScriptContent, extractScriptPaths, isForbiddenCommand } from "./forbidden-commands.js"; import { type GitRepoState, getGitRepoState } from "./git-repo-state.js"; +import { log } from "./logger.js"; import type { BashExecutionMessage, CustomMessage } from "./messages.js"; import type { ModelRegistry } from "./model-registry.js"; import { PerformanceTracker } from "./performance-tracker.js"; @@ -646,7 +647,7 @@ export class AgentSession { this._emit({ type: "message_start", message }); this._emit({ type: "message_end", message }); } catch (err) { - console.error( + log.warn( `[subagent] Failed to deliver cancellation message for agent ${agentId}: ${err instanceof Error ? err.message : String(err)}`, ); } @@ -662,13 +663,13 @@ export class AgentSession { } else { // Fallback: if streaming started between the isStreaming check and this call, deliver as follow-up this.agent.prompt(message).catch((promptErr) => { - console.error( + log.warn( `[subagent] prompt() failed for background agent ${agentId}: ${promptErr instanceof Error ? promptErr.message : String(promptErr)}`, ); try { this.agent.followUp(message); } catch (followUpErr) { - console.error( + log.error( `[subagent] followUp() also failed for background agent ${agentId}: ${followUpErr instanceof Error ? followUpErr.message : String(followUpErr)}. Background result lost.`, ); } @@ -684,7 +685,7 @@ export class AgentSession { success: result.exitCode === 0, }); } catch (emitErr) { - console.error( + log.warn( `[subagent] background_agent_end emit failed: ${emitErr instanceof Error ? emitErr.message : String(emitErr)}`, ); } @@ -1484,7 +1485,7 @@ export class AgentSession { const skill = this.resourceLoader.getSkills().skills.find((s) => s.name === skillName); if (!skill) { - console.error(`Unknown skill "${skillName}" — no skill found with that name`); + log.warn(`Unknown skill "${skillName}" — no skill found with that name`); return text; } diff --git a/packages/coding-agent/src/core/buddy/buddy-controller.ts b/packages/coding-agent/src/core/buddy/buddy-controller.ts index 53cfa434..d57dcee2 100644 --- a/packages/coding-agent/src/core/buddy/buddy-controller.ts +++ b/packages/coding-agent/src/core/buddy/buddy-controller.ts @@ -13,6 +13,7 @@ * and Telegram (activity gating + reaction budget) can use different strategies. */ +import { log } from "../logger.js"; import { type BuddyManager, checkOllama } from "./buddy-manager.js"; import type { BuddyState } from "./buddy-types.js"; @@ -212,7 +213,7 @@ export class BuddyController { } catch (err) { if (!thinkingEnded) this.callbacks.onThinkingEnd(); this.removeContextEntry(marker); - console.error("[buddy] triggerReaction failed:", err instanceof Error ? err.message : err); + log.debug(`[buddy] triggerReaction failed: ${err instanceof Error ? err.message : String(err)}`); } } @@ -247,7 +248,7 @@ export class BuddyController { } catch (err) { if (!thinkingEnded) this.callbacks.onThinkingEnd(); this.removeContextEntry(marker); - console.error("[buddy] handleNameCall failed:", err instanceof Error ? err.message : err); + log.debug(`[buddy] handleNameCall failed: ${err instanceof Error ? err.message : String(err)}`); } } diff --git a/packages/coding-agent/src/core/event-bus.ts b/packages/coding-agent/src/core/event-bus.ts index a4c87b9f..fe76067b 100644 --- a/packages/coding-agent/src/core/event-bus.ts +++ b/packages/coding-agent/src/core/event-bus.ts @@ -1,4 +1,5 @@ import { EventEmitter } from "node:events"; +import { log } from "./logger.js"; export interface EventBus { emit(channel: string, data: unknown): void; @@ -20,7 +21,7 @@ export function createEventBus(): EventBusController { try { await handler(data); } catch (err) { - console.error(`Event handler error (${channel}):`, err); + log.warn(`Event handler error (${channel}): ${err instanceof Error ? err.message : String(err)}`); } }; emitter.on(channel, safeHandler); diff --git a/packages/coding-agent/src/core/logger.ts b/packages/coding-agent/src/core/logger.ts new file mode 100644 index 00000000..6ccbf052 --- /dev/null +++ b/packages/coding-agent/src/core/logger.ts @@ -0,0 +1,57 @@ +/** + * Structured logger that routes messages appropriately based on mode. + * + * In interactive TUI mode (when stderr is taken over): + * - debug: suppressed unless DREB_DEBUG=1 + * - warn/error: routed through writeIntercepted with level info → displayed in TUI feed + * + * In non-interactive modes (JSON, RPC, print, or before TUI starts): + * - All levels write to real stderr (the diagnostic side-channel) + */ + +import { isStderrTakenOver, writeIntercepted, writeRawStderr } from "./stderr-guard.js"; + +export type LogLevel = "debug" | "warn" | "error"; + +const isDebugEnabled = (): boolean => process.env.DREB_DEBUG === "1"; + +export const log = { + /** + * Debug-level message. Suppressed in interactive mode unless DREB_DEBUG=1. + * Always writes to stderr in non-interactive modes. + */ + debug(message: string): void { + if (isStderrTakenOver()) { + if (isDebugEnabled()) { + writeIntercepted(message, "debug"); + } + // Otherwise silently suppressed + } else { + writeRawStderr(`${message}\n`); + } + }, + + /** + * Warning-level message. Always displayed to the user. + * In TUI: shown in chat feed as warning. In non-interactive: written to stderr. + */ + warn(message: string): void { + if (isStderrTakenOver()) { + writeIntercepted(message, "warn"); + } else { + writeRawStderr(`${message}\n`); + } + }, + + /** + * Error-level message. Always displayed to the user. + * In TUI: shown in chat feed as error. In non-interactive: written to stderr. + */ + error(message: string): void { + if (isStderrTakenOver()) { + writeIntercepted(message, "error"); + } else { + writeRawStderr(`${message}\n`); + } + }, +}; diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index faedb930..14bc0622 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -8,6 +8,7 @@ import chalk from "chalk"; import { minimatch } from "minimatch"; import { isValidThinkingLevel } from "../cli/args.js"; import { DEFAULT_THINKING_LEVEL } from "./defaults.js"; +import { log } from "./logger.js"; import type { ModelRegistry } from "./model-registry.js"; /** Default model IDs for each known provider */ @@ -483,7 +484,7 @@ export async function findInitialModel(options: { modelRegistry, }); if (resolved.error) { - console.error(chalk.red(resolved.error)); + log.error(chalk.red(resolved.error)); process.exit(1); } if (resolved.model) { @@ -559,7 +560,7 @@ export async function restoreModelFromSession( const reason = !restoredModel ? "model no longer exists" : "no API key available"; if (shouldPrintMessages) { - console.error(chalk.yellow(`Warning: Could not restore model ${savedProvider}/${savedModelId} (${reason}).`)); + log.warn(chalk.yellow(`Warning: Could not restore model ${savedProvider}/${savedModelId} (${reason}).`)); } // If we already have a model, use it as fallback diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index f95ac339..ab18f73c 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -7,8 +7,10 @@ import ignore from "ignore"; import { minimatch } from "minimatch"; import { CONFIG_DIR_NAME } from "../config.js"; import { type GitSource, parseGitUrl } from "../utils/git.js"; +import { log } from "./logger.js"; import { isStdoutTakenOver } from "./output-guard.js"; import type { PackageSource, SettingsManager } from "./settings-manager.js"; +import { isStderrTakenOver } from "./stderr-guard.js"; const NETWORK_TIMEOUT_MS = 10000; const UPDATE_CHECK_CONCURRENCY = 4; @@ -2157,13 +2159,34 @@ export class DefaultPackageManager implements PackageManager { private runCommand(command: string, args: string[], options?: { cwd?: string }): Promise { return new Promise((resolvePromise, reject) => { + const stderrTaken = isStderrTakenOver(); + const stdoutTaken = isStdoutTakenOver(); + // When TUI owns the terminal, pipe stdout/stderr to avoid corrupting the display. + // When stdout is taken over (non-interactive pipe mode), route to fd 2 (stderr) as before. + // Otherwise inherit for normal terminal output. + const stdio: import("node:child_process").StdioOptions = stderrTaken + ? ["ignore", "pipe", "pipe"] + : stdoutTaken + ? ["ignore", 2, 2] + : "inherit"; const child = spawn(command, args, { cwd: options?.cwd, - stdio: isStdoutTakenOver() ? ["ignore", 2, 2] : "inherit", + stdio, shell: process.platform === "win32", }); + if (stderrTaken) { + child.stdout?.on("data", (chunk: Buffer) => { + log.debug(chunk.toString()); + }); + child.stderr?.on("data", (chunk: Buffer) => { + log.warn(chunk.toString()); + }); + } child.on("error", reject); - child.on("exit", (code) => { + // Use "close" instead of "exit" to ensure all piped stdio data + // has been delivered before we resolve/reject. "exit" can fire while + // buffered data events are still pending in the pipe. + child.on("close", (code) => { if (code === 0) { resolvePromise(); } else { diff --git a/packages/coding-agent/src/core/stderr-guard.ts b/packages/coding-agent/src/core/stderr-guard.ts new file mode 100644 index 00000000..e5a36f4d --- /dev/null +++ b/packages/coding-agent/src/core/stderr-guard.ts @@ -0,0 +1,107 @@ +/** + * Stderr interception for interactive TUI mode. + * + * When the TUI is active, raw writes to process.stderr would corrupt the + * differential renderer's state. This module intercepts process.stderr.write + * and routes all output through a callback instead of letting it hit the terminal. + * + * Analogous to output-guard.ts (which handles stdout for non-interactive modes), + * but specifically for protecting the TUI's display in interactive mode. + */ + +export type StderrCallback = (message: string, level?: "warn" | "error" | "debug") => void; + +interface StderrTakeoverState { + rawStderrWrite: (chunk: string, callback?: (error?: Error | null) => void) => boolean; + originalStderrWrite: typeof process.stderr.write; + callback: StderrCallback; +} + +let stderrTakeoverState: StderrTakeoverState | undefined; + +/** + * Intercept all process.stderr.write calls and route them through the callback. + * Idempotent — multiple calls are no-ops if already taken over. + */ +export function takeOverStderr(callback: StderrCallback): void { + if (stderrTakeoverState) { + return; + } + + const rawStderrWrite = process.stderr.write.bind(process.stderr) as StderrTakeoverState["rawStderrWrite"]; + const originalStderrWrite = process.stderr.write; + + process.stderr.write = (( + chunk: string | Uint8Array, + encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), + callback?: (error?: Error | null) => void, + ): boolean => { + const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString(); + if (text.length > 0) { + try { + stderrTakeoverState?.callback(text); + } catch { + rawStderrWrite(text); + } + } + // Signal success to caller — call the callback if provided + const cb = typeof encodingOrCallback === "function" ? encodingOrCallback : callback; + if (cb) cb(null); + return true; + }) as typeof process.stderr.write; + + stderrTakeoverState = { + rawStderrWrite, + originalStderrWrite, + callback, + }; +} + +/** + * Write a message directly through the interceptor callback with level info. + * Used by the logger to pass severity through without going via process.stderr.write. + * Falls back to rawStderrWrite if stderr is not taken over. + */ +export function writeIntercepted(message: string, level: "warn" | "error" | "debug"): void { + if (stderrTakeoverState) { + try { + stderrTakeoverState.callback(message, level); + } catch { + stderrTakeoverState.rawStderrWrite(message); + } + } else { + process.stderr.write(`${message}\n`); + } +} + +/** + * Restore the original process.stderr.write behavior. + */ +export function restoreStderr(): void { + if (!stderrTakeoverState) { + return; + } + + process.stderr.write = stderrTakeoverState.originalStderrWrite; + stderrTakeoverState = undefined; +} + +/** + * Check whether stderr is currently intercepted. + */ +export function isStderrTakenOver(): boolean { + return stderrTakeoverState !== undefined; +} + +/** + * Write directly to the real stderr, bypassing interception. + * Use for intentional writes that must reach the terminal + * (e.g., fatal errors before exit, post-TUI teardown messages). + */ +export function writeRawStderr(text: string): void { + if (stderrTakeoverState) { + stderrTakeoverState.rawStderrWrite(text); + return; + } + process.stderr.write(text); +} diff --git a/packages/coding-agent/src/core/timings.ts b/packages/coding-agent/src/core/timings.ts index fb8e764b..b6d7e22e 100644 --- a/packages/coding-agent/src/core/timings.ts +++ b/packages/coding-agent/src/core/timings.ts @@ -3,6 +3,8 @@ * Enable with DREB_TIMING=1 environment variable. */ +import { log } from "./logger.js"; + const ENABLED = process.env.DREB_TIMING === "1"; const timings: Array<{ label: string; ms: number }> = []; let lastTime = Date.now(); @@ -22,10 +24,10 @@ export function time(label: string): void { export function printTimings(): void { if (!ENABLED || timings.length === 0) return; - console.error("\n--- Startup Timings ---"); + log.debug("\n--- Startup Timings ---"); for (const t of timings) { - console.error(` ${t.label}: ${t.ms}ms`); + log.debug(` ${t.label}: ${t.ms}ms`); } - console.error(` TOTAL: ${timings.reduce((a, b) => a + b.ms, 0)}ms`); - console.error("------------------------\n"); + log.debug(` TOTAL: ${timings.reduce((a, b) => a + b.ms, 0)}ms`); + log.debug("------------------------\n"); } diff --git a/packages/coding-agent/src/core/tools/subagent.ts b/packages/coding-agent/src/core/tools/subagent.ts index 725c3205..39869c4d 100644 --- a/packages/coding-agent/src/core/tools/subagent.ts +++ b/packages/coding-agent/src/core/tools/subagent.ts @@ -11,6 +11,7 @@ import { CONFIG_DIR_NAME, getPackageDir, getSubagentSessionsDir } from "../../co import { keyHint } from "../../modes/interactive/components/keybinding-hints.js"; import { attachJsonlLineReader } from "../../modes/rpc/jsonl.js"; import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js"; +import { log } from "../logger.js"; import type { ModelRegistry } from "../model-registry.js"; import { resolveCliModel } from "../model-resolver.js"; import { getTextOutput, invalidArgText, str } from "./render-utils.js"; @@ -113,19 +114,19 @@ function loadAgentsFromDir(dir: string, agents: Map): v const content = readFileSync(join(dir, file), "utf-8"); const parsed = parseAgentFrontmatter(content); if (!parsed.ok) { - console.error(`[subagent] Skipping agent file ${join(dir, file)}: ${parsed.error}`); + log.warn(`[subagent] Skipping agent file ${join(dir, file)}: ${parsed.error}`); } else { agents.set(parsed.config.name, parsed.config); } } catch (err) { - console.error( + log.warn( `[subagent] Could not read agent file ${join(dir, file)}: ${err instanceof Error ? err.message : String(err)}`, ); } } } catch (err) { if ((err as NodeJS.ErrnoException).code !== "ENOENT") { - console.error( + log.warn( `[subagent] Could not read agents directory ${dir}: ${err instanceof Error ? err.message : String(err)}`, ); } @@ -197,7 +198,7 @@ async function spawnSubagent( sessionDir?: string, ): Promise { const drebBin = findDrebBinary(); - console.error(`[subagent] spawn: agent=${agentConfig.name} cwd=${cwd}`); + log.debug(`[subagent] spawn: agent=${agentConfig.name} cwd=${cwd}`); // Validate cwd exists — spawn() throws a misleading ENOENT blaming the // binary when the cwd is invalid, making the real cause hard to diagnose @@ -287,13 +288,13 @@ async function spawnSubagent( } }); proc.stderr?.on("error", (err) => { - console.error(`[subagent] stderr stream error (agent=${agentConfig.name}): ${err.message}`); + log.warn(`[subagent] stderr stream error (agent=${agentConfig.name}): ${err.message}`); }); // Parse JSONL events from stdout if (proc.stdout) { proc.stdout.on("error", (err) => { - console.error(`[subagent] stdout stream error (agent=${agentConfig.name}): ${err.message}`); + log.warn(`[subagent] stdout stream error (agent=${agentConfig.name}): ${err.message}`); }); attachJsonlLineReader(proc.stdout, (line) => { if (!line.trim()) return; @@ -307,7 +308,7 @@ async function spawnSubagent( // (e.g. startup errors printed before JSONL mode begins) plainStdoutLines.push(line.trim()); if (line.trim().startsWith("{")) { - console.error(`[subagent] Failed to parse JSONL event: ${line.slice(0, 200)}`); + log.warn(`[subagent] Failed to parse JSONL event: ${line.slice(0, 200)}`); } return; } @@ -359,7 +360,7 @@ async function spawnSubagent( signal?.removeEventListener("abort", onAbort); const exitCode = code ?? 1; const stderr = stderrChunks.join(""); - console.error( + log.debug( `[subagent] close: agent=${agentConfig.name} exit=${exitCode} messages=${collectedMessages.length}${exitCode !== 0 ? ` stderr=${stderr.slice(0, 200)} stdout=${plainStdoutLines.join("|").slice(0, 200)}` : ""}`, ); @@ -436,11 +437,11 @@ export function discoverSessionFile(sessionDir: string, agentName: string): stri } } if (best) { - console.error(`[subagent] session file: ${best.path} (agent=${agentName})`); + log.debug(`[subagent] session file: ${best.path} (agent=${agentName})`); return best.path; } } catch (err) { - console.error( + log.warn( `[subagent] failed to discover session file (agent=${agentName}): ${err instanceof Error ? err.message : String(err)}`, ); } @@ -684,7 +685,7 @@ export async function resolveModelForSubagentSpawn( lastError = resolved.error; const reason = compactErrorReason(resolved.error); skippedModels.push({ model: modelStr, reason }); - console.error(`[subagent] Model "${modelStr}" unavailable (${reason}). Trying next fallback...`); + log.warn(`[subagent] Model "${modelStr}" unavailable (${reason}). Trying next fallback...`); continue; } @@ -698,12 +699,12 @@ export async function resolveModelForSubagentSpawn( if (!probe.ok) { lastError = probe.reason; skippedModels.push({ model: modelStr, reason: probe.reason }); - console.error(`[subagent] Model "${modelStr}" failed probe (${probe.reason}). Trying next fallback...`); + log.warn(`[subagent] Model "${modelStr}" failed probe (${probe.reason}). Trying next fallback...`); continue; } } - console.error(`[subagent] Using model "${resolved.modelId}" for subagent.`); + log.debug(`[subagent] Using model "${resolved.modelId}" for subagent.`); return { ...resolved, skippedModels }; } @@ -713,7 +714,7 @@ export async function resolveModelForSubagentSpawn( const parentResolved = resolveModelStringSingle(parentModel, parentProvider, registry); if (parentResolved.ok) { const warning = `Agent preferred models were unavailable. Falling back to parent model "${parentResolved.modelId}".`; - console.error(`[subagent] ${warning}`); + log.warn(`[subagent] ${warning}`); return { ...parentResolved, warning, skippedModels }; } lastError = parentResolved.error; @@ -1284,7 +1285,7 @@ export function createSubagentToolDefinition( try { onBackgroundComplete(agentId, result, bgSignal.aborted); } catch (err) { - console.error( + log.warn( `[subagent] onBackgroundComplete threw for agent ${agentId}: ${err instanceof Error ? err.message : String(err)}. Background result lost.`, ); } @@ -1315,7 +1316,7 @@ export function createSubagentToolDefinition( } }; run().catch((err) => { - console.error( + log.warn( `[subagent] Unhandled background error (${agentId}): ${err instanceof Error ? err.message : String(err)}`, ); const entry = backgroundAgentRegistry.get(agentId); @@ -1335,7 +1336,7 @@ export function createSubagentToolDefinition( bgSignal.aborted, ); } catch (notifyErr) { - console.error( + log.error( `[subagent] CRITICAL: Last-resort notification failed for ${agentId}: ${notifyErr instanceof Error ? notifyErr.message : String(notifyErr)}`, ); } diff --git a/packages/coding-agent/src/core/tools/terminal-render.ts b/packages/coding-agent/src/core/tools/terminal-render.ts index bdb9baad..30d9f6ef 100644 --- a/packages/coding-agent/src/core/tools/terminal-render.ts +++ b/packages/coding-agent/src/core/tools/terminal-render.ts @@ -1,4 +1,5 @@ import { TerminalTextRender } from "terminal-render"; +import { log } from "../logger.js"; /** * Maximum row or column value allowed in ANSI cursor positioning sequences. @@ -147,7 +148,7 @@ export function renderTerminalOutput(raw: string): string { return renderer.render(); } catch (err) { const detail = err instanceof Error ? err.message : String(err); - console.error(`[dreb] terminal-render fallback: TerminalTextRender failed (${detail}), returning raw output`); + log.debug(`[dreb] terminal-render fallback: TerminalTextRender failed (${detail}), returning raw output`); return raw; } } diff --git a/packages/coding-agent/src/core/tools/web-search-queue.ts b/packages/coding-agent/src/core/tools/web-search-queue.ts index 5c925334..eb6c7431 100644 --- a/packages/coding-agent/src/core/tools/web-search-queue.ts +++ b/packages/coding-agent/src/core/tools/web-search-queue.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import lockfile from "proper-lockfile"; import { getAgentDir } from "../../config.js"; +import { log } from "../logger.js"; export interface WebSearchQueueOptions { rateLimitMs?: number; @@ -79,7 +80,7 @@ export class WebSearchQueue { writeFileSync(this.timeFilePath, JSON.stringify(timestampData)); } catch (tsErr) { // Don't let timestamp write failure mask the original error - console.error(`Failed to write search timestamp: ${tsErr}`); + log.warn(`Failed to write search timestamp: ${tsErr}`); } } } finally { diff --git a/packages/coding-agent/src/core/tools/web.ts b/packages/coding-agent/src/core/tools/web.ts index 1345ffa1..0437e0c5 100644 --- a/packages/coding-agent/src/core/tools/web.ts +++ b/packages/coding-agent/src/core/tools/web.ts @@ -7,6 +7,7 @@ import { type Static, Type } from "@sinclair/typebox"; import { CONFIG_DIR_NAME } from "../../config.js"; import { keyHint } from "../../modes/interactive/components/keybinding-hints.js"; import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js"; +import { log } from "../logger.js"; import { getTextOutput, invalidArgText, str } from "./render-utils.js"; import { wrapToolDefinition } from "./tool-definition-wrapper.js"; import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.js"; @@ -240,7 +241,7 @@ async function searchDuckDuckGo(query: string): Promise { } if (results.length === 0 && html.length > 1000) { // Got a substantial response but parsed 0 results — DDG HTML structure may have changed - console.error("Warning: DDG returned HTML but 0 results were parsed. The HTML structure may have changed."); + log.warn("Warning: DDG returned HTML but 0 results were parsed. The HTML structure may have changed."); } return results; } @@ -318,7 +319,7 @@ function loadDrebConfig(): DrebConfig { return JSON.parse(readFileSync(configPath, "utf-8")) as DrebConfig; } catch (err) { const msg = err instanceof Error ? err.message : String(err); - console.error(`Warning: failed to parse config at ${configPath}: ${msg}`); + log.warn(`Warning: failed to parse config at ${configPath}: ${msg}`); } } } @@ -334,7 +335,7 @@ export function getSearchConfig(): WebSearchConfig { if ((VALID_BACKENDS as readonly string[]).includes(rawBackend)) { backend = rawBackend as WebSearchConfig["backend"]; } else { - console.error( + log.warn( `Warning: unrecognized search backend "${rawBackend}", falling back to ddg. Valid: ${VALID_BACKENDS.join(", ")}`, ); } @@ -347,14 +348,14 @@ export function getSearchConfig(): WebSearchConfig { if (!Number.isNaN(parsed) && parsed >= 0) { rateLimitMs = parsed; } else { - console.error(`Warning: invalid DREB_WEB_SEARCH_RATE_LIMIT_MS "${rateLimitEnv}", using default`); + log.warn(`Warning: invalid DREB_WEB_SEARCH_RATE_LIMIT_MS "${rateLimitEnv}", using default`); } } else if (fileConfig.search?.rate_limit_ms !== undefined) { const parsed = parseInt(String(fileConfig.search.rate_limit_ms), 10); if (!Number.isNaN(parsed) && parsed >= 0) { rateLimitMs = parsed; } else { - console.error( + log.warn( `Warning: invalid search.rate_limit_ms in config file "${fileConfig.search.rate_limit_ms}", using default`, ); } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index a05c6313..773b6c23 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -19,6 +19,7 @@ import { AuthStorage } from "./core/auth-storage.js"; import { exportFromFile } from "./core/export-html/index.js"; import type { LoadExtensionsResult } from "./core/extensions/index.js"; import { migrateKeybindingsConfigFile } from "./core/keybindings.js"; +import { log } from "./core/logger.js"; import { ModelRegistry } from "./core/model-registry.js"; import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.js"; import { restoreStdout, takeOverStdout } from "./core/output-guard.js"; @@ -29,7 +30,6 @@ import { SessionManager } from "./core/session-manager.js"; import { SettingsManager } from "./core/settings-manager.js"; import { printTimings, resetTimings, time } from "./core/timings.js"; import { allTools } from "./core/tools/index.js"; - import { runMigrations, showDeprecationWarnings } from "./migrations.js"; import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.js"; import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.js"; @@ -60,9 +60,9 @@ async function readPipedStdin(): Promise { function reportSettingsErrors(settingsManager: SettingsManager, context: string): void { const errors = settingsManager.drainErrors(); for (const { scope, error } of errors) { - console.error(chalk.yellow(`Warning (${context}, ${scope} settings): ${error.message}`)); + log.warn(chalk.yellow(`Warning (${context}, ${scope} settings): ${error.message}`)); if (error.stack) { - console.error(chalk.dim(error.stack)); + log.warn(chalk.dim(error.stack)); } } } @@ -208,16 +208,16 @@ async function handlePackageCommand(args: string[]): Promise { } if (options.invalidOption) { - console.error(chalk.red(`Unknown option ${options.invalidOption} for "${options.command}".`)); - console.error(chalk.dim(`Use "${APP_NAME} --help" or "${getPackageCommandUsage(options.command)}".`)); + log.error(chalk.red(`Unknown option ${options.invalidOption} for "${options.command}".`)); + log.error(chalk.dim(`Use "${APP_NAME} --help" or "${getPackageCommandUsage(options.command)}".`)); process.exitCode = 1; return true; } const source = options.source; if ((options.command === "install" || options.command === "remove") && !source) { - console.error(chalk.red(`Missing ${options.command} source.`)); - console.error(chalk.dim(`Usage: ${getPackageCommandUsage(options.command)}`)); + log.error(chalk.red(`Missing ${options.command} source.`)); + log.error(chalk.dim(`Usage: ${getPackageCommandUsage(options.command)}`)); process.exitCode = 1; return true; } @@ -246,7 +246,7 @@ async function handlePackageCommand(args: string[]): Promise { await packageManager.remove(source!, { local: options.local }); const removed = packageManager.removeSourceFromSettings(source!, { local: options.local }); if (!removed) { - console.error(chalk.red(`No matching package found for ${source}`)); + log.error(chalk.red(`No matching package found for ${source}`)); process.exitCode = 1; return true; } @@ -305,7 +305,7 @@ async function handlePackageCommand(args: string[]): Promise { } } catch (error: unknown) { const message = error instanceof Error ? error.message : "Unknown package command error"; - console.error(chalk.red(`Error: ${message}`)); + log.error(chalk.red(`Error: ${message}`)); process.exitCode = 1; return true; } @@ -402,7 +402,7 @@ async function callSessionDirectoryHook(extensions: LoadExtensionsResult, cwd: s } } catch (err) { const message = err instanceof Error ? err.message : String(err); - console.error(chalk.red(`Extension "${ext.path}" session_directory handler failed: ${message}`)); + log.warn(chalk.red(`Extension "${ext.path}" session_directory handler failed: ${message}`)); } } } @@ -421,7 +421,7 @@ function validateForkFlags(parsed: Args): void { ].filter((flag): flag is string => flag !== undefined); if (conflictingFlags.length > 0) { - console.error(chalk.red(`Error: --fork cannot be combined with ${conflictingFlags.join(", ")}`)); + log.error(chalk.red(`Error: --fork cannot be combined with ${conflictingFlags.join(", ")}`)); process.exit(1); } } @@ -431,7 +431,7 @@ function forkSessionOrExit(sourcePath: string, cwd: string, sessionDir?: string) return SessionManager.forkFrom(sourcePath, cwd, sessionDir); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); - console.error(chalk.red(`Error: ${message}`)); + log.error(chalk.red(`Error: ${message}`)); process.exit(1); } } @@ -460,7 +460,7 @@ async function createSessionManager( return forkSessionOrExit(resolved.path, cwd, effectiveSessionDir); case "not_found": - console.error(chalk.red(`No session found matching '${resolved.arg}'`)); + log.error(chalk.red(`No session found matching '${resolved.arg}'`)); process.exit(1); } } @@ -485,7 +485,7 @@ async function createSessionManager( } case "not_found": - console.error(chalk.red(`No session found matching '${resolved.arg}'`)); + log.error(chalk.red(`No session found matching '${resolved.arg}'`)); process.exit(1); } } @@ -528,7 +528,7 @@ function buildSessionOptions( console.warn(chalk.yellow(`Warning: ${resolved.warning}`)); } if (resolved.error) { - console.error(chalk.red(resolved.error)); + log.error(chalk.red(resolved.error)); process.exit(1); } if (resolved.model) { @@ -682,7 +682,7 @@ export async function main(args: string[]) { const extensionsResult: LoadExtensionsResult = resourceLoader.getExtensions(); for (const { path, error } of extensionsResult.errors) { - console.error(chalk.red(`Failed to load extension "${path}": ${error}`)); + log.warn(chalk.red(`Failed to load extension "${path}": ${error}`)); } // Apply pending provider registrations from extensions immediately @@ -692,7 +692,7 @@ export async function main(args: string[]) { modelRegistry.registerProvider(name, config); } catch (error) { const message = error instanceof Error ? error.message : String(error); - console.error(chalk.red(`Extension "${extensionPath}" error: ${message}`)); + log.warn(chalk.red(`Extension "${extensionPath}" error: ${message}`)); } } extensionsResult.runtime.pendingProviderRegistrations = []; @@ -747,7 +747,7 @@ export async function main(args: string[]) { result = await exportFromFile(parsed.export, outputPath); } catch (error: unknown) { const message = error instanceof Error ? error.message : "Failed to export session"; - console.error(chalk.red(`Error: ${message}`)); + log.error(chalk.red(`Error: ${message}`)); process.exit(1); } console.log(`Exported to: ${result}`); @@ -758,7 +758,7 @@ export async function main(args: string[]) { time("migrateKeybindingsConfigFile"); if (parsed.mode === "rpc" && parsed.fileArgs.length > 0) { - console.error(chalk.red("Error: @file arguments are not supported in RPC mode")); + log.error(chalk.red("Error: @file arguments are not supported in RPC mode")); process.exit(1); } @@ -773,7 +773,7 @@ export async function main(args: string[]) { const isInteractive = !parsed.print && parsed.mode === undefined; const startupBenchmark = isTruthyEnvFlag(process.env.DREB_STARTUP_BENCHMARK); if (startupBenchmark && !isInteractive) { - console.error(chalk.red("Error: DREB_STARTUP_BENCHMARK only supports interactive mode")); + log.error(chalk.red("Error: DREB_STARTUP_BENCHMARK only supports interactive mode")); process.exit(1); } const mode = parsed.mode || "text"; @@ -846,7 +846,7 @@ export async function main(args: string[]) { // Handle CLI --api-key as runtime override (not persisted) if (parsed.apiKey) { if (!sessionOptions.model) { - console.error( + log.error( chalk.red("--api-key requires a model to be specified via --model, --provider/--model, or --models"), ); process.exit(1); @@ -858,10 +858,10 @@ export async function main(args: string[]) { time("createAgentSession"); if (!isInteractive && !session.model) { - console.error(chalk.red("No models available.")); - console.error(chalk.yellow("\nSet an API key environment variable:")); - console.error(" ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc."); - console.error(chalk.yellow(`\nOr create ${getModelsPath()}`)); + log.error(chalk.red("No models available.")); + log.error(chalk.yellow("\nSet an API key environment variable:")); + log.error(" ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc."); + log.error(chalk.yellow(`\nOr create ${getModelsPath()}`)); process.exit(1); } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 966bf6b3..9aed19d4 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -70,6 +70,7 @@ import type { ResourceDiagnostic } from "../../core/resource-loader.js"; import { type SessionContext, SessionManager } from "../../core/session-manager.js"; import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.js"; import type { SourceInfo } from "../../core/source-info.js"; +import { restoreStderr, type StderrCallback, takeOverStderr } from "../../core/stderr-guard.js"; import { resolveToCwd } from "../../core/tools/path-utils.js"; import { abortBackgroundAgents, getRunningBackgroundAgents } from "../../core/tools/subagent.js"; import type { TruncationResult } from "../../core/tools/truncate.js"; @@ -586,6 +587,10 @@ export class InteractiveMode { this.ui.start(); this.isInitialized = true; + // Intercept stderr to prevent raw writes from corrupting the TUI. + // Route intercepted messages to the chat feed as warnings/errors. + this.activateStderrGuard(); + // Initialize extensions first so resources are shown before messages await this.initExtensions(); @@ -2963,11 +2968,13 @@ export class InteractiveMode { clearInterval(suspendKeepAlive); process.removeListener("SIGINT", ignoreSigint); this.ui.start(); + this.activateStderrGuard(); this.ui.requestRender(true); }); try { // Stop the TUI (restore terminal to normal mode) + restoreStderr(); this.ui.stop(); // Send SIGTSTP to process group (pid=0 means all processes in group) @@ -3111,7 +3118,8 @@ export class InteractiveMode { // Write current content to temp file fs.writeFileSync(tmpFile, currentText, "utf-8"); - // Stop TUI to release terminal + // Stop TUI and restore stderr to release terminal for editor + restoreStderr(); this.ui.stop(); // Split by space to support editor arguments (e.g., "code --wait") @@ -3137,13 +3145,35 @@ export class InteractiveMode { // Ignore cleanup errors } - // Restart TUI + // Restart TUI and re-intercept stderr this.ui.start(); + this.activateStderrGuard(); // Force full re-render since external editor uses alternate screen this.ui.requestRender(true); } } + // ========================================================================= + // Stderr guard + // ========================================================================= + + /** + * Activate stderr interception, routing messages to the TUI chat feed. + * Errors are shown with error styling; everything else as warnings. + */ + private activateStderrGuard(): void { + const callback: StderrCallback = (message, level) => { + const trimmed = message.replace(/\n$/, ""); + if (trimmed.length === 0) return; + if (level === "error") { + this.showError(trimmed); + } else { + this.showWarning(trimmed); + } + }; + takeOverStderr(callback); + } + // ========================================================================= // UI helpers // ========================================================================= @@ -5091,6 +5121,7 @@ ${cycleModelForward || cycleModelBackward ? `| \`${cycleModelForward}\` / \`${cy this.unsubscribe(); } if (this.isInitialized) { + restoreStderr(); this.ui.stop(); this.isInitialized = false; } diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 51bcf50c..aa782ffb 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -8,6 +8,7 @@ import type { AssistantMessage, ImageContent } from "@dreb/ai"; import type { AgentSession } from "../core/agent-session.js"; +import { log } from "../core/logger.js"; import { flushRawStdout, writeRawStdout } from "../core/output-guard.js"; /** @@ -72,7 +73,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti }, }, onError: (err) => { - console.error(`Extension error (${err.extensionPath}): ${err.error}`); + log.warn(`Extension error (${err.extensionPath}): ${err.error}`); }, }); @@ -104,7 +105,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti // Check for error/aborted if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") { - console.error(assistantMsg.errorMessage || `Request ${assistantMsg.stopReason}`); + log.error(assistantMsg.errorMessage || `Request ${assistantMsg.stopReason}`); exitCode = 1; } else { // Output text content diff --git a/packages/coding-agent/test/activate-stderr-guard.test.ts b/packages/coding-agent/test/activate-stderr-guard.test.ts new file mode 100644 index 00000000..cb19eb4a --- /dev/null +++ b/packages/coding-agent/test/activate-stderr-guard.test.ts @@ -0,0 +1,139 @@ +/** + * Tests for the activateStderrGuard routing logic in InteractiveMode. + * + * Verifies that the callback installed by activateStderrGuard correctly: + * - Routes level "error" → showError + * - Routes level "warn" / undefined → showWarning + * - Strips a single trailing newline before display + * - Suppresses messages that become empty after trimming + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { StderrCallback } from "../src/core/stderr-guard.js"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js"; + +// Mock stderr-guard to capture the callback passed to takeOverStderr +let capturedCallback: StderrCallback | undefined; + +vi.mock("../src/core/stderr-guard.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + takeOverStderr: (cb: StderrCallback) => { + capturedCallback = cb; + }, + }; +}); + +type ActivateStderrGuardThis = { + showError: (msg: string) => void; + showWarning: (msg: string) => void; +}; + +type InteractiveModePrototypeWithActivateStderrGuard = { + activateStderrGuard(this: ActivateStderrGuardThis): void; +}; + +const interactiveModePrototype = InteractiveMode.prototype as unknown; + +function callActivateStderrGuard(context: ActivateStderrGuardThis): void { + (interactiveModePrototype as InteractiveModePrototypeWithActivateStderrGuard).activateStderrGuard.call(context); +} + +describe("InteractiveMode.activateStderrGuard routing", () => { + afterEach(() => { + capturedCallback = undefined; + vi.restoreAllMocks(); + }); + + it("routes level 'error' to showError", () => { + const context: ActivateStderrGuardThis = { + showError: vi.fn(), + showWarning: vi.fn(), + }; + + callActivateStderrGuard(context); + expect(capturedCallback).toBeDefined(); + + capturedCallback!("something went wrong", "error"); + + expect(context.showError).toHaveBeenCalledWith("something went wrong"); + expect(context.showWarning).not.toHaveBeenCalled(); + }); + + it("routes level 'warn' to showWarning", () => { + const context: ActivateStderrGuardThis = { + showError: vi.fn(), + showWarning: vi.fn(), + }; + + callActivateStderrGuard(context); + capturedCallback!("a warning message", "warn"); + + expect(context.showWarning).toHaveBeenCalledWith("a warning message"); + expect(context.showError).not.toHaveBeenCalled(); + }); + + it("routes undefined level to showWarning (raw third-party writes)", () => { + const context: ActivateStderrGuardThis = { + showError: vi.fn(), + showWarning: vi.fn(), + }; + + callActivateStderrGuard(context); + capturedCallback!("third-party output", undefined); + + expect(context.showWarning).toHaveBeenCalledWith("third-party output"); + expect(context.showError).not.toHaveBeenCalled(); + }); + + it("routes level 'debug' to showWarning", () => { + const context: ActivateStderrGuardThis = { + showError: vi.fn(), + showWarning: vi.fn(), + }; + + callActivateStderrGuard(context); + capturedCallback!("debug info", "debug"); + + expect(context.showWarning).toHaveBeenCalledWith("debug info"); + expect(context.showError).not.toHaveBeenCalled(); + }); + + it("strips a single trailing newline before display", () => { + const context: ActivateStderrGuardThis = { + showError: vi.fn(), + showWarning: vi.fn(), + }; + + callActivateStderrGuard(context); + capturedCallback!("message with newline\n", "warn"); + + expect(context.showWarning).toHaveBeenCalledWith("message with newline"); + }); + + it("suppresses messages that become empty after trimming", () => { + const context: ActivateStderrGuardThis = { + showError: vi.fn(), + showWarning: vi.fn(), + }; + + callActivateStderrGuard(context); + capturedCallback!("\n", "warn"); + + expect(context.showWarning).not.toHaveBeenCalled(); + expect(context.showError).not.toHaveBeenCalled(); + }); + + it("suppresses completely empty messages", () => { + const context: ActivateStderrGuardThis = { + showError: vi.fn(), + showWarning: vi.fn(), + }; + + callActivateStderrGuard(context); + capturedCallback!("", "error"); + + expect(context.showError).not.toHaveBeenCalled(); + expect(context.showWarning).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/coding-agent/test/expand-skill-command.test.ts b/packages/coding-agent/test/expand-skill-command.test.ts index 056f2b7e..eef67d09 100644 --- a/packages/coding-agent/test/expand-skill-command.test.ts +++ b/packages/coding-agent/test/expand-skill-command.test.ts @@ -13,6 +13,7 @@ import { resolve } from "path"; import { describe, expect, it, vi } from "vitest"; import { AgentSession } from "../src/core/agent-session.js"; import { AuthStorage } from "../src/core/auth-storage.js"; +import { log } from "../src/core/logger.js"; import { ModelRegistry } from "../src/core/model-registry.js"; import { SessionManager } from "../src/core/session-manager.js"; import { SettingsManager } from "../src/core/settings-manager.js"; @@ -139,11 +140,11 @@ describe("AgentSession._expandSkillCommand", () => { it("returns original text for unknown skill name", () => { const session = createSession([validSkill]); try { - const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const warnSpy = vi.spyOn(log, "warn").mockImplementation(() => {}); const result = expandSkillCommand(session, "/skill:nonexistent"); expect(result).toBe("/skill:nonexistent"); - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown skill "nonexistent"')); - consoleSpy.mockRestore(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown skill "nonexistent"')); + warnSpy.mockRestore(); } finally { session.dispose(); } diff --git a/packages/coding-agent/test/interactive-mode-suspend.test.ts b/packages/coding-agent/test/interactive-mode-suspend.test.ts index e9e53c14..064b0d9b 100644 --- a/packages/coding-agent/test/interactive-mode-suspend.test.ts +++ b/packages/coding-agent/test/interactive-mode-suspend.test.ts @@ -9,6 +9,7 @@ type FakeUi = { type HandleCtrlZThis = { ui: FakeUi; + activateStderrGuard: () => void; }; type ProcessSignalHandler = () => void; @@ -34,7 +35,7 @@ describe("InteractiveMode.handleCtrlZ", () => { stop: vi.fn(), requestRender: vi.fn(), }; - const context: HandleCtrlZThis = { ui }; + const context: HandleCtrlZThis = { ui, activateStderrGuard: vi.fn() }; const keepAliveHandle = setTimeout(() => undefined, 0); clearTimeout(keepAliveHandle); @@ -84,7 +85,7 @@ describe("InteractiveMode.handleCtrlZ", () => { stop: vi.fn(), requestRender: vi.fn(), }; - const context: HandleCtrlZThis = { ui }; + const context: HandleCtrlZThis = { ui, activateStderrGuard: vi.fn() }; const keepAliveHandle = setTimeout(() => undefined, 0); clearTimeout(keepAliveHandle); const suspendError = new Error("suspend failed"); diff --git a/packages/coding-agent/test/logger.test.ts b/packages/coding-agent/test/logger.test.ts new file mode 100644 index 00000000..088adae2 --- /dev/null +++ b/packages/coding-agent/test/logger.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { log } from "../src/core/logger.js"; +import { restoreStderr, takeOverStderr } from "../src/core/stderr-guard.js"; + +describe("logger", () => { + let originalStderrWrite: typeof process.stderr.write; + let interceptedMessages: Array<{ msg: string; level?: string }> = []; + + beforeEach(() => { + originalStderrWrite = process.stderr.write; + interceptedMessages = []; + restoreStderr(); + delete process.env.DREB_DEBUG; + }); + + afterEach(() => { + restoreStderr(); + process.stderr.write = originalStderrWrite; + delete process.env.DREB_DEBUG; + }); + + describe("when stderr is NOT taken over (non-interactive mode)", () => { + it("log.debug writes to stderr", () => { + const writes: string[] = []; + process.stderr.write = ((chunk: string | Uint8Array) => { + writes.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + + log.debug("debug msg"); + expect(writes).toContain("debug msg\n"); + }); + + it("log.warn writes to stderr", () => { + const writes: string[] = []; + process.stderr.write = ((chunk: string | Uint8Array) => { + writes.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + + log.warn("warn msg"); + expect(writes).toContain("warn msg\n"); + }); + + it("log.error writes to stderr", () => { + const writes: string[] = []; + process.stderr.write = ((chunk: string | Uint8Array) => { + writes.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + + log.error("error msg"); + expect(writes).toContain("error msg\n"); + }); + }); + + describe("when stderr IS taken over (interactive TUI mode)", () => { + beforeEach(() => { + takeOverStderr((msg, level) => interceptedMessages.push({ msg, level })); + }); + + it("log.debug is suppressed (no callback fire)", () => { + log.debug("debug noise"); + expect(interceptedMessages).toEqual([]); + }); + + it("log.debug fires callback with level 'debug' when DREB_DEBUG=1", () => { + restoreStderr(); + process.env.DREB_DEBUG = "1"; + takeOverStderr((msg, level) => interceptedMessages.push({ msg, level })); + + log.debug("debug visible"); + expect(interceptedMessages).toEqual([{ msg: "debug visible", level: "debug" }]); + }); + + it("log.warn fires callback with level 'warn'", () => { + log.warn("warning message"); + expect(interceptedMessages).toEqual([{ msg: "warning message", level: "warn" }]); + }); + + it("log.error fires callback with level 'error'", () => { + log.error("error message"); + expect(interceptedMessages).toEqual([{ msg: "error message", level: "error" }]); + }); + + it("log.warn and log.error use different levels", () => { + log.warn("w"); + log.error("e"); + expect(interceptedMessages).toEqual([ + { msg: "w", level: "warn" }, + { msg: "e", level: "error" }, + ]); + }); + }); +}); diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index ab1ad477..bf5a742c 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ENV_AGENT_DIR } from "../src/config.js"; +import { log } from "../src/core/logger.js"; import { main } from "../src/main.js"; describe("package commands", () => { @@ -71,7 +72,7 @@ describe("package commands", () => { it("shows install subcommand help", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const errorSpy = vi.spyOn(log, "error").mockImplementation(() => {}); try { await expect(main(["install", "--help"])).resolves.toBeUndefined(); @@ -88,7 +89,7 @@ describe("package commands", () => { }); it("shows a friendly error for unknown install options", async () => { - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const errorSpy = vi.spyOn(log, "error").mockImplementation(() => {}); try { await expect(main(["install", "--unknown"])).resolves.toBeUndefined(); @@ -103,7 +104,7 @@ describe("package commands", () => { }); it("shows a friendly error for missing install source", async () => { - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const errorSpy = vi.spyOn(log, "error").mockImplementation(() => {}); try { await expect(main(["install"])).resolves.toBeUndefined(); @@ -122,7 +123,7 @@ describe("package commands", () => { const settingsPath = join(agentDir, "settings.json"); writeFileSync(settingsPath, JSON.stringify({ packages: ["npm:dreb-formatter"] }, null, 2)); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const errorSpy = vi.spyOn(log, "error").mockImplementation(() => {}); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { diff --git a/packages/coding-agent/test/print-mode.test.ts b/packages/coding-agent/test/print-mode.test.ts index b5de04e4..d8d09afb 100644 --- a/packages/coding-agent/test/print-mode.test.ts +++ b/packages/coding-agent/test/print-mode.test.ts @@ -1,5 +1,6 @@ import type { AssistantMessage, ImageContent } from "@dreb/ai"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { log } from "../src/core/logger.js"; import { runPrintMode } from "../src/modes/print-mode.js"; type EmitEvent = { type: string }; @@ -110,7 +111,7 @@ describe("runPrintMode", () => { it("emits session_shutdown and returns non-zero on assistant error", async () => { const session = createSession(createAssistantMessage({ stopReason: "error", errorMessage: "provider failure" })); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const errorSpy = vi.spyOn(log, "error").mockImplementation(() => {}); const exitCode = await runPrintMode(session as unknown as Parameters[0], { mode: "text", diff --git a/packages/coding-agent/test/stderr-guard.test.ts b/packages/coding-agent/test/stderr-guard.test.ts new file mode 100644 index 00000000..71316171 --- /dev/null +++ b/packages/coding-agent/test/stderr-guard.test.ts @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + isStderrTakenOver, + restoreStderr, + takeOverStderr, + writeIntercepted, + writeRawStderr, +} from "../src/core/stderr-guard.js"; + +describe("stderr-guard", () => { + // Save original so we can restore after each test + let originalStderrWrite: typeof process.stderr.write; + + beforeEach(() => { + originalStderrWrite = process.stderr.write; + // Ensure clean state + restoreStderr(); + }); + + afterEach(() => { + restoreStderr(); + // Safety: make sure process.stderr.write is the real one + process.stderr.write = originalStderrWrite; + }); + + it("isStderrTakenOver returns false initially", () => { + expect(isStderrTakenOver()).toBe(false); + }); + + it("takeOverStderr intercepts writes and routes to callback", () => { + const messages: string[] = []; + takeOverStderr((msg) => messages.push(msg)); + + expect(isStderrTakenOver()).toBe(true); + + process.stderr.write("hello"); + process.stderr.write(" world"); + + expect(messages).toEqual(["hello", " world"]); + }); + + it("callback does not fire for empty strings", () => { + const messages: string[] = []; + takeOverStderr((msg) => messages.push(msg)); + + process.stderr.write(""); + + expect(messages).toEqual([]); + }); + + it("restoreStderr reverts to original behavior", () => { + const messages: string[] = []; + takeOverStderr((msg) => messages.push(msg)); + + restoreStderr(); + + expect(isStderrTakenOver()).toBe(false); + + // After restore, writes should go to original stderr + // We spy on the original to confirm + const spy = vi.spyOn({ write: originalStderrWrite }, "write"); + process.stderr.write = spy as unknown as typeof process.stderr.write; + process.stderr.write("after restore"); + expect(spy).toHaveBeenCalledWith("after restore"); + }); + + it("is idempotent — multiple takeOverStderr calls are no-ops", () => { + const messages1: string[] = []; + const messages2: string[] = []; + + takeOverStderr((msg) => messages1.push(msg)); + takeOverStderr((msg) => messages2.push(msg)); // Should be ignored + + process.stderr.write("test"); + + expect(messages1).toEqual(["test"]); + expect(messages2).toEqual([]); // Second callback never registered + }); + + it("writeRawStderr bypasses interception", () => { + const messages: string[] = []; + const rawWrites: string[] = []; + + // Replace the real stderr.write with a tracker before taking over + const fakeWrite = ((chunk: string | Uint8Array) => { + rawWrites.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + process.stderr.write = fakeWrite; + + takeOverStderr((msg) => messages.push(msg)); + + // Regular write goes through callback + process.stderr.write("intercepted"); + expect(messages).toEqual(["intercepted"]); + expect(rawWrites).toEqual([]); // Did not reach raw + + // writeRawStderr bypasses + writeRawStderr("bypass"); + expect(rawWrites).toEqual(["bypass"]); + expect(messages).toEqual(["intercepted"]); // callback not called + }); + + it("calls the callback argument on success", () => { + const messages: string[] = []; + takeOverStderr((msg) => messages.push(msg)); + + let callbackCalled = false; + // biome-ignore lint/complexity/noBannedTypes: testing overloaded write signature + (process.stderr.write as Function)("data", () => { + callbackCalled = true; + }); + + expect(callbackCalled).toBe(true); + expect(messages).toEqual(["data"]); + }); + + it("handles encoding + callback signature", () => { + const messages: string[] = []; + takeOverStderr((msg) => messages.push(msg)); + + let callbackCalled = false; + // biome-ignore lint/complexity/noBannedTypes: testing overloaded write signature + (process.stderr.write as Function)("data", "utf-8", () => { + callbackCalled = true; + }); + + expect(callbackCalled).toBe(true); + expect(messages).toEqual(["data"]); + }); + + it("falls back to rawStderrWrite when callback throws", () => { + const rawWrites: string[] = []; + + // Install a tracker as the raw write target + const fakeWrite = ((chunk: string | Uint8Array) => { + rawWrites.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + process.stderr.write = fakeWrite; + + takeOverStderr(() => { + throw new Error("callback exploded"); + }); + + // Write should not throw — it falls back to raw + process.stderr.write("fallback message"); + expect(rawWrites).toEqual(["fallback message"]); + }); + + it("decodes Uint8Array correctly instead of producing garbage", () => { + const messages: string[] = []; + takeOverStderr((msg) => messages.push(msg)); + + const bytes = new Uint8Array([104, 101, 108, 108, 111]); // "hello" + // biome-ignore lint/complexity/noBannedTypes: testing Uint8Array input + (process.stderr.write as Function)(bytes); + + expect(messages).toEqual(["hello"]); + }); + + it("decodes Buffer correctly", () => { + const messages: string[] = []; + takeOverStderr((msg) => messages.push(msg)); + + const buf = Buffer.from("buffer text"); + // biome-ignore lint/complexity/noBannedTypes: testing Buffer input + (process.stderr.write as Function)(buf); + + expect(messages).toEqual(["buffer text"]); + }); + + describe("writeIntercepted", () => { + it("passes message and level to callback", () => { + const calls: Array<{ msg: string; level?: string }> = []; + takeOverStderr((msg, level) => calls.push({ msg, level })); + + writeIntercepted("a warning", "warn"); + writeIntercepted("an error", "error"); + writeIntercepted("debug info", "debug"); + + expect(calls).toEqual([ + { msg: "a warning", level: "warn" }, + { msg: "an error", level: "error" }, + { msg: "debug info", level: "debug" }, + ]); + }); + + it("falls back to rawStderrWrite when callback throws", () => { + const rawWrites: string[] = []; + + const fakeWrite = ((chunk: string | Uint8Array) => { + rawWrites.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + process.stderr.write = fakeWrite; + + takeOverStderr(() => { + throw new Error("boom"); + }); + + writeIntercepted("safe fallback", "error"); + expect(rawWrites).toEqual(["safe fallback"]); + }); + + it("writes to stderr directly when not taken over", () => { + const writes: string[] = []; + process.stderr.write = ((chunk: string | Uint8Array) => { + writes.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + + writeIntercepted("not intercepted", "warn"); + expect(writes).toEqual(["not intercepted\n"]); + }); + }); +}); diff --git a/packages/coding-agent/test/subagent-model-fallback.test.ts b/packages/coding-agent/test/subagent-model-fallback.test.ts index c3d9dab6..f08ab086 100644 --- a/packages/coding-agent/test/subagent-model-fallback.test.ts +++ b/packages/coding-agent/test/subagent-model-fallback.test.ts @@ -3,6 +3,7 @@ import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; import { complete, type Model } from "@dreb/ai"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { log } from "../src/core/logger.js"; import { type AgentTypeConfig, executeSingle, @@ -37,6 +38,8 @@ beforeEach(() => { vi.mocked(complete).mockReset(); vi.mocked(spawn).mockReset(); vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(log, "debug").mockImplementation(() => {}); + vi.spyOn(log, "warn").mockImplementation(() => {}); }); afterEach(() => { @@ -773,7 +776,7 @@ describe("spawn-time model availability probing", () => { expect(result.skippedModels).toEqual([{ model: "primary-model", reason: "429 rate limit" }]); } expect(complete).toHaveBeenCalledTimes(2); - expect(console.error).toHaveBeenCalledWith( + expect(log.warn).toHaveBeenCalledWith( '[subagent] Model "primary-model" failed probe (429 rate limit). Trying next fallback...', ); }); diff --git a/packages/coding-agent/test/web-search-queue.test.ts b/packages/coding-agent/test/web-search-queue.test.ts index 406cb328..bbee3416 100644 --- a/packages/coding-agent/test/web-search-queue.test.ts +++ b/packages/coding-agent/test/web-search-queue.test.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { log } from "../src/core/logger.js"; import { executeSearch, getSearchConfig } from "../src/core/tools/web.js"; import { WebSearchQueue } from "../src/core/tools/web-search-queue.js"; @@ -327,10 +328,10 @@ describe("getSearchConfig", () => { process.chdir(tempDir); process.env.DREB_WEB_SEARCH_RATE_LIMIT_MS = "abc"; - const errorSpy = vi.spyOn(console, "error"); + const warnSpy = vi.spyOn(log, "warn").mockImplementation(() => {}); const config = getSearchConfig(); expect(config.rateLimitMs).toBe(10_000); - expect(errorSpy).toHaveBeenCalledWith(`Warning: invalid DREB_WEB_SEARCH_RATE_LIMIT_MS "abc", using default`); + expect(warnSpy).toHaveBeenCalledWith(`Warning: invalid DREB_WEB_SEARCH_RATE_LIMIT_MS "abc", using default`); }); it("falls back to default on invalid config file value and logs warning", () => { @@ -341,10 +342,10 @@ describe("getSearchConfig", () => { mkdirSync(configDir, { recursive: true }); writeFileSync(join(configDir, "config.json"), JSON.stringify({ search: { rate_limit_ms: "invalid" } })); - const errorSpy = vi.spyOn(console, "error"); + const warnSpy = vi.spyOn(log, "warn").mockImplementation(() => {}); const config = getSearchConfig(); expect(config.rateLimitMs).toBe(10_000); - expect(errorSpy).toHaveBeenCalledWith( + expect(warnSpy).toHaveBeenCalledWith( 'Warning: invalid search.rate_limit_ms in config file "invalid", using default', ); }); diff --git a/packages/semantic-search/.claude-plugin/plugin.json b/packages/semantic-search/.claude-plugin/plugin.json index 4110af8e..2c861cf8 100644 --- a/packages/semantic-search/.claude-plugin/plugin.json +++ b/packages/semantic-search/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "semantic-search", "description": "Semantic codebase search — natural language queries over code and docs using embeddings, tree-sitter parsing, and POEM multi-signal ranking", - "version": "2.19.0", + "version": "2.19.1", "author": { "name": "Drew Brereton" }, diff --git a/packages/semantic-search/package.json b/packages/semantic-search/package.json index d733ca34..e8b97403 100644 --- a/packages/semantic-search/package.json +++ b/packages/semantic-search/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/semantic-search", - "version": "2.19.0", + "version": "2.19.1", "description": "Semantic codebase search engine with embedding-based ranking and MCP server", "publishConfig": { "access": "public" diff --git a/packages/telegram/package.json b/packages/telegram/package.json index 841cb203..69ac8bc9 100644 --- a/packages/telegram/package.json +++ b/packages/telegram/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/telegram", - "version": "2.19.0", + "version": "2.19.1", "description": "Telegram bot frontend for dreb coding agent", "type": "module", "main": "./dist/index.js", diff --git a/packages/tui/package.json b/packages/tui/package.json index 2403ab26..36fdd08c 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/tui", - "version": "2.19.0", + "version": "2.19.1", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js",