Skip to content

Commit 47df2dc

Browse files
authored
🤖 feat: background bash with event-based API and blocking output (#978)
## Summary Refactored background bash feature with an event-based subscription API that aligns with Claude Code's tool conventions. Adds blocking timeout for `bash_output` to reduce polling spam, and includes a UI banner for managing running processes. ## Key Changes ### Background Bash Tools - **`bash`**: Added `run_in_background` and `timeout_secs` (required) parameters. Background processes return `process_id` for tracking - **`bash_output`**: New tool with blocking `timeout_secs` (0-15s) - waits for output instead of returning immediately. Returns incremental output via byte-offset tracking - **`bash_background_list`**: Lists all background processes with status/uptime - **`bash_background_terminate`**: Terminates a process by ID ### Blocking Timeout Implementation - `timeout_secs` is required on `bash_output` (0-15 seconds) - Blocks internally with 100ms poll interval waiting for new output - Returns early when output arrives or process exits - Eliminates agent polling loops that spam the UI ### Background Processes Banner UI - Shows 'N running bashes' above chat input - Expandable panel with process details (id, script, duration) - Kill button to terminate processes directly ### Bug Fixes - Fixed trap command breaking with spaces in process names (e.g., "PR Checks 978") - Fixed macOS compatibility: `wc -c` instead of `stat -c%s` - Fixed performance: `tail -c +N` instead of slow `dd bs=1 skip=N` - Restored validation blocking `sleep` commands at script start ## Test Coverage - `bash_output` timeout behavior (blocking wait, early return, immediate return) - Incremental output and regex filtering - Trap command with spaces in paths (regression test) - Sleep command validation _Generated with `mux`_
1 parent 72eb0ff commit 47df2dc

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

59 files changed

+4565
-1094
lines changed

.storybook/mocks/orpc.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,19 @@ export interface MockORPCClientOptions {
2727
providersList?: string[];
2828
/** Mock for projects.remove - return error string to simulate failure */
2929
onProjectRemove?: (projectPath: string) => { success: true } | { success: false; error: string };
30+
/** Background processes per workspace */
31+
backgroundProcesses?: Map<
32+
string,
33+
Array<{
34+
id: string;
35+
pid: number;
36+
script: string;
37+
displayName?: string;
38+
startTime: number;
39+
status: "running" | "exited" | "killed" | "failed";
40+
exitCode?: number;
41+
}>
42+
>;
3043
}
3144

3245
/**
@@ -55,6 +68,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
5568
providersConfig = {},
5669
providersList = [],
5770
onProjectRemove,
71+
backgroundProcesses = new Map(),
5872
} = options;
5973

6074
const workspaceMap = new Map(workspaces.map((w) => [w.id, w]));
@@ -178,6 +192,19 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
178192
await new Promise(() => {}); // Never resolves
179193
},
180194
},
195+
backgroundBashes: {
196+
subscribe: async function* (input: { workspaceId: string }) {
197+
// Yield initial state
198+
yield {
199+
processes: backgroundProcesses.get(input.workspaceId) ?? [],
200+
foregroundToolCallIds: [],
201+
};
202+
// Then hang forever (like a real subscription)
203+
await new Promise(() => {});
204+
},
205+
terminate: async () => ({ success: true, data: undefined }),
206+
sendToBackground: async () => ({ success: true, data: undefined }),
207+
},
181208
},
182209
window: {
183210
setTitle: async () => undefined,

src/browser/components/AIView.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ import { evictModelFromLRU } from "@/browser/hooks/useModelLRU";
5151
import { QueuedMessage } from "./Messages/QueuedMessage";
5252
import { CompactionWarning } from "./CompactionWarning";
5353
import { ConcurrentLocalWarning } from "./ConcurrentLocalWarning";
54+
import { BackgroundProcessesBanner } from "./BackgroundProcessesBanner";
55+
import { useBackgroundBashHandlers } from "@/browser/hooks/useBackgroundBashHandlers";
5456
import { checkAutoCompaction } from "@/browser/utils/compaction/autoCompactionCheck";
5557
import { executeCompaction } from "@/browser/utils/chatCommands";
5658
import { useProviderOptions } from "@/browser/hooks/useProviderOptions";
@@ -61,6 +63,7 @@ import { useAPI } from "@/browser/contexts/API";
6163
import { useReviews } from "@/browser/hooks/useReviews";
6264
import { ReviewsBanner } from "./ReviewsBanner";
6365
import type { ReviewNoteData } from "@/common/types/review";
66+
import { PopoverError } from "./PopoverError";
6467

6568
interface AIViewProps {
6669
workspaceId: string;
@@ -119,6 +122,15 @@ const AIViewInner: React.FC<AIViewProps> = ({
119122

120123
// Reviews state
121124
const reviews = useReviews(workspaceId);
125+
126+
const {
127+
processes: backgroundBashes,
128+
handleTerminate: handleTerminateBackgroundBash,
129+
foregroundToolCallIds,
130+
handleSendToBackground: handleSendBashToBackground,
131+
handleMessageSentBackground,
132+
error: backgroundBashError,
133+
} = useBackgroundBashHandlers(api, workspaceId);
122134
const { options } = useProviderOptions();
123135
const use1M = options.anthropic?.use1MContext ?? false;
124136
// Get pending model for auto-compaction settings (threshold is per-model)
@@ -320,13 +332,17 @@ const AIViewInner: React.FC<AIViewProps> = ({
320332
}, []);
321333

322334
const handleMessageSent = useCallback(() => {
335+
// Auto-background any running foreground bash when user sends a new message
336+
// This prevents the user from waiting for the bash to complete before their message is processed
337+
handleMessageSentBackground();
338+
323339
// Enable auto-scroll when user sends a message
324340
setAutoScroll(true);
325341

326342
// Reset autoRetry when user sends a message
327343
// User action = clear intent: "I'm actively using this workspace"
328344
setAutoRetry(true);
329-
}, [setAutoScroll, setAutoRetry]);
345+
}, [setAutoScroll, setAutoRetry, handleMessageSentBackground]);
330346

331347
const handleClearHistory = useCallback(
332348
async (percentage = 1.0) => {
@@ -581,6 +597,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
581597
msg.toolName === "propose_plan" &&
582598
msg.id === latestProposePlanId
583599
}
600+
foregroundBashToolCallIds={foregroundToolCallIds}
601+
onSendBashToBackground={handleSendBashToBackground}
584602
/>
585603
</div>
586604
{isAtCutoff && (
@@ -655,6 +673,10 @@ const AIViewInner: React.FC<AIViewProps> = ({
655673
onCompactClick={handleCompactClick}
656674
/>
657675
)}
676+
<BackgroundProcessesBanner
677+
processes={backgroundBashes}
678+
onTerminate={handleTerminateBackgroundBash}
679+
/>
658680
<ReviewsBanner workspaceId={workspaceId} />
659681
<ChatInput
660682
variant="workspace"
@@ -691,6 +713,12 @@ const AIViewInner: React.FC<AIViewProps> = ({
691713
onReviewNote={handleReviewNote} // Pass review note handler to append to chat
692714
isCreating={status === "creating"} // Workspace still being set up
693715
/>
716+
717+
<PopoverError
718+
error={backgroundBashError.error}
719+
prefix="Failed to terminate:"
720+
onDismiss={backgroundBashError.clearError}
721+
/>
694722
</div>
695723
);
696724
};
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import React, { useState, useCallback, useEffect } from "react";
2+
import { Terminal, X, ChevronDown, ChevronRight } from "lucide-react";
3+
import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip";
4+
import type { BackgroundProcessInfo } from "@/common/orpc/schemas/api";
5+
import { cn } from "@/common/lib/utils";
6+
import { formatDuration } from "./tools/shared/toolUtils";
7+
8+
/**
9+
* Truncate script to reasonable display length.
10+
*/
11+
function truncateScript(script: string, maxLength = 60): string {
12+
// First line only, truncated
13+
const firstLine = script.split("\n")[0] ?? script;
14+
if (firstLine.length <= maxLength) {
15+
return firstLine;
16+
}
17+
return firstLine.slice(0, maxLength - 3) + "...";
18+
}
19+
20+
interface BackgroundProcessesBannerProps {
21+
processes: BackgroundProcessInfo[];
22+
onTerminate: (processId: string) => void;
23+
}
24+
25+
/**
26+
* Banner showing running background processes.
27+
* Displays "N running bashes" which expands on click to show details.
28+
*/
29+
export const BackgroundProcessesBanner: React.FC<BackgroundProcessesBannerProps> = (props) => {
30+
const [isExpanded, setIsExpanded] = useState(false);
31+
const [, setTick] = useState(0);
32+
33+
// Filter to only running processes
34+
const runningProcesses = props.processes.filter((p) => p.status === "running");
35+
const count = runningProcesses.length;
36+
37+
// Update duration display every second when expanded
38+
useEffect(() => {
39+
if (!isExpanded || count === 0) return;
40+
const interval = setInterval(() => setTick((t) => t + 1), 1000);
41+
return () => clearInterval(interval);
42+
}, [isExpanded, count]);
43+
44+
const { onTerminate } = props;
45+
const handleTerminate = useCallback(
46+
(processId: string, event: React.MouseEvent) => {
47+
event.stopPropagation();
48+
onTerminate(processId);
49+
},
50+
[onTerminate]
51+
);
52+
53+
const handleToggle = useCallback(() => {
54+
setIsExpanded((prev) => !prev);
55+
}, []);
56+
57+
// Don't render if no running processes
58+
if (count === 0) {
59+
return null;
60+
}
61+
62+
return (
63+
<div className="border-border bg-dark border-t px-[15px]">
64+
{/* Collapsed banner - thin stripe, content aligned with chat */}
65+
<button
66+
type="button"
67+
onClick={handleToggle}
68+
className="group mx-auto flex w-full max-w-4xl items-center gap-2 px-2 py-1 text-xs transition-colors"
69+
>
70+
<Terminal className="text-muted group-hover:text-secondary size-3.5 transition-colors" />
71+
<span className="text-muted group-hover:text-secondary transition-colors">
72+
<span className="font-medium">{count}</span>
73+
{" background bash"}
74+
{count !== 1 && "es"}
75+
</span>
76+
<div className="ml-auto">
77+
{isExpanded ? (
78+
<ChevronDown className="text-muted group-hover:text-secondary size-3.5 transition-colors" />
79+
) : (
80+
<ChevronRight className="text-muted group-hover:text-secondary size-3.5 transition-colors" />
81+
)}
82+
</div>
83+
</button>
84+
85+
{/* Expanded view - content aligned with chat */}
86+
{isExpanded && (
87+
<div className="border-border mx-auto max-h-48 max-w-4xl space-y-1.5 overflow-y-auto border-t py-2">
88+
{runningProcesses.map((proc) => (
89+
<div
90+
key={proc.id}
91+
className={cn(
92+
"hover:bg-hover flex items-center justify-between gap-3 rounded px-2 py-1.5",
93+
"transition-colors"
94+
)}
95+
>
96+
<div className="min-w-0 flex-1">
97+
<div className="text-foreground truncate font-mono text-xs" title={proc.script}>
98+
{proc.displayName ?? truncateScript(proc.script)}
99+
</div>
100+
<div className="text-muted font-mono text-[10px]">pid {proc.pid}</div>
101+
</div>
102+
<div className="flex shrink-0 items-center gap-2">
103+
<span className="text-muted text-[10px]">
104+
{formatDuration(Date.now() - proc.startTime)}
105+
</span>
106+
<Tooltip>
107+
<TooltipTrigger asChild>
108+
<button
109+
type="button"
110+
onClick={(e) => handleTerminate(proc.id, e)}
111+
className="text-muted hover:text-error rounded p-1 transition-colors"
112+
>
113+
<X size={14} />
114+
</button>
115+
</TooltipTrigger>
116+
<TooltipContent>Terminate process</TooltipContent>
117+
</Tooltip>
118+
</div>
119+
</div>
120+
))}
121+
</div>
122+
)}
123+
</div>
124+
);
125+
};

src/browser/components/Messages/MessageRenderer.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ interface MessageRendererProps {
2222
onReviewNote?: (data: ReviewNoteData) => void;
2323
/** Whether this message is the latest propose_plan tool call (for external edit detection) */
2424
isLatestProposePlan?: boolean;
25+
/** Set of tool call IDs of foreground bashes */
26+
foregroundBashToolCallIds?: Set<string>;
27+
/** Callback to send a foreground bash to background */
28+
onSendBashToBackground?: (toolCallId: string) => void;
2529
}
2630

2731
// Memoized to prevent unnecessary re-renders when parent (AIView) updates
@@ -34,6 +38,8 @@ export const MessageRenderer = React.memo<MessageRendererProps>(
3438
isCompacting,
3539
onReviewNote,
3640
isLatestProposePlan,
41+
foregroundBashToolCallIds,
42+
onSendBashToBackground,
3743
}) => {
3844
// Route based on message type
3945
switch (message.type) {
@@ -63,6 +69,8 @@ export const MessageRenderer = React.memo<MessageRendererProps>(
6369
workspaceId={workspaceId}
6470
onReviewNote={onReviewNote}
6571
isLatestProposePlan={isLatestProposePlan}
72+
foregroundBashToolCallIds={foregroundBashToolCallIds}
73+
onSendBashToBackground={onSendBashToBackground}
6674
/>
6775
);
6876
case "reasoning":

src/browser/components/Messages/ToolMessage.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,16 @@ import { StatusSetToolCall } from "../tools/StatusSetToolCall";
1111
import { WebFetchToolCall } from "../tools/WebFetchToolCall";
1212
import { BashBackgroundListToolCall } from "../tools/BashBackgroundListToolCall";
1313
import { BashBackgroundTerminateToolCall } from "../tools/BashBackgroundTerminateToolCall";
14+
import { BashOutputToolCall } from "../tools/BashOutputToolCall";
1415
import type {
1516
BashToolArgs,
1617
BashToolResult,
1718
BashBackgroundListArgs,
1819
BashBackgroundListResult,
1920
BashBackgroundTerminateArgs,
2021
BashBackgroundTerminateResult,
22+
BashOutputToolArgs,
23+
BashOutputToolResult,
2124
FileReadToolArgs,
2225
FileReadToolResult,
2326
FileEditReplaceStringToolArgs,
@@ -45,6 +48,10 @@ interface ToolMessageProps {
4548
onReviewNote?: (data: ReviewNoteData) => void;
4649
/** Whether this is the latest propose_plan in the conversation */
4750
isLatestProposePlan?: boolean;
51+
/** Set of tool call IDs of foreground bashes */
52+
foregroundBashToolCallIds?: Set<string>;
53+
/** Callback to send a foreground bash to background */
54+
onSendBashToBackground?: (toolCallId: string) => void;
4855
}
4956

5057
// Type guards using Zod schemas for single source of truth
@@ -113,22 +120,36 @@ function isBashBackgroundTerminateTool(
113120
return TOOL_DEFINITIONS.bash_background_terminate.schema.safeParse(args).success;
114121
}
115122

123+
function isBashOutputTool(toolName: string, args: unknown): args is BashOutputToolArgs {
124+
if (toolName !== "bash_output") return false;
125+
return TOOL_DEFINITIONS.bash_output.schema.safeParse(args).success;
126+
}
127+
116128
export const ToolMessage: React.FC<ToolMessageProps> = ({
117129
message,
118130
className,
119131
workspaceId,
120132
onReviewNote,
121133
isLatestProposePlan,
134+
foregroundBashToolCallIds,
135+
onSendBashToBackground,
122136
}) => {
123137
// Route to specialized components based on tool name
124138
if (isBashTool(message.toolName, message.args)) {
139+
// Only show "Background" button if this specific tool call is a foreground process
140+
const canSendToBackground = foregroundBashToolCallIds?.has(message.toolCallId) ?? false;
141+
const toolCallId = message.toolCallId;
125142
return (
126143
<div className={className}>
127144
<BashToolCall
128145
args={message.args}
129146
result={message.result as BashToolResult | undefined}
130147
status={message.status}
131148
startedAt={message.timestamp}
149+
canSendToBackground={canSendToBackground}
150+
onSendToBackground={
151+
onSendBashToBackground ? () => onSendBashToBackground(toolCallId) : undefined
152+
}
132153
/>
133154
</div>
134155
);
@@ -262,6 +283,18 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
262283
);
263284
}
264285

286+
if (isBashOutputTool(message.toolName, message.args)) {
287+
return (
288+
<div className={className}>
289+
<BashOutputToolCall
290+
args={message.args}
291+
result={message.result as BashOutputToolResult | undefined}
292+
status={message.status}
293+
/>
294+
</div>
295+
);
296+
}
297+
265298
// Fallback to generic tool call
266299
return (
267300
<div className={className}>

0 commit comments

Comments
 (0)