import spawn from "cross-spawn"; import * as path from "path"; import type { Conversation } from "../db/database"; import { loadAgentIdentity } from "./identity"; import { loadDisclawConfig } from "../config/loader"; import { resolveClaude } from "../runtime/resolve-claude"; import { sanitizedEnv } from "../runtime/env"; export interface RunAgentOptions { workspacePath: string; channelName: string; userMessage: string; conversationHistory: Conversation[]; abortController?: AbortController; } /** * Resolves the Claude CLI timeout from configuration. */ function resolveTimeoutMs(): number { try { const rootDir = path.resolve(__dirname, "../.."); const config = loadDisclawConfig(rootDir); if (config.claude_timeout_seconds) { return config.claude_timeout_seconds * 1000; } } catch { // Config not available, use default } return 120_000; } /** * Builds the full prompt string including conversation history context. * The agent identity is handled by CLAUDE.md in the workspace directory, * which Claude Code reads automatically. */ function buildPrompt( userMessage: string, conversationHistory: Conversation[], channelName: string ): string { const parts: string[] = []; // Add conversation history if present (oldest first) const history = [...conversationHistory].reverse(); if (history.length > 0) { parts.push("Previous conversation context:"); parts.push(""); for (const msg of history) { const author = msg.author_name ?? msg.role; parts.push(`[${msg.role}] ${author}: ${msg.content}`); parts.push(""); } parts.push("---"); parts.push(""); } // Add runtime context parts.push(`Discord channel: #${channelName}`); parts.push(`Date: ${new Date().toISOString().split("T")[0]}`); parts.push(""); // Add the actual user message parts.push(`New message:`); parts.push(userMessage); return parts.join("\n"); } /** * Parses the JSON output from `claude -p --output-format json`. * Extracts the text content from the response. * * The JSON output format contains a "result" field with the response text, * or an array of content blocks with type "text". */ function parseClaudeJsonOutput(raw: string): string { const trimmed = raw.trim(); if (!trimmed) { return "(no response from agent)"; } try { const parsed = JSON.parse(trimmed); // Format 1: { result: "text" } if (typeof parsed.result === "string") { return parsed.result; } // Format 2: { content: [{ type: "text", text: "..." }] } or similar if (Array.isArray(parsed.content)) { const texts: string[] = []; for (const block of parsed.content) { if (block && block.type === "text" && typeof block.text === "string") { texts.push(block.text); } } if (texts.length > 0) { return texts.join("\n"); } } // Format 3: Direct array of content blocks if (Array.isArray(parsed)) { const texts: string[] = []; for (const block of parsed) { if (block && block.type === "text" && typeof block.text === "string") { texts.push(block.text); } } if (texts.length > 0) { return texts.join("\n"); } } // Fallback: stringify the whole response return typeof parsed === "string" ? parsed : JSON.stringify(parsed, null, 2); } catch { // Not valid JSON -- return the raw output as plain text return trimmed; } } /** * Runs the Claude Code CLI as a child process and returns the text response. * * The CLI is invoked with: * claude -p "prompt" --output-format json * * The working directory is set to the workspace path so Claude Code * automatically reads the CLAUDE.md file for agent identity and context. * * Timeout defaults to 120 seconds. The process is killed if it exceeds * the timeout. */ export async function runAgent(options: RunAgentOptions): Promise { const { workspacePath, channelName, userMessage, conversationHistory, abortController, } = options; // Verify the workspace has an agent.yaml (validates it exists) and get agent name const identity = loadAgentIdentity(workspacePath); const claudeCommand = await resolveClaude(); const prompt = buildPrompt(userMessage, conversationHistory, channelName); const timeoutMs = resolveTimeoutMs(); // Load config for env_blocklist let envBlocklist: string[] = []; try { const rootDir = path.resolve(__dirname, "../.."); const config = loadDisclawConfig(rootDir); envBlocklist = config.env_blocklist; } catch { // Config not available, use empty blocklist } return new Promise((resolve, reject) => { const args = [ "-p", prompt, "--output-format", "json", ]; const child = spawn(claudeCommand, args, { cwd: workspacePath, env: sanitizedEnv( { DISCLAW_AGENT_NAME: identity.name }, envBlocklist ), stdio: ["pipe", "pipe", "pipe"], windowsHide: true, }); let stdout = ""; let stderr = ""; let killed = false; // Collect stdout if (child.stdout) { child.stdout.setEncoding("utf8"); child.stdout.on("data", (data: string) => { stdout += data; }); } // Collect stderr if (child.stderr) { child.stderr.setEncoding("utf8"); child.stderr.on("data", (data: string) => { stderr += data; }); } // Timeout handling const timer = setTimeout(() => { killed = true; child.kill("SIGTERM"); // Give it a moment to die gracefully, then force kill setTimeout(() => { try { child.kill("SIGKILL"); } catch { // Already dead } }, 5000); }, timeoutMs); // Abort controller support if (abortController) { abortController.signal.addEventListener("abort", () => { killed = true; child.kill("SIGTERM"); }); } child.on("error", (err: NodeJS.ErrnoException) => { clearTimeout(timer); if (err.code === "ENOENT") { reject( new Error( `Claude Code CLI not found at "${claudeCommand}". ` + `Install it with: npm install -g @anthropic-ai/claude-code\n` + `Or set CLAUDE_PATH in your .env file.` ) ); } else { reject(new Error(`Failed to start Claude Code CLI: ${err.message}`)); } }); child.on("close", (code) => { clearTimeout(timer); if (killed) { reject(new Error(`Claude Code CLI timed out after ${timeoutMs / 1000} seconds.`)); return; } if (code !== 0) { const errorDetail = stderr.trim() || stdout.trim() || `exit code ${code}`; reject(new Error(`Claude Code CLI error: ${errorDetail}`)); return; } const response = parseClaudeJsonOutput(stdout); resolve(response); }); // Close stdin immediately since we pass everything via args if (child.stdin) { child.stdin.end(); } }); }