diff --git a/src/servers/api/agents/agent-files.ts b/src/servers/api/agents/agent-files.ts new file mode 100644 index 00000000..1b6b26fa --- /dev/null +++ b/src/servers/api/agents/agent-files.ts @@ -0,0 +1,131 @@ +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) }; +} diff --git a/src/servers/api/agents/agent-runner.ts b/src/servers/api/agents/agent-runner.ts new file mode 100644 index 00000000..bad08f5f --- /dev/null +++ b/src/servers/api/agents/agent-runner.ts @@ -0,0 +1,209 @@ +import { mkdir } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { basename, dirname, join } from 'node:path'; +import type { TurnMessage } from '../chat/types'; +import { sendClaudeCodeStreaming } from '../../channels/send-claude-code'; +import { renameClaudeSession } from '../chat/claude-sessions'; +import { getAgentRunsDir, getOwnerHomeDir } from '../../data-path'; +import { getAgentByDirName, DEFAULT_AGENT_MODEL, type AgentRecord } from './agent-files'; +import { logger } from '../chat/logger'; + +// Starting an agent run is deliberately not "spawning a process". The claude binary is already there +// and the sidecar already owns a persistent-session abstraction, so a run is exactly a chat session +// with three things fixed up front: a working directory, an opening prompt loaded from AGENT.md, and +// inputs appended to it. Nothing here kills anything — claude-manager's idle GC reaps the session +// once it goes quiet, the same way it does for /chat. + +export type AgentRun = { + /** Our handle for the session. NOT the claude session uuid that names the transcript file. */ + sessionKey: string; + dirName: string; + cwd: string; + model: string; + startedAt: number; + finishedAt: number | null; + status: 'running' | 'finished' | 'failed'; + /** Claude's own session uuid, once the sidecar reports it. Absent until then. */ + claudeSessionId: string | null; +}; + +// In-memory only, and that is on purpose: the durable record of a run is its transcript on disk plus +// its events in chat_session_events. This map is a live view for the current server process, so +// losing it to a restart costs nothing that matters. +const runs = new Map(); + +export const listAgentRuns = (dirName?: string): AgentRun[] => + Array.from(runs.values()) + .filter((run) => !dirName || run.dirName === dirName) + .sort((a, b) => b.startedAt - a.startedAt); + +export const getAgentRun = (sessionKey: string): AgentRun | null => runs.get(sessionKey) ?? null; + +/** + * Resolve a user-supplied path to an absolute one. + * + * The file browser autofills `entry_path` in tilde form (`~/music/foo`), and a prompt must never see + * that: an agent works in absolute paths from a cwd that is NOT the target directory, so a leading + * `~` would either be pasted into a shell that expands it against the wrong home or, worse, treated + * as a literal directory name. Expanding here is what keeps every prompt free of the convention — + * `download-media` had to reimplement this itself, and no agent should have to. + */ +export function absolutizePath(value: string, homeDir: string): string { + const trimmed = value.trim(); + if (trimmed === '~') return homeDir; + if (trimmed.startsWith('~/')) return join(homeDir, trimmed.slice(2)); + return trimmed; +} + +const expandInputs = (inputs: Record, homeDir: string): Record => + Object.fromEntries( + Object.entries(inputs).map(([key, value]) => [ + key, + typeof value === 'string' && value.trimStart().startsWith('~') ? absolutizePath(value, homeDir) : value, + ]), + ); + +/** + * The opening prompt: the AGENT.md body verbatim, then the inputs. The body is authored as a complete + * runbook, so nothing is injected ahead of it — the inputs are appended as the one thing the document + * cannot know. + */ +export function buildAgentPrompt(agent: AgentRecord, inputs: Record): string { + const entries = Object.entries(inputs).filter(([, value]) => value !== undefined && value !== null && value !== ''); + if (entries.length === 0) return agent.body.trim(); + + const lines = entries.map( + ([key, value]) => `- **${key}**: ${typeof value === 'string' ? value : JSON.stringify(value)}`, + ); + return `${agent.body.trim()}\n\n## Inputs\n\n${lines.join('\n')}\n`; +} + +/** + * Give the run's transcript a title you can tell apart from the others. + * + * Without this every run of an agent is titled from the same opening prompt — an AGENT.md body can be + * tens of KB, and the list shows the first line of it, so twenty runs look identical. The transcript + * only becomes addressable at the end of the turn (that's when the harness reports its session uuid), + * which is fine: while a run is live you find it as the newest entry in the agent's project group. + */ +function titleRun( + email: string, + cwd: string, + claudeSessionId: string, + agentName: string, + inputs: Record, +): void { + const firstPath = Object.values(inputs).find((v): v is string => typeof v === 'string' && v.startsWith('/')); + const subject = firstPath ? basename(firstPath) : null; + const when = new Date().toLocaleString('sv', { dateStyle: 'short', timeStyle: 'short' }); + const title = [agentName, subject, when].filter(Boolean).join(' · '); + + try { + if (!renameClaudeSession(email, cwd, claudeSessionId, title)) { + logger.warn('Could not title agent run — transcript not found', { claudeSessionId, cwd }); + } + } catch (err) { + // Cosmetic. A run that completed successfully must not be reported as failed because of a title. + logger.warn('Failed to title agent run', { claudeSessionId, error: String(err) }); + } +} + +type StartAgentRunParams = { + dirName: string; + inputs?: Record; + user: { id: number; email: string; username: string }; +}; + +export type StartAgentRunResult = { + sessionKey: string; + dirName: string; + cwd: string; + model: string; + /** Where to look at this run: the agent's own project group in /chat, newest session on top. */ + chatUrl: string; +}; + +export async function startAgentRun(params: StartAgentRunParams): Promise { + const agent = await getAgentByDirName(params.dirName); + if (!agent) throw new Error('Agent not found'); + + const homeDir = getOwnerHomeDir(params.user.email); + const inputs = expandInputs(params.inputs ?? {}, homeDir); + + for (const [key, value] of Object.entries(inputs)) { + if (typeof value === 'string' && value.startsWith('~')) { + throw new Error(`Input "${key}" could not be resolved to an absolute path`); + } + } + + // Shared per agent, not per run — this is the project-group pin (see getAgentRunsDir). + const cwd = getAgentRunsDir(agent.dirName); + await mkdir(cwd, { recursive: true }); + + const sessionKey = randomUUID(); + const model = agent.model || DEFAULT_AGENT_MODEL; + + // The agent's own directory, so a prompt can refer to its sibling scripts. It has to be injected: + // cwd is the shared runs dir (the project-group pin), NOT the item directory, so nothing the agent + // can see would otherwise tell it where its own files are. Appended last so a real input still wins + // when we pick a subject for the run's title. + const prompt = buildAgentPrompt(agent, { ...inputs, agent_dir: dirname(agent.filePath) }); + + const run: AgentRun = { + sessionKey, + dirName: agent.dirName, + cwd, + model, + startedAt: Date.now(), + finishedAt: null, + status: 'running', + claudeSessionId: null, + }; + runs.set(sessionKey, run); + + const onMessage = (msg: TurnMessage) => { + if (msg.type === 'result') { + run.status = 'finished'; + run.finishedAt = Date.now(); + if (msg.claudeSessionId) { + run.claudeSessionId = msg.claudeSessionId; + titleRun(params.user.email, cwd, msg.claudeSessionId, agent.name || agent.dirName, inputs); + } + } else if (msg.type === 'error') { + run.status = 'failed'; + run.finishedAt = Date.now(); + logger.error('Agent run failed', { sessionKey, dirName: agent.dirName }); + } + }; + + try { + // durable: true — the events land in chat_session_events, so an officer restart mid-run costs a + // replay rather than the output. That matters more here than in a pipeline step, because an agent + // run is long and, before long, unattended. + await sendClaudeCodeStreaming({ + userId: params.user.id, + email: params.user.email, + username: params.user.username, + prompt, + sessionKey, + cwd, + model, + durable: true, + onMessage, + }); + } catch (err) { + run.status = 'failed'; + run.finishedAt = Date.now(); + throw err; + } + + logger.info('Agent run started', { sessionKey, dirName: agent.dirName, cwd, model }); + + return { + sessionKey, + dirName: agent.dirName, + cwd, + model, + chatUrl: `/chat?cwd=${encodeURIComponent(cwd)}`, + }; +} diff --git a/src/servers/api/agents/agents.ts b/src/servers/api/agents/agents.ts new file mode 100644 index 00000000..95ce7eb8 --- /dev/null +++ b/src/servers/api/agents/agents.ts @@ -0,0 +1,66 @@ +import { createRouter } from '../../create-router'; +import { listAgents, getAgentByDirName } from './agent-files'; +import { startAgentRun, listAgentRuns } from './agent-runner'; +import { readCategoryOrder } from '../tasks/task-files'; + +type TriggerConfig = { type: 'file'; extensions: string[] } | { type: 'directory' }; + +export const agentsRouter = createRouter(); + +agentsRouter.get('/', async (ctx) => { + const summaries = await listAgents(); + return ctx.json( + summaries.map((row) => ({ + dirName: row.dirName, + name: row.name, + description: row.description, + category: row.category, + triggers: (row.trigger as TriggerConfig[]) ?? [], + })), + ); +}); + +// Declared before '/:name', which would otherwise match "categories" and "runs". +agentsRouter.get('/categories', async (ctx) => ctx.json(await readCategoryOrder())); + +agentsRouter.get('/runs', (ctx) => ctx.json(listAgentRuns(ctx.req.query('agent') || undefined))); + +agentsRouter.get('/:name', async (ctx) => { + const agent = await getAgentByDirName(ctx.req.param('name')); + if (!agent) return ctx.text('Not found', 404); + + return ctx.json({ + dirName: agent.dirName, + name: agent.name, + description: agent.description, + category: agent.category, + version: agent.version, + model: agent.model, + inputs: agent.inputs, + trigger: agent.trigger, + tags: agent.tags, + concurrency: agent.concurrency, + timeout: agent.timeout, + body: agent.body, + filePath: agent.filePath, + }); +}); + +// POST /agents/:name/run — open the run's session and return immediately. The run itself is watched +// through /chat; there is no job row and nothing to poll here. +agentsRouter.post('/:name/run', async (ctx) => { + const user = ctx.get('user'); + const body = await ctx.req.json<{ inputs?: Record }>().catch(() => ({}) as { inputs?: undefined }); + + try { + const result = await startAgentRun({ + dirName: ctx.req.param('name'), + inputs: body.inputs ?? {}, + user: { id: user.id, email: user.email, username: user.username ?? '' }, + }); + return ctx.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to start agent run'; + return ctx.text(message, message === 'Agent not found' ? 404 : 400); + } +});