summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/api/package.json20
-rw-r--r--packages/api/src/agent-manager.ts86
-rw-r--r--packages/api/src/app.ts46
-rw-r--r--packages/api/src/index.ts37
-rw-r--r--packages/api/src/types.ts2
-rw-r--r--packages/api/tests/agent-manager.test.ts113
-rw-r--r--packages/api/tests/routes.test.ts106
-rw-r--r--packages/api/tsconfig.json10
-rw-r--r--packages/api/vitest.config.ts12
-rw-r--r--packages/core/package.json21
-rw-r--r--packages/core/src/agent/agent.ts142
-rw-r--r--packages/core/src/index.ts9
-rw-r--r--packages/core/src/llm/provider.ts9
-rw-r--r--packages/core/src/tools/list-files.ts38
-rw-r--r--packages/core/src/tools/read-file.ts33
-rw-r--r--packages/core/src/tools/registry.ts35
-rw-r--r--packages/core/src/tools/write-file.ts33
-rw-r--r--packages/core/src/types/index.ts53
-rw-r--r--packages/core/tests/agent/agent.test.ts244
-rw-r--r--packages/core/tests/tools/list-files.test.ts41
-rw-r--r--packages/core/tests/tools/read-file.test.ts36
-rw-r--r--packages/core/tests/tools/registry.test.ts50
-rw-r--r--packages/core/tests/tools/write-file.test.ts45
-rw-r--r--packages/core/tsconfig.json10
-rw-r--r--packages/core/vitest.config.ts14
-rw-r--r--packages/frontend/index.html12
-rw-r--r--packages/frontend/package.json25
-rw-r--r--packages/frontend/src/App.svelte32
-rw-r--r--packages/frontend/src/app.css4
-rw-r--r--packages/frontend/src/lib/chat.svelte.ts274
-rw-r--r--packages/frontend/src/lib/components/ChatInput.svelte45
-rw-r--r--packages/frontend/src/lib/components/ChatMessage.svelte26
-rw-r--r--packages/frontend/src/lib/components/ChatPanel.svelte58
-rw-r--r--packages/frontend/src/lib/components/Header.svelte54
-rw-r--r--packages/frontend/src/lib/components/ThemeSwitcher.svelte65
-rw-r--r--packages/frontend/src/lib/components/ToolCallDisplay.svelte50
-rw-r--r--packages/frontend/src/lib/config.ts6
-rw-r--r--packages/frontend/src/lib/types.ts57
-rw-r--r--packages/frontend/src/lib/ws.svelte.ts89
-rw-r--r--packages/frontend/src/main.ts9
-rw-r--r--packages/frontend/src/vite-env.d.ts2
-rw-r--r--packages/frontend/svelte.config.js5
-rw-r--r--packages/frontend/tests/chat-store.test.ts261
-rw-r--r--packages/frontend/tsconfig.json10
-rw-r--r--packages/frontend/vite.config.ts13
45 files changed, 2342 insertions, 0 deletions
diff --git a/packages/api/package.json b/packages/api/package.json
new file mode 100644
index 0000000..deea9f6
--- /dev/null
+++ b/packages/api/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "@dispatch/api",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "main": "src/index.ts",
+ "scripts": {
+ "dev": "bun --watch src/index.ts",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "hono": "^4.0.0",
+ "@dispatch/core": "workspace:*"
+ },
+ "devDependencies": {
+ "@types/bun": "latest"
+ }
+}
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
new file mode 100644
index 0000000..f60b8d3
--- /dev/null
+++ b/packages/api/src/agent-manager.ts
@@ -0,0 +1,86 @@
+import {
+ Agent,
+ type AgentEvent,
+ type AgentStatus,
+ createListFilesTool,
+ createReadFileTool,
+ createWriteFileTool,
+} from "@dispatch/core";
+
+const SYSTEM_PROMPT = `You are Dispatch, a helpful AI coding assistant. You have access to the following tools for working with files in the current working directory:
+
+- read_file: Read the contents of a file
+- write_file: Write content to a file (creates parent directories if needed)
+- list_files: List files and directories
+
+When asked to work with files, use these tools. Always confirm what you did after completing an action. Be concise and helpful.`;
+
+export class AgentManager {
+ private agent: Agent | null = null;
+ private status: AgentStatus = "idle";
+ private messageCount = 0;
+ private eventListeners: Set<(event: AgentEvent) => void> = new Set();
+
+ private getOrCreateAgent(): Agent {
+ if (!this.agent) {
+ const apiKey = process.env.OPENCODE_API_KEY ?? "";
+ const model = process.env.DISPATCH_MODEL ?? "deepseek-v4-flash-free";
+ const workingDirectory = process.env.DISPATCH_WORKING_DIR ?? process.cwd();
+
+ const tools = [
+ createReadFileTool(workingDirectory),
+ createWriteFileTool(workingDirectory),
+ createListFilesTool(workingDirectory),
+ ];
+
+ this.agent = new Agent({
+ model,
+ apiKey,
+ baseURL: "https://opencode.ai/zen/v1",
+ systemPrompt: SYSTEM_PROMPT,
+ tools,
+ workingDirectory,
+ });
+ }
+ return this.agent;
+ }
+
+ getStatus(): AgentStatus {
+ return this.status;
+ }
+
+ getMessageCount(): number {
+ return this.messageCount;
+ }
+
+ onEvent(listener: (event: AgentEvent) => void): () => void {
+ this.eventListeners.add(listener);
+ return () => {
+ this.eventListeners.delete(listener);
+ };
+ }
+
+ private emit(event: AgentEvent): void {
+ for (const listener of this.eventListeners) {
+ listener(event);
+ }
+ }
+
+ async processMessage(message: string): Promise<void> {
+ const agent = this.getOrCreateAgent();
+
+ this.messageCount += 1;
+
+ try {
+ for await (const event of agent.run(message)) {
+ this.status = event.type === "status" ? event.status : this.status;
+ this.emit(event);
+ }
+ } catch (err) {
+ const errorMsg = err instanceof Error ? err.message : String(err);
+ this.status = "error";
+ this.emit({ type: "error", error: errorMsg });
+ this.emit({ type: "status", status: "error" });
+ }
+ }
+}
diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts
new file mode 100644
index 0000000..9c31eab
--- /dev/null
+++ b/packages/api/src/app.ts
@@ -0,0 +1,46 @@
+import { Hono } from "hono";
+import { cors } from "hono/cors";
+import { AgentManager } from "./agent-manager.js";
+
+export const agentManager = new AgentManager();
+
+export const app = new Hono();
+
+app.use(
+ "*",
+ cors({
+ origin: "http://localhost:5173",
+ credentials: true,
+ allowHeaders: ["Content-Type", "Authorization"],
+ allowMethods: ["GET", "POST", "OPTIONS"],
+ }),
+);
+
+app.get("/health", (c) => {
+ return c.json({ ok: true });
+});
+
+app.get("/status", (c) => {
+ return c.json({
+ status: agentManager.getStatus(),
+ messageCount: agentManager.getMessageCount(),
+ });
+});
+
+app.post("/chat", async (c) => {
+ const body = await c.req.json<{ message?: unknown }>();
+ const message = body.message;
+
+ if (typeof message !== "string" || message.trim() === "") {
+ return c.json({ error: "message must be a non-empty string" }, 400);
+ }
+
+ if (agentManager.getStatus() === "running") {
+ return c.json({ error: "agent is already running" }, 409);
+ }
+
+ // Non-blocking — let the agent run in the background
+ agentManager.processMessage(message).catch(console.error);
+
+ return c.json({ status: "ok" });
+});
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
new file mode 100644
index 0000000..02b04b6
--- /dev/null
+++ b/packages/api/src/index.ts
@@ -0,0 +1,37 @@
+import { createBunWebSocket } from "hono/bun";
+import { agentManager, app } from "./app.js";
+
+const { upgradeWebSocket, websocket } = createBunWebSocket();
+
+app.get(
+ "/ws",
+ upgradeWebSocket((_c) => {
+ return {
+ onOpen(_event, ws) {
+ // Send current status immediately
+ ws.send(JSON.stringify({ type: "status", status: agentManager.getStatus() }));
+
+ const unsubscribe = agentManager.onEvent((event) => {
+ ws.send(JSON.stringify(event));
+ });
+
+ // Store unsubscribe fn on the raw socket for cleanup
+ (ws as unknown as { _unsub?: () => void })._unsub = unsubscribe;
+ },
+ onClose(_event, ws) {
+ const unsub = (ws as unknown as { _unsub?: () => void })._unsub;
+ if (unsub) {
+ unsub();
+ }
+ },
+ };
+ }),
+);
+
+export { app };
+
+export default {
+ port: 3000,
+ fetch: app.fetch,
+ websocket,
+};
diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts
new file mode 100644
index 0000000..a88e41b
--- /dev/null
+++ b/packages/api/src/types.ts
@@ -0,0 +1,2 @@
+// Re-export types from @dispatch/core for convenience
+export type { AgentEvent, AgentStatus } from "@dispatch/core";
diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts
new file mode 100644
index 0000000..17b0bff
--- /dev/null
+++ b/packages/api/tests/agent-manager.test.ts
@@ -0,0 +1,113 @@
+import type { AgentEvent } from "@dispatch/core";
+import { describe, expect, it, vi } from "vitest";
+
+// Mock @dispatch/core's Agent to avoid real LLM calls
+vi.mock("@dispatch/core", async () => {
+ const actual = await vi.importActual<typeof import("@dispatch/core")>("@dispatch/core");
+ return {
+ ...actual,
+ Agent: class MockAgent {
+ status = "idle";
+ messages: unknown[] = [];
+ async *run(_message: string) {
+ yield { type: "status", status: "running" } as const;
+ await new Promise<void>((r) => setTimeout(r, 10));
+ yield { type: "text-delta", delta: "Hello " } as const;
+ yield { type: "text-delta", delta: "world" } as const;
+ yield {
+ type: "done",
+ message: { role: "assistant", content: "Hello world" },
+ } as const;
+ yield { type: "status", status: "idle" } as const;
+ }
+ },
+ };
+});
+
+// Import after mock is defined (Vitest hoists vi.mock automatically)
+const { AgentManager } = await import("../src/agent-manager.js");
+
+describe("AgentManager", () => {
+ it("initial status is idle", () => {
+ const manager = new AgentManager();
+ expect(manager.getStatus()).toBe("idle");
+ });
+
+ it("initial messageCount is 0", () => {
+ const manager = new AgentManager();
+ expect(manager.getMessageCount()).toBe(0);
+ });
+
+ it("event listeners receive events during processMessage", async () => {
+ const manager = new AgentManager();
+ const events: AgentEvent[] = [];
+ manager.onEvent((event) => {
+ events.push(event);
+ });
+
+ await manager.processMessage("test");
+
+ expect(events.length).toBeGreaterThan(0);
+ expect(events[0]).toEqual({ type: "status", status: "running" });
+
+ const lastEvent = events[events.length - 1];
+ expect(lastEvent).toEqual({ type: "status", status: "idle" });
+
+ const doneEvent = events.find((e) => e.type === "done");
+ expect(doneEvent).toBeDefined();
+ });
+
+ it("emits text-delta events during processMessage", async () => {
+ const manager = new AgentManager();
+ const events: AgentEvent[] = [];
+ manager.onEvent((event) => {
+ events.push(event);
+ });
+
+ await manager.processMessage("hello");
+
+ const textDeltas = events.filter((e) => e.type === "text-delta");
+ expect(textDeltas.length).toBeGreaterThan(0);
+ });
+
+ it("messageCount increments after processMessage", async () => {
+ const manager = new AgentManager();
+ await manager.processMessage("hello");
+ expect(manager.getMessageCount()).toBe(1);
+ await manager.processMessage("world");
+ expect(manager.getMessageCount()).toBe(2);
+ });
+
+ it("status returns to idle after processMessage completes", async () => {
+ const manager = new AgentManager();
+ await manager.processMessage("test");
+ expect(manager.getStatus()).toBe("idle");
+ });
+
+ it("unsubscribe removes listener", async () => {
+ const manager = new AgentManager();
+ const events: AgentEvent[] = [];
+ const unsubscribe = manager.onEvent((event) => {
+ events.push(event);
+ });
+
+ unsubscribe();
+ await manager.processMessage("test");
+
+ expect(events.length).toBe(0);
+ });
+
+ it("multiple listeners all receive events", async () => {
+ const manager = new AgentManager();
+ const listener1 = vi.fn();
+ const listener2 = vi.fn();
+
+ manager.onEvent(listener1);
+ manager.onEvent(listener2);
+
+ await manager.processMessage("test");
+
+ expect(listener1).toHaveBeenCalled();
+ expect(listener2).toHaveBeenCalled();
+ });
+});
diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts
new file mode 100644
index 0000000..d5384b3
--- /dev/null
+++ b/packages/api/tests/routes.test.ts
@@ -0,0 +1,106 @@
+import { describe, expect, it, vi } from "vitest";
+
+// Mock @dispatch/core's Agent to avoid real LLM calls
+vi.mock("@dispatch/core", async () => {
+ const actual = await vi.importActual<typeof import("@dispatch/core")>("@dispatch/core");
+ return {
+ ...actual,
+ Agent: class MockAgent {
+ status = "idle";
+ messages: unknown[] = [];
+ async *run(_message: string) {
+ yield { type: "status", status: "running" } as const;
+ // Simulate some processing time so status stays "running"
+ await new Promise<void>((r) => setTimeout(r, 100));
+ yield { type: "text-delta", delta: "Hello " } as const;
+ yield { type: "text-delta", delta: "world" } as const;
+ yield {
+ type: "done",
+ message: { role: "assistant", content: "Hello world" },
+ } as const;
+ yield { type: "status", status: "idle" } as const;
+ }
+ },
+ };
+});
+
+const { app } = await import("../src/app.js");
+
+describe("GET /health", () => {
+ it("returns 200 with ok: true", async () => {
+ const res = await app.request("/health");
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body).toEqual({ ok: true });
+ });
+});
+
+describe("GET /status", () => {
+ it("returns idle status initially", async () => {
+ const res = await app.request("/status");
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.status).toBe("idle");
+ expect(typeof body.messageCount).toBe("number");
+ });
+});
+
+describe("POST /chat", () => {
+ it("returns 200 with valid message", async () => {
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hello world" }),
+ });
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body).toEqual({ status: "ok" });
+ });
+
+ it("returns 400 with empty message", async () => {
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "" }),
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 with whitespace-only message", async () => {
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: " " }),
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 with missing message field", async () => {
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 409 when agent is already running", async () => {
+ // Start a message (non-blocking)
+ await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "first message" }),
+ });
+
+ // Small delay to let the async generator start and emit "running" status
+ await new Promise<void>((r) => setTimeout(r, 20));
+
+ // Immediately send a second — agent should be running
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "second message" }),
+ });
+ expect(res.status).toBe(409);
+ });
+});
diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json
new file mode 100644
index 0000000..2d6fedd
--- /dev/null
+++ b/packages/api/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src",
+ "types": ["@types/bun"]
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["node_modules", "dist", "tests"]
+}
diff --git a/packages/api/vitest.config.ts b/packages/api/vitest.config.ts
new file mode 100644
index 0000000..854422e
--- /dev/null
+++ b/packages/api/vitest.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ include: ["tests/**/*.test.ts"],
+ server: {
+ deps: {
+ inline: ["zod", "@dispatch/core"],
+ },
+ },
+ },
+});
diff --git a/packages/core/package.json b/packages/core/package.json
new file mode 100644
index 0000000..3741041
--- /dev/null
+++ b/packages/core/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "@dispatch/core",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "scripts": {
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "ai": "^4.0.0",
+ "@ai-sdk/openai-compatible": "^0.2.0",
+ "zod": "^3.23.0"
+ },
+ "devDependencies": {
+ "@types/bun": "latest"
+ }
+}
diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts
new file mode 100644
index 0000000..a20a4ce
--- /dev/null
+++ b/packages/core/src/agent/agent.ts
@@ -0,0 +1,142 @@
+import type { CoreMessage } from "ai";
+import { streamText } from "ai";
+import { createProvider } from "../llm/provider.js";
+import { createToolRegistry } from "../tools/registry.js";
+import type {
+ AgentConfig,
+ AgentEvent,
+ AgentStatus,
+ ChatMessage,
+ ToolCall,
+ ToolResult,
+} from "../types/index.js";
+
+function toCoreMessages(messages: ChatMessage[]): CoreMessage[] {
+ const result: CoreMessage[] = [];
+ for (const msg of messages) {
+ if (msg.role === "user") {
+ result.push({ role: "user", content: msg.content });
+ } else if (msg.role === "assistant") {
+ result.push({ role: "assistant", content: msg.content });
+ }
+ }
+ return result;
+}
+
+function formatError(err: unknown, config: AgentConfig): string {
+ const context = `[model=${config.model}, baseURL=${config.baseURL}]`;
+
+ if (err instanceof Error) {
+ const cause = err.cause ? ` | cause: ${JSON.stringify(err.cause)}` : "";
+ // AI SDK errors often have statusCode, responseBody, or url properties
+ const extras: string[] = [];
+ const errRecord = err as unknown as Record<string, unknown>;
+ if ("statusCode" in errRecord) extras.push(`status=${errRecord.statusCode}`);
+ if ("url" in errRecord) extras.push(`url=${errRecord.url}`);
+ if ("responseBody" in errRecord) extras.push(`body=${JSON.stringify(errRecord.responseBody)}`);
+ if ("responseHeaders" in errRecord)
+ extras.push(`headers=${JSON.stringify(errRecord.responseHeaders)}`);
+
+ const detail = extras.length > 0 ? ` (${extras.join(", ")})` : "";
+ return `${err.message}${detail}${cause} ${context}`;
+ }
+
+ return `${String(err)} ${context}`;
+}
+
+export class Agent {
+ status: AgentStatus = "idle";
+ messages: ChatMessage[] = [];
+
+ private config: AgentConfig;
+
+ constructor(config: AgentConfig) {
+ this.config = config;
+ }
+
+ async *run(userMessage: string): AsyncGenerator<AgentEvent> {
+ this.status = "running";
+ yield { type: "status", status: "running" };
+
+ this.messages.push({ role: "user", content: userMessage });
+
+ const registry = createToolRegistry(this.config.tools);
+ const providerFactory = createProvider({
+ apiKey: this.config.apiKey,
+ baseURL: this.config.baseURL,
+ });
+
+ try {
+ const result = streamText({
+ model: providerFactory(this.config.model),
+ system: this.config.systemPrompt,
+ messages: toCoreMessages(this.messages),
+ tools: registry.getAISDKTools(),
+ maxSteps: 10,
+ });
+
+ let fullText = "";
+ const toolCalls: ToolCall[] = [];
+ const toolResults: ToolResult[] = [];
+
+ for await (const event of result.fullStream) {
+ if (event.type === "text-delta") {
+ fullText += event.textDelta;
+ yield { type: "text-delta", delta: event.textDelta };
+ } else if (event.type === "tool-call") {
+ const toolCall: ToolCall = {
+ id: event.toolCallId,
+ name: event.toolName,
+ arguments: event.args as Record<string, unknown>,
+ };
+ toolCalls.push(toolCall);
+ yield { type: "tool-call", toolCall };
+ } else if (event.type === "error") {
+ const errorMsg = formatError(event.error, this.config);
+ yield { type: "error", error: errorMsg };
+ this.status = "error";
+ yield { type: "status", status: "error" };
+ return;
+ }
+ }
+
+ // Tool results are available from completed steps after streaming.
+ // The generic TOOLS type resolves to never[] at compile time, so
+ // we cast through unknown to access the runtime shape.
+ const steps = await result.steps;
+ for (const step of steps) {
+ const stepToolResults = step.toolResults as unknown as Array<{
+ toolCallId: string;
+ result: unknown;
+ }>;
+ for (const tr of stepToolResults) {
+ const toolResult: ToolResult = {
+ toolCallId: tr.toolCallId,
+ result: typeof tr.result === "string" ? tr.result : JSON.stringify(tr.result),
+ isError: false,
+ };
+ toolResults.push(toolResult);
+ yield { type: "tool-result", toolResult };
+ }
+ }
+
+ const assistantMessage: ChatMessage = {
+ role: "assistant",
+ content: fullText,
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
+ toolResults: toolResults.length > 0 ? toolResults : undefined,
+ };
+ this.messages.push(assistantMessage);
+ yield { type: "done", message: assistantMessage };
+ } catch (err) {
+ const errorMsg = formatError(err, this.config);
+ yield { type: "error", error: errorMsg };
+ this.status = "error";
+ yield { type: "status", status: "error" };
+ return;
+ }
+
+ this.status = "idle";
+ yield { type: "status", status: "idle" };
+ }
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
new file mode 100644
index 0000000..b3907fa
--- /dev/null
+++ b/packages/core/src/index.ts
@@ -0,0 +1,9 @@
+// @dispatch/core — Agent runtime, LLM integration, tools
+
+export { Agent } from "./agent/agent.js";
+export { createProvider } from "./llm/provider.js";
+export { createListFilesTool } from "./tools/list-files.js";
+export { createReadFileTool } from "./tools/read-file.js";
+export { createToolRegistry } from "./tools/registry.js";
+export { createWriteFileTool } from "./tools/write-file.js";
+export * from "./types/index.js";
diff --git a/packages/core/src/llm/provider.ts b/packages/core/src/llm/provider.ts
new file mode 100644
index 0000000..06f7b72
--- /dev/null
+++ b/packages/core/src/llm/provider.ts
@@ -0,0 +1,9 @@
+import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
+
+export function createProvider(config: { apiKey: string; baseURL: string }) {
+ return createOpenAICompatible({
+ name: "opencode-zen",
+ apiKey: config.apiKey,
+ baseURL: config.baseURL,
+ });
+}
diff --git a/packages/core/src/tools/list-files.ts b/packages/core/src/tools/list-files.ts
new file mode 100644
index 0000000..360ac98
--- /dev/null
+++ b/packages/core/src/tools/list-files.ts
@@ -0,0 +1,38 @@
+import { readdir } from "node:fs/promises";
+import { join, resolve } from "node:path";
+import { z } from "zod";
+import type { ToolDefinition } from "../types/index.js";
+
+export function createListFilesTool(workingDirectory: string): ToolDefinition {
+ return {
+ name: "list_files",
+ description: "List files and directories at a path relative to the working directory.",
+ parameters: z.object({
+ path: z
+ .string()
+ .optional()
+ .describe("Path to list, relative to the working directory. Defaults to '.'"),
+ }),
+ execute: async (args: Record<string, unknown>): Promise<string> => {
+ const relPath = (args.path as string | undefined) ?? ".";
+ const absolutePath = resolve(join(workingDirectory, relPath));
+ const absoluteWorkDir = resolve(workingDirectory);
+
+ if (!absolutePath.startsWith(`${absoluteWorkDir}/`) && absolutePath !== absoluteWorkDir) {
+ return `Error: Path "${relPath}" is outside the working directory.`;
+ }
+
+ try {
+ const entries = await readdir(absolutePath, { withFileTypes: true });
+ if (entries.length === 0) {
+ return "(empty directory)";
+ }
+ return entries
+ .map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name))
+ .join("\n");
+ } catch (err) {
+ return `Error listing files: ${err instanceof Error ? err.message : String(err)}`;
+ }
+ },
+ };
+}
diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts
new file mode 100644
index 0000000..476f243
--- /dev/null
+++ b/packages/core/src/tools/read-file.ts
@@ -0,0 +1,33 @@
+import { readFile } from "node:fs/promises";
+import { join, resolve } from "node:path";
+import { z } from "zod";
+import type { ToolDefinition } from "../types/index.js";
+
+export function createReadFileTool(workingDirectory: string): ToolDefinition {
+ return {
+ name: "read_file",
+ description: "Read the contents of a file relative to the working directory.",
+ parameters: z.object({
+ path: z.string().describe("Path to the file, relative to the working directory"),
+ }),
+ execute: async (args: Record<string, unknown>): Promise<string> => {
+ const filePath = args.path as string;
+ const absolutePath = resolve(join(workingDirectory, filePath));
+ const absoluteWorkDir = resolve(workingDirectory);
+
+ if (!absolutePath.startsWith(`${absoluteWorkDir}/`) && absolutePath !== absoluteWorkDir) {
+ return `Error: Path "${filePath}" is outside the working directory.`;
+ }
+
+ try {
+ return await readFile(absolutePath, "utf8");
+ } catch (err) {
+ const code = (err as NodeJS.ErrnoException).code;
+ if (code === "ENOENT") {
+ return `Error: File "${filePath}" not found.`;
+ }
+ return `Error reading file: ${err instanceof Error ? err.message : String(err)}`;
+ }
+ },
+ };
+}
diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts
new file mode 100644
index 0000000..4699c93
--- /dev/null
+++ b/packages/core/src/tools/registry.ts
@@ -0,0 +1,35 @@
+import { tool } from "ai";
+import { z } from "zod";
+import type { ToolDefinition } from "../types/index.js";
+
+export function createToolRegistry(tools: ToolDefinition[]) {
+ const toolMap = new Map<string, ToolDefinition>(tools.map((t) => [t.name, t]));
+
+ return {
+ getTools(): ToolDefinition[] {
+ return [...toolMap.values()];
+ },
+
+ getTool(name: string): ToolDefinition | undefined {
+ return toolMap.get(name);
+ },
+
+ getAISDKTools() {
+ const result: Record<string, ReturnType<typeof tool>> = {};
+ for (const [name, def] of toolMap) {
+ const schema = def.parameters;
+ const t = tool({
+ description: def.description,
+ parameters: schema instanceof z.ZodObject ? schema : z.object({}),
+ execute: async (args) => {
+ return def.execute(args as Record<string, unknown>);
+ },
+ });
+ // The AI SDK tool() overloads cause type narrowing issues when
+ // execute is provided. The runtime value is correct.
+ result[name] = t as unknown as ReturnType<typeof tool>;
+ }
+ return result;
+ },
+ };
+}
diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts
new file mode 100644
index 0000000..23bc72a
--- /dev/null
+++ b/packages/core/src/tools/write-file.ts
@@ -0,0 +1,33 @@
+import { mkdir, writeFile } from "node:fs/promises";
+import { dirname, join, resolve } from "node:path";
+import { z } from "zod";
+import type { ToolDefinition } from "../types/index.js";
+
+export function createWriteFileTool(workingDirectory: string): ToolDefinition {
+ return {
+ name: "write_file",
+ description: "Write content to a file relative to the working directory.",
+ parameters: z.object({
+ path: z.string().describe("Path to the file, relative to the working directory"),
+ content: z.string().describe("Content to write to the file"),
+ }),
+ execute: async (args: Record<string, unknown>): Promise<string> => {
+ const filePath = args.path as string;
+ const content = args.content as string;
+ const absolutePath = resolve(join(workingDirectory, filePath));
+ const absoluteWorkDir = resolve(workingDirectory);
+
+ if (!absolutePath.startsWith(`${absoluteWorkDir}/`) && absolutePath !== absoluteWorkDir) {
+ return `Error: Path "${filePath}" is outside the working directory.`;
+ }
+
+ try {
+ await mkdir(dirname(absolutePath), { recursive: true });
+ await writeFile(absolutePath, content, "utf8");
+ return `Successfully wrote to "${filePath}".`;
+ } catch (err) {
+ return `Error writing file: ${err instanceof Error ? err.message : String(err)}`;
+ }
+ },
+ };
+}
diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts
new file mode 100644
index 0000000..a408e7d
--- /dev/null
+++ b/packages/core/src/types/index.ts
@@ -0,0 +1,53 @@
+import type { ZodType } from "zod";
+
+// Message types for the agent conversation
+export type MessageRole = "user" | "assistant" | "tool";
+
+export interface ChatMessage {
+ role: MessageRole;
+ content: string;
+ toolCalls?: ToolCall[];
+ toolResults?: ToolResult[];
+}
+
+export interface ToolCall {
+ id: string;
+ name: string;
+ arguments: Record<string, unknown>;
+}
+
+export interface ToolResult {
+ toolCallId: string;
+ result: string;
+ isError: boolean;
+}
+
+// Agent status
+export type AgentStatus = "idle" | "running" | "error";
+
+// Agent events emitted during execution (for WebSocket streaming)
+export type AgentEvent =
+ | { type: "status"; status: AgentStatus }
+ | { type: "text-delta"; delta: string }
+ | { type: "tool-call"; toolCall: ToolCall }
+ | { type: "tool-result"; toolResult: ToolResult }
+ | { type: "error"; error: string }
+ | { type: "done"; message: ChatMessage };
+
+// Tool definition interface
+export interface ToolDefinition {
+ name: string;
+ description: string;
+ parameters: ZodType;
+ execute: (args: Record<string, unknown>) => Promise<string>;
+}
+
+// Agent configuration
+export interface AgentConfig {
+ model: string;
+ apiKey: string;
+ baseURL: string;
+ systemPrompt: string;
+ tools: ToolDefinition[];
+ workingDirectory: string;
+}
diff --git a/packages/core/tests/agent/agent.test.ts b/packages/core/tests/agent/agent.test.ts
new file mode 100644
index 0000000..81d42c8
--- /dev/null
+++ b/packages/core/tests/agent/agent.test.ts
@@ -0,0 +1,244 @@
+import { describe, expect, it, vi } from "vitest";
+import { z } from "zod";
+import { Agent } from "../../src/agent/agent.js";
+import type { AgentConfig } from "../../src/types/index.js";
+
+// Mock the ai module's streamText
+vi.mock("ai", async (importOriginal) => {
+ const actual = await importOriginal<typeof import("ai")>();
+ return {
+ ...actual,
+ streamText: vi.fn(),
+ };
+});
+
+// Mock the provider
+vi.mock("@ai-sdk/openai-compatible", () => ({
+ createOpenAICompatible: vi.fn(() => (_model: string) => ({
+ type: "language-model",
+ modelId: _model,
+ })),
+}));
+
+function makeConfig(overrides: Partial<AgentConfig> = {}): AgentConfig {
+ return {
+ model: "test-model",
+ apiKey: "test-key",
+ baseURL: "https://example.com/v1",
+ systemPrompt: "You are a helpful assistant.",
+ tools: [],
+ workingDirectory: "/tmp",
+ ...overrides,
+ };
+}
+
+async function* makeFullStream(
+ events: Array<{ type: string; [key: string]: unknown }>,
+): AsyncGenerator<{ type: string; [key: string]: unknown }> {
+ for (const event of events) {
+ yield event;
+ }
+}
+
+interface MockStreamOptions {
+ events: Array<{ type: string; [key: string]: unknown }>;
+ steps?: Array<{ toolResults: Array<{ toolCallId: string; result: unknown }> }>;
+}
+
+function makeMockStreamResult(opts: MockStreamOptions) {
+ return {
+ fullStream: makeFullStream(opts.events),
+ steps: Promise.resolve(opts.steps ?? []),
+ } as ReturnType<typeof import("ai").streamText>;
+}
+
+describe("Agent", () => {
+ it("starts in idle status", () => {
+ const agent = new Agent(makeConfig());
+ expect(agent.status).toBe("idle");
+ });
+
+ it("has empty messages initially", () => {
+ const agent = new Agent(makeConfig());
+ expect(agent.messages).toHaveLength(0);
+ });
+
+ it("yields running then idle status events around a simple message", async () => {
+ const { streamText } = await import("ai");
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult({
+ events: [
+ { type: "text-delta", textDelta: "Hello!" },
+ {
+ type: "finish",
+ finishReason: "stop",
+ usage: {},
+ providerMetadata: undefined,
+ response: {},
+ },
+ ],
+ }),
+ );
+
+ const agent = new Agent(makeConfig());
+ const events = [];
+ for await (const event of agent.run("hi")) {
+ events.push(event);
+ }
+
+ const types = events.map((e) => e.type);
+ expect(types[0]).toBe("status");
+ expect(events[0]).toMatchObject({ type: "status", status: "running" });
+
+ const lastStatusEvent = events.filter((e) => e.type === "status").at(-1);
+ expect(lastStatusEvent).toMatchObject({ type: "status", status: "idle" });
+ });
+
+ it("yields text-delta events", async () => {
+ const { streamText } = await import("ai");
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult({
+ events: [
+ { type: "text-delta", textDelta: "Hello" },
+ { type: "text-delta", textDelta: " world" },
+ {
+ type: "finish",
+ finishReason: "stop",
+ usage: {},
+ providerMetadata: undefined,
+ response: {},
+ },
+ ],
+ }),
+ );
+
+ const agent = new Agent(makeConfig());
+ const events = [];
+ for await (const event of agent.run("test")) {
+ events.push(event);
+ }
+
+ const textDeltas = events.filter((e) => e.type === "text-delta");
+ expect(textDeltas).toHaveLength(2);
+ expect(textDeltas[0]).toMatchObject({ delta: "Hello" });
+ expect(textDeltas[1]).toMatchObject({ delta: " world" });
+ });
+
+ it("adds user message and assistant message to history", async () => {
+ const { streamText } = await import("ai");
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult({
+ events: [
+ { type: "text-delta", textDelta: "Response" },
+ {
+ type: "finish",
+ finishReason: "stop",
+ usage: {},
+ providerMetadata: undefined,
+ response: {},
+ },
+ ],
+ }),
+ );
+
+ const agent = new Agent(makeConfig());
+ for await (const _ of agent.run("my question")) {
+ // consume generator
+ }
+
+ expect(agent.messages).toHaveLength(2);
+ expect(agent.messages[0]).toMatchObject({
+ role: "user",
+ content: "my question",
+ });
+ expect(agent.messages[1]).toMatchObject({
+ role: "assistant",
+ content: "Response",
+ });
+ });
+
+ it("yields done event with final message", async () => {
+ const { streamText } = await import("ai");
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult({
+ events: [
+ { type: "text-delta", textDelta: "Done!" },
+ {
+ type: "finish",
+ finishReason: "stop",
+ usage: {},
+ providerMetadata: undefined,
+ response: {},
+ },
+ ],
+ }),
+ );
+
+ const agent = new Agent(makeConfig());
+ const events = [];
+ for await (const event of agent.run("test")) {
+ events.push(event);
+ }
+
+ const doneEvent = events.find((e) => e.type === "done");
+ expect(doneEvent).toBeDefined();
+ expect(doneEvent).toMatchObject({
+ type: "done",
+ message: { role: "assistant", content: "Done!" },
+ });
+ });
+
+ it("yields tool-call and tool-result events", async () => {
+ const { streamText } = await import("ai");
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult({
+ events: [
+ {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "read_file",
+ args: { path: "hello.txt" },
+ },
+ { type: "text-delta", textDelta: "Here is the file." },
+ {
+ type: "finish",
+ finishReason: "stop",
+ usage: {},
+ providerMetadata: undefined,
+ response: {},
+ },
+ ],
+ steps: [
+ {
+ toolResults: [{ toolCallId: "tc1", result: "file contents" }],
+ },
+ ],
+ }),
+ );
+
+ const toolDef = {
+ name: "read_file",
+ description: "reads a file",
+ parameters: z.object({ path: z.string() }),
+ execute: async (_args: Record<string, unknown>) => "file contents",
+ };
+
+ const agent = new Agent(makeConfig({ tools: [toolDef] }));
+ const events = [];
+ for await (const event of agent.run("read the file")) {
+ events.push(event);
+ }
+
+ const toolCallEvent = events.find((e) => e.type === "tool-call");
+ expect(toolCallEvent).toMatchObject({
+ type: "tool-call",
+ toolCall: { id: "tc1", name: "read_file" },
+ });
+
+ const toolResultEvent = events.find((e) => e.type === "tool-result");
+ expect(toolResultEvent).toMatchObject({
+ type: "tool-result",
+ toolResult: { toolCallId: "tc1", result: "file contents" },
+ });
+ });
+});
diff --git a/packages/core/tests/tools/list-files.test.ts b/packages/core/tests/tools/list-files.test.ts
new file mode 100644
index 0000000..ead1df3
--- /dev/null
+++ b/packages/core/tests/tools/list-files.test.ts
@@ -0,0 +1,41 @@
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { createListFilesTool } from "../../src/tools/list-files.js";
+
+describe("list_files tool", () => {
+ let workDir: string;
+
+ beforeEach(async () => {
+ workDir = await mkdtemp(join(tmpdir(), "dispatch-test-"));
+ });
+
+ afterEach(async () => {
+ await rm(workDir, { recursive: true, force: true });
+ });
+
+ it("lists directory contents", async () => {
+ const tool = createListFilesTool(workDir);
+ await writeFile(join(workDir, "file1.txt"), "a");
+ await writeFile(join(workDir, "file2.txt"), "b");
+ await mkdir(join(workDir, "subdir"));
+ const result = await tool.execute({ path: "." });
+ expect(result).toContain("file1.txt");
+ expect(result).toContain("file2.txt");
+ expect(result).toContain("subdir/");
+ });
+
+ it("defaults to current directory when path is undefined", async () => {
+ const tool = createListFilesTool(workDir);
+ await writeFile(join(workDir, "hello.txt"), "hi");
+ const result = await tool.execute({});
+ expect(result).toContain("hello.txt");
+ });
+
+ it("blocks path traversal", async () => {
+ const tool = createListFilesTool(workDir);
+ const result = await tool.execute({ path: "../" });
+ expect(result).toMatch(/outside the working directory/i);
+ });
+});
diff --git a/packages/core/tests/tools/read-file.test.ts b/packages/core/tests/tools/read-file.test.ts
new file mode 100644
index 0000000..ce65b37
--- /dev/null
+++ b/packages/core/tests/tools/read-file.test.ts
@@ -0,0 +1,36 @@
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { createReadFileTool } from "../../src/tools/read-file.js";
+
+describe("read_file tool", () => {
+ let workDir: string;
+
+ beforeEach(async () => {
+ workDir = await mkdtemp(join(tmpdir(), "dispatch-test-"));
+ });
+
+ afterEach(async () => {
+ await rm(workDir, { recursive: true, force: true });
+ });
+
+ it("reads an existing file", async () => {
+ const tool = createReadFileTool(workDir);
+ await writeFile(join(workDir, "hello.txt"), "Hello, world!");
+ const result = await tool.execute({ path: "hello.txt" });
+ expect(result).toBe("Hello, world!");
+ });
+
+ it("returns error for non-existent file", async () => {
+ const tool = createReadFileTool(workDir);
+ const result = await tool.execute({ path: "missing.txt" });
+ expect(result).toMatch(/not found/i);
+ });
+
+ it("blocks path traversal", async () => {
+ const tool = createReadFileTool(workDir);
+ const result = await tool.execute({ path: "../etc/passwd" });
+ expect(result).toMatch(/outside the working directory/i);
+ });
+});
diff --git a/packages/core/tests/tools/registry.test.ts b/packages/core/tests/tools/registry.test.ts
new file mode 100644
index 0000000..b6f1fca
--- /dev/null
+++ b/packages/core/tests/tools/registry.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from "vitest";
+import { z } from "zod";
+import { createToolRegistry } from "../../src/tools/registry.js";
+import type { ToolDefinition } from "../../src/types/index.js";
+
+const mockTool: ToolDefinition = {
+ name: "mock_tool",
+ description: "A mock tool for testing",
+ parameters: z.object({ input: z.string() }),
+ execute: async (_args) => "mock result",
+};
+
+const anotherTool: ToolDefinition = {
+ name: "another_tool",
+ description: "Another mock tool",
+ parameters: z.object({ value: z.number() }),
+ execute: async (_args) => "another result",
+};
+
+describe("createToolRegistry", () => {
+ it("returns all tools via getTools()", () => {
+ const registry = createToolRegistry([mockTool, anotherTool]);
+ const tools = registry.getTools();
+ expect(tools).toHaveLength(2);
+ expect(tools.map((t) => t.name)).toContain("mock_tool");
+ expect(tools.map((t) => t.name)).toContain("another_tool");
+ });
+
+ it("retrieves specific tool by name", () => {
+ const registry = createToolRegistry([mockTool, anotherTool]);
+ const tool = registry.getTool("mock_tool");
+ expect(tool).toBeDefined();
+ expect(tool?.name).toBe("mock_tool");
+ });
+
+ it("returns undefined for unknown tool", () => {
+ const registry = createToolRegistry([mockTool]);
+ expect(registry.getTool("nonexistent")).toBeUndefined();
+ });
+
+ it("getAISDKTools returns correct format", () => {
+ const registry = createToolRegistry([mockTool, anotherTool]);
+ const aiTools = registry.getAISDKTools();
+ expect(aiTools).toHaveProperty("mock_tool");
+ expect(aiTools).toHaveProperty("another_tool");
+ // Each should have description and parameters (AI SDK tool format)
+ expect(aiTools.mock_tool).toHaveProperty("description");
+ expect(aiTools.mock_tool).toHaveProperty("parameters");
+ });
+});
diff --git a/packages/core/tests/tools/write-file.test.ts b/packages/core/tests/tools/write-file.test.ts
new file mode 100644
index 0000000..01d7253
--- /dev/null
+++ b/packages/core/tests/tools/write-file.test.ts
@@ -0,0 +1,45 @@
+import { mkdtemp, readFile, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { createWriteFileTool } from "../../src/tools/write-file.js";
+
+describe("write_file tool", () => {
+ let workDir: string;
+
+ beforeEach(async () => {
+ workDir = await mkdtemp(join(tmpdir(), "dispatch-test-"));
+ });
+
+ afterEach(async () => {
+ await rm(workDir, { recursive: true, force: true });
+ });
+
+ it("writes a new file", async () => {
+ const tool = createWriteFileTool(workDir);
+ const result = await tool.execute({
+ path: "output.txt",
+ content: "test content",
+ });
+ expect(result).toMatch(/successfully wrote/i);
+ const written = await readFile(join(workDir, "output.txt"), "utf8");
+ expect(written).toBe("test content");
+ });
+
+ it("creates parent directories", async () => {
+ const tool = createWriteFileTool(workDir);
+ const result = await tool.execute({
+ path: "nested/dir/file.txt",
+ content: "nested",
+ });
+ expect(result).toMatch(/successfully wrote/i);
+ const written = await readFile(join(workDir, "nested/dir/file.txt"), "utf8");
+ expect(written).toBe("nested");
+ });
+
+ it("blocks path traversal", async () => {
+ const tool = createWriteFileTool(workDir);
+ const result = await tool.execute({ path: "../evil.txt", content: "bad" });
+ expect(result).toMatch(/outside the working directory/i);
+ });
+});
diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json
new file mode 100644
index 0000000..2d6fedd
--- /dev/null
+++ b/packages/core/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src",
+ "types": ["@types/bun"]
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["node_modules", "dist", "tests"]
+}
diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts
new file mode 100644
index 0000000..ba60b3c
--- /dev/null
+++ b/packages/core/vitest.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ include: ["tests/**/*.test.ts"],
+ server: {
+ deps: {
+ // Force inline resolution for packages that break under Bun's
+ // .bun/ symlink layout in Docker environments
+ inline: ["zod"],
+ },
+ },
+ },
+});
diff --git a/packages/frontend/index.html b/packages/frontend/index.html
new file mode 100644
index 0000000..32b56aa
--- /dev/null
+++ b/packages/frontend/index.html
@@ -0,0 +1,12 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Dispatch</title>
+ </head>
+ <body>
+ <div id="app"></div>
+ <script type="module" src="/src/main.ts"></script>
+ </body>
+</html>
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
new file mode 100644
index 0000000..acb23ad
--- /dev/null
+++ b/packages/frontend/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "@dispatch/frontend",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "typecheck": "svelte-check --tsconfig ./tsconfig.json"
+ },
+ "dependencies": {
+ "svelte": "^5.0.0"
+ },
+ "devDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^5.0.0",
+ "@tailwindcss/vite": "^4.0.0",
+ "daisyui": "^5.0.0",
+ "svelte-check": "^4.0.0",
+ "tailwindcss": "^4.0.0",
+ "vite": "^6.0.0"
+ }
+}
diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte
new file mode 100644
index 0000000..038fb09
--- /dev/null
+++ b/packages/frontend/src/App.svelte
@@ -0,0 +1,32 @@
+<script lang="ts">
+import { onMount } from "svelte";
+import ChatInput from "./lib/components/ChatInput.svelte";
+import ChatPanel from "./lib/components/ChatPanel.svelte";
+import Header from "./lib/components/Header.svelte";
+import { wsClient } from "./lib/ws.svelte.js";
+
+const STORAGE_KEY = "dispatch-theme";
+
+onMount(() => {
+ // Apply saved theme
+ const saved = localStorage.getItem(STORAGE_KEY);
+ if (saved) {
+ document.documentElement.setAttribute("data-theme", saved);
+ }
+
+ // Connect WebSocket
+ wsClient.connect();
+
+ return () => {
+ wsClient.disconnect();
+ };
+});
+</script>
+
+<div class="flex flex-col h-screen overflow-hidden bg-base-100 text-base-content">
+ <Header />
+ <div class="flex-1 overflow-hidden">
+ <ChatPanel />
+ </div>
+ <ChatInput />
+</div>
diff --git a/packages/frontend/src/app.css b/packages/frontend/src/app.css
new file mode 100644
index 0000000..5602e1f
--- /dev/null
+++ b/packages/frontend/src/app.css
@@ -0,0 +1,4 @@
+@import "tailwindcss";
+@plugin "daisyui" {
+ themes: light, dark, dracula, night, nord, sunset, cyberpunk, forest, cmyk, coffee, caramellatte;
+}
diff --git a/packages/frontend/src/lib/chat.svelte.ts b/packages/frontend/src/lib/chat.svelte.ts
new file mode 100644
index 0000000..54216ec
--- /dev/null
+++ b/packages/frontend/src/lib/chat.svelte.ts
@@ -0,0 +1,274 @@
+import { config } from "./config.js";
+import type { AgentEvent, ChatMessage, DebugInfo, ToolCallDisplay } from "./types.js";
+import { wsClient } from "./ws.svelte.js";
+
+function generateId() {
+ return Math.random().toString(36).slice(2, 11);
+}
+
+function makeDebugInfo(overrides: Partial<DebugInfo> = {}): DebugInfo {
+ return {
+ timestamp: new Date().toISOString(),
+ model: "deepseek-v4-flash-free",
+ apiBase: config.apiBase,
+ connectionStatus: wsClient.connectionStatus,
+ ...overrides,
+ };
+}
+
+function formatConversation(msgs: ChatMessage[]): string {
+ const lines: string[] = [];
+ lines.push("=== Dispatch Conversation ===");
+ lines.push(`Exported: ${new Date().toISOString()}`);
+ lines.push("");
+
+ for (const msg of msgs) {
+ const role = msg.role === "user" ? "User" : "Assistant";
+ lines.push(`--- ${role} ---`);
+ lines.push(msg.content);
+
+ if (msg.toolCalls && msg.toolCalls.length > 0) {
+ for (const tc of msg.toolCalls) {
+ lines.push(` [Tool: ${tc.name}]`);
+ lines.push(` Args: ${JSON.stringify(tc.arguments)}`);
+ if (tc.result !== undefined) {
+ const prefix = tc.isError ? " Error: " : " Result: ";
+ lines.push(`${prefix}${tc.result}`);
+ }
+ }
+ }
+
+ if (msg.debugInfo) {
+ lines.push(" [Debug Info]");
+ lines.push(` Timestamp: ${msg.debugInfo.timestamp}`);
+ if (msg.debugInfo.error) lines.push(` Error: ${msg.debugInfo.error}`);
+ if (msg.debugInfo.model) lines.push(` Model: ${msg.debugInfo.model}`);
+ if (msg.debugInfo.apiBase) lines.push(` API Base: ${msg.debugInfo.apiBase}`);
+ if (msg.debugInfo.connectionStatus)
+ lines.push(` Connection: ${msg.debugInfo.connectionStatus}`);
+ if (msg.debugInfo.agentStatus) lines.push(` Agent Status: ${msg.debugInfo.agentStatus}`);
+ if (msg.debugInfo.httpStatus) lines.push(` HTTP Status: ${msg.debugInfo.httpStatus}`);
+ if (msg.debugInfo.httpBody) lines.push(` HTTP Body: ${msg.debugInfo.httpBody}`);
+ if (msg.debugInfo.rawEvent)
+ lines.push(` Raw Event: ${JSON.stringify(msg.debugInfo.rawEvent)}`);
+ }
+
+ lines.push("");
+ }
+
+ return lines.join("\n");
+}
+
+function createChatStore() {
+ let messages: ChatMessage[] = $state([]);
+ let agentStatus: "idle" | "running" | "error" = $state("idle");
+ let isConnected = $state(false);
+ let currentAssistantId: string | null = null;
+
+ wsClient.onEvent((event) => {
+ const connected = wsClient.connectionStatus === "connected";
+ if (connected !== isConnected) {
+ isConnected = connected;
+ }
+ handleEvent(event);
+ });
+
+ $effect.root(() => {
+ $effect(() => {
+ isConnected = wsClient.connectionStatus === "connected";
+ });
+ });
+
+ function getCurrentAssistantMessage(): ChatMessage | null {
+ if (!currentAssistantId) return null;
+ return messages.find((m) => m.id === currentAssistantId) ?? null;
+ }
+
+ function ensureCurrentAssistantMessage(): ChatMessage {
+ let msg = getCurrentAssistantMessage();
+ if (!msg) {
+ const id = generateId();
+ currentAssistantId = id;
+ const newMsg: ChatMessage = {
+ id,
+ role: "assistant",
+ content: "",
+ toolCalls: [],
+ isStreaming: true,
+ };
+ messages = [...messages, newMsg];
+ msg = newMsg;
+ }
+ return msg;
+ }
+
+ function handleEvent(event: AgentEvent) {
+ switch (event.type) {
+ case "status": {
+ agentStatus = event.status;
+ if (event.status === "idle" || event.status === "error") {
+ currentAssistantId = null;
+ }
+ break;
+ }
+ case "text-delta": {
+ ensureCurrentAssistantMessage();
+ messages = messages.map((m) => {
+ if (m.id === currentAssistantId) {
+ return {
+ ...m,
+ content: m.content + event.delta,
+ isStreaming: true,
+ };
+ }
+ return m;
+ });
+ break;
+ }
+ case "tool-call": {
+ ensureCurrentAssistantMessage();
+ const toolCall: ToolCallDisplay = {
+ id: event.toolCall.id,
+ name: event.toolCall.name,
+ arguments: event.toolCall.arguments,
+ isExpanded: false,
+ };
+ messages = messages.map((m) => {
+ if (m.id === currentAssistantId) {
+ return { ...m, toolCalls: [...(m.toolCalls ?? []), toolCall] };
+ }
+ return m;
+ });
+ break;
+ }
+ case "tool-result": {
+ messages = messages.map((m) => {
+ if (m.id === currentAssistantId) {
+ return {
+ ...m,
+ toolCalls: (m.toolCalls ?? []).map((tc) => {
+ if (tc.id === event.toolResult.toolCallId) {
+ return {
+ ...tc,
+ result: event.toolResult.result,
+ isError: event.toolResult.isError,
+ };
+ }
+ return tc;
+ }),
+ };
+ }
+ return m;
+ });
+ break;
+ }
+ case "done": {
+ messages = messages.map((m) => {
+ if (m.id === currentAssistantId) {
+ return {
+ ...m,
+ content: event.message.content,
+ isStreaming: false,
+ };
+ }
+ return m;
+ });
+ currentAssistantId = null;
+ break;
+ }
+ case "error": {
+ const errMsg: ChatMessage = {
+ id: generateId(),
+ role: "assistant",
+ content: `Error: ${event.error}`,
+ isStreaming: false,
+ debugInfo: makeDebugInfo({
+ error: event.error,
+ agentStatus,
+ rawEvent: event,
+ }),
+ };
+ messages = [...messages, errMsg];
+ currentAssistantId = null;
+ agentStatus = "error";
+ break;
+ }
+ }
+ }
+
+ async function sendMessage(text: string) {
+ const userMsg: ChatMessage = {
+ id: generateId(),
+ role: "user",
+ content: text,
+ };
+ messages = [...messages, userMsg];
+ currentAssistantId = null;
+
+ const url = `${config.apiBase}/chat`;
+ try {
+ const res = await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: text }),
+ });
+ if (!res.ok) {
+ const body = await res.text();
+ const errMsg: ChatMessage = {
+ id: generateId(),
+ role: "assistant",
+ content: `Error: Failed to send message (HTTP ${res.status})`,
+ isStreaming: false,
+ debugInfo: makeDebugInfo({
+ error: `POST ${url} returned ${res.status}`,
+ agentStatus,
+ httpStatus: res.status,
+ httpBody: body,
+ }),
+ };
+ messages = [...messages, errMsg];
+ }
+ } catch (err) {
+ const errorText = err instanceof Error ? err.message : String(err);
+ const errMsg: ChatMessage = {
+ id: generateId(),
+ role: "assistant",
+ content: "Error: Could not reach the server",
+ isStreaming: false,
+ debugInfo: makeDebugInfo({
+ error: `POST ${url} failed: ${errorText}`,
+ agentStatus,
+ }),
+ };
+ messages = [...messages, errMsg];
+ }
+ }
+
+ function copyConversation(): string {
+ return formatConversation(messages);
+ }
+
+ function clear() {
+ messages = [];
+ currentAssistantId = null;
+ agentStatus = "idle";
+ }
+
+ return {
+ get messages() {
+ return messages;
+ },
+ get agentStatus() {
+ return agentStatus;
+ },
+ get isConnected() {
+ return isConnected;
+ },
+ sendMessage,
+ handleEvent,
+ copyConversation,
+ clear,
+ };
+}
+
+export const chatStore = createChatStore();
diff --git a/packages/frontend/src/lib/components/ChatInput.svelte b/packages/frontend/src/lib/components/ChatInput.svelte
new file mode 100644
index 0000000..b929923
--- /dev/null
+++ b/packages/frontend/src/lib/components/ChatInput.svelte
@@ -0,0 +1,45 @@
+<script lang="ts">
+import { chatStore } from "../chat.svelte.js";
+
+let inputEl: HTMLInputElement | undefined;
+let inputValue = $state("");
+const isDisabled = $derived(chatStore.agentStatus === "running");
+
+$effect(() => {
+ inputEl?.focus();
+});
+
+function handleKeydown(e: KeyboardEvent) {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ submit();
+ }
+}
+
+function submit() {
+ const text = inputValue.trim();
+ if (!text || isDisabled) return;
+ inputValue = "";
+ chatStore.sendMessage(text);
+}
+</script>
+
+<div class="flex items-center gap-2 p-3 border-t border-base-300 bg-base-100">
+ <input
+ bind:this={inputEl}
+ bind:value={inputValue}
+ type="text"
+ placeholder={isDisabled ? "Agent is running..." : "Type a message..."}
+ class="input input-bordered flex-1"
+ disabled={isDisabled}
+ onkeydown={handleKeydown}
+ />
+ <button
+ type="button"
+ class="btn btn-primary"
+ disabled={isDisabled || !inputValue.trim()}
+ onclick={submit}
+ >
+ Send
+ </button>
+</div>
diff --git a/packages/frontend/src/lib/components/ChatMessage.svelte b/packages/frontend/src/lib/components/ChatMessage.svelte
new file mode 100644
index 0000000..447bb29
--- /dev/null
+++ b/packages/frontend/src/lib/components/ChatMessage.svelte
@@ -0,0 +1,26 @@
+<script lang="ts">
+import type { ChatMessage } from "../types.js";
+import ToolCallDisplay from "./ToolCallDisplay.svelte";
+
+const { message }: { message: ChatMessage } = $props();
+
+const isUser = $derived(message.role === "user");
+</script>
+
+<div class="chat {isUser ? 'chat-end' : 'chat-start'} mb-2">
+ <div class="chat-bubble {isUser ? 'chat-bubble-primary' : 'chat-bubble-secondary'} max-w-[80%] break-words">
+ {#if message.content}
+ <span>{message.content}</span>
+ {/if}
+ {#if message.isStreaming}
+ <span class="inline-block w-2 h-4 bg-current animate-pulse ml-0.5 align-middle">▌</span>
+ {/if}
+ {#if message.toolCalls && message.toolCalls.length > 0}
+ <div class="mt-2">
+ {#each message.toolCalls as toolCall (toolCall.id)}
+ <ToolCallDisplay {toolCall} />
+ {/each}
+ </div>
+ {/if}
+ </div>
+</div>
diff --git a/packages/frontend/src/lib/components/ChatPanel.svelte b/packages/frontend/src/lib/components/ChatPanel.svelte
new file mode 100644
index 0000000..44efb0b
--- /dev/null
+++ b/packages/frontend/src/lib/components/ChatPanel.svelte
@@ -0,0 +1,58 @@
+<script lang="ts">
+import { chatStore } from "../chat.svelte.js";
+import { wsClient } from "../ws.svelte.js";
+import ChatMessageComponent from "./ChatMessage.svelte";
+
+let messagesEl: HTMLDivElement | undefined;
+
+const statusColor = $derived(
+ wsClient.connectionStatus === "connected"
+ ? "bg-success"
+ : wsClient.connectionStatus === "connecting"
+ ? "bg-warning"
+ : "bg-error",
+);
+
+$effect(() => {
+ // Trigger on messages change to scroll
+ void chatStore.messages;
+ if (messagesEl) {
+ messagesEl.scrollTop = messagesEl.scrollHeight;
+ }
+});
+</script>
+
+<div class="flex flex-col h-full">
+ <!-- Status bar -->
+ <div class="flex items-center gap-3 px-4 py-2 bg-base-200 border-b border-base-300 text-xs">
+ <span class="flex items-center gap-1.5">
+ <span class="w-2 h-2 rounded-full {statusColor}"></span>
+ <span class="capitalize text-base-content/70">{wsClient.connectionStatus}</span>
+ </span>
+ <span class="text-base-content/50">|</span>
+ <span class="text-base-content/70">
+ Agent:
+ <span
+ class="font-semibold {chatStore.agentStatus === 'running'
+ ? 'text-warning'
+ : chatStore.agentStatus === 'error'
+ ? 'text-error'
+ : 'text-success'}"
+ >
+ {chatStore.agentStatus === "running" ? "running..." : chatStore.agentStatus}
+ </span>
+ </span>
+ </div>
+
+ <!-- Messages -->
+ <div bind:this={messagesEl} class="flex-1 overflow-y-auto p-4">
+ {#if chatStore.messages.length === 0}
+ <div class="flex items-center justify-center h-full text-base-content/40 text-sm">
+ Send a message to start a conversation
+ </div>
+ {/if}
+ {#each chatStore.messages as message (message.id)}
+ <ChatMessageComponent {message} />
+ {/each}
+ </div>
+</div>
diff --git a/packages/frontend/src/lib/components/Header.svelte b/packages/frontend/src/lib/components/Header.svelte
new file mode 100644
index 0000000..79d371c
--- /dev/null
+++ b/packages/frontend/src/lib/components/Header.svelte
@@ -0,0 +1,54 @@
+<script lang="ts">
+import { chatStore } from "../chat.svelte.js";
+import ThemeSwitcher from "./ThemeSwitcher.svelte";
+
+let showThemeSwitcher = $state(false);
+let copyLabel = $state("Copy");
+
+function resetCopyLabel() {
+ copyLabel = "Copy";
+}
+
+async function handleCopy() {
+ const text = chatStore.copyConversation();
+ try {
+ await navigator.clipboard.writeText(text);
+ copyLabel = "Copied";
+ setTimeout(resetCopyLabel, 1500);
+ } catch {
+ copyLabel = "Failed";
+ setTimeout(resetCopyLabel, 1500);
+ }
+}
+</script>
+
+<header class="navbar bg-base-200 border-b border-base-300 px-4 min-h-14 flex-shrink-0">
+ <div class="flex-1">
+ <span class="text-xl font-bold tracking-tight">Dispatch</span>
+ </div>
+ <div class="flex-none flex items-center gap-3">
+ <span class="text-xs text-base-content/60 hidden sm:block">
+ DeepSeek V4 Flash via OpenCode Go
+ </span>
+ <button
+ type="button"
+ class="btn btn-ghost btn-sm"
+ onclick={handleCopy}
+ aria-label="Copy conversation"
+ >
+ {copyLabel}
+ </button>
+ <button
+ type="button"
+ class="btn btn-ghost btn-sm"
+ onclick={() => (showThemeSwitcher = !showThemeSwitcher)}
+ aria-label="Switch theme"
+ >
+ Theme
+ </button>
+ </div>
+</header>
+
+{#if showThemeSwitcher}
+ <ThemeSwitcher onclose={() => (showThemeSwitcher = false)} />
+{/if}
diff --git a/packages/frontend/src/lib/components/ThemeSwitcher.svelte b/packages/frontend/src/lib/components/ThemeSwitcher.svelte
new file mode 100644
index 0000000..6984e3f
--- /dev/null
+++ b/packages/frontend/src/lib/components/ThemeSwitcher.svelte
@@ -0,0 +1,65 @@
+<script lang="ts">
+const THEMES = [
+ "light",
+ "dark",
+ "dracula",
+ "night",
+ "nord",
+ "sunset",
+ "cyberpunk",
+ "forest",
+ "cmyk",
+ "coffee",
+ "caramellatte",
+] as const;
+
+const STORAGE_KEY = "dispatch-theme";
+
+const { onclose }: { onclose: () => void } = $props();
+
+let currentTheme = $state(
+ (typeof localStorage !== "undefined" && localStorage.getItem(STORAGE_KEY)) || "dark",
+);
+
+function selectTheme(theme: string) {
+ currentTheme = theme;
+ document.documentElement.setAttribute("data-theme", theme);
+ localStorage.setItem(STORAGE_KEY, theme);
+ onclose();
+}
+</script>
+
+<!-- Backdrop -->
+<div
+ class="fixed inset-0 z-40 bg-black/40"
+ role="button"
+ tabindex="0"
+ onclick={onclose}
+ onkeydown={(e) => e.key === "Escape" && onclose()}
+ aria-label="Close theme switcher"
+></div>
+
+<!-- Modal -->
+<div
+ class="fixed top-16 right-4 z-50 bg-base-100 border border-base-300 rounded-xl shadow-xl p-4 w-56"
+ role="dialog"
+ aria-label="Theme switcher"
+>
+ <p class="text-sm font-semibold mb-3 text-base-content">Select Theme</p>
+ <ul class="space-y-1">
+ {#each THEMES as theme}
+ <li>
+ <button
+ type="button"
+ class="w-full text-left px-3 py-1.5 rounded-lg text-sm capitalize hover:bg-base-200 transition-colors {currentTheme ===
+ theme
+ ? 'bg-primary text-primary-content'
+ : ''}"
+ onclick={() => selectTheme(theme)}
+ >
+ {theme}
+ </button>
+ </li>
+ {/each}
+ </ul>
+</div>
diff --git a/packages/frontend/src/lib/components/ToolCallDisplay.svelte b/packages/frontend/src/lib/components/ToolCallDisplay.svelte
new file mode 100644
index 0000000..f8e1f38
--- /dev/null
+++ b/packages/frontend/src/lib/components/ToolCallDisplay.svelte
@@ -0,0 +1,50 @@
+<script lang="ts">
+import type { ToolCallDisplay } from "../types.js";
+
+const { toolCall }: { toolCall: ToolCallDisplay } = $props();
+
+let isExpanded = $state(toolCall.isExpanded);
+
+function toggle() {
+ isExpanded = !isExpanded;
+}
+</script>
+
+<div class="collapse collapse-arrow bg-base-200 my-1 rounded-lg border border-base-300">
+ <button
+ type="button"
+ class="collapse-title flex items-center gap-2 text-sm font-medium cursor-pointer w-full text-left"
+ onclick={toggle}
+ aria-expanded={isExpanded}
+ >
+ <span class="badge badge-neutral badge-sm">tool</span>
+ <span class="font-mono">{toolCall.name}</span>
+ {#if toolCall.result !== undefined}
+ {#if toolCall.isError}
+ <span class="badge badge-error badge-sm ml-auto">error</span>
+ {:else}
+ <span class="badge badge-success badge-sm ml-auto">done</span>
+ {/if}
+ {:else}
+ <span class="badge badge-warning badge-sm ml-auto">pending</span>
+ {/if}
+ </button>
+
+ {#if isExpanded}
+ <div class="collapse-content text-xs">
+ <div class="mt-2">
+ <p class="font-semibold text-base-content/70 mb-1">Arguments</p>
+ <pre class="bg-base-300 rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all">{JSON.stringify(toolCall.arguments, null, 2)}</pre>
+ </div>
+ {#if toolCall.result !== undefined}
+ <div class="mt-2">
+ <p class="font-semibold text-base-content/70 mb-1">Result</p>
+ <pre
+ class="rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all {toolCall.isError
+ ? 'bg-error/20 text-error'
+ : 'bg-base-300'}">{toolCall.result}</pre>
+ </div>
+ {/if}
+ </div>
+ {/if}
+</div>
diff --git a/packages/frontend/src/lib/config.ts b/packages/frontend/src/lib/config.ts
new file mode 100644
index 0000000..c22746c
--- /dev/null
+++ b/packages/frontend/src/lib/config.ts
@@ -0,0 +1,6 @@
+const API_BASE = import.meta.env.VITE_API_URL ?? "http://localhost:3000";
+
+export const config = {
+ apiBase: API_BASE,
+ wsUrl: `${API_BASE.replace(/^http/, "ws")}/ws`,
+} as const;
diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts
new file mode 100644
index 0000000..6edb550
--- /dev/null
+++ b/packages/frontend/src/lib/types.ts
@@ -0,0 +1,57 @@
+export interface ToolCallDisplay {
+ id: string;
+ name: string;
+ arguments: Record<string, unknown>;
+ result?: string;
+ isError?: boolean;
+ isExpanded: boolean;
+}
+
+export interface DebugInfo {
+ timestamp: string;
+ error?: string;
+ model?: string;
+ apiBase?: string;
+ connectionStatus?: string;
+ agentStatus?: string;
+ rawEvent?: unknown;
+ httpStatus?: number;
+ httpBody?: string;
+}
+
+export interface ChatMessage {
+ id: string;
+ role: "user" | "assistant";
+ content: string;
+ toolCalls?: ToolCallDisplay[];
+ isStreaming?: boolean;
+ debugInfo?: DebugInfo;
+}
+
+export type ConnectionStatus = "connecting" | "connected" | "disconnected";
+
+export type AgentEvent =
+ | { type: "status"; status: "idle" | "running" | "error" }
+ | { type: "text-delta"; delta: string }
+ | {
+ type: "tool-call";
+ toolCall: {
+ id: string;
+ name: string;
+ arguments: Record<string, unknown>;
+ };
+ }
+ | {
+ type: "tool-result";
+ toolResult: { toolCallId: string; result: string; isError: boolean };
+ }
+ | { type: "error"; error: string }
+ | {
+ type: "done";
+ message: {
+ role: string;
+ content: string;
+ toolCalls?: unknown[];
+ toolResults?: unknown[];
+ };
+ };
diff --git a/packages/frontend/src/lib/ws.svelte.ts b/packages/frontend/src/lib/ws.svelte.ts
new file mode 100644
index 0000000..76c7ef5
--- /dev/null
+++ b/packages/frontend/src/lib/ws.svelte.ts
@@ -0,0 +1,89 @@
+import { config } from "./config.js";
+import type { AgentEvent, ConnectionStatus } from "./types.js";
+
+type EventCallback = (event: AgentEvent) => void;
+
+function createWebSocketClient(url: string) {
+ let connectionStatus: ConnectionStatus = $state("disconnected");
+ let ws: WebSocket | null = null;
+ let reconnectDelay = 1000;
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
+ let manualDisconnect = false;
+ const callbacks: EventCallback[] = [];
+
+ function connect() {
+ if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
+ return;
+ }
+ manualDisconnect = false;
+ connectionStatus = "connecting";
+ ws = new WebSocket(url);
+
+ ws.onopen = () => {
+ connectionStatus = "connected";
+ reconnectDelay = 1000;
+ };
+
+ ws.onmessage = (event: MessageEvent) => {
+ try {
+ const data = JSON.parse(event.data as string) as AgentEvent;
+ for (const cb of callbacks) {
+ cb(data);
+ }
+ } catch {
+ // ignore malformed messages
+ }
+ };
+
+ ws.onclose = () => {
+ connectionStatus = "disconnected";
+ ws = null;
+ if (!manualDisconnect) {
+ scheduleReconnect();
+ }
+ };
+
+ ws.onerror = () => {
+ ws?.close();
+ };
+ }
+
+ function scheduleReconnect() {
+ if (reconnectTimer) clearTimeout(reconnectTimer);
+ reconnectTimer = setTimeout(() => {
+ reconnectTimer = null;
+ connect();
+ }, reconnectDelay);
+ reconnectDelay = Math.min(reconnectDelay * 2, 10000);
+ }
+
+ function disconnect() {
+ manualDisconnect = true;
+ if (reconnectTimer) {
+ clearTimeout(reconnectTimer);
+ reconnectTimer = null;
+ }
+ ws?.close();
+ ws = null;
+ connectionStatus = "disconnected";
+ }
+
+ function onEvent(callback: EventCallback) {
+ callbacks.push(callback);
+ return () => {
+ const idx = callbacks.indexOf(callback);
+ if (idx !== -1) callbacks.splice(idx, 1);
+ };
+ }
+
+ return {
+ get connectionStatus() {
+ return connectionStatus;
+ },
+ connect,
+ disconnect,
+ onEvent,
+ };
+}
+
+export const wsClient = createWebSocketClient(config.wsUrl);
diff --git a/packages/frontend/src/main.ts b/packages/frontend/src/main.ts
new file mode 100644
index 0000000..fd54362
--- /dev/null
+++ b/packages/frontend/src/main.ts
@@ -0,0 +1,9 @@
+import App from "./App.svelte";
+import "./app.css";
+import { mount } from "svelte";
+
+const app = mount(App, {
+ target: document.getElementById("app") as HTMLElement,
+});
+
+export default app;
diff --git a/packages/frontend/src/vite-env.d.ts b/packages/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..4078e74
--- /dev/null
+++ b/packages/frontend/src/vite-env.d.ts
@@ -0,0 +1,2 @@
+/// <reference types="svelte" />
+/// <reference types="vite/client" />
diff --git a/packages/frontend/svelte.config.js b/packages/frontend/svelte.config.js
new file mode 100644
index 0000000..f77d881
--- /dev/null
+++ b/packages/frontend/svelte.config.js
@@ -0,0 +1,5 @@
+import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
+
+export default {
+ preprocess: vitePreprocess(),
+};
diff --git a/packages/frontend/tests/chat-store.test.ts b/packages/frontend/tests/chat-store.test.ts
new file mode 100644
index 0000000..de988a2
--- /dev/null
+++ b/packages/frontend/tests/chat-store.test.ts
@@ -0,0 +1,261 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import type { AgentEvent } from "../src/lib/types.js";
+
+// We test the logic inline since runes require svelte compilation context.
+// The chat store logic is tested via a plain reimplementation of the same logic.
+
+function generateId() {
+ return Math.random().toString(36).slice(2, 11);
+}
+
+interface ToolCallDisplay {
+ id: string;
+ name: string;
+ arguments: Record<string, unknown>;
+ result?: string;
+ isError?: boolean;
+ isExpanded: boolean;
+}
+
+interface ChatMessage {
+ id: string;
+ role: "user" | "assistant";
+ content: string;
+ toolCalls?: ToolCallDisplay[];
+ isStreaming?: boolean;
+}
+
+// Plain JS version of the chat store logic (no runes) for unit testing
+function createTestStore() {
+ let messages: ChatMessage[] = [];
+ let agentStatus: "idle" | "running" | "error" = "idle";
+ let currentAssistantId: string | null = null;
+
+ function getCurrentAssistantMessage(): ChatMessage | null {
+ if (!currentAssistantId) return null;
+ return messages.find((m) => m.id === currentAssistantId) ?? null;
+ }
+
+ function ensureCurrentAssistantMessage(): ChatMessage {
+ let msg = getCurrentAssistantMessage();
+ if (!msg) {
+ const id = generateId();
+ currentAssistantId = id;
+ const newMsg: ChatMessage = {
+ id,
+ role: "assistant",
+ content: "",
+ toolCalls: [],
+ isStreaming: true,
+ };
+ messages = [...messages, newMsg];
+ msg = newMsg;
+ }
+ return msg;
+ }
+
+ function handleEvent(event: AgentEvent) {
+ switch (event.type) {
+ case "status": {
+ agentStatus = event.status;
+ if (event.status === "idle" || event.status === "error") {
+ currentAssistantId = null;
+ }
+ break;
+ }
+ case "text-delta": {
+ ensureCurrentAssistantMessage();
+ messages = messages.map((m) => {
+ if (m.id === currentAssistantId) {
+ return {
+ ...m,
+ content: m.content + event.delta,
+ isStreaming: true,
+ };
+ }
+ return m;
+ });
+ break;
+ }
+ case "tool-call": {
+ ensureCurrentAssistantMessage();
+ const toolCall: ToolCallDisplay = {
+ id: event.toolCall.id,
+ name: event.toolCall.name,
+ arguments: event.toolCall.arguments,
+ isExpanded: false,
+ };
+ messages = messages.map((m) => {
+ if (m.id === currentAssistantId) {
+ return { ...m, toolCalls: [...(m.toolCalls ?? []), toolCall] };
+ }
+ return m;
+ });
+ break;
+ }
+ case "tool-result": {
+ messages = messages.map((m) => {
+ if (m.id === currentAssistantId) {
+ return {
+ ...m,
+ toolCalls: (m.toolCalls ?? []).map((tc) => {
+ if (tc.id === event.toolResult.toolCallId) {
+ return {
+ ...tc,
+ result: event.toolResult.result,
+ isError: event.toolResult.isError,
+ };
+ }
+ return tc;
+ }),
+ };
+ }
+ return m;
+ });
+ break;
+ }
+ case "done": {
+ messages = messages.map((m) => {
+ if (m.id === currentAssistantId) {
+ return { ...m, content: event.message.content, isStreaming: false };
+ }
+ return m;
+ });
+ currentAssistantId = null;
+ break;
+ }
+ case "error": {
+ messages = [
+ ...messages,
+ {
+ id: generateId(),
+ role: "assistant",
+ content: `Error: ${event.error}`,
+ isStreaming: false,
+ },
+ ];
+ currentAssistantId = null;
+ agentStatus = "error";
+ break;
+ }
+ }
+ }
+
+ function sendMessage(text: string) {
+ const userMsg: ChatMessage = {
+ id: generateId(),
+ role: "user",
+ content: text,
+ };
+ messages = [...messages, userMsg];
+ currentAssistantId = null;
+ }
+
+ function clear() {
+ messages = [];
+ currentAssistantId = null;
+ agentStatus = "idle";
+ }
+
+ return {
+ get messages() {
+ return messages;
+ },
+ get agentStatus() {
+ return agentStatus;
+ },
+ handleEvent,
+ sendMessage,
+ clear,
+ };
+}
+
+describe("chat store logic", () => {
+ let store: ReturnType<typeof createTestStore>;
+
+ beforeEach(() => {
+ store = createTestStore();
+ });
+
+ it("has correct initial state", () => {
+ expect(store.messages).toHaveLength(0);
+ expect(store.agentStatus).toBe("idle");
+ });
+
+ it("sendMessage adds a user message", () => {
+ store.sendMessage("hello");
+ expect(store.messages).toHaveLength(1);
+ expect(store.messages[0]?.role).toBe("user");
+ expect(store.messages[0]?.content).toBe("hello");
+ });
+
+ it("text-delta creates a streaming assistant message and appends deltas", () => {
+ store.handleEvent({ type: "text-delta", delta: "Hello" });
+ expect(store.messages).toHaveLength(1);
+ expect(store.messages[0]?.role).toBe("assistant");
+ expect(store.messages[0]?.content).toBe("Hello");
+ expect(store.messages[0]?.isStreaming).toBe(true);
+
+ store.handleEvent({ type: "text-delta", delta: " world" });
+ expect(store.messages[0]?.content).toBe("Hello world");
+ });
+
+ it("tool-call adds to current assistant message toolCalls", () => {
+ store.handleEvent({ type: "text-delta", delta: "Calling tool..." });
+ store.handleEvent({
+ type: "tool-call",
+ toolCall: { id: "tc1", name: "search", arguments: { query: "test" } },
+ });
+ const msg = store.messages[0];
+ expect(msg?.toolCalls).toHaveLength(1);
+ expect(msg?.toolCalls?.[0]?.name).toBe("search");
+ expect(msg?.toolCalls?.[0]?.id).toBe("tc1");
+ });
+
+ it("tool-result fills in result on matching tool call", () => {
+ store.handleEvent({ type: "text-delta", delta: "..." });
+ store.handleEvent({
+ type: "tool-call",
+ toolCall: { id: "tc1", name: "search", arguments: { query: "x" } },
+ });
+ store.handleEvent({
+ type: "tool-result",
+ toolResult: { toolCallId: "tc1", result: "found it", isError: false },
+ });
+ const tc = store.messages[0]?.toolCalls?.[0];
+ expect(tc?.result).toBe("found it");
+ expect(tc?.isError).toBe(false);
+ });
+
+ it("done finalizes the current assistant message", () => {
+ store.handleEvent({ type: "text-delta", delta: "partial" });
+ store.handleEvent({
+ type: "done",
+ message: { role: "assistant", content: "full content" },
+ });
+ expect(store.messages[0]?.content).toBe("full content");
+ expect(store.messages[0]?.isStreaming).toBe(false);
+ });
+
+ it("error event adds an error message and sets status to error", () => {
+ store.handleEvent({ type: "error", error: "something went wrong" });
+ expect(store.messages).toHaveLength(1);
+ expect(store.messages[0]?.content).toBe("Error: something went wrong");
+ expect(store.agentStatus).toBe("error");
+ });
+
+ it("status event updates agentStatus", () => {
+ store.handleEvent({ type: "status", status: "running" });
+ expect(store.agentStatus).toBe("running");
+ store.handleEvent({ type: "status", status: "idle" });
+ expect(store.agentStatus).toBe("idle");
+ });
+
+ it("clear resets all state", () => {
+ store.sendMessage("hi");
+ store.handleEvent({ type: "text-delta", delta: "hello" });
+ store.clear();
+ expect(store.messages).toHaveLength(0);
+ expect(store.agentStatus).toBe("idle");
+ });
+});
diff --git a/packages/frontend/tsconfig.json b/packages/frontend/tsconfig.json
new file mode 100644
index 0000000..386cd8c
--- /dev/null
+++ b/packages/frontend/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "declaration": false,
+ "declarationMap": false,
+ "types": ["vite/client"]
+ },
+ "include": ["src/**/*.ts", "src/**/*.svelte", "src/vite-env.d.ts"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts
new file mode 100644
index 0000000..3604d5e
--- /dev/null
+++ b/packages/frontend/vite.config.ts
@@ -0,0 +1,13 @@
+import { svelte } from "@sveltejs/vite-plugin-svelte";
+import tailwindcss from "@tailwindcss/vite";
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ plugins: [tailwindcss(), svelte()],
+ server: {
+ port: 5173,
+ },
+ test: {
+ include: ["tests/**/*.test.ts"],
+ },
+});