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 { BrowserWindow, BrowserView, Updater } from "electrobun/bun";
|
||||||
import { PtyClient } from "./pty-client.ts";
|
import { PtyClient } from "./pty-client.ts";
|
||||||
import { settingsDb } from "./settings-db.ts";
|
import { settingsDb } from "./settings-db.ts";
|
||||||
|
import { SidecarManager } from "./sidecar-manager.ts";
|
||||||
import type { PtyRPCSchema } from "../shared/pty-rpc-schema.ts";
|
import type { PtyRPCSchema } from "../shared/pty-rpc-schema.ts";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
|
@ -11,6 +12,7 @@ const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
|
||||||
// ── PTY daemon client ────────────────────────────────────────────────────────
|
// ── PTY daemon client ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const ptyClient = new PtyClient();
|
const ptyClient = new PtyClient();
|
||||||
|
const sidecarManager = new SidecarManager();
|
||||||
|
|
||||||
async function connectToDaemon(retries = 5, delayMs = 500): Promise<boolean> {
|
async function connectToDaemon(retries = 5, delayMs = 500): Promise<boolean> {
|
||||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||||
|
|
@ -347,6 +349,90 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
|
||||||
return { ok: false };
|
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: {},
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,20 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import ChatInput from './ChatInput.svelte';
|
import ChatInput from './ChatInput.svelte';
|
||||||
|
import type { AgentMessage, AgentStatus } from './agent-store.svelte.ts';
|
||||||
type MsgRole = 'user' | 'assistant' | 'tool-call' | 'tool-result';
|
|
||||||
|
|
||||||
interface AgentMessage {
|
|
||||||
id: number;
|
|
||||||
role: MsgRole;
|
|
||||||
content: string;
|
|
||||||
toolName?: string;
|
|
||||||
toolPath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
messages: AgentMessage[];
|
messages: AgentMessage[];
|
||||||
status: 'running' | 'idle' | 'stalled';
|
status: AgentStatus;
|
||||||
costUsd: number;
|
costUsd: number;
|
||||||
tokens: number;
|
tokens: number;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
|
@ -23,6 +14,7 @@
|
||||||
contextPct?: number;
|
contextPct?: number;
|
||||||
burnRate?: number;
|
burnRate?: number;
|
||||||
onSend?: (text: string) => void;
|
onSend?: (text: string) => void;
|
||||||
|
onStop?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|
@ -34,11 +26,12 @@
|
||||||
provider = 'claude',
|
provider = 'claude',
|
||||||
contextPct = 0,
|
contextPct = 0,
|
||||||
onSend,
|
onSend,
|
||||||
|
onStop,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let scrollEl: HTMLDivElement;
|
let scrollEl: HTMLDivElement;
|
||||||
let promptText = $state('');
|
let promptText = $state('');
|
||||||
let expandedTools = $state<Set<number>>(new Set());
|
let expandedTools = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
// Drag-resize state
|
// Drag-resize state
|
||||||
let agentPaneEl: HTMLDivElement;
|
let agentPaneEl: HTMLDivElement;
|
||||||
|
|
@ -58,7 +51,7 @@
|
||||||
onSend?.(text);
|
onSend?.(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTool(id: number) {
|
function toggleTool(id: string) {
|
||||||
const next = new Set(expandedTools);
|
const next = new Set(expandedTools);
|
||||||
next.has(id) ? next.delete(id) : next.add(id);
|
next.has(id) ? next.delete(id) : next.add(id);
|
||||||
expandedTools = next;
|
expandedTools = next;
|
||||||
|
|
@ -67,10 +60,18 @@
|
||||||
function fmtTokens(n: number) { return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); }
|
function fmtTokens(n: number) { return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); }
|
||||||
function fmtCost(n: number) { return `$${n.toFixed(3)}`; }
|
function fmtCost(n: number) { return `$${n.toFixed(3)}`; }
|
||||||
|
|
||||||
function dotClass(s: string) {
|
function dotClass(s: AgentStatus) {
|
||||||
if (s === 'running') return 'dot-progress';
|
if (s === 'running') return 'dot-progress';
|
||||||
if (s === 'stalled') return 'dot-error';
|
if (s === 'error') return 'dot-error';
|
||||||
return 'dot-success';
|
if (s === 'done') return 'dot-success';
|
||||||
|
return 'dot-idle';
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(s: AgentStatus) {
|
||||||
|
if (s === 'running') return 'Running';
|
||||||
|
if (s === 'error') return 'Error';
|
||||||
|
if (s === 'done') return 'Done';
|
||||||
|
return 'Idle';
|
||||||
}
|
}
|
||||||
|
|
||||||
function onResizeMouseDown(e: MouseEvent) {
|
function onResizeMouseDown(e: MouseEvent) {
|
||||||
|
|
@ -98,12 +99,19 @@
|
||||||
<!-- Status strip (top) -->
|
<!-- Status strip (top) -->
|
||||||
<div class="status-strip">
|
<div class="status-strip">
|
||||||
<span class="strip-dot {dotClass(status)}"></span>
|
<span class="strip-dot {dotClass(status)}"></span>
|
||||||
<span class="strip-label">{status === 'running' ? 'Running' : status === 'stalled' ? 'Stalled' : 'Done'}</span>
|
<span class="strip-label">{statusLabel(status)}</span>
|
||||||
<span class="strip-model">{model}</span>
|
<span class="strip-model">{model}</span>
|
||||||
<span class="strip-spacer"></span>
|
<span class="strip-spacer"></span>
|
||||||
<span class="strip-tokens">{fmtTokens(tokens)} tok</span>
|
<span class="strip-tokens">{fmtTokens(tokens)} tok</span>
|
||||||
<span class="strip-sep" aria-hidden="true"></span>
|
<span class="strip-sep" aria-hidden="true"></span>
|
||||||
<span class="strip-cost">{fmtCost(costUsd)}</span>
|
<span class="strip-cost">{fmtCost(costUsd)}</span>
|
||||||
|
{#if status === 'running' && onStop}
|
||||||
|
<button class="strip-stop-btn" onclick={onStop} title="Stop agent" aria-label="Stop agent">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||||
|
<rect x="3" y="3" width="10" height="10" rx="1" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main pane with floating input -->
|
<!-- Main pane with floating input -->
|
||||||
|
|
@ -126,6 +134,22 @@
|
||||||
<div class="timeline-content">{msg.content}</div>
|
<div class="timeline-content">{msg.content}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{:else if msg.role === 'thinking'}
|
||||||
|
<div class="timeline-row">
|
||||||
|
{#if !isFirst}<div class="timeline-line-up"></div>{/if}
|
||||||
|
<div class="timeline-diamond dot-thinking"></div>
|
||||||
|
{#if !isLast}<div class="timeline-line-down"></div>{/if}
|
||||||
|
<div class="thinking-content">{msg.content}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if msg.role === 'system'}
|
||||||
|
<div class="timeline-row">
|
||||||
|
{#if !isFirst}<div class="timeline-line-up"></div>{/if}
|
||||||
|
<div class="timeline-diamond dot-system"></div>
|
||||||
|
{#if !isLast}<div class="timeline-line-down"></div>{/if}
|
||||||
|
<div class="system-content">{msg.content}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{:else if msg.role === 'tool-call'}
|
{:else if msg.role === 'tool-call'}
|
||||||
<div class="timeline-row">
|
<div class="timeline-row">
|
||||||
{#if !isFirst}<div class="timeline-line-up"></div>{/if}
|
{#if !isFirst}<div class="timeline-line-up"></div>{/if}
|
||||||
|
|
@ -222,6 +246,9 @@
|
||||||
.dot-success { background: var(--ctp-green); }
|
.dot-success { background: var(--ctp-green); }
|
||||||
.dot-progress { background: var(--ctp-peach); }
|
.dot-progress { background: var(--ctp-peach); }
|
||||||
.dot-error { background: var(--ctp-red); }
|
.dot-error { background: var(--ctp-red); }
|
||||||
|
.dot-idle { background: var(--ctp-overlay1); }
|
||||||
|
.dot-thinking { background: var(--ctp-mauve); }
|
||||||
|
.dot-system { background: var(--ctp-overlay0); }
|
||||||
|
|
||||||
.strip-label { color: var(--ctp-subtext1); font-weight: 500; }
|
.strip-label { color: var(--ctp-subtext1); font-weight: 500; }
|
||||||
.strip-model { color: var(--ctp-overlay1); margin-left: 0.25rem; }
|
.strip-model { color: var(--ctp-overlay1); margin-left: 0.25rem; }
|
||||||
|
|
@ -234,6 +261,32 @@
|
||||||
}
|
}
|
||||||
.strip-cost { color: var(--ctp-text); font-weight: 500; }
|
.strip-cost { color: var(--ctp-text); font-weight: 500; }
|
||||||
|
|
||||||
|
.strip-stop-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--ctp-red);
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
color: var(--ctp-red);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-stop-btn:hover {
|
||||||
|
background: color-mix(in srgb, var(--ctp-red) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-stop-btn svg {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Main pane ────────────────────────────────────────────── */
|
/* ── Main pane ────────────────────────────────────────────── */
|
||||||
.agent-pane {
|
.agent-pane {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -424,6 +477,27 @@
|
||||||
|
|
||||||
.tool-result-content { color: var(--ctp-teal); }
|
.tool-result-content { color: var(--ctp-teal); }
|
||||||
|
|
||||||
|
/* ── Thinking content ──────────────────────────────────────── */
|
||||||
|
.thinking-content {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--ctp-overlay1);
|
||||||
|
font-style: italic;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── System content ────────────────────────────────────────── */
|
||||||
|
.system-content {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.tool-fade-overlay {
|
.tool-fade-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
|
|
||||||
|
|
@ -383,10 +383,6 @@
|
||||||
name={project.name}
|
name={project.name}
|
||||||
cwd={project.cwd}
|
cwd={project.cwd}
|
||||||
accent={project.accent}
|
accent={project.accent}
|
||||||
status={project.status}
|
|
||||||
costUsd={project.costUsd}
|
|
||||||
tokens={project.tokens}
|
|
||||||
messages={project.messages}
|
|
||||||
provider={project.provider}
|
provider={project.provider}
|
||||||
profile={project.profile}
|
profile={project.profile}
|
||||||
model={project.model}
|
model={project.model}
|
||||||
|
|
|
||||||
|
|
@ -3,26 +3,18 @@
|
||||||
import TerminalTabs from './TerminalTabs.svelte';
|
import TerminalTabs from './TerminalTabs.svelte';
|
||||||
import FileBrowser from './FileBrowser.svelte';
|
import FileBrowser from './FileBrowser.svelte';
|
||||||
import MemoryTab from './MemoryTab.svelte';
|
import MemoryTab from './MemoryTab.svelte';
|
||||||
|
import {
|
||||||
|
startAgent, stopAgent, sendPrompt, getSession, hasSession,
|
||||||
|
type AgentStatus, type AgentMessage,
|
||||||
|
} from './agent-store.svelte.ts';
|
||||||
|
|
||||||
type AgentStatus = 'running' | 'idle' | 'stalled';
|
|
||||||
type MsgRole = 'user' | 'assistant' | 'tool-call' | 'tool-result';
|
|
||||||
type ProjectTab = 'model' | 'docs' | 'context' | 'files' | 'ssh' | 'memory';
|
type ProjectTab = 'model' | 'docs' | 'context' | 'files' | 'ssh' | 'memory';
|
||||||
|
|
||||||
interface AgentMessage {
|
|
||||||
id: number;
|
|
||||||
role: MsgRole;
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
accent: string;
|
accent: string;
|
||||||
status: AgentStatus;
|
|
||||||
costUsd: number;
|
|
||||||
tokens: number;
|
|
||||||
messages: AgentMessage[];
|
|
||||||
provider?: string;
|
provider?: string;
|
||||||
profile?: string;
|
profile?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
|
@ -44,10 +36,6 @@
|
||||||
name,
|
name,
|
||||||
cwd,
|
cwd,
|
||||||
accent,
|
accent,
|
||||||
status,
|
|
||||||
costUsd,
|
|
||||||
tokens,
|
|
||||||
messages: initialMessages,
|
|
||||||
provider = 'claude',
|
provider = 'claude',
|
||||||
profile,
|
profile,
|
||||||
model = 'claude-opus-4-5',
|
model = 'claude-opus-4-5',
|
||||||
|
|
@ -60,6 +48,18 @@
|
||||||
onClone,
|
onClone,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
// ── Agent session (reactive from store) ──────────────────────────
|
||||||
|
let session = $derived(getSession(id));
|
||||||
|
let agentStatus: AgentStatus = $derived(session?.status ?? 'idle');
|
||||||
|
let agentMessages: AgentMessage[] = $derived(session?.messages ?? []);
|
||||||
|
let agentCost = $derived(session?.costUsd ?? 0);
|
||||||
|
let agentTokens = $derived((session?.inputTokens ?? 0) + (session?.outputTokens ?? 0));
|
||||||
|
let agentModel = $derived(session?.model ?? model);
|
||||||
|
|
||||||
|
// ── Demo messages (fallback when no real session) ────────────────
|
||||||
|
const demoMessages: AgentMessage[] = [];
|
||||||
|
let displayMessages = $derived(agentMessages.length > 0 ? agentMessages : demoMessages);
|
||||||
|
|
||||||
// ── Clone dialog state ──────────────────────────────────────────
|
// ── Clone dialog state ──────────────────────────────────────────
|
||||||
let showCloneDialog = $state(false);
|
let showCloneDialog = $state(false);
|
||||||
let cloneBranchName = $state('');
|
let cloneBranchName = $state('');
|
||||||
|
|
@ -83,9 +83,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
let activeTab = $state<ProjectTab>('model');
|
let activeTab = $state<ProjectTab>('model');
|
||||||
// svelte-ignore state_referenced_locally
|
|
||||||
const seedMessages = initialMessages.slice();
|
|
||||||
let messages = $state(seedMessages);
|
|
||||||
// Track which project tabs have been activated (PERSISTED-LAZY pattern)
|
// Track which project tabs have been activated (PERSISTED-LAZY pattern)
|
||||||
let activatedTabs = $state<Set<ProjectTab>>(new Set(['model']));
|
let activatedTabs = $state<Set<ProjectTab>>(new Set(['model']));
|
||||||
|
|
||||||
|
|
@ -97,15 +94,23 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSend(text: string) {
|
function handleSend(text: string) {
|
||||||
const newMsg: AgentMessage = { id: messages.length + 1, role: 'user', content: text };
|
if (hasSession(id)) {
|
||||||
messages = [...messages, newMsg];
|
// Session exists — send follow-up prompt
|
||||||
setTimeout(() => {
|
sendPrompt(id, text).catch((err) => {
|
||||||
messages = [...messages, {
|
console.error('[agent.prompt] error:', err);
|
||||||
id: messages.length + 1,
|
});
|
||||||
role: 'assistant',
|
} else {
|
||||||
content: `(demo) Received: "${text}"`,
|
// No session — start a new agent
|
||||||
}];
|
startAgent(id, provider, text, { cwd, model }).catch((err) => {
|
||||||
}, 400);
|
console.error('[agent.start] error:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStop() {
|
||||||
|
stopAgent(id).catch((err) => {
|
||||||
|
console.error('[agent.stop] error:', err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -117,12 +122,12 @@
|
||||||
>
|
>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="project-header">
|
<header class="project-header">
|
||||||
<div class="status-dot-wrap" aria-label="Status: {status}">
|
<div class="status-dot-wrap" aria-label="Status: {agentStatus}">
|
||||||
<div
|
<div
|
||||||
class="status-dot {status}"
|
class="status-dot {agentStatus}"
|
||||||
class:blink-off={status === 'running' && !blinkVisible}
|
class:blink-off={agentStatus === 'running' && !blinkVisible}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={status}
|
aria-label={agentStatus}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -230,16 +235,17 @@
|
||||||
aria-label="Model"
|
aria-label="Model"
|
||||||
>
|
>
|
||||||
<AgentPane
|
<AgentPane
|
||||||
{messages}
|
messages={displayMessages}
|
||||||
{status}
|
status={agentStatus}
|
||||||
{costUsd}
|
costUsd={agentCost}
|
||||||
{tokens}
|
tokens={agentTokens}
|
||||||
{model}
|
model={agentModel}
|
||||||
{provider}
|
{provider}
|
||||||
{profile}
|
{profile}
|
||||||
{contextPct}
|
{contextPct}
|
||||||
{burnRate}
|
{burnRate}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
|
onStop={handleStop}
|
||||||
/>
|
/>
|
||||||
<TerminalTabs projectId={id} {accent} />
|
<TerminalTabs projectId={id} {accent} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -270,7 +276,7 @@
|
||||||
<div class="ctx-stats-row">
|
<div class="ctx-stats-row">
|
||||||
<div class="ctx-stat">
|
<div class="ctx-stat">
|
||||||
<span class="ctx-stat-label">Tokens used</span>
|
<span class="ctx-stat-label">Tokens used</span>
|
||||||
<span class="ctx-stat-value">{tokens.toLocaleString()}</span>
|
<span class="ctx-stat-value">{agentTokens.toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ctx-stat">
|
<div class="ctx-stat">
|
||||||
<span class="ctx-stat-label">Context %</span>
|
<span class="ctx-stat-label">Context %</span>
|
||||||
|
|
@ -278,7 +284,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="ctx-stat">
|
<div class="ctx-stat">
|
||||||
<span class="ctx-stat-label">Model</span>
|
<span class="ctx-stat-label">Model</span>
|
||||||
<span class="ctx-stat-value">{model}</span>
|
<span class="ctx-stat-value">{agentModel}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ctx-meter-wrap" title="{contextPct}% context used">
|
<div class="ctx-meter-wrap" title="{contextPct}% context used">
|
||||||
|
|
@ -289,7 +295,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="ctx-turn-list">
|
<div class="ctx-turn-list">
|
||||||
<div class="ctx-section-label">Turn breakdown</div>
|
<div class="ctx-section-label">Turn breakdown</div>
|
||||||
{#each messages.slice(0, 5) as msg}
|
{#each displayMessages.slice(0, 5) as msg}
|
||||||
<div class="ctx-turn-row">
|
<div class="ctx-turn-row">
|
||||||
<span class="ctx-turn-role ctx-role-{msg.role}">{msg.role}</span>
|
<span class="ctx-turn-role ctx-role-{msg.role}">{msg.role}</span>
|
||||||
<span class="ctx-turn-preview">{msg.content.slice(0, 60)}{msg.content.length > 60 ? '…' : ''}</span>
|
<span class="ctx-turn-preview">{msg.content.slice(0, 60)}{msg.content.length > 60 ? '…' : ''}</span>
|
||||||
|
|
@ -403,6 +409,8 @@
|
||||||
|
|
||||||
.status-dot.running { background: var(--ctp-green); }
|
.status-dot.running { background: var(--ctp-green); }
|
||||||
.status-dot.idle { background: var(--ctp-overlay1); }
|
.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.stalled { background: var(--ctp-peach); }
|
||||||
.status-dot.blink-off { opacity: 0.3; }
|
.status-dot.blink-off { opacity: 0.3; }
|
||||||
|
|
||||||
|
|
|
||||||
345
ui-electrobun/src/mainview/agent-store.svelte.ts
Normal file
345
ui-electrobun/src/mainview/agent-store.svelte.ts
Normal file
|
|
@ -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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal state ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Map projectId -> sessionId for lookup
|
||||||
|
const projectSessionMap = new Map<string, string>();
|
||||||
|
|
||||||
|
// Map sessionId -> reactive session state
|
||||||
|
let sessions = $state<Record<string, AgentSession>>({});
|
||||||
|
|
||||||
|
// ── 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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();
|
||||||
|
|
@ -146,6 +146,50 @@ export type PtyRPCRequests = {
|
||||||
params: { id: string };
|
params: { id: string };
|
||||||
response: { ok: boolean };
|
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<string, string>;
|
||||||
|
};
|
||||||
|
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<string, never>;
|
||||||
|
response: {
|
||||||
|
sessions: Array<{
|
||||||
|
sessionId: string;
|
||||||
|
provider: string;
|
||||||
|
status: string;
|
||||||
|
costUsd: number;
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
startedAt: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Messages (Bun → WebView, fire-and-forget) ────────────────────────────────
|
// ── Messages (Bun → WebView, fire-and-forget) ────────────────────────────────
|
||||||
|
|
@ -155,6 +199,33 @@ export type PtyRPCMessages = {
|
||||||
"pty.output": { sessionId: string; data: string };
|
"pty.output": { sessionId: string; data: string };
|
||||||
/** PTY session exited. */
|
/** PTY session exited. */
|
||||||
"pty.closed": { sessionId: string; exitCode: number | null };
|
"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 ───────────────────────────────────────────────────────────
|
// ── Combined schema ───────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue