941 lines
33 KiB
TypeScript
941 lines
33 KiB
TypeScript
/**
|
|
* OpenClaw Plugin: Agent Orchestrator v0.3.0
|
|
*
|
|
* Key design: the plugin INJECTS live repo data into the AI's context
|
|
* via hooks — the AI never needs to "decide" to call tools for work questions.
|
|
* This eliminates the #1 friction point where the AI answers from stale memory.
|
|
*
|
|
* Provides:
|
|
* - Hook: intercepts work-related messages and pre-fetches live data
|
|
* - Slash command: /ao (with subcommands)
|
|
* - Agent tools: ao_sessions, ao_issues, ao_spawn, ao_batch_spawn, ao_send, ao_kill, ao_doctor
|
|
* - Background services: health monitoring + issue board scanner + auto follow-up
|
|
*/
|
|
|
|
import { execFileSync } from "node:child_process";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface PluginConfig {
|
|
aoPath?: string;
|
|
aoCwd?: string;
|
|
ghPath?: string;
|
|
healthPollIntervalMs?: number;
|
|
boardScanIntervalMs?: number;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 };
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function fetchIssues(config: PluginConfig, repo?: string): GitHubIssue[] {
|
|
const args = ["issue", "list"];
|
|
if (repo) args.push("-R", repo);
|
|
args.push("--state", "open", "--json", "number,title,labels,state,assignees,createdAt,url", "--limit", "30");
|
|
const result = tryRunGh(
|
|
config,
|
|
args,
|
|
15_000,
|
|
);
|
|
if (!result.ok) return [];
|
|
try {
|
|
return JSON.parse(result.output) as GitHubIssue[];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function formatIssueList(issues: GitHubIssue[]): string {
|
|
if (issues.length === 0) return "No open issues found.";
|
|
return issues
|
|
.map((issue, i) => {
|
|
const labels = issue.labels.map((l) => l.name).join(", ");
|
|
const labelStr = labels ? ` [${labels}]` : "";
|
|
return `${i + 1}. #${issue.number} — ${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: any) {
|
|
const config: PluginConfig = (api.pluginConfig as 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 issues = fetchIssues(config);
|
|
const sessionsResult = tryRunAo(config, ["status"], 10_000);
|
|
|
|
const issuesSummary =
|
|
issues.length > 0
|
|
? `Open issues (${issues.length}):\n${formatIssueList(issues)}`
|
|
: "No open issues across your 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
|
|
const pendingWorkSessions = new Set<string>();
|
|
|
|
function getSessionKey(event: any): string {
|
|
return event?.sessionKey || event?.sessionId || event?.channelId || "default";
|
|
}
|
|
|
|
// Hook 1: message_received — detect work-related inbound messages
|
|
const onMessageReceived = async (event: any) => {
|
|
const message =
|
|
event?.message?.text ||
|
|
event?.message?.content ||
|
|
event?.text ||
|
|
event?.content ||
|
|
"";
|
|
|
|
if (isWorkRelated(message)) {
|
|
pendingWorkSessions.add(getSessionKey(event));
|
|
api.logger.info("[ao-hook] Work-related message detected, will inject context");
|
|
}
|
|
};
|
|
|
|
// Hook 2: before_prompt_build — inject live data into the AI's system context
|
|
const onBeforePromptBuild = async (event: any) => {
|
|
const key = getSessionKey(event);
|
|
if (!pendingWorkSessions.has(key)) return;
|
|
pendingWorkSessions.delete(key);
|
|
|
|
api.logger.info("[ao-hook] Injecting live data into prompt context...");
|
|
const context = buildLiveContext();
|
|
if (!context) return;
|
|
|
|
// OpenClaw before_prompt_build supports these injection points:
|
|
if (typeof event.appendSystemContext === "function") {
|
|
event.appendSystemContext(context);
|
|
} else if (typeof event.prependContext === "function") {
|
|
event.prependContext(context);
|
|
} else if (event.context && typeof event.context === "object") {
|
|
// Fallback: write to context object directly
|
|
event.context.aoLiveData = context;
|
|
} else if (event.messages && Array.isArray(event.messages)) {
|
|
// Last resort: push a system message
|
|
event.messages.push({ role: "system", content: context });
|
|
}
|
|
|
|
api.logger.info("[ao-hook] Injected live data into prompt context");
|
|
};
|
|
|
|
// Register hooks using the correct OpenClaw event names
|
|
const register = (name: string, handler: (event: any) => 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: any) => {
|
|
const raw = (ctx.args || "").trim();
|
|
const parts = raw.split(/\s+/);
|
|
const subcommand = parts[0]?.toLowerCase() || "help";
|
|
const rest = parts.slice(1).join(" ").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 result = await spawnWithRetry(config, ["spawn", rest.split(/\s+/)[0]]);
|
|
if (!result.ok) return { text: `Failed to spawn:\n${result.error}` };
|
|
return { text: result.output };
|
|
}
|
|
|
|
case "issues": {
|
|
const issues = fetchIssues(config, rest || undefined);
|
|
return { text: formatIssueList(issues) };
|
|
}
|
|
|
|
case "batch-spawn": {
|
|
if (!rest) return { text: "Usage: /ao batch-spawn <issue1> <issue2> ..." };
|
|
const result = tryRunAo(config, ["batch-spawn", ...rest.split(/\s+/)], 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 result = tryRunAo(config, ["send", rest, "Please retry the failed task."]);
|
|
if (!result.ok) return { text: `Failed to send retry:\n${result.error}` };
|
|
return { text: `Retry sent to session ${rest}.` };
|
|
}
|
|
|
|
case "kill": {
|
|
if (!rest) return { text: "Usage: /ao kill <session-id>" };
|
|
const result = tryRunAo(config, ["session", "kill", rest]);
|
|
if (!result.ok) return { text: `Failed to kill session:\n${result.error}` };
|
|
return { text: `Session ${rest} 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
|
|
if (runSetup("openclaw", ["config", "set", "tools.allow", '["group:plugins"]']))
|
|
steps.push("✅ tools.allow → group:plugins");
|
|
else steps.push("❌ Failed to set tools.allow");
|
|
|
|
// 3. Trust the plugin
|
|
if (runSetup("openclaw", ["config", "set", "plugins.allow", '["agent-orchestrator"]']))
|
|
steps.push("✅ plugins.allow → agent-orchestrator");
|
|
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");
|
|
|
|
return { text: `AO Plugin Setup\n\n${steps.join("\n")}` };
|
|
}
|
|
|
|
default:
|
|
return {
|
|
text: [
|
|
"Agent Orchestrator commands:",
|
|
" /ao sessions — list all sessions",
|
|
" /ao status <id> — session details",
|
|
" /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:
|
|
"REQUIRED: Call this whenever the user asks about work status, progress, what's running, " +
|
|
"or how things are going. Returns LIVE session data from Agent Orchestrator. " +
|
|
"Do NOT answer session/status questions from memory — this tool has real-time data.",
|
|
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:
|
|
"REQUIRED: Call this whenever the user asks about work, tasks, issues, what to do, " +
|
|
"what needs attention, or anything about their GitHub repos. Returns LIVE issue data. " +
|
|
"Your memory about their repos is STALE — always call this tool first for any work-related question.",
|
|
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 ghArgs = ["issue", "list"];
|
|
if (params.repo) ghArgs.push("-R", params.repo);
|
|
if (params.labels) ghArgs.push("--label", params.labels);
|
|
ghArgs.push("--state", "open", "--json", "number,title,labels,state,assignees,createdAt,url", "--limit", "30");
|
|
const result = tryRunGh(
|
|
config,
|
|
ghArgs,
|
|
15_000,
|
|
);
|
|
if (!result.ok) {
|
|
return {
|
|
content: [{ type: "text", text: `Failed to fetch issues: ${result.error}` }],
|
|
isError: true,
|
|
};
|
|
}
|
|
return { content: [{ type: "text", text: result.output }] };
|
|
},
|
|
});
|
|
|
|
api.registerTool({
|
|
name: "ao_spawn",
|
|
description:
|
|
"Spawn a new Agent Orchestrator session on a GitHub issue. " +
|
|
"Use when the user asks to start an agent or work on an issue. " +
|
|
"Retries silently on transient failures.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
issue: { type: "string", description: "Issue identifier (e.g. #42)" },
|
|
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" },
|
|
decompose: { type: "boolean", description: "Decompose issue into subtasks before spawning" },
|
|
},
|
|
required: ["issue"],
|
|
},
|
|
async execute(
|
|
_toolCallId: string,
|
|
params: { issue: string; agent?: string; claimPr?: string; decompose?: boolean },
|
|
) {
|
|
const args = ["spawn", params.issue];
|
|
if (params.agent) args.push("--agent", params.agent);
|
|
if (params.claimPr) args.push("--claim-pr", params.claimPr);
|
|
if (params.decompose) args.push("--decompose");
|
|
const result = await spawnWithRetry(config, args);
|
|
if (!result.ok) {
|
|
return {
|
|
content: [{ type: "text", text: `Failed to spawn: ${result.error}` }],
|
|
isError: true,
|
|
};
|
|
}
|
|
return { content: [{ type: "text", text: result.output }] };
|
|
},
|
|
});
|
|
|
|
api.registerTool({
|
|
name: "ao_batch_spawn",
|
|
description:
|
|
"Spawn agents for multiple GitHub issues at once. " +
|
|
"IMPORTANT: Always show the user the list and get their approval BEFORE calling this. " +
|
|
"After spawning, tell the user you'll check back with status in a few minutes.",
|
|
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], 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}`);
|
|
}
|
|
};
|
|
|
|
setTimeout(() => checkStatus("Progress check (3 min)"), 3 * 60_000);
|
|
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", 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", 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(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", 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", 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;
|
|
let lastKnownIssueIds: Set<number> = 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;
|
|
}
|
|
},
|
|
});
|
|
|
|
// --- 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 issues = fetchIssues(config);
|
|
const currentIds = new Set(issues.map((i) => i.number));
|
|
|
|
if (isFirstBoardScan) {
|
|
lastKnownIssueIds = currentIds;
|
|
isFirstBoardScan = false;
|
|
api.logger.info(`[ao-board-scanner] Baseline: ${currentIds.size} open issues`);
|
|
return;
|
|
}
|
|
|
|
const newIssues = issues.filter((i) => !lastKnownIssueIds.has(i.number));
|
|
|
|
if (newIssues.length > 0) {
|
|
const summary = newIssues
|
|
.map((i) => {
|
|
const labels = i.labels.map((l) => l.name).join(", ");
|
|
return `#${i.number} — ${i.title}${labels ? ` [${labels}]` : ""}`;
|
|
})
|
|
.join("\n");
|
|
|
|
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;
|
|
}
|
|
},
|
|
});
|
|
}
|