Files
platform/src/servers/api/agents/agent-files.ts
T
pastilhasandClaude Opus 5 c69cda480d add the agents router files that 3f22a80 referenced but never committed
3f22a80 committed hono.ts with `import { agentsRouter } from './api/agents/agents'` while
src/servers/api/agents/ was still untracked, so master has been unbootable for any clone:

  error: Cannot find module './api/agents/agents' from '.../src/servers/hono.ts'

That import line was work in progress from a parallel session that happened to be sitting in
hono.ts; staging the file to mount the notify router swept it in. The machine it was committed
from kept working because the files were there on disk, which is exactly why it went unnoticed.

Committing the three files completes what that commit already assumed. Verified first that every
module they import is tracked, and that the one cross-boundary import (`TurnMessage` from
../chat/types) is `import type`, so it is stripped at runtime and does not depend on the still
uncommitted edit to that file.

The frontend half of the same feature (AgentRunnerDialog, AgentRunnerModal, useAgents) is still
untracked and deliberately left that way — no committed file references it, so it cannot break a
clone, and it is not mine to commit.

A repo-wide scan of all 1278 tracked TS files in HEAD for imports resolving to untracked or missing
modules now comes back clean apart from index.gen.html, which is generated at boot by design.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:21:13 +00:00

132 lines
4.5 KiB
TypeScript

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/<dirName>/ 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 `<provider>/<tier>` (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<string, unknown> = {};
if (yaml.trim()) {
try {
parsed = (YAML.parse(yaml) as Record<string, unknown>) ?? {};
} 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<AgentSummary[]> {
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<AgentRecord | null> {
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) };
}