feat: initialize standalone agent-workflow
This commit is contained in:
@@ -0,0 +1,650 @@
|
||||
/**
|
||||
* Executor adapters for Reasonix, Claude Code, and Agy.
|
||||
*
|
||||
* All adapters:
|
||||
* - Spawn processes with argument arrays (no shell interpolation)
|
||||
* - Include fixed safety constraints in every prompt (no commit/push/reset)
|
||||
* - Return ExecutorResult with session handle for future resume
|
||||
*/
|
||||
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { spawnStreamingChild } from "./child-process.js";
|
||||
import type {
|
||||
AgySessionHandle,
|
||||
ClaudeSessionHandle,
|
||||
ExecutorAdapter,
|
||||
ExecutorConfig,
|
||||
ExecutorKind,
|
||||
ExecutorResumeOpts,
|
||||
ExecutorResult,
|
||||
ExecutorSessionHandle,
|
||||
ExecutorStartOpts,
|
||||
ReasonixSessionHandle,
|
||||
} from "./workflow-types.js";
|
||||
import { nowIso } from "./workflow-state.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Safety prompt footer (appended to every executor instruction)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SAFETY_CONSTRAINTS = `
|
||||
## CRITICAL CONSTRAINTS — DO NOT VIOLATE
|
||||
|
||||
- DO NOT run git commit, git push, or git reset under any circumstances.
|
||||
- DO NOT rewrite git history or delete branches.
|
||||
- DO NOT create or modify files outside the scope listed above.
|
||||
- Exception: a Scope Remediation prompt may authorize removing or restoring only the exact out-of-scope paths it lists.
|
||||
- Put all temporary files in an OS temporary directory outside the repository (for example, one created with mktemp).
|
||||
- If you accidentally create a temporary repository file, remove only that artifact before exiting.
|
||||
- SQLite cleanup must include the database and its -wal, -shm, and -journal sidecars.
|
||||
- Before exiting, inspect git status --porcelain --untracked-files=all and leave no artifacts outside the allowed scope.
|
||||
- You may read, edit, and test code, but never commit or push changes.
|
||||
- If you encounter an error you cannot resolve, stop and report it clearly.
|
||||
`.trimStart();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prompt builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildStartPrompt(opts: ExecutorStartOpts): string {
|
||||
const { plan } = opts;
|
||||
const scopeList = plan.scope.map((s) => ` - ${s}`).join("\n");
|
||||
const criteriaList = plan.acceptanceCriteria.map((c, i) => ` ${i + 1}. ${c}`).join("\n");
|
||||
const cmdsText = plan.verificationCommands
|
||||
.map((cmd) => ` ${cmd.join(" ")}`)
|
||||
.join("\n");
|
||||
|
||||
return `# Implementation Task: ${plan.title}
|
||||
|
||||
## Plan
|
||||
|
||||
${plan.planMarkdown}
|
||||
|
||||
## Allowed Scope
|
||||
|
||||
Only modify files within these paths (relative to repository root):
|
||||
${scopeList}
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
${criteriaList}
|
||||
|
||||
## Verification Commands
|
||||
|
||||
After implementation, ensure these commands pass:
|
||||
${cmdsText}
|
||||
|
||||
${SAFETY_CONSTRAINTS}`;
|
||||
}
|
||||
|
||||
function buildFixPrompt(opts: ExecutorResumeOpts): string {
|
||||
const { plan, acceptedFindings } = opts;
|
||||
|
||||
if (acceptedFindings.startsWith("# Scope Remediation")) {
|
||||
return `# Scope Fix Request: ${plan.title}
|
||||
|
||||
${acceptedFindings}
|
||||
|
||||
Do not modify any other out-of-scope path. If a listed path is not clearly an executor-created artifact, stop and report it instead of deleting it.
|
||||
|
||||
## Frozen Scope
|
||||
|
||||
The implementation itself must remain within:
|
||||
${plan.scope.map((s) => ` - ${s}`).join("\n")}
|
||||
|
||||
## Verification
|
||||
|
||||
After scope cleanup, run:
|
||||
${plan.verificationCommands.map((cmd) => ` ${cmd.join(" ")}`).join("\n")}
|
||||
|
||||
${SAFETY_CONSTRAINTS}`;
|
||||
}
|
||||
|
||||
return `# Fix Request: ${plan.title}
|
||||
|
||||
The code review found the following issues that must be fixed:
|
||||
|
||||
${acceptedFindings}
|
||||
|
||||
## Scope Reminder
|
||||
|
||||
Only modify files within:
|
||||
${plan.scope.map((s) => ` - ${s}`).join("\n")}
|
||||
|
||||
## Verification
|
||||
|
||||
After applying fixes, run:
|
||||
${plan.verificationCommands.map((cmd) => ` ${cmd.join(" ")}`).join("\n")}
|
||||
|
||||
${SAFETY_CONSTRAINTS}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Child env (inherit + extra PATH entries for executors)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function executorEnv(): NodeJS.ProcessEnv {
|
||||
const homeDir = process.env.HOME || os.homedir();
|
||||
const extras = [
|
||||
path.join(homeDir, ".local", "bin"),
|
||||
path.join(homeDir, ".bun", "bin"),
|
||||
path.join(homeDir, ".n", "bin"),
|
||||
"/usr/local/bin",
|
||||
"/usr/bin",
|
||||
"/bin",
|
||||
process.env.PATH || "",
|
||||
];
|
||||
return {
|
||||
...process.env,
|
||||
PATH: [...new Set(extras.join(":").split(":").filter(Boolean))].join(":"),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveExecutable(config: ExecutorConfig, defaultBin: string): string {
|
||||
return config.binary || defaultBin;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reasonix adapter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readFirstLine(filePath: string): string | undefined {
|
||||
const CHUNK_SIZE = 65536;
|
||||
const fd = fs.openSync(filePath, "r");
|
||||
let buffer = Buffer.alloc(CHUNK_SIZE);
|
||||
let totalData = "";
|
||||
let newlineIndex = -1;
|
||||
let position = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const bytesRead = fs.readSync(fd, buffer, 0, CHUNK_SIZE, position);
|
||||
if (bytesRead === 0) break;
|
||||
totalData += buffer.toString("utf8", 0, bytesRead);
|
||||
newlineIndex = totalData.indexOf("\n");
|
||||
if (newlineIndex !== -1) {
|
||||
return totalData.slice(0, newlineIndex);
|
||||
}
|
||||
position += bytesRead;
|
||||
if (position > 1024 * 1024) break; // 1MB safety limit
|
||||
}
|
||||
return totalData;
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
function getWorkspaceFromJsonl(jsonlPath: string): string | undefined {
|
||||
try {
|
||||
const firstLine = readFirstLine(jsonlPath);
|
||||
if (!firstLine) return undefined;
|
||||
const obj = JSON.parse(firstLine);
|
||||
if (obj && obj.role === "system" && typeof obj.content === "string") {
|
||||
let match = obj.content.match(/Current workspace:\s*"([^"\n\r]+)"/i);
|
||||
if (!match) {
|
||||
match = obj.content.match(/Current workspace:\s*([^"\n\r]+)/i);
|
||||
}
|
||||
if (match) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function listJsonlFiles(dir: string, repoRoot?: string): string[] {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
const result: string[] = [];
|
||||
|
||||
function recurse(current: string) {
|
||||
if (!fs.existsSync(current)) return;
|
||||
const entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const lowerDir = entry.name.toLowerCase();
|
||||
if (lowerDir !== "archive" && lowerDir !== "subagents") {
|
||||
recurse(full);
|
||||
}
|
||||
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
||||
const lowerName = entry.name.toLowerCase();
|
||||
if (
|
||||
!lowerName.includes("events") &&
|
||||
!lowerName.includes("archive") &&
|
||||
!lowerName.includes("conflict") &&
|
||||
!lowerName.includes("subagents")
|
||||
) {
|
||||
// Check companion file name: <session>.jsonl.meta
|
||||
const metaFile = full + ".meta";
|
||||
if (fs.existsSync(metaFile)) {
|
||||
if (repoRoot) {
|
||||
const ws = getWorkspaceFromJsonl(full);
|
||||
if (ws === repoRoot) {
|
||||
result.push(full);
|
||||
}
|
||||
} else {
|
||||
result.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recurse(dir);
|
||||
return result;
|
||||
}
|
||||
|
||||
function detectNewSessionFile(beforeSnapshot: Set<string>, searchDirs: string[], repoRoot: string): string | undefined {
|
||||
const candidates: string[] = [];
|
||||
for (const dir of searchDirs) {
|
||||
for (const file of listJsonlFiles(dir, repoRoot)) {
|
||||
if (!beforeSnapshot.has(file)) {
|
||||
candidates.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) return undefined;
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
candidates.sort((a, b) => {
|
||||
try {
|
||||
return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
class ReasonixAdapter implements ExecutorAdapter {
|
||||
readonly kind: ExecutorKind = "reasonix";
|
||||
|
||||
async probe(config: ExecutorConfig = {}): Promise<boolean> {
|
||||
try {
|
||||
const res = spawnSync(resolveExecutable(config, "reasonix"), ["--version"], {
|
||||
stdio: "ignore",
|
||||
env: executorEnv(),
|
||||
});
|
||||
return res.status === 0 && !res.error;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async start(opts: ExecutorStartOpts): Promise<ExecutorResult> {
|
||||
const bin = resolveExecutable(opts.config, "reasonix");
|
||||
const prompt = buildStartPrompt(opts);
|
||||
const promptFile = path.join(opts.runDir, `cycle-${opts.cycleIndex}-prompt.txt`);
|
||||
fs.writeFileSync(promptFile, prompt, "utf8");
|
||||
|
||||
// Snapshot JSONL files before run to detect the session file created by reasonix
|
||||
const homeDir = process.env.HOME || os.homedir();
|
||||
const projectsDir = path.join(homeDir, ".reasonix", "projects");
|
||||
fs.mkdirSync(projectsDir, { recursive: true });
|
||||
const snapshotDirs = [projectsDir];
|
||||
const beforeSnapshot = new Set(listJsonlFiles(projectsDir, opts.repoRoot));
|
||||
|
||||
const args: string[] = ["run", "--dir", opts.repoRoot, prompt];
|
||||
|
||||
const startMs = Date.now();
|
||||
const child = await spawnStreamingChild(bin, args, {
|
||||
cwd: opts.repoRoot,
|
||||
env: executorEnv(),
|
||||
signal: opts.signal,
|
||||
processGroup: true,
|
||||
});
|
||||
const durationMs = Date.now() - startMs;
|
||||
|
||||
const newSession = detectNewSessionFile(beforeSnapshot, snapshotDirs, opts.repoRoot);
|
||||
const handle: ReasonixSessionHandle | undefined = newSession
|
||||
? { kind: "reasonix", sessionFilePath: newSession }
|
||||
: undefined;
|
||||
|
||||
const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : "");
|
||||
const failed = child.status !== 0 || !!child.error;
|
||||
|
||||
return {
|
||||
exitCode: child.status,
|
||||
report,
|
||||
durationMs,
|
||||
sessionHandle: handle,
|
||||
failed,
|
||||
...(child.error ? { failureReason: child.error.message } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async resume(opts: ExecutorResumeOpts): Promise<ExecutorResult> {
|
||||
const handle = opts.handle as ReasonixSessionHandle;
|
||||
const bin = resolveExecutable(opts.config, "reasonix");
|
||||
const prompt = buildFixPrompt(opts);
|
||||
const promptFile = path.join(opts.runDir, `cycle-${opts.cycleIndex}-prompt.txt`);
|
||||
fs.writeFileSync(promptFile, prompt, "utf8");
|
||||
|
||||
const args: string[] = [
|
||||
"run",
|
||||
"--dir", opts.repoRoot,
|
||||
"--resume", handle.sessionFilePath,
|
||||
prompt,
|
||||
];
|
||||
|
||||
const startMs = Date.now();
|
||||
const child = await spawnStreamingChild(bin, args, {
|
||||
cwd: opts.repoRoot,
|
||||
env: executorEnv(),
|
||||
signal: opts.signal,
|
||||
processGroup: true,
|
||||
});
|
||||
const durationMs = Date.now() - startMs;
|
||||
|
||||
const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : "");
|
||||
return {
|
||||
exitCode: child.status,
|
||||
report,
|
||||
durationMs,
|
||||
sessionHandle: handle, // Reasonix handle stays the same file
|
||||
failed: child.status !== 0 || !!child.error,
|
||||
...(child.error ? { failureReason: child.error.message } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async cancel(_handle: ExecutorSessionHandle): Promise<void> {
|
||||
// Reasonix does not expose a cancel API in v1; no-op.
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Claude Code adapter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function extractClaudeSessionId(output: string): string | undefined {
|
||||
// Claude Code outputs JSON with session_id field when using --output-format json
|
||||
try {
|
||||
for (const line of output.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("{")) continue;
|
||||
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
if (typeof parsed.session_id === "string") return parsed.session_id;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
// Also try a simple regex
|
||||
const m = output.match(/"session_id"\s*:\s*"([^"]+)"/);
|
||||
return m?.[1];
|
||||
}
|
||||
|
||||
function extractClaudeFailureReason(output: string): string | undefined {
|
||||
for (const line of output.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("{")) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
if (Array.isArray(parsed.errors)) {
|
||||
const errors = parsed.errors.filter((error): error is string => typeof error === "string" && error.trim().length > 0);
|
||||
if (errors.length > 0) return errors.join("; ");
|
||||
}
|
||||
if (parsed.is_error === true && typeof parsed.result === "string" && parsed.result.trim()) {
|
||||
return parsed.result.trim();
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON output lines.
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
class ClaudeAdapter implements ExecutorAdapter {
|
||||
readonly kind: ExecutorKind = "claude";
|
||||
|
||||
async probe(config: ExecutorConfig = {}): Promise<boolean> {
|
||||
try {
|
||||
const res = spawnSync(resolveExecutable(config, "claude"), ["--version"], {
|
||||
stdio: "ignore",
|
||||
env: executorEnv(),
|
||||
});
|
||||
return res.status === 0 && !res.error;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async start(opts: ExecutorStartOpts): Promise<ExecutorResult> {
|
||||
const bin = resolveExecutable(opts.config, "claude");
|
||||
const sessionId = randomUUID();
|
||||
const prompt = buildStartPrompt(opts);
|
||||
|
||||
const args: string[] = [
|
||||
"-p",
|
||||
"--safe-mode",
|
||||
"--permission-mode", "bypassPermissions",
|
||||
"--output-format", "json",
|
||||
"--session-id", sessionId,
|
||||
...(opts.config.model ? ["--model", opts.config.model] : []),
|
||||
prompt,
|
||||
];
|
||||
|
||||
const startMs = Date.now();
|
||||
const child = await spawnStreamingChild(bin, args, {
|
||||
cwd: opts.repoRoot,
|
||||
env: executorEnv(),
|
||||
signal: opts.signal,
|
||||
processGroup: true,
|
||||
});
|
||||
const durationMs = Date.now() - startMs;
|
||||
|
||||
const capturedId = extractClaudeSessionId(child.stdout) ?? sessionId;
|
||||
const handle: ClaudeSessionHandle = { kind: "claude", sessionId: capturedId };
|
||||
|
||||
const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : "");
|
||||
const failureReason = child.error?.message
|
||||
?? extractClaudeFailureReason(child.stdout)
|
||||
?? extractClaudeFailureReason(child.stderr);
|
||||
return {
|
||||
exitCode: child.status,
|
||||
report,
|
||||
durationMs,
|
||||
sessionHandle: handle,
|
||||
failed: child.status !== 0 || !!child.error,
|
||||
...(failureReason ? { failureReason } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async resume(opts: ExecutorResumeOpts): Promise<ExecutorResult> {
|
||||
const handle = opts.handle as ClaudeSessionHandle;
|
||||
const bin = resolveExecutable(opts.config, "claude");
|
||||
const prompt = buildFixPrompt(opts);
|
||||
|
||||
const args: string[] = [
|
||||
"-p",
|
||||
"--safe-mode",
|
||||
"--permission-mode", "bypassPermissions",
|
||||
"--output-format", "json",
|
||||
"--resume", handle.sessionId,
|
||||
...(opts.config.model ? ["--model", opts.config.model] : []),
|
||||
prompt,
|
||||
];
|
||||
|
||||
const startMs = Date.now();
|
||||
const child = await spawnStreamingChild(bin, args, {
|
||||
cwd: opts.repoRoot,
|
||||
env: executorEnv(),
|
||||
signal: opts.signal,
|
||||
processGroup: true,
|
||||
});
|
||||
const durationMs = Date.now() - startMs;
|
||||
|
||||
const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : "");
|
||||
const failureReason = child.error?.message
|
||||
?? extractClaudeFailureReason(child.stdout)
|
||||
?? extractClaudeFailureReason(child.stderr);
|
||||
return {
|
||||
exitCode: child.status,
|
||||
report,
|
||||
durationMs,
|
||||
sessionHandle: handle,
|
||||
failed: child.status !== 0 || !!child.error,
|
||||
...(failureReason ? { failureReason } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async cancel(_handle: ExecutorSessionHandle): Promise<void> {
|
||||
// Claude Code: no explicit cancel API in v1.
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agy adapter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function extractAgyConversationId(output: string): string | undefined {
|
||||
// Try to parse conversation_id from JSON output lines
|
||||
for (const line of output.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("{")) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
if (typeof parsed.conversation_id === "string") return parsed.conversation_id;
|
||||
if (typeof parsed.conversationId === "string") return parsed.conversationId;
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
class AgyAdapter implements ExecutorAdapter {
|
||||
readonly kind: ExecutorKind = "agy";
|
||||
|
||||
async probe(config: ExecutorConfig = {}): Promise<boolean> {
|
||||
try {
|
||||
const res = spawnSync(resolveExecutable(config, "agy"), ["--version"], {
|
||||
stdio: "ignore",
|
||||
env: executorEnv(),
|
||||
});
|
||||
return res.status === 0 && !res.error;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async start(opts: ExecutorStartOpts): Promise<ExecutorResult> {
|
||||
const bin = resolveExecutable(opts.config, "agy");
|
||||
const prompt = buildStartPrompt(opts);
|
||||
|
||||
const args: string[] = [
|
||||
...(opts.config.agent ? ["--agent", opts.config.agent] : []),
|
||||
...(opts.config.model ? ["--model", opts.config.model] : []),
|
||||
"--print", "--mode", "accept-edits", "--", prompt,
|
||||
];
|
||||
|
||||
const startMs = Date.now();
|
||||
const child = await spawnStreamingChild(bin, args, {
|
||||
cwd: opts.repoRoot,
|
||||
env: executorEnv(),
|
||||
signal: opts.signal,
|
||||
processGroup: true,
|
||||
});
|
||||
const durationMs = Date.now() - startMs;
|
||||
|
||||
const conversationId = extractAgyConversationId(child.stdout);
|
||||
const handle: AgySessionHandle = {
|
||||
kind: "agy",
|
||||
...(conversationId ? { conversationId } : {}),
|
||||
degraded: !conversationId,
|
||||
};
|
||||
|
||||
const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : "");
|
||||
return {
|
||||
exitCode: child.status,
|
||||
report,
|
||||
durationMs,
|
||||
sessionHandle: handle,
|
||||
failed: child.status !== 0 || !!child.error,
|
||||
...(child.error ? { failureReason: child.error.message } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async resume(opts: ExecutorResumeOpts): Promise<ExecutorResult> {
|
||||
const handle = opts.handle as AgySessionHandle;
|
||||
const bin = resolveExecutable(opts.config, "agy");
|
||||
const prompt = buildFixPrompt(opts);
|
||||
|
||||
let args: string[];
|
||||
if (handle.conversationId) {
|
||||
args = [
|
||||
"--conversation", handle.conversationId,
|
||||
...(opts.config.agent ? ["--agent", opts.config.agent] : []),
|
||||
...(opts.config.model ? ["--model", opts.config.model] : []),
|
||||
"--print", "--mode", "accept-edits", "--", prompt,
|
||||
];
|
||||
} else {
|
||||
// Degraded mode: use --continue (best effort, may pick wrong session in concurrent setups)
|
||||
args = [
|
||||
"--continue",
|
||||
...(opts.config.agent ? ["--agent", opts.config.agent] : []),
|
||||
...(opts.config.model ? ["--model", opts.config.model] : []),
|
||||
"--print", "--mode", "accept-edits", "--", prompt,
|
||||
];
|
||||
}
|
||||
|
||||
const startMs = Date.now();
|
||||
const child = await spawnStreamingChild(bin, args, {
|
||||
cwd: opts.repoRoot,
|
||||
env: executorEnv(),
|
||||
signal: opts.signal,
|
||||
processGroup: true,
|
||||
});
|
||||
const durationMs = Date.now() - startMs;
|
||||
|
||||
const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : "");
|
||||
return {
|
||||
exitCode: child.status,
|
||||
report,
|
||||
durationMs,
|
||||
sessionHandle: handle,
|
||||
failed: child.status !== 0 || !!child.error,
|
||||
...(child.error ? { failureReason: child.error.message } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async cancel(_handle: ExecutorSessionHandle): Promise<void> {
|
||||
// Agy: no explicit cancel API in v1.
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createExecutorAdapter(kind: ExecutorKind): ExecutorAdapter {
|
||||
switch (kind) {
|
||||
case "reasonix":
|
||||
return new ReasonixAdapter();
|
||||
case "claude":
|
||||
return new ClaudeAdapter();
|
||||
case "agy":
|
||||
return new AgyAdapter();
|
||||
default: {
|
||||
const _exhaustive: never = kind;
|
||||
throw new Error(`Unknown executor kind: ${_exhaustive}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Check that the executor binary is available and throw a descriptive error if not. */
|
||||
export async function probeExecutor(kind: ExecutorKind, config: ExecutorConfig = {}): Promise<void> {
|
||||
const adapter = createExecutorAdapter(kind);
|
||||
const available = await adapter.probe(config);
|
||||
if (!available) {
|
||||
const bin = config.binary || kind;
|
||||
throw new Error(
|
||||
`Executor '${kind}' is not available. ` +
|
||||
`Make sure '${bin}' is installed and on PATH. ` +
|
||||
`You can override the binary path with the 'binary' field in agent-workflow.json.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user