summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/agents
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-22 15:24:13 +0900
committerAdam Malczewski <[email protected]>2026-05-22 15:24:13 +0900
commit9ecaabd87c0e51b8a7408dabb0133a9344586859 (patch)
tree4b5809ab23948a5e3f558f3aa34c52d5038f4e19 /packages/core/src/agents
parent8f1dd855f0c4c877bff8d3a4ba193432b268b1c2 (diff)
downloaddispatch-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.ts1
-rw-r--r--packages/core/src/agents/loader.ts212
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;
+}