import { readdir } from 'node:fs/promises'; import { join } from 'node:path'; import { itemsDir } from '../../data-path'; // File-backed agent store. An agent is a directory under $OFFICER_ITEMS_DIR/agents// holding // an AGENT.md: YAML frontmatter plus a prose body that IS the agent's opening prompt. Same shape and // same parser conventions as a task, but a different concept — a task is a job the platform executes, // an agent is a single-purpose Claude session the platform opens on your behalf. // // The directory may hold anything else the prompt refers to (scripts, reference files). Unlike a // script task, nothing is materialised into a temp dir: the agent works in place with absolute paths, // so siblings are simply there to be read. const YAML = (Bun as unknown as { YAML: { parse(input: string): unknown } }).YAML; // Chat model ids are `/` (see api/chat/list-models.ts); claude-manager splits on '/' // and passes the tail to the CLI as --model. Agents default to opus rather than inheriting the bare // 'claude-code' default used elsewhere, which leaves the tier up to the CLI. export const DEFAULT_AGENT_MODEL = 'claude-code/opus'; export type AgentFrontmatter = { name: string; description: string | null; category: string | null; version: number; model: string; inputs: unknown; trigger: unknown; tags: string[] | null; // Declared for the author's intent and for the UI to display. NOTHING ENFORCES EITHER YET — there is // no runner-side scheduler or wall-clock guard. Do not read a `concurrency: 1` here as protection. concurrency: number | null; timeout: number | null; }; export type AgentRecord = AgentFrontmatter & { dirName: string; /** The prose body — this is the prompt the run opens with. */ body: string; filePath: string; }; export type AgentSummary = { dirName: string; name: string; description: string | null; category: string | null; version: number; trigger: unknown; }; const agentsRoot = () => itemsDir('agents'); const agentDir = (dirName: string) => join(agentsRoot(), dirName); const agentFile = (dirName: string) => join(agentDir(dirName), 'AGENT.md'); const asArray = (v: unknown): string[] | null => (Array.isArray(v) ? v.map(String) : null); const asNumber = (v: unknown): number | null => { if (v == null) return null; const n = typeof v === 'number' ? v : Number(v); return Number.isFinite(n) ? n : null; }; function parseAgentMd(raw: string): { fm: AgentFrontmatter; body: string } { const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); const body = match ? match[2]! : raw; const yaml = match ? match[1]! : ''; let parsed: Record = {}; if (yaml.trim()) { try { parsed = (YAML.parse(yaml) as Record) ?? {}; } catch { parsed = {}; } } const versionRaw = parsed.version; const version = typeof versionRaw === 'number' ? versionRaw : Number(versionRaw) || 1; return { fm: { name: parsed.name == null ? '' : String(parsed.name), description: parsed.description == null ? null : String(parsed.description), category: parsed.category == null ? null : String(parsed.category), version, model: parsed.model == null ? DEFAULT_AGENT_MODEL : String(parsed.model), inputs: parsed.inputs ?? null, trigger: parsed.trigger ?? null, tags: asArray(parsed.tags), concurrency: asNumber(parsed.concurrency), timeout: asNumber(parsed.timeout), }, body, }; } export async function listAgents(): Promise { let entries; try { entries = await readdir(agentsRoot(), { withFileTypes: true }); } catch { return []; } const summaries: AgentSummary[] = []; for (const entry of entries) { if (!entry.isDirectory()) continue; const file = Bun.file(agentFile(entry.name)); if (!(await file.exists())) continue; const { fm } = parseAgentMd(await file.text()); summaries.push({ dirName: entry.name, name: fm.name || entry.name, description: fm.description, category: fm.category, version: fm.version, trigger: fm.trigger ?? [], }); } summaries.sort((a, b) => a.name.localeCompare(b.name)); return summaries; } export async function getAgentByDirName(dirName: string): Promise { const file = Bun.file(agentFile(dirName)); if (!(await file.exists())) return null; const { fm, body } = parseAgentMd(await file.text()); return { ...fm, dirName, body, filePath: agentFile(dirName) }; }