323 lines
10 KiB
TypeScript
323 lines
10 KiB
TypeScript
import {useEffect, useMemo, useRef, useState} from 'react';
|
|
import {spawn, type ChildProcessWithoutNullStreams} from 'node:child_process';
|
|
import readline from 'node:readline';
|
|
|
|
import type {
|
|
BackendEvent,
|
|
BridgeSessionSnapshot,
|
|
FrontendConfig,
|
|
McpServerSnapshot,
|
|
SelectOptionPayload,
|
|
SwarmNotificationSnapshot,
|
|
SwarmTeammateSnapshot,
|
|
TaskSnapshot,
|
|
TranscriptItem,
|
|
} from '../types.js';
|
|
|
|
const PROTOCOL_PREFIX = 'OHJSON:';
|
|
const ASSISTANT_DELTA_FLUSH_MS = 33;
|
|
const ASSISTANT_DELTA_FLUSH_CHARS = 256;
|
|
|
|
const stableStringify = (value: unknown): string => JSON.stringify(value);
|
|
|
|
export function useBackendSession(config: FrontendConfig, onExit: (code?: number | null) => void) {
|
|
const [transcript, setTranscript] = useState<TranscriptItem[]>([]);
|
|
const [assistantBuffer, setAssistantBuffer] = useState('');
|
|
const [status, setStatus] = useState<Record<string, unknown>>({});
|
|
const [tasks, setTasks] = useState<TaskSnapshot[]>([]);
|
|
const [commands, setCommands] = useState<string[]>([]);
|
|
const [mcpServers, setMcpServers] = useState<McpServerSnapshot[]>([]);
|
|
const [bridgeSessions, setBridgeSessions] = useState<BridgeSessionSnapshot[]>([]);
|
|
const [modal, setModal] = useState<Record<string, unknown> | null>(null);
|
|
const [selectRequest, setSelectRequest] = useState<{title: string; command: string; options: SelectOptionPayload[]} | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [ready, setReady] = useState(false);
|
|
const [todoMarkdown, setTodoMarkdown] = useState('');
|
|
const [swarmTeammates, setSwarmTeammates] = useState<SwarmTeammateSnapshot[]>([]);
|
|
const [swarmNotifications, setSwarmNotifications] = useState<SwarmNotificationSnapshot[]>([]);
|
|
const childRef = useRef<ChildProcessWithoutNullStreams | null>(null);
|
|
const sentInitialPrompt = useRef(false);
|
|
const lastStatusSnapshotRef = useRef('');
|
|
const lastTasksSnapshotRef = useRef('');
|
|
const lastMcpSnapshotRef = useRef('');
|
|
const lastBridgeSnapshotRef = useRef('');
|
|
|
|
// Streaming deltas can arrive one token at a time; updating Ink state for each
|
|
// delta causes heavy re-rendering/flicker. Buffer and flush at ~30fps.
|
|
const assistantBufferRef = useRef('');
|
|
const pendingAssistantDeltaRef = useRef('');
|
|
const assistantFlushTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
const flushAssistantDelta = (): void => {
|
|
const pending = pendingAssistantDeltaRef.current;
|
|
if (!pending) {
|
|
return;
|
|
}
|
|
pendingAssistantDeltaRef.current = '';
|
|
assistantBufferRef.current += pending;
|
|
setAssistantBuffer(assistantBufferRef.current);
|
|
};
|
|
|
|
const clearAssistantDelta = (): void => {
|
|
pendingAssistantDeltaRef.current = '';
|
|
assistantBufferRef.current = '';
|
|
if (assistantFlushTimerRef.current) {
|
|
clearTimeout(assistantFlushTimerRef.current);
|
|
assistantFlushTimerRef.current = null;
|
|
}
|
|
setAssistantBuffer('');
|
|
};
|
|
|
|
const sendRequest = (payload: Record<string, unknown>): void => {
|
|
const child = childRef.current;
|
|
if (!child || child.stdin.destroyed) {
|
|
return;
|
|
}
|
|
child.stdin.write(JSON.stringify(payload) + '\n');
|
|
};
|
|
|
|
useEffect(() => {
|
|
const [command, ...args] = config.backend_command;
|
|
const useDetachedGroup = process.platform !== 'win32';
|
|
const child = spawn(command, args, {
|
|
stdio: ['pipe', 'pipe', 'inherit'],
|
|
env: process.env,
|
|
// On Windows, a detached child gets its own console window and can
|
|
// flash open/closed. Keep detached groups for POSIX only.
|
|
detached: useDetachedGroup,
|
|
windowsHide: true,
|
|
});
|
|
childRef.current = child;
|
|
|
|
const reader = readline.createInterface({input: child.stdout});
|
|
reader.on('line', (line) => {
|
|
if (!line.startsWith(PROTOCOL_PREFIX)) {
|
|
setTranscript((items) => [...items, {role: 'log', text: line}]);
|
|
return;
|
|
}
|
|
const event = JSON.parse(line.slice(PROTOCOL_PREFIX.length)) as BackendEvent;
|
|
handleEvent(event);
|
|
});
|
|
|
|
child.on('exit', (code) => {
|
|
setTranscript((items) => [...items, {role: 'system', text: `backend exited with code ${code ?? 0}`}]);
|
|
process.exitCode = code ?? 0;
|
|
onExit(code);
|
|
});
|
|
|
|
// Ensure child processes are killed on parent exit (prevents stale processes)
|
|
const killChild = (): void => {
|
|
if (!child.killed) {
|
|
// Kill the whole process group on POSIX. On Windows, terminate the
|
|
// direct child to avoid relying on negative PIDs.
|
|
try {
|
|
if (useDetachedGroup && child.pid) {
|
|
process.kill(-child.pid, 'SIGTERM');
|
|
} else {
|
|
child.kill('SIGTERM');
|
|
}
|
|
} catch {
|
|
child.kill('SIGTERM');
|
|
}
|
|
}
|
|
if (assistantFlushTimerRef.current) {
|
|
clearTimeout(assistantFlushTimerRef.current);
|
|
assistantFlushTimerRef.current = null;
|
|
}
|
|
};
|
|
process.on('exit', killChild);
|
|
process.on('SIGINT', killChild);
|
|
process.on('SIGTERM', killChild);
|
|
|
|
return () => {
|
|
reader.close();
|
|
killChild();
|
|
process.removeListener('exit', killChild);
|
|
process.removeListener('SIGINT', killChild);
|
|
process.removeListener('SIGTERM', killChild);
|
|
};
|
|
}, []);
|
|
|
|
const handleEvent = (event: BackendEvent): void => {
|
|
if (event.type === 'ready') {
|
|
setReady(true);
|
|
const statusSnapshot = stableStringify(event.state ?? {});
|
|
lastStatusSnapshotRef.current = statusSnapshot;
|
|
setStatus(event.state ?? {});
|
|
const tasksSnapshot = stableStringify(event.tasks ?? []);
|
|
lastTasksSnapshotRef.current = tasksSnapshot;
|
|
setTasks(event.tasks ?? []);
|
|
setCommands(event.commands ?? []);
|
|
const mcpSnapshot = stableStringify(event.mcp_servers ?? []);
|
|
lastMcpSnapshotRef.current = mcpSnapshot;
|
|
setMcpServers(event.mcp_servers ?? []);
|
|
const bridgeSnapshot = stableStringify(event.bridge_sessions ?? []);
|
|
lastBridgeSnapshotRef.current = bridgeSnapshot;
|
|
setBridgeSessions(event.bridge_sessions ?? []);
|
|
if (config.initial_prompt && !sentInitialPrompt.current) {
|
|
sentInitialPrompt.current = true;
|
|
sendRequest({type: 'submit_line', line: config.initial_prompt});
|
|
setBusy(true);
|
|
}
|
|
return;
|
|
}
|
|
if (event.type === 'state_snapshot') {
|
|
const statusSnapshot = stableStringify(event.state ?? {});
|
|
if (statusSnapshot !== lastStatusSnapshotRef.current) {
|
|
lastStatusSnapshotRef.current = statusSnapshot;
|
|
setStatus(event.state ?? {});
|
|
}
|
|
const mcpSnapshot = stableStringify(event.mcp_servers ?? []);
|
|
if (mcpSnapshot !== lastMcpSnapshotRef.current) {
|
|
lastMcpSnapshotRef.current = mcpSnapshot;
|
|
setMcpServers(event.mcp_servers ?? []);
|
|
}
|
|
const bridgeSnapshot = stableStringify(event.bridge_sessions ?? []);
|
|
if (bridgeSnapshot !== lastBridgeSnapshotRef.current) {
|
|
lastBridgeSnapshotRef.current = bridgeSnapshot;
|
|
setBridgeSessions(event.bridge_sessions ?? []);
|
|
}
|
|
return;
|
|
}
|
|
if (event.type === 'tasks_snapshot') {
|
|
const tasksSnapshot = stableStringify(event.tasks ?? []);
|
|
if (tasksSnapshot !== lastTasksSnapshotRef.current) {
|
|
lastTasksSnapshotRef.current = tasksSnapshot;
|
|
setTasks(event.tasks ?? []);
|
|
}
|
|
return;
|
|
}
|
|
if (event.type === 'transcript_item' && event.item) {
|
|
setTranscript((items) => [...items, event.item as TranscriptItem]);
|
|
return;
|
|
}
|
|
if (event.type === 'status') {
|
|
const message = event.message?.trim();
|
|
if (!message) {
|
|
return;
|
|
}
|
|
setTranscript((items) => [...items, {role: 'status', text: message}]);
|
|
return;
|
|
}
|
|
if (event.type === 'assistant_delta') {
|
|
const delta = event.message ?? '';
|
|
if (!delta) {
|
|
return;
|
|
}
|
|
pendingAssistantDeltaRef.current += delta;
|
|
if (pendingAssistantDeltaRef.current.length >= ASSISTANT_DELTA_FLUSH_CHARS) {
|
|
flushAssistantDelta();
|
|
return;
|
|
}
|
|
if (!assistantFlushTimerRef.current) {
|
|
assistantFlushTimerRef.current = setTimeout(() => {
|
|
assistantFlushTimerRef.current = null;
|
|
flushAssistantDelta();
|
|
}, ASSISTANT_DELTA_FLUSH_MS);
|
|
}
|
|
return;
|
|
}
|
|
if (event.type === 'assistant_complete') {
|
|
if (assistantFlushTimerRef.current) {
|
|
clearTimeout(assistantFlushTimerRef.current);
|
|
assistantFlushTimerRef.current = null;
|
|
}
|
|
flushAssistantDelta();
|
|
const text = event.message ?? assistantBufferRef.current;
|
|
setTranscript((items) => [...items, {role: 'assistant', text}]);
|
|
clearAssistantDelta();
|
|
setBusy(false);
|
|
return;
|
|
}
|
|
if (event.type === 'line_complete') {
|
|
// If the line ended without an assistant_complete (e.g. errors), make sure we
|
|
// don't leave stale streaming text on screen.
|
|
clearAssistantDelta();
|
|
setBusy(false);
|
|
return;
|
|
}
|
|
if ((event.type === 'tool_started' || event.type === 'tool_completed') && event.item) {
|
|
const enrichedItem: TranscriptItem = {
|
|
...event.item,
|
|
tool_name: event.item.tool_name ?? event.tool_name ?? undefined,
|
|
tool_input: event.item.tool_input ?? undefined,
|
|
is_error: event.item.is_error ?? event.is_error ?? undefined,
|
|
};
|
|
setTranscript((items) => [...items, enrichedItem]);
|
|
return;
|
|
}
|
|
if (event.type === 'clear_transcript') {
|
|
setTranscript([]);
|
|
clearAssistantDelta();
|
|
return;
|
|
}
|
|
if (event.type === 'select_request') {
|
|
const m = event.modal ?? {};
|
|
setSelectRequest({
|
|
title: String(m.title ?? 'Select'),
|
|
command: String(m.command ?? ''),
|
|
options: event.select_options ?? [],
|
|
});
|
|
return;
|
|
}
|
|
if (event.type === 'modal_request') {
|
|
setModal(event.modal ?? null);
|
|
return;
|
|
}
|
|
if (event.type === 'error') {
|
|
setTranscript((items) => [...items, {role: 'system', text: `error: ${event.message ?? 'unknown error'}`}]);
|
|
clearAssistantDelta();
|
|
setBusy(false);
|
|
return;
|
|
}
|
|
if (event.type === 'todo_update') {
|
|
if (event.todo_markdown != null) {
|
|
setTodoMarkdown(event.todo_markdown);
|
|
}
|
|
return;
|
|
}
|
|
if (event.type === 'swarm_status') {
|
|
if (event.swarm_teammates != null) {
|
|
setSwarmTeammates(event.swarm_teammates);
|
|
}
|
|
if (event.swarm_notifications != null) {
|
|
setSwarmNotifications((prev) => [...prev, ...event.swarm_notifications!].slice(-20));
|
|
}
|
|
return;
|
|
}
|
|
if (event.type === 'plan_mode_change') {
|
|
if (event.plan_mode != null) {
|
|
setStatus((s) => ({...s, permission_mode: event.plan_mode}));
|
|
}
|
|
return;
|
|
}
|
|
if (event.type === 'shutdown') {
|
|
onExit(0);
|
|
}
|
|
};
|
|
|
|
return useMemo(
|
|
() => ({
|
|
transcript,
|
|
assistantBuffer,
|
|
status,
|
|
tasks,
|
|
commands,
|
|
mcpServers,
|
|
bridgeSessions,
|
|
modal,
|
|
selectRequest,
|
|
busy,
|
|
ready,
|
|
todoMarkdown,
|
|
swarmTeammates,
|
|
swarmNotifications,
|
|
setModal,
|
|
setSelectRequest,
|
|
setBusy,
|
|
sendRequest,
|
|
}),
|
|
[assistantBuffer, bridgeSessions, busy, commands, mcpServers, modal, ready, selectRequest, status, swarmNotifications, swarmTeammates, tasks, todoMarkdown, transcript]
|
|
);
|
|
}
|