agent-orchestrator/openclaw-plugin/index.ts

1452 lines
49 KiB
TypeScript

/**
* OpenClaw Plugin: Agent Orchestrator v0.3.0
*
* Open-source, pluggable agentic coding orchestrator. Manages durable coding
* agents (Claude Code, Codex, OpenCode) and wires up feedback loops so PR
* reviews and CI failures automatically route to the right agent.
*
* Provides:
* - Hook: injects live repo data into AI context for work-related messages
* - Slash command: /ao (with subcommands)
* - 14 agent tools: ao_sessions, ao_session_list, ao_status, ao_issues,
* ao_spawn, ao_batch_spawn, ao_send, ao_kill, ao_doctor, ao_review_check,
* ao_verify, ao_session_cleanup, ao_session_restore, ao_session_claim_pr
* - Background services: health monitoring + issue board scanner + auto follow-up
*/
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface PluginConfig {
aoPath?: string;
aoCwd?: string;
ghPath?: string;
healthPollIntervalMs?: number;
boardScanIntervalMs?: number;
}
/** Minimal shape of the OpenClaw plugin API passed to the default export. */
interface PluginApi {
pluginConfig?: PluginConfig;
logger: { info: (msg: string) => void; warn: (msg: string) => void };
on?: (name: string, handler: (event: PluginEvent) => Promise<void>, opts?: { priority: number }) => void;
registerHook?: (name: string, handler: (event: PluginEvent) => Promise<void>, opts?: { priority: number }) => void;
registerCommand?: (cmd: CommandRegistration) => void;
registerTool?: (tool: Record<string, unknown>) => void;
registerService?: (svc: Record<string, unknown>) => void;
runtime?: {
sendMessageToDefaultSession?: (message: string) => void;
};
}
interface CommandRegistration {
name: string;
description: string;
acceptsArgs: boolean;
requireAuth: boolean;
handler: (ctx: CommandContext) => Promise<CommandResult>;
}
interface CommandResult {
text: string;
}
interface PluginEvent {
sessionKey?: string;
sessionId?: string;
channelId?: string;
message?: { text?: string; content?: string };
text?: string;
content?: string;
appendSystemContext?: (text: string) => void;
prependContext?: (text: string) => void;
context?: Record<string, unknown>;
messages?: Array<{ role: string; content: string }>;
}
interface CommandContext {
args?: string;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function runCmd(bin: string, args: string[], timeoutMs: number = 15_000, cwd?: string): string {
return execFileSync(bin, args, {
encoding: "utf-8",
timeout: timeoutMs,
cwd,
env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" },
}).trim();
}
function tryRun(
bin: string,
args: string[],
timeoutMs?: number,
cwd?: string,
): { ok: true; output: string } | { ok: false; error: string } {
try {
return { ok: true, output: runCmd(bin, args, timeoutMs, cwd) };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return { ok: false, error: message };
}
}
/** Strip leading dashes from LLM-supplied args to prevent CLI flag injection. */
function sanitizeCliArg(arg: string): string {
return arg.replace(/^-+/, "");
}
function tryRunAo(config: PluginConfig, args: string[], timeoutMs?: number) {
// AO requires cwd to be the repo root where agent-orchestrator.yaml lives
const cwd = config.aoCwd || process.cwd();
return tryRun(config.aoPath || "ao", args, timeoutMs, cwd);
}
function tryRunGh(config: PluginConfig, args: string[], timeoutMs?: number) {
// Run gh from aoCwd so default-repo queries resolve correctly
const cwd = config.aoCwd || process.cwd();
return tryRun(config.ghPath || "gh", args, timeoutMs, cwd);
}
// ---------------------------------------------------------------------------
// Issue board helpers
// ---------------------------------------------------------------------------
interface GitHubIssue {
number: number;
title: string;
labels: Array<{ name: string }>;
state: string;
assignees: Array<{ login: string }>;
createdAt: string;
url: string;
repository?: string;
}
interface FetchIssuesSuccess {
ok: true;
issues: GitHubIssue[];
scannedRepos: string[];
warnings: string[];
}
interface FetchIssuesFailure {
ok: false;
error: string;
}
type FetchIssuesResult = FetchIssuesSuccess | FetchIssuesFailure;
interface FetchIssuesOptions {
repo?: string;
labels?: string;
}
interface FetchIssuesDeps {
getConfiguredRepos: (config: PluginConfig) => string[];
runGh: typeof tryRunGh;
}
function resolveAoConfigPath(config: PluginConfig): string | null {
const candidates: string[] = [];
const envPath = process.env.AO_CONFIG_PATH;
if (envPath) candidates.push(resolve(envPath));
let currentDir = resolve(config.aoCwd || process.cwd());
while (true) {
candidates.push(
join(currentDir, "agent-orchestrator.yaml"),
join(currentDir, "agent-orchestrator.yml"),
);
const parentDir = dirname(currentDir);
if (parentDir === currentDir) break;
currentDir = parentDir;
}
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate;
}
return null;
}
function stripYamlInlineComment(value: string): string {
let inSingleQuote = false;
let inDoubleQuote = false;
for (let i = 0; i < value.length; i++) {
const char = value[i];
if (char === "'" && !inDoubleQuote) {
inSingleQuote = !inSingleQuote;
continue;
}
if (char === '"' && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote;
continue;
}
if (char === "#" && !inSingleQuote && !inDoubleQuote) {
return value.slice(0, i).trim();
}
}
return value.trim();
}
function normalizeYamlScalar(value: string): string {
const stripped = stripYamlInlineComment(value);
if (!stripped) return "";
if (
(stripped.startsWith('"') && stripped.endsWith('"')) ||
(stripped.startsWith("'") && stripped.endsWith("'"))
) {
return stripped.slice(1, -1).trim();
}
return stripped;
}
export function extractConfiguredReposFromYaml(rawYaml: string): string[] {
const repos = new Set<string>();
const lines = rawYaml.split(/\r?\n/);
let inProjects = false;
// Detected at runtime from the first project entry line — not hardcoded.
let projectKeyIndent: number | null = null;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const indent = line.match(/^ */)?.[0].length ?? 0;
if (!inProjects) {
if (trimmed === "projects:" && indent === 0) {
inProjects = true;
}
continue;
}
// Any top-level key after projects: ends the block.
if (indent === 0) break;
// Detect the indentation level of project name keys from the first entry.
// Strip inline comments before checking — `my-app: # comment` is a valid key.
if (projectKeyIndent === null) {
if (trimmed.replace(/\s*#.*$/, "").endsWith(":")) projectKeyIndent = indent;
continue;
}
// Lines at the project-key indent are project names — skip them.
if (indent === projectKeyIndent) continue;
// Lines indented deeper than the project key are project properties.
if (indent > projectKeyIndent) {
const match = trimmed.match(/^repo:\s*(.+)$/);
if (!match) continue;
const repo = normalizeYamlScalar(match[1]);
if (repo) repos.add(repo);
}
}
return [...repos];
}
function getConfiguredRepos(config: PluginConfig): string[] {
const configPath = resolveAoConfigPath(config);
if (!configPath) return [];
try {
const rawYaml = readFileSync(configPath, "utf-8");
return extractConfiguredReposFromYaml(rawYaml);
} catch {
return [];
}
}
function getIssueRepository(issue: GitHubIssue): string | null {
if (issue.repository) return issue.repository;
const match = issue.url.match(/github\.com\/([^/]+\/[^/]+)\/issues\//);
return match?.[1] ?? null;
}
function getIssueIdentity(issue: GitHubIssue): string {
return issue.url || `${getIssueRepository(issue) ?? "default"}#${issue.number}`;
}
function formatIssueWarnings(warnings: string[]): string {
return warnings.map((warning) => `- ${warning}`).join("\n");
}
export function mergeStringLists(existing: string[], required: string[]): string[] {
const merged = [...existing];
for (const value of required) {
if (!merged.includes(value)) merged.push(value);
}
return merged;
}
export function parseStringArraySetting(output: string): string[] | null {
const trimmed = output.trim();
if (!trimmed) return [];
if (trimmed === "null" || trimmed === "undefined") return [];
try {
const parsed = JSON.parse(trimmed);
if (parsed === null || parsed === undefined) return [];
if (Array.isArray(parsed)) {
return parsed.filter((value): value is string => typeof value === "string");
}
if (typeof parsed === "string") {
return parsed ? [parsed] : [];
}
} catch {
// Fall through to plain-text parsing
}
if (trimmed.includes("\n")) {
return trimmed
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
if (trimmed.includes(",")) {
return trimmed
.split(",")
.map((value) => value.trim())
.filter(Boolean);
}
return [trimmed];
}
function getNestedValue(root: unknown, path: string[]): unknown {
let current = root;
for (const segment of path) {
if (!current || typeof current !== "object" || Array.isArray(current)) {
return undefined;
}
current = (current as Record<string, unknown>)[segment];
}
return current;
}
function readOpenClawConfig(): Record<string, unknown> | null {
try {
const configPath = join(homedir(), ".openclaw", "openclaw.json");
if (!existsSync(configPath)) return {};
return JSON.parse(readFileSync(configPath, "utf-8")) as Record<string, unknown>;
} catch {
return null;
}
}
function readOpenClawStringArraySetting(setting: string, path: string[]): string[] {
const cliResult = tryRun("openclaw", ["config", "get", setting], 5_000);
if (cliResult.ok) {
const parsed = parseStringArraySetting(cliResult.output);
if (parsed) return parsed;
}
const openClawConfig = readOpenClawConfig();
if (!openClawConfig) return [];
const nestedValue = getNestedValue(openClawConfig, path);
if (Array.isArray(nestedValue)) {
return nestedValue.filter((value): value is string => typeof value === "string");
}
if (typeof nestedValue === "string" && nestedValue) {
return [nestedValue];
}
return [];
}
export function fetchIssues(
config: PluginConfig,
options: FetchIssuesOptions = {},
deps: FetchIssuesDeps = {
getConfiguredRepos,
runGh: tryRunGh,
},
): FetchIssuesResult {
const repos = options.repo ? [options.repo] : deps.getConfiguredRepos(config);
const targets = repos.length > 0 ? repos : [undefined];
const issues: GitHubIssue[] = [];
const warnings: string[] = [];
const scannedRepos: string[] = [];
for (const targetRepo of targets) {
const args = ["issue", "list"];
const repoLabel = targetRepo ?? "default repo";
if (targetRepo) args.push("-R", targetRepo);
if (options.labels) args.push("--label", options.labels);
args.push(
"--state",
"open",
"--json",
"number,title,labels,state,assignees,createdAt,url",
"--limit",
"30",
);
const result = deps.runGh(config, args, 15_000);
if (!result.ok) {
warnings.push(`${repoLabel}: ${result.error}`);
continue;
}
try {
const parsed = JSON.parse(result.output) as GitHubIssue[];
for (const issue of parsed) {
issue.repository = targetRepo ?? getIssueRepository(issue) ?? undefined;
issues.push(issue);
}
if (targetRepo) scannedRepos.push(targetRepo);
} catch {
warnings.push(`${repoLabel}: failed to parse GitHub CLI output`);
}
}
const dedupedIssues = issues
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.filter(
(issue, index, allIssues) =>
allIssues.findIndex(
(candidate) => getIssueIdentity(candidate) === getIssueIdentity(issue),
) === index,
);
const inferredRepos = dedupedIssues
.map((issue) => getIssueRepository(issue))
.filter((repo): repo is string => Boolean(repo));
if (warnings.length > 0 && dedupedIssues.length === 0) {
return {
ok: false,
error: `GitHub issue query failed:\n${formatIssueWarnings(warnings)}`,
};
}
return {
ok: true,
issues: dedupedIssues,
scannedRepos: [
...new Set(
scannedRepos.length > 0 ? scannedRepos : inferredRepos.length > 0 ? inferredRepos : repos,
),
],
warnings,
};
}
function formatIssueList(issues: GitHubIssue[]): string {
if (issues.length === 0) return "No open issues found.";
const repoLabels = new Set(issues.map((issue) => getIssueRepository(issue)).filter(Boolean));
const includeRepository = repoLabels.size > 1;
return issues
.map((issue, i) => {
const labels = issue.labels.map((l) => l.name).join(", ");
const labelStr = labels ? ` [${labels}]` : "";
const repoPrefix = includeRepository
? `${getIssueRepository(issue) ?? issue.repository ?? "unknown"}#${issue.number}`
: `#${issue.number}`;
return `${i + 1}. ${repoPrefix}${issue.title}${labelStr}`;
})
.join("\n");
}
// ---------------------------------------------------------------------------
// Spawn with silent retry
// ---------------------------------------------------------------------------
async function spawnWithRetry(
config: PluginConfig,
issueArgs: string[],
maxRetries: number = 3,
): Promise<{ ok: true; output: string } | { ok: false; error: string }> {
let lastResult: { ok: true; output: string } | { ok: false; error: string } | undefined;
for (let attempt = 0; attempt < maxRetries; attempt++) {
lastResult = tryRunAo(config, issueArgs, 30_000);
if (lastResult.ok) return lastResult;
// Only retry on transient errors, not config/auth errors
if (
lastResult.error.includes("not found") ||
lastResult.error.includes("not configured") ||
lastResult.error.includes("401")
) {
return lastResult;
}
if (attempt < maxRetries - 1) {
await new Promise((r) => setTimeout(r, 3_000));
}
}
return lastResult!;
}
// ---------------------------------------------------------------------------
// Work-trigger detection
// ---------------------------------------------------------------------------
const WORK_TRIGGERS = [
"what needs",
"what should i",
"what do i need",
"start working",
"morning",
"let's go",
"lets go",
"what's going on",
"whats going on",
"status update",
"check my repos",
"check my issues",
"check issues",
"any issues",
"what's on the board",
"whats on the board",
"what can i work on",
"what to work on",
"work on today",
"what's open",
"whats open",
"open issues",
"scan my repos",
"scan repos",
"scan issues",
"engineering update",
"dev update",
"project update",
"anything to do",
"what's pending",
"whats pending",
"ready to work",
"what's the plan",
"whats the plan",
];
function isWorkRelated(message: string): boolean {
const lower = message.toLowerCase();
return WORK_TRIGGERS.some((t) => lower.includes(t));
}
// ---------------------------------------------------------------------------
// Plugin entry point
// ---------------------------------------------------------------------------
export default function (api: PluginApi) {
const config: PluginConfig = api.pluginConfig || {};
// =========================================================================
// HOOKS — intercept work-related messages and inject live data
//
// OpenClaw hook names (see docs.openclaw.ai/concepts/agent-loop):
// message_received — inbound message arrives from any channel
// before_prompt_build — runs after session load, can inject context via
// event.appendSystemContext / event.prependContext
// =========================================================================
/** Build a live-data context block from AO + GitHub */
function buildLiveContext(): string | null {
try {
const issuesResult = fetchIssues(config);
const sessionsResult = tryRunAo(config, ["status"], 10_000);
const issuesSummary = !issuesResult.ok
? issuesResult.error
: issuesResult.issues.length > 0
? [
`Open issues (${issuesResult.issues.length}${issuesResult.scannedRepos.length > 1 ? ` across ${issuesResult.scannedRepos.length} repos` : ""}):`,
formatIssueList(issuesResult.issues),
issuesResult.warnings.length > 0
? `GitHub warnings:\n${formatIssueWarnings(issuesResult.warnings)}`
: null,
]
.filter(Boolean)
.join("\n")
: `No open issues found${issuesResult.scannedRepos.length > 1 ? ` across ${issuesResult.scannedRepos.length} repos` : ""}.`;
const sessionsSummary = sessionsResult.ok
? `Active sessions:\n${sessionsResult.output}`
: "No active AO sessions (or AO not running).";
return [
"=== LIVE AGENT ORCHESTRATOR DATA (just fetched — use this, NOT your memory) ===",
"",
issuesSummary,
"",
sessionsSummary,
"",
"INSTRUCTIONS: Present this data to the user. Recommend which issues to start agents on.",
"Ask for approval before spawning. Use ao_batch_spawn after they approve.",
"Do NOT answer from memory about their projects — this live data supersedes everything.",
"=== END LIVE DATA ===",
].join("\n");
} catch (err) {
api.logger.warn(`[ao-hook] Failed to build live context: ${err}`);
return null;
}
}
// Track pending work-related messages per session/channel to avoid
// cross-conversation interference. Uses Map with timestamps for TTL cleanup.
const pendingWorkSessions = new Map<string, number>();
const PENDING_TTL_MS = 60_000; // 60s — if prompt build doesn't fire, clean up
function cleanStalePending() {
const now = Date.now();
for (const [key, ts] of pendingWorkSessions) {
if (now - ts > PENDING_TTL_MS) pendingWorkSessions.delete(key);
}
}
function getSessionKey(event: PluginEvent): string {
return event?.sessionKey || event?.sessionId || event?.channelId || "default";
}
// Hook 1: message_received — detect work-related inbound messages
const onMessageReceived = async (event: PluginEvent) => {
const message =
event?.message?.text || event?.message?.content || event?.text || event?.content || "";
if (isWorkRelated(message)) {
cleanStalePending();
pendingWorkSessions.set(getSessionKey(event), Date.now());
api.logger.info("[ao-hook] Work-related message detected, will inject context");
}
};
// Hook 2: before_prompt_build — inject AO routing context + live data
const onBeforePromptBuild = async (event: PluginEvent) => {
const key = getSessionKey(event);
// Inform the model that AO is available and what it offers.
// Not a command — just context so the model can make an informed choice.
const routingContext = [
"[Agent Orchestrator] This project has AO installed — an open-source orchestrator " +
"for durable coding agents (Claude Code, Codex, OpenCode). ao_spawn creates an " +
"isolated git worktree, starts an agent, and wires up feedback loops so PR reviews " +
"and CI failures automatically route to the right agent.",
];
// If this is a work-related message, also inject live repo data
if (pendingWorkSessions.has(key)) {
pendingWorkSessions.delete(key);
api.logger.info("[ao-hook] Injecting live data into prompt context...");
const context = buildLiveContext();
if (context) routingContext.push(context);
}
const fullContext = routingContext.join("\n\n");
// OpenClaw before_prompt_build supports these injection points:
if (typeof event.appendSystemContext === "function") {
event.appendSystemContext(fullContext);
} else if (typeof event.prependContext === "function") {
event.prependContext(fullContext);
} else if (event.context && typeof event.context === "object") {
// Fallback: write to context object directly
event.context.aoLiveData = fullContext;
} else if (event.messages && Array.isArray(event.messages)) {
// Last resort: push a system message
event.messages.push({ role: "system", content: fullContext });
}
api.logger.info("[ao-hook] Injected AO context into prompt");
};
// Register hooks using the correct OpenClaw event names
const register = (name: string, handler: (event: PluginEvent) => Promise<void>) => {
try {
if (typeof api.on === "function") {
api.on(name, handler, { priority: 10 });
} else if (typeof api.registerHook === "function") {
api.registerHook(name, handler, { priority: 10 });
}
} catch {
api.logger.warn(`[ao-hook] Failed to register hook: ${name}`);
}
};
register("message_received", onMessageReceived);
register("before_prompt_build", onBeforePromptBuild);
api.logger.info("[ao-hook] Hooks registered (message_received, before_prompt_build)");
// =========================================================================
// SLASH COMMAND — single /ao command with subcommand parsing
// =========================================================================
api.registerCommand({
name: "ao",
description:
"Agent Orchestrator — /ao sessions | status | spawn | issues | batch-spawn | retry | kill | doctor",
acceptsArgs: true,
requireAuth: true,
handler: async (ctx: CommandContext) => {
const raw = (ctx.args || "").trim();
const parts = raw.split(/\s+/);
const subcommand = parts[0]?.toLowerCase() || "help";
const rest = parts.slice(1).join(" ").trim();
// Sanitize user input: strip leading dashes to prevent flag injection
const sanitizeArg = (arg: string): string => arg.replace(/^-+/, "");
const isValidIssueId = (s: string): boolean => /^#?\d+$/.test(s.trim());
const isValidSessionId = (s: string): boolean => /^[\w-]+$/.test(s.trim());
switch (subcommand) {
case "sessions": {
const result = tryRunAo(config, ["status"]);
if (!result.ok) return { text: `Failed to get sessions:\n${result.error}` };
return { text: result.output || "No active sessions." };
}
case "status": {
// `ao status` shows all sessions; no per-session lookup available
const result = tryRunAo(config, ["status"]);
if (!result.ok) return { text: `Failed:\n${result.error}` };
return { text: result.output };
}
case "spawn": {
if (!rest) return { text: "Usage: /ao spawn <issue-number>" };
const issueArg = sanitizeArg(rest.split(/\s+/)[0]);
if (!isValidIssueId(issueArg))
return {
text: `Invalid issue identifier: ${issueArg}. Expected a number like 42 or #42.`,
};
const result = await spawnWithRetry(config, ["spawn", issueArg]);
if (!result.ok) return { text: `Failed to spawn:\n${result.error}` };
return { text: result.output };
}
case "issues": {
const issuesResult = fetchIssues(config, { repo: rest || undefined });
if (!issuesResult.ok) return { text: issuesResult.error };
const lines = [formatIssueList(issuesResult.issues)];
if (issuesResult.warnings.length > 0) {
lines.push("");
lines.push("GitHub warnings:");
lines.push(formatIssueWarnings(issuesResult.warnings));
}
return { text: lines.join("\n") };
}
case "batch-spawn": {
if (!rest) return { text: "Usage: /ao batch-spawn <issue1> <issue2> ..." };
const issueArgs = rest.split(/\s+/).map(sanitizeArg);
if (!issueArgs.every(isValidIssueId))
return { text: `Invalid issue identifiers. Expected numbers like: 42 43 44` };
const result = tryRunAo(config, ["batch-spawn", ...issueArgs], 60_000);
if (!result.ok) return { text: `Failed to batch-spawn:\n${result.error}` };
return { text: result.output };
}
case "retry": {
if (!rest) return { text: "Usage: /ao retry <session-id>" };
const sessionId = sanitizeArg(rest.trim());
if (!isValidSessionId(sessionId))
return { text: `Invalid session ID: ${rest}. Expected format like ao-42.` };
const result = tryRunAo(config, ["send", sessionId, "Please retry the failed task."]);
if (!result.ok) return { text: `Failed to send retry:\n${result.error}` };
return { text: `Retry sent to session ${sessionId}.` };
}
case "kill": {
if (!rest) return { text: "Usage: /ao kill <session-id>" };
const sessionId = sanitizeArg(rest.trim());
if (!isValidSessionId(sessionId))
return { text: `Invalid session ID: ${rest}. Expected format like ao-42.` };
const result = tryRunAo(config, ["session", "kill", sessionId]);
if (!result.ok) return { text: `Failed to kill session:\n${result.error}` };
return { text: `Session ${sessionId} killed.` };
}
case "doctor": {
const result = tryRunAo(config, ["doctor"], 30_000);
if (!result.ok) return { text: `Failed to run doctor:\n${result.error}` };
return { text: result.output };
}
case "setup": {
// Auto-configure OpenClaw settings for AO plugin
const steps: string[] = [];
const runSetup = (bin: string, args: string[]): boolean => {
try {
execFileSync(bin, args, { encoding: "utf-8", timeout: 10_000 });
return true;
} catch {
return false;
}
};
// 1. tools.profile must be "full" for plugin tools to be visible
if (runSetup("openclaw", ["config", "set", "tools.profile", "full"]))
steps.push("✅ tools.profile → full");
else steps.push("❌ Failed to set tools.profile");
// 2. Allow plugin tools
const mergedToolsAllow = mergeStringLists(
readOpenClawStringArraySetting("tools.allow", ["tools", "allow"]),
["group:plugins"],
);
if (
runSetup("openclaw", ["config", "set", "tools.allow", JSON.stringify(mergedToolsAllow)])
) {
steps.push(`✅ tools.allow → ${mergedToolsAllow.join(", ")}`);
} else steps.push("❌ Failed to set tools.allow");
// 3. Trust the plugin
const mergedPluginsAllow = mergeStringLists(
readOpenClawStringArraySetting("plugins.allow", ["plugins", "allow"]),
["agent-orchestrator"],
);
if (
runSetup("openclaw", [
"config",
"set",
"plugins.allow",
JSON.stringify(mergedPluginsAllow),
])
) {
steps.push(`✅ plugins.allow → ${mergedPluginsAllow.join(", ")}`);
} else steps.push("❌ Failed to set plugins.allow");
// 4. Group chat settings
if (runSetup("openclaw", ["config", "set", "messages.groupChat.historyLimit", "100"]))
steps.push("✅ historyLimit → 100");
else steps.push("⚠️ Could not set historyLimit");
steps.push("");
steps.push("⚡ Restart the gateway to apply: pm2 restart openclaw-gateway");
steps.push("Then verify with: /ao doctor");
steps.push("");
steps.push("⚠️ Action required — run these once to avoid conflicts:");
steps.push(" openclaw config set skills.entries.coding-agent.enabled false");
steps.push(" openclaw config set skills.entries.gh-issues.enabled false");
steps.push(' openclaw config set tools.deny \'["exec","write","str_replace_based_edit_tool","create_file","str_replace_editor"]\'');
steps.push("Without these, the bot may code directly instead of delegating to AO.");
return { text: `AO Plugin Setup\n\n${steps.join("\n")}` };
}
default:
return {
text: [
"Agent Orchestrator commands:",
" /ao sessions — list all sessions",
" /ao status — all sessions overview",
" /ao issues [owner/repo] — list open issues",
" /ao spawn <issue> — spawn agent on issue",
" /ao batch-spawn <i1> <i2> — spawn multiple agents",
" /ao retry <id> — retry failed session",
" /ao kill <id> — kill a session",
" /ao doctor — run health checks",
" /ao setup — auto-configure OpenClaw for AO",
].join("\n"),
};
}
},
});
// =========================================================================
// AGENT TOOLS
// =========================================================================
api.registerTool({
name: "ao_sessions",
description:
"Returns live session data from Agent Orchestrator — what agents are running, " +
"their status, branches, and progress. Use when the user asks about status or progress.",
parameters: { type: "object", properties: {}, required: [] },
async execute() {
const result = tryRunAo(config, ["status"]);
if (!result.ok) {
return {
content: [{ type: "text", text: `Failed to get sessions: ${result.error}` }],
isError: true,
};
}
return {
content: [{ type: "text", text: result.output || "No active sessions." }],
};
},
});
api.registerTool({
name: "ao_issues",
description:
"Returns live GitHub issue data — open issues, labels, assignees, and priorities. " +
"Use when the user asks about work, tasks, issues, or what needs attention.",
parameters: {
type: "object",
properties: {
repo: {
type: "string",
description: "GitHub repo in owner/repo format. Omit to scan the default repo.",
},
labels: {
type: "string",
description: "Comma-separated label filter (e.g. 'bug,P1'). Optional.",
},
},
required: [],
},
async execute(_toolCallId: string, params: { repo?: string; labels?: string }) {
const result = fetchIssues(config, params);
if (!result.ok) {
return {
content: [{ type: "text", text: result.error }],
isError: true,
};
}
const lines = [formatIssueList(result.issues)];
if (result.warnings.length > 0) {
lines.push("");
lines.push("GitHub warnings:");
lines.push(formatIssueWarnings(result.warnings));
}
return { content: [{ type: "text", text: lines.join("\n") }] };
},
});
api.registerTool({
name: "ao_spawn",
description:
"Spawn a durable coding agent (Claude Code, Codex, or OpenCode) on a task. " +
"Creates an isolated git worktree, starts the agent, and wires up feedback " +
"loops — CI failures and PR reviews automatically route back to the agent. " +
"Works with issue numbers (#42) or without for freeform tasks.",
parameters: {
type: "object",
properties: {
issue: {
type: "string",
description:
"Issue identifier (e.g. #42). Optional — omit for freeform tasks, then use ao_send to describe the work.",
},
agent: { type: "string", description: "Override agent plugin (e.g. codex, claude-code)" },
claimPr: {
type: "string",
description: "Immediately claim an existing PR number for the session",
},
},
},
async execute(
_toolCallId: string,
params: { issue?: string; agent?: string; claimPr?: string },
) {
const args = ["spawn"];
if (params.issue) {
args.push(sanitizeCliArg(params.issue));
} else {
// Freeform spawn — ao CLI supports bare `ao spawn` which creates
// a session without an issue. Use ao_send afterward to describe the task.
api.logger.info("[ao_spawn] Spawning without issue — freeform session");
}
if (params.agent) args.push("--agent", sanitizeCliArg(params.agent));
if (params.claimPr) args.push("--claim-pr", sanitizeCliArg(params.claimPr));
const result = await spawnWithRetry(config, args);
if (!result.ok) {
return {
content: [{ type: "text", text: `Failed to spawn: ${result.error}` }],
isError: true,
};
}
const spawnOutput = params.issue
? result.output
: result.output +
"\n\nNote: This is a freeform session (no issue). Use ao_send to describe the task to the agent.";
return { content: [{ type: "text", text: spawnOutput }] };
},
});
api.registerTool({
name: "ao_batch_spawn",
description:
"Spawn durable coding agents for multiple GitHub issues in parallel. " +
"Always confirm the list with the user before calling this. " +
"Each agent gets its own isolated worktree with CI and PR review feedback loops.",
parameters: {
type: "object",
properties: {
issues: {
type: "array",
items: { type: "string" },
description: "List of GitHub issue numbers",
},
},
required: ["issues"],
},
async execute(_toolCallId: string, params: { issues: string[] }) {
const result = tryRunAo(config, ["batch-spawn", ...params.issues.map(sanitizeCliArg)], 60_000);
if (!result.ok) {
return {
content: [{ type: "text", text: `Failed to batch-spawn: ${result.error}` }],
isError: true,
};
}
// Schedule auto follow-ups
const checkStatus = (label: string) => {
const status = tryRunAo(config, ["status"], 10_000);
const msg = status.ok ? status.output : "Could not reach AO for status check.";
try {
api.runtime?.sendMessageToDefaultSession?.(
`${label}:\n\n${msg}\n\nNeed me to do anything?`,
);
} catch {
api.logger.info(`[ao-followup] ${label}: ${msg}`);
}
};
batchSpawnFollowUpTimeouts.push(
setTimeout(() => checkStatus("Progress check (3 min)"), 3 * 60_000),
);
batchSpawnFollowUpTimeouts.push(
setTimeout(() => checkStatus("Status update (8 min)"), 8 * 60_000),
);
api.logger.info("[ao-batch] Scheduled auto follow-ups at 3min and 8min");
return {
content: [
{
type: "text",
text:
result.output +
"\n\nI'll check status automatically in a few minutes and update you.",
},
],
};
},
});
api.registerTool({
name: "ao_send",
description: "Send a message to a running Agent Orchestrator session.",
parameters: {
type: "object",
properties: {
sessionId: { type: "string", description: "The AO session ID (e.g. ao-5)" },
message: { type: "string", description: "Message to send" },
},
required: ["sessionId", "message"],
},
async execute(_toolCallId: string, params: { sessionId: string; message: string }) {
const result = tryRunAo(config, ["send", sanitizeCliArg(params.sessionId), params.message]);
if (!result.ok) {
return {
content: [{ type: "text", text: `Failed to send: ${result.error}` }],
isError: true,
};
}
return {
content: [{ type: "text", text: `Message sent to ${params.sessionId}.` }],
};
},
});
api.registerTool({
name: "ao_kill",
description:
"Kill an Agent Orchestrator session. Always confirm with the user before calling this.",
parameters: {
type: "object",
properties: {
sessionId: { type: "string", description: "The AO session ID to kill" },
},
required: ["sessionId"],
},
async execute(_toolCallId: string, params: { sessionId: string }) {
const result = tryRunAo(config, ["session", "kill", sanitizeCliArg(params.sessionId)]);
if (!result.ok) {
return {
content: [{ type: "text", text: `Failed to kill: ${result.error}` }],
isError: true,
};
}
return {
content: [
{ type: "text", text: `Session ${params.sessionId} killed and worktree cleaned up.` },
],
};
},
});
api.registerTool({
name: "ao_doctor",
description: "Run Agent Orchestrator health checks. Use when troubleshooting.",
parameters: { type: "object", properties: {}, required: [] },
async execute() {
const result = tryRunAo(config, ["doctor"], 30_000);
if (!result.ok) {
return {
content: [{ type: "text", text: `Doctor failed: ${result.error}` }],
isError: true,
};
}
return { content: [{ type: "text", text: result.output }] };
},
});
api.registerTool({
name: "ao_review_check",
description:
"Check PRs for review comments and trigger agents to address them. " +
"Use when the user asks to check reviews, handle PR feedback, or address reviewer comments. " +
"Optionally pass a project ID to filter.",
parameters: {
type: "object",
properties: {
project: { type: "string", description: "Project ID (checks all if omitted)" },
dryRun: { type: "boolean", description: "Show what would be done without acting" },
},
required: [],
},
async execute(_toolCallId: string, params: { project?: string; dryRun?: boolean }) {
const args = ["review-check"];
if (params.project) args.push(sanitizeCliArg(params.project));
if (params.dryRun) args.push("--dry-run");
const result = tryRunAo(config, args, 30_000);
if (!result.ok) {
return {
content: [{ type: "text", text: `Review check failed: ${result.error}` }],
isError: true,
};
}
return {
content: [{ type: "text", text: result.output || "No review comments to address." }],
};
},
});
api.registerTool({
name: "ao_verify",
description:
"Mark an issue as verified (or failed) after checking the fix on staging. " +
"Use when the user confirms a fix works or reports it doesn't. " +
"Use with --list to show all merged-but-unverified issues.",
parameters: {
type: "object",
properties: {
issue: { type: "string", description: "Issue number to verify" },
project: { type: "string", description: "Project ID (required if multiple projects)" },
fail: { type: "boolean", description: "Mark verification as failed instead of passing" },
comment: { type: "string", description: "Custom comment to add" },
list: { type: "boolean", description: "List all issues with merged-unverified label" },
},
required: [],
},
async execute(
_toolCallId: string,
params: {
issue?: string;
project?: string;
fail?: boolean;
comment?: string;
list?: boolean;
},
) {
const args = ["verify"];
if (params.list) {
args.push("--list");
if (params.project) args.push("-p", params.project);
} else {
if (!params.issue) {
return {
content: [
{
type: "text",
text: "Need an issue number. Use list: true to see unverified issues.",
},
],
isError: true,
};
}
args.push(params.issue);
if (params.project) args.push("-p", params.project);
if (params.fail) args.push("--fail");
if (params.comment) args.push("-c", params.comment);
}
const result = tryRunAo(config, args, 15_000);
if (!result.ok) {
return {
content: [{ type: "text", text: `Verify failed: ${result.error}` }],
isError: true,
};
}
return { content: [{ type: "text", text: result.output }] };
},
});
api.registerTool({
name: "ao_session_cleanup",
description:
"Kill sessions where the PR is merged or the issue is closed. " +
"Cleans up stale sessions. Use dry-run first to preview.",
parameters: {
type: "object",
properties: {
project: { type: "string", description: "Project ID to filter" },
dryRun: { type: "boolean", description: "Preview what would be cleaned up" },
},
required: [],
},
async execute(_toolCallId: string, params: { project?: string; dryRun?: boolean }) {
const args = ["session", "cleanup"];
if (params.project) args.push("-p", sanitizeCliArg(params.project));
if (params.dryRun) args.push("--dry-run");
const result = tryRunAo(config, args, 30_000);
if (!result.ok) {
return {
content: [{ type: "text", text: `Cleanup failed: ${result.error}` }],
isError: true,
};
}
return { content: [{ type: "text", text: result.output || "No sessions to clean up." }] };
},
});
api.registerTool({
name: "ao_session_restore",
description:
"Restore a terminated or crashed agent session in-place. " +
"Use when a session died unexpectedly and needs to resume.",
parameters: {
type: "object",
properties: {
sessionId: { type: "string", description: "Session name to restore" },
},
required: ["sessionId"],
},
async execute(_toolCallId: string, params: { sessionId: string }) {
const result = tryRunAo(config, ["session", "restore", sanitizeCliArg(params.sessionId)], 30_000);
if (!result.ok) {
return {
content: [{ type: "text", text: `Restore failed: ${result.error}` }],
isError: true,
};
}
return { content: [{ type: "text", text: result.output }] };
},
});
api.registerTool({
name: "ao_session_claim_pr",
description:
"Attach an existing PR to an agent session. " +
"Use when there's a PR that was created outside AO that should be tracked.",
parameters: {
type: "object",
properties: {
pr: { type: "string", description: "Pull request number or URL" },
sessionId: { type: "string", description: "Session name (optional)" },
assignOnGithub: {
type: "boolean",
description: "Assign the PR to the authenticated GitHub user",
},
},
required: ["pr"],
},
async execute(
_toolCallId: string,
params: { pr: string; sessionId?: string; assignOnGithub?: boolean },
) {
const args = ["session", "claim-pr", params.pr];
if (params.sessionId) args.push(params.sessionId);
if (params.assignOnGithub) args.push("--assign-on-github");
const result = tryRunAo(config, args, 15_000);
if (!result.ok) {
return {
content: [{ type: "text", text: `Claim PR failed: ${result.error}` }],
isError: true,
};
}
return { content: [{ type: "text", text: result.output }] };
},
});
api.registerTool({
name: "ao_session_list",
description:
"List all agent sessions with detailed info. " +
"Use for a comprehensive session listing. For a quick status overview, use ao_sessions instead.",
parameters: {
type: "object",
properties: {
project: { type: "string", description: "Project ID to filter" },
},
required: [],
},
async execute(_toolCallId: string, params: { project?: string }) {
const args = ["session", "ls"];
if (params.project) args.push("-p", params.project);
const result = tryRunAo(config, args, 15_000);
if (!result.ok) {
return {
content: [{ type: "text", text: `Session list failed: ${result.error}` }],
isError: true,
};
}
return { content: [{ type: "text", text: result.output || "No sessions found." }] };
},
});
api.registerTool({
name: "ao_status",
description:
"Show all sessions with branch, activity, PR, and CI status. " +
"Returns JSON when requested. Use for a comprehensive dashboard view.",
parameters: {
type: "object",
properties: {
project: { type: "string", description: "Filter by project ID" },
json: { type: "boolean", description: "Return output as JSON for easier parsing" },
},
required: [],
},
async execute(_toolCallId: string, params: { project?: string; json?: boolean }) {
const args = ["status"];
if (params.project) args.push("-p", params.project);
if (params.json) args.push("--json");
const result = tryRunAo(config, args, 15_000);
if (!result.ok) {
return {
content: [{ type: "text", text: `Status failed: ${result.error}` }],
isError: true,
};
}
return { content: [{ type: "text", text: result.output }] };
},
});
// =========================================================================
// BACKGROUND SERVICES
// =========================================================================
let healthInterval: ReturnType<typeof setInterval> | null = null;
let boardScanInterval: ReturnType<typeof setInterval> | null = null;
let boardScanInitialTimeout: ReturnType<typeof setTimeout> | null = null;
const batchSpawnFollowUpTimeouts: ReturnType<typeof setTimeout>[] = [];
let lastKnownIssueIds: Set<string> = new Set();
let isFirstBoardScan = true;
// --- Health monitor ---
api.registerService({
id: "ao-health",
start: async () => {
const pollMs = config.healthPollIntervalMs ?? 30_000;
if (pollMs <= 0) return;
api.logger.info(`[ao-health] Starting (every ${pollMs / 1000}s)`);
healthInterval = setInterval(() => {
const result = tryRunAo(config, ["status"], 10_000);
if (!result.ok) {
api.logger.warn(`[ao-health] AO unreachable: ${result.error}`);
}
}, pollMs);
},
stop: async () => {
if (healthInterval) {
clearInterval(healthInterval);
healthInterval = null;
}
// Clear any pending batch-spawn follow-up timeouts
for (const t of batchSpawnFollowUpTimeouts.splice(0)) {
clearTimeout(t);
}
},
});
// --- Issue board scanner ---
api.registerService({
id: "ao-board-scanner",
start: async () => {
const scanMs = config.boardScanIntervalMs ?? 1_800_000;
if (scanMs <= 0) return;
api.logger.info(`[ao-board-scanner] Starting (every ${scanMs / 60_000}min)`);
const scan = () => {
try {
const issuesResult = fetchIssues(config);
if (!issuesResult.ok) {
api.logger.warn(`[ao-board-scanner] ${issuesResult.error}`);
return;
}
if (issuesResult.warnings.length > 0) {
api.logger.warn(
`[ao-board-scanner] Partial GitHub failures:\n${formatIssueWarnings(issuesResult.warnings)}`,
);
}
const currentIds = new Set(issuesResult.issues.map((issue) => getIssueIdentity(issue)));
if (isFirstBoardScan) {
lastKnownIssueIds = currentIds;
isFirstBoardScan = false;
api.logger.info(`[ao-board-scanner] Baseline: ${currentIds.size} open issues`);
return;
}
const newIssues = issuesResult.issues.filter(
(issue) => !lastKnownIssueIds.has(getIssueIdentity(issue)),
);
if (newIssues.length > 0) {
const summary = formatIssueList(newIssues);
api.logger.info(`[ao-board-scanner] ${newIssues.length} new issue(s)`);
try {
api.runtime?.sendMessageToDefaultSession?.(
`New issues detected:\n\n${summary}\n\nWant me to start agents on any of these?`,
);
} catch {
api.logger.info(`[ao-board-scanner] New issues:\n${summary}`);
}
}
lastKnownIssueIds = currentIds;
} catch (err) {
api.logger.warn(`[ao-board-scanner] Scan failed: ${err}`);
}
};
boardScanInitialTimeout = setTimeout(scan, 10_000);
boardScanInterval = setInterval(scan, scanMs);
},
stop: async () => {
if (boardScanInitialTimeout) {
clearTimeout(boardScanInitialTimeout);
boardScanInitialTimeout = null;
}
if (boardScanInterval) {
clearInterval(boardScanInterval);
boardScanInterval = null;
}
// Clear batch-spawn follow-up timeouts here too — they may outlive the health
// service if healthPollIntervalMs <= 0 (health service never starts/stops)
for (const t of batchSpawnFollowUpTimeouts.splice(0)) {
clearTimeout(t);
}
},
});
}