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; /** * The directory the run executes in — which is also its project group in /chat, so the caller can * build the link to it. This used to also return a ready-made `chatUrl`, which meant the server held * an opinion about frontend URL shape and drifted the moment that shape changed. `cwd` is the fact; * the URL is the frontend's business (`chatListPath`). */ cwd: string; model: 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, }; }