diff --git a/.changeset/fresh-fishes-help.md b/.changeset/fresh-fishes-help.md deleted file mode 100644 index b5146b470..000000000 --- a/.changeset/fresh-fishes-help.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@composio/ao-cli": patch -"@composio/ao": patch ---- - -Fix startup onboarding and install reliability: - -- Repair npm global install startup path by improving package resolution and web package discovery hints. -- Make `ao start` prerequisite installs explicit and interactive for required tools (`tmux`, `git`) with clearer fallback guidance. -- Keep `ao spawn` preflight check-only for `tmux` (no implicit install). -- Remove redundant agent runtime re-detection during config generation. - diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index ff27f2c4e..3eaaa1e9f 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -22,13 +22,42 @@ jobs: - name: Checkout code uses: actions/checkout@v4 with: - fetch-depth: 0 # Full history for comprehensive scan + fetch-depth: 0 # Full history to ensure base/head SHAs are available for PR scans + + - name: Install Gitleaks + run: | + set -euo pipefail + GITLEAKS_VERSION="8.24.3" + GITLEAKS_BASE_URL="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}" + ARCHIVE_NAME="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + + # Download gitleaks archive using the release filename so checksum verification works + curl -sSfL "${GITLEAKS_BASE_URL}/${ARCHIVE_NAME}" -o "${ARCHIVE_NAME}" + + # Download the combined checksums file + curl -sSfL "${GITLEAKS_BASE_URL}/gitleaks_${GITLEAKS_VERSION}_checksums.txt" -o gitleaks_checksums.txt + + # Verify checksum (sha256sum -c expects the filename in the checksums file to match the local file) + grep "${ARCHIVE_NAME}" gitleaks_checksums.txt | sha256sum -c - + + # Extract the verified binary to a user-writable directory and add it to PATH + INSTALL_DIR="${RUNNER_TEMP}/gitleaks-bin" + mkdir -p "${INSTALL_DIR}" + tar -xzf "${ARCHIVE_NAME}" -C "${INSTALL_DIR}" gitleaks + echo "${INSTALL_DIR}" >> "${GITHUB_PATH}" + + # Clean up + rm -f "${ARCHIVE_NAME}" gitleaks_checksums.txt - name: Run Gitleaks - uses: gitleaks/gitleaks-action@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + # Ensure the base and head SHAs exist locally when checkout uses the PR merge ref + git fetch origin ${{ github.event.pull_request.base.sha }} ${{ github.event.pull_request.head.sha }} --depth=1 + gitleaks detect --source . --verbose --log-opts "${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" + else + gitleaks detect --source . --verbose + fi dependency-review: name: Dependency Review diff --git a/agent-orchestrator.yaml.example b/agent-orchestrator.yaml.example index 8a7cd87f9..f2607c3e1 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 # Use https:// for remote (non-localhost) deployments +# 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..08010712a --- /dev/null +++ b/docs/openclaw-plugin-setup.md @@ -0,0 +1,196 @@ +# 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"]' +``` + +### Required: Disable Conflicting Built-in Skills + +**Without these, the bot may ignore AO and write code directly.** Run once after setup: + +```bash +# Prevent the bot from writing code directly — it should delegate to AO instead +openclaw config set tools.deny '["exec", "write", "str_replace_based_edit_tool", "create_file", "str_replace_editor"]' + +# Disable the built-in coding skill (it tells the bot to use Codex/Claude Code directly, overriding AO) +openclaw config set skills.entries.coding-agent.enabled false + +# Disable the built-in GitHub issues skill (it spawns OpenClaw sub-agents, bypassing AO) +openclaw config set skills.entries.gh-issues.enabled false +``` + +### 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.test.ts b/openclaw-plugin/index.test.ts new file mode 100644 index 000000000..da777c362 --- /dev/null +++ b/openclaw-plugin/index.test.ts @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + extractConfiguredReposFromYaml, + fetchIssues, + mergeStringLists, + parseStringArraySetting, +} from "./index.ts"; + +function makeIssue(number: number, title: string, repo: string) { + return { + number, + title, + labels: [], + state: "open", + assignees: [], + createdAt: `2026-03-${String(number).padStart(2, "0")}T00:00:00Z`, + url: `https://github.com/${repo}/issues/${number}`, + }; +} + +test("extractConfiguredReposFromYaml reads every project repo", () => { + const rawYaml = ` +port: 3000 +projects: + app: + repo: acme/app + path: ~/code/app + docs: + repo: "acme/docs" # keep quoted repos working + path: ~/code/docs +notifiers: + openclaw: + plugin: openclaw +`; + + assert.deepEqual(extractConfiguredReposFromYaml(rawYaml), ["acme/app", "acme/docs"]); +}); + +test("fetchIssues queries every configured repo when repo is omitted", () => { + const ghCalls: string[] = []; + const result = fetchIssues( + { aoCwd: "/tmp/work" }, + {}, + { + getConfiguredRepos: () => ["acme/app", "acme/docs"], + runGh: (_config, args) => { + const repoIndex = args.indexOf("-R"); + const repo = repoIndex >= 0 ? args[repoIndex + 1] : "default"; + ghCalls.push(repo); + + if (repo === "acme/app") { + return { ok: true, output: JSON.stringify([makeIssue(1, "App bug", repo)]) }; + } + if (repo === "acme/docs") { + return { ok: true, output: JSON.stringify([makeIssue(2, "Docs bug", repo)]) }; + } + + return { ok: false, error: `unexpected repo: ${repo}` }; + }, + }, + ); + + assert.deepEqual(ghCalls, ["acme/app", "acme/docs"]); + assert.equal(result.ok, true); + if (!result.ok) return; + + assert.deepEqual(result.scannedRepos, ["acme/app", "acme/docs"]); + assert.equal(result.warnings.length, 0); + assert.deepEqual( + result.issues.map((issue) => issue.repository), + ["acme/docs", "acme/app"], + ); +}); + +test("fetchIssues surfaces GitHub failures instead of reporting an empty board", () => { + const result = fetchIssues( + { aoCwd: "/tmp/work" }, + {}, + { + getConfiguredRepos: () => ["acme/app"], + runGh: () => ({ ok: false, error: "gh auth token missing" }), + }, + ); + + assert.equal(result.ok, false); + if (result.ok) return; + assert.match(result.error, /gh auth token missing/); +}); + +test("fetchIssues keeps partial failures visible when at least one repo succeeds", () => { + const result = fetchIssues( + { aoCwd: "/tmp/work" }, + {}, + { + getConfiguredRepos: () => ["acme/app", "acme/docs"], + runGh: (_config, args) => { + const repoIndex = args.indexOf("-R"); + const repo = repoIndex >= 0 ? args[repoIndex + 1] : "default"; + if (repo === "acme/app") { + return { ok: true, output: JSON.stringify([makeIssue(3, "App bug", repo)]) }; + } + return { ok: false, error: "gh not authenticated for docs repo" }; + }, + }, + ); + + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.issues.length, 1); + assert.deepEqual(result.warnings, ["acme/docs: gh not authenticated for docs repo"]); +}); + +test("allowlist helpers preserve existing entries while adding AO requirements", () => { + assert.deepEqual(mergeStringLists(["custom:tools", "group:plugins"], ["group:plugins"]), [ + "custom:tools", + "group:plugins", + ]); + assert.deepEqual(parseStringArraySetting('["group:plugins","custom:tools"]'), [ + "group:plugins", + "custom:tools", + ]); + assert.deepEqual(parseStringArraySetting("null"), []); +}); diff --git a/openclaw-plugin/index.ts b/openclaw-plugin/index.ts new file mode 100644 index 000000000..9bd0f14eb --- /dev/null +++ b/openclaw-plugin/index.ts @@ -0,0 +1,1456 @@ +/** + * OpenClaw Plugin: Agent Orchestrator v0.3.0 + * + * Open-source, pluggable agentic coding orchestrator. Manages durable coding + * agents (Claude Code, Codex, OpenCode) and wires up feedback loops so PR + * reviews and CI failures automatically route to the right agent. + * + * Provides: + * - Hook: injects live repo data into AI context for work-related messages + * - Slash command: /ao (with subcommands) + * - 14 agent tools: ao_sessions, ao_session_list, ao_status, ao_issues, + * ao_spawn, ao_batch_spawn, ao_send, ao_kill, ao_doctor, ao_review_check, + * ao_verify, ao_session_cleanup, ao_session_restore, ao_session_claim_pr + * - Background services: health monitoring + issue board scanner + auto follow-up + */ + +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface PluginConfig { + aoPath?: string; + aoCwd?: string; + ghPath?: string; + healthPollIntervalMs?: number; + boardScanIntervalMs?: number; +} + +/** Minimal shape of the OpenClaw plugin API passed to the default export. */ +interface PluginApi { + pluginConfig?: PluginConfig; + logger: { info: (msg: string) => void; warn: (msg: string) => void }; + on?: (name: string, handler: (event: PluginEvent) => Promise, opts?: { priority: number }) => void; + registerHook?: (name: string, handler: (event: PluginEvent) => Promise, opts?: { priority: number }) => void; + registerCommand?: (cmd: CommandRegistration) => void; + registerTool?: (tool: Record) => void; + registerService?: (svc: Record) => void; + runtime?: { + sendMessageToDefaultSession?: (message: string) => void; + }; +} + +interface CommandRegistration { + name: string; + description: string; + acceptsArgs: boolean; + requireAuth: boolean; + handler: (ctx: CommandContext) => Promise; +} + +interface CommandResult { + text: string; +} + +interface PluginEvent { + sessionKey?: string; + sessionId?: string; + channelId?: string; + message?: { text?: string; content?: string }; + text?: string; + content?: string; + appendSystemContext?: (text: string) => void; + prependContext?: (text: string) => void; + context?: Record; + messages?: Array<{ role: string; content: string }>; +} + +interface CommandContext { + args?: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function runCmd(bin: string, args: string[], timeoutMs: number = 15_000, cwd?: string): string { + return execFileSync(bin, args, { + encoding: "utf-8", + timeout: timeoutMs, + cwd, + env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" }, + }).trim(); +} + +function tryRun( + bin: string, + args: string[], + timeoutMs?: number, + cwd?: string, +): { ok: true; output: string } | { ok: false; error: string } { + try { + return { ok: true, output: runCmd(bin, args, timeoutMs, cwd) }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return { ok: false, error: message }; + } +} + +/** Strip leading dashes from LLM-supplied args to prevent CLI flag injection. */ +function sanitizeCliArg(arg: string): string { + return arg.replace(/^-+/, ""); +} + +function tryRunAo(config: PluginConfig, args: string[], timeoutMs?: number) { + // AO requires cwd to be the repo root where agent-orchestrator.yaml lives + const cwd = config.aoCwd || process.cwd(); + return tryRun(config.aoPath || "ao", args, timeoutMs, cwd); +} + +function tryRunGh(config: PluginConfig, args: string[], timeoutMs?: number) { + // Run gh from aoCwd so default-repo queries resolve correctly + const cwd = config.aoCwd || process.cwd(); + return tryRun(config.ghPath || "gh", args, timeoutMs, cwd); +} + +// --------------------------------------------------------------------------- +// Issue board helpers +// --------------------------------------------------------------------------- + +interface GitHubIssue { + number: number; + title: string; + labels: Array<{ name: string }>; + state: string; + assignees: Array<{ login: string }>; + createdAt: string; + url: string; + repository?: string; +} + +interface FetchIssuesSuccess { + ok: true; + issues: GitHubIssue[]; + scannedRepos: string[]; + warnings: string[]; +} + +interface FetchIssuesFailure { + ok: false; + error: string; +} + +type FetchIssuesResult = FetchIssuesSuccess | FetchIssuesFailure; + +interface FetchIssuesOptions { + repo?: string; + labels?: string; +} + +interface FetchIssuesDeps { + getConfiguredRepos: (config: PluginConfig) => string[]; + runGh: typeof tryRunGh; +} + +function resolveAoConfigPath(config: PluginConfig): string | null { + const candidates: string[] = []; + const envPath = process.env.AO_CONFIG_PATH; + if (envPath) candidates.push(resolve(envPath)); + + let currentDir = resolve(config.aoCwd || process.cwd()); + while (true) { + candidates.push( + join(currentDir, "agent-orchestrator.yaml"), + join(currentDir, "agent-orchestrator.yml"), + ); + const parentDir = dirname(currentDir); + if (parentDir === currentDir) break; + currentDir = parentDir; + } + + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + + return null; +} + +function stripYamlInlineComment(value: string): string { + let inSingleQuote = false; + let inDoubleQuote = false; + + for (let i = 0; i < value.length; i++) { + const char = value[i]; + if (char === "'" && !inDoubleQuote) { + inSingleQuote = !inSingleQuote; + continue; + } + if (char === '"' && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote; + continue; + } + if (char === "#" && !inSingleQuote && !inDoubleQuote) { + return value.slice(0, i).trim(); + } + } + + return value.trim(); +} + +function normalizeYamlScalar(value: string): string { + const stripped = stripYamlInlineComment(value); + if (!stripped) return ""; + + if ( + (stripped.startsWith('"') && stripped.endsWith('"')) || + (stripped.startsWith("'") && stripped.endsWith("'")) + ) { + return stripped.slice(1, -1).trim(); + } + + return stripped; +} + +export function extractConfiguredReposFromYaml(rawYaml: string): string[] { + const repos = new Set(); + const lines = rawYaml.split(/\r?\n/); + let inProjects = false; + // Detected at runtime from the first project entry line — not hardcoded. + let projectKeyIndent: number | null = null; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const indent = line.match(/^ */)?.[0].length ?? 0; + + if (!inProjects) { + if (trimmed === "projects:" && indent === 0) { + inProjects = true; + } + continue; + } + + // Any top-level key after projects: ends the block. + if (indent === 0) break; + + // Detect the indentation level of project name keys from the first entry. + // Strip inline comments before checking — `my-app: # comment` is a valid key. + if (projectKeyIndent === null) { + if (trimmed.replace(/\s*#.*$/, "").endsWith(":")) projectKeyIndent = indent; + continue; + } + + // Lines at the project-key indent are project names — skip them. + if (indent === projectKeyIndent) continue; + + // Lines indented deeper than the project key are project properties. + if (indent > projectKeyIndent) { + const match = trimmed.match(/^repo:\s*(.+)$/); + if (!match) continue; + const repo = normalizeYamlScalar(match[1]); + if (repo) repos.add(repo); + } + } + + return [...repos]; +} + +function getConfiguredRepos(config: PluginConfig): string[] { + const configPath = resolveAoConfigPath(config); + if (!configPath) return []; + + try { + const rawYaml = readFileSync(configPath, "utf-8"); + return extractConfiguredReposFromYaml(rawYaml); + } catch { + return []; + } +} + +function getIssueRepository(issue: GitHubIssue): string | null { + if (issue.repository) return issue.repository; + const match = issue.url.match(/github\.com\/([^/]+\/[^/]+)\/issues\//); + return match?.[1] ?? null; +} + +function getIssueIdentity(issue: GitHubIssue): string { + return issue.url || `${getIssueRepository(issue) ?? "default"}#${issue.number}`; +} + +function formatIssueWarnings(warnings: string[]): string { + return warnings.map((warning) => `- ${warning}`).join("\n"); +} + +export function mergeStringLists(existing: string[], required: string[]): string[] { + const merged = [...existing]; + for (const value of required) { + if (!merged.includes(value)) merged.push(value); + } + return merged; +} + +export function parseStringArraySetting(output: string): string[] | null { + const trimmed = output.trim(); + if (!trimmed) return []; + if (trimmed === "null" || trimmed === "undefined") return []; + + try { + const parsed = JSON.parse(trimmed); + if (parsed === null || parsed === undefined) return []; + if (Array.isArray(parsed)) { + return parsed.filter((value): value is string => typeof value === "string"); + } + if (typeof parsed === "string") { + return parsed ? [parsed] : []; + } + } catch { + // Fall through to plain-text parsing + } + + if (trimmed.includes("\n")) { + return trimmed + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + } + + if (trimmed.includes(",")) { + return trimmed + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + } + + return [trimmed]; +} + +function getNestedValue(root: unknown, path: string[]): unknown { + let current = root; + for (const segment of path) { + if (!current || typeof current !== "object" || Array.isArray(current)) { + return undefined; + } + current = (current as Record)[segment]; + } + return current; +} + +function readOpenClawConfig(): Record | null { + try { + const configPath = join(homedir(), ".openclaw", "openclaw.json"); + if (!existsSync(configPath)) return {}; + return JSON.parse(readFileSync(configPath, "utf-8")) as Record; + } catch { + return null; + } +} + +function readOpenClawStringArraySetting(setting: string, path: string[]): string[] { + const cliResult = tryRun("openclaw", ["config", "get", setting], 5_000); + if (cliResult.ok) { + const parsed = parseStringArraySetting(cliResult.output); + if (parsed) return parsed; + } + + const openClawConfig = readOpenClawConfig(); + if (!openClawConfig) return []; + + const nestedValue = getNestedValue(openClawConfig, path); + if (Array.isArray(nestedValue)) { + return nestedValue.filter((value): value is string => typeof value === "string"); + } + if (typeof nestedValue === "string" && nestedValue) { + return [nestedValue]; + } + + return []; +} + +export function fetchIssues( + config: PluginConfig, + options: FetchIssuesOptions = {}, + deps: FetchIssuesDeps = { + getConfiguredRepos, + runGh: tryRunGh, + }, +): FetchIssuesResult { + const repos = options.repo ? [options.repo] : deps.getConfiguredRepos(config); + const targets = repos.length > 0 ? repos : [undefined]; + const issues: GitHubIssue[] = []; + const warnings: string[] = []; + const scannedRepos: string[] = []; + + for (const targetRepo of targets) { + const args = ["issue", "list"]; + const repoLabel = targetRepo ?? "default repo"; + if (targetRepo) args.push("-R", targetRepo); + if (options.labels) args.push("--label", options.labels); + args.push( + "--state", + "open", + "--json", + "number,title,labels,state,assignees,createdAt,url", + "--limit", + "30", + ); + + const result = deps.runGh(config, args, 15_000); + if (!result.ok) { + warnings.push(`${repoLabel}: ${result.error}`); + continue; + } + + try { + const parsed = JSON.parse(result.output) as GitHubIssue[]; + for (const issue of parsed) { + issue.repository = targetRepo ?? getIssueRepository(issue) ?? undefined; + issues.push(issue); + } + if (targetRepo) scannedRepos.push(targetRepo); + } catch { + warnings.push(`${repoLabel}: failed to parse GitHub CLI output`); + } + } + + const dedupedIssues = issues + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .filter( + (issue, index, allIssues) => + allIssues.findIndex( + (candidate) => getIssueIdentity(candidate) === getIssueIdentity(issue), + ) === index, + ); + const inferredRepos = dedupedIssues + .map((issue) => getIssueRepository(issue)) + .filter((repo): repo is string => Boolean(repo)); + + if (warnings.length > 0 && dedupedIssues.length === 0) { + return { + ok: false, + error: `GitHub issue query failed:\n${formatIssueWarnings(warnings)}`, + }; + } + + return { + ok: true, + issues: dedupedIssues, + scannedRepos: [ + ...new Set( + scannedRepos.length > 0 ? scannedRepos : inferredRepos.length > 0 ? inferredRepos : repos, + ), + ], + warnings, + }; +} + +function formatIssueList(issues: GitHubIssue[]): string { + if (issues.length === 0) return "No open issues found."; + const repoLabels = new Set(issues.map((issue) => getIssueRepository(issue)).filter(Boolean)); + const includeRepository = repoLabels.size > 1; + return issues + .map((issue, i) => { + const labels = issue.labels.map((l) => l.name).join(", "); + const labelStr = labels ? ` [${labels}]` : ""; + const repoPrefix = includeRepository + ? `${getIssueRepository(issue) ?? issue.repository ?? "unknown"}#${issue.number}` + : `#${issue.number}`; + return `${i + 1}. ${repoPrefix} — ${issue.title}${labelStr}`; + }) + .join("\n"); +} + +// --------------------------------------------------------------------------- +// Spawn with silent retry +// --------------------------------------------------------------------------- + +async function spawnWithRetry( + config: PluginConfig, + issueArgs: string[], + maxRetries: number = 3, +): Promise<{ ok: true; output: string } | { ok: false; error: string }> { + let lastResult: { ok: true; output: string } | { ok: false; error: string } | undefined; + for (let attempt = 0; attempt < maxRetries; attempt++) { + lastResult = tryRunAo(config, issueArgs, 30_000); + if (lastResult.ok) return lastResult; + // Only retry on transient errors, not config/auth errors + if ( + lastResult.error.includes("not found") || + lastResult.error.includes("not configured") || + lastResult.error.includes("401") + ) { + return lastResult; + } + if (attempt < maxRetries - 1) { + await new Promise((r) => setTimeout(r, 3_000)); + } + } + return lastResult!; +} + +// --------------------------------------------------------------------------- +// Work-trigger detection +// --------------------------------------------------------------------------- + +const WORK_TRIGGERS = [ + "what needs", + "what should i", + "what do i need", + "start working", + "morning", + "let's go", + "lets go", + "what's going on", + "whats going on", + "status update", + "check my repos", + "check my issues", + "check issues", + "any issues", + "what's on the board", + "whats on the board", + "what can i work on", + "what to work on", + "work on today", + "what's open", + "whats open", + "open issues", + "scan my repos", + "scan repos", + "scan issues", + "engineering update", + "dev update", + "project update", + "anything to do", + "what's pending", + "whats pending", + "ready to work", + "what's the plan", + "whats the plan", +]; + +function isWorkRelated(message: string): boolean { + const lower = message.toLowerCase(); + return WORK_TRIGGERS.some((t) => lower.includes(t)); +} + +// --------------------------------------------------------------------------- +// Plugin entry point +// --------------------------------------------------------------------------- + +export default function (api: PluginApi) { + const config: PluginConfig = api.pluginConfig || {}; + + // ========================================================================= + // HOOKS — intercept work-related messages and inject live data + // + // OpenClaw hook names (see docs.openclaw.ai/concepts/agent-loop): + // message_received — inbound message arrives from any channel + // before_prompt_build — runs after session load, can inject context via + // event.appendSystemContext / event.prependContext + // ========================================================================= + + /** Build a live-data context block from AO + GitHub */ + function buildLiveContext(): string | null { + try { + const issuesResult = fetchIssues(config); + const sessionsResult = tryRunAo(config, ["status"], 10_000); + + const issuesSummary = !issuesResult.ok + ? issuesResult.error + : issuesResult.issues.length > 0 + ? [ + `Open issues (${issuesResult.issues.length}${issuesResult.scannedRepos.length > 1 ? ` across ${issuesResult.scannedRepos.length} repos` : ""}):`, + formatIssueList(issuesResult.issues), + issuesResult.warnings.length > 0 + ? `GitHub warnings:\n${formatIssueWarnings(issuesResult.warnings)}` + : null, + ] + .filter(Boolean) + .join("\n") + : `No open issues found${issuesResult.scannedRepos.length > 1 ? ` across ${issuesResult.scannedRepos.length} repos` : ""}.`; + + const sessionsSummary = sessionsResult.ok + ? `Active sessions:\n${sessionsResult.output}` + : "No active AO sessions (or AO not running)."; + + return [ + "=== LIVE AGENT ORCHESTRATOR DATA (just fetched — use this, NOT your memory) ===", + "", + issuesSummary, + "", + sessionsSummary, + "", + "INSTRUCTIONS: Present this data to the user. Recommend which issues to start agents on.", + "Ask for approval before spawning. Use ao_batch_spawn after they approve.", + "Do NOT answer from memory about their projects — this live data supersedes everything.", + "=== END LIVE DATA ===", + ].join("\n"); + } catch (err) { + api.logger.warn(`[ao-hook] Failed to build live context: ${err}`); + return null; + } + } + + // Track pending work-related messages per session/channel to avoid + // cross-conversation interference. Uses Map with timestamps for TTL cleanup. + const pendingWorkSessions = new Map(); + const PENDING_TTL_MS = 60_000; // 60s — if prompt build doesn't fire, clean up + + function cleanStalePending() { + const now = Date.now(); + for (const [key, ts] of pendingWorkSessions) { + if (now - ts > PENDING_TTL_MS) pendingWorkSessions.delete(key); + } + } + + function getSessionKey(event: PluginEvent): string { + return event?.sessionKey || event?.sessionId || event?.channelId || "default"; + } + + // Hook 1: message_received — detect work-related inbound messages + const onMessageReceived = async (event: PluginEvent) => { + const message = + event?.message?.text || event?.message?.content || event?.text || event?.content || ""; + + if (isWorkRelated(message)) { + cleanStalePending(); + pendingWorkSessions.set(getSessionKey(event), Date.now()); + api.logger.info("[ao-hook] Work-related message detected, will inject context"); + } + }; + + // Hook 2: before_prompt_build — inject AO routing context + live data + const onBeforePromptBuild = async (event: PluginEvent) => { + const key = getSessionKey(event); + + // Inform the model that AO is available and what it offers. + // Not a command — just context so the model can make an informed choice. + const routingContext = [ + "[Agent Orchestrator] This project has AO installed — an open-source orchestrator " + + "for durable coding agents (Claude Code, Codex, OpenCode). ao_spawn creates an " + + "isolated git worktree, starts an agent, and wires up feedback loops so PR reviews " + + "and CI failures automatically route to the right agent.", + ]; + + // If this is a work-related message, also inject live repo data + if (pendingWorkSessions.has(key)) { + pendingWorkSessions.delete(key); + api.logger.info("[ao-hook] Injecting live data into prompt context..."); + const context = buildLiveContext(); + if (context) routingContext.push(context); + } + + const fullContext = routingContext.join("\n\n"); + + // OpenClaw before_prompt_build supports these injection points: + if (typeof event.appendSystemContext === "function") { + event.appendSystemContext(fullContext); + } else if (typeof event.prependContext === "function") { + event.prependContext(fullContext); + } else if (event.context && typeof event.context === "object") { + // Fallback: write to context object directly + event.context.aoLiveData = fullContext; + } else if (event.messages && Array.isArray(event.messages)) { + // Last resort: push a system message + event.messages.push({ role: "system", content: fullContext }); + } + + api.logger.info("[ao-hook] Injected AO context into prompt"); + }; + + // Register hooks using the correct OpenClaw event names + const register = (name: string, handler: (event: PluginEvent) => Promise) => { + try { + if (typeof api.on === "function") { + api.on(name, handler, { priority: 10 }); + } else if (typeof api.registerHook === "function") { + api.registerHook(name, handler, { priority: 10 }); + } + } catch { + api.logger.warn(`[ao-hook] Failed to register hook: ${name}`); + } + }; + + register("message_received", onMessageReceived); + register("before_prompt_build", onBeforePromptBuild); + + api.logger.info("[ao-hook] Hooks registered (message_received, before_prompt_build)"); + + // ========================================================================= + // SLASH COMMAND — single /ao command with subcommand parsing + // ========================================================================= + + api.registerCommand({ + name: "ao", + description: + "Agent Orchestrator — /ao sessions | status | spawn | issues | batch-spawn | retry | kill | doctor", + acceptsArgs: true, + requireAuth: true, + handler: async (ctx: CommandContext) => { + const raw = (ctx.args || "").trim(); + const parts = raw.split(/\s+/); + const subcommand = parts[0]?.toLowerCase() || "help"; + const rest = parts.slice(1).join(" ").trim(); + + // Sanitize user input: strip leading dashes to prevent flag injection + const sanitizeArg = (arg: string): string => arg.replace(/^-+/, ""); + const isValidIssueId = (s: string): boolean => /^#?\d+$/.test(s.trim()); + const isValidSessionId = (s: string): boolean => /^[\w-]+$/.test(s.trim()); + + switch (subcommand) { + case "sessions": { + const result = tryRunAo(config, ["status"]); + if (!result.ok) return { text: `Failed to get sessions:\n${result.error}` }; + return { text: result.output || "No active sessions." }; + } + + case "status": { + // `ao status` shows all sessions; no per-session lookup available + const result = tryRunAo(config, ["status"]); + if (!result.ok) return { text: `Failed:\n${result.error}` }; + return { text: result.output }; + } + + case "spawn": { + if (!rest) return { text: "Usage: /ao spawn " }; + const issueArg = sanitizeArg(rest.split(/\s+/)[0]); + if (!isValidIssueId(issueArg)) + return { + text: `Invalid issue identifier: ${issueArg}. Expected a number like 42 or #42.`, + }; + const result = await spawnWithRetry(config, ["spawn", issueArg]); + if (!result.ok) return { text: `Failed to spawn:\n${result.error}` }; + return { text: result.output }; + } + + case "issues": { + const issuesResult = fetchIssues(config, { repo: rest || undefined }); + if (!issuesResult.ok) return { text: issuesResult.error }; + + const lines = [formatIssueList(issuesResult.issues)]; + if (issuesResult.warnings.length > 0) { + lines.push(""); + lines.push("GitHub warnings:"); + lines.push(formatIssueWarnings(issuesResult.warnings)); + } + + return { text: lines.join("\n") }; + } + + case "batch-spawn": { + if (!rest) return { text: "Usage: /ao batch-spawn ..." }; + const issueArgs = rest.split(/\s+/).map(sanitizeArg); + if (!issueArgs.every(isValidIssueId)) + return { text: `Invalid issue identifiers. Expected numbers like: 42 43 44` }; + const result = tryRunAo(config, ["batch-spawn", ...issueArgs], 60_000); + if (!result.ok) return { text: `Failed to batch-spawn:\n${result.error}` }; + return { text: result.output }; + } + + case "retry": { + if (!rest) return { text: "Usage: /ao retry " }; + const sessionId = sanitizeArg(rest.trim()); + if (!isValidSessionId(sessionId)) + return { text: `Invalid session ID: ${rest}. Expected format like ao-42.` }; + const result = tryRunAo(config, ["send", sessionId, "Please retry the failed task."]); + if (!result.ok) return { text: `Failed to send retry:\n${result.error}` }; + return { text: `Retry sent to session ${sessionId}.` }; + } + + case "kill": { + if (!rest) return { text: "Usage: /ao kill " }; + const sessionId = sanitizeArg(rest.trim()); + if (!isValidSessionId(sessionId)) + return { text: `Invalid session ID: ${rest}. Expected format like ao-42.` }; + const result = tryRunAo(config, ["session", "kill", sessionId]); + if (!result.ok) return { text: `Failed to kill session:\n${result.error}` }; + return { text: `Session ${sessionId} killed.` }; + } + + case "doctor": { + const result = tryRunAo(config, ["doctor"], 30_000); + if (!result.ok) return { text: `Failed to run doctor:\n${result.error}` }; + return { text: result.output }; + } + + case "setup": { + // Auto-configure OpenClaw settings for AO plugin + const steps: string[] = []; + const runSetup = (bin: string, args: string[]): boolean => { + try { + execFileSync(bin, args, { encoding: "utf-8", timeout: 10_000 }); + return true; + } catch { + return false; + } + }; + + // 1. tools.profile must be "full" for plugin tools to be visible + if (runSetup("openclaw", ["config", "set", "tools.profile", "full"])) + steps.push("✅ tools.profile → full"); + else steps.push("❌ Failed to set tools.profile"); + + // 2. Allow plugin tools + const mergedToolsAllow = mergeStringLists( + readOpenClawStringArraySetting("tools.allow", ["tools", "allow"]), + ["group:plugins"], + ); + if ( + runSetup("openclaw", ["config", "set", "tools.allow", JSON.stringify(mergedToolsAllow)]) + ) { + steps.push(`✅ tools.allow → ${mergedToolsAllow.join(", ")}`); + } else steps.push("❌ Failed to set tools.allow"); + + // 3. Trust the plugin + const mergedPluginsAllow = mergeStringLists( + readOpenClawStringArraySetting("plugins.allow", ["plugins", "allow"]), + ["agent-orchestrator"], + ); + if ( + runSetup("openclaw", [ + "config", + "set", + "plugins.allow", + JSON.stringify(mergedPluginsAllow), + ]) + ) { + steps.push(`✅ plugins.allow → ${mergedPluginsAllow.join(", ")}`); + } else steps.push("❌ Failed to set plugins.allow"); + + // 4. Group chat settings + if (runSetup("openclaw", ["config", "set", "messages.groupChat.historyLimit", "100"])) + steps.push("✅ historyLimit → 100"); + else steps.push("⚠️ Could not set historyLimit"); + + steps.push(""); + steps.push("⚡ Restart the gateway to apply: pm2 restart openclaw-gateway"); + steps.push("Then verify with: /ao doctor"); + steps.push(""); + steps.push("⚠️ Action required — run these once to avoid conflicts:"); + steps.push(" openclaw config set skills.entries.coding-agent.enabled false"); + steps.push(" openclaw config set skills.entries.gh-issues.enabled false"); + steps.push(' openclaw config set tools.deny \'["exec","write","str_replace_based_edit_tool","create_file","str_replace_editor"]\''); + steps.push("Without these, the bot may code directly instead of delegating to AO."); + + return { text: `AO Plugin Setup\n\n${steps.join("\n")}` }; + } + + default: + return { + text: [ + "Agent Orchestrator commands:", + " /ao sessions — list all sessions", + " /ao status — all sessions overview", + " /ao issues [owner/repo] — list open issues", + " /ao spawn — 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: + "Returns live session data from Agent Orchestrator — what agents are running, " + + "their status, branches, and progress. Use when the user asks about status or progress.", + parameters: { type: "object", properties: {}, required: [] }, + async execute() { + const result = tryRunAo(config, ["status"]); + if (!result.ok) { + return { + content: [{ type: "text", text: `Failed to get sessions: ${result.error}` }], + isError: true, + }; + } + return { + content: [{ type: "text", text: result.output || "No active sessions." }], + }; + }, + }); + + api.registerTool({ + name: "ao_issues", + description: + "Returns live GitHub issue data — open issues, labels, assignees, and priorities. " + + "Use when the user asks about work, tasks, issues, or what needs attention.", + parameters: { + type: "object", + properties: { + repo: { + type: "string", + description: "GitHub repo in owner/repo format. Omit to scan the default repo.", + }, + labels: { + type: "string", + description: "Comma-separated label filter (e.g. 'bug,P1'). Optional.", + }, + }, + required: [], + }, + async execute(_toolCallId: string, params: { repo?: string; labels?: string }) { + const result = fetchIssues(config, params); + if (!result.ok) { + return { + content: [{ type: "text", text: result.error }], + isError: true, + }; + } + + const lines = [formatIssueList(result.issues)]; + if (result.warnings.length > 0) { + lines.push(""); + lines.push("GitHub warnings:"); + lines.push(formatIssueWarnings(result.warnings)); + } + + return { content: [{ type: "text", text: lines.join("\n") }] }; + }, + }); + + api.registerTool({ + name: "ao_spawn", + description: + "Spawn a durable coding agent (Claude Code, Codex, or OpenCode) on a task. " + + "Creates an isolated git worktree, starts the agent, and wires up feedback " + + "loops — CI failures and PR reviews automatically route back to the agent. " + + "Works with issue numbers (#42) or without for freeform tasks.", + parameters: { + type: "object", + properties: { + issue: { + type: "string", + description: + "Issue identifier (e.g. #42). Optional — omit for freeform tasks, then use ao_send to describe the work.", + }, + agent: { type: "string", description: "Override agent plugin (e.g. codex, claude-code)" }, + claimPr: { + type: "string", + description: "Immediately claim an existing PR number for the session", + }, + decompose: { + type: "boolean", + description: "Decompose issue into subtasks before spawning", + }, + }, + }, + async execute( + _toolCallId: string, + params: { issue?: string; agent?: string; claimPr?: string; decompose?: boolean }, + ) { + const args = ["spawn"]; + if (params.issue) { + args.push(sanitizeCliArg(params.issue)); + } else { + // Freeform spawn — ao CLI supports bare `ao spawn` which creates + // a session without an issue. Use ao_send afterward to describe the task. + api.logger.info("[ao_spawn] Spawning without issue — freeform session"); + } + if (params.agent) args.push("--agent", sanitizeCliArg(params.agent)); + if (params.claimPr) args.push("--claim-pr", sanitizeCliArg(params.claimPr)); + 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, + }; + } + const spawnOutput = params.issue + ? result.output + : result.output + + "\n\nNote: This is a freeform session (no issue). Use ao_send to describe the task to the agent."; + return { content: [{ type: "text", text: spawnOutput }] }; + }, + }); + + api.registerTool({ + name: "ao_batch_spawn", + description: + "Spawn durable coding agents for multiple GitHub issues in parallel. " + + "Always confirm the list with the user before calling this. " + + "Each agent gets its own isolated worktree with CI and PR review feedback loops.", + parameters: { + type: "object", + properties: { + issues: { + type: "array", + items: { type: "string" }, + description: "List of GitHub issue numbers", + }, + }, + required: ["issues"], + }, + async execute(_toolCallId: string, params: { issues: string[] }) { + const result = tryRunAo(config, ["batch-spawn", ...params.issues.map(sanitizeCliArg)], 60_000); + + if (!result.ok) { + return { + content: [{ type: "text", text: `Failed to batch-spawn: ${result.error}` }], + isError: true, + }; + } + + // Schedule auto follow-ups + const checkStatus = (label: string) => { + const status = tryRunAo(config, ["status"], 10_000); + const msg = status.ok ? status.output : "Could not reach AO for status check."; + try { + api.runtime?.sendMessageToDefaultSession?.( + `${label}:\n\n${msg}\n\nNeed me to do anything?`, + ); + } catch { + api.logger.info(`[ao-followup] ${label}: ${msg}`); + } + }; + + batchSpawnFollowUpTimeouts.push( + setTimeout(() => checkStatus("Progress check (3 min)"), 3 * 60_000), + ); + batchSpawnFollowUpTimeouts.push( + setTimeout(() => checkStatus("Status update (8 min)"), 8 * 60_000), + ); + + api.logger.info("[ao-batch] Scheduled auto follow-ups at 3min and 8min"); + + return { + content: [ + { + type: "text", + text: + result.output + + "\n\nI'll check status automatically in a few minutes and update you.", + }, + ], + }; + }, + }); + + api.registerTool({ + name: "ao_send", + description: "Send a message to a running Agent Orchestrator session.", + parameters: { + type: "object", + properties: { + sessionId: { type: "string", description: "The AO session ID (e.g. ao-5)" }, + message: { type: "string", description: "Message to send" }, + }, + required: ["sessionId", "message"], + }, + async execute(_toolCallId: string, params: { sessionId: string; message: string }) { + const result = tryRunAo(config, ["send", sanitizeCliArg(params.sessionId), params.message]); + if (!result.ok) { + return { + content: [{ type: "text", text: `Failed to send: ${result.error}` }], + isError: true, + }; + } + return { + content: [{ type: "text", text: `Message sent to ${params.sessionId}.` }], + }; + }, + }); + + api.registerTool({ + name: "ao_kill", + description: + "Kill an Agent Orchestrator session. Always confirm with the user before calling this.", + parameters: { + type: "object", + properties: { + sessionId: { type: "string", description: "The AO session ID to kill" }, + }, + required: ["sessionId"], + }, + async execute(_toolCallId: string, params: { sessionId: string }) { + const result = tryRunAo(config, ["session", "kill", sanitizeCliArg(params.sessionId)]); + if (!result.ok) { + return { + content: [{ type: "text", text: `Failed to kill: ${result.error}` }], + isError: true, + }; + } + return { + content: [ + { type: "text", text: `Session ${params.sessionId} killed and worktree cleaned up.` }, + ], + }; + }, + }); + + api.registerTool({ + name: "ao_doctor", + description: "Run Agent Orchestrator health checks. Use when troubleshooting.", + parameters: { type: "object", properties: {}, required: [] }, + async execute() { + const result = tryRunAo(config, ["doctor"], 30_000); + if (!result.ok) { + return { + content: [{ type: "text", text: `Doctor failed: ${result.error}` }], + isError: true, + }; + } + return { content: [{ type: "text", text: result.output }] }; + }, + }); + + api.registerTool({ + name: "ao_review_check", + description: + "Check PRs for review comments and trigger agents to address them. " + + "Use when the user asks to check reviews, handle PR feedback, or address reviewer comments. " + + "Optionally pass a project ID to filter.", + parameters: { + type: "object", + properties: { + project: { type: "string", description: "Project ID (checks all if omitted)" }, + dryRun: { type: "boolean", description: "Show what would be done without acting" }, + }, + required: [], + }, + async execute(_toolCallId: string, params: { project?: string; dryRun?: boolean }) { + const args = ["review-check"]; + if (params.project) args.push(sanitizeCliArg(params.project)); + if (params.dryRun) args.push("--dry-run"); + const result = tryRunAo(config, args, 30_000); + if (!result.ok) { + return { + content: [{ type: "text", text: `Review check failed: ${result.error}` }], + isError: true, + }; + } + return { + content: [{ type: "text", text: result.output || "No review comments to address." }], + }; + }, + }); + + api.registerTool({ + name: "ao_verify", + description: + "Mark an issue as verified (or failed) after checking the fix on staging. " + + "Use when the user confirms a fix works or reports it doesn't. " + + "Use with --list to show all merged-but-unverified issues.", + parameters: { + type: "object", + properties: { + issue: { type: "string", description: "Issue number to verify" }, + project: { type: "string", description: "Project ID (required if multiple projects)" }, + fail: { type: "boolean", description: "Mark verification as failed instead of passing" }, + comment: { type: "string", description: "Custom comment to add" }, + list: { type: "boolean", description: "List all issues with merged-unverified label" }, + }, + required: [], + }, + async execute( + _toolCallId: string, + params: { + issue?: string; + project?: string; + fail?: boolean; + comment?: string; + list?: boolean; + }, + ) { + const args = ["verify"]; + if (params.list) { + args.push("--list"); + if (params.project) args.push("-p", params.project); + } else { + if (!params.issue) { + return { + content: [ + { + type: "text", + text: "Need an issue number. Use list: true to see unverified issues.", + }, + ], + isError: true, + }; + } + args.push(params.issue); + if (params.project) args.push("-p", params.project); + if (params.fail) args.push("--fail"); + if (params.comment) args.push("-c", params.comment); + } + const result = tryRunAo(config, args, 15_000); + if (!result.ok) { + return { + content: [{ type: "text", text: `Verify failed: ${result.error}` }], + isError: true, + }; + } + return { content: [{ type: "text", text: result.output }] }; + }, + }); + + api.registerTool({ + name: "ao_session_cleanup", + description: + "Kill sessions where the PR is merged or the issue is closed. " + + "Cleans up stale sessions. Use dry-run first to preview.", + parameters: { + type: "object", + properties: { + project: { type: "string", description: "Project ID to filter" }, + dryRun: { type: "boolean", description: "Preview what would be cleaned up" }, + }, + required: [], + }, + async execute(_toolCallId: string, params: { project?: string; dryRun?: boolean }) { + const args = ["session", "cleanup"]; + if (params.project) args.push("-p", sanitizeCliArg(params.project)); + if (params.dryRun) args.push("--dry-run"); + const result = tryRunAo(config, args, 30_000); + if (!result.ok) { + return { + content: [{ type: "text", text: `Cleanup failed: ${result.error}` }], + isError: true, + }; + } + return { content: [{ type: "text", text: result.output || "No sessions to clean up." }] }; + }, + }); + + api.registerTool({ + name: "ao_session_restore", + description: + "Restore a terminated or crashed agent session in-place. " + + "Use when a session died unexpectedly and needs to resume.", + parameters: { + type: "object", + properties: { + sessionId: { type: "string", description: "Session name to restore" }, + }, + required: ["sessionId"], + }, + async execute(_toolCallId: string, params: { sessionId: string }) { + const result = tryRunAo(config, ["session", "restore", sanitizeCliArg(params.sessionId)], 30_000); + if (!result.ok) { + return { + content: [{ type: "text", text: `Restore failed: ${result.error}` }], + isError: true, + }; + } + return { content: [{ type: "text", text: result.output }] }; + }, + }); + + api.registerTool({ + name: "ao_session_claim_pr", + description: + "Attach an existing PR to an agent session. " + + "Use when there's a PR that was created outside AO that should be tracked.", + parameters: { + type: "object", + properties: { + pr: { type: "string", description: "Pull request number or URL" }, + sessionId: { type: "string", description: "Session name (optional)" }, + assignOnGithub: { + type: "boolean", + description: "Assign the PR to the authenticated GitHub user", + }, + }, + required: ["pr"], + }, + async execute( + _toolCallId: string, + params: { pr: string; sessionId?: string; assignOnGithub?: boolean }, + ) { + const args = ["session", "claim-pr", params.pr]; + if (params.sessionId) args.push(params.sessionId); + if (params.assignOnGithub) args.push("--assign-on-github"); + const result = tryRunAo(config, args, 15_000); + if (!result.ok) { + return { + content: [{ type: "text", text: `Claim PR failed: ${result.error}` }], + isError: true, + }; + } + return { content: [{ type: "text", text: result.output }] }; + }, + }); + + api.registerTool({ + name: "ao_session_list", + description: + "List all agent sessions with detailed info. " + + "Use for a comprehensive session listing. For a quick status overview, use ao_sessions instead.", + parameters: { + type: "object", + properties: { + project: { type: "string", description: "Project ID to filter" }, + }, + required: [], + }, + async execute(_toolCallId: string, params: { project?: string }) { + const args = ["session", "ls"]; + if (params.project) args.push("-p", params.project); + const result = tryRunAo(config, args, 15_000); + if (!result.ok) { + return { + content: [{ type: "text", text: `Session list failed: ${result.error}` }], + isError: true, + }; + } + return { content: [{ type: "text", text: result.output || "No sessions found." }] }; + }, + }); + + api.registerTool({ + name: "ao_status", + description: + "Show all sessions with branch, activity, PR, and CI status. " + + "Returns JSON when requested. Use for a comprehensive dashboard view.", + parameters: { + type: "object", + properties: { + project: { type: "string", description: "Filter by project ID" }, + json: { type: "boolean", description: "Return output as JSON for easier parsing" }, + }, + required: [], + }, + async execute(_toolCallId: string, params: { project?: string; json?: boolean }) { + const args = ["status"]; + if (params.project) args.push("-p", params.project); + if (params.json) args.push("--json"); + const result = tryRunAo(config, args, 15_000); + if (!result.ok) { + return { + content: [{ type: "text", text: `Status failed: ${result.error}` }], + isError: true, + }; + } + return { content: [{ type: "text", text: result.output }] }; + }, + }); + + // ========================================================================= + // BACKGROUND SERVICES + // ========================================================================= + + let healthInterval: ReturnType | null = null; + let boardScanInterval: ReturnType | null = null; + let boardScanInitialTimeout: ReturnType | null = null; + const batchSpawnFollowUpTimeouts: ReturnType[] = []; + 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; + } + // Clear any pending batch-spawn follow-up timeouts + for (const t of batchSpawnFollowUpTimeouts.splice(0)) { + clearTimeout(t); + } + }, + }); + + // --- Issue board scanner --- + api.registerService({ + id: "ao-board-scanner", + start: async () => { + const scanMs = config.boardScanIntervalMs ?? 1_800_000; + if (scanMs <= 0) return; + + api.logger.info(`[ao-board-scanner] Starting (every ${scanMs / 60_000}min)`); + + const scan = () => { + try { + const issuesResult = fetchIssues(config); + if (!issuesResult.ok) { + api.logger.warn(`[ao-board-scanner] ${issuesResult.error}`); + return; + } + + if (issuesResult.warnings.length > 0) { + api.logger.warn( + `[ao-board-scanner] Partial GitHub failures:\n${formatIssueWarnings(issuesResult.warnings)}`, + ); + } + + const currentIds = new Set(issuesResult.issues.map((issue) => getIssueIdentity(issue))); + + if (isFirstBoardScan) { + lastKnownIssueIds = currentIds; + isFirstBoardScan = false; + api.logger.info(`[ao-board-scanner] Baseline: ${currentIds.size} open issues`); + return; + } + + const newIssues = issuesResult.issues.filter( + (issue) => !lastKnownIssueIds.has(getIssueIdentity(issue)), + ); + + if (newIssues.length > 0) { + const summary = formatIssueList(newIssues); + + api.logger.info(`[ao-board-scanner] ${newIssues.length} new issue(s)`); + + try { + api.runtime?.sendMessageToDefaultSession?.( + `New issues detected:\n\n${summary}\n\nWant me to start agents on any of these?`, + ); + } catch { + api.logger.info(`[ao-board-scanner] New issues:\n${summary}`); + } + } + + lastKnownIssueIds = currentIds; + } catch (err) { + api.logger.warn(`[ao-board-scanner] Scan failed: ${err}`); + } + }; + + boardScanInitialTimeout = setTimeout(scan, 10_000); + boardScanInterval = setInterval(scan, scanMs); + }, + stop: async () => { + if (boardScanInitialTimeout) { + clearTimeout(boardScanInitialTimeout); + boardScanInitialTimeout = null; + } + if (boardScanInterval) { + clearInterval(boardScanInterval); + boardScanInterval = null; + } + // Clear batch-spawn follow-up timeouts here too — they may outlive the health + // service if healthPollIntervalMs <= 0 (health service never starts/stops) + for (const t of batchSpawnFollowUpTimeouts.splice(0)) { + clearTimeout(t); + } + }, + }); +} diff --git a/openclaw-plugin/openclaw.plugin.json b/openclaw-plugin/openclaw.plugin.json new file mode 100644 index 000000000..deeb5f32f --- /dev/null +++ b/openclaw-plugin/openclaw.plugin.json @@ -0,0 +1,60 @@ +{ + "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, + "required": ["aoCwd"], + "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": { + "aoCwd": { + "label": "AO working directory", + "placeholder": "/home/user/my-project", + "help": "Path to the directory containing agent-orchestrator.yaml. Required — most commands fail without this." + }, + "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..1f2fa7710 --- /dev/null +++ b/openclaw-plugin/package.json @@ -0,0 +1,31 @@ +{ + "name": "openclaw-agent-orchestrator", + "version": "0.3.1", + "description": "OpenClaw plugin for Agent Orchestrator — AI tools, slash commands, hooks, and health monitoring for managing parallel coding agents from OpenClaw", + "license": "MIT", + "author": "Dhruv Sharma (https://github.com/illegalcall)", + "engines": { + "node": ">=18.0.0" + }, + "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/ao/CHANGELOG.md b/packages/ao/CHANGELOG.md index 2f740469c..fcb3ebfbb 100644 --- a/packages/ao/CHANGELOG.md +++ b/packages/ao/CHANGELOG.md @@ -1,5 +1,18 @@ # @composio/ao +## 0.2.1 + +### Patch Changes + +- ac625c3: Fix startup onboarding and install reliability: + - Repair npm global install startup path by improving package resolution and web package discovery hints. + - Make `ao start` prerequisite installs explicit and interactive for required tools (`tmux`, `git`) with clearer fallback guidance. + - Keep `ao spawn` preflight check-only for `tmux` (no implicit install). + - Remove redundant agent runtime re-detection during config generation. + +- Updated dependencies [ac625c3] + - @composio/ao-cli@0.2.1 + ## 0.2.0 ### Minor Changes diff --git a/packages/ao/package.json b/packages/ao/package.json index 06c7bd4ca..daf903300 100644 --- a/packages/ao/package.json +++ b/packages/ao/package.json @@ -1,6 +1,6 @@ { "name": "@composio/ao", - "version": "0.2.0", + "version": "0.2.1", "description": "Orchestrate parallel AI coding agents — global CLI wrapper", "license": "MIT", "type": "module", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 09f6a411a..1d50b2af6 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,15 @@ # @composio/ao-cli +## 0.2.1 + +### Patch Changes + +- ac625c3: Fix startup onboarding and install reliability: + - Repair npm global install startup path by improving package resolution and web package discovery hints. + - Make `ao start` prerequisite installs explicit and interactive for required tools (`tmux`, `git`) with clearer fallback guidance. + - Keep `ao spawn` preflight check-only for `tmux` (no implicit install). + - Remove redundant agent runtime re-detection during config generation. + ## 0.2.0 ### Minor Changes 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..a5c776f5d --- /dev/null +++ b/packages/cli/__tests__/commands/setup.test.ts @@ -0,0 +1,470 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Command } from "commander"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { parse as parseYaml } from "yaml"; + +// --------------------------------------------------------------------------- +// Hoisted mocks — must be defined before any imports that use them +// --------------------------------------------------------------------------- + +const { mockFindConfigFile } = vi.hoisted(() => ({ + mockFindConfigFile: vi.fn(), +})); + +const { mockReadFileSync, mockWriteFileSync, mockExistsSync, mockMkdirSync } = vi.hoisted(() => ({ + mockReadFileSync: vi.fn(), + mockWriteFileSync: vi.fn(), + mockExistsSync: vi.fn(), + mockMkdirSync: 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: (...args: unknown[]) => mockExistsSync(...args), + mkdirSync: (...args: unknown[]) => mockMkdirSync(...args), + }; +}); + +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(() => {}); + mockExistsSync.mockReturnValue(false); + mockMkdirSync.mockImplementation(() => undefined); + 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", + ]); + + // Code writes YAML config + shell profile export — at least one write + expect(mockWriteFileSync).toHaveBeenCalled(); + 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 and skips validation", 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", + ]); + + // Non-interactive mode skips pre-write validation + expect(mockValidateToken).not.toHaveBeenCalled(); + expect(mockWriteFileSync).toHaveBeenCalled(); + }); + + it("reads URL from OPENCLAW_GATEWAY_URL env var and skips validation", async () => { + process.env["OPENCLAW_GATEWAY_URL"] = "http://remote:18789"; + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "openclaw", + "--token", + "tok", + "--non-interactive", + ]); + + // Non-interactive mode skips pre-write validation + expect(mockValidateToken).not.toHaveBeenCalled(); + expect(mockWriteFileSync).toHaveBeenCalled(); + }); + + it("normalizes OPENCLAW_GATEWAY_URL without double-appending hooks path", async () => { + process.env["OPENCLAW_GATEWAY_URL"] = "http://remote:18789/hooks/agent"; + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "openclaw", + "--token", + "tok", + "--non-interactive", + ]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + expect(writtenYaml).toContain("url: http://remote:18789/hooks/agent"); + expect(writtenYaml).not.toContain("/hooks/agent/hooks/agent"); + }); + + it("skips token validation and writes config in non-interactive mode", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "openclaw", + "--url", + "http://127.0.0.1:18789/hooks/agent", + "--token", + "good-token", + "--non-interactive", + ]); + + // Non-interactive setup skips pre-write validation (gateway may not have + // the token yet on a fresh install — user restarts gateway after setup) + expect(mockValidateToken).not.toHaveBeenCalled(); + expect(mockWriteFileSync).toHaveBeenCalled(); + }); + }); + + 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 parsed = parseYaml(writtenYaml) as { defaults?: { notifiers?: string[] } }; + expect(parsed.defaults?.notifiers?.filter((name) => name === "openclaw")).toHaveLength(1); + }); + + 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("merges existing allowedSessionKeyPrefixes in openclaw.json", async () => { + const openclawConfigPath = join(homedir(), ".openclaw", "openclaw.json"); + + mockExistsSync.mockImplementation((path: string) => path === openclawConfigPath); + mockReadFileSync.mockImplementation((path: string) => { + if (path === "/tmp/agent-orchestrator.yaml") { + return MINIMAL_CONFIG; + } + if (path === openclawConfigPath) { + return JSON.stringify({ + hooks: { + enabled: false, + token: "old-token", + allowRequestSessionKey: false, + allowedSessionKeyPrefixes: ["legacy:", "hook:"], + }, + otherConfig: true, + }); + } + return ""; + }); + + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "openclaw", + "--url", + "http://127.0.0.1:18789/hooks/agent", + "--token", + "new-token", + "--non-interactive", + ]); + + const openclawWrite = mockWriteFileSync.mock.calls.find( + ([path]) => path === openclawConfigPath, + ); + expect(openclawWrite).toBeDefined(); + + const writtenJson = JSON.parse(openclawWrite![1] as string) as { + hooks: { + token: string; + enabled: boolean; + allowRequestSessionKey: boolean; + allowedSessionKeyPrefixes: string[]; + }; + otherConfig: boolean; + }; + + expect(writtenJson.otherConfig).toBe(true); + expect(writtenJson.hooks.token).toBe("new-token"); + expect(writtenJson.hooks.enabled).toBe(true); + expect(writtenJson.hooks.allowRequestSessionKey).toBe(true); + expect(writtenJson.hooks.allowedSessionKeyPrefixes).toEqual(["legacy:", "hook:"]); + }); + + 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("skips validation and writes config even with bad token in non-interactive mode", async () => { + mockValidateToken.mockResolvedValue({ valid: false, error: "Token rejected" }); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "openclaw", + "--url", + "http://127.0.0.1:18789/hooks/agent", + "--token", + "bad-token", + "--non-interactive", + ]); + + // nonInteractiveSetup skips pre-write validation, so config should still be written + expect(mockWriteFileSync).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("auto-generates token when --token missing in non-interactive mode", async () => { + delete process.env["OPENCLAW_HOOKS_TOKEN"]; + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "openclaw", + "--url", + "http://127.0.0.1:18789/hooks/agent", + "--non-interactive", + ]); + + // nonInteractiveSetup auto-generates a token when none is provided + expect(mockWriteFileSync).toHaveBeenCalled(); + }); + }); +}); 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..037d428a2 --- /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(false); + }); + }); +}); diff --git a/packages/cli/__tests__/scripts/doctor-script.test.ts b/packages/cli/__tests__/scripts/doctor-script.test.ts index af99048ef..02b8efe9e 100644 --- a/packages/cli/__tests__/scripts/doctor-script.test.ts +++ b/packages/cli/__tests__/scripts/doctor-script.test.ts @@ -29,6 +29,7 @@ function createFakeBinary(binDir: string, name: string, body: string): void { function createHealthyRepo(tempRoot: string): string { const fakeRepo = join(tempRoot, "repo"); mkdirSync(join(fakeRepo, "node_modules"), { recursive: true }); + mkdirSync(join(fakeRepo, "packages", "ao"), { recursive: true }); mkdirSync(join(fakeRepo, "packages", "core", "dist"), { recursive: true }); mkdirSync(join(fakeRepo, "packages", "cli", "dist"), { recursive: true }); mkdirSync(join(fakeRepo, "packages", "agent-orchestrator", "bin"), { recursive: true }); @@ -98,7 +99,7 @@ describe("scripts/ao-doctor.sh", () => { const result = spawnSync("bash", [scriptPath], { env: { ...process.env, - PATH: `${binDir}:${process.env.PATH || ""}`, + PATH: `${binDir}:/bin:/usr/bin`, AO_REPO_ROOT: fakeRepo, AO_CONFIG_PATH: configPath, }, @@ -149,7 +150,7 @@ describe("scripts/ao-doctor.sh", () => { const result = spawnSync("bash", [scriptPath, "--fix"], { env: { ...process.env, - PATH: `${binDir}:${process.env.PATH || ""}`, + PATH: `${binDir}:/bin:/usr/bin`, AO_REPO_ROOT: fakeRepo, AO_CONFIG_PATH: configPath, AO_DOCTOR_TMP_ROOT: tmpRoot, diff --git a/packages/cli/__tests__/scripts/update-script.test.ts b/packages/cli/__tests__/scripts/update-script.test.ts index 694f0cc5e..c17c0bbb3 100644 --- a/packages/cli/__tests__/scripts/update-script.test.ts +++ b/packages/cli/__tests__/scripts/update-script.test.ts @@ -22,6 +22,7 @@ describe("scripts/ao-update.sh", () => { const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-script-")); const fakeRepo = join(tempRoot, "repo"); mkdirSync(join(fakeRepo, "packages", "cli"), { recursive: true }); + mkdirSync(join(fakeRepo, "packages", "ao"), { recursive: true }); const binDir = join(tempRoot, "bin"); mkdirSync(binDir, { recursive: true }); @@ -79,6 +80,7 @@ esac\nexit 0`, const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-smoke-")); const fakeRepo = join(tempRoot, "repo"); mkdirSync(join(fakeRepo, "packages", "agent-orchestrator", "bin"), { recursive: true }); + mkdirSync(join(fakeRepo, "packages", "ao"), { recursive: true }); writeFileSync( join(fakeRepo, "packages", "agent-orchestrator", "bin", "ao.js"), "#!/usr/bin/env node\n", @@ -178,6 +180,7 @@ exit 0`, const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-post-dirty-")); const fakeRepo = join(tempRoot, "repo"); mkdirSync(join(fakeRepo, "packages", "cli"), { recursive: true }); + mkdirSync(join(fakeRepo, "packages", "ao"), { recursive: true }); const binDir = join(tempRoot, "bin"); mkdirSync(binDir, { recursive: true }); diff --git a/packages/cli/package.json b/packages/cli/package.json index 42b662921..50371acab 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@composio/ao-cli", - "version": "0.2.0", + "version": "0.2.1", "description": "CLI for agent-orchestrator — the `ao` command", "license": "MIT", "type": "module", @@ -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..65e416327 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -1,17 +1,221 @@ import type { Command } from "commander"; -import { executeScriptCommand } from "../lib/script-runner.js"; +import chalk from "chalk"; +import { findConfigFile, loadConfig, type Notifier, type 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 +// --------------------------------------------------------------------------- + +function pass(msg: string): void { + console.log(`${chalk.green("PASS")} ${msg}`); +} + +function warn(msg: string): void { + console.log(`${chalk.yellow("WARN")} ${msg}`); +} + +/** Returns a fail() recorder and a count() getter — local per invocation, no shared state. */ +function makeFailCounter(): { fail: (msg: string) => void; count: () => number } { + let n = 0; + return { + fail(msg: string): void { + n++; + console.log(`${chalk.red("FAIL")} ${msg}`); + }, + count(): number { + return n; + }, + }; +} + +// --------------------------------------------------------------------------- +// Notifier connectivity checks (Gap 2) +// --------------------------------------------------------------------------- + +async function checkOpenClawNotifier( + config: OrchestratorConfig, + fail: (msg: string) => void, +): 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"; + // Resolve ${ENV_VAR} placeholders written by `ao setup openclaw` — the config + // stores the literal string "${OPENCLAW_HOOKS_TOKEN}" which is truthy but wrong. + const rawToken = typeof openclawConfig["token"] === "string" ? openclawConfig["token"] : undefined; + const envVarMatch = rawToken?.match(/^\$\{([^}]+)\}$/); + const token = (envVarMatch ? process.env[envVarMatch[1]] : rawToken) ?? 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, + fail: (msg: string) => void, +): 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, fail); + } + + // 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, + fail: (msg: string) => void, +): 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 }) => { + const { fail, count: failCount } = makeFailCounter(); + + // 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: number; + 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) { + let config: ReturnType | undefined; + try { + config = loadConfig(configPath); + await checkNotifierConnectivity(config, fail); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + warn(`Notifier connectivity check failed: ${message}`); + } + + // 3. Send test notifications if requested (separate catch for accurate errors) + if (opts.testNotify && config) { + try { + await sendTestNotifications(config, fail); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + fail(`Sending test notifications failed: ${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 || failCount() > 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..451c92445 --- /dev/null +++ b/packages/cli/src/commands/setup.ts @@ -0,0 +1,531 @@ +/** + * `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, parseDocument } 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; +} + +function normalizeOpenClawHooksUrl(url: string): string { + const normalized = url.trim().replace(/\/+$/, ""); + return normalized.endsWith(HOOKS_PATH) ? normalized : `${normalized}${HOOKS_PATH}`; +} + +// --------------------------------------------------------------------------- +// 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 + const url = normalizeOpenClawHooksUrl(urlInput as string); + + // --- 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 { + const rawUrl = opts.url ?? process.env["OPENCLAW_GATEWAY_URL"]; + const token = opts.token ?? process.env["OPENCLAW_HOOKS_TOKEN"]; + + if (!rawUrl) { + 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", + ); + } + + let url = rawUrl; + 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 + url = normalizeOpenClawHooksUrl(url); + + 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"); + + // Use parseDocument to preserve YAML comments during round-trip + const doc = parseDocument(rawYaml); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rawConfig = (doc.toJS() as Record) ?? {}; + + // Write the env-var placeholder so the raw token is never committed to + // version control. ao setup openclaw exports the real value to the shell + // profile; the notifier plugin resolves it at runtime (env var → openclaw.json + // fallback for daemon contexts where the shell profile isn't sourced). + if (!rawConfig.notifiers) rawConfig.notifiers = {}; + rawConfig.notifiers.openclaw = { + plugin: "openclaw", + url: resolved.url, + token: "$" + "{OPENCLAW_HOOKS_TOKEN}", // env-var placeholder, not a JS template + 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) { + // Seed from existing defaults.notifiers so we don't silently drop notifiers + // (e.g. desktop) that the user already had for all priorities. + const base = [...new Set([...(rawConfig.defaults.notifiers as string[]), "openclaw"])]; + rawConfig.notificationRouting = { + urgent: [...base], + action: [...base], + warning: [...base], + info: [...base], + }; + } 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"); + } + } + } + + // Update the document tree from the modified plain object while preserving comments + doc.setIn(["notifiers"], rawConfig.notifiers); + doc.setIn(["defaults"], rawConfig.defaults); + doc.setIn(["notificationRouting"], rawConfig.notificationRouting); + + writeFileSync(configPath, doc.toString({ 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) ?? {}; + const existingPrefixes = Array.isArray(existingHooks.allowedSessionKeyPrefixes) + ? existingHooks.allowedSessionKeyPrefixes.filter( + (prefix): prefix is string => typeof prefix === "string", + ) + : []; + const allowedSessionKeyPrefixes = existingPrefixes.includes("hook:") + ? existingPrefixes + : [...existingPrefixes, "hook:"]; + + config.hooks = { + ...existingHooks, + enabled: true, + token, + allowRequestSessionKey: true, + allowedSessionKeyPrefixes, + }; + + 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); + + // Sanitize token: escape shell-special characters to prevent injection + // when the profile is sourced. Single-quote the value and escape any + // embedded single quotes (the only character that breaks '...' quoting). + const safeToken = token.replace(/'/g, "'\\''"); + const exportLine = `export OPENCLAW_HOOKS_TOKEN='${safeToken}'`; + + // Check if it already exists (use the same regex for detection and replacement + // to avoid silent no-ops when the line is commented, lacks the export prefix, + // or has leading whitespace) + // Negative lookahead excludes commented lines (e.g. # export OPENCLAW_HOOKS_TOKEN=...) + const existingExportRegex = /^(?!\s*#)\s*(?:export\s+)?OPENCLAW_HOOKS_TOKEN=.*$/m; + if (existsSync(profilePath)) { + const content = readFileSync(profilePath, "utf-8"); + if (existingExportRegex.test(content)) { + // Replace the existing line + const updated = content.replace(existingExportRegex, 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")} + +AO config was written successfully. Add this to your OpenClaw config (${chalk.dim("~/.openclaw/openclaw.json")}): + + ${chalk.cyan(`{ + "hooks": { + "enabled": true, + "token": "", + "allowRequestSessionKey": true, + "allowedSessionKeyPrefixes": ["hook:"] + } + }`)} +`; + + if (nonInteractive) { + console.log("\nOpenClaw-side config required:"); + console.log("AO config was written successfully. Add to ~/.openclaw/openclaw.json:"); + console.log(" hooks.enabled: true"); + console.log(' hooks.token: ""'); + console.log(" hooks.allowRequestSessionKey: true"); + console.log(' hooks.allowedSessionKeyPrefixes: ["hook:"]'); + } 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 (token auto-generated if not provided)") + .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..004bd1eb3 100644 --- a/packages/cli/src/lib/config-instruction.ts +++ b/packages/cli/src/lib/config-instruction.ts @@ -22,7 +22,7 @@ defaults: agent: claude-code # claude-code | aider | codex | opencode workspace: worktree # worktree | clone notifiers: # List of active notifier plugins - - desktop # desktop | slack | webhook | composio | openclaw + - desktop # desktop | discord | slack | webhook | composio | openclaw orchestrator: agent: claude-code # Agent for orchestrator sessions (optional override) worker: @@ -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..06db7a282 --- /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 ? `: ${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/integration-tests/src/agent-claude-code.integration.test.ts b/packages/integration-tests/src/agent-claude-code.integration.test.ts index 2c65a2d45..559f931ba 100644 --- a/packages/integration-tests/src/agent-claude-code.integration.test.ts +++ b/packages/integration-tests/src/agent-claude-code.integration.test.ts @@ -139,6 +139,7 @@ describe.skipIf(!realProject)("path encoding & JSONL reading (real Claude data)" "user", "assistant", "system", + "last-prompt", "tool_use", "progress", "permission_request", diff --git a/packages/mobile/src/screens/SessionDetailScreen.tsx b/packages/mobile/src/screens/SessionDetailScreen.tsx index c18e760b9..77a3e9bc7 100644 --- a/packages/mobile/src/screens/SessionDetailScreen.tsx +++ b/packages/mobile/src/screens/SessionDetailScreen.tsx @@ -97,13 +97,25 @@ export default function SessionDetailScreen({ route, navigation }: Props) { ); }, [killSession, sessionId, refresh]); - const handleRestore = useCallback(async () => { - try { - await restoreSession(sessionId); - refresh(); - } catch (err) { - Alert.alert("Error", err instanceof Error ? err.message : "Failed to restore session"); - } + const handleRestore = useCallback(() => { + Alert.alert( + "Restore Session", + "This will restore the session and make it active again. Are you sure?", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Restore", + onPress: async () => { + try { + await restoreSession(sessionId); + refresh(); + } catch (err) { + Alert.alert("Error", err instanceof Error ? err.message : "Failed to restore session"); + } + }, + }, + ], + ); }, [restoreSession, sessionId, refresh]); const handleOpenTerminal = useCallback(() => { 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..b598c13d8 --- /dev/null +++ b/packages/plugins/notifier-discord/package.json @@ -0,0 +1,45 @@ +{ + "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": "rimraf dist" + }, + "dependencies": { + "@composio/ao-core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.2.3", + "rimraf": "^6.0.0", + "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..cfa0cf41c --- /dev/null +++ b/packages/plugins/notifier-discord/src/index.test.ts @@ -0,0 +1,244 @@ +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()); + + // Discord requires thread_id as a URL query param, not in the JSON body + const calledUrl = fetchMock.mock.calls[0][0]; + expect(calledUrl).toBe("https://discord.com/api/webhooks/123/abc?thread_id=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: true, 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..2e586d274 --- /dev/null +++ b/packages/plugins/notifier-discord/src/index.ts @@ -0,0 +1,219 @@ +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; + // Separate counter for 429 Retry-After waits so they don't consume the error + // retry budget — a server-mandated wait shouldn't cost a retry slot. + let rateLimitRetries = 0; + + 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: wait then retry without burning an error retry slot. + // Use Retry-After if present, otherwise fall back to retryDelayMs. + if (response.status === 429) { + if (rateLimitRetries < retries) { + const retryAfter = response.headers.get("Retry-After"); + const waitMs = retryAfter ? (parseFloat(retryAfter) || 1) * 1000 : retryDelayMs; + await new Promise((resolve) => setTimeout(resolve, waitMs)); + rateLimitRetries++; + attempt--; // undo the for-loop increment so error budget is preserved + continue; + } + // Rate-limit budget exhausted — fail immediately rather than falling through + // to the error retry path (which would compound the two counters). + const body = await response.text().catch(() => ""); + lastError = new Error(`Discord webhook rate-limited (HTTP 429)${body ? `: ${body.trim()}` : ""}`); + throw lastError; + } + + 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=${encodeURIComponent(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 (!effectiveUrl) return; + const payload = buildPayload([buildEmbed(event, actions)]); + await postWithRetry(effectiveUrl, payload, retries, retryDelayMs); + }, + + async post(message: string, _context?: NotifyContext): Promise { + if (!effectiveUrl) return null; + const payload: Record = { username, content: message }; + if (avatarUrl) payload.avatar_url = avatarUrl; + // thread_id is already passed as a URL query param via effectiveUrl + 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..0ffcb757f 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., in CI/CD pipelines or automation scripts): + +```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..840c6c85c 100644 --- a/packages/plugins/notifier-openclaw/src/index.ts +++ b/packages/plugins/notifier-openclaw/src/index.ts @@ -1,3 +1,6 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { type EventPriority, type Notifier, @@ -8,6 +11,25 @@ import { } from "@composio/ao-core"; import { isRetryableHttpStatus, normalizeRetryConfig, validateUrl } from "@composio/ao-core/utils"; +/** + * Read the hooks token from ~/.openclaw/openclaw.json as a fallback for + * daemon contexts where the shell profile (and OPENCLAW_HOOKS_TOKEN) isn't + * sourced. This file is written by `ao setup openclaw` and lives outside + * the project directory so it's never committed to version control. + */ +function readTokenFromOpenClawConfig(): string | undefined { + try { + const configPath = join(homedir(), ".openclaw", "openclaw.json"); + if (!existsSync(configPath)) return undefined; + const raw = readFileSync(configPath, "utf-8"); + const config = JSON.parse(raw) as Record; + const token = (config.hooks as Record | undefined)?.token; + return typeof token === "string" && token ? token : undefined; + } catch { + return undefined; + } +} + export const manifest = { name: "openclaw", slot: "notifier" as const, @@ -15,6 +37,8 @@ export const manifest = { version: "0.1.0", }; +const DEFAULT_TIMEOUT_MS = 10_000; + type WakeMode = "now" | "next-heartbeat"; interface OpenClawWebhookPayload { @@ -36,16 +60,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 the token configured for AO.\n` + + ` Reconfigure: ao setup openclaw`, + ); + throw lastError; + } + lastError = new Error(`OpenClaw webhook failed (${response.status}): ${body}`); if (!isRetryableHttpStatus(response.status)) { @@ -61,11 +98,22 @@ 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`, + { cause: err }, + ); + } + 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) { @@ -108,13 +156,26 @@ function formatActionsLine(actions: NotifyAction[]): string { return `Actions available: ${labels}`; } +/** + * Resolve a token value that may be a `${ENV_VAR}` placeholder (as written + * into agent-orchestrator.yaml by `ao setup openclaw`) or a literal string. + * Returns undefined for empty/unresolvable values so callers can chain `??`. + */ +function resolveEnvVarToken(raw: unknown): string | undefined { + if (typeof raw !== "string" || !raw) return undefined; + const match = raw.match(/^\$\{([^}]+)\}$/); + if (match) return process.env[match[1]] || undefined; + return raw; +} + export function create(config?: Record): Notifier { const url = (typeof config?.url === "string" ? config.url : undefined) ?? "http://127.0.0.1:18789/hooks/agent"; const token = - (typeof config?.token === "string" ? config.token : undefined) ?? - process.env.OPENCLAW_HOOKS_TOKEN; + resolveEnvVarToken(config?.token) ?? + process.env.OPENCLAW_HOOKS_TOKEN ?? + readTokenFromOpenClawConfig(); const senderName = typeof config?.name === "string" ? config.name : "AO"; const sessionKeyPrefix = typeof config?.sessionKeyPrefix === "string" ? config.sessionKeyPrefix : "hook:ao:"; @@ -127,7 +188,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/packages/web/.gitignore b/packages/web/.gitignore index 567609b12..c88d8e0d2 100644 --- a/packages/web/.gitignore +++ b/packages/web/.gitignore @@ -1 +1,2 @@ build/ +dist-server/ diff --git a/packages/web/next.config.js b/packages/web/next.config.js index 5d34044fd..654d91bc4 100644 --- a/packages/web/next.config.js +++ b/packages/web/next.config.js @@ -10,6 +10,17 @@ const nextConfig = { "@composio/ao-plugin-tracker-linear", "@composio/ao-plugin-workspace-worktree", ], + async headers() { + return [ + { + source: "/sw.js", + headers: [ + { key: "Cache-Control", value: "no-cache, no-store, must-revalidate" }, + { key: "Service-Worker-Allowed", value: "/" }, + ], + }, + ]; + }, }; export default nextConfig; diff --git a/packages/web/public/offline.html b/packages/web/public/offline.html new file mode 100644 index 000000000..ef0223de7 --- /dev/null +++ b/packages/web/public/offline.html @@ -0,0 +1,86 @@ + + + + + + Offline | ao + + + +
+
+ + + +
+

You are offline

+

The ao dashboard requires a network connection. Please check your connection and try again.

+ +
+ + diff --git a/packages/web/public/sw.js b/packages/web/public/sw.js new file mode 100644 index 000000000..6b9f046ec --- /dev/null +++ b/packages/web/public/sw.js @@ -0,0 +1,38 @@ +/* globals self, caches, fetch, Response, URL, console */ +const CACHE_NAME = "ao-pwa-v2"; +const OFFLINE_URL = "/offline.html"; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.add(OFFLINE_URL)) + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)) + ) + ) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener("fetch", (event) => { + if (event.request.mode !== "navigate") return; + + const url = new URL(event.request.url); + if (url.origin !== self.location.origin) return; + + event.respondWith( + fetch(event.request).catch((err) => { + console.warn("[SW] Navigation fetch failed:", err.message); + return caches.match(OFFLINE_URL).then((r) => + r || new Response("Offline", { status: 503, headers: { "Content-Type": "text/plain" } }) + ); + }) + ); +}); diff --git a/packages/web/src/__tests__/components.test.tsx b/packages/web/src/__tests__/components.test.tsx index 67f323801..94141fdd1 100644 --- a/packages/web/src/__tests__/components.test.tsx +++ b/packages/web/src/__tests__/components.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { CIBadge, CICheckList } from "@/components/CIBadge"; import { PRStatus } from "@/components/PRStatus"; import { SessionCard } from "@/components/SessionCard"; @@ -426,6 +426,138 @@ describe("SessionCard", () => { render(); expect(screen.getByRole("button", { name: /terminate session/i })).toBeInTheDocument(); }); + + it("prevents duplicate quick-reply preset sends while a send is in flight", async () => { + let resolveSend: (() => void) | null = null; + const onSend = vi.fn( + () => + new Promise((resolve) => { + resolveSend = resolve; + }), + ); + const session = makeSession({ + id: "respond-1", + status: "needs_input", + activity: "waiting_input", + summary: "Need approval to proceed", + }); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Continue" })); + + expect(onSend).toHaveBeenCalledTimes(1); + expect(onSend).toHaveBeenCalledWith("respond-1", "continue"); + expect(screen.getByRole("button", { name: "Sending..." })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Abort" })).toBeDisabled(); + expect(screen.getByRole("textbox", { name: /type a reply to the agent/i })).toBeDisabled(); + + fireEvent.click(screen.getByRole("button", { name: "Abort" })); + expect(onSend).toHaveBeenCalledTimes(1); + + resolveSend?.(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Sent" })).toBeInTheDocument(); + }); + }); + + it("prevents duplicate enter submits and only clears the textarea after send settles", async () => { + let resolveSend: (() => void) | null = null; + const onSend = vi.fn( + () => + new Promise((resolve) => { + resolveSend = resolve; + }), + ); + const session = makeSession({ + id: "respond-2", + status: "needs_input", + activity: "waiting_input", + summary: "Need approval to proceed", + }); + + render(); + + const input = screen.getByRole("textbox", { name: /type a reply to the agent/i }); + fireEvent.change(input, { target: { value: "please continue" } }); + fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); + + expect(onSend).toHaveBeenCalledTimes(1); + expect(onSend).toHaveBeenCalledWith("respond-2", "please continue"); + expect(screen.getByRole("textbox", { name: /type a reply to the agent/i })).toBeDisabled(); + expect(screen.getByDisplayValue("please continue")).toBeInTheDocument(); + + fireEvent.keyDown(screen.getByRole("textbox", { name: /type a reply to the agent/i }), { + key: "Enter", + code: "Enter", + }); + expect(onSend).toHaveBeenCalledTimes(1); + + resolveSend?.(); + + await waitFor(() => { + expect(screen.getByRole("textbox", { name: /type a reply to the agent/i })).toHaveValue(""); + }); + }); + + it("does not show sent state or clear reply text when quick reply send fails", async () => { + const onSend = vi.fn(() => Promise.reject(new Error("network failed"))); + const session = makeSession({ + id: "respond-3", + status: "needs_input", + activity: "waiting_input", + summary: "Need approval to proceed", + }); + + render(); + + const input = screen.getByRole("textbox", { name: /type a reply to the agent/i }); + fireEvent.change(input, { target: { value: "please continue" } }); + fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); + + await waitFor(() => { + expect(onSend).toHaveBeenCalledTimes(1); + }); + + expect(screen.queryByRole("button", { name: "Sent" })).not.toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /type a reply to the agent/i })).toHaveValue( + "please continue", + ); + expect(screen.getByRole("textbox", { name: /type a reply to the agent/i })).not.toBeDisabled(); + }); + + it("shows a temporary failed state when an alert action send is rejected", async () => { + const onSend = vi.fn(() => Promise.reject(new Error("network failed"))); + const pr = makePR({ + state: "open", + ciStatus: "failing", + ciChecks: [{ name: "test", status: "failed" }], + reviewDecision: "approved", + mergeability: { + mergeable: false, + ciPassing: false, + approved: true, + noConflicts: true, + blockers: [], + }, + }); + const session = makeSession({ activity: "idle", pr }); + + render(); + + const actionButton = screen.getByRole("button", { name: "ask to fix" }); + fireEvent.click(actionButton); + + await waitFor(() => { + expect(onSend).toHaveBeenCalledTimes(1); + }); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "failed" })).toBeInTheDocument(); + }); + expect(screen.queryByRole("button", { name: "sent!" })).not.toBeInTheDocument(); + }); }); // ── AttentionZone ──────────────────────────────────────────────────── diff --git a/packages/web/src/__tests__/setup.ts b/packages/web/src/__tests__/setup.ts index f149f27ae..533b774d3 100644 --- a/packages/web/src/__tests__/setup.ts +++ b/packages/web/src/__tests__/setup.ts @@ -1 +1,19 @@ import "@testing-library/jest-dom/vitest"; + +// jsdom does not implement window.matchMedia. Provide a minimal stub so +// components that call useMediaQuery (e.g. Dashboard) work in unit tests. +// The stub always returns `false` (non-matching), which keeps tests in the +// desktop/non-mobile rendering path and avoids spurious re-renders. +Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), +}); diff --git a/packages/web/src/app/api/sessions/route.ts b/packages/web/src/app/api/sessions/route.ts index e18a0e0f6..c4d16fe12 100644 --- a/packages/web/src/app/api/sessions/route.ts +++ b/packages/web/src/app/api/sessions/route.ts @@ -38,6 +38,7 @@ export async function GET(request: Request) { const { searchParams } = new URL(request.url); const projectFilter = searchParams.get("project"); const activeOnly = searchParams.get("active") === "true"; + const orchestratorOnly = searchParams.get("orchestratorOnly") === "true"; const { config, registry, sessionManager } = await getServices(); const requestedProjectId = @@ -45,11 +46,35 @@ export async function GET(request: Request) { ? projectFilter : undefined; const coreSessions = await sessionManager.list(requestedProjectId); - const allSessions = requestedProjectId ? await sessionManager.list() : coreSessions; const visibleSessions = filterProjectSessions(coreSessions, projectFilter, config.projects); const orchestrators = listDashboardOrchestrators(visibleSessions, config.projects); const orchestratorId = orchestrators.length === 1 ? (orchestrators[0]?.id ?? null) : null; + if (orchestratorOnly) { + recordApiObservation({ + config, + method: "GET", + path: "/api/sessions", + correlationId, + startedAt, + outcome: "success", + statusCode: 200, + data: { orchestratorOnly: true, orchestratorCount: orchestrators.length }, + }); + + return jsonWithCorrelation( + { + orchestratorId, + orchestrators, + sessions: [], + }, + { status: 200 }, + correlationId, + ); + } + + const allSessions = requestedProjectId ? await sessionManager.list() : coreSessions; + let workerSessions = visibleSessions.filter((session) => !isOrchestratorSession(session)); // Convert to dashboard format diff --git a/packages/web/src/app/apple-icon.tsx b/packages/web/src/app/apple-icon.tsx new file mode 100644 index 000000000..46cab59ca --- /dev/null +++ b/packages/web/src/app/apple-icon.tsx @@ -0,0 +1,11 @@ +import { ImageResponse } from "next/og"; +import { getProjectName } from "@/lib/project-name"; +import { renderIconElement, sanitizeIconName } from "@/lib/icon-renderer"; + +export const size = { width: 180, height: 180 }; +export const contentType = "image/png"; + +export default function AppleIcon() { + const name = sanitizeIconName(getProjectName()); + return new ImageResponse(renderIconElement(180, name), { ...size }); +} diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index b592cdb0a..4d617244d 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -332,8 +332,10 @@ --z-raised: 10; --z-nav: 100; --z-modal: 200; + --z-bottom-nav: 250; --z-overlay: 300; --z-toast: 400; + --z-connection-bar: 500; /* Font-size scale — in :root to avoid --font-* font-family namespace in @theme */ --font-size-xs: 10px; @@ -388,11 +390,18 @@ /* ── Base styles ─────────────────────────────────────────────────────── */ +html { + -webkit-tap-highlight-color: transparent; + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; +} + body { font-family: var(--font-sans); background: var(--color-bg-base); color: var(--color-text-primary); min-height: 100vh; + min-height: 100dvh; letter-spacing: -0.011em; } @@ -889,17 +898,128 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { background 0.14s ease; } +.session-detail-link-pill--link { + color: var(--color-accent-blue); + border-color: color-mix(in srgb, var(--color-accent-blue) 22%, var(--color-border-subtle)); + background: color-mix(in srgb, var(--color-accent-blue) 9%, transparent); + text-decoration: none; + box-shadow: inset 0 1px 0 color-mix(in srgb, white 16%, transparent); +} + .session-detail-link-pill:hover { border-color: var(--color-border-strong); color: var(--color-text-primary); } +.session-detail-link-pill--link:hover { + border-color: color-mix(in srgb, var(--color-accent-blue) 44%, var(--color-border-strong)); + color: var(--color-accent-blue); + background: color-mix(in srgb, var(--color-accent-blue) 13%, transparent); +} + .session-detail-link-pill--accent { color: var(--color-accent); border-color: color-mix(in srgb, var(--color-accent) 20%, var(--color-border-subtle)); background: color-mix(in srgb, var(--color-accent) 8%, transparent); } +.session-detail-link-pill--link.session-detail-link-pill--accent { + color: var(--color-accent); + border-color: color-mix(in srgb, var(--color-accent) 28%, var(--color-border-subtle)); + background: color-mix(in srgb, var(--color-accent) 10%, transparent); +} + +.session-detail-link-pill--link.session-detail-link-pill--accent:hover { + border-color: color-mix(in srgb, var(--color-accent) 46%, var(--color-border-strong)); + color: var(--color-accent); + background: color-mix(in srgb, var(--color-accent) 14%, transparent); +} + +.session-detail-status-pill { + position: relative; + overflow: hidden; + box-shadow: inset 0 1px 0 color-mix(in srgb, white 16%, transparent); +} + +.session-detail-status-pill__dot { + position: relative; + z-index: 1; +} + +.session-detail-status-pill--active { + animation: session-status-active-breathe 2.8s ease-in-out infinite; +} + +.session-detail-status-pill--active .session-detail-status-pill__dot { + animation: session-status-dot-pulse 1.8s ease-in-out infinite; +} + +.session-detail-status-pill--ready { + animation: session-status-ready-breathe 3.2s ease-in-out infinite; +} + +.session-detail-status-pill--ready .session-detail-status-pill__dot { + animation: session-status-dot-pulse 2.2s ease-in-out infinite; +} + +.session-detail-status-pill--idle { + opacity: 0.92; +} + +.session-detail-status-pill--waiting { + animation: session-status-waiting-breathe 3s ease-in-out infinite; +} + +@keyframes session-status-dot-pulse { + 0%, 100% { + transform: scale(1); + opacity: 0.88; + } + 50% { + transform: scale(1.18); + opacity: 1; + } +} + +@keyframes session-status-active-breathe { + 0%, 100% { + box-shadow: + inset 0 1px 0 color-mix(in srgb, white 16%, transparent), + 0 0 0 0 color-mix(in srgb, var(--color-status-working) 0%, transparent); + } + 50% { + box-shadow: + inset 0 1px 0 color-mix(in srgb, white 18%, transparent), + 0 0 0 3px color-mix(in srgb, var(--color-status-working) 10%, transparent); + } +} + +@keyframes session-status-ready-breathe { + 0%, 100% { + box-shadow: + inset 0 1px 0 color-mix(in srgb, white 16%, transparent), + 0 0 0 0 color-mix(in srgb, var(--color-status-ready) 0%, transparent); + } + 50% { + box-shadow: + inset 0 1px 0 color-mix(in srgb, white 20%, transparent), + 0 0 0 3px color-mix(in srgb, var(--color-status-ready) 12%, transparent); + } +} + +@keyframes session-status-waiting-breathe { + 0%, 100% { + box-shadow: + inset 0 1px 0 color-mix(in srgb, white 16%, transparent), + 0 0 0 0 color-mix(in srgb, var(--color-status-attention) 0%, transparent); + } + 50% { + box-shadow: + inset 0 1px 0 color-mix(in srgb, white 18%, transparent), + 0 0 0 3px color-mix(in srgb, var(--color-status-attention) 10%, transparent); + } +} + .dark .detail-card { --color-text-secondary: #9898a0; --color-text-muted: #5c5c66; @@ -924,7 +1044,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { .session-card--fixed { display: flex; - height: 242px; + min-height: 242px; flex-direction: column; overflow: hidden; } @@ -992,7 +1112,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { } .session-card__title { - -webkit-line-clamp: 3; + -webkit-line-clamp: 2; } .session-card__secondary { @@ -1191,6 +1311,95 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { animation: ready-dot-pulse 2.8s ease-in-out infinite; } +/* ── Quick-reply section (respond cards) ─────────────────────────────── */ + +.quick-reply { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px; + border-top: 1px solid var(--color-border-default); +} + +.quick-reply__summary { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + font-size: 12px; + line-height: 1.5; + color: var(--color-text-secondary); + margin: 0; +} + +.quick-reply__presets { + display: flex; + gap: 6px; +} + +.quick-reply__preset-btn { + min-height: 44px; + padding: 0 14px; + border: 1px solid var(--color-border-default); + background: var(--color-bg-subtle); + color: var(--color-text-secondary); + font-family: var(--font-sans); + font-size: 12px; + cursor: pointer; + transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease; +} + +.quick-reply__preset-btn:hover { + border-color: var(--color-accent); + color: var(--color-accent); + background: var(--color-tint-blue); +} + +.quick-reply__input { + min-height: 44px; + height: 44px; + padding: 12px; + border: 1px solid var(--color-border-default); + background: var(--color-bg-subtle); + color: var(--color-text-primary); + font-family: var(--font-sans); + font-size: 13px; + resize: none; + overflow: hidden; + transition: height 0.15s ease, border-color 0.12s ease; + box-sizing: border-box; + width: 100%; +} + +.quick-reply__input::placeholder { + color: var(--color-text-muted); +} + +.quick-reply__input:focus { + outline: none; + border-color: var(--color-accent); + height: 80px; + overflow: auto; +} + +@media (min-width: 768px) { + .quick-reply__preset-btn { + min-height: 32px; + padding: 0 10px; + font-size: 11px; + } + + .quick-reply__input { + min-height: 32px; + height: 32px; + padding: 8px 10px; + } + + .quick-reply__input:focus { + height: 60px; + } +} + /* ── Done cards — compact, de-emphasized ─────────────────────────────── */ .session-card-done { @@ -1626,6 +1835,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { display: flex; gap: 8px; height: calc(100vh - 280px); + height: calc(100dvh - 280px); overflow-x: auto; padding-bottom: 8px; } @@ -1810,6 +2020,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { /* Card glow effects removed for Linear-clean look */ + @media (max-width: 960px) { .dashboard-hero__content { padding: 12px 14px; @@ -1837,7 +2048,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { } .session-card--fixed { - height: 232px; + min-height: 232px; } .session-card--alert-frame { @@ -1863,7 +2074,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { } .session-card--fixed { - height: 224px; + min-height: 224px; } .session-card--alert-frame { @@ -1874,3 +2085,1279 @@ 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-bottom-nav { + display: none; +} + +@media (max-width: 767px) { + .dashboard-prs-link { + display: none; + } +} + +/* ── 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; + padding-top: env(safe-area-inset-top, 0px); + padding-bottom: env(safe-area-inset-bottom, 0px); + padding-left: env(safe-area-inset-left, 0px); + } + + .project-sidebar.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: max(12px, env(safe-area-inset-left, 0px)); + padding-right: max(12px, env(safe-area-inset-right, 0px)); + padding-top: 12px; + padding-bottom: calc(84px + env(safe-area-inset-bottom, 0px)); + } + + /* -- Hero section becomes a lightweight mobile header -- */ + .dashboard-hero { + margin-bottom: 8px; + border: none; + background: transparent; + box-shadow: none; + } + + .dashboard-hero__content { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) 36px; + align-items: center; + padding: 0; + gap: 8px; + min-height: 0; + } + + .dashboard-hero__primary { + display: block; + min-height: 0; + } + + .dashboard-hero__heading { + min-width: 0; + } + + .dashboard-hero__copy { + min-width: 0; + } + + .dashboard-title { + font-size: 19px; + line-height: 1; + letter-spacing: -0.04em; + max-width: none; + margin: 0; + } + + .dashboard-subtitle { + display: none; + } + + /* Hide stat cards on mobile — replaced by the action strip */ + .dashboard-stat-cards { + display: none; + } + + /* Keep stat cards visible on pages without an action-strip replacement */ + .dashboard-stat-cards--persist-mobile { + display: flex; + } + + .dashboard-hero__meta { + display: flex; + align-items: center; + justify-content: flex-end; + } + + .dashboard-hero__meta > div { + gap: 0; + } + + .dashboard-hero__meta button { + width: 36px; + height: 36px; + border: none; + background: none; + color: var(--color-text-secondary); + box-shadow: none; + padding: 0; + } + + .mobile-menu-toggle { + width: 36px; + height: 36px; + border: none; + background: none; + color: var(--color-text-secondary); + } + + .mobile-menu-toggle:hover { + border: none; + background: none; + color: var(--color-text-primary); + } + + .mobile-bottom-nav { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: var(--z-bottom-nav); + display: grid; + grid-auto-flow: column; + grid-auto-columns: 1fr; + gap: 0; + border-top: 1px solid var(--color-border-default); + background: color-mix(in srgb, var(--color-bg-surface) 92%, transparent); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + padding: 8px max(12px, env(safe-area-inset-right, 0px)) + calc(8px + env(safe-area-inset-bottom, 0px)) + max(12px, env(safe-area-inset-left, 0px)); + } + + .mobile-bottom-nav__item { + display: inline-flex; + min-height: 44px; + align-items: center; + justify-content: center; + gap: 6px; + border: none; + background: none; + color: var(--color-text-secondary); + font-size: 11px; + font-weight: 600; + text-decoration: none; + cursor: pointer; + } + + .mobile-bottom-nav__item svg { + width: 16px; + height: 16px; + flex-shrink: 0; + } + + .mobile-bottom-nav__item[data-active="true"] { + color: var(--color-text-primary); + } + + .mobile-bottom-nav__item[aria-current="page"] { + color: var(--color-accent); + } + + .mobile-bottom-nav__item:disabled { + color: var(--color-text-tertiary); + cursor: default; + } + + /* ── Mobile action strip ─────────────────────────────────────────────── */ + + .mobile-action-strip { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: nowrap; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + min-width: 0; + padding-top: 2px; + } + + .mobile-priority-row { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 6px; + min-width: 0; + margin-bottom: 14px; + } + + .mobile-priority-row__label { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-text-tertiary); + } + + .mobile-filter-row { + display: flex; + align-items: center; + gap: 8px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + margin-bottom: 16px; + padding-bottom: 2px; + } + + .mobile-filter-row::-webkit-scrollbar { + display: none; + } + + .mobile-filter-chip { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 32px; + padding: 0 12px; + border: 1px solid var(--color-border-default); + background: color-mix(in srgb, var(--color-bg-surface) 88%, transparent); + color: var(--color-text-secondary); + font-size: 11px; + font-weight: 600; + white-space: nowrap; + cursor: pointer; + transition: + border-color 0.12s ease, + background 0.12s ease, + color 0.12s ease; + } + + .mobile-filter-chip[data-active="true"] { + border-color: color-mix(in srgb, var(--color-accent) 28%, var(--color-border-default)); + background: color-mix(in srgb, var(--color-accent) 10%, transparent); + color: var(--color-accent); + } + + .mobile-action-strip::-webkit-scrollbar { + display: none; + } + + .mobile-action-strip--all-good { + align-items: center; + } + + .mobile-action-strip__all-good { + font-size: 11px; + color: var(--color-text-tertiary); + white-space: nowrap; + } + + .mobile-action-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 12px; + border: 1px solid var(--color-border-default); + background: color-mix(in srgb, var(--color-bg-surface) 86%, transparent); + cursor: pointer; + white-space: nowrap; + min-height: 36px; + transition: + background 0.12s ease, + border-color 0.12s ease; + } + + .mobile-action-pill:hover, + .mobile-action-pill:active { + background: var(--color-bg-hover); + border-color: var(--color-border-strong); + } + + .mobile-action-pill:focus-visible { + outline: 2px solid var(--color-accent-blue); + outline-offset: 2px; + } + + .mobile-action-pill__dot { + width: 7px; + height: 7px; + border-radius: 999px; + flex-shrink: 0; + } + + .mobile-action-pill__count { + font-size: 13px; + font-weight: 600; + font-variant-numeric: tabular-nums; + line-height: 1; + } + + .mobile-action-pill__label { + font-size: 11px; + font-weight: 500; + color: var(--color-text-secondary); + } + + /* -- 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; + } + + /* Hide the "Attention Board" heading + legend on mobile — accordion provides its own headers */ + .board-section-head { + display: none; + } + + .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-detail-page { + background: var(--color-bg-base); + } + + .session-page-header { + gap: 6px; + padding: 6px 0 8px; + border: none; + background: transparent; + box-shadow: none; + } + + .session-page-header__main { + gap: 6px; + } + + .session-page-header__crumbs { + gap: 6px; + padding-bottom: 0; + border-bottom: none; + font-size: 11px; + } + + .session-page-header__mode { + padding: 0; + border: none; + background: none; + font-size: 10px; + letter-spacing: 0.08em; + } + + .session-page-header__meta { + flex-direction: row; + gap: 6px; + margin-top: 6px; + } + + .session-page-header__side { + gap: 8px; + } + + .session-page-header h1 { + font-size: 16px; + line-height: 1.12; + letter-spacing: -0.04em; + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + } + + .session-page-header--mobile .session-page-header__identity { + display: grid; + gap: 0; + } + + .session-detail-link-pill { + min-height: 28px; + padding: 0 8px; + font-size: 10px; + } + + .session-page-header__side .flex.items-baseline { + gap: 4px; + margin-right: 0; + } + + .session-page-header__side .text-\[22px\] { + font-size: 15px; + } + + .session-page-header__side .text-\[11px\] { + font-size: 10px; + } + + .session-page-header__side .h-5 { + display: none; + } + + .session-page-header__side > div:last-child { + display: flex; + flex-wrap: wrap; + gap: 6px; + width: 100%; + } + + .session-page-header__side > div:last-child > div { + padding: 0 8px; + min-height: 26px; + align-items: center; + } + + .session-page-header__side > div:last-child > div span:first-child { + font-size: 12px; + } + + .session-page-header__side > div:last-child > div span:last-child { + font-size: 9px; + letter-spacing: 0.04em; + text-transform: uppercase; + } + + .session-detail-page section.mt-5 { + margin-top: 6px; + } + + /* -- 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; + } + + /* -- iOS auto-zoom prevention on input focus -- */ + input, textarea, select { + font-size: 16px; + } + + /* ── Mobile accordion board ─────────────────────────────────────────── */ + + .accordion-board { + display: flex; + flex-direction: column; + gap: 0; + margin-top: 0; + scroll-margin-top: 56px; + } + + .accordion-section { + border: 1px solid color-mix(in srgb, var(--color-border-subtle) 58%, transparent); + background: color-mix(in srgb, var(--color-bg-surface) 78%, var(--color-bg-base)); + overflow: visible; + } + + .accordion-section + .accordion-section { + margin-top: 18px; + padding-top: 0; + border-top: none; + } + + .accordion-section--expanded { + border-color: color-mix(in srgb, var(--color-border-default) 82%, transparent); + background: color-mix(in srgb, var(--color-bg-surface) 88%, var(--color-bg-base)); + } + + .mobile-session-list { + display: flex; + flex-direction: column; + border-top: 1px solid color-mix(in srgb, var(--color-border-default) 68%, transparent); + } + + .mobile-session-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 10px; + width: 100%; + min-width: 0; + min-height: 58px; + padding: 11px 12px; + background: color-mix(in srgb, var(--color-bg-surface) 96%, transparent); + border: none; + border-top: 1px solid color-mix(in srgb, var(--color-border-subtle) 70%, transparent); + text-align: left; + transition: background 0.12s ease; + } + + .mobile-session-row:first-child { + border-top: none; + } + + .mobile-session-row:active { + background: color-mix(in srgb, var(--color-bg-hover) 82%, var(--color-bg-surface)); + } + + .mobile-session-row__preview { + display: flex; + flex: 1 1 0%; + flex-direction: column; + gap: 5px; + min-width: 0; + overflow: hidden; + background: none; + border: none; + padding: 0; + text-align: left; + cursor: pointer; + } + + .mobile-session-row__line { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + gap: 8px; + width: 100%; + min-width: 0; + overflow: hidden; + } + + .mobile-session-row__dot { + width: 7px; + height: 7px; + border-radius: 999px; + flex-shrink: 0; + margin-top: 5px; + } + + .mobile-session-row__title { + display: block; + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 600; + line-height: 1.2; + color: var(--color-text-primary); + letter-spacing: -0.02em; + } + + .mobile-session-row__chip { + display: inline-flex; + align-items: center; + justify-content: flex-end; + flex-shrink: 0; + max-width: min(14ch, 28vw); + min-height: 16px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 0 6px; + border: 1px solid color-mix(in srgb, var(--color-border-default) 88%, transparent); + background: color-mix(in srgb, var(--color-bg-surface) 92%, var(--color-bg-subtle)); + font-size: 10px; + font-weight: 600; + line-height: 1; + text-align: right; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-text-secondary); + } + + .mobile-session-row__meta { + padding-left: 15px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 10px; + line-height: 1.2; + color: var(--color-text-muted); + letter-spacing: 0.01em; + } + + .mobile-session-row__side { + display: grid; + grid-template-columns: max-content 44px; + align-items: start; + justify-content: end; + gap: 10px; + align-self: start; + flex-shrink: 0; + } + + .mobile-session-row__open { + display: inline-flex; + align-items: center; + justify-content: center; + align-self: start; + width: 44px; + min-width: 44px; + height: 44px; + min-height: 44px; + padding: 0; + flex-shrink: 0; + color: var(--color-accent); + text-decoration: none; + border: 1px solid color-mix(in srgb, var(--color-accent) 26%, var(--color-border-default)); + background: color-mix(in srgb, var(--color-accent) 8%, var(--color-bg-surface)); + transition: + background 0.12s ease, + border-color 0.12s ease, + color 0.12s ease, + transform 0.12s ease; + } + + .mobile-session-row__open:hover { + border-color: color-mix(in srgb, var(--color-accent) 44%, var(--color-border-default)); + background: color-mix(in srgb, var(--color-accent) 12%, var(--color-bg-surface)); + } + + .mobile-session-row__open:focus-visible { + border-color: color-mix(in srgb, var(--color-accent) 44%, var(--color-border-default)); + background: color-mix(in srgb, var(--color-accent) 12%, var(--color-bg-surface)); + outline: 2px solid var(--color-accent); + outline-offset: -2px; + } + + .mobile-session-row__open:active { + transform: translateY(1px); + background: color-mix(in srgb, var(--color-accent) 16%, var(--color-bg-hover)); + } + + .mobile-session-row__open-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + } + + .mobile-session-list__view-all { + min-height: 44px; + padding: 0 0 0 15px; + background: none; + border: none; + border-top: 1px solid color-mix(in srgb, var(--color-border-subtle) 84%, transparent); + text-align: left; + font-size: 12px; + font-weight: 600; + color: var(--color-accent); + cursor: pointer; + } + + .mobile-session-list__empty { + min-height: 44px; + display: flex; + align-items: center; + padding: 0 12px 0 15px; + color: var(--color-text-muted); + font-size: 12px; + } + + .accordion-header { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-height: 0; + padding: 10px 12px; + background: none; + border: none; + cursor: pointer; + text-align: left; + color: var(--color-text-primary); + } + + .accordion-header__dot { + width: 8px; + height: 8px; + border-radius: 999px; + flex-shrink: 0; + } + + .accordion-header__label { + font-size: 16px; + font-weight: 600; + flex: 1; + color: var(--color-text-muted); + letter-spacing: -0.03em; + } + + .accordion-section--expanded .accordion-header__label { + color: var(--color-text-primary); + } + + .accordion-section--collapsed .accordion-header { + background: color-mix(in srgb, var(--color-bg-surface) 72%, var(--color-bg-base)); + } + + .accordion-section--expanded .accordion-header { + background: color-mix(in srgb, var(--color-bg-surface) 92%, var(--color-bg-base)); + } + + .accordion-header__count { + font-size: 13px; + font-weight: 600; + min-width: 0; + padding: 0; + background: none; + text-align: center; + color: var(--color-text-tertiary); + font-variant-numeric: tabular-nums; + } + + .accordion-header__chevron { + font-size: 9px; + color: var(--color-text-tertiary); + flex-shrink: 0; + transition: transform 0.25s ease; + } + + .accordion-section:not(.accordion-section--collapsed) .accordion-header__chevron { + transform: rotate(90deg); + } + + /* Accordion body: hidden when collapsed, visible when expanded */ + .accordion-body { + display: grid; + grid-template-rows: 1fr; + /* grid-template-rows animation: supported Chrome 107+, Firefox 107+, Safari 16+. Graceful snap on Safari 15-. */ + transition: grid-template-rows 0.25s ease; + } + + .accordion-section--collapsed .accordion-body { + grid-template-rows: 0fr; + } + + .accordion-section--collapsed .accordion-body > * { + overflow: hidden; + } + + .mobile-pr-list { + display: flex; + flex-direction: column; + border: 1px solid color-mix(in srgb, var(--color-border-default) 82%, transparent); + background: var(--color-bg-surface); + } + + .mobile-pr-card { + display: flex; + flex-direction: column; + gap: 5px; + padding: 11px 12px; + border-top: 1px solid var(--color-border-subtle); + color: var(--color-text-primary); + text-decoration: none; + } + + .mobile-pr-card:first-child { + border-top: none; + } + + .mobile-pr-card__line { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + } + + .mobile-pr-card__number { + flex-shrink: 0; + font-family: var(--font-mono); + font-size: 11px; + font-weight: 700; + color: var(--color-accent); + } + + .mobile-pr-card__title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + font-weight: 600; + } + + .mobile-pr-card__size { + flex-shrink: 0; + padding: 1px 6px; + border: 1px solid var(--color-border-default); + background: var(--color-bg-subtle); + font-size: 10px; + font-weight: 700; + color: var(--color-text-secondary); + } + + .mobile-pr-card__meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + font-size: 10px; + color: var(--color-text-muted); + } + + .mx-auto.max-w-\[900px\] > h2 { + margin-bottom: 8px; + padding-left: 0; + font-size: 11px; + color: var(--color-text-secondary); + letter-spacing: 0.12em; + } +} + +/* ── 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; + } +} + +/* ── PWA standalone mode ───────────────────────────────────────────── */ + +@media (display-mode: standalone) { + body { + padding-top: env(safe-area-inset-top, 0px); + } +} + +/* ── Toast / Snackbar ─────────────────────────────────────────────── */ + +@keyframes toast-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes toast-out { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(8px); + } +} + +.toast-container { + position: fixed; + bottom: calc(env(safe-area-inset-bottom, 0px) + 16px); + left: 50%; + transform: translateX(-50%); + z-index: var(--z-toast); + display: flex; + flex-direction: column; + align-items: center; + pointer-events: none; +} + +@media (max-width: 767px) { + .toast-container { + bottom: calc(env(safe-area-inset-bottom, 0px) + 76px); + } +} + +.toast { + pointer-events: auto; + display: flex; + align-items: center; + gap: 8px; + background: var(--color-bg-elevated); + border: 1px solid var(--color-border-default); + color: var(--color-text-primary); + padding: 10px 16px; + max-width: 340px; + border-radius: 6px; + font-size: 13px; + line-height: 1.4; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + animation: toast-in 0.2s ease forwards; +} + +.toast__icon { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + background: var(--color-text-secondary); +} + +.toast--success .toast__icon { + background: var(--color-status-ready); +} + +.toast--error .toast__icon { + background: var(--color-status-error); +} + +.toast--info .toast__icon { + background: var(--color-text-secondary); +} + +.toast__message { + flex: 1; +} + +/* ── Bottom Sheet ─────────────────────────────────────────────────── */ + +@keyframes sheet-in { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } +} + +.bottom-sheet-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: calc(var(--z-overlay) + 20); +} + +.bottom-sheet { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: var(--color-bg-surface); + border-top: 1px solid var(--color-border-default); + border-bottom: none; + padding: 16px 20px calc(12px + env(safe-area-inset-bottom, 0px)); + z-index: calc(var(--z-overlay) + 21); + transform: translateY(0); + animation: sheet-in 0.25s ease forwards; +} + +.bottom-sheet__handle { + width: 36px; + height: 4px; + background: var(--color-border-default); + border-radius: 2px; + margin: 0 auto 16px; +} + +.bottom-sheet__header { + margin-bottom: 16px; +} + +.bottom-sheet__title { + font-size: 15px; + font-weight: 600; + color: var(--color-text-primary); + margin: 0 0 4px; +} + +.bottom-sheet__subtitle { + font-size: 12px; + color: var(--color-text-secondary); + margin: 0; +} + +.bottom-sheet__session-info { + background: var(--color-bg-subtle); + border: 1px solid var(--color-border-subtle); + padding: 10px 12px; + margin-bottom: 20px; +} + +.bottom-sheet__session-name { + font-size: 13px; + font-weight: 500; + color: var(--color-text-primary); + margin-bottom: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.bottom-sheet__session-meta { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.bottom-sheet__tag { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 8px; + border: 1px solid var(--color-border-default); + background: var(--color-bg-surface); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--color-text-secondary); +} + +.bottom-sheet__tag--accent { + color: var(--color-accent); + border-color: color-mix(in srgb, var(--color-accent) 24%, var(--color-border-default)); +} + +.bottom-sheet__tag--mono { + font-family: var(--font-mono); + text-transform: none; + letter-spacing: 0; +} + +.bottom-sheet__summary { + margin: 10px 0 0; + font-size: 12px; + line-height: 1.5; + color: var(--color-text-secondary); + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + overflow: hidden; +} + +.bottom-sheet__actions { + display: flex; + gap: 10px; +} + +.bottom-sheet__btn { + flex: 1; + min-height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + border: 1px solid var(--color-border-default); + transition: opacity 0.15s ease; +} + +.bottom-sheet__btn-icon { + width: 14px; + height: 14px; + flex-shrink: 0; +} + +.bottom-sheet__btn:active { + opacity: 0.75; +} + +.bottom-sheet__btn--cancel { + background: transparent; + color: var(--color-text-secondary); +} + +.bottom-sheet__btn--cancel:hover { + background: var(--color-bg-subtle); +} + +.bottom-sheet__btn--danger { + background: var(--color-status-error); + border-color: var(--color-status-error); + color: #ffffff; +} + +.bottom-sheet__btn--danger:hover { + opacity: 0.9; +} + +.bottom-sheet__btn--primary { + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--color-accent); + border-color: var(--color-accent); + color: #ffffff; + text-decoration: none; +} + +.bottom-sheet__btn--primary:hover { + opacity: 0.92; +} + +.bottom-sheet__btn--secondary { + background: transparent; + color: var(--color-text-primary); +} + +.bottom-sheet__btn--secondary:hover { + background: var(--color-bg-subtle); +} + +/* ─── Connection Status Bar ───────────────────────────────────────────────── */ + +.connection-bar { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: var(--z-connection-bar, 500); + height: 0; + overflow: hidden; + transition: height 0.2s ease, opacity 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 500; +} + +.connection-bar--connected { + /* Invisible — no height, no color */ +} + +.connection-bar--reconnecting { + height: 4px; + background: #d4a017; /* amber — no token for this in the design system */ + animation: connection-pulse 1.5s ease-in-out infinite; +} + +.connection-bar--disconnected { + height: 32px; + background: var(--color-status-error); /* red */ + color: #fff; + cursor: pointer; +} + +@keyframes connection-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} diff --git a/packages/web/src/app/icon-192/route.tsx b/packages/web/src/app/icon-192/route.tsx new file mode 100644 index 000000000..7ab52ed31 --- /dev/null +++ b/packages/web/src/app/icon-192/route.tsx @@ -0,0 +1,15 @@ +import { ImageResponse } from "next/og"; +import { getProjectName } from "@/lib/project-name"; +import { renderIconElement, sanitizeIconName } from "@/lib/icon-renderer"; + +export async function GET() { + const rawName = getProjectName(); + const name = sanitizeIconName(rawName); + const response = new ImageResponse(renderIconElement(192, name), { + width: 192, + height: 192, + }); + response.headers.set("Cache-Control", "public, max-age=86400, stale-while-revalidate=3600"); + response.headers.set("Content-Security-Policy", "default-src 'none'; img-src 'self'"); + return response; +} diff --git a/packages/web/src/app/icon-512/route.tsx b/packages/web/src/app/icon-512/route.tsx new file mode 100644 index 000000000..fd120d7cc --- /dev/null +++ b/packages/web/src/app/icon-512/route.tsx @@ -0,0 +1,15 @@ +import { ImageResponse } from "next/og"; +import { getProjectName } from "@/lib/project-name"; +import { renderIconElement, sanitizeIconName } from "@/lib/icon-renderer"; + +export async function GET() { + const rawName = getProjectName(); + const name = sanitizeIconName(rawName); + const response = new ImageResponse(renderIconElement(512, name), { + width: 512, + height: 512, + }); + response.headers.set("Cache-Control", "public, max-age=86400, stale-while-revalidate=3600"); + response.headers.set("Content-Security-Policy", "default-src 'none'; img-src 'self'"); + return response; +} diff --git a/packages/web/src/app/icon.tsx b/packages/web/src/app/icon.tsx index 239c481ea..90429705a 100644 --- a/packages/web/src/app/icon.tsx +++ b/packages/web/src/app/icon.tsx @@ -1,43 +1,11 @@ import { ImageResponse } from "next/og"; import { getProjectName } from "@/lib/project-name"; +import { renderIconElement, sanitizeIconName } from "@/lib/icon-renderer"; export const size = { width: 32, height: 32 }; export const contentType = "image/png"; -/** Derive a consistent hue from a string (0-360). */ -function stringToHue(s: string): number { - let hash = 0; - for (let i = 0; i < s.length; i++) { - hash = s.charCodeAt(i) + ((hash << 5) - hash); - } - return ((hash % 360) + 360) % 360; -} - export default function Icon() { - const name = getProjectName(); - const initial = (name.charAt(0) || "A").toUpperCase(); - const hue = stringToHue(name); - - return new ImageResponse( - ( -
- {initial} -
- ), - { ...size }, - ); + const name = sanitizeIconName(getProjectName()); + return new ImageResponse(renderIconElement(32, name), { ...size }); } diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index bc339e4fd..a78f259c2 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -1,7 +1,8 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { IBM_Plex_Sans, IBM_Plex_Mono, JetBrains_Mono } from "next/font/google"; import { ThemeProvider } from "next-themes"; import { getProjectName } from "@/lib/project-name"; +import { ServiceWorkerRegistrar } from "@/components/ServiceWorkerRegistrar"; import "./globals.css"; const ibmPlexSans = IBM_Plex_Sans({ @@ -25,6 +26,16 @@ const jetbrainsMono = JetBrains_Mono({ weight: ["400", "500"], }); +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + viewportFit: "cover", + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#ffffff" }, + { media: "(prefers-color-scheme: dark)", color: "#0a0d12" }, + ], +}; + export async function generateMetadata(): Promise { const projectName = getProjectName(); return { @@ -33,6 +44,11 @@ export async function generateMetadata(): Promise { default: `ao | ${projectName}`, }, description: "Dashboard for managing parallel AI coding agents", + appleWebApp: { + capable: true, + statusBarStyle: "black-translucent", + title: `ao | ${projectName}`, + }, }; } @@ -43,6 +59,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {children} + ); diff --git a/packages/web/src/app/manifest.ts b/packages/web/src/app/manifest.ts new file mode 100644 index 000000000..c5a359a3e --- /dev/null +++ b/packages/web/src/app/manifest.ts @@ -0,0 +1,20 @@ +import type { MetadataRoute } from "next"; +import { getProjectName } from "@/lib/project-name"; + +export default function manifest(): MetadataRoute.Manifest { + const projectName = getProjectName(); + return { + name: `ao | ${projectName}`, + short_name: "ao", + description: "Dashboard for managing parallel AI coding agents", + start_url: "/", + display: "standalone", + background_color: "#0a0d12", + theme_color: "#0a0d12", + icons: [ + { src: "/apple-icon", sizes: "180x180", type: "image/png" }, + { src: "/icon-192", sizes: "192x192", type: "image/png", purpose: "any" }, + { src: "/icon-512", sizes: "512x512", type: "image/png", purpose: "any" }, + ], + }; +} diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index 24281b4e5..f791b063c 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -2,136 +2,32 @@ import type { Metadata } from "next"; export const dynamic = "force-dynamic"; import { Dashboard } from "@/components/Dashboard"; -import type { DashboardSession } from "@/lib/types"; -import { getServices, getSCM } from "@/lib/services"; import { - sessionToDashboard, - resolveProject, - enrichSessionPR, - enrichSessionsMetadata, - listDashboardOrchestrators, -} from "@/lib/serialize"; -import { prCache, prCacheKey } from "@/lib/cache"; -import { getPrimaryProjectId, getProjectName, getAllProjects } from "@/lib/project-name"; -import { filterProjectSessions, filterWorkerSessions } from "@/lib/project-utils"; -import { resolveGlobalPause, type GlobalPauseState } from "@/lib/global-pause"; - -function getSelectedProjectName(projectFilter: string | undefined): string { - if (projectFilter === "all") return "All Projects"; - const projects = getAllProjects(); - if (projectFilter) { - const selectedProject = projects.find((project) => project.id === projectFilter); - if (selectedProject) return selectedProject.name; - } - return getProjectName(); -} + getDashboardPageData, + getDashboardProjectName, + resolveDashboardProjectFilter, +} from "@/lib/dashboard-page-data"; export async function generateMetadata(props: { searchParams: Promise<{ project?: string }>; }): Promise { const searchParams = await props.searchParams; - const projectFilter = searchParams.project ?? getPrimaryProjectId(); - const projectName = getSelectedProjectName(projectFilter); + const projectFilter = resolveDashboardProjectFilter(searchParams.project); + const projectName = getDashboardProjectName(projectFilter); return { title: { absolute: `ao | ${projectName}` } }; } export default async function Home(props: { searchParams: Promise<{ project?: string }> }) { const searchParams = await props.searchParams; - const projectFilter = searchParams.project ?? getPrimaryProjectId(); - const pageData: { - sessions: DashboardSession[]; - globalPause: GlobalPauseState | null; - orchestrators: Array<{ id: string; projectId: string; projectName: string }>; - } = { - sessions: [], - globalPause: null, - orchestrators: [], - }; - - try { - const { config, registry, sessionManager } = await getServices(); - const allSessions = await sessionManager.list(); - - pageData.globalPause = resolveGlobalPause(allSessions); - - const visibleSessions = filterProjectSessions(allSessions, projectFilter, config.projects); - - pageData.orchestrators = listDashboardOrchestrators(visibleSessions, config.projects); - - const coreSessions = filterWorkerSessions(allSessions, projectFilter, config.projects); - pageData.sessions = coreSessions.map(sessionToDashboard); - - const metaTimeout = new Promise((resolve) => setTimeout(resolve, 3_000)); - await Promise.race([ - enrichSessionsMetadata(coreSessions, pageData.sessions, config, registry), - metaTimeout, - ]); - - const terminalStatuses = new Set(["merged", "killed", "cleanup", "done", "terminated"]); - const enrichPromises = coreSessions.map((core, i) => { - if (!core.pr) return Promise.resolve(); - - const cacheKey = prCacheKey(core.pr.owner, core.pr.repo, core.pr.number); - const cached = prCache.get(cacheKey); - - if (cached) { - if (pageData.sessions[i].pr) { - pageData.sessions[i].pr.state = cached.state; - pageData.sessions[i].pr.title = cached.title; - pageData.sessions[i].pr.additions = cached.additions; - pageData.sessions[i].pr.deletions = cached.deletions; - pageData.sessions[i].pr.ciStatus = cached.ciStatus as - | "none" - | "pending" - | "passing" - | "failing"; - pageData.sessions[i].pr.reviewDecision = cached.reviewDecision as - | "none" - | "pending" - | "approved" - | "changes_requested"; - pageData.sessions[i].pr.ciChecks = cached.ciChecks.map((c) => ({ - name: c.name, - status: c.status as "pending" | "running" | "passed" | "failed" | "skipped", - url: c.url, - })); - pageData.sessions[i].pr.mergeability = cached.mergeability; - pageData.sessions[i].pr.unresolvedThreads = cached.unresolvedThreads; - pageData.sessions[i].pr.unresolvedComments = cached.unresolvedComments; - } - - if ( - terminalStatuses.has(core.status) || - cached.state === "merged" || - cached.state === "closed" - ) { - return Promise.resolve(); - } - } - - const project = resolveProject(core, config.projects); - const scm = getSCM(registry, project); - if (!scm) return Promise.resolve(); - return enrichSessionPR(pageData.sessions[i], scm, core.pr); - }); - const enrichTimeout = new Promise((resolve) => setTimeout(resolve, 4_000)); - await Promise.race([Promise.allSettled(enrichPromises), enrichTimeout]); - } catch { - pageData.sessions = []; - pageData.globalPause = null; - pageData.orchestrators = []; - } - - const projectName = getSelectedProjectName(projectFilter); - const projects = getAllProjects(); - const selectedProjectId = projectFilter === "all" ? undefined : projectFilter; + const projectFilter = resolveDashboardProjectFilter(searchParams.project); + const pageData = await getDashboardPageData(projectFilter); return ( diff --git a/packages/web/src/app/prs/page.tsx b/packages/web/src/app/prs/page.tsx new file mode 100644 index 000000000..6fca2e9aa --- /dev/null +++ b/packages/web/src/app/prs/page.tsx @@ -0,0 +1,36 @@ +import type { Metadata } from "next"; +import { PullRequestsPage } from "@/components/PullRequestsPage"; +import { + getDashboardPageData, + getDashboardProjectName, + resolveDashboardProjectFilter, +} from "@/lib/dashboard-page-data"; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata(props: { + searchParams: Promise<{ project?: string }>; +}): Promise { + const searchParams = await props.searchParams; + const projectFilter = resolveDashboardProjectFilter(searchParams.project); + const projectName = getDashboardProjectName(projectFilter); + return { title: { absolute: `ao | ${projectName} PRs` } }; +} + +export default async function PullRequestsRoute(props: { + searchParams: Promise<{ project?: string }>; +}) { + const searchParams = await props.searchParams; + const projectFilter = resolveDashboardProjectFilter(searchParams.project); + const pageData = await getDashboardPageData(projectFilter); + + return ( + + ); +} diff --git a/packages/web/src/app/sessions/[id]/page.test.tsx b/packages/web/src/app/sessions/[id]/page.test.tsx new file mode 100644 index 000000000..ec96b721c --- /dev/null +++ b/packages/web/src/app/sessions/[id]/page.test.tsx @@ -0,0 +1,118 @@ +import { act, render } from "@testing-library/react"; +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import type { DashboardSession } from "@/lib/types"; + +const sessionDetailSpy = vi.fn(); + +vi.mock("next/navigation", () => ({ + useParams: () => ({ id: "worker-1" }), +})); + +vi.mock("@/components/SessionDetail", () => ({ + SessionDetail: (props: unknown) => { + sessionDetailSpy(props); + return
; + }, +})); + +function makeWorkerSession(): DashboardSession { + return { + id: "worker-1", + projectId: "my-app", + status: "working", + activity: "active", + branch: "feat/test", + issueId: "https://linear.app/test/issue/INT-100", + issueUrl: "https://linear.app/test/issue/INT-100", + issueLabel: "INT-100", + summary: "Test worker session", + summaryIsFallback: false, + createdAt: new Date().toISOString(), + lastActivityAt: new Date().toISOString(), + pr: null, + metadata: {}, + }; +} + +async function flushAsyncWork(): Promise { + await act(async () => { + await Promise.resolve(); + }); +} + +describe("SessionPage project polling", () => { + beforeEach(() => { + vi.useFakeTimers(); + sessionDetailSpy.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("resolves orchestrator nav once for non-orchestrator pages and skips repeated project polling", async () => { + const workerSession = makeWorkerSession(); + + global.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "/api/sessions/worker-1") { + return { + ok: true, + status: 200, + json: async () => workerSession, + } as Response; + } + + if (url === "/api/sessions?project=my-app&orchestratorOnly=true") { + return { + ok: true, + status: 200, + json: async () => ({ + orchestratorId: "my-app-orchestrator", + orchestrators: [ + { + id: "my-app-orchestrator", + projectId: "my-app", + projectName: "My App", + }, + ], + }), + } as Response; + } + + throw new Error(`Unexpected fetch: ${url}`); + }) as typeof fetch; + + const { default: SessionPage } = await import("./page"); + + render(); + await flushAsyncWork(); + + expect(fetch).toHaveBeenCalledWith("/api/sessions/worker-1"); + + await act(async () => { + await vi.advanceTimersByTimeAsync(2_000); + }); + await flushAsyncWork(); + + expect(fetch).toHaveBeenCalledWith("/api/sessions?project=my-app&orchestratorOnly=true"); + + expect( + vi.mocked(fetch).mock.calls.filter( + ([url]) => url === "/api/sessions?project=my-app&orchestratorOnly=true", + ), + ).toHaveLength(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000); + }); + await flushAsyncWork(); + + expect( + vi.mocked(fetch).mock.calls.filter( + ([url]) => url === "/api/sessions?project=my-app&orchestratorOnly=true", + ), + ).toHaveLength(1); + }); +}); diff --git a/packages/web/src/app/sessions/[id]/page.tsx b/packages/web/src/app/sessions/[id]/page.tsx index fb855b564..453b6782c 100644 --- a/packages/web/src/app/sessions/[id]/page.tsx +++ b/packages/web/src/app/sessions/[id]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, useRef } from "react"; import { useParams } from "next/navigation"; import { isOrchestratorSession } from "@composio/ao-core/types"; import { SessionDetail } from "@/components/SessionDetail"; @@ -41,16 +41,26 @@ interface ZoneCounts { done: number; } +interface ProjectSessionsBody { + sessions?: DashboardSession[]; + orchestratorId?: string | null; + orchestrators?: Array<{ id: string; projectId: string; projectName: string }>; +} + export default function SessionPage() { const params = useParams(); const id = params.id as string; const [session, setSession] = useState(null); const [zoneCounts, setZoneCounts] = useState(null); + const [projectOrchestratorId, setProjectOrchestratorId] = useState(undefined); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const sessionProjectId = session?.projectId ?? null; const sessionIsOrchestrator = session ? isOrchestratorSession(session) : false; + const sessionProjectIdRef = useRef(null); + const sessionIsOrchestratorRef = useRef(false); + const resolvedProjectSessionsKeyRef = useRef(null); // Update document title based on session data useEffect(() => { @@ -61,6 +71,14 @@ export default function SessionPage() { } }, [session, id]); + useEffect(() => { + sessionProjectIdRef.current = sessionProjectId; + }, [sessionProjectId]); + + useEffect(() => { + sessionIsOrchestratorRef.current = sessionIsOrchestrator; + }, [sessionIsOrchestrator]); + // Fetch session data (memoized to avoid recreating on every render) const fetchSession = useCallback(async () => { try { @@ -82,13 +100,31 @@ export default function SessionPage() { } }, [id]); - const fetchZoneCounts = useCallback(async () => { - if (!sessionIsOrchestrator || !sessionProjectId) return; + const fetchProjectSessions = useCallback(async () => { + const projectId = sessionProjectIdRef.current; + if (!projectId) return; + const isOrchestrator = sessionIsOrchestratorRef.current; + const projectSessionsKey = `${projectId}:${isOrchestrator ? "orchestrator" : "worker"}`; + if (!isOrchestrator && resolvedProjectSessionsKeyRef.current === projectSessionsKey) return; try { - const res = await fetch(`/api/sessions?project=${encodeURIComponent(sessionProjectId)}`); + const query = isOrchestrator + ? `/api/sessions?project=${encodeURIComponent(projectId)}` + : `/api/sessions?project=${encodeURIComponent(projectId)}&orchestratorOnly=true`; + const res = await fetch(query); if (!res.ok) return; - const body = (await res.json()) as { sessions: DashboardSession[] }; + const body = (await res.json()) as ProjectSessionsBody; const sessions = body.sessions ?? []; + const orchestratorId = + body.orchestratorId ?? + body.orchestrators?.find((orchestrator) => orchestrator.projectId === projectId)?.id ?? + null; + setProjectOrchestratorId((current) => (current === orchestratorId ? current : orchestratorId)); + + if (!isOrchestrator) { + resolvedProjectSessionsKeyRef.current = projectSessionsKey; + return; + } + const counts: ZoneCounts = { merge: 0, respond: 0, @@ -106,24 +142,30 @@ export default function SessionPage() { } catch { // non-critical - status strip just won't show } - }, [sessionIsOrchestrator, sessionProjectId]); + }, []); + + useEffect(() => { + if (!sessionIsOrchestrator) { + setZoneCounts(null); + } + }, [sessionIsOrchestrator]); // Initial fetch — session first, zone counts after (avoids blocking on slow /api/sessions) useEffect(() => { fetchSession(); // Delay zone counts so the heavy /api/sessions call doesn't contend with session load - const t = setTimeout(fetchZoneCounts, 2000); + const t = setTimeout(fetchProjectSessions, 2000); return () => clearTimeout(t); - }, [fetchSession, fetchZoneCounts]); + }, [fetchSession, fetchProjectSessions]); // Poll every 5s useEffect(() => { const interval = setInterval(() => { fetchSession(); - fetchZoneCounts(); + fetchProjectSessions(); }, 5000); return () => clearInterval(interval); - }, [fetchSession, fetchZoneCounts]); + }, [fetchSession, fetchProjectSessions]); if (loading) { return ( @@ -151,6 +193,7 @@ export default function SessionPage() { session={session} isOrchestrator={sessionIsOrchestrator} orchestratorZones={zoneCounts ?? undefined} + projectOrchestratorId={projectOrchestratorId} /> ); } diff --git a/packages/web/src/components/AttentionZone.tsx b/packages/web/src/components/AttentionZone.tsx index 420b95744..c5e6416a4 100644 --- a/packages/web/src/components/AttentionZone.tsx +++ b/packages/web/src/components/AttentionZone.tsx @@ -1,16 +1,31 @@ "use client"; -import { memo } from "react"; -import type { DashboardSession, AttentionLevel } from "@/lib/types"; +import { memo, useEffect, useState } from "react"; +import { + type DashboardSession, + type AttentionLevel, + isPRMergeReady, +} from "@/lib/types"; import { SessionCard } from "./SessionCard"; +import { getSessionTitle } from "@/lib/format"; interface AttentionZoneProps { level: AttentionLevel; sessions: DashboardSession[]; - onSend?: (sessionId: string, message: string) => void; + onSend?: (sessionId: string, message: string) => Promise | void; onKill?: (sessionId: string) => void; onMerge?: (prNumber: number) => void; onRestore?: (sessionId: string) => void; + /** Accordion mode: whether this section is collapsed (mobile only) */ + collapsed?: boolean; + /** Accordion mode: called when the header is tapped to toggle */ + onToggle?: (level: AttentionLevel) => void; + /** Dense mobile rows rendered instead of full cards */ + compactMobile?: boolean; + /** Open the lightweight mobile preview sheet */ + onPreview?: (session: DashboardSession) => void; + /** Reset internal "show all" state when this value changes */ + resetKey?: string | number | null; } const zoneConfig: Record< @@ -56,6 +71,11 @@ const zoneConfig: Record< /** * Kanban column — always renders (even when empty) to preserve * the board shape. Cards scroll independently within each column. + * + * When `collapsed` and `onToggle` are provided the component renders + * in accordion mode (mobile): a 44 px tappable header only, with the + * card list hidden. Empty sections in accordion mode omit the dashed + * placeholder entirely — just the single-line header is shown. */ function AttentionZoneView({ level, @@ -64,8 +84,88 @@ function AttentionZoneView({ onKill, onMerge, onRestore, + collapsed, + onToggle, + compactMobile, + onPreview, + resetKey, }: AttentionZoneProps) { const config = zoneConfig[level]; + const isAccordion = onToggle !== undefined; + const [showAll, setShowAll] = useState(false); + const visibleSessions = + isAccordion && compactMobile && !showAll ? sessions.slice(0, 5) : sessions; + const hiddenCount = sessions.length - visibleSessions.length; + + useEffect(() => { + if (collapsed) { + setShowAll(false); + } + }, [collapsed]); + + useEffect(() => { + setShowAll(false); + }, [resetKey]); + + if (isAccordion) { + return ( +
+ + +
+ {sessions.length > 0 ? ( +
+ {visibleSessions.map((session) => + compactMobile ? ( + + ) : ( + + ), + )} + {compactMobile && hiddenCount > 0 ? ( + + ) : null} +
+ ) : compactMobile ? ( +
+
No sessions
+
+ ) : null} +
+
+ ); + } return (
@@ -105,13 +205,101 @@ function AttentionZoneView({ function areAttentionZonePropsEqual(prev: AttentionZoneProps, next: AttentionZoneProps): boolean { return ( prev.level === next.level && + prev.collapsed === next.collapsed && + prev.onToggle === next.onToggle && prev.onSend === next.onSend && prev.onKill === next.onKill && prev.onMerge === next.onMerge && prev.onRestore === next.onRestore && + prev.compactMobile === next.compactMobile && + prev.onPreview === next.onPreview && + prev.resetKey === next.resetKey && prev.sessions.length === next.sessions.length && prev.sessions.every((session, index) => session === next.sessions[index]) ); } export const AttentionZone = memo(AttentionZoneView, areAttentionZonePropsEqual); + +function MobileSessionRow({ + session, + level, + onPreview, +}: { + session: DashboardSession; + level: AttentionLevel; + onPreview?: (session: DashboardSession) => void; +}) { + const meta = [ + session.branch, + session.pr ? `PR #${session.pr.number}` : null, + session.issueLabel, + ].filter(Boolean); + + return ( +
+ + +
+ ); +} + +function SessionStateChip({ + session, + level, +}: { + session: DashboardSession; + level: AttentionLevel; +}) { + let label = zoneConfig[level].label.toLowerCase(); + + if (level === "merge" && session.pr && isPRMergeReady(session.pr)) { + label = "ready"; + } else if (level === "respond") { + label = session.activity === "waiting_input" ? "waiting" : "needs input"; + } else if (level === "review") { + label = session.pr?.reviewDecision === "changes_requested" ? "changes" : "review"; + } else if (level === "pending") { + label = session.pr?.unresolvedThreads ? "threads" : "pending"; + } else if (level === "working") { + label = session.activity === "idle" ? "idle" : "active"; + } + + return {label}; +} diff --git a/packages/web/src/components/BottomSheet.tsx b/packages/web/src/components/BottomSheet.tsx new file mode 100644 index 000000000..405d55d20 --- /dev/null +++ b/packages/web/src/components/BottomSheet.tsx @@ -0,0 +1,253 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { getAttentionLevel, type DashboardSession } from "@/lib/types"; +import { getSessionTitle } from "@/lib/format"; + +function getRelativeTime(dateStr: string): string { + const now = Date.now(); + const then = new Date(dateStr).getTime(); + const diffMs = now - then; + const diffSec = Math.floor(diffMs / 1000); + if (diffSec < 60) return `${diffSec}s ago`; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + return `${diffDay}d ago`; +} + +function formatTagLabel(value: string): string { + return value.replaceAll("_", " "); +} + +function isTag( + value: + | { + label: string; + tone: "accent" | "neutral" | "mono"; + } + | null, +): value is { label: string; tone: "accent" | "neutral" | "mono" } { + return value !== null; +} + +interface BottomSheetProps { + session: DashboardSession | null; + mode: "preview" | "confirm-kill"; + onConfirm: () => void; + onCancel: () => void; + onRequestKill?: () => void; + onMerge?: (prNumber: number) => void; + isMergeReady?: boolean; +} + +export function BottomSheet({ + session, + mode, + onCancel, + onConfirm, + onRequestKill, + onMerge, + isMergeReady = false, +}: BottomSheetProps) { + const touchStartYRef = useRef(null); + const sheetRef = useRef(null); + const sessionIdRef = useRef(null); + const mergePrNumber = session?.pr?.number ?? null; + + useEffect(() => { + if (!session) { + sessionIdRef.current = null; + return; + } + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") onCancel(); + } + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [session, onCancel]); + + useEffect(() => { + if (!session) return; + if (!sheetRef.current) return; + + const focusable = sheetRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + if (focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + // Only steal focus when the sheet first opens (new session id), not on SSE updates. + const isNewSession = sessionIdRef.current !== session.id; + sessionIdRef.current = session.id; + if (isNewSession) first.focus(); + + function handleTabTrap(e: KeyboardEvent) { + if (e.key === "Tab") { + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + } + + const sheet = sheetRef.current; + sheet.addEventListener("keydown", handleTabTrap); + return () => sheet.removeEventListener("keydown", handleTabTrap); + }, [session, mode]); + + function handleTouchStart(e: React.TouchEvent) { + touchStartYRef.current = e.touches[0]?.clientY ?? null; + } + + function handleTouchEnd(e: React.TouchEvent) { + if (touchStartYRef.current === null) return; + const deltaY = (e.changedTouches[0]?.clientY ?? 0) - touchStartYRef.current; + touchStartYRef.current = null; + if (deltaY > 80) { + onCancel(); + } + } + + if (!session) return null; + + const title = getSessionTitle(session); + const attention = getAttentionLevel(session); + const summary = + session.summary && !session.summaryIsFallback ? session.summary : null; + const hasLiveTerminateAction = + attention !== "done" && attention !== "merge" && session.status !== "terminated"; + const tags = [ + { label: formatTagLabel(attention), tone: "accent" as const }, + { label: formatTagLabel(session.status), tone: "neutral" as const }, + session.activity ? { label: formatTagLabel(session.activity), tone: "neutral" as const } : null, + session.branch ? { label: session.branch, tone: "mono" as const } : null, + session.pr ? { label: `PR #${session.pr.number}`, tone: "neutral" as const } : null, + session.issueLabel ? { label: session.issueLabel, tone: "neutral" as const } : null, + ].filter(isTag); + + return ( + <> + {/* Backdrop */} + diff --git a/packages/web/src/components/MobileBottomNav.tsx b/packages/web/src/components/MobileBottomNav.tsx new file mode 100644 index 000000000..c9a213bbe --- /dev/null +++ b/packages/web/src/components/MobileBottomNav.tsx @@ -0,0 +1,74 @@ +"use client"; + +import Link from "next/link"; + +export type MobileBottomNavTab = "dashboard" | "prs" | "orchestrator"; + +interface MobileBottomNavProps { + ariaLabel: string; + activeTab?: MobileBottomNavTab; + dashboardHref: string; + prsHref: string; + showOrchestrator?: boolean; + orchestratorHref?: string | null; +} + +export function MobileBottomNav({ + ariaLabel, + activeTab, + dashboardHref, + prsHref, + showOrchestrator = true, + orchestratorHref = null, +}: MobileBottomNavProps) { + return ( + + ); +} diff --git a/packages/web/src/components/PRStatus.tsx b/packages/web/src/components/PRStatus.tsx index 1ead53803..bbe33c792 100644 --- a/packages/web/src/components/PRStatus.tsx +++ b/packages/web/src/components/PRStatus.tsx @@ -128,3 +128,46 @@ export function PRTableRow({ pr }: PRTableRowProps) { ); } + +export function PRCard({ pr }: PRTableRowProps) { + const sizeLabel = getSizeLabel(pr.additions, pr.deletions); + const rateLimited = isPRRateLimited(pr); + + const reviewLabel = rateLimited + ? "stale" + : pr.isDraft + ? "draft" + : pr.reviewDecision === "approved" + ? "approved" + : pr.reviewDecision === "changes_requested" + ? "changes" + : "review"; + + const ciLabel = rateLimited + ? "CI stale" + : pr.ciStatus === "passing" + ? "CI passing" + : pr.ciStatus === "failing" + ? "CI failing" + : "CI pending"; + + return ( + +
+ #{pr.number} + {pr.title} + {!rateLimited ? {sizeLabel} : null} +
+
+ {ciLabel} + {reviewLabel} + {pr.unresolvedThreads} threads +
+
+ ); +} diff --git a/packages/web/src/components/ProjectSidebar.tsx b/packages/web/src/components/ProjectSidebar.tsx index c0735fe82..f2f5b5fed 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,61 +160,70 @@ function ProjectSidebarInner({ if (collapsed) { return ( -
+ - + + + + + + + ); } return ( -
+ + {level === "respond" && ( +
e.stopPropagation()}> + {session.summary && !session.summaryIsFallback && ( +

{session.summary}

+ )} +
+ + + +
+