/** * 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; } 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 { while (true) { while (this.queue.length > 0) { yield this.queue.shift()!; } if (this.done) return; await new Promise(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 { 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 { 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), 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, 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(/[\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 { 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 = { ...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();