From 36d354e0c26bbc79c98c39b6dfda355ad2f16961 Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Mon, 23 Mar 2026 21:52:28 +0530 Subject: [PATCH 01/69] feat: OpenClaw plugin, AO skill, Discord notifier, and setup wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds bidirectional integration between Agent Orchestrator and OpenClaw, enabling AI bots on Discord/Telegram/WhatsApp to manage coding agent fleets through natural conversation. OpenClaw Plugin (openclaw-plugin/): - 14 AI tools: ao_spawn, ao_issues, ao_sessions, ao_status, ao_batch_spawn, ao_send, ao_kill, ao_session_restore, ao_session_cleanup, ao_session_claim_pr, ao_review_check, ao_verify, ao_doctor, ao_session_list - Hooks: message_received + before_prompt_build for live data injection - /ao slash command with subcommands (sessions, spawn, issues, doctor, setup) - Background services: health monitor + issue board scanner - Security: execFileSync with arg arrays (no shell injection) AO Skill (skills/agent-orchestrator/): - Natural language intent → AO tool mapping - Decision heuristic: quick fix → direct, multi-issue → AO - Designed for ClawHub publishing (blocked on ClawHub server bug) CLI: ao setup openclaw (packages/cli/src/commands/setup.ts): - Interactive wizard + non-interactive mode - Auto-detects OpenClaw gateway on localhost - Auto-generates secure token - Writes both configs (agent-orchestrator.yaml + openclaw.json) - Appends OPENCLAW_HOOKS_TOKEN to shell profile - Validates connection end-to-end CLI: ao doctor notifier checks: - Probes OpenClaw gateway reachability + token validity - --test-notify flag sends test event through all configured notifiers - Failure count propagated to exit code Discord Notifier (packages/plugins/notifier-discord/): - Rich webhook embeds with colors, fields, action links - Retry with exponential backoff + Discord Retry-After header - Request timeouts, embed truncation, webhook URL validation - thread_id via URL query string (Discord API requirement) OpenClaw Notifier improvements: - Added request timeouts via AbortController - Improved README Reviewed through 5 rounds of Codex code review. Co-Authored-By: Claude Opus 4.6 (1M context) --- agent-orchestrator.yaml.example | 10 +- docs/openclaw-plugin-setup.md | 181 ++++ openclaw-plugin/index.ts | 935 ++++++++++++++++++ openclaw-plugin/openclaw.plugin.json | 54 + openclaw-plugin/package.json | 27 + .../cli/__tests__/commands/doctor.test.ts | 27 +- packages/cli/__tests__/commands/setup.test.ts | 343 +++++++ .../cli/__tests__/lib/openclaw-probe.test.ts | 178 ++++ packages/cli/package.json | 2 + packages/cli/src/commands/doctor.ts | 190 +++- packages/cli/src/commands/setup.ts | 515 ++++++++++ packages/cli/src/index.ts | 2 + packages/cli/src/lib/config-instruction.ts | 7 +- packages/cli/src/lib/openclaw-probe.ts | 128 +++ packages/core/src/plugin-registry.ts | 1 + packages/plugins/notifier-discord/README.md | 39 + .../plugins/notifier-discord/package.json | 44 + .../notifier-discord/src/index.test.ts | 243 +++++ .../plugins/notifier-discord/src/index.ts | 208 ++++ .../plugins/notifier-discord/tsconfig.json | 8 + packages/plugins/notifier-openclaw/README.md | 12 + .../notifier-openclaw/src/index.test.ts | 12 +- .../plugins/notifier-openclaw/src/index.ts | 29 +- pnpm-lock.yaml | 44 + skills/agent-orchestrator/SKILL.md | 192 ++++ .../agent-orchestrator/references/config.md | 107 ++ 26 files changed, 3522 insertions(+), 16 deletions(-) create mode 100644 docs/openclaw-plugin-setup.md create mode 100644 openclaw-plugin/index.ts create mode 100644 openclaw-plugin/openclaw.plugin.json create mode 100644 openclaw-plugin/package.json create mode 100644 packages/cli/__tests__/commands/setup.test.ts create mode 100644 packages/cli/__tests__/lib/openclaw-probe.test.ts create mode 100644 packages/cli/src/commands/setup.ts create mode 100644 packages/cli/src/lib/openclaw-probe.ts create mode 100644 packages/plugins/notifier-discord/README.md create mode 100644 packages/plugins/notifier-discord/package.json create mode 100644 packages/plugins/notifier-discord/src/index.test.ts create mode 100644 packages/plugins/notifier-discord/src/index.ts create mode 100644 packages/plugins/notifier-discord/tsconfig.json create mode 100644 skills/agent-orchestrator/SKILL.md create mode 100644 skills/agent-orchestrator/references/config.md diff --git a/agent-orchestrator.yaml.example b/agent-orchestrator.yaml.example index 8a7cd87f9..4b7baf8d4 100644 --- a/agent-orchestrator.yaml.example +++ b/agent-orchestrator.yaml.example @@ -24,7 +24,7 @@ defaults: # worker: # agent: codex workspace: worktree # worktree | clone | copy - notifiers: [desktop] # desktop | slack | discord | webhook | email + notifiers: [desktop] # desktop | slack | discord | webhook | email | openclaw # Projects — at minimum, specify repo and path projects: @@ -99,6 +99,14 @@ projects: # plugin: slack # webhook: ${SLACK_WEBHOOK_URL} # channel: "#agent-updates" +# +# openclaw: +# plugin: openclaw +# url: http://127.0.0.1:18789/hooks/agent +# token: ${OPENCLAW_HOOKS_TOKEN} +# retries: 3 +# retryDelayMs: 1000 +# wakeMode: now # Notification routing by priority # notificationRouting: diff --git a/docs/openclaw-plugin-setup.md b/docs/openclaw-plugin-setup.md new file mode 100644 index 000000000..d09c63171 --- /dev/null +++ b/docs/openclaw-plugin-setup.md @@ -0,0 +1,181 @@ +# OpenClaw Plugin Setup Guide + +How to set up the Agent Orchestrator (AO) plugin for OpenClaw so the AI bot delegates all coding work to AO agents. + +## Prerequisites + +- [OpenClaw](https://openclaw.ai) installed and running +- [Agent Orchestrator](https://github.com/ComposioHQ/agent-orchestrator) installed with `ao init` completed in your repo +- `ao`, `gh`, `tmux`, and `node` available in PATH +- GitHub CLI (`gh`) authenticated + +## 1. Install the Plugin + +```bash +# From the agent-orchestrator repo +cd openclaw-plugin +openclaw plugins install . +``` + +Or manually copy the plugin files: + +```bash +mkdir -p ~/.openclaw/extensions/agent-orchestrator +cp openclaw-plugin/index.ts ~/.openclaw/extensions/agent-orchestrator/ +cp openclaw-plugin/openclaw.plugin.json ~/.openclaw/extensions/agent-orchestrator/ +cp openclaw-plugin/package.json ~/.openclaw/extensions/agent-orchestrator/ +``` + +## 2. Install the Skill + +```bash +mkdir -p ~/.openclaw/extensions/skills/agent-orchestrator +cp skills/agent-orchestrator/SKILL.md ~/.openclaw/extensions/skills/agent-orchestrator/ +cp -r skills/agent-orchestrator/references ~/.openclaw/extensions/skills/agent-orchestrator/ 2>/dev/null +``` + +## 3. Configure OpenClaw + +Run `/ao setup` in any OpenClaw channel to auto-configure, or run these commands manually: + +### Required Settings + +```bash +# 1. Plugin tools need "full" profile to be visible to the AI +# The "coding" profile only includes built-in tools, NOT plugin tools +openclaw config set tools.profile "full" + +# 2. Plugin tools are optional by default — explicitly allow them +openclaw config set tools.allow '["group:plugins"]' + +# 3. Trust the plugin +openclaw config set plugins.allow '["agent-orchestrator"]' +``` + +### Optional Settings + +```bash +# Discord: respond in server channels when @mentioned (default is DM-only) +openclaw config set channels.discord.groupPolicy "open" +# Read last 100 messages for context when @mentioned in a channel +openclaw config set messages.groupChat.historyLimit 100 +``` + +### Plugin Config (if `ao` isn't in PATH or repo isn't in default location) + +```bash +# Set the path to the ao binary +openclaw config set plugins.entries.agent-orchestrator.config.aoPath "/path/to/ao" + +# Set the working directory (must contain agent-orchestrator.yaml) +openclaw config set plugins.entries.agent-orchestrator.config.aoCwd "/path/to/your/repo" + +# Set the path to gh binary (if not in PATH) +openclaw config set plugins.entries.agent-orchestrator.config.ghPath "/path/to/gh" +``` + +## 4. Set Up Identity Files + +Create these files in `~/.openclaw/workspace/` to give the bot its personality and instructions: + +### IDENTITY.md + +```markdown +# IDENTITY.md + +- **Name:** AO +- **Creature:** AI Engineering Manager +- **Vibe:** Sharp, concise, proactive +- **Emoji:** ⚡ + +## Default Setup + +- **GitHub account:** +- **Primary repo:** +- **AO project ID:** +- **Owner:** + +## How You Operate + +You are a MANAGER. You never write code yourself. You delegate ALL coding work to Agent Orchestrator. + +When asked about work → use `ao_issues` tool +When asked about status → use `ao_sessions` or `ao_status` tool +When asked to start work → use `ao_spawn` tool (always include project ID) +When asked to start multiple → use `ao_batch_spawn` tool +When talking to an agent → use `ao_send` tool +When stopping an agent → use `ao_kill` tool (confirm first) + +If an AO tool fails, report the error. Do NOT fall back to coding directly. +``` + +### SOUL.md + +```markdown +# SOUL.md + +You are AO — an AI engineering manager. You manage coding agents through Agent Orchestrator. + +You NEVER write code directly. You delegate ALL coding to AO agents via ao_spawn. +Even if spawning fails, you report the failure — you don't code directly. + +Always include full PR URLs when reporting: https://github.com///pull/ +``` + +## 5. Restart and Verify + +```bash +# Restart the gateway +pm2 restart openclaw-gateway +# Or however you run OpenClaw + +# Verify the plugin loaded +openclaw plugins list | grep agent-orchestrator + +# Verify tools are visible +openclaw agent --agent main -m "List your tools" +# Should show ao_sessions, ao_issues, ao_spawn, etc. + +# Verify AO works +/ao doctor +``` + +## Why These Settings Matter + +| Setting | Why | +|---------|-----| +| `tools.profile: "full"` | The `coding` profile only includes built-in tools. Plugin tools require `full`. | +| `tools.allow: ["group:plugins"]` | OpenClaw treats ALL plugin tools as optional. Without this, they're invisible to the AI. | +| `tools.deny: [exec, write, ...]` | Without this, the bot will write code directly instead of delegating to AO. | +| `skills.entries.coding-agent.enabled: false` | This built-in skill tells the bot to use Codex/Claude Code. It overrides AO. | +| `skills.entries.gh-issues.enabled: false` | This built-in skill spawns OpenClaw sub-agents. It bypasses AO. | +| `aoCwd` | `ao spawn` must run from the directory containing `agent-orchestrator.yaml`. | + +## Troubleshooting + +| Problem | Cause | Fix | +|---------|-------|-----| +| Bot says "no ao_* tools available" | `tools.profile` is not `full` or `tools.allow` missing `group:plugins` | Run `/ao setup` | +| Bot writes code directly | `coding-agent` skill is active or `exec`/`write` not denied | Run `/ao setup` | +| `ao spawn` returns "No config found" | `aoCwd` not set or wrong path | Set `plugins.entries.agent-orchestrator.config.aoCwd` | +| `ao: not found` | `ao` not in PATH | Create symlink or set `aoPath` in plugin config | +| Only 2-3 issues shown (not all) | Bot answering from stale session memory | Clear sessions: `rm ~/.openclaw/agents/main/sessions/sessions.json` | +| Bot only responds in DMs | `groupPolicy` is `allowlist` | Set `channels.discord.groupPolicy` to `open` | +| Bot responds to every message | `mentionPatterns` too broad | Remove patterns, rely on native @mentions | +| Sessions show "exited" immediately | Agent (Claude Code) won't run as root | Run AO as non-root user | + +## Architecture + +``` +Discord message → OpenClaw Gateway → AI Model (with AO tools) + ↓ + ao_spawn tool + ↓ + AO CLI (agent-orchestrator) + ↓ + Git worktree + Claude Code agent + ↓ + Branch → Commit → PR +``` + +The bot (OpenClaw) is the **manager**. AO is the **workforce**. The bot never codes — it uses AO tools to spawn agents that do the actual work. diff --git a/openclaw-plugin/index.ts b/openclaw-plugin/index.ts new file mode 100644 index 000000000..d71ae79b9 --- /dev/null +++ b/openclaw-plugin/index.ts @@ -0,0 +1,935 @@ +/** + * 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(); + + 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) => { + 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 " }; + 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 ..." }; + 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 " }; + 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 " }; + 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 — session details", + " /ao issues [owner/repo] — list open issues", + " /ao spawn — spawn agent on issue", + " /ao batch-spawn — spawn multiple agents", + " /ao retry — retry failed session", + " /ao kill — 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 | null = null; + let boardScanInterval: ReturnType | null = null; + let lastKnownIssueIds: Set = 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}`); + } + }; + + setTimeout(scan, 10_000); + boardScanInterval = setInterval(scan, scanMs); + }, + stop: async () => { + if (boardScanInterval) { + clearInterval(boardScanInterval); + boardScanInterval = null; + } + }, + }); +} diff --git a/openclaw-plugin/openclaw.plugin.json b/openclaw-plugin/openclaw.plugin.json new file mode 100644 index 000000000..2b0f46df9 --- /dev/null +++ b/openclaw-plugin/openclaw.plugin.json @@ -0,0 +1,54 @@ +{ + "id": "agent-orchestrator", + "name": "Agent Orchestrator", + "description": "Control and monitor Agent Orchestrator (AO) from OpenClaw. Slash commands, AI tools, issue board scanning, proactive planning, and health monitoring.", + "version": "0.3.0", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "aoPath": { + "type": "string", + "description": "Path to the ao CLI binary. Defaults to 'ao' (found via PATH)." + }, + "aoCwd": { + "type": "string", + "description": "Working directory for ao commands. Must contain agent-orchestrator.yaml. Defaults to the current working directory." + }, + "healthPollIntervalMs": { + "type": "number", + "description": "How often to poll AO health in milliseconds. Default: 30000 (30s). Set to 0 to disable." + }, + "boardScanIntervalMs": { + "type": "number", + "description": "How often to scan the issue board for changes in milliseconds. Default: 1800000 (30min). Set to 0 to disable." + }, + "ghPath": { + "type": "string", + "description": "Path to the gh CLI binary. Defaults to 'gh' (found via PATH)." + } + } + }, + "uiHints": { + "aoPath": { + "label": "AO CLI path", + "placeholder": "ao", + "help": "Leave empty to use 'ao' from PATH" + }, + "healthPollIntervalMs": { + "label": "Health poll interval (ms)", + "placeholder": "30000", + "help": "Set to 0 to disable health monitoring" + }, + "boardScanIntervalMs": { + "label": "Board scan interval (ms)", + "placeholder": "1800000", + "help": "How often to scan for new issues. Default 30min. Set to 0 to disable." + }, + "ghPath": { + "label": "GitHub CLI path", + "placeholder": "gh", + "help": "Leave empty to use 'gh' from PATH" + } + } +} diff --git a/openclaw-plugin/package.json b/openclaw-plugin/package.json new file mode 100644 index 000000000..02d5c2efc --- /dev/null +++ b/openclaw-plugin/package.json @@ -0,0 +1,27 @@ +{ + "name": "@composio/openclaw-agent-orchestrator", + "version": "0.3.0", + "description": "OpenClaw plugin for Agent Orchestrator — AI tools, slash commands, hooks, and health monitoring for managing parallel coding agents from OpenClaw", + "license": "MIT", + "type": "module", + "main": "index.ts", + "openclaw": { + "extensions": ["./index.ts"] + }, + "files": [ + "index.ts", + "openclaw.plugin.json", + "package.json" + ], + "repository": { + "type": "git", + "url": "https://github.com/ComposioHQ/agent-orchestrator.git", + "directory": "openclaw-plugin" + }, + "homepage": "https://github.com/ComposioHQ/agent-orchestrator/tree/main/openclaw-plugin", + "bugs": "https://github.com/ComposioHQ/agent-orchestrator/issues", + "keywords": ["openclaw", "openclaw-plugin", "plugin", "agent-orchestrator", "composio", "ai-agents", "coding-agents"], + "publishConfig": { + "access": "public" + } +} diff --git a/packages/cli/__tests__/commands/doctor.test.ts b/packages/cli/__tests__/commands/doctor.test.ts index 823c56cf2..d965a7684 100644 --- a/packages/cli/__tests__/commands/doctor.test.ts +++ b/packages/cli/__tests__/commands/doctor.test.ts @@ -1,12 +1,23 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { Command } from "commander"; -const { mockExecuteScriptCommand } = vi.hoisted(() => ({ - mockExecuteScriptCommand: vi.fn(), +const { mockRunRepoScript, mockFindConfigFile } = vi.hoisted(() => ({ + mockRunRepoScript: vi.fn(), + mockFindConfigFile: vi.fn(), })); vi.mock("../../src/lib/script-runner.js", () => ({ - executeScriptCommand: (...args: unknown[]) => mockExecuteScriptCommand(...args), + runRepoScript: (...args: unknown[]) => mockRunRepoScript(...args), +})); + +vi.mock("@composio/ao-core", () => ({ + findConfigFile: (...args: unknown[]) => mockFindConfigFile(...args), + loadConfig: vi.fn(), +})); + +vi.mock("../../src/lib/openclaw-probe.js", () => ({ + probeGateway: vi.fn(), + validateToken: vi.fn(), })); import { registerDoctor } from "../../src/commands/doctor.js"; @@ -18,8 +29,10 @@ describe("doctor command", () => { program = new Command(); program.exitOverride(); registerDoctor(program); - mockExecuteScriptCommand.mockReset(); - mockExecuteScriptCommand.mockResolvedValue(undefined); + mockRunRepoScript.mockReset(); + mockRunRepoScript.mockResolvedValue(0); + mockFindConfigFile.mockReset(); + mockFindConfigFile.mockReturnValue(null); }); afterEach(() => { @@ -29,12 +42,12 @@ describe("doctor command", () => { it("runs the doctor script with no extra args by default", async () => { await program.parseAsync(["node", "test", "doctor"]); - expect(mockExecuteScriptCommand).toHaveBeenCalledWith("ao-doctor.sh", []); + expect(mockRunRepoScript).toHaveBeenCalledWith("ao-doctor.sh", []); }); it("passes through --fix", async () => { await program.parseAsync(["node", "test", "doctor", "--fix"]); - expect(mockExecuteScriptCommand).toHaveBeenCalledWith("ao-doctor.sh", ["--fix"]); + expect(mockRunRepoScript).toHaveBeenCalledWith("ao-doctor.sh", ["--fix"]); }); }); diff --git a/packages/cli/__tests__/commands/setup.test.ts b/packages/cli/__tests__/commands/setup.test.ts new file mode 100644 index 000000000..c8812fcf2 --- /dev/null +++ b/packages/cli/__tests__/commands/setup.test.ts @@ -0,0 +1,343 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Command } from "commander"; + +// --------------------------------------------------------------------------- +// Hoisted mocks — must be defined before any imports that use them +// --------------------------------------------------------------------------- + +const { mockFindConfigFile } = vi.hoisted(() => ({ + mockFindConfigFile: vi.fn(), +})); + +const { mockReadFileSync, mockWriteFileSync } = vi.hoisted(() => ({ + mockReadFileSync: vi.fn(), + mockWriteFileSync: vi.fn(), +})); + +const { mockProbeGateway, mockValidateToken } = vi.hoisted(() => ({ + mockProbeGateway: vi.fn(), + mockValidateToken: vi.fn(), +})); + +vi.mock("@composio/ao-core", () => ({ + findConfigFile: (...args: unknown[]) => mockFindConfigFile(...args), +})); + +vi.mock("node:fs", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + readFileSync: (...args: unknown[]) => mockReadFileSync(...args), + writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args), + existsSync: () => true, + }; +}); + +vi.mock("../../src/lib/openclaw-probe.js", () => ({ + probeGateway: (...args: unknown[]) => mockProbeGateway(...args), + validateToken: (...args: unknown[]) => mockValidateToken(...args), + DEFAULT_OPENCLAW_URL: "http://127.0.0.1:18789", + HOOKS_PATH: "/hooks/agent", +})); + +import { registerSetup } from "../../src/commands/setup.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const MINIMAL_CONFIG = ` +port: 3000 +defaults: + notifiers: + - desktop +projects: + my-app: + name: my-app + repo: owner/repo + path: ~/code/my-app +`; + +const CONFIG_WITH_OPENCLAW = ` +port: 3000 +defaults: + notifiers: + - desktop + - openclaw +notifiers: + openclaw: + plugin: openclaw + url: http://127.0.0.1:18789/hooks/agent + token: "\${OPENCLAW_HOOKS_TOKEN}" +projects: + my-app: + name: my-app +`; + +function createProgram(): Command { + const program = new Command(); + program.exitOverride(); // throw instead of process.exit + registerSetup(program); + return program; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("setup openclaw command", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.restoreAllMocks(); + mockFindConfigFile.mockReturnValue("/tmp/agent-orchestrator.yaml"); + mockReadFileSync.mockReturnValue(MINIMAL_CONFIG); + mockWriteFileSync.mockImplementation(() => {}); + mockValidateToken.mockResolvedValue({ valid: true }); + mockProbeGateway.mockResolvedValue({ reachable: false }); + + // Force non-interactive (no TTY in test environment) + Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true }); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + describe("non-interactive mode", () => { + it("writes config when --url and --token provided", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", "test", "setup", "openclaw", + "--url", "http://127.0.0.1:18789/hooks/agent", + "--token", "test-token", + "--non-interactive", + ]); + + expect(mockWriteFileSync).toHaveBeenCalledOnce(); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + expect(writtenYaml).toContain("openclaw"); + expect(writtenYaml).toContain("plugin: openclaw"); + expect(writtenYaml).toContain("http://127.0.0.1:18789/hooks/agent"); + }); + + it("reads token from OPENCLAW_HOOKS_TOKEN env var", async () => { + process.env["OPENCLAW_HOOKS_TOKEN"] = "env-token"; + const program = createProgram(); + + await program.parseAsync([ + "node", "test", "setup", "openclaw", + "--url", "http://127.0.0.1:18789/hooks/agent", + "--non-interactive", + ]); + + expect(mockValidateToken).toHaveBeenCalledWith( + "http://127.0.0.1:18789/hooks/agent", + "env-token", + ); + expect(mockWriteFileSync).toHaveBeenCalledOnce(); + }); + + it("reads URL from OPENCLAW_GATEWAY_URL env var", async () => { + process.env["OPENCLAW_GATEWAY_URL"] = "http://remote:18789"; + const program = createProgram(); + + await program.parseAsync([ + "node", "test", "setup", "openclaw", + "--token", "tok", + "--non-interactive", + ]); + + expect(mockValidateToken).toHaveBeenCalledWith( + "http://remote:18789/hooks/agent", + "tok", + ); + }); + + it("validates token against gateway before saving", async () => { + mockValidateToken.mockResolvedValue({ valid: true }); + const program = createProgram(); + + await program.parseAsync([ + "node", "test", "setup", "openclaw", + "--url", "http://127.0.0.1:18789/hooks/agent", + "--token", "good-token", + "--non-interactive", + ]); + + expect(mockValidateToken).toHaveBeenCalledWith( + "http://127.0.0.1:18789/hooks/agent", + "good-token", + ); + expect(mockWriteFileSync).toHaveBeenCalledOnce(); + }); + }); + + describe("config writing", () => { + it("adds openclaw to defaults.notifiers", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", "test", "setup", "openclaw", + "--url", "http://127.0.0.1:18789/hooks/agent", + "--token", "tok", + "--non-interactive", + ]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + expect(writtenYaml).toContain("openclaw"); + // Should have both desktop and openclaw in defaults.notifiers + expect(writtenYaml).toContain("desktop"); + }); + + it("does not duplicate openclaw in defaults.notifiers", async () => { + mockReadFileSync.mockReturnValue(CONFIG_WITH_OPENCLAW); + const program = createProgram(); + + await program.parseAsync([ + "node", "test", "setup", "openclaw", + "--url", "http://127.0.0.1:18789/hooks/agent", + "--token", "tok", + "--non-interactive", + ]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const matches = writtenYaml.match(/openclaw/g); + // "openclaw" appears in: defaults.notifiers list, notifiers.openclaw key, + // plugin: openclaw, and the token ref. Should not have extra duplicates. + // Count just in defaults section + const defaultsSection = writtenYaml.split("notifiers:")[0] + writtenYaml.split("notifiers:")[1]; + expect(defaultsSection).toBeDefined(); + }); + + it("writes correct notifier block structure", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", "test", "setup", "openclaw", + "--url", "http://custom:9999/hooks/agent", + "--token", "tok", + "--non-interactive", + ]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + expect(writtenYaml).toContain("plugin: openclaw"); + expect(writtenYaml).toContain("http://custom:9999/hooks/agent"); + expect(writtenYaml).toContain("${OPENCLAW_HOOKS_TOKEN}"); + expect(writtenYaml).toContain("retries: 3"); + expect(writtenYaml).toContain("retryDelayMs: 1000"); + expect(writtenYaml).toContain("wakeMode: now"); + }); + + it("preserves existing projects in config", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", "test", "setup", "openclaw", + "--url", "http://127.0.0.1:18789/hooks/agent", + "--token", "tok", + "--non-interactive", + ]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + expect(writtenYaml).toContain("my-app"); + expect(writtenYaml).toContain("owner/repo"); + }); + + it("writes to the correct config path", async () => { + mockFindConfigFile.mockReturnValue("/custom/path/agent-orchestrator.yaml"); + const program = createProgram(); + + await program.parseAsync([ + "node", "test", "setup", "openclaw", + "--url", "http://127.0.0.1:18789/hooks/agent", + "--token", "tok", + "--non-interactive", + ]); + + expect(mockWriteFileSync.mock.calls[0][0]).toBe("/custom/path/agent-orchestrator.yaml"); + }); + }); + + describe("error handling", () => { + it("exits when no config file found", async () => { + mockFindConfigFile.mockReturnValue(null); + const program = createProgram(); + + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync([ + "node", "test", "setup", "openclaw", + "--url", "http://127.0.0.1:18789/hooks/agent", + "--token", "tok", + "--non-interactive", + ]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("exits when validation fails in non-interactive mode", async () => { + mockValidateToken.mockResolvedValue({ valid: false, error: "Token rejected" }); + const program = createProgram(); + + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync([ + "node", "test", "setup", "openclaw", + "--url", "http://127.0.0.1:18789/hooks/agent", + "--token", "bad-token", + "--non-interactive", + ]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("exits when --url missing in non-interactive mode", async () => { + const program = createProgram(); + + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync([ + "node", "test", "setup", "openclaw", + "--token", "tok", + "--non-interactive", + ]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("exits when --token missing in non-interactive mode", async () => { + delete process.env["OPENCLAW_HOOKS_TOKEN"]; + const program = createProgram(); + + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync([ + "node", "test", "setup", "openclaw", + "--url", "http://127.0.0.1:18789/hooks/agent", + "--non-interactive", + ]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); + }); +}); diff --git a/packages/cli/__tests__/lib/openclaw-probe.test.ts b/packages/cli/__tests__/lib/openclaw-probe.test.ts new file mode 100644 index 000000000..5395ea531 --- /dev/null +++ b/packages/cli/__tests__/lib/openclaw-probe.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { probeGateway, validateToken } from "../../src/lib/openclaw-probe.js"; + +describe("openclaw-probe", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe("probeGateway", () => { + it("returns reachable:true on 200", async () => { + const fetchMock = vi.fn().mockResolvedValue({ status: 200, ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const result = await probeGateway("http://127.0.0.1:18789"); + + expect(result.reachable).toBe(true); + expect(result.httpStatus).toBe(200); + expect(result.error).toBeUndefined(); + }); + + it("returns reachable:true even on non-200 (gateway is up)", async () => { + const fetchMock = vi.fn().mockResolvedValue({ status: 404, ok: false }); + vi.stubGlobal("fetch", fetchMock); + + const result = await probeGateway("http://127.0.0.1:18789"); + + expect(result.reachable).toBe(true); + expect(result.httpStatus).toBe(404); + }); + + it("returns reachable:false on ECONNREFUSED", async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error("fetch failed: ECONNREFUSED")); + vi.stubGlobal("fetch", fetchMock); + + const result = await probeGateway("http://127.0.0.1:18789"); + + expect(result.reachable).toBe(false); + expect(result.error).toContain("not reachable"); + }); + + it("returns reachable:false on timeout", async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error("The operation was aborted")); + vi.stubGlobal("fetch", fetchMock); + + const result = await probeGateway("http://127.0.0.1:18789", 100); + + expect(result.reachable).toBe(false); + expect(result.error).toContain("timed out"); + }); + + it("strips trailing slashes from URL", async () => { + const fetchMock = vi.fn().mockResolvedValue({ status: 200, ok: true }); + vi.stubGlobal("fetch", fetchMock); + + await probeGateway("http://127.0.0.1:18789///"); + + expect(fetchMock.mock.calls[0][0]).toBe("http://127.0.0.1:18789"); + }); + + it("uses default URL when none provided", async () => { + const fetchMock = vi.fn().mockResolvedValue({ status: 200, ok: true }); + vi.stubGlobal("fetch", fetchMock); + + await probeGateway(); + + expect(fetchMock.mock.calls[0][0]).toBe("http://127.0.0.1:18789"); + }); + }); + + describe("validateToken", () => { + it("returns valid:true on 200", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + const result = await validateToken("http://127.0.0.1:18789", "good-token"); + + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it("sends Bearer token in Authorization header", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + await validateToken("http://127.0.0.1:18789", "my-secret"); + + const headers = fetchMock.mock.calls[0][1].headers; + expect(headers["Authorization"]).toBe("Bearer my-secret"); + }); + + it("appends /hooks/agent if not in URL", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + await validateToken("http://127.0.0.1:18789", "tok"); + + expect(fetchMock.mock.calls[0][0]).toBe("http://127.0.0.1:18789/hooks/agent"); + }); + + it("does not double-append /hooks/agent", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + await validateToken("http://127.0.0.1:18789/hooks/agent", "tok"); + + expect(fetchMock.mock.calls[0][0]).toBe("http://127.0.0.1:18789/hooks/agent"); + }); + + it("returns valid:false with message on 401", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 401 }); + vi.stubGlobal("fetch", fetchMock); + + const result = await validateToken("http://127.0.0.1:18789", "bad-token"); + + expect(result.valid).toBe(false); + expect(result.error).toContain("Token rejected"); + }); + + it("returns valid:false with message on 403", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 403 }); + vi.stubGlobal("fetch", fetchMock); + + const result = await validateToken("http://127.0.0.1:18789", "bad-token"); + + expect(result.valid).toBe(false); + expect(result.error).toContain("Token rejected"); + }); + + it("returns valid:false with body on unexpected status", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + text: () => Promise.resolve("internal error"), + }); + vi.stubGlobal("fetch", fetchMock); + + const result = await validateToken("http://127.0.0.1:18789", "tok"); + + expect(result.valid).toBe(false); + expect(result.error).toContain("500"); + expect(result.error).toContain("internal error"); + }); + + it("returns valid:false on ECONNREFUSED", async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")); + vi.stubGlobal("fetch", fetchMock); + + const result = await validateToken("http://127.0.0.1:18789", "tok"); + + expect(result.valid).toBe(false); + expect(result.error).toContain("Can't reach"); + }); + + it("returns valid:false on timeout", async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error("The operation was aborted")); + vi.stubGlobal("fetch", fetchMock); + + const result = await validateToken("http://127.0.0.1:18789", "tok", 100); + + expect(result.valid).toBe(false); + expect(result.error).toContain("timed out"); + }); + + it("sends a test payload with correct structure", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + await validateToken("http://127.0.0.1:18789", "tok"); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.message).toContain("Connection test"); + expect(body.name).toBe("AO"); + expect(body.sessionKey).toBe("hook:ao:setup-test"); + expect(body.wakeMode).toBe("now"); + expect(body.deliver).toBe(true); + }); + }); +}); diff --git a/packages/cli/package.json b/packages/cli/package.json index 42b662921..9f324f7d8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -40,6 +40,7 @@ "@composio/ao-plugin-agent-opencode": "workspace:*", "@composio/ao-plugin-notifier-composio": "workspace:*", "@composio/ao-plugin-notifier-desktop": "workspace:*", + "@composio/ao-plugin-notifier-discord": "workspace:*", "@composio/ao-plugin-notifier-openclaw": "workspace:*", "@composio/ao-plugin-notifier-slack": "workspace:*", "@composio/ao-plugin-notifier-webhook": "workspace:*", @@ -53,6 +54,7 @@ "@composio/ao-plugin-workspace-clone": "workspace:*", "@composio/ao-plugin-workspace-worktree": "workspace:*", "@composio/ao-web": "workspace:*", + "@clack/prompts": "^0.9.1", "chalk": "^5.4.0", "commander": "^13.0.0", "ora": "^8.1.0", diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index cb4cf1611..6906a75b7 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -1,17 +1,197 @@ import type { Command } from "commander"; -import { executeScriptCommand } from "../lib/script-runner.js"; +import chalk from "chalk"; +import { findConfigFile, loadConfig } from "@composio/ao-core"; +import type { Notifier, OrchestratorConfig } from "@composio/ao-core"; +import { runRepoScript } from "../lib/script-runner.js"; +import { probeGateway, validateToken } from "../lib/openclaw-probe.js"; + +// --------------------------------------------------------------------------- +// Helpers — match the PASS / WARN / FAIL style of ao-doctor.sh +// --------------------------------------------------------------------------- + +let tsFailures = 0; + +function pass(msg: string): void { + console.log(`${chalk.green("PASS")} ${msg}`); +} + +function warn(msg: string): void { + console.log(`${chalk.yellow("WARN")} ${msg}`); +} + +function fail(msg: string): void { + tsFailures++; + console.log(`${chalk.red("FAIL")} ${msg}`); +} + +// --------------------------------------------------------------------------- +// Notifier connectivity checks (Gap 2) +// --------------------------------------------------------------------------- + +async function checkOpenClawNotifier(config: OrchestratorConfig): Promise { + const openclawConfig = config.notifiers?.["openclaw"]; + if (!openclawConfig || openclawConfig.plugin !== "openclaw") { + warn("OpenClaw notifier is not configured. Fix: run ao setup openclaw"); + return; + } + + const url = + (typeof openclawConfig["url"] === "string" ? openclawConfig["url"] : undefined) ?? + "http://127.0.0.1:18789"; + const token = + (typeof openclawConfig["token"] === "string" ? openclawConfig["token"] : undefined) ?? + process.env["OPENCLAW_HOOKS_TOKEN"]; + + // Step 1: Probe gateway reachability + const probe = await probeGateway(url); + if (!probe.reachable) { + fail( + `OpenClaw gateway is not reachable at ${url}. ` + + `Fix: ensure OpenClaw is running (openclaw status) or fix the URL in your config`, + ); + return; + } + + pass(`OpenClaw gateway is reachable at ${url} (HTTP ${probe.httpStatus})`); + + // Step 2: Validate auth token if present + if (!token) { + warn( + "OpenClaw token is not set. Fix: set OPENCLAW_HOOKS_TOKEN env var or add token to notifiers.openclaw in config", + ); + return; + } + + const tokenResult = await validateToken(url, token); + if (!tokenResult.valid) { + fail(`OpenClaw token validation failed: ${tokenResult.error}`); + return; + } + + pass("OpenClaw token is valid"); +} + +async function checkNotifierConnectivity(config: OrchestratorConfig): Promise { + console.log(""); // blank line before notifier section + console.log("Notifier connectivity:"); + + const configuredNotifiers = Object.keys(config.notifiers ?? {}); + if (configuredNotifiers.length === 0) { + warn("No notifiers are configured. Fix: add notifiers to your agent-orchestrator.yaml"); + return; + } + + // Check OpenClaw specifically (it's the only one we can probe without side effects) + if (config.notifiers?.["openclaw"]) { + await checkOpenClawNotifier(config); + } + + // Report other configured notifiers as present (we can't health-check Slack/desktop/webhook without sending) + for (const [name, notifierConfig] of Object.entries(config.notifiers ?? {})) { + if (name === "openclaw") continue; // already checked above + const plugin = notifierConfig.plugin; + pass(`${name} notifier is configured (plugin: ${plugin})`); + } +} + +// --------------------------------------------------------------------------- +// Test-notify (Gap 3) +// --------------------------------------------------------------------------- + +async function sendTestNotifications(config: OrchestratorConfig): Promise { + const { createPluginRegistry } = await import("@composio/ao-core"); + const registry = createPluginRegistry(); + await registry.loadFromConfig(config); + + const activeNotifierNames = config.defaults?.notifiers ?? []; + const configuredNotifiers = Object.keys(config.notifiers ?? {}); + + // Combine both lists (defaults + configured) and deduplicate + const allNames = [...new Set([...activeNotifierNames, ...configuredNotifiers])]; + + if (allNames.length === 0) { + warn("No notifiers to test. Fix: configure notifiers in your agent-orchestrator.yaml"); + return; + } + + console.log(`\nSending test notification to ${allNames.length} notifier(s)...\n`); + + for (const name of allNames) { + const notifier = registry.get("notifier", name); + if (!notifier) { + warn(`${name}: plugin not loaded (may not be installed)`); + continue; + } + + try { + const testEvent = { + id: `doctor-test-${Date.now()}`, + type: "summary.all_complete" as const, + priority: "info" as const, + sessionId: "doctor-test", + projectId: "doctor", + timestamp: new Date(), + message: "Test notification from ao doctor --test-notify", + data: { source: "ao-doctor" }, + }; + + await notifier.notify(testEvent); + pass(`${name}: test notification sent`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + fail(`${name}: ${message}`); + } + } +} + +// --------------------------------------------------------------------------- +// Command registration +// --------------------------------------------------------------------------- export function registerDoctor(program: Command): void { program .command("doctor") .description("Run install, environment, and runtime health checks") .option("--fix", "Apply safe fixes for launcher and stale temp issues") - .action(async (opts: { fix?: boolean }) => { - const args: string[] = []; + .option("--test-notify", "Send a test notification through each configured notifier") + .action(async (opts: { fix?: boolean; testNotify?: boolean }) => { + tsFailures = 0; // reset for each run + // 1. Run the existing shell-based checks + const scriptArgs: string[] = []; if (opts.fix) { - args.push("--fix"); + scriptArgs.push("--fix"); } - await executeScriptCommand("ao-doctor.sh", args); + let shellExitCode = 0; + try { + shellExitCode = await runRepoScript("ao-doctor.sh", scriptArgs); + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + shellExitCode = 1; + } + + // 2. Run TypeScript-based notifier checks if a config file exists + const configPath = findConfigFile(); + if (configPath) { + try { + const config = loadConfig(configPath); + await checkNotifierConnectivity(config); + + // 3. Send test notifications if requested + if (opts.testNotify) { + await sendTestNotifications(config); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + warn(`Could not load config for notifier checks: ${message}`); + } + } else if (opts.testNotify) { + fail("No config file found. Cannot test notifiers without agent-orchestrator.yaml"); + } + + // Exit non-zero if shell checks or notifier checks failed + if (shellExitCode !== 0 || tsFailures > 0) { + process.exit(shellExitCode || 1); + } }); } diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts new file mode 100644 index 000000000..813ebb5b1 --- /dev/null +++ b/packages/cli/src/commands/setup.ts @@ -0,0 +1,515 @@ +/** + * `ao setup openclaw` — interactive wizard + non-interactive mode + * for wiring AO notifications to an OpenClaw gateway. + * + * Interactive: ao setup openclaw (human in terminal) + * Programmatic: ao setup openclaw --url X --token Y --non-interactive + * (OpenClaw agent calling via exec tool) + */ + +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { randomBytes } from "node:crypto"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import chalk from "chalk"; +import type { Command } from "commander"; +import { parse as yamlParse, stringify as yamlStringify } from "yaml"; +import { findConfigFile } from "@composio/ao-core"; +import { + probeGateway, + validateToken, + DEFAULT_OPENCLAW_URL, + HOOKS_PATH, +} from "../lib/openclaw-probe.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export class SetupAbortedError extends Error { + constructor(message: string, public readonly exitCode: number = 1) { + super(message); + this.name = "SetupAbortedError"; + } +} + +interface SetupOptions { + url?: string; + token?: string; + nonInteractive?: boolean; +} + +interface ResolvedConfig { + url: string; + token: string; +} + +// --------------------------------------------------------------------------- +// Interactive prompts (dynamic import to keep @clack/prompts optional) +// --------------------------------------------------------------------------- + +async function interactiveSetup(existingUrl?: string): Promise { + const clack = await import("@clack/prompts"); + + clack.intro(chalk.bgCyan(chalk.black(" ao setup openclaw "))); + + // --- Step 1: Gateway URL --------------------------------------------------- + const defaultUrl = `${DEFAULT_OPENCLAW_URL}${HOOKS_PATH}`; + let detectedUrl: string | undefined; + + const spin = clack.spinner(); + spin.start("Detecting OpenClaw gateway on localhost..."); + + const probe = await probeGateway(DEFAULT_OPENCLAW_URL); + if (probe.reachable) { + detectedUrl = defaultUrl; + spin.stop(`Found OpenClaw gateway at ${DEFAULT_OPENCLAW_URL}`); + } else { + spin.stop("No OpenClaw gateway detected on localhost"); + } + + const urlInput = await clack.text({ + message: "OpenClaw webhook URL:", + placeholder: defaultUrl, + initialValue: existingUrl ?? detectedUrl ?? defaultUrl, + validate: (v) => { + if (!v) return "URL is required"; + if (!v.startsWith("http://") && !v.startsWith("https://")) + return "Must start with http:// or https://"; + }, + }); + + if (clack.isCancel(urlInput)) { + clack.cancel("Setup cancelled."); + throw new SetupAbortedError("Setup cancelled.", 0); + } + + // Normalize: ensure URL ends with /hooks/agent + let url = urlInput as string; + if (!url.endsWith(HOOKS_PATH)) { + url = url.replace(/\/+$/, "") + HOOKS_PATH; + } + + // --- Step 2: Token --------------------------------------------------------- + const envToken = process.env["OPENCLAW_HOOKS_TOKEN"]; + let tokenValue: string; + + if (envToken) { + const useEnv = await clack.confirm({ + message: `Found OPENCLAW_HOOKS_TOKEN in environment. Use it?`, + initialValue: true, + }); + + if (clack.isCancel(useEnv)) { + clack.cancel("Setup cancelled."); + throw new SetupAbortedError("Setup cancelled.", 0); + } + + if (useEnv) { + tokenValue = envToken; + } else { + const input = await clack.password({ + message: "Enter your OpenClaw hooks token:", + validate: (v) => (!v ? "Token is required" : undefined), + }); + if (clack.isCancel(input)) { + clack.cancel("Setup cancelled."); + throw new SetupAbortedError("Setup cancelled.", 0); + } + tokenValue = input as string; + } + } else { + const generatedToken = randomBytes(32).toString("base64url"); + const tokenChoice = await clack.select({ + message: "How would you like to set the hooks token?", + options: [ + { value: "generate", label: "Auto-generate a secure token (recommended)" }, + { value: "manual", label: "Enter an existing token manually" }, + ], + }); + + if (clack.isCancel(tokenChoice)) { + clack.cancel("Setup cancelled."); + throw new SetupAbortedError("Setup cancelled.", 0); + } + + if (tokenChoice === "manual") { + const input = await clack.password({ + message: "Enter your OpenClaw hooks token:", + validate: (v) => (!v ? "Token is required" : undefined), + }); + if (clack.isCancel(input)) { + clack.cancel("Setup cancelled."); + throw new SetupAbortedError("Setup cancelled.", 0); + } + tokenValue = input as string; + } else { + tokenValue = generatedToken; + clack.log.success(`Generated token: ${chalk.dim(tokenValue.slice(0, 8))}...`); + } + } + + // --- Step 3: Validate ------------------------------------------------------ + spin.start("Validating token against gateway..."); + + const validation = await validateToken(url, tokenValue); + if (validation.valid) { + spin.stop("Token validated — connection works!"); + } else { + spin.stop(`Validation failed: ${validation.error}`); + + const cont = await clack.confirm({ + message: "Save config anyway? (you can fix the token later)", + initialValue: false, + }); + + if (clack.isCancel(cont) || !cont) { + clack.cancel("Setup cancelled. Fix the issue and retry."); + throw new SetupAbortedError("Setup cancelled. Fix the issue and retry."); + } + } + + return { url, token: tokenValue }; +} + +// --------------------------------------------------------------------------- +// Non-interactive path +// --------------------------------------------------------------------------- + +async function nonInteractiveSetup(opts: SetupOptions): Promise { + let url = + opts.url ?? + (process.env["OPENCLAW_GATEWAY_URL"] + ? `${process.env["OPENCLAW_GATEWAY_URL"]}${HOOKS_PATH}` + : undefined); + const token = opts.token ?? process.env["OPENCLAW_HOOKS_TOKEN"]; + + if (!url) { + throw new SetupAbortedError( + "Error: --url is required in non-interactive mode.\n" + + " Example: ao setup openclaw --url http://127.0.0.1:18789/hooks/agent --token YOUR_TOKEN --non-interactive", + ); + } + + if (!url.startsWith("http://") && !url.startsWith("https://")) { + throw new SetupAbortedError( + "Error: --url must start with http:// or https://", + ); + } + + // Normalize: ensure URL ends with /hooks/agent + if (!url.endsWith(HOOKS_PATH)) { + url = url.replace(/\/+$/, "") + HOOKS_PATH; + } + + const resolvedToken = token ?? randomBytes(32).toString("base64url"); + if (!token) { + console.log(chalk.dim("No token provided — auto-generated a secure token.")); + } + + // Skip pre-write validation — on fresh installs the gateway won't have the + // token yet. We write both configs first, then the user restarts the gateway. + console.log(chalk.dim("Skipping pre-validation (token will be written to both configs).")); + + return { url, token: resolvedToken }; +} + +// --------------------------------------------------------------------------- +// Config writer +// --------------------------------------------------------------------------- + +function writeOpenClawConfig( + configPath: string, + resolved: ResolvedConfig, + nonInteractive: boolean, +): void { + const rawYaml = readFileSync(configPath, "utf-8"); + const rawConfig = yamlParse(rawYaml) ?? {}; + + // Add notifiers.openclaw block — write actual token, not a placeholder + // (AO does not expand env placeholders in YAML at runtime) + if (!rawConfig.notifiers) rawConfig.notifiers = {}; + rawConfig.notifiers.openclaw = { + plugin: "openclaw", + url: resolved.url, + token: resolved.token, + retries: 3, + retryDelayMs: 1000, + wakeMode: "now", + }; + + // Add "openclaw" to defaults.notifiers if not already present + if (!rawConfig.defaults) rawConfig.defaults = {}; + if (!rawConfig.defaults.notifiers) rawConfig.defaults.notifiers = ["desktop"]; + if (!Array.isArray(rawConfig.defaults.notifiers)) { + rawConfig.defaults.notifiers = [rawConfig.defaults.notifiers]; + } + if (!rawConfig.defaults.notifiers.includes("openclaw")) { + rawConfig.defaults.notifiers.push("openclaw"); + } + + // Add "openclaw" to notificationRouting so notifications actually fire + // (AO prefers per-priority routing over defaults.notifiers) + if (!rawConfig.notificationRouting) { + // Initialize with default routing that includes openclaw + rawConfig.notificationRouting = { + urgent: ["desktop", "openclaw"], + action: ["desktop", "openclaw"], + warning: ["openclaw"], + info: ["openclaw"], + }; + } else if (typeof rawConfig.notificationRouting === "object") { + for (const priority of Object.keys(rawConfig.notificationRouting)) { + const list = rawConfig.notificationRouting[priority]; + if (Array.isArray(list) && !list.includes("openclaw")) { + list.push("openclaw"); + } + } + } + + writeFileSync(configPath, yamlStringify(rawConfig, { indent: 2 })); + + if (nonInteractive) { + console.log(chalk.green(`✓ Config written to ${configPath}`)); + } +} + +/** + * Write the hooks block into ~/.openclaw/openclaw.json. + * Returns true on success, false on failure (caller should fall back to + * printing manual instructions). + */ +function writeOpenClawJsonConfig(token: string): boolean { + try { + const openclawDir = join(homedir(), ".openclaw"); + const openclawJsonPath = join(openclawDir, "openclaw.json"); + + let config: Record = {}; + if (existsSync(openclawJsonPath)) { + const raw = readFileSync(openclawJsonPath, "utf-8"); + config = JSON.parse(raw) as Record; + } else if (!existsSync(openclawDir)) { + mkdirSync(openclawDir, { recursive: true }); + } + + // Merge the hooks block (preserve other existing keys in hooks if any) + const existingHooks = + (config.hooks as Record | undefined) ?? {}; + config.hooks = { + ...existingHooks, + enabled: true, + token, + allowRequestSessionKey: true, + allowedSessionKeyPrefixes: ["hook:"], + }; + + writeFileSync(openclawJsonPath, JSON.stringify(config, null, 2) + "\n"); + return true; + } catch { + return false; + } +} + +/** + * Append `export OPENCLAW_HOOKS_TOKEN=...` to the user's shell profile + * (~/.zshrc or ~/.bashrc). Skips if the export line already exists. + * Returns the profile path on success, undefined on failure. + */ +function writeShellExport(token: string): string | undefined { + try { + const shell = process.env["SHELL"] ?? ""; + const profileName = shell.endsWith("/zsh") ? ".zshrc" : ".bashrc"; + const profilePath = join(homedir(), profileName); + + const exportLine = `export OPENCLAW_HOOKS_TOKEN="${token}"`; + + // Check if it already exists + if (existsSync(profilePath)) { + const content = readFileSync(profilePath, "utf-8"); + if (content.includes("OPENCLAW_HOOKS_TOKEN=")) { + // Replace the existing line + const updated = content.replace( + /^export OPENCLAW_HOOKS_TOKEN=.*$/m, + exportLine, + ); + writeFileSync(profilePath, updated); + return profilePath; + } + } + + // Append + const prefix = existsSync(profilePath) ? "\n" : ""; + writeFileSync( + profilePath, + `${prefix}# Added by ao setup openclaw\n${exportLine}\n`, + { flag: "a" }, + ); + return profilePath; + } catch { + return undefined; + } +} + +function printOpenClawInstructions( + nonInteractive: boolean, + openclawConfigWritten: boolean, + shellProfilePath: string | undefined, +): void { + if (openclawConfigWritten) { + // Both configs written automatically + if (nonInteractive) { + console.log(chalk.green("✓ Both configs written (agent-orchestrator.yaml + ~/.openclaw/openclaw.json)")); + if (shellProfilePath) { + console.log(chalk.green(`✓ OPENCLAW_HOOKS_TOKEN exported in ${shellProfilePath}`)); + } + console.log("Restart OpenClaw gateway to apply."); + } else { + console.log(`\n${chalk.green.bold("Done — both configs written.")}`); + console.log(chalk.dim(" agent-orchestrator.yaml — notifiers.openclaw block")); + console.log(chalk.dim(" ~/.openclaw/openclaw.json — hooks block")); + if (shellProfilePath) { + console.log(chalk.dim(` ${shellProfilePath} — OPENCLAW_HOOKS_TOKEN export`)); + } + console.log(`\n${chalk.yellow("Restart OpenClaw gateway to apply.")}`); + } + } else { + // Fallback: could not write OpenClaw config, print manual instructions + const instructions = ` +${chalk.bold("OpenClaw-side config required")} + +Add this to your OpenClaw config (${chalk.dim("~/.openclaw/openclaw.json")}): + + ${chalk.cyan(`{ + "hooks": { + "enabled": true, + "token": "", + "allowRequestSessionKey": true, + "allowedSessionKeyPrefixes": ["hook:"] + } + }`)} + +Then set the token as an environment variable for AO: + + ${chalk.cyan('export OPENCLAW_HOOKS_TOKEN=""')} + +Add it to your shell profile (~/.zshrc or ~/.bashrc) to persist.`; + + if (nonInteractive) { + console.log("\nOpenClaw-side config required:"); + console.log('Add to ~/.openclaw/openclaw.json:'); + console.log(' hooks.enabled: true'); + console.log(' hooks.token: ""'); + console.log(' hooks.allowRequestSessionKey: true'); + console.log(' hooks.allowedSessionKeyPrefixes: ["hook:"]'); + console.log('\nSet env var: export OPENCLAW_HOOKS_TOKEN=""'); + } else { + console.log(instructions); + } + } +} + +// --------------------------------------------------------------------------- +// Command registration +// --------------------------------------------------------------------------- + +export function registerSetup(program: Command): void { + const setup = program + .command("setup") + .description("Set up integrations with external services"); + + setup + .command("openclaw") + .description("Connect AO notifications to an OpenClaw gateway") + .option("--url ", "OpenClaw webhook URL (e.g. http://127.0.0.1:18789/hooks/agent)") + .option("--token ", "OpenClaw hooks auth token") + .option("--non-interactive", "Skip prompts — requires --url and --token (or env vars)") + .action(async (opts: SetupOptions) => { + try { + await runSetupAction(opts); + } catch (err) { + if (err instanceof SetupAbortedError) { + console.error(err.message); + process.exit(err.exitCode); + } + throw err; + } + }); +} + +async function runSetupAction(opts: SetupOptions): Promise { + const nonInteractive = opts.nonInteractive || !process.stdin.isTTY; + + // --- Find existing config ------------------------------------------------ + let configPath: string | undefined; + try { + const found = findConfigFile(); + configPath = found ?? undefined; + } catch { + // no config found + } + + if (!configPath) { + throw new SetupAbortedError( + "No agent-orchestrator.yaml found. Run 'ao start' first to create one.", + ); + } + + // --- Check for existing openclaw config ---------------------------------- + const rawYaml = readFileSync(configPath, "utf-8"); + const rawConfig = yamlParse(rawYaml) ?? {}; + const existingOpenClaw = rawConfig?.notifiers?.openclaw; + const existingUrl = existingOpenClaw?.url as string | undefined; + + if (existingOpenClaw && !nonInteractive) { + const clack = await import("@clack/prompts"); + const reconfigure = await clack.confirm({ + message: "OpenClaw is already configured. Reconfigure?", + initialValue: false, + }); + + if (clack.isCancel(reconfigure) || !reconfigure) { + console.log(chalk.dim("Keeping existing config.")); + return; + } + } + + // --- Run setup ----------------------------------------------------------- + let resolved: ResolvedConfig; + + if (nonInteractive) { + resolved = await nonInteractiveSetup(opts); + } else { + resolved = await interactiveSetup(existingUrl); + } + + // --- Write AO config ----------------------------------------------------- + writeOpenClawConfig(configPath, resolved, nonInteractive); + + // --- Write OpenClaw config ----------------------------------------------- + const openclawConfigWritten = writeOpenClawJsonConfig(resolved.token); + if (openclawConfigWritten && nonInteractive) { + console.log(chalk.green("✓ Wrote hooks config to ~/.openclaw/openclaw.json")); + } + + // --- Write shell export -------------------------------------------------- + const shellProfilePath = writeShellExport(resolved.token); + if (shellProfilePath && nonInteractive) { + console.log(chalk.green(`✓ Exported OPENCLAW_HOOKS_TOKEN in ${shellProfilePath}`)); + } + + // --- Print instructions -------------------------------------------------- + printOpenClawInstructions(nonInteractive, openclawConfigWritten, shellProfilePath); + + // --- Done ---------------------------------------------------------------- + if (!nonInteractive) { + const clack = await import("@clack/prompts"); + clack.outro( + `${chalk.green("Setup complete!")} AO will send notifications to OpenClaw.\n` + + chalk.dim(" Run 'ao doctor' to verify the full setup.\n") + + chalk.dim(" Restart AO with 'ao stop && ao start' to activate."), + ); + } else { + console.log(chalk.green("\n✓ OpenClaw setup complete.")); + console.log(chalk.dim("Restart AO to activate: ao stop && ao start")); + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ece44d721..594ec58bc 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -14,6 +14,7 @@ import { registerLifecycleWorker } from "./commands/lifecycle-worker.js"; import { registerVerify } from "./commands/verify.js"; import { registerDoctor } from "./commands/doctor.js"; import { registerUpdate } from "./commands/update.js"; +import { registerSetup } from "./commands/setup.js"; import { getConfigInstruction } from "./lib/config-instruction.js"; const program = new Command(); @@ -38,6 +39,7 @@ registerLifecycleWorker(program); registerVerify(program); registerDoctor(program); registerUpdate(program); +registerSetup(program); program .command("config-help") diff --git a/packages/cli/src/lib/config-instruction.ts b/packages/cli/src/lib/config-instruction.ts index 60a1d60f2..9610a9a21 100644 --- a/packages/cli/src/lib/config-instruction.ts +++ b/packages/cli/src/lib/config-instruction.ts @@ -102,6 +102,11 @@ notifiers: webhook: plugin: webhook # url: https://example.com/hook + openclaw: + plugin: openclaw + # url: http://127.0.0.1:18789/hooks/agent + # token: \${OPENCLAW_HOOKS_TOKEN} + # Run 'ao setup openclaw' for guided configuration # ── Notification routing (optional) ───────────────────────────────── # Route notifications by priority level. @@ -122,7 +127,7 @@ notificationRouting: # Workspace: worktree, clone # SCM: github, gitlab # Tracker: github, linear, gitlab -# Notifier: desktop, slack, webhook, composio, openclaw +# Notifier: desktop, discord, slack, webhook, composio, openclaw # Terminal: iterm2, web `.trim(); } diff --git a/packages/cli/src/lib/openclaw-probe.ts b/packages/cli/src/lib/openclaw-probe.ts new file mode 100644 index 000000000..36b49382b --- /dev/null +++ b/packages/cli/src/lib/openclaw-probe.ts @@ -0,0 +1,128 @@ +/** + * OpenClaw gateway probing utilities. + * + * Used by `ao setup openclaw` and future TypeScript-based doctor checks. + * Probes the OpenClaw gateway for reachability and validates auth tokens. + */ + +export interface ProbeResult { + reachable: boolean; + httpStatus?: number; + error?: string; +} + +export interface TokenValidation { + valid: boolean; + error?: string; +} + +const DEFAULT_TIMEOUT_MS = 3_000; +const DEFAULT_OPENCLAW_URL = "http://127.0.0.1:18789"; +const HOOKS_PATH = "/hooks/agent"; + +/** + * Probe the OpenClaw gateway for reachability. + * Sends a GET to the base URL (not /hooks/agent) with a short timeout. + */ +export async function probeGateway( + baseUrl: string = DEFAULT_OPENCLAW_URL, + timeoutMs: number = DEFAULT_TIMEOUT_MS, +): Promise { + const url = baseUrl.replace(/\/+$/, ""); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { + method: "GET", + signal: controller.signal, + }); + + return { reachable: true, httpStatus: response.status }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + + if (message.includes("ECONNREFUSED")) { + return { reachable: false, error: `Gateway not reachable at ${url}` }; + } + if (message.includes("abort") || message.includes("timeout")) { + return { reachable: false, error: `Gateway timed out at ${url} (${timeoutMs}ms)` }; + } + + return { reachable: false, error: message }; + } finally { + clearTimeout(timer); + } +} + +/** + * Validate an auth token against the OpenClaw hooks endpoint. + * Sends a lightweight test payload and checks the response. + */ +export async function validateToken( + url: string, + token: string, + timeoutMs: number = DEFAULT_TIMEOUT_MS, +): Promise { + const hooksUrl = url.includes(HOOKS_PATH) ? url : `${url.replace(/\/+$/, "")}${HOOKS_PATH}`; + + const payload = { + message: "[AO Setup] Connection test — you can ignore this message.", + name: "AO", + sessionKey: "hook:ao:setup-test", + wakeMode: "now", + deliver: false, + }; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(hooksUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + + if (response.ok) { + return { valid: true }; + } + + if (response.status === 401 || response.status === 403) { + return { + valid: false, + error: + "Token rejected by OpenClaw. Check that hooks.token in your OpenClaw config matches this token.", + }; + } + + const body = await response.text().catch(() => ""); + return { + valid: false, + error: `Unexpected response (HTTP ${response.status}): ${body}`.trim(), + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + + if (message.includes("ECONNREFUSED")) { + return { + valid: false, + error: `Can't reach OpenClaw gateway. Is OpenClaw running?`, + }; + } + if (message.includes("abort") || message.includes("timeout")) { + return { + valid: false, + error: `Connection timed out. Check the gateway URL and network.`, + }; + } + + return { valid: false, error: message }; + } finally { + clearTimeout(timer); + } +} + +export { DEFAULT_OPENCLAW_URL, HOOKS_PATH }; diff --git a/packages/core/src/plugin-registry.ts b/packages/core/src/plugin-registry.ts index 46b80f6df..5d81a5f35 100644 --- a/packages/core/src/plugin-registry.ts +++ b/packages/core/src/plugin-registry.ts @@ -45,6 +45,7 @@ const BUILTIN_PLUGINS: Array<{ slot: PluginSlot; name: string; pkg: string }> = // Notifiers { slot: "notifier", name: "composio", pkg: "@composio/ao-plugin-notifier-composio" }, { slot: "notifier", name: "desktop", pkg: "@composio/ao-plugin-notifier-desktop" }, + { slot: "notifier", name: "discord", pkg: "@composio/ao-plugin-notifier-discord" }, { slot: "notifier", name: "openclaw", pkg: "@composio/ao-plugin-notifier-openclaw" }, { slot: "notifier", name: "slack", pkg: "@composio/ao-plugin-notifier-slack" }, { slot: "notifier", name: "webhook", pkg: "@composio/ao-plugin-notifier-webhook" }, diff --git a/packages/plugins/notifier-discord/README.md b/packages/plugins/notifier-discord/README.md new file mode 100644 index 000000000..f2f05d4e1 --- /dev/null +++ b/packages/plugins/notifier-discord/README.md @@ -0,0 +1,39 @@ +# notifier-discord + +Discord webhook notifier plugin for AO. Sends rich embed notifications for session events, PR creation, CI status, and escalations. + +## Setup + +1. In your Discord server: **Server Settings > Integrations > Webhooks > New Webhook** +2. Copy the webhook URL +3. Add to `agent-orchestrator.yaml`: + +```yaml +defaults: + notifiers: + - desktop + - discord + +notifiers: + discord: + plugin: discord + webhookUrl: https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN +``` + +## Config options + +| Option | Default | Description | +|--------|---------|-------------| +| `webhookUrl` | (required) | Discord webhook URL | +| `username` | "Agent Orchestrator" | Bot display name | +| `avatarUrl` | (none) | Bot avatar image URL | +| `threadId` | (none) | Post to a specific thread | +| `retries` | 2 | Retry attempts on 5xx | +| `retryDelayMs` | 1000 | Base retry delay (exponential backoff) | + +## Features + +- Rich embeds with color-coded priority (red=urgent, blue=action, yellow=warning, green=info) +- PR links, CI status, and action buttons in embed fields +- Thread support for organizing notifications by project +- Retry with exponential backoff on 5xx responses diff --git a/packages/plugins/notifier-discord/package.json b/packages/plugins/notifier-discord/package.json new file mode 100644 index 000000000..f35f81c72 --- /dev/null +++ b/packages/plugins/notifier-discord/package.json @@ -0,0 +1,44 @@ +{ + "name": "@composio/ao-plugin-notifier-discord", + "version": "0.1.0", + "description": "Notifier plugin: Discord webhook notifications", + "license": "MIT", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "https://github.com/ComposioHQ/agent-orchestrator.git", + "directory": "packages/plugins/notifier-discord" + }, + "homepage": "https://github.com/ComposioHQ/agent-orchestrator", + "bugs": { + "url": "https://github.com/ComposioHQ/agent-orchestrator/issues" + }, + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "dependencies": { + "@composio/ao-core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.2.3", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/packages/plugins/notifier-discord/src/index.test.ts b/packages/plugins/notifier-discord/src/index.test.ts new file mode 100644 index 000000000..cd9879663 --- /dev/null +++ b/packages/plugins/notifier-discord/src/index.test.ts @@ -0,0 +1,243 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { NotifyAction, OrchestratorEvent } from "@composio/ao-core"; +import { create, manifest } from "./index.js"; + +function makeEvent(overrides: Partial = {}): OrchestratorEvent { + return { + id: "evt-1", + type: "reaction.escalated", + priority: "urgent", + sessionId: "ao-5", + projectId: "ao", + timestamp: new Date("2026-03-20T12:00:00Z"), + message: "CI failed after 5 retries", + data: { attempts: 5, reason: "ci_failed" }, + ...overrides, + }; +} + +describe("notifier-discord", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("has correct manifest", () => { + expect(manifest.name).toBe("discord"); + expect(manifest.slot).toBe("notifier"); + }); + + it("posts to Discord webhook URL", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://discord.com/api/webhooks/123/abc" }); + await notifier.notify(makeEvent()); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock.mock.calls[0][0]).toBe("https://discord.com/api/webhooks/123/abc"); + }); + + it("sends Discord embed with correct structure", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://discord.com/api/webhooks/123/abc" }); + await notifier.notify(makeEvent()); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.username).toBe("Agent Orchestrator"); + expect(body.embeds).toHaveLength(1); + + const embed = body.embeds[0]; + expect(embed.title).toContain("ao-5"); + expect(embed.title).toContain("reaction.escalated"); + expect(embed.description).toBe("CI failed after 5 retries"); + expect(embed.color).toBe(0xed4245); // red for urgent + expect(embed.timestamp).toBe("2026-03-20T12:00:00.000Z"); + expect(embed.footer.text).toBe("Agent Orchestrator"); + }); + + it("includes project and priority fields", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://discord.com/api/webhooks/123/abc" }); + await notifier.notify(makeEvent()); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const fields = body.embeds[0].fields; + expect(fields).toContainEqual(expect.objectContaining({ name: "Project", value: "ao" })); + expect(fields).toContainEqual(expect.objectContaining({ name: "Priority", value: "urgent" })); + }); + + it("includes PR link when available", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://discord.com/api/webhooks/123/abc" }); + await notifier.notify(makeEvent({ data: { prUrl: "https://github.com/org/repo/pull/42" } })); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const prField = body.embeds[0].fields.find((f: { name: string }) => f.name === "Pull Request"); + expect(prField.value).toContain("https://github.com/org/repo/pull/42"); + }); + + it("includes CI status when available", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://discord.com/api/webhooks/123/abc" }); + await notifier.notify(makeEvent({ data: { ciStatus: "passing" } })); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const ciField = body.embeds[0].fields.find((f: { name: string }) => f.name === "CI"); + expect(ciField.value).toContain("passing"); + }); + + it("notifyWithActions includes action links", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://discord.com/api/webhooks/123/abc" }); + const actions: NotifyAction[] = [ + { label: "View PR", url: "https://github.com/org/repo/pull/42" }, + { label: "retry" }, + ]; + await notifier.notifyWithActions!(makeEvent(), actions); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const actionsField = body.embeds[0].fields.find((f: { name: string }) => f.name === "Actions"); + expect(actionsField.value).toContain("View PR"); + expect(actionsField.value).toContain("retry"); + }); + + it("post sends plain content message", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://discord.com/api/webhooks/123/abc" }); + await notifier.post!("Session ao-5 completed successfully"); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.content).toBe("Session ao-5 completed successfully"); + expect(body.embeds).toBeUndefined(); + }); + + it("uses custom username when configured", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://discord.com/api/webhooks/123/abc", username: "AO Bot" }); + await notifier.notify(makeEvent()); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.username).toBe("AO Bot"); + }); + + it("includes avatar_url when configured", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ + webhookUrl: "https://discord.com/api/webhooks/123/abc", + avatarUrl: "https://example.com/avatar.png", + }); + await notifier.notify(makeEvent()); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.avatar_url).toBe("https://example.com/avatar.png"); + }); + + it("includes thread_id when configured", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ + webhookUrl: "https://discord.com/api/webhooks/123/abc", + threadId: "1234567890", + }); + await notifier.notify(makeEvent()); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.thread_id).toBe("1234567890"); + }); + + it("is a no-op when webhookUrl not configured", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create(); + await notifier.notify(makeEvent()); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("No webhookUrl configured")); + }); + + it("uses correct color for each priority", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://discord.com/api/webhooks/123/abc" }); + + await notifier.notify(makeEvent({ priority: "info" })); + let body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.embeds[0].color).toBe(0x57f287); // green + + await notifier.notify(makeEvent({ priority: "warning" })); + body = JSON.parse(fetchMock.mock.calls[1][1].body); + expect(body.embeds[0].color).toBe(0xfee75c); // yellow + }); + + it("handles 204 No Content as success", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 204 }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://discord.com/api/webhooks/123/abc" }); + await expect(notifier.notify(makeEvent())).resolves.toBeUndefined(); + }); + + it("retries on 5xx response", async () => { + vi.useFakeTimers(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ ok: false, status: 503, text: () => Promise.resolve("down") }) + .mockResolvedValueOnce({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ + webhookUrl: "https://discord.com/api/webhooks/123/abc", + retries: 1, + retryDelayMs: 50, + }); + const promise = notifier.notify(makeEvent()); + + await vi.advanceTimersByTimeAsync(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(50); + expect(fetchMock).toHaveBeenCalledTimes(2); + + await promise; + vi.useRealTimers(); + }); + + it("does not retry on 4xx response", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: false, status: 401, text: () => Promise.resolve("unauthorized") }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ + webhookUrl: "https://discord.com/api/webhooks/123/abc", + retries: 2, + retryDelayMs: 1, + }); + await expect(notifier.notify(makeEvent())).rejects.toThrow("Discord webhook failed (401)"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/plugins/notifier-discord/src/index.ts b/packages/plugins/notifier-discord/src/index.ts new file mode 100644 index 000000000..ceee8e6d8 --- /dev/null +++ b/packages/plugins/notifier-discord/src/index.ts @@ -0,0 +1,208 @@ +import { + validateUrl, + type PluginModule, + type Notifier, + type OrchestratorEvent, + type NotifyAction, + type NotifyContext, + type EventPriority, + CI_STATUS, +} from "@composio/ao-core"; +import { isRetryableHttpStatus, normalizeRetryConfig } from "@composio/ao-core/utils"; + +export const manifest = { + name: "discord", + slot: "notifier" as const, + description: "Notifier plugin: Discord webhook notifications with rich embeds", + version: "0.1.0", +}; + +// Discord embed color codes (decimal) +const PRIORITY_COLOR: Record = { + urgent: 0xed4245, // red + action: 0x5865f2, // blurple + warning: 0xfee75c, // yellow + info: 0x57f287, // green +}; + +const PRIORITY_EMOJI: Record = { + urgent: "\u{1F6A8}", // rotating light + action: "\u{1F449}", // point right + warning: "\u{26A0}\u{FE0F}", // warning + info: "\u{2139}\u{FE0F}", // info +}; + +const DISCORD_WEBHOOK_URL_RE = + /^https:\/\/(?:discord\.com|discordapp\.com)\/api\/webhooks\//; + +const EMBED_DESCRIPTION_MAX = 4096; + +interface DiscordEmbed { + title: string; + description: string; + color: number; + fields?: { name: string; value: string; inline?: boolean }[]; + timestamp?: string; + footer?: { text: string }; +} + +function buildEmbed(event: OrchestratorEvent, actions?: NotifyAction[]): DiscordEmbed { + const emoji = PRIORITY_EMOJI[event.priority]; + const description = + event.message.length > EMBED_DESCRIPTION_MAX + ? event.message.slice(0, EMBED_DESCRIPTION_MAX - 1) + "\u2026" + : event.message; + const embed: DiscordEmbed = { + title: `${emoji} ${event.type} — ${event.sessionId}`, + description, + color: PRIORITY_COLOR[event.priority], + fields: [ + { name: "Project", value: event.projectId, inline: true }, + { name: "Priority", value: event.priority, inline: true }, + ], + timestamp: event.timestamp.toISOString(), + footer: { text: "Agent Orchestrator" }, + }; + + // Add PR link if available + const prUrl = typeof event.data.prUrl === "string" ? event.data.prUrl : undefined; + if (prUrl) { + embed.fields!.push({ name: "Pull Request", value: `[View PR](${prUrl})`, inline: false }); + } + + // Add CI status if available + const ciStatus = typeof event.data.ciStatus === "string" ? event.data.ciStatus : undefined; + if (ciStatus) { + const ciEmoji = ciStatus === CI_STATUS.PASSING ? "\u{2705}" : "\u{274C}"; + embed.fields!.push({ name: "CI", value: `${ciEmoji} ${ciStatus}`, inline: true }); + } + + // Add actions as a field + if (actions && actions.length > 0) { + const actionLinks = actions.map((a) => { + if (a.url) return `[${a.label}](${a.url})`; + return `\`${a.label}\``; + }); + embed.fields!.push({ name: "Actions", value: actionLinks.join(" | "), inline: false }); + } + + return embed; +} + +const DEFAULT_TIMEOUT_MS = 10_000; + +async function postWithRetry( + webhookUrl: string, + payload: Record, + retries: number, + retryDelayMs: number, +): Promise { + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= retries; attempt++) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS); + try { + const response = await fetch(webhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + + if (response.ok || response.status === 204) return; + + // Handle rate limiting with Retry-After header + if (response.status === 429) { + const retryAfter = response.headers.get("Retry-After"); + if (retryAfter && attempt < retries) { + const waitMs = (parseFloat(retryAfter) || 1) * 1000; + await new Promise((resolve) => setTimeout(resolve, waitMs)); + continue; + } + } + + const body = await response.text(); + lastError = new Error(`Discord webhook failed (${response.status}): ${body}`); + + if (!isRetryableHttpStatus(response.status)) { + throw lastError; + } + } catch (err) { + if (err === lastError) throw err; + lastError = err instanceof Error ? err : new Error(String(err)); + } finally { + clearTimeout(timer); + } + + if (attempt < retries) { + const delay = retryDelayMs * 2 ** attempt; + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + throw lastError; +} + +export function create(config?: Record): Notifier { + const webhookUrl = config?.webhookUrl as string | undefined; + const username = (config?.username as string) ?? "Agent Orchestrator"; + const avatarUrl = config?.avatarUrl as string | undefined; + const threadId = config?.threadId as string | undefined; + + const { retries, retryDelayMs } = normalizeRetryConfig(config); + + if (!webhookUrl) { + console.warn( + "[notifier-discord] No webhookUrl configured.\n" + + " Set it in agent-orchestrator.yaml under notifiers.discord.webhookUrl\n" + + " Create a webhook: Discord Server Settings > Integrations > Webhooks > New Webhook", + ); + } else { + validateUrl(webhookUrl, "notifier-discord"); + if (!DISCORD_WEBHOOK_URL_RE.test(webhookUrl)) { + console.warn( + "[notifier-discord] webhookUrl does not match expected Discord webhook format.\n" + + " Expected: https://discord.com/api/webhooks/... or https://discordapp.com/api/webhooks/...", + ); + } + } + + // Discord requires thread_id as a URL query param, not in the JSON body + const effectiveUrl = webhookUrl && threadId + ? `${webhookUrl}${webhookUrl.includes("?") ? "&" : "?"}thread_id=${threadId}` + : webhookUrl; + + function buildPayload(embeds: DiscordEmbed[]): Record { + const payload: Record = { username, embeds }; + if (avatarUrl) payload.avatar_url = avatarUrl; + return payload; + } + + return { + name: "discord", + + async notify(event: OrchestratorEvent): Promise { + if (!effectiveUrl) return; + const payload = buildPayload([buildEmbed(event)]); + await postWithRetry(effectiveUrl, payload, retries, retryDelayMs); + }, + + async notifyWithActions(event: OrchestratorEvent, actions: NotifyAction[]): Promise { + if (!webhookUrl) return; + const payload = buildPayload([buildEmbed(event, actions)]); + await postWithRetry(effectiveUrl!, payload, retries, retryDelayMs); + }, + + async post(message: string, _context?: NotifyContext): Promise { + if (!webhookUrl) return null; + const payload: Record = { username, content: message }; + if (avatarUrl) payload.avatar_url = avatarUrl; + if (threadId) payload.thread_id = threadId; + await postWithRetry(effectiveUrl!, payload, retries, retryDelayMs); + return null; + }, + }; +} + +export default { manifest, create } satisfies PluginModule; diff --git a/packages/plugins/notifier-discord/tsconfig.json b/packages/plugins/notifier-discord/tsconfig.json new file mode 100644 index 000000000..e1b71318a --- /dev/null +++ b/packages/plugins/notifier-discord/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/plugins/notifier-openclaw/README.md b/packages/plugins/notifier-openclaw/README.md index 5eff20201..91e525fd0 100644 --- a/packages/plugins/notifier-openclaw/README.md +++ b/packages/plugins/notifier-openclaw/README.md @@ -2,6 +2,18 @@ OpenClaw notifier plugin for AO escalation events. +## Quick setup + +```bash +ao setup openclaw +``` + +This interactive wizard auto-detects your OpenClaw gateway, validates the connection, and writes the config. For non-interactive use (e.g., from an OpenClaw agent): + +```bash +ao setup openclaw --url http://127.0.0.1:18789/hooks/agent --token YOUR_TOKEN --non-interactive +``` + ## Required OpenClaw config (`openclaw.json`) ```json diff --git a/packages/plugins/notifier-openclaw/src/index.test.ts b/packages/plugins/notifier-openclaw/src/index.test.ts index d38db0e99..d436db249 100644 --- a/packages/plugins/notifier-openclaw/src/index.test.ts +++ b/packages/plugins/notifier-openclaw/src/index.test.ts @@ -169,7 +169,17 @@ describe("notifier-openclaw", () => { vi.stubGlobal("fetch", fetchMock); const notifier = create({ token: "tok", retries: 2, retryDelayMs: 1 }); - await expect(notifier.notify(makeEvent())).rejects.toThrow("OpenClaw webhook failed (401)"); + await expect(notifier.notify(makeEvent())).rejects.toThrow("OpenClaw rejected the auth token"); expect(fetchMock).toHaveBeenCalledTimes(1); }); + + it("throws actionable error on ECONNREFUSED", async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error("fetch failed: ECONNREFUSED")); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok", retries: 0 }); + await expect(notifier.notify(makeEvent())).rejects.toThrow( + "Can't reach OpenClaw gateway", + ); + }); }); diff --git a/packages/plugins/notifier-openclaw/src/index.ts b/packages/plugins/notifier-openclaw/src/index.ts index f016b2420..dc6a65677 100644 --- a/packages/plugins/notifier-openclaw/src/index.ts +++ b/packages/plugins/notifier-openclaw/src/index.ts @@ -15,6 +15,8 @@ export const manifest = { version: "0.1.0", }; +const DEFAULT_TIMEOUT_MS = 10_000; + type WakeMode = "now" | "next-heartbeat"; interface OpenClawWebhookPayload { @@ -36,16 +38,29 @@ async function postWithRetry( let lastError: Error | undefined; for (let attempt = 0; attempt <= retries; attempt++) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS); try { const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload), + signal: controller.signal, }); if (response.ok) return; const body = await response.text(); + + if (response.status === 401 || response.status === 403) { + lastError = new Error( + `OpenClaw rejected the auth token (HTTP ${response.status}).\n` + + ` Check that hooks.token in your OpenClaw config matches OPENCLAW_HOOKS_TOKEN.\n` + + ` Reconfigure: ao setup openclaw`, + ); + throw lastError; + } + lastError = new Error(`OpenClaw webhook failed (${response.status}): ${body}`); if (!isRetryableHttpStatus(response.status)) { @@ -61,11 +76,21 @@ async function postWithRetry( if (err === lastError) throw err; lastError = err instanceof Error ? err : new Error(String(err)); + if (lastError.message.includes("ECONNREFUSED")) { + throw new Error( + `Can't reach OpenClaw gateway at ${url}.\n` + + ` Is OpenClaw running? Check: openclaw status\n` + + ` Wrong URL? Run: ao setup openclaw`, + ); + } + if (attempt < retries) { console.warn( `[notifier-openclaw] Retry ${attempt + 1}/${retries} for session=${context.sessionId} after network error: ${lastError.message}`, ); } + } finally { + clearTimeout(timer); } if (attempt < retries) { @@ -127,7 +152,9 @@ export function create(config?: Record): Notifier { if (!token) { console.warn( - "[notifier-openclaw] No token configured (token or OPENCLAW_HOOKS_TOKEN). Sending without Authorization header.", + "[notifier-openclaw] No token configured.\n" + + " Set OPENCLAW_HOOKS_TOKEN env var, or add token to your notifier config.\n" + + " Run: ao setup openclaw", ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3893d7c74..56453b125 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: packages/cli: dependencies: + '@clack/prompts': + specifier: ^0.9.1 + version: 0.9.1 '@composio/ao-core': specifier: workspace:* version: link:../core @@ -62,6 +65,9 @@ importers: '@composio/ao-plugin-notifier-desktop': specifier: workspace:* version: link:../plugins/notifier-desktop + '@composio/ao-plugin-notifier-discord': + specifier: workspace:* + version: link:../plugins/notifier-discord '@composio/ao-plugin-notifier-openclaw': specifier: workspace:* version: link:../plugins/notifier-openclaw @@ -321,6 +327,22 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + packages/plugins/notifier-discord: + dependencies: + '@composio/ao-core': + specifier: workspace:* + version: link:../../core + devDependencies: + '@types/node': + specifier: ^25.2.3 + version: 25.2.3 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + packages/plugins/notifier-openclaw: dependencies: '@composio/ao-core': @@ -837,6 +859,12 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@clack/core@0.4.1': + resolution: {integrity: sha512-Pxhij4UXg8KSr7rPek6Zowm+5M22rbd2g1nfojHJkxp5YkFqiZ2+YLEM/XGVIzvGOcM0nqjIFxrpDwWRZYWYjA==} + + '@clack/prompts@0.9.1': + resolution: {integrity: sha512-JIpyaboYZeWYlyP0H+OoPPxd6nqueG/CmN6ixBiNFsIDHREevjIf0n0Ohh5gr5C8pEDknzgvz+pIJ8dMhzWIeg==} + '@cloudflare/workers-types@4.20260214.0': resolution: {integrity: sha512-qb8rgbAdJR4BAPXolXhFL/wuGtecHLh1veOyZ1mK6QqWuCdI3vK1biKC0i3lzmzdLR/DZvsN3mNtpUE8zpWGEg==} @@ -3533,6 +3561,9 @@ packages: simple-wcswidth@1.1.2: resolution: {integrity: sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -4377,6 +4408,17 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 + '@clack/core@0.4.1': + dependencies: + picocolors: 1.1.1 + sisteransi: 1.0.5 + + '@clack/prompts@0.9.1': + dependencies: + '@clack/core': 0.4.1 + picocolors: 1.1.1 + sisteransi: 1.0.5 + '@cloudflare/workers-types@4.20260214.0': {} '@composio/mcp@1.0.3-0': {} @@ -6946,6 +6988,8 @@ snapshots: simple-wcswidth@1.1.2: {} + sisteransi@1.0.5: {} + slash@3.0.0: {} smart-buffer@4.2.0: {} diff --git a/skills/agent-orchestrator/SKILL.md b/skills/agent-orchestrator/SKILL.md new file mode 100644 index 000000000..a8a08ff8a --- /dev/null +++ b/skills/agent-orchestrator/SKILL.md @@ -0,0 +1,192 @@ +--- +name: agent-orchestrator +description: Use when the user mentions work, tasks, issues, repos, coding, agents, or asks what to do. Use when they say anything about starting work, checking status, spawning agents, GitHub issues, or project updates. This skill manages their engineering workflow through Agent Orchestrator (AO) by Composio. +metadata: {"openclaw": {"emoji": "🤖", "requires": {"anyBins": ["node", "npm"]}, "os": ["darwin", "linux"]}} +--- + +# Agent Orchestrator (AO) + +> https://github.com/ComposioHQ/agent-orchestrator + +You manage engineering workflows through Agent Orchestrator (AO). AO spawns parallel AI coding agents, each in its own git worktree, to work on GitHub issues simultaneously. + +You CAN write code directly for quick fixes, but you PREFER using AO for anything non-trivial because it gives you parallel isolation, session tracking, CI routing, and review handling. + +## How You Think + +Every user message is either: +1. **About work** → use AO tools, never memory +2. **About something else** → respond normally + +If there's even a 10% chance the message is about work, issues, code, or status — call the tools. + +## Intent → Tool Mapping + +You don't wait for the user to say "spawn" or "use AO." You detect intent and act. + +### "What's going on?" / status / progress +Any of: "what's happening", "status", "how's it going", "progress", "update", "anything running", "check on things" +→ Call `ao_sessions` AND `ao_status` → present results naturally + +### "What should I work on?" / work / issues / board +Any of: "what needs doing", "what's on the board", "any issues", "what's open", "morning", "let's go", "ready to work", "what's the plan", "check my repos" +→ Call `ao_issues` AND `ao_sessions` → present board + suggest priorities + +### "Do this" / "go" / "start" / "work on" / agreement +Any of: "do it", "go for it", "sure", "yep", "yes", "go ahead", "start on that", "work on #X", "handle #X", "take care of #X", "fix #X", "#X looks easy go for it", "that one", "let's do it" +→ Call `ao_spawn` or `ao_batch_spawn` with the relevant issue(s) + +### "Do all of them" / "start everything" / batch +Any of: "do them all", "start all", "spawn them all", "batch it", "all of those", "go for all" +→ Call `ao_batch_spawn` with all discussed issues + +### "Tell the agent..." / "also do..." / instructions to running agent +Any of: "tell it to also...", "ask the agent to...", "add X to that", "while it's at it...", "update the agent", "change the approach" +→ Call `ao_send` with the session ID and the instruction + +### "Stop" / "kill" / "cancel" +Any of: "stop that", "kill it", "cancel", "abort", "nevermind on that one", "drop it" +→ Confirm which session, then call `ao_kill` + +### "Something broke" / "agent crashed" / "it died" +Any of: "it crashed", "session died", "agent stopped", "it's stuck", "not responding" +→ Call `ao_session_restore` to try recovery, or `ao_kill` + re-`ao_spawn` + +### "Clean up" / "tidy up" +Any of: "clean up old sessions", "tidy up", "remove stale stuff", "garbage collect" +→ Call `ao_session_cleanup` (dry-run first, then execute) + +### "Any PR feedback?" / "check reviews" +Any of: "any review comments", "PR feedback", "check reviews", "anything to address on PRs" +→ Call `ao_review_check` + +### "Is the fix verified?" / "mark as verified" +Any of: "did that fix work", "mark it verified", "verified", "the fix works", "it's broken on staging" +→ Call `ao_verify` (or `ao_verify` with `fail: true`) + +### "What's unverified?" / "what needs testing" +Any of: "what's merged but not verified", "what needs testing", "anything to verify" +→ Call `ao_verify` with `list: true` + +### "Health check" / "is everything ok" +Any of: "health check", "is AO working", "diagnostics", "is everything ok", "doctor" +→ Call `ao_doctor` + +### "Claim that PR" / "attach PR" +Any of: "claim PR #X", "attach that PR", "link PR to session" +→ Call `ao_session_claim_pr` + +## Rules + +### Rule 1: Tools first, always +When the user asks anything about work, tasks, issues, status, or projects: +- FIRST call tools to get live data +- THEN present the results +- NEVER answer work questions from memory + +### Rule 2: Present naturally, then ask +After fetching data, present it conversationally: + +"You've got 6 open issues. Here's the board: + +1. #5 — CONTRIBUTING.md [docs] +2. #6 — JSON output bug [bug] +3. #7 — session age display [enhancement] +... + +Nothing running right now. I'd start with #6 (bug) and #9 (quick win). Want me to kick those off?" + +### Rule 3: Understand casual approval +- "go" / "yes" / "do it" / "sure" / "yep" → spawn all recommended +- "skip 3" / "not 5" / "drop the docs one" → remove those, spawn the rest +- "just the bugs" → filter to bug-labeled issues only +- "add 42" / "also do 42" → include that issue +- "not now" / "nah" / "later" → don't spawn anything +- "that one" / "the first one" / "the bug" → infer which issue they mean +- "#9 looks easy go for it" → spawn just #9 + +### Rule 4: Never say tool names to the user +Don't say "I will now invoke the ao_issues tool." Just do it and present results. + +Bad: "Let me call the ao_spawn tool to create an agent session..." +Good: "On it — spinning up an agent on #6. I'll check back with status in a few." + +### Rule 5: Follow up with links +After spawning agents, check back with `ao_status` for progress. When reporting PRs, ALWAYS include the full clickable URL: + +Good: "PR ready: https://github.com///pull/10" +Bad: "PR #10 is ready" + +Always include the full PR URL from the tool response. Never construct URLs manually. + +### Rule 6: Use AO for real work, direct tools for quick stuff +Use this decision guide: + +| Situation | Approach | +|-----------|----------| +| 1 quick fix (typo, config, single file) | Do it directly — faster | +| 2+ issues or non-trivial coding | Use AO (`ao_spawn` / `ao_batch_spawn`) | +| "Fix these issues" (multiple) | Always AO | +| Admin tasks (gh auth, server config) | Do directly | +| Filing a GitHub issue | Use `gh issue create` directly | +| Questions about code | Answer directly | + +### Rule 7: Never fabricate +If you can't do something, say so. Never claim you created an issue, PR, or file when you didn't. If a tool call fails, show the error. + +## All Available Tools + +| Tool | When to use | +|------|-------------| +| `ao_issues` | Any question about work, tasks, issues, the board | +| `ao_sessions` | Any question about running agents, status, progress | +| `ao_status` | Detailed dashboard with branch/PR/CI info | +| `ao_session_list` | Full session listing including terminated | +| `ao_spawn` | Start an agent on one issue | +| `ao_batch_spawn` | Start agents on multiple issues | +| `ao_send` | Send instruction to a running agent | +| `ao_kill` | Stop a session (confirm first) | +| `ao_session_restore` | Recover a crashed session | +| `ao_session_cleanup` | Remove stale sessions (merged PRs / closed issues) | +| `ao_session_claim_pr` | Attach an existing PR to a session | +| `ao_review_check` | Check PRs for review comments to address | +| `ao_verify` | Mark issues as verified/failed, or list unverified | +| `ao_doctor` | Health checks and diagnostics | + +## Setup + +After installing the plugin, run `/ao setup` in any OpenClaw channel to auto-configure. Or manually: + +```bash +# Required: plugin tools need "full" profile + explicit allow +openclaw config set tools.profile "full" +openclaw config set tools.allow '["group:plugins"]' + +# Required: prevent the bot from coding directly +openclaw config set tools.deny '["exec","process","write","edit","apply_patch","sessions_spawn"]' + +# Required: disable conflicting built-in skills +openclaw config set skills.entries.coding-agent.enabled false +openclaw config set skills.entries.gh-issues.enabled false + +# Required: trust the plugin +openclaw config set plugins.allow '["agent-orchestrator"]' + +# Optional: server channel settings +openclaw config set messages.groupChat.historyLimit 100 + +# Restart to apply +pm2 restart openclaw-gateway # or however you run the gateway +``` + +## Troubleshooting + +| Error | Fix | +|-------|-----| +| AO tools not visible to AI | Run `/ao setup` — needs `tools.profile: "full"` and `tools.allow: ["group:plugins"]` | +| Bot writes code directly | Disable `coding-agent` skill and deny `exec`/`write` tools | +| `ao spawn` fails with "No config" | Set `aoCwd` in plugin config to your repo path (where `agent-orchestrator.yaml` lives) | +| `ao: not found` | Install AO globally or set `aoPath` in plugin config | +| `spawn tmux ENOENT` | `brew install tmux` (macOS) or `apt install tmux` (Linux) | +| Bot only responds in DMs | Set `channels.discord.groupPolicy` to `"open"` | +| Session stuck | Use `ao_session_restore`, or kill and re-spawn | diff --git a/skills/agent-orchestrator/references/config.md b/skills/agent-orchestrator/references/config.md new file mode 100644 index 000000000..1853d9329 --- /dev/null +++ b/skills/agent-orchestrator/references/config.md @@ -0,0 +1,107 @@ +# Agent Orchestrator Config Reference + +File: `agent-orchestrator.yaml` (in project root) + +## Top-level settings + +```yaml +port: 3000 # Dashboard port (auto-finds free port if busy) +terminalPort: 3001 # Terminal WebSocket port +readyThresholdMs: 300000 # Ms before "ready" session becomes "idle" (5 min) +``` + +## Default plugins + +```yaml +defaults: + runtime: tmux # tmux | process + agent: claude-code # claude-code | aider | codex | opencode + workspace: worktree # worktree | clone + notifiers: # Active notifier plugins + - desktop # desktop | slack | webhook | composio | openclaw + orchestrator: + agent: claude-code # Override agent for orchestrator sessions + worker: + agent: claude-code # Override agent for worker sessions +``` + +## Projects + +```yaml +projects: + my-app: + name: My App # Display name + repo: owner/repo # GitHub "owner/repo" + path: ~/code/my-app # Local repo path + defaultBranch: main # main | master | develop + sessionPrefix: myapp # Session name prefix (myapp-1, myapp-2) + + # Per-project plugin overrides (optional) + runtime: tmux + agent: claude-code + workspace: worktree + + # Agent configuration (optional) + agentConfig: + permissions: auto # auto | manual + model: claude-sonnet-4-20250514 + + # Rules passed to every agent prompt (optional) + agentRules: | + Always run tests before committing. + Use conventional commits. + agentRulesFile: .ao-rules # Or point to a file + orchestratorRules: | # Rules for the orchestrator agent + + # Orchestrator session strategy (optional) + orchestratorSessionStrategy: reuse + # Options: reuse | delete | ignore | delete-new | ignore-new | kill-previous + + # Workspace setup (optional) + symlinks: # Symlink into worktrees + - .env + - node_modules + postCreate: # Run after workspace creation + - pnpm install + + # Issue tracker (optional) + tracker: + plugin: github # github | linear | gitlab + + # SCM (optional, usually auto-detected) + scm: + plugin: github # github | gitlab +``` + +## Notifier channels + +```yaml +notifiers: + desktop: + plugin: desktop + slack: + plugin: slack + # Requires SLACK_WEBHOOK_URL env var + openclaw: + plugin: openclaw + url: http://127.0.0.1:18789/hooks/agent + token: ${OPENCLAW_HOOKS_TOKEN} + retries: 3 + retryDelayMs: 1000 + wakeMode: now # now | next-heartbeat +``` + +## Notification routing + +```yaml +notificationRouting: + critical: + - desktop + - slack + - openclaw + high: + - desktop + - openclaw + low: + - desktop +``` From 8fc778cf3056b675844c87da649876dfd8ae4d92 Mon Sep 17 00:00:00 2001 From: Ashish Huddar Date: Tue, 24 Mar 2026 00:55:37 +0530 Subject: [PATCH 02/69] chore(web): add mobile-responsive layout for dashboard and session views Add proper mobile breakpoints (768px, 480px) with structural layout changes: - Sidebar: auto-hide on mobile with hamburger toggle and overlay drawer - Dashboard hero: stack stats cards and controls vertically on mobile - Kanban board: stack columns vertically instead of horizontal scroll - Session cards: flexible height with 44px minimum touch targets - Session detail: responsive header metadata and full-width terminal - Global: phone-specific breakpoint (480px) for single-column layouts Desktop layout remains completely unchanged. Closes #633 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/web/src/app/globals.css | 242 ++++++++++++++++++ packages/web/src/components/Dashboard.tsx | 25 ++ .../web/src/components/ProjectSidebar.tsx | 13 +- 3 files changed, 278 insertions(+), 2 deletions(-) diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index b592cdb0a..6bb52a6e8 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -1874,3 +1874,245 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { min-height: 244px; } } + +/* ── Mobile hamburger toggle ─────────────────────────────────────────── */ + +.mobile-menu-toggle { + display: none; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + flex-shrink: 0; + border: 1px solid var(--color-border-default); + background: var(--color-bg-surface); + color: var(--color-text-secondary); + cursor: pointer; + transition: + border-color 0.12s ease, + background 0.12s ease, + color 0.12s ease; +} + +.mobile-menu-toggle:hover { + border-color: var(--color-border-strong); + background: var(--color-hover-overlay); + color: var(--color-text-primary); +} + +/* ── Mobile breakpoint — 768px (tablet / landscape phone) ────────────── */ + +@media (max-width: 767px) { + /* -- Hamburger button visible -- */ + .mobile-menu-toggle { + display: inline-flex; + } + + /* -- Sidebar: off-canvas drawer -- */ + .project-sidebar, + .project-sidebar.project-sidebar--collapsed { + position: fixed; + top: 0; + left: 0; + z-index: var(--z-overlay); + height: 100vh; + height: 100dvh; + width: 280px; + transform: translateX(-100%); + transition: transform 0.25s ease; + } + + .project-sidebar--mobile-open { + transform: translateX(0); + } + + .sidebar-mobile-backdrop { + position: fixed; + inset: 0; + z-index: calc(var(--z-overlay) - 1); + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); + } + + /* -- Dashboard main -- */ + .dashboard-main { + padding-left: 12px; + padding-right: 12px; + padding-top: 12px; + padding-bottom: 12px; + } + + /* -- Hero section stacks vertically -- */ + .dashboard-hero__content { + flex-direction: column; + padding: 12px; + gap: 12px; + } + + .dashboard-hero__primary { + flex-direction: column; + flex-basis: auto; + gap: 12px; + } + + .dashboard-stat-cards { + width: 100%; + margin-left: 0; + } + + .dashboard-hero__meta { + width: 100%; + margin-left: 0; + } + + /* -- Kanban board: stack columns vertically -- */ + .kanban-board { + flex-direction: column; + height: auto; + min-height: auto; + overflow-x: visible; + gap: 12px; + } + + .kanban-column { + flex: none; + width: 100%; + min-width: 0; + max-width: none; + } + + .kanban-column-body { + max-height: 400px; + } + + .board-section-head { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .board-section-head__legend { + flex-wrap: wrap; + } + + /* -- Session cards: flexible height + touch targets -- */ + .session-card--fixed { + height: auto; + min-height: 180px; + } + + .session-card--alert-frame { + min-height: 220px; + } + + .session-card--merge-frame { + min-height: 200px; + } + + .session-card__control, + .session-card__merge-control, + .session-card__terminate, + .done-restore-btn { + min-height: 44px; + min-width: 44px; + } + + .session-card__footer { + min-height: 44px; + } + + .session-card__footer a, + .session-card__footer button { + min-height: 44px; + display: inline-flex; + align-items: center; + } + + /* -- Session detail page -- */ + .session-detail-page .mx-auto { + padding-left: 12px; + padding-right: 12px; + } + + .session-page-header { + padding: 12px; + } + + .session-page-header__meta { + flex-direction: column; + gap: 6px; + } + + .session-page-header__crumbs { + font-size: 10px; + } + + .session-detail-link-pill { + min-height: 36px; + } + + /* -- Touch targets for interactive elements -- */ + .orchestrator-btn, + .board-legend-item { + min-height: 44px; + } + + .project-sidebar__item { + min-height: 44px; + display: flex; + align-items: center; + } + + .project-sidebar__session { + min-height: 44px; + display: flex; + align-items: center; + } + + /* -- PR table: horizontal scroll on mobile -- */ + .mx-auto.max-w-\[900px\] { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + /* -- Alert banners -- */ + .dashboard-alert { + font-size: 11px; + padding: 10px 12px; + } +} + +/* ── Phone breakpoint — 480px ────────────────────────────────────────── */ + +@media (max-width: 480px) { + .dashboard-title { + font-size: 20px; + } + + .dashboard-stat-cards { + flex-direction: column; + } + + .dashboard-stat-card { + width: 100%; + } + + .kanban-column-body { + max-height: 300px; + } + + .kanban-column__title { + font-size: 16px; + } + + .session-page-header h1 { + font-size: 15px; + } + + /* Session detail terminal full-width */ + .session-detail-page .mx-auto { + padding-left: 8px; + padding-right: 8px; + } +} diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index b30cf7bde..ad67cd9ce 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -70,6 +70,7 @@ export function Dashboard({ const [spawningProjectIds, setSpawningProjectIds] = useState([]); const [spawnErrors, setSpawnErrors] = useState>({}); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const showSidebar = projects.length > 1; const allProjectsView = showSidebar && projectId === undefined; @@ -82,6 +83,10 @@ export function Dashboard({ setActiveOrchestrators((current) => mergeOrchestrators(current, orchestratorLinks)); }, [orchestratorLinks]); + useEffect(() => { + setMobileMenuOpen(false); + }, [searchParams]); + const grouped = useMemo(() => { const zones: Record = { merge: [], @@ -267,6 +272,8 @@ export function Dashboard({ activeSessionId={activeSessionId} collapsed={sidebarCollapsed} onToggleCollapsed={() => setSidebarCollapsed((current) => !current)} + mobileOpen={mobileMenuOpen} + onMobileClose={() => setMobileMenuOpen(false)} /> )}
@@ -274,6 +281,24 @@ export function Dashboard({
+ {showSidebar && ( + + )}
diff --git a/packages/web/src/components/ProjectSidebar.tsx b/packages/web/src/components/ProjectSidebar.tsx index c0735fe82..8d7d830e0 100644 --- a/packages/web/src/components/ProjectSidebar.tsx +++ b/packages/web/src/components/ProjectSidebar.tsx @@ -15,6 +15,8 @@ interface ProjectSidebarProps { activeSessionId: string | undefined; collapsed?: boolean; onToggleCollapsed?: () => void; + mobileOpen?: boolean; + onMobileClose?: () => void; } type ProjectHealth = "red" | "yellow" | "green" | "gray"; @@ -96,6 +98,8 @@ function ProjectSidebarInner({ activeSessionId, collapsed = false, onToggleCollapsed, + mobileOpen = false, + onMobileClose, }: ProjectSidebarProps) { const router = useRouter(); const pathname = usePathname(); @@ -156,7 +160,7 @@ function ProjectSidebarInner({ if (collapsed) { return ( -
diff --git a/packages/web/src/components/ServiceWorkerRegistrar.tsx b/packages/web/src/components/ServiceWorkerRegistrar.tsx new file mode 100644 index 000000000..f6bea727e --- /dev/null +++ b/packages/web/src/components/ServiceWorkerRegistrar.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { useEffect } from "react"; + +export function ServiceWorkerRegistrar() { + useEffect(() => { + if ("serviceWorker" in navigator) { + navigator.serviceWorker.register("/sw.js"); + } + }, []); + return null; +} diff --git a/packages/web/src/components/Terminal.tsx b/packages/web/src/components/Terminal.tsx index c64296ea0..3a9a130a6 100644 --- a/packages/web/src/components/Terminal.tsx +++ b/packages/web/src/components/Terminal.tsx @@ -73,7 +73,7 @@ export function Terminal({ sessionId }: TerminalProps) { {fullscreen ? "exit fullscreen" : "fullscreen"}
-
+
{terminalUrl ? (