openhands / src /hooks /use-bash-command-runner.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
63522a5 verified
Raw
History Blame Contribute Delete
6.6 kB
import { useCallback, useEffect, useRef } from "react";
import type {
BashCommand,
BashError,
BashEvent,
BashOutput,
} from "@openhands/typescript-client";
import type { CommandResult } from "#/api/runtime-service/agent-server-runtime-service";
import { sendWebSocketAuth } from "#/utils/websocket-auth";
import { startHandshakeWatchdog } from "#/utils/websocket-handshake";
import { buildBashWebSocketUrl } from "#/utils/websocket-url";
interface WaitingCommand {
command: string;
cwd: string;
timeout: number;
resolve: (result: CommandResult) => void;
reject: (error: Error) => void;
}
interface PendingCommand {
resolve: (result: CommandResult) => void;
reject: (error: Error) => void;
}
interface ActiveCommand extends PendingCommand {
stdout: string[];
stderr: string[];
}
export type BashCommandRunner = (
command: string,
cwd: string,
timeout: number,
) => Promise<CommandResult>;
function isBashCommand(event: BashEvent): event is BashCommand {
return event.kind === "BashCommand";
}
function isBashOutput(event: BashEvent): event is BashOutput {
return event.kind === "BashOutput";
}
function isBashError(event: BashEvent): event is BashError {
return event.kind === "BashError";
}
/**
* Maintains a persistent WebSocket connection to the agent-server's
* `/sockets/bash-events` endpoint and exposes a `runCommand` function that
* executes a bash command and returns a Promise that resolves when the
* final `BashOutput` (non-null `exit_code`) arrives.
*
* Commands are correlated using a FIFO queue: each `BashCommand` echo
* received from the server is paired with the oldest outstanding request in
* the queue, and subsequent `BashOutput` events are matched by `command_id`.
*
* Commands are buffered until the socket's open handler sends authentication.
*/
export function useBashCommandRunner(
conversationUrl: string | null | undefined,
sessionApiKey: string | null | undefined,
enabled: boolean,
): BashCommandRunner {
const wsRef = useRef<WebSocket | null>(null);
const readyWsRef = useRef<WebSocket | null>(null);
const waitingQueueRef = useRef<WaitingCommand[]>([]);
// Commands whose request was sent; waiting for the BashCommand echo to get command_id
const pendingQueueRef = useRef<PendingCommand[]>([]);
// Commands whose command_id is known; waiting for BashOutput with non-null exit_code
const activeCommandsRef = useRef<Map<string, ActiveCommand>>(new Map());
useEffect(() => {
if (!enabled) return;
const wsUrl = buildBashWebSocketUrl(conversationUrl);
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
readyWsRef.current = null;
// Abort a handshake stuck in CONNECTING — a hung bash-events handshake
// would also block the conversation's events socket (the one the chat
// needs) until it settles.
const cancelHandshakeWatchdog = startHandshakeWatchdog(ws);
ws.onopen = () => {
cancelHandshakeWatchdog();
sendWebSocketAuth(ws, sessionApiKey);
readyWsRef.current = ws;
for (const {
command,
cwd,
timeout,
resolve,
reject,
} of waitingQueueRef.current) {
pendingQueueRef.current.push({ resolve, reject });
ws.send(JSON.stringify({ command, cwd, timeout }));
}
waitingQueueRef.current = [];
};
ws.onmessage = (event: MessageEvent) => {
let data: BashEvent;
try {
data = JSON.parse(event.data as string) as BashEvent;
} catch {
return; // ignore malformed frames
}
if (isBashCommand(data)) {
// Associate the next pending request with the server-assigned command_id
const pending = pendingQueueRef.current.shift();
if (pending) {
activeCommandsRef.current.set(data.id, {
...pending,
stdout: [],
stderr: [],
});
}
} else if (isBashOutput(data) && data.command_id) {
const active = activeCommandsRef.current.get(data.command_id);
if (active) {
if (data.stdout) active.stdout.push(data.stdout);
if (data.stderr) active.stderr.push(data.stderr);
if (data.exit_code != null) {
activeCommandsRef.current.delete(data.command_id);
active.resolve({
exit_code: data.exit_code,
stdout: active.stdout.join(""),
stderr: active.stderr.join(""),
});
}
}
} else if (isBashError(data)) {
rejectAll(`Bash error: ${data.code}: ${data.detail}`);
}
};
function rejectAll(reason: string): void {
const err = new Error(reason);
for (const { reject: rej } of waitingQueueRef.current) rej(err);
waitingQueueRef.current = [];
for (const p of pendingQueueRef.current) p.reject(err);
pendingQueueRef.current = [];
for (const a of activeCommandsRef.current.values()) a.reject(err);
activeCommandsRef.current.clear();
}
ws.onclose = () => {
cancelHandshakeWatchdog();
wsRef.current = null;
readyWsRef.current = null;
rejectAll("Bash WebSocket closed");
};
ws.onerror = () => {
wsRef.current = null;
readyWsRef.current = null;
rejectAll("Bash WebSocket error");
};
return () => {
// The close handler is nulled below, so cancel the watchdog here.
cancelHandshakeWatchdog();
// Prevent the close/error handlers from double-rejecting after unmount
ws.onclose = null;
ws.onerror = null;
ws.close();
wsRef.current = null;
readyWsRef.current = null;
rejectAll("Bash WebSocket unmounted");
};
}, [enabled, conversationUrl, sessionApiKey]);
const runCommand: BashCommandRunner = useCallback(
(command: string, cwd: string, timeout: number) =>
new Promise<CommandResult>((resolve, reject) => {
const ws = wsRef.current;
if (
!ws ||
ws.readyState === WebSocket.CLOSED ||
ws.readyState === WebSocket.CLOSING
) {
reject(new Error("Bash WebSocket not available"));
return;
}
if (ws.readyState !== WebSocket.OPEN || readyWsRef.current !== ws) {
waitingQueueRef.current.push({
command,
cwd,
timeout,
resolve,
reject,
});
} else {
pendingQueueRef.current.push({ resolve, reject });
ws.send(JSON.stringify({ command, cwd, timeout }));
}
}),
[],
);
return runCommand;
}