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"; import { Semaphore } from "../runtime/concurrency"; import type { RunResult } from "./types"; // --------------------------------------------------------------------------- // Module-level semaphore — lazy-initialised on first runAgent() call so the // config is guaranteed to be loaded before we fix the concurrency cap. // --------------------------------------------------------------------------- let _semaphore: Semaphore | null = null; function getSemaphore(): Semaphore { if (!_semaphore) { let max = 4; try { const rootDir = path.resolve(__dirname, "../.."); const config = loadDisclawConfig(rootDir); max = config.max_concurrent_agents; } catch { // Config not available — fall back to default } _semaphore = new Semaphore(max); } return _semaphore; } /** * Exposed only for testing — resets the module-level semaphore so unit tests * can inject a fresh instance with a known concurrency cap. * @internal */ export function _resetSemaphore(max?: number): void { _semaphore = max !== undefined ? new Semaphore(max) : null; } 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 } // Acquire a concurrency slot before spawning; always release in finally. const semaphore = getSemaphore(); await semaphore.acquire(); try { return await new Promise((resolve) => { // NOTE: --bare is intentionally NOT used here. --bare disables OAuth/keychain // auth and requires ANTHROPIC_API_KEY, which breaks Claude Pro/Max subscriptions. // CLAUDE.md isolation is handled structurally: workspaces live under // ~/.disclaw/workspaces/ (outside the DisClaw repo), so walk-up will never // reach the DisClaw project CLAUDE.md. The workspace CLAUDE.md is picked up // automatically because cwd is set to workspacePath. 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 — kill the process; the "close" handler will resolve // with { status: "timeout" } once the process exits (killed flag is set). 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") { resolve({ status: "cli-error", message: `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.`, stderr: undefined, }); } else { resolve({ status: "cli-error", message: `Failed to start Claude Code CLI: ${err.message}`, stderr: undefined, }); } }); child.on("close", (code) => { clearTimeout(timer); if (killed) { resolve({ status: "timeout", timeoutMs }); return; } if (code !== 0) { const errorDetail = stderr.trim() || stdout.trim() || `exit code ${code}`; resolve({ status: "cli-error", message: `Claude Code CLI error: ${errorDetail}`, stderr: stderr.trim() || undefined, }); return; } const text = parseClaudeJsonOutput(stdout); resolve({ status: "success", text }); }); // Close stdin immediately since we pass everything via args if (child.stdin) { child.stdin.end(); } }); } finally { semaphore.release(); } }