Add RunResult type in src/agent/types.ts (success|timeout|cli-error|parse-error). Runner resolves instead of rejects for all error cases. Router handles all four status variants explicitly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
319 lines
9 KiB
TypeScript
319 lines
9 KiB
TypeScript
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<RunResult> {
|
|
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<RunResult>((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();
|
|
}
|
|
}
|