Files
platform/src/servers/api/agents/agent-runner.ts
T
pastilhasandClaude Opus 5 236541fa2a chat urls: a group is a path, a session decides its own directory
the chat group moves from ?cwd= to a path suffix behind a g/ discriminator
(/chat/g/home/me/project), and a session url carries no group at all.

the real fix is not the spelling. a session's working directory was read back
off the query string to decide where the agent executes, so the address bar was
the authority on where code runs. a pasted or refreshed /chat/<id> arrives with
no ?cwd= at all, so a turn sent before the resolve landed ran in the default
general_chat_sessions dir instead of the project; and a hand-edited ?cwd= could
name a group the session doesn't belong to, with nothing to reconcile them.

loadClaudeSessionById already resolves a session's cwd from the id alone, so the
id is the only source of truth there. it now travels on SelectedSession.cwd,
which is what the composer reads. the url can no longer contradict it.

the vocabulary lives in apps/ChatHistory/chat-routes.ts so a link built in a
panel and one built in a screen cannot drift.

also: startAgentRun no longer returns a literal chatUrl — it returns cwd and
AgentRunnerModal builds the link, so the server holds no copy of the frontend
url shape. and the post-turn permalink strips a stale ?cwd= instead of carrying
it forward onto the new session's url.

walkthrough doc gains item 13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 23:59:39 +00:00

213 lines
8.4 KiB
TypeScript

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;
/**
* 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<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,
};
}