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>
This commit is contained in:
2026-08-01 15:21:13 +00:00
co-authored by Claude Opus 5
parent 7d5e73cba8
commit c69cda480d
3 changed files with 406 additions and 0 deletions
+131
View File
@@ -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/<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) };
}
+209
View File
@@ -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<string, AgentRun>();
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<string, unknown>, homeDir: string): Record<string, unknown> =>
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, unknown>): 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<string, unknown>,
): 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<string, unknown>;
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<StartAgentRunResult> {
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)}`,
};
}
+66
View File
@@ -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<string, unknown> }>().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);
}
});