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
+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)}`,
};
}