feat(electrobun): agent execution layer — sidecar manager + message adapters + store
- SidecarManager: spawns claude/codex/ollama runners via Bun.spawn(), NDJSON stdio protocol, Claude CLI auto-detection, env stripping, AbortController stop, Deno/Node runtime detection - MessageAdapter: parses Claude stream-json, Codex ThreadEvent, Ollama chunks into common AgentMessage format - agent-store.svelte.ts: per-project reactive session state, RPC event listeners for agent.message/status/cost - AgentPane: wired to real sessions (start/stop/prompt), stop button, thinking/system message rendering - ProjectCard: status dot from real agent status, cost/tokens from store - 5 new RPC types (agent.start/stop/prompt/list + events)
This commit is contained in:
parent
95f1f8208f
commit
ef0183de7f
8 changed files with 1566 additions and 61 deletions
|
|
@ -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<boolean> {
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
|
|
@ -347,6 +349,90 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
|
|||
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: {},
|
||||
|
|
|
|||
497
ui-electrobun/src/bun/message-adapter.ts
Normal file
497
ui-electrobun/src/bun/message-adapter.ts
Normal file
|
|
@ -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<string, unknown>,
|
||||
): 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<string, unknown>): 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<string, unknown>,
|
||||
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<string, unknown>)
|
||||
: {};
|
||||
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<string, unknown>,
|
||||
uuid: string,
|
||||
ts: number,
|
||||
parentId?: string,
|
||||
): AgentMessage[] {
|
||||
const msg =
|
||||
typeof raw.message === "object" && raw.message !== null
|
||||
? (raw.message as Record<string, unknown>)
|
||||
: undefined;
|
||||
if (!msg) return [];
|
||||
|
||||
const content = Array.isArray(msg.content)
|
||||
? (msg.content as Array<Record<string, unknown>>)
|
||||
: 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<string, unknown>,
|
||||
uuid: string,
|
||||
ts: number,
|
||||
parentId?: string,
|
||||
): AgentMessage[] {
|
||||
const msg =
|
||||
typeof raw.message === "object" && raw.message !== null
|
||||
? (raw.message as Record<string, unknown>)
|
||||
: undefined;
|
||||
if (!msg) return [];
|
||||
|
||||
const content = Array.isArray(msg.content)
|
||||
? (msg.content as Array<Record<string, unknown>>)
|
||||
: 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<string, unknown>,
|
||||
uuid: string,
|
||||
ts: number,
|
||||
): AgentMessage[] {
|
||||
const usage =
|
||||
typeof raw.usage === "object" && raw.usage !== null
|
||||
? (raw.usage as Record<string, unknown>)
|
||||
: 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<string, unknown>): 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<string, unknown>)
|
||||
: {};
|
||||
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<string, unknown>)?.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<string, unknown>,
|
||||
uuid: string,
|
||||
ts: number,
|
||||
): AgentMessage[] {
|
||||
const item =
|
||||
typeof raw.item === "object" && raw.item !== null
|
||||
? (raw.item as Record<string, unknown>)
|
||||
: {};
|
||||
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<string, unknown>): 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<string, unknown>)
|
||||
: {};
|
||||
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 }];
|
||||
}
|
||||
}
|
||||
428
ui-electrobun/src/bun/sidecar-manager.ts
Normal file
428
ui-electrobun/src/bun/sidecar-manager.ts
Normal file
|
|
@ -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<string, string>;
|
||||
}
|
||||
|
||||
type MessageCallback = (sessionId: string, messages: AgentMessage[]) => void;
|
||||
type StatusCallback = (sessionId: string, status: SessionStatus, error?: string) => void;
|
||||
|
||||
interface ActiveSession {
|
||||
state: SessionState;
|
||||
proc: ReturnType<typeof Bun.spawn>;
|
||||
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<string, string>, claudeConfigDir?: string): Record<string, string> {
|
||||
const clean: Record<string, string> = {};
|
||||
|
||||
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<string, ActiveSession>();
|
||||
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<string, unknown>): 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<void> {
|
||||
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<void> {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>)
|
||||
: 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<string, unknown>;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue