runner/agent/src/index.ts
Markov 8b6ea6c462 Initial runner: agent container + host runner + IPC
- agent/: Claude Agent SDK inside Docker container
  - Persistent sessions (resume/checkpoint)
  - MCP tools for Tracker (chat, tasks)
  - File-based IPC protocol
- runner.py: Host-side container manager
  - Docker lifecycle management
  - IPC file processing → Tracker REST API
  - Interactive CLI for testing
- Dockerfile: node:22-slim + Claude Agent SDK
- Based on NanoClaw architecture, stripped to essentials
2026-02-16 22:31:30 +01:00

365 lines
10 KiB
TypeScript

/**
* Team Board Agent Runner
* Runs inside a container. Receives prompts via stdin, outputs results to stdout.
* Supports persistent sessions via Claude Agent SDK.
*
* Input: JSON on stdin (ContainerInput)
* Follow-up messages: JSON files in /workspace/ipc/input/
* Output: JSON wrapped in markers on stdout
* Close signal: /workspace/ipc/input/_close
*/
import fs from 'fs';
import path from 'path';
import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk';
// --- Types ---
interface ContainerInput {
prompt: string;
sessionId?: string;
agentSlug: string;
agentName: string;
secrets?: Record<string, string>;
}
interface ContainerOutput {
status: 'success' | 'error';
result: string | null;
newSessionId?: string;
error?: string;
}
interface SDKUserMessage {
type: 'user';
message: { role: 'user'; content: string };
parent_tool_use_id: null;
session_id: string;
}
// --- Constants ---
const IPC_INPUT_DIR = '/workspace/ipc/input';
const IPC_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
const IPC_POLL_MS = 500;
const OUTPUT_START = '---AGENT_OUTPUT_START---';
const OUTPUT_END = '---AGENT_OUTPUT_END---';
// Secrets to strip from Bash subprocesses
const SECRET_ENV_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'];
// --- MessageStream: push-based async iterable for streaming prompts ---
class MessageStream {
private queue: SDKUserMessage[] = [];
private waiting: (() => void) | null = null;
private done = false;
push(text: string): void {
this.queue.push({
type: 'user',
message: { role: 'user', content: text },
parent_tool_use_id: null,
session_id: '',
});
this.waiting?.();
}
end(): void {
this.done = true;
this.waiting?.();
}
async *[Symbol.asyncIterator](): AsyncGenerator<SDKUserMessage> {
while (true) {
while (this.queue.length > 0) {
yield this.queue.shift()!;
}
if (this.done) return;
await new Promise<void>(r => { this.waiting = r; });
this.waiting = null;
}
}
}
// --- Helpers ---
function log(msg: string): void {
console.error(`[agent] ${msg}`);
}
function writeOutput(output: ContainerOutput): void {
console.log(OUTPUT_START);
console.log(JSON.stringify(output));
console.log(OUTPUT_END);
}
async function readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => { data += chunk; });
process.stdin.on('end', () => resolve(data));
process.stdin.on('error', reject);
});
}
function shouldClose(): boolean {
if (fs.existsSync(IPC_CLOSE_SENTINEL)) {
try { fs.unlinkSync(IPC_CLOSE_SENTINEL); } catch {}
return true;
}
return false;
}
function drainIpcInput(): string[] {
try {
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
const files = fs.readdirSync(IPC_INPUT_DIR)
.filter(f => f.endsWith('.json'))
.sort();
const messages: string[] = [];
for (const file of files) {
const filePath = path.join(IPC_INPUT_DIR, file);
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
fs.unlinkSync(filePath);
if (data.type === 'message' && data.text) {
messages.push(data.text);
}
} catch (err) {
log(`Failed to process IPC file ${file}: ${err}`);
try { fs.unlinkSync(filePath); } catch {}
}
}
return messages;
} catch {
return [];
}
}
function waitForIpcMessage(): Promise<string | null> {
return new Promise((resolve) => {
const poll = () => {
if (shouldClose()) { resolve(null); return; }
const msgs = drainIpcInput();
if (msgs.length > 0) { resolve(msgs.join('\n')); return; }
setTimeout(poll, IPC_POLL_MS);
};
poll();
});
}
// --- Hooks ---
/** Strip API keys from Bash subprocess environments */
function createSanitizeBashHook(): HookCallback {
return async (input) => {
const preInput = input as PreToolUseHookInput;
const command = (preInput.tool_input as { command?: string })?.command;
if (!command) return {};
const unsetPrefix = `unset ${SECRET_ENV_VARS.join(' ')} 2>/dev/null; `;
return {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
updatedInput: {
...(preInput.tool_input as Record<string, unknown>),
command: unsetPrefix + command,
},
},
};
};
}
/** Archive transcript before compaction */
function createPreCompactHook(): HookCallback {
return async (input) => {
const preCompact = input as PreCompactHookInput;
const transcriptPath = preCompact.transcript_path;
if (!transcriptPath || !fs.existsSync(transcriptPath)) return {};
try {
const content = fs.readFileSync(transcriptPath, 'utf-8');
const archiveDir = '/workspace/conversations';
fs.mkdirSync(archiveDir, { recursive: true });
const date = new Date().toISOString().split('T')[0];
const time = new Date().toISOString().replace(/[:.]/g, '-');
fs.writeFileSync(
path.join(archiveDir, `${date}-${time}.jsonl`),
content
);
log(`Archived transcript before compaction`);
} catch (err) {
log(`Failed to archive: ${err}`);
}
return {};
};
}
// --- Main query loop ---
async function runQuery(
prompt: string,
sessionId: string | undefined,
input: ContainerInput,
sdkEnv: Record<string, string | undefined>,
mcpServerPath: string,
resumeAt?: string,
): Promise<{ newSessionId?: string; lastAssistantUuid?: string; closedDuringQuery: boolean }> {
const stream = new MessageStream();
stream.push(prompt);
// Poll IPC during query
let ipcPolling = true;
let closedDuringQuery = false;
const pollIpc = () => {
if (!ipcPolling) return;
if (shouldClose()) {
closedDuringQuery = true;
stream.end();
ipcPolling = false;
return;
}
const msgs = drainIpcInput();
for (const text of msgs) {
log(`IPC message piped (${text.length} chars)`);
stream.push(text);
}
setTimeout(pollIpc, IPC_POLL_MS);
};
setTimeout(pollIpc, IPC_POLL_MS);
let newSessionId: string | undefined;
let lastAssistantUuid: string | undefined;
// Load system prompt from CLAUDE.md if exists
const claudeMdPath = '/workspace/CLAUDE.md';
let systemPromptAppend: string | undefined;
if (fs.existsSync(claudeMdPath)) {
systemPromptAppend = fs.readFileSync(claudeMdPath, 'utf-8');
}
for await (const message of query({
prompt: stream,
options: {
cwd: '/workspace',
resume: sessionId,
resumeSessionAt: resumeAt,
systemPrompt: systemPromptAppend
? { type: 'preset' as const, preset: 'claude_code' as const, append: systemPromptAppend }
: undefined,
allowedTools: [
'Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep',
'WebSearch', 'WebFetch',
'Task', 'TaskOutput', 'TaskStop',
'TeamCreate', 'TeamDelete', 'SendMessage',
'TodoWrite', 'NotebookEdit',
'mcp__tracker__*',
],
env: sdkEnv,
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
mcpServers: {
tracker: {
command: 'node',
args: [mcpServerPath],
env: {
AGENT_SLUG: input.agentSlug,
AGENT_NAME: input.agentName,
},
},
},
hooks: {
PreCompact: [{ hooks: [createPreCompactHook()] }],
PreToolUse: [{ matcher: 'Bash', hooks: [createSanitizeBashHook()] }],
},
},
})) {
if (message.type === 'system' && message.subtype === 'init') {
newSessionId = message.session_id;
log(`Session: ${newSessionId}`);
}
if (message.type === 'assistant' && 'uuid' in message) {
lastAssistantUuid = (message as { uuid: string }).uuid;
}
if (message.type === 'result') {
const text = 'result' in message ? (message as { result?: string }).result : null;
if (text) {
const clean = text.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
if (clean) {
writeOutput({ status: 'success', result: clean, newSessionId });
}
}
}
}
ipcPolling = false;
return { newSessionId, lastAssistantUuid, closedDuringQuery };
}
// --- Entry point ---
async function main(): Promise<void> {
let input: ContainerInput;
try {
const raw = await readStdin();
input = JSON.parse(raw);
try { fs.unlinkSync('/tmp/input.json'); } catch {}
log(`Agent '${input.agentName}' (${input.agentSlug}) starting`);
} catch (err) {
writeOutput({ status: 'error', result: null, error: `Bad input: ${err}` });
process.exit(1);
}
// Build SDK env with secrets (never exposed to Bash)
const sdkEnv: Record<string, string | undefined> = { ...process.env };
for (const [key, value] of Object.entries(input.secrets || {})) {
sdkEnv[key] = value;
}
const mcpServerPath = path.join(
path.dirname(new URL(import.meta.url).pathname),
'mcp.js'
);
let sessionId = input.sessionId;
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
try { fs.unlinkSync(IPC_CLOSE_SENTINEL); } catch {}
// Drain pending IPC
let prompt = input.prompt;
const pending = drainIpcInput();
if (pending.length > 0) {
prompt += '\n' + pending.join('\n');
}
// Query loop: run → wait for IPC → run again (persistent session)
let resumeAt: string | undefined;
try {
while (true) {
log(`Query start (session: ${sessionId || 'new'})`);
const result = await runQuery(prompt, sessionId, input, sdkEnv, mcpServerPath, resumeAt);
if (result.newSessionId) sessionId = result.newSessionId;
if (result.lastAssistantUuid) resumeAt = result.lastAssistantUuid;
if (result.closedDuringQuery) { log('Closed during query'); break; }
// Session update marker
writeOutput({ status: 'success', result: null, newSessionId: sessionId });
log('Waiting for next message...');
const next = await waitForIpcMessage();
if (next === null) { log('Close signal received'); break; }
log(`New message (${next.length} chars)`);
prompt = next;
}
} catch (err) {
writeOutput({ status: 'error', result: null, newSessionId: sessionId, error: String(err) });
process.exit(1);
}
}
main();