diff options
| author | Adam Malczewski <[email protected]> | 2026-05-22 15:24:13 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-22 15:24:13 +0900 |
| commit | 9ecaabd87c0e51b8a7408dabb0133a9344586859 (patch) | |
| tree | 4b5809ab23948a5e3f558f3aa34c52d5038f4e19 /packages/core/src/agents | |
| parent | 8f1dd855f0c4c877bff8d3a4ba193432b268b1c2 (diff) | |
| download | dispatch-9ecaabd87c0e51b8a7408dabb0133a9344586859.tar.gz dispatch-9ecaabd87c0e51b8a7408dabb0133a9344586859.zip | |
feat: agent builder, CWD support, auto-save, UI polish, unavailable tool handling
- Agent Builder: full CRUD with card grid, drag-and-drop model reorder, edit/delete
- Auto-save on edit with 600ms debounce, AbortController for concurrency, fieldset disabled until name entered
- Agent definitions stored as TOML with cwd field, loaded from global/project dirs
- Working directory: per-tab CWD override in Chat Settings, agent default CWD, auto-create on first message
- CWD validation: check-dir endpoint with ~ expansion, real-time validity indicator
- Subagent CWD validated against parent's effective CWD using path.relative
- Unavailable tool calls: caught gracefully, shown as tool call with error badge, model retries
- UI: tab bar border radius, sidebar border removed, chat input ghost style, scroll-to-bottom rectangle
- Skills dir collapse uses CSS rotation, Model Choice renamed to Chat Settings, System Prompt view removed
- Reusable SkillsBrowser/ToolPermissions with external mode for Agent Builder
- ModelSelector: Agent/Manual toggle, agent list, Agent Settings link
- Page router, skills recursive scanning, bin/up gopass removed, docker volume mounts
Diffstat (limited to 'packages/core/src/agents')
| -rw-r--r-- | packages/core/src/agents/index.ts | 1 | ||||
| -rw-r--r-- | packages/core/src/agents/loader.ts | 212 |
2 files changed, 213 insertions, 0 deletions
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; +} |
