diff options
62 files changed, 3218 insertions, 1374 deletions
@@ -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">✔</span> + {:else if cwdExists === false} + <span class="text-warning text-lg" title="Directory does not exist (will be created)">✖</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} > - ↓ + ▼ </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, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - } +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} - 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">✔</span> + {:else if cwdExists === false} + <span class="text-warning text-sm" title="Will be created">✖</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 ( |
