Skip to content

Commit b84f658

Browse files
authored
🤖 Add overflow_policy to prevent git status temp file spam (#256)
Fixes console log spam from background git status polling operations. ## Problem GitStatusStore and GitStatusIndicator poll git status every 3 seconds. When output exceeds the 16KB limit, the bash tool writes full output to temp files, causing console spam: ``` [gitStatus] Script failed: [OUTPUT OVERFLOW - ...] Full output saved to /var/folders/.../bash-ffe12c1a.txt ``` Background operations don't need full output - they just fail silently. ## Solution Added `overflow_policy: 'truncate' | 'tmpfile'` configuration option: - **`'truncate'`** - Returns first 50 lines inline, no temp file (for background ops) - **`'tmpfile'`** - Writes full output to temp file (default, for AI operations) The option is **NOT exposed to AI** - it's only available through internal configuration. ## Changes - Added `overflow_policy?` to `ToolConfiguration` interface - Updated bash.ts to branch on policy when handling overflow - Plumbed through IPC types and handlers - Applied `overflow_policy: 'truncate'` to GitStatusStore and GitStatusIndicator ## Testing - Added 2 new tests verifying both policies - All 504 tests pass - Verified AI tool schema doesn't expose the parameter _Generated with `cmux`_
1 parent dbd7836 commit b84f658

File tree

5 files changed

+125
-27
lines changed

5 files changed

+125
-27
lines changed

src/services/ipcMain.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -595,7 +595,10 @@ export class IpcMain {
595595
_event,
596596
workspaceId: string,
597597
script: string,
598-
options?: { timeout_secs?: number; niceness?: number }
598+
options?: {
599+
timeout_secs?: number;
600+
niceness?: number;
601+
}
599602
) => {
600603
try {
601604
// Get workspace metadata to find workspacePath
@@ -616,11 +619,13 @@ export class IpcMain {
616619
using tempDir = new DisposableTempDir("cmux-ipc-bash");
617620

618621
// Create bash tool with workspace's cwd and secrets
622+
// All IPC bash calls are from UI (background operations) - use truncate to avoid temp file spam
619623
const bashTool = createBashTool({
620624
cwd: workspacePath,
621625
secrets: secretsToRecord(projectSecrets),
622626
niceness: options?.niceness,
623627
tempDir: tempDir.path,
628+
overflow_policy: "truncate",
624629
});
625630

626631
// Execute the script with provided options

src/services/tools/bash.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,76 @@ describe("bash tool", () => {
157157
}
158158
});
159159

160+
it("should truncate overflow output when overflow_policy is 'truncate'", async () => {
161+
const tempDir = new TestTempDir("test-bash-truncate");
162+
const tool = createBashTool({
163+
cwd: process.cwd(),
164+
tempDir: tempDir.path,
165+
overflow_policy: "truncate",
166+
});
167+
168+
const args: BashToolArgs = {
169+
script: "for i in {1..400}; do echo line$i; done", // Exceeds 300 line hard cap
170+
timeout_secs: 5,
171+
};
172+
173+
const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult;
174+
175+
expect(result.success).toBe(false);
176+
if (!result.success) {
177+
// Should contain truncation notice
178+
expect(result.error).toContain("[OUTPUT TRUNCATED");
179+
expect(result.error).toContain("Showing first 50 of");
180+
expect(result.error).toContain("lines:");
181+
182+
// Should contain first 50 lines
183+
expect(result.error).toContain("line1");
184+
expect(result.error).toContain("line50");
185+
186+
// Should NOT contain line 51 or beyond
187+
expect(result.error).not.toContain("line51");
188+
expect(result.error).not.toContain("line100");
189+
190+
// Should NOT create temp file
191+
const files = fs.readdirSync(tempDir.path);
192+
const bashFiles = files.filter((f) => f.startsWith("bash-"));
193+
expect(bashFiles.length).toBe(0);
194+
}
195+
196+
tempDir[Symbol.dispose]();
197+
});
198+
199+
it("should use tmpfile policy by default when overflow_policy not specified", async () => {
200+
const tempDir = new TestTempDir("test-bash-default");
201+
const tool = createBashTool({
202+
cwd: process.cwd(),
203+
tempDir: tempDir.path,
204+
// overflow_policy not specified - should default to tmpfile
205+
});
206+
207+
const args: BashToolArgs = {
208+
script: "for i in {1..400}; do echo line$i; done",
209+
timeout_secs: 5,
210+
};
211+
212+
const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult;
213+
214+
expect(result.success).toBe(false);
215+
if (!result.success) {
216+
// Should use tmpfile behavior
217+
expect(result.error).toContain("[OUTPUT OVERFLOW");
218+
expect(result.error).toContain("saved to");
219+
expect(result.error).not.toContain("[OUTPUT TRUNCATED");
220+
221+
// Verify temp file was created
222+
const files = fs.readdirSync(tempDir.path);
223+
const bashFiles = files.filter((f) => f.startsWith("bash-"));
224+
expect(bashFiles.length).toBe(1);
225+
}
226+
227+
tempDir[Symbol.dispose]();
228+
});
229+
160230
it("should interleave stdout and stderr", async () => {
161231
using testEnv = createTestBashTool();
162232
const tool = testEnv.tool;

src/services/tools/bash.ts

Lines changed: 43 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -320,38 +320,56 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => {
320320
wall_duration_ms,
321321
});
322322
} else if (truncated) {
323-
// Save overflow output to temp file instead of returning an error
324-
// We don't show ANY of the actual output to avoid overwhelming context.
325-
// Instead, save it to a temp file and encourage the agent to use filtering tools.
326-
try {
327-
// Use 8 hex characters for short, memorable temp file IDs
328-
const fileId = Math.random().toString(16).substring(2, 10);
329-
const overflowPath = path.join(config.tempDir, `bash-${fileId}.txt`);
330-
const fullOutput = lines.join("\n");
331-
fs.writeFileSync(overflowPath, fullOutput, "utf-8");
332-
333-
const output = `[OUTPUT OVERFLOW - ${overflowReason ?? "unknown reason"}]
323+
// Handle overflow based on policy
324+
const overflowPolicy = config.overflow_policy ?? "tmpfile";
334325

335-
Full output (${lines.length} lines) saved to ${overflowPath}
336-
337-
Use selective filtering tools (e.g. grep) to extract relevant information and continue your task
338-
339-
File will be automatically cleaned up when stream ends.`;
326+
if (overflowPolicy === "truncate") {
327+
// Return truncated output with first 50 lines
328+
const maxTruncateLines = 50;
329+
const truncatedLines = lines.slice(0, maxTruncateLines);
330+
const truncatedOutput = truncatedLines.join("\n");
331+
const errorMessage = `[OUTPUT TRUNCATED - ${overflowReason ?? "unknown reason"}]\n\nShowing first ${maxTruncateLines} of ${lines.length} lines:\n\n${truncatedOutput}`;
340332

341333
resolveOnce({
342334
success: false,
343-
error: output,
344-
exitCode: -1,
345-
wall_duration_ms,
346-
});
347-
} catch (err) {
348-
// If temp file creation fails, fall back to original error
349-
resolveOnce({
350-
success: false,
351-
error: `Command output overflow: ${overflowReason ?? "unknown reason"}. Failed to save overflow to temp file: ${String(err)}`,
335+
error: errorMessage,
352336
exitCode: -1,
353337
wall_duration_ms,
354338
});
339+
} else {
340+
// tmpfile policy: Save overflow output to temp file instead of returning an error
341+
// We don't show ANY of the actual output to avoid overwhelming context.
342+
// Instead, save it to a temp file and encourage the agent to use filtering tools.
343+
try {
344+
// Use 8 hex characters for short, memorable temp file IDs
345+
const fileId = Math.random().toString(16).substring(2, 10);
346+
const overflowPath = path.join(config.tempDir, `bash-${fileId}.txt`);
347+
const fullOutput = lines.join("\n");
348+
fs.writeFileSync(overflowPath, fullOutput, "utf-8");
349+
350+
const output = `[OUTPUT OVERFLOW - ${overflowReason ?? "unknown reason"}]
351+
352+
Full output (${lines.length} lines) saved to ${overflowPath}
353+
354+
Use selective filtering tools (e.g. grep) to extract relevant information and continue your task
355+
356+
File will be automatically cleaned up when stream ends.`;
357+
358+
resolveOnce({
359+
success: false,
360+
error: output,
361+
exitCode: -1,
362+
wall_duration_ms,
363+
});
364+
} catch (err) {
365+
// If temp file creation fails, fall back to original error
366+
resolveOnce({
367+
success: false,
368+
error: `Command output overflow: ${overflowReason ?? "unknown reason"}. Failed to save overflow to temp file: ${String(err)}`,
369+
exitCode: -1,
370+
wall_duration_ms,
371+
});
372+
}
355373
}
356374
} else if (exitCode === 0 || exitCode === null) {
357375
resolveOnce({

src/types/ipc.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,10 @@ export interface IPCApi {
210210
executeBash(
211211
workspaceId: string,
212212
script: string,
213-
options?: { timeout_secs?: number; niceness?: number }
213+
options?: {
214+
timeout_secs?: number;
215+
niceness?: number;
216+
}
214217
): Promise<Result<BashToolResult, string>>;
215218
openTerminal(workspacePath: string): Promise<void>;
216219

src/utils/tools/tools.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export interface ToolConfiguration {
2121
niceness?: number;
2222
/** Temporary directory for tool outputs (required) */
2323
tempDir: string;
24+
/** Overflow policy for bash tool output (optional, not exposed to AI) */
25+
overflow_policy?: "truncate" | "tmpfile";
2426
}
2527

2628
/**

0 commit comments

Comments
 (0)