Skip to content

Commit 236bd42

Browse files
committed
feat: interactive shell streaming with live output and quiesce detection
Replace batch-output shell execution with spawn-based streaming: - Shell commands now stream output line-by-line via ProcessEvent::ToolOutput - 5-second quiesce detection kills interactive prompts (waiting_for_input) - Frontend renders live output in ToolCall component with auto-scroll - ToolOutput events broadcast via separate SSE channel (capacity 1024) - Timeout enforcement wraps entire streaming operation - Watchdog now kills child process when quiesce detected Fixes: - P1: Timeout was ignored in streaming mode (now enforced) - P1: Watchdog didn't kill child (now passes Arc<Mutex<Child>>) - P2: Frontend call_id mismatch (now uses pendingToolCallIdsRef) Files changed: - src/tools/shell.rs: Complete rewrite with streaming - src/lib.rs: Add ProcessEvent::ToolOutput variant - src/api/state.rs: Wire SSE for tool_output events - interface/: Live output rendering components - prompts/: Interactive guidance for LLM
1 parent 55ad26a commit 236bd42

File tree

18 files changed

+761
-83
lines changed

18 files changed

+761
-83
lines changed

interface/src/api/client.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,19 @@ export interface ToolStartedEvent {
199199
args: string;
200200
}
201201

202+
export interface ToolOutputEvent {
203+
type: "tool_output";
204+
agent_id: string;
205+
channel_id: string | null;
206+
process_type: string;
207+
process_id: string;
208+
/** Stable identifier matching the tool_call that initiated this stream. */
209+
call_id: string;
210+
tool_name: string;
211+
line: string;
212+
stream: "stdout" | "stderr";
213+
}
214+
202215
export interface ToolCompletedEvent {
203216
type: "tool_completed";
204217
agent_id: string;
@@ -267,6 +280,7 @@ export type ApiEvent =
267280
| BranchCompletedEvent
268281
| ToolStartedEvent
269282
| ToolCompletedEvent
283+
| ToolOutputEvent
270284
| OpenCodePartUpdatedEvent
271285
| WorkerTextEvent
272286
| CortexChatMessageEvent;

interface/src/components/ToolCall.tsx

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
import {useState} from "react";
22
import {cx} from "class-variance-authority";
3-
import type {TranscriptStep, OpenCodePart} from "@/api/client";
3+
import type {OpenCodePart} from "@/api/client";
4+
import type { TranscriptStep as SchemaTranscriptStep } from "@/api/types";
5+
6+
// Extended TranscriptStep with live_output for streaming shell output
7+
type ExtendedTranscriptStep = SchemaTranscriptStep & {
8+
live_output?: string;
9+
};
10+
11+
// Use the extended type for pairing
12+
type TranscriptStep = ExtendedTranscriptStep;
413

514
// ---------------------------------------------------------------------------
615
// Types
@@ -25,6 +34,8 @@ export interface ToolCallPair {
2534
status: ToolCallStatus;
2635
/** Human-readable summary provided by live opencode parts */
2736
title?: string | null;
37+
/** Live streaming output from tool_output SSE events (running tools only) */
38+
liveOutput?: string;
2839
}
2940

3041
// ---------------------------------------------------------------------------
@@ -42,12 +53,13 @@ export type TranscriptItem =
4253

4354
export function pairTranscriptSteps(steps: TranscriptStep[]): TranscriptItem[] {
4455
const items: TranscriptItem[] = [];
45-
const resultsById = new Map<string, {name: string; text: string}>();
56+
const resultsById = new Map<string, {name: string; text: string; liveOutput?: string}>();
4657

4758
// First pass: index all tool_result steps by call_id
4859
for (const step of steps) {
4960
if (step.type === "tool_result") {
50-
resultsById.set(step.call_id, {name: step.name, text: step.text});
61+
const liveOutput = step.live_output;
62+
resultsById.set(step.call_id, {name: step.name, text: step.text, liveOutput});
5163
}
5264
}
5365

@@ -77,6 +89,7 @@ export function pairTranscriptSteps(steps: TranscriptStep[]): TranscriptItem[] {
7789
resultRaw: result?.text ?? null,
7890
result: parsedResult,
7991
status: result ? (isError ? "error" : "completed") : "running",
92+
liveOutput: result?.liveOutput,
8093
},
8194
});
8295
}
@@ -378,6 +391,15 @@ const toolRenderers: Record<string, ToolRenderer> = {
378391
);
379392
},
380393
resultView(pair) {
394+
if (pair.status === "running" && pair.liveOutput) {
395+
return (
396+
<div className="px-3 py-2">
397+
<pre className="max-h-60 overflow-auto whitespace-pre-wrap font-mono text-tiny text-ink-dull">
398+
{pair.liveOutput}
399+
</pre>
400+
</div>
401+
);
402+
}
381403
if (!pair.resultRaw) return null;
382404
return <ShellResultView pair={pair} />;
383405
},
@@ -1118,6 +1140,15 @@ function renderResult(
11181140
renderer: ToolRenderer,
11191141
): React.ReactNode {
11201142
if (pair.status === "running") {
1143+
if (pair.liveOutput) {
1144+
return (
1145+
<div className="px-3 py-2">
1146+
<pre className="max-h-60 overflow-auto whitespace-pre-wrap font-mono text-tiny text-ink-dull">
1147+
{pair.liveOutput}
1148+
</pre>
1149+
</div>
1150+
);
1151+
}
11211152
return (
11221153
<div className="flex items-center gap-2 px-3 py-2 text-tiny text-ink-faint">
11231154
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-accent" />

interface/src/hooks/useLiveContext.tsx

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { createContext, useContext, useCallback, useEffect, useRef, useState, useMemo, type ReactNode } from "react";
22
import { useQuery, useQueryClient } from "@tanstack/react-query";
3-
import { api, type AgentMessageEvent, type ChannelInfo, type ToolStartedEvent, type ToolCompletedEvent, type TranscriptStep, type OpenCodePart, type OpenCodePartUpdatedEvent, type WorkerTextEvent } from "@/api/client";
3+
import { api, type AgentMessageEvent, type ChannelInfo, type ToolStartedEvent, type ToolCompletedEvent, type ToolOutputEvent, type OpenCodePart, type OpenCodePartUpdatedEvent, type WorkerTextEvent } from "@/api/client";
4+
import type { TranscriptStep as SchemaTranscriptStep } from "@/api/types";
5+
6+
/** Extended TranscriptStep with live_output for streaming shell output */
7+
type TranscriptStep = SchemaTranscriptStep & {
8+
live_output?: string;
9+
};
410
import { generateId } from "@/lib/id";
511
import { useEventSource, type ConnectionState } from "@/hooks/useEventSource";
612
import { useChannelLiveState, type ChannelLiveState, type ActiveWorker } from "@/hooks/useChannelLiveState";
@@ -230,6 +236,39 @@ export function LiveContextProvider({ children, onBootstrapped }: { children: Re
230236
}
231237
}, [channelHandlers, bumpWorkerVersion]);
232238

239+
const handleToolOutput = useCallback((data: unknown) => {
240+
const event = data as ToolOutputEvent;
241+
if (event.process_type === "worker") {
242+
setLiveTranscripts((prev) => {
243+
const steps = prev[event.process_id] ?? [];
244+
// Use the stable call_id from the event to find or create the result step
245+
const existingIndex = steps.findIndex(
246+
(s) => s.type === "tool_result" && s.call_id === event.call_id
247+
);
248+
if (existingIndex >= 0) {
249+
// Append to existing result step
250+
const step = steps[existingIndex];
251+
const existingOutput = (step as TranscriptStep).live_output ?? "";
252+
const newOutput = existingOutput + event.line + "\n";
253+
const updatedStep = { ...step, live_output: newOutput };
254+
const newSteps = [...steps];
255+
newSteps[existingIndex] = updatedStep;
256+
return { ...prev, [event.process_id]: newSteps };
257+
}
258+
// Create new result step with the event's call_id
259+
const step: TranscriptStep = {
260+
type: "tool_result",
261+
call_id: event.call_id,
262+
name: event.tool_name,
263+
text: "",
264+
live_output: event.line + "\n",
265+
};
266+
return { ...prev, [event.process_id]: [...steps, step] };
267+
});
268+
bumpWorkerVersion();
269+
}
270+
}, [bumpWorkerVersion]);
271+
233272
// Handle OpenCode part updates — upsert parts into the per-worker ordered map
234273
const handleOpenCodePartUpdated = useCallback((data: unknown) => {
235274
const event = data as OpenCodePartUpdatedEvent;
@@ -280,6 +319,7 @@ export function LiveContextProvider({ children, onBootstrapped }: { children: Re
280319
worker_completed: wrappedWorkerCompleted,
281320
tool_started: wrappedToolStarted,
282321
tool_completed: wrappedToolCompleted,
322+
tool_output: handleToolOutput,
283323
opencode_part_updated: handleOpenCodePartUpdated,
284324
worker_text: handleWorkerText,
285325
agent_message_sent: handleAgentMessage,
@@ -289,7 +329,7 @@ export function LiveContextProvider({ children, onBootstrapped }: { children: Re
289329
notification_created: handleNotificationCreated,
290330
notification_updated: handleNotificationUpdated,
291331
}),
292-
[channelHandlers, wrappedWorkerStarted, wrappedWorkerStatus, wrappedWorkerIdle, wrappedWorkerCompleted, wrappedToolStarted, wrappedToolCompleted, handleOpenCodePartUpdated, handleWorkerText, handleAgentMessage, bumpTaskVersion, handleCortexChatMessage, handleNotificationCreated, handleNotificationUpdated],
332+
[channelHandlers, wrappedWorkerStarted, wrappedWorkerStatus, wrappedWorkerIdle, wrappedWorkerCompleted, wrappedToolStarted, wrappedToolCompleted, handleToolOutput, handleOpenCodePartUpdated, handleWorkerText, handleAgentMessage, bumpTaskVersion, handleCortexChatMessage, handleNotificationCreated, handleNotificationUpdated],
293333
);
294334

295335
const onReconnect = useCallback(() => {

interface/src/lib/primitives.tsx

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import * as React from "react";
2+
import {cx} from "class-variance-authority";
3+
import {
4+
Banner as PrimitiveBanner,
5+
Button as PrimitiveButton,
6+
FilterButton as PrimitiveFilterButton,
7+
NumberStepper as PrimitiveNumberStepper,
8+
} from "../../node_modules/@spacedrive/primitives/dist/index.js";
9+
10+
export * from "../../node_modules/@spacedrive/primitives/dist/index.js";
11+
12+
type LegacyButtonVariant = "ghost" | "secondary" | "destructive";
13+
type PrimitiveButtonVariant = NonNullable<
14+
React.ComponentProps<typeof PrimitiveButton>["variant"]
15+
>;
16+
type PrimitiveButtonSize = React.ComponentProps<typeof PrimitiveButton>["size"];
17+
type PrimitiveButtonRounding =
18+
React.ComponentProps<typeof PrimitiveButton>["rounding"];
19+
type ButtonVariant = PrimitiveButtonVariant | LegacyButtonVariant;
20+
type CompatButtonBaseProps = {
21+
children?: React.ReactNode;
22+
className?: string;
23+
loading?: boolean;
24+
rounding?: PrimitiveButtonRounding;
25+
size?: PrimitiveButtonSize;
26+
variant?: ButtonVariant;
27+
};
28+
type CompatActionButtonProps = CompatButtonBaseProps &
29+
Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
30+
href?: undefined;
31+
};
32+
type CompatLinkButtonProps = CompatButtonBaseProps &
33+
Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "children"> & {
34+
href: string;
35+
};
36+
type CompatButtonProps = CompatActionButtonProps | CompatLinkButtonProps;
37+
38+
function hasHref(
39+
props: CompatButtonProps,
40+
): props is CompatLinkButtonProps {
41+
return "href" in props && props.href !== undefined;
42+
}
43+
44+
function mapButtonVariant(
45+
variant: ButtonVariant | undefined,
46+
): PrimitiveButtonVariant | undefined {
47+
switch (variant) {
48+
case "ghost":
49+
return "subtle";
50+
case "secondary":
51+
return "gray";
52+
case "destructive":
53+
return "outline";
54+
default:
55+
return variant;
56+
}
57+
}
58+
59+
function legacyButtonClassName(variant: ButtonVariant | undefined) {
60+
if (variant === "destructive") {
61+
return "border-red-500/40 text-red-300 hover:border-red-500/60 hover:bg-red-500/10";
62+
}
63+
64+
return undefined;
65+
}
66+
67+
function LoadingSpinner() {
68+
return (
69+
<span
70+
aria-hidden="true"
71+
className="inline-block size-3 shrink-0 animate-spin rounded-full border border-current border-t-transparent"
72+
/>
73+
);
74+
}
75+
76+
export const Button = React.forwardRef<
77+
React.ElementRef<typeof PrimitiveButton>,
78+
CompatButtonProps
79+
>(({variant, loading = false, className, children, ...props}, ref) => {
80+
const buttonClassName = cx(legacyButtonClassName(variant), className);
81+
const buttonVariant = mapButtonVariant(variant);
82+
const content = (
83+
<>
84+
{loading ? <LoadingSpinner /> : null}
85+
{children}
86+
</>
87+
);
88+
89+
if (hasHref(props)) {
90+
return (
91+
<PrimitiveButton
92+
{...props}
93+
ref={ref}
94+
variant={buttonVariant}
95+
className={buttonClassName}
96+
>
97+
{content}
98+
</PrimitiveButton>
99+
);
100+
}
101+
102+
const actionProps = props as CompatActionButtonProps;
103+
104+
return (
105+
<PrimitiveButton
106+
{...actionProps}
107+
ref={ref}
108+
disabled={loading || actionProps.disabled}
109+
aria-busy={loading || undefined}
110+
variant={buttonVariant}
111+
className={buttonClassName}
112+
>
113+
{content}
114+
</PrimitiveButton>
115+
);
116+
});
117+
118+
Button.displayName = "Button";
119+
120+
type BannerDotMode = "pulse" | "static";
121+
type CompatBannerProps = React.ComponentProps<typeof PrimitiveBanner> & {
122+
dot?: BannerDotMode;
123+
};
124+
125+
export const Banner = React.forwardRef<
126+
React.ElementRef<typeof PrimitiveBanner>,
127+
CompatBannerProps
128+
>(({dot, showDot, className, ...props}, ref) => (
129+
<PrimitiveBanner
130+
{...props}
131+
ref={ref}
132+
showDot={showDot ?? dot !== undefined}
133+
className={cx(
134+
dot === "pulse" && "[&>span:first-child]:animate-pulse",
135+
className,
136+
)}
137+
/>
138+
));
139+
140+
Banner.displayName = "Banner";
141+
142+
type CompatFilterButtonProps = React.ComponentProps<
143+
typeof PrimitiveFilterButton
144+
> & {
145+
colorClass?: string;
146+
};
147+
148+
export const FilterButton = React.forwardRef<
149+
React.ElementRef<typeof PrimitiveFilterButton>,
150+
CompatFilterButtonProps
151+
>(({colorClass, active, className, ...props}, ref) => (
152+
<PrimitiveFilterButton
153+
{...props}
154+
ref={ref}
155+
active={active}
156+
className={cx(active && colorClass, className)}
157+
/>
158+
));
159+
160+
FilterButton.displayName = "FilterButton";
161+
162+
type CompatNumberStepperProps = React.ComponentProps<
163+
typeof PrimitiveNumberStepper
164+
> & {
165+
type?: "float" | "int";
166+
variant?: string;
167+
};
168+
169+
export const NumberStepper = React.forwardRef<
170+
React.ElementRef<typeof PrimitiveNumberStepper>,
171+
CompatNumberStepperProps
172+
>(({type, variant: _variant, allowFloat, ...props}, ref) => (
173+
<PrimitiveNumberStepper
174+
{...props}
175+
ref={ref}
176+
allowFloat={allowFloat ?? type === "float"}
177+
/>
178+
));
179+
180+
NumberStepper.displayName = "NumberStepper";

prompts/en/tools/shell_description.md.j2

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@ Execute a shell command. Use this for file operations, running scripts, building
22

33
Use the optional `env` parameter to set per-command environment variables (e.g. `[{"key": "RUST_LOG", "value": "debug"}]`). Dangerous variables that enable library injection (LD_PRELOAD, NODE_OPTIONS, etc.) are blocked.
44

5-
To install tools that persist across restarts, place binaries in the persistent tools directory at $SPACEBOT_DIR/tools/bin (already on PATH). For example: `curl -fsSL https://example.com/tool -o $SPACEBOT_DIR/tools/bin/tool && chmod +x $SPACEBOT_DIR/tools/bin/tool`
5+
To install tools that persist across restarts, place binaries in the persistent tools directory at $SPACEBOT_DIR/tools/bin (already on PATH). For example: `curl -fsSL https://example.com/tool -o $SPACEBOT_DIR/tools/bin/tool && chmod +x $SPACEBOT_DIR/tools/bin/tool`
6+
7+
Commands have no stdin — interactive prompts cannot be answered. If a command waits for input (no output for 5 seconds), it will be killed and you will receive `waiting_for_input: true` with the captured prompt text. To avoid this, always pass `--yes`, `-y`, or `--non-interactive` flags, or pipe input (e.g. `echo y | command`). The environment already sets `CI=true` and `DEBIAN_FRONTEND=noninteractive`.

prompts/en/worker.md.j2

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ Examples:
8787

8888
Execute shell commands. Use this for running builds, tests, git operations, package management, and any system commands. Supports optional `env` parameter for setting per-command environment variables (e.g. `RUST_LOG=debug`).
8989

90+
Commands have no stdin. Interactive prompts (confirmations, license acceptances) will be detected and killed after 5 seconds of silence. Always use `--yes`, `-y`, `--non-interactive`, or pipe input (e.g. `echo y | apt install foo`) to bypass prompts. The environment already sets `CI=true` and `DEBIAN_FRONTEND=noninteractive`.
91+
9092
### File tools (file_read, file_write, file_edit, file_list)
9193

9294
Four separate tools for file operations:

src/agent/channel_history.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -467,7 +467,8 @@ pub(crate) fn event_is_for_channel(event: &ProcessEvent, channel_id: &ChannelId)
467467
| ProcessEvent::StatusUpdate { .. }
468468
| ProcessEvent::TaskUpdated { .. }
469469
| ProcessEvent::WorkerText { .. }
470-
| ProcessEvent::CortexChatUpdate { .. } => false,
470+
| ProcessEvent::CortexChatUpdate { .. }
471+
| ProcessEvent::ToolOutput { .. } => false,
471472
}
472473
}
473474

0 commit comments

Comments
 (0)