CRITICAL: - PTY leak: Terminal.svelte now calls pty.close on destroy, not just unsubscribe - Agent session cleanup: clearSession() removes done/error sessions, backend deletes after 60s grace period HIGH: - Clone branch passthrough: user's branch name flows through callback - Circular imports: extracted rpc.ts singleton, broke main.ts ↔ App.svelte cycle - Settings wired to runtime: Terminal reads cursor/scrollback from settings - Security disclaimer: added "prototype — not system keyring" notice - ThemeEditor: fixed basePalette → initialPalette reference MEDIUM: - Clone race: UUID suffix instead of count-based index - Silent failures: structured error returns from PTY handlers - WebKitGTK mount: only current + previous group mounted - Debug listeners: gated behind DEBUG, cleanup on destroy - NDJSON residual buffer parsed on process exit - Codex adapter: deduplicated tool_call/tool_result - extraEnv: rejects CLAUDE*/CODEX*/OLLAMA* keys - settings-db: runMigrations() with version tracking - active_group: persisted via settings.set LOW: - Removed dead demo code, unused variables - color-mix() fallbacks added
499 lines
13 KiB
TypeScript
499 lines
13 KiB
TypeScript
// 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": {
|
|
// Fix #13: Only emit tool_call on item.started, tool_result on item.completed
|
|
// Prevents duplicate tool_call messages.
|
|
const messages: AgentMessage[] = [];
|
|
const toolUseId = str(item.id, uuid);
|
|
if (eventType === "item.started") {
|
|
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 }];
|
|
}
|
|
}
|