summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rwxr-xr-xbin/up9
-rw-r--r--docker-compose.yml2
-rw-r--r--packages/api/src/agent-manager.ts61
-rw-r--r--packages/api/src/app.ts23
-rw-r--r--packages/api/src/index.ts8
-rw-r--r--packages/api/src/permission-manager.ts2
-rw-r--r--packages/api/src/routes/agents.ts109
-rw-r--r--packages/api/src/routes/config.ts2
-rw-r--r--packages/api/src/routes/models.ts29
-rw-r--r--packages/api/src/routes/skills.ts11
-rw-r--r--packages/api/src/routes/tabs.ts28
-rw-r--r--packages/core/src/agent/agent.ts147
-rw-r--r--packages/core/src/agents/index.ts1
-rw-r--r--packages/core/src/agents/loader.ts212
-rw-r--r--packages/core/src/config/loader.ts4
-rw-r--r--packages/core/src/config/schema.ts25
-rw-r--r--packages/core/src/config/watcher.ts14
-rw-r--r--packages/core/src/credentials/api-keys.ts19
-rw-r--r--packages/core/src/credentials/claude.ts95
-rw-r--r--packages/core/src/credentials/index.ts26
-rw-r--r--packages/core/src/credentials/opencode.ts9
-rw-r--r--packages/core/src/credentials/store.ts26
-rw-r--r--packages/core/src/db/messages.ts26
-rw-r--r--packages/core/src/db/settings.ts4
-rw-r--r--packages/core/src/index.ts1
-rw-r--r--packages/core/src/llm/provider.ts11
-rw-r--r--packages/core/src/models/registry.ts8
-rw-r--r--packages/core/src/skills/index.ts6
-rw-r--r--packages/core/src/skills/loader.ts140
-rw-r--r--packages/core/src/skills/parser.ts4
-rw-r--r--packages/core/src/tools/bash-arity.ts60
-rw-r--r--packages/core/src/tools/run-shell.ts14
-rw-r--r--packages/core/src/tools/shell-analyze.ts29
-rw-r--r--packages/core/src/types/index.ts28
-rw-r--r--packages/core/tests/config/loader.test.ts2
-rw-r--r--packages/core/tests/llm/provider.test.ts40
-rw-r--r--packages/frontend/src/App.svelte24
-rw-r--r--packages/frontend/src/app.css61
-rw-r--r--packages/frontend/src/lib/components/AgentBuilder.svelte746
-rw-r--r--packages/frontend/src/lib/components/ChatInput.svelte4
-rw-r--r--packages/frontend/src/lib/components/ChatMessage.svelte2
-rw-r--r--packages/frontend/src/lib/components/ChatPanel.svelte74
-rw-r--r--packages/frontend/src/lib/components/ClaudeReset.svelte247
-rw-r--r--packages/frontend/src/lib/components/ConfigPanel.svelte177
-rw-r--r--packages/frontend/src/lib/components/Header.svelte3
-rw-r--r--packages/frontend/src/lib/components/HotReloadIndicator.svelte38
-rw-r--r--packages/frontend/src/lib/components/KeyUsage.svelte320
-rw-r--r--packages/frontend/src/lib/components/MarkdownRenderer.svelte308
-rw-r--r--packages/frontend/src/lib/components/ModelSelector.svelte202
-rw-r--r--packages/frontend/src/lib/components/ModelStatus.svelte254
-rw-r--r--packages/frontend/src/lib/components/PermissionPrompt.svelte5
-rw-r--r--packages/frontend/src/lib/components/SettingsPanel.svelte201
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte44
-rw-r--r--packages/frontend/src/lib/components/SkillsBrowser.svelte247
-rw-r--r--packages/frontend/src/lib/components/SystemPromptPanel.svelte66
-rw-r--r--packages/frontend/src/lib/components/TabBar.svelte5
-rw-r--r--packages/frontend/src/lib/components/TaskListPanel.svelte11
-rw-r--r--packages/frontend/src/lib/components/ThemeSwitcher.svelte16
-rw-r--r--packages/frontend/src/lib/components/ToolPermissions.svelte99
-rw-r--r--packages/frontend/src/lib/router.svelte.ts12
-rw-r--r--packages/frontend/src/lib/tabs.svelte.ts159
-rw-r--r--packages/frontend/tests/chat-store.test.ts32
62 files changed, 3218 insertions, 1374 deletions
diff --git a/bin/up b/bin/up
index 2cccfa8..2e67857 100755
--- a/bin/up
+++ b/bin/up
@@ -1,20 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
-# Force GPG to use terminal-based pinentry (required for SSH sessions)
-export GPG_TTY=$(tty)
-
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
-# Load secrets from gopass
-OPENCODE_API_KEY="$(gopass show -o projects/ai-api/opencode_go_key)"
-
# Pass host user identity so the container runs as the same UID/GID
export HOST_UID="$(id -u)"
export HOST_GID="$(id -g)"
export HOST_USER="$(whoami)"
# Start all services
-OPENCODE_API_KEY="$OPENCODE_API_KEY" \
- docker compose -f "$PROJECT_DIR/docker-compose.yml" up "$@"
+docker compose -f "$PROJECT_DIR/docker-compose.yml" up "$@"
diff --git a/docker-compose.yml b/docker-compose.yml
index 2ce08a8..d8514da 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -10,6 +10,8 @@ services:
- .:/app
- ${HOME}/.claude:/home/${HOST_USER:-dispatch}/.claude
- ${HOME}/.local/share/dispatch:/home/${HOST_USER:-dispatch}/.local/share/dispatch
+ - ${HOME}/.skills:/home/${HOST_USER:-dispatch}/.skills
+ - ${HOME}/.config/dispatch:/home/${HOST_USER:-dispatch}/.config/dispatch
env_file:
- .env.dispatch
environment:
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
index 7e814af..82b8456 100644
--- a/packages/api/src/agent-manager.ts
+++ b/packages/api/src/agent-manager.ts
@@ -299,7 +299,24 @@ export class AgentManager {
if (!tabAgent.agent) {
const defaultWorkDir = process.env.DISPATCH_WORKING_DIR ?? process.cwd();
- const workingDirectory = tabAgent.workingDirectoryOverride ?? defaultWorkDir;
+ let workingDirectory = tabAgent.workingDirectoryOverride ?? defaultWorkDir;
+
+ // Expand ~ to home directory
+ if (workingDirectory === "~" || workingDirectory.startsWith("~/")) {
+ const { homedir } = await import("node:os");
+ const { join } = await import("node:path");
+ workingDirectory = join(homedir(), workingDirectory.slice(1));
+ }
+
+ // Auto-create the working directory if it doesn't exist
+ try {
+ const { mkdirSync, existsSync } = await import("node:fs");
+ if (!existsSync(workingDirectory)) {
+ mkdirSync(workingDirectory, { recursive: true });
+ }
+ } catch {
+ // Ignore — tool execution will surface the error naturally
+ }
// Build tools list — child agents use their toolsOverride whitelist,
// parent agents use permission settings from DB
@@ -573,15 +590,34 @@ export class AgentManager {
const tabId = crypto.randomUUID();
const title = options.task.length > 50 ? `${options.task.slice(0, 47)}...` : options.task;
- // Validate working directory is within the parent's workspace
+ // Validate working directory is within the parent agent's effective CWD
const defaultWorkDir = process.env.DISPATCH_WORKING_DIR ?? process.cwd();
+ let parentEffectiveDir = options.parentTabId
+ ? (this.tabAgents.get(options.parentTabId)?.workingDirectoryOverride ?? defaultWorkDir)
+ : defaultWorkDir;
+
+ // Expand ~ in parent dir
+ if (parentEffectiveDir === "~" || parentEffectiveDir.startsWith("~/")) {
+ const { homedir } = await import("node:os");
+ const { join } = await import("node:path");
+ parentEffectiveDir = join(homedir(), parentEffectiveDir.slice(1));
+ }
+
if (options.workingDirectory) {
- const { resolve } = await import("node:path");
- const resolved = resolve(options.workingDirectory);
- const parentDir = resolve(defaultWorkDir);
- if (!resolved.startsWith(`${parentDir}/`) && resolved !== parentDir) {
+ const { isAbsolute, relative, resolve, join } = await import("node:path");
+ // Expand ~ in child working directory
+ let childDir = options.workingDirectory;
+ if (childDir === "~" || childDir.startsWith("~/")) {
+ const { homedir } = await import("node:os");
+ childDir = join(homedir(), childDir.slice(1));
+ }
+ const parentDir = resolve(parentEffectiveDir);
+ const resolved = resolve(parentDir, childDir);
+ const rel = relative(parentDir, resolved);
+ const isOutside = rel.startsWith("..") || isAbsolute(rel);
+ if (isOutside) {
throw new Error(
- `Working directory "${options.workingDirectory}" is outside the workspace "${parentDir}".`,
+ `Working directory "${options.workingDirectory}" is outside the parent's working directory "${parentDir}".`,
);
}
}
@@ -676,8 +712,19 @@ export class AgentManager {
keyId?: string,
modelId?: string,
reasoningEffort?: "none" | "low" | "medium" | "high" | "max",
+ workingDirectory?: string,
): Promise<void> {
const tabAgent = this._getOrCreateTabAgent(tabId);
+
+ // Apply working directory override from frontend if provided
+ if (workingDirectory !== undefined) {
+ const prevDir = tabAgent.workingDirectoryOverride;
+ tabAgent.workingDirectoryOverride = workingDirectory || undefined;
+ // Invalidate cached agent if working directory changed
+ if (prevDir !== tabAgent.workingDirectoryOverride) {
+ tabAgent.agent = null;
+ }
+ }
tabAgent.abortController = new AbortController();
tabAgent.status = "running";
this.messageCount += 1;
diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts
index f5588a6..37514c3 100644
--- a/packages/api/src/app.ts
+++ b/packages/api/src/app.ts
@@ -2,9 +2,10 @@ import { Hono } from "hono";
import { cors } from "hono/cors";
import { AgentManager } from "./agent-manager.js";
import { PermissionManager } from "./permission-manager.js";
+import { agentsRoutes } from "./routes/agents.js";
import { configRoutes } from "./routes/config.js";
-import { skillsRoutes } from "./routes/skills.js";
import { modelsRoutes, startWakeScheduler } from "./routes/models.js";
+import { skillsRoutes } from "./routes/skills.js";
import { tabsRoutes } from "./routes/tabs.js";
export const permissionManager = new PermissionManager();
@@ -35,7 +36,14 @@ app.get("/status", (c) => {
});
app.post("/chat", async (c) => {
- const body = await c.req.json<{ tabId?: unknown; message?: unknown; keyId?: unknown; modelId?: unknown; reasoningEffort?: unknown }>();
+ const body = await c.req.json<{
+ tabId?: unknown;
+ message?: unknown;
+ keyId?: unknown;
+ modelId?: unknown;
+ reasoningEffort?: unknown;
+ workingDirectory?: unknown;
+ }>();
const { tabId, message } = body;
if (typeof tabId !== "string" || tabId.trim() === "") {
@@ -52,13 +60,15 @@ app.post("/chat", async (c) => {
const keyId = typeof body.keyId === "string" ? body.keyId : undefined;
const modelId = typeof body.modelId === "string" ? body.modelId : undefined;
+ const workingDirectory = typeof body.workingDirectory === "string" ? body.workingDirectory : undefined;
const validEfforts = ["none", "low", "medium", "high", "max"];
- const reasoningEffort = typeof body.reasoningEffort === "string" && validEfforts.includes(body.reasoningEffort)
- ? (body.reasoningEffort as "none" | "low" | "medium" | "high" | "max")
- : undefined;
+ const reasoningEffort =
+ typeof body.reasoningEffort === "string" && validEfforts.includes(body.reasoningEffort)
+ ? (body.reasoningEffort as "none" | "low" | "medium" | "high" | "max")
+ : undefined;
// Non-blocking — let the agent run in the background
- agentManager.processMessage(tabId, message, keyId, modelId, reasoningEffort).catch(console.error);
+ agentManager.processMessage(tabId, message, keyId, modelId, reasoningEffort, workingDirectory).catch(console.error);
return c.json({ status: "ok" });
});
@@ -67,6 +77,7 @@ app.route("/config", configRoutes);
app.route("/skills", skillsRoutes);
app.route("/models", modelsRoutes);
app.route("/tabs", tabsRoutes);
+app.route("/agents", agentsRoutes);
// Start the wake scheduler on boot (restores persisted schedule)
startWakeScheduler();
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
index a05f800..045196b 100644
--- a/packages/api/src/index.ts
+++ b/packages/api/src/index.ts
@@ -1,6 +1,6 @@
+import type { PermissionReply } from "@dispatch/core";
import { createBunWebSocket } from "hono/bun";
import { agentManager, app, permissionManager } from "./app.js";
-import type { PermissionReply } from "@dispatch/core";
const { upgradeWebSocket, websocket } = createBunWebSocket();
@@ -41,7 +41,11 @@ app.get(
id?: string;
reply?: string;
};
- if (message.type === "permission-reply" && typeof message.id === "string" && typeof message.reply === "string") {
+ if (
+ message.type === "permission-reply" &&
+ typeof message.id === "string" &&
+ typeof message.reply === "string"
+ ) {
const validReplies: PermissionReply[] = ["once", "always", "reject"];
if (validReplies.includes(message.reply as PermissionReply)) {
permissionManager.reply(message.id, message.reply as PermissionReply);
diff --git a/packages/api/src/permission-manager.ts b/packages/api/src/permission-manager.ts
index 6a04d3d..d98dc52 100644
--- a/packages/api/src/permission-manager.ts
+++ b/packages/api/src/permission-manager.ts
@@ -1,7 +1,7 @@
import {
- PermissionService,
type PermissionReply,
type PermissionRequest,
+ PermissionService,
type Ruleset,
} from "@dispatch/core";
diff --git a/packages/api/src/routes/agents.ts b/packages/api/src/routes/agents.ts
new file mode 100644
index 0000000..42339bf
--- /dev/null
+++ b/packages/api/src/routes/agents.ts
@@ -0,0 +1,109 @@
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import type { AgentDefinition } from "@dispatch/core";
+import { deleteAgent, getAgentDirs, loadAgents, saveAgent } from "@dispatch/core";
+import { Hono } from "hono";
+
+const SAFE_SLUG_RE = /^[a-zA-Z0-9_-]+$/;
+
+function isValidSlug(slug: string): boolean {
+ return SAFE_SLUG_RE.test(slug) && slug.length > 0 && slug.length <= 100;
+}
+
+const agentsRoutes = new Hono();
+
+// GET /agents — list all agents (global + project-scoped)
+// Query param: ?projectDir=... (optional, the working directory)
+agentsRoutes.get("/", (c) => {
+ const projectDir = c.req.query("projectDir") || process.env.DISPATCH_WORKING_DIR || undefined;
+ const agents = loadAgents(projectDir);
+ const dirs = getAgentDirs(projectDir);
+ return c.json({ agents, dirs });
+});
+
+// GET /agents/dirs — list available agent directories
+agentsRoutes.get("/dirs", (c) => {
+ const projectDir = c.req.query("projectDir") || process.env.DISPATCH_WORKING_DIR || undefined;
+ const dirs = getAgentDirs(projectDir);
+ return c.json({ dirs });
+});
+
+// POST /agents — create or update an agent
+agentsRoutes.post("/", async (c) => {
+ try {
+ const body = await c.req.json<AgentDefinition>();
+ // Validate required fields
+ if (!body.name || !body.slug || !body.scope) {
+ return c.json({ error: "name, slug, and scope are required" }, 400);
+ }
+ if (!isValidSlug(body.slug)) {
+ return c.json(
+ { error: "Invalid slug: must be alphanumeric with hyphens/underscores only" },
+ 400,
+ );
+ }
+ if (body.scope !== "global" && body.scope.includes("..")) {
+ return c.json({ error: "Invalid scope" }, 400);
+ }
+ // Ensure arrays exist
+ const agent: AgentDefinition = {
+ name: body.name,
+ description: body.description || "",
+ skills: body.skills || [],
+ tools: body.tools || [],
+ models: body.models || [],
+ scope: body.scope,
+ slug: body.slug,
+ ...(body.cwd ? { cwd: body.cwd } : {}),
+ };
+ saveAgent(agent);
+ return c.json({ ok: true, agent });
+ } catch (err) {
+ return c.json({ error: err instanceof Error ? err.message : "Failed to save agent" }, 500);
+ }
+});
+
+// DELETE /agents/:slug — delete an agent
+// Query param: ?scope=... (required: "global" or directory path)
+agentsRoutes.delete("/:slug", (c) => {
+ const slug = c.req.param("slug");
+ const scope = c.req.query("scope");
+ if (!scope) {
+ return c.json({ error: "scope query param is required" }, 400);
+ }
+ if (!isValidSlug(slug)) {
+ return c.json({ error: "Invalid slug" }, 400);
+ }
+ if (slug === "default" && scope === "global") {
+ return c.json({ error: "Cannot delete the default agent" }, 403);
+ }
+ if (scope !== "global" && scope.includes("..")) {
+ return c.json({ error: "Invalid scope" }, 400);
+ }
+ const deleted = deleteAgent(slug, scope);
+ if (!deleted) {
+ return c.json({ error: "Agent not found" }, 404);
+ }
+ return c.json({ ok: true });
+});
+
+// GET /agents/check-dir?path=... — check if a directory exists
+agentsRoutes.get("/check-dir", (c) => {
+ let dirPath = c.req.query("path");
+ if (!dirPath) {
+ return c.json({ exists: false });
+ }
+ // Expand ~ to home directory
+ if (dirPath === "~" || dirPath.startsWith("~/")) {
+ dirPath = path.join(os.homedir(), dirPath.slice(1));
+ }
+ try {
+ const stat = fs.statSync(dirPath);
+ return c.json({ exists: stat.isDirectory() });
+ } catch {
+ return c.json({ exists: false });
+ }
+});
+
+export { agentsRoutes };
diff --git a/packages/api/src/routes/config.ts b/packages/api/src/routes/config.ts
index 2d08167..65a1e2a 100644
--- a/packages/api/src/routes/config.ts
+++ b/packages/api/src/routes/config.ts
@@ -1,5 +1,5 @@
-import { Hono } from "hono";
import type { DispatchConfig } from "@dispatch/core";
+import { Hono } from "hono";
let getConfig: () => DispatchConfig = () => ({ permissions: {} });
diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts
index 86411f1..1daf37e 100644
--- a/packages/api/src/routes/models.ts
+++ b/packages/api/src/routes/models.ts
@@ -3,11 +3,11 @@ import {
ANTHROPIC_MODELS_FALLBACK,
type ClaudeAccount,
fetchAnthropicModels,
- getClaudeAccountsFromDB,
fetchCopilotUsage,
fetchOpencodeUsage,
getAccountUsage,
getAnthropicHeaders,
+ getClaudeAccountsFromDB,
getDatabase,
importCredentialsFromFile,
listApiKeys,
@@ -22,9 +22,7 @@ import { Hono } from "hono";
let getRegistry: () => ModelRegistry | null = () => null;
let getAccounts: () => ClaudeAccount[] = () => [];
-export function setModelsGetter(
- registryGetter: () => ModelRegistry | null,
-): void {
+export function setModelsGetter(registryGetter: () => ModelRegistry | null): void {
getRegistry = registryGetter;
}
@@ -80,8 +78,9 @@ modelsRoutes.get("/available", async (c) => {
if (key.definition.provider === "anthropic") {
const credFile = key.definition.credentials_file;
const accounts = resolveClaudeAccounts();
- const account = accounts.find((a) => a.id === keyId)
- ?? (credFile ? accounts.find((a) => a.source === credFile) : accounts[0]);
+ const account =
+ accounts.find((a) => a.id === keyId) ??
+ (credFile ? accounts.find((a) => a.source === credFile) : accounts[0]);
if (!account) {
return c.json({ error: "no Claude credentials found" }, 500);
@@ -413,9 +412,8 @@ async function wakeAllClaudeAccounts(): Promise<
}
}
}
- const accounts = configuredKeyIds.size > 0
- ? allAccounts.filter((a) => configuredKeyIds.has(a.id))
- : allAccounts;
+ const accounts =
+ configuredKeyIds.size > 0 ? allAccounts.filter((a) => configuredKeyIds.has(a.id)) : allAccounts;
if (accounts.length === 0) {
return [{ label: "(none)", ok: false, error: "no Claude accounts available" }];
}
@@ -483,7 +481,10 @@ function nextOccurrenceAt15(hour: number): number {
function loadScheduleFromDB(): WakeSchedule {
try {
const db = getDatabase();
- const rows = db.query("SELECT hour, next_wake_at FROM wake_schedule").all() as Array<{ hour: number; next_wake_at: number }>;
+ const rows = db.query("SELECT hour, next_wake_at FROM wake_schedule").all() as Array<{
+ hour: number;
+ next_wake_at: number;
+ }>;
const schedule: WakeSchedule = {};
let needsUpdate = false;
for (const row of rows) {
@@ -508,7 +509,9 @@ function persistSchedule(scheduleToSave?: WakeSchedule): void {
const db = getDatabase();
const data = scheduleToSave ?? wakeSchedule;
db.run("DELETE FROM wake_schedule");
- const insert = db.query("INSERT INTO wake_schedule (hour, next_wake_at) VALUES ($hour, $nextWakeAt)");
+ const insert = db.query(
+ "INSERT INTO wake_schedule (hour, next_wake_at) VALUES ($hour, $nextWakeAt)",
+ );
for (const [hour, nextWakeAt] of Object.entries(data)) {
insert.run({ $hour: Number(hour), $nextWakeAt: nextWakeAt });
}
@@ -517,8 +520,8 @@ function persistSchedule(scheduleToSave?: WakeSchedule): void {
}
}
-let wakeSchedule: WakeSchedule = loadScheduleFromDB();
-let pendingRetries: PendingRetry[] = [];
+const wakeSchedule: WakeSchedule = loadScheduleFromDB();
+const pendingRetries: PendingRetry[] = [];
// HMR-safe: clear previous tick before starting a new one
(globalThis as Record<string, unknown>)._dispatchWakeTimer ??= undefined;
diff --git a/packages/api/src/routes/skills.ts b/packages/api/src/routes/skills.ts
index 245fb4c..7696b47 100644
--- a/packages/api/src/routes/skills.ts
+++ b/packages/api/src/routes/skills.ts
@@ -1,9 +1,14 @@
-import { Hono } from "hono";
import type { AgentSkillMapping, SkillDefinition, SkillScope } from "@dispatch/core";
+import { Hono } from "hono";
-let getSkills: () => { skills: SkillDefinition[]; mappings: AgentSkillMapping[] } = () => ({ skills: [], mappings: [] });
+let getSkills: () => { skills: SkillDefinition[]; mappings: AgentSkillMapping[] } = () => ({
+ skills: [],
+ mappings: [],
+});
-export function setSkillsGetter(getter: () => { skills: SkillDefinition[]; mappings: AgentSkillMapping[] }): void {
+export function setSkillsGetter(
+ getter: () => { skills: SkillDefinition[]; mappings: AgentSkillMapping[] },
+): void {
getSkills = getter;
}
diff --git a/packages/api/src/routes/tabs.ts b/packages/api/src/routes/tabs.ts
index 288cd51..6e6734d 100644
--- a/packages/api/src/routes/tabs.ts
+++ b/packages/api/src/routes/tabs.ts
@@ -1,23 +1,26 @@
-import { Hono } from "hono";
import {
+ archiveTab,
createTab,
+ deleteSetting,
+ getMessagesForTab,
+ getSetting,
getTab,
listOpenTabs,
- updateTabTitle,
+ setSetting,
updateTabModel,
updateTabStatus,
- archiveTab,
- getMessagesForTab,
- getSetting,
- setSetting,
- deleteSetting,
+ updateTabTitle,
} from "@dispatch/core";
+import { Hono } from "hono";
export const tabsRoutes = new Hono();
-let getAgentManager: () => { stopTab(id: string): void; deleteTab(id: string): void } | null = () => null;
+let getAgentManager: () => { stopTab(id: string): void; deleteTab(id: string): void } | null = () =>
+ null;
-export function setTabsAgentManager(getter: () => { stopTab(id: string): void; deleteTab(id: string): void } | null): void {
+export function setTabsAgentManager(
+ getter: () => { stopTab(id: string): void; deleteTab(id: string): void } | null,
+): void {
getAgentManager = getter;
}
@@ -69,7 +72,12 @@ tabsRoutes.get("/:id/messages", (c) => {
tabsRoutes.patch("/:id", async (c) => {
const id = c.req.param("id");
- const body = await c.req.json<{ title?: string; keyId?: string; modelId?: string; status?: string }>();
+ const body = await c.req.json<{
+ title?: string;
+ keyId?: string;
+ modelId?: string;
+ status?: string;
+ }>();
if (body.title !== undefined) updateTabTitle(id, body.title);
if (body.keyId !== undefined || body.modelId !== undefined) {
updateTabModel(id, body.keyId ?? null, body.modelId ?? null);
diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts
index 006ee1b..c2c5880 100644
--- a/packages/core/src/agent/agent.ts
+++ b/packages/core/src/agent/agent.ts
@@ -1,11 +1,11 @@
+import { realpathSync } from "node:fs";
+import { dirname, isAbsolute, relative, resolve } from "node:path";
import type { CoreMessage } from "ai";
import { streamText } from "ai";
-import { dirname, isAbsolute, relative, resolve } from "node:path";
-import { realpathSync } from "node:fs";
+import { buildBillingHeaderValue, SYSTEM_IDENTITY } from "../credentials/claude.js";
import { createProvider, prefixToolName, unprefixToolName } from "../llm/provider.js";
import { createToolRegistry } from "../tools/registry.js";
import { analyzeCommand } from "../tools/shell-analyze.js";
-import { buildBillingHeaderValue, SYSTEM_IDENTITY } from "../credentials/claude.js";
import type {
AgentConfig,
AgentEvent,
@@ -21,7 +21,10 @@ function toCoreMessages(messages: ChatMessage[], isAnthropic?: boolean): CoreMes
if (msg.role === "user") {
result.push({ role: "user", content: msg.content });
} else if (msg.role === "assistant") {
- const parts: Array<{ type: "text"; text: string } | { type: "tool-call"; toolCallId: string; toolName: string; args: Record<string, unknown> }> = [{ type: "text", text: msg.content }];
+ const parts: Array<
+ | { type: "text"; text: string }
+ | { type: "tool-call"; toolCallId: string; toolName: string; args: Record<string, unknown> }
+ > = [{ type: "text", text: msg.content }];
for (const tc of msg.toolCalls ?? []) {
const toolName = isAnthropic ? prefixToolName(tc.name) : tc.name;
parts.push({ type: "tool-call", toolCallId: tc.id, toolName, args: tc.arguments });
@@ -29,7 +32,12 @@ function toCoreMessages(messages: ChatMessage[], isAnthropic?: boolean): CoreMes
result.push({ role: "assistant", content: parts });
for (const tr of msg.toolResults ?? []) {
const toolName = isAnthropic ? prefixToolName(tr.toolName) : tr.toolName;
- result.push({ role: "tool", content: [{ type: "tool-result", toolCallId: tr.toolCallId, toolName, result: tr.result }] });
+ result.push({
+ role: "tool",
+ content: [
+ { type: "tool-result", toolCallId: tr.toolCallId, toolName, result: tr.result },
+ ],
+ });
}
}
}
@@ -76,7 +84,12 @@ export class Agent {
const registry = createToolRegistry(this.config.tools);
const tool = registry.getTool(tc.name);
if (!tool) {
- return { toolCallId: tc.id, toolName: tc.name, result: `Unknown tool: ${tc.name}`, isError: true };
+ return {
+ toolCallId: tc.id,
+ toolName: tc.name,
+ result: `Unknown tool: ${tc.name}`,
+ isError: true,
+ };
}
// Permission check for shell commands — only prompt for external directory access.
@@ -133,7 +146,8 @@ export class Agent {
// Check if outside workspace
const rel = relative(this.config.workingDirectory, resolvedPath);
- const isOutside = rel.startsWith("../") || rel.startsWith("..\\") || rel === ".." || isAbsolute(rel);
+ const isOutside =
+ rel.startsWith("../") || rel.startsWith("..\\") || rel === ".." || isAbsolute(rel);
if (isOutside) {
const permissionType =
@@ -194,7 +208,10 @@ export class Agent {
}
}
- async *run(userMessage: string, options?: { reasoningEffort?: "none" | "low" | "medium" | "high" | "max" }): AsyncGenerator<AgentEvent> {
+ async *run(
+ userMessage: string,
+ options?: { reasoningEffort?: "none" | "low" | "medium" | "high" | "max" },
+ ): AsyncGenerator<AgentEvent> {
this.status = "running";
yield { type: "status", status: "running" };
@@ -213,8 +230,8 @@ export class Agent {
const aiTools = registry.getAISDKTools();
const tools = isAnthropic
? Object.fromEntries(
- Object.entries(aiTools).map(([name, tool]) => [prefixToolName(name), tool]),
- )
+ Object.entries(aiTools).map(([name, tool]) => [prefixToolName(name), tool]),
+ )
: aiTools;
// Build system prompt
@@ -246,8 +263,19 @@ export class Agent {
};
if (isAnthropic && effort !== "none") {
- const budgetTokens = effort === "max" ? 16000 : effort === "high" ? 10000 : effort === "medium" ? 5000 : effort === "low" ? 2000 : 0;
- streamOptions.providerOptions = { anthropic: { thinking: { type: "enabled" as const, budgetTokens } } };
+ const budgetTokens =
+ effort === "max"
+ ? 16000
+ : effort === "high"
+ ? 10000
+ : effort === "medium"
+ ? 5000
+ : effort === "low"
+ ? 2000
+ : 0;
+ streamOptions.providerOptions = {
+ anthropic: { thinking: { type: "enabled" as const, budgetTokens } },
+ };
streamOptions.maxTokens = budgetTokens + 8000;
} else if (!isAnthropic && effort !== "none") {
streamOptions.providerOptions = { openaiCompatible: { reasoningEffort: effort } };
@@ -258,31 +286,64 @@ export class Agent {
let stepText = "";
const stepToolCalls: ToolCall[] = [];
- for await (const event of result.fullStream) {
- if (event.type === "text-delta") {
- stepText += event.textDelta;
- finalText += event.textDelta;
- yield { type: "text-delta", delta: event.textDelta };
- } else if (event.type === "reasoning") {
- yield { type: "reasoning-delta", delta: event.textDelta };
- } else if (event.type === "tool-call") {
- const rawName = event.toolName;
- const toolName = isAnthropic ? unprefixToolName(rawName) : rawName;
- const toolCall: ToolCall = {
- id: event.toolCallId,
- name: toolName,
- arguments: event.args as Record<string, unknown>,
- };
- stepToolCalls.push(toolCall);
- allToolCalls.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;
+ try {
+ for await (const event of result.fullStream) {
+ if (event.type === "text-delta") {
+ stepText += event.textDelta;
+ finalText += event.textDelta;
+ yield { type: "text-delta", delta: event.textDelta };
+ } else if (event.type === "reasoning") {
+ yield { type: "reasoning-delta", delta: event.textDelta };
+ } else if (event.type === "tool-call") {
+ const rawName = event.toolName;
+ const toolName = isAnthropic ? unprefixToolName(rawName) : rawName;
+ const toolCall: ToolCall = {
+ id: event.toolCallId,
+ name: toolName,
+ arguments: event.args as Record<string, unknown>,
+ };
+ stepToolCalls.push(toolCall);
+ allToolCalls.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;
+ }
}
+ } catch (streamErr) {
+ const errMsg = streamErr instanceof Error ? streamErr.message : String(streamErr);
+ const unavailMatch = errMsg.match(
+ /tried to call unavailable tool '([^']+)'/i,
+ );
+ if (!unavailMatch) throw streamErr;
+
+ // Model tried to call an unavailable tool.
+ // Add a synthetic tool call + error result for the bad tool.
+ const badToolName = unavailMatch[1];
+ const fakeId = `unavail_${crypto.randomUUID().slice(0, 8)}`;
+ const availableTools = Object.keys(aiTools).join(", ");
+ const errorResult = `Tool "${badToolName}" is not available. Available tools: ${availableTools}. Please use only available tools.`;
+
+ const badToolCall: ToolCall = {
+ id: fakeId,
+ name: badToolName,
+ arguments: {},
+ };
+ stepToolCalls.push(badToolCall);
+ allToolCalls.push(badToolCall);
+ yield { type: "tool-call", toolCall: badToolCall };
+
+ const badToolResult: ToolResult = {
+ toolCallId: fakeId,
+ toolName: badToolName,
+ result: errorResult,
+ isError: true,
+ };
+ allToolResults.push(badToolResult);
+ yield { type: "tool-result", toolResult: badToolResult };
}
// No tool calls means the agent is done
@@ -304,7 +365,17 @@ export class Agent {
// Execute tool calls manually
const stepToolResults: ToolResult[] = [];
+ // Track tool calls that already have results (e.g. synthetic unavailable-tool errors)
+ const alreadyResolved = new Set(allToolResults.map((r) => r.toolCallId));
+
for (const tc of stepToolCalls) {
+ // Skip execution for tool calls that already have synthetic results
+ if (alreadyResolved.has(tc.id)) {
+ const existing = allToolResults.find((r) => r.toolCallId === tc.id);
+ if (existing) stepToolResults.push(existing);
+ continue;
+ }
+
const shellOutputQueue: Array<{ data: string; stream: "stdout" | "stderr" }> = [];
const execPromise = this.executeToolWithStreaming(tc, shellOutputQueue);
@@ -321,7 +392,9 @@ export class Agent {
}
const raceResult = await Promise.race([
execPromise.then((r) => ({ done: true as const, value: r })),
- new Promise<{ done: false }>((resolve) => setImmediate(() => resolve({ done: false }))),
+ new Promise<{ done: false }>((resolve) =>
+ setImmediate(() => resolve({ done: false })),
+ ),
]);
if (raceResult.done) {
toolResult = raceResult.value;
diff --git a/packages/core/src/agents/index.ts b/packages/core/src/agents/index.ts
new file mode 100644
index 0000000..13f6244
--- /dev/null
+++ b/packages/core/src/agents/index.ts
@@ -0,0 +1 @@
+export { deleteAgent, getAgentDirs, loadAgents, saveAgent } from "./loader.js";
diff --git a/packages/core/src/agents/loader.ts b/packages/core/src/agents/loader.ts
new file mode 100644
index 0000000..ec29983
--- /dev/null
+++ b/packages/core/src/agents/loader.ts
@@ -0,0 +1,212 @@
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import { parse as parseTOML, stringify as stringifyTOML } from "smol-toml";
+import type { AgentDefinition, AgentModelEntry } from "../types/index.js";
+
+// ─── Helpers ─────────────────────────────────────────────────────
+
+/** Sanitize a slug to prevent path traversal */
+function sanitizeSlug(slug: string): string {
+ // Strip directory components and ensure only safe characters
+ const base = path.basename(slug);
+ const clean = base
+ .replace(/[^a-zA-Z0-9_-]/g, "-")
+ .replace(/-+/g, "-")
+ .replace(/^-|-$/g, "");
+ if (!clean) throw new Error("Invalid agent slug");
+ return clean;
+}
+
+// ─── Constants ───────────────────────────────────────────────────
+
+const GLOBAL_AGENTS_DIR = path.join(os.homedir(), ".config", "dispatch", "agents");
+
+function getProjectAgentsDir(projectDir: string): string {
+ return path.join(projectDir, ".dispatch", "agents");
+}
+
+// ─── Public API ──────────────────────────────────────────────────
+
+/**
+ * Returns the agent directories that exist or could exist.
+ * Always includes global. Includes project dir if projectDir is provided.
+ */
+export function getAgentDirs(
+ projectDir?: string,
+): Array<{ label: string; path: string; scope: string }> {
+ const dirs: Array<{ label: string; path: string; scope: string }> = [
+ { label: "Global (~/.config/dispatch/agents)", path: GLOBAL_AGENTS_DIR, scope: "global" },
+ ];
+ if (projectDir) {
+ dirs.push({
+ label: `.dispatch/agents (${path.basename(projectDir)})`,
+ path: getProjectAgentsDir(projectDir),
+ scope: projectDir,
+ });
+ }
+ return dirs;
+}
+
+/**
+ * Ensure the default global agent exists. Creates it if missing.
+ */
+function ensureDefaultAgent(): void {
+ const filePath = path.join(GLOBAL_AGENTS_DIR, "default.toml");
+ if (fs.existsSync(filePath)) return;
+
+ const defaultAgent: AgentDefinition = {
+ name: "Default",
+ description: "Default agent with all tools enabled",
+ skills: [],
+ tools: ["read", "edit", "bash", "summon"],
+ models: [],
+ scope: "global",
+ slug: "default",
+ };
+ saveAgent(defaultAgent);
+}
+
+/**
+ * Load all agent definitions from global + project directories.
+ * Auto-generates the default global agent if it doesn't exist.
+ */
+export function loadAgents(projectDir?: string): AgentDefinition[] {
+ ensureDefaultAgent();
+
+ const agents: AgentDefinition[] = [];
+
+ // Global agents
+ agents.push(...loadAgentsFromDir(GLOBAL_AGENTS_DIR, "global"));
+
+ // Project-scoped agents
+ if (projectDir) {
+ agents.push(...loadAgentsFromDir(getProjectAgentsDir(projectDir), projectDir));
+ }
+
+ return agents;
+}
+
+/**
+ * Save (create or update) an agent definition to a TOML file.
+ * The scope determines which directory:
+ * - "global" -> ~/.config/dispatch/agents/
+ * - any other string -> that directory path + /.dispatch/agents/
+ */
+export function saveAgent(agent: AgentDefinition): void {
+ if (agent.scope !== "global" && agent.scope.includes("..")) {
+ throw new Error("Invalid agent scope");
+ }
+ const dir = agent.scope === "global" ? GLOBAL_AGENTS_DIR : getProjectAgentsDir(agent.scope);
+
+ fs.mkdirSync(dir, { recursive: true });
+
+ const tomlContent: Record<string, unknown> = {
+ name: agent.name,
+ description: agent.description,
+ skills: agent.skills,
+ tools: agent.tools,
+ };
+
+ if (agent.cwd) {
+ tomlContent.cwd = agent.cwd;
+ }
+
+ // smol-toml handles [[models]] array-of-tables
+ if (agent.models.length > 0) {
+ tomlContent.models = agent.models.map((m) => ({
+ key_id: m.key_id,
+ model_id: m.model_id,
+ }));
+ }
+
+ const content = stringifyTOML(tomlContent);
+ const safeSlug = sanitizeSlug(agent.slug);
+ const filePath = path.join(dir, `${safeSlug}.toml`);
+ fs.writeFileSync(filePath, content, "utf-8");
+}
+
+/**
+ * Delete an agent TOML file.
+ */
+export function deleteAgent(slug: string, scope: string): boolean {
+ if (scope !== "global" && scope.includes("..")) {
+ throw new Error("Invalid agent scope");
+ }
+ const dir = scope === "global" ? GLOBAL_AGENTS_DIR : getProjectAgentsDir(scope);
+
+ const safeSlug = sanitizeSlug(slug);
+ const filePath = path.join(dir, `${safeSlug}.toml`);
+ if (fs.existsSync(filePath)) {
+ fs.unlinkSync(filePath);
+ return true;
+ }
+ return false;
+}
+
+// ─── Internal ────────────────────────────────────────────────────
+
+function loadAgentsFromDir(dir: string, scope: string): AgentDefinition[] {
+ if (!fs.existsSync(dir)) return [];
+
+ const results: AgentDefinition[] = [];
+ let entries: fs.Dirent[];
+ try {
+ entries = fs.readdirSync(dir, { withFileTypes: true });
+ } catch {
+ return [];
+ }
+
+ for (const entry of entries) {
+ if (!entry.isFile() || !entry.name.endsWith(".toml")) continue;
+
+ const filePath = path.join(dir, entry.name);
+ const slug = entry.name.slice(0, -5); // remove .toml
+
+ try {
+ const raw = fs.readFileSync(filePath, "utf-8");
+ const parsed = parseTOML(raw);
+
+ const models: AgentModelEntry[] = [];
+ if (Array.isArray(parsed.models)) {
+ for (const m of parsed.models) {
+ if (m && typeof m === "object" && "key_id" in m && "model_id" in m) {
+ models.push({
+ key_id: String((m as Record<string, unknown>).key_id),
+ model_id: String((m as Record<string, unknown>).model_id),
+ });
+ }
+ }
+ }
+
+ const skills: string[] = [];
+ if (Array.isArray(parsed.skills)) {
+ for (const s of parsed.skills) {
+ if (typeof s === "string") skills.push(s);
+ }
+ }
+
+ const tools: string[] = [];
+ if (Array.isArray(parsed.tools)) {
+ for (const t of parsed.tools) {
+ if (typeof t === "string") tools.push(t);
+ }
+ }
+
+ results.push({
+ name: typeof parsed.name === "string" ? parsed.name : slug,
+ description: typeof parsed.description === "string" ? parsed.description : "",
+ skills,
+ tools,
+ models,
+ scope,
+ slug,
+ ...(typeof parsed.cwd === "string" && parsed.cwd ? { cwd: parsed.cwd } : {}),
+ });
+ } catch {
+ // Skip unparseable files
+ }
+ }
+
+ return results;
+}
diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts
index 3b4d733..bccbb8f 100644
--- a/packages/core/src/config/loader.ts
+++ b/packages/core/src/config/loader.ts
@@ -28,7 +28,9 @@ export function loadConfig(dir: string): DispatchConfig {
// File doesn't exist — return empty default
return DEFAULT_CONFIG;
}
- console.warn(`dispatch: failed to parse dispatch.toml: ${err instanceof Error ? err.message : String(err)}`);
+ console.warn(
+ `dispatch: failed to parse dispatch.toml: ${err instanceof Error ? err.message : String(err)}`,
+ );
throw err;
}
diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts
index 2cd8d55..ef10b65 100644
--- a/packages/core/src/config/schema.ts
+++ b/packages/core/src/config/schema.ts
@@ -1,8 +1,4 @@
-import type {
- ConfigError,
- DispatchConfig,
- KeyDefinition,
-} from "../types/index.js";
+import type { ConfigError, DispatchConfig, KeyDefinition } from "../types/index.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -33,19 +29,28 @@ function validatePermissions(
const result: Record<string, string | Record<string, string>> = {};
for (const [key, value] of Object.entries(raw)) {
if (!isPermissionsValue(value)) {
- errors.push({ path: `${path}.${key}`, message: "must be a string or a flat string-keyed object" });
+ errors.push({
+ path: `${path}.${key}`,
+ message: "must be a string or a flat string-keyed object",
+ });
continue;
}
if (typeof value === "string") {
if (!isValidAction(value)) {
- errors.push({ path: `${path}.${key}`, message: `invalid action "${value}"; must be "allow", "deny", or "ask"` });
+ errors.push({
+ path: `${path}.${key}`,
+ message: `invalid action "${value}"; must be "allow", "deny", or "ask"`,
+ });
continue;
}
} else {
let hasError = false;
for (const [pattern, action] of Object.entries(value)) {
if (!isValidAction(action)) {
- errors.push({ path: `${path}.${key}.${pattern}`, message: `invalid action "${action}"; must be "allow", "deny", or "ask"` });
+ errors.push({
+ path: `${path}.${key}.${pattern}`,
+ message: `invalid action "${action}"; must be "allow", "deny", or "ask"`,
+ });
hasError = true;
}
}
@@ -80,7 +85,9 @@ function validateKey(raw: unknown, path: string, errors: ConfigError[]): KeyDefi
id: raw["id"] as string,
provider: raw["provider"] as string,
base_url: raw["base_url"] as string,
- ...(typeof raw["credentials_file"] === "string" ? { credentials_file: raw["credentials_file"] } as Pick<KeyDefinition, "credentials_file"> : {}),
+ ...(typeof raw["credentials_file"] === "string"
+ ? ({ credentials_file: raw["credentials_file"] } as Pick<KeyDefinition, "credentials_file">)
+ : {}),
};
}
diff --git a/packages/core/src/config/watcher.ts b/packages/core/src/config/watcher.ts
index 42b2f87..70821ed 100644
--- a/packages/core/src/config/watcher.ts
+++ b/packages/core/src/config/watcher.ts
@@ -1,5 +1,5 @@
-import { watch } from "chokidar";
import { join } from "node:path";
+import { watch } from "chokidar";
import type { DispatchConfig } from "../types/index.js";
import { loadConfig } from "./loader.js";
@@ -26,7 +26,9 @@ export function createConfigWatcher(
const config = loadConfig(dir);
onChange(config);
} catch (err) {
- console.warn(`dispatch: retaining last known config due to parse error: ${err instanceof Error ? err.message : String(err)}`);
+ console.warn(
+ `dispatch: retaining last known config due to parse error: ${err instanceof Error ? err.message : String(err)}`,
+ );
}
}, 300);
};
@@ -36,7 +38,9 @@ export function createConfigWatcher(
watcher.on("unlink", handleChange);
watcher.on("error", (err) => {
- console.warn(`dispatch: config watcher error: ${err instanceof Error ? err.message : String(err)}`);
+ console.warn(
+ `dispatch: config watcher error: ${err instanceof Error ? err.message : String(err)}`,
+ );
});
return {
@@ -46,7 +50,9 @@ export function createConfigWatcher(
debounceTimer = null;
}
watcher.close().catch((err) => {
- console.warn(`dispatch: error closing config watcher: ${err instanceof Error ? err.message : String(err)}`);
+ console.warn(
+ `dispatch: error closing config watcher: ${err instanceof Error ? err.message : String(err)}`,
+ );
});
},
};
diff --git a/packages/core/src/credentials/api-keys.ts b/packages/core/src/credentials/api-keys.ts
index 5f92ffa..af5aa0e 100644
--- a/packages/core/src/credentials/api-keys.ts
+++ b/packages/core/src/credentials/api-keys.ts
@@ -33,9 +33,9 @@ export function setApiKey(keyId: string, provider: string, apiKey: string): void
*/
export function getApiKey(keyId: string): string | null {
const db = getDatabase();
- const row = db.query(
- "SELECT api_key FROM api_keys WHERE key_id = $keyId",
- ).get({ $keyId: keyId }) as { api_key: string } | null;
+ const row = db
+ .query("SELECT api_key FROM api_keys WHERE key_id = $keyId")
+ .get({ $keyId: keyId }) as { api_key: string } | null;
return row?.api_key ?? null;
}
@@ -57,11 +57,16 @@ export function deleteApiKey(keyId: string): void {
/**
* List all stored API keys with metadata (key value excluded for security).
*/
-export function listApiKeys(): Array<{ keyId: string; provider: string; importedAt: number; updatedAt: number }> {
+export function listApiKeys(): Array<{
+ keyId: string;
+ provider: string;
+ importedAt: number;
+ updatedAt: number;
+}> {
const db = getDatabase();
- const rows = db.query(
- "SELECT key_id, provider, imported_at, updated_at FROM api_keys ORDER BY key_id",
- ).all() as Array<Record<string, unknown>>;
+ const rows = db
+ .query("SELECT key_id, provider, imported_at, updated_at FROM api_keys ORDER BY key_id")
+ .all() as Array<Record<string, unknown>>;
return rows.map((row) => ({
keyId: row.key_id as string,
provider: row.provider as string,
diff --git a/packages/core/src/credentials/claude.ts b/packages/core/src/credentials/claude.ts
index 1b9d148..6018207 100644
--- a/packages/core/src/credentials/claude.ts
+++ b/packages/core/src/credentials/claude.ts
@@ -1,9 +1,16 @@
-import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, readdirSync } from "node:fs";
-import { getStoredCredentials, updateStoredTokens, listStoredCredentials } from "./store.js";
-import { getDatabase } from "../db/index.js";
-import { dirname, join, basename } from "node:path";
-import { homedir } from "node:os";
import { createHash } from "node:crypto";
+import {
+ chmodSync,
+ existsSync,
+ mkdirSync,
+ readdirSync,
+ readFileSync,
+ writeFileSync,
+} from "node:fs";
+import { homedir } from "node:os";
+import { basename, dirname, join } from "node:path";
+import { getDatabase } from "../db/index.js";
+import { getStoredCredentials, listStoredCredentials, updateStoredTokens } from "./store.js";
export interface ClaudeCredentials {
accessToken: string;
@@ -41,7 +48,10 @@ function parseCredentialsFile(raw: string): ClaudeCredentials | null {
const data = (parsed as Record<string, unknown>).claudeAiOauth ?? parsed;
const creds = data as Record<string, unknown>;
- if ((creds as Record<string, unknown>).mcpOAuth && !(creds as Record<string, unknown>).accessToken) {
+ if (
+ (creds as Record<string, unknown>).mcpOAuth &&
+ !(creds as Record<string, unknown>).accessToken
+ ) {
return null;
}
@@ -57,7 +67,8 @@ function parseCredentialsFile(raw: string): ClaudeCredentials | null {
accessToken: creds.accessToken as string,
refreshToken: creds.refreshToken as string,
expiresAt: creds.expiresAt as number,
- subscriptionType: typeof creds.subscriptionType === "string" ? creds.subscriptionType : undefined,
+ subscriptionType:
+ typeof creds.subscriptionType === "string" ? creds.subscriptionType : undefined,
};
}
@@ -122,7 +133,7 @@ async function refreshViaOAuth(refreshToken: string): Promise<ClaudeCredentials
return null;
}
- const data = await response.json() as Record<string, unknown>;
+ const data = (await response.json()) as Record<string, unknown>;
if (!data.access_token || typeof data.access_token !== "string") {
return null;
}
@@ -131,7 +142,8 @@ async function refreshViaOAuth(refreshToken: string): Promise<ClaudeCredentials
accessToken: data.access_token as string,
refreshToken: (data.refresh_token as string) ?? refreshToken,
expiresAt: Date.now() + ((data.expires_in as number) ?? 36_000) * 1000,
- subscriptionType: typeof data.subscriptionType === "string" ? data.subscriptionType : undefined,
+ subscriptionType:
+ typeof data.subscriptionType === "string" ? data.subscriptionType : undefined,
};
} catch {
return null;
@@ -220,7 +232,11 @@ export function discoverClaudeAccounts(): ClaudeAccount[] {
export function refreshAccountCredentials(account: ClaudeAccount): ClaudeCredentials | null {
const cached = accountCacheMap.get(account.id);
const now = Date.now();
- if (cached && now - cached.cachedAt < CREDENTIAL_CACHE_TTL_MS && cached.creds.expiresAt > now + 60_000) {
+ if (
+ cached &&
+ now - cached.cachedAt < CREDENTIAL_CACHE_TTL_MS &&
+ cached.creds.expiresAt > now + 60_000
+ ) {
return cached.creds;
}
@@ -257,10 +273,16 @@ export function refreshAccountCredentials(account: ClaudeAccount): ClaudeCredent
return null;
}
-export async function refreshAccountCredentialsAsync(account: ClaudeAccount): Promise<ClaudeCredentials | null> {
+export async function refreshAccountCredentialsAsync(
+ account: ClaudeAccount,
+): Promise<ClaudeCredentials | null> {
const cached = accountCacheMap.get(account.id);
const now = Date.now();
- if (cached && now - cached.cachedAt < CREDENTIAL_CACHE_TTL_MS && cached.creds.expiresAt > now + 60_000) {
+ if (
+ cached &&
+ now - cached.cachedAt < CREDENTIAL_CACHE_TTL_MS &&
+ cached.creds.expiresAt > now + 60_000
+ ) {
return cached.creds;
}
@@ -294,7 +316,12 @@ export async function refreshAccountCredentialsAsync(account: ClaudeAccount): Pr
account.credentials = refreshed;
// Update DB if this is a DB-backed account, otherwise write to file
if (account.source.startsWith("db:")) {
- updateStoredTokens(account.id, refreshed.accessToken, refreshed.refreshToken, refreshed.expiresAt);
+ updateStoredTokens(
+ account.id,
+ refreshed.accessToken,
+ refreshed.refreshToken,
+ refreshed.expiresAt,
+ );
} else {
writeCredentialsFile(account.source, refreshed);
}
@@ -323,14 +350,14 @@ function computeCch(messageText: string): string {
}
function computeVersionSuffix(messageText: string, version: string): string {
- const sampled = [4, 7, 20]
- .map((i) => (i < messageText.length ? messageText[i] : "0"))
- .join("");
+ const sampled = [4, 7, 20].map((i) => (i < messageText.length ? messageText[i] : "0")).join("");
const input = `${BILLING_SALT}${sampled}${version}`;
return createHash("sha256").update(input).digest("hex").slice(0, 3);
}
-export function buildBillingHeaderValue(messages: Array<{ role: string; content: string }>): string {
+export function buildBillingHeaderValue(
+ messages: Array<{ role: string; content: string }>,
+): string {
const text = extractFirstUserMessageText(messages);
const version = process.env.ANTHROPIC_CLI_VERSION ?? CC_VERSION;
const suffix = computeVersionSuffix(text, version);
@@ -404,11 +431,16 @@ export async function fetchAnthropicModels(accessToken: string): Promise<string[
return [];
}
- const data = (await response.json()) as { data?: Array<{ id: string }>; models?: Array<{ id: string }> };
+ const data = (await response.json()) as {
+ data?: Array<{ id: string }>;
+ models?: Array<{ id: string }>;
+ };
const entries = data.data ?? data.models ?? [];
return entries.map((m) => m.id).filter(Boolean);
} catch (err) {
- console.warn(`dispatch: failed to fetch Anthropic models: ${err instanceof Error ? err.message : String(err)}`);
+ console.warn(
+ `dispatch: failed to fetch Anthropic models: ${err instanceof Error ? err.message : String(err)}`,
+ );
return [];
}
}
@@ -434,7 +466,9 @@ export interface ClaudeProfile {
* Validate that Claude credentials are usable by hitting the OAuth profile endpoint.
* Returns the profile info if valid, or null if the token is dead.
*/
-export async function validateAccountCredentials(account: ClaudeAccount): Promise<ClaudeProfile | null> {
+export async function validateAccountCredentials(
+ account: ClaudeAccount,
+): Promise<ClaudeProfile | null> {
const creds = await refreshAccountCredentialsAsync(account);
if (!creds) return null;
@@ -487,7 +521,8 @@ async function fetchClaudeUsage(accessToken: string): Promise<ClaudeUsageReport
// API returns utilization as 0-100 percentage; normalize to 0-1 fraction
const rawUtil = typeof b.utilization === "number" ? b.utilization : undefined;
const utilization = rawUtil !== undefined ? rawUtil / 100 : undefined;
- const resetsAt = typeof b.resets_at === "string" ? Date.parse(b.resets_at as string) : undefined;
+ const resetsAt =
+ typeof b.resets_at === "string" ? Date.parse(b.resets_at as string) : undefined;
if (utilization === undefined && resetsAt === undefined) return undefined;
return { utilization, resetsAt };
};
@@ -503,9 +538,13 @@ async function fetchClaudeUsage(accessToken: string): Promise<ClaudeUsageReport
// Try to extract identity
const accountId =
- typeof data.account_id === "string" ? data.account_id :
- typeof data.user_id === "string" ? data.user_id :
- typeof data.org_id === "string" ? data.org_id : undefined;
+ typeof data.account_id === "string"
+ ? data.account_id
+ : typeof data.user_id === "string"
+ ? data.user_id
+ : typeof data.org_id === "string"
+ ? data.org_id
+ : undefined;
if (accountId) report.accountId = accountId;
const email = typeof data.email === "string" ? data.email : undefined;
@@ -520,9 +559,9 @@ async function fetchClaudeUsage(accessToken: string): Promise<ClaudeUsageReport
function getCachedUsage(keyId: string): ClaudeUsageReport | null {
try {
const db = getDatabase();
- const row = db.query(
- "SELECT report_json FROM usage_cache WHERE key_id = $keyId",
- ).get({ $keyId: keyId }) as { report_json: string } | null;
+ const row = db
+ .query("SELECT report_json FROM usage_cache WHERE key_id = $keyId")
+ .get({ $keyId: keyId }) as { report_json: string } | null;
if (!row) return null;
return JSON.parse(row.report_json) as ClaudeUsageReport;
} catch {
@@ -559,4 +598,4 @@ export async function getAccountUsage(account: ClaudeAccount): Promise<ClaudeUsa
return report;
}
return getCachedUsage(account.id);
-} \ No newline at end of file
+}
diff --git a/packages/core/src/credentials/index.ts b/packages/core/src/credentials/index.ts
index 8cbedd9..e7f8a12 100644
--- a/packages/core/src/credentials/index.ts
+++ b/packages/core/src/credentials/index.ts
@@ -1,4 +1,12 @@
export {
+ deleteApiKey,
+ getApiKey,
+ listApiKeys,
+ resolveApiKey,
+ type StoredApiKey,
+ setApiKey,
+} from "./api-keys.js";
+export {
ANTHROPIC_MODELS_FALLBACK,
buildBillingHeaderValue,
type ClaudeAccount,
@@ -7,11 +15,11 @@ export {
type ClaudeUsageBucket,
type ClaudeUsageReport,
discoverClaudeAccounts,
- getClaudeAccountsFromDB,
fetchAnthropicModels,
getAccountUsage,
getAnthropicBetas,
getAnthropicHeaders,
+ getClaudeAccountsFromDB,
refreshAccountCredentials,
refreshAccountCredentialsAsync,
SYSTEM_IDENTITY,
@@ -27,18 +35,10 @@ export {
type OpencodeUsageReport,
} from "./opencode.js";
export {
- type StoredCredential,
- importCredentialsFromFile,
- getStoredCredentials,
- updateStoredTokens,
deleteStoredCredentials,
+ getStoredCredentials,
+ importCredentialsFromFile,
listStoredCredentials,
+ type StoredCredential,
+ updateStoredTokens,
} from "./store.js";
-export {
- type StoredApiKey,
- setApiKey,
- getApiKey,
- resolveApiKey,
- deleteApiKey,
- listApiKeys,
-} from "./api-keys.js";
diff --git a/packages/core/src/credentials/opencode.ts b/packages/core/src/credentials/opencode.ts
index b8a9d4d..d4d4851 100644
--- a/packages/core/src/credentials/opencode.ts
+++ b/packages/core/src/credentials/opencode.ts
@@ -70,9 +70,7 @@ function parseOcBucket(
return { utilization, resetsAt };
}
-export async function fetchOpencodeUsage(
- keyId: string,
-): Promise<OpencodeUsageReport | null> {
+export async function fetchOpencodeUsage(keyId: string): Promise<OpencodeUsageReport | null> {
const cookie = resolveApiKey("opencode-cookie");
const wsId = getWorkspaceId(keyId);
@@ -96,10 +94,7 @@ export async function fetchOpencodeUsage(
const html = await response.text();
// Auth redirect check
- if (
- html.includes("/auth/authorize") ||
- html.includes('window.location="/auth/authorize"')
- ) {
+ if (html.includes("/auth/authorize") || html.includes('window.location="/auth/authorize"')) {
return null;
}
diff --git a/packages/core/src/credentials/store.ts b/packages/core/src/credentials/store.ts
index 6c814f9..662b322 100644
--- a/packages/core/src/credentials/store.ts
+++ b/packages/core/src/credentials/store.ts
@@ -1,6 +1,6 @@
+import { existsSync, readFileSync } from "node:fs";
import { getDatabase } from "../db/index.js";
import type { ClaudeCredentials } from "./claude.js";
-import { existsSync, readFileSync } from "node:fs";
export interface StoredCredential {
keyId: string;
@@ -41,7 +41,8 @@ function parseCredentialsFile(raw: string): ClaudeCredentials | null {
accessToken: creds.accessToken as string,
refreshToken: creds.refreshToken as string,
expiresAt: creds.expiresAt as number,
- subscriptionType: typeof creds.subscriptionType === "string" ? creds.subscriptionType : undefined,
+ subscriptionType:
+ typeof creds.subscriptionType === "string" ? creds.subscriptionType : undefined,
};
}
@@ -62,7 +63,10 @@ export function importCredentialsFromFile(
try {
raw = readFileSync(filePath, "utf-8").trim();
} catch (e) {
- return { success: false, error: `Failed to read file: ${e instanceof Error ? e.message : String(e)}` };
+ return {
+ success: false,
+ error: `Failed to read file: ${e instanceof Error ? e.message : String(e)}`,
+ };
}
if (!raw) {
@@ -106,9 +110,11 @@ export function importCredentialsFromFile(
*/
export function getStoredCredentials(keyId: string): StoredCredential | null {
const db = getDatabase();
- const row = db.query(
- "SELECT key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at FROM credentials WHERE key_id = $keyId",
- ).get({ $keyId: keyId }) as Record<string, unknown> | null;
+ const row = db
+ .query(
+ "SELECT key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at FROM credentials WHERE key_id = $keyId",
+ )
+ .get({ $keyId: keyId }) as Record<string, unknown> | null;
if (!row) return null;
@@ -159,9 +165,11 @@ export function deleteStoredCredentials(keyId: string): void {
*/
export function listStoredCredentials(): StoredCredential[] {
const db = getDatabase();
- const rows = db.query(
- "SELECT key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at FROM credentials ORDER BY key_id",
- ).all() as Array<Record<string, unknown>>;
+ const rows = db
+ .query(
+ "SELECT key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at FROM credentials ORDER BY key_id",
+ )
+ .all() as Array<Record<string, unknown>>;
return rows.map((row) => ({
keyId: row.key_id as string,
diff --git a/packages/core/src/db/messages.ts b/packages/core/src/db/messages.ts
index 5a8758f..80a1f22 100644
--- a/packages/core/src/db/messages.ts
+++ b/packages/core/src/db/messages.ts
@@ -10,14 +10,30 @@ export interface MessageRow {
createdAt: number;
}
-export function appendMessage(tabId: string, id: string, role: string, contentJson: string, thinking?: string): void {
+export function appendMessage(
+ tabId: string,
+ id: string,
+ role: string,
+ contentJson: string,
+ thinking?: string,
+): void {
const db = getDatabase();
- const maxSeq = db.query("SELECT COALESCE(MAX(seq), -1) as max_seq FROM messages WHERE tab_id = $tabId").get({ $tabId: tabId }) as { max_seq: number };
+ const maxSeq = db
+ .query("SELECT COALESCE(MAX(seq), -1) as max_seq FROM messages WHERE tab_id = $tabId")
+ .get({ $tabId: tabId }) as { max_seq: number };
const seq = (maxSeq?.max_seq ?? -1) + 1;
db.query(
`INSERT INTO messages (id, tab_id, seq, role, content_json, thinking, created_at)
VALUES ($id, $tabId, $seq, $role, $contentJson, $thinking, $now)`,
- ).run({ $id: id, $tabId: tabId, $seq: seq, $role: role, $contentJson: contentJson, $thinking: thinking ?? null, $now: Date.now() });
+ ).run({
+ $id: id,
+ $tabId: tabId,
+ $seq: seq,
+ $role: role,
+ $contentJson: contentJson,
+ $thinking: thinking ?? null,
+ $now: Date.now(),
+ });
}
export function updateMessage(id: string, contentJson: string, thinking?: string): void {
@@ -29,7 +45,9 @@ export function updateMessage(id: string, contentJson: string, thinking?: string
export function getMessagesForTab(tabId: string): MessageRow[] {
const db = getDatabase();
- const rows = db.query("SELECT * FROM messages WHERE tab_id = $tabId ORDER BY seq ASC").all({ $tabId: tabId }) as Array<Record<string, unknown>>;
+ const rows = db
+ .query("SELECT * FROM messages WHERE tab_id = $tabId ORDER BY seq ASC")
+ .all({ $tabId: tabId }) as Array<Record<string, unknown>>;
return rows.map((row) => ({
id: row.id as string,
tabId: row.tab_id as string,
diff --git a/packages/core/src/db/settings.ts b/packages/core/src/db/settings.ts
index 51d6ea2..f9d152e 100644
--- a/packages/core/src/db/settings.ts
+++ b/packages/core/src/db/settings.ts
@@ -2,7 +2,9 @@ import { getDatabase } from "./index.js";
export function getSetting(key: string): string | null {
const db = getDatabase();
- const row = db.query("SELECT value FROM settings WHERE key = $key").get({ $key: key }) as { value: string } | null;
+ const row = db.query("SELECT value FROM settings WHERE key = $key").get({ $key: key }) as {
+ value: string;
+ } | null;
return row?.value ?? null;
}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index c5aa81b..283916a 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -2,6 +2,7 @@
// Agent & LLM
export { Agent } from "./agent/agent.js";
+export { deleteAgent, getAgentDirs, loadAgents, saveAgent } from "./agents/index.js";
// Config
export {
configToRuleset,
diff --git a/packages/core/src/llm/provider.ts b/packages/core/src/llm/provider.ts
index 3131b1a..7cbb829 100644
--- a/packages/core/src/llm/provider.ts
+++ b/packages/core/src/llm/provider.ts
@@ -1,7 +1,7 @@
-import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { createAnthropic } from "@ai-sdk/anthropic";
-import { wrapLanguageModel } from "ai";
+import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import type { LanguageModelV1Middleware, LanguageModelV1Prompt } from "ai";
+import { wrapLanguageModel } from "ai";
function normalizeMessages(msgs: unknown[]): unknown[] {
return msgs.map((msg: unknown) => {
@@ -19,7 +19,10 @@ function normalizeMessages(msgs: unknown[]): unknown[] {
);
const existingMetadata = (message.providerMetadata ?? {}) as Record<string, unknown>;
- const existingOpenAICompat = (existingMetadata.openaiCompatible ?? {}) as Record<string, unknown>;
+ const existingOpenAICompat = (existingMetadata.openaiCompatible ?? {}) as Record<
+ string,
+ unknown
+ >;
return {
...message,
@@ -120,4 +123,4 @@ function createAnthropicProvider(config: ProviderConfig) {
};
}
-export { prefixToolName, unprefixToolName }; \ No newline at end of file
+export { prefixToolName, unprefixToolName };
diff --git a/packages/core/src/models/registry.ts b/packages/core/src/models/registry.ts
index 9ea0b32..4a24a51 100644
--- a/packages/core/src/models/registry.ts
+++ b/packages/core/src/models/registry.ts
@@ -10,10 +10,7 @@ export class ModelRegistry {
this._initConfig(keys, new Map());
}
- private _initConfig(
- keys: KeyDefinition[],
- existingStates: Map<string, KeyState>,
- ): void {
+ private _initConfig(keys: KeyDefinition[], existingStates: Map<string, KeyState>): void {
this.keyOrder = keys.map((k) => k.id);
const newStates = new Map<string, KeyState>();
@@ -83,8 +80,7 @@ export class ModelRegistry {
return this.keyOrder
.map((id) => this.keyStates.get(id))
.filter(
- (state): state is KeyState =>
- state !== undefined && state.definition.provider === provider,
+ (state): state is KeyState => state !== undefined && state.definition.provider === provider,
);
}
}
diff --git a/packages/core/src/skills/index.ts b/packages/core/src/skills/index.ts
index 5f958b3..a3fac70 100644
--- a/packages/core/src/skills/index.ts
+++ b/packages/core/src/skills/index.ts
@@ -1,7 +1,7 @@
-export { parseSkillFile } from "./parser.js";
export {
+ createSkillsWatcher,
+ getSkillByName,
loadSkills,
resolveSkillsForAgent,
- getSkillByName,
- createSkillsWatcher,
} from "./loader.js";
+export { parseSkillFile } from "./parser.js";
diff --git a/packages/core/src/skills/loader.ts b/packages/core/src/skills/loader.ts
index 1dcd39e..5d043dd 100644
--- a/packages/core/src/skills/loader.ts
+++ b/packages/core/src/skills/loader.ts
@@ -1,89 +1,58 @@
import * as fs from "node:fs";
-import * as path from "node:path";
import * as os from "node:os";
+import * as path from "node:path";
import chokidar from "chokidar";
-import type { SkillDefinition, AgentSkillMapping, SkillScope } from "../types/index.js";
+import type { AgentSkillMapping, SkillDefinition, SkillScope } from "../types/index.js";
import { parseSkillFile } from "./parser.js";
// ─── Internal Helpers ────────────────────────────────────────────
-function loadSkillsFromDir(
- dir: string,
- scope: SkillScope,
-): SkillDefinition[] {
- if (!fs.existsSync(dir)) {
- return [];
- }
+/**
+ * Recursively scan a directory for .md skill files.
+ * The `directory` field on each skill is the relative path from `baseDir` to the file's parent.
+ * Skips the `agents/` subdirectory (handled separately).
+ */
+function scanSkillsRecursive(baseDir: string, scope: SkillScope): SkillDefinition[] {
+ if (!fs.existsSync(baseDir)) return [];
const results: SkillDefinition[] = [];
- let entries: fs.Dirent[];
- try {
- entries = fs.readdirSync(dir, { withFileTypes: true });
- } catch {
- return [];
- }
- for (const entry of entries) {
- if (!entry.isFile() || !entry.name.endsWith(".md")) {
- continue;
- }
- const filePath = path.join(dir, entry.name);
+ function walk(dir: string) {
+ let entries: fs.Dirent[];
try {
- const content = fs.readFileSync(filePath, "utf-8");
- const skill = parseSkillFile(
- filePath,
- content,
- scope,
- // We'll set directory based on the parent dir segment — caller sets it
- "default",
- );
- results.push(skill);
+ entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
- // Skip unreadable files
+ return;
}
- }
-
- return results;
-}
-
-function loadSkillsFromDirWithDirectory(
- dir: string,
- scope: SkillScope,
- directory: SkillDefinition["directory"],
-): SkillDefinition[] {
- if (!fs.existsSync(dir)) {
- return [];
- }
- const results: SkillDefinition[] = [];
- let entries: fs.Dirent[];
- try {
- entries = fs.readdirSync(dir, { withFileTypes: true });
- } catch {
- return [];
- }
-
- for (const entry of entries) {
- if (!entry.isFile() || !entry.name.endsWith(".md")) {
- continue;
- }
- const filePath = path.join(dir, entry.name);
- try {
- const content = fs.readFileSync(filePath, "utf-8");
- const skill = parseSkillFile(filePath, content, scope, directory);
- results.push(skill);
- } catch {
- // Skip unreadable files
+ for (const entry of entries) {
+ const fullPath = path.join(dir, entry.name);
+
+ if (entry.isDirectory()) {
+ // Skip agents/ at the top level (handled by loadAgentMappings)
+ const relFromBase = path.relative(baseDir, fullPath);
+ if (relFromBase === "agents") continue;
+ walk(fullPath);
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
+ const relDir = path.relative(baseDir, dir);
+ // relDir is "" for root, "general" for general/, "general/webapps" for nested
+ const directory = relDir === "." ? "" : relDir;
+ try {
+ const content = fs.readFileSync(fullPath, "utf-8");
+ const skill = parseSkillFile(fullPath, content, scope, directory);
+ results.push(skill);
+ } catch {
+ // Skip unreadable files
+ }
+ }
}
}
+ walk(baseDir);
return results;
}
-function loadAgentMappings(
- agentsDir: string,
- scope: SkillScope,
-): AgentSkillMapping[] {
+function loadAgentMappings(agentsDir: string, scope: SkillScope): AgentSkillMapping[] {
if (!fs.existsSync(agentsDir)) {
return [];
}
@@ -141,44 +110,16 @@ export function loadSkills(projectDir: string): {
const skills: SkillDefinition[] = [];
const mappings: AgentSkillMapping[] = [];
- // 1. Global default/
- skills.push(
- ...loadSkillsFromDirWithDirectory(
- path.join(globalBase, "default"),
- "global",
- "default",
- ),
- );
+ // 1. Scan all global skills recursively (skipping agents/)
+ skills.push(...scanSkillsRecursive(globalBase, "global"));
- // 2. Project default/
- skills.push(
- ...loadSkillsFromDirWithDirectory(
- path.join(projectBase, "default"),
- "project",
- "default",
- ),
- );
+ // 2. Scan all project skills recursively (skipping agents/)
+ skills.push(...scanSkillsRecursive(projectBase, "project"));
// 3. Agent mappings — global then project
mappings.push(...loadAgentMappings(path.join(globalBase, "agents"), "global"));
mappings.push(...loadAgentMappings(path.join(projectBase, "agents"), "project"));
- // 4. Project/ skills (manually activated)
- skills.push(
- ...loadSkillsFromDirWithDirectory(
- path.join(globalBase, "project"),
- "global",
- "project",
- ),
- );
- skills.push(
- ...loadSkillsFromDirWithDirectory(
- path.join(projectBase, "project"),
- "project",
- "project",
- ),
- );
-
return { skills, mappings };
}
@@ -305,6 +246,3 @@ export function createSkillsWatcher(
},
};
}
-
-// Keep loadSkillsFromDir exported for potential testing use
-export { loadSkillsFromDir };
diff --git a/packages/core/src/skills/parser.ts b/packages/core/src/skills/parser.ts
index 4df5450..289073f 100644
--- a/packages/core/src/skills/parser.ts
+++ b/packages/core/src/skills/parser.ts
@@ -1,6 +1,6 @@
-import { parse } from "smol-toml";
import * as path from "node:path";
-import type { SkillDefinition, SkillScope, SkillDirectory } from "../types/index.js";
+import { parse } from "smol-toml";
+import type { SkillDefinition, SkillDirectory, SkillScope } from "../types/index.js";
const FRONTMATTER_DELIMITER = "+++";
diff --git a/packages/core/src/tools/bash-arity.ts b/packages/core/src/tools/bash-arity.ts
index 5dde955..1aaba8e 100644
--- a/packages/core/src/tools/bash-arity.ts
+++ b/packages/core/src/tools/bash-arity.ts
@@ -1,37 +1,37 @@
// Hardcoded dictionary of well-known commands and their arity
// (number of tokens that form the "human-understandable" prefix)
const ARITY: Record<string, number> = {
- "git": 2, // "git checkout", "git commit", etc.
- "npm": 3, // "npm run dev", "npm install -g"
- "docker": 2, // "docker compose", "docker build"
- "kubectl": 2, // "kubectl get", "kubectl apply"
- "bun": 2, // "bun install", "bun test"
- "cargo": 2, // "cargo build", "cargo test"
- "go": 2, // "go build", "go test"
- "python": 2, // "python -m", "python script.py"
- "python3": 2,
- "pip": 2,
- "pip3": 2,
- "brew": 2,
- "apt": 2,
+ git: 2, // "git checkout", "git commit", etc.
+ npm: 3, // "npm run dev", "npm install -g"
+ docker: 2, // "docker compose", "docker build"
+ kubectl: 2, // "kubectl get", "kubectl apply"
+ bun: 2, // "bun install", "bun test"
+ cargo: 2, // "cargo build", "cargo test"
+ go: 2, // "go build", "go test"
+ python: 2, // "python -m", "python script.py"
+ python3: 2,
+ pip: 2,
+ pip3: 2,
+ brew: 2,
+ apt: 2,
"apt-get": 2,
- "dnf": 2,
- "yum": 2,
- "pacman": 2,
- "systemctl": 2,
- "journalctl": 2,
- "ssh": 2,
- "scp": 2,
- "rsync": 2,
- "curl": 2, // "curl -X", "curl https://"
- "wget": 2,
- "tar": 2, // "tar -xzf", "tar -czf"
- "zip": 2,
- "unzip": 2,
- "chown": 2,
- "chmod": 2,
- "mount": 2,
- "umount": 2,
+ dnf: 2,
+ yum: 2,
+ pacman: 2,
+ systemctl: 2,
+ journalctl: 2,
+ ssh: 2,
+ scp: 2,
+ rsync: 2,
+ curl: 2, // "curl -X", "curl https://"
+ wget: 2,
+ tar: 2, // "tar -xzf", "tar -czf"
+ zip: 2,
+ unzip: 2,
+ chown: 2,
+ chmod: 2,
+ mount: 2,
+ umount: 2,
// Default: all other commands are arity 1
};
diff --git a/packages/core/src/tools/run-shell.ts b/packages/core/src/tools/run-shell.ts
index d549316..608c91d 100644
--- a/packages/core/src/tools/run-shell.ts
+++ b/packages/core/src/tools/run-shell.ts
@@ -11,12 +11,12 @@ export function createRunShellTool(workingDirectory: string): ToolDefinition {
"Execute a shell command in the working directory. Returns stdout, stderr, and exit code. Use for running tests, builds, git operations, package management, and other development tasks.",
parameters: z.object({
command: z.string().describe("The shell command to execute"),
- timeout: z
- .number()
- .optional()
- .describe("Timeout in milliseconds (default 2 minutes)"),
+ timeout: z.number().optional().describe("Timeout in milliseconds (default 2 minutes)"),
}),
- execute: async (args: Record<string, unknown>, context?: ToolExecuteContext): Promise<string> => {
+ execute: async (
+ args: Record<string, unknown>,
+ context?: ToolExecuteContext,
+ ): Promise<string> => {
const command = args.command as string;
const timeout = (args.timeout as number | undefined) ?? DEFAULT_TIMEOUT;
@@ -68,7 +68,5 @@ export function createRunShellTool(workingDirectory: string): ToolDefinition {
}
function getShell(): [string, string[]] {
- return process.platform === "win32"
- ? ["powershell", ["-Command"]]
- : ["bash", ["-c"]];
+ return process.platform === "win32" ? ["powershell", ["-Command"]] : ["bash", ["-c"]];
}
diff --git a/packages/core/src/tools/shell-analyze.ts b/packages/core/src/tools/shell-analyze.ts
index 23bbfc9..b70108b 100644
--- a/packages/core/src/tools/shell-analyze.ts
+++ b/packages/core/src/tools/shell-analyze.ts
@@ -1,6 +1,6 @@
-import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
+import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
import * as BashArity from "./bash-arity.js";
// Commands that touch files — triggers external_directory check.
@@ -12,9 +12,27 @@ import * as BashArity from "./bash-arity.js";
// - `cd` state changes: we don't track cwd mutations across pipeline stages
// - Interpreter escapes: `python -c "open('/etc/passwd')"`, `node -e "..."` bypass this entirely
const FILE_COMMANDS = new Set([
- "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown",
- "cat", "ls", "find", "grep",
- "head", "tail", "less", "more", "wc", "diff", "file", "stat", "du", "df",
+ "rm",
+ "cp",
+ "mv",
+ "mkdir",
+ "touch",
+ "chmod",
+ "chown",
+ "cat",
+ "ls",
+ "find",
+ "grep",
+ "head",
+ "tail",
+ "less",
+ "more",
+ "wc",
+ "diff",
+ "file",
+ "stat",
+ "du",
+ "df",
]);
// Lazy-initialized parser
@@ -142,6 +160,7 @@ function isInsideWorkspace(filePath: string, wd: string): boolean {
// rel === "" means filePath IS the workspace root — that is inside.
// If relative path starts with "../" or is ".." exactly, or is an absolute path
// (on Windows when drives differ), the file is outside the workspace.
- const isOutside = rel.startsWith(`..${sep}`) || rel.startsWith("../") || rel === ".." || isAbsolute(rel);
+ const isOutside =
+ rel.startsWith(`..${sep}`) || rel.startsWith("../") || rel === ".." || isAbsolute(rel);
return !isOutside;
}
diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts
index eaf8669..8f80b08 100644
--- a/packages/core/src/types/index.ts
+++ b/packages/core/src/types/index.ts
@@ -110,7 +110,7 @@ export interface KeyState {
// ─── Skills Types ────────────────────────────────────────────────
export type SkillScope = "global" | "project";
-export type SkillDirectory = "default" | "agents" | "project";
+export type SkillDirectory = string;
export interface SkillDefinition {
name: string;
@@ -146,3 +146,29 @@ export interface ConfigError {
path: string;
message: string;
}
+
+// ─── Agent Definition Types ──────────────────────────────────────
+
+export interface AgentModelEntry {
+ key_id: string;
+ model_id: string;
+}
+
+export interface AgentDefinition {
+ /** Human-readable name */
+ name: string;
+ /** Short description of what this agent does */
+ description: string;
+ /** Skills to auto-include, as "scope:name" strings */
+ skills: string[];
+ /** Allowed tools (allowlist) */
+ tools: string[];
+ /** Key+model fallback hierarchy, tried in order */
+ models: AgentModelEntry[];
+ /** Where the TOML was loaded from: "global" or a directory path */
+ scope: string;
+ /** The slug (filename without .toml) */
+ slug: string;
+ /** Default working directory for this agent (optional, absolute path) */
+ cwd?: string;
+}
diff --git a/packages/core/tests/config/loader.test.ts b/packages/core/tests/config/loader.test.ts
index 5c02326..a572425 100644
--- a/packages/core/tests/config/loader.test.ts
+++ b/packages/core/tests/config/loader.test.ts
@@ -1,7 +1,7 @@
+import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, sep } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { writeFileSync, mkdirSync, rmSync } from "node:fs";
import { configToRuleset, loadConfig } from "../../src/config/loader.js";
const TMP = join("/tmp/opencode", "dispatch-config-test");
diff --git a/packages/core/tests/llm/provider.test.ts b/packages/core/tests/llm/provider.test.ts
index 9b5439e..2a98556 100644
--- a/packages/core/tests/llm/provider.test.ts
+++ b/packages/core/tests/llm/provider.test.ts
@@ -31,16 +31,21 @@ const { createProvider } = await import("../../src/llm/provider.js");
// A helper that runs the middleware's transformParams on a prompt
// and returns the resulting normalized prompt.
-async function runTransform(
- prompt: unknown[],
-): Promise<unknown[]> {
+async function runTransform(prompt: unknown[]): Promise<unknown[]> {
const wrappedModel = createProvider({
apiKey: "test-key",
baseURL: "https://example.com/v1",
})("test-model");
const middleware = (
- (wrappedModel as unknown) as { _middleware: Array<{ transformParams: (args: { type: string; params: Record<string, unknown> }) => Promise<unknown> }> }
+ wrappedModel as unknown as {
+ _middleware: Array<{
+ transformParams: (args: {
+ type: string;
+ params: Record<string, unknown>;
+ }) => Promise<unknown>;
+ }>;
+ }
)._middleware;
const result = await middleware[0]!.transformParams({
@@ -59,7 +64,14 @@ describe("createProvider middleware", () => {
})("test-model");
const middleware = (
- (wrappedModel as unknown) as { _middleware: Array<{ transformParams: (args: { type: string; params: Record<string, unknown> }) => Promise<unknown> }> }
+ wrappedModel as unknown as {
+ _middleware: Array<{
+ transformParams: (args: {
+ type: string;
+ params: Record<string, unknown>;
+ }) => Promise<unknown>;
+ }>;
+ }
)._middleware;
const params = { prompt: [], temperature: 0.5 };
@@ -108,9 +120,7 @@ describe("createProvider middleware", () => {
const prompt = [
{
role: "assistant",
- content: [
- { type: "text", text: "Hello!" },
- ],
+ content: [{ type: "text", text: "Hello!" }],
},
];
@@ -141,9 +151,7 @@ describe("createProvider middleware", () => {
});
it("does not modify system messages", async () => {
- const prompt = [
- { role: "system", content: "You are a helpful assistant." },
- ];
+ const prompt = [{ role: "system", content: "You are a helpful assistant." }];
const normalized = await runTransform(prompt);
expect(normalized).toEqual(prompt);
@@ -274,11 +282,17 @@ describe("createProvider middleware", () => {
const normalized = await runTransform(prompt);
const msg1 = normalized[0] as Record<string, unknown>;
- const compat1 = (msg1.providerMetadata as Record<string, unknown>).openaiCompatible as Record<string, unknown>;
+ const compat1 = (msg1.providerMetadata as Record<string, unknown>).openaiCompatible as Record<
+ string,
+ unknown
+ >;
expect(compat1.reasoning_content).toBe("First thought.");
const msg2 = normalized[1] as Record<string, unknown>;
- const compat2 = (msg2.providerMetadata as Record<string, unknown>).openaiCompatible as Record<string, unknown>;
+ const compat2 = (msg2.providerMetadata as Record<string, unknown>).openaiCompatible as Record<
+ string,
+ unknown
+ >;
expect(compat2.reasoning_content).toBe("Second thought.");
});
});
diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte
index 8288dfd..33415bd 100644
--- a/packages/frontend/src/App.svelte
+++ b/packages/frontend/src/App.svelte
@@ -1,16 +1,18 @@
<script lang="ts">
import { onMount } from "svelte";
+import AgentBuilder from "./lib/components/AgentBuilder.svelte";
import ChatInput from "./lib/components/ChatInput.svelte";
import ChatPanel from "./lib/components/ChatPanel.svelte";
import Header from "./lib/components/Header.svelte";
-import TabBar from "./lib/components/TabBar.svelte";
+import HotReloadIndicator from "./lib/components/HotReloadIndicator.svelte";
import PermissionPrompt from "./lib/components/PermissionPrompt.svelte";
import SidebarPanel from "./lib/components/SidebarPanel.svelte";
-import HotReloadIndicator from "./lib/components/HotReloadIndicator.svelte";
-import { tabStore } from "./lib/tabs.svelte.js";
-import { wsClient } from "./lib/ws.svelte.js";
+import TabBar from "./lib/components/TabBar.svelte";
import { config } from "./lib/config.js";
+import { router } from "./lib/router.svelte.js";
+import { tabStore } from "./lib/tabs.svelte.js";
import type { KeyInfo } from "./lib/types.js";
+import { wsClient } from "./lib/ws.svelte.js";
const STORAGE_KEY = "dispatch-theme";
@@ -66,6 +68,7 @@ onMount(() => {
<div class="flex flex-col h-screen overflow-hidden">
<Header onToggleSidebar={() => sidebarOpen = !sidebarOpen} />
+ {#if router.page === "dashboard"}
<div class="flex flex-1 overflow-hidden relative">
<!-- Main chat area -->
<div class="flex flex-col flex-1 min-w-0 overflow-hidden">
@@ -85,7 +88,7 @@ onMount(() => {
class:w-0={!sidebarOpen}
>
<div
- class="w-80 flex-1 min-h-0 overflow-y-auto bg-base-100 border-l border-base-300 px-2 py-2 flex flex-col gap-2 [&>*]:shrink-0 transition-transform duration-300 ease-out"
+ class="w-80 flex-1 min-h-0 overflow-y-auto bg-base-100 px-2 py-2 flex flex-col gap-2 [&>*]:shrink-0 transition-transform duration-300 ease-out"
style="transform: translateX({sidebarOpen ? '0' : '100%'})"
>
<SidebarPanel
@@ -96,6 +99,8 @@ onMount(() => {
activeKeyId={tabStore.activeTab?.keyId ?? null}
activeModelId={tabStore.activeTab?.modelId ?? null}
reasoningEffort={tabStore.activeTab?.reasoningEffort ?? "max"}
+ activeAgentSlug={tabStore.activeTab?.agentSlug ?? null}
+ workingDirectory={tabStore.activeTab?.workingDirectory ?? null}
onKeyChange={(keyId) => tabStore.setKey(keyId)}
onModelChange={(keyId, modelId) => tabStore.changeModel(keyId, modelId)}
onReasoningChange={(effort) => {
@@ -105,10 +110,15 @@ onMount(() => {
tabStore.tabs.find(t => t.id === tab.id)!.reasoningEffort = effort;
}
}}
+ onAgentChange={(agent) => tabStore.setAgent(agent)}
+ onWorkingDirectoryChange={(dir) => tabStore.setWorkingDirectory(dir)}
/>
</div>
</div>
</div>
+ {:else if router.page === "agent-builder"}
+ <AgentBuilder keys={modelsData.keys} />
+ {/if}
</div>
<!-- Backdrop for sidebar on small screens -->
@@ -116,7 +126,11 @@ onMount(() => {
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 bg-black/30 z-20 sm:hidden"
+ role="button"
+ tabindex="0"
onclick={() => sidebarOpen = false}
+ onkeydown={(e) => { if (e.key === 'Escape' || e.key === 'Enter') sidebarOpen = false; }}
+ aria-label="Close sidebar"
></div>
{/if}
diff --git a/packages/frontend/src/app.css b/packages/frontend/src/app.css
index a9ee1b6..169f7e4 100644
--- a/packages/frontend/src/app.css
+++ b/packages/frontend/src/app.css
@@ -1,31 +1,57 @@
@import "tailwindcss";
@import "highlight.js/styles/atom-one-dark.min.css";
@plugin "daisyui" {
- themes: light, dark, dracula, night, nord, sunset, cyberpunk, forest, cmyk, coffee, caramellatte, garden, luxury;
+ themes:
+ light, dark, dracula, night, nord, sunset, cyberpunk, forest, cmyk, coffee, caramellatte,
+ garden, luxury;
}
.markdown-body {
& p {
margin-block: 0.5em;
- &:first-child { margin-block-start: 0; }
- &:last-child { margin-block-end: 0; }
+ &:first-child {
+ margin-block-start: 0;
+ }
+ &:last-child {
+ margin-block-end: 0;
+ }
}
- & h1, & h2, & h3, & h4, & h5, & h6 {
+ & h1,
+ & h2,
+ & h3,
+ & h4,
+ & h5,
+ & h6 {
font-weight: 600;
line-height: 1.25;
margin-block: 0.75em 0.25em;
- &:first-child { margin-block-start: 0; }
+ &:first-child {
+ margin-block-start: 0;
+ }
}
- & h1 { font-size: 1.4em; }
- & h2 { font-size: 1.2em; }
- & h3 { font-size: 1.1em; }
- & ul, & ol {
+ & h1 {
+ font-size: 1.4em;
+ }
+ & h2 {
+ font-size: 1.2em;
+ }
+ & h3 {
+ font-size: 1.1em;
+ }
+ & ul,
+ & ol {
padding-inline-start: 1.5em;
margin-block: 0.5em;
}
- & ul { list-style-type: disc; }
- & ol { list-style-type: decimal; }
- & li { margin-block: 0.15em; }
+ & ul {
+ list-style-type: disc;
+ }
+ & ol {
+ list-style-type: decimal;
+ }
+ & li {
+ margin-block: 0.15em;
+ }
& pre {
overflow-x: auto;
border-radius: var(--radius-box);
@@ -54,16 +80,21 @@
& a {
color: oklch(var(--color-primary));
text-decoration: underline;
- &:hover { opacity: 0.8; }
+ &:hover {
+ opacity: 0.8;
+ }
+ }
+ & strong {
+ font-weight: 600;
}
- & strong { font-weight: 600; }
& table {
width: 100%;
border-collapse: collapse;
margin-block: 0.5em;
font-size: 0.875em;
}
- & th, & td {
+ & th,
+ & td {
border: 1px solid oklch(var(--color-base-content) / 0.15);
padding: 0.4em 0.75em;
text-align: start;
diff --git a/packages/frontend/src/lib/components/AgentBuilder.svelte b/packages/frontend/src/lib/components/AgentBuilder.svelte
new file mode 100644
index 0000000..4a2d5d8
--- /dev/null
+++ b/packages/frontend/src/lib/components/AgentBuilder.svelte
@@ -0,0 +1,746 @@
+<script module>
+// Session-level cache for models per key; avoids refetching across component instances
+const modelCache = new Map();
+</script>
+
+<script lang="ts">
+ import { config } from "../config.js";
+ import { router } from "../router.svelte.js";
+ import type { KeyInfo } from "../types.js";
+ import SkillsBrowser from "./SkillsBrowser.svelte";
+ import ToolPermissions from "./ToolPermissions.svelte";
+
+ interface AgentModelEntry {
+ key_id: string;
+ model_id: string;
+ }
+
+ interface AgentDefinition {
+ name: string;
+ description: string;
+ skills: string[];
+ tools: string[];
+ models: AgentModelEntry[];
+ scope: string;
+ slug: string;
+ cwd?: string;
+ }
+
+ interface DirEntry {
+ label: string;
+ path: string;
+ scope: string;
+ }
+
+ const { keys = [] }: { keys?: KeyInfo[] } = $props();
+
+
+
+ // Portal action (escape stacking context)
+ function portal(node: HTMLElement) {
+ document.body.appendChild(node);
+ return {
+ destroy() {
+ node.remove();
+ },
+ };
+ }
+
+ // State
+ let agents = $state<AgentDefinition[]>([]);
+ let dirs = $state<DirEntry[]>([]);
+ let loading = $state(false);
+ let error = $state<string | null>(null);
+
+ // Editor state
+ let editing = $state(false);
+ let editingSlug = $state<string | null>(null); // null = new agent
+
+ let formName = $state("");
+ let formDescription = $state("");
+ let formScope = $state("global");
+ let formCwd = $state("");
+ let cwdExists = $state<boolean | null>(null);
+ let cwdCheckTimer: ReturnType<typeof setTimeout> | null = null;
+ let formSkills = $state<Set<string>>(new Set());
+ let formTools = $state<Set<string>>(new Set());
+ let formModels = $state<AgentModelEntry[]>([]);
+
+ // Model selection modal state
+ let modelModalIndex = $state<number | null>(null);
+ let modelModalType = $state<"key" | "model">("key");
+ let modalAvailableModels = $state<string[]>([]);
+ let modalLoadingModels = $state(false);
+ let modalModelError = $state<string | null>(null);
+
+ // Drag-and-drop reorder state
+ let dragIndex = $state<number | null>(null);
+ let dragOverIndex = $state<number | null>(null);
+
+ // Delete confirm
+ let deletingAgent = $state<AgentDefinition | null>(null);
+
+ function slugify(name: string): string {
+ return name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ || "agent";
+ }
+
+ async function fetchAgents() {
+ loading = true;
+ error = null;
+ try {
+ const res = await fetch(`${config.apiBase}/agents`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data = await res.json();
+ agents = data.agents ?? [];
+ dirs = data.dirs ?? [];
+ } catch (e) {
+ error = e instanceof Error ? e.message : "Failed to fetch agents";
+ } finally {
+ loading = false;
+ }
+ }
+
+ function handleSkillToggle(key: string, checked: boolean) {
+ const next = new Set(formSkills);
+ if (checked) next.add(key);
+ else next.delete(key);
+ formSkills = next;
+ }
+
+ function startNewAgent() {
+ formReady = false;
+ editingSlug = null;
+ formName = "";
+ formDescription = "";
+ formScope = dirs[0]?.scope ?? "global";
+ formCwd = "";
+ formSkills = new Set();
+ formTools = new Set();
+ formModels = [];
+ editing = true;
+ // Allow the effect to skip the initial population
+ setTimeout(() => { formReady = true; }, 0);
+ }
+
+ function startEdit(agent: AgentDefinition) {
+ formReady = false;
+ editingSlug = agent.slug;
+ formName = agent.name;
+ formDescription = agent.description;
+ formScope = agent.scope;
+ formCwd = agent.cwd ?? "";
+ formSkills = new Set(agent.skills);
+ formTools = new Set(agent.tools);
+ formModels = agent.models.map((m) => ({ ...m }));
+ editing = true;
+ // Allow the effect to skip the initial population
+ setTimeout(() => { formReady = true; }, 0);
+ }
+
+ function cancelEdit() {
+ // Flush any pending debounced save before leaving
+ if (debounceTimer) {
+ clearTimeout(debounceTimer);
+ debounceTimer = null;
+ saveAgent();
+ }
+ formReady = false;
+ editing = false;
+ editingSlug = null;
+ }
+
+ function handleToolToggle(id: string, checked: boolean) {
+ const next = new Set(formTools);
+ if (checked) next.add(id);
+ else next.delete(id);
+ formTools = next;
+ }
+
+ function addModelEntry() {
+ formModels = [...formModels, { key_id: "", model_id: "" }];
+ }
+
+ function removeModelEntry(i: number) {
+ formModels = formModels.filter((_, idx) => idx !== i);
+ }
+
+ async function openKeyModal(i: number) {
+ modelModalIndex = i;
+ modelModalType = "key";
+ }
+
+ async function openModelModal(i: number) {
+ const entry = formModels[i];
+ if (!entry || !entry.key_id) return;
+ modelModalIndex = i;
+ modelModalType = "model";
+ modalModelError = null;
+ modalAvailableModels = [];
+
+ if (modelCache.has(entry.key_id)) {
+ modalAvailableModels = modelCache.get(entry.key_id)!;
+ return;
+ }
+
+ modalLoadingModels = true;
+ try {
+ const res = await fetch(
+ `${config.apiBase}/models/available?keyId=${encodeURIComponent(entry.key_id)}`,
+ );
+ if (!res.ok) {
+ const d = await res.json().catch(() => ({}));
+ modalModelError = d.error ?? `HTTP ${res.status}`;
+ return;
+ }
+ const d = await res.json();
+ modalAvailableModels = d.models ?? [];
+ modelCache.set(entry.key_id, modalAvailableModels);
+ } catch (e) {
+ modalModelError = e instanceof Error ? e.message : "Failed";
+ } finally {
+ modalLoadingModels = false;
+ }
+ }
+
+ function selectKey(keyId: string) {
+ if (modelModalIndex === null) return;
+ const idx = modelModalIndex;
+ formModels = formModels.map((m, i) =>
+ i === idx ? { key_id: keyId, model_id: "" } : m,
+ );
+ modelModalIndex = null;
+ // Immediately open model selection for the same row
+ openModelModal(idx);
+ }
+
+ function selectModel(model: string) {
+ if (modelModalIndex === null) return;
+ formModels = formModels.map((m, i) =>
+ i === modelModalIndex ? { ...m, model_id: model } : m,
+ );
+ modelModalIndex = null;
+ }
+
+ function closeModal() {
+ modelModalIndex = null;
+ }
+
+ let formError = $state<string | null>(null);
+ let saving = $state(false);
+ let formReady = false;
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
+ let saveAbort: AbortController | null = null;
+
+ function isFormValid(): boolean {
+ if (!formName.trim()) return false;
+ if (formModels.length === 0) return false;
+ if (formModels.some((m) => !m.key_id || !m.model_id)) return false;
+ return true;
+ }
+
+ async function saveAgent() {
+ if (!isFormValid()) return;
+ formError = null;
+
+ // Cancel any in-flight save
+ saveAbort?.abort();
+ const controller = new AbortController();
+ saveAbort = controller;
+
+ const payload: AgentDefinition = {
+ name: formName.trim(),
+ description: formDescription.trim(),
+ skills: Array.from(formSkills),
+ tools: Array.from(formTools),
+ models: formModels,
+ scope: formScope,
+ slug: editingSlug ?? slugify(formName.trim()),
+ ...(formCwd.trim() ? { cwd: formCwd.trim() } : {}),
+ };
+
+ saving = true;
+ try {
+ const res = await fetch(`${config.apiBase}/agents`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ signal: controller.signal,
+ });
+ if (!res.ok) {
+ const d = await res.json().catch(() => ({}));
+ formError = d.error ?? `HTTP ${res.status}`;
+ return;
+ }
+ // If this was a new agent, lock in the slug for future saves
+ if (!editingSlug) {
+ editingSlug = payload.slug;
+ }
+ await fetchAgents();
+ } catch (e) {
+ if (e instanceof DOMException && e.name === "AbortError") return;
+ formError = e instanceof Error ? e.message : "Failed to save";
+ } finally {
+ if (saveAbort === controller) {
+ saving = false;
+ saveAbort = null;
+ }
+ }
+ }
+
+ // Check if CWD directory exists (debounced)
+ $effect(() => {
+ const cwd = formCwd;
+ if (!cwd.trim()) {
+ cwdExists = null;
+ return;
+ }
+ if (cwdCheckTimer) clearTimeout(cwdCheckTimer);
+ cwdCheckTimer = setTimeout(async () => {
+ try {
+ const res = await fetch(
+ `${config.apiBase}/agents/check-dir?path=${encodeURIComponent(cwd.trim())}`,
+ );
+ if (res.ok) {
+ const data = await res.json();
+ cwdExists = data.exists ?? false;
+ }
+ } catch {
+ cwdExists = null;
+ }
+ }, 300);
+ });
+
+ // Auto-save with debounce whenever form fields change
+ $effect(() => {
+ // Read all reactive form fields to subscribe
+ const _ = [formName, formDescription, formScope, formCwd, formSkills, formTools, formModels];
+ if (!formReady || !editing) return;
+ if (debounceTimer) clearTimeout(debounceTimer);
+ debounceTimer = setTimeout(() => {
+ saveAgent();
+ }, 600);
+ });
+
+ async function deleteAgent(agent: AgentDefinition) {
+ try {
+ // Prevent auto-save from re-creating the deleted agent
+ formReady = false;
+ if (debounceTimer) {
+ clearTimeout(debounceTimer);
+ debounceTimer = null;
+ }
+ saveAbort?.abort();
+
+ const res = await fetch(
+ `${config.apiBase}/agents/${encodeURIComponent(agent.slug)}?scope=${encodeURIComponent(agent.scope)}`,
+ { method: "DELETE" },
+ );
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ deletingAgent = null;
+ editing = false;
+ editingSlug = null;
+ await fetchAgents();
+ } catch (e) {
+ error = e instanceof Error ? e.message : "Failed to delete";
+ }
+ }
+
+ const hasName = $derived(formName.trim().length > 0);
+
+ const globalAgents = $derived(agents.filter((a) => a.scope === "global"));
+ const projectAgents = $derived(agents.filter((a) => a.scope !== "global"));
+
+ // Fetch on mount
+ $effect(() => {
+ fetchAgents();
+ });
+</script>
+
+<div class="flex flex-col flex-1 overflow-hidden">
+ <!-- Page header -->
+ <div class="flex items-center gap-4 px-6 py-4 border-b border-base-300 bg-base-200 shrink-0">
+ <h1 class="text-xl font-bold flex-1">Agent Settings</h1>
+ <button
+ type="button"
+ class="btn btn-ghost btn-sm"
+ onclick={() => { if (editing) { cancelEdit(); } else { router.navigate("dashboard"); } }}
+ >
+ {editing ? '← Back to Agent Settings' : '← Back to Dashboard'}
+ </button>
+ </div>
+
+ <div class="flex-1 overflow-y-auto px-6 py-6">
+ {#if error}
+ <div class="alert alert-error mb-4">{error}</div>
+ {/if}
+
+ {#if editing}
+ <!-- Agent Editor Form -->
+ <div class="card bg-base-200 shadow-md">
+ <div class="card-body gap-4">
+ <h2 class="card-title">{editingSlug ? "Edit Agent" : "New Agent"}</h2>
+
+ {#if formError}
+ <div class="alert alert-error text-sm">{formError}</div>
+ {/if}
+
+ <!-- Name -->
+ <div class="form-control gap-1">
+ <label class="label py-0" for="agent-name">
+ <span class="label-text font-semibold">Name *</span>
+ </label>
+ <input
+ id="agent-name"
+ type="text"
+ class="input input-bordered"
+ placeholder="my-agent"
+ bind:value={formName}
+ />
+ {#if formName}
+ <span class="text-xs text-base-content/50">slug: {slugify(formName)}</span>
+ {/if}
+ </div>
+
+ <fieldset disabled={!hasName} class="contents">
+ <!-- Description -->
+ <div class="form-control gap-1">
+ <label class="label py-0" for="agent-desc">
+ <span class="label-text font-semibold">Description</span>
+ </label>
+ <input
+ id="agent-desc"
+ type="text"
+ class="input input-bordered"
+ placeholder="What does this agent do?"
+ bind:value={formDescription}
+ />
+ </div>
+
+ <!-- Scope -->
+ <div class="form-control gap-1">
+ <label class="label py-0" for="agent-scope">
+ <span class="label-text font-semibold">Scope</span>
+ </label>
+ <select id="agent-scope" class="select select-bordered" bind:value={formScope}>
+ <option value="global">Global</option>
+ {#each dirs.filter((d) => d.scope !== "global") as dir}
+ <option value={dir.scope}>{dir.label} ({dir.path})</option>
+ {/each}
+ </select>
+ </div>
+
+ <!-- Working Directory -->
+ <div class="form-control gap-1">
+ <label class="label py-0" for="agent-cwd">
+ <span class="label-text font-semibold">Working Directory</span>
+ </label>
+ <div class="flex items-center gap-2">
+ <input
+ id="agent-cwd"
+ type="text"
+ class="input input-bordered font-mono text-sm flex-1"
+ placeholder="default (project root)"
+ bind:value={formCwd}
+ />
+ {#if formCwd.trim()}
+ {#if cwdExists === true}
+ <span class="text-success text-lg" title="Directory exists">&#x2714;</span>
+ {:else if cwdExists === false}
+ <span class="text-warning text-lg" title="Directory does not exist (will be created)">&#x2716;</span>
+ {:else}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ {/if}
+ </div>
+ {#if cwdExists === false && formCwd.trim()}
+ <span class="text-xs text-warning">Directory will be created automatically on first message.</span>
+ {:else}
+ <span class="text-xs text-base-content/50">Absolute path. Leave empty to use the project root.</span>
+ {/if}
+ </div>
+
+ <!-- Models -->
+ <div class="form-control gap-2">
+ <div class="label py-0">
+ <span class="label-text font-semibold">Models *</span>
+ </div>
+ <div class="flex flex-col gap-2">
+ {#each formModels as entry, i (i)}
+ <div
+ class="flex items-center gap-2 rounded p-1 transition-colors {dragOverIndex === i ? 'bg-primary/10 border border-primary/30' : ''}"
+ draggable="true"
+ role="listitem"
+ ondragstart={(e) => {
+ dragIndex = i;
+ if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
+ }}
+ ondragover={(e) => {
+ e.preventDefault();
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
+ dragOverIndex = i;
+ }}
+ ondragleave={() => {
+ if (dragOverIndex === i) dragOverIndex = null;
+ }}
+ ondrop={(e) => {
+ e.preventDefault();
+ if (dragIndex !== null && dragIndex !== i) {
+ const reordered = [...formModels];
+ const moved = reordered.splice(dragIndex, 1)[0];
+ if (moved) {
+ reordered.splice(i, 0, moved);
+ formModels = reordered;
+ }
+ }
+ dragIndex = null;
+ dragOverIndex = null;
+ }}
+ ondragend={() => { dragIndex = null; dragOverIndex = null; }}
+ >
+ <span class="badge badge-sm badge-neutral font-mono w-6 shrink-0 cursor-grab">{i + 1}</span>
+ <button
+ type="button"
+ class="btn btn-sm btn-outline flex-1 font-mono truncate"
+ onclick={() => openKeyModal(i)}
+ >
+ {entry.key_id || "Select Key"}
+ </button>
+ <button
+ type="button"
+ class="btn btn-sm btn-outline flex-1 font-mono truncate"
+ onclick={() => openModelModal(i)}
+ disabled={!entry.key_id}
+ >
+ {entry.model_id || "Select Model"}
+ </button>
+ <button
+ type="button"
+ class="btn btn-sm btn-ghost text-error"
+ onclick={() => removeModelEntry(i)}
+ aria-label="Remove model entry"
+ >
+ ✕
+ </button>
+ </div>
+ {/each}
+ </div>
+ <button
+ type="button"
+ class="btn btn-sm btn-ghost w-fit"
+ onclick={addModelEntry}
+ >
+ + Add Model
+ </button>
+ </div>
+
+ <!-- Tools -->
+ <div class="form-control gap-2">
+ <div class="label py-0">
+ <span class="label-text font-semibold">Tools</span>
+ </div>
+ <ToolPermissions
+ checkedTools={formTools}
+ onToolToggle={handleToolToggle}
+ />
+ </div>
+
+ <!-- Skills -->
+ <div class="form-control gap-2">
+ <div class="label py-0">
+ <span class="label-text font-semibold">Skills</span>
+ </div>
+ <SkillsBrowser
+ apiBase={config.apiBase}
+ checkedSkills={formSkills}
+ onSkillToggle={handleSkillToggle}
+ />
+ </div>
+
+ <!-- Actions -->
+ <div class="card-actions justify-between items-center pt-2">
+ {#if editingSlug && !(editingSlug === "default" && formScope === "global")}
+ <button
+ type="button"
+ class="btn btn-error btn-ghost"
+ onclick={() => {
+ const agent = agents.find((a) => a.slug === editingSlug && a.scope === formScope);
+ if (agent) deletingAgent = agent;
+ }}
+ >Delete</button>
+ {:else}
+ <div></div>
+ {/if}
+ <div class="flex items-center gap-2">
+ {#if saving}
+ <span class="text-xs text-base-content/50">Saving...</span>
+ {/if}
+ </div>
+ </div>
+ </fieldset>
+ </div>
+ </div>
+ {:else}
+ <!-- Agent List -->
+ {#if loading}
+ <div class="flex justify-center py-16">
+ <span class="loading loading-spinner loading-lg"></span>
+ </div>
+ {:else}
+ <!-- Global Agents -->
+ <section class="mb-8">
+ <h2 class="text-lg font-semibold mb-3">Global Agents</h2>
+ <div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
+ {#each globalAgents as agent}
+ <button
+ type="button"
+ class="card bg-base-200 shadow-sm hover:bg-base-300 transition-colors cursor-pointer text-left"
+ onclick={() => startEdit(agent)}
+ >
+ <div class="card-body p-4 gap-2">
+ <h3 class="font-semibold font-mono truncate">{agent.name}</h3>
+ {#if agent.description}
+ <p class="text-sm text-base-content/60 truncate">{agent.description}</p>
+ {/if}
+ <div class="flex gap-2 flex-wrap">
+ <span class="badge badge-sm badge-neutral">{agent.models.length} model{agent.models.length !== 1 ? "s" : ""}</span>
+ <span class="badge badge-sm badge-neutral">{agent.skills.length} skill{agent.skills.length !== 1 ? "s" : ""}</span>
+ <span class="badge badge-sm badge-outline">{agent.tools.length} tool{agent.tools.length !== 1 ? "s" : ""}</span>
+ </div>
+ </div>
+ </button>
+ {/each}
+ <button
+ type="button"
+ class="card bg-base-200 shadow-sm hover:bg-base-300 transition-colors cursor-pointer border-2 border-dashed border-base-content/20"
+ onclick={startNewAgent}
+ >
+ <div class="card-body p-4 items-center justify-center">
+ <span class="text-2xl text-base-content/30">+</span>
+ <span class="text-sm text-base-content/50">New Agent</span>
+ </div>
+ </button>
+ </div>
+ </section>
+
+ <!-- Project Agents -->
+ {#if projectAgents.length > 0 || dirs.some((d) => d.scope !== "global")}
+ <section>
+ <h2 class="text-lg font-semibold mb-3">Project Agents</h2>
+ {#if projectAgents.length === 0}
+ <p class="text-base-content/50 italic text-sm">No project agents yet.</p>
+ {:else}
+ <div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
+ {#each projectAgents as agent}
+ <button
+ type="button"
+ class="card bg-base-200 shadow-sm hover:bg-base-300 transition-colors cursor-pointer text-left"
+ onclick={() => startEdit(agent)}
+ >
+ <div class="card-body p-4 gap-2">
+ <h3 class="font-semibold font-mono truncate">{agent.name}</h3>
+ {#if agent.description}
+ <p class="text-sm text-base-content/60 truncate">{agent.description}</p>
+ {/if}
+ <p class="text-xs text-base-content/40 font-mono truncate">{agent.scope}</p>
+ <div class="flex gap-2 flex-wrap">
+ <span class="badge badge-sm badge-neutral">{agent.models.length} model{agent.models.length !== 1 ? "s" : ""}</span>
+ <span class="badge badge-sm badge-neutral">{agent.skills.length} skill{agent.skills.length !== 1 ? "s" : ""}</span>
+ <span class="badge badge-sm badge-outline">{agent.tools.length} tool{agent.tools.length !== 1 ? "s" : ""}</span>
+ </div>
+ </div>
+ </button>
+ {/each}
+ </div>
+ {/if}
+ </section>
+ {/if}
+ {/if}
+ {/if}
+ </div>
+</div>
+
+<!-- Key selection modal -->
+{#if modelModalIndex !== null && modelModalType === "key"}
+ <div class="modal modal-open" use:portal>
+ <div class="modal-box">
+ <h3 class="font-bold text-xl">Select Key</h3>
+ <div class="mt-4 flex flex-col gap-2">
+ {#each keys as key}
+ <button
+ type="button"
+ class="btn {formModels[modelModalIndex ?? 0]?.key_id === key.id ? 'btn-primary' : 'btn-ghost'} justify-start text-base"
+ onclick={() => selectKey(key.id)}
+ >
+ <span class="font-mono">{key.id}</span>
+ <span class="badge ml-auto">{key.provider}</span>
+ <span class="badge {key.status === 'active' ? 'badge-success' : 'badge-error'}">{key.status}</span>
+ </button>
+ {/each}
+ </div>
+ <div class="modal-action">
+ <button type="button" class="btn" onclick={closeModal}>Cancel</button>
+ </div>
+ </div>
+ <button type="button" class="modal-backdrop" onclick={closeModal} aria-label="Close modal"></button>
+ </div>
+{/if}
+
+<!-- Model selection modal -->
+{#if modelModalIndex !== null && modelModalType === "model"}
+ <div class="modal modal-open" use:portal>
+ <div class="modal-box">
+ <h3 class="font-bold text-xl">Select Model</h3>
+ {#if modalLoadingModels}
+ <div class="flex justify-center py-8">
+ <span class="loading loading-spinner loading-lg"></span>
+ </div>
+ {:else if modalModelError}
+ <div class="alert alert-error mt-4 text-base">
+ <span>{modalModelError}</span>
+ </div>
+ {:else}
+ <div class="mt-4 flex flex-col gap-1 max-h-96 overflow-y-auto">
+ {#each modalAvailableModels as model}
+ <button
+ type="button"
+ class="btn {formModels[modelModalIndex ?? 0]?.model_id === model ? 'btn-primary' : 'btn-ghost'} justify-start font-mono text-base"
+ onclick={() => selectModel(model)}
+ >
+ {model}
+ </button>
+ {/each}
+ </div>
+ {/if}
+ <div class="modal-action">
+ <button type="button" class="btn" onclick={closeModal}>Cancel</button>
+ </div>
+ </div>
+ <button type="button" class="modal-backdrop" onclick={closeModal} aria-label="Close modal"></button>
+ </div>
+{/if}
+
+<!-- Delete confirm modal -->
+{#if deletingAgent !== null}
+ {@const agentToDelete = deletingAgent}
+ <div class="modal modal-open" use:portal>
+ <div class="modal-box">
+ <h3 class="font-bold text-lg">Delete Agent</h3>
+ <p class="py-4">
+ Are you sure you want to delete <span class="font-mono font-semibold">{agentToDelete.name}</span>? This cannot be undone.
+ </p>
+ <div class="modal-action">
+ <button type="button" class="btn btn-ghost" onclick={() => { deletingAgent = null; }}>Cancel</button>
+ <button
+ type="button"
+ class="btn btn-error"
+ onclick={() => deleteAgent(agentToDelete)}
+ >Delete</button>
+ </div>
+ </div>
+ <button type="button" class="modal-backdrop" onclick={() => { deletingAgent = null; }} aria-label="Close modal"></button>
+ </div>
+{/if}
diff --git a/packages/frontend/src/lib/components/ChatInput.svelte b/packages/frontend/src/lib/components/ChatInput.svelte
index 8910bb4..21c85fe 100644
--- a/packages/frontend/src/lib/components/ChatInput.svelte
+++ b/packages/frontend/src/lib/components/ChatInput.svelte
@@ -24,13 +24,13 @@ function submit() {
}
</script>
-<div class="flex items-center gap-2 p-3 border-t border-base-300 bg-base-100">
+<div class="flex items-center gap-2 p-3">
<input
bind:this={inputEl}
bind:value={inputValue}
type="text"
placeholder="Type a message..."
- class="input flex-1"
+ class="input input-ghost flex-1"
onkeydown={handleKeydown}
/>
<button
diff --git a/packages/frontend/src/lib/components/ChatMessage.svelte b/packages/frontend/src/lib/components/ChatMessage.svelte
index 398bee8..308e6ba 100644
--- a/packages/frontend/src/lib/components/ChatMessage.svelte
+++ b/packages/frontend/src/lib/components/ChatMessage.svelte
@@ -1,6 +1,6 @@
<script lang="ts">
-import type { ChatMessage } from "../types.js";
import { appSettings } from "../settings.svelte.js";
+import type { ChatMessage } from "../types.js";
import MarkdownRenderer from "./MarkdownRenderer.svelte";
import ToolCallDisplay from "./ToolCallDisplay.svelte";
diff --git a/packages/frontend/src/lib/components/ChatPanel.svelte b/packages/frontend/src/lib/components/ChatPanel.svelte
index 24d8c7f..8c92e4f 100644
--- a/packages/frontend/src/lib/components/ChatPanel.svelte
+++ b/packages/frontend/src/lib/components/ChatPanel.svelte
@@ -1,46 +1,46 @@
<script lang="ts">
- import { untrack } from "svelte";
- import { tabStore } from "../tabs.svelte.js";
- import ChatMessageComponent from "./ChatMessage.svelte";
+import { untrack } from "svelte";
+import { tabStore } from "../tabs.svelte.js";
+import ChatMessageComponent from "./ChatMessage.svelte";
- let messagesEl: HTMLDivElement | undefined;
- let userScrolledUp = $state(false);
- let isAutoScrolling = false;
+let messagesEl: HTMLDivElement | undefined;
+let userScrolledUp = $state(false);
+let isAutoScrolling = false;
- const messages = $derived(tabStore.activeTab?.messages ?? []);
+const messages = $derived(tabStore.activeTab?.messages ?? []);
- function isNearBottom(el: HTMLElement): boolean {
- return el.scrollHeight - el.scrollTop - el.clientHeight < 64;
- }
+function isNearBottom(el: HTMLElement): boolean {
+ return el.scrollHeight - el.scrollTop - el.clientHeight < 64;
+}
- function scrollToBottom(animate = false) {
- if (!messagesEl) return;
- messagesEl.scrollTo({
- top: messagesEl.scrollHeight,
- behavior: animate ? "smooth" : "instant",
- });
- }
+function scrollToBottom(animate = false) {
+ if (!messagesEl) return;
+ messagesEl.scrollTo({
+ top: messagesEl.scrollHeight,
+ behavior: animate ? "smooth" : "instant",
+ });
+}
- function handleScroll() {
- if (!messagesEl || isAutoScrolling) return;
- userScrolledUp = !isNearBottom(messagesEl);
- }
+function handleScroll() {
+ if (!messagesEl || isAutoScrolling) return;
+ userScrolledUp = !isNearBottom(messagesEl);
+}
- function resumeAutoScroll() {
- userScrolledUp = false;
- isAutoScrolling = true;
- scrollToBottom(true);
- }
+function resumeAutoScroll() {
+ userScrolledUp = false;
+ isAutoScrolling = true;
+ scrollToBottom(true);
+}
- $effect(() => {
- const count = messages.length;
- void count;
- if (messagesEl) {
- untrack(() => {
- if (!userScrolledUp) scrollToBottom(false);
- });
- }
- });
+$effect(() => {
+ const count = messages.length;
+ void count;
+ if (messagesEl) {
+ untrack(() => {
+ if (!userScrolledUp) scrollToBottom(false);
+ });
+ }
+});
</script>
<div class="flex flex-col h-full">
@@ -67,12 +67,12 @@
<!-- Scroll-to-bottom button -->
<button
type="button"
- class="absolute bottom-0 left-1/2 -translate-x-1/2 mb-4 btn btn-circle btn-sm shadow-lg transition-opacity duration-200 {userScrolledUp ? 'opacity-100' : 'opacity-0 pointer-events-none'}"
+ class="absolute bottom-0 left-1/2 -translate-x-1/2 mb-4 btn btn-sm px-8 rounded-lg shadow-lg transition-opacity duration-200 {userScrolledUp ? 'opacity-100' : 'opacity-0 pointer-events-none'}"
onclick={resumeAutoScroll}
aria-label="Scroll to bottom"
tabindex={userScrolledUp ? 0 : -1}
>
- ↓
+ &#x25BC;
</button>
</div>
</div>
diff --git a/packages/frontend/src/lib/components/ClaudeReset.svelte b/packages/frontend/src/lib/components/ClaudeReset.svelte
index bcefbcf..baddb73 100644
--- a/packages/frontend/src/lib/components/ClaudeReset.svelte
+++ b/packages/frontend/src/lib/components/ClaudeReset.svelte
@@ -1,151 +1,152 @@
<script lang="ts">
- const { apiBase = "" }: { apiBase?: string } = $props();
-
- // Map of hour (0-23) → scheduled wake timestamp (ms)
- let schedule = $state<Record<number, number>>({});
-
- function formatHour(h: number): string {
- const display = h % 12;
- return display === 0 ? "12" : String(display);
- }
-
- function nextOccurrenceAt15(hour: number): number {
- const now = new Date();
- const target = new Date(now);
- target.setHours(hour, 15, 0, 0);
- if (target.getTime() <= Date.now()) {
- target.setDate(target.getDate() + 1);
- }
- return target.getTime();
+const { apiBase = "" }: { apiBase?: string } = $props();
+
+// Map of hour (0-23) → scheduled wake timestamp (ms)
+let schedule = $state<Record<number, number>>({});
+
+function formatHour(h: number): string {
+ const display = h % 12;
+ return display === 0 ? "12" : String(display);
+}
+
+function nextOccurrenceAt15(hour: number): number {
+ const now = new Date();
+ const target = new Date(now);
+ target.setHours(hour, 15, 0, 0);
+ if (target.getTime() <= Date.now()) {
+ target.setDate(target.getDate() + 1);
}
+ return target.getTime();
+}
- async function loadFromServer(): Promise<void> {
- try {
- const res = await fetch(`${apiBase}/models/wake-schedule`);
- if (!res.ok) return;
- const data = (await res.json()) as { schedule: Record<string, number> };
- const parsed: Record<number, number> = {};
- for (const [k, v] of Object.entries(data.schedule)) {
- parsed[Number(k)] = v;
- }
- schedule = parsed;
- } catch {
- // Network error — leave schedule empty
- }
- }
-
- async function parseScheduleResponse(res: Response): Promise<void> {
+async function loadFromServer(): Promise<void> {
+ try {
+ const res = await fetch(`${apiBase}/models/wake-schedule`);
+ if (!res.ok) return;
const data = (await res.json()) as { schedule: Record<string, number> };
const parsed: Record<number, number> = {};
for (const [k, v] of Object.entries(data.schedule)) {
parsed[Number(k)] = v;
}
schedule = parsed;
+ } catch {
+ // Network error — leave schedule empty
}
+}
- async function toggleOnServer(hour: number, ts: number): Promise<void> {
- try {
- const res = await fetch(`${apiBase}/models/wake-schedule/toggle`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ hour, timestamp: ts }),
- });
- if (!res.ok) return;
- await parseScheduleResponse(res);
- } catch {
- // Network error — keep local state
- }
+async function parseScheduleResponse(res: Response): Promise<void> {
+ const data = (await res.json()) as { schedule: Record<string, number> };
+ const parsed: Record<number, number> = {};
+ for (const [k, v] of Object.entries(data.schedule)) {
+ parsed[Number(k)] = v;
}
-
- async function removeFromServer(hour: number): Promise<void> {
- try {
- const res = await fetch(`${apiBase}/models/wake-schedule/toggle`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ hour }),
- });
- if (!res.ok) return;
- await parseScheduleResponse(res);
- } catch {
- // Network error — keep local state
- }
+ schedule = parsed;
+}
+
+async function toggleOnServer(hour: number, ts: number): Promise<void> {
+ try {
+ const res = await fetch(`${apiBase}/models/wake-schedule/toggle`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ hour, timestamp: ts }),
+ });
+ if (!res.ok) return;
+ await parseScheduleResponse(res);
+ } catch {
+ // Network error — keep local state
}
-
- function toggleHour(hour: number): void {
- if (schedule[hour] !== undefined) {
- void removeFromServer(hour);
- } else {
- const ts = nextOccurrenceAt15(hour);
- void toggleOnServer(hour, ts);
- }
+}
+
+async function removeFromServer(hour: number): Promise<void> {
+ try {
+ const res = await fetch(`${apiBase}/models/wake-schedule/toggle`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ hour }),
+ });
+ if (!res.ok) return;
+ await parseScheduleResponse(res);
+ } catch {
+ // Network error — keep local state
}
-
- $effect(() => {
- void loadFromServer();
- });
-
- // Compute "faded" hours: the 4 hours after each scheduled block
- const fadedHours = $derived((): Set<number> => {
- const result = new Set<number>();
- for (const h of Object.keys(schedule).map(Number)) {
- for (let i = 1; i <= 4; i++) {
- const faded = (h + i) % 24;
- if (schedule[faded] === undefined) {
- result.add(faded);
- }
+}
+
+function toggleHour(hour: number): void {
+ if (schedule[hour] !== undefined) {
+ void removeFromServer(hour);
+ } else {
+ const ts = nextOccurrenceAt15(hour);
+ void toggleOnServer(hour, ts);
+ }
+}
+
+$effect(() => {
+ void loadFromServer();
+});
+
+// Compute "faded" hours: the 4 hours after each scheduled block
+const fadedHours = $derived((): Set<number> => {
+ const result = new Set<number>();
+ for (const h of Object.keys(schedule).map(Number)) {
+ for (let i = 1; i <= 4; i++) {
+ const faded = (h + i) % 24;
+ if (schedule[faded] === undefined) {
+ result.add(faded);
}
}
- return result;
- });
+ }
+ return result;
+});
- const currentHour = $derived(new Date().getHours());
+const currentHour = $derived(new Date().getHours());
- function blockClass(hour: number): string {
- const isScheduled = schedule[hour] !== undefined;
- const isCurrent = hour === currentHour;
- const isFaded = fadedHours().has(hour);
+function blockClass(hour: number): string {
+ const isScheduled = schedule[hour] !== undefined;
+ const isCurrent = hour === currentHour;
+ const isFaded = fadedHours().has(hour);
- let base = "flex items-center justify-center rounded cursor-pointer select-none text-[10px] font-mono transition-colors";
+ let base =
+ "flex items-center justify-center rounded cursor-pointer select-none text-[10px] font-mono transition-colors";
+ if (isScheduled) {
+ base += " bg-primary text-primary-content";
+ } else if (isFaded) {
+ base += " bg-primary/25 text-base-content";
+ } else {
+ base += " bg-base-300 text-base-content/60 hover:bg-base-content/10";
+ }
+
+ if (isCurrent) {
if (isScheduled) {
- base += " bg-primary text-primary-content";
- } else if (isFaded) {
- base += " bg-primary/25 text-base-content";
+ base += " ring-2 ring-accent ring-offset-1 ring-offset-base-200";
} else {
- base += " bg-base-300 text-base-content/60 hover:bg-base-content/10";
- }
-
- if (isCurrent) {
- if (isScheduled) {
- base += " ring-2 ring-accent ring-offset-1 ring-offset-base-200";
- } else {
- base += " ring-2 ring-accent ring-offset-1 ring-offset-base-200";
- }
+ base += " ring-2 ring-accent ring-offset-1 ring-offset-base-200";
}
-
- return base;
- }
-
- function formatAmPm(hour24: number): string {
- const h = hour24 % 12;
- const ampm = hour24 < 12 ? "AM" : "PM";
- return `${h === 0 ? "12" : String(h)}:00 ${ampm}`;
}
- function resetHour(wakeHour: number): number {
- return (wakeHour + 5) % 24;
- }
-
- const scheduledHours = $derived(
- Object.keys(schedule)
- .map(Number)
- .sort((a, b) => (schedule[a] ?? 0) - (schedule[b] ?? 0))
- );
-
- const amRow1 = Array.from({ length: 6 }, (_, i) => i); // 0–5
- const amRow2 = Array.from({ length: 6 }, (_, i) => i + 6); // 6–11
- const pmRow1 = Array.from({ length: 6 }, (_, i) => i + 12); // 12–17
- const pmRow2 = Array.from({ length: 6 }, (_, i) => i + 18); // 18–23
+ return base;
+}
+
+function formatAmPm(hour24: number): string {
+ const h = hour24 % 12;
+ const ampm = hour24 < 12 ? "AM" : "PM";
+ return `${h === 0 ? "12" : String(h)}:00 ${ampm}`;
+}
+
+function resetHour(wakeHour: number): number {
+ return (wakeHour + 5) % 24;
+}
+
+const scheduledHours = $derived(
+ Object.keys(schedule)
+ .map(Number)
+ .sort((a, b) => (schedule[a] ?? 0) - (schedule[b] ?? 0)),
+);
+
+const amRow1 = Array.from({ length: 6 }, (_, i) => i); // 0–5
+const amRow2 = Array.from({ length: 6 }, (_, i) => i + 6); // 6–11
+const pmRow1 = Array.from({ length: 6 }, (_, i) => i + 12); // 12–17
+const pmRow2 = Array.from({ length: 6 }, (_, i) => i + 18); // 18–23
</script>
<div class="flex flex-col gap-2">
diff --git a/packages/frontend/src/lib/components/ConfigPanel.svelte b/packages/frontend/src/lib/components/ConfigPanel.svelte
index 25cbe98..f517d28 100644
--- a/packages/frontend/src/lib/components/ConfigPanel.svelte
+++ b/packages/frontend/src/lib/components/ConfigPanel.svelte
@@ -1,94 +1,101 @@
<script lang="ts">
- interface AgentTemplate {
- name: string;
- description?: string;
- model_tag?: string;
- tools?: string[];
+interface AgentTemplate {
+ name: string;
+ description?: string;
+ model_tag?: string;
+ tools?: string[];
+}
+
+interface ModelEntry {
+ id: string;
+ provider?: string;
+ tags?: string[];
+}
+
+interface KeyEntry {
+ id: string;
+ provider?: string;
+ status?: string;
+ lastError?: string;
+ exhaustedAt?: string;
+}
+
+interface ConfigData {
+ agents?: Record<string, AgentTemplate>;
+ models?: Record<string, { provider?: string; tags?: string[] }>;
+ keys?: Record<string, { env?: string }>;
+ fallback?: string[];
+ permissions?: Record<string, unknown>;
+}
+
+interface ModelsData {
+ models?: ModelEntry[];
+ tags?: Record<string, string[]>;
+ keys?: KeyEntry[];
+}
+
+const { apiBase }: { apiBase: string } = $props();
+
+let configData = $state<ConfigData | null>(null);
+let modelsData = $state<ModelsData | null>(null);
+let error = $state<string | null>(null);
+let loading = $state(false);
+
+async function fetchData() {
+ loading = true;
+ error = null;
+ try {
+ const [configRes, modelsRes] = await Promise.all([
+ fetch(`${apiBase}/config`),
+ fetch(`${apiBase}/models`),
+ ]);
+ if (!configRes.ok) throw new Error(`/config returned ${configRes.status}`);
+ if (!modelsRes.ok) throw new Error(`/models returned ${modelsRes.status}`);
+ const configJson = await configRes.json();
+ const modelsJson = await modelsRes.json();
+ configData = configJson.config ?? configJson;
+ modelsData = modelsJson;
+ } catch (e) {
+ error = e instanceof Error ? e.message : String(e);
+ } finally {
+ loading = false;
}
+}
- interface ModelEntry {
- id: string;
- provider?: string;
- tags?: string[];
- }
-
- interface KeyEntry {
- id: string;
- provider?: string;
- status?: string;
- lastError?: string;
- exhaustedAt?: string;
- }
-
- interface ConfigData {
- agents?: Record<string, AgentTemplate>;
- models?: Record<string, { provider?: string; tags?: string[] }>;
- keys?: Record<string, { env?: string }>;
- fallback?: string[];
- permissions?: Record<string, unknown>;
- }
-
- interface ModelsData {
- models?: ModelEntry[];
- tags?: Record<string, string[]>;
- keys?: KeyEntry[];
- }
-
- const { apiBase }: { apiBase: string } = $props();
-
- let configData = $state<ConfigData | null>(null);
- let modelsData = $state<ModelsData | null>(null);
- let error = $state<string | null>(null);
- let loading = $state(false);
-
- async function fetchData() {
- loading = true;
- error = null;
- try {
- const [configRes, modelsRes] = await Promise.all([
- fetch(`${apiBase}/config`),
- fetch(`${apiBase}/models`),
- ]);
- if (!configRes.ok) throw new Error(`/config returned ${configRes.status}`);
- if (!modelsRes.ok) throw new Error(`/models returned ${modelsRes.status}`);
- const configJson = await configRes.json();
- const modelsJson = await modelsRes.json();
- configData = configJson.config ?? configJson;
- modelsData = modelsJson;
- } catch (e) {
- error = e instanceof Error ? e.message : String(e);
- } finally {
- loading = false;
- }
- }
-
- $effect(() => {
- fetchData();
- });
-
- const modelCount = $derived(modelsData?.models?.length ?? 0);
- const keyCount = $derived(modelsData?.keys?.length ?? 0);
-
- function formatDate(iso: string | undefined): string {
- if (!iso) return "";
- try {
- return new Date(iso).toLocaleString();
- } catch {
- return iso;
- }
- }
-
- function permissionEntries(permissions: Record<string, unknown>): Array<{ name: string; value: unknown }> {
- return Object.entries(permissions).map(([name, value]) => ({ name, value }));
- }
+$effect(() => {
+ fetchData();
+});
- function isSimpleRule(value: unknown): value is { action: string } {
- return typeof value === "object" && value !== null && "action" in value && Object.keys(value).length === 1;
- }
+const modelCount = $derived(modelsData?.models?.length ?? 0);
+const keyCount = $derived(modelsData?.keys?.length ?? 0);
- function isPatternRule(value: unknown): value is Record<string, { action: string }> {
- return typeof value === "object" && value !== null && !("action" in value);
+function formatDate(iso: string | undefined): string {
+ if (!iso) return "";
+ try {
+ return new Date(iso).toLocaleString();
+ } catch {
+ return iso;
}
+}
+
+function permissionEntries(
+ permissions: Record<string, unknown>,
+): Array<{ name: string; value: unknown }> {
+ return Object.entries(permissions).map(([name, value]) => ({ name, value }));
+}
+
+function isSimpleRule(value: unknown): value is { action: string } {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ "action" in value &&
+ Object.keys(value).length === 1
+ );
+}
+
+function isPatternRule(value: unknown): value is Record<string, { action: string }> {
+ return typeof value === "object" && value !== null && !("action" in value);
+}
</script>
<details class="collapse collapse-arrow bg-base-200 mt-2">
diff --git a/packages/frontend/src/lib/components/Header.svelte b/packages/frontend/src/lib/components/Header.svelte
index ada5500..06b8ac1 100644
--- a/packages/frontend/src/lib/components/Header.svelte
+++ b/packages/frontend/src/lib/components/Header.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+import { router } from "../router.svelte.js";
import { tabStore } from "../tabs.svelte.js";
import { wsClient } from "../ws.svelte.js";
import ThemeSwitcher from "./ThemeSwitcher.svelte";
@@ -27,7 +28,7 @@ async function handleCopy() {
<header class="navbar bg-base-200 border-b border-base-300 px-4 min-h-14 flex-shrink-0">
<div class="navbar-start">
- <span class="text-xl font-bold tracking-tight">Dispatch</span>
+ <button class="text-xl font-bold tracking-tight btn btn-ghost px-0" onclick={() => router.navigate("dashboard")}>Dispatch</button>
</div>
<div class="navbar-end flex items-center gap-3">
<span class="flex items-center gap-1.5 text-xs text-base-content/60">
diff --git a/packages/frontend/src/lib/components/HotReloadIndicator.svelte b/packages/frontend/src/lib/components/HotReloadIndicator.svelte
index 3e34d3b..8d37578 100644
--- a/packages/frontend/src/lib/components/HotReloadIndicator.svelte
+++ b/packages/frontend/src/lib/components/HotReloadIndicator.svelte
@@ -1,26 +1,26 @@
<script lang="ts">
- const { active }: { active: boolean } = $props();
+const { active }: { active: boolean } = $props();
- let visible = $state(false);
- let timer: ReturnType<typeof setTimeout> | null = null;
+let visible = $state(false);
+let timer: ReturnType<typeof setTimeout> | null = null;
- $effect(() => {
- if (active) {
- visible = true;
- if (timer !== null) clearTimeout(timer);
- timer = setTimeout(() => {
- visible = false;
- timer = null;
- }, 2000);
- }
- return () => {
- if (timer !== null) {
- clearTimeout(timer);
- timer = null;
- }
+$effect(() => {
+ if (active) {
+ visible = true;
+ if (timer !== null) clearTimeout(timer);
+ timer = setTimeout(() => {
visible = false;
- };
- });
+ timer = null;
+ }, 2000);
+ }
+ return () => {
+ if (timer !== null) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ visible = false;
+ };
+});
</script>
{#if visible}
diff --git a/packages/frontend/src/lib/components/KeyUsage.svelte b/packages/frontend/src/lib/components/KeyUsage.svelte
index f5f7d6d..fc85739 100644
--- a/packages/frontend/src/lib/components/KeyUsage.svelte
+++ b/packages/frontend/src/lib/components/KeyUsage.svelte
@@ -1,201 +1,197 @@
<script lang="ts">
- import type { KeyInfo, KeyUsageData, UsageBucket } from "../types.js";
+import type { KeyInfo, KeyUsageData, UsageBucket } from "../types.js";
- const {
- keys = [],
- apiBase = "",
- }: {
- keys?: KeyInfo[];
- apiBase?: string;
- } = $props();
+const {
+ keys = [],
+ apiBase = "",
+}: {
+ keys?: KeyInfo[];
+ apiBase?: string;
+} = $props();
- interface KeyUsageEntry {
- keyId: string;
- provider: string;
- data: KeyUsageData | null;
- error: string | null;
- loading: boolean;
- }
+interface KeyUsageEntry {
+ keyId: string;
+ provider: string;
+ data: KeyUsageData | null;
+ error: string | null;
+ loading: boolean;
+}
- // In-memory cache for the current session (backend DB is the persistent cache)
- const usageCache = new Map<string, KeyUsageData>();
+// In-memory cache for the current session (backend DB is the persistent cache)
+const usageCache = new Map<string, KeyUsageData>();
- function buildEntries(keyList: KeyInfo[]): KeyUsageEntry[] {
- return keyList.map((k) => {
- const cached = usageCache.get(k.id);
- return {
- keyId: k.id,
- provider: k.provider,
- data: cached ?? null,
- error: null,
- loading: true, // always show spinner during refresh
- };
- });
- }
+function buildEntries(keyList: KeyInfo[]): KeyUsageEntry[] {
+ return keyList.map((k) => {
+ const cached = usageCache.get(k.id);
+ return {
+ keyId: k.id,
+ provider: k.provider,
+ data: cached ?? null,
+ error: null,
+ loading: true, // always show spinner during refresh
+ };
+ });
+}
- let entries = $state<KeyUsageEntry[]>([]);
+let entries = $state<KeyUsageEntry[]>([]);
- async function fetchOne(key: KeyInfo) {
- try {
- const res = await fetch(
- `${apiBase}/models/key-usage?keyId=${encodeURIComponent(key.id)}`,
- );
- if (!res.ok) {
- const data = await res.json().catch(() => ({}));
- updateEntry(key.id, {
- error: data.error ?? `HTTP ${res.status}`,
- loading: false,
- });
- } else {
- const fresh = await res.json() as KeyUsageData;
- usageCache.set(key.id, fresh);
- updateEntry(key.id, {
- data: fresh,
- error: null,
- loading: false,
- });
- }
- } catch (e) {
+async function fetchOne(key: KeyInfo) {
+ try {
+ const res = await fetch(`${apiBase}/models/key-usage?keyId=${encodeURIComponent(key.id)}`);
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ updateEntry(key.id, {
+ error: data.error ?? `HTTP ${res.status}`,
+ loading: false,
+ });
+ } else {
+ const fresh = (await res.json()) as KeyUsageData;
+ usageCache.set(key.id, fresh);
updateEntry(key.id, {
- error: e instanceof Error ? e.message : "Failed to fetch",
+ data: fresh,
+ error: null,
loading: false,
});
}
+ } catch (e) {
+ updateEntry(key.id, {
+ error: e instanceof Error ? e.message : "Failed to fetch",
+ loading: false,
+ });
}
+}
- function updateEntry(
- keyId: string,
- patch: { data?: KeyUsageData | null; error?: string | null; loading?: boolean },
- ) {
- entries = entries.map((e) =>
- e.keyId === keyId ? { ...e, ...patch } : e,
- );
- }
+function updateEntry(
+ keyId: string,
+ patch: { data?: KeyUsageData | null; error?: string | null; loading?: boolean },
+) {
+ entries = entries.map((e) => (e.keyId === keyId ? { ...e, ...patch } : e));
+}
- // Sync entries with keys reactively — runs before DOM update so
- // cached data renders on first paint without a flash of empty state.
- $effect.pre(() => {
- entries = buildEntries(keys);
- });
+// Sync entries with keys reactively — runs before DOM update so
+// cached data renders on first paint without a flash of empty state.
+$effect.pre(() => {
+ entries = buildEntries(keys);
+});
+
+// Fetch data and set up 90s auto-refresh
+$effect(() => {
+ const currentKeys = keys;
+ // Fire all fetches in parallel
+ for (const key of currentKeys) {
+ fetchOne(key);
+ }
- // Fetch data and set up 90s auto-refresh
- $effect(() => {
- const currentKeys = keys;
- // Fire all fetches in parallel
+ // Refresh every 90s
+ const interval = setInterval(() => {
for (const key of currentKeys) {
+ updateEntry(key.id, { loading: true });
fetchOne(key);
}
+ }, 90_000);
- // Refresh every 90s
- const interval = setInterval(() => {
- for (const key of currentKeys) {
- updateEntry(key.id, { loading: true });
- fetchOne(key);
- }
- }, 90_000);
+ return () => clearInterval(interval);
+});
- return () => clearInterval(interval);
- });
-
- // Merge duplicate Claude entries — all anthropic keys return the same
- // set of accounts, so collect and deduplicate under one "Claude" card.
- const claudeAccounts = $derived.by(() => {
- const seen = new Set<string>();
- const accounts: Array<{
- label: string;
- source: string;
- subscriptionType?: string;
- fiveHour?: UsageBucket;
- sevenDay?: UsageBucket;
- error?: string;
- }> = [];
- const claudeEntries = entries.filter((e) => e.provider === "anthropic");
- for (const e of claudeEntries) {
- if (!e.data || e.data.provider !== "anthropic" || !e.data.accounts) continue;
- for (const acct of e.data.accounts) {
- if (!seen.has(acct.source)) {
- seen.add(acct.source);
- accounts.push(acct);
- }
+// Merge duplicate Claude entries — all anthropic keys return the same
+// set of accounts, so collect and deduplicate under one "Claude" card.
+const claudeAccounts = $derived.by(() => {
+ const seen = new Set<string>();
+ const accounts: Array<{
+ label: string;
+ source: string;
+ subscriptionType?: string;
+ fiveHour?: UsageBucket;
+ sevenDay?: UsageBucket;
+ error?: string;
+ }> = [];
+ const claudeEntries = entries.filter((e) => e.provider === "anthropic");
+ for (const e of claudeEntries) {
+ if (!e.data || e.data.provider !== "anthropic" || !e.data.accounts) continue;
+ for (const acct of e.data.accounts) {
+ if (!seen.has(acct.source)) {
+ seen.add(acct.source);
+ accounts.push(acct);
}
}
- return accounts;
- });
+ }
+ return accounts;
+});
- const claudeLoading = $derived(
- entries.some((e) => e.provider === "anthropic" && e.loading),
- );
- const claudeError = $derived(
- entries.find((e) => e.provider === "anthropic" && e.error)?.error ?? null,
- );
+const claudeLoading = $derived(entries.some((e) => e.provider === "anthropic" && e.loading));
+const claudeError = $derived(
+ entries.find((e) => e.provider === "anthropic" && e.error)?.error ?? null,
+);
- const nonClaudeEntries = $derived(
- entries.filter((e) => e.provider !== "anthropic"),
- );
+const nonClaudeEntries = $derived(entries.filter((e) => e.provider !== "anthropic"));
- function progressClass(utilization: number): string {
- if (utilization > 0.8) return "progress-error";
- if (utilization >= 0.5) return "progress-warning";
- return "progress-success";
- }
+function progressClass(utilization: number): string {
+ if (utilization > 0.8) return "progress-error";
+ if (utilization >= 0.5) return "progress-warning";
+ return "progress-success";
+}
- function formatDate(ts: number): string {
- const diff = ts - Date.now();
- const days = Math.floor(diff / 86400000);
- const d = new Date(ts);
- const dateStr = d.toLocaleDateString("en-US", { month: "2-digit", day: "2-digit" })
- + " "
- + d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
+function formatDate(ts: number): string {
+ const diff = ts - Date.now();
+ const days = Math.floor(diff / 86400000);
+ const d = new Date(ts);
+ const dateStr =
+ d.toLocaleDateString("en-US", { month: "2-digit", day: "2-digit" }) +
+ " " +
+ d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
- if (diff <= 0) {
- return d.toLocaleString();
+ if (diff <= 0) {
+ return d.toLocaleString();
+ }
+ if (diff < 48 * 60 * 60 * 1000) {
+ const hours = Math.floor(diff / 3600000);
+ const minutes = Math.floor((diff % 3600000) / 60000);
+ return `in ${hours}:${String(minutes).padStart(2, "0")}`;
+ }
+ if (days <= 30) {
+ const weeks = Math.floor(days / 7);
+ const remDays = days % 7;
+ if (weeks > 0 && remDays > 0) {
+ return `in ${weeks}w ${remDays}d (${dateStr})`;
}
- if (diff < 48 * 60 * 60 * 1000) {
- const hours = Math.floor(diff / 3600000);
- const minutes = Math.floor((diff % 3600000) / 60000);
- return `in ${hours}:${String(minutes).padStart(2, "0")}`;
+ if (weeks > 0) {
+ return `in ${weeks} week${weeks > 1 ? "s" : ""} (${dateStr})`;
}
- if (days <= 30) {
- const weeks = Math.floor(days / 7);
- const remDays = days % 7;
- if (weeks > 0 && remDays > 0) {
- return `in ${weeks}w ${remDays}d (${dateStr})`;
- }
- if (weeks > 0) {
- return `in ${weeks} week${weeks > 1 ? "s" : ""} (${dateStr})`;
- }
- return `in ${days} day${days > 1 ? "s" : ""} (${dateStr})`;
- }
- return d.toLocaleDateString("en-US", {
- month: "2-digit",
- day: "2-digit",
- year: "numeric",
- });
+ return `in ${days} day${days > 1 ? "s" : ""} (${dateStr})`;
}
+ return d.toLocaleDateString("en-US", {
+ month: "2-digit",
+ day: "2-digit",
+ year: "numeric",
+ });
+}
- function formatKeyId(id: string): string {
- if (/^github-copilot/i.test(id)) return "Copilot";
- return id.split("-").map((part) => {
+function formatKeyId(id: string): string {
+ if (/^github-copilot/i.test(id)) return "Copilot";
+ return id
+ .split("-")
+ .map((part) => {
if (part.toLowerCase() === "opencode") return "OpenCode";
return part.charAt(0).toUpperCase() + part.slice(1);
- }).join(" ");
- }
+ })
+ .join(" ");
+}
- // Cycle durations in ms
- const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
- const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000;
- const THIRTY_DAY_MS = 30 * 24 * 60 * 60 * 1000;
+// Cycle durations in ms
+const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
+const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000;
+const THIRTY_DAY_MS = 30 * 24 * 60 * 60 * 1000;
- function cycleElapsedPct(resetsAt: number | undefined, cycleDurationMs: number): number {
- if (!resetsAt) return -1;
- const timeRemaining = resetsAt - Date.now();
- const elapsed = cycleDurationMs - timeRemaining;
- return Math.max(0, Math.min(100, Math.round((elapsed / cycleDurationMs) * 100)));
- }
+function cycleElapsedPct(resetsAt: number | undefined, cycleDurationMs: number): number {
+ if (!resetsAt) return -1;
+ const timeRemaining = resetsAt - Date.now();
+ const elapsed = cycleDurationMs - timeRemaining;
+ return Math.max(0, Math.min(100, Math.round((elapsed / cycleDurationMs) * 100)));
+}
- function hasBucketData(bucket: UsageBucket | undefined): boolean {
- return bucket !== undefined && bucket.utilization !== undefined;
- }
+function hasBucketData(bucket: UsageBucket | undefined): boolean {
+ return bucket !== undefined && bucket.utilization !== undefined;
+}
</script>
<div class="flex flex-col gap-3 flex-1 min-h-0">
diff --git a/packages/frontend/src/lib/components/MarkdownRenderer.svelte b/packages/frontend/src/lib/components/MarkdownRenderer.svelte
index f73140f..0fbf314 100644
--- a/packages/frontend/src/lib/components/MarkdownRenderer.svelte
+++ b/packages/frontend/src/lib/components/MarkdownRenderer.svelte
@@ -1,172 +1,172 @@
<script lang="ts">
- import { Marked } from "marked";
- import { markedHighlight } from "marked-highlight";
- import DOMPurify from "dompurify";
- import hljs from "highlight.js/lib/core";
- // Hot set — matches roughly what ChatGPT preloads. Registered eagerly so
- // common code blocks highlight on first paint without a network roundtrip.
- import bash from "highlight.js/lib/languages/bash";
- import c from "highlight.js/lib/languages/c";
- import cpp from "highlight.js/lib/languages/cpp";
- import csharp from "highlight.js/lib/languages/csharp";
- import css from "highlight.js/lib/languages/css";
- import go from "highlight.js/lib/languages/go";
- import java from "highlight.js/lib/languages/java";
- import javascript from "highlight.js/lib/languages/javascript";
- import json from "highlight.js/lib/languages/json";
- import markdown from "highlight.js/lib/languages/markdown";
- import php from "highlight.js/lib/languages/php";
- import plaintext from "highlight.js/lib/languages/plaintext";
- import python from "highlight.js/lib/languages/python";
- import ruby from "highlight.js/lib/languages/ruby";
- import rust from "highlight.js/lib/languages/rust";
- import shell from "highlight.js/lib/languages/shell";
- import sql from "highlight.js/lib/languages/sql";
- import typescript from "highlight.js/lib/languages/typescript";
- import xml from "highlight.js/lib/languages/xml";
- import yaml from "highlight.js/lib/languages/yaml";
+import DOMPurify from "dompurify";
+import hljs from "highlight.js/lib/core";
+// Hot set — matches roughly what ChatGPT preloads. Registered eagerly so
+// common code blocks highlight on first paint without a network roundtrip.
+import bash from "highlight.js/lib/languages/bash";
+import c from "highlight.js/lib/languages/c";
+import cpp from "highlight.js/lib/languages/cpp";
+import csharp from "highlight.js/lib/languages/csharp";
+import css from "highlight.js/lib/languages/css";
+import go from "highlight.js/lib/languages/go";
+import java from "highlight.js/lib/languages/java";
+import javascript from "highlight.js/lib/languages/javascript";
+import json from "highlight.js/lib/languages/json";
+import markdown from "highlight.js/lib/languages/markdown";
+import php from "highlight.js/lib/languages/php";
+import plaintext from "highlight.js/lib/languages/plaintext";
+import python from "highlight.js/lib/languages/python";
+import ruby from "highlight.js/lib/languages/ruby";
+import rust from "highlight.js/lib/languages/rust";
+import shell from "highlight.js/lib/languages/shell";
+import sql from "highlight.js/lib/languages/sql";
+import typescript from "highlight.js/lib/languages/typescript";
+import xml from "highlight.js/lib/languages/xml";
+import yaml from "highlight.js/lib/languages/yaml";
+import { Marked } from "marked";
+import { markedHighlight } from "marked-highlight";
- hljs.registerLanguage("bash", bash);
- hljs.registerLanguage("c", c);
- hljs.registerLanguage("cpp", cpp);
- hljs.registerLanguage("csharp", csharp);
- hljs.registerLanguage("css", css);
- hljs.registerLanguage("go", go);
- hljs.registerLanguage("java", java);
- hljs.registerLanguage("javascript", javascript);
- hljs.registerLanguage("json", json);
- hljs.registerLanguage("markdown", markdown);
- hljs.registerLanguage("php", php);
- hljs.registerLanguage("plaintext", plaintext);
- hljs.registerLanguage("python", python);
- hljs.registerLanguage("ruby", ruby);
- hljs.registerLanguage("rust", rust);
- hljs.registerLanguage("shell", shell);
- hljs.registerLanguage("sql", sql);
- hljs.registerLanguage("typescript", typescript);
- hljs.registerLanguage("xml", xml);
- hljs.registerLanguage("yaml", yaml);
+hljs.registerLanguage("bash", bash);
+hljs.registerLanguage("c", c);
+hljs.registerLanguage("cpp", cpp);
+hljs.registerLanguage("csharp", csharp);
+hljs.registerLanguage("css", css);
+hljs.registerLanguage("go", go);
+hljs.registerLanguage("java", java);
+hljs.registerLanguage("javascript", javascript);
+hljs.registerLanguage("json", json);
+hljs.registerLanguage("markdown", markdown);
+hljs.registerLanguage("php", php);
+hljs.registerLanguage("plaintext", plaintext);
+hljs.registerLanguage("python", python);
+hljs.registerLanguage("ruby", ruby);
+hljs.registerLanguage("rust", rust);
+hljs.registerLanguage("shell", shell);
+hljs.registerLanguage("sql", sql);
+hljs.registerLanguage("typescript", typescript);
+hljs.registerLanguage("xml", xml);
+hljs.registerLanguage("yaml", yaml);
- // Normalize common aliases to their canonical highlight.js language names.
- // The canonical name is what we'll attempt to dynamically import.
- const ALIASES: Record<string, string> = {
- js: "javascript",
- jsx: "javascript",
- mjs: "javascript",
- cjs: "javascript",
- ts: "typescript",
- tsx: "typescript",
- py: "python",
- py3: "python",
- rb: "ruby",
- sh: "bash",
- shell: "bash",
- zsh: "bash",
- yml: "yaml",
- "c++": "cpp",
- cxx: "cpp",
- "c#": "csharp",
- cs: "csharp",
- htm: "xml",
- html: "xml",
- svg: "xml",
- md: "markdown",
- mdx: "markdown",
- golang: "go",
- rs: "rust",
- kt: "kotlin",
- ps1: "powershell",
- };
+// Normalize common aliases to their canonical highlight.js language names.
+// The canonical name is what we'll attempt to dynamically import.
+const ALIASES: Record<string, string> = {
+ js: "javascript",
+ jsx: "javascript",
+ mjs: "javascript",
+ cjs: "javascript",
+ ts: "typescript",
+ tsx: "typescript",
+ py: "python",
+ py3: "python",
+ rb: "ruby",
+ sh: "bash",
+ shell: "bash",
+ zsh: "bash",
+ yml: "yaml",
+ "c++": "cpp",
+ cxx: "cpp",
+ "c#": "csharp",
+ cs: "csharp",
+ htm: "xml",
+ html: "xml",
+ svg: "xml",
+ md: "markdown",
+ mdx: "markdown",
+ golang: "go",
+ rs: "rust",
+ kt: "kotlin",
+ ps1: "powershell",
+};
- function normalizeLang(lang: string): string {
- const lower = lang.toLowerCase().trim();
- return ALIASES[lower] ?? lower;
- }
+function normalizeLang(lang: string): string {
+ const lower = lang.toLowerCase().trim();
+ return ALIASES[lower] ?? lower;
+}
- const loadCache = new Map<string, Promise<boolean>>();
+const loadCache = new Map<string, Promise<boolean>>();
- async function ensureLanguage(lang: string): Promise<boolean> {
- const name = normalizeLang(lang);
- if (hljs.getLanguage(name)) return true;
- if (loadCache.has(name)) return loadCache.get(name)!;
+async function ensureLanguage(lang: string): Promise<boolean> {
+ const name = normalizeLang(lang);
+ if (hljs.getLanguage(name)) return true;
+ if (loadCache.has(name)) return loadCache.get(name)!;
- const promise = (async () => {
- try {
- // Dynamic import for languages not in the hot set above.
- // @vite-ignore: the variable `name` is intentionally dynamic;
- // missing modules are caught by the try/catch below.
- const mod = await import(/* @vite-ignore */ `highlight.js/lib/languages/${name}`);
- hljs.registerLanguage(name, mod.default);
- return true;
- } catch {
- return false;
- }
- })();
+ const promise = (async () => {
+ try {
+ // Dynamic import for languages not in the hot set above.
+ // @vite-ignore: the variable `name` is intentionally dynamic;
+ // missing modules are caught by the try/catch below.
+ const mod = await import(/* @vite-ignore */ `highlight.js/lib/languages/${name}`);
+ hljs.registerLanguage(name, mod.default);
+ return true;
+ } catch {
+ return false;
+ }
+ })();
- loadCache.set(name, promise);
- return promise;
- }
+ loadCache.set(name, promise);
+ return promise;
+}
- function escapeHtml(s: string): string {
- return s
- .replace(/&/g, "&amp;")
- .replace(/</g, "&lt;")
- .replace(/>/g, "&gt;")
- .replace(/"/g, "&quot;")
- .replace(/'/g, "&#39;");
- }
+function escapeHtml(s: string): string {
+ return s
+ .replace(/&/g, "&amp;")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;")
+ .replace(/"/g, "&quot;")
+ .replace(/'/g, "&#39;");
+}
- const md = new Marked(
- markedHighlight({
- emptyLangClass: "hljs",
- langPrefix: "hljs language-",
- async: true,
- async highlight(code: string, lang: string): Promise<string> {
- if (!lang) return escapeHtml(code);
- const name = normalizeLang(lang);
- const loaded = await ensureLanguage(name);
- if (!loaded) return escapeHtml(code);
- try {
- return hljs.highlight(code, { language: name, ignoreIllegals: true }).value;
- } catch {
- return escapeHtml(code);
- }
- },
- }),
- {
- gfm: true,
- breaks: true,
+const md = new Marked(
+ markedHighlight({
+ emptyLangClass: "hljs",
+ langPrefix: "hljs language-",
+ async: true,
+ async highlight(code: string, lang: string): Promise<string> {
+ if (!lang) return escapeHtml(code);
+ const name = normalizeLang(lang);
+ const loaded = await ensureLanguage(name);
+ if (!loaded) return escapeHtml(code);
+ try {
+ return hljs.highlight(code, { language: name, ignoreIllegals: true }).value;
+ } catch {
+ return escapeHtml(code);
+ }
},
- );
+ }),
+ {
+ gfm: true,
+ breaks: true,
+ },
+);
- const { text = "", streaming = false }: { text?: string; streaming?: boolean } = $props();
+const { text = "", streaming = false }: { text?: string; streaming?: boolean } = $props();
- function closeOpenDelimiters(src: string): string {
- let out = src;
- const fenceCount = (out.match(/^```/gm) || []).length;
- if (fenceCount % 2 !== 0) out += "\n```";
- const boldCount = (out.match(/\*\*/g) || []).length;
- if (boldCount % 2 !== 0) out += "**";
- const inlineCode = (out.match(/(?<!`)`(?!`)/g) || []).length;
- if (inlineCode % 2 !== 0) out += "`";
- return out;
- }
+function closeOpenDelimiters(src: string): string {
+ let out = src;
+ const fenceCount = (out.match(/^```/gm) || []).length;
+ if (fenceCount % 2 !== 0) out += "\n```";
+ const boldCount = (out.match(/\*\*/g) || []).length;
+ if (boldCount % 2 !== 0) out += "**";
+ const inlineCode = (out.match(/(?<!`)`(?!`)/g) || []).length;
+ if (inlineCode % 2 !== 0) out += "`";
+ return out;
+}
- let html = $state("");
- let renderToken = 0;
+let html = $state("");
+let renderToken = 0;
- $effect(() => {
- const src = streaming ? closeOpenDelimiters(text) : text;
- const myToken = ++renderToken;
- (async () => {
- try {
- const raw = (await md.parse(src)) as string;
- if (myToken === renderToken) html = DOMPurify.sanitize(raw);
- } catch {
- // swallow — keeps last successful render visible
- }
- })();
- });
+$effect(() => {
+ const src = streaming ? closeOpenDelimiters(text) : text;
+ const myToken = ++renderToken;
+ (async () => {
+ try {
+ const raw = (await md.parse(src)) as string;
+ if (myToken === renderToken) html = DOMPurify.sanitize(raw);
+ } catch {
+ // swallow — keeps last successful render visible
+ }
+ })();
+});
</script>
<div class="markdown-body">
diff --git a/packages/frontend/src/lib/components/ModelSelector.svelte b/packages/frontend/src/lib/components/ModelSelector.svelte
index 0af5ae6..21364f9 100644
--- a/packages/frontend/src/lib/components/ModelSelector.svelte
+++ b/packages/frontend/src/lib/components/ModelSelector.svelte
@@ -1,10 +1,22 @@
-<script module>
- const modelCache = new Map<string, string[]>();
+<script module lang="ts">
+const modelCache = new Map<string, string[]>();
</script>
<script lang="ts">
import type { KeyInfo } from "../types.js";
import { config } from "../config.js";
+ import { router } from "../router.svelte.js";
+
+ interface AgentInfo {
+ name: string;
+ slug: string;
+ scope: string;
+ description: string;
+ skills: string[];
+ tools: string[];
+ models: Array<{ key_id: string; model_id: string }>;
+ cwd?: string;
+ }
// Moves an element to document.body so modals escape the sidebar's
// transform stacking context and cover the full viewport.
@@ -22,17 +34,25 @@
activeKeyId = null,
activeModelId = null,
reasoningEffort = "max",
+ activeAgentSlug = null,
+ workingDirectory = null,
onKeyChange,
onModelChange,
onReasoningChange,
+ onAgentChange = (_agent: AgentInfo | null) => {},
+ onWorkingDirectoryChange = (_dir: string | null) => {},
}: {
keys?: KeyInfo[];
activeKeyId?: string | null;
activeModelId?: string | null;
reasoningEffort?: string;
+ activeAgentSlug?: string | null;
+ workingDirectory?: string | null;
onKeyChange: (keyId: string) => void;
onModelChange: (keyId: string, modelId: string) => void;
onReasoningChange: (effort: string) => void;
+ onAgentChange?: (agent: AgentInfo | null) => void;
+ onWorkingDirectoryChange?: (dir: string | null) => void;
} = $props();
let showKeyModal = $state(false);
@@ -41,6 +61,56 @@
let loadingModels = $state(false);
let modelError = $state<string | null>(null);
+ let cwdExists = $state<boolean | null>(null);
+ let cwdCheckTimer: ReturnType<typeof setTimeout> | null = null;
+
+ $effect(() => {
+ const cwd = workingDirectory;
+ if (!cwd) {
+ cwdExists = null;
+ return;
+ }
+ cwdExists = null;
+ if (cwdCheckTimer) clearTimeout(cwdCheckTimer);
+ cwdCheckTimer = setTimeout(async () => {
+ try {
+ const res = await fetch(
+ `${config.apiBase}/agents/check-dir?path=${encodeURIComponent(cwd)}`,
+ );
+ if (res.ok) {
+ const data = await res.json();
+ cwdExists = data.exists ?? false;
+ }
+ } catch {
+ cwdExists = null;
+ }
+ }, 300);
+ });
+
+ let modeOverride = $state<"manual" | "agent" | null>(null);
+ let mode = $derived(modeOverride ?? (activeAgentSlug ? "agent" : "manual"));
+ let agents = $state<AgentInfo[]>([]);
+ let loadingAgents = $state(false);
+
+ $effect(() => {
+ fetchAgents();
+ });
+
+ async function fetchAgents() {
+ loadingAgents = true;
+ try {
+ const res = await fetch(`${config.apiBase}/agents`);
+ if (res.ok) {
+ const data = await res.json();
+ agents = data.agents ?? [];
+ }
+ } catch {
+ /* ignore */
+ } finally {
+ loadingAgents = false;
+ }
+ }
+
function selectKey(keyId: string) {
showKeyModal = false;
onKeyChange(keyId);
@@ -93,35 +163,119 @@
</script>
<div class="bg-base-200 rounded-lg p-3">
- <div class="flex items-center justify-between">
- <span class="text-sm font-medium">Key</span>
- <button class="btn btn-sm btn-outline" onclick={() => (showKeyModal = true)}>
- {activeKeyId ?? "Select Key"}
- </button>
+ <!-- Working Directory -->
+ <div class="form-control mb-3">
+ <label class="label py-0" for="cwd-input">
+ <span class="label-text text-xs font-semibold">Working Directory</span>
+ </label>
+ <div class="flex items-center gap-1.5 mt-1">
+ <input
+ id="cwd-input"
+ type="text"
+ class="input input-bordered input-sm font-mono text-xs flex-1"
+ placeholder="default (project root)"
+ value={workingDirectory ?? ""}
+ oninput={(e) => {
+ const val = e.currentTarget.value.trim();
+ onWorkingDirectoryChange(val || null);
+ }}
+ />
+ {#if workingDirectory}
+ {#if cwdExists === true}
+ <span class="text-success text-sm" title="Directory exists">&#x2714;</span>
+ {:else if cwdExists === false}
+ <span class="text-warning text-sm" title="Will be created">&#x2716;</span>
+ {:else}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ {/if}
+ </div>
</div>
- <div class="flex items-center justify-between mt-2">
- <span class="text-sm font-medium">Model</span>
- <button class="btn btn-sm btn-outline" onclick={() => openModelModal()} disabled={!activeKeyId}>
- {activeModelId ?? "Select Model"}
+ <!-- Toggle -->
+ <div class="flex items-center gap-2 mb-3">
+ <button
+ class="btn btn-xs {mode === 'manual' ? 'btn-primary' : 'btn-ghost'}"
+ onclick={() => { modeOverride = "manual"; onAgentChange(null); }}
+ >
+ Manual
+ </button>
+ <button
+ class="btn btn-xs {mode === 'agent' ? 'btn-primary' : 'btn-ghost'}"
+ onclick={() => { modeOverride = "agent"; fetchAgents(); }}
+ >
+ Agent
</button>
</div>
- {#if activeModelId}
+ {#if mode === "manual"}
+ <div class="flex items-center justify-between">
+ <span class="text-sm font-medium">Key</span>
+ <button class="btn btn-sm btn-outline" onclick={() => (showKeyModal = true)}>
+ {activeKeyId ?? "Select Key"}
+ </button>
+ </div>
+
<div class="flex items-center justify-between mt-2">
- <span class="text-sm font-medium">Thinking</span>
- <select
- class="select select-bordered select-sm"
- value={reasoningEffort}
- onchange={(e) => onReasoningChange(e.currentTarget.value)}
- >
- <option value="none">Off</option>
- <option value="low">Low</option>
- <option value="medium">Medium</option>
- <option value="high">High</option>
- <option value="max">Max</option>
- </select>
+ <span class="text-sm font-medium">Model</span>
+ <button class="btn btn-sm btn-outline" onclick={() => openModelModal()} disabled={!activeKeyId}>
+ {activeModelId ?? "Select Model"}
+ </button>
</div>
+
+ {#if activeModelId}
+ <div class="flex items-center justify-between mt-2">
+ <span class="text-sm font-medium">Thinking</span>
+ <select
+ class="select select-bordered select-sm"
+ value={reasoningEffort}
+ onchange={(e) => onReasoningChange(e.currentTarget.value)}
+ >
+ <option value="none">Off</option>
+ <option value="low">Low</option>
+ <option value="medium">Medium</option>
+ <option value="high">High</option>
+ <option value="max">Max</option>
+ </select>
+ </div>
+ {/if}
+ {:else}
+ <!-- Agent selection UI -->
+ {#if loadingAgents}
+ <div class="flex items-center gap-2 py-2 text-base-content/60">
+ <span class="loading loading-spinner loading-xs"></span>
+ Loading agents...
+ </div>
+ {:else if agents.length === 0}
+ <p class="text-base-content/50 text-sm py-2">No agents configured.</p>
+ {:else}
+ <div class="flex flex-col gap-1.5">
+ {#each agents as agent (agent.slug + ":" + agent.scope)}
+ <button
+ class="w-full text-left rounded-lg px-3 py-2 transition-colors {activeAgentSlug === agent.slug ? 'bg-primary text-primary-content' : 'bg-base-300 hover:bg-base-200'}"
+ onclick={() => onAgentChange(agent)}
+ >
+ <div class="flex items-center justify-between gap-2">
+ <span class="font-medium text-sm">{agent.name}</span>
+ <div class="flex gap-1 shrink-0">
+ <span class="badge badge-xs">{agent.models.length} model{agent.models.length !== 1 ? 's' : ''}</span>
+ <span class="badge badge-xs badge-outline">{agent.scope === "global" ? "global" : "project"}</span>
+ </div>
+ </div>
+ {#if agent.description}
+ <p class="text-xs opacity-60 mt-0.5">{agent.description}</p>
+ {/if}
+ </button>
+ {/each}
+ </div>
+ {/if}
+ <button
+ type="button"
+ class="btn btn-outline btn-sm w-full mt-2 hover:bg-base-300 hover:border-base-300 text-base-content/60"
+ onclick={() => router.navigate("agent-builder")}
+ >
+ Agent Settings
+ </button>
{/if}
</div>
diff --git a/packages/frontend/src/lib/components/ModelStatus.svelte b/packages/frontend/src/lib/components/ModelStatus.svelte
index a627de8..1270fcc 100644
--- a/packages/frontend/src/lib/components/ModelStatus.svelte
+++ b/packages/frontend/src/lib/components/ModelStatus.svelte
@@ -1,148 +1,154 @@
<script lang="ts">
- interface KeyInfo {
- id: string;
- provider: string;
- status: "active" | "exhausted";
- lastError: string | null;
- exhaustedAt: number | null;
- }
+interface KeyInfo {
+ id: string;
+ provider: string;
+ status: "active" | "exhausted";
+ lastError: string | null;
+ exhaustedAt: number | null;
+}
- interface CredentialStatus {
- keyId: string;
- provider: string;
- subscriptionType: string | null;
- importedAt: number;
- updatedAt: number;
- expired: boolean;
- }
+interface CredentialStatus {
+ keyId: string;
+ provider: string;
+ subscriptionType: string | null;
+ importedAt: number;
+ updatedAt: number;
+ expired: boolean;
+}
- const {
- keys = [],
- currentModel,
- apiBase = "",
- }: {
- keys?: KeyInfo[];
- currentModel?: string;
- apiBase?: string;
- } = $props();
+const {
+ keys = [],
+ currentModel,
+ apiBase = "",
+}: {
+ keys?: KeyInfo[];
+ currentModel?: string;
+ apiBase?: string;
+} = $props();
- const activeKeys = $derived(keys.filter((k) => k.status === "active").length);
- const totalKeys = $derived(keys.length);
- const allActive = $derived(totalKeys > 0 && activeKeys === totalKeys);
- const allExhausted = $derived(totalKeys > 0 && activeKeys === 0);
- const someExhausted = $derived(totalKeys > 0 && activeKeys < totalKeys && activeKeys > 0);
+const activeKeys = $derived(keys.filter((k) => k.status === "active").length);
+const totalKeys = $derived(keys.length);
+const allActive = $derived(totalKeys > 0 && activeKeys === totalKeys);
+const allExhausted = $derived(totalKeys > 0 && activeKeys === 0);
+const someExhausted = $derived(totalKeys > 0 && activeKeys < totalKeys && activeKeys > 0);
- let credentialStatus = $state<Record<string, CredentialStatus>>({});
- let importingKey = $state<string | null>(null);
- let importError = $state<string | null>(null);
- let importSuccess = $state<string | null>(null);
+let credentialStatus = $state<Record<string, CredentialStatus>>({});
+let importingKey = $state<string | null>(null);
+let importError = $state<string | null>(null);
+let importSuccess = $state<string | null>(null);
- let apiKeyStatus = $state<Record<string, { keyId: string; provider: string; importedAt: number; updatedAt: number }>>({});
- let showKeyModal = $state<string | null>(null); // keyId or null
- let keyModalValue = $state("");
- let keyModalError = $state<string | null>(null);
- let keyModalSaving = $state(false);
+let apiKeyStatus = $state<
+ Record<string, { keyId: string; provider: string; importedAt: number; updatedAt: number }>
+>({});
+let showKeyModal = $state<string | null>(null); // keyId or null
+let keyModalValue = $state("");
+let keyModalError = $state<string | null>(null);
+let keyModalSaving = $state(false);
- async function loadCredentialStatus(): Promise<void> {
- try {
- const res = await fetch(`${apiBase}/models/credentials-status`);
- if (!res.ok) return;
- const data = await res.json() as { credentials: CredentialStatus[] };
- const map: Record<string, CredentialStatus> = {};
- for (const cred of data.credentials) {
- map[cred.keyId] = cred;
- }
- credentialStatus = map;
- } catch {
- // ignore
+async function loadCredentialStatus(): Promise<void> {
+ try {
+ const res = await fetch(`${apiBase}/models/credentials-status`);
+ if (!res.ok) return;
+ const data = (await res.json()) as { credentials: CredentialStatus[] };
+ const map: Record<string, CredentialStatus> = {};
+ for (const cred of data.credentials) {
+ map[cred.keyId] = cred;
}
+ credentialStatus = map;
+ } catch {
+ // ignore
}
+}
- async function importCredentials(keyId: string): Promise<void> {
- importingKey = keyId;
- importError = null;
- importSuccess = null;
- try {
- const res = await fetch(`${apiBase}/models/import-credentials`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ keyId }),
- });
- const data = await res.json() as { success?: boolean; error?: string };
- if (!res.ok || !data.success) {
- importError = data.error ?? "Import failed";
- } else {
- importSuccess = keyId;
- await loadCredentialStatus();
- setTimeout(() => { importSuccess = null; }, 3000);
- }
- } catch (e) {
- importError = e instanceof Error ? e.message : "Network error";
- } finally {
- importingKey = null;
+async function importCredentials(keyId: string): Promise<void> {
+ importingKey = keyId;
+ importError = null;
+ importSuccess = null;
+ try {
+ const res = await fetch(`${apiBase}/models/import-credentials`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ keyId }),
+ });
+ const data = (await res.json()) as { success?: boolean; error?: string };
+ if (!res.ok || !data.success) {
+ importError = data.error ?? "Import failed";
+ } else {
+ importSuccess = keyId;
+ await loadCredentialStatus();
+ setTimeout(() => {
+ importSuccess = null;
+ }, 3000);
}
+ } catch (e) {
+ importError = e instanceof Error ? e.message : "Network error";
+ } finally {
+ importingKey = null;
}
+}
- async function loadApiKeyStatus(): Promise<void> {
- try {
- const res = await fetch(`${apiBase}/models/api-keys-status`);
- if (!res.ok) return;
- const data = await res.json() as { keys: Array<{ keyId: string; provider: string; importedAt: number; updatedAt: number }> };
- const map: Record<string, typeof data.keys[0]> = {};
- for (const k of data.keys) {
- map[k.keyId] = k;
- }
- apiKeyStatus = map;
- } catch {
- // ignore
+async function loadApiKeyStatus(): Promise<void> {
+ try {
+ const res = await fetch(`${apiBase}/models/api-keys-status`);
+ if (!res.ok) return;
+ const data = (await res.json()) as {
+ keys: Array<{ keyId: string; provider: string; importedAt: number; updatedAt: number }>;
+ };
+ const map: Record<string, (typeof data.keys)[0]> = {};
+ for (const k of data.keys) {
+ map[k.keyId] = k;
}
+ apiKeyStatus = map;
+ } catch {
+ // ignore
}
+}
- async function saveApiKey(): Promise<void> {
- if (!showKeyModal || !keyModalValue.trim()) return;
- keyModalSaving = true;
- keyModalError = null;
- try {
- const res = await fetch(`${apiBase}/models/set-api-key`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ keyId: showKeyModal, apiKey: keyModalValue.trim() }),
- });
- const data = await res.json() as { success?: boolean; error?: string };
- if (!res.ok || !data.success) {
- keyModalError = data.error ?? "Failed to save";
- } else {
- showKeyModal = null;
- keyModalValue = "";
- await loadApiKeyStatus();
- }
- } catch (e) {
- keyModalError = e instanceof Error ? e.message : "Network error";
- } finally {
- keyModalSaving = false;
+async function saveApiKey(): Promise<void> {
+ if (!showKeyModal || !keyModalValue.trim()) return;
+ keyModalSaving = true;
+ keyModalError = null;
+ try {
+ const res = await fetch(`${apiBase}/models/set-api-key`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ keyId: showKeyModal, apiKey: keyModalValue.trim() }),
+ });
+ const data = (await res.json()) as { success?: boolean; error?: string };
+ if (!res.ok || !data.success) {
+ keyModalError = data.error ?? "Failed to save";
+ } else {
+ showKeyModal = null;
+ keyModalValue = "";
+ await loadApiKeyStatus();
}
+ } catch (e) {
+ keyModalError = e instanceof Error ? e.message : "Network error";
+ } finally {
+ keyModalSaving = false;
}
+}
- $effect(() => {
- void loadCredentialStatus();
- void loadApiKeyStatus();
- });
+$effect(() => {
+ void loadCredentialStatus();
+ void loadApiKeyStatus();
+});
- function timeAgo(ts: number | null): string {
- if (ts === null) return "";
- const diffMs = Date.now() - ts;
- const diffSec = Math.floor(diffMs / 1000);
- if (diffSec < 60) return `${diffSec}s ago`;
- const diffMin = Math.floor(diffSec / 60);
- if (diffMin < 60) return `${diffMin}m ago`;
- const diffHr = Math.floor(diffMin / 60);
- return `${diffHr}h ago`;
- }
+function timeAgo(ts: number | null): string {
+ if (ts === null) return "";
+ const diffMs = Date.now() - ts;
+ const diffSec = Math.floor(diffMs / 1000);
+ if (diffSec < 60) return `${diffSec}s ago`;
+ const diffMin = Math.floor(diffSec / 60);
+ if (diffMin < 60) return `${diffMin}m ago`;
+ const diffHr = Math.floor(diffMin / 60);
+ return `${diffHr}h ago`;
+}
- function truncate(str: string | null, max: number): string {
- if (!str) return "";
- return str.length > max ? str.slice(0, max) + "..." : str;
- }
+function truncate(str: string | null, max: number): string {
+ if (!str) return "";
+ return str.length > max ? str.slice(0, max) + "..." : str;
+}
</script>
<div class="flex flex-col gap-3">
diff --git a/packages/frontend/src/lib/components/PermissionPrompt.svelte b/packages/frontend/src/lib/components/PermissionPrompt.svelte
index ce7afd7..3a67b30 100644
--- a/packages/frontend/src/lib/components/PermissionPrompt.svelte
+++ b/packages/frontend/src/lib/components/PermissionPrompt.svelte
@@ -1,7 +1,10 @@
<script lang="ts">
import type { PermissionPrompt } from "../types.js";
-const { pending, onReply }: {
+const {
+ pending,
+ onReply,
+}: {
pending: PermissionPrompt[];
onReply: (id: string, reply: "once" | "always" | "reject") => void;
} = $props();
diff --git a/packages/frontend/src/lib/components/SettingsPanel.svelte b/packages/frontend/src/lib/components/SettingsPanel.svelte
index 6580308..79574d2 100644
--- a/packages/frontend/src/lib/components/SettingsPanel.svelte
+++ b/packages/frontend/src/lib/components/SettingsPanel.svelte
@@ -1,99 +1,102 @@
<script lang="ts">
- import type { KeyInfo } from "../types.js";
- import { appSettings } from "../settings.svelte.js";
+import { appSettings } from "../settings.svelte.js";
+import type { KeyInfo } from "../types.js";
- const {
- keys = [],
- apiBase = "",
- }: {
- keys?: KeyInfo[];
- apiBase?: string;
- } = $props();
+const {
+ keys = [],
+ apiBase = "",
+}: {
+ keys?: KeyInfo[];
+ apiBase?: string;
+} = $props();
- let titleKeyId = $state<string | null>(null);
- let titleModelId = $state<string | null>(null);
- let availableModels = $state<string[]>([]);
- let loadingModels = $state(false);
- let autoExpandThinking = $state(appSettings.autoExpandThinking);
+let titleKeyId = $state<string | null>(null);
+let titleModelId = $state<string | null>(null);
+let availableModels = $state<string[]>([]);
+let loadingModels = $state(false);
+let autoExpandThinking = $state(appSettings.autoExpandThinking);
- async function loadSettings(): Promise<void> {
- try {
- const res = await fetch(`${apiBase}/tabs/settings/title-model`);
- if (res.ok) {
- const data = await res.json() as { keyId: string | null; modelId: string | null };
- titleKeyId = data.keyId;
- if (titleKeyId) {
- await loadModelsForKey(titleKeyId);
- }
- titleModelId = data.modelId;
+async function loadSettings(): Promise<void> {
+ try {
+ const res = await fetch(`${apiBase}/tabs/settings/title-model`);
+ if (res.ok) {
+ const data = (await res.json()) as { keyId: string | null; modelId: string | null };
+ titleKeyId = data.keyId;
+ if (titleKeyId) {
+ await loadModelsForKey(titleKeyId);
}
- } catch {
- // ignore
+ titleModelId = data.modelId;
}
- try {
- const res = await fetch(`${apiBase}/tabs/settings/auto-expand-thinking`);
- if (res.ok) {
- const data = await res.json() as { value: string | null };
- autoExpandThinking = data.value === "true";
- appSettings.autoExpandThinking = autoExpandThinking;
- }
- } catch {
- // ignore
+ } catch {
+ // ignore
+ }
+ try {
+ const res = await fetch(`${apiBase}/tabs/settings/auto-expand-thinking`);
+ if (res.ok) {
+ const data = (await res.json()) as { value: string | null };
+ autoExpandThinking = data.value === "true";
+ appSettings.autoExpandThinking = autoExpandThinking;
}
+ } catch {
+ // ignore
}
+}
- async function toggleAutoExpand(): Promise<void> {
- autoExpandThinking = !autoExpandThinking;
- appSettings.autoExpandThinking = autoExpandThinking;
- fetch(`${apiBase}/tabs/settings/auto-expand-thinking`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ value: String(autoExpandThinking) }),
- }).catch(() => {});
- }
+async function toggleAutoExpand(): Promise<void> {
+ autoExpandThinking = !autoExpandThinking;
+ appSettings.autoExpandThinking = autoExpandThinking;
+ fetch(`${apiBase}/tabs/settings/auto-expand-thinking`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ value: String(autoExpandThinking) }),
+ }).catch(() => {});
+}
- async function loadModelsForKey(keyId: string): Promise<void> {
- loadingModels = true;
- try {
- const res = await fetch(`${apiBase}/models/available?keyId=${encodeURIComponent(keyId)}`);
- if (!res.ok) { availableModels = []; return; }
- const data = await res.json() as { models: string[] };
- availableModels = data.models ?? [];
- } catch {
+async function loadModelsForKey(keyId: string): Promise<void> {
+ loadingModels = true;
+ try {
+ const res = await fetch(`${apiBase}/models/available?keyId=${encodeURIComponent(keyId)}`);
+ if (!res.ok) {
availableModels = [];
- } finally {
- loadingModels = false;
+ return;
}
+ const data = (await res.json()) as { models: string[] };
+ availableModels = data.models ?? [];
+ } catch {
+ availableModels = [];
+ } finally {
+ loadingModels = false;
}
+}
- function saveTitleModel(): void {
- fetch(`${apiBase}/tabs/settings/title-model`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ keyId: titleKeyId, modelId: titleModelId }),
- }).catch(() => {});
- }
+function saveTitleModel(): void {
+ fetch(`${apiBase}/tabs/settings/title-model`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ keyId: titleKeyId, modelId: titleModelId }),
+ }).catch(() => {});
+}
- async function onKeyChange(e: Event): Promise<void> {
- const select = e.target as HTMLSelectElement;
- titleKeyId = select.value || null;
- titleModelId = null;
- availableModels = [];
- if (titleKeyId) {
- await loadModelsForKey(titleKeyId);
- }
- saveTitleModel();
+async function onKeyChange(e: Event): Promise<void> {
+ const select = e.target as HTMLSelectElement;
+ titleKeyId = select.value || null;
+ titleModelId = null;
+ availableModels = [];
+ if (titleKeyId) {
+ await loadModelsForKey(titleKeyId);
}
+ saveTitleModel();
+}
- async function onModelChange(e: Event): Promise<void> {
- const select = e.target as HTMLSelectElement;
- titleModelId = select.value || null;
- saveTitleModel();
- }
+async function onModelChange(e: Event): Promise<void> {
+ const select = e.target as HTMLSelectElement;
+ titleModelId = select.value || null;
+ saveTitleModel();
+}
- $effect(() => {
- void loadSettings();
- });
+$effect(() => {
+ void loadSettings();
+});
</script>
<div class="flex flex-col gap-3">
@@ -103,26 +106,30 @@
<p class="text-xs text-base-content/70">Title Generation Model</p>
<p class="text-xs text-base-content/40">Used to generate short titles for new tabs after the first message.</p>
- <label class="text-xs text-base-content/60">Key</label>
- <select class="select select-bordered select-sm w-full" onchange={onKeyChange} value={titleKeyId ?? ""}>
- <option value="">Select a key...</option>
- {#each keys as key (key.id)}
- <option value={key.id}>{key.id} ({key.provider})</option>
- {/each}
- </select>
+ <label class="text-xs text-base-content/60">
+ Key
+ <select class="select select-bordered select-sm w-full" onchange={onKeyChange} value={titleKeyId ?? ""}>
+ <option value="">Select a key...</option>
+ {#each keys as key (key.id)}
+ <option value={key.id}>{key.id} ({key.provider})</option>
+ {/each}
+ </select>
+ </label>
- <label class="text-xs text-base-content/60">Model</label>
- <select
- class="select select-bordered select-sm w-full"
- onchange={onModelChange}
- value={titleModelId ?? ""}
- disabled={!titleKeyId || loadingModels}
- >
- <option value="">{loadingModels ? "Loading models..." : "Select a model..."}</option>
- {#each availableModels as model (model)}
- <option value={model}>{model}</option>
- {/each}
- </select>
+ <label class="text-xs text-base-content/60">
+ Model
+ <select
+ class="select select-bordered select-sm w-full"
+ onchange={onModelChange}
+ value={titleModelId ?? ""}
+ disabled={!titleKeyId || loadingModels}
+ >
+ <option value="">{loadingModels ? "Loading models..." : "Select a model..."}</option>
+ {#each availableModels as model (model)}
+ <option value={model}>{model}</option>
+ {/each}
+ </select>
+ </label>
<div class="divider my-0"></div>
diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte
index a0aeb1d..041679a 100644
--- a/packages/frontend/src/lib/components/SidebarPanel.svelte
+++ b/packages/frontend/src/lib/components/SidebarPanel.svelte
@@ -7,7 +7,6 @@ import ModelSelector from "./ModelSelector.svelte";
import ModelStatus from "./ModelStatus.svelte";
import SettingsPanel from "./SettingsPanel.svelte";
import SkillsBrowser from "./SkillsBrowser.svelte";
-import SystemPromptPanel from "./SystemPromptPanel.svelte";
import TaskListPanel from "./TaskListPanel.svelte";
import ToolPermissions from "./ToolPermissions.svelte";
@@ -19,9 +18,13 @@ const {
activeKeyId = null,
activeModelId = null,
reasoningEffort = "max",
+ activeAgentSlug = null as string | null,
+ workingDirectory = null as string | null,
onKeyChange,
onModelChange,
onReasoningChange,
+ onAgentChange = (_agent: any) => {},
+ onWorkingDirectoryChange = (_dir: string | null) => {},
}: {
keys?: KeyInfo[];
tasks?: TaskItem[];
@@ -30,9 +33,13 @@ const {
activeKeyId?: string | null;
activeModelId?: string | null;
reasoningEffort?: string;
+ activeAgentSlug?: string | null;
+ workingDirectory?: string | null;
onKeyChange: (keyId: string) => void;
onModelChange: (keyId: string, modelId: string) => void;
onReasoningChange: (effort: string) => void;
+ onAgentChange?: (agent: any) => void;
+ onWorkingDirectoryChange?: (dir: string | null) => void;
} = $props();
interface Panel {
@@ -41,11 +48,11 @@ interface Panel {
}
let nextId = 0;
-let panels = $state<Panel[]>([{ id: nextId++, selected: "Model Choice" }]);
+let panels = $state<Panel[]>([{ id: nextId++, selected: "Chat Settings" }]);
const viewOptions = [
"Select a view",
- "Model Choice",
+ "Chat Settings",
"Key Usage",
"Claude Reset",
"Model Status",
@@ -53,7 +60,6 @@ const viewOptions = [
"Config",
"Skills",
"Tools",
- "System Prompt",
"Settings",
];
@@ -104,16 +110,20 @@ function contentClass(selected: string): string {
</div>
<div class={contentClass(panel.selected)}>
- {#if panel.selected === "Model Choice"}
- <ModelSelector
- {keys}
- {activeKeyId}
- {activeModelId}
- {reasoningEffort}
- {onKeyChange}
- {onModelChange}
- {onReasoningChange}
- />
+ {#if panel.selected === "Chat Settings"}
+ <ModelSelector
+ {keys}
+ {activeKeyId}
+ {activeModelId}
+ {reasoningEffort}
+ {onKeyChange}
+ {onModelChange}
+ {onReasoningChange}
+ {activeAgentSlug}
+ {onAgentChange}
+ {workingDirectory}
+ {onWorkingDirectoryChange}
+ />
{:else if panel.selected === "Key Usage"}
<KeyUsage {keys} {apiBase} />
{:else if panel.selected === "Claude Reset"}
@@ -128,16 +138,14 @@ function contentClass(selected: string): string {
<SkillsBrowser {apiBase} />
{:else if panel.selected === "Tools"}
<ToolPermissions entries={permissionLog} {apiBase} />
- {:else if panel.selected === "System Prompt"}
- <SystemPromptPanel {apiBase} />
- {:else if panel.selected === "Settings"}
+ {:else if panel.selected === "Settings"}
<SettingsPanel {keys} {apiBase} />
{/if}
</div>
</div>
{/each}
- <button type="button" class="btn btn-sm btn-ghost w-full" onclick={addPanel}>
+ <button type="button" class="btn bg-base-200 hover:bg-base-300 border-none w-full text-lg" onclick={addPanel}>
+
</button>
</div>
diff --git a/packages/frontend/src/lib/components/SkillsBrowser.svelte b/packages/frontend/src/lib/components/SkillsBrowser.svelte
index 685f890..add43e4 100644
--- a/packages/frontend/src/lib/components/SkillsBrowser.svelte
+++ b/packages/frontend/src/lib/components/SkillsBrowser.svelte
@@ -7,7 +7,7 @@ interface Skill {
description: string;
tags: string[];
scope: "global" | "project";
- directory: "default" | "agents" | "project";
+ directory: string;
}
interface SkillsResponse {
@@ -20,7 +20,27 @@ interface SkillDetail extends Skill {
source: string;
}
-const { apiBase }: { apiBase: string } = $props();
+interface DirGroup {
+ path: string;
+ label: string;
+ scope: "global" | "project";
+ skills: Skill[];
+}
+
+const {
+ apiBase,
+ checkedSkills = null,
+ onSkillToggle = null,
+}: {
+ apiBase: string;
+ /** External checked set (agent builder mode). When null, uses appSettings. */
+ checkedSkills?: Set<string> | null;
+ /** Callback when a skill is toggled in external mode. */
+ onSkillToggle?: ((key: string, checked: boolean) => void) | null;
+} = $props();
+
+/** Whether we're in external (agent builder) mode */
+const externalMode = $derived(checkedSkills !== null && onSkillToggle !== null);
let skills = $state<Skill[]>([]);
let loading = $state(false);
@@ -28,6 +48,7 @@ let error = $state<string | null>(null);
let expandedSkill = $state<string | null>(null);
let expandedDetail = $state<SkillDetail | null>(null);
let loadingDetail = $state(false);
+let collapsedDirs = $state<Set<string>>(new Set());
async function fetchSkills() {
loading = true;
@@ -48,23 +69,64 @@ function skillKey(skill: Skill): string {
return `${skill.scope}:${skill.name}`;
}
+/** Build a unique key for a directory group (scope + path) */
+function dirKey(group: DirGroup): string {
+ return `${group.scope}:${group.path}`;
+}
+
function isChecked(skill: Skill): boolean {
- return appSettings.skillChecks[skillKey(skill)] === true;
+ const key = skillKey(skill);
+ if (externalMode) {
+ return checkedSkills!.has(key);
+ }
+ return appSettings.skillChecks[key] === true;
}
function isInjected(skill: Skill): boolean {
+ if (externalMode) return false;
return tabStore.activeTab?.injectedSkills.includes(skillKey(skill)) ?? false;
}
function toggleCheck(skill: Skill): void {
const key = skillKey(skill);
+ if (externalMode) {
+ onSkillToggle!(key, !checkedSkills!.has(key));
+ return;
+ }
appSettings.skillChecks = { ...appSettings.skillChecks, [key]: !isChecked(skill) };
}
function resetChecks(): void {
+ if (externalMode) return;
appSettings.skillChecks = {};
}
+function toggleDir(key: string): void {
+ const next = new Set(collapsedDirs);
+ if (next.has(key)) next.delete(key);
+ else next.add(key);
+ collapsedDirs = next;
+}
+
+/** Check if a group is hidden because an ancestor directory is collapsed */
+function isHiddenByParent(group: DirGroup): boolean {
+ if (!group.path.includes("/")) return false;
+ // Check each ancestor path segment
+ const parts = group.path.split("/");
+ for (let i = 1; i < parts.length; i++) {
+ const ancestorPath = parts.slice(0, i).join("/");
+ const ancestorKey = `${group.scope}:${ancestorPath}`;
+ if (collapsedDirs.has(ancestorKey)) return true;
+ }
+ return false;
+}
+
+/** Get the top-level directory for grouping spacing */
+function topLevelDir(group: DirGroup): string {
+ const slash = group.path.indexOf("/");
+ return slash === -1 ? group.path : group.path.slice(0, slash);
+}
+
async function toggleExpand(skill: Skill) {
const key = skillKey(skill);
if (expandedSkill === key) {
@@ -92,7 +154,37 @@ $effect(() => {
fetchSkills();
});
-const checkedCount = $derived(Object.values(appSettings.skillChecks).filter((v) => v).length);
+const checkedCount = $derived(
+ externalMode
+ ? checkedSkills!.size
+ : Object.values(appSettings.skillChecks).filter((v) => v).length,
+);
+
+/** Group skills by scope + directory, sorted */
+const dirGroups = $derived.by((): DirGroup[] => {
+ const map = new Map<string, DirGroup>();
+ for (const skill of skills) {
+ const key = `${skill.scope}:${skill.directory}`;
+ let group = map.get(key);
+ if (!group) {
+ const label = skill.directory || "(root)";
+ group = { path: skill.directory, label, scope: skill.scope, skills: [] };
+ map.set(key, group);
+ }
+ group.skills.push(skill);
+ }
+ // Sort: global before project, then alphabetically by path
+ const groups = Array.from(map.values());
+ groups.sort((a, b) => {
+ if (a.scope !== b.scope) return a.scope === "global" ? -1 : 1;
+ return a.path.localeCompare(b.path);
+ });
+ // Sort skills within each group alphabetically
+ for (const g of groups) {
+ g.skills.sort((a, b) => a.name.localeCompare(b.name));
+ }
+ return groups;
+});
</script>
<div class="flex flex-col gap-3">
@@ -102,7 +194,7 @@ const checkedCount = $derived(Object.values(appSettings.skillChecks).filter((v)
<span class="badge badge-sm badge-neutral">{skills.length}</span>
{/if}
{#if checkedCount > 0}
- <span class="badge badge-sm badge-primary">{checkedCount} queued</span>
+ <span class="badge badge-sm badge-primary">{checkedCount} {externalMode ? 'selected' : 'queued'}</span>
{/if}
<button
class="btn btn-xs btn-ghost ml-auto"
@@ -113,7 +205,9 @@ const checkedCount = $derived(Object.values(appSettings.skillChecks).filter((v)
</button>
</div>
- <p class="text-xs text-base-content/40">Check skills to inject with your next message.</p>
+ {#if !externalMode}
+ <p class="text-xs text-base-content/40">Check skills to inject with your next message.</p>
+ {/if}
{#if loading}
<div class="flex items-center gap-2 py-2 text-base-content/60">
@@ -127,64 +221,97 @@ const checkedCount = $derived(Object.values(appSettings.skillChecks).filter((v)
No skills found. Create <code class="font-mono">.skills/</code> directories to get started.
</p>
{:else}
- <div class="flex flex-col gap-0.5">
- {#each skills as skill (skillKey(skill))}
- {@const key = skillKey(skill)}
- {@const checked = isChecked(skill)}
- {@const injected = isInjected(skill)}
- <div
- class="rounded p-1.5 transition-colors {injected ? 'bg-primary/10 border border-primary/20' : 'hover:bg-base-200'}"
- >
- <label class="flex items-start gap-2 cursor-pointer">
- <input
- type="checkbox"
- class="checkbox checkbox-sm checkbox-primary rounded-sm mt-0.5"
- checked={checked}
- onchange={() => toggleCheck(skill)}
- />
- <div class="flex-1 min-w-0">
- <div class="flex items-center gap-1.5 flex-wrap">
- <button
- class="font-mono text-xs text-left hover:underline {injected ? 'text-primary font-semibold' : 'text-base-content'}"
- onclick={() => toggleExpand(skill)}
- >
- {skill.name}
- </button>
- <span class="badge badge-xs {skill.scope === 'global' ? 'badge-info' : 'badge-warning'}">{skill.scope}</span>
- {#if injected}
- <span class="badge badge-xs badge-primary">active</span>
- {/if}
- {#each skill.tags as tag}
- <span class="badge badge-xs badge-outline">{tag}</span>
- {/each}
- </div>
- {#if skill.description}
- <p class="text-xs text-base-content/50 truncate">{skill.description}</p>
- {/if}
- </div>
- </label>
-
- {#if expandedSkill === key}
- <div class="mt-2 ml-6 bg-base-300 rounded p-2">
- {#if loadingDetail}
- <span class="loading loading-spinner loading-xs text-base-content/40"></span>
- {:else if expandedDetail}
- <pre class="whitespace-pre-wrap font-mono text-xs overflow-x-auto max-h-60 overflow-y-auto">{expandedDetail.content}</pre>
- {:else}
- <p class="text-error text-xs">Failed to load skill content.</p>
+ <div class="flex flex-col">
+ {#each dirGroups as group, idx (dirKey(group))}
+ {@const collapsed = collapsedDirs.has(dirKey(group))}
+ {@const hidden = isHiddenByParent(group)}
+ {@const prevGroup = dirGroups[idx - 1]}
+ {@const isNewTopLevel = idx === 0 || !prevGroup || topLevelDir(group) !== topLevelDir(prevGroup) || group.scope !== prevGroup.scope}
+ {#if !hidden}
+ {#if isNewTopLevel && idx > 0}
+ <div class="divider my-1"></div>
+ {/if}
+ <div class="{isNewTopLevel ? '' : 'mt-0.5'}">
+ <div class="rounded border border-base-content/20">
+ <!-- Directory header -->
+ <button
+ type="button"
+ class="flex items-center gap-1.5 w-full px-2 py-1.5 text-left hover:bg-base-200 transition-colors rounded-t"
+ onclick={() => toggleDir(dirKey(group))}
+ >
+ <span class="text-xs text-base-content/40 w-3 inline-block transition-transform {collapsed ? '-rotate-90' : ''}">▼</span>
+ <span class="font-mono text-xs font-semibold text-base-content/70">{group.label}</span>
+ <span class="badge badge-xs {group.scope === 'global' ? 'badge-info' : 'badge-warning'}">{group.scope}</span>
+ <span class="badge badge-xs badge-neutral ml-auto">{group.skills.length}</span>
+ </button>
+
+ <!-- Skills in this directory -->
+ {#if !collapsed}
+ <div class="flex flex-col gap-0.5 px-1 pb-1">
+ {#each group.skills as skill (skillKey(skill))}
+ {@const key = skillKey(skill)}
+ {@const checked = isChecked(skill)}
+ {@const injected = isInjected(skill)}
+ <div
+ class="rounded p-1.5 transition-colors {injected ? 'bg-primary/10 border border-primary/20' : 'hover:bg-base-200'}"
+ >
+ <label class="flex items-start gap-2 cursor-pointer">
+ <input
+ type="checkbox"
+ class="checkbox checkbox-sm checkbox-primary rounded-sm mt-0.5"
+ checked={checked}
+ onchange={() => toggleCheck(skill)}
+ />
+ <div class="flex-1 min-w-0">
+ <div class="flex items-center gap-1.5 flex-wrap">
+ <button
+ class="font-mono text-xs text-left hover:underline {injected ? 'text-primary font-semibold' : 'text-base-content'}"
+ onclick={() => toggleExpand(skill)}
+ >
+ {skill.name}
+ </button>
+ {#if injected}
+ <span class="badge badge-xs badge-primary">active</span>
+ {/if}
+ {#each skill.tags as tag}
+ <span class="badge badge-xs badge-outline">{tag}</span>
+ {/each}
+ </div>
+ {#if skill.description}
+ <p class="text-xs text-base-content/50 truncate">{skill.description}</p>
+ {/if}
+ </div>
+ </label>
+
+ {#if expandedSkill === key}
+ <div class="mt-2 ml-6 bg-base-300 rounded p-2">
+ {#if loadingDetail}
+ <span class="loading loading-spinner loading-xs text-base-content/40"></span>
+ {:else if expandedDetail}
+ <pre class="whitespace-pre-wrap font-mono text-xs overflow-x-auto max-h-60 overflow-y-auto">{expandedDetail.content}</pre>
+ {:else}
+ <p class="text-error text-xs">Failed to load skill content.</p>
+ {/if}
+ </div>
+ {/if}
+ </div>
+ {/each}
+ </div>
{/if}
</div>
- {/if}
- </div>
+ </div>
+ {/if}
{/each}
</div>
{/if}
- <button
- class="btn btn-sm btn-ghost w-full"
- disabled={!appSettings.skillChecksDirty}
- onclick={resetChecks}
- >
- Reset
- </button>
+ {#if !externalMode}
+ <button
+ class="btn btn-sm btn-ghost w-full"
+ disabled={!appSettings.skillChecksDirty}
+ onclick={resetChecks}
+ >
+ Reset
+ </button>
+ {/if}
</div>
diff --git a/packages/frontend/src/lib/components/SystemPromptPanel.svelte b/packages/frontend/src/lib/components/SystemPromptPanel.svelte
index 6b91ebe..d9039f4 100644
--- a/packages/frontend/src/lib/components/SystemPromptPanel.svelte
+++ b/packages/frontend/src/lib/components/SystemPromptPanel.svelte
@@ -1,42 +1,42 @@
<script lang="ts">
- import { onMount } from "svelte";
- import { appSettings } from "../settings.svelte.js";
-
- const {
- apiBase = "",
- }: {
- apiBase?: string;
- } = $props();
-
- const DEFAULT_PROMPT = "You are Dispatch, a helpful AI coding assistant. Be concise and helpful.";
-
- async function loadPrompt(): Promise<void> {
- try {
- const res = await fetch(`${apiBase}/tabs/settings/system_prompt`);
- if (res.ok) {
- const data = await res.json() as { value: string | null };
- const value = data.value ?? DEFAULT_PROMPT;
- appSettings.systemPrompt = value;
- appSettings.savedSystemPrompt = value;
- }
- } catch {
- // ignore
+import { onMount } from "svelte";
+import { appSettings } from "../settings.svelte.js";
+
+const {
+ apiBase = "",
+}: {
+ apiBase?: string;
+} = $props();
+
+const DEFAULT_PROMPT = "You are Dispatch, a helpful AI coding assistant. Be concise and helpful.";
+
+async function loadPrompt(): Promise<void> {
+ try {
+ const res = await fetch(`${apiBase}/tabs/settings/system_prompt`);
+ if (res.ok) {
+ const data = (await res.json()) as { value: string | null };
+ const value = data.value ?? DEFAULT_PROMPT;
+ appSettings.systemPrompt = value;
+ appSettings.savedSystemPrompt = value;
}
+ } catch {
+ // ignore
}
+}
- function resetPrompt(): void {
- appSettings.systemPrompt = appSettings.savedSystemPrompt;
- }
+function resetPrompt(): void {
+ appSettings.systemPrompt = appSettings.savedSystemPrompt;
+}
- const isDirty = $derived(appSettings.systemPrompt !== appSettings.savedSystemPrompt);
+const isDirty = $derived(appSettings.systemPrompt !== appSettings.savedSystemPrompt);
- onMount(() => {
- if (!appSettings.systemPrompt) {
- appSettings.systemPrompt = DEFAULT_PROMPT;
- appSettings.savedSystemPrompt = DEFAULT_PROMPT;
- }
- loadPrompt();
- });
+onMount(() => {
+ if (!appSettings.systemPrompt) {
+ appSettings.systemPrompt = DEFAULT_PROMPT;
+ appSettings.savedSystemPrompt = DEFAULT_PROMPT;
+ }
+ loadPrompt();
+});
</script>
<div class="flex flex-col gap-3 flex-1 min-h-0">
diff --git a/packages/frontend/src/lib/components/TabBar.svelte b/packages/frontend/src/lib/components/TabBar.svelte
index cfa5887..feb1e5b 100644
--- a/packages/frontend/src/lib/components/TabBar.svelte
+++ b/packages/frontend/src/lib/components/TabBar.svelte
@@ -25,12 +25,13 @@ const activeUserTabId = $derived(
<!-- Top row: user tabs -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
- class="overflow-x-auto bg-base-200 flex-shrink-0"
+ class="overflow-x-auto bg-base-200 flex-shrink-0 {hasSubagentTabs ? '' : 'rounded-br-lg'}"
ondblclick={(e) => { if (e.target === e.currentTarget) tabStore.createNewTab(); }}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
role="tablist"
+ tabindex="0"
class="tabs tabs-lift min-w-max"
ondblclick={(e) => { if (e.target === e.currentTarget) tabStore.createNewTab(); }}
>
@@ -72,7 +73,7 @@ const activeUserTabId = $derived(
<!-- Bottom row: subagent tabs (hidden when empty) -->
{#if hasSubagentTabs}
- <div class="overflow-x-auto bg-base-200 flex-shrink-0 border-t border-base-300">
+ <div class="overflow-x-auto bg-base-200 flex-shrink-0 border-t border-base-300 rounded-br-lg">
<div
role="tablist"
class="tabs tabs-lift tabs-xs min-w-max"
diff --git a/packages/frontend/src/lib/components/TaskListPanel.svelte b/packages/frontend/src/lib/components/TaskListPanel.svelte
index d70373f..17ade55 100644
--- a/packages/frontend/src/lib/components/TaskListPanel.svelte
+++ b/packages/frontend/src/lib/components/TaskListPanel.svelte
@@ -29,17 +29,6 @@ function isChecked(status: TaskItem["status"]): boolean {
function isIndeterminate(status: TaskItem["status"]): boolean {
return status === "in_progress";
}
-
-function statusLabel(status: TaskItem["status"]): string {
- switch (status) {
- case "pending":
- return "Pending";
- case "in_progress":
- return "In progress";
- case "done":
- return "Done";
- }
-}
</script>
<div class="flex flex-col gap-2">
diff --git a/packages/frontend/src/lib/components/ThemeSwitcher.svelte b/packages/frontend/src/lib/components/ThemeSwitcher.svelte
index fe12cc5..418fcea 100644
--- a/packages/frontend/src/lib/components/ThemeSwitcher.svelte
+++ b/packages/frontend/src/lib/components/ThemeSwitcher.svelte
@@ -1,8 +1,18 @@
<script lang="ts">
const THEMES = [
- "light", "dark", "dracula", "night", "nord", "sunset",
- "cyberpunk", "forest", "cmyk", "coffee", "caramellatte",
- "garden", "luxury",
+ "light",
+ "dark",
+ "dracula",
+ "night",
+ "nord",
+ "sunset",
+ "cyberpunk",
+ "forest",
+ "cmyk",
+ "coffee",
+ "caramellatte",
+ "garden",
+ "luxury",
] as const;
const STORAGE_KEY = "dispatch-theme";
diff --git a/packages/frontend/src/lib/components/ToolPermissions.svelte b/packages/frontend/src/lib/components/ToolPermissions.svelte
index c6af47f..6533c7b 100644
--- a/packages/frontend/src/lib/components/ToolPermissions.svelte
+++ b/packages/frontend/src/lib/components/ToolPermissions.svelte
@@ -3,8 +3,6 @@ import { onMount } from "svelte";
import { appSettings } from "../settings.svelte.js";
import type { LogEntry } from "../types.js";
-const { entries, apiBase = "" }: { entries: LogEntry[]; apiBase?: string } = $props();
-
interface ToolPermission {
id: string;
label: string;
@@ -24,13 +22,30 @@ const toolPermissions: ToolPermission[] = [
label: "Summon agents",
description: "Allow the AI to spawn child agents to work on tasks",
},
- {
- id: "external_directory",
- label: "External directories",
- description: "Allow access to files outside the workspace",
- },
];
+const {
+ entries = [],
+ apiBase = "",
+ checkedTools = null,
+ onToolToggle = null,
+}: {
+ entries?: LogEntry[];
+ apiBase?: string;
+ /** External checked set (agent builder mode). When null, uses appSettings. */
+ checkedTools?: Set<string> | null;
+ /** Callback when a tool is toggled in external mode. */
+ onToolToggle?: ((id: string, checked: boolean) => void) | null;
+} = $props();
+
+/** Whether we're in external (agent builder) mode */
+const externalMode = $derived(checkedTools !== null && onToolToggle !== null);
+
+function isChecked(id: string): boolean {
+ if (externalMode) return checkedTools!.has(id);
+ return appSettings.toolPerms[id] === true;
+}
+
async function loadPermissions(): Promise<void> {
const loaded: Record<string, boolean> = { ...appSettings.toolPerms };
for (const perm of toolPermissions) {
@@ -51,6 +66,10 @@ async function loadPermissions(): Promise<void> {
}
function togglePermission(id: string): void {
+ if (externalMode) {
+ onToolToggle!(id, !checkedTools!.has(id));
+ return;
+ }
appSettings.toolPerms = { ...appSettings.toolPerms, [id]: !appSettings.toolPerms[id] };
}
@@ -59,13 +78,17 @@ function resetPermissions(): void {
}
onMount(() => {
- loadPermissions();
+ if (!externalMode) {
+ loadPermissions();
+ }
});
</script>
<div class="flex flex-col gap-3">
<div class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Tool Permissions</div>
- <p class="text-xs text-base-content/40">Changes are applied when you send your next message.</p>
+ {#if !externalMode}
+ <p class="text-xs text-base-content/40">Changes are applied when you send your next message.</p>
+ {/if}
<div class="flex flex-col gap-1.5">
{#each toolPermissions as perm (perm.id)}
@@ -73,7 +96,7 @@ onMount(() => {
<input
type="checkbox"
class="checkbox checkbox-sm rounded-sm mt-0.5"
- checked={appSettings.toolPerms[perm.id]}
+ checked={isChecked(perm.id)}
onchange={() => togglePermission(perm.id)}
/>
<div class="flex flex-col">
@@ -84,35 +107,37 @@ onMount(() => {
{/each}
</div>
- <button
- class="btn btn-sm btn-ghost w-full"
- disabled={!appSettings.toolPermsDirty}
- onclick={resetPermissions}
- >
- Reset
- </button>
+ {#if !externalMode}
+ <button
+ class="btn btn-sm btn-ghost w-full"
+ disabled={!appSettings.toolPermsDirty}
+ onclick={resetPermissions}
+ >
+ Reset
+ </button>
- <p class="text-xs text-base-content/40">Warning: changing tool access will reset the AI's prompt cache for active conversations, which may increase usage costs.</p>
+ <p class="text-xs text-base-content/40">Warning: changing tool access will reset the AI's prompt cache for active conversations, which may increase usage costs.</p>
- <!-- Permission Log -->
- {#if entries.length > 0}
- <div class="collapse collapse-arrow bg-base-200 mt-2">
- <input type="checkbox" />
- <div class="collapse-title text-sm font-medium py-2 min-h-0">
- Log ({entries.length})
- </div>
- <div class="collapse-content text-xs max-h-40 overflow-y-auto">
- {#each entries as entry (entry.id)}
- <div class="flex items-center gap-2 py-1 border-b border-base-300">
- <span class="badge badge-sm {entry.action === 'reject' ? 'badge-error' : 'badge-success'}">
- {entry.action}
- </span>
- <span class="text-base-content/70">{entry.permission}</span>
- <span class="text-base-content/50 ml-auto text-xs">{entry.timestamp}</span>
- </div>
- <p class="text-base-content/60 pl-2 pb-1">{entry.description}</p>
- {/each}
+ <!-- Permission Log -->
+ {#if entries.length > 0}
+ <div class="collapse collapse-arrow bg-base-200 mt-2">
+ <input type="checkbox" />
+ <div class="collapse-title text-sm font-medium py-2 min-h-0">
+ Log ({entries.length})
+ </div>
+ <div class="collapse-content text-xs max-h-40 overflow-y-auto">
+ {#each entries as entry (entry.id)}
+ <div class="flex items-center gap-2 py-1 border-b border-base-300">
+ <span class="badge badge-sm {entry.action === 'reject' ? 'badge-error' : 'badge-success'}">
+ {entry.action}
+ </span>
+ <span class="text-base-content/70">{entry.permission}</span>
+ <span class="text-base-content/50 ml-auto text-xs">{entry.timestamp}</span>
+ </div>
+ <p class="text-base-content/60 pl-2 pb-1">{entry.description}</p>
+ {/each}
+ </div>
</div>
- </div>
+ {/if}
{/if}
</div>
diff --git a/packages/frontend/src/lib/router.svelte.ts b/packages/frontend/src/lib/router.svelte.ts
new file mode 100644
index 0000000..8bed880
--- /dev/null
+++ b/packages/frontend/src/lib/router.svelte.ts
@@ -0,0 +1,12 @@
+type Page = "dashboard" | "agent-builder";
+
+let currentPage = $state<Page>("dashboard");
+
+export const router = {
+ get page() {
+ return currentPage;
+ },
+ navigate(page: Page) {
+ currentPage = page;
+ },
+};
diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts
index 15a0736..7722a7e 100644
--- a/packages/frontend/src/lib/tabs.svelte.ts
+++ b/packages/frontend/src/lib/tabs.svelte.ts
@@ -38,6 +38,12 @@ export interface Tab {
parentTabId: string | null;
/** Persistent tabs stay until manually closed. Temp tabs disappear when agent finishes. */
persistent: boolean;
+ /** Slug of the selected agent, or null for manual mode */
+ agentSlug: string | null;
+ /** Scope of the selected agent */
+ agentScope: string | null;
+ /** Custom working directory override for this tab */
+ workingDirectory: string | null;
}
function createTabStore() {
@@ -96,12 +102,18 @@ function createTabStore() {
injectedSkills: [],
parentTabId: null,
persistent: true,
+ agentSlug: null,
+ agentScope: null,
+ workingDirectory: null,
};
tabs = [...tabs, tab];
activeTabId = id;
- // Auto-check default skills for injection with the first message
- void autoCheckDefaultSkills();
+ // Auto-check default skills then apply default agent (sequential to avoid race)
+ void (async () => {
+ await autoCheckDefaultSkills();
+ await autoSelectDefaultAgent(id);
+ })();
return tab;
}
@@ -181,6 +193,9 @@ function createTabStore() {
injectedSkills: [],
parentTabId: tabData.parentTabId ?? null,
persistent: true,
+ agentSlug: null,
+ agentScope: null,
+ workingDirectory: null,
};
tabs = [...tabs, newTab];
activeTabId = agentId;
@@ -209,7 +224,7 @@ function createTabStore() {
if (fallback && !fallback.persistent) {
updateTab(fallback.id, { persistent: true });
}
- activeTabId = fallback?.id;
+ activeTabId = fallback?.id ?? null;
} else {
await createNewTab();
}
@@ -447,6 +462,9 @@ function createTabStore() {
injectedSkills: [],
parentTabId: newTabEvent.parentTabId ?? null,
persistent: newTabEvent.parentTabId == null,
+ agentSlug: null,
+ agentScope: null,
+ workingDirectory: null,
};
tabs = [...tabs, tab];
}
@@ -478,6 +496,73 @@ function createTabStore() {
}
}
+ async function autoSelectDefaultAgent(tabId: string): Promise<void> {
+ try {
+ const res = await fetch(`${config.apiBase}/agents`);
+ if (!res.ok) return;
+ const data = (await res.json()) as {
+ agents?: Array<{
+ slug: string;
+ scope: string;
+ name: string;
+ skills: string[];
+ tools: string[];
+ models: Array<{ key_id: string; model_id: string }>;
+ cwd?: string;
+ }>;
+ };
+ const agents = data.agents ?? [];
+ const defaultAgent = agents.find((a: { slug: string; scope: string }) => a.slug === "default" && a.scope === "global");
+ if (!defaultAgent) return;
+
+ const tab = getTabById(tabId);
+ if (!tab) return;
+
+ // Apply the default agent
+ const firstModel = defaultAgent.models[0];
+ const patch: Partial<Tab> = {
+ agentSlug: defaultAgent.slug,
+ agentScope: defaultAgent.scope,
+ workingDirectory: defaultAgent.cwd || null,
+ };
+ if (firstModel) {
+ patch.keyId = firstModel.key_id;
+ patch.modelId = firstModel.model_id;
+ }
+ updateTab(tabId, patch);
+
+ // Merge the agent's skills into existing checked skills
+ if (defaultAgent.skills.length > 0) {
+ const checks: Record<string, boolean> = { ...appSettings.skillChecks };
+ for (const skillKey of defaultAgent.skills) {
+ checks[skillKey] = true;
+ }
+ appSettings.skillChecks = checks;
+ }
+
+ // Apply tool permissions
+ const perms: Record<string, boolean> = {};
+ for (const key of Object.keys(appSettings.toolPerms)) {
+ perms[key] = false;
+ }
+ for (const tool of defaultAgent.tools) {
+ perms[tool] = true;
+ }
+ appSettings.toolPerms = perms;
+
+ // Persist to backend
+ if (firstModel) {
+ fetch(`${config.apiBase}/tabs/${tabId}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ keyId: firstModel.key_id, modelId: firstModel.model_id }),
+ }).catch(() => {});
+ }
+ } catch {
+ // Silently ignore
+ }
+ }
+
async function fetchSkillContent(scope: string, name: string): Promise<string | null> {
try {
const res = await fetch(
@@ -584,6 +669,7 @@ function createTabStore() {
...(tab.keyId ? { keyId: tab.keyId } : {}),
...(tab.modelId ? { modelId: tab.modelId } : {}),
reasoningEffort: tab.reasoningEffort,
+ ...(tab.workingDirectory ? { workingDirectory: tab.workingDirectory } : {}),
}),
});
if (!res.ok) {
@@ -639,6 +725,71 @@ function createTabStore() {
}).catch(() => {});
}
+ function setWorkingDirectory(dir: string | null): void {
+ const tab = getActiveTab();
+ if (!tab) return;
+ updateTab(tab.id, { workingDirectory: dir || null });
+ }
+
+ function setAgent(
+ agent: {
+ slug: string;
+ scope: string;
+ skills: string[];
+ tools: string[];
+ models: Array<{ key_id: string; model_id: string }>;
+ cwd?: string;
+ } | null,
+ ): void {
+ const tab = getActiveTab();
+ if (!tab) return;
+
+ if (!agent) {
+ // Switch back to manual mode — clear agent
+ updateTab(tab.id, { agentSlug: null, agentScope: null });
+ return;
+ }
+
+ // Apply agent's first model as the active key+model
+ const firstModel = agent.models[0];
+ const patch: Partial<Tab> = {
+ agentSlug: agent.slug,
+ agentScope: agent.scope,
+ workingDirectory: agent.cwd || null,
+ };
+ if (firstModel) {
+ patch.keyId = firstModel.key_id;
+ patch.modelId = firstModel.model_id;
+ }
+ updateTab(tab.id, patch);
+
+ // Reset and apply the agent's skills (don't accumulate from previous agents)
+ const checks: Record<string, boolean> = {};
+ for (const skillKey of agent.skills) {
+ checks[skillKey] = true;
+ }
+ appSettings.skillChecks = checks;
+
+ // Always reset tool permissions to agent's allowlist (even if empty)
+ const perms: Record<string, boolean> = {};
+ for (const key of Object.keys(appSettings.toolPerms)) {
+ perms[key] = false;
+ }
+ for (const tool of agent.tools) {
+ perms[tool] = true;
+ }
+ appSettings.toolPerms = perms;
+
+ // Persist to backend
+ fetch(`${config.apiBase}/tabs/${tab.id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ ...(firstModel ? { keyId: firstModel.key_id, modelId: firstModel.model_id } : {}),
+ }),
+ }).catch(() => {});
+ }
+
function replyPermission(id: string, reply: "once" | "always" | "reject"): void {
if (wsClient.connectionStatus !== "connected") return;
const prompt = pendingPermissions.find((p) => p.id === id);
@@ -722,10 +873,12 @@ function createTabStore() {
sendMessage,
changeModel,
setKey,
+ setAgent,
replyPermission,
copyConversation,
promoteTab,
openAgentTab,
+ setWorkingDirectory,
};
}
diff --git a/packages/frontend/tests/chat-store.test.ts b/packages/frontend/tests/chat-store.test.ts
index a56ac13..978bce1 100644
--- a/packages/frontend/tests/chat-store.test.ts
+++ b/packages/frontend/tests/chat-store.test.ts
@@ -87,15 +87,15 @@ function createTestStore(wsSend?: (data: unknown) => void) {
ensureCurrentAssistantMessage();
messages = messages.map((m) => {
if (m.id === currentAssistantId) {
- const segments: ContentSegment[] = [
- ...m.content,
- {
- type: "tool-call",
- id: event.toolCall.id,
- name: event.toolCall.name,
- arguments: event.toolCall.arguments,
- },
- ];
+ const segments: ContentSegment[] = [
+ ...m.content,
+ {
+ type: "tool-call",
+ id: event.toolCall.id,
+ name: event.toolCall.name,
+ arguments: event.toolCall.arguments,
+ },
+ ];
return { ...m, content: segments };
}
return m;
@@ -109,7 +109,11 @@ function createTestStore(wsSend?: (data: unknown) => void) {
...m,
content: m.content.map((seg) => {
if (seg.type === "tool-call" && seg.id === event.toolResult.toolCallId) {
- return { ...seg, result: event.toolResult.result, isError: event.toolResult.isError };
+ return {
+ ...seg,
+ result: event.toolResult.result,
+ isError: event.toolResult.isError,
+ };
}
return seg;
}),
@@ -338,7 +342,9 @@ describe("chat store logic", () => {
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).toEqual([{ type: "text", text: "Error: something went wrong" }]);
+ expect(store.messages[0]?.content).toEqual([
+ { type: "text", text: "Error: something went wrong" },
+ ]);
expect(store.agentStatus).toBe("error");
});
@@ -529,7 +535,9 @@ describe("permission log", () => {
});
// Shell output parsing logic (mirrors ToolCallDisplay logic)
-function parseShellResult(result: string): { stdout: string; stderr: string; exitCode: number } | null {
+function parseShellResult(
+ result: string,
+): { stdout: string; stderr: string; exitCode: number } | null {
try {
const parsed = JSON.parse(result) as unknown;
if (