- 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
365 lines
10 KiB
TypeScript
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();
|