Files
spoon/apps/agent-worker/src/runtime/claude-adapter.ts
T

128 lines
4.2 KiB
TypeScript

import path from 'node:path';
import { randomUUID } from 'node:crypto';
import type { NormalizedAgentEvent } from '../agent-events';
import { normalizeClaudeJsonLine } from '../agent-events';
import { env } from '../env';
import type { AdapterFactory, TurnResult } from './agent-runtime';
import { writeJsonFile } from './auth';
import {
buildMarkedCommand,
killBoxProcessesByMarker,
streamExecInContainer,
} from './docker';
import type { AdapterWorkspace } from './provider';
import { claudeEnv, claudeModel, isClaudeOAuthProfile } from './provider';
export const createClaudeAdapter: AdapterFactory = (deps) => {
const execStream = deps?.execStream ?? streamExecInContainer;
const prepareAuth = async (workspace: AdapterWorkspace) => {
// API-key profiles authenticate purely via ANTHROPIC_API_KEY in the
// environment; only OAuth-json profiles need `.credentials.json` on disk.
if (!isClaudeOAuthProfile(workspace.claim)) return;
const secret = workspace.claim.aiProviderProfile?.secret;
if (!secret) {
throw new Error('Claude auth profile is missing credentials.json contents.');
}
const credentialsPath = path.join(
workspace.homeDir,
'.claude',
'.credentials.json',
);
await writeJsonFile(credentialsPath, secret, 'Claude auth');
};
const runTurn = async (
workspace: AdapterWorkspace,
prompt: string,
onEvent: (event: NormalizedAgentEvent) => Promise<void>,
): Promise<TurnResult> => {
workspace.runtime = 'claude';
workspace.turnMarker = `spoon-turn-${randomUUID()}`;
const marker = workspace.turnMarker;
// NOTE: exact flags are UNVERIFIED against the pinned CLI and are confirmed
// in Task 11's smoke. `--verbose` is required for `stream-json`;
// `--resume <sessionId>` carries continuity.
const argv = [
'claude',
'-p',
prompt,
'--output-format',
'stream-json',
'--verbose',
'--dangerously-skip-permissions',
'--model',
claudeModel(workspace.claim),
...(workspace.claudeSessionId
? ['--resume', workspace.claudeSessionId]
: []),
];
const command = buildMarkedCommand(marker, argv);
const aiEnv = claudeEnv(workspace.claim, workspace.containerHome);
const secretEnv = Object.fromEntries(
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
);
let capturedSessionId: string | undefined;
let capturedError: string | undefined;
let finalMessage: string | undefined;
// execa's own `timeout` only kills the local docker-exec client, not the
// in-box process group. Schedule an explicit marker kill on timeout and clear
// it once the exec resolves.
const killTimer = setTimeout(() => {
void killBoxProcessesByMarker({
containerName: workspace.boxName,
marker,
});
}, env.jobTimeoutMs);
let result;
try {
result = await execStream({
containerName: workspace.boxName,
containerCwd: workspace.containerRepo,
command,
environment: { ...aiEnv, ...secretEnv },
redact: workspace.redact,
timeoutMs: env.jobTimeoutMs,
onStdoutLine: async (line) => {
for (const event of normalizeClaudeJsonLine(line)) {
if (event.kind === 'session') capturedSessionId = event.sessionId;
if (event.kind === 'error') capturedError = event.message;
if (event.kind === 'assistant_completed' && event.content) {
finalMessage = event.content;
}
await onEvent(event);
}
},
onStderrLine: async (line) => {
if (!line.trim()) return;
await onEvent({ kind: 'status', status: line });
},
});
} finally {
clearTimeout(killTimer);
}
if (result.exitCode !== 0) {
capturedError = result.output;
}
return { finalMessage, sessionId: capturedSessionId, error: capturedError };
};
const abort = async (workspace: AdapterWorkspace) => {
if (!workspace.turnMarker) return;
await killBoxProcessesByMarker({
containerName: workspace.boxName,
marker: workspace.turnMarker,
});
};
return { name: 'claude', prepareAuth, runTurn, abort };
};