Skip to content

Commit 37dba49

Browse files
committed
fix(coding-agent): hide Bun console diagnostics in TUI
Fixes #675 Fixes #676
1 parent 747a11f commit 37dba49

3 files changed

Lines changed: 126 additions & 0 deletions

File tree

packages/coding-agent/src/modes/interactive/changes.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,30 @@
11
# changes
22

3+
## Bun console diagnostics stay behind the interactive terminal guard (2026-08-03)
4+
5+
### What changed
6+
7+
- While the TUI owns the terminal, the interactive stderr guard now routes `console.info`, `console.warn`, and
8+
`console.error` through its hidden, redacted debug-log sink and restores the exact console methods whenever the
9+
terminal is released.
10+
- Coverage models Bun's native console behavior, which bypasses a replaced `process.stderr.write`, and pins
11+
terminal silence, debug-log redaction, and restoration.
12+
13+
### Why
14+
15+
- omo-senpi emits ulw-loop and start-work diagnostics through `console.*`. Node routes those calls through the
16+
patched stderr writer, but Bun writes them through its native console implementation, so the diagnostics could
17+
corrupt the interactive footer even though direct `process.stderr.write` coverage was green.
18+
19+
### Why this cannot be expressed externally
20+
21+
- Extensions cannot protect the host TUI from runtime-specific console output before it reaches the terminal.
22+
The host must own console interception for exactly the interval in which it owns the terminal.
23+
24+
### Expected merge conflict zones
25+
26+
- LOW: `interactive-stderr-guard.ts` and its focused regression test.
27+
328
## Backfill: exit alias and footer provider priority (2026-08-01)
429

530
### What changed

packages/coding-agent/src/modes/interactive/interactive-stderr-guard.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,23 @@
11
import * as fs from "node:fs";
22
import * as path from "node:path";
3+
import { format } from "node:util";
34
import { getDebugLogPath } from "../../config.ts";
45
import { restoreStderr, takeOverStderr } from "../../core/output-guard.ts";
56
import { redactSensitiveOutput } from "../../core/sensitive-output.ts";
67

8+
const consoleLevels = ["error", "info", "warn"] as const;
9+
10+
type ConsoleLevel = (typeof consoleLevels)[number];
11+
type ConsoleMethod = (...data: unknown[]) => void;
12+
13+
interface InteractiveConsoleState {
14+
readonly error: ConsoleMethod;
15+
readonly info: ConsoleMethod;
16+
readonly warn: ConsoleMethod;
17+
}
18+
19+
let interactiveConsoleState: InteractiveConsoleState | undefined;
20+
721
function appendHiddenInteractiveStderr(text: string): void {
822
if (text.length === 0) {
923
return;
@@ -17,10 +31,47 @@ function appendHiddenInteractiveStderr(text: string): void {
1731
fs.chmodSync(debugLogPath, 0o600);
1832
}
1933

34+
function replaceConsoleMethod(level: ConsoleLevel, method: ConsoleMethod): void {
35+
Object.defineProperty(console, level, {
36+
configurable: true,
37+
value: method,
38+
writable: true,
39+
});
40+
}
41+
42+
function takeOverInteractiveConsole(): void {
43+
if (interactiveConsoleState) {
44+
return;
45+
}
46+
interactiveConsoleState = {
47+
error: console.error,
48+
info: console.info,
49+
warn: console.warn,
50+
};
51+
const writeHiddenConsoleDiagnostic = (...data: unknown[]) => {
52+
process.stderr.write(`${format(...data)}\n`);
53+
};
54+
for (const level of consoleLevels) {
55+
replaceConsoleMethod(level, writeHiddenConsoleDiagnostic);
56+
}
57+
}
58+
59+
function restoreInteractiveConsole(): void {
60+
if (!interactiveConsoleState) {
61+
return;
62+
}
63+
for (const level of consoleLevels) {
64+
replaceConsoleMethod(level, interactiveConsoleState[level]);
65+
}
66+
interactiveConsoleState = undefined;
67+
}
68+
2069
export function takeOverInteractiveStderr(): void {
2170
takeOverStderr(appendHiddenInteractiveStderr, redactSensitiveOutput);
71+
takeOverInteractiveConsole();
2272
}
2373

2474
export function restoreInteractiveStderr(): void {
75+
restoreInteractiveConsole();
2576
restoreStderr();
2677
}

packages/coding-agent/test/interactive-stderr-guard.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,19 @@ import {
1111

1212
const originalStderrWrite = process.stderr.write;
1313
const originalAgentDir = process.env[ENV_AGENT_DIR];
14+
const consoleLevels = ["error", "info", "warn"] as const;
15+
const originalConsoleMethods = {
16+
error: console.error,
17+
info: console.info,
18+
warn: console.warn,
19+
};
1420
const githubFineGrainedPat = "github_pat_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
1521

1622
const tempDirs: string[] = [];
1723

24+
type ConsoleLevel = keyof typeof originalConsoleMethods;
25+
type ConsoleMethod = (...data: unknown[]) => void;
26+
1827
function replaceStderrWrite(write: typeof process.stderr.write): void {
1928
Object.defineProperty(process.stderr, "write", {
2029
configurable: true,
@@ -39,10 +48,21 @@ function createCapturingStderrWrite(capture: (text: string) => void): typeof pro
3948
}) satisfies typeof process.stderr.write;
4049
}
4150

51+
function replaceConsoleMethod(level: ConsoleLevel, method: ConsoleMethod): void {
52+
Object.defineProperty(console, level, {
53+
configurable: true,
54+
value: method,
55+
writable: true,
56+
});
57+
}
58+
4259
afterEach(() => {
4360
restoreInteractiveStderr();
4461
restoreStderr();
4562
replaceStderrWrite(originalStderrWrite);
63+
for (const level of consoleLevels) {
64+
replaceConsoleMethod(level, originalConsoleMethods[level]);
65+
}
4666
if (originalAgentDir === undefined) {
4767
delete process.env[ENV_AGENT_DIR];
4868
} else {
@@ -146,4 +166,34 @@ describe("interactive stderr guard", () => {
146166
expect(log).not.toContain(githubFineGrainedPat);
147167
expect((statSync(debugLogPath).mode & 0o777).toString(8)).toBe("600");
148168
});
169+
170+
it("hides Bun-style console diagnostics while the TUI owns the terminal and restores them afterwards", () => {
171+
const agentDir = mkdtempSync(join(tmpdir(), "pi-hidden-console-"));
172+
tempDirs.push(agentDir);
173+
process.env[ENV_AGENT_DIR] = agentDir;
174+
let terminalText = "";
175+
const bunConsoleWrite = (...data: unknown[]) => {
176+
terminalText += `${data.map(String).join(" ")}\n`;
177+
};
178+
for (const level of consoleLevels) {
179+
replaceConsoleMethod(level, bunConsoleWrite);
180+
}
181+
182+
takeOverInteractiveStderr();
183+
console.info("omo-senpi start-work-continuation skipped SECRET_TOKEN=console-secret");
184+
console.warn("omo-senpi ulw-loop status ignored", { reason: "non-zero-exit" });
185+
console.error("omo-senpi component registration failed");
186+
187+
expect(terminalText).toBe("");
188+
restoreInteractiveStderr();
189+
console.info("visible after restore");
190+
expect(terminalText).toBe("visible after restore\n");
191+
192+
const log = readFileSync(getDebugLogPath(), "utf8");
193+
expect(log).toContain("start-work-continuation skipped");
194+
expect(log).toContain("ulw-loop status ignored");
195+
expect(log).toContain("component registration failed");
196+
expect(log).toContain("SECRET_TOKEN=[REDACTED]");
197+
expect(log).not.toContain("console-secret");
198+
});
149199
});

0 commit comments

Comments
 (0)