diff --git a/ui-electrobun/src/bun/index.ts b/ui-electrobun/src/bun/index.ts index f028f35..b6f41fb 100644 --- a/ui-electrobun/src/bun/index.ts +++ b/ui-electrobun/src/bun/index.ts @@ -2,6 +2,7 @@ import path from "path"; import { BrowserWindow, BrowserView, Updater } from "electrobun/bun"; import { PtyClient } from "./pty-client.ts"; import { settingsDb } from "./settings-db.ts"; +import { SidecarManager } from "./sidecar-manager.ts"; import type { PtyRPCSchema } from "../shared/pty-rpc-schema.ts"; import { randomUUID } from "crypto"; @@ -11,6 +12,7 @@ const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`; // ── PTY daemon client ──────────────────────────────────────────────────────── const ptyClient = new PtyClient(); +const sidecarManager = new SidecarManager(); async function connectToDaemon(retries = 5, delayMs = 500): Promise { for (let attempt = 1; attempt <= retries; attempt++) { @@ -347,6 +349,90 @@ const rpc = BrowserView.defineRPC({ return { ok: false }; } }, + + // ── Agent handlers ────────────────────────────────────────────────── + + "agent.start": ({ sessionId, provider, prompt, cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv }) => { + try { + const result = sidecarManager.startSession(sessionId, provider, prompt, { + cwd, + model, + systemPrompt, + maxTurns, + permissionMode, + claudeConfigDir, + extraEnv, + }); + + if (result.ok) { + // Forward sidecar messages to webview + sidecarManager.onMessage(sessionId, (sid, messages) => { + try { + rpc.send["agent.message"]({ sessionId: sid, messages }); + } catch (err) { + console.error("[agent.message] forward error:", err); + } + }); + + sidecarManager.onStatus(sessionId, (sid, status, error) => { + try { + rpc.send["agent.status"]({ sessionId: sid, status, error }); + } catch (err) { + console.error("[agent.status] forward error:", err); + } + + // Send cost update on status change + const sessions = sidecarManager.listSessions(); + const session = sessions.find((s) => s.sessionId === sid); + if (session) { + try { + rpc.send["agent.cost"]({ + sessionId: sid, + costUsd: session.costUsd, + inputTokens: session.inputTokens, + outputTokens: session.outputTokens, + }); + } catch { /* ignore */ } + } + }); + } + + return result; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + console.error("[agent.start]", err); + return { ok: false, error }; + } + }, + + "agent.stop": ({ sessionId }) => { + try { + return sidecarManager.stopSession(sessionId); + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + console.error("[agent.stop]", err); + return { ok: false, error }; + } + }, + + "agent.prompt": ({ sessionId, prompt }) => { + try { + return sidecarManager.writePrompt(sessionId, prompt); + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + console.error("[agent.prompt]", err); + return { ok: false, error }; + } + }, + + "agent.list": () => { + try { + return { sessions: sidecarManager.listSessions() }; + } catch (err) { + console.error("[agent.list]", err); + return { sessions: [] }; + } + }, }, messages: {}, diff --git a/ui-electrobun/src/bun/message-adapter.ts b/ui-electrobun/src/bun/message-adapter.ts new file mode 100644 index 0000000..2d20c93 --- /dev/null +++ b/ui-electrobun/src/bun/message-adapter.ts @@ -0,0 +1,497 @@ +// Message Adapter — parses provider-specific NDJSON events into common AgentMessage format +// Standalone for Bun process (no Svelte/Tauri deps). Mirrors the Tauri adapter layer. + +// ── Type guards ────────────────────────────────────────────────────────────── + +function str(v: unknown, fallback = ""): string { + return typeof v === "string" ? v : fallback; +} + +function num(v: unknown, fallback = 0): number { + return typeof v === "number" ? v : fallback; +} + +// ── Types ──────────────────────────────────────────────────────────────────── + +export type AgentMessageType = + | "init" + | "text" + | "thinking" + | "tool_call" + | "tool_result" + | "status" + | "compaction" + | "cost" + | "error" + | "unknown"; + +export interface AgentMessage { + id: string; + type: AgentMessageType; + parentId?: string; + content: unknown; + timestamp: number; +} + +export type ProviderId = "claude" | "codex" | "ollama"; + +// ── Public API ─────────────────────────────────────────────────────────────── + +/** Parse a raw NDJSON event from a sidecar runner into AgentMessage[] */ +export function parseMessage( + provider: ProviderId, + raw: Record, +): AgentMessage[] { + switch (provider) { + case "claude": + return adaptClaudeMessage(raw); + case "codex": + return adaptCodexMessage(raw); + case "ollama": + return adaptOllamaMessage(raw); + default: + return adaptClaudeMessage(raw); + } +} + +// ── Claude adapter ─────────────────────────────────────────────────────────── + +function adaptClaudeMessage(raw: Record): AgentMessage[] { + const uuid = str(raw.uuid) || crypto.randomUUID(); + const ts = Date.now(); + const parentId = + typeof raw.parent_tool_use_id === "string" + ? raw.parent_tool_use_id + : undefined; + + switch (raw.type) { + case "system": + return adaptClaudeSystem(raw, uuid, ts); + case "assistant": + return adaptClaudeAssistant(raw, uuid, ts, parentId); + case "user": + return adaptClaudeUser(raw, uuid, ts, parentId); + case "result": + return adaptClaudeResult(raw, uuid, ts); + default: + return [{ id: uuid, type: "unknown", content: raw, timestamp: ts }]; + } +} + +function adaptClaudeSystem( + raw: Record, + uuid: string, + ts: number, +): AgentMessage[] { + const subtype = str(raw.subtype); + + if (subtype === "init") { + return [ + { + id: uuid, + type: "init", + content: { + sessionId: str(raw.session_id), + model: str(raw.model), + cwd: str(raw.cwd), + tools: Array.isArray(raw.tools) + ? raw.tools.filter((t): t is string => typeof t === "string") + : [], + }, + timestamp: ts, + }, + ]; + } + + if (subtype === "compact_boundary") { + const meta = + typeof raw.compact_metadata === "object" && raw.compact_metadata !== null + ? (raw.compact_metadata as Record) + : {}; + return [ + { + id: uuid, + type: "compaction", + content: { + trigger: str(meta.trigger, "auto"), + preTokens: num(meta.pre_tokens), + }, + timestamp: ts, + }, + ]; + } + + return [ + { + id: uuid, + type: "status", + content: { + subtype, + message: typeof raw.status === "string" ? raw.status : undefined, + }, + timestamp: ts, + }, + ]; +} + +function adaptClaudeAssistant( + raw: Record, + uuid: string, + ts: number, + parentId?: string, +): AgentMessage[] { + const msg = + typeof raw.message === "object" && raw.message !== null + ? (raw.message as Record) + : undefined; + if (!msg) return []; + + const content = Array.isArray(msg.content) + ? (msg.content as Array>) + : undefined; + if (!content) return []; + + const messages: AgentMessage[] = []; + for (const block of content) { + switch (block.type) { + case "text": + messages.push({ + id: `${uuid}-text-${messages.length}`, + type: "text", + parentId, + content: { text: str(block.text) }, + timestamp: ts, + }); + break; + case "thinking": + messages.push({ + id: `${uuid}-think-${messages.length}`, + type: "thinking", + parentId, + content: { text: str(block.thinking ?? block.text) }, + timestamp: ts, + }); + break; + case "tool_use": + messages.push({ + id: `${uuid}-tool-${messages.length}`, + type: "tool_call", + parentId, + content: { + toolUseId: str(block.id), + name: str(block.name), + input: block.input, + }, + timestamp: ts, + }); + break; + } + } + return messages; +} + +function adaptClaudeUser( + raw: Record, + uuid: string, + ts: number, + parentId?: string, +): AgentMessage[] { + const msg = + typeof raw.message === "object" && raw.message !== null + ? (raw.message as Record) + : undefined; + if (!msg) return []; + + const content = Array.isArray(msg.content) + ? (msg.content as Array>) + : undefined; + if (!content) return []; + + const messages: AgentMessage[] = []; + for (const block of content) { + if (block.type === "tool_result") { + messages.push({ + id: `${uuid}-result-${messages.length}`, + type: "tool_result", + parentId, + content: { + toolUseId: str(block.tool_use_id), + output: block.content ?? raw.tool_use_result, + }, + timestamp: ts, + }); + } + } + return messages; +} + +function adaptClaudeResult( + raw: Record, + uuid: string, + ts: number, +): AgentMessage[] { + const usage = + typeof raw.usage === "object" && raw.usage !== null + ? (raw.usage as Record) + : undefined; + return [ + { + id: uuid, + type: "cost", + content: { + totalCostUsd: num(raw.total_cost_usd), + durationMs: num(raw.duration_ms), + inputTokens: num(usage?.input_tokens), + outputTokens: num(usage?.output_tokens), + numTurns: num(raw.num_turns), + isError: raw.is_error === true, + result: typeof raw.result === "string" ? raw.result : undefined, + }, + timestamp: ts, + }, + ]; +} + +// ── Codex adapter ──────────────────────────────────────────────────────────── + +function adaptCodexMessage(raw: Record): AgentMessage[] { + const ts = Date.now(); + const uuid = crypto.randomUUID(); + + switch (raw.type) { + case "thread.started": + return [ + { + id: uuid, + type: "init", + content: { + sessionId: str(raw.thread_id), + model: "", + cwd: "", + tools: [], + }, + timestamp: ts, + }, + ]; + case "turn.started": + return [ + { + id: uuid, + type: "status", + content: { subtype: "turn_started" }, + timestamp: ts, + }, + ]; + case "turn.completed": { + const usage = + typeof raw.usage === "object" && raw.usage !== null + ? (raw.usage as Record) + : {}; + return [ + { + id: uuid, + type: "cost", + content: { + totalCostUsd: 0, + durationMs: 0, + inputTokens: num(usage.input_tokens), + outputTokens: num(usage.output_tokens), + numTurns: 1, + isError: false, + }, + timestamp: ts, + }, + ]; + } + case "turn.failed": + return [ + { + id: uuid, + type: "error", + content: { + message: str( + (raw.error as Record)?.message, + "Turn failed", + ), + }, + timestamp: ts, + }, + ]; + case "item.started": + case "item.updated": + case "item.completed": + return adaptCodexItem(raw, uuid, ts); + case "error": + return [ + { + id: uuid, + type: "error", + content: { message: str(raw.message, "Unknown error") }, + timestamp: ts, + }, + ]; + default: + return [{ id: uuid, type: "unknown", content: raw, timestamp: ts }]; + } +} + +function adaptCodexItem( + raw: Record, + uuid: string, + ts: number, +): AgentMessage[] { + const item = + typeof raw.item === "object" && raw.item !== null + ? (raw.item as Record) + : {}; + const itemType = str(item.type); + const eventType = str(raw.type); + + switch (itemType) { + case "agent_message": + if (eventType !== "item.completed") return []; + return [ + { + id: uuid, + type: "text", + content: { text: str(item.text) }, + timestamp: ts, + }, + ]; + case "reasoning": + if (eventType !== "item.completed") return []; + return [ + { + id: uuid, + type: "thinking", + content: { text: str(item.text) }, + timestamp: ts, + }, + ]; + case "command_execution": { + const messages: AgentMessage[] = []; + const toolUseId = str(item.id, uuid); + if (eventType === "item.started" || eventType === "item.completed") { + messages.push({ + id: `${uuid}-call`, + type: "tool_call", + content: { + toolUseId, + name: "Bash", + input: { command: str(item.command) }, + }, + timestamp: ts, + }); + } + if (eventType === "item.completed") { + messages.push({ + id: `${uuid}-result`, + type: "tool_result", + content: { + toolUseId, + output: str(item.aggregated_output), + }, + timestamp: ts, + }); + } + return messages; + } + default: + return []; + } +} + +// ── Ollama adapter ─────────────────────────────────────────────────────────── + +function adaptOllamaMessage(raw: Record): AgentMessage[] { + const ts = Date.now(); + const uuid = crypto.randomUUID(); + + switch (raw.type) { + case "system": { + const subtype = str(raw.subtype); + if (subtype === "init") { + return [ + { + id: uuid, + type: "init", + content: { + sessionId: str(raw.session_id), + model: str(raw.model), + cwd: str(raw.cwd), + tools: [], + }, + timestamp: ts, + }, + ]; + } + return [ + { + id: uuid, + type: "status", + content: { + subtype, + message: typeof raw.status === "string" ? raw.status : undefined, + }, + timestamp: ts, + }, + ]; + } + case "chunk": { + const messages: AgentMessage[] = []; + const msg = + typeof raw.message === "object" && raw.message !== null + ? (raw.message as Record) + : {}; + const done = raw.done === true; + const thinking = str(msg.thinking); + if (thinking) { + messages.push({ + id: `${uuid}-think`, + type: "thinking", + content: { text: thinking }, + timestamp: ts, + }); + } + const text = str(msg.content); + if (text) { + messages.push({ + id: `${uuid}-text`, + type: "text", + content: { text }, + timestamp: ts, + }); + } + if (done) { + const evalDuration = num(raw.eval_duration); + const durationMs = + evalDuration > 0 ? Math.round(evalDuration / 1_000_000) : 0; + messages.push({ + id: `${uuid}-cost`, + type: "cost", + content: { + totalCostUsd: 0, + durationMs, + inputTokens: num(raw.prompt_eval_count), + outputTokens: num(raw.eval_count), + numTurns: 1, + isError: str(raw.done_reason) === "error", + }, + timestamp: ts, + }); + } + return messages; + } + case "error": + return [ + { + id: uuid, + type: "error", + content: { message: str(raw.message, "Ollama error") }, + timestamp: ts, + }, + ]; + default: + return [{ id: uuid, type: "unknown", content: raw, timestamp: ts }]; + } +} diff --git a/ui-electrobun/src/bun/sidecar-manager.ts b/ui-electrobun/src/bun/sidecar-manager.ts new file mode 100644 index 0000000..dc94b91 --- /dev/null +++ b/ui-electrobun/src/bun/sidecar-manager.ts @@ -0,0 +1,428 @@ +// Sidecar Manager — spawns and manages agent sidecar processes via Bun.spawn() +// Each session runs a provider-specific runner (.mjs) communicating via NDJSON on stdio. + +import { join } from "path"; +import { homedir } from "os"; +import { existsSync } from "fs"; +import { parseMessage, type AgentMessage, type ProviderId } from "./message-adapter.ts"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export type SessionStatus = "running" | "idle" | "done" | "error"; + +export interface SessionState { + sessionId: string; + provider: ProviderId; + status: SessionStatus; + costUsd: number; + inputTokens: number; + outputTokens: number; + startedAt: number; +} + +export interface StartSessionOptions { + cwd?: string; + model?: string; + systemPrompt?: string; + maxTurns?: number; + permissionMode?: string; + claudeConfigDir?: string; + extraEnv?: Record; +} + +type MessageCallback = (sessionId: string, messages: AgentMessage[]) => void; +type StatusCallback = (sessionId: string, status: SessionStatus, error?: string) => void; + +interface ActiveSession { + state: SessionState; + proc: ReturnType; + controller: AbortController; + onMessage: MessageCallback[]; + onStatus: StatusCallback[]; +} + +// ── Environment stripping ──────────────────────────────────────────────────── + +const STRIP_PREFIXES = ["CLAUDE", "CODEX", "OLLAMA", "ANTHROPIC_"]; +const WHITELIST_PREFIXES = ["CLAUDE_CODE_EXPERIMENTAL_"]; + +function buildCleanEnv(extraEnv?: Record, claudeConfigDir?: string): Record { + const clean: Record = {}; + + for (const [key, value] of Object.entries(process.env)) { + if (value === undefined) continue; + const shouldStrip = STRIP_PREFIXES.some((p) => key.startsWith(p)); + const isWhitelisted = WHITELIST_PREFIXES.some((p) => key.startsWith(p)); + if (!shouldStrip || isWhitelisted) { + clean[key] = value; + } + } + + if (claudeConfigDir) { + clean["CLAUDE_CONFIG_DIR"] = claudeConfigDir; + } + if (extraEnv) { + Object.assign(clean, extraEnv); + } + + return clean; +} + +// ── Claude CLI detection ───────────────────────────────────────────────────── + +function findClaudeCli(): string | undefined { + const candidates = [ + join(homedir(), ".local", "bin", "claude"), + join(homedir(), ".claude", "local", "claude"), + "/usr/local/bin/claude", + "/usr/bin/claude", + ]; + for (const p of candidates) { + if (existsSync(p)) return p; + } + try { + const result = Bun.spawnSync(["which", "claude"]); + const path = new TextDecoder().decode(result.stdout).trim(); + if (path && existsSync(path)) return path; + } catch { + // not found + } + return undefined; +} + +// ── Runner resolution ──────────────────────────────────────────────────────── + +function resolveRunnerPath(provider: ProviderId): string { + // Sidecar runners live in the repo's sidecar/dist/ directory + const repoRoot = join(import.meta.dir, "..", "..", ".."); + return join(repoRoot, "sidecar", "dist", `${provider}-runner.mjs`); +} + +function findNodeRuntime(): string { + // Prefer Deno, fallback to Node.js (matching Tauri sidecar behavior) + try { + const result = Bun.spawnSync(["which", "deno"]); + const path = new TextDecoder().decode(result.stdout).trim(); + if (path) return path; + } catch { /* fallthrough */ } + + try { + const result = Bun.spawnSync(["which", "node"]); + const path = new TextDecoder().decode(result.stdout).trim(); + if (path) return path; + } catch { /* fallthrough */ } + + return "node"; // last resort +} + +// ── SidecarManager ─────────────────────────────────────────────────────────── + +export class SidecarManager { + private sessions = new Map(); + private claudePath: string | undefined; + private nodeRuntime: string; + + constructor() { + this.claudePath = findClaudeCli(); + this.nodeRuntime = findNodeRuntime(); + + if (this.claudePath) { + console.log(`[sidecar] Claude CLI found at ${this.claudePath}`); + } else { + console.warn("[sidecar] Claude CLI not found — Claude sessions will fail"); + } + console.log(`[sidecar] Node runtime: ${this.nodeRuntime}`); + } + + /** Start an agent session with the given provider */ + startSession( + sessionId: string, + provider: ProviderId, + prompt: string, + options: StartSessionOptions = {}, + ): { ok: boolean; error?: string } { + if (this.sessions.has(sessionId)) { + return { ok: false, error: "Session already exists" }; + } + + if (provider === "claude" && !this.claudePath) { + return { ok: false, error: "Claude CLI not found. Install Claude Code first." }; + } + + const runnerPath = resolveRunnerPath(provider); + if (!existsSync(runnerPath)) { + return { ok: false, error: `Runner not found: ${runnerPath}` }; + } + + const controller = new AbortController(); + const env = buildCleanEnv(options.extraEnv, options.claudeConfigDir); + + const proc = Bun.spawn([this.nodeRuntime, runnerPath], { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env, + signal: controller.signal, + }); + + const state: SessionState = { + sessionId, + provider, + status: "running", + costUsd: 0, + inputTokens: 0, + outputTokens: 0, + startedAt: Date.now(), + }; + + const session: ActiveSession = { + state, + proc, + controller, + onMessage: [], + onStatus: [], + }; + + this.sessions.set(sessionId, session); + + // Start reading stdout NDJSON + this.readStdout(sessionId, session); + + // Read stderr for logging + this.readStderr(sessionId, session); + + // Monitor process exit + proc.exited.then((exitCode) => { + const s = this.sessions.get(sessionId); + if (s) { + s.state.status = exitCode === 0 ? "done" : "error"; + this.emitStatus(sessionId, s.state.status, exitCode !== 0 ? `Exit code: ${exitCode}` : undefined); + } + }); + + // Send the query command to the runner + const queryMsg = { + type: "query", + sessionId, + prompt, + cwd: options.cwd, + model: options.model, + systemPrompt: options.systemPrompt, + maxTurns: options.maxTurns, + permissionMode: options.permissionMode ?? "bypassPermissions", + claudeConfigDir: options.claudeConfigDir, + extraEnv: options.extraEnv, + }; + + this.writeToProcess(sessionId, queryMsg); + + return { ok: true }; + } + + /** Stop a running session */ + stopSession(sessionId: string): { ok: boolean; error?: string } { + const session = this.sessions.get(sessionId); + if (!session) { + return { ok: false, error: "Session not found" }; + } + + // Send stop command to runner first + this.writeToProcess(sessionId, { type: "stop", sessionId }); + + // Abort after a grace period if still running + setTimeout(() => { + const s = this.sessions.get(sessionId); + if (s && s.state.status === "running") { + s.controller.abort(); + s.state.status = "done"; + this.emitStatus(sessionId, "done"); + this.sessions.delete(sessionId); + } + }, 3000); + + return { ok: true }; + } + + /** Send a follow-up prompt to a running session */ + writePrompt(sessionId: string, prompt: string): { ok: boolean; error?: string } { + const session = this.sessions.get(sessionId); + if (!session) { + return { ok: false, error: "Session not found" }; + } + if (session.state.status !== "running" && session.state.status !== "idle") { + return { ok: false, error: `Session is ${session.state.status}` }; + } + + this.writeToProcess(sessionId, { type: "query", sessionId, prompt }); + session.state.status = "running"; + this.emitStatus(sessionId, "running"); + + return { ok: true }; + } + + /** List all sessions with their state */ + listSessions(): SessionState[] { + return Array.from(this.sessions.values()).map((s) => ({ ...s.state })); + } + + /** Register a callback for messages from a specific session */ + onMessage(sessionId: string, callback: MessageCallback): void { + const session = this.sessions.get(sessionId); + if (session) { + session.onMessage.push(callback); + } + } + + /** Register a callback for status changes of a specific session */ + onStatus(sessionId: string, callback: StatusCallback): void { + const session = this.sessions.get(sessionId); + if (session) { + session.onStatus.push(callback); + } + } + + /** Clean up a completed session */ + removeSession(sessionId: string): void { + const session = this.sessions.get(sessionId); + if (session) { + if (session.state.status === "running") { + session.controller.abort(); + } + this.sessions.delete(sessionId); + } + } + + // ── Internal ─────────────────────────────────────────────────────────────── + + private writeToProcess(sessionId: string, msg: Record): void { + const session = this.sessions.get(sessionId); + if (!session) return; + + try { + const line = JSON.stringify(msg) + "\n"; + session.proc.stdin.write(line); + } catch (err) { + console.error(`[sidecar] Write error for ${sessionId}:`, err); + } + } + + private async readStdout(sessionId: string, session: ActiveSession): Promise { + const reader = session.proc.stdout; + const decoder = new TextDecoder(); + let buffer = ""; + + try { + for await (const chunk of reader) { + buffer += decoder.decode(chunk, { stream: true }); + + let newlineIdx: number; + while ((newlineIdx = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, newlineIdx).trim(); + buffer = buffer.slice(newlineIdx + 1); + if (!line) continue; + + this.handleNdjsonLine(sessionId, session, line); + } + } + } catch (err) { + // Stream closed — expected on process exit + if (!session.controller.signal.aborted) { + console.error(`[sidecar] stdout read error for ${sessionId}:`, err); + } + } + } + + private async readStderr(sessionId: string, session: ActiveSession): Promise { + const reader = session.proc.stderr; + const decoder = new TextDecoder(); + + try { + for await (const chunk of reader) { + const text = decoder.decode(chunk, { stream: true }); + // Log sidecar stderr as debug output + for (const line of text.split("\n")) { + if (line.trim()) { + console.log(`[sidecar:${sessionId}] ${line.trim()}`); + } + } + } + } catch { + // Stream closed — expected + } + } + + private handleNdjsonLine(sessionId: string, session: ActiveSession, line: string): void { + let raw: Record; + try { + raw = JSON.parse(line); + } catch { + console.warn(`[sidecar] Invalid JSON from ${sessionId}: ${line.slice(0, 100)}`); + return; + } + + // Handle sidecar-level events (not forwarded to message adapter) + const type = raw.type; + if (type === "ready" || type === "pong") return; + + if (type === "agent_started") { + session.state.status = "running"; + this.emitStatus(sessionId, "running"); + return; + } + + if (type === "agent_stopped") { + session.state.status = "done"; + this.emitStatus(sessionId, "done"); + return; + } + + if (type === "agent_error") { + session.state.status = "error"; + const errorMsg = typeof raw.message === "string" ? raw.message : "Unknown error"; + this.emitStatus(sessionId, "error", errorMsg); + return; + } + + // Extract the inner event for agent_event wrapper + const event = + type === "agent_event" && typeof raw.event === "object" && raw.event !== null + ? (raw.event as Record) + : raw; + + // Parse through message adapter + const messages = parseMessage(session.state.provider, event); + + // Update session state from cost messages + for (const msg of messages) { + if (msg.type === "cost") { + const cost = msg.content as Record; + if (typeof cost.totalCostUsd === "number") session.state.costUsd = cost.totalCostUsd; + if (typeof cost.inputTokens === "number") session.state.inputTokens += cost.inputTokens; + if (typeof cost.outputTokens === "number") session.state.outputTokens += cost.outputTokens; + } + } + + // Emit to callbacks + if (messages.length > 0) { + for (const cb of session.onMessage) { + try { + cb(sessionId, messages); + } catch (err) { + console.error(`[sidecar] Message callback error for ${sessionId}:`, err); + } + } + } + } + + private emitStatus(sessionId: string, status: SessionStatus, error?: string): void { + const session = this.sessions.get(sessionId); + if (!session) return; + for (const cb of session.onStatus) { + try { + cb(sessionId, status, error); + } catch (err) { + console.error(`[sidecar] Status callback error for ${sessionId}:`, err); + } + } + } +} diff --git a/ui-electrobun/src/mainview/AgentPane.svelte b/ui-electrobun/src/mainview/AgentPane.svelte index d2aae1e..29e4e7d 100644 --- a/ui-electrobun/src/mainview/AgentPane.svelte +++ b/ui-electrobun/src/mainview/AgentPane.svelte @@ -1,20 +1,11 @@ @@ -117,12 +122,12 @@ >
-
+
@@ -230,16 +235,17 @@ aria-label="Model" >
@@ -270,7 +276,7 @@
Tokens used - {tokens.toLocaleString()} + {agentTokens.toLocaleString()}
Context % @@ -278,7 +284,7 @@
Model - {model} + {agentModel}
@@ -289,7 +295,7 @@
- {#each messages.slice(0, 5) as msg} + {#each displayMessages.slice(0, 5) as msg}
{msg.role} {msg.content.slice(0, 60)}{msg.content.length > 60 ? '…' : ''} @@ -403,6 +409,8 @@ .status-dot.running { background: var(--ctp-green); } .status-dot.idle { background: var(--ctp-overlay1); } + .status-dot.done { background: var(--ctp-green); } + .status-dot.error { background: var(--ctp-red); } .status-dot.stalled { background: var(--ctp-peach); } .status-dot.blink-off { opacity: 0.3; } diff --git a/ui-electrobun/src/mainview/agent-store.svelte.ts b/ui-electrobun/src/mainview/agent-store.svelte.ts new file mode 100644 index 0000000..b50b00f --- /dev/null +++ b/ui-electrobun/src/mainview/agent-store.svelte.ts @@ -0,0 +1,345 @@ +/** + * Agent session store — manages per-project agent state and RPC communication. + * + * Listens for agent.message, agent.status, agent.cost events from Bun process. + * Exposes reactive Svelte 5 rune state per project. + */ + +import { electrobun, appRpc } from './main.ts'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export type AgentStatus = 'idle' | 'running' | 'done' | 'error'; +export type MsgRole = 'user' | 'assistant' | 'tool-call' | 'tool-result' | 'thinking' | 'system'; + +export interface AgentMessage { + id: string; + role: MsgRole; + content: string; + toolName?: string; + toolInput?: string; + toolPath?: string; + timestamp: number; +} + +export interface AgentSession { + sessionId: string; + projectId: string; + provider: string; + status: AgentStatus; + messages: AgentMessage[]; + costUsd: number; + inputTokens: number; + outputTokens: number; + model: string; + error?: string; +} + +interface StartOptions { + cwd?: string; + model?: string; + systemPrompt?: string; + maxTurns?: number; + permissionMode?: string; + claudeConfigDir?: string; + extraEnv?: Record; +} + +// ── Internal state ─────────────────────────────────────────────────────────── + +// Map projectId -> sessionId for lookup +const projectSessionMap = new Map(); + +// Map sessionId -> reactive session state +let sessions = $state>({}); + +// ── RPC event listeners (registered once) ──────────────────────────────────── + +let listenersRegistered = false; + +function ensureListeners() { + if (listenersRegistered) return; + listenersRegistered = true; + + // agent.message — raw messages from sidecar, converted to display format + electrobun.rpc?.addMessageListener('agent.message', (payload: { + sessionId: string; + messages: Array<{ + id: string; + type: string; + parentId?: string; + content: unknown; + timestamp: number; + }>; + }) => { + const session = sessions[payload.sessionId]; + if (!session) return; + + const converted: AgentMessage[] = []; + for (const raw of payload.messages) { + const msg = convertRawMessage(raw); + if (msg) converted.push(msg); + } + + if (converted.length > 0) { + session.messages = [...session.messages, ...converted]; + } + }); + + // agent.status — session status changes + electrobun.rpc?.addMessageListener('agent.status', (payload: { + sessionId: string; + status: string; + error?: string; + }) => { + const session = sessions[payload.sessionId]; + if (!session) return; + + session.status = normalizeStatus(payload.status); + if (payload.error) session.error = payload.error; + }); + + // agent.cost — token/cost updates + electrobun.rpc?.addMessageListener('agent.cost', (payload: { + sessionId: string; + costUsd: number; + inputTokens: number; + outputTokens: number; + }) => { + const session = sessions[payload.sessionId]; + if (!session) return; + + session.costUsd = payload.costUsd; + session.inputTokens = payload.inputTokens; + session.outputTokens = payload.outputTokens; + }); +} + +// ── Message conversion ─────────────────────────────────────────────────────── + +function convertRawMessage(raw: { + id: string; + type: string; + parentId?: string; + content: unknown; + timestamp: number; +}): AgentMessage | null { + const c = raw.content as Record | undefined; + + switch (raw.type) { + case 'text': + return { + id: raw.id, + role: 'assistant', + content: String(c?.text ?? ''), + timestamp: raw.timestamp, + }; + + case 'thinking': + return { + id: raw.id, + role: 'thinking', + content: String(c?.text ?? ''), + timestamp: raw.timestamp, + }; + + case 'tool_call': { + const name = String(c?.name ?? 'Tool'); + const input = c?.input as Record | undefined; + // Extract file path from common tool input patterns + const path = extractToolPath(name, input); + return { + id: raw.id, + role: 'tool-call', + content: formatToolInput(name, input), + toolName: name, + toolInput: JSON.stringify(input, null, 2), + toolPath: path, + timestamp: raw.timestamp, + }; + } + + case 'tool_result': { + const output = c?.output; + const text = typeof output === 'string' + ? output + : JSON.stringify(output, null, 2); + return { + id: raw.id, + role: 'tool-result', + content: truncateOutput(text, 500), + timestamp: raw.timestamp, + }; + } + + case 'init': { + const model = String(c?.model ?? ''); + // Update session model from init message + const sid = String(c?.sessionId ?? ''); + for (const s of Object.values(sessions)) { + if (s.sessionId === raw.id || (sid && s.sessionId.includes(sid.slice(0, 8)))) { + if (model) s.model = model; + } + } + return { + id: raw.id, + role: 'system', + content: `Session initialized${model ? ` (${model})` : ''}`, + timestamp: raw.timestamp, + }; + } + + case 'error': + return { + id: raw.id, + role: 'system', + content: `Error: ${String(c?.message ?? 'Unknown error')}`, + timestamp: raw.timestamp, + }; + + case 'cost': + case 'status': + case 'compaction': + case 'unknown': + return null; + + default: + return null; + } +} + +function extractToolPath(name: string, input: Record | undefined): string | undefined { + if (!input) return undefined; + // Common patterns: file_path, path, command (for Bash) + if (typeof input.file_path === 'string') return input.file_path; + if (typeof input.path === 'string') return input.path; + if (name === 'Bash' && typeof input.command === 'string') { + return input.command.length > 80 ? input.command.slice(0, 80) + '...' : input.command; + } + return undefined; +} + +function formatToolInput(name: string, input: Record | undefined): string { + if (!input) return ''; + if (name === 'Bash' && typeof input.command === 'string') return input.command; + if (typeof input.file_path === 'string') return input.file_path; + return JSON.stringify(input, null, 2); +} + +function truncateOutput(text: string, maxLines: number): string { + const lines = text.split('\n'); + if (lines.length <= maxLines) return text; + return lines.slice(0, maxLines).join('\n') + `\n... (${lines.length - maxLines} more lines)`; +} + +function normalizeStatus(status: string): AgentStatus { + if (status === 'running' || status === 'idle' || status === 'done' || status === 'error') { + return status; + } + return 'idle'; +} + +// ── Public API ─────────────────────────────────────────────────────────────── + +/** Start an agent session for a project. */ +export async function startAgent( + projectId: string, + provider: string, + prompt: string, + options: StartOptions = {}, +): Promise<{ ok: boolean; error?: string }> { + ensureListeners(); + + const sessionId = `${projectId}-${Date.now()}`; + + // Create reactive session state + sessions[sessionId] = { + sessionId, + projectId, + provider, + status: 'running', + messages: [{ + id: `${sessionId}-user-0`, + role: 'user', + content: prompt, + timestamp: Date.now(), + }], + costUsd: 0, + inputTokens: 0, + outputTokens: 0, + model: options.model ?? 'claude-opus-4-5', + }; + + projectSessionMap.set(projectId, sessionId); + + const result = await appRpc.request['agent.start']({ + sessionId, + provider: provider as 'claude' | 'codex' | 'ollama', + prompt, + cwd: options.cwd, + model: options.model, + systemPrompt: options.systemPrompt, + maxTurns: options.maxTurns, + permissionMode: options.permissionMode, + claudeConfigDir: options.claudeConfigDir, + extraEnv: options.extraEnv, + }); + + if (!result.ok) { + sessions[sessionId].status = 'error'; + sessions[sessionId].error = result.error; + } + + return result; +} + +/** Stop a running agent session for a project. */ +export async function stopAgent(projectId: string): Promise<{ ok: boolean; error?: string }> { + const sessionId = projectSessionMap.get(projectId); + if (!sessionId) return { ok: false, error: 'No session for project' }; + + const result = await appRpc.request['agent.stop']({ sessionId }); + + if (result.ok) { + const session = sessions[sessionId]; + if (session) session.status = 'done'; + } + + return result; +} + +/** Send a follow-up prompt to a running session. */ +export async function sendPrompt(projectId: string, prompt: string): Promise<{ ok: boolean; error?: string }> { + const sessionId = projectSessionMap.get(projectId); + if (!sessionId) return { ok: false, error: 'No session for project' }; + + const session = sessions[sessionId]; + if (!session) return { ok: false, error: 'Session not found' }; + + // Add user message immediately + session.messages = [...session.messages, { + id: `${sessionId}-user-${Date.now()}`, + role: 'user', + content: prompt, + timestamp: Date.now(), + }]; + + session.status = 'running'; + + return appRpc.request['agent.prompt']({ sessionId, prompt }); +} + +/** Get the current session for a project (reactive). */ +export function getSession(projectId: string): AgentSession | undefined { + const sessionId = projectSessionMap.get(projectId); + if (!sessionId) return undefined; + return sessions[sessionId]; +} + +/** Check if a project has an active session. */ +export function hasSession(projectId: string): boolean { + return projectSessionMap.has(projectId); +} + +/** Initialize listeners on module load. */ +ensureListeners(); diff --git a/ui-electrobun/src/shared/pty-rpc-schema.ts b/ui-electrobun/src/shared/pty-rpc-schema.ts index 88959a8..44c888f 100644 --- a/ui-electrobun/src/shared/pty-rpc-schema.ts +++ b/ui-electrobun/src/shared/pty-rpc-schema.ts @@ -146,6 +146,50 @@ export type PtyRPCRequests = { params: { id: string }; response: { ok: boolean }; }; + + // ── Agent RPC ───────────────────────────────────────────────────────────── + + /** Start an agent session with a given provider. */ + "agent.start": { + params: { + sessionId: string; + provider: "claude" | "codex" | "ollama"; + prompt: string; + cwd?: string; + model?: string; + systemPrompt?: string; + maxTurns?: number; + permissionMode?: string; + claudeConfigDir?: string; + extraEnv?: Record; + }; + response: { ok: boolean; error?: string }; + }; + /** Stop a running agent session. */ + "agent.stop": { + params: { sessionId: string }; + response: { ok: boolean; error?: string }; + }; + /** Send a follow-up prompt to a running agent session. */ + "agent.prompt": { + params: { sessionId: string; prompt: string }; + response: { ok: boolean; error?: string }; + }; + /** List all active agent sessions with their state. */ + "agent.list": { + params: Record; + response: { + sessions: Array<{ + sessionId: string; + provider: string; + status: string; + costUsd: number; + inputTokens: number; + outputTokens: number; + startedAt: number; + }>; + }; + }; }; // ── Messages (Bun → WebView, fire-and-forget) ──────────────────────────────── @@ -155,6 +199,33 @@ export type PtyRPCMessages = { "pty.output": { sessionId: string; data: string }; /** PTY session exited. */ "pty.closed": { sessionId: string; exitCode: number | null }; + + // ── Agent events (Bun → WebView) ───────────────────────────────────────── + + /** Agent message(s) parsed from sidecar NDJSON. */ + "agent.message": { + sessionId: string; + messages: Array<{ + id: string; + type: string; + parentId?: string; + content: unknown; + timestamp: number; + }>; + }; + /** Agent session status change. */ + "agent.status": { + sessionId: string; + status: string; + error?: string; + }; + /** Agent cost/token update. */ + "agent.cost": { + sessionId: string; + costUsd: number; + inputTokens: number; + outputTokens: number; + }; }; // ── Combined schema ───────────────────────────────────────────────────────────